diff --git a/.changeset/mosaic-typed-value-change.md b/.changeset/mosaic-typed-value-change.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-typed-value-change.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/pagination/pagination.tsx b/packages/mosaic/src/components/pagination/pagination.tsx index 03150ca76aa..ac32f84046a 100644 --- a/packages/mosaic/src/components/pagination/pagination.tsx +++ b/packages/mosaic/src/components/pagination/pagination.tsx @@ -89,7 +89,7 @@ export const Pagination = React.forwardRef(functio const sizes = new Set( [...pageSizeOptions, itemsPerPage].filter(size => Number.isFinite(size) && size >= 1).map(Math.floor), ); - return [...sizes].sort((a, b) => a - b).map(size => ({ value: String(size), label: String(size) })); + return [...sizes].sort((a, b) => a - b).map(size => ({ value: size, label: String(size) })); }, [pageSizeOptions, itemsPerPage]); const isFirst = current <= 1; const isLast = current >= pageCount; @@ -126,8 +126,8 @@ export const Pagination = React.forwardRef(functio onPageSizeChange(Number(value))} + value={itemsPerPage} + onValueChange={onPageSizeChange} > void; + +describe('Radio.Group onValueChange', () => { + test('takes the value type from a typed value', () => { + void ( + { + expectTypeOf(value).toEqualTypeOf(); + }} + /> + ); + }); + + test('gives a string when defaultValue is a literal', () => { + void ( + { + expectTypeOf(value).toEqualTypeOf(); + }} + /> + ); + }); + + test('gives a string when nothing is typed', () => { + void ( + { + expectTypeOf(value).toEqualTypeOf(); + }} + /> + ); + }); + + test('takes the value type from a typed handler', () => { + void ( + { + expectTypeOf(value).toEqualTypeOf(); + }} + /> + ); + }); + + test('accepts values that are not strings', () => { + void ( + { + expectTypeOf(value).toEqualTypeOf(); + }} + > + + + ); + }); + + test('rejects a value the handler does not accept', () => { + void ( + <> + + + + ); + }); +}); diff --git a/packages/mosaic/src/components/radio/radio.test.tsx b/packages/mosaic/src/components/radio/radio.test.tsx index 9d9c6f8a29a..9017b7dd36b 100644 --- a/packages/mosaic/src/components/radio/radio.test.tsx +++ b/packages/mosaic/src/components/radio/radio.test.tsx @@ -197,6 +197,22 @@ describe('Mosaic Radio', () => { expect(formEntries(container)).toEqual([['access', 'all']]); }); + it('submits a non-string value as its JSON', () => { + const { container } = render( +
+ + + + +
, + ); + expect(formEntries(container)).toEqual([['size', '10']]); + expect(screen.getAllByRole('radio')[1]).toHaveAttribute('value', '{"id":20}'); + }); + it('submits nothing with a form when it has no name', () => { const { container } = render(
diff --git a/packages/mosaic/src/components/radio/radio.tsx b/packages/mosaic/src/components/radio/radio.tsx index 07ae17bedba..1c30af39a46 100644 --- a/packages/mosaic/src/components/radio/radio.tsx +++ b/packages/mosaic/src/components/radio/radio.tsx @@ -16,11 +16,11 @@ import { firstLine, indicatorSizes, layouts, sizes, styles } from './radio.style /** `stack` gives each choice its own row. `grid` fits as many columns of cards as the width allows. */ export type RadioLayout = 'stack' | 'grid'; -export interface RadioGroupProps +export interface RadioGroupProps extends Omit, 'defaultValue' | 'className' | 'style'>, MosaicStyleProps { - value?: string; - defaultValue?: string; - onValueChange?: (value: string) => void; + value?: Value; + defaultValue?: Value; + onValueChange?: (value: Value) => void; name?: string; disabled?: boolean; /** @default 'stack' */ @@ -30,23 +30,20 @@ export interface RadioGroupProps interface RadioGroupContextValue { name: string; submits: boolean; - value: string | undefined; - setValue: (value: string) => void; + value: unknown; + setValue: (value: unknown) => void; disabled: boolean | undefined; } const RadioGroupContext = React.createContext(null); -const RadioGroup = React.forwardRef(function MosaicRadioGroup( +// eslint-disable-next-line typescript/consistent-type-assertions -- The ref wrapper drops the value type parameter; the group only hands back values its caller passed in. +const RadioGroup = React.forwardRef>(function MosaicRadioGroup( { value: valueProp, defaultValue, onValueChange, name: nameProp, disabled, layout = 'stack', xstyle, ...rest }, ref, ) { const generatedName = React.useId(); - const [value, setValue] = useControllableState(valueProp, defaultValue, next => { - if (next !== undefined) { - onValueChange?.(next); - } - }); + const [value, setValue] = useControllableState(valueProp, defaultValue, onValueChange); const context = React.useMemo( () => ({ name: nameProp ?? generatedName, submits: nameProp !== undefined, value, setValue, disabled }), [nameProp, generatedName, value, setValue, disabled], @@ -66,14 +63,14 @@ const RadioGroup = React.forwardRef(function Mo /> ); -}); +}) as (props: RadioGroupProps & { ref?: React.Ref }) => React.ReactElement; export interface RadioItemProps extends Omit, 'type' | 'size' | 'value' | 'className' | 'style'>, MosaicStyleProps { size?: 'sm' | 'md'; - value?: string; + value?: unknown; } const RadioItem = React.forwardRef(function MosaicRadioItem( @@ -103,7 +100,7 @@ const RadioItem = React.forwardRef(function Mo }); const group = React.useContext(RadioGroupContext); const disabled = (fieldProps?.disabled ?? disabledProp) || group?.disabled; - const checked = group && value !== undefined ? group.value === value : checkedProp; + const checked = group && value !== undefined ? Object.is(group.value, value) : checkedProp; return ( (function Mo (function Mo ); }); +function toFormValue(value: unknown): string { + if (value === null) { + return ''; + } + if (typeof value === 'string') { + return value; + } + if (typeof value === 'bigint') { + return value.toString(); + } + try { + return JSON.stringify(value); + } catch { + return ''; + } +} + export interface RadioSectionProps extends Omit, 'className' | 'style'>, MosaicStyleProps { /** The section's heading, rendered as its ``. */ @@ -197,7 +211,7 @@ const RadioSection = React.forwardRef(fu export interface RadioCardProps extends Omit, 'className' | 'style'>, MosaicStyleProps { - value: string; + value: unknown; disabled?: boolean; } @@ -216,7 +230,7 @@ const RadioCard = React.forwardRef(function Mo ref, ) { const group = React.useContext(RadioGroupContext); - const checked = group?.value === value; + const checked = group !== null && Object.is(group.value, value); return ( void; + +describe('SegmentedControl.Root onValueChange', () => { + test('takes the value type from a typed value', () => { + void ( + { + expectTypeOf(value).toEqualTypeOf(); + }} + /> + ); + }); + + test('gives a string when defaultValue is a literal', () => { + void ( + { + expectTypeOf(value).toEqualTypeOf(); + }} + /> + ); + }); + + test('gives a string when nothing is typed', () => { + void ( + { + expectTypeOf(value).toEqualTypeOf(); + }} + /> + ); + }); + + test('takes the value type from a typed handler', () => { + void ( + { + expectTypeOf(value).toEqualTypeOf(); + }} + /> + ); + }); + + test('accepts values that are not strings', () => { + void ( + { + expectTypeOf(value).toEqualTypeOf(); + }} + > + + + ); + }); + + test('rejects a value the handler does not accept', () => { + void ( + <> + + + + ); + }); +}); diff --git a/packages/mosaic/src/components/segmented-control/segmented-control.tsx b/packages/mosaic/src/components/segmented-control/segmented-control.tsx index 28e21d83721..5e0db2801c7 100644 --- a/packages/mosaic/src/components/segmented-control/segmented-control.tsx +++ b/packages/mosaic/src/components/segmented-control/segmented-control.tsx @@ -14,10 +14,11 @@ import { styles } from './segmented-control.styles'; type StyledProps = Omit & MosaicStyleProps; -export type SegmentedControlRootProps = StyledProps; +export type SegmentedControlRootProps = StyledProps>; export type SegmentedControlItemProps = StyledProps; -const Root = React.forwardRef(function MosaicSegmentedControlRoot( +// eslint-disable-next-line typescript/consistent-type-assertions -- The ref wrapper drops the value type parameter; the root only hands back values its caller passed in. +const Root = React.forwardRef>(function MosaicSegmentedControlRoot( { xstyle, children, ...rest }, ref, ) { @@ -32,7 +33,9 @@ const Root = React.forwardRef(functio {children} ); -}); +}) as ( + props: SegmentedControlRootProps & { ref?: React.Ref }, +) => React.ReactElement; const Item = React.forwardRef(function MosaicSegmentedControlItem( { xstyle, children, ...rest }, diff --git a/packages/mosaic/src/components/select/select.test-d.tsx b/packages/mosaic/src/components/select/select.test-d.tsx new file mode 100644 index 00000000000..2d45ce0b30c --- /dev/null +++ b/packages/mosaic/src/components/select/select.test-d.tsx @@ -0,0 +1,104 @@ +import { describe, expectTypeOf, test } from 'vitest'; + +import { Select } from './select'; + +type Expiration = '1d' | '7d'; + +declare const expiration: Expiration | null; +declare const expirationItems: { value: Expiration; label: string }[]; +declare const onExpirationChange: (expiration: Expiration) => void; + +describe('Select.Root onValueChange', () => { + test('takes the value type from typed items', () => { + void ( + { + expectTypeOf(value).toEqualTypeOf(); + }} + > + {null} + + ); + }); + + test('takes the value type from a typed value, including null for no selection', () => { + void ( + { + expectTypeOf(value).toEqualTypeOf(); + }} + > + {null} + + ); + }); + + test('gives a string when the items are literals', () => { + void ( + { + expectTypeOf(value).toEqualTypeOf(); + }} + > + {null} + + ); + }); + + test('gives an array with multiple', () => { + void ( + { + expectTypeOf(value).toEqualTypeOf(); + }} + > + {null} + + ); + }); + + test('accepts values that are not strings', () => { + void ( + { + expectTypeOf(value).toEqualTypeOf(); + }} + > + + + ); + }); + + test('rejects a value or an item the handler does not accept', () => { + void ( + <> + + {null} + + + {null} + + + ); + }); +}); diff --git a/packages/mosaic/src/components/select/select.test.tsx b/packages/mosaic/src/components/select/select.test.tsx index 7a4e8fc648c..5e52777d0dd 100644 --- a/packages/mosaic/src/components/select/select.test.tsx +++ b/packages/mosaic/src/components/select/select.test.tsx @@ -178,6 +178,36 @@ describe('Mosaic Select', () => { expect(trigger.querySelector('.cl-select-trigger-icon')).toBeInTheDocument(); }); + it('renders and selects items whose values are objects', async () => { + const user = userEvent.setup(); + const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const onValueChange = vi.fn(); + const admin = { value: { id: 'admin' }, label: 'Admin' }; + const member = { value: { id: 'member' }, label: 'Member' }; + const renderItems = (items: (typeof admin)[]) => ( + + + + + ); + const { rerender } = render(renderItems([admin, member])); + + await user.click(screen.getByRole('combobox')); + await user.click(screen.getByRole('option', { name: 'Member' })); + rerender(renderItems([member, admin])); + await user.click(screen.getByRole('combobox')); + + expect(onValueChange).toHaveBeenCalledWith(member.value); + expect(screen.getAllByRole('option').map(option => option.textContent)).toEqual(['Member', 'Admin']); + expect(screen.getByRole('option', { name: 'Member' })).toHaveAttribute('data-selected', ''); + expect(screen.getByRole('option', { name: 'Admin' })).not.toHaveAttribute('data-selected'); + expect(log).not.toHaveBeenCalled(); + log.mockRestore(); + }); + it('renders one option per item when the popup is given no children', () => { render( { + value: Value; /** Shown in the row and in the trigger once selected; also drives typeahead. */ label: string; /** @@ -36,23 +36,28 @@ export interface SelectItem { disabled?: boolean; } -interface SelectItemsProps { +interface SelectItemsProps { /** * Every choice. Required, unlike the primitive's: the options only mount while the listbox is * open, so this is what lets the closed trigger show the selected label. `Select.Popup` * renders them as `Select.Option`s when it is given no children. */ - items: SelectItem[]; + items: SelectItem[]; } -export type SelectProps = - | (Omit & SelectItemsProps) - | (Omit & SelectItemsProps); +export type SelectProps = + | (Omit, 'items'> & SelectItemsProps) + | (Omit, 'items'> & SelectItemsProps); -const ItemsContext = React.createContext([]); +const ItemsContext = React.createContext[]>([]); /** Holds the selection and the items; renders no element of its own. */ -export function SelectRoot({ items, alignItemWithTrigger, children, ...rest }: SelectProps): React.ReactElement { +export function SelectRoot({ + items, + alignItemWithTrigger, + children, + ...rest +}: SelectProps): React.ReactElement { const described = items.some(item => item.description !== undefined); return ( @@ -84,7 +89,7 @@ export type SelectTriggerProps = Omit, 'children' * Content that replaces the selected label. A function renders inside the value slot and * receives the selected values; anything else replaces the value slot entirely. */ - children?: React.ReactNode | ((value: string[]) => React.ReactNode); + children?: React.ReactNode | ((value: unknown[]) => React.ReactNode); }; /** @@ -208,9 +213,10 @@ export const SelectPopup = React.forwardRef(fu )} > {children ?? - items.map(item => ( + items.map((item, index) => ( ))} @@ -222,7 +228,10 @@ export const SelectPopup = React.forwardRef(fu }); export interface SelectOptionProps - extends Omit, MosaicStyleProps, SelectItem { + extends + Omit, + MosaicStyleProps, + SelectItem { /** * The row's content, composed from `Select.Media` and `Select.Content`. Without children the row * shows `label` and `description`. diff --git a/packages/mosaic/src/features/api-keys/create-api-key.dialog.tsx b/packages/mosaic/src/features/api-keys/create-api-key.dialog.tsx index 3a49e5d7506..672e2234004 100644 --- a/packages/mosaic/src/features/api-keys/create-api-key.dialog.tsx +++ b/packages/mosaic/src/features/api-keys/create-api-key.dialog.tsx @@ -128,10 +128,8 @@ function CreateKeyStep(props: CreateAPIKeyDialogProps & { inputRef: Ref{m.expirationLabel} ({ value, label: m.expirationOptions[value] }))} - value={expiration ?? undefined} - onValueChange={value => - form.setValue('expiration', expirationValues.find(option => option === value) ?? null) - } + value={expiration} + onValueChange={value => form.setValue('expiration', value)} > diff --git a/packages/mosaic/src/features/oauth-consent/oauth-consent.skeleton.tsx b/packages/mosaic/src/features/oauth-consent/oauth-consent.skeleton.tsx index 3e24d0550c5..dd36f4c6239 100644 --- a/packages/mosaic/src/features/oauth-consent/oauth-consent.skeleton.tsx +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.skeleton.tsx @@ -59,7 +59,7 @@ export function OAuthConsentSkeleton({ {m.organizationLabel} diff --git a/packages/mosaic/src/features/oauth-consent/oauth-consent.view.tsx b/packages/mosaic/src/features/oauth-consent/oauth-consent.view.tsx index a7f7c204ec1..5a9c6c1e2f1 100644 --- a/packages/mosaic/src/features/oauth-consent/oauth-consent.view.tsx +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.view.tsx @@ -76,13 +76,9 @@ export function OAuthConsentView(props: OAuthConsentViewProps) { {m.organizationLabel} { - if (typeof value === 'string') { - props.onOrganizationChange(value); - } - }} + onValueChange={props.onOrganizationChange} > @@ -261,7 +257,7 @@ function OrganizationAccess({ }) { const labelId = useId(); const locale = useLocale(); - const summarize = (value: string[]) => + const summarize = (value: unknown[]) => value.length === 1 ? organizations.find(organization => organization.value === value[0])?.label : plural(m.organizationCount, value.length, locale); @@ -279,13 +275,11 @@ function OrganizationAccess({ aria-labelledby={labelId} value={selection.access} disabled={disabled} - onValueChange={value => { - if (value === 'selected') { - selection.onAccessChange('selected'); - return; + onValueChange={access => { + selection.onAccessChange(access); + if (access === 'all') { + selection.onSelectedChange([]); } - selection.onAccessChange('all'); - selection.onSelectedChange([]); }} > diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/members-table-tab.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/members-table-tab.view.tsx index e46ff49a029..28e7998b4e5 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/members-table-tab.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/members-table-tab.view.tsx @@ -298,7 +298,7 @@ function MemberRow({ value && onChangeRole(member.id, value)} + onValueChange={role => onChangeRole(member.id, role)} > {m.roleLabel} diff --git a/packages/mosaic/src/primitives/hooks/use-composite.ts b/packages/mosaic/src/primitives/hooks/use-composite.ts index 2477e29e3b6..9219c257d66 100644 --- a/packages/mosaic/src/primitives/hooks/use-composite.ts +++ b/packages/mosaic/src/primitives/hooks/use-composite.ts @@ -35,9 +35,9 @@ export interface UseCompositeReturn { export interface CompositeItems { getItems: () => HTMLElement[]; - getItem: (value: string) => HTMLElement | undefined; - getValue: (item: HTMLElement) => string | undefined; - register: (item: HTMLElement, value: string | undefined) => void; + getItem: (value: unknown) => HTMLElement | undefined; + getValue: (item: HTMLElement) => unknown; + register: (item: HTMLElement, value: unknown) => void; unregister: (item: HTMLElement) => void; } @@ -56,7 +56,7 @@ export function useCompositeItems(): CompositeItems { return items; } -export function useCompositeItem(value?: string): React.RefCallback { +export function useCompositeItem(value?: unknown): React.RefCallback { const { register, unregister } = React.useContext(CompositeItemsContext); const registeredRef = React.useRef(null); return React.useCallback( @@ -213,11 +213,11 @@ export function useComposite(element: HTMLElement | null, options: UseCompositeO } function createCompositeItems(): CompositeItems { - const items = new Map(); + const items = new Map(); const getItems = () => Array.from(items.keys()).sort(sortByDocumentPosition); return { getItems, - getItem: value => getItems().find(item => items.get(item) === value), + getItem: value => getItems().find(item => Object.is(items.get(item), value)), getValue: item => items.get(item), register: (item, value) => { items.set(item, value); diff --git a/packages/mosaic/src/primitives/segmented-control/README.md b/packages/mosaic/src/primitives/segmented-control/README.md index 11e51394890..5db56529e90 100644 --- a/packages/mosaic/src/primitives/segmented-control/README.md +++ b/packages/mosaic/src/primitives/segmented-control/README.md @@ -33,18 +33,18 @@ import { SegmentedControl } from '@/primitives/segmented-control'; ### `SegmentedControl.Root` -| Prop | Type | Default | Description | -| --------------- | ------------------------- | ------- | --------------------------------- | -| `value` | `string` | — | Controlled selected value | -| `defaultValue` | `string` | `""` | Initial selected value | -| `onValueChange` | `(value: string) => void` | — | Called when the selection changes | -| `disabled` | `boolean` | `false` | Disables every item | +| Prop | Type | Default | Description | +| --------------- | ------------------------ | ------- | --------------------------------- | +| `value` | `Value` | — | Controlled selected value | +| `defaultValue` | `Value` | — | Initial selected value | +| `onValueChange` | `(value: Value) => void` | — | Called when the selection changes | +| `disabled` | `boolean` | `false` | Disables every item | ### `SegmentedControl.Item` | Prop | Type | Default | Description | | ---------- | --------- | ------------ | ----------------------------------------------------------- | -| `value` | `string` | **required** | Unique option identifier | +| `value` | `unknown` | **required** | Unique option identifier, compared with `Object.is` | | `disabled` | `boolean` | — | Disables the item (uses `aria-disabled`, remains focusable) | ## Keyboard Navigation diff --git a/packages/mosaic/src/primitives/segmented-control/segmented-control-context.ts b/packages/mosaic/src/primitives/segmented-control/segmented-control-context.ts index f8b1d7f4509..1d31def2722 100644 --- a/packages/mosaic/src/primitives/segmented-control/segmented-control-context.ts +++ b/packages/mosaic/src/primitives/segmented-control/segmented-control-context.ts @@ -3,8 +3,8 @@ import { createContext, useContext } from 'react'; export type SegmentedControlDirection = 'forward' | 'backward'; export interface SegmentedControlContextValue { - value: string; - select: (value: string) => void; + value: unknown; + select: (value: unknown) => void; disabled: boolean; direction: SegmentedControlDirection; } diff --git a/packages/mosaic/src/primitives/segmented-control/segmented-control-item.tsx b/packages/mosaic/src/primitives/segmented-control/segmented-control-item.tsx index 1b698078168..451cd758895 100644 --- a/packages/mosaic/src/primitives/segmented-control/segmented-control-item.tsx +++ b/packages/mosaic/src/primitives/segmented-control/segmented-control-item.tsx @@ -6,8 +6,8 @@ import { useCompositeItem } from '../hooks/use-composite'; import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../utils'; import { useSegmentedControlContext } from './segmented-control-context'; -export interface SegmentedControlItemProps extends ComponentProps<'button'> { - value: string; +export interface SegmentedControlItemProps extends Omit, 'value'> { + value: unknown; disabled?: boolean; } @@ -19,7 +19,7 @@ export const SegmentedControlItem = React.forwardRef { if (!disabled) { select(itemValue); diff --git a/packages/mosaic/src/primitives/segmented-control/segmented-control-root.tsx b/packages/mosaic/src/primitives/segmented-control/segmented-control-root.tsx index 80a4aadaa40..d7de999421e 100644 --- a/packages/mosaic/src/primitives/segmented-control/segmented-control-root.tsx +++ b/packages/mosaic/src/primitives/segmented-control/segmented-control-root.tsx @@ -12,18 +12,19 @@ import { type SegmentedControlDirection, } from './segmented-control-context'; -export interface SegmentedControlRootProps extends Omit, 'defaultValue'> { - value?: string; - defaultValue?: string; - onValueChange?: (value: string) => void; +export interface SegmentedControlRootProps extends Omit, 'defaultValue'> { + value?: Value; + defaultValue?: Value; + onValueChange?: (value: Value) => void; disabled?: boolean; } -export const SegmentedControlRoot = React.forwardRef( +// eslint-disable-next-line typescript/consistent-type-assertions -- The ref wrapper drops the value type parameter; the root only hands back values its caller passed in. +export const SegmentedControlRoot = React.forwardRef>( function SegmentedControlRoot(props, ref) { const { render, value: valueProp, defaultValue, onValueChange, disabled = false, children, ...otherProps } = props; - const [value, setValue] = useControllableState(valueProp, defaultValue ?? '', onValueChange); + const [value, setValue] = useControllableState(valueProp, defaultValue, onValueChange); const [direction, setDirection] = useState('forward'); const readingDirection = useDirection(); const [element, setElement] = useState(null); @@ -32,8 +33,8 @@ export const SegmentedControlRoot = React.forwardRef { - if (nextValue !== value) { + (nextValue: unknown) => { + if (!Object.is(nextValue, value)) { setValue(nextValue); } }, @@ -55,7 +56,7 @@ export const SegmentedControlRoot = React.forwardRef { - const index = items.getItems().findIndex(item => items.getValue(item) === value); + const index = items.getItems().findIndex(item => Object.is(items.getValue(item), value)); const previousIndex = selectedIndexRef.current; selectedIndexRef.current = index; if (index !== -1 && previousIndex !== -1 && previousIndex !== index) { @@ -96,4 +97,6 @@ export const SegmentedControlRoot = React.forwardRef ); }, -); +) as ( + props: SegmentedControlRootProps & { ref?: React.Ref }, +) => React.ReactElement; diff --git a/packages/mosaic/src/primitives/select/README.md b/packages/mosaic/src/primitives/select/README.md index f2af43ddb08..af1dac0bacf 100644 --- a/packages/mosaic/src/primitives/select/README.md +++ b/packages/mosaic/src/primitives/select/README.md @@ -51,7 +51,7 @@ const [value, setValue] = useState('apple'); ### Multiple -`multiple` makes the value a `string[]`. Picking an option adds or removes it and keeps the popup open. +`multiple` makes the value an array. Picking an option adds or removes it and keeps the popup open. ```tsx void` | — | Called with the new value when the selection changes | -| `open` | `boolean` | — | Controlled open state | -| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) | -| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes | -| `items` | `SelectItem[]` | — | `{ label, value }` pairs for label resolution before options mount | -| `alignItemWithTrigger` | `boolean` | `true` | Visually align selected option over the trigger | -| `placement` | `Placement` | `"bottom-start"` | Floating UI placement | -| `sideOffset` | `number` | `4` | Gap between trigger and popup (px) | +| Prop | Type | Default | Description | +| ---------------------- | -------------------------- | ---------------- | -------------------------------------------------------------------- | +| `multiple` | `boolean` | `false` | Allow selecting several options | +| `value` | `Value \| null \| Value[]` | — | Controlled selected value; `null` for none, an array when `multiple` | +| `defaultValue` | `Value \| null \| Value[]` | — | Initial selected value (uncontrolled); an array when `multiple` | +| `onValueChange` | `(value) => void` | — | Called with the new value when the selection changes | +| `open` | `boolean` | — | Controlled open state | +| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) | +| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes | +| `items` | `SelectItem[]` | — | `{ label, value }` pairs for label resolution before options mount | +| `alignItemWithTrigger` | `boolean` | `true` | Visually align selected option over the trigger | +| `placement` | `Placement` | `"bottom-start"` | Floating UI placement | +| `sideOffset` | `number` | `4` | Gap between trigger and popup (px) | ### `Select.Value` -| Prop | Type | Default | Description | -| ------------- | ----------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| `placeholder` | `ReactNode` | — | Shown when no value is selected | -| `children` | `ReactNode \| ((value: string[]) => ReactNode)` | — | Replaces the selected labels; a function receives the selected values, and the placeholder shows instead while nothing is selected | +| Prop | Type | Default | Description | +| ------------- | ------------------------------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `placeholder` | `ReactNode` | — | Shown when no value is selected | +| `children` | `ReactNode \| ((value: unknown[]) => ReactNode)` | — | Replaces the selected labels; a function receives the selected values, and the placeholder shows instead while nothing is selected | ### `Select.Option` -| Prop | Type | Default | Description | -| ---------- | --------- | --------------------- | -------------------------------------- | -| `value` | `string` | **required** | The option's value | -| `label` | `string` | falls back to `value` | Display label, also used for typeahead | -| `disabled` | `boolean` | — | Prevents selection | +| Prop | Type | Default | Description | +| ---------- | --------- | --------------- | --------------------------------------------- | +| `value` | `unknown` | **required** | The option's value, compared with `Object.is` | +| `label` | `string` | `String(value)` | Display label, also used for typeahead | +| `disabled` | `boolean` | — | Prevents selection | ### `Select.Trigger`, `Select.Positioner`, `Select.Popup` @@ -173,7 +173,8 @@ Typeahead is active only while the popup is open. It highlights the matching opt ## Important Notes -- **`label` on `Select.Option`** drives both display in `Select.Value` and typeahead matching. If omitted, `value` is used for both. +- **`label` on `Select.Option`** drives both display in `Select.Value` and typeahead matching. If omitted, `String(value)` is used for both. +- **Values can be any type.** They are compared with `Object.is`, and `Value` is inferred from `items`, `value`, `defaultValue` or `onValueChange`. - **`items` prop** is only for label resolution — it does not control which options render. You still render `Select.Option` children yourself. - **Disabled options** can still receive keyboard focus but cannot be selected. - **Popup contents freeze while closing.** The popup outlives `open` by its exit animation, so its children are wrapped in `Freeze` (`../utils`) and hold their last frame: picking an option does not move `data-selected` to the new row under the animation. The popup element itself keeps updating, so `data-closed` / `data-ending-style` still land. diff --git a/packages/mosaic/src/primitives/select/__tests__/select.test.tsx b/packages/mosaic/src/primitives/select/__tests__/select.test.tsx index 644592a3b6d..4b639831792 100644 --- a/packages/mosaic/src/primitives/select/__tests__/select.test.tsx +++ b/packages/mosaic/src/primitives/select/__tests__/select.test.tsx @@ -93,8 +93,8 @@ describe('Select', () => { expect(value?.textContent).toBe('Pick a fruit...'); }); - it('shows placeholder when the controlled value is an empty string', () => { - renderSelect({ value: '' }); + it('shows placeholder when the controlled value is null', () => { + renderSelect({ value: null }); const value = document.querySelector('[data-testid="select-value"]'); expect(value?.textContent).toBe('Pick a fruit...'); }); @@ -574,7 +574,7 @@ describe('Select', () => { it('positions like a menu when nothing is selected', async () => { const user = userEvent.setup(); - renderSelect({ alignItemWithTrigger: true, value: '' }); + renderSelect({ alignItemWithTrigger: true, value: null }); await user.click(screen.getByRole('combobox')); diff --git a/packages/mosaic/src/primitives/select/select-context.ts b/packages/mosaic/src/primitives/select/select-context.ts index 6f4f480f392..42851114ad7 100644 --- a/packages/mosaic/src/primitives/select/select-context.ts +++ b/packages/mosaic/src/primitives/select/select-context.ts @@ -9,14 +9,14 @@ import { createContext, type CSSProperties, type RefObject, useContext } from 'r import type { TransitionProps } from '../hooks/use-transition'; -export interface SelectItem { +export interface SelectItem { label: string; - value: string; + value: Value; } export interface SelectContextValue { open: boolean; - items: SelectItem[] | undefined; + items: SelectItem[] | undefined; floatingContext: FloatingContext; refs: ExtendedRefs; floatingStyles: CSSProperties; @@ -29,22 +29,22 @@ export interface SelectContextValue { setSelectedIndex: React.Dispatch>; multiple: boolean; /** The selection in the order it was made; at most one value unless `multiple`. */ - selectedValues: string[]; + selectedValues: unknown[]; /** Labels of values picked while uncontrolled, kept after the options unmount; `null` when controlled. */ - cachedLabels: ReadonlyMap | null; + cachedLabels: ReadonlyMap | null; elementsRef: React.MutableRefObject>; labelsRef: React.MutableRefObject>; popupRef: RefObject; arrowRef: React.MutableRefObject; /** Where focus goes when the listbox closes, or `null` to leave focus alone. */ returnFocusRef: React.MutableRefObject; - valueToLabelRef: React.MutableRefObject>; + valueToLabelRef: React.MutableRefObject>; selectedItemRef: React.MutableRefObject; /** The pointer type of the last press on the trigger, or `'keyboard'`; read when the popup opens. */ openMethodRef: React.MutableRefObject; /** Whether the popup is currently overlaying the selected option on the trigger. */ alignItemWithTrigger: boolean; - handleSelect: (value: string, index: number) => void; + handleSelect: (value: unknown, index: number) => void; mounted: boolean; transitionProps: TransitionProps; } @@ -61,7 +61,7 @@ export function useSelectContext() { export interface SelectHighlightContextValue { activeIndex: number | null; - selectedValues: string[]; + selectedValues: unknown[]; } export const SelectHighlightContext = createContext(null); @@ -75,9 +75,9 @@ export function useSelectHighlightContext() { export interface SelectOptionsContextValue { getItemProps: UseInteractionsReturn['getItemProps']; - handleSelect: (value: string, index: number) => void; + handleSelect: (value: unknown, index: number) => void; setSelectedIndex: React.Dispatch>; - valueToLabelRef: React.MutableRefObject>; + valueToLabelRef: React.MutableRefObject>; selectedItemRef: React.MutableRefObject; } diff --git a/packages/mosaic/src/primitives/select/select-option.tsx b/packages/mosaic/src/primitives/select/select-option.tsx index 2cf5a38d19e..5e39f70dfc5 100644 --- a/packages/mosaic/src/primitives/select/select-option.tsx +++ b/packages/mosaic/src/primitives/select/select-option.tsx @@ -6,15 +6,15 @@ import React, { useEffect } from 'react'; import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../utils'; import { useSelectHighlightContext, useSelectOptionsContext } from './select-context'; -export interface SelectOptionProps extends ComponentProps<'button'> { - value: string; +export interface SelectOptionProps extends Omit, 'value'> { + value: unknown; label?: string; disabled?: boolean; } export const SelectOption = React.forwardRef(function SelectOption(props, ref) { const { activeIndex, selectedValues } = useSelectHighlightContext(); - const { ref: itemRef, index } = useListItem({ label: props.label ?? props.value }); + const { ref: itemRef, index } = useListItem({ label: props.label ?? String(props.value) }); return ( Object.is(selected, props.value))} + isAnchor={Object.is(selectedValues.at(-1), props.value)} /> ); }); @@ -42,7 +42,7 @@ const SelectOptionBody = React.memo(function SelectOptionBody(bodyProps: SelectO const { render, value, label, disabled, ...otherProps } = props; const { setSelectedIndex, getItemProps, handleSelect, valueToLabelRef, selectedItemRef } = useSelectOptionsContext(); - const displayLabel = label ?? value; + const displayLabel = label ?? String(value); useEffect(() => { const map = valueToLabelRef.current; diff --git a/packages/mosaic/src/primitives/select/select-root.tsx b/packages/mosaic/src/primitives/select/select-root.tsx index 4ce17277461..e14ac3e1f3a 100644 --- a/packages/mosaic/src/primitives/select/select-root.tsx +++ b/packages/mosaic/src/primitives/select/select-root.tsx @@ -20,7 +20,7 @@ import { useRole, useTypeahead, } from '@floating-ui/react'; -import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { type ReactElement, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useControllableState } from '../hooks/use-controllable-state'; import { useReturnFocus } from '../hooks/use-return-focus'; @@ -39,9 +39,9 @@ import { export type { SelectItem } from './select-context'; -interface SelectBaseProps { +interface SelectBaseProps { /** Array of `{ label, value }` items for label resolution before options mount. */ - items?: SelectItem[]; + items?: SelectItem[]; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; @@ -58,31 +58,31 @@ interface SelectBaseProps { children: ReactNode; } -export interface SelectSingleProps extends SelectBaseProps { +export interface SelectSingleProps extends SelectBaseProps { multiple?: false; - value?: string; - defaultValue?: string; - onValueChange?: (value: string) => void; + value?: Value | null; + defaultValue?: Value | null; + onValueChange?: (value: Value) => void; } -export interface SelectMultipleProps extends SelectBaseProps { +export interface SelectMultipleProps extends SelectBaseProps { /** Lets several options be selected. Picking an option toggles it and keeps the popup open. */ multiple: true; - value?: string[]; - defaultValue?: string[]; - onValueChange?: (value: string[]) => void; + value?: Value[]; + defaultValue?: Value[]; + onValueChange?: (value: Value[]) => void; } -export type SelectProps = SelectSingleProps | SelectMultipleProps; +export type SelectProps = SelectSingleProps | SelectMultipleProps; -function toValues(value: string | undefined): string[] | undefined { +function toValues(value: unknown): unknown[] | undefined { if (value === undefined) { return undefined; } - return value === '' ? [] : [value]; + return value === null ? [] : [value]; } -function SelectInner(props: SelectProps) { +function SelectInner(props: SelectProps) { const { items, alignItemWithTrigger: alignProp = true, @@ -101,20 +101,20 @@ function SelectInner(props: SelectProps) { const singleValue = props.multiple ? undefined : props.value; const controlledSingle = useMemo(() => toValues(singleValue), [singleValue]); const controlledValues = props.multiple ? props.value : controlledSingle; - const [selectedValues, setSelectedValues] = useControllableState( + const [selectedValues, setSelectedValues] = useControllableState( controlledValues, (props.multiple ? props.defaultValue : toValues(props.defaultValue)) ?? [], ); const [selectedIndex, setSelectedIndex] = useState(null); - const [cachedLabels, setCachedLabels] = useState>(new Map()); + const [cachedLabels, setCachedLabels] = useState>(new Map()); const [activeIndex, setActiveIndex] = useState(null); const elementsRef = useRef>([]); const labelsRef = useRef>([]); const arrowRef = useRef(null); const popupRef = useRef(null); - const valueToLabelRef = useRef>(new Map()); + const valueToLabelRef = useRef>(new Map()); const selectedItemRef = useRef(null); const openRef = useRef(open); // eslint-disable-next-line react/refs -- This ref holds an imperative resource or the latest callback outside React state. @@ -199,10 +199,10 @@ function SelectInner(props: SelectProps) { const isControlled = controlledValues !== undefined; const handleSelect = useCallback( - (value: string, index: number) => { + (value: unknown, index: number) => { if (multiple) { - const next = selectedValues.includes(value) - ? selectedValues.filter(selected => selected !== value) + const next = selectedValues.some(selected => Object.is(selected, value)) + ? selectedValues.filter(selected => !Object.is(selected, value)) : [...selectedValues, value]; setSelectedValues(next); onMultipleValueChange?.(next); @@ -219,7 +219,7 @@ function SelectInner(props: SelectProps) { // showing a stale label. Only cache labels in uncontrolled mode where // the value always persists after selection. if (!isControlled) { - const label = valueToLabelRef.current.get(value) ?? value; + const label = valueToLabelRef.current.get(value) ?? String(value); setCachedLabels(prev => new Map(prev).set(value, label)); } if (!multiple) { @@ -355,7 +355,8 @@ function SelectInner(props: SelectProps) { ); } -export function SelectRoot(props: SelectProps) { +// eslint-disable-next-line typescript/consistent-type-assertions -- The implementation is value-agnostic; the root only hands back values its caller passed in. +export const SelectRoot = function SelectRoot(props: SelectProps) { const parentId = useFloatingParentNodeId(); if (parentId === null) { @@ -367,4 +368,4 @@ export function SelectRoot(props: SelectProps) { } return ; -} +} as (props: SelectProps) => ReactElement; diff --git a/packages/mosaic/src/primitives/select/select-value.tsx b/packages/mosaic/src/primitives/select/select-value.tsx index 62dbed1944a..3fd6dca9c0c 100644 --- a/packages/mosaic/src/primitives/select/select-value.tsx +++ b/packages/mosaic/src/primitives/select/select-value.tsx @@ -8,23 +8,23 @@ import type { SelectItem } from './select-context'; import { useSelectContext } from './select-context'; function resolveLabel( - value: string, - items: SelectItem[] | undefined, - cachedLabels: ReadonlyMap | null, - valueToLabelRef: React.MutableRefObject>, + value: unknown, + items: SelectItem[] | undefined, + cachedLabels: ReadonlyMap | null, + valueToLabelRef: React.MutableRefObject>, ): string { return ( cachedLabels?.get(value) ?? - items?.find(i => i.value === value)?.label ?? + items?.find(i => Object.is(i.value, value))?.label ?? valueToLabelRef.current.get(value) ?? - value + String(value) ); } export interface SelectValueProps extends Omit, 'children'> { placeholder?: ReactNode; /** Replaces the selected labels. A function receives the selected values in the order they were picked. */ - children?: ReactNode | ((value: string[]) => ReactNode); + children?: ReactNode | ((value: unknown[]) => ReactNode); } export const SelectValue = React.forwardRef(function SelectValue(props, ref) { diff --git a/packages/swingset/src/stories/segmented-control.mdx b/packages/swingset/src/stories/segmented-control.mdx index db617c9a4b1..a5efaf4c6bf 100644 --- a/packages/swingset/src/stories/segmented-control.mdx +++ b/packages/swingset/src/stories/segmented-control.mdx @@ -42,19 +42,19 @@ The selected pill is `.cl-segmented-control-indicator`. Style that class to rest ### `SegmentedControl.Root` -| Prop | Type | Default | Description | -| --------------- | ------------------------- | ------- | --------------------------------- | -| `value` | `string` | — | Controlled selected value | -| `defaultValue` | `string` | `""` | Initial selected value | -| `onValueChange` | `(value: string) => void` | — | Called when the selection changes | -| `disabled` | `boolean` | `false` | Disables every item | +| Prop | Type | Default | Description | +| --------------- | ------------------------ | ------- | --------------------------------- | +| `value` | `Value` | — | Controlled selected value | +| `defaultValue` | `Value` | — | Initial selected value | +| `onValueChange` | `(value: Value) => void` | — | Called when the selection changes | +| `disabled` | `boolean` | `false` | Disables every item | ### `SegmentedControl.Item` -| Prop | Type | Default | Description | -| ---------- | --------- | ------------ | ------------------------------------- | -| `value` | `string` | **required** | Unique option identifier | -| `disabled` | `boolean` | — | Disables the item; it stays focusable | +| Prop | Type | Default | Description | +| ---------- | --------- | ------------ | --------------------------------------------------- | +| `value` | `unknown` | **required** | Unique option identifier, compared with `Object.is` | +| `disabled` | `boolean` | — | Disables the item; it stays focusable | ## Disabled diff --git a/packages/swingset/src/stories/select.component.mdx b/packages/swingset/src/stories/select.component.mdx index 65861997673..99f8a459bc4 100644 --- a/packages/swingset/src/stories/select.component.mdx +++ b/packages/swingset/src/stories/select.component.mdx @@ -200,12 +200,12 @@ const [value, setValue] = useState('member'); ### Multiple -`multiple` makes the value a `string[]`. Picking an option adds or removes it and keeps the list +`multiple` makes the value an array. Picking an option adds or removes it and keeps the list open, and every selected row shows the check. The trigger lists the selected labels separated by commas; pass a function as its children to render the value yourself. ```tsx -function renderValue(value: string[]) { +function renderValue(value: unknown[]) { if (value.length === 0) { return 'Select languages'; } diff --git a/packages/swingset/src/stories/select.component.stories.tsx b/packages/swingset/src/stories/select.component.stories.tsx index cb4a9ac8b6c..620f2969ac9 100644 --- a/packages/swingset/src/stories/select.component.stories.tsx +++ b/packages/swingset/src/stories/select.component.stories.tsx @@ -211,7 +211,7 @@ const languages = [ { value: 'swift', label: 'Swift' }, ]; -function renderLanguages(value: string[]) { +function renderLanguages(value: unknown[]) { if (value.length === 0) { return 'Select languages'; } diff --git a/packages/swingset/src/stories/select.mdx b/packages/swingset/src/stories/select.mdx index 8c20379d637..27c840456ab 100644 --- a/packages/swingset/src/stories/select.mdx +++ b/packages/swingset/src/stories/select.mdx @@ -60,7 +60,7 @@ const [value, setValue] = useState('apple'); ### Multiple -`multiple` makes the value a `string[]`. Picking an option adds or removes it and keeps the +`multiple` makes the value an array. Picking an option adds or removes it and keeps the popup open. `Select.Value` shows the selected labels joined with commas; pass a function as its children to render the selection yourself. @@ -104,36 +104,36 @@ props. `Select.Portal` is optional. ### `Select.Root` -| Prop | Type | Default | Description | -| --------------------------------- | ------------------------------------ | --------------------------- | ------------------------------------------------------------------ | -| multiple | boolean | false | Allow selecting several options | -| value | string \| string[] | — | Controlled selected value; an array when `multiple` | -| defaultValue | string \| string[] | — | Initial selected value (uncontrolled); an array when `multiple` | -| onValueChange | (value) => void | — | Called with the new value when the selection changes | -| open | boolean | — | Controlled open state | -| defaultOpen | boolean | false | Initial open state (uncontrolled) | -| onOpenChange | (open: boolean) => void | — | Called when the open state changes | -| items | SelectItem[] | — | `{ label, value }[]` used to resolve the selected label early | -| alignItemWithTrigger | boolean | true | Overlay the selected item on the trigger (native-`` style) | +| placement | Placement | 'bottom-start' | Placement when not aligning to the item | +| sideOffset | number | 4 | Gap in px (applies only when `alignItemWithTrigger={false}`) | + +`SelectItem` is `{ label: string; value: Value }` — disabling is done per-`Option`. ### `Select.Value` -| Prop | Type | Default | Description | -| ------------------------ | ---------------------------------------------------------- | ------- | --------------------------------------------------------------------- | -| placeholder | ReactNode | — | Rendered when no value is selected | -| children | ReactNode \| ((value: string[]) => ReactNode) | — | Replaces the selected labels; a function receives the selected values | +| Prop | Type | Default | Description | +| ------------------------ | ----------------------------------------------------------- | ------- | --------------------------------------------------------------------- | +| placeholder | ReactNode | — | Rendered when no value is selected | +| children | ReactNode \| ((value: unknown[]) => ReactNode) | — | Replaces the selected labels; a function receives the selected values | ### `Select.Option` -| Prop | Type | Default | Description | -| --------------------- | -------------------- | --------------------- | -------------------------------------------- | -| value | string | — (required) | The option's value | -| label | string | falls back to `value` | Display label, also used for typeahead | -| disabled | boolean | — | Prevent selection (stays keyboard-focusable) | +| Prop | Type | Default | Description | +| --------------------- | -------------------- | --------------- | --------------------------------------------- | +| value | unknown | — (required) | The option's value, compared with `Object.is` | +| label | string | `String(value)` | Display label, also used for typeahead | +| disabled | boolean | — | Prevent selection (stays keyboard-focusable) | `Select.Trigger`, `Select.Positioner`, and `Select.Popup` take no additional props beyond standard HTML attributes for their default element. `Select.Portal` accepts `root`.