Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-tabs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
10 changes: 10 additions & 0 deletions packages/mosaic/src/components/tabs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export { Tabs } from './tabs';
export type {
TabsIndicatorProps,
TabsListProps,
TabsPanelProps,
TabsPanelsProps,
TabsRootProps,
TabsTabProps,
TabsTriggerProps,
} from './tabs';
68 changes: 68 additions & 0 deletions packages/mosaic/src/components/tabs/tabs.styles.ts
Original file line number Diff line number Diff line change
@@ -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'],
},
},
Comment thread
austincalvelage marked this conversation as resolved.
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',
},
});
89 changes: 89 additions & 0 deletions packages/mosaic/src/components/tabs/tabs.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Tabs.Root defaultValue='members'>
<Tabs.List>
<Tabs.Tab value='members'>Members</Tabs.Tab>
<Tabs.Tab value='invitations'>Invitations</Tabs.Tab>
<Tabs.Indicator data-testid='indicator' />
</Tabs.List>
<Tabs.Panels>
<Tabs.Panel value='members'>Member list</Tabs.Panel>
<Tabs.Panel value='invitations'>Invitation list</Tabs.Panel>
</Tabs.Panels>
</Tabs.Root>,
);

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<HTMLDivElement>();
render(
<Tabs.Root defaultValue='members'>
<Tabs.Panels
ref={ref}
xstyle={atoms.spaced}
>
<Tabs.Panel value='members'>Member list</Tabs.Panel>
</Tabs.Panels>
</Tabs.Root>,
);

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);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('renders a custom panel wrapper and merges its refs and classes', () => {
const ref = React.createRef<HTMLDivElement>();
const renderRef = React.createRef<HTMLDivElement>();
render(
<Tabs.Root defaultValue='members'>
<Tabs.Panels
ref={ref}
render={
<div
ref={renderRef}
data-testid='custom-panels'
className='from-source'
/>
}
>
<Tabs.Panel value='members'>Member list</Tabs.Panel>
</Tabs.Panels>
</Tabs.Root>,
);

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'));
});
});
122 changes: 122 additions & 0 deletions packages/mosaic/src/components/tabs/tabs.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = Omit<Props, 'className' | 'style'> & 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<Omit<HeadlessTabsRootProps, 'orientation'>>;
export type TabsListProps = StyledProps<HeadlessTabsListProps>;
export type TabsTabProps = StyledProps<HeadlessTabsTabProps>;
/** Props for a standalone tab trigger used outside `List`, keyed to a `Panel` by `value`. */
export type TabsTriggerProps = StyledProps<HeadlessTabsTriggerProps>;
export type TabsPanelProps = StyledProps<HeadlessTabsPanelProps>;
/** 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<HeadlessTabsIndicatorProps>;

function Root({ xstyle, children, ...rest }: TabsRootProps) {
return (
<Primitive.Root {...rest}>
<div {...mergeStyleProps(themeProps('tabs-root'), stylex.props(reset.base, styles.root, xstyle))}>{children}</div>
</Primitive.Root>
);
}

function List({ xstyle, ...rest }: TabsListProps) {
return (
<Primitive.List
{...mergeStyleProps(themeProps('tabs-list'), stylex.props(reset.base, styles.list, xstyle), rest)}
/>
);
}

const Tab = React.forwardRef<HTMLButtonElement, TabsTabProps>(function MosaicTabsTab({ xstyle, ...rest }, ref) {
return (
<Primitive.Tab
ref={ref}
{...mergeStyleProps(
themeProps('tabs-tab'),
stylex.props(reset.base, styles.tab, focusOutline.visible, xstyle),
rest,
)}
/>
);
});

const Trigger = React.forwardRef<HTMLButtonElement, TabsTriggerProps>(function MosaicTabsTrigger(
{ xstyle, ...rest },
ref,
) {
return (
<Primitive.Trigger
ref={ref}
{...mergeStyleProps(
themeProps('tabs-trigger'),
stylex.props(reset.base, styles.tab, focusOutline.visible, xstyle),
rest,
)}
/>
);
});

const Panel = React.forwardRef<HTMLDivElement, TabsPanelProps>(function MosaicTabsPanel({ xstyle, ...rest }, ref) {
return (
<Primitive.Panel
ref={ref}
{...mergeStyleProps(
themeProps('tabs-panel'),
stylex.props(reset.base, styles.panel, focusOutline.visible, xstyle),
rest,
)}
/>
);
});
Comment thread
austincalvelage marked this conversation as resolved.

const Panels = React.forwardRef<HTMLDivElement, TabsPanelsProps>(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 (
<Primitive.Indicator
{...mergeStyleProps(themeProps('tabs-indicator'), stylex.props(reset.base, styles.indicator, xstyle), rest)}
/>
);
}

export const Tabs = {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Root,
List,
Tab,
Trigger,
Panels,
Panel,
Indicator,
};
10 changes: 10 additions & 0 deletions packages/mosaic/src/styles/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
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')),
Expand Down
7 changes: 7 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 };

Expand Down Expand Up @@ -889,6 +895,7 @@ export const registry: StoryModule[] = [
sectionModule,
selectComponentModule,
tableModule,
tabsComponentModule,
textModule,
toastModule,
tooltipComponentModule,
Expand Down
Loading
Loading