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
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"dependencies": {
"@base-ui/react": "^1.0.0",
"@wordpress/element": "file:../element",
"@wordpress/i18n": "file:../i18n",
"clsx": "^2.1.1"
},
"devDependencies": {
Expand Down
9 changes: 9 additions & 0 deletions packages/ui/src/form/primitives/field/control.tsx
Original file line number Diff line number Diff line change
@@ -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 } />;
}
);
18 changes: 18 additions & 0 deletions packages/ui/src/form/primitives/field/description.tsx
Original file line number Diff line number Diff line change
@@ -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 }
/>
);
} );
36 changes: 36 additions & 0 deletions packages/ui/src/form/primitives/field/details.tsx
Original file line number Diff line number Diff line change
@@ -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={ <VisuallyHidden /> }>
{ __( 'More details follow the field.' ) }
</_Field.Description>
<div
ref={ ref }
className={ clsx( fieldStyles.description, className ) }
{ ...restProps }
></div>
</>
);
}
);
8 changes: 8 additions & 0 deletions packages/ui/src/form/primitives/field/index.ts
Original file line number Diff line number Diff line change
@@ -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 };
9 changes: 9 additions & 0 deletions packages/ui/src/form/primitives/field/item.tsx
Original file line number Diff line number Diff line change
@@ -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 } />;
} );
21 changes: 21 additions & 0 deletions packages/ui/src/form/primitives/field/label.tsx
Original file line number Diff line number Diff line change
@@ -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 }
/>
);
}
);
31 changes: 31 additions & 0 deletions packages/ui/src/form/primitives/field/root.tsx
Original file line number Diff line number Diff line change
@@ -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 > ) => (
<Stack { ...props } direction="column" gap="xs" />
);

/**
* 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 }
/>
);
} );
137 changes: 137 additions & 0 deletions packages/ui/src/form/primitives/field/stories/index.story.tsx
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll want to keep an eye on when this merges in relation to #74143, since I believe we'll need to remove these after the upgrade.

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: (
<>
<Field.Label>Label</Field.Label>
<Field.Control
render={ <input type="text" placeholder="Placeholder" /> }
/>
<Field.Description>
The accessible description.
</Field.Description>
</>
),
},
};

const MyNonRefForwardingControl = (
props: React.ComponentProps< 'input' >
) => {
return <input type="text" { ...props } />;
};

/**
* 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 (
<Field.Root { ...args }>
<Field.Label htmlFor={ controlId }>Label</Field.Label>
<MyNonRefForwardingControl
placeholder="Placeholder"
id={ controlId }
aria-describedby={ descriptionId }
/>
<Field.Description id={ descriptionId }>
The accessible description.
</Field.Description>
</Field.Root>
);
},
};

/**
* 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 (
<Field.Root { ...args }>
<Field.Label id={ labelId }>Label</Field.Label>
<MyNonRefForwardingControl
placeholder="Placeholder"
aria-labelledby={ labelId }
aria-describedby={ descriptionId }
/>
<Field.Description id={ descriptionId }>
The accessible description.
</Field.Description>{ ' ' }
</Field.Root>
);
},
};

/**
* 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: (
<>
<Field.Label>Label</Field.Label>
<Field.Control
render={ <input type="text" placeholder="Placeholder" /> }
/>
<Field.Details>
Details can include{ ' ' }
<a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/a">
links to more information
</a>{ ' ' }
and other semantic elements.
</Field.Details>
</>
),
},
};
36 changes: 36 additions & 0 deletions packages/ui/src/form/primitives/field/test/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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.Root ref={ rootRef } name="test-field">
<Field.Item ref={ itemRef }>
<Field.Label ref={ labelRef }>Field Label</Field.Label>
<Field.Control ref={ controlRef } render={ <input /> } />
<Field.Description ref={ descriptionRef }>
Field description
</Field.Description>
<Field.Details ref={ detailsRef }>
Field <a href="#details">details</a>
</Field.Details>
</Field.Item>
</Field.Root>
);

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 );
} );
} );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
} );
} );
it( 'associates label with control', () => {
render(
<Field.Root name="accessible-field">
<Field.Label>Username</Field.Label>
<Field.Control render={ <input /> } />
</Field.Root>
);
expect(
screen.getByRole( 'textbox', { name: 'Username' } )
).toBeInTheDocument();
} );
it( 'renders disabled state', () => {
render(
<Field.Root name="disabled-field" disabled>
<Field.Label>Disabled Field</Field.Label>
<Field.Control render={ <input /> } />
</Field.Root>
);
expect( screen.getByRole( 'textbox' ) ).toBeDisabled();
} );
it( 'applies custom className', () => {
render(
<Field.Root name="custom-field" className="custom-root">
<Field.Label className="custom-label">Label</Field.Label>
<Field.Control render={ <input /> } />
</Field.Root>
);
expect(
screen.getByRole( 'textbox', { name: 'Label' } )
).toBeInTheDocument();
expect( screen.getByText( 'Label' ) ).toHaveClass( 'custom-label' );
} );

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've omitted these kinds of basic tests here, because it would be testing the central functionality that the underlying library should be providing (and no doubt has its own tests for).

Having a foundational library like this to build from, I think our testing strategy should focus more on integration points, where there's a higher chance of failure or regression. For example, our RadioControl replacement will be composed with these Field components, and it would be good to test for correct labeling there. I've been experimenting with this library for months now, and that's where the cost–benefit balance for tests seemed to be. In a sense, it better matches real-life usage, with broader coverage across element types.

It will be a slight shift from @wordpress/components, where we often needed to have many tests for library behavior, specifically because we would be responsible for any back compat. The @wordpress/ui package lets go of this burden, because it will be a versioned and bundle package, like @wordpress/dataviews.

Loading
Loading