diff --git a/apps/www/src/content/docs/components/command/demo.ts b/apps/www/src/content/docs/components/command/demo.ts index 9a54b8b07..4f16a96a5 100644 --- a/apps/www/src/content/docs/components/command/demo.ts +++ b/apps/www/src/content/docs/components/command/demo.ts @@ -233,6 +233,9 @@ export const itemsDemo = { )} + + {(count) => (count === 1 ? '1 result' : \`\${count} results\`)} + diff --git a/apps/www/src/content/docs/components/command/index.mdx b/apps/www/src/content/docs/components/command/index.mdx index 89159adf4..5074fd22c 100644 --- a/apps/www/src/content/docs/components/command/index.mdx +++ b/apps/www/src/content/docs/components/command/index.mdx @@ -36,6 +36,7 @@ import { Command } from "@raystack/apsara"; + @@ -110,10 +111,16 @@ Scrollable container for groups and items. Renders with `role="listbox"`. ### Empty -Rendered when no items are visible (all filtered out, or the list is empty). Automatically hidden when the list contains at least one item. +Rendered when no items are visible (all filtered out, or the list is empty). Automatically hidden when the list contains at least one item. Built on [Base UI Autocomplete.Empty](https://base-ui.com/react/components/autocomplete). +### Status + +A visually hidden live region. Screen readers announce changes to its children. Place it anywhere inside `Command`. A function child receives the number of matching items. The count needs the `items` prop. Without it, the function receives `null`. Built on [Base UI Autocomplete.Status](https://base-ui.com/react/components/autocomplete). + + + ### Item A selectable entry. When `value` is omitted and `children` is a string, the string is used as the value. `onClick` fires on pointer click **and** on keyboard `Enter` when the item is highlighted. Pass a [`Kbd`](/docs/components/kbd) as `trailingIcon` to show a keyboard hint, and use the `ghost` variant so the keys sit on the item's own background. @@ -161,6 +168,7 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test | `command-input` | The `` element itself | | `command-content` | The `Command.Content` list | | `command-empty` | `Command.Empty` | +| `command-status` | `Command.Status` | | `command-group` | `Command.Group` wrapper | | `command-label` | `Command.Label` | | `command-item` | Each `Command.Item` | @@ -177,5 +185,6 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test - Follows the [WAI-ARIA Combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/). - Input uses `role="combobox"`, list uses `role="listbox"`, items use `role="option"`. - Keyboard: `ArrowDown` / `ArrowUp` move the highlight, `Enter` activates the highlighted item, `Escape` closes the dialog. +- `Command.Status` is a polite live region. Use it to announce the number of matching items. - Disabled items expose `aria-disabled="true"`. The arrow keys still highlight them, and `Enter` does not activate them. - When used inside `Command.Dialog`, focus is trapped in the dialog and returned to the trigger on close. diff --git a/apps/www/src/content/docs/components/command/props.ts b/apps/www/src/content/docs/components/command/props.ts index d98976187..dd8659dd6 100644 --- a/apps/www/src/content/docs/components/command/props.ts +++ b/apps/www/src/content/docs/components/command/props.ts @@ -83,6 +83,17 @@ export interface CommandContentProps { } export interface CommandEmptyProps { + /** Content to show when no items match. */ + children?: React.ReactNode; + + /** Additional CSS class names. */ + className?: string; +} + +export interface CommandStatusProps { + /** Text to announce. A function receives the number of matching items, or `null` without the `items` prop. */ + children?: React.ReactNode | ((count: number | null) => React.ReactNode); + /** Additional CSS class names. */ className?: string; } diff --git a/packages/react/components/command/__tests__/command.test.tsx b/packages/react/components/command/__tests__/command.test.tsx index 5c53793f3..51b97d7a6 100644 --- a/packages/react/components/command/__tests__/command.test.tsx +++ b/packages/react/components/command/__tests__/command.test.tsx @@ -133,6 +133,52 @@ describe('Command', () => { }); }); + describe('Status', () => { + it('announces the match count with the items prop', async () => { + const user = userEvent.setup(); + const items = ['Calendar', 'Calculator', 'Profile']; + const { container } = render( + + + + {(item: string) => ( + + {item} + + )} + + {count => `${count} results`} + + ); + const status = container.querySelector('[data-slot="command-status"]'); + expect(status).toHaveTextContent('3 results'); + + await user.type(screen.getByRole('combobox'), 'cal'); + await waitFor(() => expect(status).toHaveTextContent(/^2 results$/)); + }); + + it('passes null to a function child without the items prop', () => { + const { container } = render( + + + + {ITEMS.map(item => ( + + {item.label} + + ))} + + + {count => (count === null ? 'No count' : `${count} results`)} + + + ); + expect( + container.querySelector('[data-slot="command-status"]') + ).toHaveTextContent('No count'); + }); + }); + describe('Auto-search disabled when items prop is passed', () => { it('does not apply custom filtering to CommandItem when items is provided', async () => { const user = userEvent.setup(); diff --git a/packages/react/components/command/__tests__/data-slots.test.tsx b/packages/react/components/command/__tests__/data-slots.test.tsx index 5b2b1c4cf..004a0df1c 100644 --- a/packages/react/components/command/__tests__/data-slots.test.tsx +++ b/packages/react/components/command/__tests__/data-slots.test.tsx @@ -32,6 +32,7 @@ const BasicCommand = () => ( Disabled item + ); @@ -50,7 +51,8 @@ describe('Command data-slot contract', () => { 'command-item-leading-icon', 'command-item-label', 'command-item-trailing-icon', - 'command-separator' + 'command-separator', + 'command-status' ]); }); diff --git a/packages/react/components/command/command-empty.tsx b/packages/react/components/command/command-empty.tsx index 4dc4e28f4..4e25564cf 100644 --- a/packages/react/components/command/command-empty.tsx +++ b/packages/react/components/command/command-empty.tsx @@ -1,19 +1,16 @@ 'use client'; +import { Autocomplete as AutocompletePrimitive } from '@base-ui/react/autocomplete'; import { cx } from 'class-variance-authority'; -import { type ComponentProps } from 'react'; +import { type ReactNode } from 'react'; import styles from './command.module.css'; +import { useCommandContext } from './command-root'; -export interface CommandEmptyProps extends ComponentProps<'div'> {} +export type CommandEmptyProps = AutocompletePrimitive.Empty.Props; -export const CommandEmpty = ({ - ref, - className, - ...props -}: CommandEmptyProps) => { +export const CommandEmpty = ({ className, ...props }: CommandEmptyProps) => { return ( -
+ typeof item === 'object' && + item !== null && + Array.isArray((item as { items?: unknown }).items); + +const countItems = (items: readonly unknown[]) => + items.reduce( + (count, item) => count + (isGroup(item) ? item.items.length : 1), + 0 + ); + +export interface CommandStatusProps + extends Omit { + /** Text to announce. A function receives the number of matching items, or `null` without the `items` prop. */ + children?: ReactNode | ((count: number | null) => ReactNode); +} + +export const CommandStatus = ({ + className, + children, + ...props +}: CommandStatusProps) => { + const { hasItems } = useCommandContext(); + const filteredItems = AutocompletePrimitive.useFilteredItems(); + + const content = + typeof children === 'function' + ? children(hasItems ? countItems(filteredItems) : null) + : children; + + return ( + + {content} + + ); +}; + +CommandStatus.displayName = 'Command.Status'; diff --git a/packages/react/components/command/command.module.css b/packages/react/components/command/command.module.css index 26f41997d..42cbc4079 100644 --- a/packages/react/components/command/command.module.css +++ b/packages/react/components/command/command.module.css @@ -117,6 +117,18 @@ display: none; } +.status { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + .group { display: flex; flex-direction: column; diff --git a/packages/react/components/command/command.tsx b/packages/react/components/command/command.tsx index a3e197db4..9c951f2cf 100644 --- a/packages/react/components/command/command.tsx +++ b/packages/react/components/command/command.tsx @@ -4,7 +4,7 @@ import { CommandDialogContent, CommandDialogTrigger } from './command-dialog'; -import { CommandEmpty } from './command-empty'; +import { CommandEmpty, CommandStatus } from './command-empty'; import { CommandInput } from './command-input'; import { CommandItem } from './command-item'; import { CommandGroup, CommandLabel, CommandSeparator } from './command-misc'; @@ -15,6 +15,7 @@ export const Command = Object.assign(CommandRoot, { Content: CommandContent, Item: CommandItem, Empty: CommandEmpty, + Status: CommandStatus, Group: CommandGroup, Label: CommandLabel, Separator: CommandSeparator, diff --git a/packages/react/components/command/index.tsx b/packages/react/components/command/index.tsx index da668ec9b..e7d55454e 100644 --- a/packages/react/components/command/index.tsx +++ b/packages/react/components/command/index.tsx @@ -1,7 +1,10 @@ export { Command } from './command'; export type { CommandContentProps } from './command-content'; export type { CommandDialogContentProps } from './command-dialog'; -export type { CommandEmptyProps } from './command-empty'; +export type { + CommandEmptyProps, + CommandStatusProps +} from './command-empty'; export type { CommandInputProps } from './command-input'; export type { CommandItemProps } from './command-item'; export type { diff --git a/packages/react/index.tsx b/packages/react/index.tsx index fe8d40934..f8102d8ec 100644 --- a/packages/react/index.tsx +++ b/packages/react/index.tsx @@ -145,7 +145,8 @@ export { type CommandItemProps, type CommandLabelProps, type CommandProps, - type CommandSeparatorProps + type CommandSeparatorProps, + type CommandStatusProps } from './components/command'; export { Container, type ContainerProps } from './components/container'; export {