style(webui): float in conversation highlight

This commit is contained in:
Xubin Ren
2026-07-30 01:13:37 +08:00
parent 0a8bc0ac29
commit 73d3a49a27
2 changed files with 70 additions and 11 deletions
+41 -5
View File
@@ -107,6 +107,8 @@ export const ChatList = memo(function ChatList({
const listContentRef = useRef<HTMLDivElement>(null); const listContentRef = useRef<HTMLDivElement>(null);
const activeRowRef = useRef<HTMLDivElement>(null); const activeRowRef = useRef<HTMLDivElement>(null);
const activeHighlightRef = useRef<HTMLDivElement>(null); const activeHighlightRef = useRef<HTMLDivElement>(null);
const activeHighlightSurfaceRef = useRef<HTMLDivElement>(null);
const highlightVisibleRef = useRef(false);
const labels = useMemo<ChatGroupLabels>(() => ({ const labels = useMemo<ChatGroupLabels>(() => ({
pinned: t("chat.groups.pinned"), pinned: t("chat.groups.pinned"),
all: t("chat.groups.all"), all: t("chat.groups.all"),
@@ -162,17 +164,27 @@ export const ChatList = memo(function ChatList({
}, [showArchived, sort]); }, [showArchived, sort]);
useLayoutEffect(() => { useLayoutEffect(() => {
let resetTransitionFrame: number | null = null;
const updateHighlight = () => { const updateHighlight = () => {
const content = listContentRef.current; const content = listContentRef.current;
const row = activeRowRef.current; const row = activeRowRef.current;
const highlight = activeHighlightRef.current; const highlight = activeHighlightRef.current;
const surface = activeHighlightSurfaceRef.current;
if (!highlight) return; if (!highlight || !surface) return;
if (!content || !row) { if (!content || !row) {
highlight.style.opacity = "0"; surface.style.opacity = "0";
surface.style.transform = "scale(0.97)";
highlightVisibleRef.current = false;
return; return;
} }
const shouldFloatIn = !highlightVisibleRef.current;
if (shouldFloatIn) {
highlight.style.transitionProperty = "none";
}
const contentRect = content.getBoundingClientRect(); const contentRect = content.getBoundingClientRect();
const rowRect = row.getBoundingClientRect(); const rowRect = row.getBoundingClientRect();
highlight.style.width = `${rowRect.width}px`; highlight.style.width = `${rowRect.width}px`;
@@ -180,7 +192,21 @@ export const ChatList = memo(function ChatList({
highlight.style.transform = `translate3d(${rowRect.left - contentRect.left}px, ${ highlight.style.transform = `translate3d(${rowRect.left - contentRect.left}px, ${
rowRect.top - contentRect.top rowRect.top - contentRect.top
}px, 0)`; }px, 0)`;
highlight.style.opacity = "1";
if (shouldFloatIn) {
void highlight.offsetWidth;
}
surface.style.opacity = "1";
surface.style.transform = "scale(1)";
highlightVisibleRef.current = true;
if (shouldFloatIn) {
resetTransitionFrame = window.requestAnimationFrame(() => {
highlight.style.removeProperty("transition-property");
resetTransitionFrame = null;
});
}
}; };
updateHighlight(); updateHighlight();
@@ -196,6 +222,10 @@ export const ChatList = memo(function ChatList({
window.addEventListener("resize", updateHighlight); window.addEventListener("resize", updateHighlight);
return () => { return () => {
if (resetTransitionFrame !== null) {
window.cancelAnimationFrame(resetTransitionFrame);
}
activeHighlightRef.current?.style.removeProperty("transition-property");
resizeObserver?.disconnect(); resizeObserver?.disconnect();
window.removeEventListener("resize", updateHighlight); window.removeEventListener("resize", updateHighlight);
}; };
@@ -449,8 +479,14 @@ export const ChatList = memo(function ChatList({
ref={activeHighlightRef} ref={activeHighlightRef}
data-testid="active-chat-highlight" data-testid="active-chat-highlight"
aria-hidden="true" aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 z-0 !mt-0 rounded-xl bg-sidebar-foreground/[0.055] opacity-0 transition-[transform,width,height,opacity] duration-300 ease-out will-change-transform motion-reduce:transition-none dark:bg-white/[0.07]" className="pointer-events-none absolute left-0 top-0 z-0 !mt-0 transition-[transform,width,height] duration-300 ease-out will-change-transform motion-reduce:transition-none"
/> >
<div
ref={activeHighlightSurfaceRef}
data-testid="active-chat-highlight-surface"
className="h-full w-full scale-[0.97] rounded-xl bg-sidebar-foreground/[0.055] opacity-0 transition-[opacity,transform] duration-200 ease-out motion-reduce:transition-none dark:bg-white/[0.07]"
/>
</div>
</div> </div>
</div> </div>
); );
+29 -6
View File
@@ -220,7 +220,12 @@ describe("ChatList", () => {
expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument(); expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument();
}); });
it("slides one borderless highlight between selected topics", () => { it("floats a borderless highlight in, then slides it between selected topics", () => {
let revealFrame: FrameRequestCallback | null = null;
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
revealFrame = callback;
return 1;
});
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
function (this: HTMLElement) { function (this: HTMLElement) {
if (this.hasAttribute("data-chat-list-content")) { if (this.hasAttribute("data-chat-list-content")) {
@@ -250,11 +255,26 @@ describe("ChatList", () => {
const { rerender } = render( const { rerender } = render(
<ChatList <ChatList
{...props} {...props}
activeKey="websocket:active" activeKey={null}
/>, />,
); );
const highlight = screen.getByTestId("active-chat-highlight"); const highlight = screen.getByTestId("active-chat-highlight");
const surface = screen.getByTestId("active-chat-highlight-surface");
expect(surface).toHaveClass(
"bg-sidebar-foreground/[0.055]",
"transition-[opacity,transform]",
"motion-reduce:transition-none",
);
expect(surface).toHaveStyle("opacity: 0; transform: scale(0.97)");
rerender(
<ChatList
{...props}
activeKey="websocket:active"
/>,
);
const activeButton = screen.getByTitle("Active topic"); const activeButton = screen.getByTitle("Active topic");
expect(activeButton).toHaveAttribute("aria-current", "page"); expect(activeButton).toHaveAttribute("aria-current", "page");
expect(activeButton.parentElement).not.toHaveClass( expect(activeButton.parentElement).not.toHaveClass(
@@ -262,13 +282,16 @@ describe("ChatList", () => {
"shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]", "shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]",
); );
expect(highlight).toHaveClass( expect(highlight).toHaveClass(
"bg-sidebar-foreground/[0.055]", "transition-[transform,width,height]",
"transition-[transform,width,height,opacity]",
"motion-reduce:transition-none", "motion-reduce:transition-none",
); );
expect(highlight).toHaveStyle( expect(highlight).toHaveStyle(
"width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); opacity: 1", "width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); transition-property: none",
); );
expect(surface).toHaveStyle("opacity: 1; transform: scale(1)");
revealFrame?.(0);
expect(highlight.style.transitionProperty).toBe("");
rerender( rerender(
<ChatList <ChatList
@@ -280,7 +303,7 @@ describe("ChatList", () => {
expect(screen.getByTitle("Active topic")).not.toHaveAttribute("aria-current"); expect(screen.getByTitle("Active topic")).not.toHaveAttribute("aria-current");
expect(screen.getByTitle("Inactive topic")).toHaveAttribute("aria-current", "page"); expect(screen.getByTitle("Inactive topic")).toHaveAttribute("aria-current", "page");
expect(highlight).toHaveStyle( expect(highlight).toHaveStyle(
"width: 284px; height: 40px; transform: translate3d(8px, 48px, 0); opacity: 1", "width: 284px; height: 40px; transform: translate3d(8px, 48px, 0)",
); );
}); });