Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-typed-value-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
6 changes: 3 additions & 3 deletions packages/mosaic/src/components/pagination/pagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export const Pagination = React.forwardRef<HTMLElement, PaginationProps>(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;
Expand Down Expand Up @@ -126,8 +126,8 @@ export const Pagination = React.forwardRef<HTMLElement, PaginationProps>(functio
</span>
<Select.Root
items={pageSizeItems}
value={String(itemsPerPage)}
onValueChange={value => onPageSizeChange(Number(value))}
value={itemsPerPage}
onValueChange={onPageSizeChange}
>
<Select.Trigger
aria-label={pageSizeLabel}
Expand Down
83 changes: 83 additions & 0 deletions packages/mosaic/src/components/radio/radio.test-d.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, expectTypeOf, test } from 'vitest';

import { Radio } from './radio';

type Access = 'all' | 'selected';

declare const access: Access;
declare const onAccessChange: (access: Access) => void;

describe('Radio.Group onValueChange', () => {
test('takes the value type from a typed value', () => {
void (
<Radio.Group
value={access}
onValueChange={value => {
expectTypeOf(value).toEqualTypeOf<Access>();
}}
/>
);
});

test('gives a string when defaultValue is a literal', () => {
void (
<Radio.Group
defaultValue='all'
onValueChange={value => {
expectTypeOf(value).toEqualTypeOf<string>();
}}
/>
);
});

test('gives a string when nothing is typed', () => {
void (
<Radio.Group
onValueChange={value => {
expectTypeOf(value).toEqualTypeOf<string>();
}}
/>
);
});

test('takes the value type from a typed handler', () => {
void (
<Radio.Group
defaultValue='all'
onValueChange={(value: Access) => {
expectTypeOf(value).toEqualTypeOf<Access>();
}}
/>
);
});

test('accepts values that are not strings', () => {
void (
<Radio.Group
defaultValue={1}
onValueChange={value => {
expectTypeOf(value).toEqualTypeOf<number>();
}}
>
<Radio.Item value={1} />
</Radio.Group>
);
});

test('rejects a value the handler does not accept', () => {
void (
<>
<Radio.Group
// @ts-expect-error -- The handler does not accept this value
value='other'
onValueChange={onAccessChange}
/>
<Radio.Group
// @ts-expect-error -- The handler does not accept this value
defaultValue='other'
onValueChange={onAccessChange}
/>
</>
);
});
});
16 changes: 16 additions & 0 deletions packages/mosaic/src/components/radio/radio.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<form>
<Radio.Group
name='size'
defaultValue={10}
>
<Radio.Item value={10} />
<Radio.Item value={{ id: 20 }} />
</Radio.Group>
</form>,
);
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(
<form>
Expand Down
50 changes: 32 additions & 18 deletions packages/mosaic/src/components/radio/radio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value = string>
extends Omit<React.ComponentPropsWithoutRef<'div'>, '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' */
Expand All @@ -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<RadioGroupContextValue | null>(null);

const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>(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<HTMLDivElement, RadioGroupProps<unknown>>(function MosaicRadioGroup(
{ value: valueProp, defaultValue, onValueChange, name: nameProp, disabled, layout = 'stack', xstyle, ...rest },
ref,
) {
const generatedName = React.useId();
const [value, setValue] = useControllableState<string | undefined>(valueProp, defaultValue, next => {
if (next !== undefined) {
onValueChange?.(next);
}
});
const [value, setValue] = useControllableState(valueProp, defaultValue, onValueChange);
const context = React.useMemo<RadioGroupContextValue>(
() => ({ name: nameProp ?? generatedName, submits: nameProp !== undefined, value, setValue, disabled }),
[nameProp, generatedName, value, setValue, disabled],
Expand All @@ -66,14 +63,14 @@ const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>(function Mo
/>
</RadioGroupContext.Provider>
);
});
}) as <Value = string>(props: RadioGroupProps<Value> & { ref?: React.Ref<HTMLDivElement> }) => React.ReactElement;

export interface RadioItemProps
extends
Omit<React.ComponentPropsWithoutRef<'input'>, 'type' | 'size' | 'value' | 'className' | 'style'>,
MosaicStyleProps {
size?: 'sm' | 'md';
value?: string;
value?: unknown;
}

const RadioItem = React.forwardRef<HTMLInputElement, RadioItemProps>(function MosaicRadioItem(
Expand Down Expand Up @@ -103,7 +100,7 @@ const RadioItem = React.forwardRef<HTMLInputElement, RadioItemProps>(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;
Comment thread
alexcarpenter marked this conversation as resolved.
Resolved

return (
<span
Expand All @@ -115,7 +112,7 @@ const RadioItem = React.forwardRef<HTMLInputElement, RadioItemProps>(function Mo
<input
ref={ref}
type='radio'
value={value}
value={value === undefined ? undefined : toFormValue(value)}
name={group?.name ?? nameProp}
form={group?.submits === false ? '' : undefined}
checked={checked}
Expand Down Expand Up @@ -155,6 +152,23 @@ const RadioItem = React.forwardRef<HTMLInputElement, RadioItemProps>(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<React.ComponentPropsWithoutRef<'fieldset'>, 'className' | 'style'>, MosaicStyleProps {
/** The section's heading, rendered as its `<legend>`. */
Expand Down Expand Up @@ -197,7 +211,7 @@ const RadioSection = React.forwardRef<HTMLFieldSetElement, RadioSectionProps>(fu

export interface RadioCardProps
extends Omit<React.ComponentPropsWithoutRef<'label'>, 'className' | 'style'>, MosaicStyleProps {
value: string;
value: unknown;
disabled?: boolean;
}

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

return (
<FieldProvider
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, expectTypeOf, test } from 'vitest';

import { SegmentedControl } from './segmented-control';

type Access = 'all' | 'selected';

declare const access: Access;
declare const onAccessChange: (access: Access) => void;

describe('SegmentedControl.Root onValueChange', () => {
test('takes the value type from a typed value', () => {
void (
<SegmentedControl.Root
value={access}
onValueChange={value => {
expectTypeOf(value).toEqualTypeOf<Access>();
}}
/>
);
});

test('gives a string when defaultValue is a literal', () => {
void (
<SegmentedControl.Root
defaultValue='all'
onValueChange={value => {
expectTypeOf(value).toEqualTypeOf<string>();
}}
/>
);
});

test('gives a string when nothing is typed', () => {
void (
<SegmentedControl.Root
onValueChange={value => {
expectTypeOf(value).toEqualTypeOf<string>();
}}
/>
);
});

test('takes the value type from a typed handler', () => {
void (
<SegmentedControl.Root
defaultValue='all'
onValueChange={(value: Access) => {
expectTypeOf(value).toEqualTypeOf<Access>();
}}
/>
);
});

test('accepts values that are not strings', () => {
void (
<SegmentedControl.Root
defaultValue={1}
onValueChange={value => {
expectTypeOf(value).toEqualTypeOf<number>();
}}
>
<SegmentedControl.Item value={1} />
</SegmentedControl.Root>
);
});

test('rejects a value the handler does not accept', () => {
void (
<>
<SegmentedControl.Root
// @ts-expect-error -- The handler does not accept this value
value='other'
onValueChange={onAccessChange}
/>
<SegmentedControl.Root
// @ts-expect-error -- The handler does not accept this value
defaultValue='other'
onValueChange={onAccessChange}
/>
</>
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ import { styles } from './segmented-control.styles';

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

export type SegmentedControlRootProps = StyledProps<HeadlessSegmentedControlRootProps>;
export type SegmentedControlRootProps<Value = string> = StyledProps<HeadlessSegmentedControlRootProps<Value>>;
export type SegmentedControlItemProps = StyledProps<HeadlessSegmentedControlItemProps>;

const Root = React.forwardRef<HTMLDivElement, SegmentedControlRootProps>(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<HTMLDivElement, SegmentedControlRootProps<unknown>>(function MosaicSegmentedControlRoot(
{ xstyle, children, ...rest },
ref,
) {
Expand All @@ -32,7 +33,9 @@ const Root = React.forwardRef<HTMLDivElement, SegmentedControlRootProps>(functio
{children}
</Primitive.Root>
);
});
}) as <Value = string>(
props: SegmentedControlRootProps<Value> & { ref?: React.Ref<HTMLDivElement> },
) => React.ReactElement;

const Item = React.forwardRef<HTMLButtonElement, SegmentedControlItemProps>(function MosaicSegmentedControlItem(
{ xstyle, children, ...rest },
Expand Down
Loading
Loading