From a7bc5e0c6b57fae275585e81424c1de28087d442 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 5 Nov 2026 11:56:36 -0500 Subject: [PATCH 1/5] refactor(mosaic): type onValueChange of choice components by their values --- .changeset/mosaic-typed-value-change.md | 2 + .../src/components/radio/radio.test-d.tsx | 49 ++++++++++++++ .../mosaic/src/components/radio/radio.tsx | 13 ++-- .../segmented-control.test-d.tsx | 49 ++++++++++++++ .../segmented-control/segmented-control.tsx | 7 +- .../src/components/select/select.test-d.tsx | 64 +++++++++++++++++++ .../mosaic/src/components/select/select.tsx | 21 +++--- .../api-keys/create-api-key.dialog.tsx | 4 +- .../oauth-consent/oauth-consent.view.tsx | 16 ++--- .../segmented-control-root.tsx | 16 +++-- .../src/primitives/select/select-context.ts | 4 +- .../src/primitives/select/select-root.tsx | 29 +++++---- 12 files changed, 224 insertions(+), 50 deletions(-) create mode 100644 .changeset/mosaic-typed-value-change.md create mode 100644 packages/mosaic/src/components/radio/radio.test-d.tsx create mode 100644 packages/mosaic/src/components/segmented-control/segmented-control.test-d.tsx create mode 100644 packages/mosaic/src/components/select/select.test-d.tsx 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/radio/radio.test-d.tsx b/packages/mosaic/src/components/radio/radio.test-d.tsx new file mode 100644 index 00000000000..d2034f84c43 --- /dev/null +++ b/packages/mosaic/src/components/radio/radio.test-d.tsx @@ -0,0 +1,49 @@ +import { describe, expectTypeOf, test } from 'vitest'; + +import { Radio } from './radio'; + +declare const access: 'all' | 'selected'; + +describe('Radio.Group onValueChange', () => { + test('gives a string when the group has no value', () => { + void ( + { + expectTypeOf(value).toEqualTypeOf(); + }} + /> + ); + }); + + test('gives the type of value', () => { + void ( + { + expectTypeOf(value).toEqualTypeOf<'all' | 'selected'>(); + }} + /> + ); + }); + + test('gives the type of defaultValue', () => { + void ( + { + expectTypeOf(value).toEqualTypeOf<'all' | 'selected'>(); + }} + /> + ); + }); + + test('rejects a value the handler does not accept', () => { + void ( + value} + /> + ); + }); +}); diff --git a/packages/mosaic/src/components/radio/radio.tsx b/packages/mosaic/src/components/radio/radio.tsx index 07ae17bedba..097854c1121 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?: T; + defaultValue?: T; + onValueChange?: (value: T) => void; name?: string; disabled?: boolean; /** @default 'stack' */ @@ -37,6 +37,7 @@ interface RadioGroupContextValue { const RadioGroupContext = React.createContext(null); +// 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, @@ -66,7 +67,9 @@ const RadioGroup = React.forwardRef(function Mo /> ); -}); +}) as ( + props: RadioGroupProps & { ref?: React.Ref }, +) => React.ReactElement; export interface RadioItemProps extends diff --git a/packages/mosaic/src/components/segmented-control/segmented-control.test-d.tsx b/packages/mosaic/src/components/segmented-control/segmented-control.test-d.tsx new file mode 100644 index 00000000000..91f686df80b --- /dev/null +++ b/packages/mosaic/src/components/segmented-control/segmented-control.test-d.tsx @@ -0,0 +1,49 @@ +import { describe, expectTypeOf, test } from 'vitest'; + +import { SegmentedControl } from './segmented-control'; + +declare const access: 'all' | 'selected'; + +describe('SegmentedControl.Root onValueChange', () => { + test('gives a string when the group has no value', () => { + void ( + { + expectTypeOf(value).toEqualTypeOf(); + }} + /> + ); + }); + + test('gives the type of value', () => { + void ( + { + expectTypeOf(value).toEqualTypeOf<'all' | 'selected'>(); + }} + /> + ); + }); + + test('gives the type of defaultValue', () => { + void ( + { + expectTypeOf(value).toEqualTypeOf<'all' | 'selected'>(); + }} + /> + ); + }); + + test('rejects a value the handler does not accept', () => { + void ( + value} + /> + ); + }); +}); diff --git a/packages/mosaic/src/components/segmented-control/segmented-control.tsx b/packages/mosaic/src/components/segmented-control/segmented-control.tsx index 28e21d83721..3f984f80b61 100644 --- a/packages/mosaic/src/components/segmented-control/segmented-control.tsx +++ b/packages/mosaic/src/components/segmented-control/segmented-control.tsx @@ -14,9 +14,10 @@ import { styles } from './segmented-control.styles'; type StyledProps = Omit & MosaicStyleProps; -export type SegmentedControlRootProps = StyledProps; +export type SegmentedControlRootProps = StyledProps>; export type SegmentedControlItemProps = StyledProps; +// 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..6d44764763d --- /dev/null +++ b/packages/mosaic/src/components/select/select.test-d.tsx @@ -0,0 +1,64 @@ +import { describe, expectTypeOf, test } from 'vitest'; + +import { Select } from './select'; + +declare const label: string; + +const expirations = [ + { value: '1d', label: '1 day' }, + { value: '7d', label: '7 days' }, +] as const; + +describe('Select.Root onValueChange', () => { + test('gives a string when the items carry strings', () => { + void ( + { + expectTypeOf(value).toEqualTypeOf(); + }} + > + {null} + + ); + }); + + test('gives the type of the item values', () => { + void ( + ({ ...item }))} + onValueChange={value => { + expectTypeOf(value).toEqualTypeOf<'1d' | '7d'>(); + }} + > + {null} + + ); + }); + + test('gives an array of the item values when multiple', () => { + void ( + ({ ...item }))} + onValueChange={value => { + expectTypeOf(value).toEqualTypeOf>(); + }} + > + {null} + + ); + }); + + test('rejects a value that is not an item', () => { + void ( + ({ ...item }))} + // @ts-expect-error -- No item carries this value + value='30d' + > + {null} + + ); + }); +}); diff --git a/packages/mosaic/src/components/select/select.tsx b/packages/mosaic/src/components/select/select.tsx index 158512c462b..4969b0e8a58 100644 --- a/packages/mosaic/src/components/select/select.tsx +++ b/packages/mosaic/src/components/select/select.tsx @@ -24,8 +24,8 @@ import { scrollAreaRoot, scrollAreaViewport } from '../scroll-area'; import { selectOptionScope } from './select.markers.stylex'; import * as slots from './select.styles'; -export interface SelectItem { - value: string; +export interface SelectItem { + value: T; /** 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([]); /** 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 ( 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..ab8a094e73f 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 @@ -129,9 +129,7 @@ function CreateKeyStep(props: CreateAPIKeyDialogProps & { inputRef: Ref ({ value, label: m.expirationOptions[value] }))} value={expiration ?? undefined} - onValueChange={value => - form.setValue('expiration', expirationValues.find(option => option === value) ?? null) - } + onValueChange={value => form.setValue('expiration', value)} > 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..834949348d3 100644 --- a/packages/mosaic/src/features/oauth-consent/oauth-consent.view.tsx +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.view.tsx @@ -78,11 +78,7 @@ export function OAuthConsentView(props: OAuthConsentViewProps) { items={props.organizations} value={organizationId ?? undefined} alignItemWithTrigger={false} - onValueChange={value => { - if (typeof value === 'string') { - props.onOrganizationChange(value); - } - }} + onValueChange={props.onOrganizationChange} > @@ -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/primitives/segmented-control/segmented-control-root.tsx b/packages/mosaic/src/primitives/segmented-control/segmented-control-root.tsx index 80a4aadaa40..5dfc9af402a 100644 --- a/packages/mosaic/src/primitives/segmented-control/segmented-control-root.tsx +++ b/packages/mosaic/src/primitives/segmented-control/segmented-control-root.tsx @@ -12,13 +12,17 @@ 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< + ComponentProps<'div'>, + 'defaultValue' +> { + value?: T; + defaultValue?: T; + onValueChange?: (value: T) => void; disabled?: boolean; } +// 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; @@ -96,4 +100,6 @@ export const SegmentedControlRoot = React.forwardRef ); }, -); +) as ( + props: SegmentedControlRootProps & { ref?: React.Ref }, +) => React.ReactElement; diff --git a/packages/mosaic/src/primitives/select/select-context.ts b/packages/mosaic/src/primitives/select/select-context.ts index 6f4f480f392..4f65f120ac7 100644 --- a/packages/mosaic/src/primitives/select/select-context.ts +++ b/packages/mosaic/src/primitives/select/select-context.ts @@ -9,9 +9,9 @@ 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: T; } export interface SelectContextValue { diff --git a/packages/mosaic/src/primitives/select/select-root.tsx b/packages/mosaic/src/primitives/select/select-root.tsx index 4ce17277461..3a231c95aed 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,22 +58,22 @@ 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?: NoInfer; + defaultValue?: NoInfer; + onValueChange?: (value: NoInfer) => 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?: NoInfer[]; + defaultValue?: NoInfer[]; + onValueChange?: (value: NoInfer[]) => void; } -export type SelectProps = SelectSingleProps | SelectMultipleProps; +export type SelectProps = SelectSingleProps | SelectMultipleProps; function toValues(value: string | undefined): string[] | undefined { if (value === undefined) { @@ -355,7 +355,8 @@ function SelectInner(props: SelectProps) { ); } -export function SelectRoot(props: SelectProps) { +// eslint-disable-next-line typescript/consistent-type-assertions -- The root only hands back values from its items, so the item value type carries over to onValueChange. +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; From 8d46ddb2dce8eb9156ddc06dbe75d0ecc49d5900 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 5 Nov 2026 13:40:10 -0500 Subject: [PATCH 2/5] refactor(mosaic): make choice primitives value-agnostic, following Base UI --- .../src/components/pagination/pagination.tsx | 6 +- .../src/components/radio/radio.test-d.tsx | 60 ++++++++++---- .../mosaic/src/components/radio/radio.tsx | 34 ++++---- .../segmented-control.test-d.tsx | 60 ++++++++++---- .../segmented-control/segmented-control.tsx | 8 +- .../src/components/select/select.test-d.tsx | 78 ++++++++++++++----- .../src/components/select/select.test.tsx | 30 +++++++ .../mosaic/src/components/select/select.tsx | 32 ++++---- .../api-keys/create-api-key.dialog.tsx | 2 +- .../oauth-consent/oauth-consent.skeleton.tsx | 2 +- .../oauth-consent/oauth-consent.view.tsx | 4 +- .../members-table-tab.view.tsx | 2 +- ...nization-profile-invite-members.fields.tsx | 2 +- .../src/primitives/hooks/use-composite.ts | 12 +-- .../primitives/segmented-control/README.md | 14 ++-- .../segmented-control-context.ts | 4 +- .../segmented-control-item.tsx | 8 +- .../segmented-control-root.tsx | 25 +++--- .../mosaic/src/primitives/select/README.md | 39 +++++----- .../select/__tests__/select.test.tsx | 6 +- .../src/primitives/select/select-context.ts | 20 ++--- .../src/primitives/select/select-option.tsx | 12 +-- .../src/primitives/select/select-root.tsx | 48 ++++++------ .../src/primitives/select/select-value.tsx | 14 ++-- 24 files changed, 328 insertions(+), 194 deletions(-) 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('gives a string when the group has no value', () => { + test('takes the value type from a typed value', () => { void ( { - expectTypeOf(value).toEqualTypeOf(); + expectTypeOf(value).toEqualTypeOf(); }} /> ); }); - test('gives the type of value', () => { + test('gives a string when defaultValue is a literal', () => { void ( { - expectTypeOf(value).toEqualTypeOf<'all' | 'selected'>(); + expectTypeOf(value).toEqualTypeOf(); }} /> ); }); - test('gives the type of defaultValue', () => { + test('gives a string when nothing is typed', () => { void ( { - expectTypeOf(value).toEqualTypeOf<'all' | 'selected'>(); + expectTypeOf(value).toEqualTypeOf(); }} /> ); }); - test('rejects a value the handler does not accept', () => { + test('takes the value type from a typed handler', () => { void ( value} + defaultValue='all' + onValueChange={(value: Access) => { + 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.tsx b/packages/mosaic/src/components/radio/radio.tsx index 097854c1121..a8c28b90322 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?: T; - defaultValue?: T; - onValueChange?: (value: T) => void; + value?: Value; + defaultValue?: Value; + onValueChange?: (value: Value) => void; name?: string; disabled?: boolean; /** @default 'stack' */ @@ -30,24 +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); // 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( +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], @@ -67,16 +63,14 @@ const RadioGroup = React.forwardRef(function Mo /> ); -}) as ( - props: RadioGroupProps & { ref?: React.Ref }, -) => React.ReactElement; +}) 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( @@ -106,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 (fu export interface RadioCardProps extends Omit, 'className' | 'style'>, MosaicStyleProps { - value: string; + value: unknown; disabled?: boolean; } @@ -219,7 +213,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('gives a string when the group has no value', () => { + test('takes the value type from a typed value', () => { void ( { - expectTypeOf(value).toEqualTypeOf(); + expectTypeOf(value).toEqualTypeOf(); }} /> ); }); - test('gives the type of value', () => { + test('gives a string when defaultValue is a literal', () => { void ( { - expectTypeOf(value).toEqualTypeOf<'all' | 'selected'>(); + expectTypeOf(value).toEqualTypeOf(); }} /> ); }); - test('gives the type of defaultValue', () => { + test('gives a string when nothing is typed', () => { void ( { - expectTypeOf(value).toEqualTypeOf<'all' | 'selected'>(); + expectTypeOf(value).toEqualTypeOf(); }} /> ); }); - test('rejects a value the handler does not accept', () => { + test('takes the value type from a typed handler', () => { void ( value} + defaultValue='all' + onValueChange={(value: Access) => { + 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 3f984f80b61..5e0db2801c7 100644 --- a/packages/mosaic/src/components/segmented-control/segmented-control.tsx +++ b/packages/mosaic/src/components/segmented-control/segmented-control.tsx @@ -14,11 +14,11 @@ import { styles } from './segmented-control.styles'; type StyledProps = Omit & MosaicStyleProps; -export type SegmentedControlRootProps = StyledProps>; +export type SegmentedControlRootProps = StyledProps>; export type SegmentedControlItemProps = StyledProps; // 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( +const Root = React.forwardRef>(function MosaicSegmentedControlRoot( { xstyle, children, ...rest }, ref, ) { @@ -33,8 +33,8 @@ const Root = React.forwardRef(functio {children} ); -}) as ( - props: SegmentedControlRootProps & { ref?: React.Ref }, +}) as ( + props: SegmentedControlRootProps & { ref?: React.Ref }, ) => React.ReactElement; const Item = React.forwardRef(function MosaicSegmentedControlItem( diff --git a/packages/mosaic/src/components/select/select.test-d.tsx b/packages/mosaic/src/components/select/select.test-d.tsx index 6d44764763d..2d45ce0b30c 100644 --- a/packages/mosaic/src/components/select/select.test-d.tsx +++ b/packages/mosaic/src/components/select/select.test-d.tsx @@ -2,20 +2,19 @@ import { describe, expectTypeOf, test } from 'vitest'; import { Select } from './select'; -declare const label: string; +type Expiration = '1d' | '7d'; -const expirations = [ - { value: '1d', label: '1 day' }, - { value: '7d', label: '7 days' }, -] as const; +declare const expiration: Expiration | null; +declare const expirationItems: { value: Expiration; label: string }[]; +declare const onExpirationChange: (expiration: Expiration) => void; describe('Select.Root onValueChange', () => { - test('gives a string when the items carry strings', () => { + test('takes the value type from typed items', () => { void ( { - expectTypeOf(value).toEqualTypeOf(); + expectTypeOf(value).toEqualTypeOf(); }} > {null} @@ -23,12 +22,13 @@ describe('Select.Root onValueChange', () => { ); }); - test('gives the type of the item values', () => { + test('takes the value type from a typed value, including null for no selection', () => { void ( ({ ...item }))} + items={[]} + value={expiration} onValueChange={value => { - expectTypeOf(value).toEqualTypeOf<'1d' | '7d'>(); + expectTypeOf(value).toEqualTypeOf(); }} > {null} @@ -36,13 +36,12 @@ describe('Select.Root onValueChange', () => { ); }); - test('gives an array of the item values when multiple', () => { + test('gives a string when the items are literals', () => { void ( ({ ...item }))} + items={[{ value: '1d', label: '1 day' }]} onValueChange={value => { - expectTypeOf(value).toEqualTypeOf>(); + expectTypeOf(value).toEqualTypeOf(); }} > {null} @@ -50,15 +49,56 @@ describe('Select.Root onValueChange', () => { ); }); - test('rejects a value that is not an item', () => { + test('gives an array with multiple', () => { void ( ({ ...item }))} - // @ts-expect-error -- No item carries this value - value='30d' + multiple + items={expirationItems} + onValueChange={value => { + 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: T; +export interface SelectItem { + value: Value; /** Shown in the row and in the trigger once selected; also drives typeahead. */ label: string; /** @@ -36,28 +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, 'items'> & SelectItemsProps) - | (Omit, 'items'> & 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({ +export function SelectRoot({ items, alignItemWithTrigger, children, ...rest -}: SelectProps): React.ReactElement { +}: SelectProps): React.ReactElement { const described = items.some(item => item.description !== undefined); return ( @@ -89,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); }; /** @@ -213,9 +213,10 @@ export const SelectPopup = React.forwardRef(fu )} > {children ?? - items.map(item => ( + items.map((item, index) => ( ))} @@ -227,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 ab8a094e73f..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,7 +128,7 @@ function CreateKeyStep(props: CreateAPIKeyDialogProps & { inputRef: Ref{m.expirationLabel} ({ value, label: m.expirationOptions[value] }))} - value={expiration ?? undefined} + 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 834949348d3..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,7 +76,7 @@ export function OAuthConsentView(props: OAuthConsentViewProps) { {m.organizationLabel} @@ -257,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); 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 5dfc9af402a..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,22 +12,19 @@ import { type SegmentedControlDirection, } from './segmented-control-context'; -export interface SegmentedControlRootProps extends Omit< - ComponentProps<'div'>, - 'defaultValue' -> { - value?: T; - defaultValue?: T; - onValueChange?: (value: T) => void; +export interface SegmentedControlRootProps extends Omit, 'defaultValue'> { + value?: Value; + defaultValue?: Value; + onValueChange?: (value: Value) => void; disabled?: boolean; } // 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( +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); @@ -36,8 +33,8 @@ export const SegmentedControlRoot = React.forwardRef { - if (nextValue !== value) { + (nextValue: unknown) => { + if (!Object.is(nextValue, value)) { setValue(nextValue); } }, @@ -59,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) { @@ -100,6 +97,6 @@ export const SegmentedControlRoot = React.forwardRef ); }, -) as ( - props: SegmentedControlRootProps & { ref?: React.Ref }, +) 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..98a067f7353 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` @@ -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 4f65f120ac7..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: T; + 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 3a231c95aed..e14ac3e1f3a 100644 --- a/packages/mosaic/src/primitives/select/select-root.tsx +++ b/packages/mosaic/src/primitives/select/select-root.tsx @@ -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?: NoInfer; - defaultValue?: NoInfer; - onValueChange?: (value: NoInfer) => 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?: NoInfer[]; - defaultValue?: NoInfer[]; - onValueChange?: (value: NoInfer[]) => 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,8 +355,8 @@ function SelectInner(props: SelectProps) { ); } -// eslint-disable-next-line typescript/consistent-type-assertions -- The root only hands back values from its items, so the item value type carries over to onValueChange. -export const SelectRoot = 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) { @@ -368,4 +368,4 @@ export const SelectRoot = function SelectRoot(props: SelectProps) { } return ; -} as (props: SelectProps) => ReactElement; +} 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) { From 3524d3c24110f71afa442e32bf0c0654e545cfaa Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 5 Nov 2026 13:52:41 -0500 Subject: [PATCH 3/5] docs(swingset): update Select and SegmentedControl value types in stories --- .../mosaic/src/primitives/select/README.md | 10 ++-- .../src/stories/segmented-control.mdx | 20 ++++---- .../swingset/src/stories/select.component.mdx | 4 +- .../src/stories/select.component.stories.tsx | 2 +- packages/swingset/src/stories/select.mdx | 50 +++++++++---------- 5 files changed, 43 insertions(+), 43 deletions(-) diff --git a/packages/mosaic/src/primitives/select/README.md b/packages/mosaic/src/primitives/select/README.md index 98a067f7353..f11e38593f6 100644 --- a/packages/mosaic/src/primitives/select/README.md +++ b/packages/mosaic/src/primitives/select/README.md @@ -135,11 +135,11 @@ By default, the selected option visually aligns with the trigger. Disable this f ### `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` | falls back to `value` | Display label, also used for typeahead | +| `disabled` | `boolean` | — | Prevents selection | ### `Select.Trigger`, `Select.Positioner`, `Select.Popup` 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..1c87fdb78bf 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 | falls back to `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`. From 18a2066a6a7bbc2fc0a6b05f7899d2b24cc56b33 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 5 Nov 2026 14:38:32 -0500 Subject: [PATCH 4/5] docs(mosaic): document SelectItem and the String(value) label fallback --- .../mosaic/src/primitives/select/README.md | 12 +++--- packages/swingset/src/stories/select.mdx | 40 +++++++++---------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/mosaic/src/primitives/select/README.md b/packages/mosaic/src/primitives/select/README.md index f11e38593f6..af1dac0bacf 100644 --- a/packages/mosaic/src/primitives/select/README.md +++ b/packages/mosaic/src/primitives/select/README.md @@ -121,7 +121,7 @@ By default, the selected option visually aligns with the trigger. Disable this f | `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 | +| `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) | @@ -135,11 +135,11 @@ By default, the selected option visually aligns with the trigger. Disable this f ### `Select.Option` -| Prop | Type | Default | Description | -| ---------- | --------- | --------------------- | --------------------------------------------- | -| `value` | `unknown` | **required** | The option's value, compared with `Object.is` | -| `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` diff --git a/packages/swingset/src/stories/select.mdx b/packages/swingset/src/stories/select.mdx index 1c87fdb78bf..27c840456ab 100644 --- a/packages/swingset/src/stories/select.mdx +++ b/packages/swingset/src/stories/select.mdx @@ -104,21 +104,21 @@ props. `Select.Portal` is optional. ### `Select.Root` -| 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 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` @@ -129,11 +129,11 @@ props. `Select.Portal` is optional. ### `Select.Option` -| Prop | Type | Default | Description | -| --------------------- | -------------------- | --------------------- | --------------------------------------------- | -| value | unknown | — (required) | The option's value, compared with `Object.is` | -| 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`. From 6a20e6a4d7de7a1d651e8af4667ef530444871c9 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 6 Nov 2026 13:13:20 -0500 Subject: [PATCH 5/5] fix(mosaic): submit non-string Radio.Group values as JSON --- .../src/components/radio/radio.test.tsx | 16 ++++++++++++++++ .../mosaic/src/components/radio/radio.tsx | 19 ++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) 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 a8c28b90322..1c30af39a46 100644 --- a/packages/mosaic/src/components/radio/radio.tsx +++ b/packages/mosaic/src/components/radio/radio.tsx @@ -112,7 +112,7 @@ const RadioItem = React.forwardRef(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 ``. */