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
120 changes: 120 additions & 0 deletions pages/table-root/sticky-header.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useMemo } from 'react';

import Header from '~components/header';
import SegmentedControl from '~components/segmented-control';
import SpaceBetween from '~components/space-between';
import TableBody from '~components/table-body';
import TableBodyCell from '~components/table-body-cell';
import TableHead from '~components/table-head';
import TableHeaderCell from '~components/table-header-cell';
import TableRoot, { TableRootProps } from '~components/table-root';
import TableRow from '~components/table-row';

import { useAppContext } from '../app/app-context';
import { SimplePage } from '../app/templates';

import styles from './styles.scss';

// Wide enough to scroll sideways, and long enough to scroll the page, so the pinned header can be checked
// against both. The Name header's sort button exercises pointer interaction with the pinned copy.

const GRID_COLUMNS: ReadonlyArray<TableRootProps.ColumnDefinition> = [
{ size: 260 },
{ size: 260 },
{ size: 260 },
{ size: 260 },
{ size: 260 },
];
const ROW_COUNT = 60;

interface Row {
id: string;
name: string;
type: string;
size: string;
status: string;
region: string;
}

const makeRows = (n: number): Row[] =>
Array.from({ length: n }, (_, index) => ({
id: `row-${index}`,
name: `Resource ${String(index).padStart(3, '0')}`,
type: index % 3 === 0 ? 'Compute' : index % 3 === 1 ? 'Storage' : 'Network',
size: `${(index % 8) + 1} GiB`,
status: index % 2 === 0 ? 'Available' : 'Pending',
region: index % 2 === 0 ? 'us-east-1' : 'eu-west-1',
}));

export default function TableStickyHeaderPage() {
const { urlParams, setUrlParams } = useAppContext<'layout' | 'sortDescending'>();
const layout = urlParams.layout === 'auto' ? 'auto' : 'grid';
const descending = urlParams.sortDescending === true || urlParams.sortDescending === 'true';

const rows = useMemo(() => {
const direction = descending ? -1 : 1;
return makeRows(ROW_COUNT).sort((a, b) => direction * a.name.localeCompare(b.name));
}, [descending]);

const columnLayout: TableRootProps.ColumnLayout =
layout === 'grid' ? { type: 'grid', columns: GRID_COLUMNS } : { type: 'auto' };

return (
<SimplePage
title="Table atomics — sticky header"
settings={
<SegmentedControl
label="Column layout"
selectedId={layout}
onChange={({ detail }) => setUrlParams({ layout: detail.selectedId })}
options={[
{ id: 'grid', text: 'Grid' },
{ id: 'auto', text: 'Auto' },
]}
/>
}
screenshotArea={{}}
>
<SpaceBetween size="s">
<Header counter={`(${rows.length})`}>Resources</Header>
<TableRoot
columnLayout={columnLayout}
ariaLabel="Resources"
stickyHeader={
<TableHead>
<TableRow>
<TableHeaderCell ariaSort={descending ? 'descending' : 'ascending'}>
<button
type="button"
className={styles['sort-button']}
onClick={() => setUrlParams({ sortDescending: !descending })}
>
Name {descending ? '▼' : '▲'}
</button>
</TableHeaderCell>
<TableHeaderCell>Type</TableHeaderCell>
<TableHeaderCell>Size</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Region</TableHeaderCell>
</TableRow>
</TableHead>
}
>
<TableBody>
{rows.map(row => (
<TableRow key={row.id}>
<TableBodyCell>{row.name}</TableBodyCell>
<TableBodyCell>{row.type}</TableBodyCell>
<TableBodyCell>{row.size}</TableBodyCell>
<TableBodyCell>{row.status}</TableBodyCell>
<TableBodyCell>{row.region}</TableBodyCell>
</TableRow>
))}
</TableBody>
</TableRoot>
</SpaceBetween>
</SimplePage>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -30931,13 +30931,30 @@ use the \`id\` attribute, consider setting it on a parent element instead.",
"optional": true,
"type": "string",
},
{
"description": "Specifies a vertical offset (in pixels) for the sticky header. For example, use this if you
need to position the sticky header below other fixed position elements on the page.",
"name": "stickyHeaderVerticalOffset",
"optional": true,
"type": "number",
},
],
"regions": [
{
"description": "The table's content. Provide a \`TableHead\` followed by a \`TableBody\` that contains the rows.",
"description": "The table's content. Provide a \`TableHead\` followed by a \`TableBody\` that contains the rows. When you
use \`stickyHeader\`, provide only the \`TableBody\` here.",
"isDefault": true,
"name": "children",
},
{
"description": "A \`TableHead\` that stays pinned to the top of its scroll container as the body scrolls. When you
provide it, render only the \`TableBody\` as \`children\`.

The header is rendered twice, as the real header and a pinned copy, so use controlled state for any
controls in it.",
"isDefault": false,
"name": "stickyHeader",
},
],
"releaseStatus": "stable",
}
Expand Down
7 changes: 6 additions & 1 deletion src/internal/components/focus-lock/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,14 @@ export function isFocusable(element: HTMLElement): boolean {
return element.matches(tabbables) && isVisible(element);
}

/** Get all elements that could be focused, including ones that are currently hidden. */
export function getAllFocusableCandidates(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(tabbables));
}

/** Get all elements that can be focused, either programmatically or by the user. */
export function getAllFocusables(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(tabbables)).filter(isVisible);
return getAllFocusableCandidates(container).filter(isVisible);
}

/** Get all focusable elements that can be reached with the keyboard. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { RefObject, useLayoutEffect } from 'react';

import { getAllFocusableCandidates } from '../focus-lock/utils';

// Takes a sticky header copy's controls, including hidden ones, out of the tab order but leaves them clickable.
// `inert` would also stop clicks, letting them fall through to whatever is underneath.
export function useUntabbableDescendants(containerRef: RefObject<HTMLElement>) {
useLayoutEffect(() => {
if (containerRef.current) {
for (const element of getAllFocusableCandidates(containerRef.current)) {
element.tabIndex = -1;
}
}
});
Comment on lines +10 to +16
Comment thread
gethinwebster marked this conversation as resolved.
Resolved
}
121 changes: 121 additions & 0 deletions src/table-root/__integ__/sticky-header.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects';
import useBrowser from '@cloudscape-design/browser-test-tools/use-browser';

import scrollbarStyles from '../../../lib/components/internal/components/sticky-scrollbar/styles.selectors.js';
import styles from '../../../lib/components/table-root/styles.selectors.js';

const slot = `.${styles['sticky-slot']}`;
const stickyScrollbar = `.${scrollbarStyles['sticky-scrollbar-visible']}`;
const scroller = `.${styles['body-scroller']}`;
const copyCells = `${slot} th`;
const realCells = `${scroller} thead th`;

class StickyHeaderPage extends BasePageObject {
getCellBoxes(selector: string) {
return this.browser.execute(
(selector: string) =>
Array.from(document.querySelectorAll(selector)).map(cell => {
const { left, width } = cell.getBoundingClientRect();
return { left: Math.round(left), width: Math.round(width) };
}),
selector
);
}

getFocusedElement() {
return this.browser.execute((scroller: string) => {
const active = document.activeElement;
return { inRealHeader: !!active?.closest(`${scroller} thead`), tag: active?.tagName };
}, scroller);
}

async expectCopyAlignedWithRealHeader() {
await this.waitForAssertion(async () => {
const copy = await this.getCellBoxes(copyCells);
expect(copy).toHaveLength(5);
expect(copy).toEqual(await this.getCellBoxes(realCells));
});
}
}

const setupTest = (layout: 'grid' | 'auto', testFn: (page: StickyHeaderPage) => Promise<void>) =>
useBrowser({ width: 900, height: 800 }, async browser => {
await browser.url(`#/light/table-root/sticky-header?layout=${layout}`);
const page = new StickyHeaderPage(browser);
await page.waitForVisible(copyCells);
await testFn(page);
});

describe.each(['grid', 'auto'] as const)('TableRoot sticky header, %s layout', layout => {
test(
'stays pinned to the top of the viewport as the page scrolls',
setupTest(layout, async page => {
const { top: startTop } = await page.getBoundingBox(slot);
expect(startTop).toBeGreaterThan(0);

await page.windowScrollTo({ top: 1000 });
await page.waitForAssertion(async () => {
await expect(page.getBoundingBox(slot)).resolves.toEqual(expect.objectContaining({ top: 0 }));
});
})
);

test(
'lines up with the columns',
setupTest(layout, async page => {
await page.expectCopyAlignedWithRealHeader();
})
);

test(
'stays lined up with the columns after the viewport resizes',
setupTest(layout, async page => {
await page.setWindowSize({ width: 1400, height: 800 });
await page.expectCopyAlignedWithRealHeader();
})
);
});

test(
'moves focus to the real header when a pinned header control is clicked, and still activates it',
setupTest('grid', async page => {
await page.windowScrollTo({ top: 1000 });
await expect(page.getElementAttribute(`${realCells}:first-child`, 'aria-sort')).resolves.toBe('ascending');

await page.click(`${slot} button`);

await page.waitForAssertion(async () => {
await expect(page.getElementAttribute(`${realCells}:first-child`, 'aria-sort')).resolves.toBe('descending');
});
await expect(page.getFocusedElement()).resolves.toEqual({ inRealHeader: true, tag: 'BUTTON' });
// The click doesn't scroll the page back up to the real header.
await expect(page.getWindowScroll()).resolves.toEqual(expect.objectContaining({ top: 1000 }));
})
);

test(
'follows the table when it is scrolled with the sticky scrollbar',
setupTest('grid', async page => {
await page.windowScrollTo({ top: 1000 });
await page.waitForVisible(stickyScrollbar);
await page.elementScrollTo(stickyScrollbar, { left: 200 });
await page.waitForAssertion(async () => {
await expect(page.getElementScroll(`${slot} > *`)).resolves.toEqual(expect.objectContaining({ left: 200 }));
});
await page.expectCopyAlignedWithRealHeader();
})
);

test(
'stays lined up with the columns as the table scrolls horizontally',
setupTest('grid', async page => {
await page.windowScrollTo({ top: 1000 });
await page.elementScrollTo(scroller, { left: 200 });
await page.waitForAssertion(async () => {
await expect(page.getElementScroll(`${slot} > *`)).resolves.toEqual(expect.objectContaining({ left: 200 }));
});
await page.expectCopyAlignedWithRealHeader();
})
);
12 changes: 5 additions & 7 deletions src/table-root/__tests__/basic-table-roles.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,15 @@ import React from 'react';

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

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

describe('Table role semantics', () => {
describe('auto layout uses native table semantics (no explicit roles)', () => {
test('the table, rows, and cells carry no ARIA role attributes', () => {
describe('auto layout', () => {
test('sets role=table on the table and no roles on its parts', () => {
const { wrapper } = renderResourcesTable({ items: makeItems(20) });
const table = findTable(wrapper);
expect(table.hasAttribute('role')).toBe(false);
expect(table.getAttribute('role')).toBe('table');
expect(table.querySelectorAll('[role]')).toHaveLength(0);
});
});
Expand Down
Loading
Loading