Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/a11y-table-row-aria-expanded.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@astryxdesign/core': patch
---

[fix] Table tree + groupedRows plugins: no longer set row-level tree ARIA on `<tr>` elements — aria-expanded (both plugins) and aria-level (tree plugin) are only valid on rows inside a treegrid (axe aria-conditional-attr), and Astryx Table is a native table. Expansion state stays announced by the expander/chevron buttons, which already carry aria-expanded (same pattern as the rowExpansion plugin). Note the trade-off: tree depth is now conveyed visually by indentation only — the removed aria-level was invalid in this context (and unreliably announced because of it), but assistive technology currently gets no programmatic depth cue; announcing depth (for example via the expander's accessible name) is a tracked follow-up. If you queried `tr[aria-expanded]` or `tr[aria-level]` in tests, target the button or the row's content instead.
@AKnassa
50 changes: 0 additions & 50 deletions .github/a11y-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -973,26 +973,11 @@
"impact": "serious",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/color-contrast?application=playwright"
},
{
"key": "TableGroupedRows::Custom Order And Header::aria-conditional-attr",
"impact": "serious",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/aria-conditional-attr?application=playwright"
},
{
"key": "TableGroupedRows::Default::aria-conditional-attr",
"impact": "serious",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/aria-conditional-attr?application=playwright"
},
{
"key": "TableGroupedRows::Default::color-contrast",
"impact": "serious",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/color-contrast?application=playwright"
},
{
"key": "TableGroupedRows::Initially Collapsed::aria-conditional-attr",
"impact": "serious",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/aria-conditional-attr?application=playwright"
},
{
"key": "TableGroupedRows::Initially Collapsed::color-contrast",
"impact": "serious",
Expand All @@ -1018,41 +1003,6 @@
"impact": "minor",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/empty-table-header?application=playwright"
},
{
"key": "TableTree::Default::aria-conditional-attr",
"impact": "serious",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/aria-conditional-attr?application=playwright"
},
{
"key": "TableTree::Expand And Collapse All::aria-conditional-attr",
"impact": "serious",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/aria-conditional-attr?application=playwright"
},
{
"key": "TableTree::Header Expand All Control::aria-conditional-attr",
"impact": "serious",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/aria-conditional-attr?application=playwright"
},
{
"key": "TableTree::Indent Sizes::aria-conditional-attr",
"impact": "serious",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/aria-conditional-attr?application=playwright"
},
{
"key": "TableTree::Lazy Loaded Children::aria-conditional-attr",
"impact": "serious",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/aria-conditional-attr?application=playwright"
},
{
"key": "TableTree::With Selection::aria-conditional-attr",
"impact": "serious",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/aria-conditional-attr?application=playwright"
},
{
"key": "TableTree::With Sibling Sorting::aria-conditional-attr",
"impact": "serious",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/aria-conditional-attr?application=playwright"
},
{
"key": "Text::Color Variants::color-contrast",
"impact": "serious",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,19 @@ describe('useTableGroupedRows', () => {
expect(coreToggle).toHaveAttribute('aria-expanded', 'true');
});

it('sets aria-expanded on the header row reflecting collapse state', () => {
it('keeps aria-expanded off header rows — the chevron button carries it (axe aria-conditional-attr: rows outside a treegrid)', () => {
render(<Harness initialCollapsed={new Set(['Core'])} />);
const rows = screen.getAllByRole('row');
// rows[1] = Core header (collapsed), rows[2] = Infra header (expanded).
expect(rows[1]).toHaveAttribute('aria-expanded', 'false');
expect(rows[2]).toHaveAttribute('aria-expanded', 'true');
expect(rows[1]).not.toHaveAttribute('aria-expanded');
expect(rows[2]).not.toHaveAttribute('aria-expanded');
// The collapse state lives on the chevron buttons instead.
expect(
screen.getByRole('button', {name: 'Expand group Core'}),
).toHaveAttribute('aria-expanded', 'false');
expect(
screen.getByRole('button', {name: 'Collapse group Infra'}),
).toHaveAttribute('aria-expanded', 'true');
});

it('respects groupOrder in the flattened data', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,6 @@ export function useTableGroupedRows<T extends Record<string, unknown>>(
// button below is the accessible, keyboard-operable control, so the
// row keeps its implicit `row` role (no role override here).
onClick: toggle,
'aria-expanded': !collapsed,
},
xstyle: [...props.xstyle, styles.headerRow],
children: (
Expand Down
44 changes: 26 additions & 18 deletions packages/core/src/Table/plugins/tree/useTableTreeData.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -317,23 +317,20 @@ describe('useTableTreeData — row-click expansion', () => {
// =============================================================================

describe('useTableTreeData — row ARIA', () => {
it('sets 1-based aria-level on every body row', () => {
it('never sets aria-level on rows — the indent conveys hierarchy (axe aria-conditional-attr: rows outside a treegrid)', () => {
render(<TreeTable defaultExpandedIds={['src', 'components']} />);

expect(getRowByText('src')).toHaveAttribute('aria-level', '1');
expect(getRowByText('components')).toHaveAttribute('aria-level', '2');
expect(getRowByText('Button.tsx')).toHaveAttribute('aria-level', '3');
expect(getRowByText('README.md')).toHaveAttribute('aria-level', '1');
expect(getRowByText('src')).not.toHaveAttribute('aria-level');
expect(getRowByText('components')).not.toHaveAttribute('aria-level');
expect(getRowByText('Button.tsx')).not.toHaveAttribute('aria-level');
expect(getRowByText('README.md')).not.toHaveAttribute('aria-level');
});

it('sets aria-expanded on expandable rows and omits it on leaves', () => {
it('never sets aria-expanded on rows — only the expander button carries it (axe aria-conditional-attr: rows outside a treegrid)', () => {
render(<TreeTable defaultExpandedIds={['src']} />);

expect(getRowByText('src')).toHaveAttribute('aria-expanded', 'true');
expect(getRowByText('components')).toHaveAttribute(
'aria-expanded',
'false',
);
expect(getRowByText('src')).not.toHaveAttribute('aria-expanded');
expect(getRowByText('components')).not.toHaveAttribute('aria-expanded');
expect(getRowByText('utils.ts')).not.toHaveAttribute('aria-expanded');
expect(getRowByText('README.md')).not.toHaveAttribute('aria-expanded');
});
Expand Down Expand Up @@ -410,11 +407,12 @@ describe('useTableTreeData — stability when the data shape changes', () => {
}
});

it('removes tree ARIA from rows when nested data becomes flat', () => {
it('keeps rows free of tree ARIA when nested data becomes flat', () => {
const {rerender} = render(
<TreeTable data={fileTree} defaultExpandedIds={['src']} />,
);
expect(getRowByText('src')).toHaveAttribute('aria-level', '1');
// Nested too: rows never carry aria-level (axe aria-conditional-attr).
expect(getRowByText('src')).not.toHaveAttribute('aria-level');

rerender(<TreeTable data={flat} />);

Expand Down Expand Up @@ -533,7 +531,9 @@ describe('useTableTreeData — indentation', () => {
);

const leafRow = getRowByText('leaf-e');
expect(leafRow).toHaveAttribute('aria-level', '5');
// Depth is conveyed by the indent, never aria-level (axe
// aria-conditional-attr: rows outside a treegrid).
expect(leafRow).not.toHaveAttribute('aria-level');
const wrapper = within(leafRow).getByText('leaf-e').closest('td')
?.firstElementChild as HTMLElement;
expect(wrapper.getAttribute('style')).toContain('calc(4 *');
Expand All @@ -559,7 +559,7 @@ describe('useTableTreeData — treeColumnKey', () => {

it('falls back to the first column when the configured column is absent', () => {
// e.g. columnSettings hid the configured tree column — the expander
// must not vanish while rows still announce aria-expanded.
// must not vanish: the button is the sole carrier of expansion state.
render(<TreeTable treeColumnKey="not-a-column" />);

const srcRow = getRowByText('src');
Expand Down Expand Up @@ -639,7 +639,7 @@ describe('useTableTreeData — degenerate configurations', () => {
expect(() => render(<ZeroColumnsTable />)).not.toThrow();
});

it('updates aria-level in place when a row is reparented deeper', () => {
it('updates the indent in place when a row is reparented deeper, without aria-level (axe aria-conditional-attr)', () => {
const flat: FileRow[] = [
{
id: 'a',
Expand All @@ -661,14 +661,22 @@ describe('useTableTreeData — degenerate configurations', () => {
},
];

const moverIndent = () =>
(
within(getRowByText('mover')).getByText('mover').closest('td')
?.firstElementChild as HTMLElement
).getAttribute('style') ?? '';

const {rerender} = render(
<TreeTable data={flat} defaultExpandedIds={['a']} />,
);
expect(getRowByText('mover')).toHaveAttribute('aria-level', '1');
expect(moverIndent()).not.toContain('calc');
expect(getRowByText('mover')).not.toHaveAttribute('aria-level');

rerender(<TreeTable data={nested} defaultExpandedIds={['a']} />);

expect(getRowByText('mover')).toHaveAttribute('aria-level', '2');
expect(moverIndent()).toContain('calc(1 *');
expect(getRowByText('mover')).not.toHaveAttribute('aria-level');
});
});

Expand Down
77 changes: 16 additions & 61 deletions packages/core/src/Table/plugins/tree/useTableTreeData.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@
*
* Expansion state flows through an external store (TreeStore) so each
* row's expander subscribes independently — a toggle re-renders only the
* affected cells, not the whole body. Row ARIA (aria-level,
* aria-expanded) is applied imperatively via a ref callback on each
* <tr>, exactly like selection's row styling; each subscription
* self-cleans when the row disconnects.
* affected cells, not the whole body. Rows carry no tree ARIA at all:
* aria-level and aria-expanded are only valid on role=row inside a
* treegrid (axe aria-conditional-attr), and the host is a native
* <table>. Hierarchy is conveyed by the tree column's indent; expansion
* state by aria-expanded on the expander button.
*
* When `hasExpandableRows` is false (flat data), every transform is a
* pass-through: adopting the plugin ahead of hierarchical data is a
Expand All @@ -42,7 +43,7 @@ import {
import * as stylex from '@stylexjs/stylex';
import {colorVars, radiusVars, spacingVars} from '../../../theme/tokens.stylex';
import {Icon} from '../../../Icon';
import {mergeRefs, rtlStyles} from '../../../utils';
import {rtlStyles} from '../../../utils';
import type {
TablePlugin,
TableColumn,
Expand Down Expand Up @@ -192,27 +193,6 @@ function useRowMetaSnapshot<T extends Record<string, unknown>>(
return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}

// =============================================================================
// Row ARIA (imperative, mirrors selection's row styling)
// =============================================================================

function applyRowTreeAria(
el: HTMLTableRowElement,
meta: TableTreeRowMeta | undefined,
): void {
if (!meta) {
el.removeAttribute('aria-level');
el.removeAttribute('aria-expanded');
return;
}
el.setAttribute('aria-level', String(meta.level + 1));
if (meta.hasChildren) {
el.setAttribute('aria-expanded', String(meta.isExpanded));
} else {
el.removeAttribute('aria-expanded');
}
}

// =============================================================================
// Styles
// =============================================================================
Expand Down Expand Up @@ -453,8 +433,7 @@ export function useTableTreeData<T extends Record<string, unknown>>(
const store = storeRef.current;

// Notify subscribers on every render — useSyncExternalStore only
// re-renders cells whose snapshot actually changed. Row ref subscribers
// apply imperative ARIA independently.
// re-renders cells whose snapshot actually changed.
useEffect(() => {
store.notify();
});
Expand Down Expand Up @@ -606,35 +585,11 @@ export function useTableTreeData<T extends Record<string, unknown>>(
},

transformBodyRow(props: BodyRowRenderProps, item: T) {
// Attach a ref that subscribes to the store for imperative row
// ARIA. The ref returns a cleanup so React unsubscribes on
// detach — without it, every row re-render would leak one
// subscription (toggles shift rowIndex and re-render rows, so
// the listener set would grow on every toggle). The ref is
// attached even when no row is expandable so tree ARIA is
// removed if the data turns flat.
const treeRef: React.RefCallback<HTMLTableRowElement> = el => {
if (!el) {
return;
}
const apply = () => {
const cfg = store.getConfig();
applyRowTreeAria(
el,
cfg.hasExpandableRows ? cfg.getRowMeta(item) : undefined,
);
};
apply();
const unsub = store.subscribe(apply);
return () => {
unsub();
};
};

const withRef = {
...props,
ref: props.ref ? mergeRefs(props.ref, treeRef) : treeRef,
};
// Rows deliberately carry no tree ARIA: aria-level and
// aria-expanded are only valid on role=row inside a treegrid
// (axe aria-conditional-attr), and the host is a native <table>.
// Hierarchy is conveyed by the tree column's indent; expansion
// state by aria-expanded on the expander button.

// Whole-row-click expansion (opt-in). Only expandable rows are
// clickable; leaves and flat data stay inert. `hasExpandableRows` is
Expand All @@ -646,13 +601,13 @@ export function useTableTreeData<T extends Record<string, unknown>>(
cfg.hasExpandableRows &&
cfg.getRowMeta(item)?.hasChildren === true;
if (!rowClickExpandable) {
return withRef;
return props;
}

return {
...withRef,
...props,
htmlProps: {
...withRef.htmlProps,
...props.htmlProps,
onClick: (event: React.MouseEvent<HTMLTableRowElement>) => {
// Don't hijack clicks on interactive cell content (the chevron
// already stops propagation, but a composed selection checkbox,
Expand All @@ -671,7 +626,7 @@ export function useTableTreeData<T extends Record<string, unknown>>(
cfg.onToggleItem(item);
},
},
xstyle: [...withRef.xstyle, treeStyles.clickableRow],
xstyle: [...props.xstyle, treeStyles.clickableRow],
};
},
};
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/Table/useTableTreeData.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const docs = {
subComponentOf: 'Table',
displayName: 'useTableTreeData',
description:
'Headless tree plugin for Table: renders nested rows with per-level indentation and expand/collapse chevrons in the tree column (the first column by default), and reflects hierarchy on body rows via aria-level and aria-expanded. Composable with the other Table plugins: the canonical plugin order places tree before selection, so the checkbox column lands left of the indented tree column. Feed it the treeConfig from useTableTreeState, or construct the config directly for server-driven or pre-flattened trees. When no row is expandable (flat data), every transform is a pass-through and the table renders identically to one without the plugin. Known limitation: the tree column wraps its cell content, so textOverflow="truncate" tooltips do not apply within the tree column.',
'Headless tree plugin for Table: renders nested rows with per-level indentation and expand/collapse chevrons in the tree column (the first column by default), with no tree ARIA on body rows (aria-level and aria-expanded are only valid in a treegrid; expansion state is announced by aria-expanded on the expander button, while depth is currently conveyed visually by the indentation only — a programmatic depth cue for assistive technology is a known follow-up). Composable with the other Table plugins: the canonical plugin order places tree before selection, so the checkbox column lands left of the indented tree column. Feed it the treeConfig from useTableTreeState, or construct the config directly for server-driven or pre-flattened trees. When no row is expandable (flat data), every transform is a pass-through and the table renders identically to one without the plugin. Known limitation: the tree column wraps its cell content, so textOverflow="truncate" tooltips do not apply within the tree column.',
props: [
{
name: 'getRowMeta',
Expand Down Expand Up @@ -92,7 +92,7 @@ export const docsDense = {
name: 'useTableTreeData',
displayName: 'useTableTreeData',
description:
'Headless tree plugin: indent + expander chevron on the tree column (first column by default), aria-level/aria-expanded on body rows. Canonical plugin order puts tree before selection (checkbox column lands left of tree column). Consume treeConfig from useTableTreeState, or construct directly for server-driven trees. hasExpandableRows=false => full no-op (flat-data migration).',
'Headless tree plugin: indent + expander chevron on the tree column (first column by default), no tree ARIA on body rows (not a treegrid; depth is visual-only via indent, AT depth cue = known follow-up), aria-expanded on the expander button. Canonical plugin order puts tree before selection (checkbox column lands left of tree column). Consume treeConfig from useTableTreeState, or construct directly for server-driven trees. hasExpandableRows=false => full no-op (flat-data migration).',
propDescriptions: {
getRowMeta:
'structural meta per visible row: {id, level (0-based), hasChildren, isExpanded}',
Expand Down
Loading