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(({ 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.module.scss b/src/components/ToggleGroup/ToggleGroup.module.scss new file mode 100644 index 0000000..606bb49 --- /dev/null +++ b/src/components/ToggleGroup/ToggleGroup.module.scss @@ -0,0 +1,183 @@ +@use "../../style/layers"; + +@layer components { + .toggleGroup { + --toggle-group-spacer-size: 0px; + + display: inline-flex; + flex-direction: row; + + &[data-border="true"] { + --toggle-group-spacer-size: var(--border-width); + } + + &[data-orientation="vertical"] { + flex-direction: column; + } + + &[data-equal="true"] { + display: inline-grid; + + &[data-orientation="vertical"] { + grid-template-columns: 1fr; + } + } + + &-item { + cursor: pointer; + + position: relative; + + display: flex; + gap: 0.5rem; + align-items: center; + justify-content: center; + + line-height: 1rem; + white-space: nowrap; + + &[data-reverse="true"] { + flex-direction: row-reverse; + } + + &[data-disabled] { + cursor: not-allowed; + opacity: 0.4; + } + + /* The item's own class names always reflect its unpressed variant; + * base-ui sets `data-pressed` natively, and these custom properties + * (set inline per-item) are what this rule swaps in - so the + * pressed/unpressed variant swap never needs to be decided in JS. + */ + &[data-pressed] { + --variant-border: var(--toggle-group-item-pressed-border-color); + + color: var(--toggle-group-item-pressed-color, inherit); + background-color: var(--toggle-group-item-pressed-background-color); + } + } + + &-spacer { + flex-shrink: 0; + border-color: transparent; + background-color: transparent; + + /* The width itself never animates (a 1px change looks stepped/jittery + * no matter how it's eased), so it's always a 0s (instant) transition. + * The delay is what's doing the work: hiding first sets border-color/ + * background-color transparent (250ms fade) while the width transition + * is delayed by that same 250ms, so the spacer keeps its full width + * until the fade finishes, then collapses in a single frame instead of + * collapsing immediately and leaving nothing for the fade to render. + */ + @media (prefers-reduced-motion: no-preference) { + transition: + background-color 250ms ease-in-out, + border-color 250ms ease-in-out, + border-left-width 0s linear 250ms, + border-top-width 0s linear 250ms; + } + } + + &[data-border="true"] &-spacer { + border-color: var(--toggle-group-spacer-border-color); + background-color: var(--toggle-group-spacer-background-color); + + /* Showing goes the other way: no delay, so the width snaps to its full + * size immediately, giving the color something to fade into right away. + */ + @media (prefers-reduced-motion: no-preference) { + transition: + background-color 250ms ease-in-out, + border-color 250ms ease-in-out, + border-left-width 0s linear 0s, + border-top-width 0s linear 0s; + } + } + + &[data-orientation="horizontal"] &-spacer { + width: 0; + border-left-style: solid; + border-left-width: var(--toggle-group-spacer-size); + } + + &[data-orientation="vertical"] &-spacer { + width: auto; + height: 0; + border-top-style: solid; + border-top-width: var(--toggle-group-spacer-size); + } + + /* Higher specificity than the plain `[data-border="true"] &-spacer` rule + * above, so this wins whenever either neighboring item is pressed - + * `:has()` covers the next item, the adjacent sibling combinator covers + * the previous one - without needing to know which one in JS. + */ + &[data-border="true"] { + .toggleGroup-item[data-pressed] + .toggleGroup-spacer, + .toggleGroup-spacer:has(+ .toggleGroup-item[data-pressed]) { + border-color: var(--toggle-group-spacer-active-border-color); + background-color: var(--toggle-group-spacer-active-background-color); + } + } + + &[data-orientation="horizontal"] { + .toggleGroup-item:first-child:not(:only-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + .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; + } + } + } + + &[data-orientation="vertical"] { + .toggleGroup-item:first-child:not(:only-child) { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } + + .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.module.scss.d.ts b/src/components/ToggleGroup/ToggleGroup.module.scss.d.ts new file mode 100644 index 0000000..d2e1f46 --- /dev/null +++ b/src/components/ToggleGroup/ToggleGroup.module.scss.d.ts @@ -0,0 +1,3 @@ +export declare const toggleGroup: string; +export declare const toggleGroupItem: string; +export declare const toggleGroupSpacer: string; diff --git a/src/components/ToggleGroup/ToggleGroup.stories.tsx b/src/components/ToggleGroup/ToggleGroup.stories.tsx new file mode 100644 index 0000000..32669a6 --- /dev/null +++ b/src/components/ToggleGroup/ToggleGroup.stories.tsx @@ -0,0 +1,147 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { + AlignCenter, + AlignLeft, + AlignRight, +} from 'lucide-react'; + +import { ToggleGroup, ToggleGroupProps } from './ToggleGroup'; + +const meta: Meta = { + 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: , 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 new file mode 100644 index 0000000..a83df73 --- /dev/null +++ b/src/components/ToggleGroup/ToggleGroup.tsx @@ -0,0 +1,151 @@ +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, + getResolvedProps, + 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; + ariaLabel?: string; +}; + +interface ToggleGroupBaseProps extends Omit, + 'defaultValue' | + 'onChange' | + 'value' +> { + activeVariant?: ElementVariant; + border?: boolean; + corners?: boolean | ElementCorners; + disabled?: boolean; + equal?: boolean; + intent?: ElementIntent; + loopFocus?: boolean; + options: ToggleGroupOption[]; + orientation?: ElementOrientation; + rounded?: boolean; + size?: ElementSize; + 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 | undefined) => void; + value?: ToggleGroupValue; + } +); + +export const ToggleGroup = forwardRef, ToggleGroupProps>(({ + activeVariant = 'solid', + border = false, + className, + defaultValue, + equal = false, + intent = 'secondary', + multiple, + onChange, + corners = 'normal', + options, + orientation = 'horizontal', + rounded = false, + size = 'normal', + style, + value, + 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..900740a --- /dev/null +++ b/src/components/ToggleGroup/ToggleGroup.utils.test.ts @@ -0,0 +1,134 @@ +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(); + }); + + 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', () => { + 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..ac38fa8 --- /dev/null +++ b/src/components/ToggleGroup/ToggleGroup.utils.ts @@ -0,0 +1,160 @@ +import { CSSProperties } from 'react'; + +import { + ElementIntent, + 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 | undefined) => void) | undefined)?.(values[0]); + } + }, +}); + +/** + * 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[] => {