diff --git a/apps/www/src/content/docs/components/command/index.mdx b/apps/www/src/content/docs/components/command/index.mdx index cc99370b7..dbc3728dc 100644 --- a/apps/www/src/content/docs/components/command/index.mdx +++ b/apps/www/src/content/docs/components/command/index.mdx @@ -177,5 +177,5 @@ 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. -- Disabled items expose `aria-disabled="true"` and are skipped in keyboard navigation. +- 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/dataview/index.mdx b/apps/www/src/content/docs/dataview/index.mdx index fbd8d0e25..c41e811ac 100644 --- a/apps/www/src/content/docs/dataview/index.mdx +++ b/apps/www/src/content/docs/dataview/index.mdx @@ -263,6 +263,7 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test | `data-view-filters` | Filter chip row | | `data-view-add-filter` | Default add-filter trigger | | `data-view-add-filter-item` | An entry in the add-filter menu | +| `data-view-filter-summary-status` | Visually hidden live region that announces the footer text | | `data-view-filter-summary` | "Items hidden by filters" footer | | `data-view-filter-summary-text` | Count + label group | | `data-view-filter-summary-count` | Hidden-row count | @@ -297,6 +298,9 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test cells stay associated with their row, and key presses bubbling up from interactive children (buttons, links in cells) are ignored so they don't also trigger row activation. +- The "hidden by filters" footer of List and Timeline has a visually hidden + `role="status"` region. It announces the footer text, for example "3 items + hidden by filters", once the search and filters stop changing for 500ms. - Renderer-specific semantics are documented on the [List](/docs/dataview/list#accessibility) and [Timeline](/docs/dataview/timeline#accessibility) pages. diff --git a/packages/raystack/components/command/__tests__/command.test.tsx b/packages/raystack/components/command/__tests__/command.test.tsx index 8075d7c0e..5c53793f3 100644 --- a/packages/raystack/components/command/__tests__/command.test.tsx +++ b/packages/raystack/components/command/__tests__/command.test.tsx @@ -284,6 +284,31 @@ describe('Command', () => { const option = screen.getByText('Calendar').closest('[role="option"]'); expect(option).toHaveAttribute('aria-disabled', 'true'); }); + + it('reaches disabled items with the arrow keys but does not activate them', async () => { + const user = userEvent.setup(); + const handleClick = vi.fn(); + render( + + + + Calendar + + Billing + + + + ); + + await user.click(screen.getByRole('combobox')); + await user.keyboard('{ArrowDown}'); + expect(screen.getByRole('option', { name: 'Billing' })).toHaveAttribute( + 'data-highlighted' + ); + + await user.keyboard('{Enter}'); + expect(handleClick).not.toHaveBeenCalled(); + }); }); describe('CommandDialog', () => { diff --git a/packages/raystack/components/command/command-item.tsx b/packages/raystack/components/command/command-item.tsx index 3990d8edb..e6123b473 100644 --- a/packages/raystack/components/command/command-item.tsx +++ b/packages/raystack/components/command/command-item.tsx @@ -20,7 +20,6 @@ export const CommandItem = ({ value: providedValue, leadingIcon, trailingIcon, - disabled, ...props }: CommandItemProps) => { const value = @@ -41,8 +40,13 @@ export const CommandItem = ({ if (!isMatched) return null; } - const content = ( - <> + return ( + {leadingIcon && ( {leadingIcon} @@ -59,35 +63,6 @@ export const CommandItem = ({ {trailingIcon} )} - - ); - - /** - * TODO: Fix this when Base UI fixes this issue - * This is a workaround to prevent item focus when the disabled prop is true. - */ - if (disabled) { - return ( -
- {content} -
- ); - } - - return ( - - {content} ); }; diff --git a/packages/raystack/components/data-view/__tests__/data-slots.test.tsx b/packages/raystack/components/data-view/__tests__/data-slots.test.tsx index 8fd48283d..30e3a41a5 100644 --- a/packages/raystack/components/data-view/__tests__/data-slots.test.tsx +++ b/packages/raystack/components/data-view/__tests__/data-slots.test.tsx @@ -253,6 +253,7 @@ describe('DataView data-slot contract', () => { ); expectSlots(container, [ + 'data-view-filter-summary-status', 'data-view-filter-summary', 'data-view-filter-summary-text', 'data-view-filter-summary-count', diff --git a/packages/raystack/components/data-view/__tests__/data-view.test.tsx b/packages/raystack/components/data-view/__tests__/data-view.test.tsx index 9e98859d4..ee77ecfe4 100644 --- a/packages/raystack/components/data-view/__tests__/data-view.test.tsx +++ b/packages/raystack/components/data-view/__tests__/data-view.test.tsx @@ -1,6 +1,14 @@ -import { act, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; // biome-ignore lint/suspicious/noShadowRestrictedNames: legitimate export name import { DataView } from '../data-view'; @@ -381,6 +389,92 @@ describe('DataView', () => { expect(screen.queryByText('John Doe')).not.toBeInTheDocument(); expect(screen.getByText('Jane Smith')).toBeInTheDocument(); }); + + describe('status region', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('announces the filter summary 500ms after the search changes', () => { + render( + + + + + + + ); + const status = screen.getByRole('status'); + + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'jane' } + }); + act(() => { + vi.advanceTimersByTime(499); + }); + expect(status).toBeEmptyDOMElement(); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(status).toHaveTextContent('2 items hidden by filters'); + }); + + it('announces that items might be hidden in server mode without totalRowCount', () => { + render( + + + + ); + act(() => { + vi.advanceTimersByTime(500); + }); + expect(screen.getByRole('status')).toHaveTextContent( + 'Some items might be hidden by filters' + ); + }); + }); + + it('names the icon-only add-filter and sort direction buttons', async () => { + const user = userEvent.setup(); + render( + + + + ); + expect( + screen.getByRole('button', { name: 'Add filter' }) + ).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Display' })); + await user.click( + screen.getByRole('button', { name: 'Sort direction: ascending' }) + ); + expect( + screen.getByRole('button', { name: 'Sort direction: descending' }) + ).toBeInTheDocument(); + }); }); describe('Multi-view', () => { diff --git a/packages/raystack/components/data-view/components/clear-filters.tsx b/packages/raystack/components/data-view/components/clear-filters.tsx index 80df7f8ae..4de60998d 100644 --- a/packages/raystack/components/data-view/components/clear-filters.tsx +++ b/packages/raystack/components/data-view/components/clear-filters.tsx @@ -1,7 +1,8 @@ 'use client'; import { cx } from 'class-variance-authority'; -import { useCallback } from 'react'; +import { useCallback, useEffect } from 'react'; +import { useDebouncedState } from '~/hooks'; import { XIcon } from '~/icons'; import { Button } from '../../button'; import { Flex } from '../../flex'; @@ -50,59 +51,87 @@ export function FilterSummary({ className }: DataViewClearFiltersProps) { (mode === 'server' || (typeof hiddenLeafRowCount === 'number' && hiddenLeafRowCount > 0)); + const label = + hiddenLeafRowCount === null + ? 'Some items might be hidden by filters' + : 'items hidden by filters'; + const message = !showFilterSummary + ? '' + : hiddenLeafRowCount === null + ? label + : `${hiddenLeafRowCount} ${label}`; + const [status, setStatus] = useDebouncedState('', 500); + useEffect(() => { + setStatus(message); + }, [message, setStatus]); + const handleClearFilters = useCallback(() => { updateTableQuery(prev => ({ ...prev, filters: [], search: '' })); }, [updateTableQuery]); - // Matches DataTable: render only when rows are hidden by filters. - // `isEmptyState` controls styling (bordered panel), not visibility. - if (!showFilterSummary) return null; - return ( - - {mode === 'server' && hiddenLeafRowCount === null ? ( - + {/* Always rendered: a live region announces only text that changes after it mounts. */} + + {status} + + {/* Matches DataTable: render only when rows are hidden by filters. + `isEmptyState` controls styling (bordered panel), not visibility. */} + {showFilterSummary && ( + - Some items might be hidden by filters - - ) : ( - - - {hiddenLeafRowCount} - - + {label} + + ) : ( + + + {hiddenLeafRowCount} + + + {label} + + + )} + )} - - + ); } diff --git a/packages/raystack/components/data-view/components/filters.tsx b/packages/raystack/components/data-view/components/filters.tsx index c2e55708f..deac06bf0 100644 --- a/packages/raystack/components/data-view/components/filters.tsx +++ b/packages/raystack/components/data-view/components/filters.tsx @@ -50,6 +50,7 @@ function AddFilter({ diff --git a/packages/raystack/components/data-view/components/ordering.tsx b/packages/raystack/components/data-view/components/ordering.tsx index 791e00a96..d044172bd 100644 --- a/packages/raystack/components/data-view/components/ordering.tsx +++ b/packages/raystack/components/data-view/components/ordering.tsx @@ -69,6 +69,11 @@ export function Ordering({ columnList, onChange, value }: OrderingProps) { onClick={handleOrderChange} size={4} disabled={columnList.length === 0} + aria-label={ + value.order === SortOrders.ASC + ? 'Sort direction: ascending' + : 'Sort direction: descending' + } data-slot='data-view-ordering-direction' >