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 packages/ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

### Enhancements

- `Autocomplete.Status`, `Combobox.Status`: Add a `Status` subcomponent that announces async list status to screen readers. Item popups give Status its own collapsing grid row so a visible result count sits above the list without overlaying items ([#82195](https://github.com/WordPress/gutenberg/pull/82195)).
- `Autocomplete.Popup`, `Combobox.Popup`, `SearchableChipSelect`, `SearchableChipSelectControl`, `Select.Popup`, and `SelectControl`: Add `width` on Popups and `popupWidth` on composites, with preset width constraints (`anchor`, `content`, `sm`, `md`, `lg`, and `available`) for the item popup. `SelectControl` defaults to `content` to preserve prior popup sizing ([#82087](https://github.com/WordPress/gutenberg/pull/82087), [#82193](https://github.com/WordPress/gutenberg/pull/82193)).
- `Menu`: Use `Text` for item labels and descriptions to share typography and apply `text-wrap: pretty`. ([#82237](https://github.com/WordPress/gutenberg/pull/82237))
- `Text`: Apply `text-wrap: pretty` by default to improve line wrapping. ([#82133](https://github.com/WordPress/gutenberg/pull/82133))
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/form/primitives/autocomplete/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ export { Portal } from './portal';
export { Positioner } from './positioner';
export { Root } from './root';
export { Row } from './row';
export { Status } from './status';
export { Value } from './value';
25 changes: 25 additions & 0 deletions packages/ui/src/form/primitives/autocomplete/status.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Autocomplete as _Autocomplete } from '@base-ui/react/autocomplete';
import clsx from 'clsx';
import { forwardRef } from '@wordpress/element';
import type { AutocompleteStatusProps } from './types';
import itemPopupStyles from '../../../utils/css/item-popup.module.css';

/**
* Announces a status message politely to screen readers. Use it for the
* status of an asynchronously loaded list.
*
* Keep this element mounted. Do not hide it with `display: none`, `hidden`,
* `aria-hidden`, or by omitting the component. Change or omit the children
* instead.
*/
export const Status = forwardRef< HTMLDivElement, AutocompleteStatusProps >(
function Status( { className, ...restProps }, ref ) {
return (
<_Autocomplete.Status
className={ clsx( itemPopupStyles.status, className ) }
ref={ ref }
{ ...restProps }
/>
);
}
);
178 changes: 133 additions & 45 deletions packages/ui/src/form/primitives/autocomplete/stories/index.story.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
import type { Meta, StoryObj } from '@storybook/react-vite';
import type { CSSProperties } from 'react';
import { useRef, useState } from '@wordpress/element';
import { search } from '@wordpress/icons';
import * as Autocomplete from '../index';
import { Icon } from '../../../../icon';
import { Spinner } from '../../../../spinner';
import { Stack } from '../../../../stack';
import { VisuallyHidden } from '../../../../visually-hidden';
import { Input } from '../../input';
import { InputLayout } from '../../input-layout';
import {
Expand Down Expand Up @@ -35,6 +39,7 @@ const meta: Meta< typeof Autocomplete.Root > = {
'Autocomplete.Row': Autocomplete.Row,
'Autocomplete.Value': Autocomplete.Value,
'Autocomplete.Empty': Autocomplete.Empty,
'Autocomplete.Status': Autocomplete.Status,
'Autocomplete.Clear': Autocomplete.Clear,
},
parameters: {
Expand Down Expand Up @@ -126,54 +131,137 @@ export const OpenOnlyOnMatch: Story = {
},
};

function getStatusChildren( {
loading,
count,
visibleCount,
}: {
loading: boolean;
count: number;
visibleCount: boolean;
} ) {
if ( loading ) {
return (
<Stack direction="row" gap="sm" align="center">
<Spinner />
Loading…
</Stack>
);
}

if ( count === 0 ) {
return null;
}

const message =
count === 1 ? '1 result found.' : `${ count } results found.`;

if ( visibleCount ) {
return message;
}

return <VisuallyHidden>{ message }</VisuallyHidden>;
}

function AsyncStatus( {
loading,
visibleCount,
}: {
loading: boolean;
visibleCount: boolean;
} ) {
const filteredItems = BaseAutocomplete.useFilteredItems< FixtureItem >();

return (
<Autocomplete.Status>
{ getStatusChildren( {
loading,
count: filteredItems.length,
visibleCount,
} ) }
</Autocomplete.Status>
);
}

function AsyncItemsTemplate( {
args,
visibleCount,
}: {
args: Story[ 'args' ];
visibleCount: boolean;
} ) {
const [ query, setQuery ] = useState( '' );
const [ loading, setLoading ] = useState( false );
const [ results, setResults ] = useState< typeof URLS >( [] );
const timeoutRef = useRef< ReturnType< typeof setTimeout > >();

return (
<Autocomplete.Root
{ ...args }
items={ results }
value={ query }
onValueChange={ ( newValue ) => {
setQuery( newValue );
setLoading( true );
setResults( [] );
clearTimeout( timeoutRef.current );
timeoutRef.current = setTimeout( () => {
setResults(
URLS.filter( ( item ) =>
item.value
.toLowerCase()
.includes( newValue.toLowerCase() )
)
);
setLoading( false );
}, 500 );
} }
>
<Autocomplete.Input placeholder="Enter a URL" />
<Autocomplete.Popup>
<AsyncStatus
loading={ loading }
visibleCount={ visibleCount }
/>
<Autocomplete.Empty>
{ loading ? null : 'No matching items.' }
</Autocomplete.Empty>
<Autocomplete.List>
<Autocomplete.ListBody>
<Autocomplete.Collection>
{ ( item: FixtureItem ) => (
<Autocomplete.Item
key={ item.id }
value={ item }
>
{ item.value }
</Autocomplete.Item>
) }
</Autocomplete.Collection>
</Autocomplete.ListBody>
</Autocomplete.List>
</Autocomplete.Popup>
</Autocomplete.Root>
);
}

/**
* Fetches matching items asynchronously. Keep `Status` mounted. It shows
* loading, then a visually hidden result count. Use `Empty` for no results.
*/
export const AsyncItems: Story = {
render: function Template( args ) {
const [ query, setQuery ] = useState( '' );
const [ loading, setLoading ] = useState( false );
const [ results, setResults ] = useState< typeof URLS >( [] );
return <AsyncItemsTemplate args={ args } visibleCount={ false } />;
},
};

return (
<Autocomplete.Root
{ ...args }
items={ results }
value={ query }
onValueChange={ ( newValue ) => {
setQuery( newValue );
setLoading( true );
setTimeout( () => {
setResults(
URLS.filter( ( item ) =>
item.value
.toLowerCase()
.includes( newValue.toLowerCase() )
)
);
setLoading( false );
}, 500 );
} }
>
<Autocomplete.Input placeholder="Enter a URL" />
<Autocomplete.Popup>
<Autocomplete.Empty>
{ loading ? 'Loading...' : 'No matching items.' }
</Autocomplete.Empty>
<Autocomplete.List>
<Autocomplete.ListBody>
<Autocomplete.Collection>
{ ( item: FixtureItem ) => (
<Autocomplete.Item
key={ item.id }
value={ item }
>
{ item.value }
</Autocomplete.Item>
) }
</Autocomplete.Collection>
</Autocomplete.ListBody>
</Autocomplete.List>
</Autocomplete.Popup>
</Autocomplete.Root>
);
/**
* Same async pattern as `AsyncItems`, with the result count visible in the
* popup.
*/
export const AsyncItemsVisibleCount: Story = {
render: function Template( args ) {
return <AsyncItemsTemplate args={ args } visibleCount />;
},
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,17 @@ describe( 'Autocomplete', () => {
const itemRef = createRef< HTMLDivElement >();
const clearRef = createRef< HTMLButtonElement >();
const emptyRef = createRef< HTMLDivElement >();
const statusRef = createRef< HTMLDivElement >();

render(
<Autocomplete.Root items={ ITEMS }>
<Autocomplete.InputGroup ref={ inputGroupRef }>
<Autocomplete.Input ref={ inputRef } placeholder="Search" />
</Autocomplete.InputGroup>
<Autocomplete.Popup ref={ popupRef }>
<Autocomplete.Status ref={ statusRef }>
Loading...
</Autocomplete.Status>
Comment on lines +42 to +44

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should we also check that the rendered node has role="status", aria-live="polite", and aria-atomic="true"? Or do we delegate / trust base ui on this? (the current ref assertion would also pass for a plain <div>, so it does not protect the accessible behavior these subcomponents add)

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.

Yes, this is just Base UI behavior and we have no integration here that would alter that.

<Autocomplete.Empty ref={ emptyRef }>
No results found.
</Autocomplete.Empty>
Expand Down Expand Up @@ -78,6 +82,13 @@ describe( 'Autocomplete', () => {
expect( itemRef.current ).toBeInstanceOf( HTMLDivElement );
expect( clearRef.current ).toBeInstanceOf( HTMLButtonElement );
expect( emptyRef.current ).toBeInstanceOf( HTMLDivElement );
expect( emptyRef.current ).toHaveAttribute( 'role', 'status' );
expect( emptyRef.current ).toHaveAttribute( 'aria-live', 'polite' );
expect( emptyRef.current ).toHaveAttribute( 'aria-atomic', 'true' );
expect( statusRef.current ).toBeInstanceOf( HTMLDivElement );
expect( statusRef.current ).toHaveAttribute( 'role', 'status' );
expect( statusRef.current ).toHaveAttribute( 'aria-live', 'polite' );
expect( statusRef.current ).toHaveAttribute( 'aria-atomic', 'true' );
} );

describe( 'portal', () => {
Expand Down
6 changes: 6 additions & 0 deletions packages/ui/src/form/primitives/autocomplete/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,10 @@ export type AutocompleteRowProps = ComponentProps<
children?: React.ReactNode;
};

export type AutocompleteStatusProps = ComponentProps<
typeof _Autocomplete.Status
> & {
children?: React.ReactNode;
};

export type AutocompleteValueProps = _Autocomplete.Value.Props;
1 change: 1 addition & 0 deletions packages/ui/src/form/primitives/combobox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ export { Portal } from './portal';
export { Positioner } from './positioner';
export { Popup } from './popup';
export { Root } from './root';
export { Status } from './status';
export { Trigger } from './trigger';
export { Value } from './value';
25 changes: 25 additions & 0 deletions packages/ui/src/form/primitives/combobox/status.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Combobox as _Combobox } from '@base-ui/react/combobox';
import clsx from 'clsx';
import { forwardRef } from '@wordpress/element';
import type { ComboboxStatusProps } from './types';
import itemPopupStyles from '../../../utils/css/item-popup.module.css';

/**
* Announces a status message politely to screen readers. Use it for the
* status of an asynchronously loaded list.
*
* Keep this element mounted. Do not hide it with `display: none`, `hidden`,
* `aria-hidden`, or by omitting the component. Change or omit the children
* instead.
*/
export const Status = forwardRef< HTMLDivElement, ComboboxStatusProps >(
function Status( { className, ...restProps }, ref ) {
return (
<_Combobox.Status
className={ clsx( itemPopupStyles.status, className ) }
ref={ ref }
{ ...restProps }
/>
);
}
);
Loading
Loading