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-field-horizontal-checkbox.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Comment thread
alexcarpenter marked this conversation as resolved.
11 changes: 11 additions & 0 deletions packages/mosaic/src/components/checkbox/checkbox.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ export const styles = stylex.create({
position: 'absolute',
},
},
fieldHitTarget: {
'::before': {
inset: `calc(-1 * (${space['2']} + 1px))`,
display: 'block',
},
},
indicator: {
inset: 0,
alignItems: 'center',
Expand Down Expand Up @@ -75,6 +81,11 @@ export const sizes = stylex.create({
md: { height: space['4'], width: space['4'] },
});

export const firstLine = stylex.create({
sm: { marginTop: `max(0px, calc((1lh - ${space['3.5']}) / 2))` },
md: { marginTop: `max(0px, calc((1lh - ${space['4']}) / 2))` },
});

export const indicatorSizes = stylex.create({
sm: { fontSize: space['2.5'] },
md: { fontSize: space['3'] },
Expand Down
38 changes: 33 additions & 5 deletions packages/mosaic/src/components/checkbox/checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import type { MosaicStyleProps } from '../../props';
import { mergeStyleProps, themeProps } from '../../props';
import { focusOutline } from '../../utils/focus-outline.styles';
import { reset } from '../../utils/reset.styles';
import { useOptionalFieldContext, useOptionalFieldControlProps } from '../field/field.context';
import { Icon } from '../icon';
import { checkboxInputMarker } from './checkbox.markers.stylex';
import { indicatorSizes, sizes, styles } from './checkbox.styles';
import { firstLine, indicatorSizes, sizes, styles } from './checkbox.styles';

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

export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(function MosaicCheckbox(
{ size = 'md', indeterminate = false, disabled, xstyle, ...rest },
{
size = 'md',
indeterminate = false,
disabled: disabledProp,
required: requiredProp,
id,
'aria-invalid': ariaInvalid,
'aria-labelledby': ariaLabelledBy,
'aria-describedby': ariaDescribedBy,
xstyle,
...rest
},
ref,
) {
const horizontalField = useOptionalFieldContext()?.orientation === 'horizontal';
const fieldProps = useOptionalFieldControlProps({
id,
disabled: disabledProp,
required: requiredProp,
ariaInvalid,
ariaLabelledBy,
ariaDescribedBy,
});
const disabled = fieldProps?.disabled ?? disabledProp;
const setInputRef = React.useCallback(
(node: HTMLInputElement | null) => {
if (node) {
Expand All @@ -37,20 +59,26 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(functi
<span
{...mergeStyleProps(
themeProps('checkbox', { size, disabled }),
stylex.props(reset.base, styles.root, sizes[size], indicatorSizes[size], xstyle),
stylex.props(reset.base, styles.root, sizes[size], horizontalField && firstLine[size], xstyle),
)}
>
<input
ref={setInputRef}
type='checkbox'
disabled={disabled}
required={fieldProps?.required ?? requiredProp}
id={fieldProps?.id ?? id}
aria-invalid={fieldProps?.['aria-invalid'] ?? ariaInvalid}
aria-labelledby={fieldProps?.['aria-labelledby'] ?? ariaLabelledBy}
aria-describedby={fieldProps?.['aria-describedby'] ?? ariaDescribedBy}
{...mergeStyleProps(
themeProps('checkbox-input'),
stylex.props(
reset.base,
focusOutline.visible,
styles.input,
styles.hitTarget,
horizontalField && styles.fieldHitTarget,
sizes[size],
checkboxInputMarker,
),
Expand All @@ -61,7 +89,7 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(functi
aria-hidden
{...mergeStyleProps(
themeProps('checkbox-indicator', { state: 'checked' }),
stylex.props(reset.base, styles.indicator, styles.checkedIndicator),
stylex.props(reset.base, styles.indicator, indicatorSizes[size], styles.checkedIndicator),
)}
>
<Icon
Expand All @@ -73,7 +101,7 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(functi
aria-hidden
{...mergeStyleProps(
themeProps('checkbox-indicator', { state: 'indeterminate' }),
stylex.props(reset.base, styles.indicator, styles.indeterminateIndicator),
stylex.props(reset.base, styles.indicator, indicatorSizes[size], styles.indeterminateIndicator),
)}
>
<Icon
Expand Down
9 changes: 7 additions & 2 deletions packages/mosaic/src/components/field/field.context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import React from 'react';
/** `label` for natively labelable controls; `span` for a control a `<label>` would activate on click. */
export type FieldLabelElementType = 'label' | 'span';

export type FieldOrientation = 'vertical' | 'horizontal';

interface RegisteredControl {
id: string | null;
labelElementType: FieldLabelElementType;
Expand All @@ -12,6 +14,7 @@ interface RegisteredControl {
interface FieldContextValue {
controlId: string;
labelElementType: FieldLabelElementType;
orientation: FieldOrientation;
disabled: boolean;
required: boolean;
invalid: boolean;
Expand All @@ -30,12 +33,13 @@ export function mergeIds(...values: Array<string | undefined>): string | undefin
}

interface FieldProviderProps extends React.PropsWithChildren {
orientation: FieldOrientation;
disabled: boolean;
required: boolean;
invalid: boolean;
}

export function FieldProvider({ children, disabled, required, invalid }: FieldProviderProps) {
export function FieldProvider({ children, orientation, disabled, required, invalid }: FieldProviderProps) {
const generatedId = React.useId();
const defaultControlId = `cl-field-${generatedId}`;
const [controlId, setControlId] = React.useState(defaultControlId);
Expand Down Expand Up @@ -73,6 +77,7 @@ export function FieldProvider({ children, disabled, required, invalid }: FieldPr
() => ({
controlId,
labelElementType,
orientation,
disabled,
required,
invalid,
Expand All @@ -82,7 +87,7 @@ export function FieldProvider({ children, disabled, required, invalid }: FieldPr
setLabelIds,
setMessageIds,
}),
[controlId, labelElementType, disabled, required, invalid, labelIds, messageIds, registerControlId],
[controlId, labelElementType, orientation, disabled, required, invalid, labelIds, messageIds, registerControlId],
);

return <FieldContext.Provider value={context}>{children}</FieldContext.Provider>;
Expand Down
12 changes: 12 additions & 0 deletions packages/mosaic/src/components/field/field.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ export const styles = stylex.create({
display: 'flex',
flexDirection: 'column',
},
horizontal: {
gap: space['2'],
alignItems: 'flex-start',
flexDirection: 'row',
},
content: {
gap: space['1'],
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
minWidth: 0,
},
label: {
color: colorVars['--cl-color-brand'],
fontWeight: fontWeightVars['--cl-font-medium'],
Expand Down
72 changes: 72 additions & 0 deletions packages/mosaic/src/components/field/field.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { hydrateRoot } from 'react-dom/client';
import { renderToString } from 'react-dom/server';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { Checkbox } from '../checkbox';
import { Input } from '../input';
import { Select } from '../select';
import { Field } from './field';
Expand All @@ -14,6 +15,7 @@ const overrides = stylex.create({
root: { display: 'grid' },
label: { fontWeight: 700 },
description: { opacity: 0.8 },
content: { gap: '4px' },
error: { fontWeight: 600 },
});

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

it('associates a checkbox laid out beside its label', async () => {
render(
<Field.Root
orientation='horizontal'
data-testid='root'
>
<Checkbox />
<Field.Content data-testid='content'>
<Field.Label>Sign out of all devices</Field.Label>
<Field.Description>Recommended after changing your password.</Field.Description>
</Field.Content>
</Field.Root>,
);

const checkbox = screen.getByRole('checkbox', { name: 'Sign out of all devices' });
expect(screen.getByTestId('root')).toHaveAttribute('data-orientation', 'horizontal');
expect(screen.getByTestId('content')).toHaveClass('cl-field-content');
expect(checkbox).toHaveAttribute(
'aria-describedby',
screen.getByText('Recommended after changing your password.').id,
);
await userEvent.click(screen.getByText('Sign out of all devices'));
expect(checkbox).toBeChecked();
});

it('toggles a checkbox wrapped in its label', async () => {
render(
<Field.Root>
<Field.Label>
<Checkbox />
Stay signed in
</Field.Label>
</Field.Root>,
);

await userEvent.click(screen.getByText('Stay signed in'));
expect(screen.getByRole('checkbox', { name: 'Stay signed in' })).toBeChecked();
});

it('propagates semantic state to a checkbox', () => {
render(
<Field.Root
disabled
required
invalid
>
<Checkbox />
</Field.Root>,
);

const checkbox = screen.getByRole('checkbox');
expect(checkbox).toBeDisabled();
expect(checkbox).toBeRequired();
expect(checkbox).toHaveAttribute('aria-invalid', 'true');
expect(checkbox.parentElement).toHaveAttribute('data-disabled');
});

it('propagates semantic state while preserving explicit control props', () => {
render(
<>
Expand Down Expand Up @@ -285,6 +344,7 @@ describe('Mosaic Field', () => {
const rootRef = React.createRef<HTMLDivElement>();
const labelRef = React.createRef<HTMLLabelElement>();
const descriptionRef = React.createRef<HTMLParagraphElement>();
const contentRef = React.createRef<HTMLDivElement>();
const errorRef = React.createRef<HTMLParagraphElement>();

render(
Expand All @@ -304,6 +364,10 @@ describe('Mosaic Field', () => {
>
Description
</Field.Description>
<Field.Content
ref={contentRef}
data-content='field'
/>
<Field.Error
ref={errorRef}
role='status'
Expand All @@ -316,6 +380,7 @@ describe('Mosaic Field', () => {
expect(rootRef.current).toHaveAttribute('data-root', 'field');
expect(labelRef.current).toHaveAttribute('for', 'name');
expect(descriptionRef.current).toHaveAttribute('title', 'Help');
expect(contentRef.current).toHaveAttribute('data-content', 'field');
expect(errorRef.current).toHaveAttribute('role', 'status');
});

Expand All @@ -327,11 +392,16 @@ describe('Mosaic Field', () => {
>
<Field.Label xstyle={overrides.label}>Email</Field.Label>
<Field.Description xstyle={overrides.description}>Description</Field.Description>
<Field.Content
xstyle={overrides.content}
data-testid='content'
/>
<Field.Error xstyle={overrides.error}>Error</Field.Error>
</Field.Root>,
);

expect(screen.getByTestId('root')).toHaveClass('cl-field-root', ...atoms(overrides.root));
expect(screen.getByTestId('content')).toHaveClass('cl-field-content', ...atoms(overrides.content));
expect(screen.getByText('Email')).toHaveClass('cl-field-label', ...atoms(overrides.label));
expect(screen.getByText('Description')).toHaveClass('cl-field-description', ...atoms(overrides.description));
expect(screen.getByText('Error').closest('p')).toHaveClass('cl-field-error', ...atoms(overrides.error));
Expand Down Expand Up @@ -365,12 +435,14 @@ describe('Mosaic Field', () => {
<Field.Root render={props => <section {...props} />}>
<Field.Label render={props => <label {...props} />}>Biography</Field.Label>
<Field.Description render={props => <div {...props} />}>Description</Field.Description>
<Field.Content render={props => <section {...props} />}>Content</Field.Content>
<Field.Error render={props => <div {...props} />}>Error</Field.Error>
</Field.Root>,
);

expect(screen.getByText('Biography').closest('section')).not.toBeNull();
expect(screen.getByText('Description').tagName).toBe('DIV');
expect(screen.getByText('Content').tagName).toBe('SECTION');
expect(screen.getByText('Error').closest('div')).toHaveClass('cl-field-error');
});

Expand Down
Loading
Loading