From 9477af2919329c5d279adc463c2745284313ee42 Mon Sep 17 00:00:00 2001 From: Natalie Mclaren Date: Wed, 22 Jul 2026 16:18:11 +0200 Subject: [PATCH] feat: add segmented control radio and tabs --- src/index.ts | 1 + src/segmented-control/index.ts | 14 ++ .../segmented-control-tabs.stories.tsx | 153 ++++++++++++++ .../segmented-control-tabs.test.tsx | 148 +++++++++++++ .../segmented-control-tabs.tsx | 161 ++++++++++++++ .../segmented-control.module.css} | 185 +++++++++------- .../segmented-control.stories.tsx | 173 +++++++++++++++ .../segmented-control.test.tsx | 173 +++++++++++++++ src/segmented-control/segmented-control.tsx | 199 ++++++++++++++++++ src/tabs/tabs.mdx | 10 +- src/tabs/tabs.stories.jsx | 48 +---- src/tabs/tabs.tsx | 123 +++-------- 12 files changed, 1164 insertions(+), 224 deletions(-) create mode 100644 src/segmented-control/index.ts create mode 100644 src/segmented-control/segmented-control-tabs.stories.tsx create mode 100644 src/segmented-control/segmented-control-tabs.test.tsx create mode 100644 src/segmented-control/segmented-control-tabs.tsx rename src/{tabs/tabs.module.css => segmented-control/segmented-control.module.css} (61%) create mode 100644 src/segmented-control/segmented-control.stories.tsx create mode 100644 src/segmented-control/segmented-control.test.tsx create mode 100644 src/segmented-control/segmented-control.tsx diff --git a/src/index.ts b/src/index.ts index a27472339..53d3e80c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,7 @@ export * from './text-link' // form fields export * from './checkbox-field' export * from './password-field' +export * from './segmented-control' export * from './select-field' export * from './switch-field' export * from './text-area' diff --git a/src/segmented-control/index.ts b/src/segmented-control/index.ts new file mode 100644 index 000000000..1b1fe6da5 --- /dev/null +++ b/src/segmented-control/index.ts @@ -0,0 +1,14 @@ +export type { + AriaDescriptionProps, + AriaLabelProps, + SegmentedControlOption, + SegmentedControlProps, + SegmentedControlRadioOption, + SegmentedControlRadioProps, +} from './segmented-control' +export { SegmentedControl, SegmentedControlRadio } from './segmented-control' +export type { + SegmentedControlTabsOption, + SegmentedControlTabsProps, +} from './segmented-control-tabs' +export { SegmentedControlTabs } from './segmented-control-tabs' diff --git a/src/segmented-control/segmented-control-tabs.stories.tsx b/src/segmented-control/segmented-control-tabs.stories.tsx new file mode 100644 index 000000000..247e2878c --- /dev/null +++ b/src/segmented-control/segmented-control-tabs.stories.tsx @@ -0,0 +1,153 @@ +import * as React from 'react' + +import { Box } from '../box' + +import { SegmentedControlTabs } from './segmented-control-tabs' + +import type { Meta, StoryObj } from '@storybook/react' + +const meta = { + title: '📑 Menus & tabs/Segmented Control/Tabs', + component: SegmentedControlTabs, + parameters: { + badges: ['accessible'], + figma: { + url: 'https://www.figma.com/design/AH9ioCnZZLfiA5XcHFabOr/-Refresh--Product-%E2%80%93-Web?node-id=5-1187', + path: 'Refresh - Product Web', + }, + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +function ControlledExample({ width }: { width?: 'maxContent' | 'full' }) { + const [selectedOptionId, setSelectedOptionId] = React.useState('all') + + return ( + + ) +} + +function UpgradeIcon() { + return ( + + ) +} + +export const Default: Story = { + render: () => , +} + +export const FullWidth: Story = { + render: () => ( + + + + ), +} + +export const FullWidthWithWrappingLabels: Story = { + render: () => ( + + + + ), +} + +export const DisabledOption: Story = { + render: () => ( + + ), +} + +export const WithExtraContent: Story = { + render: () => ( + , + tabContent: 'Date and time settings', + }, + { + id: 'location', + label: 'Location', + extraLabel: '2', + tabContent: 'Location settings', + }, + ]} + /> + ), +} + +export const WithLabelsAsLinks: Story = { + render: () => ( + , + }, + { + id: 'browse', + label: 'Browse', + tabContent: 'Browse integrations', + tabRender: , + }, + ]} + /> + ), +} diff --git a/src/segmented-control/segmented-control-tabs.test.tsx b/src/segmented-control/segmented-control-tabs.test.tsx new file mode 100644 index 000000000..ec670da89 --- /dev/null +++ b/src/segmented-control/segmented-control-tabs.test.tsx @@ -0,0 +1,148 @@ +import * as React from 'react' + +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { axe } from 'jest-axe' + +import { flushMicrotasks, TestIcon } from '../utils/test-helpers' + +import { SegmentedControlTabs } from './segmented-control-tabs' + +async function flushAnimationFrames() { + await act( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ) +} + +async function renderSegmentedControlTabs(element: React.ReactElement) { + const result = render(element) + await flushAnimationFrames() + return result +} + +describe('SegmentedControlTabs', () => { + it('renders option metadata and switches controlled panels', async () => { + const onSelectedOptionChange = jest.fn() + const user = userEvent.setup() + + function Example() { + const [selectedOptionId, setSelectedOptionId] = React.useState('all') + + return ( + { + onSelectedOptionChange(id) + setSelectedOptionId(id) + }} + options={[ + { id: 'all', label: 'All', tabContent: 'All notifications' }, + { + id: 'unread', + label: 'Unread', + extraLabel: '2', + extraIcon: , + tabContent: 'Unread notifications', + }, + ]} + /> + ) + } + + await renderSegmentedControlTabs() + + expect(await screen.findByRole('tabpanel', { name: 'All' })).toBeVisible() + expect(screen.getByRole('tab', { name: 'Unread 2' })).toBeVisible() + + await user.click(screen.getByRole('tab', { name: 'Unread 2' })) + await flushMicrotasks() + + expect(onSelectedOptionChange).toHaveBeenCalledWith('unread') + expect(screen.getByRole('tabpanel', { name: 'Unread 2' })).toBeVisible() + expect(screen.getByText('Unread notifications')).toBeVisible() + }) + + it('renders tabs as links and supports content wrappers', async () => { + function ContentWrapper({ children }: { children: React.ReactNode }) { + return {children} + } + + await renderSegmentedControlTabs( + , + }, + { + id: 'browse', + label: 'Browse', + tabContent: 'Browse integrations', + Wrapper: ContentWrapper, + }, + ]} + />, + ) + + expect(await screen.findByRole('tab', { name: 'Installed' })).toHaveAttribute( + 'href', + '#installed', + ) + expect(screen.getByTitle('Wrapped tab')).toBeVisible() + }) + + it('removes disabled tabs from keyboard navigation', async () => { + const user = userEvent.setup() + + await renderSegmentedControlTabs( + , + ) + + await screen.findByRole('tabpanel', { name: 'All' }) + await user.tab() + expect(screen.getByRole('tab', { name: 'All' })).toHaveFocus() + + await user.keyboard('{ArrowRight}') + await flushMicrotasks() + + expect(screen.getByRole('tab', { name: 'Mentions' })).toHaveFocus() + expect(screen.getByRole('tab', { name: 'Unread' })).toBeDisabled() + }) + + it('has no automated accessibility violations', async () => { + const { container } = await renderSegmentedControlTabs( + , + ) + + await screen.findByRole('tabpanel', { name: 'All' }) + expect(await axe(container)).toHaveNoViolations() + }) +}) diff --git a/src/segmented-control/segmented-control-tabs.tsx b/src/segmented-control/segmented-control-tabs.tsx new file mode 100644 index 000000000..352686041 --- /dev/null +++ b/src/segmented-control/segmented-control-tabs.tsx @@ -0,0 +1,161 @@ +import * as React from 'react' + +import { Box } from '../box' +import { Stack } from '../stack' +import { Tab, TabList, TabPanel, Tabs } from '../tabs' + +import styles from './segmented-control.module.css' + +import type { StackProps } from '../stack' +import type { AriaLabelProps } from './segmented-control' + +type SegmentedControlTabsOption = { + /** A unique value for the option. */ + id: TOptionId + + /** The visible label for the option, including optional inline icon content. */ + label: React.ReactNode + + /** Optional secondary text, such as a count or status. */ + extraLabel?: string + + /** Optional content displayed after the labels, such as an upgrade icon. */ + extraIcon?: React.ReactNode +} & ( + | { + /** An element used to render the tab, such as a router link. */ + tabRender?: React.ReactElement + Wrapper?: never + } + | { + tabRender?: never + + /** A component that wraps the tab content, such as a tooltip. */ + Wrapper?: React.ComponentType<{ children: React.ReactNode }> + } +) & { + /** The panel content associated with the option. */ + tabContent: React.ReactNode + + /** Controls when the panel content is mounted. */ + renderMode?: 'always' | 'active' | 'lazy' + + /** Whether the option is unavailable. */ + disabled?: boolean + } + +type ControlledTabsSelectionProps = { + /** The currently selected option. */ + selectedOptionId: TOptionId + initialSelectedOptionId?: never +} + +type UncontrolledTabsSelectionProps = { + selectedOptionId?: never + /** The initially selected option. */ + initialSelectedOptionId: TOptionId +} + +type SegmentedControlTabsProps = AriaLabelProps & + (ControlledTabsSelectionProps | UncontrolledTabsSelectionProps) & { + /** The options and corresponding panels displayed by the tab control. */ + options: ReadonlyArray> + + /** Called with the newly selected option value. */ + onSelectedOptionChange?: (id: TOptionId) => void + + /** How wide the segmented control should be. */ + width?: 'maxContent' | 'full' + + /** How to align a content-width segmented control within its container. */ + align?: 'start' | 'center' | 'end' + + /** Space between the tab list and its panels. */ + contentOffset?: StackProps['space'] + + /** Determines the visual treatment of the tabs. */ + variant?: 'neutral' | 'themed' + } + +function SegmentedControlTabs({ + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + selectedOptionId, + initialSelectedOptionId, + options, + onSelectedOptionChange, + width, + align, + contentOffset, + variant, +}: SegmentedControlTabsProps) { + function handleSelectedIdChange(selectedId: string | null | undefined) { + if (typeof selectedId === 'string') { + onSelectedOptionChange?.(selectedId as TOptionId) + } + } + + const selectionProps = + selectedOptionId !== undefined + ? { selectedId: selectedOptionId } + : { defaultSelectedId: initialSelectedOptionId } + const labelProps = + ariaLabel !== undefined + ? { 'aria-label': ariaLabel } + : { 'aria-labelledby': ariaLabelledBy } + + return ( + + + + {options.map( + ({ + id, + label, + extraLabel, + extraIcon, + tabRender, + Wrapper = React.Fragment, + disabled, + }) => ( + + + + {label} + {extraLabel ? ( + + {extraLabel} + + ) : null} + {extraIcon} + + + + ), + )} + + {options.map(({ id, tabContent, renderMode }) => ( + + {tabContent} + + ))} + + + ) +} + +export { SegmentedControlTabs } +export type { SegmentedControlTabsOption, SegmentedControlTabsProps } diff --git a/src/tabs/tabs.module.css b/src/segmented-control/segmented-control.module.css similarity index 61% rename from src/tabs/tabs.module.css rename to src/segmented-control/segmented-control.module.css index 2b9f958ff..8245eb915 100644 --- a/src/tabs/tabs.module.css +++ b/src/segmented-control/segmented-control.module.css @@ -25,123 +25,162 @@ --reactist-tab-track-border-radius: 20px; --reactist-tab-track-border-width: 2px; - --reactist-tab-selected-transition: none; --reactist-tab-border-radius: 20px; --reactist-tab-border-width: 1px; --reactist-tab-padding-x: var(--reactist-spacing-medium); - --reactist-tab-padding-y: var(--reactist-spacing-small); + --reactist-tab-padding-y: var(--reactist-spacing-xsmall); --reactist-tab-font-size: var(--reactist-font-size-body); --reactist-tab-font-weight: var(--reactist-font-weight-medium); - --reactist-tab-line-height: 21px; + --reactist-tab-line-height: 22px; } -.tab { +.list { box-sizing: border-box; - padding: var(--reactist-tab-padding-y) var(--reactist-tab-padding-x); - border: none; - background: none; - cursor: pointer; - font-size: var(--reactist-tab-font-size); - font-weight: var(--reactist-tab-font-weight); - line-height: var(--reactist-tab-line-height); - z-index: 1; - text-decoration: none; - border: var(--reactist-tab-border-width) solid transparent; - border-radius: var(--reactist-tab-border-radius); + position: relative; + border: var(--reactist-tab-track-border-width) solid transparent; + border-radius: var(--reactist-tab-track-border-radius); + gap: 1px; } -.fullTabList .tab { +.list-neutral { + border-color: var(--reactist-tab-neutral-track); + background-color: var(--reactist-tab-neutral-track); +} + +.list-themed { + border-color: var(--reactist-tab-themed-track); + background-color: var(--reactist-tab-themed-track); +} + +.items-default-spacing { + gap: 1px; +} + +.full, +.full-items { flex: 1; } -.track { - position: absolute; - top: calc(-1 * var(--reactist-tab-track-border-width)); - right: calc(-1 * var(--reactist-tab-track-border-width)); - bottom: calc(-1 * var(--reactist-tab-track-border-width)); - left: calc(-1 * var(--reactist-tab-track-border-width)); - border-radius: var(--reactist-tab-track-border-radius); - border-width: var(--reactist-tab-track-border-width); - border-style: solid; +.full-items { + align-items: stretch; + flex-wrap: nowrap; } -.selected { - position: absolute; - z-index: 0; - top: 0; - height: 100%; +.full > .item, +.full-items > .item { + flex: 1; + text-align: center; +} + +.item { + /* Ensures the element is mouse-interactive in Electron. */ + -webkit-app-region: no-drag; + box-sizing: border-box; + display: inline-flex; + align-self: stretch; + align-items: center; + justify-content: center; + padding: var(--reactist-tab-padding-y) var(--reactist-tab-padding-x); border: var(--reactist-tab-border-width) solid transparent; border-radius: var(--reactist-tab-border-radius); - transition: var(--reactist-tab-selected-transition); + background-color: transparent; + font-size: var(--reactist-tab-font-size); + font-weight: var(--reactist-tab-font-weight); + line-height: var(--reactist-tab-line-height); + text-decoration: none; + cursor: pointer; } -/* - * Variant options - */ - -.tab, -.tab-neutral { - background-color: var(--reactist-tab-neutral-unselected-fill); +.item-neutral { color: var(--reactist-tab-neutral-unselected-tint); + background-color: var(--reactist-tab-neutral-unselected-fill); +} + +.item-themed { + color: var(--reactist-tab-themed-unselected-tint); + background-color: var(--reactist-tab-themed-unselected-fill); } -.tab[aria-selected='true'], -.tab-neutral[aria-selected='true'] { +.item-neutral[aria-selected='true'], +.radio-item.item-neutral:has(input:checked) { + border-color: var(--reactist-tab-neutral-border); color: var(--reactist-tab-neutral-selected-tint); + background-color: var(--reactist-tab-neutral-selected-fill); + box-shadow: var(--reactist-tab-neutral-shadow); } -.tab[aria-disabled='true'], -.tab-neutral[aria-disabled='true'] { - background-color: var(--reactist-tab-neutral-disabled-fill); +.item-themed[aria-selected='true'] { + border-color: var(--reactist-tab-themed-border); + color: var(--reactist-tab-themed-selected-tint); + background-color: var(--reactist-tab-themed-selected-fill); + box-shadow: var(--reactist-tab-themed-shadow); +} + +.item-neutral[aria-disabled='true'], +.radio-item.item-neutral:has(input:disabled) { color: var(--reactist-tab-neutral-disabled-tint); + background-color: var(--reactist-tab-neutral-disabled-fill); cursor: not-allowed; } -.tab[aria-selected='false']:not([aria-disabled='true']):hover, -.tab-neutral[aria-selected='false']:not([aria-disabled='true']):hover { - background-color: var(--reactist-tab-neutral-hover-fill); +.item-themed[aria-disabled='true'] { + color: var(--reactist-tab-themed-disabled-tint); + background-color: var(--reactist-tab-themed-disabled-fill); + cursor: not-allowed; +} + +.item-neutral[aria-selected='false']:not([aria-disabled='true']):hover, +.radio-item.item-neutral:hover:not(:has(input:checked)):not(:has(input:disabled)) { color: var(--reactist-tab-neutral-hover-tint); + background-color: var(--reactist-tab-neutral-hover-fill); } -.tab-themed { - background-color: var(--reactist-tab-themed-unselected-fill); - color: var(--reactist-tab-themed-unselected-tint); +.item-themed[aria-selected='false']:not([aria-disabled='true']):hover { + color: var(--reactist-tab-themed-hover-tint); + background-color: var(--reactist-tab-themed-hover-fill); } -.tab-themed[aria-selected='true'] { - color: var(--reactist-tab-themed-selected-tint); +.item:focus-visible, +.radio-item:has(input:focus-visible) { + outline: 5px auto Highlight; + outline: 5px auto -webkit-focus-ring-color; } -.tab-themed[aria-disabled='true'] { - background-color: var(--reactist-tab-themed-disabled-fill); - color: var(--reactist-tab-themed-disabled-tint); +.radio-item > input { + clip: rect(0 0 0 0); + clip-path: inset(50%); + width: 1px; + height: 1px; + position: absolute; + overflow: hidden; + white-space: nowrap; } -.tab-themed[aria-selected='false']:not([aria-disabled='true']):hover { - background-color: var(--reactist-tab-themed-hover-fill); - color: var(--reactist-tab-themed-hover-tint); +.item-with-icon { + padding: var(--reactist-spacing-xsmall) var(--reactist-spacing-xsmall) + var(--reactist-spacing-small); } -.track, -.track-neutral { - background: var(--reactist-tab-neutral-track); - border-color: var(--reactist-tab-neutral-track); +.item-content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--reactist-spacing-xsmall); + font-size: inherit; } -.track-themed { - background: var(--reactist-tab-themed-track); - border-color: var(--reactist-tab-themed-track); +.tab-item-content { + font-size: inherit; } -.selected, -.selected-neutral { - background-color: var(--reactist-tab-neutral-selected-fill); - border-color: var(--reactist-tab-neutral-border); - box-shadow: var(--reactist-tab-neutral-shadow); +.tab-item-extra-label { + font-size: var(--reactist-font-size-caption); + font-weight: var(--reactist-font-weight-regular); } -.selected-themed { - background-color: var(--reactist-tab-themed-selected-fill); - border-color: var(--reactist-tab-themed-border); - box-shadow: var(--reactist-tab-themed-shadow); +.panel { + display: flex; + flex-direction: column; + flex-grow: 1; } diff --git a/src/segmented-control/segmented-control.stories.tsx b/src/segmented-control/segmented-control.stories.tsx new file mode 100644 index 000000000..d83c52269 --- /dev/null +++ b/src/segmented-control/segmented-control.stories.tsx @@ -0,0 +1,173 @@ +import * as React from 'react' + +import { Badge } from '../badge' +import { Box } from '../box' +import { Inline } from '../inline' +import { Tooltip } from '../tooltip' + +import { SegmentedControlRadio } from './segmented-control' + +import type { Meta, StoryObj } from '@storybook/react' + +const meta = { + title: '📑 Menus & tabs/Segmented Control/Radio', + component: SegmentedControlRadio, + parameters: { + badges: ['accessible'], + figma: [ + { + url: 'https://www.figma.com/design/LYlWNzvhMDh907l07mPPQk/Product-Library---Web?node-id=1286-25423', + path: 'Web › Components / Todoist › Tabs', + }, + { + url: 'https://www.figma.com/design/AH9ioCnZZLfiA5XcHFabOr/-Refresh--Product-%E2%80%93-Web?node-id=5-1187', + path: 'Refresh - Product Web', + }, + ], + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +const LAYOUT_OPTIONS = [ + { id: 'list', label: 'List' }, + { id: 'board', label: 'Board' }, + { id: 'calendar', label: 'Calendar' }, +] + +function ControlledExample({ width }: { width?: 'maxContent' | 'full' }) { + const [selectedOptionId, setSelectedOptionId] = React.useState('list') + + return ( + + ) +} + +function ViewIcon({ shape }: { shape: 'list' | 'board' | 'calendar' }) { + return ( + + ) +} + +function ListTooltip({ children }: { children: React.ReactNode }) { + return {children} +} + +function BoardTooltip({ children }: { children: React.ReactNode }) { + return {children} +} + +function CalendarTooltip({ children }: { children: React.ReactNode }) { + return {children} +} + +export const Default: Story = { + render: () => , +} + +export const FullWidth: Story = { + render: () => ( + + + + ), +} + +export const DisabledOption: Story = { + render: () => ( + + ), +} + +export const WithIcons: Story = { + render: () => ( + }, + { id: 'board', label: 'Board', icon: }, + { id: 'calendar', label: 'Calendar', icon: }, + ]} + /> + ), +} + +export const WithBadge: Story = { + render: () => ( + + Goals + + + ), + }, + ]} + /> + ), +} + +export const WithTooltips: Story = { + render: () => ( + , + Wrapper: ListTooltip, + }, + { + id: 'board', + label: 'Board', + icon: , + Wrapper: BoardTooltip, + }, + { + id: 'calendar', + label: 'Calendar', + icon: , + Wrapper: CalendarTooltip, + }, + ]} + /> + ), +} diff --git a/src/segmented-control/segmented-control.test.tsx b/src/segmented-control/segmented-control.test.tsx new file mode 100644 index 000000000..df5d063d3 --- /dev/null +++ b/src/segmented-control/segmented-control.test.tsx @@ -0,0 +1,173 @@ +import * as React from 'react' + +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { axe } from 'jest-axe' + +import { flushMicrotasks, TestIcon } from '../utils/test-helpers' + +import { SegmentedControl } from './segmented-control' + +const OPTIONS = [ + { id: 'list', label: 'List' }, + { id: 'board', label: 'Board' }, + { id: 'calendar', label: 'Calendar' }, +] as const + +async function flushAnimationFrames() { + await act( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ) +} + +async function renderSegmentedControl(element: React.ReactElement) { + const result = render(element) + await flushAnimationFrames() + return result +} + +describe('SegmentedControl', () => { + it('selects an option in uncontrolled mode', async () => { + const onSelectedOptionChange = jest.fn() + const user = userEvent.setup() + + await renderSegmentedControl( + , + ) + + expect(screen.getByRole('radio', { name: 'List' })).toBeChecked() + + await user.click(screen.getByRole('radio', { name: 'Board' })) + await flushMicrotasks() + + expect(screen.getByRole('radio', { name: 'Board' })).toBeChecked() + expect(onSelectedOptionChange).toHaveBeenCalledWith('board') + }) + + it('does not update the selected option until controlled props change', async () => { + const onSelectedOptionChange = jest.fn() + const user = userEvent.setup() + const { rerender } = await renderSegmentedControl( + , + ) + + await user.click(screen.getByRole('radio', { name: 'Board' })) + await flushMicrotasks() + + expect(onSelectedOptionChange).toHaveBeenCalledWith('board') + expect(screen.getByRole('radio', { name: 'List' })).toBeChecked() + + rerender( + , + ) + + expect(screen.getByRole('radio', { name: 'Board' })).toBeChecked() + }) + + it('supports keyboard selection', async () => { + const onSelectedOptionChange = jest.fn() + const user = userEvent.setup() + + await renderSegmentedControl( + , + ) + + await user.tab() + expect(screen.getByRole('radio', { name: 'List' })).toHaveFocus() + + await user.keyboard('{ArrowRight}') + await flushMicrotasks() + + expect(screen.getByRole('radio', { name: 'Board' })).toHaveFocus() + expect(screen.getByRole('radio', { name: 'Board' })).toBeChecked() + expect(onSelectedOptionChange).toHaveBeenCalledWith('board') + }) + + it('supports disabled options, icons, rich labels, and content wrappers', async () => { + function ContentWrapper({ children }: { children: React.ReactNode }) { + return {children} + } + + await renderSegmentedControl( + + List recommended + + ), + icon: , + Wrapper: ContentWrapper, + }, + { id: 'board', label: 'Board', disabled: true }, + ]} + />, + ) + await flushMicrotasks() + + expect(screen.getByRole('radio', { name: 'List recommended' })).toBeChecked() + expect(screen.getByRole('radio', { name: 'Board' })).toBeDisabled() + expect(screen.getByTitle('Wrapped option')).toBeVisible() + }) + + it('can be labelled and described by other elements', async () => { + await renderSegmentedControl( + <> +

Project layout

+

Changes how tasks are arranged.

+ + , + ) + await flushMicrotasks() + + const group = screen.getByRole('radiogroup', { name: 'Project layout' }) + expect(group).toHaveAccessibleDescription('Changes how tasks are arranged.') + }) + + describe('a11y', () => { + it('renders with no a11y violations', async () => { + const { container } = await renderSegmentedControl( + , + ) + await flushMicrotasks() + + expect(await axe(container)).toHaveNoViolations() + }) + }) +}) diff --git a/src/segmented-control/segmented-control.tsx b/src/segmented-control/segmented-control.tsx new file mode 100644 index 000000000..10705f76c --- /dev/null +++ b/src/segmented-control/segmented-control.tsx @@ -0,0 +1,199 @@ +import * as React from 'react' + +import { Radio, RadioGroup, useRadioStore } from '@ariakit/react' +import classNames from 'classnames' + +import { Box } from '../box' + +import styles from './segmented-control.module.css' + +import type { RadioStoreProps } from '@ariakit/react' +import type { BoxJustifyContent } from '../box' + +type AriaLabelProps = + | { + /** The accessible label for the segmented control. */ + 'aria-label': string + 'aria-labelledby'?: never + } + | { + 'aria-label'?: never + /** One or more element IDs that label the segmented control. */ + 'aria-labelledby': string + } + +type AriaDescriptionProps = + | { + /** An accessible description for the segmented control. */ + 'aria-description'?: string + 'aria-describedby'?: never + } + | { + 'aria-description'?: never + /** One or more element IDs that describe the segmented control. */ + 'aria-describedby'?: string + } + +type SegmentedControlOption = { + /** A unique value for the option. */ + id: TOptionId + + /** An optional icon displayed above the label. */ + icon?: React.ReactNode + + /** The visible label for the option. */ + label: React.ReactNode + + /** An optional component that wraps the option content, such as a tooltip. */ + Wrapper?: React.ComponentType<{ children: React.ReactNode }> + + /** Whether the option is unavailable. */ + disabled?: boolean +} + +type ControlledSelectionProps = { + /** The currently selected option. */ + selectedOptionId: TOptionId + initialSelectedOptionId?: never +} + +type UncontrolledSelectionProps = { + selectedOptionId?: never + /** The initially selected option. */ + initialSelectedOptionId: TOptionId +} + +type SegmentedControlProps = AriaLabelProps & + AriaDescriptionProps & + (ControlledSelectionProps | UncontrolledSelectionProps) & { + /** The options displayed by the segmented control. */ + options: ReadonlyArray> + + /** Called with the newly selected option value. */ + onSelectedOptionChange?: (id: TOptionId) => void + + /** How wide the segmented control should be. */ + width?: 'maxContent' | 'full' + + /** How to align a content-width segmented control within its container. */ + align?: 'start' | 'center' | 'end' + } + +type SegmentedControlRadioOption = + SegmentedControlOption +type SegmentedControlRadioProps = + SegmentedControlProps + +function SegmentedControlRadioInner( + { + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + 'aria-description': ariaDescription, + 'aria-describedby': ariaDescribedBy, + selectedOptionId, + initialSelectedOptionId, + options, + onSelectedOptionChange, + width = 'maxContent', + align = 'start', + }: SegmentedControlProps, + ref: React.ForwardedRef, +) { + function handleValueChange(value: RadioStoreProps['value']) { + if (typeof value === 'string') { + onSelectedOptionChange?.(value as TOptionId) + } + } + + const store = useRadioStore({ + defaultValue: initialSelectedOptionId, + value: selectedOptionId, + setValue: onSelectedOptionChange ? handleValueChange : undefined, + }) + + const justifyContentAlignMap: Record = { + start: 'flexStart', + center: 'center', + end: 'flexEnd', + } + + return ( + + + } + > + {options.map(({ id, icon, label, Wrapper, disabled }) => { + const OptionWrapper = Wrapper ?? React.Fragment + + return ( + ( + + )} + /> + ) + })} + + + ) +} + +const SegmentedControlRadio = React.forwardRef(SegmentedControlRadioInner) as < + TOptionId extends string = string, +>( + props: SegmentedControlProps & React.RefAttributes, +) => React.ReactElement + +/** + * @deprecated Use `SegmentedControlRadio`. This alias is retained so consumers of the initial + * Reactist port can migrate without a breaking change. + */ +const SegmentedControl = SegmentedControlRadio + +export { SegmentedControl, SegmentedControlRadio } +export type { + AriaDescriptionProps, + AriaLabelProps, + SegmentedControlOption, + SegmentedControlProps, + SegmentedControlRadioOption, + SegmentedControlRadioProps, +} diff --git a/src/tabs/tabs.mdx b/src/tabs/tabs.mdx index b2e0376b4..b7e7f81af 100644 --- a/src/tabs/tabs.mdx +++ b/src/tabs/tabs.mdx @@ -47,7 +47,7 @@ behaviour, see [ARIA: tab role](https://developer.mozilla.org/en-US/docs/Web/Acc ## Colors -The following CSS custom properties are available so that the tabs' colors can be customized: +The following CSS custom properties are shared with Segmented Control and can be customized: ``` --reactist-tab-neutral-selected-tint @@ -87,7 +87,7 @@ The following CSS custom properties are available so that the tabs' sizes can be --reactist-tab-line-height ``` -## Other +## Shadows The following CSS custom properties are also available to customize other tabs' styles: @@ -95,8 +95,6 @@ The following CSS custom properties are also available to customize other tabs' --reactist-tab-neutral-shadow --reactist-tab-themed-shadow - ---reactist-tab-selected-transition ``` ## Stories @@ -113,10 +111,6 @@ The following CSS custom properties are also available to customize other tabs' -### Selected tab with slide animation - - - ### Using the `` component diff --git a/src/tabs/tabs.stories.jsx b/src/tabs/tabs.stories.jsx index 414ba263e..42931c7a0 100644 --- a/src/tabs/tabs.stories.jsx +++ b/src/tabs/tabs.stories.jsx @@ -46,7 +46,7 @@ const Template = ({ ) export default { - title: '📑 Menus & tabs/Tabs', + title: '📑 Menus & tabs/Tabs/Compound API', component: Tabs, parameters: { @@ -223,52 +223,6 @@ export const TabsAlignedToTheCenter = { name: 'Tabs aligned to the center', } -export const SelectedTabWithSlideAnimation = { - render: () => ( - - - - Tab 1 - Tab 2 - Tab 3 - - - - Content of tab 1 - - - - - Content of tab 2 - - - - - Content of tab 3 - - - - ), - - parameters: { - docs: { - source: { - type: 'code', - }, - }, - }, - - name: 'Selected tab with slide animation', - - style: { - border: '1px solid red', - }, -} - export const UsingTheTabAwareSlotComponent = { render: () => ( diff --git a/src/tabs/tabs.tsx b/src/tabs/tabs.tsx index ca7f45378..36c527f12 100644 --- a/src/tabs/tabs.tsx +++ b/src/tabs/tabs.tsx @@ -12,7 +12,7 @@ import classNames from 'classnames' import { Box } from '../box' import { Inline } from '../inline' -import styles from './tabs.module.css' +import styles from '../segmented-control/segmented-control.module.css' import type { TabPanelProps as BaseTabPanelProps, @@ -72,13 +72,8 @@ function Tabs({ selectedId, setSelectedId: onSelectedIdChange, }) - const actualSelectedId = useStoreState(tabStore, 'selectedId') - const memoizedTabState = React.useMemo( - () => ({ tabStore, variant, selectedId: selectedId ?? actualSelectedId ?? null }), - [variant, tabStore, selectedId, actualSelectedId], - ) - return {children} + return {children} } interface TabProps @@ -104,14 +99,22 @@ interface TabProps * Represents the individual tab elements within the group. Each `` must have a corresponding `` component. */ const Tab = React.forwardRef(function Tab( - { children, id, disabled, exceptionallySetClassName, render, onClick }, + { + children, + id, + disabled, + exceptionallySetClassName, + accessibleWhenDisabled = false, + render, + ...props + }, ref, ): React.ReactElement | null { const tabContextValue = React.useContext(TabsContext) if (!tabContextValue) return null const { variant, tabStore } = tabContextValue - const className = classNames(exceptionallySetClassName, styles.tab, styles[`tab-${variant}`]) + const className = classNames(exceptionallySetClassName, styles.item, styles[`item-${variant}`]) return ( (function Tab( ref={ref} disabled={disabled} store={tabStore} - render={render} className={className} - onClick={onClick} + accessibleWhenDisabled={accessibleWhenDisabled} + render={render ?? (({ style, ...renderProps }) =>