-
Notifications
You must be signed in to change notification settings - Fork 476
feat(mosaic): add styled tabs component #9857
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
7c1ac87
feat(mosaic): add styled tabs component
prestonwebdev 6c46095
Update .changeset/mosaic-tabs.md
prestonwebdev 5f7e374
feat(mosaic): refine styled Tabs with Panels wrapper and Root container
prestonwebdev 7aaea85
test(mosaic): cover custom tabs panel rendering
austincalvelage a8adf22
fix(mosaic): stabilize tabs layout and spacing
austincalvelage 60511a4
fix(mosaic): restrict styled tabs to horizontal orientation
austincalvelage 14a0a8f
fix(mosaic): align tabs disabled styles and typography
austincalvelage 7e73cdf
docs(mosaic): clarify tabs constraints and trim redundant comments
austincalvelage c377697
Merge branch 'main' into prestonwebdev/mosaic-tabs
austincalvelage 6f3d097
fix(mosaic): match tab panel focus outline to tabs
austincalvelage 9a4a88c
chore(mosaic): use empty changeset for tab panel focus
austincalvelage 18ad858
chore(mosaic): remove duplicate tabs changeset
austincalvelage 1b8943b
Merge branch 'main' into prestonwebdev/mosaic-tabs
austincalvelage File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| --- | ||
| --- |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'], | ||
| }, | ||
| }, | ||
| 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', | ||
| }, | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
|
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')); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| )} | ||
| /> | ||
| ); | ||
| }); | ||
|
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 = { | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| Root, | ||
| List, | ||
| Tab, | ||
| Trigger, | ||
| Panels, | ||
| Panel, | ||
| Indicator, | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.