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
6 changes: 5 additions & 1 deletion e2e/treegrid.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,13 @@ async function expectCellOrChildFocused(_page: Page, cell: Locator): Promise<voi
/**
* Helper to focus a cell, handling cells that contain links/buttons.
* Returns the focused element (either the cell or a focusable child).
*
* Uses programmatic focus (HTMLElement.focus()) rather than a mouse click so
* that click-side effects (e.g., the rowheader click handler toggling row
* expansion) do not fire just because a test wants to seed focus.
*/
async function focusCell(_page: Page, cell: Locator): Promise<Locator> {
await cell.click({ position: { x: 5, y: 5 } });
await cell.evaluate((el: HTMLElement) => el.focus());

// Check if focus is on the cell or a child element
const cellIsFocused = await cell.evaluate((el) => document.activeElement === el);
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "apg-patterns-examples",
"type": "module",
"version": "0.3.7",
"version": "0.3.8",
"private": false,
"description": "Accessible UI components following WAI-ARIA APG patterns. Multi-framework implementations in React, Vue, Svelte, and Astro with documentation and tests.",
"author": "masuP9",
Expand Down
14 changes: 14 additions & 0 deletions src/content/accessibility-docs/treegrid/en.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,20 @@ Total columns (for virtualization)
- Tree operations (expand/collapse) only work at the rowheader column
- Rows have aria-level to indicate hierarchy depth

### Mouse Support (non-APG extension)

APG does not specify mouse behavior for the treegrid pattern. This implementation adds a minimal mouse layer that preserves the cell-only focus model and keyboard semantics defined above.

| Action | Behavior |
| ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| Click any cell | Move focus to the cell (updates roving tabindex). Row selection (`aria-selected`) is unchanged |
| Click a rowheader of an expandable, non-disabled row | Above, plus toggle expand/collapse |
| Click on a row with `aria-disabled="true"` | Focus may move, but expansion is not toggled (matches arrow-key behavior on disabled rows) |
| Click on an interactive descendant (`<button>`, `<a>`, `<input>`, `<select>`, `<textarea>`) | The treegrid mouse handler defers to the descendant; no focus shuffle or expansion toggle |
| Double-click | No additional behavior. Cell activation remains <kbd>Enter</kbd>-only |

Row selection is intentionally kept as a <kbd>Space</kbd>-only operation so that the APG cell-only focus model is preserved.

### Focus Management

| Event | Behavior |
Expand Down
14 changes: 14 additions & 0 deletions src/content/accessibility-docs/treegrid/ja.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,20 @@ aria-labelの代替
- ツリー操作(展開/折りたたみ)はrowheader列でのみ機能します
- 行には階層の深さを示すaria-levelがあります

### マウスサポート(APG仕様外の拡張)

APG は treegrid パターンのマウス挙動を規定していません。本実装は、上記の cell-only focus model とキーボードのセマンティクスを保ったまま、最小限のマウス操作を加えています。

| 操作 | 振る舞い |
| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
| 任意のセルをクリック | そのセルにフォーカスを移動(roving tabindex を更新)。行選択 (`aria-selected`) は**変更されない** |
| 展開可能な非 disabled 行の rowheader をクリック | 上記に加え、展開/折りたたみをトグル |
| `aria-disabled="true"` の行のセルをクリック | フォーカスは移動するが、展開トグルは**起きない**(矢印キーの挙動と整合) |
| セル内のインタラクティブな子要素(`<button>` / `<a>` / `<input>` / `<select>` / `<textarea>`)をクリック | treegrid 側のハンドリングはスキップされ、子要素のデフォルト挙動に任せる |
| ダブルクリック | 追加挙動なし。セルのアクティベートは引き続き <kbd>Enter</kbd> キー専用 |

行選択(`aria-selected`)は引き続き <kbd>Space</kbd> キー専用とし、APG の cell-only focus model を保持します。

### フォーカス管理

| イベント | 振る舞い |
Expand Down
35 changes: 35 additions & 0 deletions src/patterns/treegrid/TreeGrid.astro
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ function getRowAriaSelected(flatRow: FlatRow): 'true' | 'false' | undefined {
(cell) => {
cell.addEventListener('keydown', this.handleKeyDown.bind(this) as EventListener);
cell.addEventListener('focusin', this.handleFocus.bind(this) as EventListener);
cell.addEventListener('click', this.handleCellClick.bind(this) as EventListener);
}
);
}
Expand All @@ -302,6 +303,7 @@ function getRowAriaSelected(flatRow: FlatRow): 'true' | 'false' | undefined {
(cell) => {
cell.removeEventListener('keydown', this.handleKeyDown.bind(this) as EventListener);
cell.removeEventListener('focusin', this.handleFocus.bind(this) as EventListener);
cell.removeEventListener('click', this.handleCellClick.bind(this) as EventListener);
}
);
}
Expand Down Expand Up @@ -355,6 +357,39 @@ function getRowAriaSelected(flatRow: FlatRow): 'true' | 'false' | undefined {
this.focusedCellId = cell.dataset.cellId ?? null;
}

// Non-APG extension: mouse click moves cell focus; clicking a rowheader of
// an expandable, non-disabled row toggles its expansion. Row selection
// (aria-selected) is intentionally not changed — that remains a Space-only
// operation, matching the APG cell-only focus model.
private handleCellClick(event: MouseEvent) {
const target = event.target;
if (target instanceof Element && target.closest('button, a, input, select, textarea')) {
return;
}

const cell = event.currentTarget as HTMLElement;
const { rowId, isRowHeader, disabled, colIndex: colIndexStr } = cell.dataset;
if (!rowId) return;

const row = this.querySelector<HTMLElement>(`[role="row"][data-row-id="${rowId}"]`);
const rowDisabled = row?.getAttribute('aria-disabled') === 'true';

// Focus moves regardless of disabled state, matching arrow-key navigation.
this.focusCell(cell);

// Expansion toggle is suppressed for disabled rows/cells.
if (disabled === 'true' || rowDisabled) return;

if (isRowHeader === 'true' && row?.dataset.hasChildren === 'true') {
const colIndex = Number(colIndexStr ?? 0);
if (this.expandedIds.has(rowId)) {
this.collapseRow(rowId, colIndex);
} else {
this.expandRow(rowId);
}
}
}

private expandRow(rowId: string) {
const row = this.querySelector<HTMLElement>(`[role="row"][data-row-id="${rowId}"]`);
if (!row) return;
Expand Down
34 changes: 34 additions & 0 deletions src/patterns/treegrid/TreeGrid.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,39 @@
}
}

// Non-APG extension: mouse click moves cell focus; clicking a rowheader of an
// expandable, non-disabled row toggles its expansion. Row selection
// (aria-selected) is intentionally not changed — that remains a Space-only
// operation, matching the APG cell-only focus model.
function handleCellClick(event: MouseEvent, cell: TreeGridCellData, rowId: string) {
if (
event.target instanceof Element &&
event.target.closest('button, a, input, select, textarea')
) {
return;
}

const pos = cellPositionMap.get(cell.id);
if (!pos) return;

const flatRow = visibleRows[pos.rowIndex];
if (!flatRow) return;

// Focus moves regardless of disabled state, matching arrow-key navigation.
focusCell(cell.id);

// Expansion toggle is suppressed for disabled rows/cells.
if (cell.disabled || flatRow.node.disabled) return;

if (pos.isRowHeader && flatRow.hasChildren) {
if (expandedIds.has(rowId)) {
collapseRow(rowId, cell.id);
} else {
expandRow(rowId);
}
}
}

// =============================================================================
// Helper Functions
// =============================================================================
Expand Down Expand Up @@ -561,6 +594,7 @@
class:disabled={cell.disabled || flatRow.node.disabled}
style:padding-left={getCellPaddingLeft(flatRow, colIndex)}
onkeydown={(e) => handleKeyDown(e, cell, flatRow.node.id)}
onclick={(e) => handleCellClick(e, cell, flatRow.node.id)}
onfocusin={() => setFocusedCellId(cell.id)}
use:registerCell={cell.id}
>
Expand Down
137 changes: 137 additions & 0 deletions src/patterns/treegrid/TreeGrid.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1165,4 +1165,141 @@ describe('TreeGrid', () => {
expect(onFocusChange).toHaveBeenCalled();
});
});

// ===========================================================================
// Mouse (non-APG extension)
// ===========================================================================
describe('Mouse interactions', () => {
it('clicking a rowheader of a collapsed parent expands it', async () => {
const user = userEvent.setup();
render(
<TreeGrid columns={createBasicColumns()} nodes={createBasicNodes()} ariaLabel="Files" />
);

const docsRowheader = screen.getByRole('rowheader', { name: 'Documents' });
expect(docsRowheader.closest('[role="row"]')).toHaveAttribute('aria-expanded', 'false');

await user.click(docsRowheader);

expect(docsRowheader.closest('[role="row"]')).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('rowheader', { name: 'report.pdf' })).toBeInTheDocument();
});

it('clicking a rowheader of an expanded parent collapses it', async () => {
const user = userEvent.setup();
render(
<TreeGrid
columns={createBasicColumns()}
nodes={createBasicNodes()}
ariaLabel="Files"
defaultExpandedIds={['docs']}
/>
);

const docsRowheader = screen.getByRole('rowheader', { name: 'Documents' });
expect(docsRowheader.closest('[role="row"]')).toHaveAttribute('aria-expanded', 'true');

await user.click(docsRowheader);

expect(docsRowheader.closest('[role="row"]')).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByRole('rowheader', { name: 'report.pdf' })).not.toBeInTheDocument();
});

it('clicking a non-rowheader cell does not toggle expansion', async () => {
const user = userEvent.setup();
render(
<TreeGrid columns={createBasicColumns()} nodes={createBasicNodes()} ariaLabel="Files" />
);

const sizeCell = screen
.getAllByRole('gridcell')
.find(
(el) =>
el.textContent === '--' &&
el.closest('[role="row"]')?.getAttribute('aria-level') === '1'
);
expect(sizeCell).toBeDefined();

await user.click(sizeCell!);

expect(sizeCell!.closest('[role="row"]')).toHaveAttribute('aria-expanded', 'false');
// Focus moves to the clicked cell
expect(sizeCell).toHaveAttribute('tabindex', '0');
});

it('clicking a cell moves focus and roving tabindex to it', async () => {
const user = userEvent.setup();
render(
<TreeGrid columns={createBasicColumns()} nodes={createBasicNodes()} ariaLabel="Files" />
);

const readmeRowheader = screen.getByRole('rowheader', { name: 'README.md' });
await user.click(readmeRowheader);

expect(readmeRowheader).toHaveAttribute('tabindex', '0');
expect(document.activeElement).toBe(readmeRowheader);
// The previously focusable cell should now be tabindex="-1"
const docsRowheader = screen.getByRole('rowheader', { name: 'Documents' });
expect(docsRowheader).toHaveAttribute('tabindex', '-1');
});

it('clicking a rowheader of a disabled row does not toggle expansion', async () => {
// Focus IS allowed to move into a disabled cell (matches arrow-key
// navigation: disabled cells are focusable per APG). Only the
// expansion toggle is suppressed.
const user = userEvent.setup();
const onExpandedChange = vi.fn();
render(
<TreeGrid
columns={createBasicColumns()}
nodes={createNodesWithDisabled()}
ariaLabel="Files"
onExpandedChange={onExpandedChange}
/>
);

const docsRowheader = screen.getByRole('rowheader', { name: 'Documents' });
expect(docsRowheader.closest('[role="row"]')).toHaveAttribute('aria-expanded', 'false');

await user.click(docsRowheader);

expect(docsRowheader.closest('[role="row"]')).toHaveAttribute('aria-expanded', 'false');
expect(onExpandedChange).not.toHaveBeenCalled();
});

it('clicking the chevron icon inside a rowheader also toggles expansion', async () => {
const user = userEvent.setup();
render(
<TreeGrid columns={createBasicColumns()} nodes={createBasicNodes()} ariaLabel="Files" />
);

const docsRowheader = screen.getByRole('rowheader', { name: 'Documents' });
const chevron = docsRowheader.querySelector('.apg-treegrid-expand-icon');
expect(chevron).not.toBeNull();

await user.click(chevron as Element);

expect(docsRowheader.closest('[role="row"]')).toHaveAttribute('aria-expanded', 'true');
});

it('clicking does not change row selection (aria-selected)', async () => {
const user = userEvent.setup();
render(
<TreeGrid
columns={createBasicColumns()}
nodes={createBasicNodes()}
ariaLabel="Files"
selectable
/>
);

const docsRowheader = screen.getByRole('rowheader', { name: 'Documents' });
const row = docsRowheader.closest('[role="row"]')!;
expect(row).toHaveAttribute('aria-selected', 'false');

await user.click(docsRowheader);

expect(row).toHaveAttribute('aria-selected', 'false');
});
});
});
41 changes: 41 additions & 0 deletions src/patterns/treegrid/TreeGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,46 @@ export function TreeGrid({
]
);

// ==========================================================================
// Mouse Handling
// ==========================================================================
// Non-APG extension: mouse click moves cell focus; clicking a rowheader of an
// expandable, non-disabled row toggles its expansion. Row selection
// (aria-selected) is intentionally not changed — that remains a Space-only
// operation, matching the APG cell-only focus model.

const handleCellClick = useCallback(
(event: React.MouseEvent, cell: TreeGridCellData, rowId: string, colIndex: number) => {
// Let interactive descendants (links, buttons, form controls) handle their own clicks.
if (
event.target instanceof Element &&
event.target.closest('button, a, input, select, textarea')
) {
return;
}

const flatRow = rowMap.get(rowId);
if (!flatRow) return;

// Focus moves regardless of disabled state, matching arrow-key navigation
// (disabled cells are focusable per APG).
focusCell(cell.id);

// Expansion toggle is suppressed for disabled rows/cells.
if (cell.disabled || flatRow.node.disabled) return;

const rowHeaderColIndex = getRowHeaderColumnIndex();
if (colIndex === rowHeaderColIndex && flatRow.hasChildren) {
if (expandedSet.has(rowId)) {
collapseNode(rowId);
} else {
expandNode(rowId);
}
}
},
[rowMap, focusCell, getRowHeaderColumnIndex, expandedSet, expandNode, collapseNode]
);

// ==========================================================================
// Effects
// ==========================================================================
Expand Down Expand Up @@ -640,6 +680,7 @@ export function TreeGrid({
aria-colindex={totalColumns ? startColIndex + colIndex : undefined}
aria-colspan={cell.colspan}
onKeyDown={(e) => handleKeyDown(e, cell, node.id, colIndex)}
onClick={(e) => handleCellClick(e, cell, node.id, colIndex)}
onFocus={() => setFocusedCellId(cell.id)}
className={`apg-treegrid-cell ${isFocused ? 'focused' : ''} ${cell.disabled ? 'disabled' : ''}`}
>
Expand Down
Loading
Loading