From bc63f8c811009debc40e5b16a6590609349723e1 Mon Sep 17 00:00:00 2001 From: gethinw Date: Wed, 5 Aug 2026 07:06:48 +0000 Subject: [PATCH 01/13] feat(beta): add BasicTable beta component under src/beta/basic-table-0.1 Moved from the standalone AWS-UI-Basic-Table-Components package. Flat API (BasicTable + BasicTable* named exports). Toolkit imports re-pointed to the OSS @cloudscape-design/component-toolkit + repo originals (table-role, sticky-columns, drag-handle-wrapper, transition); vendored dupes deleted. --- .../__tests__/basic-table-a11y.test.tsx | 368 +++++++++++++ ...basic-table-column-virtualization.test.tsx | 115 +++++ .../__tests__/basic-table-i18n.test.tsx | 69 +++ .../__tests__/basic-table-resize.test.tsx | 174 +++++++ .../basic-table-sticky-columns.test.tsx | 152 ++++++ .../__tests__/basic-table.test.tsx | 153 ++++++ src/beta/basic-table-0.1/__tests__/setup.ts | 33 ++ .../__tests__/use-basic-table.test.tsx | 225 ++++++++ .../__tests__/use-column-window.test.tsx | 172 +++++++ .../__tests__/use-virtualization.test.tsx | 276 ++++++++++ src/beta/basic-table-0.1/basic-table/USAGE.md | 234 +++++++++ .../basic-table-0.1/basic-table/context.ts | 55 ++ .../basic-table-0.1/basic-table/index.tsx | 88 ++++ .../basic-table-0.1/basic-table/interfaces.ts | 211 ++++++++ .../basic-table-0.1/basic-table/internal.tsx | 482 ++++++++++++++++++ .../basic-table-0.1/basic-table/styles.scss | 440 ++++++++++++++++ .../basic-table/use-basic-table.ts | 327 ++++++++++++ src/beta/basic-table-0.1/index.tsx | 33 ++ .../internal/base-component.ts | 41 ++ .../basic-table-0.1/internal/environment.ts | 11 + src/beta/basic-table-0.1/internal/events.ts | 94 ++++ .../custom-css-properties/index.scss | 10 + .../internal/hooks/styles-check.ts | 103 ++++ .../internal/hooks/use-base-component.ts | 47 ++ .../internal/hooks/use-visual-mode.ts | 8 + .../internal/styles/_index.scss | 94 ++++ .../internal/styles/_tokens.scss | 31 ++ .../internal/tooltip/index.tsx | 65 +++ .../internal/utils/apply-display-name.ts | 5 + .../internal/utils/get-visual-theme.ts | 10 + .../test-utils/dom/basic-table/index.ts | 34 ++ .../basic-table-0.1/test-utils/dom/index.ts | 26 + .../basic-table-0.1/test-utils/tsconfig.json | 11 + src/beta/basic-table-0.1/types/analytics.ts | 44 ++ .../basic-table-0.1/types/base-component.ts | 20 + src/beta/basic-table-0.1/types/events.ts | 34 ++ .../use-virtualization/index.ts | 13 + .../use-virtualization/interfaces.ts | 86 ++++ .../use-virtualization/use-column-window.ts | 171 +++++++ .../use-live-announcement.ts | 95 ++++ .../use-virtualization/use-virtual-model.ts | 327 ++++++++++++ .../use-virtualization/use-virtualization.ts | 139 +++++ 42 files changed, 5126 insertions(+) create mode 100644 src/beta/basic-table-0.1/__tests__/basic-table-a11y.test.tsx create mode 100644 src/beta/basic-table-0.1/__tests__/basic-table-column-virtualization.test.tsx create mode 100644 src/beta/basic-table-0.1/__tests__/basic-table-i18n.test.tsx create mode 100644 src/beta/basic-table-0.1/__tests__/basic-table-resize.test.tsx create mode 100644 src/beta/basic-table-0.1/__tests__/basic-table-sticky-columns.test.tsx create mode 100644 src/beta/basic-table-0.1/__tests__/basic-table.test.tsx create mode 100644 src/beta/basic-table-0.1/__tests__/setup.ts create mode 100644 src/beta/basic-table-0.1/__tests__/use-basic-table.test.tsx create mode 100644 src/beta/basic-table-0.1/__tests__/use-column-window.test.tsx create mode 100644 src/beta/basic-table-0.1/__tests__/use-virtualization.test.tsx create mode 100644 src/beta/basic-table-0.1/basic-table/USAGE.md create mode 100644 src/beta/basic-table-0.1/basic-table/context.ts create mode 100644 src/beta/basic-table-0.1/basic-table/index.tsx create mode 100644 src/beta/basic-table-0.1/basic-table/interfaces.ts create mode 100644 src/beta/basic-table-0.1/basic-table/internal.tsx create mode 100644 src/beta/basic-table-0.1/basic-table/styles.scss create mode 100644 src/beta/basic-table-0.1/basic-table/use-basic-table.ts create mode 100644 src/beta/basic-table-0.1/index.tsx create mode 100644 src/beta/basic-table-0.1/internal/base-component.ts create mode 100644 src/beta/basic-table-0.1/internal/environment.ts create mode 100644 src/beta/basic-table-0.1/internal/events.ts create mode 100644 src/beta/basic-table-0.1/internal/generated/custom-css-properties/index.scss create mode 100644 src/beta/basic-table-0.1/internal/hooks/styles-check.ts create mode 100644 src/beta/basic-table-0.1/internal/hooks/use-base-component.ts create mode 100644 src/beta/basic-table-0.1/internal/hooks/use-visual-mode.ts create mode 100644 src/beta/basic-table-0.1/internal/styles/_index.scss create mode 100644 src/beta/basic-table-0.1/internal/styles/_tokens.scss create mode 100644 src/beta/basic-table-0.1/internal/tooltip/index.tsx create mode 100644 src/beta/basic-table-0.1/internal/utils/apply-display-name.ts create mode 100644 src/beta/basic-table-0.1/internal/utils/get-visual-theme.ts create mode 100644 src/beta/basic-table-0.1/test-utils/dom/basic-table/index.ts create mode 100644 src/beta/basic-table-0.1/test-utils/dom/index.ts create mode 100644 src/beta/basic-table-0.1/test-utils/tsconfig.json create mode 100644 src/beta/basic-table-0.1/types/analytics.ts create mode 100644 src/beta/basic-table-0.1/types/base-component.ts create mode 100644 src/beta/basic-table-0.1/types/events.ts create mode 100644 src/beta/basic-table-0.1/use-virtualization/index.ts create mode 100644 src/beta/basic-table-0.1/use-virtualization/interfaces.ts create mode 100644 src/beta/basic-table-0.1/use-virtualization/use-column-window.ts create mode 100644 src/beta/basic-table-0.1/use-virtualization/use-live-announcement.ts create mode 100644 src/beta/basic-table-0.1/use-virtualization/use-virtual-model.ts create mode 100644 src/beta/basic-table-0.1/use-virtualization/use-virtualization.ts diff --git a/src/beta/basic-table-0.1/__tests__/basic-table-a11y.test.tsx b/src/beta/basic-table-0.1/__tests__/basic-table-a11y.test.tsx new file mode 100644 index 0000000000..b540b2843b --- /dev/null +++ b/src/beta/basic-table-0.1/__tests__/basic-table-a11y.test.tsx @@ -0,0 +1,368 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; + +import { KeyCode } from '@amzn/awsui-component-toolkit/internal'; +import createWrapper from '../../dist/test-utils/dom'; +import BasicTable, { BasicTableProps } from '../basic-table'; + +// Accessibility tests for the compound BasicTable. The sub-components spread the useBasicTable hook's +// role/ARIA getters onto native ////`. */ + export interface RowProps extends React.HTMLAttributes { + /** Zero-based data index of this row. */ + index: number; + /** Stable identity for the row's expansion/region wiring. Required for accessible expansion: + * it links the disclosure toggle's `aria-controls` to the `ExpandedContent` region and lets + * Escape return focus to the toggle. Without it, expansion still renders but loses that wiring. */ + id?: string; + /** Whether the row's `ExpandedContent` region is shown (consumer-controlled). */ + expanded?: boolean; + /** Invoked when the row's expansion is toggled from within (e.g. Escape in the region). */ + onToggleExpand?: () => void; + children?: React.ReactNode; // Cells + optional ExpandedContent + } + + /** Props for `BasicTable.Cell`. Positional by default (Nth Cell = Nth column); pass `columnId` + * to bind by id. May carry a `style` (e.g. a `gridColumnStart` from `useColumnVirtualization`) + * and standard cell HTML attributes. */ + export interface CellProps extends React.TdHTMLAttributes { + /** Bind to a column by id instead of by position (only needed with column virtualization). */ + columnId?: string; + children?: React.ReactNode; + } + + /** Props for `BasicTable.ExpandedContent` — arbitrary non-tabular expanded detail nested inside + * its `Row`. Reads `expanded`/`id` from its row; renders the labeled region only when the row + * is expanded. */ + export interface ExpandedContentProps { + /** Accessible name for the expanded region, tying it to its row. */ + label?: string; + children: React.ReactNode; + } + + export interface ColumnWidthsDetail { + /** Per-column widths (px), keyed by column INDEX. */ + widths: Record; + } + + export interface StickyColumns { + /** Number of leading columns pinned to the inline-start edge. @defaultValue 0 */ + first?: number; + /** Number of trailing columns pinned to the inline-end edge. @defaultValue 0 */ + last?: number; + } + + export interface I18nStrings { + /** Accessible name for the grid, set as its `aria-label`. Recommended — a data grid should + * always have an accessible name. */ + tableLabel?: string; + /** Custom role description for a column's resize handle. @i18n @defaultValue 'resize handle' */ + resizerRoleDescription?: string; + } +} diff --git a/src/beta/basic-table-0.1/basic-table/internal.tsx b/src/beta/basic-table-0.1/basic-table/internal.tsx new file mode 100644 index 0000000000..59323e3d24 --- /dev/null +++ b/src/beta/basic-table-0.1/basic-table/internal.tsx @@ -0,0 +1,482 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import clsx from 'clsx'; + +import { useMergeRefs, useSingleTabStopNavigation, useUniqueId } from '@cloudscape-design/component-toolkit/internal'; + +import { GridNavigationProvider } from '../../../table/table-role'; +import LiveRegion from '../../../live-region/internal'; +import StatusIndicator from '../../../status-indicator/internal'; + +import { getBaseProps } from '../internal/base-component'; +import DragHandleWrapper from '../../../internal/components/drag-handle-wrapper'; +import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { StickyColumnsCellState, useStickyCellStyles } from '../../../table/sticky-columns'; +import { + BasicRowContextProvider, + BasicRowContextValue, + BasicTableContextProvider, + ColumnIndexProvider, + useBasicRowContext, + useBasicTableContext, + useColumnIndexContext, +} from './context'; +import { BasicTableProps } from './interfaces'; +import { useBasicTable } from './use-basic-table'; + +import styles from './styles.css.js'; + +// Maps a context name to its themable class so compact density picks up the compact-table visual +// context. +const getVisualContextClassname = (contextType: string) => `awsui-context-${contextType}`; + +// The compound components over the headless `useBasicTable` hook. Columns are a positional width +// config on `Root`; the header and rows are declarative children. Each part self-renders its DOM +// from the hook's prop-getters (read from context) and binds to the Nth column via a positional +// `ColumnIndexContext`, or by an explicit `columnId`. Per-element concerns that need a hook each — +// roving tabindex (`useSingleTabStopNavigation`) and per-cell sticky (`useStickyCellStyles`) — run +// inside Cell / HeaderCell. A virtualizing consumer spreads `useVirtualization` `rowProps` +// (absolute offset + measure ref) onto `Row`. + +// Maps the sticky store's per-cell state to this component's sticky style keys. +function stickyClassNames(state: null | StickyColumnsCellState): Record { + if (!state) { + return {}; + } + return { + [styles['sticky-cell']]: true, + [styles['sticky-cell-pad-inline-start']]: state.padInlineStart, + [styles['sticky-cell-last-inline-start']]: state.lastInsetInlineStart, + [styles['sticky-cell-last-inline-end']]: state.lastInsetInlineEnd, + }; +} + +// Wrap each child of Header / Row in a positional `ColumnIndexProvider` (emits no DOM, so the +// ` + ); +}; + +// Declarative header rowgroup — renders the HeaderCell children it is GIVEN (positional), never +// auto-generated from config. +export const Header = ({ sticky, children }: BasicTableProps.HeaderProps): React.ReactElement => { + const ctx = useBasicTableContext('Header'); + const groupProps = ctx.getHeaderGroupProps(); + return ( + + + {withColumnIndices(children)} + + + ); +}; + +// --- Body -------------------------------------------------------------------- + +// Renders the mapped Row children directly (no harvesting) and carries the runway ref/style spread +// from `useVirtualization` in the virtual case. +export const Body = React.forwardRef(function Body( + { children, className, ...rest }, + ref +) { + const ctx = useBasicTableContext('Body'); + const bodyProps = ctx.getBodyProps(); + return ( + + {children} + + ); +}); + +// --- Cell + ExpandedContent -------------------------------------------------- + +// Self-renders one body cell (` + ); +}; + +// The expanded region is NESTED inside the measured Row (same ``), on a second grid +// line spanning all columns, so windowing only sees "one taller auto row." It reads `expanded`/`id` +// from its row context and owns the region a11y (region role + label + Escape-to-return-focus). The +// disclosure toggle is placed by the consumer in a Cell with id `${Row.id}-toggle`. +export const ExpandedContent = ({ + label, + children, +}: BasicTableProps.ExpandedContentProps): React.ReactElement | null => { + const ctx = useBasicTableContext('ExpandedContent'); + const row = useBasicRowContext('ExpandedContent'); + if (!row.expanded) { + return null; + } + const regionId = row.id ? `${row.id}-region` : undefined; + return ( + + ); +}; + +// --- Row --------------------------------------------------------------------- + +// Renders its Cell / ExpandedContent children directly (no harvesting), wrapping each in a +// positional `ColumnIndexProvider`, and provides the row context so those children learn their +// index / expansion. Static grid props come from the hook; a virtual consumer's spread `rowProps` +// (absolute offset + aria-rowindex override + measure ref) wins. +export const Row = React.forwardRef(function Row( + { index, id, expanded, onToggleExpand, children, className, style, ...rest }, + ref +) { + const ctx = useBasicTableContext('Row'); + const rowProps = ctx.getRowProps(index); + const rowContext = useMemo( + () => ({ index, id, expanded, onToggleExpand }), + [index, id, expanded, onToggleExpand] + ); + return ( + + + {withColumnIndices(children)} + + + ); +}); + +// --- Root -------------------------------------------------------------------- + +type InternalRootProps = BasicTableProps & InternalBaseComponentProps; + +export function InternalRoot(props: InternalRootProps) { + const { + columns, + role = 'grid', + resizableColumns = false, + columnWidths, + onColumnWidthsChange, + stickyColumns, + contentDensity = 'comfortable', + totalRowCount = 0, + height, + maxHeight, + header, + empty, + loading = false, + loadingText, + i18nStrings, + children, + __internalRootRef, + } = props; + + const table = useBasicTable({ + columns, + role, + resizableColumns, + columnWidths, + onColumnWidthsChange, + stickyColumns, + contentDensity, + totalRowCount, + i18nStrings, + }); + + const columnCount = table.columnCount; + const showLoading = loading; + const showEmpty = !loading && totalRowCount === 0; + + const stickyFirst = stickyColumns?.first ?? 0; + const stickyLast = stickyColumns?.last ?? 0; + const pageSize = Math.max(1, Math.min(totalRowCount || 1, 100)); + + const tableRef = useRef(null); + const scrollContainerRef = useMergeRefs(table.stickyColumns.refs.wrapper); + const mergedTableRef = useMergeRefs(tableRef, table.stickyColumns.refs.table); + + const baseProps = getBaseProps(props); + const tableProps = table.getTableProps(); + + return ( +
+ {header &&
{header}
} + + tableRef.current} + > +
+
/
, so the whole compound +// collapses to one role="grid" tree: grid -> rowgroup -> row -> columnheader/gridcell, with +// full-dataset aria-rowcount/rowindex and aria-colcount/colindex coherence and roving-tabindex +// cell-by-cell keyboard navigation via the shared GridNavigationProvider. +// +// Columns are a positional width list; the header is declared with Header/HeaderCell children. +// +// Expansion is nested: there is no auto disclosure column; the consumer renders its own toggle +// button (id `${rowId}-toggle`) inside a Cell and an ExpandedContent region nested in the same Row. +// The region owns its a11y (role="region" + label, colspan, Escape returns focus to the toggle) and +// is marked data-awsui-table-suppress-navigation so the grid navigation leaves its content's arrow +// keys alone. + +interface Item { + id: string; + name: string; + status: string; +} + +const makeItems = (n: number): Item[] => + Array.from({ length: n }, (_, i) => ({ id: `row-${i}`, name: `Resource ${i}`, status: i % 2 === 0 ? 'Up' : 'Down' })); + +const i18nStrings: BasicTableProps.I18nStrings = { + tableLabel: 'Log events', +}; + +interface TreeOptions { + expandable?: boolean; + singleColumn?: boolean; + expandedItems?: ReadonlyArray; + loading?: boolean; + loadingText?: string; + empty?: React.ReactNode; +} + +const DATA_COLUMNS = 2; + +function detail(item: Item) { + return ( +
+

Log record {item.id}

+
+
Level
+
INFO
+
Message
+
{item.name}
+
+ +
+ ); +} + +// Stateful harness: expansion state lives in the consumer. The toggle and region are +// consumer-rendered; BasicTable only spreads the hook's getters and the row's expansion context. +function LogTable({ items, options }: { items: Item[]; options: TreeOptions }) { + const [expanded, setExpanded] = useState>(new Set(options.expandedItems ?? [])); + const toggle = (id: string) => + setExpanded(prev => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + + const columns: BasicTableProps.ColumnDefinition[] = options.singleColumn ? [{}] : [{}, {}]; + + return ( + + + Name + {!options.singleColumn && Status} + + + {items.map((item, index) => { + const isExpanded = expanded.has(item.id); + return ( + toggle(item.id)} + > + + {options.expandable && ( + + )} + {item.name} + + {!options.singleColumn && {item.status}} + {options.expandable && ( + + {detail(item)} + + )} + + ); + })} + + + ); +} + +function renderTable(items: Item[], options: TreeOptions = {}) { + const { container, rerender } = render(); + const wrapper = createWrapper(container).findBasicTable()!; + const update = (nextItems: Item[], nextOptions: TreeOptions = options) => + rerender(); + return { container, wrapper, update }; +} + +function getGrid(container: HTMLElement): HTMLElement { + return container.querySelector('[role="grid"]') as HTMLElement; +} +// Helpers: the toggle and region are consumer DOM addressed by their stable ids. +const findToggle = (container: HTMLElement, id: string) => container.querySelector(`#${id}-toggle`) as HTMLElement | null; +const findRegion = (container: HTMLElement, id: string) => container.querySelector(`#${id}-region`) as HTMLElement | null; + +describe('BasicTable (compound components) a11y', () => { + describe('axe / HTML validity', () => { + test('validates a plain compound grid', async () => { + const { container } = renderTable(makeItems(20)); + await expect(container).toValidateA11y(); + }); + + test('validates a grid with collapsed expandable rows (consumer toggles)', async () => { + const { container } = renderTable(makeItems(20), { expandable: true }); + await expect(container).toValidateA11y(); + }); + + test('validates a grid with expanded, labeled nested regions', async () => { + const { container } = renderTable(makeItems(20), { expandable: true, expandedItems: ['row-0', 'row-3'] }); + await expect(container).toValidateA11y(); + }); + + test('validates the reduced single-column shape', async () => { + const { container } = renderTable(makeItems(30), { singleColumn: true }); + await expect(container).toValidateA11y(); + }); + + test('validates the empty and loading states', async () => { + const empty = renderTable([], { empty: No log events }); + await expect(empty.container).toValidateA11y(); + + const loading = renderTable([], { loading: true, loadingText: 'Loading log events' }); + await expect(loading.container).toValidateA11y(); + }); + }); + + describe('compound structure produces a single grid accessibility tree', () => { + test('the compound sub-components collapse to one grid -> rowgroup -> row tree', () => { + const { container } = renderTable(makeItems(20), { expandable: true, expandedItems: ['row-0'] }); + expect(container.querySelectorAll('[role="grid"]')).toHaveLength(1); + const grid = getGrid(container); + + const directChildren = Array.from(grid.children); + expect(directChildren.length).toBeGreaterThan(0); + directChildren.forEach(child => expect(child.getAttribute('role')).toBe('rowgroup')); + + grid.querySelectorAll('[role="row"]').forEach(row => { + expect(row.closest('[role="rowgroup"]')).not.toBeNull(); + }); + grid.querySelectorAll('[role="columnheader"], [role="gridcell"]').forEach(cell => { + expect(cell.closest('[role="row"]')).not.toBeNull(); + }); + }); + + test('the loading state renders as a valid full-width row inside the grid', () => { + const { wrapper, container } = renderTable([], { loading: true, loadingText: 'Loading' }); + const status = wrapper.findLoadingText()!.getElement(); + const cell = status.closest('[role="gridcell"]'); + expect(cell).not.toBeNull(); + expect(cell!.closest('[role="row"]')).not.toBeNull(); + expect(cell!.closest('[role="rowgroup"]')).not.toBeNull(); + expect(getGrid(container)).not.toBeNull(); + }); + }); + + describe('keyboard navigation (shared roving-tabindex grid model)', () => { + test('the grid container is programmatically focusable but NOT a tab stop, and has no active descendant', () => { + const { container } = renderTable(makeItems(20)); + const grid = getGrid(container); + expect(grid.getAttribute('role')).toBe('grid'); + expect(grid.getAttribute('tabindex')).toBe('-1'); + expect(grid.getAttribute('aria-activedescendant')).toBeNull(); + }); + + test('cells are native
/ carrying role + aria-colindex', () => { + const { wrapper } = renderTable(makeItems(20)); + expect(wrapper.findColumnHeaders()[0].getElement().tagName).toBe('TH'); + const row0 = wrapper.findRowByIndex(0)!.getElement(); + const cells = Array.from(row0.querySelectorAll('[role="gridcell"]')); + expect(cells[0].tagName).toBe('TD'); + expect(cells[0].getAttribute('aria-colindex')).toBe('1'); + expect(cells[1].getAttribute('aria-colindex')).toBe('2'); + }); + + test('exactly one cell is the roving tab stop (tabindex 0) after mount', async () => { + const { container } = renderTable(makeItems(20)); + const grid = getGrid(container); + await waitFor(() => expect(grid.querySelectorAll('[tabindex="0"]')).toHaveLength(1)); + const target = grid.querySelector('[tabindex="0"]')!; + expect(['TD', 'TH']).toContain(target.tagName); + expect(target.getAttribute('role')).toMatch(/columnheader|gridcell/); + }); + + test('Arrow keys move focus cell-by-cell (Left/Right within a row, Up/Down across rows); Home/End to row ends', async () => { + const { container, wrapper } = renderTable(makeItems(20)); + const grid = getGrid(container); + + await waitFor(() => expect(grid.querySelector('[tabindex="0"]')).not.toBeNull()); + const firstTarget = grid.querySelector('[tabindex="0"]') as HTMLElement; + firstTarget.focus(); + expect(document.activeElement).toBe(firstTarget); + const colOf = () => document.activeElement?.getAttribute('aria-colindex'); + const rowOf = () => document.activeElement?.closest('[role="row"]')?.getAttribute('aria-rowindex'); + expect(colOf()).toBe('1'); + expect(rowOf()).toBe('1'); // header row + + fireEvent.keyDown(grid, { keyCode: KeyCode.right }); + expect(colOf()).toBe('2'); + fireEvent.keyDown(grid, { keyCode: KeyCode.left }); + expect(colOf()).toBe('1'); + + fireEvent.keyDown(grid, { keyCode: KeyCode.down }); + expect(rowOf()).toBe('2'); // data row 0 (header is 1) + expect(colOf()).toBe('1'); + expect(document.activeElement).toBe(wrapper.findRowByIndex(0)!.getElement().querySelectorAll('[role="gridcell"]')[0]); + + fireEvent.keyDown(grid, { keyCode: KeyCode.end }); + expect(colOf()).toBe('2'); + fireEvent.keyDown(grid, { keyCode: KeyCode.home }); + expect(colOf()).toBe('1'); + + fireEvent.keyDown(grid, { keyCode: KeyCode.up }); + expect(rowOf()).toBe('1'); + expect(colOf()).toBe('1'); + }); + + test('does not hijack arrow keys originating inside the expanded region', () => { + const { container } = renderTable(makeItems(20), { expandable: true, expandedItems: ['row-0'] }); + const grid = getGrid(container); + + const innerButton = findRegion(container, 'row-0')!.querySelector('button')!; + innerButton.focus(); + expect(document.activeElement).toBe(innerButton); + + fireEvent.keyDown(grid, { keyCode: KeyCode.down }); + expect(document.activeElement).toBe(innerButton); + }); + }); + + describe('decision-2 nested expansion wiring', () => { + test('the consumer disclosure control reflects aria-expanded / aria-controls across a toggle', () => { + const { container } = renderTable(makeItems(10), { expandable: true }); + const toggle = findToggle(container, 'row-0')!; + + expect(toggle.tagName).toBe('BUTTON'); + expect(toggle.getAttribute('aria-label')).toBe('Expand details for Resource 0'); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + expect(toggle.getAttribute('aria-controls')).toBeNull(); + expect(findRegion(container, 'row-0')).toBeNull(); + + fireEvent.click(toggle); + + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + const region = findRegion(container, 'row-0')!; + expect(region).not.toBeNull(); + expect(toggle.getAttribute('aria-controls')).toBe(region.id); + expect(region.getAttribute('role')).toBe('region'); + }); + + test('the expanded region carries the consumer-supplied accessible name', () => { + const { container } = renderTable(makeItems(10), { expandable: true, expandedItems: ['row-2'] }); + const region = findRegion(container, 'row-2')!; + expect(region.getAttribute('aria-label')).toBe('Details for Resource 2'); + }); + + test('Escape inside the expanded region returns focus to its disclosure toggle (source-wired)', () => { + const { container } = renderTable(makeItems(10), { expandable: true, expandedItems: ['row-0'] }); + const toggle = findToggle(container, 'row-0')!; + const innerButton = findRegion(container, 'row-0')!.querySelector('button')!; + + innerButton.focus(); + expect(document.activeElement).toBe(innerButton); + + fireEvent.keyDown(innerButton, { key: 'Escape' }); + expect(document.activeElement).toBe(toggle); + }); + + test('the expanded region content is reachable (not aria-hidden / inert / disabled)', () => { + const { container } = renderTable(makeItems(10), { expandable: true, expandedItems: ['row-0'] }); + const innerButton = findRegion(container, 'row-0')!.querySelector('button') as HTMLButtonElement; + expect(innerButton.closest('[aria-hidden="true"]')).toBeNull(); + expect(innerButton.closest('[inert]')).toBeNull(); + expect(innerButton.hasAttribute('disabled')).toBe(false); + innerButton.focus(); + expect(document.activeElement).toBe(innerButton); + }); + }); + + describe('full-dataset ARIA coherence', () => { + test('aria-rowcount counts the header once; aria-colcount is the configured column count (no disclosure column)', () => { + const { container } = renderTable(makeItems(500), { expandable: true }); + const grid = getGrid(container); + expect(grid.getAttribute('aria-rowcount')).toBe('501'); + expect(grid.getAttribute('aria-colcount')).toBe(String(DATA_COLUMNS)); + }); + + test('the header row is aria-rowindex 1 with the first data column at aria-colindex 1', () => { + const { wrapper } = renderTable(makeItems(500), { expandable: true }); + const header = wrapper.findHeaderRow()!.getElement(); + expect(header.getAttribute('aria-rowindex')).toBe('1'); + const headers = header.querySelectorAll('[role="columnheader"]'); + expect(headers[0].getAttribute('aria-colindex')).toBe('1'); + expect(headers[1].getAttribute('aria-colindex')).toBe('2'); + }); + + test('data rows carry a full-dataset aria-rowindex and 1-based cell colindex', () => { + const { wrapper } = renderTable(makeItems(500)); + const row0 = wrapper.findRowByIndex(0)!.getElement(); + expect(row0.getAttribute('aria-rowindex')).toBe('2'); + const cells = row0.querySelectorAll('[role="gridcell"]'); + expect(cells[0].getAttribute('aria-colindex')).toBe('1'); + expect(cells[1].getAttribute('aria-colindex')).toBe('2'); + }); + + test('the nested expanded region shares its data row index and spans all columns without changing aria-rowcount', () => { + const { container, wrapper } = renderTable(makeItems(500), { expandable: true, expandedItems: ['row-0'] }); + // Expanding a row does not add to the row count (nested expansion is one taller row). + expect(getGrid(container).getAttribute('aria-rowcount')).toBe('501'); + + const region = findRegion(container, 'row-0')!; + const expandedRow = region.closest('[role="row"]')!; + expect(expandedRow.getAttribute('aria-rowindex')).toBe('2'); // shares its data row's index + expect(expandedRow).toBe(wrapper.findRowByIndex(0)!.getElement()); + + const expandedCell = region.closest('[role="gridcell"]')!; + expect(expandedCell.getAttribute('aria-colindex')).toBe('1'); + expect(expandedCell.getAttribute('aria-colspan')).toBe(String(DATA_COLUMNS)); + }); + }); +}); diff --git a/src/beta/basic-table-0.1/__tests__/basic-table-column-virtualization.test.tsx b/src/beta/basic-table-0.1/__tests__/basic-table-column-virtualization.test.tsx new file mode 100644 index 0000000000..c2c86b968d --- /dev/null +++ b/src/beta/basic-table-0.1/__tests__/basic-table-column-virtualization.test.tsx @@ -0,0 +1,115 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { act, render } from '@testing-library/react'; + +import { ColumnVirtualizationResult } from '../use-virtualization'; +import { useColumnVirtualization } from '../use-virtualization/use-column-window'; + +// Tests for useColumnVirtualization, a standalone horizontal-windowing primitive (not a BasicTable +// prop; a consumer wires it by hand, as useVirtualization is wired for vertical windowing). It +// returns `{ visibleColumns, ref, trackStart }`: the consumer renders only the cells whose index is +// in visibleColumns, pinning each to its absolute track via trackStart (a grid-column-start), so the +// shared grid-template-columns (and thus aria-colindex) is unchanged. +// +// The pure geometry (computeColumnWindow) is covered in use-column-window.test.tsx and is not +// duplicated here. This suite covers the hook's observable output: default all-columns, the +// trackStart mapping, the callback ref, and a scroll-driven recompute of visibleColumns. + +const WIDTHS = Array.from({ length: 30 }, () => 150); + +// jsdom lacks ResizeObserver; the hook observes its scroll node with one. A no-op mock is enough +// (recompute is driven by the scroll event below). requestAnimationFrame is made synchronous so +// the rAF-throttled scroll recompute resolves within the act(). +const OriginalResizeObserver = window.ResizeObserver; +let rafSpy: ReturnType; +beforeEach(() => { + class MockResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + } + window.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; + rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => { + cb(0); + return 1; + }); +}); +afterEach(() => { + window.ResizeObserver = OriginalResizeObserver; + rafSpy.mockRestore(); +}); + +function Harness({ + config, + onResult, +}: { + config: Parameters[0]; + onResult: (r: ColumnVirtualizationResult) => void; +}) { + const cv = useColumnVirtualization(config); + onResult(cv); + return
; +} + +function renderCV( + config: Parameters[0], + geom?: { viewport: number; scrollLeft: number } +) { + let current!: ColumnVirtualizationResult; + const { container } = render( (current = c)} />); + const node = container.querySelector('[data-testid="scroll"]') as HTMLElement; + if (geom) { + Object.defineProperty(node, 'clientWidth', { configurable: true, get: () => geom.viewport }); + Object.defineProperty(node, 'scrollLeft', { configurable: true, get: () => geom.scrollLeft }); + } + return { + get result() { + return current; + }, + node, + }; +} + +const sorted = (set: Set) => [...set].sort((a, b) => a - b); + +describe('useColumnVirtualization (standalone primitive, #4)', () => { + test('default (no measured viewport): all columns visible; trackStart is the 1-based grid line; ref is a callback', () => { + const h = renderCV({ widths: WIDTHS, overscan: 3 }); + expect(h.result.visibleColumns.size).toBe(30); + expect(h.result.trackStart(0)).toBe(1); + expect(h.result.trackStart(5)).toBe(6); + expect(typeof h.result.ref).toBe('function'); + }); + + test('windows to the visible span + overscan on scroll (left edge)', () => { + const h = renderCV({ widths: WIDTHS, overscan: 3 }, { viewport: 400, scrollLeft: 0 }); + act(() => { + h.node.dispatchEvent(new Event('scroll')); + }); + // [0,400) intersects cols 0,1,2 (150px each); +3 overscan -> 0..5. + expect(sorted(h.result.visibleColumns)).toEqual([0, 1, 2, 3, 4, 5]); + }); + + test('slides the window when scrolled right', () => { + const h = renderCV({ widths: WIDTHS, overscan: 3 }, { viewport: 400, scrollLeft: 2000 }); + act(() => { + h.node.dispatchEvent(new Event('scroll')); + }); + // [2000,2400) intersects cols 13,14,15; +3 overscan -> 10..18. + expect(sorted(h.result.visibleColumns)).toEqual([10, 11, 12, 13, 14, 15, 16, 17, 18]); + }); + + test('pinned first/last columns are always included even far off-window', () => { + const h = renderCV({ widths: WIDTHS, overscan: 0, pinnedFirst: 2, pinnedLast: 1 }, { viewport: 200, scrollLeft: 2000 }); + act(() => { + h.node.dispatchEvent(new Event('scroll')); + }); + // Window around scrollLeft 2000 plus pinned cols 0,1 (first) and 29 (last). + const visible = h.result.visibleColumns; + expect(visible.has(0)).toBe(true); + expect(visible.has(1)).toBe(true); + expect(visible.has(29)).toBe(true); + expect(visible.has(13)).toBe(true); // within the scrolled window + }); +}); diff --git a/src/beta/basic-table-0.1/__tests__/basic-table-i18n.test.tsx b/src/beta/basic-table-0.1/__tests__/basic-table-i18n.test.tsx new file mode 100644 index 0000000000..ca201021da --- /dev/null +++ b/src/beta/basic-table-0.1/__tests__/basic-table-i18n.test.tsx @@ -0,0 +1,69 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import BasicTable, { BasicTableProps } from '../basic-table'; + +// Localization is prop-driven: the consumer passes already-localized strings through the typed +// `i18nStrings` object and the component reads them directly. There is no I18nProvider runtime in +// this package. The resize-handle role description has a hardcoded English fallback for when no +// value is supplied. + +interface Item { + id: string; + name: string; + status: string; +} + +const makeItems = (n: number): Item[] => + Array.from({ length: n }, (_, i) => ({ id: `row-${i}`, name: `Resource ${i}`, status: i % 2 === 0 ? 'Up' : 'Down' })); + +// resizableColumns renders the resize handle whose toggle carries aria-roledescription. +function buildTree(i18nStrings?: BasicTableProps.I18nStrings) { + const columns: BasicTableProps.ColumnDefinition[] = [ + { id: 'name', minWidth: 120 }, + { id: 'status' }, // flexible (no width → shares remaining space) + ]; + const items = makeItems(10); + return ( + + + Name + Status + + + {items.map((item, index) => ( + + {item.name} + {item.status} + + ))} + + + ); +} + +// The resize toggle button owns aria-roledescription. +const getResizerRoleDescription = (container: HTMLElement) => + container.querySelector('[aria-roledescription]')!.getAttribute('aria-roledescription'); + +const getTableLabel = (container: HTMLElement) => + container.querySelector('[role="grid"]')!.getAttribute('aria-label'); + +describe('BasicTable i18nStrings passthrough (#20)', () => { + test('(a) resizerRoleDescription passes through to the resize handle', () => { + const { container } = render(buildTree({ resizerRoleDescription: 'width handle' })); + expect(getResizerRoleDescription(container)).toBe('width handle'); + }); + + test('(b) tableLabel passes through to the grid aria-label', () => { + const { container } = render(buildTree({ tableLabel: 'Resources' })); + expect(getTableLabel(container)).toBe('Resources'); + }); + + test('(c) no i18nStrings: the hardcoded English resize-handle fallback remains', () => { + const { container } = render(buildTree()); + expect(getResizerRoleDescription(container)).toBe('resize handle'); + }); +}); diff --git a/src/beta/basic-table-0.1/__tests__/basic-table-resize.test.tsx b/src/beta/basic-table-0.1/__tests__/basic-table-resize.test.tsx new file mode 100644 index 0000000000..de4530783d --- /dev/null +++ b/src/beta/basic-table-0.1/__tests__/basic-table-resize.test.tsx @@ -0,0 +1,174 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; + +import BasicTable, { BasicTableProps } from '../basic-table'; + +// Keyboard and screen-reader tests for the column resize handle. Each HeaderCell renders its own +// resize handle when resizableColumns is set. The handle has a two-element model: a focusable toggle +// (a single roving tab stop in its header cell) plus a role="slider" separator that owns Left/Right +// while in keyboard-drag mode and is marked data-awsui-table-suppress-navigation so the grid +// navigation does not hijack those keys. +// +// jsdom has no layout (getBoundingClientRect() === 0), so width is driven off a controlled +// columnWidths map (fed back through onColumnWidthsChange), not a measured DOM width. + +interface Item { + id: string; + name: string; + status: string; +} + +const makeItems = (n: number): Item[] => + Array.from({ length: n }, (_, i) => ({ id: `row-${i}`, name: `Resource ${i}`, status: i % 2 === 0 ? 'Up' : 'Down' })); + +const i18nStrings: BasicTableProps.I18nStrings = { tableLabel: 'Resources' }; + +const NAME_START_WIDTH = 200; +const STATUS_WIDTH = 150; +const NAME_MIN_WIDTH = 120; + +// A controlled harness that feeds onColumnWidthsChange back into columnWidths, so aria-valuenow +// and the freeze-on-resize basis track the latest width across repeated keyboard steps. +function ResizableTable({ onWidths, nameMinWidth }: { onWidths: ReturnType; nameMinWidth?: number }) { + const [widths, setWidths] = useState>({ 0: NAME_START_WIDTH, 1: STATUS_WIDTH }); + const columns: BasicTableProps.ColumnDefinition[] = [ + { id: 'name', minWidth: nameMinWidth }, + { id: 'status' }, // flexible (no width → shares remaining space) + ]; + return ( + { + onWidths(event.detail); + setWidths(event.detail.widths); + }} + i18nStrings={i18nStrings} + > + + Name + Status + + + {makeItems(20).map((item, index) => ( + + {item.name} + {item.status} + + ))} + + + ); +} + +function renderResizable(opts: { nameMinWidth?: number } = { nameMinWidth: NAME_MIN_WIDTH }) { + const onWidths = vi.fn(); + const { container } = render(); + const grid = container.querySelector('[role="grid"]') as HTMLElement; + // The declarative header renders one columnheader per column; the first is the "name" column. + const nameHeader = grid.querySelectorAll('[role="columnheader"]')[0] as HTMLElement; + const getToggle = () => nameHeader.querySelector('button') as HTMLButtonElement; + const getSeparator = () => nameHeader.querySelector('[role="slider"]') as HTMLElement; + const lastWidths = () => onWidths.mock.calls[onWidths.mock.calls.length - 1][0].widths as Record; + return { container, grid, nameHeader, getToggle, getSeparator, onWidths, lastWidths }; +} + +describe('BasicTable resize handle keyboard + SR a11y (#8)', () => { + test('the toggle has an accessible name (aria-labelledby -> header text) and resize roledescription', () => { + const { nameHeader, getToggle } = renderResizable(); + const toggle = getToggle(); + expect(toggle.tagName).toBe('BUTTON'); + expect(toggle.getAttribute('aria-roledescription')).toBe('resize handle'); + + const labelledby = toggle.getAttribute('aria-labelledby'); + expect(labelledby).toBeTruthy(); + const labelEl = document.getElementById(labelledby!); + expect(labelEl).toBe(nameHeader); + expect(labelEl!.textContent).toContain('Name'); + }); + + test('the toggle is the single roving tab stop in its header cell (tabIndex 0)', async () => { + const { grid, getToggle } = renderResizable(); + await waitFor(() => expect(getToggle().tabIndex).toBe(0)); + expect(grid.querySelectorAll('[tabindex="0"]')).toHaveLength(1); + }); + + test('the separator is a slider, suppresses grid navigation, and exposes width via ARIA (hidden until dragging)', () => { + const { getSeparator } = renderResizable(); + const separator = getSeparator(); + expect(separator.getAttribute('role')).toBe('slider'); + expect(separator.hasAttribute('data-awsui-table-suppress-navigation')).toBe(true); + expect(separator.getAttribute('tabindex')).toBe('-1'); + expect(separator.getAttribute('aria-valuemin')).toBe(String(NAME_MIN_WIDTH)); + expect(separator.getAttribute('aria-valuenow')).toBe(String(NAME_START_WIDTH)); + // The slider is effectively unbounded above; instead of a nonsensical numeric max it exposes a + // human-readable width via aria-valuetext. + expect(separator.hasAttribute('aria-valuemax')).toBe(false); + expect(separator.getAttribute('aria-valuetext')).toBe(`${NAME_START_WIDTH} pixels`); + expect(separator.getAttribute('aria-hidden')).toBe('true'); + }); + + test.each(['Enter', ' '])('%s on the toggle enters keyboard-drag mode: separator shown + focused', key => { + const { getToggle, getSeparator } = renderResizable(); + fireEvent.keyDown(getToggle(), { key }); + expect(getSeparator().getAttribute('aria-hidden')).toBe('false'); + expect(document.activeElement).toBe(getSeparator()); + }); + + test('ArrowRight/ArrowLeft on the separator adjust the width by 10px and clamp at minWidth', () => { + const { getToggle, getSeparator, lastWidths } = renderResizable(); + + fireEvent.keyDown(getToggle(), { key: 'Enter' }); + + // ArrowRight: 200 -> 210. + fireEvent.keyDown(getSeparator(), { key: 'ArrowRight' }); + expect(lastWidths()[0]).toBe(NAME_START_WIDTH + 10); + expect(getSeparator().getAttribute('aria-valuenow')).toBe(String(NAME_START_WIDTH + 10)); + + // ArrowLeft: 210 -> 200. + fireEvent.keyDown(getSeparator(), { key: 'ArrowLeft' }); + expect(lastWidths()[0]).toBe(NAME_START_WIDTH); + + // Repeated ArrowLeft clamps at minWidth and never goes below it. + for (let i = 0; i < 30; i++) { + fireEvent.keyDown(getSeparator(), { key: 'ArrowLeft' }); + expect(lastWidths()[0]).toBeGreaterThanOrEqual(NAME_MIN_WIDTH); + } + expect(lastWidths()[0]).toBe(NAME_MIN_WIDTH); + // The other column is untouched by name's resize. + expect(lastWidths()[1]).toBe(STATUS_WIDTH); + }); + + test('Escape exits keyboard-drag mode: separator hidden again + focus returns to the toggle', () => { + const { getToggle, getSeparator } = renderResizable(); + const toggle = getToggle(); + + fireEvent.keyDown(toggle, { key: 'Enter' }); + expect(getSeparator().getAttribute('aria-hidden')).toBe('false'); + expect(document.activeElement).toBe(getSeparator()); + + fireEvent.keyDown(getSeparator(), { key: 'Escape' }); + expect(getSeparator().getAttribute('aria-hidden')).toBe('true'); + expect(document.activeElement).toBe(toggle); + }); + + // A column with no configured minWidth floors every resize at DEFAULT_COLUMN_WIDTH (120): the hook + // clamps at `col.minWidth ?? DEFAULT_COLUMN_WIDTH` (use-basic-table.ts resizeFloors / + // resizeMinWidthOf), so an un-configured column cannot collapse toward 0. + test('R1: a column with NO configured minWidth clamps at DEFAULT_COLUMN_WIDTH (120)', () => { + const { getToggle, getSeparator, lastWidths } = renderResizable({}); + // No configured minWidth -> the resize floor (and aria-valuemin) is the default 120, not 0. + expect(getSeparator().getAttribute('aria-valuemin')).toBe('120'); + + fireEvent.keyDown(getToggle(), { key: 'Enter' }); + // 30 ArrowLefts from 200px would reach -100 unclamped; the width clamps at the 120 default floor. + for (let i = 0; i < 30; i++) { + fireEvent.keyDown(getSeparator(), { key: 'ArrowLeft' }); + } + expect(lastWidths()[0]).toBe(120); + }); +}); diff --git a/src/beta/basic-table-0.1/__tests__/basic-table-sticky-columns.test.tsx b/src/beta/basic-table-0.1/__tests__/basic-table-sticky-columns.test.tsx new file mode 100644 index 0000000000..8e300d789e --- /dev/null +++ b/src/beta/basic-table-0.1/__tests__/basic-table-sticky-columns.test.tsx @@ -0,0 +1,152 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import BasicTable, { BasicTableProps } from '../basic-table'; + +import styles from '../basic-table/styles.css.js'; + +// Tests for sticky (pinned) columns. Sticky styling comes from the shared sticky-columns primitive: +// useStickyColumns measures cumulative offsets and disables itself when the table is not scrollable +// or too narrow, and useStickyCellStyles toggles the sticky classes on each header/body cell. +// +// jsdom has no layout — getBoundingClientRect() returns 0 — so the primitive's isEnabled check is +// always false and no sticky class would apply. getBoundingClientRect is mocked per tag (a wide +// table, a narrower scroll-container div, fixed-width cells) to drive isEnabled=true so the base +// sticky-cell class lands on the pinned cells. +// +// The boundary-shadow classes (sticky-cell-last-inline-start / -last-inline-end) depend on scroll +// "stuck" state (wrapper.scrollWidth/clientWidth/scrollLeft, all 0 in jsdom and not mockable via +// getBoundingClientRect), so only the base sticky-cell class (position and which columns are pinned) +// is asserted, never the stuck-state shadow variants. + +interface Item { + id: string; + a: string; + b: string; + c: string; + d: string; +} + +const makeItems = (n: number): Item[] => + Array.from({ length: n }, (_, i) => ({ id: `row-${i}`, a: `A${i}`, b: `B${i}`, c: `C${i}`, d: `D${i}` })); + +const i18nStrings: BasicTableProps.I18nStrings = { tableLabel: 'Resources' }; + +const COLUMNS = ['a', 'b', 'c', 'd'] as const; + +const WRAPPER_WIDTH = 800; +const TABLE_WIDTH = 2000; +const CELL_WIDTH = 100; + +function rect(width: number): DOMRect { + return { width, height: 20, top: 0, left: 0, right: width, bottom: 20, x: 0, y: 0, toJSON: () => ({}) } as DOMRect; +} + +function mockLayout() { + return vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.tagName === 'TABLE') { + return rect(TABLE_WIDTH); + } + if (this.tagName === 'TD' || this.tagName === 'TH') { + return rect(CELL_WIDTH); + } + return rect(WRAPPER_WIDTH); + }); +} + +function renderTable(stickyColumns?: BasicTableProps.StickyColumns) { + const columns: BasicTableProps.ColumnDefinition[] = COLUMNS.map(id => ({ + id, + width: 150, + })); + const items = makeItems(20); + const { container } = render( + + + {COLUMNS.map(id => ( + + {id.toUpperCase()} + + ))} + + + {items.map((item, index) => ( + + {COLUMNS.map(id => ( + + {item[id]} + + ))} + + ))} + + + ); + const grid = container.querySelector('[role="grid"]') as HTMLElement; + const headers = Array.from(grid.querySelectorAll('[role="columnheader"]')) as HTMLElement[]; + const firstRow = grid.querySelector('[role="row"][aria-rowindex="2"]') as HTMLElement; + const bodyCells = Array.from(firstRow.querySelectorAll('[role="gridcell"]')) as HTMLElement[]; + return { container, grid, headers, bodyCells }; +} + +const STICKY = styles['sticky-cell']; + +describe('BasicTable sticky columns (#6)', () => { + let layoutSpy: ReturnType; + beforeEach(() => { + layoutSpy = mockLayout(); + }); + afterEach(() => { + layoutSpy.mockRestore(); + }); + + test('stickyColumns={{ first: 1 }} pins the first column header and body cell (not the others)', () => { + const { headers, bodyCells } = renderTable({ first: 1 }); + expect(headers).toHaveLength(4); + + expect(headers[0]).toHaveClass(STICKY); + expect(bodyCells[0]).toHaveClass(STICKY); + + expect(headers[1]).not.toHaveClass(STICKY); + expect(bodyCells[1]).not.toHaveClass(STICKY); + expect(headers[3]).not.toHaveClass(STICKY); + }); + + test('stickyColumns={{ last: 1 }} pins the last column header and body cell (not the first)', () => { + const { headers, bodyCells } = renderTable({ last: 1 }); + + const lastIndex = headers.length - 1; + expect(headers[lastIndex]).toHaveClass(STICKY); + expect(bodyCells[bodyCells.length - 1]).toHaveClass(STICKY); + + expect(headers[0]).not.toHaveClass(STICKY); + expect(bodyCells[0]).not.toHaveClass(STICKY); + }); + + test('no stickyColumns prop: no header or body cell is sticky (feature inert)', () => { + const { headers, bodyCells } = renderTable(); + for (const cell of [...headers, ...bodyCells]) { + expect(cell).not.toHaveClass(STICKY); + } + }); + + test('smoke: setting stickyColumns does not change the rendered column/row structure', () => { + const withSticky = renderTable({ first: 1, last: 1 }); + const withoutSticky = renderTable(); + + expect(withSticky.headers).toHaveLength(4); + expect(withoutSticky.headers).toHaveLength(4); + + const rows = withSticky.grid.querySelectorAll('[role="row"]'); + expect(rows.length).toBeGreaterThan(1); // header row + data rows + expect(withSticky.bodyCells).toHaveLength(4); + }); +}); diff --git a/src/beta/basic-table-0.1/__tests__/basic-table.test.tsx b/src/beta/basic-table-0.1/__tests__/basic-table.test.tsx new file mode 100644 index 0000000000..a625a39fc2 --- /dev/null +++ b/src/beta/basic-table-0.1/__tests__/basic-table.test.tsx @@ -0,0 +1,153 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import createWrapper from '../../dist/test-utils/dom'; +import BasicTable, { BasicTableProps } from '../basic-table'; + +import styles from '../basic-table/styles.css.js'; + +// Tests for the BasicTable compound components (Root/Header/HeaderCell/Body/Row/Cell) over the +// headless useBasicTable hook, accessed through the generated test-utils wrapper. Columns are a +// positional width list on Root; the header is declared with Header/HeaderCell children (Root does +// not auto-render it) and the body is mapped Row/Cell children. Sorting is not part of the core; it +// is composed by the consumer and is covered by the demo-scoped sorting test. + +interface Item { + id: string; + name: string; + status: string; +} + +const makeItems = (n: number): Item[] => + Array.from({ length: n }, (_, index) => ({ + id: `row-${index}`, + name: `Resource ${index}`, + status: index % 2 === 0 ? 'Available' : 'Pending', + })); + +// Positional layout: Name fixed 200, Status flexible (no width). +const COLUMNS: ReadonlyArray = [{ width: 200 }, {}]; + +interface RenderOptions { + count?: number; + items?: Item[]; + contentDensity?: 'comfortable' | 'compact'; + stickyHeader?: boolean; + loading?: boolean; + loadingText?: string; + empty?: React.ReactNode; +} + +// Stateful harness for the compound BasicTable over the headless hook. +function BasicTableHarness({ options }: { options: RenderOptions }) { + const items = options.items ?? makeItems(options.count ?? 5); + return ( + + + Name + Status + + + {items.map((item, index) => ( + + {item.name} + {item.status} + + ))} + + + ); +} + +function renderTable(options: RenderOptions = {}) { + const utils = render(); + const wrapper = createWrapper(utils.container).findBasicTable()!; + return { wrapper, ...utils }; +} + +function grid(wrapper: ReturnType['wrapper']) { + return wrapper.find('[role="grid"]')!.getElement(); +} + +describe('BasicTable (compound components over headless hook)', () => { + test('renders the declarative header cells and they are discoverable through the wrapper', () => { + const { wrapper } = renderTable(); + expect(wrapper).not.toBeNull(); + // The consumer declares the header; Root does NOT auto-render it from config. + expect(wrapper.findColumnHeaders()).toHaveLength(2); + expect(wrapper.findColumnHeaders()[0].getElement().textContent).toContain('Name'); + expect(wrapper.findColumnHeaders()[1].getElement().textContent).toContain('Status'); + }); + + test('exposes full-dataset aria-rowcount + aria-colcount and 1-based header colindex', () => { + const { wrapper } = renderTable({ count: 40 }); + // Root.totalRowCount is authoritative: aria-rowcount = totalRowCount + 1 (header). + expect(grid(wrapper).getAttribute('aria-rowcount')).toBe('41'); + expect(grid(wrapper).getAttribute('aria-colcount')).toBe('2'); + expect(wrapper.findColumnHeaders()[0].getElement().getAttribute('aria-colindex')).toBe('1'); + expect(wrapper.findColumnHeaders()[1].getElement().getAttribute('aria-colindex')).toBe('2'); + }); + + test('renders the mapped Row/Cell children with full-dataset aria-rowindex', () => { + const { wrapper } = renderTable({ count: 5 }); + expect(wrapper.findRows()).toHaveLength(5); + + const row0 = wrapper.findRowByIndex(0)!; + expect(row0.getElement().getAttribute('aria-rowindex')).toBe('2'); // header is 1 + const cells = row0.findAll('[role="gridcell"]'); + expect(cells).toHaveLength(2); + expect(cells[0].getElement().textContent).toBe('Resource 0'); + expect(cells[0].getElement().getAttribute('aria-colindex')).toBe('1'); + expect(cells[1].getElement().textContent).toBe('Available'); + expect(cells[1].getElement().getAttribute('aria-colindex')).toBe('2'); + + expect(wrapper.findRowByIndex(4)!.getElement().getAttribute('aria-rowindex')).toBe('6'); + }); + + describe('empty / loading', () => { + test('renders the empty state (and no data rows) when totalRowCount is 0', () => { + const { wrapper } = renderTable({ items: [], empty: 'No resources' }); + expect(wrapper.findRows()).toHaveLength(0); + expect(wrapper.getElement().textContent).toContain('No resources'); + }); + + test('renders a loading status indicator carrying the announced loading text', () => { + const { wrapper } = renderTable({ loading: true, loadingText: 'Loading resources' }); + expect(wrapper.findLoadingText()!.getElement()).toHaveTextContent('Loading resources'); + }); + }); + + describe('presentation', () => { + test('contentDensity="compact" applies the shared compact-table visual context', () => { + const { wrapper } = renderTable({ contentDensity: 'compact' }); + expect(grid(wrapper).className).toMatch(/compact-table/); + }); + + test('contentDensity defaults to comfortable (no compact-table context)', () => { + const { wrapper } = renderTable(); + expect(grid(wrapper).className).not.toMatch(/compact-table/); + }); + + test('Header sticky renders the header rowgroup with the sticky-header class', () => { + const { container } = renderTable({ stickyHeader: true }); + const thead = container.querySelector('thead')!; + expect(thead.classList.contains(styles['sticky-header'])).toBe(true); + }); + + test('without Header sticky the header rowgroup is not sticky', () => { + const { container } = renderTable(); + const thead = container.querySelector('thead')!; + expect(thead.classList.contains(styles['sticky-header'])).toBe(false); + }); + }); +}); diff --git a/src/beta/basic-table-0.1/__tests__/setup.ts b/src/beta/basic-table-0.1/__tests__/setup.ts new file mode 100644 index 0000000000..2fd9c7c0f5 --- /dev/null +++ b/src/beta/basic-table-0.1/__tests__/setup.ts @@ -0,0 +1,33 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import "@testing-library/jest-dom/vitest"; + +import axe from "axe-core"; +import { expect } from "vitest"; + +// Registers the `toValidateA11y` matcher used by the a11y suites. It runs +// axe-core against a rendered container and fails with the collected +// violations, mirroring the Cloudscape components test setup. +expect.extend({ + async toValidateA11y(received: HTMLElement) { + const results = await axe.run(received, { + rules: { + // Colour-contrast relies on real computed styles and is unreliable + // under jsdom, so it is disabled for component-level checks. + "color-contrast": { enabled: false }, + }, + }); + const pass = results.violations.length === 0; + return { + pass, + message: () => + pass + ? "expected the element to have accessibility violations" + : "expected the element to have no accessibility violations, but found:\n" + + results.violations + .map(violation => ` - [${violation.id}] ${violation.help} (${violation.nodes.length} node(s))`) + .join("\n"), + }; + }, +}); diff --git a/src/beta/basic-table-0.1/__tests__/use-basic-table.test.tsx b/src/beta/basic-table-0.1/__tests__/use-basic-table.test.tsx new file mode 100644 index 0000000000..871f39598c --- /dev/null +++ b/src/beta/basic-table-0.1/__tests__/use-basic-table.test.tsx @@ -0,0 +1,225 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import { act, renderHook } from '@testing-library/react'; +import { BasicTableProps, UseBasicTableConfig } from '../basic-table/interfaces'; +import { useBasicTable, UseBasicTableResult } from '../basic-table/use-basic-table'; + +// useBasicTable is the single behaviour implementation for BasicTable: a positional `columns` width +// list plus table state in, pure spreadable prop-getters out. The getters call no React hooks, so a +// consumer may call them in a loop or conditionally. All column access is by index; a columnId is +// only resolved when explicitly supplied (column-virtualization binding). Sorting is not in the core +// — it is composed via the SortToggle helper, covered by the demo-scoped sorting test. These tests +// drive the hook directly (getters + helpers), then a raw-DOM proof mounts a bare +// spreading only the getters onto native elements with no BasicTable.* components, +// asserting the grid a11y contract holds by construction. + +// Positional column layout: fixed 200 (floored at 150), a flexible track (no width), fixed 100. +const COLUMNS: BasicTableProps.ColumnDefinition[] = [{ width: 200, minWidth: 150 }, {}, { width: 100 }]; + +// Same shape but with stable ids — only needed to exercise id-based column resolution. +const ID_COLUMNS: BasicTableProps.ColumnDefinition[] = [{ id: 'name', width: 200 }, { id: 'status' }, { id: 'size', width: 100 }]; + +function renderTableHook(config?: Partial) { + return renderHook(() => useBasicTable({ columns: COLUMNS, totalRowCount: 40, ...config })); +} + +describe('useBasicTable getters (index-based, refined API)', () => { + test('getTableProps: grid role, header-counted aria-rowcount, column aria-colcount, label, tabIndex', () => { + const { result } = renderTableHook({ i18nStrings: { tableLabel: 'Resources' } }); + const props = result.current.getTableProps(); + expect(props.role).toBe('grid'); + expect(props['aria-rowcount']).toBe(41); // totalRowCount 40 + header + expect(props['aria-colcount']).toBe(3); + expect(props['aria-label']).toBe('Resources'); + expect(props.tabIndex).toBe(-1); + }); + + test('role="table" is reflected verbatim', () => { + const { result } = renderTableHook({ role: 'table' }); + expect(result.current.getTableProps().role).toBe('table'); + }); + + test('getHeaderGroupProps: header row is aria-rowindex 1 and carries the shared column template', () => { + const { result } = renderTableHook(); + const group = result.current.getHeaderGroupProps(); + expect(group.role).toBe('row'); + expect(group['aria-rowindex']).toBe(1); + // col0 fixed 200, col1 flexible (minmax 0 1fr), col2 fixed 100 — one shared template. + expect(group.style.gridTemplateColumns).toBe('200px minmax(0px, 1fr) 100px'); + }); + + test('getColumnHeaderProps: 1-based aria-colindex + scope col, and NO core aria-sort (sorting is composed)', () => { + const { result } = renderTableHook(); + const first = result.current.getColumnHeaderProps(0); + expect(first.role).toBe('columnheader'); + expect(first.scope).toBe('col'); + expect(first['aria-colindex']).toBe(1); + expect(result.current.getColumnHeaderProps(1)['aria-colindex']).toBe(2); + expect(result.current.getColumnHeaderProps(2)['aria-colindex']).toBe(3); + // The core header getter never emits aria-sort — the consumer spreads it (composed sorting). + expect('aria-sort' in first).toBe(false); + }); + + test('getBodyProps / getRowProps / getCellProps carry the grid ARIA numbers (by index)', () => { + const { result } = renderTableHook(); + expect(result.current.getBodyProps().role).toBe('rowgroup'); + + const row = result.current.getRowProps(0); + expect(row.role).toBe('row'); + expect(row['aria-rowindex']).toBe(2); // header is 1, data index 0 -> 2 + expect(row.style.gridTemplateColumns).toBe('200px minmax(0px, 1fr) 100px'); + + const cell = result.current.getCellProps(1, 0); + expect(cell.role).toBe('gridcell'); + expect(cell['aria-colindex']).toBe(2); + expect(cell['data-awsui-row-index']).toBe(0); + // Without a row index the data attribute is omitted. + expect(result.current.getCellProps(1)['data-awsui-row-index']).toBeUndefined(); + }); + + test('getResizeHandleProps: default resize roledescription (overridable via i18nStrings)', () => { + const { result } = renderTableHook(); + const handle = result.current.getResizeHandleProps(0); + expect(handle['aria-roledescription']).toBe('resize handle'); + expect(typeof handle.onPointerDown).toBe('function'); + + const { result: r2 } = renderTableHook({ i18nStrings: { resizerRoleDescription: 'width handle' } }); + expect(r2.current.getResizeHandleProps(0)['aria-roledescription']).toBe('width handle'); + }); + + test('resolveColumnIndex: by id when supplied, positional fallback otherwise', () => { + const { result } = renderHook(() => useBasicTable({ columns: ID_COLUMNS, totalRowCount: 3 })); + expect(result.current.resolveColumnIndex('status', null)).toBe(1); + expect(result.current.resolveColumnIndex('size', null)).toBe(2); + expect(result.current.resolveColumnIndex('missing', null)).toBe(-1); + // No id -> positional value (or 0 when none). + expect(result.current.resolveColumnIndex(undefined, 2)).toBe(2); + expect(result.current.resolveColumnIndex(undefined, null)).toBe(0); + }); + + test('stickyColumnId: config id if present, else the index', () => { + const { result } = renderHook(() => useBasicTable({ columns: ID_COLUMNS, totalRowCount: 3 })); + expect(result.current.stickyColumnId(0)).toBe('name'); + const { result: positional } = renderTableHook(); + expect(positional.current.stickyColumnId(0)).toBe('0'); + expect(positional.current.stickyColumnId(2)).toBe('2'); + }); + + test('gridTemplateColumns: controlled widths (keyed by INDEX) honour the column minWidth floor', () => { + // col0 width controlled below its 150 minWidth -> clamped to minWidth in the track. + const { result } = renderTableHook({ columnWidths: { 0: 80 } }); + expect(result.current.getHeaderGroupProps().style.gridTemplateColumns).toBe('150px minmax(0px, 1fr) 100px'); + }); + + test('adjustColumnWidth: keyboard step clamps at the resize floor (controlled, index-keyed)', () => { + const onColumnWidthsChange = vi.fn(); + const { result } = renderTableHook({ columnWidths: { 0: 200 }, onColumnWidthsChange }); + + // +10 from 200. + act(() => result.current.adjustColumnWidth(0, 10)); + expect(onColumnWidthsChange).toHaveBeenLastCalledWith( + expect.objectContaining({ detail: { widths: expect.objectContaining({ 0: 210 }) } }) + ); + + // A large negative step clamps at the column's floor (150), never below it. + act(() => result.current.adjustColumnWidth(0, -1000)); + expect(onColumnWidthsChange).toHaveBeenLastCalledWith( + expect.objectContaining({ detail: { widths: expect.objectContaining({ 0: 150 }) } }) + ); + }); + + test('currentColumnWidth + resizeMinWidthOf expose the resize slider values (controlled)', () => { + const { result } = renderTableHook({ columnWidths: { 0: 175 } }); + expect(result.current.currentColumnWidth(0)).toBe(175); + // col0 declares minWidth 150; a column with no minWidth floors at DEFAULT_COLUMN_WIDTH (120). + expect(result.current.resizeMinWidthOf(0)).toBe(150); + expect(result.current.resizeMinWidthOf(1)).toBe(120); + }); + + test('columnCount + columns reflect the config', () => { + const { result } = renderTableHook(); + expect(result.current.columnCount).toBe(3); + expect(result.current.columns).toHaveLength(3); + }); +}); + +// ----------------------------------------------------------------------------- +// Raw-DOM proof: the headless path with no BasicTable.* components. +// ----------------------------------------------------------------------------- + +const HEADER_LABELS = ['Name', 'Status', 'Size']; +const ROW_KEYS = ['name', 'status', 'size'] as const; +const DATA = [ + { name: 'Alpha', status: 'Up', size: '1' }, + { name: 'Beta', status: 'Down', size: '2' }, +]; + +// A bare table that spreads ONLY the hook's index-based getters onto native table elements. +function RawGrid(config?: Partial) { + function Grid() { + const t: UseBasicTableResult = useBasicTable({ columns: COLUMNS, totalRowCount: DATA.length, ...config }); + return ( +
+ + + {HEADER_LABELS.map((label, columnIndex) => ( + + ))} + + + + {DATA.map((item, rowIndex) => ( + + {ROW_KEYS.map((key, columnIndex) => ( + + ))} + + ))} + +
+ {label} +
+ {item[key]} +
+ ); + } + return render(); +} + +describe('useBasicTable raw-DOM contract (no BasicTable.* components)', () => { + test('the spread getters alone produce a coherent role=grid a11y tree', () => { + const { container } = RawGrid({ i18nStrings: { tableLabel: 'Resources' } }); + const grid = container.querySelector('table')!; + expect(grid.getAttribute('role')).toBe('grid'); + expect(grid.getAttribute('aria-rowcount')).toBe('3'); // 2 data rows + header + expect(grid.getAttribute('aria-colcount')).toBe('3'); + expect(grid.getAttribute('aria-label')).toBe('Resources'); + + const headers = Array.from(grid.querySelectorAll('[role="columnheader"]')); + expect(headers).toHaveLength(3); + expect(headers[0].getAttribute('aria-colindex')).toBe('1'); + expect(headers[0].getAttribute('scope')).toBe('col'); + expect(headers[2].getAttribute('aria-colindex')).toBe('3'); + + // Data rows carry a full-dataset aria-rowindex; cells carry a 1-based aria-colindex. + const rows = Array.from(grid.querySelectorAll('tbody [role="row"]')); + expect(rows[0].getAttribute('aria-rowindex')).toBe('2'); + expect(rows[1].getAttribute('aria-rowindex')).toBe('3'); + const firstRowCells = rows[0].querySelectorAll('[role="gridcell"]'); + expect(firstRowCells[0].getAttribute('aria-colindex')).toBe('1'); + expect(firstRowCells[2].getAttribute('aria-colindex')).toBe('3'); + expect(firstRowCells[0].getAttribute('data-awsui-row-index')).toBe('0'); + }); + + test('the shared grid-template-columns aligns the header row and every data row', () => { + const { container } = RawGrid(); + const template = '200px minmax(0px, 1fr) 100px'; + const headerRow = container.querySelector('thead [role="row"]') as HTMLElement; + const dataRow = container.querySelector('tbody [role="row"]') as HTMLElement; + expect(headerRow.style.gridTemplateColumns).toBe(template); + expect(dataRow.style.gridTemplateColumns).toBe(template); + }); +}); diff --git a/src/beta/basic-table-0.1/__tests__/use-column-window.test.tsx b/src/beta/basic-table-0.1/__tests__/use-column-window.test.tsx new file mode 100644 index 0000000000..0ba8db82f8 --- /dev/null +++ b/src/beta/basic-table-0.1/__tests__/use-column-window.test.tsx @@ -0,0 +1,172 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { computeColumnWindow } from '../use-virtualization/use-column-window'; + +// Pure-geometry unit tests for the column-virtualization window. computeColumnWindow maps a +// horizontal viewport (scrollLeft + width) onto the fixed px widths of the data columns and returns +// the intersecting indices (± overscan) unioned with any pinned first/last columns. + +// 10 columns × 100px each = 1000px total. +const WIDTHS = Array.from({ length: 10 }, () => 100); + +function indices(set: Set): number[] { + return [...set].sort((a, b) => a - b); +} + +describe('computeColumnWindow', () => { + test('viewport at the left edge selects the leading columns', () => { + const { + first, + last, + indices: set, + } = computeColumnWindow({ + widths: WIDTHS, + leadingOffset: 0, + scrollLeft: 0, + viewportWidth: 250, + overscan: 0, + pinnedFirst: 0, + pinnedLast: 0, + }); + // [0,250) intersects columns 0,1,2 ([0,100),[100,200),[200,300)). + expect(first).toBe(0); + expect(last).toBe(2); + expect(indices(set)).toEqual([0, 1, 2]); + }); + + test('viewport in the middle selects the middle columns', () => { + const { first, last } = computeColumnWindow({ + widths: WIDTHS, + leadingOffset: 0, + scrollLeft: 420, + viewportWidth: 200, + overscan: 0, + pinnedFirst: 0, + pinnedLast: 0, + }); + // [420,620) intersects columns 4 ([400,500)),5,6 ([600,700)). + expect(first).toBe(4); + expect(last).toBe(6); + }); + + test('viewport at the right edge selects the trailing columns', () => { + const { first, last } = computeColumnWindow({ + widths: WIDTHS, + leadingOffset: 0, + scrollLeft: 800, + viewportWidth: 200, + overscan: 0, + pinnedFirst: 0, + pinnedLast: 0, + }); + // [800,1000) intersects columns 8,9. + expect(first).toBe(8); + expect(last).toBe(9); + }); + + test('leadingOffset shifts the intersection by the disclosure track width', () => { + const { first, last } = computeColumnWindow({ + widths: WIDTHS, + leadingOffset: 40, + scrollLeft: 0, + viewportWidth: 250, + overscan: 0, + pinnedFirst: 0, + pinnedLast: 0, + }); + // Columns now start at 40: col0 [40,140), col1 [140,240), col2 [240,340). [0,250) hits 0,1,2. + expect(first).toBe(0); + expect(last).toBe(2); + }); + + test('overscan expands the window symmetrically and clamps to [0, n-1]', () => { + const { + first, + last, + indices: set, + } = computeColumnWindow({ + widths: WIDTHS, + leadingOffset: 0, + scrollLeft: 420, + viewportWidth: 200, + overscan: 2, + pinnedFirst: 0, + pinnedLast: 0, + }); + // Base window 4..6, overscan 2 -> 2..8. + expect(first).toBe(2); + expect(last).toBe(8); + expect(indices(set)).toEqual([2, 3, 4, 5, 6, 7, 8]); + + // Near the left edge overscan clamps at 0. + const left = computeColumnWindow({ + widths: WIDTHS, + leadingOffset: 0, + scrollLeft: 0, + viewportWidth: 100, + overscan: 5, + pinnedFirst: 0, + pinnedLast: 0, + }); + expect(left.first).toBe(0); + }); + + test('pinnedFirst / pinnedLast are always included even far off-window', () => { + const { indices: set } = computeColumnWindow({ + widths: WIDTHS, + leadingOffset: 0, + scrollLeft: 420, + viewportWidth: 200, + overscan: 0, + pinnedFirst: 2, + pinnedLast: 1, + }); + // Window 4..6 plus pinned columns 0,1 (first) and 9 (last). + expect(indices(set)).toEqual([0, 1, 4, 5, 6, 9]); + }); + + test('zero (or negative) viewport width falls back to ALL indices', () => { + const { + first, + last, + indices: set, + } = computeColumnWindow({ + widths: WIDTHS, + leadingOffset: 0, + scrollLeft: 0, + viewportWidth: 0, + overscan: 0, + pinnedFirst: 0, + pinnedLast: 0, + }); + expect(first).toBe(0); + expect(last).toBe(9); + expect(set.size).toBe(10); + }); + + test('empty widths returns an empty window', () => { + const { indices: set } = computeColumnWindow({ + widths: [], + leadingOffset: 0, + scrollLeft: 0, + viewportWidth: 500, + overscan: 3, + pinnedFirst: 1, + pinnedLast: 1, + }); + expect(set.size).toBe(0); + }); + + test('scrolled entirely past all content falls back to ALL indices (safe)', () => { + const { indices: set } = computeColumnWindow({ + widths: WIDTHS, + leadingOffset: 0, + scrollLeft: 5000, + viewportWidth: 200, + overscan: 0, + pinnedFirst: 0, + pinnedLast: 0, + }); + expect(set.size).toBe(10); + }); +}); diff --git a/src/beta/basic-table-0.1/__tests__/use-virtualization.test.tsx b/src/beta/basic-table-0.1/__tests__/use-virtualization.test.tsx new file mode 100644 index 0000000000..0c7083462f --- /dev/null +++ b/src/beta/basic-table-0.1/__tests__/use-virtualization.test.tsx @@ -0,0 +1,276 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { act, render } from '@testing-library/react'; + +import { VirtualizationConfig, VirtualizationResult } from '../use-virtualization'; +import { useVirtualization } from '../use-virtualization'; + +// useVirtualization is the raw, count-based virtualization primitive. It owns its own inner scroll +// container (resolved from the runway node's nearest scrollable ancestor), windows a row `count` over +// that viewport, and returns plain positioning props the consumer spreads onto a BasicTable. It is +// data/column/expansion agnostic: index-based only. +// +// jsdom has no layout, so the primitive is mounted through a harness that spreads `runwayProps` onto +// a real overflow:auto
(its nearest scrollable ancestor). After mount the scroll ancestor's +// geometry is stubbed (clientHeight/scrollHeight/scrollTop, with a writable scrollTop) so +// scroll-driven windowing, the live-tail pin, and scrollToIndex/scrollToEnd exercise the real engine. + +// Records every ResizeObserver so a test can fire a measurement callback deterministically. +interface MockObserver { + cb: ResizeObserverCallback; + node?: Element; + disconnected: boolean; +} +let observers: MockObserver[] = []; +const OriginalResizeObserver = window.ResizeObserver; + +beforeEach(() => { + observers = []; + class MockResizeObserver { + private record: MockObserver; + constructor(cb: ResizeObserverCallback) { + this.record = { cb, disconnected: false }; + observers.push(this.record); + } + observe(node: Element) { + this.record.node = node; + } + unobserve() {} + disconnect() { + this.record.disconnected = true; + } + } + window.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; +}); + +afterEach(() => { + window.ResizeObserver = OriginalResizeObserver; +}); + +function stubHeight(height: number): HTMLElement { + const node = document.createElement('div'); + node.getBoundingClientRect = () => + ({ height, width: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON() {} }) as DOMRect; + return node; +} + +interface Geometry { + clientHeight?: number; + scrollHeight?: number; + scrollTop?: number; +} + +function Harness({ + config, + onResult, +}: { + config: VirtualizationConfig; + onResult: (result: VirtualizationResult) => void; +}) { + const v = useVirtualization(config); + onResult(v); + // The runway's nearest scrollable ancestor (overflowY:auto) is the viewport the primitive + // resolves + measures. Runway spreads runwayProps (relative runway + measure/scroll-locate ref). + return ( +
+
+
+ ); +} + +function renderVirtual(config: VirtualizationConfig, geom: Geometry = {}) { + let current!: VirtualizationResult; + const { container, rerender } = render( (current = v)} />); + const scroll = container.querySelector('[data-testid="scroll"]') as HTMLElement; + let top = geom.scrollTop ?? 0; + const clientHeight = geom.clientHeight ?? 0; + let scrollHeight = geom.scrollHeight ?? 0; + Object.defineProperty(scroll, 'clientHeight', { configurable: true, get: () => clientHeight }); + Object.defineProperty(scroll, 'scrollHeight', { configurable: true, get: () => scrollHeight }); + // Clamp scrollTop to [0, scrollHeight-clientHeight] exactly like a real browser, so the + // scrollToEnd clamp-vs-seed behaviour is faithfully exercised. + Object.defineProperty(scroll, 'scrollTop', { + configurable: true, + get: () => top, + set: v => (top = Math.max(0, Math.min(v, Math.max(0, scrollHeight - clientHeight)))), + }); + return { + get result() { + return current; + }, + scroll, + setScrollTop: (v: number) => (top = v), + setScrollHeight: (v: number) => (scrollHeight = v), + rerenderWithCount: (count: number) => + rerender( (current = v)} />), + }; +} + +describe('useVirtualization (count-based primitive)', () => { + describe('windowing', () => { + test('windows a large dataset to far fewer rows than it holds, with {index, offset} items', () => { + const { result } = renderVirtual({ count: 1000, estimatedRowHeight: 20, overscan: 5 }); + expect(result.window.length).toBeGreaterThan(0); + expect(result.window.length).toBeLessThan(1000); + // 600px fallback viewport / 20px rows = 30 visible; +5 overscan -> last data index 35. + expect(result.visibleRange).toEqual({ firstIndex: 0, lastIndex: 35 }); + expect(result.window[0]).toEqual({ index: 0, offset: 0 }); + expect(result.window[1]).toEqual({ index: 1, offset: 20 }); + expect(result.window[result.window.length - 1]).toEqual({ index: 35, offset: 35 * 20 }); + }); + + test('runway is sized (min) to the full virtual height and is a relative positioning context', () => { + const { result } = renderVirtual({ count: 1000, estimatedRowHeight: 20 }); + expect(result.runwayProps.style!.minBlockSize).toBe(1000 * 20); + expect(result.runwayProps.style!.position).toBe('relative'); + expect(typeof result.runwayProps.ref).toBe('function'); + }); + + test('recomputes the visible range + fires onVisibleRangeChange when the container scrolls', () => { + const onVisibleRangeChange = vi.fn(); + const h = renderVirtual( + { count: 1000, estimatedRowHeight: 20, overscan: 5, onVisibleRangeChange }, + { clientHeight: 0, scrollHeight: 20000, scrollTop: 0 } + ); + // Initial windowed range is announced on mount. + expect(onVisibleRangeChange).toHaveBeenLastCalledWith({ firstIndex: 0, lastIndex: 35 }); + onVisibleRangeChange.mockClear(); + + // Scroll to offset 4000 (row 200): firstVisible 200 - 5 = 195; lastVisible 230 + 5 = 235. + act(() => { + h.setScrollTop(4000); + h.scroll.dispatchEvent(new Event('scroll')); + }); + expect(h.result.visibleRange).toEqual({ firstIndex: 195, lastIndex: 235 }); + expect(onVisibleRangeChange).toHaveBeenLastCalledWith({ firstIndex: 195, lastIndex: 235 }); + }); + }); + + describe('rowProps', () => { + test('positions a fixed row absolutely at its offset, clamps its block-size, sets aria-rowindex=index+2', () => { + const { result } = renderVirtual({ count: 100, estimatedRowHeight: 20 }); + const first = result.rowProps(0, 0); + expect(first['aria-rowindex']).toBe(2); // header counted as row 1 + expect(first.style!.position).toBe('absolute'); + expect(first.style!.insetBlockStart).toBe(0); + expect(first.style!.blockSize).toBe(20); // fixed rows are clamped to their model pitch + expect(typeof first.ref).toBe('function'); + + const fifth = result.rowProps(5, 100); + expect(fifth['aria-rowindex']).toBe(7); + expect(fifth.style!.insetBlockStart).toBe(100); + }); + + test("an 'auto' row is left unbounded (no block-size clamp) so it can measure its real height", () => { + const { result } = renderVirtual({ count: 10, estimatedRowHeight: 20, getRowHeight: () => 'auto', overscan: 5 }); + const auto = result.rowProps(0, 0); + expect(auto.style!.blockSize).toBeUndefined(); + }); + }); + + describe('getRowHeight strategy', () => { + test('uniform fixed rows: total runway is a simple product', () => { + const { result } = renderVirtual({ count: 1000, estimatedRowHeight: 20 }); + expect(result.runwayProps.style!.minBlockSize).toBe(1000 * 20); + }); + + test("'auto' rows seed at the estimate, then an observed measurement reflows the runway", () => { + // NOTE: use the live `h.result` accessor (not a destructured value) so post-reflow reads see + // the latest render's result. + const h = renderVirtual({ count: 10, estimatedRowHeight: 20, getRowHeight: () => 'auto', overscan: 5 }); + // Pre-measurement: every auto row seeds at estimatedRowHeight -> 10 * 20. + expect(h.result.runwayProps.style!.minBlockSize).toBe(10 * 20); + + // The measure ref for a fixed row is never observed; an auto row IS observed, and firing its + // observer applies the real height and reflows the runway. + const before = observers.length; + act(() => h.result.rowProps(0, 0).ref!(stubHeight(55))); + expect(observers.length).toBe(before + 1); + act(() => { + const obs = observers[observers.length - 1]; + obs.cb([], obs as unknown as ResizeObserver); + }); + expect(h.result.runwayProps.style!.minBlockSize).toBe(55 + 9 * 20); + }); + }); + + describe('scroll anchoring + live tail', () => { + test('scrollToEnd pins the viewport to the bottom of the runway; isPinnedToEnd reflects it', () => { + const h = renderVirtual({ count: 100, estimatedRowHeight: 20 }, { clientHeight: 100, scrollHeight: 2000, scrollTop: 0 }); + expect(h.result.isPinnedToEnd()).toBe(false); // gap 1900 > one row + + act(() => h.result.scrollToEnd()); + expect(h.scroll.scrollTop).toBe(1900); // scrollHeight - clientHeight + expect(h.result.isPinnedToEnd()).toBe(true); // gap 0 + }); + + test('isPinnedToEnd tolerates within one row of the bottom (absorbs the sub-pixel clamp gap)', () => { + // gap 15 < estimatedRowHeight 20 -> pinned. + const near = renderVirtual({ count: 100, estimatedRowHeight: 20 }, { clientHeight: 100, scrollHeight: 1000, scrollTop: 885 }); + expect(near.result.isPinnedToEnd()).toBe(true); + // gap 25 > 20 -> not pinned. + const up = renderVirtual({ count: 100, estimatedRowHeight: 20 }, { clientHeight: 100, scrollHeight: 1000, scrollTop: 875 }); + expect(up.result.isPinnedToEnd()).toBe(false); + }); + + test('when pinned, an appended row re-targets the true bottom instead of the stale position', () => { + const h = renderVirtual({ count: 50, estimatedRowHeight: 20 }, { clientHeight: 100, scrollHeight: 1000, scrollTop: 900 }); + // A user scroll at the bottom edge latches the end-pin. + act(() => { + h.scroll.scrollTop = 900; + h.scroll.dispatchEvent(new Event('scroll')); + }); + expect(h.result.isPinnedToEnd()).toBe(true); + + // Append one row: runway grows one row (1000 -> 1020). The anchor-correction layout effect + // must drive scrollTop to the NEW end (1020 - 100 = 920), not hold 900. + act(() => { + h.setScrollHeight(1020); + h.scroll.dispatchEvent(new Event('scroll')); + }); + // Append one row: count grows 50 -> 51 (runway 1000 -> 1020). The anchor-correction layout + // effect re-targets the pinned viewport to the new bottom (1020 - 100 = 920), not the stale 900. + act(() => { + h.setScrollHeight(1020); + h.rerenderWithCount(51); + }); + expect(h.result.isPinnedToEnd()).toBe(true); + expect(h.scroll.scrollTop).toBe(920); + }); + + test('a wheel scroll-up (deltaY < 0) releases live-tail follow', () => { + const h = renderVirtual({ count: 50, estimatedRowHeight: 20 }, { clientHeight: 100, scrollHeight: 1000, scrollTop: 0 }); + act(() => h.result.scrollToEnd()); + expect(h.result.isPinnedToEnd()).toBe(true); + + act(() => { + h.scroll.dispatchEvent(new WheelEvent('wheel', { deltaY: -10 })); + }); + // The latch is released: scrolling up away from the bottom is no longer pinned. + act(() => { + h.setScrollTop(0); + h.scroll.dispatchEvent(new Event('scroll')); + }); + expect(h.result.isPinnedToEnd()).toBe(false); + }); + + test('F-U8: scrollToIndex targets the row start AND releases the live-tail pin', () => { + const h = renderVirtual({ count: 50, estimatedRowHeight: 20 }, { clientHeight: 100, scrollHeight: 1000, scrollTop: 900 }); + act(() => h.result.scrollToEnd()); + expect(h.result.isPinnedToEnd()).toBe(true); + + // A programmatic scroll-to-row is an explicit "go here" intent: land at the target row start + // (index 10 -> 10 * 20 = 200) and RELEASE the pin so the layout effect does not snap back. + act(() => h.result.scrollToIndex(10)); + expect(h.scroll.scrollTop).toBe(200); + expect(h.result.isPinnedToEnd()).toBe(false); + }); + + test('scrollToIndex is a no-op for an out-of-range index', () => { + const h = renderVirtual({ count: 10, estimatedRowHeight: 20 }, { clientHeight: 100, scrollHeight: 200, scrollTop: 50 }); + act(() => h.result.scrollToIndex(999)); + expect(h.scroll.scrollTop).toBe(50); + }); + }); +}); diff --git a/src/beta/basic-table-0.1/basic-table/USAGE.md b/src/beta/basic-table-0.1/basic-table/USAGE.md new file mode 100644 index 0000000000..a95163780f --- /dev/null +++ b/src/beta/basic-table-0.1/basic-table/USAGE.md @@ -0,0 +1,234 @@ + + + +# BasicTable usage + +BasicTable is a minimal, composable table you assemble from compound parts. It owns the +windowing-free table concerns — column-track layout and alignment, resizable columns, +sticky header, sticky columns, keyboard grid navigation, custom row-detail expansion, +empty/loading states, density, styling, and the grid accessibility semantics. It renders +the header and rows it is *given* as declarative children: it has no `items` prop and does +not own the data. + +Two more capabilities ship alongside it, each opt-in and wired by hand: + +- **Virtualization** is a separate primitive, `useVirtualization`, not a BasicTable prop. It + windows a large or streaming dataset over a BasicTable so only the visible rows render. + `useColumnVirtualization` does the same for the horizontal axis. +- **Sorting** is composed on top by the consumer. The core carries no sort state; the consumer + owns its sort state, sorts its own data, and marks the sorted column. A ready-made `SortToggle` + pattern is shown in the composed-sorting demo. + +## Layers and exports + +The package (default export `BasicTable`, plus named exports) provides three things: + +- **Compound components** — `BasicTable.{Root, Header, HeaderCell, Body, Row, Cell, + ExpandedContent}`. A thin self-rendering layer; each part renders its own DOM using the + hook's prop-getters. This is what most consumers use. +- **Headless hook** — `useBasicTable`. A positional `columns` config plus table state in, pure + prop-getters out. Use it directly only to build a table surface the compound parts can't + render for you. +- **Virtualization primitives** — `useVirtualization` and `useColumnVirtualization`. Standalone + count-based windowing hooks the consumer spreads onto a BasicTable. + +Also exported: the props type `BasicTableProps`, `UseBasicTableConfig`, `UseBasicTableResult`, +`BasicTableGetters`, and the virtualization config/result types. + +## When to use + +- Use BasicTable when you want to own how each row and its cells are assembled — the parts + read like markup, and expanded detail stays next to the row it belongs to. +- Add `useVirtualization` when the dataset is large enough that rendering every row hurts + responsiveness, or when rows arrive continuously (a live log stream) and the view must keep + up. BasicTable itself renders exactly the rows you pass it. +- Use the built-in row expansion when a row expands into detail that doesn't match the column + layout — a key-value record, a raw or formatted log line, or a small chart. +- Use Cloudscape `Table` for small static datasets or when you need built-in selection, inline + editing, or the standard expandable-rows model that reuses the same columns. BasicTable keeps a + smaller surface and does not offer those. + +## Composing the parts + +`Root` takes the `columns` config and wraps a declarative `Header` (of `HeaderCell`s) and a +`Body` (of `Row`s, each holding `Cell`s and an optional `ExpandedContent`). Cells bind to columns +**by position** by default — the Nth `HeaderCell` / `Cell` maps to the Nth `columns` entry. + +```tsx + + + Name + Description + + + {rows.map((row, index) => ( + + {row.name} + {row.description} + + ))} + + +``` + +`Header` and `Body` render the children they are given — there is no function-child template and +no config harvested from the JSX. + +## Columns + +`columns` is a positional width list; each entry is `{ width?, minWidth?, id? }` in column order. + +- A **flexible** column omits `width` and gets `minmax(minWidth, 1fr)`, sharing the remaining + space. A **fixed** column sets `width`. +- `minWidth` is the `minmax` floor for a flexible column and the floor a resize cannot drop below. +- `id` is optional. Supply it only to bind a `HeaderCell` / `Cell` by `columnId` instead of by + position, or when `useColumnVirtualization` targets the column. + +The `columns` list is the single source of column *width* authority; the header cells are the +source of column *order and count*. + +### Resizing + +Set `resizableColumns` on `Root` to render resize handles. Widths are keyed by column **index**; +read `onColumnWidthsChange` and pass `columnWidths` back to control and persist them. A column +cannot resize below its `minWidth` (or the default floor of 120px). The handle supports pointer +drag and a keyboard-drag mode (Enter/Space to enter, arrows to resize, Enter/Escape to exit). + +### Sticky columns + +`stickyColumns={{ first, last }}` pins that many leading / trailing columns during horizontal +scroll. + +## Sorting + +Sorting is composed, not built in, and ships no package export. Place a sort trigger inside a +`HeaderCell`, keep your own sort state, sort your own data, and set `aria-sort` on the `HeaderCell`. +The composed-sorting demo provides a ready-made `SortToggle` you can copy: + +```tsx + + + Name + + +``` + +The demo's `SortToggleState` is `'none' | 'ascending' | 'descending'`. The toggle renders the label +and a directional caret and participates in roving-tabindex keyboard navigation. + +## Row expansion + +A row can expand into arbitrary non-tabular content. Nest a `BasicTable.ExpandedContent` inside the +`Row`; it renders a labeled region spanning all columns, and only when the row is expanded. + +- Expansion is consumer-controlled per row: set `expanded` on the `Row` and update it from + `onToggleExpand`. +- Give the `Row` a stable `id`. Place the disclosure toggle in a `Cell` with id `${row.id}-toggle`; + pressing Escape inside the expanded region returns focus to that toggle (or calls + `onToggleExpand` if it isn't found). +- Give `ExpandedContent` a `label` so screen-reader users navigating by region know which row they + are reading. + +## Virtualization + +`useVirtualization` windows a dataset over a BasicTable. It knows nothing about data, columns, or +expansion — it takes a row `count` and a height strategy and returns positioning props to spread: + +```tsx +const v = useVirtualization({ count: items.length, estimatedRowHeight: 40 }); + + + {/* header cells */} + + {v.window.map(({ index, offset }) => ( + + {/* cells */} + + ))} + + +``` + +Config (`VirtualizationConfig`): + +- `count` — total row count of the full dataset. +- `estimatedRowHeight` — runway height per row before measurement. +- `getRowHeight?(index)` — return a fixed px height, or `'auto'` to measure the row (wrapping + lines, or a row expanded into nested content). Omit for uniform fixed rows (the fast path, no row + is observed). Keep the reference stable. +- `overscan?` — rows rendered beyond the visible range on each side (default 10). +- `getExpandedRowHeight?(index)` — a pre-measurement runway seed for an `'auto'` row so the runway + does not jump on first entry. +- `onVisibleRangeChange?` — fires when the windowed index range changes. + +Result (`VirtualizationResult`): + +- `window` — the `[{ index, offset }]` slice to iterate (visible range plus overscan), *not* the + whole dataset. +- `runwayProps` — spread onto `Body`; sizes the runway to the full virtual height and provides the + ref used to locate the scroll viewport. +- `rowProps(index, offset)` — spread onto each windowed `Row`; sets absolute offset positioning, + the `aria-rowindex` override, and a measure ref for `'auto'` rows. Fixed rows are clamped to their + model height; `'auto'` rows are left unbounded so they measure their real height. +- `scrollToIndex(index)` — scroll a row into view; releases the live-tail pin. +- `scrollToEnd()` — pin the viewport to the last row (compose stick-to-bottom live tail on top). +- `isPinnedToEnd()` — true when the viewport is pinned to the last row. +- `visibleRange` — the current `{ firstIndex, lastIndex }`. + +Bound the viewport (a `height` / `maxHeight` on `Root`, or a bounded parent) so the table windows +instead of mounting every row. Set `totalRowCount` on `Root` to the full dataset size so the grid's +`aria-rowcount` and empty detection are correct even though only a slice is rendered. + +### Column virtualization + +`useColumnVirtualization` windows the horizontal axis. It only makes sense when every column has a +fixed px width (deterministic offsets); flexible (`1fr`) columns should not use it. + +Config (`ColumnVirtualizationConfig`): `widths` (fixed px widths in column order), `leadingOffset?` +(width of any leading track before the first column, default 0), `overscan?` (default 3), +`pinnedFirst?` / `pinnedLast?` (leading/trailing columns always rendered, default 0). + +Result (`ColumnVirtualizationResult`): `visibleColumns` (the `Set` of column indices to +render this frame), `ref` (attach to the horizontal scroll container), and `trackStart(columnIndex)` +(the absolute grid line the column starts at, spread as `grid-column-start` so a windowed cell lands +on its real track). Render only the cells whose index is in `visibleColumns` and give each a +`columnId` (from its `columns` entry) so it binds to the right column. + +## The headless hook + +`useBasicTable(config)` is the behaviour implementation the compound parts call. Reach for it +directly only when the parts can't render your surface. Its config mirrors `Root`'s headless +subset: `columns`, `role`, `resizableColumns`, `columnWidths` / `onColumnWidthsChange`, +`stickyColumns`, `contentDensity`, `totalRowCount`, and `i18nStrings`. + +It returns pure, index-based prop-getters to spread onto your own DOM — `getTableProps`, +`getHeaderGroupProps`, `getColumnHeaderProps`, `getResizeHandleProps`, `getBodyProps`, +`getRowProps`, `getCellProps` — plus `gridTemplateColumns`, `columnCount`, the sticky-columns model, +and the resize helpers. The getters never call React hooks (a consumer may call them in a loop or +conditionally), so element-level concerns that need a hook per element — roving tabindex and +per-cell sticky offsets — are applied by the parts themselves, not returned by the getters. + +## Root reference + +Beyond the shared config above, `BasicTable.Root` adds the presentational shell: + +- `children` — a declarative `Header` plus a `Body`. +- `columnLayout` — `'fixed'` (default) or `'auto'`. +- `height` / `maxHeight` — px bounds for the scroll viewport. +- `header` — a slot above the grid (title, counter, actions). +- `empty` — rendered when `totalRowCount` is 0. +- `loading` / `loadingText` — loading state for the whole grid. + +## Accessibility + +- BasicTable owns the grid accessibility semantics: the row and column counts and indices, the + disclosure column, the expanded-region wiring, focus management, and keyboard behavior. When you + build on the parts (or the headless hook), spread its props rather than re-authoring ARIA. +- Keyboard model: `role="grid"` (the default) gives cell-by-cell roving-tabindex navigation; the + grid is a single tab stop. Use `role="table"` for static, non-interactive data. +- `totalRowCount` on `Root` is authoritative for the grid's `aria-rowcount` and empty detection — + set it to the full dataset size, especially under virtualization where only a slice is rendered. + The header is row 1, so data rows carry `aria-rowindex` of index + 2. +- Supply the accessible names the component needs: `i18nStrings.tableLabel` for the table, + `ExpandedContent`'s `label` for each expanded region, and (when resizing) + `i18nStrings.resizerRoleDescription` for the resize handle. diff --git a/src/beta/basic-table-0.1/basic-table/context.ts b/src/beta/basic-table-0.1/basic-table/context.ts new file mode 100644 index 0000000000..f4aeeec1f7 --- /dev/null +++ b/src/beta/basic-table-0.1/basic-table/context.ts @@ -0,0 +1,55 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { createContext, useContext } from 'react'; + +import { UseBasicTableResult } from './use-basic-table'; + +// Shared state `BasicTable.Root` provides to its compound components. The context carries the WHOLE +// `useBasicTable` hook instance — the pure prop-getters plus the column config and the shared +// models (sticky-columns, resize wiring). The +// Header/HeaderCell/Body/Row/Cell/ExpandedContent components read the getters + config from here +// and self-render their DOM; there is no JSX harvesting and no second behaviour implementation. + +const BasicTableContext = createContext(null); + +export const BasicTableContextProvider = BasicTableContext.Provider; + +export function useBasicTableContext(component: string): UseBasicTableResult { + const context = useContext(BasicTableContext); + if (!context) { + throw new Error(`BasicTable.${component} must be used within BasicTable.Root.`); + } + return context; +} + +// Row-scoped context so `Cell` learns its row index (for `getCellProps`) and `ExpandedContent` +// learns its row's `expanded`/`id`/`onToggleExpand` — all WITHOUT the parent probing child types. +export interface BasicRowContextValue { + index: number; + id?: string; + expanded?: boolean; + onToggleExpand?: () => void; +} + +const BasicRowContext = createContext(null); + +export const BasicRowContextProvider = BasicRowContext.Provider; + +export function useBasicRowContext(component: string): BasicRowContextValue { + const context = useContext(BasicRowContext); + if (!context) { + throw new Error(`BasicTable.${component} must be used within BasicTable.Row.`); + } + return context; +} + +// Positional column index. `Header` and `Row` wrap each of their +// children in this Provider (a Context.Provider emits NO DOM, so the `
`/`` stays a direct +// grid child), giving the Nth `HeaderCell`/`Cell` its column index WITHOUT the parent probing +// `child.type`. A cell that sets an explicit `columnId` ignores this and resolves by id instead. +export const ColumnIndexContext = createContext(null); +export const ColumnIndexProvider = ColumnIndexContext.Provider; + +export function useColumnIndexContext(): number | null { + return useContext(ColumnIndexContext); +} diff --git a/src/beta/basic-table-0.1/basic-table/index.tsx b/src/beta/basic-table-0.1/basic-table/index.tsx new file mode 100644 index 0000000000..a7bf49a9eb --- /dev/null +++ b/src/beta/basic-table-0.1/basic-table/index.tsx @@ -0,0 +1,88 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { BasicTableProps } from './interfaces'; +import { Body, Cell, ExpandedContent, Header, HeaderCell, InternalRoot, Row } from './internal'; + +// Public API of the BasicTable beta module. Exports are FLAT (BasicTable + BasicTable*): the parts +// are individually importable, tree-shakeable, and each is a normal documentable component with its +// own props type. `BasicTable` is the root/default export; the parts are named exports. The headless +// `useBasicTable` hook and the virtualization primitives are re-exported for convenience. + +// `BasicTable` is the base component: it takes the positional `columns` config, drives the headless +// `useBasicTable` hook, provides its instance to the self-rendering parts, and renders the grid shell +// around the declarative header + rows it is given. +function BasicTable({ + columnLayout = 'fixed', + role = 'grid', + loading = false, + resizableColumns = false, + ...props +}: BasicTableProps) { + const baseComponentProps = useBaseComponent('BasicTable', { + props: { columnLayout, role, resizableColumns }, + }); + return ( + + ); +} + +// Flat aliases for the self-rendering parts (defined in ./internal). Aliasing keeps one source of +// truth for the implementation while presenting a flat, documentable public surface. +const BasicTableHeader = Header; +const BasicTableHeaderCell = HeaderCell; +const BasicTableBody = Body; +const BasicTableRow = Row; +const BasicTableCell = Cell; +const BasicTableExpandedContent = ExpandedContent; + +applyDisplayName(BasicTable, 'BasicTable'); +applyDisplayName(BasicTableHeader, 'BasicTableHeader'); +applyDisplayName(BasicTableHeaderCell, 'BasicTableHeaderCell'); +applyDisplayName(BasicTableBody, 'BasicTableBody'); +applyDisplayName(BasicTableRow, 'BasicTableRow'); +applyDisplayName(BasicTableCell, 'BasicTableCell'); +applyDisplayName(BasicTableExpandedContent, 'BasicTableExpandedContent'); + +export default BasicTable; +export { BasicTableHeader, BasicTableHeaderCell, BasicTableBody, BasicTableRow, BasicTableCell, BasicTableExpandedContent }; + +export { useBasicTable } from './use-basic-table'; +export { useVirtualization } from '../use-virtualization/use-virtualization'; +export { useColumnVirtualization } from '../use-virtualization/use-column-window'; + +// Root + headless types. +export type { BasicTableProps }; +export type { UseBasicTableConfig, BasicTableGetters } from './interfaces'; +export type { UseBasicTableResult } from './use-basic-table'; +export type { StickyColumnsModel } from '../../../table/sticky-columns'; + +// Flat per-part props types (documenter keys on `Props`). +export type BasicTableHeaderProps = BasicTableProps.HeaderProps; +export type BasicTableHeaderCellProps = BasicTableProps.HeaderCellProps; +export type BasicTableBodyProps = BasicTableProps.BodyProps; +export type BasicTableRowProps = BasicTableProps.RowProps; +export type BasicTableCellProps = BasicTableProps.CellProps; +export type BasicTableExpandedContentProps = BasicTableProps.ExpandedContentProps; + +// Virtualization primitive types. +export type { + VirtualizationConfig, + VirtualizationResult, + VirtualizationWindowItem, + VirtualizationRowProps, + VirtualizationVisibleRange, + ColumnVirtualizationConfig, + ColumnVirtualizationResult, +} from '../use-virtualization/interfaces'; diff --git a/src/beta/basic-table-0.1/basic-table/interfaces.ts b/src/beta/basic-table-0.1/basic-table/interfaces.ts new file mode 100644 index 0000000000..5e4ddd8efe --- /dev/null +++ b/src/beta/basic-table-0.1/basic-table/interfaces.ts @@ -0,0 +1,211 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { BaseComponentProps } from '../types/base-component'; +import { NonCancelableEventHandler } from '../types/events'; + +// Public types for BasicTable: the headless `useBasicTable` hook config and the compound-component +// props. BasicTable renders the header and rows it is given (declarative `Header` / `HeaderCell` / +// `Body` / `Row` / `Cell` / `ExpandedContent` children; there is no `items` prop) and owns no data. +// It is windowing-free — to window a large dataset, a consumer composes the separate +// `useVirtualization` primitive on top (its types live in `../use-virtualization/interfaces`). +// +// Columns are a positional width list: `columns: [{ width?, minWidth?, id? }, ...]`, matched to +// cells by position (Nth HeaderCell/Cell = Nth column). A flexible column omits `width` +// (`minmax(minWidth, 1fr)`); a fixed column sets `width`. A stable `id` is only needed to bind a +// cell by id or to use `useColumnVirtualization`. + +/** Config for the headless `useBasicTable` hook and, by extension, `BasicTable.Root`. */ +export interface UseBasicTableConfig { + /** Positional column layout list (the single source of column WIDTH authority). One entry per + * column, in order; the Nth HeaderCell/Cell binds to the Nth entry. */ + columns: ReadonlyArray; + + /** Semantic role of the grid. `"grid"` (default) for interactive keyboard nav; `"table"` for + * static data. @defaultValue "grid" */ + role?: BasicTableProps.Role; + + /** Enables column resize handles. Emits via `onColumnWidthsChange`. @defaultValue false */ + resizableColumns?: boolean; + /** Controlled per-column widths (px), keyed by column INDEX. Uncontrolled if omitted. */ + columnWidths?: Record; + onColumnWidthsChange?: NonCancelableEventHandler; + + /** Pins a number of leading (`first`) / trailing (`last`) columns during horizontal scroll. */ + stickyColumns?: BasicTableProps.StickyColumns; + + /** `"compact"` reduces cell padding via the shared Cloudscape compact-table context. + * @defaultValue "comfortable" */ + contentDensity?: 'comfortable' | 'compact'; + + /** True total dataset row count — authoritative source for the grid's `aria-rowcount` / SR count + * and empty detection (needed because the windowed consumer only renders a slice). */ + totalRowCount?: number; + + /** Localized accessibility strings and label functions. @i18n */ + i18nStrings?: BasicTableProps.I18nStrings; +} + +// NOTE: `UseBasicTableResult` (the hook's return type) lives in `./use-basic-table` because it +// references internal Cloudscape types (the sticky-columns model). + +/** Return shapes of the `useBasicTable` prop-getters (spreadable DOM props, no hooks). */ +export namespace BasicTableGetters { + export interface TableProps { + role: BasicTableProps.Role; + 'aria-rowcount': number; + 'aria-colcount': number; + 'aria-label'?: string; + tabIndex: number; + } + export interface HeaderGroupProps { + role: 'row'; + 'aria-rowindex': number; + style: React.CSSProperties; + } + export interface ColumnHeaderProps { + role: 'columnheader'; + 'aria-colindex': number; + scope: 'col'; + } + export interface ResizeHandleProps { + 'aria-roledescription': string; + onPointerDown: (event: React.PointerEvent) => void; + } + export interface BodyProps { + role: 'rowgroup'; + } + export interface RowProps { + role: 'row'; + 'aria-rowindex': number; + style: React.CSSProperties; + } + export interface CellProps { + role: 'gridcell' | 'cell'; + 'aria-colindex': number; + 'data-awsui-row-index'?: number; + } +} + +/** Props for `BasicTable.Root`. Extends the headless config with the presentational shell + * (viewport height, empty/loading) and the compound children (Header + Body). */ +export interface BasicTableProps extends BaseComponentProps, UseBasicTableConfig { + /** Compound children — a declarative `Header` plus a `Body` (the mapped `Row`s). */ + children: React.ReactNode; + + /** Column layout. `"fixed"` (default) applies fixed table layout; `"auto"` sizes to content. + * @defaultValue "fixed" */ + columnLayout?: BasicTableProps.ColumnLayout; + + /** Fixed height (px) of the scroll viewport. */ + height?: number; + /** Maximum height (px) of the scroll viewport. */ + maxHeight?: number; + + /** Header slot above the grid (title, counter, actions). */ + header?: React.ReactNode; + /** Rendered when there are no rows. */ + empty?: React.ReactNode; + /** Loading state for the whole grid. */ + loading?: boolean; + loadingText?: string; +} + +export namespace BasicTableProps { + export type Role = 'grid' | 'table'; + export type ColumnLayout = 'fixed' | 'auto'; + + /** A column's layout authority — a positional entry (order is the identity). A flexible column + * omits `width` (`minmax(minWidth, 1fr)`); a fixed column sets `width`. A stable `id` is only + * needed when the column-virtualization primitive is used. */ + export interface ColumnDefinition { + /** Fixed track width (px). Omit for a flexible track that shares remaining space. */ + width?: number; + /** Minimum track width (px) — the `minmax` floor for a flexible track, and the resize floor. */ + minWidth?: number; + /** Stable column identifier — only required when `useColumnVirtualization` binds to this + * column (and to bind a `HeaderCell`/`Cell` by id instead of position). */ + id?: string; + } + + /** Props for `BasicTable.Header` — a declarative header rowgroup. Renders the `HeaderCell` + * children it is GIVEN (positional); BasicTable never auto-generates the header from config. */ + export interface HeaderProps { + /** Renders a sticky header. @defaultValue false */ + sticky?: boolean; + children?: React.ReactNode; + } + + /** Props for `BasicTable.HeaderCell` — self-renders one column header (``). Positional by + * default (Nth HeaderCell = Nth column); pass `columnId` to bind by id. To make a column + * sortable, set `aria-sort` here (spread through) and render your own sort control in the + * children — the table holds no sort state. */ + export interface HeaderCellProps extends React.ThHTMLAttributes { + /** Bind to a column by id instead of by position (only needed with column virtualization). */ + columnId?: string; + children?: React.ReactNode; + } + + /** Props for `BasicTable.Body`. Renders ELEMENT children (Row elements) — not a function-child. + * Accepts the runway props spread from `useVirtualization` (style + ref) in the virtual case. */ + export interface BodyProps extends React.HTMLAttributes { + children?: React.ReactNode; + } + + /** Props for `BasicTable.Row`. Structural — carries NO `item`/data. `index` is the row's + * data index (drives `aria-rowindex` and cell wiring). Accepts standard row HTML attributes + * (including the `style`/`aria-rowindex` spread from `useVirtualization`); a `ref` (e.g. the + * virtualization measure ref) forwards to the underlying `
`/`` stays a direct grid child) so the Nth cell learns its column index without the +// parent probing `child.type`. +function withColumnIndices(children: React.ReactNode): React.ReactNode { + return React.Children.map(children, (child, index) => + child === null || child === undefined ? child : {child} + ); +} + +// --- Resize handle (element-level focusable) --------------------------------- + +function ResizeHandle({ columnIndex, headerId }: { columnIndex: number; headerId: string }): React.ReactElement { + const ctx = useBasicTableContext('ResizeHandle'); + const toggleRef = useRef(null); + const separatorRef = useRef(null); + const { tabIndex } = useSingleTabStopNavigation(toggleRef); + const [isKeyboardDragging, setIsKeyboardDragging] = useState(false); + const [showButtons, setShowButtons] = useState(false); + const currentWidth = ctx.currentColumnWidth(columnIndex); + const minWidth = ctx.resizeMinWidthOf(columnIndex); + const handleProps = ctx.getResizeHandleProps(columnIndex); + + const step = (delta: number) => ctx.adjustColumnWidth(columnIndex, delta); + const enterKeyboardDrag = () => { + setIsKeyboardDragging(true); + setShowButtons(true); + }; + // Focus the separator only after `isKeyboardDragging` commits and clears its `aria-hidden`, so + // focus never lands inside an aria-hidden subtree. + useEffect(() => { + if (isKeyboardDragging) { + separatorRef.current?.focus(); + } + }, [isKeyboardDragging]); + const exitKeyboardDrag = () => { + setIsKeyboardDragging(false); + setShowButtons(false); + toggleRef.current?.focus(); + }; + + // The resize handle uses the generic `DragHandleWrapper` (depends on component-toolkit only, not + // table-coupled) to provide the focus ring, the hover tooltip, and the direction-button + // keyboard-resize control. Pointer drag stays on the toggle (`handleProps.onPointerDown` → the + // index-based `startColumnResize`); the sibling role="slider" separator owns Arrow-key drag. The + // width model is index-keyed via `grid-template-columns` — it does NOT pull the table-DOM-coupled + // `resizer/resizer-lookup` or the id-keyed `ColumnWidthsProvider`. Direction buttons step ±20px. + return ( + + minWidth ? 'active' : 'disabled', + 'inline-end': 'active', + }} + triggerMode="controlled" + controlledShowButtons={showButtons} + clickDragThreshold={3} + hideButtonsOnDrag={false} + tooltipText={ctx.resizerRoleDescription} + wrapperClassName={styles['resize-handle-drag']} + onDirectionClick={direction => { + if (direction === 'inline-start') { + step(-20); + } else if (direction === 'inline-end') { + step(20); + } + }} + > + `). Positional by default (its column index comes from the +// `ColumnIndexContext` supplied by `Header`); an explicit `columnId` binds by id. Layers per-cell +// roving tabindex + sticky styles on the hook's static header props. To make a column sortable, set +// `aria-sort` here (spread through `rest`) and render your own sort control in `children`. +export const HeaderCell = ({ + columnId, + children, + className, + style, + ...rest +}: BasicTableProps.HeaderCellProps): React.ReactElement => { + const ctx = useBasicTableContext('HeaderCell'); + const positional = useColumnIndexContext(); + const columnIndex = ctx.resolveColumnIndex(columnId, positional); + const stickyId = ctx.stickyColumnId(columnIndex); + const ref = useRef(null); + const { tabIndex } = useSingleTabStopNavigation(ref); + const sticky = useStickyCellStyles({ + stickyColumns: ctx.stickyColumns, + columnId: stickyId, + getClassName: stickyClassNames, + }); + const headerId = useUniqueId('basic-table-header-'); + const registerRef = useMemo( + () => (node: HTMLElement | null) => ctx.registerHeaderCell(columnIndex, node), + [ctx, columnIndex] + ); + const mergedRef = useMergeRefs(ref, sticky.ref, registerRef); + const headerProps = ctx.getColumnHeaderProps(columnIndex); + return ( + + {children} + {ctx.resizableColumns && } +
`). Positional by default (column index from +// `ColumnIndexContext` supplied by `Row`); an explicit `columnId` binds by id. Layers per-cell +// roving tabindex + sticky styles on the hook's static cell props; reads its row index from the row +// context. +export const Cell = ({ + columnId, + children, + className, + style, + ...rest +}: BasicTableProps.CellProps): React.ReactElement => { + const ctx = useBasicTableContext('Cell'); + const positional = useColumnIndexContext(); + const columnIndex = ctx.resolveColumnIndex(columnId, positional); + const stickyId = ctx.stickyColumnId(columnIndex); + const { index } = useBasicRowContext('Cell'); + const ref = useRef(null); + const { tabIndex } = useSingleTabStopNavigation(ref); + const sticky = useStickyCellStyles({ + stickyColumns: ctx.stickyColumns, + columnId: stickyId, + getClassName: stickyClassNames, + }); + const mergedRef = useMergeRefs(ref, sticky.ref); + const cellProps = ctx.getCellProps(columnIndex, index); + return ( + + {children} +
+
{ + if (event.key === 'Escape') { + event.stopPropagation(); + const toggle = row.id ? document.getElementById(`${row.id}-toggle`) : null; + if (toggle) { + toggle.focus(); + } else { + row.onToggleExpand?.(); + } + } + }} + > + {children} +
+
0 && styles['grid-table-sticky'], + contentDensity === 'compact' && getVisualContextClassname('compact-table') + )} + > + {children} + {(showLoading || showEmpty) && ( + + + + + + )} +
+ {showLoading ? ( + + + {loadingText} + + + ) : ( +
{empty}
+ )} +
+ + + + + ); +} diff --git a/src/beta/basic-table-0.1/basic-table/styles.scss b/src/beta/basic-table-0.1/basic-table/styles.scss new file mode 100644 index 0000000000..24d826e0a7 --- /dev/null +++ b/src/beta/basic-table-0.1/basic-table/styles.scss @@ -0,0 +1,440 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '../internal/styles' as styles; +@use '../internal/styles/tokens' as awsui; + +// Styles the BasicTable to visually match the Cloudscape `Table` by default. Every colour / +// border / spacing value below is a Cloudscape design token rather than a bespoke value, so the +// look tracks Table and the visual refresh automatically. The scroll viewport (.scroll-container) +// scrolls with the native browser scrollbar — there is no custom / synthetic / overlay scrollbar. + +.root { + @include styles.styles-reset; + position: relative; + // Height-owning flex column: when the component (or a parent) is given a bounded block-size, + // the scroll container flexes to fill it and windows. Combined with the height / maxHeight + // props (which bound .scroll-container directly), this guarantees a bounded viewport so the + // model windows instead of mounting every row. + display: flex; + flex-direction: column; + inline-size: 100%; + // Sit on the container content surface, like a table inside a Container. + background: awsui.$color-background-container-content; +} + +.header { + margin-block-end: awsui.$space-scaled-s; +} + +.scroll-container { + position: relative; + // Fill the height-owning root when it is bounded (min-block-size:0 lets a flex item shrink + // below its content so it can scroll); an explicit height / maxHeight prop overrides this + // with a fixed viewport. Either way the viewport is bounded so the model windows. + flex: 1 1 auto; + min-block-size: 0; + // Native scrollbar only — no ::-webkit-scrollbar / overlay / synthetic scrollbar. + overflow: auto; + inline-size: 100%; + + // This div is only the overflow viewport (windowing measures it). The grid semantics (role=grid, + // tabIndex=-1, aria-row/colcount) live on the inner .grid-table, and the single tab stop is the + // roving cell managed by the shared GridNavigationProvider, so the focus ring lives on the + // focused cell (see the cell :focus-visible rules below). +} + +// The grid is a real (so ///
/ are valid HTML and the shared +// grid-navigation processor gets a genuine HTMLTableElement to query), but laid out as a BLOCK +// so the tbody runway + absolutely-positioned windowed rows and the CSS-grid column tracks +// govern layout instead of native table formatting. +.grid-table { + display: block; + inline-size: 100%; + border-collapse: collapse; +} + +// When sticky (pinned) columns are active, grow the measured table box to its content width so the +// sticky-columns isEnabled gate (tableWidth > wrapperWidth) can pass. min-inline-size keeps the box +// filling the wrapper when content is narrower (so sticky correctly stays disabled and 1fr columns +// still stretch without spurious h-scroll). Scoped to sticky-opted-in tables only, so non-sticky +// text tables keep their clamped inline-size:100% (truncation, not h-scroll). +.grid-table-sticky { + inline-size: max-content; + min-inline-size: 100%; +} + +.header-rowgroup { + display: block; +} + +// Header treatment: table-header surface + a default divider under the header row. A grid (not +// flex) so the shared column template governs every column's width identically to the body rows +// (columns align across rows content-independently). +.header-row { + display: grid; + inline-size: 100%; + background: awsui.$color-background-table-header; + border-block-end: awsui.$border-divider-list-width solid awsui.$color-border-divider-default; +} + +// Sticky header is opt-in; the header stays inside the single scroll container so it scrolls +// horizontally with the body while pinning vertically. +// +// The rowgroup itself is the sticky box (not the inner .header-row): its containing block is the +// whole .scroll-container content (header + body runway), so inset-block-start:0 has the travel +// room to pin. Sticking the inner row instead traps it in the short rowgroup box (no travel) and +// it scrolls away with content. +.sticky-header { + position: sticky; + inset-block-start: 0; + // Sticky-header stacking: 800 pins the header above sticky columns (798); a bare `1` risks + // under-layering other console stacking contexts. + z-index: 800; + background: awsui.$color-background-table-header; +} + +.header-cell { + position: relative; + min-inline-size: 0; + // Left-align header text. Without this a plain non-sortable inherits the UA + // `th { text-align: center }`; a composed sort trigger sets its own text-align:start, so only + // plain headers would otherwise be centered. + text-align: start; + // Opaque header surface per cell: when the header is sticky over the windowed (absolutely + // positioned) body rows, a transparent cell lets a body row composite behind the column label, + // dropping its contrast below WCAG AA in dark mode. Painting the table-header surface on the cell + // itself keeps the label on the header background. + background: awsui.$color-background-table-header; + color: awsui.$color-text-column-header; + font-weight: awsui.$font-weight-heading-s; + line-height: awsui.$line-height-heading-xs; + // The header cell has no inner content wrapper (a composed sort trigger, when present, brings its + // own), so it sums two vertical padding layers here to reach the standard header height at density + // parity. The token scales in compact mode, so 2x keeps that parity. + padding-block: calc(2 * #{awsui.$space-scaled-xxs}); + padding-inline: awsui.$space-scaled-l; + box-sizing: border-box; +} + +// Static inter-column divider on the trailing edge, so resizable and non-resizable headers read +// identically. Suppressed on the last column and when a resize handle is present (that draws its +// own divider on the same edge). +.header-cell::after { + content: ''; + position: absolute; + inset-inline-end: 0; + inset-block: awsui.$space-xxs; // same block gap as the resize-handle divider + inline-size: 0; + border-inline-start: awsui.$border-divider-list-width solid awsui.$color-border-divider-default; + box-sizing: border-box; + pointer-events: none; +} +.header-cell:last-child::after { + display: none; +} +.header-cell-resizable::after { + // resize-handle draws the trailing divider in the resizable case + display: none; +} + +// Column resize affordance: pins the resize handle to the header cell's inline-end edge. The handle +// is a focusable, keyboard-operable toggle (native