Skip to content

Commit ed05370

Browse files
committed
feat: Sticky header for atomic table components
1 parent c7e4aef commit ed05370

13 files changed

Lines changed: 824 additions & 20 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import React, { useMemo, useState } from 'react';
4+
5+
import Box from '~components/box';
6+
import Header from '~components/header';
7+
import SegmentedControl from '~components/segmented-control';
8+
import SpaceBetween from '~components/space-between';
9+
import TableBody from '~components/table-body';
10+
import TableBodyCell from '~components/table-body-cell';
11+
import TableHead from '~components/table-head';
12+
import TableHeaderCell from '~components/table-header-cell';
13+
import TableRoot, { TableRootProps } from '~components/table-root';
14+
import TableRow from '~components/table-row';
15+
16+
// Sticky header in both layout modes. The Name header carries a sort button so the pinned copy's
17+
// pointer interaction can be exercised.
18+
19+
const GRID_COLUMNS: ReadonlyArray<TableRootProps.ColumnDefinition> = [
20+
{ size: 260 },
21+
{ size: 260 },
22+
{ size: 260 },
23+
{ size: 260 },
24+
{ size: 260 },
25+
];
26+
const ROW_COUNT = 60;
27+
28+
interface Row {
29+
id: string;
30+
name: string;
31+
type: string;
32+
size: string;
33+
status: string;
34+
region: string;
35+
}
36+
37+
const makeRows = (n: number): Row[] =>
38+
Array.from({ length: n }, (_, index) => ({
39+
id: `row-${index}`,
40+
name: `Resource ${String(index).padStart(3, '0')}`,
41+
type: index % 3 === 0 ? 'Compute' : index % 3 === 1 ? 'Storage' : 'Network',
42+
size: `${(index % 8) + 1} GiB`,
43+
status: index % 2 === 0 ? 'Available' : 'Pending',
44+
region: index % 2 === 0 ? 'us-east-1' : 'eu-west-1',
45+
}));
46+
47+
export default function TableStickyHeaderPage() {
48+
const [layout, setLayout] = useState<'grid' | 'auto'>('grid');
49+
const [ascending, setAscending] = useState(true);
50+
const rows = useMemo(() => {
51+
const direction = ascending ? 1 : -1;
52+
return makeRows(ROW_COUNT).sort((a, b) => direction * a.name.localeCompare(b.name));
53+
}, [ascending]);
54+
55+
const columnLayout: TableRootProps.ColumnLayout =
56+
layout === 'grid' ? { type: 'grid', columns: GRID_COLUMNS } : { type: 'auto' };
57+
58+
return (
59+
<Box padding="l">
60+
<SpaceBetween size="l">
61+
<Box variant="h1">Table atomics — sticky header</Box>
62+
<Box color="text-body-secondary">
63+
Scroll the page down — the header pins to the top. Scroll the table sideways — the pinned header tracks the
64+
columns. Switch between grid and auto layout to exercise both alignment paths.
65+
</Box>
66+
<SegmentedControl
67+
selectedId={layout}
68+
onChange={({ detail }) => setLayout(detail.selectedId as 'grid' | 'auto')}
69+
label="Column layout"
70+
options={[
71+
{ id: 'grid', text: 'Grid layout' },
72+
{ id: 'auto', text: 'Auto layout' },
73+
]}
74+
/>
75+
<Header counter={`(${rows.length})`}>Resources</Header>
76+
<TableRoot
77+
columnLayout={columnLayout}
78+
ariaLabel="Resources"
79+
stickyHeader={
80+
<TableHead>
81+
<TableRow>
82+
<TableHeaderCell ariaSort={ascending ? 'ascending' : 'descending'}>
83+
<button
84+
type="button"
85+
onClick={() => setAscending(value => !value)}
86+
style={{ font: 'inherit', background: 'none', border: 0, padding: 0, cursor: 'pointer' }}
87+
>
88+
Name {ascending ? '▲' : '▼'}
89+
</button>
90+
</TableHeaderCell>
91+
<TableHeaderCell>Type</TableHeaderCell>
92+
<TableHeaderCell>Size</TableHeaderCell>
93+
<TableHeaderCell>Status</TableHeaderCell>
94+
<TableHeaderCell>Region</TableHeaderCell>
95+
</TableRow>
96+
</TableHead>
97+
}
98+
>
99+
<TableBody>
100+
{rows.map(row => (
101+
<TableRow key={row.id}>
102+
<TableBodyCell>{row.name}</TableBodyCell>
103+
<TableBodyCell>{row.type}</TableBodyCell>
104+
<TableBodyCell>{row.size}</TableBodyCell>
105+
<TableBodyCell>{row.status}</TableBodyCell>
106+
<TableBodyCell>{row.region}</TableBodyCell>
107+
</TableRow>
108+
))}
109+
</TableBody>
110+
</TableRoot>
111+
</SpaceBetween>
112+
</Box>
113+
);
114+
}

‎src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30931,13 +30931,27 @@ use the \`id\` attribute, consider setting it on a parent element instead.",
3093130931
"optional": true,
3093230932
"type": "string",
3093330933
},
30934+
{
30935+
"description": "Specifies a vertical offset (in pixels) for the sticky header. For example, use this if you
30936+
need to position the sticky header below other fixed position elements on the page.",
30937+
"name": "stickyHeaderVerticalOffset",
30938+
"optional": true,
30939+
"type": "number",
30940+
},
3093430941
],
3093530942
"regions": [
3093630943
{
30937-
"description": "The table's content. Provide a \`TableHead\` followed by a \`TableBody\` that contains the rows.",
30944+
"description": "The table's content. Provide a \`TableHead\` followed by a \`TableBody\` that contains the rows. When you
30945+
use \`stickyHeader\`, provide only the \`TableBody\` here.",
3093830946
"isDefault": true,
3093930947
"name": "children",
3094030948
},
30949+
{
30950+
"description": "A \`TableHead\` that stays pinned to the top of its scroll container as the body scrolls. When you
30951+
provide it, render only the \`TableBody\` as \`children\`.",
30952+
"isDefault": false,
30953+
"name": "stickyHeader",
30954+
},
3094130955
],
3094230956
"releaseStatus": "stable",
3094330957
}

‎src/internal/components/sticky-header/__tests__/use-cells-resize-observer.test.tsx‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@ import { useCellsResizeObserver } from '../use-cells-resize-observer';
55

66
const observers: Array<{ callback: () => void; elements: Set<Element> }> = [];
77
const OriginalResizeObserver = window.ResizeObserver;
8+
// A real observer reports a newly observed element before React runs passive effects.
9+
let notifyOnObserve = false;
810

911
beforeEach(() => {
1012
observers.length = 0;
13+
notifyOnObserve = false;
1114
window.ResizeObserver = class {
1215
private entry: { callback: () => void; elements: Set<Element> };
1316
constructor(callback: () => void) {
@@ -16,6 +19,9 @@ beforeEach(() => {
1619
}
1720
observe(element: Element) {
1821
this.entry.elements.add(element);
22+
if (notifyOnObserve) {
23+
this.entry.callback();
24+
}
1925
}
2026
unobserve(element: Element) {
2127
this.entry.elements.delete(element);
@@ -79,6 +85,23 @@ test('calls the latest callback', () => {
7985
expect(latest).toHaveBeenCalledTimes(1);
8086
});
8187

88+
test('reports newly observed cells to the callback from the same render', () => {
89+
notifyOnObserve = true;
90+
const cell = document.createElement('th');
91+
const first = jest.fn();
92+
const latest = jest.fn();
93+
const { rerender } = renderHook(
94+
({ cells, onResize }: { cells: HTMLElement[]; onResize: () => void }) =>
95+
useCellsResizeObserver(() => cells, onResize),
96+
{ initialProps: { cells: [] as HTMLElement[], onResize: first } }
97+
);
98+
99+
rerender({ cells: [cell], onResize: latest });
100+
101+
expect(first).not.toHaveBeenCalled();
102+
expect(latest).toHaveBeenCalledTimes(1);
103+
});
104+
82105
test('stops reporting after unmount', () => {
83106
const cell = document.createElement('th');
84107
const { onResize, unmount } = renderCells([cell]);

‎src/internal/components/sticky-header/use-cells-resize-observer.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22
// SPDX-License-Identifier: Apache-2.0
33
import { useEffect, useLayoutEffect, useRef } from 'react';
44

5-
import { useStableCallback } from '@cloudscape-design/component-toolkit/internal';
6-
75
export function useCellsResizeObserver(getCells: () => ReadonlyArray<HTMLElement>, onResize: () => void) {
8-
const stableOnResize = useStableCallback(onResize);
6+
// Not useStableCallback: the observer can report new cells before passive effects update it.
7+
const onResizeRef = useRef(onResize);
8+
useLayoutEffect(() => {
9+
onResizeRef.current = onResize;
10+
});
11+
912
const observerRef = useRef<ResizeObserver | null>(null);
1013
const observedCellsRef = useRef<ReadonlyArray<HTMLElement>>([]);
1114

@@ -20,7 +23,7 @@ export function useCellsResizeObserver(getCells: () => ReadonlyArray<HTMLElement
2023
return;
2124
}
2225
if (!observerRef.current) {
23-
observerRef.current = new ResizeObserver(() => stableOnResize());
26+
observerRef.current = new ResizeObserver(() => onResizeRef.current());
2427
}
2528
const observer = observerRef.current;
2629
observedCells.filter(cell => !cells.includes(cell)).forEach(cell => observer.unobserve(cell));
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import { RefObject, useLayoutEffect } from 'react';
4+
5+
import { getAllFocusables } from '../focus-lock/utils';
6+
7+
// Takes a sticky header copy's controls out of the tab order but leaves them clickable, including
8+
// controls the header adds or enables later without a re-render of its owner.
9+
// `inert` would also stop clicks, letting them fall through to whatever is underneath.
10+
export function useUntabbableDescendants(containerRef: RefObject<HTMLElement>) {
11+
useLayoutEffect(() => {
12+
const container = containerRef.current;
13+
if (!container) {
14+
return;
15+
}
16+
const removeTabStops = () => {
17+
for (const element of getAllFocusables(container)) {
18+
if (element.tabIndex !== -1) {
19+
element.tabIndex = -1;
20+
}
21+
}
22+
};
23+
removeTabStops();
24+
const observer = new MutationObserver(removeTabStops);
25+
observer.observe(container, {
26+
childList: true,
27+
subtree: true,
28+
attributes: true,
29+
attributeFilter: ['tabindex', 'disabled'],
30+
});
31+
return () => observer.disconnect();
32+
});
33+
}

‎src/table-root/__tests__/basic-table-roles.test.tsx‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,15 @@ import React from 'react';
44

55
import { findTable, makeItems, renderResourcesTable, ResourcesTable } from './table-fixtures';
66

7-
// Role semantics for the atomic table. In `auto` layout the parts are native
8-
// <table>/<thead>/<tr>/<th>/<tbody>/<td>, so the browser supplies the table semantics and no explicit
9-
// ARIA roles are emitted. In `grid` layout the parts are laid out with display:grid, which strips the
10-
// native table semantics, so the hook restores role=table -> rowgroup -> row -> columnheader/cell.
7+
// In `auto` layout only the table carries an explicit role; its parts keep their native roles. In `grid`
8+
// layout display:grid strips the native semantics, so every part gets an explicit role.
119

1210
describe('Table role semantics', () => {
13-
describe('auto layout uses native table semantics (no explicit roles)', () => {
14-
test('the table, rows, and cells carry no ARIA role attributes', () => {
11+
describe('auto layout', () => {
12+
test('sets role=table on the table and no roles on its parts', () => {
1513
const { wrapper } = renderResourcesTable({ items: makeItems(20) });
1614
const table = findTable(wrapper);
17-
expect(table.hasAttribute('role')).toBe(false);
15+
expect(table.getAttribute('role')).toBe('table');
1816
expect(table.querySelectorAll('[role]')).toHaveLength(0);
1917
});
2018
});

0 commit comments

Comments
 (0)