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
2 changes: 1 addition & 1 deletion apps/www/src/content/docs/components/command/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 4 additions & 0 deletions apps/www/src/content/docs/dataview/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
25 changes: 25 additions & 0 deletions packages/raystack/components/command/__tests__/command.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Command>
<Command.Input placeholder='Search' />
<Command.Content>
<Command.Item>Calendar</Command.Item>
<Command.Item disabled onClick={handleClick}>
Billing
</Command.Item>
</Command.Content>
</Command>
);

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', () => {
Expand Down
39 changes: 7 additions & 32 deletions packages/raystack/components/command/command-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ export const CommandItem = ({
value: providedValue,
leadingIcon,
trailingIcon,
disabled,
...props
}: CommandItemProps) => {
const value =
Expand All @@ -41,8 +40,13 @@ export const CommandItem = ({
if (!isMatched) return null;
}

const content = (
<>
return (
<AutocompletePrimitive.Item
data-slot='command-item'
value={value}
className={cx(styles.item, className)}
{...props}
>
{leadingIcon && (
<span data-slot='command-item-leading-icon' className={styles.itemIcon}>
{leadingIcon}
Expand All @@ -59,35 +63,6 @@ export const CommandItem = ({
{trailingIcon}
</span>
)}
</>
);

/**
* 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 (
<div
data-slot='command-item'
className={cx(styles.item, className)}
role='option'
data-disabled={true}
aria-disabled={true}
>
{content}
</div>
);
}

return (
<AutocompletePrimitive.Item
data-slot='command-item'
value={value}
className={cx(styles.item, className)}
{...props}
>
{content}
</AutocompletePrimitive.Item>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ describe('DataView data-slot contract', () => {
</DataView>
);
expectSlots(container, [
'data-view-filter-summary-status',
'data-view-filter-summary',
'data-view-filter-summary-text',
'data-view-filter-summary-count',
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(
<DataView
data={mockData}
fields={mockFields}
defaultSort={defaultSort}
>
<DataView.Toolbar>
<DataView.Search />
</DataView.Toolbar>
<DataView.List variant='table' columns={mockColumns} />
</DataView>
);
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(
<DataView
data={mockData}
fields={mockFields}
defaultSort={defaultSort}
mode='server'
query={{
filters: [{ name: 'name', operator: 'neq', value: 'John Doe' }]
}}
>
<DataView.List variant='table' columns={mockColumns} />
</DataView>
);
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(
<DataView
data={mockData}
fields={mockFields}
defaultSort={defaultSort}
query={{ filters: [{ name: 'name', operator: 'eq', value: 'x' }] }}
>
<DataView.Toolbar />
</DataView>
);
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', () => {
Expand Down
119 changes: 74 additions & 45 deletions packages/raystack/components/data-view/components/clear-filters.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 (
<Flex
className={cx(
styles.filterSummaryFooter,
isEmptyState && styles.filterSummaryFooterEmpty,
className
)}
justify='center'
align='center'
data-slot='data-view-filter-summary'
>
{mode === 'server' && hiddenLeafRowCount === null ? (
<span
className={styles.filterSummaryLabel}
data-slot='data-view-filter-summary-label'
<>
{/* Always rendered: a live region announces only text that changes after it mounts. */}
<span
role='status'
className={styles['sr-only']}
data-slot='data-view-filter-summary-status'
>
{status}
</span>
{/* Matches DataTable: render only when rows are hidden by filters.
`isEmptyState` controls styling (bordered panel), not visibility. */}
{showFilterSummary && (
<Flex
className={cx(
styles.filterSummaryFooter,
isEmptyState && styles.filterSummaryFooterEmpty,
className
)}
justify='center'
align='center'
data-slot='data-view-filter-summary'
>
Some items might be hidden by filters
</span>
) : (
<Flex align='center' gap={2} data-slot='data-view-filter-summary-text'>
<span
className={styles.filterSummaryCount}
data-slot='data-view-filter-summary-count'
>
{hiddenLeafRowCount}
</span>
<span
className={styles.filterSummaryLabel}
data-slot='data-view-filter-summary-label'
{hiddenLeafRowCount === null ? (
<span
className={styles.filterSummaryLabel}
data-slot='data-view-filter-summary-label'
>
{label}
</span>
) : (
<Flex
align='center'
gap={2}
data-slot='data-view-filter-summary-text'
>
<span
className={styles.filterSummaryCount}
data-slot='data-view-filter-summary-count'
>
{hiddenLeafRowCount}
</span>
<span
className={styles.filterSummaryLabel}
data-slot='data-view-filter-summary-label'
>
{label}
</span>
</Flex>
)}
<Button
variant='text'
color='neutral'
size='small'
trailingIcon={<XIcon />}
onClick={handleClearFilters}
data-slot='data-view-filter-summary-clear'
>
items hidden by filters
</span>
Clear Filters
</Button>
</Flex>
)}
<Button
variant='text'
color='neutral'
size='small'
trailingIcon={<XIcon />}
onClick={handleClearFilters}
data-slot='data-view-filter-summary-clear'
>
Clear Filters
</Button>
</Flex>
</>
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ function AddFilter<TData>({
<IconButton
size={4}
className={className}
aria-label='Add filter'
data-slot='data-view-add-filter'
>
<FilterIcon />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ export function Ordering({ columnList, onChange, value }: OrderingProps) {
onClick={handleOrderChange}
size={4}
disabled={columnList.length === 0}
aria-label={
Comment thread
rohanchkrabrty marked this conversation as resolved.
value.order === SortOrders.ASC
? 'Sort direction: ascending'
: 'Sort direction: descending'
}
data-slot='data-view-ordering-direction'
>
<SortDescendingIcon
Expand Down
Loading
Loading