Skip to content

Commit aa23ee5

Browse files
refactor(mosaic): type onValueChange of choice components by their values (#10675)
1 parent 964fbe3 commit aa23ee5

30 files changed

Lines changed: 539 additions & 198 deletions
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎packages/mosaic/src/components/pagination/pagination.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export const Pagination = React.forwardRef<HTMLElement, PaginationProps>(functio
8989
const sizes = new Set(
9090
[...pageSizeOptions, itemsPerPage].filter(size => Number.isFinite(size) && size >= 1).map(Math.floor),
9191
);
92-
return [...sizes].sort((a, b) => a - b).map(size => ({ value: String(size), label: String(size) }));
92+
return [...sizes].sort((a, b) => a - b).map(size => ({ value: size, label: String(size) }));
9393
}, [pageSizeOptions, itemsPerPage]);
9494
const isFirst = current <= 1;
9595
const isLast = current >= pageCount;
@@ -126,8 +126,8 @@ export const Pagination = React.forwardRef<HTMLElement, PaginationProps>(functio
126126
</span>
127127
<Select.Root
128128
items={pageSizeItems}
129-
value={String(itemsPerPage)}
130-
onValueChange={value => onPageSizeChange(Number(value))}
129+
value={itemsPerPage}
130+
onValueChange={onPageSizeChange}
131131
>
132132
<Select.Trigger
133133
aria-label={pageSizeLabel}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, expectTypeOf, test } from 'vitest';
2+
3+
import { Radio } from './radio';
4+
5+
type Access = 'all' | 'selected';
6+
7+
declare const access: Access;
8+
declare const onAccessChange: (access: Access) => void;
9+
10+
describe('Radio.Group onValueChange', () => {
11+
test('takes the value type from a typed value', () => {
12+
void (
13+
<Radio.Group
14+
value={access}
15+
onValueChange={value => {
16+
expectTypeOf(value).toEqualTypeOf<Access>();
17+
}}
18+
/>
19+
);
20+
});
21+
22+
test('gives a string when defaultValue is a literal', () => {
23+
void (
24+
<Radio.Group
25+
defaultValue='all'
26+
onValueChange={value => {
27+
expectTypeOf(value).toEqualTypeOf<string>();
28+
}}
29+
/>
30+
);
31+
});
32+
33+
test('gives a string when nothing is typed', () => {
34+
void (
35+
<Radio.Group
36+
onValueChange={value => {
37+
expectTypeOf(value).toEqualTypeOf<string>();
38+
}}
39+
/>
40+
);
41+
});
42+
43+
test('takes the value type from a typed handler', () => {
44+
void (
45+
<Radio.Group
46+
defaultValue='all'
47+
onValueChange={(value: Access) => {
48+
expectTypeOf(value).toEqualTypeOf<Access>();
49+
}}
50+
/>
51+
);
52+
});
53+
54+
test('accepts values that are not strings', () => {
55+
void (
56+
<Radio.Group
57+
defaultValue={1}
58+
onValueChange={value => {
59+
expectTypeOf(value).toEqualTypeOf<number>();
60+
}}
61+
>
62+
<Radio.Item value={1} />
63+
</Radio.Group>
64+
);
65+
});
66+
67+
test('rejects a value the handler does not accept', () => {
68+
void (
69+
<>
70+
<Radio.Group
71+
// @ts-expect-error -- The handler does not accept this value
72+
value='other'
73+
onValueChange={onAccessChange}
74+
/>
75+
<Radio.Group
76+
// @ts-expect-error -- The handler does not accept this value
77+
defaultValue='other'
78+
onValueChange={onAccessChange}
79+
/>
80+
</>
81+
);
82+
});
83+
});

‎packages/mosaic/src/components/radio/radio.test.tsx‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,22 @@ describe('Mosaic Radio', () => {
197197
expect(formEntries(container)).toEqual([['access', 'all']]);
198198
});
199199

200+
it('submits a non-string value as its JSON', () => {
201+
const { container } = render(
202+
<form>
203+
<Radio.Group
204+
name='size'
205+
defaultValue={10}
206+
>
207+
<Radio.Item value={10} />
208+
<Radio.Item value={{ id: 20 }} />
209+
</Radio.Group>
210+
</form>,
211+
);
212+
expect(formEntries(container)).toEqual([['size', '10']]);
213+
expect(screen.getAllByRole('radio')[1]).toHaveAttribute('value', '{"id":20}');
214+
});
215+
200216
it('submits nothing with a form when it has no name', () => {
201217
const { container } = render(
202218
<form>

‎packages/mosaic/src/components/radio/radio.tsx‎

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@ import { firstLine, indicatorSizes, layouts, sizes, styles } from './radio.style
1616
/** `stack` gives each choice its own row. `grid` fits as many columns of cards as the width allows. */
1717
export type RadioLayout = 'stack' | 'grid';
1818

19-
export interface RadioGroupProps
19+
export interface RadioGroupProps<Value = string>
2020
extends Omit<React.ComponentPropsWithoutRef<'div'>, 'defaultValue' | 'className' | 'style'>, MosaicStyleProps {
21-
value?: string;
22-
defaultValue?: string;
23-
onValueChange?: (value: string) => void;
21+
value?: Value;
22+
defaultValue?: Value;
23+
onValueChange?: (value: Value) => void;
2424
name?: string;
2525
disabled?: boolean;
2626
/** @default 'stack' */
@@ -30,23 +30,20 @@ export interface RadioGroupProps
3030
interface RadioGroupContextValue {
3131
name: string;
3232
submits: boolean;
33-
value: string | undefined;
34-
setValue: (value: string) => void;
33+
value: unknown;
34+
setValue: (value: unknown) => void;
3535
disabled: boolean | undefined;
3636
}
3737

3838
const RadioGroupContext = React.createContext<RadioGroupContextValue | null>(null);
3939

40-
const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>(function MosaicRadioGroup(
40+
// 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.
41+
const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps<unknown>>(function MosaicRadioGroup(
4142
{ value: valueProp, defaultValue, onValueChange, name: nameProp, disabled, layout = 'stack', xstyle, ...rest },
4243
ref,
4344
) {
4445
const generatedName = React.useId();
45-
const [value, setValue] = useControllableState<string | undefined>(valueProp, defaultValue, next => {
46-
if (next !== undefined) {
47-
onValueChange?.(next);
48-
}
49-
});
46+
const [value, setValue] = useControllableState(valueProp, defaultValue, onValueChange);
5047
const context = React.useMemo<RadioGroupContextValue>(
5148
() => ({ name: nameProp ?? generatedName, submits: nameProp !== undefined, value, setValue, disabled }),
5249
[nameProp, generatedName, value, setValue, disabled],
@@ -66,14 +63,14 @@ const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>(function Mo
6663
/>
6764
</RadioGroupContext.Provider>
6865
);
69-
});
66+
}) as <Value = string>(props: RadioGroupProps<Value> & { ref?: React.Ref<HTMLDivElement> }) => React.ReactElement;
7067

7168
export interface RadioItemProps
7269
extends
7370
Omit<React.ComponentPropsWithoutRef<'input'>, 'type' | 'size' | 'value' | 'className' | 'style'>,
7471
MosaicStyleProps {
7572
size?: 'sm' | 'md';
76-
value?: string;
73+
value?: unknown;
7774
}
7875

7976
const RadioItem = React.forwardRef<HTMLInputElement, RadioItemProps>(function MosaicRadioItem(
@@ -103,7 +100,7 @@ const RadioItem = React.forwardRef<HTMLInputElement, RadioItemProps>(function Mo
103100
});
104101
const group = React.useContext(RadioGroupContext);
105102
const disabled = (fieldProps?.disabled ?? disabledProp) || group?.disabled;
106-
const checked = group && value !== undefined ? group.value === value : checkedProp;
103+
const checked = group && value !== undefined ? Object.is(group.value, value) : checkedProp;
107104

108105
return (
109106
<span
@@ -115,7 +112,7 @@ const RadioItem = React.forwardRef<HTMLInputElement, RadioItemProps>(function Mo
115112
<input
116113
ref={ref}
117114
type='radio'
118-
value={value}
115+
value={value === undefined ? undefined : toFormValue(value)}
119116
name={group?.name ?? nameProp}
120117
form={group?.submits === false ? '' : undefined}
121118
checked={checked}
@@ -155,6 +152,23 @@ const RadioItem = React.forwardRef<HTMLInputElement, RadioItemProps>(function Mo
155152
);
156153
});
157154

155+
function toFormValue(value: unknown): string {
156+
if (value === null) {
157+
return '';
158+
}
159+
if (typeof value === 'string') {
160+
return value;
161+
}
162+
if (typeof value === 'bigint') {
163+
return value.toString();
164+
}
165+
try {
166+
return JSON.stringify(value);
167+
} catch {
168+
return '';
169+
}
170+
}
171+
158172
export interface RadioSectionProps
159173
extends Omit<React.ComponentPropsWithoutRef<'fieldset'>, 'className' | 'style'>, MosaicStyleProps {
160174
/** The section's heading, rendered as its `<legend>`. */
@@ -197,7 +211,7 @@ const RadioSection = React.forwardRef<HTMLFieldSetElement, RadioSectionProps>(fu
197211

198212
export interface RadioCardProps
199213
extends Omit<React.ComponentPropsWithoutRef<'label'>, 'className' | 'style'>, MosaicStyleProps {
200-
value: string;
214+
value: unknown;
201215
disabled?: boolean;
202216
}
203217

@@ -216,7 +230,7 @@ const RadioCard = React.forwardRef<HTMLLabelElement, RadioCardProps>(function Mo
216230
ref,
217231
) {
218232
const group = React.useContext(RadioGroupContext);
219-
const checked = group?.value === value;
233+
const checked = group !== null && Object.is(group.value, value);
220234

221235
return (
222236
<FieldProvider
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, expectTypeOf, test } from 'vitest';
2+
3+
import { SegmentedControl } from './segmented-control';
4+
5+
type Access = 'all' | 'selected';
6+
7+
declare const access: Access;
8+
declare const onAccessChange: (access: Access) => void;
9+
10+
describe('SegmentedControl.Root onValueChange', () => {
11+
test('takes the value type from a typed value', () => {
12+
void (
13+
<SegmentedControl.Root
14+
value={access}
15+
onValueChange={value => {
16+
expectTypeOf(value).toEqualTypeOf<Access>();
17+
}}
18+
/>
19+
);
20+
});
21+
22+
test('gives a string when defaultValue is a literal', () => {
23+
void (
24+
<SegmentedControl.Root
25+
defaultValue='all'
26+
onValueChange={value => {
27+
expectTypeOf(value).toEqualTypeOf<string>();
28+
}}
29+
/>
30+
);
31+
});
32+
33+
test('gives a string when nothing is typed', () => {
34+
void (
35+
<SegmentedControl.Root
36+
onValueChange={value => {
37+
expectTypeOf(value).toEqualTypeOf<string>();
38+
}}
39+
/>
40+
);
41+
});
42+
43+
test('takes the value type from a typed handler', () => {
44+
void (
45+
<SegmentedControl.Root
46+
defaultValue='all'
47+
onValueChange={(value: Access) => {
48+
expectTypeOf(value).toEqualTypeOf<Access>();
49+
}}
50+
/>
51+
);
52+
});
53+
54+
test('accepts values that are not strings', () => {
55+
void (
56+
<SegmentedControl.Root
57+
defaultValue={1}
58+
onValueChange={value => {
59+
expectTypeOf(value).toEqualTypeOf<number>();
60+
}}
61+
>
62+
<SegmentedControl.Item value={1} />
63+
</SegmentedControl.Root>
64+
);
65+
});
66+
67+
test('rejects a value the handler does not accept', () => {
68+
void (
69+
<>
70+
<SegmentedControl.Root
71+
// @ts-expect-error -- The handler does not accept this value
72+
value='other'
73+
onValueChange={onAccessChange}
74+
/>
75+
<SegmentedControl.Root
76+
// @ts-expect-error -- The handler does not accept this value
77+
defaultValue='other'
78+
onValueChange={onAccessChange}
79+
/>
80+
</>
81+
);
82+
});
83+
});

‎packages/mosaic/src/components/segmented-control/segmented-control.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ import { styles } from './segmented-control.styles';
1414

1515
type StyledProps<Props> = Omit<Props, 'className' | 'style'> & MosaicStyleProps;
1616

17-
export type SegmentedControlRootProps = StyledProps<HeadlessSegmentedControlRootProps>;
17+
export type SegmentedControlRootProps<Value = string> = StyledProps<HeadlessSegmentedControlRootProps<Value>>;
1818
export type SegmentedControlItemProps = StyledProps<HeadlessSegmentedControlItemProps>;
1919

20-
const Root = React.forwardRef<HTMLDivElement, SegmentedControlRootProps>(function MosaicSegmentedControlRoot(
20+
// 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.
21+
const Root = React.forwardRef<HTMLDivElement, SegmentedControlRootProps<unknown>>(function MosaicSegmentedControlRoot(
2122
{ xstyle, children, ...rest },
2223
ref,
2324
) {
@@ -32,7 +33,9 @@ const Root = React.forwardRef<HTMLDivElement, SegmentedControlRootProps>(functio
3233
{children}
3334
</Primitive.Root>
3435
);
35-
});
36+
}) as <Value = string>(
37+
props: SegmentedControlRootProps<Value> & { ref?: React.Ref<HTMLDivElement> },
38+
) => React.ReactElement;
3639

3740
const Item = React.forwardRef<HTMLButtonElement, SegmentedControlItemProps>(function MosaicSegmentedControlItem(
3841
{ xstyle, children, ...rest },

0 commit comments

Comments
 (0)