diff --git a/.changeset/mosaic-field-horizontal-checkbox.md b/.changeset/mosaic-field-horizontal-checkbox.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-field-horizontal-checkbox.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/checkbox/checkbox.styles.ts b/packages/mosaic/src/components/checkbox/checkbox.styles.ts index ad42d4e4557..38c9a7babc2 100644 --- a/packages/mosaic/src/components/checkbox/checkbox.styles.ts +++ b/packages/mosaic/src/components/checkbox/checkbox.styles.ts @@ -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', @@ -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'] }, diff --git a/packages/mosaic/src/components/checkbox/checkbox.tsx b/packages/mosaic/src/components/checkbox/checkbox.tsx index a5e25adb302..58e7cce643b 100644 --- a/packages/mosaic/src/components/checkbox/checkbox.tsx +++ b/packages/mosaic/src/components/checkbox/checkbox.tsx @@ -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, 'type' | 'size' | 'className' | 'style'>, MosaicStyleProps { @@ -16,9 +17,30 @@ export interface CheckboxProps } export const Checkbox = React.forwardRef(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) { @@ -37,13 +59,18 @@ export const Checkbox = React.forwardRef(functi (functi focusOutline.visible, styles.input, styles.hitTarget, + horizontalField && styles.fieldHitTarget, sizes[size], checkboxInputMarker, ), @@ -61,7 +89,7 @@ export const Checkbox = React.forwardRef(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), )} > (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), )} > ` would activate on click. */ export type FieldLabelElementType = 'label' | 'span'; +export type FieldOrientation = 'vertical' | 'horizontal'; + interface RegisteredControl { id: string | null; labelElementType: FieldLabelElementType; @@ -12,6 +14,7 @@ interface RegisteredControl { interface FieldContextValue { controlId: string; labelElementType: FieldLabelElementType; + orientation: FieldOrientation; disabled: boolean; required: boolean; invalid: boolean; @@ -30,12 +33,13 @@ export function mergeIds(...values: Array): 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); @@ -73,6 +77,7 @@ export function FieldProvider({ children, disabled, required, invalid }: FieldPr () => ({ controlId, labelElementType, + orientation, disabled, required, invalid, @@ -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 {children}; diff --git a/packages/mosaic/src/components/field/field.styles.ts b/packages/mosaic/src/components/field/field.styles.ts index 725113a9037..01a7147ceaf 100644 --- a/packages/mosaic/src/components/field/field.styles.ts +++ b/packages/mosaic/src/components/field/field.styles.ts @@ -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'], diff --git a/packages/mosaic/src/components/field/field.test.tsx b/packages/mosaic/src/components/field/field.test.tsx index 8d5a1e61c10..22572b7b628 100644 --- a/packages/mosaic/src/components/field/field.test.tsx +++ b/packages/mosaic/src/components/field/field.test.tsx @@ -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'; @@ -14,6 +15,7 @@ const overrides = stylex.create({ root: { display: 'grid' }, label: { fontWeight: 700 }, description: { opacity: 0.8 }, + content: { gap: '4px' }, error: { fontWeight: 600 }, }); @@ -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( + + + + Sign out of all devices + Recommended after changing your password. + + , + ); + + 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( + + + + Stay signed in + + , + ); + + 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( + + + , + ); + + 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( <> @@ -285,6 +344,7 @@ describe('Mosaic Field', () => { const rootRef = React.createRef(); const labelRef = React.createRef(); const descriptionRef = React.createRef(); + const contentRef = React.createRef(); const errorRef = React.createRef(); render( @@ -304,6 +364,10 @@ describe('Mosaic Field', () => { > Description + { 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'); }); @@ -327,11 +392,16 @@ describe('Mosaic Field', () => { > Email Description + Error , ); 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)); @@ -365,12 +435,14 @@ describe('Mosaic Field', () => {
}>
}>Description +
}>Content
}>Error , ); 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'); }); diff --git a/packages/mosaic/src/components/field/field.tsx b/packages/mosaic/src/components/field/field.tsx index 28b5c1470b0..6f792681cda 100644 --- a/packages/mosaic/src/components/field/field.tsx +++ b/packages/mosaic/src/components/field/field.tsx @@ -11,6 +11,7 @@ import { feedbackHeight, feedbackStyles } from '../../utils/feedback.styles'; import { reset } from '../../utils/reset.styles'; import { sizes as typographySizes, styles as typographyStyles } from '../../utils/typography.styles'; import { visuallyHidden } from '../../utils/visually-hidden.styles'; +import type { FieldOrientation } from './field.context'; import { FieldMessageProvider, FieldProvider, @@ -30,13 +31,15 @@ function useNativeLabelWarning(label: HTMLElement | null) { /** Props for a field container that associates exactly one form control. */ export interface FieldRootProps extends MosaicComponentProps<'div'> { + /** `horizontal` places the control beside a `Field.Content` that stacks the label and supporting text. */ + orientation?: FieldOrientation; disabled?: boolean; required?: boolean; invalid?: boolean; } const Root = React.forwardRef(function MosaicFieldRoot( - { render, xstyle, disabled = false, required = false, invalid = false, ...rest }, + { render, xstyle, orientation = 'vertical', disabled = false, required = false, invalid = false, ...rest }, ref, ) { const element = useRender({ @@ -44,12 +47,22 @@ const Root = React.forwardRef(function MosaicFie render, ref, props: { - ...mergeStyleProps(themeProps('field-root'), stylex.props(reset.base, styles.root, xstyle), rest), + ...mergeStyleProps( + themeProps('field-root', { orientation }), + stylex.props( + reset.base, + styles.root, + orientation === 'horizontal' && [typographySizes.sm, styles.horizontal], + xstyle, + ), + rest, + ), }, }); return ( (function MosaicFiel }); }); +/** Props for the container that stacks a horizontal field's label and supporting text. */ +export type FieldContentProps = MosaicComponentProps<'div'>; + +const Content = React.forwardRef(function MosaicFieldContent( + { render, xstyle, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: mergeStyleProps(themeProps('field-content'), stylex.props(reset.base, styles.content, xstyle), rest), + }); +}); + /** Props for supporting field text. */ export type FieldDescriptionProps = MosaicComponentProps<'p'>; @@ -244,4 +272,4 @@ const FieldSuccess = React.forwardRef(f }); /** Styled parts for composing an automatically associated single-control field. */ -export const Field = { Root, Label, Description, Message, Error: FieldError, Success: FieldSuccess }; +export const Field = { Root, Label, Content, Description, Message, Error: FieldError, Success: FieldSuccess }; diff --git a/packages/swingset/src/stories/field.component.mdx b/packages/swingset/src/stories/field.component.mdx index a0b2ef64be4..0c6db42e283 100644 --- a/packages/swingset/src/stories/field.component.mdx +++ b/packages/swingset/src/stories/field.component.mdx @@ -79,12 +79,32 @@ through `aria-labelledby` rather than `htmlFor`. Description and error relations Explicit `htmlFor` still renders a native `label`. `Select.Trigger` opts in; `Input` and `OTP` keep the native label. +### Horizontal + + + +Pass `orientation='horizontal'` to `Field.Root` to place the control beside its label, as with a `Checkbox`. Wrap the label, description, and messages in `Field.Content` so they stack beside the control. A `Checkbox` aligns with the first line of the label. + +```tsx + + + + Sign out of all devices + Recommended after changing your password. + + +``` + ## Parts | Part | Stable slot class | Description | | ------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Field.Root` | `.cl-field-root` | Unstyled `div` and field context provider. | +| `Field.Root` | `.cl-field-root` | `div` that stacks its parts, or places the control beside `Field.Content` when horizontal, and provides the field context. | | `Field.Label` | `.cl-field-label` | Native `label` associated with the field control; a `span` that focuses the control when it cannot be natively labelled, such as a `Select` trigger. | +| `Field.Content` | `.cl-field-content` | `div` that stacks the label, description, and messages beside the control in a horizontal field. | | `Field.Description` | `.cl-field-description` | Supporting `p` associated with the field control. | | `Field.Message` | `.cl-field-message` | Height-animated live region for the messages below. | | `Field.Error` | `.cl-field-error` | Associated error `p` with an alert icon. | @@ -104,4 +124,4 @@ The Mosaic field is themed with **StyleX**. Each styled part carries the stable } ``` -`Field.Root` ships no layout. Higher-level blocks own how its parts are arranged; for example, a settings row can provide the grid and alignment for a field. Customize an `Input` through its exposed tokens, `.cl-input`, `className`, and `style`; Field does not add control-specific styling. +`Field.Root` only stacks its parts, or places the control beside `Field.Content` when horizontal; `data-orientation` reflects which. Higher-level blocks own any other arrangement; for example, a settings row can provide the grid and alignment for a field. Customize an `Input` through its exposed tokens, `.cl-input`, `className`, and `style`; Field does not add control-specific styling. diff --git a/packages/swingset/src/stories/field.component.stories.tsx b/packages/swingset/src/stories/field.component.stories.tsx index 3b49a98e152..1eaca9a9427 100644 --- a/packages/swingset/src/stories/field.component.stories.tsx +++ b/packages/swingset/src/stories/field.component.stories.tsx @@ -1,4 +1,5 @@ import { Button } from '@clerk/mosaic/components/button'; +import { Checkbox } from '@clerk/mosaic/components/checkbox'; import { Field } from '@clerk/mosaic/components/field'; import { Input } from '@clerk/mosaic/components/input'; import { Select } from '@clerk/mosaic/components/select'; @@ -157,3 +158,28 @@ export function WithSelect() { ); } + +export function Horizontal() { + return ( +
+ + + + Sign out of all devices + + It is recommended to sign out of all other devices which may have used your old password. + + + + + + + Send me product updates, feature announcements, and occasional offers by email + + +
+ ); +}