diff --git a/package-lock.json b/package-lock.json index be1ce4e0268ad4..3f6503c88060d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56199,6 +56199,7 @@ "dependencies": { "@base-ui/react": "^1.0.0", "@wordpress/element": "file:../element", + "@wordpress/i18n": "file:../i18n", "clsx": "^2.1.1" }, "devDependencies": { diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index db3b650f1e42c9..28fb2b0d196bea 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -10,3 +10,4 @@ - Add `Stack` component ([#73928](https://github.com/WordPress/gutenberg/pull/73928)). - Add `VisuallyHidden` component ([#74189](https://github.com/WordPress/gutenberg/pull/74189)). +- Add `Field` primitives ([#74190](https://github.com/WordPress/gutenberg/pull/74190)). diff --git a/packages/ui/package.json b/packages/ui/package.json index f0be6636430a18..c5ae8d2265e491 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -46,6 +46,7 @@ "dependencies": { "@base-ui/react": "^1.0.0", "@wordpress/element": "file:../element", + "@wordpress/i18n": "file:../i18n", "clsx": "^2.1.1" }, "devDependencies": { diff --git a/packages/ui/src/form/primitives/field/control.tsx b/packages/ui/src/form/primitives/field/control.tsx new file mode 100644 index 00000000000000..291522ccc7ccbf --- /dev/null +++ b/packages/ui/src/form/primitives/field/control.tsx @@ -0,0 +1,9 @@ +import { Field as _Field } from '@base-ui/react/field'; +import { forwardRef } from '@wordpress/element'; +import type { FieldControlProps } from './types'; + +export const Control = forwardRef< HTMLInputElement, FieldControlProps >( + function Control( props, ref ) { + return <_Field.Control ref={ ref } { ...props } />; + } +); diff --git a/packages/ui/src/form/primitives/field/description.tsx b/packages/ui/src/form/primitives/field/description.tsx new file mode 100644 index 00000000000000..9ecef06d862214 --- /dev/null +++ b/packages/ui/src/form/primitives/field/description.tsx @@ -0,0 +1,18 @@ +import clsx from 'clsx'; +import { Field as _Field } from '@base-ui/react/field'; +import { forwardRef } from '@wordpress/element'; +import fieldStyles from '../../../utils/css/field.module.css'; +import type { FieldDescriptionProps } from './types'; + +export const Description = forwardRef< + HTMLParagraphElement, + FieldDescriptionProps +>( function Description( { className, ...restProps }, ref ) { + return ( + <_Field.Description + ref={ ref } + className={ clsx( fieldStyles.description, className ) } + { ...restProps } + /> + ); +} ); diff --git a/packages/ui/src/form/primitives/field/details.tsx b/packages/ui/src/form/primitives/field/details.tsx new file mode 100644 index 00000000000000..f30725b9e3f142 --- /dev/null +++ b/packages/ui/src/form/primitives/field/details.tsx @@ -0,0 +1,36 @@ +import clsx from 'clsx'; +import { Field as _Field } from '@base-ui/react/field'; +import { forwardRef } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import fieldStyles from '../../../utils/css/field.module.css'; +import type { FieldDetailsProps } from './types'; +import { VisuallyHidden } from '../../../visually-hidden'; + +/** + * A component for showing additional information about the field, + * styled similarly to a normal `Field.Description`. + * Unlike a normal description, it can include links and other semantic elements. + * + * Although this content is not associated with the field using direct semantics, + * it is made discoverable to screen reader users via a visually hidden description, + * alerting them to the presence of additional information below. + * + * If the content only includes plain text, use `Field.Description` instead, + * so the readout is not unnecessarily verbose for screen reader users. + */ +export const Details = forwardRef< HTMLDivElement, FieldDetailsProps >( + function Details( { className, ...restProps }, ref ) { + return ( + <> + <_Field.Description render={ }> + { __( 'More details follow the field.' ) } + +
+ + ); + } +); diff --git a/packages/ui/src/form/primitives/field/index.ts b/packages/ui/src/form/primitives/field/index.ts new file mode 100644 index 00000000000000..a2a2ce945dd7d1 --- /dev/null +++ b/packages/ui/src/form/primitives/field/index.ts @@ -0,0 +1,8 @@ +import { Root } from './root'; +import { Item } from './item'; +import { Label } from './label'; +import { Description } from './description'; +import { Details } from './details'; +import { Control } from './control'; + +export { Root, Item, Label, Description, Details, Control }; diff --git a/packages/ui/src/form/primitives/field/item.tsx b/packages/ui/src/form/primitives/field/item.tsx new file mode 100644 index 00000000000000..e2430b1967d93f --- /dev/null +++ b/packages/ui/src/form/primitives/field/item.tsx @@ -0,0 +1,9 @@ +import { Field as _Field } from '@base-ui/react/field'; +import { forwardRef } from '@wordpress/element'; +import type { FieldItemProps } from './types'; + +export const Item: React.ForwardRefExoticComponent< + FieldItemProps & React.RefAttributes< HTMLDivElement > +> = forwardRef( function Item( props, ref ) { + return <_Field.Item ref={ ref } { ...props } />; +} ); diff --git a/packages/ui/src/form/primitives/field/label.tsx b/packages/ui/src/form/primitives/field/label.tsx new file mode 100644 index 00000000000000..d85560b8117bd5 --- /dev/null +++ b/packages/ui/src/form/primitives/field/label.tsx @@ -0,0 +1,21 @@ +import clsx from 'clsx'; +import { Field as _Field } from '@base-ui/react/field'; +import { forwardRef } from '@wordpress/element'; +import fieldStyles from '../../../utils/css/field.module.css'; +import type { FieldLabelProps } from './types'; + +export const Label = forwardRef< HTMLLabelElement, FieldLabelProps >( + function Label( { className, variant, ...restProps }, ref ) { + return ( + <_Field.Label + ref={ ref } + className={ clsx( + fieldStyles.label, + variant && fieldStyles[ `is-${ variant }` ], + className + ) } + { ...restProps } + /> + ); + } +); diff --git a/packages/ui/src/form/primitives/field/root.tsx b/packages/ui/src/form/primitives/field/root.tsx new file mode 100644 index 00000000000000..b924384437f868 --- /dev/null +++ b/packages/ui/src/form/primitives/field/root.tsx @@ -0,0 +1,31 @@ +import clsx from 'clsx'; +import { Field as _Field } from '@base-ui/react/field'; +import { forwardRef } from '@wordpress/element'; +import resetStyles from '../../../utils/css/resets.module.css'; +import type { FieldRootProps } from './types'; +import { Stack } from '../../../stack'; + +const DEFAULT_RENDER = ( props: React.ComponentProps< typeof Stack > ) => ( + +); + +/** + * A low-level component that associates an accessible label and description + * with a single form control element. + * + * Simply wrapping a control with this component does not guarantee + * accessible labeling. See examples for how to associate the label in different cases. + */ +export const Root = forwardRef< HTMLDivElement, FieldRootProps >( function Root( + { className, render = DEFAULT_RENDER, ...restProps }, + ref +) { + return ( + <_Field.Root + ref={ ref } + className={ clsx( resetStyles[ 'box-sizing' ], className ) } + render={ render } + { ...restProps } + /> + ); +} ); diff --git a/packages/ui/src/form/primitives/field/stories/index.story.tsx b/packages/ui/src/form/primitives/field/stories/index.story.tsx new file mode 100644 index 00000000000000..36065859aa611b --- /dev/null +++ b/packages/ui/src/form/primitives/field/stories/index.story.tsx @@ -0,0 +1,137 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useId } from '@wordpress/element'; +import '@wordpress/theme/design-tokens.css'; +import { Field } from '../../../..'; + +const meta: Meta< typeof Field.Root > = { + title: 'Design System/Components/Form/Primitives/Field', + component: Field.Root, + subcomponents: { + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + Item: Field.Item, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + Label: Field.Label, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + Control: Field.Control, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + Description: Field.Description, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + Details: Field.Details, + }, +}; +export default meta; + +/** + * If your control component forwards refs, as well as the `aria-labelledby` and `aria-describedby` props + * to the actual underlying HTML element to be labeled, + * you can simply place your control in the `render` prop of `Field.Control`. + */ +export const Default: StoryObj< typeof Field.Root > = { + args: { + children: ( + <> + Label + } + /> + + The accessible description. + + + ), + }, +}; + +const MyNonRefForwardingControl = ( + props: React.ComponentProps< 'input' > +) => { + return ; +}; + +/** + * If your control component does not forward refs, but does forward the `id` prop + * to the actual underlying HTML element to be labeled, use the `htmlFor` prop + * of the `Field.Label` component to associate the label with the control. + * + * This is preferred over `aria-labelledby` because it allows users to click the + * label to focus the control. + */ +export const UsingHtmlFor: StoryObj< typeof Field.Root > = { + name: 'Using htmlFor', + render: ( args ) => { + const controlId = useId(); + const descriptionId = useId(); + + return ( + + Label + + + The accessible description. + + + ); + }, +}; + +/** + * If your control component does not forward refs nor the `id` prop, but does + * forward the `aria-labelledby` prop to the actual underlying HTML element to be + * labeled, use the `id` prop of the `Field.Label` component to associate the + * label with the control. + */ +export const UsingAriaLabelledby: StoryObj< typeof Field.Root > = { + name: 'Using aria-labelledby', + render: ( args ) => { + const labelId = useId(); + const descriptionId = useId(); + + return ( + + Label + + + The accessible description. + { ' ' } + + ); + }, +}; + +/** + * To add rich content (such as links) to the description, use `Field.Details`. + * + * Although this content is not associated with the field using direct semantics, + * it is made discoverable to screen reader users via a visually hidden description, + * alerting them to the presence of additional information below. + * + * If the content only includes plain text, use `Field.Description` instead, + * so the readout is not unnecessarily verbose for screen reader users. + */ +export const WithDetails: StoryObj< typeof Field.Root > = { + args: { + children: ( + <> + Label + } + /> + + Details can include{ ' ' } + + links to more information + { ' ' } + and other semantic elements. + + + ), + }, +}; diff --git a/packages/ui/src/form/primitives/field/test/index.test.tsx b/packages/ui/src/form/primitives/field/test/index.test.tsx new file mode 100644 index 00000000000000..4363826d12e527 --- /dev/null +++ b/packages/ui/src/form/primitives/field/test/index.test.tsx @@ -0,0 +1,36 @@ +import { render } from '@testing-library/react'; +import { createRef } from '@wordpress/element'; +import * as Field from '../index'; + +describe( 'Field', () => { + it( 'forwards ref', () => { + const rootRef = createRef< HTMLDivElement >(); + const itemRef = createRef< HTMLDivElement >(); + const controlRef = createRef< HTMLInputElement >(); + const labelRef = createRef< HTMLLabelElement >(); + const descriptionRef = createRef< HTMLParagraphElement >(); + const detailsRef = createRef< HTMLDivElement >(); + + render( + + + Field Label + } /> + + Field description + + + Field details + + + + ); + + expect( rootRef.current ).toBeInstanceOf( HTMLDivElement ); + expect( itemRef.current ).toBeInstanceOf( HTMLDivElement ); + expect( controlRef.current ).toBeInstanceOf( HTMLInputElement ); + expect( labelRef.current ).toBeInstanceOf( HTMLLabelElement ); + expect( descriptionRef.current ).toBeInstanceOf( HTMLParagraphElement ); + expect( detailsRef.current ).toBeInstanceOf( HTMLDivElement ); + } ); +} ); diff --git a/packages/ui/src/form/primitives/field/types.ts b/packages/ui/src/form/primitives/field/types.ts new file mode 100644 index 00000000000000..30bd8bd35ed4a8 --- /dev/null +++ b/packages/ui/src/form/primitives/field/types.ts @@ -0,0 +1,82 @@ +import type { Field } from '@base-ui/react/field'; +import type { ComponentProps } from '../../../utils/types'; + +export type FieldRootProps = Omit< + ComponentProps< typeof Field.Root >, + | 'disabled' + // TODO: Maybe allow these when we have validation support ready. + | 'dirty' + | 'invalid' + | 'touched' + | 'validate' + | 'validationDebounceTime' + | 'validationMode' +> & { + children?: Field.Root.Props[ 'children' ]; + /** + * Whether the field is disabled. + * + * @default false + */ + disabled?: Field.Root.Props[ 'disabled' ]; +}; + +export type FieldItemProps = ComponentProps< typeof Field.Item > & { + children?: React.ReactNode; +}; + +export type FieldLabelProps = ComponentProps< typeof Field.Label > & { + /** + * The label string, or the string and the element to associate it with. + * + * To keep things accessible, do not include other interactive + * elements such as links or buttons. + */ + children?: Field.Label.Props[ 'children' ]; + /** + * The visual variant of the label. + * + * Use 'plain' for controls like checkboxes and radio buttons. + * + * @default 'default' + */ + variant?: 'default' | 'plain'; +}; + +export type FieldControlProps = Omit< + ComponentProps< typeof Field.Control >, + 'defaultValue' +> & { + children?: Field.Control.Props[ 'children' ]; + /** + * The default value to use in uncontrolled mode. + */ + defaultValue?: Field.Control.Props[ 'defaultValue' ]; +}; + +export type FieldDescriptionProps = ComponentProps< + typeof Field.Description +> & { + /** + * The accessible description, associated using `aria-describedby`. + * + * For screen reader accessibility, this should only contain plain text, + * and no semantics such as links. + */ + children?: string; +}; + +export type FieldDetailsProps = ComponentProps< 'div' > & { + /** + * Additional information about the field, which unlike a normal description, + * can include links and other semantic elements. + * + * Although this content is not associated with the field using direct semantics, + * it is made discoverable to screen reader users via a visually hidden description, + * alerting them to the presence of additional information below. + * + * Do not use this component when the details content is only plain text, + * as it makes the readout unnecessarily verbose for screen reader users. + */ + children?: React.ReactNode; +}; diff --git a/packages/ui/src/form/primitives/index.ts b/packages/ui/src/form/primitives/index.ts new file mode 100644 index 00000000000000..090e4e93b84d07 --- /dev/null +++ b/packages/ui/src/form/primitives/index.ts @@ -0,0 +1 @@ +export * as Field from './field'; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 2f06a9456b4aa0..8efe7bdc93fcaa 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,4 +1,5 @@ export * from './box'; export * from './badge'; +export * from './form/primitives'; export * from './stack'; export * from './visually-hidden'; diff --git a/packages/ui/src/utils/css/field.module.css b/packages/ui/src/utils/css/field.module.css new file mode 100644 index 00000000000000..dd5c15f1f8142f --- /dev/null +++ b/packages/ui/src/utils/css/field.module.css @@ -0,0 +1,27 @@ +@layer wp-ui-utilities, wp-ui-components, wp-ui-compositions, wp-ui-overrides; + +@layer wp-ui-utilities { + .label { + --wp-ui-field-label-line-height: var(--wpds-font-line-height-x-small); + + font-family: var(--wpds-font-family-body); + font-size: var(--wpds-font-size-x-small); + line-height: var(--wp-ui-field-label-line-height); + font-weight: 499; /* TODO: Use variable? */ + text-transform: uppercase; + color: var(--wpds-color-fg-content-neutral); + + &.is-plain { + font-size: var(--wpds-font-size-medium); + text-transform: none; + } + } + + .description { + margin: 0; + font-family: var(--wpds-font-family-body); + font-size: var(--wpds-font-size-small); + line-height: var(--wpds-font-line-height-x-small); + color: var(--wpds-color-fg-content-neutral-weak); + } +} diff --git a/packages/ui/src/utils/css/resets.module.css b/packages/ui/src/utils/css/resets.module.css new file mode 100644 index 00000000000000..4c003ee93f30ed --- /dev/null +++ b/packages/ui/src/utils/css/resets.module.css @@ -0,0 +1,13 @@ +@layer wp-ui-utilities, wp-ui-components, wp-ui-compositions, wp-ui-overrides; + +@layer wp-ui-utilities { + .box-sizing { + box-sizing: border-box; + + *, + *::before, + *::after { + box-sizing: inherit; + } + } +} diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index a7d445ffa2cbbb..b853d74120bdfa 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -4,6 +4,10 @@ "compilerOptions": { "types": [ "node", "jest", "@testing-library/jest-dom" ] }, - "references": [ { "path": "../element" }, { "path": "../theme" } ], + "references": [ + { "path": "../element" }, + { "path": "../i18n" }, + { "path": "../theme" } + ], "exclude": [] }