-
Notifications
You must be signed in to change notification settings - Fork 4.8k
Add Field primitives
#74190
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add Field primitives
#74190
Changes from all commits
9b7d4eb
a7d0462
acbec00
6de3303
08004df
9e6d5dc
b2b9b62
52139a7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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 } />; | ||
| } | ||
| ); |
| 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 } | ||
| /> | ||
| ); | ||
| } ); |
| 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> | ||
| </> | ||
| ); | ||
| } | ||
| ); |
| 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 }; |
| 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 } />; | ||
| } ); |
| 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 } | ||
| /> | ||
| ); | ||
| } | ||
| ); |
| 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 } | ||
| /> | ||
| ); | ||
| } ); |
| 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 | ||
| 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> | ||
| </> | ||
| ), | ||
| }, | ||
| }; | ||
| 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 ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 It will be a slight shift from |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
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.