From 37fdc01b21c3c9c90e24be01ec0244e305289e24 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Wed, 7 Oct 2026 19:50:06 -0500 Subject: [PATCH 1/4] feat: [command] use base ui empty and status --- .../www/src/components/docs/search.module.css | 4 + .../content/docs/components/command/demo.ts | 1 + .../content/docs/components/command/index.mdx | 11 ++- .../content/docs/components/command/props.ts | 14 +++ .../command/__tests__/command.test.tsx | 90 +++++++++++++++++++ .../command/__tests__/data-slots.test.tsx | 4 +- .../components/command/command-empty.tsx | 76 ++++++++++++++-- .../react/components/command/command-item.tsx | 12 ++- .../react/components/command/command-root.tsx | 3 +- .../components/command/command.module.css | 16 +++- packages/react/components/command/command.tsx | 3 +- packages/react/shared/item-count/index.ts | 1 + .../react/shared/item-count/item-count.tsx | 51 +++++++++++ 13 files changed, 269 insertions(+), 17 deletions(-) create mode 100644 packages/react/shared/item-count/index.ts create mode 100644 packages/react/shared/item-count/item-count.tsx diff --git a/apps/www/src/components/docs/search.module.css b/apps/www/src/components/docs/search.module.css index 4b44bd1e0..221eb3252 100644 --- a/apps/www/src/components/docs/search.module.css +++ b/apps/www/src/components/docs/search.module.css @@ -26,6 +26,10 @@ min-height: 320px; } +.searchEmpty:empty { + min-height: 0; +} + .searchList .searchGroupLabel { font-size: var(--rs-font-size-small); line-height: var(--rs-line-height-small); diff --git a/apps/www/src/content/docs/components/command/demo.ts b/apps/www/src/content/docs/components/command/demo.ts index 9a54b8b07..0aa29d52b 100644 --- a/apps/www/src/content/docs/components/command/demo.ts +++ b/apps/www/src/content/docs/components/command/demo.ts @@ -102,6 +102,7 @@ export const inlineDemo = { Billing Settings + ` }; diff --git a/apps/www/src/content/docs/components/command/index.mdx b/apps/www/src/content/docs/components/command/index.mdx index 89159adf4..4a5dfc5b7 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. +Shows its children only when no items match the search, or the list has no items. The element stays in the DOM as a live region (`role="status"`), so screen readers announce it when it appears. Built on [Base UI Autocomplete.Empty](https://base-ui.com/react/components/autocomplete). +### Status + +A visually hidden live region that announces the number of matching items while the input has text. Place it anywhere inside `Command`. 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.Empty` and `Command.Status` are polite live regions. Screen readers announce when nothing matches and how many items match. - 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..9eb3e0190 100644 --- a/apps/www/src/content/docs/components/command/props.ts +++ b/apps/www/src/content/docs/components/command/props.ts @@ -83,6 +83,20 @@ 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 while the input has text. A function receives the number of matching items. + * @default count => `${count} results` + */ + children?: React.ReactNode | ((count: number) => 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..4deec33dc 100644 --- a/packages/react/components/command/__tests__/command.test.tsx +++ b/packages/react/components/command/__tests__/command.test.tsx @@ -133,6 +133,96 @@ describe('Command', () => { }); }); + describe('Empty and Status', () => { + it('keeps the empty element mounted and fills it only with no matches', async () => { + const user = userEvent.setup(); + const { container } = render(); + const empty = container.querySelector('[data-slot="command-empty"]'); + expect(empty).toHaveAttribute('role', 'status'); + expect(empty).toBeEmptyDOMElement(); + + const input = screen.getByRole('combobox'); + await user.type(input, 'zzz'); + await waitFor(() => expect(empty).toHaveTextContent('No results found.')); + + await user.clear(input); + await user.type(input, 'cal'); + await waitFor(() => expect(empty).toBeEmptyDOMElement()); + }); + + it('shows the empty state with the items prop when nothing matches', async () => { + const user = userEvent.setup(); + const items = ['Calendar', 'Calculator']; + const { container } = render( + + + + {(item: string) => ( + + {item} + + )} + + No results found. + + ); + const empty = container.querySelector('[data-slot="command-empty"]'); + expect(empty).toBeEmptyDOMElement(); + + await user.type(screen.getByRole('combobox'), 'zzz'); + await waitFor(() => expect(empty).toHaveTextContent('No results found.')); + }); + + it('announces the number of matches while searching', async () => { + const user = userEvent.setup(); + const { container } = render( + + + + {ITEMS.map(item => ( + + {item.label} + + ))} + + + + ); + const status = container.querySelector('[data-slot="command-status"]'); + expect(status).toHaveAttribute('role', 'status'); + expect(status).toBeEmptyDOMElement(); + + const input = screen.getByRole('combobox'); + await user.type(input, 'cal'); + await waitFor(() => expect(status).toHaveTextContent('2 results')); + + await user.type(input, 'e'); + await waitFor(() => expect(status).toHaveTextContent('1 result')); + }); + + it('counts filtered items with the items prop', async () => { + const user = userEvent.setup(); + const items = ['Calendar', 'Calculator', 'Profile']; + const { container } = render( + + + + {(item: string) => ( + + {item} + + )} + + {count => `${count} commands`} + + ); + const status = container.querySelector('[data-slot="command-status"]'); + + await user.type(screen.getByRole('combobox'), 'cal'); + await waitFor(() => expect(status).toHaveTextContent('2 commands')); + }); + }); + 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 0f63e622b..75119567b 100644 --- a/packages/react/components/command/command-empty.tsx +++ b/packages/react/components/command/command-empty.tsx @@ -1,24 +1,86 @@ '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 { useItemCount } from '~/shared/item-count'; import styles from './command.module.css'; +import { useCommandContext } from './command-root'; -export interface CommandEmptyProps extends ComponentProps<'div'> {} +/** The number of items that match the search. */ +const useMatchCount = () => { + const { hasItems } = useCommandContext(); + const filteredItems = AutocompletePrimitive.useFilteredItems(); + const renderedCount = useItemCount() ?? 0; + + if (!hasItems) return renderedCount; + return filteredItems.reduce( + (count, item) => count + (isGroup(item) ? item.items.length : 1), + 0 + ); +}; + +const isGroup = (item: unknown): item is { items: readonly unknown[] } => + typeof item === 'object' && + item !== null && + Array.isArray((item as { items?: unknown }).items); + +export type CommandEmptyProps = AutocompletePrimitive.Empty.Props; export const CommandEmpty = ({ - ref, className, + children, ...props -}: CommandEmptyProps & { ref?: React.Ref }) => { +}: CommandEmptyProps) => { + const count = useMatchCount(); + return ( -
+ > + {count ? null : children} + ); }; CommandEmpty.displayName = 'Command.Empty'; + +export interface CommandStatusProps + extends Omit { + /** + * Text to announce. A function receives the number of matching items. + * @default count => `${count} results` + */ + children?: ReactNode | ((count: number) => ReactNode); +} + +const defaultStatus = (count: number) => + `${count} ${count === 1 ? 'result' : 'results'}`; + +export const CommandStatus = ({ + className, + children = defaultStatus, + ...props +}: CommandStatusProps) => { + const { inputValue } = useCommandContext(); + const count = useMatchCount(); + + let content: ReactNode = null; + if (inputValue) { + content = typeof children === 'function' ? children(count) : children; + } + + return ( + + {content} + + ); +}; + +CommandStatus.displayName = 'Command.Status'; diff --git a/packages/react/components/command/command-item.tsx b/packages/react/components/command/command-item.tsx index e6123b473..30d716897 100644 --- a/packages/react/components/command/command-item.tsx +++ b/packages/react/components/command/command-item.tsx @@ -3,6 +3,7 @@ import { Autocomplete as AutocompletePrimitive } from '@base-ui/react/autocomplete'; import { cx } from 'class-variance-authority'; import { type ReactNode } from 'react'; +import { useRegisterItem } from '~/shared/item-count'; import { getMatch } from '../menu/utils'; import styles from './command.module.css'; import { useCommandContext } from './command-root'; @@ -31,14 +32,17 @@ export const CommandItem = ({ const { inputValue, hasItems } = useCommandContext(); - if (!hasItems && inputValue?.length) { - const isMatched = getMatch( + const hidden = + !hasItems && + !!inputValue?.length && + !getMatch( typeof value === 'string' ? value : undefined, children, inputValue ); - if (!isMatched) return null; - } + useRegisterItem(!hidden); + + if (hidden) return null; return (
- {children} + {children}
diff --git a/packages/react/components/command/command.module.css b/packages/react/components/command/command.module.css index 26f41997d..9a94162d2 100644 --- a/packages/react/components/command/command.module.css +++ b/packages/react/components/command/command.module.css @@ -113,8 +113,20 @@ color: var(--rs-color-foreground-base-secondary); } -.list:has([role="option"]) .empty { - display: none; +.empty:empty { + padding: 0; +} + +.status { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; } .group { 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/shared/item-count/index.ts b/packages/react/shared/item-count/index.ts new file mode 100644 index 000000000..a6b9c1531 --- /dev/null +++ b/packages/react/shared/item-count/index.ts @@ -0,0 +1 @@ +export * from './item-count'; diff --git a/packages/react/shared/item-count/item-count.tsx b/packages/react/shared/item-count/item-count.tsx new file mode 100644 index 000000000..a1b9d8774 --- /dev/null +++ b/packages/react/shared/item-count/item-count.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { + createContext, + type ReactNode, + useCallback, + useContext, + useState +} from 'react'; +import { useIsomorphicLayoutEffect } from '~/hooks/useIsomorphicLayoutEffect'; + +/* + * Counts the items a list renders. Lists that filter their own children + * (instead of using Base UI's `items` prop) use the count to know when nothing + * matches and how many results to announce. + */ + +type RegisterItem = () => () => void; + +const RegisterItemContext = createContext(null); +const ItemCountContext = createContext(null); + +export function ItemCountProvider({ children }: { children: ReactNode }) { + const [count, setCount] = useState(0); + + const register = useCallback(() => { + setCount(value => value + 1); + return () => setCount(value => value - 1); + }, []); + + return ( + + {children} + + ); +} + +/** Adds the calling item to the nearest count while `enabled` is true. */ +export function useRegisterItem(enabled = true) { + const register = useContext(RegisterItemContext); + + useIsomorphicLayoutEffect(() => { + if (!enabled || !register) return; + return register(); + }, [enabled, register]); +} + +/** The number of registered items, or `null` outside a provider. */ +export function useItemCount() { + return useContext(ItemCountContext); +} From c73d6968ce2dc3d3b57a1e8d1e417aa381c2cbc4 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Mon, 26 Oct 2026 23:30:40 +0530 Subject: [PATCH 2/4] refactor: [command] remove the item count registry Command.Empty renders Autocomplete.Empty and keeps the CSS rule that hides it while the list has options. Command.Status passes its children through while the input has text. A function child gets the match count from Base UI's filtered items, so the count needs the items prop. --- .../www/src/components/docs/search.module.css | 4 -- .../content/docs/components/command/demo.ts | 4 +- .../content/docs/components/command/index.mdx | 6 +- .../content/docs/components/command/props.ts | 5 +- .../command/__tests__/command.test.tsx | 72 +++++-------------- .../components/command/command-empty.tsx | 59 +++++---------- .../react/components/command/command-item.tsx | 12 ++-- .../react/components/command/command-root.tsx | 3 +- .../components/command/command.module.css | 4 +- packages/react/shared/item-count/index.ts | 1 - .../react/shared/item-count/item-count.tsx | 51 ------------- 11 files changed, 49 insertions(+), 172 deletions(-) delete mode 100644 packages/react/shared/item-count/index.ts delete mode 100644 packages/react/shared/item-count/item-count.tsx diff --git a/apps/www/src/components/docs/search.module.css b/apps/www/src/components/docs/search.module.css index 221eb3252..4b44bd1e0 100644 --- a/apps/www/src/components/docs/search.module.css +++ b/apps/www/src/components/docs/search.module.css @@ -26,10 +26,6 @@ min-height: 320px; } -.searchEmpty:empty { - min-height: 0; -} - .searchList .searchGroupLabel { font-size: var(--rs-font-size-small); line-height: var(--rs-line-height-small); diff --git a/apps/www/src/content/docs/components/command/demo.ts b/apps/www/src/content/docs/components/command/demo.ts index 0aa29d52b..4f16a96a5 100644 --- a/apps/www/src/content/docs/components/command/demo.ts +++ b/apps/www/src/content/docs/components/command/demo.ts @@ -102,7 +102,6 @@ export const inlineDemo = { Billing Settings - ` }; @@ -234,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 4a5dfc5b7..09bc18a32 100644 --- a/apps/www/src/content/docs/components/command/index.mdx +++ b/apps/www/src/content/docs/components/command/index.mdx @@ -111,13 +111,13 @@ Scrollable container for groups and items. Renders with `role="listbox"`. ### Empty -Shows its children only when no items match the search, or the list has no items. The element stays in the DOM as a live region (`role="status"`), so screen readers announce it when it appears. Built on [Base UI Autocomplete.Empty](https://base-ui.com/react/components/autocomplete). +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 that announces the number of matching items while the input has text. Place it anywhere inside `Command`. Built on [Base UI Autocomplete.Status](https://base-ui.com/react/components/autocomplete). +A visually hidden live region. Screen readers announce its children while the input has text. Place it anywhere inside `Command`. A function child receives the number of matching items. The count needs the `items` prop, so without it a function child renders nothing. Built on [Base UI Autocomplete.Status](https://base-ui.com/react/components/autocomplete). @@ -185,6 +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.Empty` and `Command.Status` are polite live regions. Screen readers announce when nothing matches and how many items match. +- `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 9eb3e0190..ae07f010e 100644 --- a/apps/www/src/content/docs/components/command/props.ts +++ b/apps/www/src/content/docs/components/command/props.ts @@ -91,10 +91,7 @@ export interface CommandEmptyProps { } export interface CommandStatusProps { - /** - * Text to announce while the input has text. A function receives the number of matching items. - * @default count => `${count} results` - */ + /** Text to announce while the input has text. A function receives the number of matching items and needs the `items` prop. */ children?: React.ReactNode | ((count: number) => React.ReactNode); /** Additional CSS class names. */ diff --git a/packages/react/components/command/__tests__/command.test.tsx b/packages/react/components/command/__tests__/command.test.tsx index 4deec33dc..f7f8ee2c0 100644 --- a/packages/react/components/command/__tests__/command.test.tsx +++ b/packages/react/components/command/__tests__/command.test.tsx @@ -133,26 +133,10 @@ describe('Command', () => { }); }); - describe('Empty and Status', () => { - it('keeps the empty element mounted and fills it only with no matches', async () => { + describe('Status', () => { + it('announces the match count with the items prop while the input has text', async () => { const user = userEvent.setup(); - const { container } = render(); - const empty = container.querySelector('[data-slot="command-empty"]'); - expect(empty).toHaveAttribute('role', 'status'); - expect(empty).toBeEmptyDOMElement(); - - const input = screen.getByRole('combobox'); - await user.type(input, 'zzz'); - await waitFor(() => expect(empty).toHaveTextContent('No results found.')); - - await user.clear(input); - await user.type(input, 'cal'); - await waitFor(() => expect(empty).toBeEmptyDOMElement()); - }); - - it('shows the empty state with the items prop when nothing matches', async () => { - const user = userEvent.setup(); - const items = ['Calendar', 'Calculator']; + const items = ['Calendar', 'Calculator', 'Profile']; const { container } = render( @@ -163,17 +147,17 @@ describe('Command', () => { )} - No results found. + {count => `${count} results`} ); - const empty = container.querySelector('[data-slot="command-empty"]'); - expect(empty).toBeEmptyDOMElement(); + const status = container.querySelector('[data-slot="command-status"]'); + expect(status).toBeEmptyDOMElement(); - await user.type(screen.getByRole('combobox'), 'zzz'); - await waitFor(() => expect(empty).toHaveTextContent('No results found.')); + await user.type(screen.getByRole('combobox'), 'cal'); + await waitFor(() => expect(status).toHaveTextContent(/^2 results$/)); }); - it('announces the number of matches while searching', async () => { + it('renders nothing for a function child without the items prop', async () => { const user = userEvent.setup(); const { container } = render( @@ -185,41 +169,17 @@ describe('Command', () => { ))} - - - ); - const status = container.querySelector('[data-slot="command-status"]'); - expect(status).toHaveAttribute('role', 'status'); - expect(status).toBeEmptyDOMElement(); - - const input = screen.getByRole('combobox'); - await user.type(input, 'cal'); - await waitFor(() => expect(status).toHaveTextContent('2 results')); - - await user.type(input, 'e'); - await waitFor(() => expect(status).toHaveTextContent('1 result')); - }); - - it('counts filtered items with the items prop', async () => { - const user = userEvent.setup(); - const items = ['Calendar', 'Calculator', 'Profile']; - const { container } = render( - - - - {(item: string) => ( - - {item} - - )} - - {count => `${count} commands`} + {count => `${count} results`} ); - const status = container.querySelector('[data-slot="command-status"]'); await user.type(screen.getByRole('combobox'), 'cal'); - await waitFor(() => expect(status).toHaveTextContent('2 commands')); + await waitFor(() => + expect(screen.getAllByRole('option')).toHaveLength(2) + ); + expect( + container.querySelector('[data-slot="command-status"]') + ).toBeEmptyDOMElement(); }); }); diff --git a/packages/react/components/command/command-empty.tsx b/packages/react/components/command/command-empty.tsx index 75119567b..7f77d3eb9 100644 --- a/packages/react/components/command/command-empty.tsx +++ b/packages/react/components/command/command-empty.tsx @@ -3,73 +3,52 @@ import { Autocomplete as AutocompletePrimitive } from '@base-ui/react/autocomplete'; import { cx } from 'class-variance-authority'; import { type ReactNode } from 'react'; -import { useItemCount } from '~/shared/item-count'; import styles from './command.module.css'; import { useCommandContext } from './command-root'; -/** The number of items that match the search. */ -const useMatchCount = () => { - const { hasItems } = useCommandContext(); - const filteredItems = AutocompletePrimitive.useFilteredItems(); - const renderedCount = useItemCount() ?? 0; - - if (!hasItems) return renderedCount; - return filteredItems.reduce( - (count, item) => count + (isGroup(item) ? item.items.length : 1), - 0 - ); -}; - -const isGroup = (item: unknown): item is { items: readonly unknown[] } => - typeof item === 'object' && - item !== null && - Array.isArray((item as { items?: unknown }).items); - export type CommandEmptyProps = AutocompletePrimitive.Empty.Props; -export const CommandEmpty = ({ - className, - children, - ...props -}: CommandEmptyProps) => { - const count = useMatchCount(); - +export const CommandEmpty = ({ className, ...props }: CommandEmptyProps) => { return ( - {count ? null : children} - + /> ); }; CommandEmpty.displayName = 'Command.Empty'; +const isGroup = (item: unknown): item is { items: readonly unknown[] } => + 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. - * @default count => `${count} results` - */ + /** Text to announce while the input has text. A function receives the number of matching items and needs the `items` prop. */ children?: ReactNode | ((count: number) => ReactNode); } -const defaultStatus = (count: number) => - `${count} ${count === 1 ? 'result' : 'results'}`; - export const CommandStatus = ({ className, - children = defaultStatus, + children, ...props }: CommandStatusProps) => { - const { inputValue } = useCommandContext(); - const count = useMatchCount(); + const { inputValue, hasItems } = useCommandContext(); + const filteredItems = AutocompletePrimitive.useFilteredItems(); let content: ReactNode = null; if (inputValue) { - content = typeof children === 'function' ? children(count) : children; + if (typeof children !== 'function') content = children; + else if (hasItems) content = children(countItems(filteredItems)); } return ( diff --git a/packages/react/components/command/command-item.tsx b/packages/react/components/command/command-item.tsx index 30d716897..e6123b473 100644 --- a/packages/react/components/command/command-item.tsx +++ b/packages/react/components/command/command-item.tsx @@ -3,7 +3,6 @@ import { Autocomplete as AutocompletePrimitive } from '@base-ui/react/autocomplete'; import { cx } from 'class-variance-authority'; import { type ReactNode } from 'react'; -import { useRegisterItem } from '~/shared/item-count'; import { getMatch } from '../menu/utils'; import styles from './command.module.css'; import { useCommandContext } from './command-root'; @@ -32,17 +31,14 @@ export const CommandItem = ({ const { inputValue, hasItems } = useCommandContext(); - const hidden = - !hasItems && - !!inputValue?.length && - !getMatch( + if (!hasItems && inputValue?.length) { + const isMatched = getMatch( typeof value === 'string' ? value : undefined, children, inputValue ); - useRegisterItem(!hidden); - - if (hidden) return null; + if (!isMatched) return null; + } return ( ({ {...props} >
- {children} + {children}
diff --git a/packages/react/components/command/command.module.css b/packages/react/components/command/command.module.css index 9a94162d2..42cbc4079 100644 --- a/packages/react/components/command/command.module.css +++ b/packages/react/components/command/command.module.css @@ -113,8 +113,8 @@ color: var(--rs-color-foreground-base-secondary); } -.empty:empty { - padding: 0; +.list:has([role="option"]) .empty { + display: none; } .status { diff --git a/packages/react/shared/item-count/index.ts b/packages/react/shared/item-count/index.ts deleted file mode 100644 index a6b9c1531..000000000 --- a/packages/react/shared/item-count/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './item-count'; diff --git a/packages/react/shared/item-count/item-count.tsx b/packages/react/shared/item-count/item-count.tsx deleted file mode 100644 index 3d5be9ef1..000000000 --- a/packages/react/shared/item-count/item-count.tsx +++ /dev/null @@ -1,51 +0,0 @@ -'use client'; - -import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; -import { - createContext, - type ReactNode, - useCallback, - useContext, - useState -} from 'react'; - -/* - * Counts the items a list renders. Lists that filter their own children - * (instead of using Base UI's `items` prop) use the count to know when nothing - * matches and how many results to announce. - */ - -type RegisterItem = () => () => void; - -const RegisterItemContext = createContext(null); -const ItemCountContext = createContext(null); - -export function ItemCountProvider({ children }: { children: ReactNode }) { - const [count, setCount] = useState(0); - - const register = useCallback(() => { - setCount(value => value + 1); - return () => setCount(value => value - 1); - }, []); - - return ( - - {children} - - ); -} - -/** Adds the calling item to the nearest count while `enabled` is true. */ -export function useRegisterItem(enabled = true) { - const register = useContext(RegisterItemContext); - - useIsoLayoutEffect(() => { - if (!enabled || !register) return; - return register(); - }, [enabled, register]); -} - -/** The number of registered items, or `null` outside a provider. */ -export function useItemCount() { - return useContext(ItemCountContext); -} From 7c2395c76e2de70fdab9fb8a5e394194dd6ee3ce Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Mon, 26 Oct 2026 23:40:32 +0530 Subject: [PATCH 3/4] refactor: [command] pass null to the status function without items A function child of Command.Status runs while the input has text. It receives the match count with the items prop, and null without it. --- .../src/content/docs/components/command/index.mdx | 2 +- .../src/content/docs/components/command/props.ts | 4 ++-- .../components/command/__tests__/command.test.tsx | 14 ++++++-------- .../react/components/command/command-empty.tsx | 10 ++++++---- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/www/src/content/docs/components/command/index.mdx b/apps/www/src/content/docs/components/command/index.mdx index 09bc18a32..d98b15893 100644 --- a/apps/www/src/content/docs/components/command/index.mdx +++ b/apps/www/src/content/docs/components/command/index.mdx @@ -117,7 +117,7 @@ Rendered when no items are visible (all filtered out, or the list is empty). Aut ### Status -A visually hidden live region. Screen readers announce its children while the input has text. Place it anywhere inside `Command`. A function child receives the number of matching items. The count needs the `items` prop, so without it a function child renders nothing. Built on [Base UI Autocomplete.Status](https://base-ui.com/react/components/autocomplete). +A visually hidden live region. Screen readers announce its children while the input has text. 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). diff --git a/apps/www/src/content/docs/components/command/props.ts b/apps/www/src/content/docs/components/command/props.ts index ae07f010e..bda5320be 100644 --- a/apps/www/src/content/docs/components/command/props.ts +++ b/apps/www/src/content/docs/components/command/props.ts @@ -91,8 +91,8 @@ export interface CommandEmptyProps { } export interface CommandStatusProps { - /** Text to announce while the input has text. A function receives the number of matching items and needs the `items` prop. */ - children?: React.ReactNode | ((count: number) => React.ReactNode); + /** Text to announce while the input has text. 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 f7f8ee2c0..1fa858c74 100644 --- a/packages/react/components/command/__tests__/command.test.tsx +++ b/packages/react/components/command/__tests__/command.test.tsx @@ -157,7 +157,7 @@ describe('Command', () => { await waitFor(() => expect(status).toHaveTextContent(/^2 results$/)); }); - it('renders nothing for a function child without the items prop', async () => { + it('passes null to a function child without the items prop', async () => { const user = userEvent.setup(); const { container } = render( @@ -169,17 +169,15 @@ describe('Command', () => { ))} - {count => `${count} results`} + + {count => (count === null ? 'No count' : `${count} results`)} + ); + const status = container.querySelector('[data-slot="command-status"]'); await user.type(screen.getByRole('combobox'), 'cal'); - await waitFor(() => - expect(screen.getAllByRole('option')).toHaveLength(2) - ); - expect( - container.querySelector('[data-slot="command-status"]') - ).toBeEmptyDOMElement(); + await waitFor(() => expect(status).toHaveTextContent(/^No count$/)); }); }); diff --git a/packages/react/components/command/command-empty.tsx b/packages/react/components/command/command-empty.tsx index 7f77d3eb9..00ce04da0 100644 --- a/packages/react/components/command/command-empty.tsx +++ b/packages/react/components/command/command-empty.tsx @@ -33,8 +33,8 @@ const countItems = (items: readonly unknown[]) => export interface CommandStatusProps extends Omit { - /** Text to announce while the input has text. A function receives the number of matching items and needs the `items` prop. */ - children?: ReactNode | ((count: number) => ReactNode); + /** Text to announce while the input has text. A function receives the number of matching items, or `null` without the `items` prop. */ + children?: ReactNode | ((count: number | null) => ReactNode); } export const CommandStatus = ({ @@ -47,8 +47,10 @@ export const CommandStatus = ({ let content: ReactNode = null; if (inputValue) { - if (typeof children !== 'function') content = children; - else if (hasItems) content = children(countItems(filteredItems)); + content = + typeof children === 'function' + ? children(hasItems ? countItems(filteredItems) : null) + : children; } return ( From 2970d9f616dc2818f0afb40f374a965554411484 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Mon, 26 Oct 2026 23:46:37 +0530 Subject: [PATCH 4/4] refactor: [command] remove the input gate from status Command.Status renders its children at all times. The app decides when to announce, for example by returning null from the function child. --- .../src/content/docs/components/command/index.mdx | 2 +- .../src/content/docs/components/command/props.ts | 2 +- .../components/command/__tests__/command.test.tsx | 14 ++++++-------- .../react/components/command/command-empty.tsx | 15 ++++++--------- 4 files changed, 14 insertions(+), 19 deletions(-) diff --git a/apps/www/src/content/docs/components/command/index.mdx b/apps/www/src/content/docs/components/command/index.mdx index d98b15893..5074fd22c 100644 --- a/apps/www/src/content/docs/components/command/index.mdx +++ b/apps/www/src/content/docs/components/command/index.mdx @@ -117,7 +117,7 @@ Rendered when no items are visible (all filtered out, or the list is empty). Aut ### Status -A visually hidden live region. Screen readers announce its children while the input has text. 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). +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). diff --git a/apps/www/src/content/docs/components/command/props.ts b/apps/www/src/content/docs/components/command/props.ts index bda5320be..dd8659dd6 100644 --- a/apps/www/src/content/docs/components/command/props.ts +++ b/apps/www/src/content/docs/components/command/props.ts @@ -91,7 +91,7 @@ export interface CommandEmptyProps { } export interface CommandStatusProps { - /** Text to announce while the input has text. A function receives the number of matching items, or `null` without the `items` prop. */ + /** 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. */ diff --git a/packages/react/components/command/__tests__/command.test.tsx b/packages/react/components/command/__tests__/command.test.tsx index 1fa858c74..51b97d7a6 100644 --- a/packages/react/components/command/__tests__/command.test.tsx +++ b/packages/react/components/command/__tests__/command.test.tsx @@ -134,7 +134,7 @@ describe('Command', () => { }); describe('Status', () => { - it('announces the match count with the items prop while the input has text', async () => { + it('announces the match count with the items prop', async () => { const user = userEvent.setup(); const items = ['Calendar', 'Calculator', 'Profile']; const { container } = render( @@ -151,14 +151,13 @@ describe('Command', () => { ); const status = container.querySelector('[data-slot="command-status"]'); - expect(status).toBeEmptyDOMElement(); + 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', async () => { - const user = userEvent.setup(); + it('passes null to a function child without the items prop', () => { const { container } = render( @@ -174,10 +173,9 @@ describe('Command', () => {
); - const status = container.querySelector('[data-slot="command-status"]'); - - await user.type(screen.getByRole('combobox'), 'cal'); - await waitFor(() => expect(status).toHaveTextContent(/^No count$/)); + expect( + container.querySelector('[data-slot="command-status"]') + ).toHaveTextContent('No count'); }); }); diff --git a/packages/react/components/command/command-empty.tsx b/packages/react/components/command/command-empty.tsx index 00ce04da0..4e25564cf 100644 --- a/packages/react/components/command/command-empty.tsx +++ b/packages/react/components/command/command-empty.tsx @@ -33,7 +33,7 @@ const countItems = (items: readonly unknown[]) => export interface CommandStatusProps extends Omit { - /** Text to announce while the input has text. A function receives the number of matching items, or `null` without the `items` prop. */ + /** Text to announce. A function receives the number of matching items, or `null` without the `items` prop. */ children?: ReactNode | ((count: number | null) => ReactNode); } @@ -42,16 +42,13 @@ export const CommandStatus = ({ children, ...props }: CommandStatusProps) => { - const { inputValue, hasItems } = useCommandContext(); + const { hasItems } = useCommandContext(); const filteredItems = AutocompletePrimitive.useFilteredItems(); - let content: ReactNode = null; - if (inputValue) { - content = - typeof children === 'function' - ? children(hasItems ? countItems(filteredItems) : null) - : children; - } + const content = + typeof children === 'function' + ? children(hasItems ? countItems(filteredItems) : null) + : children; return (