Skip to content

Commit c6e11c9

Browse files
feat(mosaic): support checkboxes in Field with a horizontal orientation (#10024)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 4b3b93d commit c6e11c9

9 files changed

Lines changed: 216 additions & 12 deletions

File tree

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

‎packages/mosaic/src/components/checkbox/checkbox.styles.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ export const styles = stylex.create({
4747
position: 'absolute',
4848
},
4949
},
50+
fieldHitTarget: {
51+
'::before': {
52+
inset: `calc(-1 * (${space['2']} + 1px))`,
53+
display: 'block',
54+
},
55+
},
5056
indicator: {
5157
inset: 0,
5258
alignItems: 'center',
@@ -75,6 +81,11 @@ export const sizes = stylex.create({
7581
md: { height: space['4'], width: space['4'] },
7682
});
7783

84+
export const firstLine = stylex.create({
85+
sm: { marginTop: `max(0px, calc((1lh - ${space['3.5']}) / 2))` },
86+
md: { marginTop: `max(0px, calc((1lh - ${space['4']}) / 2))` },
87+
});
88+
7889
export const indicatorSizes = stylex.create({
7990
sm: { fontSize: space['2.5'] },
8091
md: { fontSize: space['3'] },

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

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import type { MosaicStyleProps } from '../../props';
55
import { mergeStyleProps, themeProps } from '../../props';
66
import { focusOutline } from '../../utils/focus-outline.styles';
77
import { reset } from '../../utils/reset.styles';
8+
import { useOptionalFieldContext, useOptionalFieldControlProps } from '../field/field.context';
89
import { Icon } from '../icon';
910
import { checkboxInputMarker } from './checkbox.markers.stylex';
10-
import { indicatorSizes, sizes, styles } from './checkbox.styles';
11+
import { firstLine, indicatorSizes, sizes, styles } from './checkbox.styles';
1112

1213
export interface CheckboxProps
1314
extends Omit<React.ComponentPropsWithoutRef<'input'>, 'type' | 'size' | 'className' | 'style'>, MosaicStyleProps {
@@ -16,9 +17,30 @@ export interface CheckboxProps
1617
}
1718

1819
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(function MosaicCheckbox(
19-
{ size = 'md', indeterminate = false, disabled, xstyle, ...rest },
20+
{
21+
size = 'md',
22+
indeterminate = false,
23+
disabled: disabledProp,
24+
required: requiredProp,
25+
id,
26+
'aria-invalid': ariaInvalid,
27+
'aria-labelledby': ariaLabelledBy,
28+
'aria-describedby': ariaDescribedBy,
29+
xstyle,
30+
...rest
31+
},
2032
ref,
2133
) {
34+
const horizontalField = useOptionalFieldContext()?.orientation === 'horizontal';
35+
const fieldProps = useOptionalFieldControlProps({
36+
id,
37+
disabled: disabledProp,
38+
required: requiredProp,
39+
ariaInvalid,
40+
ariaLabelledBy,
41+
ariaDescribedBy,
42+
});
43+
const disabled = fieldProps?.disabled ?? disabledProp;
2244
const setInputRef = React.useCallback(
2345
(node: HTMLInputElement | null) => {
2446
if (node) {
@@ -37,20 +59,26 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(functi
3759
<span
3860
{...mergeStyleProps(
3961
themeProps('checkbox', { size, disabled }),
40-
stylex.props(reset.base, styles.root, sizes[size], indicatorSizes[size], xstyle),
62+
stylex.props(reset.base, styles.root, sizes[size], horizontalField && firstLine[size], xstyle),
4163
)}
4264
>
4365
<input
4466
ref={setInputRef}
4567
type='checkbox'
4668
disabled={disabled}
69+
required={fieldProps?.required ?? requiredProp}
70+
id={fieldProps?.id ?? id}
71+
aria-invalid={fieldProps?.['aria-invalid'] ?? ariaInvalid}
72+
aria-labelledby={fieldProps?.['aria-labelledby'] ?? ariaLabelledBy}
73+
aria-describedby={fieldProps?.['aria-describedby'] ?? ariaDescribedBy}
4774
{...mergeStyleProps(
4875
themeProps('checkbox-input'),
4976
stylex.props(
5077
reset.base,
5178
focusOutline.visible,
5279
styles.input,
5380
styles.hitTarget,
81+
horizontalField && styles.fieldHitTarget,
5482
sizes[size],
5583
checkboxInputMarker,
5684
),
@@ -61,7 +89,7 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(functi
6189
aria-hidden
6290
{...mergeStyleProps(
6391
themeProps('checkbox-indicator', { state: 'checked' }),
64-
stylex.props(reset.base, styles.indicator, styles.checkedIndicator),
92+
stylex.props(reset.base, styles.indicator, indicatorSizes[size], styles.checkedIndicator),
6593
)}
6694
>
6795
<Icon
@@ -73,7 +101,7 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(functi
73101
aria-hidden
74102
{...mergeStyleProps(
75103
themeProps('checkbox-indicator', { state: 'indeterminate' }),
76-
stylex.props(reset.base, styles.indicator, styles.indeterminateIndicator),
104+
stylex.props(reset.base, styles.indicator, indicatorSizes[size], styles.indeterminateIndicator),
77105
)}
78106
>
79107
<Icon

‎packages/mosaic/src/components/field/field.context.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import React from 'react';
44
/** `label` for natively labelable controls; `span` for a control a `<label>` would activate on click. */
55
export type FieldLabelElementType = 'label' | 'span';
66

7+
export type FieldOrientation = 'vertical' | 'horizontal';
8+
79
interface RegisteredControl {
810
id: string | null;
911
labelElementType: FieldLabelElementType;
@@ -12,6 +14,7 @@ interface RegisteredControl {
1214
interface FieldContextValue {
1315
controlId: string;
1416
labelElementType: FieldLabelElementType;
17+
orientation: FieldOrientation;
1518
disabled: boolean;
1619
required: boolean;
1720
invalid: boolean;
@@ -30,12 +33,13 @@ export function mergeIds(...values: Array<string | undefined>): string | undefin
3033
}
3134

3235
interface FieldProviderProps extends React.PropsWithChildren {
36+
orientation: FieldOrientation;
3337
disabled: boolean;
3438
required: boolean;
3539
invalid: boolean;
3640
}
3741

38-
export function FieldProvider({ children, disabled, required, invalid }: FieldProviderProps) {
42+
export function FieldProvider({ children, orientation, disabled, required, invalid }: FieldProviderProps) {
3943
const generatedId = React.useId();
4044
const defaultControlId = `cl-field-${generatedId}`;
4145
const [controlId, setControlId] = React.useState(defaultControlId);
@@ -73,6 +77,7 @@ export function FieldProvider({ children, disabled, required, invalid }: FieldPr
7377
() => ({
7478
controlId,
7579
labelElementType,
80+
orientation,
7681
disabled,
7782
required,
7883
invalid,
@@ -82,7 +87,7 @@ export function FieldProvider({ children, disabled, required, invalid }: FieldPr
8287
setLabelIds,
8388
setMessageIds,
8489
}),
85-
[controlId, labelElementType, disabled, required, invalid, labelIds, messageIds, registerControlId],
90+
[controlId, labelElementType, orientation, disabled, required, invalid, labelIds, messageIds, registerControlId],
8691
);
8792

8893
return <FieldContext.Provider value={context}>{children}</FieldContext.Provider>;

‎packages/mosaic/src/components/field/field.styles.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ export const styles = stylex.create({
88
display: 'flex',
99
flexDirection: 'column',
1010
},
11+
horizontal: {
12+
gap: space['2'],
13+
alignItems: 'flex-start',
14+
flexDirection: 'row',
15+
},
16+
content: {
17+
gap: space['1'],
18+
display: 'flex',
19+
flexDirection: 'column',
20+
flexGrow: 1,
21+
minWidth: 0,
22+
},
1123
label: {
1224
color: colorVars['--cl-color-brand'],
1325
fontWeight: fontWeightVars['--cl-font-medium'],

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

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { hydrateRoot } from 'react-dom/client';
66
import { renderToString } from 'react-dom/server';
77
import { afterEach, describe, expect, it, vi } from 'vitest';
88

9+
import { Checkbox } from '../checkbox';
910
import { Input } from '../input';
1011
import { Select } from '../select';
1112
import { Field } from './field';
@@ -14,6 +15,7 @@ const overrides = stylex.create({
1415
root: { display: 'grid' },
1516
label: { fontWeight: 700 },
1617
description: { opacity: 0.8 },
18+
content: { gap: '4px' },
1719
error: { fontWeight: 600 },
1820
});
1921

@@ -175,6 +177,63 @@ describe('Mosaic Field', () => {
175177
expect(inside).not.toHaveClass('cl-field-control');
176178
});
177179

180+
it('associates a checkbox laid out beside its label', async () => {
181+
render(
182+
<Field.Root
183+
orientation='horizontal'
184+
data-testid='root'
185+
>
186+
<Checkbox />
187+
<Field.Content data-testid='content'>
188+
<Field.Label>Sign out of all devices</Field.Label>
189+
<Field.Description>Recommended after changing your password.</Field.Description>
190+
</Field.Content>
191+
</Field.Root>,
192+
);
193+
194+
const checkbox = screen.getByRole('checkbox', { name: 'Sign out of all devices' });
195+
expect(screen.getByTestId('root')).toHaveAttribute('data-orientation', 'horizontal');
196+
expect(screen.getByTestId('content')).toHaveClass('cl-field-content');
197+
expect(checkbox).toHaveAttribute(
198+
'aria-describedby',
199+
screen.getByText('Recommended after changing your password.').id,
200+
);
201+
await userEvent.click(screen.getByText('Sign out of all devices'));
202+
expect(checkbox).toBeChecked();
203+
});
204+
205+
it('toggles a checkbox wrapped in its label', async () => {
206+
render(
207+
<Field.Root>
208+
<Field.Label>
209+
<Checkbox />
210+
Stay signed in
211+
</Field.Label>
212+
</Field.Root>,
213+
);
214+
215+
await userEvent.click(screen.getByText('Stay signed in'));
216+
expect(screen.getByRole('checkbox', { name: 'Stay signed in' })).toBeChecked();
217+
});
218+
219+
it('propagates semantic state to a checkbox', () => {
220+
render(
221+
<Field.Root
222+
disabled
223+
required
224+
invalid
225+
>
226+
<Checkbox />
227+
</Field.Root>,
228+
);
229+
230+
const checkbox = screen.getByRole('checkbox');
231+
expect(checkbox).toBeDisabled();
232+
expect(checkbox).toBeRequired();
233+
expect(checkbox).toHaveAttribute('aria-invalid', 'true');
234+
expect(checkbox.parentElement).toHaveAttribute('data-disabled');
235+
});
236+
178237
it('propagates semantic state while preserving explicit control props', () => {
179238
render(
180239
<>
@@ -285,6 +344,7 @@ describe('Mosaic Field', () => {
285344
const rootRef = React.createRef<HTMLDivElement>();
286345
const labelRef = React.createRef<HTMLLabelElement>();
287346
const descriptionRef = React.createRef<HTMLParagraphElement>();
347+
const contentRef = React.createRef<HTMLDivElement>();
288348
const errorRef = React.createRef<HTMLParagraphElement>();
289349

290350
render(
@@ -304,6 +364,10 @@ describe('Mosaic Field', () => {
304364
>
305365
Description
306366
</Field.Description>
367+
<Field.Content
368+
ref={contentRef}
369+
data-content='field'
370+
/>
307371
<Field.Error
308372
ref={errorRef}
309373
role='status'
@@ -316,6 +380,7 @@ describe('Mosaic Field', () => {
316380
expect(rootRef.current).toHaveAttribute('data-root', 'field');
317381
expect(labelRef.current).toHaveAttribute('for', 'name');
318382
expect(descriptionRef.current).toHaveAttribute('title', 'Help');
383+
expect(contentRef.current).toHaveAttribute('data-content', 'field');
319384
expect(errorRef.current).toHaveAttribute('role', 'status');
320385
});
321386

@@ -327,11 +392,16 @@ describe('Mosaic Field', () => {
327392
>
328393
<Field.Label xstyle={overrides.label}>Email</Field.Label>
329394
<Field.Description xstyle={overrides.description}>Description</Field.Description>
395+
<Field.Content
396+
xstyle={overrides.content}
397+
data-testid='content'
398+
/>
330399
<Field.Error xstyle={overrides.error}>Error</Field.Error>
331400
</Field.Root>,
332401
);
333402

334403
expect(screen.getByTestId('root')).toHaveClass('cl-field-root', ...atoms(overrides.root));
404+
expect(screen.getByTestId('content')).toHaveClass('cl-field-content', ...atoms(overrides.content));
335405
expect(screen.getByText('Email')).toHaveClass('cl-field-label', ...atoms(overrides.label));
336406
expect(screen.getByText('Description')).toHaveClass('cl-field-description', ...atoms(overrides.description));
337407
expect(screen.getByText('Error').closest('p')).toHaveClass('cl-field-error', ...atoms(overrides.error));
@@ -365,12 +435,14 @@ describe('Mosaic Field', () => {
365435
<Field.Root render={props => <section {...props} />}>
366436
<Field.Label render={props => <label {...props} />}>Biography</Field.Label>
367437
<Field.Description render={props => <div {...props} />}>Description</Field.Description>
438+
<Field.Content render={props => <section {...props} />}>Content</Field.Content>
368439
<Field.Error render={props => <div {...props} />}>Error</Field.Error>
369440
</Field.Root>,
370441
);
371442

372443
expect(screen.getByText('Biography').closest('section')).not.toBeNull();
373444
expect(screen.getByText('Description').tagName).toBe('DIV');
445+
expect(screen.getByText('Content').tagName).toBe('SECTION');
374446
expect(screen.getByText('Error').closest('div')).toHaveClass('cl-field-error');
375447
});
376448

0 commit comments

Comments
 (0)