diff --git a/.changeset/mosaic-tabs.md b/.changeset/mosaic-tabs.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-tabs.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/tabs/index.ts b/packages/mosaic/src/components/tabs/index.ts new file mode 100644 index 00000000000..ad10c98dbd1 --- /dev/null +++ b/packages/mosaic/src/components/tabs/index.ts @@ -0,0 +1,10 @@ +export { Tabs } from './tabs'; +export type { + TabsIndicatorProps, + TabsListProps, + TabsPanelProps, + TabsPanelsProps, + TabsRootProps, + TabsTabProps, + TabsTriggerProps, +} from './tabs'; diff --git a/packages/mosaic/src/components/tabs/tabs.styles.ts b/packages/mosaic/src/components/tabs/tabs.styles.ts new file mode 100644 index 00000000000..6a5843d6184 --- /dev/null +++ b/packages/mosaic/src/components/tabs/tabs.styles.ts @@ -0,0 +1,68 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, durationVars, easingVars, fontFamilyVars, space, typeScaleVars } from '../../tokens.stylex'; + +const reduceMotion = '@media (prefers-reduced-motion: reduce)' as const; + +export const styles = stylex.create({ + root: { + gap: space['6'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, + list: { + gap: space['5'], + alignItems: 'center', + display: 'flex', + flexWrap: 'nowrap', + position: 'relative', + borderBottomColor: colorVars['--cl-color-border-subtle'], + borderBottomStyle: 'solid', + borderBottomWidth: '1px', + }, + tab: { + borderStyle: 'none', + gap: space['2'], + alignItems: 'center', + backgroundColor: 'transparent', + color: { + default: colorVars['--cl-color-foreground-secondary'], + ':where([data-selected])': colorVars['--cl-color-foreground'], + '@media (hover: hover)': { + default: null, + ':hover:not([data-selected]):not([data-disabled])': colorVars['--cl-color-foreground'], + }, + }, + cursor: { default: 'pointer', ':is([data-disabled])': 'not-allowed' }, + display: 'inline-flex', + fontFamily: fontFamilyVars['--cl-font-family-sans'], + fontSize: typeScaleVars['--cl-text-sm-size'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + opacity: { default: 1, ':is([data-disabled])': 0.5 }, + paddingBottom: space['3'], + paddingTop: space['2.5'], + }, + panels: { + display: 'grid', + }, + panel: { + gridColumnEnd: '2', + gridColumnStart: '1', + gridRowEnd: '2', + gridRowStart: '1', + }, + indicator: { + backgroundColor: colorVars['--cl-color-foreground'], + pointerEvents: 'none', + position: 'absolute', + transitionDuration: { + default: durationVars['--cl-duration-base'], + [reduceMotion]: durationVars['--cl-duration-instant'], + }, + 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 new file mode 100644 index 00000000000..e0c247bb2d9 --- /dev/null +++ b/packages/mosaic/src/components/tabs/tabs.test.tsx @@ -0,0 +1,89 @@ +import * as stylex from '@stylexjs/stylex'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { Tabs } from './tabs'; +import { styles } from './tabs.styles'; + +const atoms = stylex.create({ + spaced: { marginTop: '8px' }, +}); + +describe('Mosaic Tabs', () => { + it('renders styled parts and changes the visible panel', async () => { + render( + + + Members + Invitations + + + + Member list + Invitation list + + , + ); + + 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(); + + await userEvent.click(screen.getByRole('tab', { name: 'Invitations' })); + + expect(screen.getByRole('tab', { name: 'Invitations' })).toHaveAttribute('data-selected'); + expect(screen.getByText('Invitation list')).toBeVisible(); + }); + + it('renders a stackable panel wrapper with a ref and xstyle', () => { + const ref = React.createRef(); + render( + + + Member list + + , + ); + + const atom = stylex.props(atoms.spaced).className ?? ''; + const panelsAtom = stylex.props(styles.panels).className ?? ''; + const panelAtom = stylex.props(styles.panel).className ?? ''; + expect(ref.current).toHaveClass('cl-tabs-panels', panelsAtom, atom); + expect(screen.getByText('Member list')).toHaveClass('cl-tabs-panel', panelAtom); + }); + + it('renders a custom panel wrapper and merges its refs and classes', () => { + const ref = React.createRef(); + const renderRef = React.createRef(); + render( + + + } + > + Member list + + , + ); + + const panels = screen.getByTestId('custom-panels'); + const panelsAtom = stylex.props(styles.panels).className ?? ''; + expect(ref.current).toBe(panels); + expect(renderRef.current).toBe(panels); + expect(panels).toHaveClass('cl-tabs-panels', panelsAtom, 'from-source'); + expect(panels).toContainElement(screen.getByRole('tabpanel')); + }); +}); diff --git a/packages/mosaic/src/components/tabs/tabs.tsx b/packages/mosaic/src/components/tabs/tabs.tsx new file mode 100644 index 00000000000..dbdec6c580f --- /dev/null +++ b/packages/mosaic/src/components/tabs/tabs.tsx @@ -0,0 +1,122 @@ +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { + TabsIndicatorProps as HeadlessTabsIndicatorProps, + TabsListProps as HeadlessTabsListProps, + TabsPanelProps as HeadlessTabsPanelProps, + TabsProps as HeadlessTabsRootProps, + TabsTabProps as HeadlessTabsTabProps, + TabsTriggerProps as HeadlessTabsTriggerProps, +} from '../../primitives/tabs'; +import { Tabs as Primitive } from '../../primitives/tabs'; +import { useRender } from '../../primitives/utils'; +import type { MosaicComponentProps, MosaicStyleProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { focusOutline } from '../../utils/focus-outline.styles'; +import { reset } from '../../utils/reset.styles'; +import { styles } from './tabs.styles'; + +type StyledProps = Omit & MosaicStyleProps; + +/** + * Orientation is omitted because the styled layout and indicator currently support only horizontal tabs. + * The headless primitive still supports vertical orientation. + */ +export type TabsRootProps = StyledProps>; +export type TabsListProps = StyledProps; +export type TabsTabProps = StyledProps; +/** Props for a standalone tab trigger used outside `List`, keyed to a `Panel` by `value`. */ +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 ( + +
{children}
+
+ ); +} + +function List({ xstyle, ...rest }: TabsListProps) { + return ( + + ); +} + +const Tab = React.forwardRef(function MosaicTabsTab({ xstyle, ...rest }, ref) { + return ( + + ); +}); + +const Trigger = React.forwardRef(function MosaicTabsTrigger( + { xstyle, ...rest }, + ref, +) { + return ( + + ); +}); + +const Panel = React.forwardRef(function MosaicTabsPanel({ xstyle, ...rest }, ref) { + return ( + + ); +}); + +const Panels = React.forwardRef(function MosaicTabsPanels( + { render, xstyle, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: mergeStyleProps(themeProps('tabs-panels'), stylex.props(reset.base, styles.panels, xstyle), rest), + }); +}); + +function Indicator({ xstyle, ...rest }: TabsIndicatorProps) { + return ( + + ); +} + +export const Tabs = { + Root, + List, + Tab, + Trigger, + Panels, + Panel, + Indicator, +}; diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts index 42e98b18309..ce52bbf7ae2 100644 --- a/packages/mosaic/src/styles/index.ts +++ b/packages/mosaic/src/styles/index.ts @@ -112,6 +112,16 @@ export type { TableSelectCellProps, TableSort, } from '../components/table'; +export { Tabs } from '../components/tabs'; +export type { + TabsIndicatorProps, + TabsListProps, + TabsPanelProps, + TabsPanelsProps, + TabsRootProps, + TabsTabProps, + TabsTriggerProps, +} from '../components/tabs'; export { Section } from '../components/section'; export type { SectionActionsProps, diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 1e3027bab2c..2d1a8835b71 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -82,6 +82,7 @@ const docModules: Record> = { section: dynamic(() => import('../stories/section.mdx')), select: dynamic(() => import('../stories/select.component.mdx')), table: dynamic(() => import('../stories/table.mdx')), + tabs: dynamic(() => import('../stories/tabs.component.mdx')), toast: dynamic(() => import('../stories/toast.mdx')), text: dynamic(() => import('../stories/text.mdx')), tooltip: dynamic(() => import('../stories/tooltip.component.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 799c79f0b8b..5ecedfc66f5 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -245,6 +245,7 @@ import { Selection as TableSelection, Sorting as TableSorting, } from '../stories/table.stories'; +import { Default as TabsComponentDefault, meta as tabsComponentMeta } from '../stories/tabs.component.stories'; import { meta as tabsMeta } from '../stories/tabs.stories'; import { meta as tagInputMeta } from '../stories/tag-input.stories'; import { @@ -675,6 +676,11 @@ const tableModule: StoryModule = { Overflow: TableOverflow, }; +const tabsComponentModule: StoryModule = { + meta: tabsComponentMeta, + Default: TabsComponentDefault, +}; + // Planned but not yet implemented; the entry reserves its sidebar slot with a todo dot. const toastModule: StoryModule = { meta: toastMeta, Default: ToastDefault, Anchored: ToastAnchored }; @@ -889,6 +895,7 @@ export const registry: StoryModule[] = [ sectionModule, selectComponentModule, tableModule, + tabsComponentModule, textModule, toastModule, tooltipComponentModule, diff --git a/packages/swingset/src/stories/tabs.component.mdx b/packages/swingset/src/stories/tabs.component.mdx new file mode 100644 index 00000000000..ba23260f43a --- /dev/null +++ b/packages/swingset/src/stories/tabs.component.mdx @@ -0,0 +1,55 @@ +import * as TabsStories from './tabs.component.stories'; + +# Tabs + +A set of layered sections where one panel is shown at a time. Tabs include keyboard navigation, +ARIA wiring, and an indicator that slides between selections. + +## Example + +Click a tab, or focus one and use the arrow keys, to swap the visible panel. + + + +## Usage + +```tsx +import { Tabs } from '@clerk/mosaic/components/tabs'; + + + + Account + Security + + + + Account content + Security content + +; +``` + +## Parts + +| Part | Element | Description | +| ---------------- | ---------- | ------------------------------------------------------------------------------------- | +| `Tabs.Root` | `
` | Column container providing active-value state and activation mode for horizontal tabs | +| `Tabs.List` | `
` | `role="tablist"` container with roving-tabindex navigation | +| `Tabs.Tab` | `