From 1d8aec9c986277cca90f252703c9cdd534014bff Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 8 Oct 2026 14:42:59 -0600 Subject: [PATCH 1/5] feat(mosaic): anchor the tabs indicator to the selected tab Co-Authored-By: Claude Opus 5.5 --- .changeset/tabs-anchor-indicator.md | 2 + .../segmented-control.styles.ts | 1 + packages/mosaic/src/components/tabs/index.ts | 1 - .../mosaic/src/components/tabs/tabs.styles.ts | 55 ++++- .../mosaic/src/components/tabs/tabs.test.tsx | 72 +++++- packages/mosaic/src/components/tabs/tabs.tsx | 36 +-- ...rganization-profile-members-panel.view.tsx | 1 - packages/mosaic/src/primitives/tabs/README.md | 40 ++-- .../src/primitives/tabs/tabs-context.ts | 10 +- .../src/primitives/tabs/tabs-indicator.tsx | 70 +----- .../mosaic/src/primitives/tabs/tabs-list.tsx | 3 +- .../mosaic/src/primitives/tabs/tabs-panel.tsx | 5 +- .../mosaic/src/primitives/tabs/tabs-root.tsx | 109 ++++++--- .../mosaic/src/primitives/tabs/tabs.test.tsx | 226 +++++------------- .../mosaic/src/primitives/utils/dom.test.ts | 25 -- packages/mosaic/src/primitives/utils/dom.ts | 19 -- packages/mosaic/src/styles/index.ts | 1 - .../swingset/src/stories/tabs.component.mdx | 23 +- .../src/stories/tabs.component.stories.tsx | 1 - packages/swingset/src/stories/tabs.mdx | 39 +-- 20 files changed, 340 insertions(+), 399 deletions(-) create mode 100644 .changeset/tabs-anchor-indicator.md diff --git a/.changeset/tabs-anchor-indicator.md b/.changeset/tabs-anchor-indicator.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/tabs-anchor-indicator.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/segmented-control/segmented-control.styles.ts b/packages/mosaic/src/components/segmented-control/segmented-control.styles.ts index 57fe9fa37a4..14657cde42b 100644 --- a/packages/mosaic/src/components/segmented-control/segmented-control.styles.ts +++ b/packages/mosaic/src/components/segmented-control/segmented-control.styles.ts @@ -12,6 +12,7 @@ import { typeScaleVars, } from '../../tokens.stylex'; +// Literals: StyleX cannot import them. Must match `tabs.styles.ts`. const anchors = '@supports (anchor-scope: all) and (not (-moz-appearance: none))' as const; const noAnchors = '@supports not ((anchor-scope: all) and (not (-moz-appearance: none)))' as const; const reduceMotion = '@media (prefers-reduced-motion: reduce)' as const; diff --git a/packages/mosaic/src/components/tabs/index.ts b/packages/mosaic/src/components/tabs/index.ts index ad10c98dbd1..9cf08932446 100644 --- a/packages/mosaic/src/components/tabs/index.ts +++ b/packages/mosaic/src/components/tabs/index.ts @@ -1,6 +1,5 @@ export { Tabs } from './tabs'; export type { - TabsIndicatorProps, TabsListProps, TabsPanelProps, TabsPanelsProps, diff --git a/packages/mosaic/src/components/tabs/tabs.styles.ts b/packages/mosaic/src/components/tabs/tabs.styles.ts index 1c9e0ca5494..0901952b737 100644 --- a/packages/mosaic/src/components/tabs/tabs.styles.ts +++ b/packages/mosaic/src/components/tabs/tabs.styles.ts @@ -2,9 +2,24 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, durationVars, easingVars, fontFamilyVars, space, typeScaleVars } from '../../tokens.stylex'; +// Literals: StyleX cannot import them. Must match `segmented-control.styles.ts`. +const anchors = '@supports (anchor-scope: all) and (not (-moz-appearance: none))' as const; +const noAnchors = '@supports not ((anchor-scope: all) and (not (-moz-appearance: none)))' as const; const reduceMotion = '@media (prefers-reduced-motion: reduce)' as const; +const activeAnchor = '--_cl-tabs-active'; const halfGap = `calc(${space['5']} / -2)`; +const lead = durationVars['--cl-duration-slow']; +const trail = durationVars['--cl-duration-slower']; + +const underline = { + backgroundColor: colorVars['--cl-color-foreground'], + pointerEvents: 'none', + position: 'absolute', + bottom: '-1px', + height: '2px', +} as const; + export const styles = stylex.create({ root: { gap: space['6'], @@ -13,6 +28,8 @@ export const styles = stylex.create({ width: '100%', }, list: { + // eslint-disable-next-line @stylexjs/valid-styles -- `anchor-scope`: anchor positioning postdates StyleX's property allowlist; it compiles and emits correctly. + anchorScope: activeAnchor, gap: space['5'], alignItems: 'center', display: 'flex', @@ -44,6 +61,10 @@ export const styles = stylex.create({ paddingBottom: space['3'], paddingTop: space['2.5'], }, + anchor: { + // eslint-disable-next-line @stylexjs/valid-styles -- `anchor-name`: anchor positioning postdates StyleX's property allowlist; it compiles and emits correctly. + anchorName: { default: null, ':where([data-selected])': activeAnchor }, + }, hitArea: { '--_cl-tab-hit-end': { default: '0px', ':has(+ [role="tab"])': halfGap }, '--_cl-tab-hit-start': { default: halfGap, ':first-child': '0px' }, @@ -66,16 +87,32 @@ export const styles = stylex.create({ gridRowStart: '1', }, indicator: { - backgroundColor: colorVars['--cl-color-foreground'], - pointerEvents: 'none', - position: 'absolute', + ...underline, + // eslint-disable-next-line @stylexjs/valid-styles -- `position-anchor`: anchor positioning postdates StyleX's property allowlist; it compiles and emits correctly. + positionAnchor: activeAnchor, + display: { + [anchors]: { default: 'none', ':where([data-selected] ~ *)': 'block' }, + default: 'none', + }, + insetInlineEnd: 'anchor(end)', + insetInlineStart: 'anchor(start)', transitionDuration: { - default: durationVars['--cl-duration-base'], - [reduceMotion]: durationVars['--cl-duration-instant'], + default: `${trail}, ${lead}`, + [reduceMotion]: { + default: durationVars['--cl-duration-instant'], + ':where([data-direction="backward"])': durationVars['--cl-duration-instant'], + }, + ':where([data-direction="backward"])': `${lead}, ${trail}`, + }, + transitionProperty: 'inset-inline-start, inset-inline-end', + transitionTimingFunction: easingVars['--cl-ease-default'], + }, + fallbackIndicator: { + ...underline, + insetInline: 0, + display: { + default: 'none', + [noAnchors]: { default: 'none', ':where([data-selected] > *)': 'block' }, }, - transitionProperty: 'left, width', - transitionTimingFunction: easingVars['--cl-ease-enter'], - bottom: '-1px', - height: '2px', }, }); diff --git a/packages/mosaic/src/components/tabs/tabs.test.tsx b/packages/mosaic/src/components/tabs/tabs.test.tsx index e0c247bb2d9..89eb25c07f7 100644 --- a/packages/mosaic/src/components/tabs/tabs.test.tsx +++ b/packages/mosaic/src/components/tabs/tabs.test.tsx @@ -18,7 +18,6 @@ describe('Mosaic Tabs', () => { Members Invitations - Member list @@ -29,7 +28,6 @@ describe('Mosaic Tabs', () => { expect(screen.getByRole('tablist')).toHaveClass('cl-tabs-list'); expect(screen.getByRole('tab', { name: 'Members' })).toHaveClass('cl-tabs-tab'); - expect(screen.getByTestId('indicator')).toHaveClass('cl-tabs-indicator'); expect(screen.getByText('Member list')).toBeVisible(); expect(screen.getByText('Invitation list')).not.toBeVisible(); @@ -39,6 +37,76 @@ describe('Mosaic Tabs', () => { expect(screen.getByText('Invitation list')).toBeVisible(); }); + it('renders the anchored indicator in the list and a fallback indicator in each tab', () => { + render( + + + Members + Invitations + + , + ); + + const list = screen.getByRole('tablist'); + const anchored = list.lastElementChild; + expect(anchored).toHaveClass('cl-tabs-indicator', stylex.props(styles.indicator).className ?? ''); + expect(anchored).toHaveAttribute('aria-hidden', 'true'); + + for (const tab of screen.getAllByRole('tab')) { + const fallback = tab.querySelector('.cl-tabs-indicator'); + expect(fallback).toHaveClass(stylex.props(styles.fallbackIndicator).className ?? ''); + expect(fallback).toHaveAttribute('aria-hidden', 'true'); + } + expect(screen.getByRole('tab', { name: 'Members' })).toHaveAccessibleName('Members'); + }); + + it('marks the indicator with the direction the selection traveled', async () => { + render( + + + Members + Invitations + Requests + + , + ); + const indicator = screen.getByRole('tablist').lastElementChild; + + await userEvent.click(screen.getByRole('tab', { name: 'Members' })); + expect(indicator).toHaveAttribute('data-direction', 'backward'); + + await userEvent.click(screen.getByRole('tab', { name: 'Requests' })); + expect(indicator).toHaveAttribute('data-direction', 'forward'); + }); + + it('keeps a nested tab list on its own indicator', async () => { + render( + + + Outer A + Outer B + + + + + Inner A + Inner B + + + + , + ); + const outer = screen.getByRole('tablist', { name: 'Outer' }); + const inner = screen.getByRole('tablist', { name: 'Inner' }); + + await userEvent.click(screen.getByRole('tab', { name: 'Inner A' })); + + expect(inner.lastElementChild).toHaveAttribute('data-direction', 'backward'); + expect(outer.lastElementChild).toHaveAttribute('data-direction', 'forward'); + expect(outer).toHaveClass(stylex.props(styles.list).className ?? ''); + expect(inner).toHaveClass(stylex.props(styles.list).className ?? ''); + }); + it('renders a stackable panel wrapper with a ref and xstyle', () => { const ref = React.createRef(); render( diff --git a/packages/mosaic/src/components/tabs/tabs.tsx b/packages/mosaic/src/components/tabs/tabs.tsx index ead3c3dabfc..8cdf5efde70 100644 --- a/packages/mosaic/src/components/tabs/tabs.tsx +++ b/packages/mosaic/src/components/tabs/tabs.tsx @@ -2,7 +2,6 @@ import * as stylex from '@stylexjs/stylex'; import React from 'react'; import type { - TabsIndicatorProps as HeadlessTabsIndicatorProps, TabsListProps as HeadlessTabsListProps, TabsPanelProps as HeadlessTabsPanelProps, TabsProps as HeadlessTabsRootProps, @@ -31,7 +30,6 @@ export type TabsTriggerProps = StyledProps; export type TabsPanelProps = StyledProps; /** Props for the optional wrapper that stacks the panels in one grid cell so they can animate in and out. */ export type TabsPanelsProps = MosaicComponentProps<'div'>; -export type TabsIndicatorProps = StyledProps; function Root({ xstyle, children, ...rest }: TabsRootProps) { return ( @@ -41,24 +39,35 @@ function Root({ xstyle, children, ...rest }: TabsRootProps) { ); } -function List({ xstyle, ...rest }: TabsListProps) { +function List({ xstyle, children, ...rest }: TabsListProps) { return ( - + + {children} + + ); } -const Tab = React.forwardRef(function MosaicTabsTab({ xstyle, ...rest }, ref) { +const Tab = React.forwardRef(function MosaicTabsTab( + { xstyle, children, ...rest }, + ref, +) { return ( + > + + {children} + ); }); @@ -103,14 +112,6 @@ const Panels = React.forwardRef(function Mosaic }); }); -function Indicator({ xstyle, ...rest }: TabsIndicatorProps) { - return ( - - ); -} - export const Tabs = { Root, List, @@ -118,5 +119,4 @@ export const Tabs = { Trigger, Panels, Panel, - Indicator, }; diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.view.tsx index 3749fcf2014..fc0a61a2f61 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.view.tsx @@ -79,7 +79,6 @@ export function OrganizationProfileMembersPanelView({ {tab.label} ))} - {tabs.map(tab => (