From 2887c1a52eeff96846934a008f8867c37754cf5a Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Thu, 24 Sep 2026 07:21:54 +0200 Subject: [PATCH 1/4] Add `` --- src/components/Button/Button.stories.tsx | 12 ++ src/components/Button/Button.tsx | 5 +- .../InputText/InputText.stories.tsx | 8 + src/components/InputText/InputText.tsx | 5 +- .../ToggleGroup/ToggleGroup.module.scss | 177 ++++++++++++++++++ .../ToggleGroup/ToggleGroup.module.scss.d.ts | 3 + .../ToggleGroup/ToggleGroup.stories.tsx | 147 +++++++++++++++ src/components/ToggleGroup/ToggleGroup.tsx | 122 ++++++++++++ .../ToggleGroup/ToggleGroup.utils.test.ts | 90 +++++++++ .../ToggleGroup/ToggleGroup.utils.ts | 123 ++++++++++++ src/components/ToggleGroup/index.ts | 6 + src/index.ts | 1 + src/style/_sizes.scss | 3 + src/style/variants.module.scss | 1 + src/types.ts | 2 + src/utils/getStyleClassNames.tsx | 3 +- 16 files changed, 705 insertions(+), 3 deletions(-) create mode 100644 src/components/ToggleGroup/ToggleGroup.module.scss create mode 100644 src/components/ToggleGroup/ToggleGroup.module.scss.d.ts create mode 100644 src/components/ToggleGroup/ToggleGroup.stories.tsx create mode 100644 src/components/ToggleGroup/ToggleGroup.tsx create mode 100644 src/components/ToggleGroup/ToggleGroup.utils.test.ts create mode 100644 src/components/ToggleGroup/ToggleGroup.utils.ts create mode 100644 src/components/ToggleGroup/index.ts diff --git a/src/components/Button/Button.stories.tsx b/src/components/Button/Button.stories.tsx index 03c8933..1deb9de 100644 --- a/src/components/Button/Button.stories.tsx +++ b/src/components/Button/Button.stories.tsx @@ -56,6 +56,12 @@ const meta: Meta = { description: 'Whether the button has a visible border.', table: { category: 'Appearance' }, }, + corners: { + control: 'select', + options: ['tight', 'normal', 'wide'], + description: 'The corner radius of the button.', + table: { category: 'Appearance' }, + }, rounded: { control: 'boolean', description: 'Whether the button has rounded ends.', @@ -102,6 +108,7 @@ export const Text: Story = { args: { border: false, collapsePadding: false, + corners: 'normal', disabled: false, icon: undefined, iconPosition: undefined, @@ -119,6 +126,7 @@ export const TextIcon: Story = { args: { border: false, collapsePadding: false, + corners: 'normal', disabled: false, icon: , iconPosition: 'start', @@ -136,6 +144,7 @@ export const Icon: Story = { args: { border: false, collapsePadding: false, + corners: 'normal', disabled: false, icon: , iconPosition: 'start', @@ -152,6 +161,7 @@ export const Link: Story = { args: { border: false, collapsePadding: false, + corners: 'normal', href: 'https://example.com', intent: 'secondary', rounded: false, @@ -170,6 +180,7 @@ export const VisualComparison: Story = { iconPosition: 'start', size: 'normal', collapsePadding: false, + corners: 'normal', loading: false, disabled: false, rounded: false, @@ -196,6 +207,7 @@ export const FileInput: StoryObj = { args: { accept: ['.pdf', '.png', '.jpg'], collapsePadding: false, + corners: 'normal', disabled: false, icon: , iconPosition: 'start', diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 992d270..7e3546b 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -8,6 +8,7 @@ import { import clsx from 'clsx'; import { + ElementCorners, ElementIntent, ElementSize, ElementVariant, @@ -23,6 +24,7 @@ export interface ButtonProps extends Omit = { ], tags: ['autodocs'], argTypes: { + corners: { + control: 'select', + options: ['tight', 'normal', 'wide'], + description: 'The corner radius of the input.', + table: { category: 'Appearance' }, + }, disabled: { control: 'boolean', description: 'Whether the component is disabled.', @@ -33,6 +39,7 @@ type Story = StoryObj; export const Default: Story = { name: 'Default', args: { + corners: 'normal', disabled: false, placeholder: 'Enter your name...', }, @@ -41,6 +48,7 @@ export const Default: Story = { export const WithIcon: Story = { name: 'With Icon', args: { + corners: 'normal', disabled: false, placeholder: 'Search...', icon: , diff --git a/src/components/InputText/InputText.tsx b/src/components/InputText/InputText.tsx index 3e897f6..afb93d7 100644 --- a/src/components/InputText/InputText.tsx +++ b/src/components/InputText/InputText.tsx @@ -5,6 +5,7 @@ import { } from 'react'; import clsx from 'clsx'; +import { ElementCorners } from '../../types'; import { getStyleClassNames } from '../../utils/getStyleClassNames'; import styles from './InputText.module.scss'; @@ -12,10 +13,12 @@ import styles from './InputText.module.scss'; export interface InputTextProps extends InputHTMLAttributes { icon?: ReactElement; loading?: boolean; + corners?: boolean | ElementCorners; } export const InputText = forwardRef(({ className, + corners = 'normal', icon, loading = false, // TODO: Implement skeleton loading state disabled, @@ -32,7 +35,7 @@ export const InputText = forwardRef(({ = { + title: 'Components/ToggleGroup', + component: ToggleGroup, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + className: { table: { disable: true } }, + activeVariant: { + control: 'select', + options: ['solid', 'shaded', 'ghost', 'surface'], + description: 'The variant applied to pressed items.', + table: { category: 'Appearance' }, + }, + variant: { + control: 'select', + options: ['solid', 'shaded', 'ghost', 'surface'], + description: 'The variant applied to unpressed items.', + table: { category: 'Appearance' }, + }, + intent: { + control: 'select', + options: ['primary', 'secondary', 'danger', 'warning', 'success', 'info'], + description: 'The color of items.', + table: { category: 'Appearance' }, + }, + size: { + control: 'select', + options: ['small', 'normal', 'large'], + description: 'The size of each item.', + table: { category: 'Appearance' }, + }, + border: { + control: 'boolean', + description: 'Whether the group has a visible border.', + table: { category: 'Appearance' }, + }, + corners: { + control: 'select', + options: ['tight', 'normal', 'wide'], + description: 'The corner radius of each item.', + table: { category: 'Appearance' }, + }, + rounded: { + control: 'boolean', + description: 'Whether the group has rounded ends.', + table: { category: 'Appearance' }, + }, + equal: { + control: 'boolean', + description: 'Whether all items are forced to an equal size.', + table: { category: 'Appearance' }, + }, + orientation: { + control: 'radio', + options: ['horizontal', 'vertical'], + description: 'The orientation of the group.', + table: { category: 'Appearance' }, + }, + multiple: { + control: 'boolean', + description: 'Whether multiple items can be pressed at once.', + table: { category: 'Behavior' }, + }, + loopFocus: { + control: 'boolean', + description: 'Whether arrow-key navigation wraps from the last item to the first, and vice versa.', + table: { category: 'Behavior' }, + }, + disabled: { + control: 'boolean', + description: 'Whether the group is disabled.', + table: { category: 'State' }, + }, + defaultValue: { table: { disable: true } }, + options: { table: { disable: true } }, + value: { table: { disable: true } }, + onChange: { table: { disable: true } }, + }, +}; + +export default meta; +type Story = StoryObj; + +const sharedArgs = { + activeVariant: 'shaded', + border: true, + corners: 'normal', + disabled: false, + equal: false, + intent: 'secondary', + loopFocus: true, + multiple: false, + orientation: 'horizontal', + rounded: false, + size: 'normal', + variant: 'ghost', +} as const; + +export const Text: Story = { + name: 'Text', + args: { + ...sharedArgs, + options: [ + { value: 'system', text: 'System' }, + { value: 'light', text: 'Light' }, + { value: 'dark', text: 'Dark' }, + ], + defaultValue: ['system'], + }, +}; + +export const TextIcon: Story = { + name: 'Text & Icon', + args: { + ...sharedArgs, + options: [ + { value: 'left', text: 'Left', icon: }, + { value: 'center', text: 'Center', icon: }, + { value: 'right', text: 'Right', icon: }, + ], + defaultValue: ['left'], + }, +}; + +export const Icon: Story = { + name: 'Icon', + args: { + ...sharedArgs, + options: [ + { value: 'left', icon: }, + { value: 'center', icon: }, + { value: 'right', icon: }, + ], + defaultValue: ['left'], + }, +}; diff --git a/src/components/ToggleGroup/ToggleGroup.tsx b/src/components/ToggleGroup/ToggleGroup.tsx new file mode 100644 index 0000000..ebc3284 --- /dev/null +++ b/src/components/ToggleGroup/ToggleGroup.tsx @@ -0,0 +1,122 @@ +import { + ElementRef, + forwardRef, + Fragment, + HTMLAttributes, + ReactElement, +} from 'react'; +import { Toggle as BaseToggle } from '@base-ui/react/toggle'; +import { ToggleGroup as BaseToggleGroup } from '@base-ui/react/toggle-group'; +import clsx from 'clsx'; + +import { + ElementCorners, + ElementIntent, + ElementOrientation, + ElementSize, + ElementVariant, +} from '../../types'; +import { getStyleClassNames } from '../../utils/getStyleClassNames'; +import { + getItemPressedStyle, + getRootStyle, + getSpacerStyle, +} from './ToggleGroup.utils'; + +import sizes from '../../style/sizes.module.scss'; +import styles from './ToggleGroup.module.scss'; + +export type ToggleGroupValue = string; + +export type ToggleGroupOption = { + value: ToggleGroupValue; + text?: string; + icon?: ReactElement; + iconPosition?: 'start' | 'end'; + disabled?: boolean; +}; + +export interface ToggleGroupProps extends Omit, + 'defaultValue' | + 'onChange' | + 'value' +> { + activeVariant?: ElementVariant; + border?: boolean; + corners?: boolean | ElementCorners; + defaultValue?: ToggleGroupValue[]; + disabled?: boolean; + equal?: boolean; + intent?: ElementIntent; + loopFocus?: boolean; + multiple?: boolean; + onChange?: (values: ToggleGroupValue[]) => void; + options: ToggleGroupOption[]; + orientation?: ElementOrientation; + rounded?: boolean; + size?: ElementSize; + value?: ToggleGroupValue[]; + variant?: ElementVariant; +} + +export const ToggleGroup = forwardRef, ToggleGroupProps>(({ + activeVariant = 'solid', + border = false, + className, + equal = false, + intent = 'secondary', + onChange, + corners = 'normal', + options, + orientation = 'horizontal', + rounded = false, + size = 'normal', + variant = 'shaded', + ...props +}, ref): JSX.Element => ( + + {options.map((option, index) => ( + + {index > 0 && ( +
+ )} + + {option.icon && ( + {option.icon} + )} + {option.text && ( + {option.text} + )} + + + ))} + +)); + +ToggleGroup.displayName = 'ToggleGroup'; diff --git a/src/components/ToggleGroup/ToggleGroup.utils.test.ts b/src/components/ToggleGroup/ToggleGroup.utils.test.ts new file mode 100644 index 0000000..f6fbba4 --- /dev/null +++ b/src/components/ToggleGroup/ToggleGroup.utils.test.ts @@ -0,0 +1,90 @@ +import { + describe, + expect, + it, +} from 'vitest'; + +import { + getItemPressedStyle, + getRootStyle, + getSpacerStyle, +} from './ToggleGroup.utils'; + +describe('getRootStyle', () => { + it('leaves both grid template properties undefined when equal is false.', () => { + expect(getRootStyle(false, 'horizontal', 3)).toEqual({ + gridTemplateColumns: undefined, + gridTemplateRows: undefined, + }); + }); + + it('builds a column template with a track per option when horizontal and equal.', () => { + expect(getRootStyle(true, 'horizontal', 3)).toEqual({ + gridTemplateColumns: '1fr var(--toggle-group-spacer-size, 0px) 1fr var(--toggle-group-spacer-size, 0px) 1fr', + gridTemplateRows: undefined, + }); + }); + + it('builds a row template with a track per option when vertical and equal.', () => { + expect(getRootStyle(true, 'vertical', 2)).toEqual({ + gridTemplateColumns: undefined, + gridTemplateRows: '1fr var(--toggle-group-spacer-size, 0px) 1fr', + }); + }); +}); + +describe('getSpacerStyle', () => { + it('resolves both the unpressed and pressed color sets.', () => { + expect(getSpacerStyle('shaded', 'solid', 'primary')).toEqual({ + '--toggle-group-spacer-border-color': 'var(--color-shaded-primary-border)', + '--toggle-group-spacer-background-color': 'var(--color-shaded-primary-bg)', + '--toggle-group-spacer-active-border-color': 'var(--color-solid-primary-bg)', + '--toggle-group-spacer-active-background-color': 'var(--color-solid-primary-bg)', + }); + }); + + it('uses the background color as the border color for the solid variant.', () => { + expect(getSpacerStyle('solid', 'solid', 'primary')).toEqual({ + '--toggle-group-spacer-border-color': 'var(--color-solid-primary-bg)', + '--toggle-group-spacer-background-color': 'var(--color-solid-primary-bg)', + '--toggle-group-spacer-active-border-color': 'var(--color-solid-primary-bg)', + '--toggle-group-spacer-active-background-color': 'var(--color-solid-primary-bg)', + }); + }); + + it('uses the dedicated border color for the ghost variant.', () => { + expect(getSpacerStyle('ghost', 'ghost', 'danger')).toEqual({ + '--toggle-group-spacer-border-color': 'var(--color-ghost-danger-border)', + '--toggle-group-spacer-background-color': 'var(--color-ghost-danger-bg)', + '--toggle-group-spacer-active-border-color': 'var(--color-ghost-danger-border)', + '--toggle-group-spacer-active-background-color': 'var(--color-ghost-danger-bg)', + }); + }); + + it('uses the fixed, intent-independent card colors for the surface variant.', () => { + expect(getSpacerStyle('surface', 'surface', 'primary')).toEqual({ + '--toggle-group-spacer-border-color': 'var(--color-card-border)', + '--toggle-group-spacer-background-color': 'var(--color-card-bg)', + '--toggle-group-spacer-active-border-color': 'var(--color-card-border)', + '--toggle-group-spacer-active-background-color': 'var(--color-card-bg)', + }); + }); +}); + +describe('getItemPressedStyle', () => { + it('resolves the pressed border, background, and text color for the solid variant.', () => { + expect(getItemPressedStyle('solid', 'primary')).toEqual({ + '--toggle-group-item-pressed-border-color': 'var(--color-solid-primary-bg)', + '--toggle-group-item-pressed-background-color': 'var(--color-solid-primary-bg)', + '--toggle-group-item-pressed-color': 'var(--color-solid-primary-text)', + }); + }); + + it('omits the text color for the surface variant, which has none.', () => { + expect(getItemPressedStyle('surface', 'primary')).toEqual({ + '--toggle-group-item-pressed-border-color': 'var(--color-card-border)', + '--toggle-group-item-pressed-background-color': 'var(--color-card-bg)', + '--toggle-group-item-pressed-color': undefined, + }); + }); +}); diff --git a/src/components/ToggleGroup/ToggleGroup.utils.ts b/src/components/ToggleGroup/ToggleGroup.utils.ts new file mode 100644 index 0000000..ca77f38 --- /dev/null +++ b/src/components/ToggleGroup/ToggleGroup.utils.ts @@ -0,0 +1,123 @@ +import { CSSProperties } from 'react'; + +import { + ElementIntent, + ElementOrientation, + ElementVariant, +} from '../../types'; + +/** + * Computes the root element's grid template properties. + * + * For equal-width/height items, sized to the widest/tallest one, without + * measuring anything in JS: an explicit grid template where every item track is + * `1fr` naturally equalizes them (unlike `flex: 1`, which doesn't equalize + * siblings in a shrink-to-fit container). + * + * @param equal - Whether items should be forced to an equal size. + * @param orientation - The group's orientation. + * @param optionCount - The number of items, used to build one grid track per item. + * @returns CSS grid template properties for the root element's inline `style`. + */ +export const getRootStyle = ( + equal: boolean, + orientation: ElementOrientation, + optionCount: number, +): CSSProperties => { + const equalTemplate = equal ? Array.from({ + length: optionCount, + }, () => '1fr').join(' var(--toggle-group-spacer-size, 0px) ') : undefined; + return { + gridTemplateColumns: orientation === 'vertical' ? undefined : equalTemplate, + gridTemplateRows: orientation === 'vertical' ? equalTemplate : undefined, + } as CSSProperties; +}; + +type VariantColors = { + border: string; + background: string; + text?: string; +}; + +/** + * Resolves a variant/intent pair down to the same border, background, and text + * colors `variants.module.scss` would apply, so those colors can be reused + * outside of `getStyleClassNames`' class-based approach - as CSS custom + * properties consumed by `[data-pressed]`/adjacency selectors instead. + * + * @param elementVariant - The variant to resolve. + * @param elementIntent - The intent to resolve. + * @returns The border, background, and (when the variant sets one) text color. + */ +const getVariantColors = ( + elementVariant: ElementVariant, + elementIntent: ElementIntent, +): VariantColors => { + if (elementVariant === 'surface') { + return { + border: 'var(--color-card-border)', + background: 'var(--color-card-bg)', + }; + } + return { + border: elementVariant === 'solid' ? ( + `var(--color-solid-${elementIntent}-bg)` + ) : ( + `var(--color-${elementVariant}-${elementIntent}-border)` + ), + background: `var(--color-${elementVariant}-${elementIntent}-bg)`, + text: `var(--color-${elementVariant}-${elementIntent}-text)`, + }; +}; + +/** + * Computes the divider's border/background color custom properties. + * + * Layers a background and border color on top of each other so that both + * properties can support alpha and will match neighboring elements that have + * variants applied. Exposes both the unpressed and pressed color sets, so + * `ToggleGroup.module.scss` can pick between them with `:has()`/adjacency + * selectors instead of this needing to know which neighbor is pressed. + * + * @param elementVariant - The unpressed variant of the item(s) the divider sits next to. + * @param activeVariant - The pressed variant of the item(s) the divider sits next to. + * @param elementIntent - The group's intent. + * @returns CSS custom properties consumed by `ToggleGroup.module.scss`'s `&-spacer` rule. + */ +export const getSpacerStyle = ( + elementVariant: ElementVariant, + activeVariant: ElementVariant, + elementIntent: ElementIntent, +): CSSProperties => { + const unpressed = getVariantColors(elementVariant, elementIntent); + const pressed = getVariantColors(activeVariant, elementIntent); + return { + '--toggle-group-spacer-border-color': unpressed.border, + '--toggle-group-spacer-background-color': unpressed.background, + '--toggle-group-spacer-active-border-color': pressed.border, + '--toggle-group-spacer-active-background-color': pressed.background, + } as CSSProperties; +}; + +/** + * Computes an item's pressed-state color custom properties. + * + * The item's own class names always reflect its unpressed variant; these + * properties are picked up by a `[data-pressed]` CSS rule instead, so the + * pressed/unpressed swap doesn't need to be decided in JS. + * + * @param activeVariant - The variant to apply once the item is pressed. + * @param elementIntent - The group's intent. + * @returns CSS custom properties consumed by `ToggleGroup.module.scss`'s `&-item[data-pressed]` rule. + */ +export const getItemPressedStyle = ( + activeVariant: ElementVariant, + elementIntent: ElementIntent, +): CSSProperties => { + const pressed = getVariantColors(activeVariant, elementIntent); + return { + '--toggle-group-item-pressed-border-color': pressed.border, + '--toggle-group-item-pressed-background-color': pressed.background, + '--toggle-group-item-pressed-color': pressed.text, + } as CSSProperties; +}; diff --git a/src/components/ToggleGroup/index.ts b/src/components/ToggleGroup/index.ts new file mode 100644 index 0000000..c6154a7 --- /dev/null +++ b/src/components/ToggleGroup/index.ts @@ -0,0 +1,6 @@ +export { + ToggleGroup, + type ToggleGroupOption, + type ToggleGroupProps, + type ToggleGroupValue, +} from './ToggleGroup'; diff --git a/src/index.ts b/src/index.ts index 7912450..661917d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,5 +37,6 @@ export * from './components/Table'; export * from './components/Tabs'; export * from './components/Tag'; export * from './components/ThemeProvider'; +export * from './components/ToggleGroup'; export * from './types'; export * from './utils'; diff --git a/src/style/_sizes.scss b/src/style/_sizes.scss index 5e50825..1965b01 100644 --- a/src/style/_sizes.scss +++ b/src/style/_sizes.scss @@ -1,3 +1,6 @@ +// Keep in sync with `ElementSize` in `src/types.ts`. +$sizes: "small", "normal", "large"; + // Ensure hit-box is at least 40px, regardless of the element's own visual size. @mixin min-hit-area($reference-size) { &::before { diff --git a/src/style/variants.module.scss b/src/style/variants.module.scss index ab847bb..fe21c74 100644 --- a/src/style/variants.module.scss +++ b/src/style/variants.module.scss @@ -9,6 +9,7 @@ $sides: "top", "right", "bottom", "left"; @include text.ui; border-color: transparent; + border-style: solid; border-width: var(--border-width); transition-timing-function: ease-in-out; diff --git a/src/types.ts b/src/types.ts index 95e07d9..d224ab5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,8 @@ export const ELEMENT_INTENTS = [ 'info', ] as const; +export type ElementCorners = 'tight' | 'normal' | 'wide'; + export type ElementIntent = (typeof ELEMENT_INTENTS)[number]; export type ElementOrientation = 'horizontal' | 'vertical'; diff --git a/src/utils/getStyleClassNames.tsx b/src/utils/getStyleClassNames.tsx index f7dd60a..b1d534e 100644 --- a/src/utils/getStyleClassNames.tsx +++ b/src/utils/getStyleClassNames.tsx @@ -1,6 +1,7 @@ import clsx, { ClassValue } from 'clsx'; import { + ElementCorners, ElementElevation, ElementIntent, ElementSize, @@ -27,7 +28,7 @@ type GetStyleClassNamesConfig = { rounded?: boolean; square?: boolean; border?: boolean | Side | Side[]; - corners?: boolean | 'tight' | 'normal' | 'wide'; + corners?: boolean | ElementCorners; }; export const getStyleClassNames = (config: GetStyleClassNamesConfig): string[] => { From e6f47eebd196fec81553f8ca4495904662f302de Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Fri, 25 Sep 2026 12:17:26 +0200 Subject: [PATCH 2/4] PR Feedback --- src/components/ToggleGroup/ToggleGroup.module.scss | 14 ++++++++++---- src/components/ToggleGroup/ToggleGroup.stories.tsx | 6 +++--- src/components/ToggleGroup/ToggleGroup.tsx | 5 ++++- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/components/ToggleGroup/ToggleGroup.module.scss b/src/components/ToggleGroup/ToggleGroup.module.scss index b053c37..606bb49 100644 --- a/src/components/ToggleGroup/ToggleGroup.module.scss +++ b/src/components/ToggleGroup/ToggleGroup.module.scss @@ -123,25 +123,28 @@ } &[data-orientation="horizontal"] { - .toggleGroup-item:first-child { + .toggleGroup-item:first-child:not(:only-child) { border-top-right-radius: 0; border-bottom-right-radius: 0; } - .toggleGroup-item:last-child { + .toggleGroup-item:last-child:not(:only-child) { border-top-left-radius: 0; border-bottom-left-radius: 0; } + /* stylelint-disable-next-line no-descending-specificity */ .toggleGroup-item:not(:first-child, :last-child) { border-radius: 0; } .toggleGroup-item { + /* stylelint-disable-next-line no-descending-specificity */ &:not(:first-child) { border-left-width: 0; } + /* stylelint-disable-next-line no-descending-specificity */ &:not(:last-child) { border-right-width: 0; } @@ -149,25 +152,28 @@ } &[data-orientation="vertical"] { - .toggleGroup-item:first-child { + .toggleGroup-item:first-child:not(:only-child) { border-bottom-right-radius: 0; border-bottom-left-radius: 0; } - .toggleGroup-item:last-child { + .toggleGroup-item:last-child:not(:only-child) { border-top-left-radius: 0; border-top-right-radius: 0; } + /* stylelint-disable-next-line no-descending-specificity */ .toggleGroup-item:not(:first-child, :last-child) { border-radius: 0; } .toggleGroup-item { + /* stylelint-disable-next-line no-descending-specificity */ &:not(:first-child) { border-top-width: 0; } + /* stylelint-disable-next-line no-descending-specificity */ &:not(:last-child) { border-bottom-width: 0; } diff --git a/src/components/ToggleGroup/ToggleGroup.stories.tsx b/src/components/ToggleGroup/ToggleGroup.stories.tsx index 2087344..3474d1d 100644 --- a/src/components/ToggleGroup/ToggleGroup.stories.tsx +++ b/src/components/ToggleGroup/ToggleGroup.stories.tsx @@ -138,9 +138,9 @@ export const Icon: Story = { args: { ...sharedArgs, options: [ - { value: 'left', icon: }, - { value: 'center', icon: }, - { value: 'right', icon: }, + { value: 'left', icon: , ariaLabel: 'Align left' }, + { value: 'center', icon: , ariaLabel: 'Align center' }, + { value: 'right', icon: , ariaLabel: 'Align right' }, ], defaultValue: ['left'], }, diff --git a/src/components/ToggleGroup/ToggleGroup.tsx b/src/components/ToggleGroup/ToggleGroup.tsx index ebc3284..64c928b 100644 --- a/src/components/ToggleGroup/ToggleGroup.tsx +++ b/src/components/ToggleGroup/ToggleGroup.tsx @@ -34,6 +34,7 @@ export type ToggleGroupOption = { icon?: ReactElement; iconPosition?: 'start' | 'end'; disabled?: boolean; + ariaLabel?: string; }; export interface ToggleGroupProps extends Omit, @@ -71,6 +72,7 @@ export const ToggleGroup = forwardRef, Toggle orientation = 'horizontal', rounded = false, size = 'normal', + style, variant = 'shaded', ...props }, ref): JSX.Element => ( @@ -80,7 +82,7 @@ export const ToggleGroup = forwardRef, Toggle className={clsx(styles.toggleGroup, className)} data-border={border} data-equal={equal} - style={getRootStyle(equal, orientation, options.length)} + style={{ ...style, ...getRootStyle(equal, orientation, options.length) }} orientation={orientation} onValueChange={onChange} > @@ -95,6 +97,7 @@ export const ToggleGroup = forwardRef, Toggle Date: Fri, 25 Sep 2026 13:22:01 +0200 Subject: [PATCH 3/4] WIP --- .../ThemeProvider/ThemeProvider.context.ts | 9 ++++- .../ThemeProvider/ThemeProvider.tsx | 3 +- src/components/ThemeProvider/index.ts | 1 + .../ToggleGroup/ToggleGroup.stories.tsx | 10 ++--- src/components/ToggleGroup/ToggleGroup.tsx | 37 +++++++++++++++--- .../ToggleGroup/ToggleGroup.utils.test.ts | 38 +++++++++++++++++++ .../ToggleGroup/ToggleGroup.utils.ts | 37 ++++++++++++++++++ 7 files changed, 122 insertions(+), 13 deletions(-) diff --git a/src/components/ThemeProvider/ThemeProvider.context.ts b/src/components/ThemeProvider/ThemeProvider.context.ts index b84f267..6c435ef 100644 --- a/src/components/ThemeProvider/ThemeProvider.context.ts +++ b/src/components/ThemeProvider/ThemeProvider.context.ts @@ -4,10 +4,17 @@ import { light } from './themes/light'; import { SelectOption } from '../Select'; import { Theme } from './ThemeProvider.types'; +/* `dark` is left undefined for the system option, which has no inherent light/ + * dark polarity of its own - only for options backed by a registered theme, so + * consumers can filter/group options by polarity (e.g. separate light-theme and + * dark-theme pickers). + */ +export type ThemeOption = SelectOption & { dark?: boolean }; + export interface ThemeContextValue { key: string; theme: Theme; - options: SelectOption[]; + options: ThemeOption[]; setTheme: (key: string) => void; } diff --git a/src/components/ThemeProvider/ThemeProvider.tsx b/src/components/ThemeProvider/ThemeProvider.tsx index d0387fd..a6d02d2 100644 --- a/src/components/ThemeProvider/ThemeProvider.tsx +++ b/src/components/ThemeProvider/ThemeProvider.tsx @@ -29,9 +29,10 @@ export const ThemeProvider = ({ const registry = useStore(themeStore); const options = useMemo(() => [ { value: SYSTEM_THEME_KEY, label: 'System' }, - ...Object.entries(registry).map(([k, { theme: { displayName } }]) => ({ + ...Object.entries(registry).map(([k, { theme: { displayName, dark } }]) => ({ value: k, label: displayName, + dark, })), ], [registry]); diff --git a/src/components/ThemeProvider/index.ts b/src/components/ThemeProvider/index.ts index dbe500d..89f6891 100644 --- a/src/components/ThemeProvider/index.ts +++ b/src/components/ThemeProvider/index.ts @@ -3,6 +3,7 @@ export { type ThemeProviderProps, } from './ThemeProvider'; export { SYSTEM_THEME_KEY, THEME_STORAGE_KEY } from './ThemeProvider.constants'; +export type { ThemeOption } from './ThemeProvider.context'; export { useTheme, useThemeManager } from './ThemeProvider.hooks'; export { getThemeStyleSheet, diff --git a/src/components/ToggleGroup/ToggleGroup.stories.tsx b/src/components/ToggleGroup/ToggleGroup.stories.tsx index 3474d1d..32669a6 100644 --- a/src/components/ToggleGroup/ToggleGroup.stories.tsx +++ b/src/components/ToggleGroup/ToggleGroup.stories.tsx @@ -5,7 +5,7 @@ import { AlignRight, } from 'lucide-react'; -import { ToggleGroup } from './ToggleGroup'; +import { ToggleGroup, ToggleGroupProps } from './ToggleGroup'; const meta: Meta = { title: 'Components/ToggleGroup', @@ -90,7 +90,7 @@ const meta: Meta = { }; export default meta; -type Story = StoryObj; +type Story = StoryObj; const sharedArgs = { activeVariant: 'shaded', @@ -116,7 +116,7 @@ export const Text: Story = { { value: 'light', text: 'Light' }, { value: 'dark', text: 'Dark' }, ], - defaultValue: ['system'], + defaultValue: 'system', }, }; @@ -129,7 +129,7 @@ export const TextIcon: Story = { { value: 'center', text: 'Center', icon: }, { value: 'right', text: 'Right', icon: }, ], - defaultValue: ['left'], + defaultValue: 'left', }, }; @@ -142,6 +142,6 @@ export const Icon: Story = { { value: 'center', icon: , ariaLabel: 'Align center' }, { value: 'right', icon: , ariaLabel: 'Align right' }, ], - defaultValue: ['left'], + defaultValue: 'left', }, }; diff --git a/src/components/ToggleGroup/ToggleGroup.tsx b/src/components/ToggleGroup/ToggleGroup.tsx index 64c928b..9443f7f 100644 --- a/src/components/ToggleGroup/ToggleGroup.tsx +++ b/src/components/ToggleGroup/ToggleGroup.tsx @@ -19,6 +19,7 @@ import { import { getStyleClassNames } from '../../utils/getStyleClassNames'; import { getItemPressedStyle, + getResolvedProps, getRootStyle, getSpacerStyle, } from './ToggleGroup.utils'; @@ -37,7 +38,7 @@ export type ToggleGroupOption = { ariaLabel?: string; }; -export interface ToggleGroupProps extends Omit, +interface ToggleGroupBaseProps extends Omit, 'defaultValue' | 'onChange' | 'value' @@ -45,27 +46,45 @@ export interface ToggleGroupProps extends Omit, activeVariant?: ElementVariant; border?: boolean; corners?: boolean | ElementCorners; - defaultValue?: ToggleGroupValue[]; disabled?: boolean; equal?: boolean; intent?: ElementIntent; loopFocus?: boolean; - multiple?: boolean; - onChange?: (values: ToggleGroupValue[]) => void; options: ToggleGroupOption[]; orientation?: ElementOrientation; rounded?: boolean; size?: ElementSize; - value?: ToggleGroupValue[]; variant?: ElementVariant; } +/* `multiple` decides whether `value`/`defaultValue`/`onChange` work with a + * single value (typical) or an array. Base UI's underlying `ToggleGroup` always + * works with arrays, so this wraps/unwraps at this boundary rather than pushing + * that array-handling onto every caller. + */ +export type ToggleGroupProps = ToggleGroupBaseProps & ( + | { + multiple: true; + defaultValue?: ToggleGroupValue[]; + onChange?: (value: ToggleGroupValue[]) => void; + value?: ToggleGroupValue[]; + } + | { + multiple?: false; + defaultValue?: ToggleGroupValue; + onChange?: (value: ToggleGroupValue) => void; + value?: ToggleGroupValue; + } +); + export const ToggleGroup = forwardRef, ToggleGroupProps>(({ activeVariant = 'solid', border = false, className, + defaultValue, equal = false, intent = 'secondary', + multiple, onChange, corners = 'normal', options, @@ -73,6 +92,7 @@ export const ToggleGroup = forwardRef, Toggle rounded = false, size = 'normal', style, + value, variant = 'shaded', ...props }, ref): JSX.Element => ( @@ -84,7 +104,12 @@ export const ToggleGroup = forwardRef, Toggle data-equal={equal} style={{ ...style, ...getRootStyle(equal, orientation, options.length) }} orientation={orientation} - onValueChange={onChange} + {...getResolvedProps({ + defaultValue, + multiple, + onChange, + value, + })} > {options.map((option, index) => ( diff --git a/src/components/ToggleGroup/ToggleGroup.utils.test.ts b/src/components/ToggleGroup/ToggleGroup.utils.test.ts index f6fbba4..e95210a 100644 --- a/src/components/ToggleGroup/ToggleGroup.utils.test.ts +++ b/src/components/ToggleGroup/ToggleGroup.utils.test.ts @@ -2,14 +2,52 @@ import { describe, expect, it, + vi, } from 'vitest'; import { getItemPressedStyle, + getResolvedProps, getRootStyle, getSpacerStyle, } from './ToggleGroup.utils'; +describe('getResolvedProps', () => { + it('leaves value/defaultValue undefined when neither is provided and not multiple.', () => { + const props = getResolvedProps({ multiple: false }); + expect(props.value).toBeUndefined(); + expect(props.defaultValue).toBeUndefined(); + }); + + it('wraps a single value/defaultValue in an array when not multiple.', () => { + const props = getResolvedProps({ multiple: false, value: 'left', defaultValue: 'left' }); + expect(props.value).toEqual(['left']); + expect(props.defaultValue).toEqual(['left']); + }); + + it('passes value/defaultValue through unwrapped when multiple.', () => { + const props = getResolvedProps({ multiple: true, value: ['left', 'right'], defaultValue: ['left'] }); + expect(props.value).toEqual(['left', 'right']); + expect(props.defaultValue).toEqual(['left']); + }); + + it('calls onChange with the first value when not multiple.', () => { + const onChange = vi.fn(); + getResolvedProps({ multiple: false, onChange }).onValueChange(['left', 'right']); + expect(onChange).toHaveBeenCalledWith('left'); + }); + + it('calls onChange with the full array when multiple.', () => { + const onChange = vi.fn(); + getResolvedProps({ multiple: true, onChange }).onValueChange(['left', 'right']); + expect(onChange).toHaveBeenCalledWith(['left', 'right']); + }); + + it('does not throw when onChange is not provided.', () => { + expect(() => getResolvedProps({ multiple: false }).onValueChange(['left'])).not.toThrow(); + }); +}); + describe('getRootStyle', () => { it('leaves both grid template properties undefined when equal is false.', () => { expect(getRootStyle(false, 'horizontal', 3)).toEqual({ diff --git a/src/components/ToggleGroup/ToggleGroup.utils.ts b/src/components/ToggleGroup/ToggleGroup.utils.ts index ca77f38..6482311 100644 --- a/src/components/ToggleGroup/ToggleGroup.utils.ts +++ b/src/components/ToggleGroup/ToggleGroup.utils.ts @@ -5,6 +5,43 @@ import { ElementOrientation, ElementVariant, } from '../../types'; +import { ToggleGroupProps, ToggleGroupValue } from './ToggleGroup'; + +type ToggleGroupGroupProps = Pick; + +/** + * Adapts this component's own `value`/`defaultValue`/`onChange` (a single + * value when not `multiple`, matching how most consumers think about a + * single-selection group) into the array-shaped props base-ui's underlying + * `ToggleGroup` always expects, regardless of `multiple`. + * + * @param props - This component's own `value`/`defaultValue`/`onChange`/`multiple` props. + * @returns `value`/`defaultValue`/`onValueChange` props, ready to spread onto base-ui's `ToggleGroup`. + */ +export const getResolvedProps = ({ + defaultValue, + multiple, + onChange, + value, +}: ToggleGroupGroupProps): { + defaultValue: ToggleGroupValue[] | undefined; + onValueChange: (values: ToggleGroupValue[]) => void; + value: ToggleGroupValue[] | undefined; + } => ({ + value: multiple ? (value as ToggleGroupValue[] | undefined) : ( + value !== undefined ? [value as ToggleGroupValue] : undefined + ), + defaultValue: multiple ? (defaultValue as ToggleGroupValue[] | undefined) : ( + defaultValue !== undefined ? [defaultValue as ToggleGroupValue] : undefined + ), + onValueChange: (values) => { + if (multiple) { + (onChange as ((values: ToggleGroupValue[]) => void) | undefined)?.(values); + } else { + (onChange as ((value: ToggleGroupValue) => void) | undefined)?.(values[0]); + } + }, +}); /** * Computes the root element's grid template properties. From d115daaef1be806c3cb636cda1155cadee963279 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Fri, 25 Sep 2026 13:57:41 +0200 Subject: [PATCH 4/4] PR Feedback --- src/components/ToggleGroup/ToggleGroup.tsx | 3 ++- src/components/ToggleGroup/ToggleGroup.utils.test.ts | 6 ++++++ src/components/ToggleGroup/ToggleGroup.utils.ts | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/ToggleGroup/ToggleGroup.tsx b/src/components/ToggleGroup/ToggleGroup.tsx index 9443f7f..a83df73 100644 --- a/src/components/ToggleGroup/ToggleGroup.tsx +++ b/src/components/ToggleGroup/ToggleGroup.tsx @@ -72,7 +72,7 @@ export type ToggleGroupProps = ToggleGroupBaseProps & ( | { multiple?: false; defaultValue?: ToggleGroupValue; - onChange?: (value: ToggleGroupValue) => void; + onChange?: (value: ToggleGroupValue | undefined) => void; value?: ToggleGroupValue; } ); @@ -104,6 +104,7 @@ export const ToggleGroup = forwardRef, Toggle data-equal={equal} style={{ ...style, ...getRootStyle(equal, orientation, options.length) }} orientation={orientation} + multiple={multiple} {...getResolvedProps({ defaultValue, multiple, diff --git a/src/components/ToggleGroup/ToggleGroup.utils.test.ts b/src/components/ToggleGroup/ToggleGroup.utils.test.ts index e95210a..900740a 100644 --- a/src/components/ToggleGroup/ToggleGroup.utils.test.ts +++ b/src/components/ToggleGroup/ToggleGroup.utils.test.ts @@ -46,6 +46,12 @@ describe('getResolvedProps', () => { it('does not throw when onChange is not provided.', () => { expect(() => getResolvedProps({ multiple: false }).onValueChange(['left'])).not.toThrow(); }); + + it('calls onChange with undefined when deselecting the only pressed item and not multiple.', () => { + const onChange = vi.fn(); + getResolvedProps({ multiple: false, onChange }).onValueChange([]); + expect(onChange).toHaveBeenCalledWith(undefined); + }); }); describe('getRootStyle', () => { diff --git a/src/components/ToggleGroup/ToggleGroup.utils.ts b/src/components/ToggleGroup/ToggleGroup.utils.ts index 6482311..ac38fa8 100644 --- a/src/components/ToggleGroup/ToggleGroup.utils.ts +++ b/src/components/ToggleGroup/ToggleGroup.utils.ts @@ -38,7 +38,7 @@ export const getResolvedProps = ({ if (multiple) { (onChange as ((values: ToggleGroupValue[]) => void) | undefined)?.(values); } else { - (onChange as ((value: ToggleGroupValue) => void) | undefined)?.(values[0]); + (onChange as ((value: ToggleGroupValue | undefined) => void) | undefined)?.(values[0]); } }, });