From 05db2baa943e354d730b07345c3af6da9a99ee91 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Tue, 29 Sep 2026 15:58:15 +0530 Subject: [PATCH 1/4] fix: [command, data-view] close keyboard and screen reader gaps Command.Item passes disabled to the Base UI item, so arrow keys reach disabled items and Enter does not activate them. DataView.Toolbar announces the result count in a role="status" region while a search or filter is active. The add-filter and sort direction icon buttons have accessible names. --- .../content/docs/components/command/index.mdx | 2 +- apps/www/src/content/docs/dataview/index.mdx | 4 ++ .../command/__tests__/command.test.tsx | 25 ++++++++ .../components/command/command-item.tsx | 39 ++--------- .../data-view/__tests__/data-slots.test.tsx | 1 + .../data-view/__tests__/data-view.test.tsx | 64 +++++++++++++++++++ .../data-view/components/filters.tsx | 1 + .../data-view/components/ordering.tsx | 5 ++ .../data-view/components/toolbar.tsx | 37 ++++++----- .../components/data-view/data-view.module.css | 12 ++++ 10 files changed, 141 insertions(+), 49 deletions(-) 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..b564a7db1 100644 --- a/apps/www/src/content/docs/dataview/index.mdx +++ b/apps/www/src/content/docs/dataview/index.mdx @@ -260,6 +260,7 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test | Slot | Element | |------|---------| | `data-view-toolbar` | Toolbar container | +| `data-view-toolbar-status` | Visually hidden result-count live region | | `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 | @@ -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. +- `DataView.Toolbar` has a visually hidden `role="status"` region. While a + search or filter is active, it announces the number of matching rows, for + example "3 results". In server mode the count is the rows loaded so far. - 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..aaa358109 100644 --- a/packages/raystack/components/data-view/__tests__/data-slots.test.tsx +++ b/packages/raystack/components/data-view/__tests__/data-slots.test.tsx @@ -183,6 +183,7 @@ describe('DataView data-slot contract', () => { ); expectSlots(container, [ 'data-view-toolbar', + 'data-view-toolbar-status', 'data-view-filters', 'data-view-add-filter', 'data-view-display-trigger', 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..7350cd7bb 100644 --- a/packages/raystack/components/data-view/__tests__/data-view.test.tsx +++ b/packages/raystack/components/data-view/__tests__/data-view.test.tsx @@ -381,6 +381,70 @@ describe('DataView', () => { expect(screen.queryByText('John Doe')).not.toBeInTheDocument(); expect(screen.getByText('Jane Smith')).toBeInTheDocument(); }); + + it('announces the result count while a search is active', async () => { + const user = userEvent.setup(); + render( + + + + + + + ); + const status = screen.getByRole('status'); + expect(status).toBeEmptyDOMElement(); + + const search = screen.getByRole('textbox'); + await user.type(search, 'jo'); + expect(status).toHaveTextContent('2 results'); + + await user.type(search, 'hn d'); + expect(status).toHaveTextContent('1 result'); + + await user.clear(search); + expect(status).toBeEmptyDOMElement(); + }); + + it('counts only data rows in the announced result count when grouped', () => { + render( + + + + ); + expect(screen.getByRole('status')).toHaveTextContent('2 results'); + }); + + 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 ascending' })); + expect( + screen.getByRole('button', { name: 'Sort descending' }) + ).toBeInTheDocument(); + }); }); describe('Multi-view', () => { 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..193458173 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 ascending' + : 'Sort descending' + } data-slot='data-view-ordering-direction' > ({ className, children }: PropsWithChildren) { - const { shouldShowFilters } = useDataView(); + const { shouldShowFilters, table, isLoading } = useDataView(); if (!shouldShowFilters) return null; - if (children) { - return ( - - {children} - - ); - } + const resultCount = + !isLoading && hasActiveTableFiltering(table) + ? countLeafRows(table.getFilteredRowModel().rows) + : null; return ( ({ align='start' data-slot='data-view-toolbar' > - /> - /> + {children || ( + <> + /> + /> + + )} + + {resultCount === null + ? '' + : `${resultCount} ${resultCount === 1 ? 'result' : 'results'}`} + ); } diff --git a/packages/raystack/components/data-view/data-view.module.css b/packages/raystack/components/data-view/data-view.module.css index 9035898cb..a2ca26de6 100644 --- a/packages/raystack/components/data-view/data-view.module.css +++ b/packages/raystack/components/data-view/data-view.module.css @@ -11,6 +11,18 @@ padding-left: var(--rs-space-7); } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + /* Display popover */ .display-popover-content { padding: 0px; From 56db4090a51f408b53d6b055602ec89f3fb7a757 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Mon, 5 Oct 2026 01:41:50 +0530 Subject: [PATCH 2/4] fix: [data-view] announce the filter summary instead of a result count Server mode has no count of matching results, and a count that changes on every keystroke makes screen readers announce once per key. The toolbar status now reads the footer text, debounced by 500ms. --- apps/www/src/content/docs/dataview/index.mdx | 9 +- .../data-view/__tests__/data-view.test.tsx | 105 +++++++++++------- .../data-view/components/clear-filters.tsx | 58 ++++++---- .../data-view/components/toolbar.tsx | 24 ++-- 4 files changed, 120 insertions(+), 76 deletions(-) diff --git a/apps/www/src/content/docs/dataview/index.mdx b/apps/www/src/content/docs/dataview/index.mdx index b564a7db1..a1dc111fd 100644 --- a/apps/www/src/content/docs/dataview/index.mdx +++ b/apps/www/src/content/docs/dataview/index.mdx @@ -260,7 +260,7 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test | Slot | Element | |------|---------| | `data-view-toolbar` | Toolbar container | -| `data-view-toolbar-status` | Visually hidden result-count live region | +| `data-view-toolbar-status` | Visually hidden live region for the filter summary | | `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 | @@ -298,9 +298,10 @@ 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. -- `DataView.Toolbar` has a visually hidden `role="status"` region. While a - search or filter is active, it announces the number of matching rows, for - example "3 results". In server mode the count is the rows loaded so far. +- `DataView.Toolbar` has a visually hidden `role="status"` region. It + announces the same text as the "hidden by filters" footer, 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/data-view/__tests__/data-view.test.tsx b/packages/raystack/components/data-view/__tests__/data-view.test.tsx index 7350cd7bb..d5cef2852 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'; @@ -382,45 +390,64 @@ describe('DataView', () => { expect(screen.getByText('Jane Smith')).toBeInTheDocument(); }); - it('announces the result count while a search is active', async () => { - const user = userEvent.setup(); - render( - - - - - - - ); - const status = screen.getByRole('status'); - expect(status).toBeEmptyDOMElement(); - - const search = screen.getByRole('textbox'); - await user.type(search, 'jo'); - expect(status).toHaveTextContent('2 results'); + describe('status region', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); - await user.type(search, 'hn d'); - expect(status).toHaveTextContent('1 result'); + afterEach(() => { + vi.useRealTimers(); + }); - await user.clear(search); - expect(status).toBeEmptyDOMElement(); - }); - - it('counts only data rows in the announced result count when grouped', () => { - render( - - - - ); - expect(screen.getByRole('status')).toHaveTextContent('2 results'); + 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 () => { diff --git a/packages/raystack/components/data-view/components/clear-filters.tsx b/packages/raystack/components/data-view/components/clear-filters.tsx index 80df7f8ae..a750c6723 100644 --- a/packages/raystack/components/data-view/components/clear-filters.tsx +++ b/packages/raystack/components/data-view/components/clear-filters.tsx @@ -18,24 +18,11 @@ export interface DataViewClearFiltersProps { } /** - * The filter-summary row plus a "Clear Filters" action. Reads everything from - * `DataView` context and renders nothing when there is nothing to clear. - * - * Flat by default (the footer `DataView.List` renders when rows are hidden by - * filters); a bordered panel in the empty state. Shared between the List footer - * and `DataView.ClearFilters` so the markup lives in one place. - * - * Internal, not exported from the package. + * What the filter summary shows, or `null` when it renders nothing. `count` is + * `null` in server mode without `totalRowCount`. */ -export function FilterSummary({ className }: DataViewClearFiltersProps) { - const { - table, - mode, - isLoading, - totalRowCount, - isEmptyState, - updateTableQuery - } = useDataView(); +export function useFilterSummary() { + const { table, mode, isLoading, totalRowCount } = useDataView(); const rows = table?.getRowModel()?.rows ?? []; const hiddenLeafRowCount = @@ -50,13 +37,40 @@ export function FilterSummary({ className }: DataViewClearFiltersProps) { (mode === 'server' || (typeof hiddenLeafRowCount === 'number' && hiddenLeafRowCount > 0)); + if (!showFilterSummary) return null; + if (hiddenLeafRowCount === null) { + const label = 'Some items might be hidden by filters'; + return { count: null, label, text: label }; + } + const label = 'items hidden by filters'; + return { + count: hiddenLeafRowCount, + label, + text: `${hiddenLeafRowCount} ${label}` + }; +} + +/** + * The filter-summary row plus a "Clear Filters" action. Reads everything from + * `DataView` context and renders nothing when there is nothing to clear. + * + * Flat by default (the footer `DataView.List` renders when rows are hidden by + * filters); a bordered panel in the empty state. Shared between the List footer + * and `DataView.ClearFilters` so the markup lives in one place. + * + * Internal, not exported from the package. + */ +export function FilterSummary({ className }: DataViewClearFiltersProps) { + const { isEmptyState, updateTableQuery } = useDataView(); + const summary = useFilterSummary(); + 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; + if (!summary) return null; return ( - {mode === 'server' && hiddenLeafRowCount === null ? ( + {summary.count === null ? ( - Some items might be hidden by filters + {summary.label} ) : ( @@ -82,13 +96,13 @@ export function FilterSummary({ className }: DataViewClearFiltersProps) { className={styles.filterSummaryCount} data-slot='data-view-filter-summary-count' > - {hiddenLeafRowCount} + {summary.count} - items hidden by filters + {summary.label} )} diff --git a/packages/raystack/components/data-view/components/toolbar.tsx b/packages/raystack/components/data-view/components/toolbar.tsx index 199196547..16283fac6 100644 --- a/packages/raystack/components/data-view/components/toolbar.tsx +++ b/packages/raystack/components/data-view/components/toolbar.tsx @@ -1,11 +1,12 @@ 'use client'; import { cx } from 'class-variance-authority'; -import { PropsWithChildren } from 'react'; +import { PropsWithChildren, useEffect } from 'react'; +import { useDebouncedState } from '~/hooks'; import { Flex } from '../../flex'; import styles from '../data-view.module.css'; import { useDataView } from '../hooks/useDataView'; -import { countLeafRows, hasActiveTableFiltering } from '../utils'; +import { useFilterSummary } from './clear-filters'; import { DisplayControls } from './display-controls'; import { Filters } from './filters'; @@ -24,13 +25,16 @@ export function Toolbar({ className, children }: PropsWithChildren) { - const { shouldShowFilters, table, isLoading } = useDataView(); - if (!shouldShowFilters) return null; + const { shouldShowFilters, isLoading } = useDataView(); + const summaryText = useFilterSummary()?.text ?? ''; + const [status, setStatus] = useDebouncedState('', 500); + + useEffect(() => { + // Keep the last text while loading so a refetch does not re-announce it. + setStatus(previous => (isLoading ? previous : summaryText)); + }, [isLoading, summaryText, setStatus]); - const resultCount = - !isLoading && hasActiveTableFiltering(table) - ? countLeafRows(table.getFilteredRowModel().rows) - : null; + if (!shouldShowFilters) return null; return ( ({ className={styles['sr-only']} data-slot='data-view-toolbar-status' > - {resultCount === null - ? '' - : `${resultCount} ${resultCount === 1 ? 'result' : 'results'}`} + {status} ); From 4f22db80484696bc947e0d0f2177388e6b6b1a08 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Mon, 5 Oct 2026 02:04:33 +0530 Subject: [PATCH 3/4] fix: [data-view] announce the filter summary from the footer The status region is a span inside FilterSummary, so List and Timeline announce the footer text. The span stays rendered when the footer is hidden, because a live region announces only text that changes after it mounts. --- apps/www/src/content/docs/dataview/index.mdx | 9 +- .../data-view/__tests__/data-slots.test.tsx | 2 +- .../data-view/__tests__/data-view.test.tsx | 3 +- .../data-view/components/clear-filters.tsx | 165 ++++++++++-------- .../data-view/components/toolbar.tsx | 43 ++--- 5 files changed, 115 insertions(+), 107 deletions(-) diff --git a/apps/www/src/content/docs/dataview/index.mdx b/apps/www/src/content/docs/dataview/index.mdx index a1dc111fd..c41e811ac 100644 --- a/apps/www/src/content/docs/dataview/index.mdx +++ b/apps/www/src/content/docs/dataview/index.mdx @@ -260,10 +260,10 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test | Slot | Element | |------|---------| | `data-view-toolbar` | Toolbar container | -| `data-view-toolbar-status` | Visually hidden live region for the filter summary | | `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 | @@ -298,10 +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. -- `DataView.Toolbar` has a visually hidden `role="status"` region. It - announces the same text as the "hidden by filters" footer, for example - "3 items hidden by filters", once the search and filters stop changing for - 500ms. +- 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/data-view/__tests__/data-slots.test.tsx b/packages/raystack/components/data-view/__tests__/data-slots.test.tsx index aaa358109..30e3a41a5 100644 --- a/packages/raystack/components/data-view/__tests__/data-slots.test.tsx +++ b/packages/raystack/components/data-view/__tests__/data-slots.test.tsx @@ -183,7 +183,6 @@ describe('DataView data-slot contract', () => { ); expectSlots(container, [ 'data-view-toolbar', - 'data-view-toolbar-status', 'data-view-filters', 'data-view-add-filter', 'data-view-display-trigger', @@ -254,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 d5cef2852..4291623c3 100644 --- a/packages/raystack/components/data-view/__tests__/data-view.test.tsx +++ b/packages/raystack/components/data-view/__tests__/data-view.test.tsx @@ -409,6 +409,7 @@ describe('DataView', () => { + ); const status = screen.getByRole('status'); @@ -438,7 +439,7 @@ describe('DataView', () => { filters: [{ name: 'name', operator: 'neq', value: 'John Doe' }] }} > - + ); act(() => { diff --git a/packages/raystack/components/data-view/components/clear-filters.tsx b/packages/raystack/components/data-view/components/clear-filters.tsx index a750c6723..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'; @@ -18,11 +19,24 @@ export interface DataViewClearFiltersProps { } /** - * What the filter summary shows, or `null` when it renders nothing. `count` is - * `null` in server mode without `totalRowCount`. + * The filter-summary row plus a "Clear Filters" action. Reads everything from + * `DataView` context and renders nothing when there is nothing to clear. + * + * Flat by default (the footer `DataView.List` renders when rows are hidden by + * filters); a bordered panel in the empty state. Shared between the List footer + * and `DataView.ClearFilters` so the markup lives in one place. + * + * Internal, not exported from the package. */ -export function useFilterSummary() { - const { table, mode, isLoading, totalRowCount } = useDataView(); +export function FilterSummary({ className }: DataViewClearFiltersProps) { + const { + table, + mode, + isLoading, + totalRowCount, + isEmptyState, + updateTableQuery + } = useDataView(); const rows = table?.getRowModel()?.rows ?? []; const hiddenLeafRowCount = @@ -37,86 +51,87 @@ export function useFilterSummary() { (mode === 'server' || (typeof hiddenLeafRowCount === 'number' && hiddenLeafRowCount > 0)); - if (!showFilterSummary) return null; - if (hiddenLeafRowCount === null) { - const label = 'Some items might be hidden by filters'; - return { count: null, label, text: label }; - } - const label = 'items hidden by filters'; - return { - count: hiddenLeafRowCount, - label, - text: `${hiddenLeafRowCount} ${label}` - }; -} - -/** - * The filter-summary row plus a "Clear Filters" action. Reads everything from - * `DataView` context and renders nothing when there is nothing to clear. - * - * Flat by default (the footer `DataView.List` renders when rows are hidden by - * filters); a bordered panel in the empty state. Shared between the List footer - * and `DataView.ClearFilters` so the markup lives in one place. - * - * Internal, not exported from the package. - */ -export function FilterSummary({ className }: DataViewClearFiltersProps) { - const { isEmptyState, updateTableQuery } = useDataView(); - const summary = useFilterSummary(); + 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 (!summary) return null; - return ( - - {summary.count === 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 && ( + - {summary.label} - - ) : ( - - - {summary.count} - - + {label} + + ) : ( + + + {hiddenLeafRowCount} + + + {label} + + + )} + )} - - + ); } diff --git a/packages/raystack/components/data-view/components/toolbar.tsx b/packages/raystack/components/data-view/components/toolbar.tsx index 16283fac6..96f3231ad 100644 --- a/packages/raystack/components/data-view/components/toolbar.tsx +++ b/packages/raystack/components/data-view/components/toolbar.tsx @@ -1,12 +1,10 @@ 'use client'; import { cx } from 'class-variance-authority'; -import { PropsWithChildren, useEffect } from 'react'; -import { useDebouncedState } from '~/hooks'; +import { PropsWithChildren } from 'react'; import { Flex } from '../../flex'; import styles from '../data-view.module.css'; import { useDataView } from '../hooks/useDataView'; -import { useFilterSummary } from './clear-filters'; import { DisplayControls } from './display-controls'; import { Filters } from './filters'; @@ -25,17 +23,23 @@ export function Toolbar({ className, children }: PropsWithChildren) { - const { shouldShowFilters, isLoading } = useDataView(); - const summaryText = useFilterSummary()?.text ?? ''; - const [status, setStatus] = useDebouncedState('', 500); - - useEffect(() => { - // Keep the last text while loading so a refetch does not re-announce it. - setStatus(previous => (isLoading ? previous : summaryText)); - }, [isLoading, summaryText, setStatus]); - + const { shouldShowFilters } = useDataView(); if (!shouldShowFilters) return null; + if (children) { + return ( + + {children} + + ); + } + return ( ({ align='start' data-slot='data-view-toolbar' > - {children || ( - <> - /> - /> - - )} - - {status} - + /> + /> ); } From a9cbb82f7a81caf1ed51b5fdc563a89d42ffdec5 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Mon, 5 Oct 2026 02:12:02 +0530 Subject: [PATCH 4/4] fix: [data-view] name the sort button by its current direction "Sort ascending" read as an action, but pressing it sorts descending. --- .../components/data-view/__tests__/data-view.test.tsx | 6 ++++-- .../raystack/components/data-view/components/ordering.tsx | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) 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 4291623c3..ee77ecfe4 100644 --- a/packages/raystack/components/data-view/__tests__/data-view.test.tsx +++ b/packages/raystack/components/data-view/__tests__/data-view.test.tsx @@ -468,9 +468,11 @@ describe('DataView', () => { ).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Display' })); - await user.click(screen.getByRole('button', { name: 'Sort ascending' })); + await user.click( + screen.getByRole('button', { name: 'Sort direction: ascending' }) + ); expect( - screen.getByRole('button', { name: 'Sort descending' }) + screen.getByRole('button', { name: 'Sort direction: descending' }) ).toBeInTheDocument(); }); }); diff --git a/packages/raystack/components/data-view/components/ordering.tsx b/packages/raystack/components/data-view/components/ordering.tsx index 193458173..d044172bd 100644 --- a/packages/raystack/components/data-view/components/ordering.tsx +++ b/packages/raystack/components/data-view/components/ordering.tsx @@ -71,8 +71,8 @@ export function Ordering({ columnList, onChange, value }: OrderingProps) { disabled={columnList.length === 0} aria-label={ value.order === SortOrders.ASC - ? 'Sort ascending' - : 'Sort descending' + ? 'Sort direction: ascending' + : 'Sort direction: descending' } data-slot='data-view-ordering-direction' >