From f520cc6597a242421660137974a71562b1fd8a4f Mon Sep 17 00:00:00 2001
From: AK <144495202+AKnassa@users.noreply.github.com>
Date: Mon, 3 Aug 2026 15:15:31 -0400
Subject: [PATCH] fix(Table): drop row tree ARIA outside treegrid
axe aria-conditional-attr (serious): aria-expanded and aria-level are
valid on rows only inside a treegrid, and Astryx Table is a native
table. The tree and groupedRows plugins set them on every
,
firing on all 10 baselined TableTree/TableGroupedRows stories plus
the never-baselined Row Click Expansion story.
Rows now carry no tree ARIA; expansion state stays announced by the
chevron buttons (rowExpansion-plugin pattern). The per-row
ref/subscription machinery existed solely to apply row ARIA and is
removed with it (-77 lines). Trade-off recorded in the changeset:
depth is currently visual-only via indentation; a programmatic depth
cue for AT is a tracked follow-up. Baseline: -10 entries.
Verified: 474/474 Table tests, core typecheck, eslint clean, axe
audit 0 violations across all 12 stories with --fail-on-new.
Part of #4681.
---
.changeset/a11y-table-row-aria-expanded.md | 6 ++
.github/a11y-baseline.json | 50 ------------
.../groupedRows/useTableGroupedRows.test.tsx | 13 +++-
.../groupedRows/useTableGroupedRows.tsx | 1 -
.../plugins/tree/useTableTreeData.test.tsx | 44 ++++++-----
.../Table/plugins/tree/useTableTreeData.tsx | 77 ++++---------------
.../core/src/Table/useTableTreeData.doc.mjs | 4 +-
7 files changed, 60 insertions(+), 135 deletions(-)
create mode 100644 .changeset/a11y-table-row-aria-expanded.md
diff --git a/.changeset/a11y-table-row-aria-expanded.md b/.changeset/a11y-table-row-aria-expanded.md
new file mode 100644
index 000000000000..155340235faa
--- /dev/null
+++ b/.changeset/a11y-table-row-aria-expanded.md
@@ -0,0 +1,6 @@
+---
+'@astryxdesign/core': patch
+---
+
+[fix] Table tree + groupedRows plugins: no longer set row-level tree ARIA on `
` 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
diff --git a/.github/a11y-baseline.json b/.github/a11y-baseline.json
index 84e9c710ad67..233fa1576f2b 100644
--- a/.github/a11y-baseline.json
+++ b/.github/a11y-baseline.json
@@ -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",
@@ -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",
diff --git a/packages/core/src/Table/plugins/groupedRows/useTableGroupedRows.test.tsx b/packages/core/src/Table/plugins/groupedRows/useTableGroupedRows.test.tsx
index cc5bffd7daf6..12155277ce28 100644
--- a/packages/core/src/Table/plugins/groupedRows/useTableGroupedRows.test.tsx
+++ b/packages/core/src/Table/plugins/groupedRows/useTableGroupedRows.test.tsx
@@ -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();
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', () => {
diff --git a/packages/core/src/Table/plugins/groupedRows/useTableGroupedRows.tsx b/packages/core/src/Table/plugins/groupedRows/useTableGroupedRows.tsx
index 4ec1b0a18235..81e0bb1b5ef7 100644
--- a/packages/core/src/Table/plugins/groupedRows/useTableGroupedRows.tsx
+++ b/packages/core/src/Table/plugins/groupedRows/useTableGroupedRows.tsx
@@ -306,7 +306,6 @@ export function useTableGroupedRows>(
// 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: (
diff --git a/packages/core/src/Table/plugins/tree/useTableTreeData.test.tsx b/packages/core/src/Table/plugins/tree/useTableTreeData.test.tsx
index bdab599a1667..6c858ba73f1f 100644
--- a/packages/core/src/Table/plugins/tree/useTableTreeData.test.tsx
+++ b/packages/core/src/Table/plugins/tree/useTableTreeData.test.tsx
@@ -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();
- 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();
- 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');
});
@@ -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(
,
);
- 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();
@@ -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 *');
@@ -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();
const srcRow = getRowByText('src');
@@ -639,7 +639,7 @@ describe('useTableTreeData — degenerate configurations', () => {
expect(() => render()).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',
@@ -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(
,
);
- expect(getRowByText('mover')).toHaveAttribute('aria-level', '1');
+ expect(moverIndent()).not.toContain('calc');
+ expect(getRowByText('mover')).not.toHaveAttribute('aria-level');
rerender();
- expect(getRowByText('mover')).toHaveAttribute('aria-level', '2');
+ expect(moverIndent()).toContain('calc(1 *');
+ expect(getRowByText('mover')).not.toHaveAttribute('aria-level');
});
});
diff --git a/packages/core/src/Table/plugins/tree/useTableTreeData.tsx b/packages/core/src/Table/plugins/tree/useTableTreeData.tsx
index 8d1d6ed772f4..486699c238bb 100644
--- a/packages/core/src/Table/plugins/tree/useTableTreeData.tsx
+++ b/packages/core/src/Table/plugins/tree/useTableTreeData.tsx
@@ -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
- * , 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
+ * . 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
@@ -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,
@@ -192,27 +193,6 @@ function useRowMetaSnapshot>(
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
// =============================================================================
@@ -453,8 +433,7 @@ export function useTableTreeData>(
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();
});
@@ -606,35 +585,11 @@ export function useTableTreeData>(
},
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 = 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 .
+ // 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
@@ -646,13 +601,13 @@ export function useTableTreeData>(
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) => {
// Don't hijack clicks on interactive cell content (the chevron
// already stops propagation, but a composed selection checkbox,
@@ -671,7 +626,7 @@ export function useTableTreeData>(
cfg.onToggleItem(item);
},
},
- xstyle: [...withRef.xstyle, treeStyles.clickableRow],
+ xstyle: [...props.xstyle, treeStyles.clickableRow],
};
},
};
diff --git a/packages/core/src/Table/useTableTreeData.doc.mjs b/packages/core/src/Table/useTableTreeData.doc.mjs
index 7d457699507f..20f2f44ce937 100644
--- a/packages/core/src/Table/useTableTreeData.doc.mjs
+++ b/packages/core/src/Table/useTableTreeData.doc.mjs
@@ -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',
@@ -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}',