diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index 5f98d2c83f0..f97f5c84a8f 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -5668,13 +5668,17 @@ export function WebShellSidebar({ className="w-full" aria-label={t('sidebar.sessionSource')} > - + - {t('sidebar.sessionSource.tasks')} + + {t('sidebar.sessionSource.tasks')} + - + - {t('sidebar.sessionSource.channels')} + + {t('sidebar.sessionSource.channels')} + diff --git a/packages/web-shell/client/components/ui/react18-ref-compat.test.tsx b/packages/web-shell/client/components/ui/react18-ref-compat.test.tsx index 82f76271ca2..3bf528a1baa 100644 --- a/packages/web-shell/client/components/ui/react18-ref-compat.test.tsx +++ b/packages/web-shell/client/components/ui/react18-ref-compat.test.tsx @@ -23,6 +23,7 @@ import { TableHeader, TableRow, } from './table'; +import { TabsList } from './tabs'; import { Tooltip, TooltipContent, @@ -59,6 +60,7 @@ describe('React 18 ref compatibility', () => { ['TableHead', TableHead], ['TableCell', TableCell], ['TableCaption', TableCaption], + ['TabsList', TabsList], ['TooltipTrigger', TooltipTrigger], ['TooltipContent', TooltipContent], ])('%s forwards refs', (_name, Component) => { diff --git a/packages/web-shell/client/components/ui/tabs.test.tsx b/packages/web-shell/client/components/ui/tabs.test.tsx new file mode 100644 index 00000000000..61a2bfbafa3 --- /dev/null +++ b/packages/web-shell/client/components/ui/tabs.test.tsx @@ -0,0 +1,196 @@ +// @vitest-environment jsdom +import * as React from 'react'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { Tabs, TabsList, TabsTrigger } from './tabs'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const mounted: Array<{ container: HTMLElement; root: Root }> = []; + +function renderTabs(ui: React.ReactElement): { + container: HTMLElement; + root: Root; +} { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ container, root }); + act(() => root.render(ui)); + return { container, root }; +} + +afterEach(() => { + while (mounted.length > 0) { + const { container, root } = mounted.pop()!; + act(() => root.unmount()); + container.remove(); + } +}); + +const INDICATOR = '[data-slot="tabs-list-indicator"]'; +const TRANSITION_CLASS = 'transition-['; + +async function flushFrame() { + await act(async () => { + await new Promise((resolve) => requestAnimationFrame(resolve)); + }); +} + +describe('TabsList sliding indicator', () => { + it('renders the indicator over the active trigger in the default variant', () => { + const { container } = renderTabs( + + + Tasks + Channels + + , + ); + + const indicator = container.querySelector(INDICATOR); + expect(indicator).not.toBeNull(); + expect((indicator as HTMLElement).style.opacity).toBe('1'); + expect( + container.querySelector('[data-slot="tabs-trigger"][data-state="active"]') + ?.textContent, + ).toBe('Tasks'); + }); + + it('hides the indicator when the active trigger disappears', async () => { + const { container, root } = renderTabs( + + + Tasks + Channels + + , + ); + const indicator = container.querySelector(INDICATOR) as HTMLElement; + expect(indicator.style.opacity).toBe('1'); + + await act(async () => { + root.render( + + + Tasks + Channels + + , + ); + }); + + expect(indicator.style.opacity).toBe('0'); + }); + + it('does not render the indicator in the line variant', () => { + const { container } = renderTabs( + + + Tasks + Channels + + , + ); + + expect(container.querySelector(INDICATOR)).toBeNull(); + }); + + it('forwards a ref to the list element while rendering the indicator', () => { + const ref = React.createRef(); + const { container } = renderTabs( + + + Tasks + Channels + + , + ); + + expect(ref.current).toBe( + container.querySelector('[data-slot="tabs-list"]'), + ); + expect(container.querySelector(INDICATOR)).not.toBeNull(); + }); + + it('animates mutation-driven moves but tracks resizes without a transition', async () => { + let resizeCallback: (() => void) | undefined; + const SharedResizeObserver = globalThis.ResizeObserver; + class CapturingResizeObserver { + constructor(callback: () => void) { + resizeCallback = callback; + } + observe() {} + unobserve() {} + disconnect() {} + } + Object.assign(globalThis, { + ResizeObserver: CapturingResizeObserver, + }); + + try { + const { container, root } = renderTabs( + + + Tasks + Channels + + , + ); + const indicator = container.querySelector(INDICATOR) as HTMLElement; + const list = container.querySelector( + '[data-slot="tabs-list"]', + ) as HTMLElement; + + // jsdom reports zero boxes; give the list and triggers plausible + // geometry so the resize-driven measurement has something to read. + const stubRect = (el: HTMLElement, left: number, width: number) => { + Object.defineProperty(el, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + left, + top: 0, + width, + height: 25, + right: left + width, + bottom: 25, + x: left, + y: 0, + toJSON: () => ({}), + }), + }); + }; + stubRect(list, 0, 200); + for (const [i, trigger] of [ + ...list.querySelectorAll('[data-slot="tabs-trigger"]'), + ].entries()) { + stubRect(trigger, 3 + i * 80, 80); + } + + // Resize-driven write: arms the hook and snaps — no transition class. + act(() => resizeCallback!()); + expect(indicator.className).not.toContain(TRANSITION_CLASS); + await flushFrame(); + expect(indicator.style.left).toBe('3px'); + expect(indicator.className).not.toContain(TRANSITION_CLASS); + + // Mutation-driven write (activation change): the transition arms. + await act(async () => { + root.render( + + + Tasks + Channels + + , + ); + }); + expect(indicator.style.left).toBe('83px'); + expect(indicator.className).toContain(TRANSITION_CLASS); + } finally { + Object.assign(globalThis, { ResizeObserver: SharedResizeObserver }); + } + }); +}); diff --git a/packages/web-shell/client/components/ui/tabs.tsx b/packages/web-shell/client/components/ui/tabs.tsx index 6a75c2bfecc..32c6ee241ae 100644 --- a/packages/web-shell/client/components/ui/tabs.tsx +++ b/packages/web-shell/client/components/ui/tabs.tsx @@ -1,4 +1,10 @@ -import type * as React from 'react'; +import { forwardRef, useLayoutEffect, useRef, useState } from 'react'; +import type { + ComponentProps, + ComponentPropsWithoutRef, + CSSProperties, + RefObject, +} from 'react'; import { cva, type VariantProps } from 'class-variance-authority'; import { Tabs as TabsPrimitive } from 'radix-ui'; @@ -8,7 +14,7 @@ function Tabs({ className, orientation = 'horizontal', ...props -}: React.ComponentProps) { +}: ComponentProps) { return ( & - VariantProps) { +// Measures the active trigger and positions the sliding pill behind it. +// MutationObserver (not the Tabs value) drives re-measurement so uncontrolled +// roots and keyboard navigation are covered; ResizeObserver keeps the pill +// glued to the trigger across container resizes. +function useSlidingIndicator( + listRef: RefObject, + enabled: boolean, +) { + const [frame, setFrame] = useState<{ + style: CSSProperties; + animate: boolean; + }>({ style: { opacity: 0 }, animate: false }); + const [ready, setReady] = useState(false); + + useLayoutEffect(() => { + const list = listRef.current; + if (!list || !enabled) { + return; + } + + // Transitions arm one frame after the first measurable box, so the + // initial position — and a reveal from display:none — snap instead of + // flying in from the origin. + let armed = false; + let armFrame: number | undefined; + const arm = () => { + if (armed) { + return; + } + armed = true; + armFrame = requestAnimationFrame(() => setReady(true)); + }; + + const measure = (animate: boolean) => { + const active = list.querySelector( + '[data-slot="tabs-trigger"][data-state="active"]', + ); + if (!active) { + setFrame((previous) => ({ + animate: false, + style: { ...previous.style, opacity: 0 }, + })); + return; + } + const listRect = list.getBoundingClientRect(); + const rect = active.getBoundingClientRect(); + setFrame({ + animate, + style: { + left: rect.left - listRect.left + list.scrollLeft, + top: rect.top - listRect.top + list.scrollTop, + width: rect.width, + height: rect.height, + // The trigger fades when disabled; inline styles beat classes, so + // the pill's dimming has to live in the same inline write. + opacity: active.matches(':disabled') ? 0.5 : 1, + }, + }); + if (rect.width > 0) { + arm(); + } + }; + + measure(false); + + const mutationObserver = new MutationObserver(() => measure(true)); + mutationObserver.observe(list, { + attributes: true, + attributeFilter: ['data-state', 'disabled'], + childList: true, + subtree: true, + }); + // Resize-driven moves skip the transition so the pill tracks a drag 1:1 + // instead of easing toward each intermediate target. + const resizeObserver = new ResizeObserver(() => measure(false)); + resizeObserver.observe(list); + + return () => { + if (armFrame !== undefined) { + cancelAnimationFrame(armFrame); + } + mutationObserver.disconnect(); + resizeObserver.disconnect(); + }; + }, [listRef, enabled]); + + return { style: frame.style, animated: ready && frame.animate }; +} + +type TabsListProps = Omit< + ComponentPropsWithoutRef, + 'asChild' +> & + VariantProps; + +const TabsList = forwardRef(function TabsList( + { className, variant, children, ...props }, + forwardedRef, +) { + const resolvedVariant = variant ?? 'default'; + const listRef = useRef(null); + const { style, animated } = useSlidingIndicator( + listRef, + resolvedVariant === 'default', + ); + + const setRefs = (node: HTMLDivElement | null) => { + listRef.current = node; + if (typeof forwardedRef === 'function') { + forwardedRef(node); + } else if (forwardedRef) { + forwardedRef.current = node; + } + }; + return ( + > + {resolvedVariant === 'default' && ( + + )} + {children} + ); -} +}); function TabsTrigger({ className, ...props -}: React.ComponentProps) { +}: ComponentProps) { return ( ) { +}: ComponentProps) { return ( { + const box = el.getBoundingClientRect(); + return { left: box.left, width: box.width }; + }); + return rect; +} + +test.describe('session source switch sliding indicator', () => { + test('pill overlays the active trigger and slides on switch @smoke', async ({ + page, + }, testInfo) => { + await openSidebarWithSourceSwitch( + page, + String(testInfo.project.use.baseURL), + ); + + const tasksTab = page.getByRole('tab', { name: 'Tasks' }); + const channelsTab = page.getByRole('tab', { name: 'Channels' }); + const indicator = page.locator('[data-slot="tabs-list-indicator"]'); + await expect(indicator).toBeVisible(); + + const expectOverlay = async (tab: Locator) => { + const [tabBox, pillBox] = await Promise.all([ + horizontalBox(tab), + horizontalBox(indicator), + ]); + expect(pillBox.left).toBeCloseTo(tabBox.left, 0); + expect(pillBox.width).toBeCloseTo(tabBox.width, 0); + }; + + await expectOverlay(tasksTab); + + // Record the pill's left edge through the switch to prove it slides + // (intermediate positions) instead of cross-fading in place. + const tracePromise = page.evaluate(async () => { + const pill = document.querySelector('[data-slot="tabs-list-indicator"]'); + if (!pill) { + return []; + } + const samples: number[] = []; + const start = performance.now(); + await new Promise((resolve) => { + const tick = () => { + samples.push(pill.getBoundingClientRect().left); + if (performance.now() - start < 400) { + requestAnimationFrame(tick); + } else { + resolve(); + } + }; + requestAnimationFrame(tick); + }); + return samples; + }); + await channelsTab.click(); + const trace = await tracePromise; + + await expectOverlay(channelsTab); + + const startLeft = (await horizontalBox(tasksTab)).left; + const endLeft = (await horizontalBox(channelsTab)).left; + const intermediate = trace.filter( + (left) => + left > startLeft + 1 && left < endLeft - 1 && Number.isFinite(left), + ); + expect(intermediate.length).toBeGreaterThan(0); + }); + + test('snaps without a transition under prefers-reduced-motion @smoke', async ({ + page, + }, testInfo) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await openSidebarWithSourceSwitch( + page, + String(testInfo.project.use.baseURL), + ); + + const indicator = page.locator('[data-slot="tabs-list-indicator"]'); + await expect(indicator).toBeVisible(); + + const tasksLeft = ( + await horizontalBox(page.getByRole('tab', { name: 'Tasks' })) + ).left; + const channelsLeft = ( + await horizontalBox(page.getByRole('tab', { name: 'Channels' })) + ).left; + + const tracePromise = page.evaluate(async () => { + const pill = document.querySelector('[data-slot="tabs-list-indicator"]'); + if (!pill) { + return []; + } + const samples: number[] = []; + const start = performance.now(); + await new Promise((resolve) => { + const tick = () => { + samples.push(pill.getBoundingClientRect().left); + if (performance.now() - start < 400) { + requestAnimationFrame(tick); + } else { + resolve(); + } + }; + requestAnimationFrame(tick); + }); + return samples; + }); + await page.getByRole('tab', { name: 'Channels' }).click(); + const trace = await tracePromise; + + // The pill jumps: every sampled position is one of the two endpoints. + const endpoints = [tasksLeft, channelsLeft]; + const offEndpoint = trace.filter((left) => + endpoints.every((end) => Math.abs(left - end) > 1), + ); + expect(offEndpoint).toEqual([]); + expect(trace.some((left) => Math.abs(left - channelsLeft) <= 1)).toBe(true); + }); +});