From 8b710423fb92e7bb2ba0c4f37976c84cc738a19e Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Sat, 18 Jul 2026 10:21:51 +0000 Subject: [PATCH 1/6] feat(virtual-table): logs/streaming-specialized VirtualTable (F2-A1 design-study cell) Config-driven, logs/streaming-specialized VirtualTable design-study cell: view/viewConfig union (standard/patterns/raw), built-in live tail, two-mode filter with materialised match-indicator column, R-EXPAND arbitrary-content slot with log-record/pattern-detail presets, full-dataset windowing + opt-in measurement, and the CloudWatch Logs Insights standard + patterns + raw demos. Unit + a11y tests and usage docs included. Design study only; official Cloudscape API review + design/release sign-off remain human-gated. --- build-tools/utils/pluralize.js | 1 + .../cloudwatch-patterns.page.tsx | 422 +++++++++++++ pages/virtual-table/cloudwatch-raw.page.tsx | 63 ++ .../cloudwatch-standard.page.tsx | 238 ++++++++ pages/virtual-table/simple.page.tsx | 58 ++ src/test-utils/dom/virtual-table/index.ts | 64 ++ src/virtual-table/USAGE.md | 317 ++++++++++ .../__tests__/use-expansion.test.tsx | 74 +++ .../__tests__/use-filter.test.tsx | 102 ++++ .../__tests__/use-live-tail.test.tsx | 107 ++++ .../__tests__/use-virtual-model.test.tsx | 191 ++++++ .../__tests__/virtual-table-a11y.test.tsx | 556 ++++++++++++++++++ .../__tests__/virtual-table.test.tsx | 261 ++++++++ src/virtual-table/index.tsx | 46 ++ src/virtual-table/interfaces.ts | 352 +++++++++++ src/virtual-table/internal.tsx | 522 ++++++++++++++++ src/virtual-table/styles.scss | 198 +++++++ src/virtual-table/use-expansion.ts | 67 +++ src/virtual-table/use-filter.ts | 106 ++++ src/virtual-table/use-live-announcement.ts | 57 ++ src/virtual-table/use-live-tail.ts | 66 +++ src/virtual-table/use-virtual-model.ts | 309 ++++++++++ 22 files changed, 4177 insertions(+) create mode 100644 pages/virtual-table/cloudwatch-patterns.page.tsx create mode 100644 pages/virtual-table/cloudwatch-raw.page.tsx create mode 100644 pages/virtual-table/cloudwatch-standard.page.tsx create mode 100644 pages/virtual-table/simple.page.tsx create mode 100644 src/test-utils/dom/virtual-table/index.ts create mode 100644 src/virtual-table/USAGE.md create mode 100644 src/virtual-table/__tests__/use-expansion.test.tsx create mode 100644 src/virtual-table/__tests__/use-filter.test.tsx create mode 100644 src/virtual-table/__tests__/use-live-tail.test.tsx create mode 100644 src/virtual-table/__tests__/use-virtual-model.test.tsx create mode 100644 src/virtual-table/__tests__/virtual-table-a11y.test.tsx create mode 100644 src/virtual-table/__tests__/virtual-table.test.tsx create mode 100644 src/virtual-table/index.tsx create mode 100644 src/virtual-table/interfaces.ts create mode 100644 src/virtual-table/internal.tsx create mode 100644 src/virtual-table/styles.scss create mode 100644 src/virtual-table/use-expansion.ts create mode 100644 src/virtual-table/use-filter.ts create mode 100644 src/virtual-table/use-live-announcement.ts create mode 100644 src/virtual-table/use-live-tail.ts create mode 100644 src/virtual-table/use-virtual-model.ts diff --git a/build-tools/utils/pluralize.js b/build-tools/utils/pluralize.js index 0a6439d9c0..3bbe7ec409 100644 --- a/build-tools/utils/pluralize.js +++ b/build-tools/utils/pluralize.js @@ -95,6 +95,7 @@ const pluralizationMap = { TreeView: 'TreeViews', TruncatedText: 'TruncatedTexts', TutorialPanel: 'TutorialPanels', + VirtualTable: 'VirtualTables', Wizard: 'Wizards', }; diff --git a/pages/virtual-table/cloudwatch-patterns.page.tsx b/pages/virtual-table/cloudwatch-patterns.page.tsx new file mode 100644 index 0000000000..e1e3b3921c --- /dev/null +++ b/pages/virtual-table/cloudwatch-patterns.page.tsx @@ -0,0 +1,422 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useCallback, useMemo, useState } from 'react'; + +import Button from '~components/button'; +import VirtualTable, { VirtualTableProps } from '~components/virtual-table'; +import { colorBackgroundControlDisabled, colorChartsPaletteCategorical1, colorTextBodySecondary } from '~design-tokens'; + +import ScreenshotArea from '../utils/screenshot-area'; + +// CloudWatch Logs Insights "Show patterns" view on the F2-A1 config-driven, +// logs-specialized VirtualTable (view="patterns"). Demonstrates (see +// research/cloudwatch-requirements.md): +// - CW-6 R-EXPAND shape B: an expanded row renders a pattern detail (histogram + +// token enumeration + pattern actions) — arbitrary, non-tabular, NOT the +// column set — measured at the patterns density (~150 px estimate). +// - CW-9 per-column sort (a patterns-view capability; the standard view has none): +// the component renders keyboard-operable sort triggers and emits intent; +// the console applies the sort (reflect-not-sort). +// - CW-11 compare/diff mode as an alternate patterns column set: `viewConfig.diffMode` +// signals the component while the console swaps the column definitions. +// - Shared cross-row histogram scale computed ONCE over the FULL dataset and supplied +// on `PatternsViewConfig.histogramPeak`. Unlike the generic F1 cells (which +// keep the peak consumer-side and close over it), F2 OWNS the patterns view, +// so it surfaces the resolved full-dataset peak to the patterns cell through +// `CellContext.histogramPeak` — the cell reads `ctx.histogramPeak` rather +// than recomputing over the windowed slice, so the y-scale is stable across +// scroll (design decision B3). +// Density is preserved: 23 px collapsed rows, ~150 px expanded estimate, overscan 20. + +type Severity = 'high' | 'medium' | 'low' | 'info'; +type FindingType = 'new' | 'change-in-count' | 'missing'; + +interface LogPattern { + id: string; + severity: Severity; + pattern: string; + matchingLogs: number; + sharePercent: number; + // Per-bucket frequency over the query window; normalised to a shared peak. + histogram: ReadonlyArray; + previousHistogram: ReadonlyArray; + lastSeen: number; + tokens: ReadonlyArray; + // Compare/diff-mode fields (CW-11). + diffCount: number; + findingType: FindingType; +} + +const SEVERITIES: ReadonlyArray = ['high', 'medium', 'low', 'info']; +const FINDING_TYPES: ReadonlyArray = ['new', 'change-in-count', 'missing']; +const PATTERN_TEMPLATES = [ + 'Handler failed: DynamoDB throttled after <*> retries (requestId=<*>)', + 'Processed event <*> for requestId=<*> in <*>ms', + 'START RequestId: <*> Version: <*>', + 'REPORT RequestId: <*> Duration: <*> ms Billed Duration: <*> ms', + 'Timed out after <*>ms waiting for downstream <*>', + 'Cold start: initialising runtime for <*>', +]; + +const BUCKET_COUNT = 24; + +function makePattern(index: number): LogPattern { + const severity = SEVERITIES[index % SEVERITIES.length]; + const template = PATTERN_TEMPLATES[index % PATTERN_TEMPLATES.length]; + const histogram = Array.from({ length: BUCKET_COUNT }, (_, bucket) => + Math.round(20 + 80 * Math.abs(Math.sin(index * 0.7 + bucket * 0.4)) * (1 + (index % 5))) + ); + const previousHistogram = histogram.map(value => Math.max(0, Math.round(value * (0.6 + (index % 3) * 0.2)))); + const matchingLogs = histogram.reduce((sum, value) => sum + value, 0); + const previousTotal = previousHistogram.reduce((sum, value) => sum + value, 0); + return { + id: `pattern-${index}`, + severity, + pattern: `${template} #${index}`, + matchingLogs, + sharePercent: Number(((matchingLogs % 1000) / 10).toFixed(1)), + histogram, + previousHistogram, + lastSeen: 1_700_000_000_000 - index * 60_000, + tokens: [`requestId`, `durationMs`, `retryCount#${index % 7}`, `region-${index % 4}`], + diffCount: matchingLogs - previousTotal, + findingType: FINDING_TYPES[index % FINDING_TYPES.length], + }; +} + +const PATTERN_COUNT = 800; + +// Shared cross-row histogram scale. Computed over the FULL dataset so every row's +// sparkline is normalised to the same y-axis and the scale does not jump as the +// window changes on scroll (the console's computeGlobalHistogramPeak). Supplied to +// VirtualTable on `viewConfig.histogramPeak`; the component surfaces it to the +// patterns cell via `CellContext.histogramPeak`. +function computeGlobalHistogramPeak(patterns: ReadonlyArray): number { + let peak = 1; + for (const pattern of patterns) { + for (const value of pattern.histogram) { + if (value > peak) { + peak = value; + } + } + for (const value of pattern.previousHistogram) { + if (value > peak) { + peak = value; + } + } + } + return peak; +} + +// Visually-hidden text so screen-reader users get the histogram's meaning while the +// bars stay decorative. Standard clip pattern (a dev page has no util import). +const visuallyHidden: React.CSSProperties = { + position: 'absolute', + inlineSize: 1, + blockSize: 1, + padding: 0, + margin: -1, + overflow: 'hidden', + clip: 'rect(0 0 0 0)', + whiteSpace: 'nowrap', + border: 0, +}; + +// Concise non-visual summary of a frequency histogram: total matches, interval +// count, and peak bucket. Carries the sparkline's meaning to assistive tech. +function describeHistogram(values: ReadonlyArray): string { + const total = values.reduce((sum, value) => sum + value, 0); + const peak = values.reduce((max, value) => (value > max ? value : max), 0); + return `${total.toLocaleString()} matches across ${values.length} intervals, peak ${peak.toLocaleString()} in one interval.`; +} + +// Short trend statement comparing the current window to the previous one (diff mode). +function describeTrend(pattern: LogPattern): string { + const current = pattern.histogram.reduce((sum, value) => sum + value, 0); + const previous = pattern.previousHistogram.reduce((sum, value) => sum + value, 0); + const delta = current - previous; + if (delta === 0) { + return `Frequency unchanged vs previous period (${current.toLocaleString()} matches).`; + } + const direction = delta > 0 ? 'up' : 'down'; + const magnitude = + previous === 0 + ? `${Math.abs(delta).toLocaleString()} matches` + : `${Math.abs(Math.round((delta / previous) * 100))}%`; + return `Frequency ${direction} ${magnitude} vs previous period (${current.toLocaleString()} vs ${previous.toLocaleString()} matches).`; +} + +// Fixed UTC HH:MM:SS so the demo/screenshot is reproducible across locales/timezones. +function formatTimeUtc(ms: number): string { + return `${new Date(ms).toISOString().slice(11, 19)} UTC`; +} + +// Human-readable labels for the diff-mode finding types. +const FINDING_LABELS: Record = { + new: 'New', + 'change-in-count': 'Change in count', + missing: 'Missing', +}; + +// A minimal sparkline normalised to a shared peak. The bars are decorative +// (aria-hidden); when `summary` is supplied it is exposed to screen readers as +// visually-hidden text, so the frequency column cell and the expanded detail are +// not silent (the header/cell content stays consistent for SR — CW-16, WCAG 1.1.1). +function Sparkline({ + values, + peak, + muted = false, + summary, +}: { + values: ReadonlyArray; + peak: number; + muted?: boolean; + summary?: string; +}) { + return ( + + + {values.map((value, bucket) => ( + + ))} + + {summary && {summary}} + + ); +} + +// R-EXPAND shape B: arbitrary, non-tabular pattern detail — histogram (current, plus +// previous in diff mode), token enumeration, and pattern actions. Deliberately not +// the column layout; measured at ~150 px. Closes over the shared full-dataset peak +// (an arbitrary render slot; RowContext does not carry the peak — only the grid cell +// receives it via CellContext.histogramPeak). +function PatternDetail({ pattern, peak, diffMode }: { pattern: LogPattern; peak: number; diffMode: boolean }) { + return ( +
+
+
+
Current
+ +
+ {diffMode && ( +
+
Previous
+ +
+ )} +
+ {diffMode &&
{describeTrend(pattern)}
} +
+ Tokens: + {pattern.tokens.map(token => ( + + {token} + + ))} +
+
+ + + +
+
+ ); +} + +export default function VirtualTableCloudWatchPatternsPage() { + const [diffMode, setDiffMode] = useState(false); + const [expandedItems, setExpandedItems] = useState>([]); + const [sortingColumn, setSortingColumn] = useState | undefined>(); + const [sortingDescending, setSortingDescending] = useState(false); + + const basePatterns = useMemo>( + () => Array.from({ length: PATTERN_COUNT }, (_, index) => makePattern(index)), + [] + ); + + // Shared histogram peak over the full dataset — stable across scroll, computed + // once. Supplied to VirtualTable on viewConfig.histogramPeak; F2 surfaces it to the + // patterns cell via CellContext.histogramPeak (the F2 divergence from F1, which + // keeps it consumer-side). PatternDetail (an arbitrary render slot) closes over the + // same value. + const histogramPeak = useMemo(() => computeGlobalHistogramPeak(basePatterns), [basePatterns]); + + // VirtualTable reflects sort state and emits intent; it does not sort data. The + // console applies the sort here (CW-9). Sorting keys differ per column set. + const items = useMemo>(() => { + const field = sortingColumn?.sortingField; + if (!field) { + return basePatterns; + } + const sorted = [...basePatterns].sort((a, b) => { + const left = a[field as keyof LogPattern]; + const right = b[field as keyof LogPattern]; + if (typeof left === 'number' && typeof right === 'number') { + return left - right; + } + return String(left).localeCompare(String(right)); + }); + return sortingDescending ? sorted.reverse() : sorted; + }, [basePatterns, sortingColumn, sortingDescending]); + + const severityCell = useCallback( + (item: LogPattern) => {item.severity}, + [] + ); + + // Normal-mode columns. The frequency cell reads the shared cross-row peak from + // CellContext.histogramPeak (surfaced by the component), falling back to the + // consumer value for type safety. + const normalColumns = useMemo>>( + () => [ + { id: 'severity', header: 'Severity', cell: severityCell, width: 110 }, + { + id: 'matchingLogs', + header: 'Matching logs', + cell: item => item.matchingLogs.toLocaleString(), + sortingField: 'matchingLogs', + width: 130, + }, + { + id: 'sharePercent', + header: 'Share', + cell: item => `${item.sharePercent}%`, + sortingField: 'sharePercent', + width: 90, + }, + { + id: 'frequency', + header: 'Frequency', + // Reads the shared full-dataset peak from CellContext (F2 surfaces it here), + // not from a consumer closure. The sparkline carries a visually-hidden summary + // so the cell is not silent (WCAG 1.1.1). + cell: (item, ctx) => ( + + ), + width: 140, + }, + { + id: 'lastSeen', + header: 'Last seen', + cell: item => formatTimeUtc(item.lastSeen), + sortingField: 'lastSeen', + width: 110, + }, + { + id: 'pattern', + header: 'Pattern', + cell: item => {item.pattern}, + isStretch: true, + }, + ], + [severityCell, histogramPeak] + ); + + // CW-11 compare/diff mode: a different column set with its own sort. The console + // swaps the column definitions; `viewConfig.diffMode` signals the component. + const diffColumns = useMemo>>( + () => [ + { id: 'severity', header: 'Severity', cell: severityCell, width: 110 }, + { + id: 'diffCount', + header: '(Previous → Current) difference count', + cell: item => (item.diffCount >= 0 ? `+${item.diffCount.toLocaleString()}` : item.diffCount.toLocaleString()), + sortingField: 'diffCount', + width: 260, + }, + { + id: 'findingType', + header: 'Difference description', + cell: item => FINDING_LABELS[item.findingType], + sortingField: 'findingType', + width: 180, + }, + { + id: 'pattern', + header: 'Pattern', + cell: item => {item.pattern}, + isStretch: true, + }, + ], + [severityCell] + ); + + const columnDefinitions = diffMode ? diffColumns : normalColumns; + + const handleSortingChange = useCallback((detail: VirtualTableProps.SortingState) => { + setSortingColumn(detail.sortingColumn); + setSortingDescending(detail.sortingDescending); + }, []); + + const toggleDiffMode = useCallback(() => { + // The two modes have disjoint sort fields; clear sort so a stale column from + // the other set is not applied to the swapped-in column definitions. + setSortingColumn(undefined); + setSortingDescending(false); + setDiffMode(value => !value); + }, []); + + return ( + <> +

VirtualTable — CloudWatch Logs Insights, show patterns view (F2-A1)

+

+ Logs-specialized VirtualTable with view="patterns". {items.length.toLocaleString()}{' '} + patterns · {diffMode ? 'compare / diff mode' : 'normal mode'}. Per-column sort is a patterns capability (the + standard view has none); the shared histogram y-scale is computed over the full dataset and reaches each cell + through CellContext.histogramPeak. Row expansion renders arbitrary non-tabular pattern detail + (shape B, ~150 px). +

+
+ +
+ +
+ + items={items} + trackBy={item => item.id} + viewConfig={{ type: 'patterns', columnDefinitions, histogramPeak, diffMode }} + estimatedRowHeight={23} + overscan={20} + stickyHeader={true} + resizableColumns={true} + sortingColumn={sortingColumn} + sortingDescending={sortingDescending} + onSortingChange={event => handleSortingChange(event.detail)} + expandedContentPreset="pattern-detail" + getExpandedContent={item => } + getExpandedRowHeight={() => 150} + expandedItems={expandedItems} + onExpandChange={event => setExpandedItems(event.detail.expandedItems)} + ariaLabels={{ + gridLabel: 'CloudWatch log patterns', + expandRowLabel: item => `Show detail for pattern ${item.pattern}`, + collapseRowLabel: item => `Hide detail for pattern ${item.pattern}`, + }} + i18nStrings={{ expandedRegionLabel: 'Pattern detail' }} + /> +
+
+ + ); +} diff --git a/pages/virtual-table/cloudwatch-raw.page.tsx b/pages/virtual-table/cloudwatch-raw.page.tsx new file mode 100644 index 0000000000..f5e76e4f43 --- /dev/null +++ b/pages/virtual-table/cloudwatch-raw.page.tsx @@ -0,0 +1,63 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import VirtualTable from '~components/virtual-table'; + +import ScreenshotArea from '../utils/screenshot-area'; + +// CloudWatch raw / file log-line view (CW-15) as the SAME F2-A1 API reduced to +// view="raw": a single monospaced line renderer, no columns and no expansion, at +// RAW_LINE_HEIGHT density (estimatedRowHeight 20, overscan 40 = FILE_VIEW_OVERSCAN). +// Long lines wrap and are measured (getRowHeight="auto" for wrapping candidates only, +// so short lines stay fixed at 20px and pay no observer cost); full-dataset +// aria-rowcount/aria-rowindex hold under windowing. Proves all three surfaces reach +// from one config-driven API with no specialized raw mode. + +interface RawLine { + id: string; + text: string; +} + +const rawItems: RawLine[] = Array.from({ length: 5000 }, (_, index) => { + const stamp = `2026-07-18T05:${String(Math.floor(index / 60) % 60).padStart(2, '0')}:${String(index % 60).padStart(2, '0')}Z`; + const wraps = index % 7 === 0; + return { + id: `line-${index}`, + text: wraps + ? `${stamp} [ERROR] request ${index} failed ` + 'at handler /api/resource -> service -> repository '.repeat(4) + : `${stamp} [INFO] request ${index} completed in ${20 + (index % 200)}ms`, + }; +}); + +export default function VirtualTableCloudWatchRawPage() { + return ( + <> +

VirtualTable — CloudWatch raw log view (F2-A1)

+

+ The raw/file surface on view="raw": one monospaced line renderer, no columns, no + expansion. Long lines wrap and are measured; short lines stay fixed at the 20 px raw-line density. +

+ +
+ item.id} + estimatedRowHeight={20} + overscan={40} + getRowHeight={item => (item.text.length > 120 ? 'auto' : 20)} + viewConfig={{ + type: 'raw', + renderLine: item => ( + + {item.text} + + ), + }} + ariaLabels={{ gridLabel: 'Raw log events' }} + /> +
+
+ + ); +} diff --git a/pages/virtual-table/cloudwatch-standard.page.tsx b/pages/virtual-table/cloudwatch-standard.page.tsx new file mode 100644 index 0000000000..30a21198ac --- /dev/null +++ b/pages/virtual-table/cloudwatch-standard.page.tsx @@ -0,0 +1,238 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import Button from '~components/button'; +import VirtualTable, { VirtualTableProps } from '~components/virtual-table'; +import { colorTextBodySecondary } from '~design-tokens'; + +import ScreenshotArea from '../utils/screenshot-area'; + +// CloudWatch Logs Insights STANDARD view on the F2-A1 config-driven, logs-specialized +// VirtualTable (view="standard"). Exercises the opinionated built-ins the console gets +// for free — live tail and the two-mode filter are component-owned mechanics; the +// console supplies only policy (follow state, filter predicate) through the +// override-seam. R-EXPAND shape A (~300px log-record detail) is arbitrary non-tabular +// content via getExpandedContent + the log-record preset. Density 23/300 preserved; +// the raw surface (CW-15) lives in cloudwatch-raw.page.tsx. + +interface LogRecord { + id: string; + timestamp: string; + level: 'ERROR' | 'WARN' | 'INFO'; + message: string; + fields: Record; +} + +const LEVELS: Array = ['INFO', 'INFO', 'INFO', 'WARN', 'ERROR']; + +function makeRecord(index: number): LogRecord { + const level = LEVELS[index % LEVELS.length]; + return { + id: `log-${index}`, + timestamp: new Date(Date.UTC(2026, 6, 18, 5, 0, 0) + index * 1000).toISOString(), + level, + message: `${level} request ${index} completed handler=/api/v${(index % 3) + 1}/resource`, + fields: { + '@requestId': `req-${1000 + index}`, + durationMs: String(20 + (index % 200)), + accountId: `1234567890${index % 10}`, + region: index % 2 === 0 ? 'us-east-1' : 'eu-west-1', + }, + }; +} + +const INITIAL_ITEMS: LogRecord[] = Array.from({ length: 2000 }, (_, index) => makeRecord(index)); + +// CW-8 dynamic columns + a single stretch-last @message column. Standard view has NO +// per-column sort (sort is a patterns-view capability, CW-9), so no sortingField. +const columnDefinitions: Array> = [ + { + id: '@timestamp', + header: '@timestamp', + width: 230, + cell: item => {item.timestamp}, + }, + { id: 'level', header: 'level', width: 90, cell: item => item.level }, + { + id: '@requestId', + header: '@requestId', + width: 150, + cell: item => {item.fields['@requestId']}, + }, + { id: 'durationMs', header: 'duration (ms)', width: 130, cell: item => item.fields.durationMs }, + { + id: '@message', + header: '@message', + isStretch: true, + cell: item => {item.message}, + }, +]; + +// R-EXPAND shape A: arbitrary, non-tabular log-record detail — a key/value grid over +// ALL fields plus a collapsible raw JSON block. Deliberately NOT the column set. +function LogRecordDetail({ item }: { item: LogRecord }) { + const entries: Array<[string, string]> = [ + ['@timestamp', item.timestamp], + ['level', item.level], + ['@message', item.message], + ...Object.entries(item.fields), + ]; + return ( +
+
+ {entries.map(([key, value]) => ( + + {key} + {value} + + ))} +
+
+ Raw JSON +
+          {JSON.stringify(
+            { id: item.id, timestamp: item.timestamp, level: item.level, message: item.message, ...item.fields },
+            null,
+            2
+          )}
+        
+
+
+ ); +} + +export default function VirtualTableCloudWatchStandardPage() { + const [items, setItems] = useState(INITIAL_ITEMS); + const [following, setFollowing] = useState(true); + const [expandedItems, setExpandedItems] = useState([]); + const [visibleRange, setVisibleRange] = useState({ + firstIndex: 0, + lastIndex: 0, + }); + const [filterText, setFilterText] = useState(''); + const [filterMode, setFilterMode] = useState<'subset' | 'mark-in-place'>('mark-in-place'); + const ref = useRef(null); + const nextIndexRef = useRef(INITIAL_ITEMS.length); + + // Built-in live tail: VirtualTable owns pin-to-newest + release-on-scroll-away; the + // console owns only the follow policy. The append stream runs continuously (mounted + // once, NOT gated on `following`) so releasing follow does not silence it — new rows + // keep arriving below the viewport, which is exactly what release-on-scroll-away is + // for: the viewport is held put while events pile up, and re-following snaps to + // newest. `following` governs only auto-scroll, which the component owns. ids/records + // are computed OUTSIDE the setState updater so React 18 StrictMode's double-invoke is + // safe. + useEffect(() => { + const timer = setInterval(() => { + const base = nextIndexRef.current; + nextIndexRef.current = base + 3; + const added = [makeRecord(base), makeRecord(base + 1), makeRecord(base + 2)]; + setItems(prev => [...prev, ...added]); + }, 1000); + return () => clearInterval(timer); + }, []); + + const handleExpandChange = useCallback( + (event: { detail: VirtualTableProps.ExpandChangeDetail }) => + setExpandedItems([...event.detail.expandedItems]), + [] + ); + + // Two-mode filter (CW-10): the console supplies only the predicate + highlight; the + // component owns the match-indicator column, non-visual conveyance, counts, and + // keyboard next/prev-match. mark-in-place keeps surrounding context visible. + const filter = useMemo | undefined>(() => { + const query = filterText.trim().toLowerCase(); + if (!query) { + return undefined; + } + return { + mode: filterMode, + predicate: item => item.message.toLowerCase().includes(query), + highlight: (item, columnId) => columnId === '@message' && item.message.toLowerCase().includes(query), + }; + }, [filterText, filterMode]); + + const revealFirstError = () => { + const firstError = items.find(item => item.level === 'ERROR'); + if (firstError) { + ref.current?.revealItem(firstError.id); + } + }; + + return ( + <> +

VirtualTable — CloudWatch Logs Insights, standard view (F2-A1)

+

+ Logs-specialized VirtualTable with view="standard". Live tail and the mark-in-place + filter are built-in mechanics; this page supplies only follow state and a predicate. Row expansion renders + arbitrary non-tabular log-record detail (shape A, ~300 px). +

+
+ + + + + Visible rows {visibleRange.firstIndex}–{visibleRange.lastIndex} · {following ? 'following' : 'paused'} + +
+ +
+ item.id} + viewConfig={{ type: 'standard', columnDefinitions }} + estimatedRowHeight={23} + overscan={20} + stickyHeader={true} + resizableColumns={true} + follow={following} + onFollowChange={event => setFollowing(event.detail.follow)} + expandedContentPreset="log-record" + getExpandedContent={item => } + getExpandedRowHeight={() => 300} + expandedItems={expandedItems} + onExpandChange={handleExpandChange} + filter={filter} + onVisibleRangeChange={event => setVisibleRange(event.detail)} + imperativeRef={ref} + ariaLabels={{ + gridLabel: 'CloudWatch Logs Insights results', + expandRowLabel: item => `Show details for log ${item.fields['@requestId']}`, + collapseRowLabel: item => `Hide details for log ${item.fields['@requestId']}`, + followToggleLabel: 'Live tail', + filterMatchLabel: () => 'Filter match', + matchNavigationLabel: 'Filter match navigation', + previousMatchLabel: 'Previous match', + nextMatchLabel: 'Next match', + }} + i18nStrings={{ + filterCountText: (matched, total) => `${matched} of ${total} matches`, + appendAnnouncementText: count => `${count} new log events`, + expandedRegionLabel: 'Log record detail', + }} + /> +
+
+ + ); +} diff --git a/pages/virtual-table/simple.page.tsx b/pages/virtual-table/simple.page.tsx new file mode 100644 index 0000000000..b57703ae2b --- /dev/null +++ b/pages/virtual-table/simple.page.tsx @@ -0,0 +1,58 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import VirtualTable from '~components/virtual-table'; + +import ScreenshotArea from '../utils/screenshot-area'; + +interface Log { + id: string; + timestamp: string; + level: string; + message: string; +} + +const items: Log[] = Array.from({ length: 25 }, (_, index) => ({ + id: `log-${index}`, + timestamp: `2026-07-18T05:${String(index % 60).padStart(2, '0')}:00Z`, + level: index % 5 === 0 ? 'ERROR' : 'INFO', + message: `Log event ${index}`, +})); + +export default function VirtualTableSimplePage() { + return ( + <> +

VirtualTable — scaffold (F2-A1, logs-specialized)

+

+ Config-driven, logs-specialized VirtualTable (cell F2-A1). A required viewConfig whose{' '} + type selects the built-in surface supplies that view's columns. This dev page exercises the + scaffolded standard-view shell; windowing, measurement, live tail, the two-mode filter, and row expansion land + in the core implementation unit. +

+ + item.id} + estimatedRowHeight={23} + viewConfig={{ + type: 'standard', + columnDefinitions: [ + { id: 'timestamp', header: 'Timestamp', width: 210, cell: item => item.timestamp }, + { id: 'level', header: 'Level', width: 90, cell: item => item.level }, + { id: 'message', header: 'Message', isStretch: true, cell: item => item.message }, + ], + }} + expandedContentPreset="log-record" + getExpandedContent={item =>
Detail for {item.id}
} + defaultExpandedItems={['log-2']} + ariaLabels={{ + gridLabel: 'Log events', + expandRowLabel: item => `Show detail for ${item.id}`, + collapseRowLabel: item => `Hide detail for ${item.id}`, + }} + /> +
+ + ); +} diff --git a/src/test-utils/dom/virtual-table/index.ts b/src/test-utils/dom/virtual-table/index.ts new file mode 100644 index 0000000000..20d1347be1 --- /dev/null +++ b/src/test-utils/dom/virtual-table/index.ts @@ -0,0 +1,64 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ComponentWrapper, ElementWrapper } from '@cloudscape-design/test-utils-core/dom'; + +import styles from '../../../virtual-table/styles.selectors.js'; + +export default class VirtualTableWrapper extends ComponentWrapper { + static rootSelector: string = styles.root; + + /** Rendered (windowed) rows only. */ + findRows(): Array { + return this.findAllByClassName(styles.row); + } + + /** + * Returns null for rows outside the current window (by design). Locates by the row's + * `aria-rowindex`, which is the full-dataset index offset by the header row: header + * is `aria-rowindex=1`, so data row `dataIndex` is `aria-rowindex=dataIndex+2`. + */ + findRowByIndex(dataIndex: number): ElementWrapper | null { + return this.find(`.${styles.row}[aria-rowindex="${dataIndex + 2}"]`); + } + + /** The disclosure toggle button for a data row, or null if outside the window. */ + findExpandToggle(dataIndex: number): ElementWrapper | null { + return this.findRowByIndex(dataIndex)?.findByClassName(styles['disclosure-button']) ?? null; + } + + /** The labeled expanded region for a data row, or null if not expanded/windowed. */ + findExpandedRegion(dataIndex: number): ElementWrapper | null { + // The expanded row shares its data row's aria-rowindex (dataIndex + 2) and carries + // the `expanded-row` class, so it is addressable by selector alone — no runtime + // attribute read, which keeps this method valid in the selectors test-utils too. + return this.find(`.${styles['expanded-row']}[aria-rowindex="${dataIndex + 2}"] .${styles['expanded-region']}`); + } + + findColumnHeaders(): Array { + return this.findAllByClassName(styles['header-cell']); + } + + /** The header row element (returned regardless of the stickyHeader prop). */ + findHeaderRow(): ElementWrapper | null { + return this.findByClassName(styles['header-row']); + } + + /** The live-tail follow toggle, or null when the live-tail seam is not wired. */ + findFollowToggle(): ElementWrapper | null { + return this.findByClassName(styles['follow-toggle']); + } + + /** + * The total-vs-filtered match-count region, or null when no filter is set. Rendered + * for both filter modes (subset shows the filtered length, mark-in-place the match + * count) under the `match-count` class. + */ + findMatchCount(): ElementWrapper | null { + return this.findByClassName(styles['match-count']); + } + + /** The polite append aria-live node. */ + findLiveRegion(): ElementWrapper | null { + return this.findByClassName(styles['live-region']); + } +} diff --git a/src/virtual-table/USAGE.md b/src/virtual-table/USAGE.md new file mode 100644 index 0000000000..0e3edad5c6 --- /dev/null +++ b/src/virtual-table/USAGE.md @@ -0,0 +1,317 @@ + + + +# VirtualTable usage + +VirtualTable is a virtualization-first table built for high-density log and +streaming data. It renders only the rows in view, so it stays responsive with tens +of thousands of rows and with data that appends continuously, such as a live log +stream. It's a new, additive component that coexists with Table — reach for it when +the data is a large or streaming log-style feed, when rows need to expand into +content laid out differently from the columns, or when you need live tail and +in-place filtering as first-class behavior. + +VirtualTable is a config-driven, opinionated component: you pick a built-in view +and describe its columns or line renderer, supply the data and an optional expanded +content slot, and it handles windowing, measurement, live tail, filtering, +keyboard interaction, and announcements for you. It ships three built-in surfaces +rather than being a raw grid, so the common log tables come out of the box while +you keep control of policy through a small set of hooks. + +## When to use + +- Use VirtualTable when the data is a large or continuously appending feed and the + view needs to keep up without the cost of rendering every row. +- Use VirtualTable when rows expand into detail that doesn't match the column + layout — a key-value record, a raw or formatted line, or a small chart with + actions. +- Use VirtualTable when you need live tail, in-place filtering with match + navigation, or a raw line view as built-in behavior rather than something you + assemble yourself. +- Use 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. VirtualTable deliberately keeps a smaller, opinionated surface and + doesn't offer those. + +## The view model + +VirtualTable is organized around a single view configuration that both selects the +built-in surface and supplies that surface's columns or renderer: + +- The results view is the primary grid: dynamic columns, a leading disclosure + column when rows expand, a fixed layout with one stretch column, and + keyboard-operable column resizing with persisted widths. +- The aggregation view is a second grid with its own columns, per-column sort, an + optional compare mode, and a shared chart scale you compute once over the whole + dataset and supply, kept stable as the user scrolls. +- The raw view is a single monospaced line per row, with no columns and no + expansion, at raw-line density. + +Keep the three configurations side by side and swap which one is active from your +own tab or segmented control. VirtualTable owns each view's grid, virtualization, +accessibility, and expansion; you own the control that switches between them. Hold +one configuration object per view so switching views is a swap, not a reshape of +props. + +## Who owns what + +VirtualTable packages live tail and filtering as built-in features, and it draws a +clear line between mechanism and policy. The component owns the mechanics and the +accessibility contract; you own the policy: + +- For live tail, you own whether following is on; the component owns pinning to the + newest row, releasing when the user scrolls up, and the batched, polite + announcement of new rows. +- For filtering, you own the predicate and the highlight decision; the component + owns the match-indicator column, the total-and-filtered counts, keyboard + match navigation, and conveying a match without relying on color alone. + +This keeps the common behavior consistent and accessible across consoles while +leaving the decisions that vary to you. + +## General guidelines + +### Do + +Views and columns + +- Match the estimated row height to your content density so the scrollbar is + accurate before rows are measured. +- Give one column the room to stretch so the grid fills its container, and size the + other columns to their content. +- Turn on column resizing when users need to read long values, and persist the + widths they choose so the layout survives a reload. +- Keep the column set focused. Move secondary detail into the expanded row rather + than adding columns users must scroll to reach. + +Row expansion + +- Use the expanded row for content that reads differently from the columns: a + key-value record, a raw line, a small chart, or nested panels. +- Start from a built-in expanded layout when it fits, and supply your own content + when it doesn't — your content takes over for that row. +- Give every expanded region a clear, row-specific accessible name so people + navigating by region know which row they're reading. +- Let the expanded content size itself. Variable expanded heights are measured and + windowed for you, so detail of different sizes all scroll smoothly. + +Live tail and streaming + +- Append new rows to the end of the dataset. Turn following on to pin the view to + the newest row; the view releases on its own the moment a user scrolls up to + read, and re-engaging snaps back to newest. +- Keep the appended data flowing independently of whether following is on, so + releasing follow leaves earlier rows in place to read while new rows keep + arriving. +- Keep rows at a fixed height when you can — fixed rows skip measurement and stay + fast at very large sizes. Opt individual rows into measurement only when their + height truly varies. + +Filtering + +- Choose in-place marking when users need surrounding context and a sense of how + many rows matched, and choose the reduced subset when they only want the matches. +- Convey a match with text or an icon in addition to any highlight color, and give + the match-navigation controls clear accessible names. +- Compute any cross-row scale, such as a shared chart maximum, once over the full + dataset so it stays stable as the user scrolls. Don't derive it from the rows + currently in view. + +Header and states + +- Use a sticky header for long grids so column meaning stays visible while + scrolling. +- Provide an empty state with a short explanation and, where it helps, a recovery + action. +- Show the loading state while the first page of data is on its way, and write + loading text that says what's loading. + +Accessibility + +- Always supply the accessible names VirtualTable asks for — the grid name, the + expand and collapse control labels, the follow toggle label, the expanded region + name, and the match-navigation labels when filtering in place. These names are + part of the component's accessibility contract; without them the grid, its + controls, and its regions have no accessible name. +- Keep interactive content inside an expanded row reachable by keyboard. + VirtualTable moves focus into the region and returns it to the row's control when + the user leaves, and it keeps arrow-key row navigation from interfering with + controls inside the region. + +### Don't + +- Don't render the full dataset yourself or wrap VirtualTable around an + already-rendered list — it windows the data for you, and pre-rendering defeats + the purpose. +- Don't reuse the column layout for the expanded row when the detail is genuinely + different. If the expanded content is just more columns, a standard table suits + better. +- Don't compute a shared scale, running total, or count from the visible rows — it + will jump as the user scrolls. Compute it over the full dataset. +- Don't rely on color alone to show which rows match a filter; pair it with text or + an icon so the match is conveyed without sight of the highlight. +- Don't couple your data feed to whether following is on. Stopping follow should + hold the view still, not stop new rows from arriving. +- Don't assume VirtualTable sorts your data. It reflects sort intent but doesn't + reorder rows itself, so wire sorting only where it fits your data — a + fast-appending feed is usually left unsorted. +- Don't leave the grid, its expand controls, its follow toggle, its expanded + regions, or its match-navigation controls without accessible names. + +## Features + +### Virtualization + +VirtualTable renders only the rows near the viewport and recycles their DOM as the +user scrolls, so it handles very large datasets without the cost of a full render. +Rows can be a fixed height for the fastest path, or opt into measurement when their +height varies. Measured heights are cached and the scroll position is corrected as +rows above the viewport settle, so content doesn't drift under the user. + +### Custom row expansion + +A row can expand into arbitrary content that isn't bound to the column layout — +key-value detail, a raw or formatted record, a chart, or nested panels. Built-in +expanded layouts cover the common log-record and pattern-detail shapes as a +starting point, and any custom content you supply takes over for that row, so the +built-ins are defaults rather than a closed set. The expanded region is a +first-class part of the grid's structure with its own accessible name and its own +measured height, so panels of different sizes all window correctly. Expansion can +be controlled or left to the component. + +### Live tail + +Appending rows to the dataset is the streaming path, and following is built in. +When following is on, the view pins to the newest row as data arrives; when the +user scrolls up to read, the view releases following and reports the change so your +control can reflect it. New-row announcements are batched and summarized so a fast +feed doesn't flood assistive technology, and the auto-scroll is immediate rather +than animated so it stays comfortable for motion-sensitive users. + +### Two-mode filtering + +Filtering is a built-in feature with two modes. In-place marking keeps every row +visible, flags the matches, and adds a match-indicator column and a total-and- +filtered count so users keep their context. The reduced subset shows only matching +rows. Either way you supply the predicate, and the component provides +keyboard-operable next- and previous-match navigation and conveys each match +without relying on color alone. + +### Columns, sorting, and comparison + +Columns are defined per view and can be resized, with widths reported so you can +persist them. In the aggregation view, columns can be sorted — VirtualTable +reflects the sort state and reports the user's sort intent, but it doesn't reorder +the data itself, so you sort the dataset and pass it back. Compare-style views are +a swap to a different set of columns rather than a separate mode. + +## Writing guidelines + +Write grid content so it scans quickly. Keep it short, consistent, and specific to +the row. + +### Grid name + +- Give the grid a short, descriptive name that says what the rows are, such as + *Log events* or *Query patterns*. +- Use sentence case and no ending punctuation. + +### Column headers + +- Use nouns or short noun phrases, such as *Timestamp*, *Level*, or *Message*. +- Keep headers to one or two words where you can, and use sentence case. + +### Cells + +- Keep cell text terse and scannable. Avoid full sentences in a cell. +- Right-align numeric values and keep units consistent down a column. + +### Expand and collapse controls + +- Write the control label so it names the action and the row it acts on, and + reflects whether the row is open or closed, such as *Show details for this event*. + +### Follow toggle + +- Name the toggle for the action, such as *Follow new events*, and keep the label + stable whether or not following is on. + +### Expanded region name + +- Name the region for the row it belongs to, such as *Details for 10:04:22 event*, + so people who navigate by region know which row they're reading. + +### Match navigation + +- Name the match-navigation group and its controls plainly, such as *Filter + matches*, *Previous match*, and *Next match*. + +### Empty state + +- Say why there's nothing to show and what to do next, such as *No log events in + the selected time range. Widen the range to see more.* + +### Loading text + +- Say what's loading, such as *Loading log events*. Keep it to a short phrase. + +## API reference + +The full typed API lives in `interfaces.ts` (`VirtualTableProps` and the +`VirtualTableProps` namespace) and is described in the design document +`deliverables/design/design-F2-A1.md`. In summary: + +- Data and identity: `items`, a required `trackBy` — a stable key is mandatory + because row DOM is recycled — and `viewConfig`. +- View configuration: `viewConfig` is one object whose `type` discriminant + (`"standard"`, `"patterns"`, or `"raw"`) selects the built-in surface and + supplies that view's `columnDefinitions` (or `renderLine` for raw, and + `histogramPeak` / `diffMode` for patterns). +- Virtualization: `estimatedRowHeight`, `getRowHeight` (fixed px or `"auto"` to + measure), `overscan`, and `onVisibleRangeChange`. +- Live tail: `follow` / `onFollowChange` (controlled follow state) and + `renderAppendAnnouncement` for the batched new-row announcement. +- Row expansion: `getExpandedContent` (arbitrary non-tabular content, enables the + leading disclosure column), `expandedContentPreset` (a built-in layout used as + the default under the slot), `getExpandedRowHeight`, `expandedItems` / + `defaultExpandedItems` / `onExpandChange`. +- Filtering: `filter` — a `mode` (`"subset"` or `"mark-in-place"`), a `predicate`, + and an optional per-cell `highlight`. +- Columns, sorting, sizing: `sortingColumn` / `sortingDescending` / + `onSortingChange` (VirtualTable reflects sort state and emits intent; it doesn't + sort the data), `resizableColumns`, `columnWidths`, and `stickyHeader`. +- State and labels: `empty`, `loading` / `loadingText`, `ariaLabels`, and + `i18nStrings`. +- Imperative surface: `imperativeRef` exposing `scrollToEnd`, `scrollToItem`, + `revealItem`, and `isPinnedToEnd` for scroll anchoring, live tail, and revealing + a specific row. + +Three behaviors worth calling out for implementers: + +- Keyboard model: the grid is a single tab stop and arrow keys move an active row + (row-granular), not a cell cursor. Left and right arrows don't move a cell + selection. This is a deliberate choice for a virtualized log grid; if you need + 2D cell navigation, Table is the better fit. +- Accessible names: the labels in `ariaLabels` and `i18nStrings` — the grid name, + the expand and collapse labels, the follow toggle label, the expanded region + name, and the match-navigation labels — are required for a complete accessible + experience. Omitting them leaves those elements without an accessible name. +- Reveal and filtering: `revealItem` expands, highlights, and scrolls a row into + view when it's present. If a reduced-subset filter currently hides that row, + reveal does nothing for it — clear or widen the filter first, because + VirtualTable won't silently drop a filter you own. + +## Examples + +Runnable dev pages in `pages/virtual-table/`: + +- `simple.page.tsx` — the minimal starting point: the standard results view with a + few columns and expanded log-record detail. +- `cloudwatch-standard.page.tsx` — the CloudWatch Logs Insights standard view: + dynamic columns, a stretch-last message column, expanded log-record detail, + built-in live tail, and both filter modes. +- `cloudwatch-raw.page.tsx` — the raw view: a single wrapping line per row with no + expansion, at raw-line density. +- `cloudwatch-patterns.page.tsx` — the "Show patterns" view: per-column sort, a + compare/diff column swap, a shared histogram scale supplied over the full + dataset, and expanded pattern detail. diff --git a/src/virtual-table/__tests__/use-expansion.test.tsx b/src/virtual-table/__tests__/use-expansion.test.tsx new file mode 100644 index 0000000000..450c740efa --- /dev/null +++ b/src/virtual-table/__tests__/use-expansion.test.tsx @@ -0,0 +1,74 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { act, renderHook } from '../../__tests__/render-hook'; +import { useExpansion } from '../use-expansion'; + +// VirtualTable (F2-A1) owns the R-EXPAND expansion state. These tests pin the +// controlled/uncontrolled contract and the stable signature the windowing layout depends +// on (the layout only recomputes when the set of expanded ids actually changes). + +interface Item { + id: string; +} + +const trackBy = (item: Item) => item.id; +const a: Item = { id: 'a' }; + +describe('VirtualTable (F2-A1) useExpansion', () => { + test('is empty by default (uncontrolled)', () => { + const { result } = renderHook(() => useExpansion({ trackBy })); + expect(result.current.expandedIds.size).toBe(0); + expect(result.current.expandedSignature).toBe(''); + expect(result.current.isExpanded('a')).toBe(false); + }); + + test('honours defaultExpandedItems', () => { + const { result } = renderHook(() => useExpansion({ trackBy, defaultExpandedItems: ['a'] })); + expect(result.current.isExpanded('a')).toBe(true); + expect(result.current.expandedSignature).toBe('a'); + }); + + test('toggle adds then removes an id and fires onExpandChange with the resulting set', () => { + const onExpandChange = jest.fn(); + const { result } = renderHook(() => useExpansion({ trackBy, onExpandChange })); + + act(() => result.current.toggle(a)); + expect(result.current.isExpanded('a')).toBe(true); + expect(onExpandChange).toHaveBeenLastCalledWith( + expect.objectContaining({ detail: expect.objectContaining({ item: a, expanded: true, expandedItems: ['a'] }) }) + ); + + act(() => result.current.toggle(a)); + expect(result.current.isExpanded('a')).toBe(false); + expect(onExpandChange).toHaveBeenLastCalledWith( + expect.objectContaining({ detail: expect.objectContaining({ expanded: false, expandedItems: [] }) }) + ); + }); + + test('expand is idempotent', () => { + const { result } = renderHook(() => useExpansion({ trackBy })); + act(() => result.current.expand(a)); + act(() => result.current.expand(a)); + expect(result.current.expandedIds.size).toBe(1); + }); + + test('controlled mode is driven by expandedItems, not internal state', () => { + const onExpandChange = jest.fn(); + const { result } = renderHook(() => useExpansion({ trackBy, expandedItems: ['a'], onExpandChange })); + expect(result.current.isExpanded('a')).toBe(true); + + // Toggling in controlled mode emits collapse intent but must not mutate local state: the + // controlled prop is unchanged, so the row stays expanded until the consumer updates it. + act(() => result.current.toggle(a)); + expect(onExpandChange).toHaveBeenLastCalledWith( + expect.objectContaining({ detail: expect.objectContaining({ expanded: false, expandedItems: [] }) }) + ); + expect(result.current.isExpanded('a')).toBe(true); + }); + + test('signature is order-independent and stable across identical sets', () => { + const first = renderHook(() => useExpansion({ trackBy, defaultExpandedItems: ['b', 'a'] })); + const second = renderHook(() => useExpansion({ trackBy, defaultExpandedItems: ['a', 'b'] })); + expect(first.result.current.expandedSignature).toBe(second.result.current.expandedSignature); + }); +}); diff --git a/src/virtual-table/__tests__/use-filter.test.tsx b/src/virtual-table/__tests__/use-filter.test.tsx new file mode 100644 index 0000000000..0f0152d038 --- /dev/null +++ b/src/virtual-table/__tests__/use-filter.test.tsx @@ -0,0 +1,102 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { act, renderHook } from '../../__tests__/render-hook'; +import { useFilter } from '../use-filter'; + +// The F2-A1 headline two-mode filter (CW-10). The console supplies only the predicate +// (override-seam); the hook owns which rows render, the match set, the materialised +// match-indicator column, the total-vs-filtered counts, and cyclic keyboard next/previous +// match navigation. These tests pin that contract against the shape the core wires +// (subset -> filtered visibleItems + no indicator column; mark-in-place -> all items + +// match set + indicator column + navigation). + +interface Row { + id: string; +} +const trackBy = (row: Row) => row.id; +const items: Row[] = Array.from({ length: 6 }, (_, i) => ({ id: String(i) })); +// Matches at dataset indices 1, 3, 5. +const predicate = (row: Row) => Number(row.id) % 2 === 1; + +describe('VirtualTable (F2-A1) useFilter', () => { + test('with no filter renders every row, no indicator column, no matches', () => { + const { result } = renderHook(() => useFilter({ items, trackBy })); + expect(result.current.visibleItems).toBe(items); + expect(result.current.hasIndicatorColumn).toBe(false); + expect(result.current.matchedCount).toBe(items.length); + expect(result.current.totalCount).toBe(items.length); + expect(result.current.currentMatchIndex).toBe(-1); + expect(result.current.isMatch('1')).toBe(false); + expect(result.current.isCellHighlighted(items[1], 'message')).toBe(false); + }); + + test('subset mode renders only matching rows and needs no indicator column', () => { + const { result } = renderHook(() => useFilter({ items, trackBy, filter: { mode: 'subset', predicate } })); + expect(result.current.visibleItems.map(row => row.id)).toEqual(['1', '3', '5']); + expect(result.current.hasIndicatorColumn).toBe(false); + // Every rendered subset row is a match, so the count is the filtered length; the match + // set stays empty (no indicator/highlight needed) and there is no navigation cursor. + expect(result.current.matchedCount).toBe(3); + expect(result.current.totalCount).toBe(6); + expect(result.current.isMatch('1')).toBe(false); + expect(result.current.currentMatchIndex).toBe(-1); + }); + + test('mark-in-place mode keeps all rows, materialises the indicator column, and marks matches', () => { + const highlight = (_row: Row, columnId: string) => columnId === 'message'; + const { result } = renderHook(() => + useFilter({ items, trackBy, filter: { mode: 'mark-in-place', predicate, highlight } }) + ); + expect(result.current.visibleItems).toBe(items); + expect(result.current.hasIndicatorColumn).toBe(true); + expect(result.current.matchedCount).toBe(3); + expect(result.current.totalCount).toBe(6); + expect(result.current.isMatch('3')).toBe(true); + expect(result.current.isMatch('2')).toBe(false); + // Consumer highlight applies only in mark-in-place and only to the named column. + expect(result.current.isCellHighlighted(items[3], 'message')).toBe(true); + expect(result.current.isCellHighlighted(items[3], 'level')).toBe(false); + // The navigation cursor starts on the first match (dataset index 1). + expect(result.current.currentMatchIndex).toBe(1); + }); + + test('match navigation cycles through matches in both directions', () => { + const { result } = renderHook(() => + useFilter({ items, trackBy, filter: { mode: 'mark-in-place', predicate } }) + ); + + // goToNextMatch advances the cursor and returns the visibleItems index to reveal. + let idx = -1; + act(() => { + idx = result.current.goToNextMatch(); + }); + expect(idx).toBe(3); + act(() => { + idx = result.current.goToNextMatch(); + }); + expect(idx).toBe(5); + // Wraps back to the first match. + act(() => { + idx = result.current.goToNextMatch(); + }); + expect(idx).toBe(1); + // Previous wraps to the last match. + act(() => { + idx = result.current.goToPreviousMatch(); + }); + expect(idx).toBe(5); + }); + + test('match navigation is a no-op when there are no matches', () => { + const { result } = renderHook(() => + useFilter({ items, trackBy, filter: { mode: 'mark-in-place', predicate: () => false } }) + ); + let idx = 0; + act(() => { + idx = result.current.goToNextMatch(); + }); + expect(idx).toBe(-1); + expect(result.current.matchedCount).toBe(0); + expect(result.current.currentMatchIndex).toBe(-1); + }); +}); diff --git a/src/virtual-table/__tests__/use-live-tail.test.tsx b/src/virtual-table/__tests__/use-live-tail.test.tsx new file mode 100644 index 0000000000..9f15954bb9 --- /dev/null +++ b/src/virtual-table/__tests__/use-live-tail.test.tsx @@ -0,0 +1,107 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { act, renderHook } from '../../__tests__/render-hook'; +import { useLiveTail } from '../use-live-tail'; + +// Built-in live tail (F2 headline feature). `follow` is controlled — the console owns the +// policy, VirtualTable owns the pin-to-newest mechanics (in a layout effect, after the +// runway grows, so the pin is tolerance-independent) and the release-on-scroll-away +// detection (fired at most once per follow session). These tests pin that contract with a +// spied scrollToEnd/isPinnedToEnd and a real element for the scroll listener. + +function makeRef(): React.RefObject { + return { current: document.createElement('div') } as React.RefObject; +} + +describe('VirtualTable (F2-A1) useLiveTail', () => { + test('pins to newest on entering follow and on every append while following', () => { + const scrollToEnd = jest.fn(); + const ref = makeRef(); + let count = 3; + const { rerender } = renderHook(() => + useLiveTail({ + follow: true, + itemCount: count, + scrollContainerRef: ref, + scrollToEnd, + isPinnedToEnd: () => true, + }) + ); + // Pinned on entering follow. + expect(scrollToEnd).toHaveBeenCalledTimes(1); + + // Each append (itemCount change) re-pins to newest. + count = 4; + act(() => rerender({})); + expect(scrollToEnd).toHaveBeenCalledTimes(2); + }); + + test('does not pin when not following', () => { + const scrollToEnd = jest.fn(); + const ref = makeRef(); + let count = 3; + const { rerender } = renderHook(() => + useLiveTail({ + follow: false, + itemCount: count, + scrollContainerRef: ref, + scrollToEnd, + isPinnedToEnd: () => false, + }) + ); + expect(scrollToEnd).not.toHaveBeenCalled(); + count = 4; + act(() => rerender({})); + expect(scrollToEnd).not.toHaveBeenCalled(); + }); + + test('releases follow once when the user scrolls away from the bottom', () => { + const onFollowChange = jest.fn(); + const ref = makeRef(); + renderHook(() => + useLiveTail({ + follow: true, + itemCount: 10, + scrollContainerRef: ref, + scrollToEnd: () => {}, + isPinnedToEnd: () => false, // user has scrolled up + onFollowChange, + }) + ); + + act(() => { + ref.current!.dispatchEvent(new Event('scroll')); + }); + expect(onFollowChange).toHaveBeenCalledTimes(1); + expect(onFollowChange).toHaveBeenLastCalledWith( + expect.objectContaining({ detail: { follow: false, reason: 'scroll-away' } }) + ); + + // Only once per follow session — a second scroll does not re-fire. + act(() => { + ref.current!.dispatchEvent(new Event('scroll')); + }); + expect(onFollowChange).toHaveBeenCalledTimes(1); + }); + + test('does not release follow when a scroll lands pinned to the bottom (our own pin)', () => { + const onFollowChange = jest.fn(); + const ref = makeRef(); + renderHook(() => + useLiveTail({ + follow: true, + itemCount: 10, + scrollContainerRef: ref, + scrollToEnd: () => {}, + isPinnedToEnd: () => true, // still at the bottom edge + onFollowChange, + }) + ); + act(() => { + ref.current!.dispatchEvent(new Event('scroll')); + }); + expect(onFollowChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/virtual-table/__tests__/use-virtual-model.test.tsx b/src/virtual-table/__tests__/use-virtual-model.test.tsx new file mode 100644 index 0000000000..4d36711761 --- /dev/null +++ b/src/virtual-table/__tests__/use-virtual-model.test.tsx @@ -0,0 +1,191 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { act, renderHook } from '../../__tests__/render-hook'; +import { useVirtualModel } from '../use-virtual-model'; + +// The logs-specialized F2-A1 core drives the SAME windowing + opt-in measurement engine the +// F1 cells use: one owned inner scroll container, data rows fixed at the density estimate +// (no measurement cost) unless getRowHeight opts them into 'auto' (wrapping raw lines, +// CW-15), and only expanded regions / opted-in rows measured, with anchor-based offset +// correction so measured growth above the fold does not shift the viewport. jsdom has no +// layout, so the DOM-driven paths are exercised with a controllable ResizeObserver and a +// fake scroll container whose scrollTop/clientHeight/scrollHeight are stubbed (viewport +// falls back to 600px when clientHeight is 0). Because the engine windows over whatever +// array it is handed, a subset filter's filtered array makes aria-rowcount the filtered +// length automatically (verified at the component level). + +interface Row { + id: string; +} +const trackBy = (row: Row) => row.id; +const makeItems = (n: number): Row[] => Array.from({ length: n }, (_, i) => ({ id: String(i) })); + +interface FakeContainer { + clientHeight?: number; + scrollHeight?: number; + scrollTop?: number; +} +function makeContainer({ clientHeight = 0, scrollHeight = 0, scrollTop = 0 }: FakeContainer = {}): HTMLElement { + const el = document.createElement('div'); + let top = scrollTop; + Object.defineProperty(el, 'clientHeight', { configurable: true, get: () => clientHeight }); + Object.defineProperty(el, 'scrollHeight', { configurable: true, get: () => scrollHeight }); + Object.defineProperty(el, 'scrollTop', { configurable: true, get: () => top, set: value => (top = value) }); + return el; +} + +// 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 ModelOptions { + items: Row[]; + container?: HTMLElement; + expandedIds?: Set; + expandedSignature?: string; + estimatedRowHeight?: number; + getRowHeight?: (item: Row) => number | 'auto'; + getExpandedRowHeight?: (item: Row) => number | 'auto'; + overscan?: number; +} + +function renderModel(options: ModelOptions) { + const ref = { current: options.container ?? makeContainer() } as React.RefObject; + const { result, rerender } = renderHook(() => + useVirtualModel({ + items: options.items, + trackBy, + expandedIds: options.expandedIds ?? new Set(), + expandedSignature: options.expandedSignature ?? '', + estimatedRowHeight: options.estimatedRowHeight ?? 20, + getRowHeight: options.getRowHeight, + getExpandedRowHeight: options.getExpandedRowHeight, + overscan: options.overscan ?? 5, + scrollContainerRef: ref, + }) + ); + return { result, rerender, container: ref.current! }; +} + +describe('VirtualTable (F2-A1) useVirtualModel', () => { + test('windows a large dataset to far fewer rows than it holds', () => { + const items = makeItems(1000); + const { result } = renderModel({ items, estimatedRowHeight: 20, overscan: 5 }); + expect(result.current.slots.length).toBeGreaterThan(0); + expect(result.current.slots.length).toBeLessThan(items.length); + expect(result.current.firstIndex).toBe(0); + // Window is bounded: 600px viewport / 20px rows = 30 visible + 5 overscan = last data index 35. + expect(result.current.lastIndex).toBe(35); + expect(result.current.slots.every(slot => slot.type === 'data')).toBe(true); + // Fixed rows: total runway is a simple product, independent of the window. + expect(result.current.totalSize).toBe(1000 * 20); + }); + + test('recomputes the visible range when the container scrolls', () => { + const container = makeContainer(); + const { result } = renderModel({ items: makeItems(1000), estimatedRowHeight: 20, overscan: 5, container }); + expect(result.current.firstIndex).toBe(0); + + // Scroll to offset 4000 (row 200). The scroll listener re-reads scrollTop and the window + // recomputes: firstVisible 200 - 5 overscan = 195; lastVisible 230 + 5 = 235. + act(() => { + container.scrollTop = 4000; + container.dispatchEvent(new Event('scroll')); + }); + expect(result.current.firstIndex).toBe(195); + expect(result.current.lastIndex).toBe(235); + }); + + test('inserts an expanded slot immediately after its data row and grows the runway by its height', () => { + const items = makeItems(50); + const { result } = renderModel({ + items, + expandedIds: new Set(['0']), + expandedSignature: '0', + getExpandedRowHeight: () => 120, + }); + const expandedSlot = result.current.slots.find(slot => slot.type === 'expanded'); + expect(expandedSlot).toBeDefined(); + expect(expandedSlot!.index).toBe(0); + expect(result.current.totalSize).toBe(50 * 20 + 120); + }); + + test('does not observe fixed-height rows but does observe auto rows, and applies the measured height', () => { + const items = makeItems(10); + const { result } = renderModel({ items, estimatedRowHeight: 20, getRowHeight: () => 'auto' }); + expect(result.current.totalSize).toBe(10 * 20); + + const before = observers.length; + // observers[0] is the engine's own viewport ResizeObserver (attached on mount); `before` + // is captured after mount so any new observer here is the measurement one. A fixed row + // (auto=false) is never observed — it never pays measurement cost. + act(() => result.current.measureRef('d:0', false)(stubHeight(55))); + expect(observers.length).toBe(before); + + // An auto data row is observed (the F2-A1 getRowHeight='auto' path that lets the raw view + // wrap long lines, CW-15); firing the observer applies the real height and reflows the runway. + act(() => result.current.measureRef('d:0', true)(stubHeight(55))); + expect(observers.length).toBe(before + 1); + act(() => observers[observers.length - 1].cb([], observers[observers.length - 1] as unknown as ResizeObserver)); + expect(result.current.totalSize).toBe(55 + 9 * 20); + }); + + test('scrollToIndex positions the container at the row start', () => { + const container = makeContainer(); + const { result } = renderModel({ items: makeItems(100), estimatedRowHeight: 20, container }); + act(() => result.current.scrollToIndex(10)); + expect(container.scrollTop).toBe(10 * 20); + }); + + test('scrollToEnd pins the container to the bottom of the runway', () => { + const container = makeContainer({ scrollHeight: 1234 }); + const { result } = renderModel({ items: makeItems(100), container }); + act(() => result.current.scrollToEnd()); + expect(container.scrollTop).toBe(1234); + }); + + test('isPinnedToEnd reflects whether the viewport is at the bottom edge', () => { + const pinned = makeContainer({ clientHeight: 100, scrollHeight: 100, scrollTop: 0 }); + expect(renderModel({ items: makeItems(100), container: pinned }).result.current.isPinnedToEnd()).toBe(true); + + const scrolledUp = makeContainer({ clientHeight: 100, scrollHeight: 1000, scrollTop: 0 }); + expect(renderModel({ items: makeItems(100), container: scrolledUp }).result.current.isPinnedToEnd()).toBe(false); + }); +}); diff --git a/src/virtual-table/__tests__/virtual-table-a11y.test.tsx b/src/virtual-table/__tests__/virtual-table-a11y.test.tsx new file mode 100644 index 0000000000..3e5bad3485 --- /dev/null +++ b/src/virtual-table/__tests__/virtual-table-a11y.test.tsx @@ -0,0 +1,556 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { act, fireEvent, render } from '@testing-library/react'; + +import '../../__a11y__/to-validate-a11y'; +import createWrapper from '../../../lib/components/test-utils/dom'; +import VirtualTable, { VirtualTableProps } from '../../../lib/components/virtual-table'; + +// A11y tests for the logs-specialized, config-driven VirtualTable (impl-F2-A1-tests-a11y). +// Placement mirrors the F1-A1/F1-A2 tests-a11y decision: the `__a11y__` directory is the +// node/puppeteer integ runner (`.ts`-only, tsconfig.integ), so axe + RTL keyboard/SR `.tsx` +// tests belong under `__tests__/` where the unit runner collects them — its testRegex +// `(/__tests__/.*(\.|/)test)\.[jt]sx?$` matches `virtual-table-a11y.test.tsx` and ts-jest +// transforms the `.tsx` via `tsconfig.unit.json`, so `-pr` runs it as part of `npm test`. +// +// Coverage (mapped to the unit desc): axe/HTML validity across all three views + the +// filtered + expanded + follow states; the two-mode filter's NON-VISUAL match conveyance +// (WCAG 1.4.1: text/ARIA on the indicator, not colour alone) + keyboard next/previous-match +// + indicator-column aria-colindex coherence when the mark-in-place column materialises; +// the rate-limited polite live-append announcement; row-granular keyboard nav via +// aria-activedescendant (incl. ArrowLeft/Right inert); per-column sort-trigger a11y +// (patterns view); disclosure activation + Tab-in / Escape-out of the expanded region; +// focus-under-recycling; full-dataset aria-rowcount [filtered in subset]/rowindex + +// aria-colcount/colindex + expanded role=region labeling. +// +// ariaLabels/i18nStrings are supplied as REQUIRED accessible names (grid, disclosure, +// follow toggle, expanded region, filter-match, match-navigation group + prev/next) — the +// component owns the mechanism, the console owns the names; omitting them would fail axe +// button-name / region-name, which the axe cases below exercise. + +interface Log { + id: string; + timestamp: string; + level: string; + message: string; +} + +const makeLogs = (n: number): Log[] => + Array.from({ length: n }, (_, i) => ({ + id: String(i), + timestamp: `t${i}`, + level: i % 2 === 1 ? 'ERROR' : 'INFO', + message: `message ${i}`, + })); + +// Stable module-level labels so useLiveAnnouncement's coalescing is exercised by dataset +// growth, not perturbed by label identity churn. +const ariaLabels: VirtualTableProps.AriaLabels = { + gridLabel: 'Log events', + expandRowLabel: log => `Expand details for ${log.message}`, + collapseRowLabel: log => `Collapse details for ${log.message}`, + followToggleLabel: 'Follow new logs', + filterMatchLabel: log => `Matches filter: ${log.message}`, + matchNavigationLabel: 'Filter match navigation', + previousMatchLabel: 'Previous match', + nextMatchLabel: 'Next match', +}; + +const i18nStrings: VirtualTableProps.I18nStrings = { + loadingText: 'Loading log events', + filterCountText: (matched, total) => `${matched} of ${total} matches`, + appendAnnouncementText: count => `${count} new log events`, + expandedRegionLabel: 'Log record detail', +}; + +const standardColumns: ReadonlyArray> = [ + { id: 'timestamp', header: 'Timestamp', width: 200, cell: log => log.timestamp }, + { id: 'level', header: 'Level', width: 90, cell: log => log.level }, + { id: 'message', header: 'Message', isStretch: true, cell: log => log.message }, +]; +const standardConfig: VirtualTableProps.StandardViewConfig = { + type: 'standard', + columnDefinitions: standardColumns, +}; +// Data columns for the standard view (timestamp + level + message); the disclosure and/or +// match-indicator columns, when present, are counted on top. +const DATA_COLUMNS = standardColumns.length; + +const patternsColumns: ReadonlyArray> = [ + { id: 'severity', header: 'Severity', width: 90, cell: log => log.level }, + { id: 'count', header: 'Count', width: 120, sortingField: 'count', cell: log => log.message }, + { id: 'pattern', header: 'Pattern', isStretch: true, cell: log => log.message }, +]; +const patternsConfig: VirtualTableProps.PatternsViewConfig = { + type: 'patterns', + columnDefinitions: patternsColumns, + histogramPeak: 100, +}; + +const rawConfig: VirtualTableProps.RawViewConfig = { type: 'raw', renderLine: log => log.message }; + +// Arbitrary, non-tabular expanded content (goal 6): NOT the column set. +const detail = (log: Log) => ( +
+

Log record {log.id}

+
+
Level
+
{log.level}
+
Message
+
{log.message}
+
+ +
+); + +type Overrides = Partial> & { expandable?: boolean }; + +function buildTree(items: Log[], overrides: Overrides = {}) { + const { expandable, ...props } = overrides; + return ( + log.id} + viewConfig={standardConfig} + estimatedRowHeight={23} + getExpandedContent={expandable ? detail : undefined} + getExpandedRowHeight={expandable ? () => 300 : undefined} + ariaLabels={ariaLabels} + i18nStrings={i18nStrings} + {...props} + /> + ); +} + +function renderTable(items: Log[], overrides: Overrides = {}) { + const { container, rerender } = render(buildTree(items, overrides)); + const wrapper = createWrapper(container).findVirtualTable()!; + const update = (nextItems: Log[], nextOverrides: Overrides = overrides) => + rerender(buildTree(nextItems, nextOverrides)); + return { container, wrapper, update }; +} + +function getGrid(container: HTMLElement): HTMLElement { + return container.querySelector('[role="grid"]') as HTMLElement; +} + +describe('VirtualTable F2-A1 a11y', () => { + describe('axe / HTML validity', () => { + test('validates the plain standard view (no expansion, no filter)', async () => { + const { container } = renderTable(makeLogs(20)); + await expect(container).toValidateA11y(); + }); + + test('validates a standard view with a collapsed disclosure column', async () => { + const { container } = renderTable(makeLogs(20), { expandable: true }); + await expect(container).toValidateA11y(); + }); + + test('validates a standard view with an expanded, labeled region containing arbitrary content', async () => { + // A single expanded row keeps the region name unique (i18nStrings.expandedRegionLabel is + // a shared string; simultaneously-expanded rows would repeat a landmark name). + const { container } = renderTable(makeLogs(20), { expandable: true, expandedItems: ['3'] }); + await expect(container).toValidateA11y(); + }); + + test('validates the mark-in-place filter (indicator column + match navigation + follow toggle)', async () => { + const { container } = renderTable(makeLogs(20), { + filter: { mode: 'mark-in-place', predicate: log => log.level === 'ERROR', highlight: () => true }, + follow: true, + onFollowChange: () => {}, + }); + await expect(container).toValidateA11y(); + }); + + test('validates the subset filter view', async () => { + const { container } = renderTable(makeLogs(20), { + filter: { mode: 'subset', predicate: log => log.level === 'ERROR' }, + }); + await expect(container).toValidateA11y(); + }); + + test('validates the patterns view with sortable headers (native sort triggers + aria-sort)', async () => { + const { container } = renderTable(makeLogs(20), { + viewConfig: patternsConfig, + sortingColumn: patternsColumns[1], + }); + await expect(container).toValidateA11y(); + }); + + test('validates the raw view (single monospaced column)', async () => { + const { container } = renderTable(makeLogs(50), { + viewConfig: rawConfig, + estimatedRowHeight: 20, + overscan: 40, + getRowHeight: log => (log.message.length > 120 ? 'auto' : 20), + }); + 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 }); + await expect(loading.container).toValidateA11y(); + }); + }); + + describe('grid accessibility tree', () => { + test('exactly one role=grid that owns only rowgroups -> rows -> columnheader/gridcell', () => { + const { container } = renderTable(makeLogs(20), { expandable: true, expandedItems: ['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('non-row chrome (loading / live-region) is a sibling of the grid, never a grid child', () => { + const { wrapper } = renderTable([], { loading: true }); + const status = wrapper.find('[role="status"]')!.getElement(); + expect(status.closest('[role="grid"]')).toBeNull(); + + const live = renderTable(makeLogs(5)).wrapper.findLiveRegion()!.getElement(); + expect(live.closest('[role="grid"]')).toBeNull(); + }); + }); + + describe('keyboard navigation (aria-activedescendant grid model)', () => { + test('the grid is a single always-present tab stop and seeds an active descendant', () => { + const { container, wrapper } = renderTable(makeLogs(20)); + const grid = getGrid(container); + expect(grid.getAttribute('tabindex')).toBe('0'); + const firstRow = wrapper.findRowByIndex(0)!.getElement(); + expect(grid.getAttribute('aria-activedescendant')).toBe(firstRow.id); + expect(document.getElementById(firstRow.id)).not.toBeNull(); + }); + + test('Arrow/Home/End move the active row when the grid holds focus', () => { + const { container, wrapper } = renderTable(makeLogs(20)); + const grid = getGrid(container); + + fireEvent.keyDown(grid, { key: 'ArrowDown' }); + expect(grid.getAttribute('aria-activedescendant')).toBe(wrapper.findRowByIndex(1)!.getElement().id); + + fireEvent.keyDown(grid, { key: 'End' }); + expect(grid.getAttribute('aria-activedescendant')).toBe(wrapper.findRowByIndex(19)!.getElement().id); + + fireEvent.keyDown(grid, { key: 'Home' }); + expect(grid.getAttribute('aria-activedescendant')).toBe(wrapper.findRowByIndex(0)!.getElement().id); + }); + + test('does not hijack arrow keys originating inside the expanded region', () => { + const { container, wrapper } = renderTable(makeLogs(20), { expandable: true, expandedItems: ['0'] }); + const grid = getGrid(container); + const before = grid.getAttribute('aria-activedescendant'); + + const innerButton = wrapper.findExpandedRegion(0)!.getElement().querySelector('button')!; + fireEvent.keyDown(innerButton, { key: 'ArrowDown' }); + + // onGridKeyDown bails unless the grid container itself is the event target, so arbitrary + // expanded content (goal 6) keeps its own arrow-key behaviour. + expect(grid.getAttribute('aria-activedescendant')).toBe(before); + }); + + test('ArrowLeft / ArrowRight are inert (deliberate row-granular grid, not 2D cell nav)', () => { + // Like the F1 threads, the grid is deliberately row-granular: aria-activedescendant + // references a role="row" (never a gridcell) and onGridKeyDown handles only + // Up/Down/Home/End — a legitimate APG row-as-widget variant of role="grid". The + // horizontal cell-navigation keys are intentionally inert; carried to impl-F2-A1-docs + // as an explicit a11y contract note. + const { container, wrapper } = renderTable(makeLogs(20)); + const grid = getGrid(container); + + fireEvent.keyDown(grid, { key: 'ArrowDown' }); + const active = grid.getAttribute('aria-activedescendant'); + expect(active).toBe(wrapper.findRowByIndex(1)!.getElement().id); + + fireEvent.keyDown(grid, { key: 'ArrowRight' }); + expect(grid.getAttribute('aria-activedescendant')).toBe(active); + fireEvent.keyDown(grid, { key: 'ArrowLeft' }); + expect(grid.getAttribute('aria-activedescendant')).toBe(active); + }); + }); + + describe('two-mode filter accessibility', () => { + test('mark-in-place conveys a match non-visually (visually-hidden text + decorative marker), WCAG 1.4.1', () => { + const { wrapper } = renderTable(makeLogs(20), { + filter: { mode: 'mark-in-place', predicate: log => log.level === 'ERROR' }, + }); + // Row 1 is ERROR (odd index) => a match; row 0 is INFO => not a match. In mark-in-place + // every row still renders (context preserved). + const matchRow = wrapper.findRowByIndex(1)!.getElement(); + const matchIndicator = matchRow.querySelectorAll('[role="gridcell"]')[0]; + // The visible marker is decorative... + expect(matchIndicator.querySelector('[aria-hidden="true"]')).not.toBeNull(); + // ...and the match is carried by text for assistive tech (not colour alone). + expect(matchIndicator.textContent).toContain('Matches filter: message 1'); + + const nonMatchRow = wrapper.findRowByIndex(0)!.getElement(); + const nonMatchIndicator = nonMatchRow.querySelectorAll('[role="gridcell"]')[0]; + expect(nonMatchIndicator.textContent).toBe(''); + }); + + test('match navigation exposes a named group with keyboard-operable prev/next controls', () => { + const { container, wrapper } = renderTable(makeLogs(20), { + filter: { mode: 'mark-in-place', predicate: log => log.level === 'ERROR' }, + }); + const group = container.querySelector('[role="group"]')!; + expect(group.getAttribute('aria-label')).toBe('Filter match navigation'); + + const buttons = Array.from(group.querySelectorAll('button')); + expect(buttons).toHaveLength(2); + expect(buttons[0].tagName).toBe('BUTTON'); // native = implicitly keyboard-operable + expect(buttons[0].getAttribute('aria-label')).toBe('Previous match'); + expect(buttons[1].getAttribute('aria-label')).toBe('Next match'); + // The visible chevron glyphs are decorative, so the accessible name is the aria-label only. + expect(buttons[0].querySelector('[aria-hidden="true"]')).not.toBeNull(); + + // Activating next-match moves the active row to a matching (odd-index) row. + const grid = getGrid(container); + fireEvent.click(buttons[1]); + expect(grid.getAttribute('aria-activedescendant')).toBe(wrapper.findRowByIndex(3)!.getElement().id); + }); + + test('activating previous-match reveals a matching row, wrapping backward cyclically', () => { + const { container, wrapper } = renderTable(makeLogs(20), { + filter: { mode: 'mark-in-place', predicate: log => log.level === 'ERROR' }, + }); + const group = container.querySelector('[role="group"]')!; + const prevButton = Array.from(group.querySelectorAll('button'))[0]; + const grid = getGrid(container); + // ERROR rows are the odd indices 1,3,...,19 (ten matches). From the initial cursor, + // previous-match steps backward and wraps to the last match => data row 19, exercising + // the prev keyboard reveal end-to-end (symmetric to the next-match assertion above). + fireEvent.click(prevButton); + expect(grid.getAttribute('aria-activedescendant')).toBe(wrapper.findRowByIndex(19)!.getElement().id); + }); + + test('subset mode renders no indicator column and no match navigation, and reports the filtered count', () => { + const { container, wrapper } = renderTable(makeLogs(20), { + filter: { mode: 'subset', predicate: log => log.level === 'ERROR' }, + }); + // No mark-in-place indicator column => data columns start at aria-colindex 1. + expect(getGrid(container).getAttribute('aria-colcount')).toBe(String(DATA_COLUMNS)); + expect(wrapper.findColumnHeaders()[0].getElement().getAttribute('aria-colindex')).toBe('1'); + // No match-navigation group in subset mode. + expect(container.querySelector('[role="group"]')).toBeNull(); + // The count region reports the filtered length (10 ERROR of 20). + expect(wrapper.findMatchCount()!.getElement()).toHaveTextContent('10 of 20 matches'); + }); + + test('the indicator column materialises + shifts data colindex coherently when mark-in-place toggles on and off', () => { + const { container, wrapper, update } = renderTable(makeLogs(20)); + // No filter: 3 data columns, first data column at colindex 1. + expect(getGrid(container).getAttribute('aria-colcount')).toBe(String(DATA_COLUMNS)); + expect(wrapper.findColumnHeaders()[0].getElement().getAttribute('aria-colindex')).toBe('1'); + + // Toggle on mark-in-place: the indicator column is counted (colindex 1) and every data + // column shifts down by one, coherently. + update(makeLogs(20), { filter: { mode: 'mark-in-place', predicate: log => log.level === 'ERROR' } }); + expect(getGrid(container).getAttribute('aria-colcount')).toBe(String(DATA_COLUMNS + 1)); + expect(wrapper.findColumnHeaders()[0].getElement().getAttribute('aria-colindex')).toBe('2'); + const indicatorHeader = getGrid(container).querySelectorAll('[role="columnheader"]')[0]; + expect(indicatorHeader.getAttribute('aria-colindex')).toBe('1'); + + // Toggle back off: the indicator column disappears and the counts revert coherently. + update(makeLogs(20), {}); + expect(getGrid(container).getAttribute('aria-colcount')).toBe(String(DATA_COLUMNS)); + expect(wrapper.findColumnHeaders()[0].getElement().getAttribute('aria-colindex')).toBe('1'); + }); + }); + + describe('patterns view sort-trigger accessibility', () => { + test('sortable headers expose a keyboard-operable trigger named by the header; non-sortable do not', () => { + const { wrapper } = renderTable(makeLogs(10), { viewConfig: patternsConfig }); + const [severity, count] = wrapper.findColumnHeaders(); + + // Sortable "count" column: a native (keyboard-operable) trigger whose accessible name is + // the column header, plus aria-sort. + const sortButton = count.getElement().querySelector('button')!; + expect(sortButton).not.toBeNull(); + expect(sortButton.tagName).toBe('BUTTON'); + expect(sortButton.textContent).toBe('Count'); + expect(count.getElement().getAttribute('aria-sort')).toBe('none'); + + // Non-sortable "severity" column: no trigger, no aria-sort attribute. + expect(severity.getElement().querySelector('button')).toBeNull(); + expect(severity.getElement().getAttribute('aria-sort')).toBeNull(); + }); + + test('aria-sort reflects the active column and direction', () => { + const { wrapper } = renderTable(makeLogs(10), { + viewConfig: patternsConfig, + sortingColumn: patternsColumns[1], + sortingDescending: true, + }); + expect(wrapper.findColumnHeaders()[1].getElement().getAttribute('aria-sort')).toBe('descending'); + }); + }); + + describe('disclosure + expanded region wiring', () => { + test('the disclosure control is a labeled button reflecting aria-expanded / aria-controls', () => { + const { wrapper } = renderTable(makeLogs(10), { expandable: true }); + const toggle = wrapper.findExpandToggle(0)!.getElement(); + + expect(toggle.tagName).toBe('BUTTON'); + expect(toggle.getAttribute('aria-label')).toBe('Expand details for message 0'); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + expect(toggle.getAttribute('aria-controls')).toBeNull(); + + fireEvent.click(toggle); + + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + expect(toggle.getAttribute('aria-label')).toBe('Collapse details for message 0'); + const region = wrapper.findExpandedRegion(0)!.getElement(); + 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 { wrapper } = renderTable(makeLogs(10), { expandable: true, expandedItems: ['2'] }); + const region = wrapper.findExpandedRegion(2)!.getElement(); + expect(region.getAttribute('aria-label')).toBe('Log record detail'); + }); + + test('Escape inside the expanded region returns focus to its disclosure trigger', () => { + const { wrapper } = renderTable(makeLogs(10), { expandable: true, expandedItems: ['0'] }); + const toggle = wrapper.findExpandToggle(0)!.getElement(); + const innerButton = wrapper.findExpandedRegion(0)!.getElement().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 (Tab-in half: not aria-hidden / inert / disabled)', () => { + const { wrapper } = renderTable(makeLogs(10), { expandable: true, expandedItems: ['0'] }); + const region = wrapper.findExpandedRegion(0)!.getElement(); + const innerButton = region.querySelector('button') as HTMLButtonElement; + + // jsdom cannot perform real Tab traversal; the Tab-in half of the contract is that the + // region's interactive content is genuinely focusable — not disabled and not inside an + // aria-hidden or inert ancestor that would remove it from the tab order. + 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('live-append announcement', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + test('coalesces a burst of appends into one debounced polite message (rate-limited)', () => { + const { wrapper, update } = renderTable(makeLogs(10)); + const live = wrapper.findLiveRegion()!.getElement(); + // Polite (not assertive): the tail does not interrupt the SR on every batch — the + // rate-limiting half of the reduced-interruption contract. The auto-scroll itself is an + // instant scrollTop assignment in use-live-tail (reduced-motion-safe by construction); + // jsdom cannot assert scroll smoothness, so the covered surface here is the announcement. + expect(live.getAttribute('aria-live')).toBe('polite'); + expect(live.textContent).toBe(''); + + // Two appends within the debounce window collapse into a single announcement. + update(makeLogs(13)); + update(makeLogs(15)); + expect(live.textContent).toBe(''); + + act(() => { + jest.advanceTimersByTime(500); + }); + expect(live.textContent).toContain('5 new log events'); + }); + + test('does not announce when the dataset shrinks or is replaced', () => { + const { wrapper, update } = renderTable(makeLogs(10)); + const live = wrapper.findLiveRegion()!.getElement(); + + update(makeLogs(4)); + act(() => { + jest.advanceTimersByTime(500); + }); + expect(live.textContent).toBe(''); + }); + }); + + describe('focus under row recycling', () => { + test('keeps focus on a control when rows are appended (trackBy-keyed identity)', () => { + const { wrapper, update } = renderTable(makeLogs(20), { expandable: true }); + const toggle = wrapper.findExpandToggle(0)!.getElement(); + + toggle.focus(); + expect(document.activeElement).toBe(toggle); + + // Appending rows must not remount the still-windowed row 0, so its focused disclosure + // control retains focus (recycling is keyed by trackBy identity). + update(makeLogs(30), { expandable: true }); + expect(document.activeElement).toBe(wrapper.findExpandToggle(0)!.getElement()); + }); + }); + + describe('full-dataset ARIA coherence under windowing', () => { + test('aria-rowcount counts the header once and aria-colcount counts the disclosure column', () => { + const { container } = renderTable(makeLogs(500), { expandable: true }); + const grid = getGrid(container); + expect(grid.getAttribute('aria-rowcount')).toBe('501'); + expect(grid.getAttribute('aria-colcount')).toBe(String(DATA_COLUMNS + 1)); + }); + + test('the header row is aria-rowindex 1 with the disclosure columnheader at aria-colindex 1', () => { + const { wrapper } = renderTable(makeLogs(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'); // materialised disclosure column + expect(headers[1].getAttribute('aria-colindex')).toBe('2'); // first data column + }); + + test('data rows carry a full-dataset aria-rowindex and disclosure=1 / data-from-2 colindex', () => { + const { wrapper } = renderTable(makeLogs(500), { expandable: true }); + const row0 = wrapper.findRowByIndex(0)!.getElement(); + expect(row0.getAttribute('aria-rowindex')).toBe('2'); // header is 1, so data index 0 -> 2 + + const cells = row0.querySelectorAll('[role="gridcell"]'); + expect(cells[0].getAttribute('aria-colindex')).toBe('1'); // disclosure cell + expect(cells[1].getAttribute('aria-colindex')).toBe('2'); // first data cell + }); + + test('subset filter makes aria-rowcount the filtered length', () => { + const { container } = renderTable(makeLogs(100), { + filter: { mode: 'subset', predicate: log => log.level === 'ERROR' }, + }); + // 50 ERROR of 100, + the header row. + expect(getGrid(container).getAttribute('aria-rowcount')).toBe('51'); + }); + + test('the expanded row shares its data row index and spans all columns without changing aria-rowcount', () => { + const { container, wrapper } = renderTable(makeLogs(500), { expandable: true, expandedItems: ['0'] }); + // Expanding a row does not add to the row count (design B1). + expect(getGrid(container).getAttribute('aria-rowcount')).toBe('501'); + + const region = wrapper.findExpandedRegion(0)!.getElement(); + const expandedRow = region.closest('[role="row"]')!; + expect(expandedRow.getAttribute('aria-rowindex')).toBe('2'); // shares its data row's index + + const expandedCell = expandedRow.querySelector('[role="gridcell"]')!; + expect(expandedCell.getAttribute('aria-colindex')).toBe('1'); + expect(expandedCell.getAttribute('aria-colspan')).toBe(String(DATA_COLUMNS + 1)); + }); + }); +}); diff --git a/src/virtual-table/__tests__/virtual-table.test.tsx b/src/virtual-table/__tests__/virtual-table.test.tsx new file mode 100644 index 0000000000..0647452ded --- /dev/null +++ b/src/virtual-table/__tests__/virtual-table.test.tsx @@ -0,0 +1,261 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; + +import createWrapper from '../../../lib/components/test-utils/dom'; +import VirtualTable, { VirtualTableProps } from '../../../lib/components/virtual-table'; + +import styles from '../../../lib/components/virtual-table/styles.css.js'; + +// impl-F2-A1-tests-unit: exercises the logs-specialized config-driven VirtualTable through +// the built library + test-utils wrapper. Scope is the RESOLVED public API + engine +// behaviour (windowing, view/viewConfig switching, the two-mode filter's materialised +// indicator column and coherent colindex recompute, R-EXPAND lazy mount, per-column sort +// activation, the live-tail seam, imperative ref); axe/keyboard/SR audits are +// impl-F2-A1-tests-a11y scope and intentionally not duplicated here. Component tests import +// the built lib, so they compile/run after the -pr build (as with the F1 threads). + +interface Log { + id: string; + timestamp: string; + level: string; + message: string; +} + +const makeLogs = (n: number): Log[] => + Array.from({ length: n }, (_, i) => ({ + id: String(i), + timestamp: `t${i}`, + level: i % 2 === 1 ? 'ERROR' : 'INFO', + message: `message ${i}`, + })); + +const standardColumns: ReadonlyArray> = [ + { id: 'timestamp', header: 'Timestamp', width: 200, cell: (log: Log) => log.timestamp }, + { id: 'message', header: 'Message', isStretch: true, cell: (log: Log) => log.message }, +]; +const standardConfig: VirtualTableProps.StandardViewConfig = { + type: 'standard', + columnDefinitions: standardColumns, +}; + +function renderTable(props: Partial> = {}, itemCount = 3) { + const { container } = render( + log.id} + viewConfig={standardConfig} + ariaLabels={{ gridLabel: 'Log events' }} + {...props} + /> + ); + const wrapper = createWrapper(container).findVirtualTable()!; + const grid = () => wrapper.findByClassName(styles['scroll-container'])!.getElement(); + return { container, wrapper, grid }; +} + +describe('VirtualTable (F2-A1)', () => { + test('renders the root element, is discoverable, and renders the standard view columns', () => { + const { container, wrapper } = renderTable(); + expect(container.firstChild).toHaveClass(styles.root); + expect(wrapper).not.toBeNull(); + expect(wrapper.findColumnHeaders()).toHaveLength(standardColumns.length); + expect(wrapper.findRows().length).toBeGreaterThan(0); + }); + + test('windows a large dataset and returns null for rows outside the window', () => { + const { wrapper } = renderTable({}, 500); + expect(wrapper.findRows().length).toBeLessThan(500); + expect(wrapper.findRowByIndex(0)).not.toBeNull(); + // Deep row is outside the top window (viewport 600 / 23px + overscan 20), so the wrapper + // returns null by design — windowing, not a missing row. + expect(wrapper.findRowByIndex(499)).toBeNull(); + }); + + test('sets full-dataset aria-rowcount (header counted once) and aria-colcount', () => { + const { grid } = renderTable({}, 500); + expect(grid().getAttribute('aria-rowcount')).toBe('501'); + expect(grid().getAttribute('aria-colcount')).toBe('2'); + }); + + test('materialises a counted leading disclosure column only when expansion is enabled', () => { + const expandable = renderTable( + { + getExpandedContent: (log: Log) =>
detail-{log.id}
, + i18nStrings: { expandedRegionLabel: 'Log record detail' }, + }, + 10 + ); + expect(expandable.grid().getAttribute('aria-colcount')).toBe('3'); + // Disclosure occupies aria-colindex 1, so data columns start at 2 in both header and body. + expect(expandable.wrapper.findColumnHeaders()[0].getElement().getAttribute('aria-colindex')).toBe('2'); + expect(expandable.wrapper.findExpandToggle(0)).not.toBeNull(); + + const plain = renderTable({}, 10); + expect(plain.grid().getAttribute('aria-colcount')).toBe('2'); + expect(plain.wrapper.findColumnHeaders()[0].getElement().getAttribute('aria-colindex')).toBe('1'); + expect(plain.wrapper.findExpandToggle(0)).toBeNull(); + }); + + test('subset filter makes aria-rowcount the filtered length', () => { + // 100 logs, ERROR on odd indices -> 50 matches; subset renders only matches. + const { grid, wrapper } = renderTable( + { + filter: { mode: 'subset', predicate: (log: Log) => log.level === 'ERROR' }, + i18nStrings: { filterCountText: (matched, total) => `${matched} of ${total}` }, + }, + 100 + ); + expect(grid().getAttribute('aria-rowcount')).toBe('51'); + // The match-count region renders for a filtered table (folds NB2; exercises findMatchCount). + const matchCount = wrapper.findMatchCount(); + expect(matchCount).not.toBeNull(); + expect(matchCount!.getElement()).toHaveTextContent('50 of 100'); + }); + + test('mark-in-place filter materialises + counts the match-indicator column, shifting data colindex coherently', () => { + const marked = renderTable( + { filter: { mode: 'mark-in-place', predicate: (log: Log) => log.level === 'ERROR' } }, + 10 + ); + // +1 indicator column over the plain 2-column standard view. + expect(marked.grid().getAttribute('aria-colcount')).toBe('3'); + expect(marked.wrapper.findColumnHeaders()[0].getElement().getAttribute('aria-colindex')).toBe('2'); + + // Disclosure + indicator both present: disclosure=1, indicator=2, data columns from 3. + const both = renderTable( + { + getExpandedContent: (log: Log) =>
detail-{log.id}
, + filter: { mode: 'mark-in-place', predicate: (log: Log) => log.level === 'ERROR' }, + }, + 10 + ); + expect(both.grid().getAttribute('aria-colcount')).toBe('4'); + expect(both.wrapper.findColumnHeaders()[0].getElement().getAttribute('aria-colindex')).toBe('3'); + }); + + test('renders the expanded region only for expanded rows (lazy mount)', () => { + const { wrapper, container } = renderTable( + { + getExpandedContent: (log: Log) =>
detail-{log.id}
, + expandedItems: ['5'], + i18nStrings: { expandedRegionLabel: 'Log record detail' }, + }, + 50 + ); + const region = wrapper.findExpandedRegion(5); + expect(region).not.toBeNull(); + expect(region!.getElement().getAttribute('aria-label')).toBe('Log record detail'); + expect(container.textContent).toContain('detail-5'); + // A collapsed (but windowed) row does not build its expanded subtree. + expect(wrapper.findExpandedRegion(0)).toBeNull(); + expect(container.textContent).not.toContain('detail-0'); + }); + + test('uncontrolled disclosure toggle expands the row and fires onExpandChange', () => { + const onExpandChange = jest.fn(); + const { wrapper } = renderTable( + { + getExpandedContent: (log: Log) =>
detail-{log.id}
, + onExpandChange, + i18nStrings: { expandedRegionLabel: 'Detail' }, + }, + 10 + ); + expect(wrapper.findExpandedRegion(0)).toBeNull(); + fireEvent.click(wrapper.findExpandToggle(0)!.getElement()); + expect(onExpandChange).toHaveBeenCalledWith( + expect.objectContaining({ detail: expect.objectContaining({ expanded: true, expandedItems: ['0'] }) }) + ); + expect(wrapper.findExpandedRegion(0)).not.toBeNull(); + }); + + describe('patterns view sorting (CW-9, patterns-only, reflect-not-sort)', () => { + const patternsColumns: ReadonlyArray> = [ + { id: 'severity', header: 'Severity', cell: (log: Log) => log.level }, + { id: 'count', header: 'Count', sortingField: 'count', cell: (log: Log) => log.message }, + ]; + const patternsConfig: VirtualTableProps.PatternsViewConfig = { + type: 'patterns', + columnDefinitions: patternsColumns, + }; + + test('renders a keyboard-operable sort trigger only on sortable columns', () => { + const onSortingChange = jest.fn(); + const { wrapper } = renderTable({ viewConfig: patternsConfig, onSortingChange }, 10); + const headers = wrapper.findColumnHeaders(); + + // Non-sortable column: no aria-sort, no trigger. + expect(headers[0].getElement().getAttribute('aria-sort')).toBeNull(); + expect(headers[0].getElement().querySelector('button')).toBeNull(); + + // Sortable column: aria-sort 'none' when inactive, activation fires onSortingChange. + expect(headers[1].getElement().getAttribute('aria-sort')).toBe('none'); + const sortButton = headers[1].getElement().querySelector('button')!; + expect(sortButton).not.toBeNull(); + fireEvent.click(sortButton); + expect(onSortingChange).toHaveBeenCalledWith( + expect.objectContaining({ detail: expect.objectContaining({ sortingDescending: false }) }) + ); + }); + + test('reflects the active sort column and direction via aria-sort', () => { + const { wrapper } = renderTable( + { viewConfig: patternsConfig, sortingColumn: patternsColumns[1], sortingDescending: false }, + 10 + ); + expect(wrapper.findColumnHeaders()[1].getElement().getAttribute('aria-sort')).toBe('ascending'); + }); + }); + + test('renders the raw view as a single-column surface with no disclosure column', () => { + const rawConfig: VirtualTableProps.RawViewConfig = { type: 'raw', renderLine: (log: Log) => log.message }; + const { wrapper, grid } = renderTable({ viewConfig: rawConfig }, 10); + expect(wrapper.findExpandToggle(0)).toBeNull(); + expect(grid().getAttribute('aria-colcount')).toBe('1'); + }); + + test('exposes the imperative streaming handle', () => { + const ref = React.createRef(); + renderTable({ imperativeRef: ref }, 10); + expect(typeof ref.current!.scrollToEnd).toBe('function'); + expect(typeof ref.current!.scrollToItem).toBe('function'); + expect(typeof ref.current!.revealItem).toBe('function'); + expect(typeof ref.current!.isPinnedToEnd()).toBe('boolean'); + }); + + test('renders the follow toggle when the live-tail seam is wired and reflects follow state', () => { + const onFollowChange = jest.fn(); + const { wrapper } = renderTable( + { follow: true, onFollowChange, ariaLabels: { gridLabel: 'Log events', followToggleLabel: 'Follow' } }, + 10 + ); + const toggle = wrapper.findFollowToggle(); + expect(toggle).not.toBeNull(); + expect(toggle!.getElement().getAttribute('aria-pressed')).toBe('true'); + fireEvent.click(toggle!.getElement()); + expect(onFollowChange).toHaveBeenCalledWith( + expect.objectContaining({ detail: expect.objectContaining({ follow: false, reason: 'control' }) }) + ); + }); + + test('renders a polite append aria-live region', () => { + const { wrapper } = renderTable({}, 3); + const live = wrapper.findLiveRegion(); + expect(live).not.toBeNull(); + expect(live!.getElement().getAttribute('aria-live')).toBe('polite'); + }); + + test('renders the empty slot when there are no items', () => { + const { wrapper, container } = renderTable({ empty: No log events }, 0); + expect(wrapper.findRows()).toHaveLength(0); + expect(container.textContent).toContain('No log events'); + }); + + test('renders a status role while loading', () => { + const { container } = renderTable({ loading: true, loadingText: 'Loading log events' }, 0); + expect(container.querySelector('[role="status"]')).not.toBeNull(); + expect(container.textContent).toContain('Loading log events'); + }); +}); diff --git a/src/virtual-table/index.tsx b/src/virtual-table/index.tsx new file mode 100644 index 0000000000..256f4d722b --- /dev/null +++ b/src/virtual-table/index.tsx @@ -0,0 +1,46 @@ +// 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 { VirtualTableProps } from './interfaces'; +import InternalVirtualTable from './internal'; + +export { VirtualTableProps }; + +// Config-driven, logs-specialized VirtualTable (cell F2-A1). One component driven by a +// required `viewConfig` whose `type` discriminant selects the surface. The raw view +// defaults its overscan +// to FILE_VIEW_OVERSCAN (40) when the consumer does not set it; the grid views default +// to TABLE_VIEW_OVERSCAN (20). +export default function VirtualTable({ + estimatedRowHeight = 23, + follow = false, + resizableColumns = false, + stickyHeader = true, + sortingDescending = false, + loading = false, + ...props +}: VirtualTableProps) { + const overscan = props.overscan ?? (props.viewConfig.type === 'raw' ? 40 : 20); + const baseComponentProps = useBaseComponent('VirtualTable', { + props: { view: props.viewConfig.type, estimatedRowHeight, overscan, follow, resizableColumns, stickyHeader }, + }); + return ( + + ); +} + +applyDisplayName(VirtualTable, 'VirtualTable'); diff --git a/src/virtual-table/interfaces.ts b/src/virtual-table/interfaces.ts new file mode 100644 index 0000000000..79c3e4b0fb --- /dev/null +++ b/src/virtual-table/interfaces.ts @@ -0,0 +1,352 @@ +// 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'; + +// VirtualTable (cell F2-A1): a logs/streaming-specialized, config-driven table, +// additive to and coexisting with `Table`. A required `viewConfig` object whose `type` +// discriminant selects the built-in surface (standard / patterns / raw) supplies that +// view's columns and renderers. This file is the public API surface; +// the windowing engine, measurement, live-tail mechanics, the two-mode filter, and +// the R-EXPAND a11y wiring land in impl-F2-A1-core. See +// deliverables/design/design-F2-A1.md. +export interface VirtualTableProps extends BaseComponentProps { + /** + * The full dataset for the current view. VirtualTable windows this to the visible + * range; the array is never fully rendered. Appending to it is the streaming/live + * path (see `follow`, `onVisibleRangeChange`, and the imperative ref). + */ + items: ReadonlyArray; + + /** + * Per-view configuration AND the single source of truth for which built-in surface + * is active: the `type` discriminant selects the view, and the body supplies that + * view's column set / renderers. This is F2's opinionation made explicit — + * VirtualTable ships three surfaces rather than being a raw grid: + * - "standard" the results grid: dynamic columns, leading disclosure column, + * fixed layout with a stretch-last column, keyboard-operable column + * resize with persisted widths (no per-column sort — sort is a + * patterns-view capability, CW-9). + * - "patterns" the aggregation grid: its own column set, per-column sort, + * diff/compare mode, and a shared histogram y-scale computed across + * the FULL dataset. + * - "raw" the monospaced raw log-line view (single column, no expansion), at + * RAW_LINE_HEIGHT density. + * The console owns the tab/segmented control that swaps the config; VirtualTable + * owns each view's grid, virtualization, a11y, and expansion. Kept as one object + * (rather than a flat columnDefinitions array) so a console can hold all three view + * configs and swap without re-shaping props. + */ + viewConfig: VirtualTableProps.ViewConfig; + + /** + * Stable identity per row. REQUIRED (unlike Table, where it is optional): row + * virtualization recycles DOM nodes, so a stable key is mandatory for correct focus + * retention, expansion state, and measurement caching across recycling and live + * append. + * @displayname track by + */ + trackBy: (item: T) => string; + + // --- Virtualization ------------------------------------------------------ + + /** + * Estimated collapsed row height in px, used to size the scroll runway before a row + * is measured. Defaults to the logs density; CloudWatch standard/patterns use 23 + * (ROW_HEIGHT), raw uses 20 (RAW_LINE_HEIGHT). + * @defaultValue 23 + */ + estimatedRowHeight?: number; + + /** + * Per-row height strategy. Return a fixed px height, or `"auto"` to opt that row + * into measurement (ResizeObserver-backed, measure-on-first-window-entry with a + * cached height and incremental offset correction). Omitting the prop means every + * collapsed row is fixed at `estimatedRowHeight`; expanded rows are always measured. + */ + getRowHeight?: (item: T) => number | 'auto'; + + /** + * Rows rendered beyond the visible range on each side. CloudWatch uses 20 for the + * grid views and 40 for the raw view (TABLE_VIEW_OVERSCAN / FILE_VIEW_OVERSCAN). + * @defaultValue 20 + */ + overscan?: number; + + /** + * Fires when the windowed visible range changes. Variable heights break a linear + * scrollTop estimate, so the accurate dataset-relative range is surfaced here for + * scroll-triggered prefetch. `firstIndex`/`lastIndex` index into `items`. + */ + onVisibleRangeChange?: NonCancelableEventHandler; + + // --- Live tail (built-in, F2 headline feature) --------------------------- + + /** + * Controlled follow (stick-to-bottom) state. When true, appended rows pin the + * viewport to newest; when the user scrolls away VirtualTable calls + * `onFollowChange(false)` and stops auto-scrolling. Controlled by design + * (override-seam): the console owns follow policy, the component owns the + * pin-to-newest mechanics, scroll-anchoring, and the accessible append announcement. + * @defaultValue false + */ + follow?: boolean; + + /** + * Fires when the follow state should change — on user scroll-away (false) or on + * reaching bottom / pressing the follow control (true). + */ + onFollowChange?: NonCancelableEventHandler; + + /** + * Optional accessible summary for a batch of appended rows, announced politely and + * rate-limited (~500 ms debounce, summarized) on the component-owned aria-live + * region during tail. When omitted, a default count-based message is used from + * `i18nStrings`. + */ + renderAppendAnnouncement?: (detail: VirtualTableProps.AppendDetail) => string; + + // --- Row expansion (R-EXPAND) -------------------------------------------- + + /** + * Renders arbitrary, non-tabular content for an expanded row. This is the R-EXPAND + * SUBSTRATE (goal 6): whatever it returns is hosted in the measured expanded region + * and inherits the full disclosure a11y + measurement contract. The two built-in + * presets (shape A log-record detail, shape B pattern detail) are DEFAULTS layered + * on this slot via `expandedContentPreset` — they are not a closed set. Presence of + * this prop (or a preset) enables the leading disclosure column. + */ + getExpandedContent?: (item: T, context: VirtualTableProps.RowContext) => React.ReactNode; + + /** + * Selects a built-in expanded-content layout, used as the default under + * `getExpandedContent`. "log-record" = shape A (~300 px key/value detail); + * "pattern-detail" = shape B (~150 px histogram + tokens + actions). Returning + * custom content from `getExpandedContent` overrides it for that row (override-seam). + */ + expandedContentPreset?: VirtualTableProps.ExpandedContentPreset; + + /** + * Estimated expanded height in px per row, seeding the runway before the expanded + * region is measured. Defaults follow the active preset (300 log-record, 150 + * pattern-detail); the real height is always measured. + */ + getExpandedRowHeight?: (item: T) => number; + + /** Controlled set of expanded row keys (trackBy values). */ + expandedItems?: ReadonlyArray; + + /** Uncontrolled initial set of expanded row keys. @defaultValue [] */ + defaultExpandedItems?: ReadonlyArray; + + /** Fires when a row's expansion is toggled (keyboard or pointer). */ + onExpandChange?: NonCancelableEventHandler>; + + // --- Two-mode filter (built-in) ------------------------------------------ + + /** + * The two-mode logs filter (CW-10), packaged as a component feature with the console + * supplying only the predicate (override-seam). VirtualTable owns the mechanism: + * match-indicator column, per-cell highlight styling, total-vs-filtered counts, + * keyboard next/previous-match, and a NON-VISUAL match conveyance (text/ARIA on the + * indicator, not colour alone; WCAG 1.4.1). + * - mode "subset" renders only matching rows (filtered count). + * - mode "mark-in-place" renders all rows, marks matches in place + a match + * indicator column, so context is preserved. + * Omitting `filter` disables filtering entirely. + */ + filter?: VirtualTableProps.Filter; + + // --- Columns / sort / layout (per-view surface, mirrors Table) ----------- + + /** Column currently sorted by (from the active view's column set). */ + sortingColumn?: VirtualTableProps.ColumnDefinition; + /** Sort direction. @defaultValue false */ + sortingDescending?: boolean; + /** Fires on sort control activation (keyboard-operable — not pointer-only). */ + onSortingChange?: NonCancelableEventHandler>; + + /** + * Enables keyboard-operable column resize on the active view. VirtualTable owns a + * fixed table layout with a stretch-last column; widths persist per view. + * @defaultValue false + */ + resizableColumns?: boolean; + + /** Persisted per-column widths (by column id). */ + columnWidths?: Record; + + /** Sticky header with horizontal scroll synced to the body. @defaultValue true */ + stickyHeader?: boolean; + + // --- State / labels ------------------------------------------------------ + + /** Rendered when `items` is empty for the active view. */ + empty?: React.ReactNode; + /** Loading state (initial fetch). SR-announced via i18nStrings. */ + loading?: boolean; + /** Loading text rendered/announced while `loading` is true. */ + loadingText?: string; + + /** + * Accessibility labels and the ARIA/announcement string set: the grid label, + * per-view labels, the expand/collapse trigger label, the follow toggle label, + * filter-count and append announcement templates, and loading/error text. + * @i18n + */ + ariaLabels?: VirtualTableProps.AriaLabels; + i18nStrings?: VirtualTableProps.I18nStrings; + + /** + * Imperative handle for consumer-composed streaming controls the component does not + * render (e.g. a "jump to newest" button in console chrome): scroll to newest, + * scroll a specific row into view, reveal (expand + highlight) a row, and query + * whether the view is pinned to the end. + */ + imperativeRef?: React.Ref; +} + +export namespace VirtualTableProps { + export type View = 'standard' | 'patterns' | 'raw'; + export type ExpandedContentPreset = 'log-record' | 'pattern-detail'; + + export interface ColumnDefinition { + id: string; + header: React.ReactNode; + /** + * Cell renderer. Receives the item and CellContext. CellContext exposes row + * position and expansion, NOT the windowed slice. Cross-row scale (the patterns + * histogram peak) is delivered as the single resolved scalar + * `CellContext.histogramPeak` — populated only for `view="patterns"` — because it + * must be stable across scroll and computed over the full dataset. + */ + cell: (item: T, context: CellContext) => React.ReactNode; + sortingField?: string; + sortingComparator?: (a: T, b: T) => number; + /** Fixed layout width in px; the single stretch-last column omits it (last wins). */ + width?: number; + isStretch?: boolean; + minWidth?: number; + } + + export interface CellContext { + /** Dataset-relative index of this row (not window-relative). */ + rowIndex: number; + totalItemCount: number; + isExpanded: boolean; + /** True when this cell is a filter match in mark-in-place mode. */ + isFilterMatch: boolean; + /** + * The shared histogram y-scale peak across the FULL dataset. Populated ONLY for + * `view="patterns"` from the consumer-supplied `PatternsViewConfig.histogramPeak`; + * `undefined` when that config value is omitted, and for standard/raw views. The + * cell reads it here rather than recomputing over the windowed slice, so the + * y-scale is stable across scroll (B3 preserved). Only this single resolved scalar + * crosses the boundary — the windowed item slice does not. + */ + histogramPeak?: number; + } + + export interface RowContext { + rowIndex: number; + totalItemCount: number; + } + + export type ViewConfig = StandardViewConfig | PatternsViewConfig | RawViewConfig; + + export interface StandardViewConfig { + type: 'standard'; + columnDefinitions: ReadonlyArray>; + } + + export interface PatternsViewConfig { + type: 'patterns'; + columnDefinitions: ReadonlyArray>; + /** + * The shared histogram peak across the FULL dataset, supplied by the consumer. + * VirtualTable does not read per-item histogram buckets, so it cannot compute this + * itself: compute it once over `items` and pass it here. The component surfaces it + * to the patterns cell via `CellContext.histogramPeak` so the y-scale is shared and + * stable across scroll. When omitted, `CellContext.histogramPeak` is `undefined`. + * Always a full-dataset value, never window-scoped. + */ + histogramPeak?: number; + /** Enables diff/compare mode (an alternate patterns column set + row pairing). */ + diffMode?: boolean; + } + + export interface RawViewConfig { + type: 'raw'; + /** Single monospaced line renderer at RAW_LINE_HEIGHT; no columns, no expansion. */ + renderLine: (item: T) => React.ReactNode; + } + + export interface Filter { + mode: 'subset' | 'mark-in-place'; + predicate: (item: T) => boolean; + /** Optional per-cell highlight, applied only in mark-in-place mode. */ + highlight?: (item: T, columnId: string) => boolean; + } + + export interface VisibleRangeDetail { + firstIndex: number; + lastIndex: number; + } + export interface FollowChangeDetail { + follow: boolean; + reason: 'scroll-away' | 'reached-bottom' | 'control'; + } + export interface ExpandChangeDetail { + item: T; + expanded: boolean; + expandedItems: ReadonlyArray; + } + export interface AppendDetail { + appendedCount: number; + } + export interface SortingState { + sortingColumn: ColumnDefinition; + sortingDescending: boolean; + } + + export interface Ref { + /** Scroll to and (optionally) pin to the newest row. */ + scrollToEnd(options?: { pin?: boolean }): void; + /** Scroll a row (by trackBy key) into view. */ + scrollToItem(key: string): void; + /** + * Expand + highlight + scroll a row into view (CW-13 reveal). In `mark-in-place` + * mode (or with no filter) the row is present, so it is expanded, highlighted, and + * scrolled into view. If a `subset` filter currently hides the target row, reveal + * is a no-op for that row — the console should clear or widen the filter first + * (VirtualTable does not silently drop a consumer-owned filter). + */ + revealItem(key: string): void; + /** Whether the view is currently pinned to the last row. */ + isPinnedToEnd(): boolean; + } + + export interface AriaLabels { + gridLabel: string; + viewLabel?: (view: View) => string; + expandRowLabel?: (item: T) => string; + collapseRowLabel?: (item: T) => string; + followToggleLabel?: string; + filterMatchLabel?: (item: T) => string; + /** Accessible name for the match-navigation group (mark-in-place filter). */ + matchNavigationLabel?: string; + /** Accessible name for the previous-match control (mark-in-place filter). */ + previousMatchLabel?: string; + /** Accessible name for the next-match control (mark-in-place filter). */ + nextMatchLabel?: string; + } + + export interface I18nStrings { + loadingText?: string; + filterCountText?: (matched: number, total: number) => string; + appendAnnouncementText?: (count: number) => string; + expandedRegionLabel?: string; + } +} diff --git a/src/virtual-table/internal.tsx b/src/virtual-table/internal.tsx new file mode 100644 index 0000000000..e2c625efec --- /dev/null +++ b/src/virtual-table/internal.tsx @@ -0,0 +1,522 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import clsx from 'clsx'; + +import { useUniqueId } from '@cloudscape-design/component-toolkit/internal'; + +import { getBaseProps } from '../internal/base-component'; +import { fireNonCancelableEvent } from '../internal/events'; +import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { VirtualTableProps } from './interfaces'; +import { useExpansion } from './use-expansion'; +import { INDICATOR_COL_WIDTH, useFilter } from './use-filter'; +import { useLiveAnnouncement } from './use-live-announcement'; +import { useLiveTail } from './use-live-tail'; +import { useVirtualModel } from './use-virtual-model'; + +import styles from './styles.css.js'; + +interface InternalVirtualTableProps extends VirtualTableProps, InternalBaseComponentProps {} + +// impl-F2-A1-core: the opinionated logs core. The `viewConfig.type` discriminant selects +// the built-in surface (standard / patterns / raw); the shared windowing engine, live +// tail, two-mode filter, R-EXPAND substrate, and full-dataset a11y are wired here per +// design-F2-A1.md (Chorus rfjLh1FFEJ0m). Override-seam throughout: the component owns the +// mechanism + a11y contract, the console owns policy (follow state, filter predicate, +// sort application, view swap). +export default function InternalVirtualTable(props: InternalVirtualTableProps) { + const { + items, + viewConfig, + trackBy, + estimatedRowHeight = 23, + getRowHeight, + getExpandedContent, + expandedContentPreset, + getExpandedRowHeight, + expandedItems, + defaultExpandedItems, + onExpandChange, + follow = false, + onFollowChange, + renderAppendAnnouncement, + filter, + sortingColumn, + sortingDescending = false, + onSortingChange, + stickyHeader = true, + columnWidths, + empty, + loading = false, + loadingText, + ariaLabels, + i18nStrings, + imperativeRef, + onVisibleRangeChange, + __internalRootRef, + ...rest + } = props; + const baseProps = getBaseProps(rest); + const baseId = useUniqueId('virtual-table'); + const scrollContainerRef = useRef(null); + + const isRaw = viewConfig.type === 'raw'; + const columns = viewConfig.type === 'raw' ? [] : viewConfig.columnDefinitions; + const renderLine = viewConfig.type === 'raw' ? viewConfig.renderLine : undefined; + const overscan = props.overscan ?? (isRaw ? 40 : 20); + + // Two-mode filter: subset returns only matching rows (so the windowed dataset — and + // aria-rowcount — is the filtered length); mark-in-place returns all rows + the match + // set + a materialised indicator column. + const filterModel = useFilter({ items, trackBy, filter }); + const visibleItems = filterModel.visibleItems; + + const hasDisclosureColumn = !isRaw && (!!getExpandedContent || !!expandedContentPreset); + const hasIndicatorColumn = filterModel.hasIndicatorColumn; + + // Column-index accounting: disclosure = 1 (when present), match indicator = next (when + // mark-in-place highlighting is active), data columns follow. columnCount and every + // aria-colindex recompute coherently when the indicator column appears/disappears. + let nextColIndex = 1; + const disclosureColIndex = hasDisclosureColumn ? nextColIndex++ : undefined; + const indicatorColIndex = hasIndicatorColumn ? nextColIndex++ : undefined; + const dataColumnStart = nextColIndex; + const dataColumnCount = isRaw ? 1 : columns.length; + const columnCount = dataColumnCount + (hasDisclosureColumn ? 1 : 0) + (hasIndicatorColumn ? 1 : 0); + + // Single stretch target: if several columns set isStretch, the last-declared wins (CW-8). + const stretchColumnId = [...columns].reverse().find(column => column.isStretch)?.id; + + // Component-owned expansion + shared windowing engine over the (possibly filtered) view. + const expansion = useExpansion({ trackBy, expandedItems, defaultExpandedItems, onExpandChange }); + const model = useVirtualModel({ + items: visibleItems, + trackBy, + expandedIds: expansion.expandedIds, + expandedSignature: expansion.expandedSignature, + estimatedRowHeight, + getRowHeight, + getExpandedRowHeight, + defaultExpandedEstimate: presetExpandedHeight(expandedContentPreset), + overscan, + scrollContainerRef, + }); + + // Fire the dataset-relative visible range for scroll-triggered prefetch (in an effect, + // never during render). + useEffect(() => { + if (onVisibleRangeChange && model.firstIndex >= 0) { + fireNonCancelableEvent(onVisibleRangeChange, { firstIndex: model.firstIndex, lastIndex: model.lastIndex }); + } + }, [model.firstIndex, model.lastIndex, onVisibleRangeChange]); + + // Built-in live tail: pin-to-newest on append while following; release on scroll-away. + useLiveTail({ + follow, + itemCount: visibleItems.length, + scrollContainerRef, + scrollToEnd: model.scrollToEnd, + isPinnedToEnd: model.isPinnedToEnd, + onFollowChange, + }); + + // Debounced polite append announcement, keyed on the full dataset count. + const appendMessage = useLiveAnnouncement( + items.length, + renderAppendAnnouncement + ? detail => renderAppendAnnouncement({ appendedCount: detail.addedCount }) + : i18nStrings?.appendAnnouncementText + ? detail => i18nStrings.appendAnnouncementText!(detail.addedCount) + : undefined + ); + + // The resolved shared histogram y-scale for the patterns view: consumer-supplied peak + // across the FULL dataset when given. NOTE (panel/-cw-patterns): a component-computed + // default peak needs a histogram-value accessor the config does not yet expose, so the + // core surfaces only the supplied value; -cw-patterns supplies it and this seam is + // flagged for the panel rather than invented here. + const histogramPeak = viewConfig.type === 'patterns' ? viewConfig.histogramPeak : undefined; + + // Active-descendant keyboard model: the scroll container is the single always-present + // tab stop (reachable at any offset, incl. live-tail pinned-to-end); arrows move an + // active ROW (row-granular grid, matching the F1 pinned deviation) and never steal keys + // from interactive cell content or the expanded region (onGridKeyDown bails unless the + // grid itself is focused). + const idToIndex = useMemo(() => { + const map = new Map(); + visibleItems.forEach((item, index) => map.set(trackBy(item), index)); + return map; + }, [visibleItems, trackBy]); + + const [activeId, setActiveId] = useState(undefined); + const windowedDataIds = new Set( + model.slots.filter(slot => slot.type === 'data').map(slot => trackBy(visibleItems[slot.index])) + ); + const effectiveActiveId = + activeId && idToIndex.has(activeId) ? activeId : visibleItems.length ? trackBy(visibleItems[0]) : undefined; + const activeDescendant = + effectiveActiveId && windowedDataIds.has(effectiveActiveId) ? rowDomId(baseId, effectiveActiveId) : undefined; + + const moveActive = useCallback( + (delta: number | 'first' | 'last') => { + if (!visibleItems.length) { + return; + } + const currentIndex = effectiveActiveId ? (idToIndex.get(effectiveActiveId) ?? 0) : 0; + let target: number; + if (delta === 'first') { + target = 0; + } else if (delta === 'last') { + target = visibleItems.length - 1; + } else { + target = Math.min(visibleItems.length - 1, Math.max(0, currentIndex + delta)); + } + setActiveId(trackBy(visibleItems[target])); + model.scrollToIndex(target); + }, + [visibleItems, effectiveActiveId, idToIndex, trackBy, model] + ); + + const onGridKeyDown = (event: React.KeyboardEvent) => { + if (event.target !== event.currentTarget) { + return; + } + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + moveActive(1); + break; + case 'ArrowUp': + event.preventDefault(); + moveActive(-1); + break; + case 'Home': + event.preventDefault(); + moveActive('first'); + break; + case 'End': + event.preventDefault(); + moveActive('last'); + break; + } + }; + + // Reflect-not-sort: the component reflects sort intent + fires onSortingChange; the + // console applies the sort to `items`. Sort is a patterns-view capability (CW-9). + const handleSort = (column: VirtualTableProps.ColumnDefinition) => { + const active = sortingColumn?.id === column.id; + const nextDescending = active ? !sortingDescending : false; + fireNonCancelableEvent(onSortingChange, { sortingColumn: column, sortingDescending: nextDescending }); + }; + const ariaSortOf = ( + column: VirtualTableProps.ColumnDefinition + ): 'ascending' | 'descending' | 'none' | undefined => { + if (!column.sortingField) { + return undefined; + } + if (sortingColumn?.id === column.id) { + return sortingDescending ? 'descending' : 'ascending'; + } + return 'none'; + }; + + // Match navigation (mark-in-place): keyboard-operable next/previous-match reveal. + const revealMatch = (visibleIndex: number) => { + if (visibleIndex < 0) { + return; + } + setActiveId(trackBy(visibleItems[visibleIndex])); + model.scrollToIndex(visibleIndex); + }; + + useImperativeHandle( + imperativeRef, + () => ({ + scrollToEnd: () => model.scrollToEnd(), + scrollToItem: (key: string) => { + const index = idToIndex.get(key); + if (index !== undefined) { + model.scrollToIndex(index); + } + }, + // CW-13 reveal: expand + scroll a row into view. If a subset filter has hidden the + // row it is not in visibleItems and cannot be scrolled to; clearing a + // consumer-owned subset filter needs a console hook — flagged for the panel. + revealItem: (key: string) => { + const index = idToIndex.get(key); + if (index === undefined) { + return; + } + expansion.expand(visibleItems[index]); + setActiveId(key); + model.scrollToIndex(index); + }, + isPinnedToEnd: () => model.isPinnedToEnd(), + }), + [model, idToIndex, visibleItems, expansion] + ); + + const showEmpty = !loading && visibleItems.length === 0; + const regionLabel = (item: T) => i18nStrings?.expandedRegionLabel ?? ariaLabels?.expandRowLabel?.(item); + + const renderDataRow = (index: number, start: number, auto: boolean) => { + const item = visibleItems[index]; + const id = trackBy(item); + const expanded = expansion.isExpanded(id); + const isFilterMatch = filterModel.isMatch(id); + const toggleId = `${baseId}-toggle-${id}`; + const regionId = `${baseId}-region-${id}`; + const context: VirtualTableProps.CellContext = { + rowIndex: index, + totalItemCount: visibleItems.length, + isExpanded: expanded, + isFilterMatch, + histogramPeak, + }; + return ( +
+ {hasDisclosureColumn && ( + +
+ ); + }; + + const renderExpandedRow = (index: number, start: number, auto: boolean) => { + const item = visibleItems[index]; + const id = trackBy(item); + const toggleId = `${baseId}-toggle-${id}`; + const regionId = `${baseId}-region-${id}`; + return ( +
+ {/* Full-width gridcell keeps a valid grid child model; the arbitrary, non-tabular + content lives in a labeled region inside it (design B2). A div (not a span) + hosts content that may include block-level elements. */} +
+
{ + if (event.key === 'Escape') { + event.stopPropagation(); + document.getElementById(toggleId)?.focus(); + } + }} + > + {getExpandedContent?.(item, { rowIndex: index, totalItemCount: visibleItems.length })} +
+
+
+ ); + }; + + return ( +
+ {(onFollowChange || filter) && ( +
+ {onFollowChange && ( + + )} + {/* Total-vs-filtered count is shown for BOTH modes (subset uses the filtered + length, mark-in-place the match count); match navigation is mark-in-place only. */} + {filter && ( + + {i18nStrings?.filterCountText?.(filterModel.matchedCount, filterModel.totalCount)} + + )} + {filter && filterModel.hasIndicatorColumn && ( +
+ + +
+ )} +
+ )} + +
+
+
+ {hasDisclosureColumn && ( + + )} + {hasIndicatorColumn && ( + + )} + {isRaw ? ( + + ) : ( + columns.map((column, columnIndex) => { + const sort = ariaSortOf(column); + return ( + + {sort ? ( + + ) : ( + column.header + )} + + ); + }) + )} +
+
+ + {!loading && !showEmpty && ( +
+ {model.slots.map(slot => + slot.type === 'data' + ? renderDataRow(slot.index, slot.start, slot.auto) + : renderExpandedRow(slot.index, slot.start, slot.auto) + )} +
+ )} +
+ + {loading && ( +
+ {loadingText ?? i18nStrings?.loadingText} +
+ )} + + {showEmpty &&
{empty}
} + +
+ {appendMessage} +
+
+ ); +} + +function rowDomId(baseId: string, id: string): string { + return `${baseId}-row-${id}`; +} + +// Default pre-measurement runway for an expanded region, seeded by the active preset +// (shape A log-record ~300, shape B pattern-detail ~150). The real height is measured. +function presetExpandedHeight(preset?: VirtualTableProps.ExpandedContentPreset): number { + return preset === 'pattern-detail' ? 150 : 300; +} + +// Fixed-layout column sizing: an explicit width pins the column; the stretch column +// (CW-8, single last-wins target) fills remaining space; otherwise columns share space. +function cellStyle( + column: VirtualTableProps.ColumnDefinition, + columnWidths: Record | undefined, + stretch: boolean +): React.CSSProperties { + const width = columnWidths?.[column.id] ?? column.width; + if (stretch) { + return { flex: '1 1 auto', minInlineSize: column.minWidth }; + } + if (width !== undefined) { + return { flex: `0 0 ${width}px`, minInlineSize: column.minWidth }; + } + return { flex: '1 1 0', minInlineSize: column.minWidth ?? 0 }; +} diff --git a/src/virtual-table/styles.scss b/src/virtual-table/styles.scss new file mode 100644 index 0000000000..43ca176703 --- /dev/null +++ b/src/virtual-table/styles.scss @@ -0,0 +1,198 @@ +/* + 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; + +.root { + @include styles.styles-reset; + position: relative; + display: block; + inline-size: 100%; +} + +.toolbar { + display: flex; + align-items: center; + gap: awsui.$space-scaled-xs; + margin-block-end: awsui.$space-scaled-s; +} + +.follow-toggle { + @include styles.styles-reset; + cursor: pointer; +} + +.scroll-container { + position: relative; + overflow: auto; + inline-size: 100%; + + // The scroll container is the single tab stop (active-descendant keyboard model), so + // it carries the focus ring; the active row is indicated via .row-active. + &:focus-visible { + outline: 2px solid awsui.$color-border-item-focused; + outline-offset: -2px; + } +} + +.header-rowgroup { + display: block; +} + +.header-row { + display: flex; +} + +// Sticky header is opt-in: only pin the header row when the consumer sets stickyHeader. +// The header stays inside the single scroll container so it scrolls horizontally with +// the body (header <-> body sync, CW-14) while pinning vertically. +.sticky-header { + & > .header-rowgroup { + position: sticky; + inset-block-start: 0; + z-index: 1; + background: awsui.$color-background-container-content; + } +} + +.header-cell { + min-inline-size: 0; + font-weight: awsui.$font-weight-bold; + padding-block: awsui.$space-scaled-xxs; + padding-inline: awsui.$space-scaled-xs; + box-sizing: border-box; +} + +.disclosure-header, +.disclosure-cell { + flex: 0 0 auto; + inline-size: awsui.$space-xl; + display: flex; + align-items: center; + justify-content: center; +} + +// The body is the scroll runway. In the scaffold it renders rows in normal flow; the +// core replaces this with absolutely-positioned windowed rows over a sized runway. +.body { + display: block; + position: relative; + inline-size: 100%; +} + +.row, +.expanded-row { + display: flex; + inline-size: 100%; + box-sizing: border-box; +} + +.row { + align-items: center; + &:focus-visible { + outline: 2px solid awsui.$color-border-item-focused; + outline-offset: -2px; + } +} + +// Active descendant highlight: the container holds the tab stop, this row is the +// aria-activedescendant target moved by the arrow keys. +.row-active { + outline: 2px solid awsui.$color-border-item-focused; + outline-offset: -2px; +} + +.cell { + min-inline-size: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding-block: awsui.$space-scaled-xxs; + padding-inline: awsui.$space-scaled-xs; + box-sizing: border-box; +} + +.disclosure-button { + @include styles.styles-reset; + cursor: pointer; + inline-size: 100%; + block-size: 100%; +} + +.expanded-cell { + flex: 1 1 auto; + min-inline-size: 0; + box-sizing: border-box; +} + +.expanded-region { + display: block; + inline-size: 100%; + box-sizing: border-box; + padding-block: awsui.$space-scaled-s; + padding-inline: awsui.$space-scaled-m; +} + +.loading, +.empty { + padding-block: awsui.$space-scaled-l; + text-align: center; +} + +// Materialised mark-in-place match-indicator column (INDICATOR_COL_WIDTH=25). Present +// only while highlighting; counted as a real columnheader so aria-colindex stays coherent. +.indicator-header, +.indicator-cell { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; +} + +.indicator-marker { + inline-size: 6px; + block-size: 6px; + border-start-start-radius: 50%; + border-start-end-radius: 50%; + border-end-start-radius: 50%; + border-end-end-radius: 50%; + background: awsui.$color-border-item-focused; +} + +// A cell whose consumer-supplied highlight predicate matches in mark-in-place mode. The +// non-visual match conveyance lives on the indicator column; this is a visual aid only. +.cell-highlight { + background: awsui.$color-background-item-selected; +} + +.sort-button { + @include styles.styles-reset; + cursor: pointer; + font-weight: awsui.$font-weight-bold; + inline-size: 100%; + text-align: start; +} + +.match-nav { + display: flex; + align-items: center; + gap: awsui.$space-scaled-xxs; +} + +.match-count { + min-inline-size: 0; +} + +// SR-only helper for the non-visual match label (WCAG 1.4.1) and any other text that +// must reach assistive tech without being shown. +.visually-hidden { + @include styles.awsui-util-hide; +} + +.live-region { + @include styles.awsui-util-hide; +} diff --git a/src/virtual-table/use-expansion.ts b/src/virtual-table/use-expansion.ts new file mode 100644 index 0000000000..682b373d7d --- /dev/null +++ b/src/virtual-table/use-expansion.ts @@ -0,0 +1,67 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { useCallback, useMemo, useState } from 'react'; + +import { fireNonCancelableEvent, NonCancelableEventHandler } from '../internal/events'; +import { VirtualTableProps } from './interfaces'; + +// Controlled/uncontrolled expansion state for the R-EXPAND disclosure column. The +// component owns this state and exposes a stable signature so the windowing layout only +// recomputes when the set of expanded ids actually changes, not on every render. +interface UseExpansionParams { + trackBy: (item: T) => string; + expandedItems?: ReadonlyArray; + defaultExpandedItems?: ReadonlyArray; + onExpandChange?: NonCancelableEventHandler>; +} + +interface Expansion { + expandedIds: ReadonlySet; + expandedSignature: string; + isExpanded: (id: string) => boolean; + toggle: (item: T) => void; + expand: (item: T) => void; +} + +export function useExpansion({ + trackBy, + expandedItems, + defaultExpandedItems, + onExpandChange, +}: UseExpansionParams): Expansion { + const controlled = expandedItems !== undefined; + const [uncontrolled, setUncontrolled] = useState>(defaultExpandedItems ?? []); + const current = controlled ? expandedItems! : uncontrolled; + + const expandedIds = useMemo(() => new Set(current), [current]); + const expandedSignature = useMemo(() => [...current].sort().join('\u0000'), [current]); + + const isExpanded = useCallback((id: string) => expandedIds.has(id), [expandedIds]); + + const setExpanded = useCallback( + (item: T, expanded: boolean) => { + const id = trackBy(item); + const next = new Set(current); + if (expanded) { + next.add(id); + } else { + next.delete(id); + } + const nextArray = [...next]; + if (!controlled) { + setUncontrolled(nextArray); + } + fireNonCancelableEvent(onExpandChange, { item, expanded, expandedItems: nextArray }); + }, + [current, controlled, trackBy, onExpandChange] + ); + + const toggle = useCallback( + (item: T) => setExpanded(item, !expandedIds.has(trackBy(item))), + [setExpanded, expandedIds, trackBy] + ); + + const expand = useCallback((item: T) => setExpanded(item, true), [setExpanded]); + + return { expandedIds, expandedSignature, isExpanded, toggle, expand }; +} diff --git a/src/virtual-table/use-filter.ts b/src/virtual-table/use-filter.ts new file mode 100644 index 0000000000..1062119e48 --- /dev/null +++ b/src/virtual-table/use-filter.ts @@ -0,0 +1,106 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { useCallback, useMemo, useRef, useState } from 'react'; + +import { VirtualTableProps } from './interfaces'; + +// The two-mode logs filter (CW-10), owned by VirtualTable as a first-class feature — the +// console supplies only the predicate (override-seam), the component owns the mechanism: +// which rows render, the match set, the materialised match-indicator column, the +// total-vs-filtered counts, and keyboard next/previous-match navigation with a NON-VISUAL +// match conveyance (text/ARIA on the indicator, not colour alone; WCAG 1.4.1). +// +// - "subset" renders only matching rows (aria-rowcount reflects the filtered +// length, since the engine windows the returned `visibleItems`). +// - "mark-in-place" renders all rows, marks matches in place + adds a match-indicator +// column so surrounding context is preserved. +export const INDICATOR_COL_WIDTH = 25; + +interface UseFilterParams { + items: ReadonlyArray; + trackBy: (item: T) => string; + filter?: VirtualTableProps.Filter; +} + +export interface FilterModel { + /** Items to window/render: the filtered subset, or all items in mark-in-place. */ + visibleItems: ReadonlyArray; + /** Whether the mark-in-place indicator column is materialised + counted. */ + hasIndicatorColumn: boolean; + /** Whether a given row (by id) is a filter match (mark-in-place). */ + isMatch: (id: string) => boolean; + /** Whether a specific cell should be highlighted (mark-in-place, consumer-supplied). */ + isCellHighlighted: (item: T, columnId: string) => boolean; + matchedCount: number; + totalCount: number; + /** Index into visibleItems of the currently focused match (-1 when none). */ + currentMatchIndex: number; + /** Move focus to the next/previous match; returns the visibleItems index to reveal, or -1. */ + goToNextMatch: () => number; + goToPreviousMatch: () => number; +} + +export function useFilter({ items, trackBy, filter }: UseFilterParams): FilterModel { + const [cursor, setCursor] = useState(0); + const cursorRef = useRef(0); + cursorRef.current = cursor; + + const { visibleItems, matchIds, matchIndices } = useMemo(() => { + if (!filter) { + return { visibleItems: items, matchIds: new Set(), matchIndices: [] as number[] }; + } + const predicate = filter.predicate; + if (filter.mode === 'subset') { + const visible = items.filter(predicate); + // In subset mode every rendered row is a match; the indicator column is not needed. + return { visibleItems: visible, matchIds: new Set(), matchIndices: [] as number[] }; + } + // mark-in-place: render everything, record which rows (and their visible index) match. + const ids = new Set(); + const indices: number[] = []; + items.forEach((item, index) => { + if (predicate(item)) { + ids.add(trackBy(item)); + indices.push(index); + } + }); + return { visibleItems: items, matchIds: ids, matchIndices: indices }; + }, [items, trackBy, filter]); + + const hasIndicatorColumn = !!filter && filter.mode === 'mark-in-place'; + + const isMatch = useCallback((id: string) => matchIds.has(id), [matchIds]); + + const highlight = filter?.highlight; + const isCellHighlighted = useCallback( + (item: T, columnId: string) => hasIndicatorColumn && !!highlight && highlight(item, columnId), + [hasIndicatorColumn, highlight] + ); + + const step = useCallback( + (delta: number) => { + if (matchIndices.length === 0) { + return -1; + } + const next = (cursorRef.current + delta + matchIndices.length) % matchIndices.length; + setCursor(next); + return matchIndices[next]; + }, + [matchIndices] + ); + + const goToNextMatch = useCallback(() => step(1), [step]); + const goToPreviousMatch = useCallback(() => step(-1), [step]); + + return { + visibleItems, + hasIndicatorColumn, + isMatch, + isCellHighlighted, + matchedCount: filter ? (filter.mode === 'subset' ? visibleItems.length : matchIds.size) : items.length, + totalCount: items.length, + currentMatchIndex: matchIndices.length ? matchIndices[cursor % matchIndices.length] : -1, + goToNextMatch, + goToPreviousMatch, + }; +} diff --git a/src/virtual-table/use-live-announcement.ts b/src/virtual-table/use-live-announcement.ts new file mode 100644 index 0000000000..d08e941a31 --- /dev/null +++ b/src/virtual-table/use-live-announcement.ts @@ -0,0 +1,57 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { useEffect, useRef, useState } from 'react'; + +const APPEND_DEBOUNCE_MS = 500; + +// Coalesces streaming appends into a single polite announcement per burst. F2 owns live +// tail, so the component owns the debounce + aria-live region; the console supplies only +// the message text (renderAppendAnnouncement / i18nStrings.appendAnnouncementText). A +// high-rate tail announces "N new log events" once per ~500 ms batch, not once per row. +export function useLiveAnnouncement( + totalCount: number, + appendAnnouncement?: (detail: { addedCount: number; totalCount: number }) => string | undefined +): string { + const [message, setMessage] = useState(''); + const previousCount = useRef(totalCount); + const pendingAdded = useRef(0); + const timer = useRef>(); + + useEffect(() => { + const delta = totalCount - previousCount.current; + previousCount.current = totalCount; + + // Only appends are announced; a shrink/replace resets the running tally. + if (delta <= 0) { + pendingAdded.current = 0; + return; + } + if (!appendAnnouncement) { + return; + } + pendingAdded.current += delta; + + if (timer.current) { + clearTimeout(timer.current); + } + timer.current = setTimeout(() => { + const added = pendingAdded.current; + pendingAdded.current = 0; + const next = appendAnnouncement({ addedCount: added, totalCount }); + if (next !== undefined) { + // Toggle a trailing zero-width space when the text repeats so the SR re-announces. + setMessage(prev => (prev === next ? next + '\u200b' : next)); + } + }, APPEND_DEBOUNCE_MS); + }, [totalCount, appendAnnouncement]); + + useEffect(() => { + return () => { + if (timer.current) { + clearTimeout(timer.current); + } + }; + }, []); + + return message; +} diff --git a/src/virtual-table/use-live-tail.ts b/src/virtual-table/use-live-tail.ts new file mode 100644 index 0000000000..91fc33cd86 --- /dev/null +++ b/src/virtual-table/use-live-tail.ts @@ -0,0 +1,66 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { useEffect, useLayoutEffect, useRef } from 'react'; + +import { fireNonCancelableEvent, NonCancelableEventHandler } from '../internal/events'; +import { VirtualTableProps } from './interfaces'; + +// Built-in live tail (F2 headline feature). `follow` is controlled — the console owns the +// policy (and typically renders its own "jump to newest" control), VirtualTable owns the +// pin-to-newest mechanics and the release-on-scroll-away detection: +// - while following, an append pins the viewport to the newest row in a layout effect, +// AFTER the runway grows, so the pin is exact and independent of any at-bottom +// tolerance (the coupling the F1 demos had to work around consumer-side); +// - when the user scrolls away from the bottom, it fires onFollowChange(false, +// 'scroll-away') once so the console can release follow. +// Scroll is an instant scrollTop assignment (never smooth), so it is reduced-motion-safe +// by construction; the prefers-reduced-motion query is read only to keep that guarantee +// explicit for any future animated affordance. +interface UseLiveTailParams { + follow: boolean; + itemCount: number; + scrollContainerRef: React.RefObject; + scrollToEnd: () => void; + isPinnedToEnd: () => boolean; + onFollowChange?: NonCancelableEventHandler; +} + +export function useLiveTail({ + follow, + itemCount, + scrollContainerRef, + scrollToEnd, + isPinnedToEnd, + onFollowChange, +}: UseLiveTailParams): void { + // Pin to newest on entering follow and on every append while following. Runs in a + // layout effect so the runway has already grown to the new total height. + useLayoutEffect(() => { + if (follow) { + scrollToEnd(); + } + }, [follow, itemCount, scrollToEnd]); + + // Release follow when the user scrolls away from the bottom. Fired at most once per + // follow session (a ref guards against re-firing before the controlled prop updates); + // our own pin lands at the bottom (isPinnedToEnd() true) so auto-scroll never triggers it. + const releasedRef = useRef(false); + useEffect(() => { + releasedRef.current = false; + }, [follow]); + + useEffect(() => { + const el = scrollContainerRef.current; + if (!el || !follow) { + return; + } + const onScroll = () => { + if (!releasedRef.current && !isPinnedToEnd()) { + releasedRef.current = true; + fireNonCancelableEvent(onFollowChange, { follow: false, reason: 'scroll-away' }); + } + }; + el.addEventListener('scroll', onScroll, { passive: true }); + return () => el.removeEventListener('scroll', onScroll); + }, [follow, scrollContainerRef, isPinnedToEnd, onFollowChange]); +} diff --git a/src/virtual-table/use-virtual-model.ts b/src/virtual-table/use-virtual-model.ts new file mode 100644 index 0000000000..cb69783e88 --- /dev/null +++ b/src/virtual-table/use-virtual-model.ts @@ -0,0 +1,309 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; + +// Windowing + opt-in measurement engine for the logs-specialized VirtualTable +// (impl-F2-A1-core). This is the SAME behaviour/a11y engine the F1 cells use — a single +// owned inner scroll container (design B4), the body windows within it, data rows are +// fixed at `estimatedRowHeight` (23 at CloudWatch grid density, 20 for raw) so a 100k +// fixed-row stream never pays ResizeObserver cost, and only 'auto' data rows (wrapping +// raw lines, CW-15) and expanded regions are measured (design B5). Measured growth of a +// slot above the fold is corrected by re-anchoring on the first visible data row's +// identity, so scroll position stays put as variable EXPANDED heights are discovered +// (no CW-3 drift). The component windows over whatever item array it is handed — in a +// `subset` filter that array is the filtered set, so aria-rowcount reflects the filtered +// length automatically. + +const DEFAULT_EXPANDED_ESTIMATE = 200; +const DEFAULT_VIEWPORT = 600; + +// A row slot is either a data row or the expanded region row that follows it. The +// expanded slot is a real grid row in the DOM (design B2) but does not consume a +// data-row aria-rowindex (design B1) — the index sequence tracks data rows only. +export interface RowSlot { + type: 'data' | 'expanded'; + index: number; + key: string; + auto: boolean; +} + +export interface PositionedSlot extends RowSlot { + start: number; + size: number; +} + +interface UseVirtualModelParams { + items: ReadonlyArray; + trackBy: (item: T) => string; + expandedIds: ReadonlySet; + expandedSignature: string; + estimatedRowHeight: number; + /** Height strategy for a DATA row: a fixed px value or 'auto' (measured). Omit -> fixed at estimatedRowHeight. */ + getRowHeight?: (item: T) => number | 'auto'; + /** Height strategy for the expanded region of an item: a fixed px value or 'auto' (measured). */ + getExpandedRowHeight?: (item: T) => number | 'auto'; + /** Pre-measurement runway estimate for an 'auto' expanded region (preset A ~300 / B ~150). */ + defaultExpandedEstimate?: number; + overscan: number; + scrollContainerRef: React.RefObject; +} + +export interface VirtualModel { + slots: PositionedSlot[]; + totalSize: number; + firstIndex: number; + lastIndex: number; + measureRef: (key: string, auto: boolean) => (node: HTMLElement | null) => void; + scrollToEnd: () => void; + scrollToIndex: (index: number) => void; + isPinnedToEnd: () => boolean; +} + +// Largest i such that offsets[i] <= target (offsets is strictly non-decreasing). +function findFloorIndex(offsets: number[], target: number): number { + let lo = 0; + let hi = offsets.length - 1; + let result = 0; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (offsets[mid] <= target) { + result = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + return result; +} + +export function useVirtualModel({ + items, + trackBy, + expandedIds, + expandedSignature, + estimatedRowHeight, + getRowHeight, + getExpandedRowHeight, + defaultExpandedEstimate = DEFAULT_EXPANDED_ESTIMATE, + overscan, + scrollContainerRef, +}: UseVirtualModelParams): VirtualModel { + const [scrollTop, setScrollTop] = useState(0); + const [viewport, setViewport] = useState(DEFAULT_VIEWPORT); + const [measureVersion, setMeasureVersion] = useState(0); + + const measured = useRef(new Map()); + const observers = useRef(new Map()); + const anchor = useRef<{ id: string; top: number } | null>(null); + + const latest = useRef({ getRowHeight, getExpandedRowHeight, estimatedRowHeight, defaultExpandedEstimate }); + latest.current = { getRowHeight, getExpandedRowHeight, estimatedRowHeight, defaultExpandedEstimate }; + + const layout = useMemo(() => { + const { + getRowHeight: grh, + getExpandedRowHeight: gerh, + estimatedRowHeight: est, + defaultExpandedEstimate: expEst, + } = latest.current; + const slots: RowSlot[] = []; + const offsets: number[] = []; + const startById = new Map(); + let cursor = 0; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const id = trackBy(item); + + // Data rows are fixed at the density estimate by default (no measurement cost). When + // getRowHeight opts a row into 'auto', it is measured so wrapping rows (the raw view, + // CW-15) window at their real height; a numeric return pins it. + const rawData = grh ? grh(item) : est; + const dataAuto = rawData === 'auto'; + const dataSize = dataAuto ? (measured.current.get('d:' + id) ?? est) : (rawData as number); + slots.push({ type: 'data', index: i, key: 'd:' + id, auto: dataAuto }); + offsets.push(cursor); + startById.set(id, cursor); + cursor += dataSize; + + if (expandedIds.has(id)) { + const rawExp = gerh ? gerh(item) : 'auto'; + const expAuto = rawExp === 'auto'; + const expSize = expAuto ? (measured.current.get('e:' + id) ?? expEst) : (rawExp as number); + slots.push({ type: 'expanded', index: i, key: 'e:' + id, auto: expAuto }); + offsets.push(cursor); + cursor += expSize; + } + } + + return { slots, offsets, startById, totalSize: cursor }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items, expandedSignature, estimatedRowHeight, measureVersion, trackBy]); + + useEffect(() => { + const el = scrollContainerRef.current; + if (!el) { + return; + } + setViewport(el.clientHeight || DEFAULT_VIEWPORT); + setScrollTop(el.scrollTop); + const ro = new ResizeObserver(() => setViewport(el.clientHeight || DEFAULT_VIEWPORT)); + ro.observe(el); + return () => ro.disconnect(); + }, [scrollContainerRef]); + + const layoutRef = useRef(layout); + layoutRef.current = layout; + + useEffect(() => { + const el = scrollContainerRef.current; + if (!el) { + return; + } + const onScroll = () => { + const top = el.scrollTop; + setScrollTop(top); + const { slots, offsets } = layoutRef.current; + const idx = findFloorIndex(offsets, top); + for (let i = idx; i < slots.length; i++) { + if (slots[i].type === 'data') { + anchor.current = { id: slots[i].key.slice(2), top: offsets[i] - top }; + break; + } + } + }; + el.addEventListener('scroll', onScroll, { passive: true }); + return () => el.removeEventListener('scroll', onScroll); + }, [scrollContainerRef]); + + useLayoutEffect(() => { + const el = scrollContainerRef.current; + if (!el || !anchor.current) { + return; + } + const newStart = layout.startById.get(anchor.current.id); + if (newStart === undefined) { + return; + } + const desiredTop = newStart - anchor.current.top; + if (Math.abs(desiredTop - el.scrollTop) > 0.5) { + el.scrollTop = desiredTop; + setScrollTop(desiredTop); + } + }, [measureVersion, layout, scrollContainerRef]); + + const measureRef = useCallback( + (key: string, auto: boolean) => (node: HTMLElement | null) => { + const existing = observers.current.get(key); + if (!node) { + if (existing) { + existing.ro.disconnect(); + observers.current.delete(key); + } + return; + } + // Fixed-height slots are never observed — they never pay measurement cost. + if (!auto) { + return; + } + if (existing && existing.node === node) { + return; + } + if (existing) { + existing.ro.disconnect(); + } + const ro = new ResizeObserver(() => { + const h = node.getBoundingClientRect().height; + const prev = measured.current.get(key); + if (prev === undefined || Math.abs(prev - h) > 0.5) { + measured.current.set(key, h); + setMeasureVersion(v => v + 1); + } + }); + ro.observe(node); + observers.current.set(key, { node, ro }); + }, + [] + ); + + useEffect(() => { + const map = observers.current; + return () => { + map.forEach(({ ro }) => ro.disconnect()); + map.clear(); + }; + }, []); + + const { + slots: windowedSlots, + firstIndex, + lastIndex, + } = useMemo(() => { + const { slots, offsets, totalSize } = layout; + if (slots.length === 0) { + return { slots: [] as PositionedSlot[], firstIndex: -1, lastIndex: -1 }; + } + const effectiveViewport = viewport || DEFAULT_VIEWPORT; + const firstVisible = findFloorIndex(offsets, scrollTop); + const lastVisible = findFloorIndex(offsets, scrollTop + effectiveViewport); + const startIdx = Math.max(0, firstVisible - overscan); + const endIdx = Math.min(slots.length - 1, lastVisible + overscan); + + const positioned: PositionedSlot[] = []; + let firstData = -1; + let lastData = -1; + for (let i = startIdx; i <= endIdx; i++) { + const start = offsets[i]; + const size = (i + 1 < offsets.length ? offsets[i + 1] : totalSize) - start; + positioned.push({ ...slots[i], start, size }); + if (slots[i].type === 'data') { + if (firstData === -1) { + firstData = slots[i].index; + } + lastData = slots[i].index; + } + } + return { slots: positioned, firstIndex: firstData, lastIndex: lastData }; + }, [layout, scrollTop, viewport, overscan]); + + const scrollToEnd = useCallback(() => { + const el = scrollContainerRef.current; + if (el) { + el.scrollTop = el.scrollHeight; + } + }, [scrollContainerRef]); + + const scrollToIndex = useCallback( + (index: number) => { + const el = scrollContainerRef.current; + if (!el || index < 0 || index >= items.length) { + return; + } + const id = trackBy(items[index]); + const start = layout.startById.get(id); + if (start !== undefined) { + el.scrollTop = start; + } + }, + [scrollContainerRef, items, trackBy, layout] + ); + + const isPinnedToEnd = useCallback(() => { + const el = scrollContainerRef.current; + if (!el) { + return false; + } + return el.scrollHeight - (el.scrollTop + el.clientHeight) <= 1; + }, [scrollContainerRef]); + + return { + slots: windowedSlots, + totalSize: layout.totalSize, + firstIndex, + lastIndex, + measureRef, + scrollToEnd, + scrollToIndex, + isPinnedToEnd, + }; +} From b3a8448922d956d1d8351a8e44ac5d8d26ab8337 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Tue, 21 Jul 2026 11:56:47 +0000 Subject: [PATCH 2/6] feat: VirtualTable (logs / F2-A1) P5 default styling matches Cloudscape Table Reuse Cloudscape Table design tokens for the default look (header surface + thead divider, column-header typography, per-row divider, sticky-header surface, selected-row style hook, single-line truncate = Table wrapLines=false with opt-in .cell-wrap, expanded region as detail panel). Native scrollbar only (no custom/overlay/synthetic scrollbar). No always-on row hover (Table has no default general row hover). --- src/virtual-table/styles.scss | 74 ++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 9 deletions(-) diff --git a/src/virtual-table/styles.scss b/src/virtual-table/styles.scss index 43ca176703..b6351794d1 100644 --- a/src/virtual-table/styles.scss +++ b/src/virtual-table/styles.scss @@ -6,11 +6,24 @@ @use '../internal/styles' as styles; @use '../internal/styles/tokens' as awsui; +// VirtualTable (F2-A1, logs-specialized) is styled to visually match the existing +// Cloudscape `Table` BY DEFAULT. Every colour / border / spacing value below is a +// Cloudscape design token REUSED from the Table component styles +// (src/table/header-cell/styles.scss and src/table/body-cell/styles.scss) rather +// than a bespoke value, so the look tracks Table + visual refresh automatically. +// The virtualization viewport (.scroll-container) scrolls with the NATIVE browser +// scrollbar — there is no custom / synthetic / overlay scrollbar anywhere. +// The logs-specialized affordances (toolbar / live-tail follow toggle / mark-in-place +// indicator column / match navigation / sortable header button) reuse the same Table +// tokens so they read as part of the same surface. + .root { @include styles.styles-reset; position: relative; display: block; inline-size: 100%; + // Sit on the container content surface, like a Table inside a Container. + background: awsui.$color-background-container-content; } .toolbar { @@ -27,6 +40,7 @@ .scroll-container { position: relative; + // Native scrollbar only — no ::-webkit-scrollbar / overlay / synthetic scrollbar. overflow: auto; inline-size: 100%; @@ -42,27 +56,36 @@ display: block; } +// Header treatment matches Table's thead: table-header surface + a default divider +// under the header row (Table: border-block-end divider-list-width solid +// color-border-divider-default). .header-row { display: flex; + 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: only pin the header row when the consumer sets stickyHeader. // The header stays inside the single scroll container so it scrolls horizontally with -// the body (header <-> body sync, CW-14) while pinning vertically. +// the body (header <-> body sync, CW-14) while pinning vertically. Its surface matches +// the non-sticky header (Table sticky-header look). .sticky-header { & > .header-rowgroup { position: sticky; inset-block-start: 0; z-index: 1; - background: awsui.$color-background-container-content; + background: awsui.$color-background-table-header; } } .header-cell { min-inline-size: 0; - font-weight: awsui.$font-weight-bold; + color: awsui.$color-text-column-header; + font-weight: awsui.$font-weight-heading-s; + line-height: awsui.$line-height-heading-xs; + // Table cell rhythm: dense vertical padding, space-scaled-l inline padding. padding-block: awsui.$space-scaled-xxs; - padding-inline: awsui.$space-scaled-xs; + padding-inline: awsui.$space-scaled-l; box-sizing: border-box; } @@ -75,8 +98,9 @@ justify-content: center; } -// The body is the scroll runway. In the scaffold it renders rows in normal flow; the -// core replaces this with absolutely-positioned windowed rows over a sized runway. +// The body is the scroll runway. The core sizes it to the full virtualized height and +// absolutely positions the windowed rows within it via inline styles (position/offset +// set in internal.tsx), so this class only provides the runway box. .body { display: block; position: relative; @@ -92,6 +116,18 @@ .row { align-items: center; + // Table's per-row divider: the border sits on the row (not the cell) so it always + // lands at the row boundary regardless of cell padding / fixed windowed row height. + border-block-end: awsui.$border-divider-list-width solid awsui.$color-border-divider-secondary; + + // Selected-row surface, matching Table's selected row (color-background-item-selected + // + selected border). F2-A1 has no selection prop today; this is an inert style hook + // so a marked row reads identically to Table if a consumer sets the class. + &.row-selected { + background: awsui.$color-background-item-selected; + border-block: awsui.$border-width-item-selected solid awsui.$color-border-item-selected; + } + &:focus-visible { outline: 2px solid awsui.$color-border-item-focused; outline-offset: -2px; @@ -107,14 +143,26 @@ .cell { min-inline-size: 0; + // Default: single line, truncate with an ellipsis. This matches Table's default + // (wrapLines = false). Wrapping is available via the cell-wrap hook on measured rows + // (getRowHeight: 'auto'), mirroring Table's opt-in wrapLines. overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding-block: awsui.$space-scaled-xxs; - padding-inline: awsui.$space-scaled-xs; + padding-inline: awsui.$space-scaled-l; box-sizing: border-box; } +// Wrapping affordance (Table wrapLines parity). Apply on cells in rows that opt into +// measured heights (getRowHeight: 'auto') so wrapped content is measured and windowed +// correctly — e.g. the raw/file line renderer. +.cell-wrap { + white-space: normal; + overflow-wrap: break-word; + text-overflow: clip; +} + .disclosure-button { @include styles.styles-reset; cursor: pointer; @@ -128,12 +176,17 @@ box-sizing: border-box; } +// The expanded region sits on the container content surface with a divider below, so an +// expanded row reads as a detail panel attached to its data row (Table expandable-rows +// look), while holding arbitrary non-tabular content (R-EXPAND shapes A/B). .expanded-region { display: block; inline-size: 100%; box-sizing: border-box; + background: awsui.$color-background-container-content; + border-block-end: awsui.$border-divider-list-width solid awsui.$color-border-divider-secondary; padding-block: awsui.$space-scaled-s; - padding-inline: awsui.$space-scaled-m; + padding-inline: awsui.$space-scaled-l; } .loading, @@ -169,10 +222,13 @@ background: awsui.$color-background-item-selected; } +// Sortable header trigger: reuse Table's column-header typography so a sortable header +// matches Table's look rather than a bespoke bold button. .sort-button { @include styles.styles-reset; cursor: pointer; - font-weight: awsui.$font-weight-bold; + color: awsui.$color-text-column-header; + font-weight: awsui.$font-weight-heading-s; inline-size: 100%; text-align: start; } From b50e10a4d62933671a175256426b7ade60a5010c Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Tue, 21 Jul 2026 13:46:10 +0000 Subject: [PATCH 3/6] test: register virtual-table required props + refresh documenter snapshot --- .../required-props-for-components.ts | 5 + .../__snapshots__/documenter.test.ts.snap | 1120 +++++++++++++++++ 2 files changed, 1125 insertions(+) diff --git a/src/__tests__/required-props-for-components.ts b/src/__tests__/required-props-for-components.ts index 3430c62d2d..0936aed235 100644 --- a/src/__tests__/required-props-for-components.ts +++ b/src/__tests__/required-props-for-components.ts @@ -6,6 +6,11 @@ export { defaultSplitPanelContextProps } from '../split-panel/__tests__/helpers' const defaultProps: Record> = { tabs: { tabs: [] }, table: { columnDefinitions: [] }, + 'virtual-table': { + items: [], + viewConfig: { type: 'standard', columnDefinitions: [] }, + trackBy: () => '', + }, cards: { cardDefinition: {} }, autosuggest: { options: [], enteredPrefix: '' }, 'anchor-navigation': { anchors: [] }, diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 65987a3dc6..e2accf8616 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -33703,6 +33703,845 @@ specified in the \`i18nStrings\` property.", } `; +exports[`Components definition for virtual-table matches the snapshot: virtual-table 1`] = ` +{ + "dashCaseName": "virtual-table", + "events": [ + { + "cancelable": false, + "description": "Fires when a row's expansion is toggled (keyboard or pointer).", + "detailInlineType": { + "name": "VirtualTableProps.ExpandChangeDetail", + "properties": [ + { + "name": "expanded", + "optional": false, + "type": "boolean", + }, + { + "name": "expandedItems", + "optional": false, + "type": "ReadonlyArray", + }, + { + "inlineType": { + "name": "NonNullable", + "type": "union", + "values": [ + "T", + "{}", + ], + }, + "name": "item", + "optional": false, + "type": "NonNullable", + }, + ], + "type": "object", + }, + "detailType": "VirtualTableProps.ExpandChangeDetail", + "name": "onExpandChange", + }, + { + "cancelable": false, + "description": "Fires when the follow state should change — on user scroll-away (false) or on +reaching bottom / pressing the follow control (true).", + "detailInlineType": { + "name": "VirtualTableProps.FollowChangeDetail", + "properties": [ + { + "name": "follow", + "optional": false, + "type": "boolean", + }, + { + "inlineType": { + "name": ""control" | "scroll-away" | "reached-bottom"", + "type": "union", + "values": [ + "control", + "scroll-away", + "reached-bottom", + ], + }, + "name": "reason", + "optional": false, + "type": "string", + }, + ], + "type": "object", + }, + "detailType": "VirtualTableProps.FollowChangeDetail", + "name": "onFollowChange", + }, + { + "cancelable": false, + "description": "Fires on sort control activation (keyboard-operable — not pointer-only).", + "detailInlineType": { + "name": "VirtualTableProps.SortingState", + "properties": [ + { + "inlineType": { + "name": "VirtualTableProps.ColumnDefinition", + "properties": [ + { + "inlineType": { + "name": "(item: T, context: VirtualTableProps.CellContext) => React.ReactNode", + "parameters": [ + { + "name": "item", + "type": "T", + }, + { + "name": "context", + "type": "VirtualTableProps.CellContext", + }, + ], + "returnType": "React.ReactNode", + "type": "function", + }, + "name": "cell", + "optional": false, + "type": "(item: T, context: VirtualTableProps.CellContext) => React.ReactNode", + }, + { + "name": "header", + "optional": true, + "type": "React.ReactNode", + }, + { + "name": "id", + "optional": false, + "type": "string", + }, + { + "name": "isStretch", + "optional": true, + "type": "boolean", + }, + { + "name": "minWidth", + "optional": true, + "type": "number", + }, + { + "inlineType": { + "name": "(a: T, b: T) => number", + "parameters": [ + { + "name": "a", + "type": "T", + }, + { + "name": "b", + "type": "T", + }, + ], + "returnType": "number", + "type": "function", + }, + "name": "sortingComparator", + "optional": true, + "type": "((a: T, b: T) => number)", + }, + { + "name": "sortingField", + "optional": true, + "type": "string", + }, + { + "name": "width", + "optional": true, + "type": "number", + }, + ], + "type": "object", + }, + "name": "sortingColumn", + "optional": false, + "type": "VirtualTableProps.ColumnDefinition", + }, + { + "name": "sortingDescending", + "optional": false, + "type": "boolean", + }, + ], + "type": "object", + }, + "detailType": "VirtualTableProps.SortingState", + "name": "onSortingChange", + }, + { + "cancelable": false, + "description": "Fires when the windowed visible range changes. Variable heights break a linear +scrollTop estimate, so the accurate dataset-relative range is surfaced here for +scroll-triggered prefetch. \`firstIndex\`/\`lastIndex\` index into \`items\`.", + "detailInlineType": { + "name": "VirtualTableProps.VisibleRangeDetail", + "properties": [ + { + "name": "firstIndex", + "optional": false, + "type": "number", + }, + { + "name": "lastIndex", + "optional": false, + "type": "number", + }, + ], + "type": "object", + }, + "detailType": "VirtualTableProps.VisibleRangeDetail", + "name": "onVisibleRangeChange", + }, + ], + "functions": [ + { + "description": "Whether the view is currently pinned to the last row.", + "name": "isPinnedToEnd", + "parameters": [], + "returnType": "boolean", + }, + { + "description": "Expand + highlight + scroll a row into view (CW-13 reveal). In \`mark-in-place\` +mode (or with no filter) the row is present, so it is expanded, highlighted, and +scrolled into view. If a \`subset\` filter currently hides the target row, reveal +is a no-op for that row — the console should clear or widen the filter first +(VirtualTable does not silently drop a consumer-owned filter).", + "name": "revealItem", + "parameters": [ + { + "name": "key", + "type": "string", + }, + ], + "returnType": "void", + }, + { + "description": "Scroll to and (optionally) pin to the newest row.", + "name": "scrollToEnd", + "parameters": [ + { + "name": "options", + "type": "{ pin?: boolean | undefined; }", + }, + ], + "returnType": "void", + }, + { + "description": "Scroll a row (by trackBy key) into view.", + "name": "scrollToItem", + "parameters": [ + { + "name": "key", + "type": "string", + }, + ], + "returnType": "void", + }, + ], + "name": "VirtualTable", + "properties": [ + { + "description": "Accessibility labels and the ARIA/announcement string set: the grid label, +per-view labels, the expand/collapse trigger label, the follow toggle label, +filter-count and append announcement templates, and loading/error text.", + "i18nTag": true, + "inlineType": { + "name": "VirtualTableProps.AriaLabels", + "properties": [ + { + "inlineType": { + "name": "(item: T) => string", + "parameters": [ + { + "name": "item", + "type": "T", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "collapseRowLabel", + "optional": true, + "type": "((item: T) => string)", + }, + { + "inlineType": { + "name": "(item: T) => string", + "parameters": [ + { + "name": "item", + "type": "T", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "expandRowLabel", + "optional": true, + "type": "((item: T) => string)", + }, + { + "inlineType": { + "name": "(item: T) => string", + "parameters": [ + { + "name": "item", + "type": "T", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "filterMatchLabel", + "optional": true, + "type": "((item: T) => string)", + }, + { + "name": "followToggleLabel", + "optional": true, + "type": "string", + }, + { + "name": "gridLabel", + "optional": false, + "type": "string", + }, + { + "name": "matchNavigationLabel", + "optional": true, + "type": "string", + }, + { + "name": "nextMatchLabel", + "optional": true, + "type": "string", + }, + { + "name": "previousMatchLabel", + "optional": true, + "type": "string", + }, + { + "inlineType": { + "name": "(view: VirtualTableProps.View) => string", + "parameters": [ + { + "name": "view", + "type": "VirtualTableProps.View", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "viewLabel", + "optional": true, + "type": "((view: VirtualTableProps.View) => string)", + }, + ], + "type": "object", + }, + "name": "ariaLabels", + "optional": true, + "type": "VirtualTableProps.AriaLabels", + }, + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "description": "Persisted per-column widths (by column id).", + "name": "columnWidths", + "optional": true, + "type": "Record", + }, + { + "description": "Uncontrolled initial set of expanded row keys.", + "name": "defaultExpandedItems", + "optional": true, + "type": "ReadonlyArray", + }, + { + "defaultValue": "23", + "description": "Estimated collapsed row height in px, used to size the scroll runway before a row +is measured. Defaults to the logs density; CloudWatch standard/patterns use 23 +(ROW_HEIGHT), raw uses 20 (RAW_LINE_HEIGHT).", + "name": "estimatedRowHeight", + "optional": true, + "type": "number", + }, + { + "description": "Selects a built-in expanded-content layout, used as the default under +\`getExpandedContent\`. "log-record" = shape A (~300 px key/value detail); +"pattern-detail" = shape B (~150 px histogram + tokens + actions). Returning +custom content from \`getExpandedContent\` overrides it for that row (override-seam).", + "inlineType": { + "name": "VirtualTableProps.ExpandedContentPreset", + "type": "union", + "values": [ + "log-record", + "pattern-detail", + ], + }, + "name": "expandedContentPreset", + "optional": true, + "type": "string", + }, + { + "description": "Controlled set of expanded row keys (trackBy values).", + "name": "expandedItems", + "optional": true, + "type": "ReadonlyArray", + }, + { + "description": "The two-mode logs filter (CW-10), packaged as a component feature with the console +supplying only the predicate (override-seam). VirtualTable owns the mechanism: +match-indicator column, per-cell highlight styling, total-vs-filtered counts, +keyboard next/previous-match, and a NON-VISUAL match conveyance (text/ARIA on the +indicator, not colour alone; WCAG 1.4.1). + - mode "subset" renders only matching rows (filtered count). + - mode "mark-in-place" renders all rows, marks matches in place + a match + indicator column, so context is preserved. +Omitting \`filter\` disables filtering entirely.", + "inlineType": { + "name": "VirtualTableProps.Filter", + "properties": [ + { + "inlineType": { + "name": "(item: T, columnId: string) => boolean", + "parameters": [ + { + "name": "item", + "type": "T", + }, + { + "name": "columnId", + "type": "string", + }, + ], + "returnType": "boolean", + "type": "function", + }, + "name": "highlight", + "optional": true, + "type": "((item: T, columnId: string) => boolean)", + }, + { + "inlineType": { + "name": ""subset" | "mark-in-place"", + "type": "union", + "values": [ + "subset", + "mark-in-place", + ], + }, + "name": "mode", + "optional": false, + "type": "string", + }, + { + "inlineType": { + "name": "(item: T) => boolean", + "parameters": [ + { + "name": "item", + "type": "T", + }, + ], + "returnType": "boolean", + "type": "function", + }, + "name": "predicate", + "optional": false, + "type": "(item: T) => boolean", + }, + ], + "type": "object", + }, + "name": "filter", + "optional": true, + "type": "VirtualTableProps.Filter", + }, + { + "defaultValue": "false", + "description": "Controlled follow (stick-to-bottom) state. When true, appended rows pin the +viewport to newest; when the user scrolls away VirtualTable calls +\`onFollowChange(false)\` and stops auto-scrolling. Controlled by design +(override-seam): the console owns follow policy, the component owns the +pin-to-newest mechanics, scroll-anchoring, and the accessible append announcement.", + "name": "follow", + "optional": true, + "type": "boolean", + }, + { + "description": "Renders arbitrary, non-tabular content for an expanded row. This is the R-EXPAND +SUBSTRATE (goal 6): whatever it returns is hosted in the measured expanded region +and inherits the full disclosure a11y + measurement contract. The two built-in +presets (shape A log-record detail, shape B pattern detail) are DEFAULTS layered +on this slot via \`expandedContentPreset\` — they are not a closed set. Presence of +this prop (or a preset) enables the leading disclosure column.", + "inlineType": { + "name": "(item: T, context: VirtualTableProps.RowContext) => React.ReactNode", + "parameters": [ + { + "name": "item", + "type": "T", + }, + { + "name": "context", + "type": "VirtualTableProps.RowContext", + }, + ], + "returnType": "React.ReactNode", + "type": "function", + }, + "name": "getExpandedContent", + "optional": true, + "type": "((item: T, context: VirtualTableProps.RowContext) => React.ReactNode)", + }, + { + "description": "Estimated expanded height in px per row, seeding the runway before the expanded +region is measured. Defaults follow the active preset (300 log-record, 150 +pattern-detail); the real height is always measured.", + "inlineType": { + "name": "(item: T) => number", + "parameters": [ + { + "name": "item", + "type": "T", + }, + ], + "returnType": "number", + "type": "function", + }, + "name": "getExpandedRowHeight", + "optional": true, + "type": "((item: T) => number)", + }, + { + "description": "Per-row height strategy. Return a fixed px height, or \`"auto"\` to opt that row +into measurement (ResizeObserver-backed, measure-on-first-window-entry with a +cached height and incremental offset correction). Omitting the prop means every +collapsed row is fixed at \`estimatedRowHeight\`; expanded rows are always measured.", + "inlineType": { + "name": "(item: T) => number | "auto"", + "parameters": [ + { + "name": "item", + "type": "T", + }, + ], + "returnType": "number | "auto"", + "type": "function", + }, + "name": "getRowHeight", + "optional": true, + "type": "((item: T) => number | "auto")", + }, + { + "inlineType": { + "name": "VirtualTableProps.I18nStrings", + "properties": [ + { + "inlineType": { + "name": "(count: number) => string", + "parameters": [ + { + "name": "count", + "type": "number", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "appendAnnouncementText", + "optional": true, + "type": "((count: number) => string)", + }, + { + "name": "expandedRegionLabel", + "optional": true, + "type": "string", + }, + { + "inlineType": { + "name": "(matched: number, total: number) => string", + "parameters": [ + { + "name": "matched", + "type": "number", + }, + { + "name": "total", + "type": "number", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "filterCountText", + "optional": true, + "type": "((matched: number, total: number) => string)", + }, + { + "name": "loadingText", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "i18nStrings", + "optional": true, + "type": "VirtualTableProps.I18nStrings", + }, + { + "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, +use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + { + "description": "Imperative handle for consumer-composed streaming controls the component does not +render (e.g. a "jump to newest" button in console chrome): scroll to newest, +scroll a specific row into view, reveal (expand + highlight) a row, and query +whether the view is pinned to the end.", + "inlineType": { + "name": "((instance: VirtualTableProps.Ref | null) => void) | React.RefObject", + "type": "union", + "values": [ + "(instance: VirtualTableProps.Ref | null) => void", + "React.RefObject", + ], + }, + "name": "imperativeRef", + "optional": true, + "type": "((instance: VirtualTableProps.Ref | null) => void) | React.RefObject", + }, + { + "description": "The full dataset for the current view. VirtualTable windows this to the visible +range; the array is never fully rendered. Appending to it is the streaming/live +path (see \`follow\`, \`onVisibleRangeChange\`, and the imperative ref).", + "name": "items", + "optional": false, + "type": "ReadonlyArray", + }, + { + "defaultValue": "false", + "description": "Loading state (initial fetch). SR-announced via i18nStrings.", + "name": "loading", + "optional": true, + "type": "boolean", + }, + { + "description": "Loading text rendered/announced while \`loading\` is true.", + "name": "loadingText", + "optional": true, + "type": "string", + }, + { + "description": "Rows rendered beyond the visible range on each side. CloudWatch uses 20 for the +grid views and 40 for the raw view (TABLE_VIEW_OVERSCAN / FILE_VIEW_OVERSCAN).", + "name": "overscan", + "optional": true, + "type": "number", + }, + { + "description": "Optional accessible summary for a batch of appended rows, announced politely and +rate-limited (~500 ms debounce, summarized) on the component-owned aria-live +region during tail. When omitted, a default count-based message is used from +\`i18nStrings\`.", + "inlineType": { + "name": "(detail: VirtualTableProps.AppendDetail) => string", + "parameters": [ + { + "name": "detail", + "type": "VirtualTableProps.AppendDetail", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "renderAppendAnnouncement", + "optional": true, + "type": "((detail: VirtualTableProps.AppendDetail) => string)", + }, + { + "defaultValue": "false", + "description": "Enables keyboard-operable column resize on the active view. VirtualTable owns a +fixed table layout with a stretch-last column; widths persist per view.", + "name": "resizableColumns", + "optional": true, + "type": "boolean", + }, + { + "description": "Column currently sorted by (from the active view's column set).", + "inlineType": { + "name": "VirtualTableProps.ColumnDefinition", + "properties": [ + { + "inlineType": { + "name": "(item: T, context: VirtualTableProps.CellContext) => React.ReactNode", + "parameters": [ + { + "name": "item", + "type": "T", + }, + { + "name": "context", + "type": "VirtualTableProps.CellContext", + }, + ], + "returnType": "React.ReactNode", + "type": "function", + }, + "name": "cell", + "optional": false, + "type": "(item: T, context: VirtualTableProps.CellContext) => React.ReactNode", + }, + { + "name": "header", + "optional": true, + "type": "React.ReactNode", + }, + { + "name": "id", + "optional": false, + "type": "string", + }, + { + "name": "isStretch", + "optional": true, + "type": "boolean", + }, + { + "name": "minWidth", + "optional": true, + "type": "number", + }, + { + "inlineType": { + "name": "(a: T, b: T) => number", + "parameters": [ + { + "name": "a", + "type": "T", + }, + { + "name": "b", + "type": "T", + }, + ], + "returnType": "number", + "type": "function", + }, + "name": "sortingComparator", + "optional": true, + "type": "((a: T, b: T) => number)", + }, + { + "name": "sortingField", + "optional": true, + "type": "string", + }, + { + "name": "width", + "optional": true, + "type": "number", + }, + ], + "type": "object", + }, + "name": "sortingColumn", + "optional": true, + "type": "VirtualTableProps.ColumnDefinition", + }, + { + "defaultValue": "false", + "description": "Sort direction.", + "name": "sortingDescending", + "optional": true, + "type": "boolean", + }, + { + "defaultValue": "true", + "description": "Sticky header with horizontal scroll synced to the body.", + "name": "stickyHeader", + "optional": true, + "type": "boolean", + }, + { + "description": "Stable identity per row. REQUIRED (unlike Table, where it is optional): row +virtualization recycles DOM nodes, so a stable key is mandatory for correct focus +retention, expansion state, and measurement caching across recycling and live +append.", + "inlineType": { + "name": "(item: T) => string", + "parameters": [ + { + "name": "item", + "type": "T", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "trackBy", + "optional": false, + "type": "(item: T) => string", + }, + { + "description": "Per-view configuration AND the single source of truth for which built-in surface +is active: the \`type\` discriminant selects the view, and the body supplies that +view's column set / renderers. This is F2's opinionation made explicit — +VirtualTable ships three surfaces rather than being a raw grid: + - "standard" the results grid: dynamic columns, leading disclosure column, + fixed layout with a stretch-last column, keyboard-operable column + resize with persisted widths (no per-column sort — sort is a + patterns-view capability, CW-9). + - "patterns" the aggregation grid: its own column set, per-column sort, + diff/compare mode, and a shared histogram y-scale computed across + the FULL dataset. + - "raw" the monospaced raw log-line view (single column, no expansion), at + RAW_LINE_HEIGHT density. +The console owns the tab/segmented control that swaps the config; VirtualTable +owns each view's grid, virtualization, a11y, and expansion. Kept as one object +(rather than a flat columnDefinitions array) so a console can hold all three view +configs and swap without re-shaping props.", + "inlineType": { + "name": "VirtualTableProps.ViewConfig", + "type": "union", + "values": [ + "VirtualTableProps.StandardViewConfig", + "VirtualTableProps.PatternsViewConfig", + "VirtualTableProps.RawViewConfig", + ], + }, + "name": "viewConfig", + "optional": false, + "type": "VirtualTableProps.ViewConfig", + }, + ], + "regions": [ + { + "description": "Rendered when \`items\` is empty for the active view.", + "isDefault": false, + "name": "empty", + }, + ], + "releaseStatus": "stable", +} +`; + exports[`Components definition for wizard matches the snapshot: wizard 1`] = ` { "dashCaseName": "wizard", @@ -46281,6 +47120,164 @@ Supported options: ], "name": "TutorialTaskWrapper", }, + { + "methods": [ + { + "name": "findColumnHeaders", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "Array", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + { + "description": "The labeled expanded region for a data row, or null if not expanded/windowed.", + "name": "findExpandedRegion", + "parameters": [ + { + "flags": { + "isOptional": false, + }, + "name": "dataIndex", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "The disclosure toggle button for a data row, or null if outside the window.", + "name": "findExpandToggle", + "parameters": [ + { + "flags": { + "isOptional": false, + }, + "name": "dataIndex", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "The live-tail follow toggle, or null when the live-tail seam is not wired.", + "name": "findFollowToggle", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "The header row element (returned regardless of the stickyHeader prop).", + "name": "findHeaderRow", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "The polite append aria-live node.", + "name": "findLiveRegion", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "The total-vs-filtered match-count region, or null when no filter is set. Rendered +for both filter modes (subset shows the filtered length, mark-in-place the match +count) under the \`match-count\` class.", + "name": "findMatchCount", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Returns null for rows outside the current window (by design). Locates by the row's +\`aria-rowindex\`, which is the full-dataset index offset by the header row: header +is \`aria-rowindex=1\`, so data row \`dataIndex\` is \`aria-rowindex=dataIndex+2\`.", + "name": "findRowByIndex", + "parameters": [ + { + "flags": { + "isOptional": false, + }, + "name": "dataIndex", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Rendered (windowed) rows only.", + "name": "findRows", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "Array", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + ], + "name": "VirtualTableWrapper", + }, { "methods": [ { @@ -55138,6 +56135,129 @@ Supported options: ], "name": "TutorialTaskWrapper", }, + { + "methods": [ + { + "name": "findColumnHeaders", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + { + "description": "The labeled expanded region for a data row, or null if not expanded/windowed.", + "name": "findExpandedRegion", + "parameters": [ + { + "flags": { + "isOptional": false, + }, + "name": "dataIndex", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "The disclosure toggle button for a data row, or null if outside the window.", + "name": "findExpandToggle", + "parameters": [ + { + "flags": { + "isOptional": false, + }, + "name": "dataIndex", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "The live-tail follow toggle, or null when the live-tail seam is not wired.", + "name": "findFollowToggle", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "The header row element (returned regardless of the stickyHeader prop).", + "name": "findHeaderRow", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "The polite append aria-live node.", + "name": "findLiveRegion", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "The total-vs-filtered match-count region, or null when no filter is set. Rendered +for both filter modes (subset shows the filtered length, mark-in-place the match +count) under the \`match-count\` class.", + "name": "findMatchCount", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Returns null for rows outside the current window (by design). Locates by the row's +\`aria-rowindex\`, which is the full-dataset index offset by the header row: header +is \`aria-rowindex=1\`, so data row \`dataIndex\` is \`aria-rowindex=dataIndex+2\`.", + "name": "findRowByIndex", + "parameters": [ + { + "flags": { + "isOptional": false, + }, + "name": "dataIndex", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Rendered (windowed) rows only.", + "name": "findRows", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + ], + "name": "VirtualTableWrapper", + }, { "methods": [ { From 439b4e46fcb9d8a7224bce509cedb152e59f8665 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Thu, 23 Jul 2026 07:19:46 +0000 Subject: [PATCH 4/6] =?UTF-8?q?fix(virtual-table):=20P6=20iteration=20?= =?UTF-8?q?=E2=80=94=20bounded-viewport=20windowing,=20fixed-row-height=20?= =?UTF-8?q?clamp,=20disclosure=20glyph,=20focus-gated=20active=20outline,?= =?UTF-8?q?=20Table=20density?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cloudwatch-patterns.page.tsx | 4 +- pages/virtual-table/cloudwatch-raw.page.tsx | 3 +- .../cloudwatch-standard.page.tsx | 4 +- src/virtual-table/USAGE.md | 5 +- .../__tests__/virtual-table-a11y.test.tsx | 30 +++++- .../__tests__/virtual-table.test.tsx | 29 ++++++ src/virtual-table/index.tsx | 4 +- src/virtual-table/interfaces.ts | 31 ++++--- src/virtual-table/internal.tsx | 93 +++++++++++++++---- src/virtual-table/styles.scss | 74 +++++++++++++-- 10 files changed, 231 insertions(+), 46 deletions(-) diff --git a/pages/virtual-table/cloudwatch-patterns.page.tsx b/pages/virtual-table/cloudwatch-patterns.page.tsx index e1e3b3921c..a33686efa5 100644 --- a/pages/virtual-table/cloudwatch-patterns.page.tsx +++ b/pages/virtual-table/cloudwatch-patterns.page.tsx @@ -391,15 +391,15 @@ export default function VirtualTableCloudWatchPatternsPage() { -
+
items={items} trackBy={item => item.id} viewConfig={{ type: 'patterns', columnDefinitions, histogramPeak, diffMode }} + height={480} estimatedRowHeight={23} overscan={20} stickyHeader={true} - resizableColumns={true} sortingColumn={sortingColumn} sortingDescending={sortingDescending} onSortingChange={event => handleSortingChange(event.detail)} diff --git a/pages/virtual-table/cloudwatch-raw.page.tsx b/pages/virtual-table/cloudwatch-raw.page.tsx index f5e76e4f43..c20614f6ff 100644 --- a/pages/virtual-table/cloudwatch-raw.page.tsx +++ b/pages/virtual-table/cloudwatch-raw.page.tsx @@ -39,10 +39,11 @@ export default function VirtualTableCloudWatchRawPage() { expansion. Long lines wrap and are measured; short lines stay fixed at the 20 px raw-line density.

-
+
item.id} + height={480} estimatedRowHeight={20} overscan={40} getRowHeight={item => (item.text.length > 120 ? 'auto' : 20)} diff --git a/pages/virtual-table/cloudwatch-standard.page.tsx b/pages/virtual-table/cloudwatch-standard.page.tsx index 30a21198ac..030c78f6b4 100644 --- a/pages/virtual-table/cloudwatch-standard.page.tsx +++ b/pages/virtual-table/cloudwatch-standard.page.tsx @@ -196,15 +196,15 @@ export default function VirtualTableCloudWatchStandardPage() {
-
+
item.id} viewConfig={{ type: 'standard', columnDefinitions }} + height={480} estimatedRowHeight={23} overscan={20} stickyHeader={true} - resizableColumns={true} follow={following} onFollowChange={event => setFollowing(event.detail.follow)} expandedContentPreset="log-record" diff --git a/src/virtual-table/USAGE.md b/src/virtual-table/USAGE.md index 0e3edad5c6..2992908735 100644 --- a/src/virtual-table/USAGE.md +++ b/src/virtual-table/USAGE.md @@ -267,7 +267,8 @@ The full typed API lives in `interfaces.ts` (`VirtualTableProps` and the (`"standard"`, `"patterns"`, or `"raw"`) selects the built-in surface and supplies that view's `columnDefinitions` (or `renderLine` for raw, and `histogramPeak` / `diffMode` for patterns). -- Virtualization: `estimatedRowHeight`, `getRowHeight` (fixed px or `"auto"` to +- Virtualization: `height` / `maxHeight` (bound the scroll viewport — required for + windowing), `estimatedRowHeight`, `getRowHeight` (fixed px or `"auto"` to measure), `overscan`, and `onVisibleRangeChange`. - Live tail: `follow` / `onFollowChange` (controlled follow state) and `renderAppendAnnouncement` for the batched new-row announcement. @@ -279,7 +280,7 @@ The full typed API lives in `interfaces.ts` (`VirtualTableProps` and the and an optional per-cell `highlight`. - Columns, sorting, sizing: `sortingColumn` / `sortingDescending` / `onSortingChange` (VirtualTable reflects sort state and emits intent; it doesn't - sort the data), `resizableColumns`, `columnWidths`, and `stickyHeader`. + sort the data), `columnWidths` (static per-column px widths), and `stickyHeader`. - State and labels: `empty`, `loading` / `loadingText`, `ariaLabels`, and `i18nStrings`. - Imperative surface: `imperativeRef` exposing `scrollToEnd`, `scrollToItem`, diff --git a/src/virtual-table/__tests__/virtual-table-a11y.test.tsx b/src/virtual-table/__tests__/virtual-table-a11y.test.tsx index 3e5bad3485..225425faee 100644 --- a/src/virtual-table/__tests__/virtual-table-a11y.test.tsx +++ b/src/virtual-table/__tests__/virtual-table-a11y.test.tsx @@ -226,11 +226,14 @@ describe('VirtualTable F2-A1 a11y', () => { }); describe('keyboard navigation (aria-activedescendant grid model)', () => { - test('the grid is a single always-present tab stop and seeds an active descendant', () => { + test('the grid is a single always-present tab stop and seeds an active descendant once focused', () => { const { container, wrapper } = renderTable(makeLogs(20)); const grid = getGrid(container); expect(grid.getAttribute('tabindex')).toBe('0'); const firstRow = wrapper.findRowByIndex(0)!.getElement(); + // F-ACTIVE: no active descendant is advertised until the grid actually holds focus. + expect(grid.getAttribute('aria-activedescendant')).toBeNull(); + fireEvent.focus(grid); expect(grid.getAttribute('aria-activedescendant')).toBe(firstRow.id); expect(document.getElementById(firstRow.id)).not.toBeNull(); }); @@ -238,6 +241,7 @@ describe('VirtualTable F2-A1 a11y', () => { test('Arrow/Home/End move the active row when the grid holds focus', () => { const { container, wrapper } = renderTable(makeLogs(20)); const grid = getGrid(container); + fireEvent.focus(grid); fireEvent.keyDown(grid, { key: 'ArrowDown' }); expect(grid.getAttribute('aria-activedescendant')).toBe(wrapper.findRowByIndex(1)!.getElement().id); @@ -252,6 +256,7 @@ describe('VirtualTable F2-A1 a11y', () => { test('does not hijack arrow keys originating inside the expanded region', () => { const { container, wrapper } = renderTable(makeLogs(20), { expandable: true, expandedItems: ['0'] }); const grid = getGrid(container); + fireEvent.focus(grid); const before = grid.getAttribute('aria-activedescendant'); const innerButton = wrapper.findExpandedRegion(0)!.getElement().querySelector('button')!; @@ -270,6 +275,7 @@ describe('VirtualTable F2-A1 a11y', () => { // as an explicit a11y contract note. const { container, wrapper } = renderTable(makeLogs(20)); const grid = getGrid(container); + fireEvent.focus(grid); fireEvent.keyDown(grid, { key: 'ArrowDown' }); const active = grid.getAttribute('aria-activedescendant'); @@ -280,6 +286,28 @@ describe('VirtualTable F2-A1 a11y', () => { fireEvent.keyDown(grid, { key: 'ArrowLeft' }); expect(grid.getAttribute('aria-activedescendant')).toBe(active); }); + + test('advertises no active descendant until the grid holds focus, and clears it on blur (F-ACTIVE)', () => { + const { container, wrapper } = renderTable(makeLogs(20)); + const grid = getGrid(container); + const firstRowId = wrapper.findRowByIndex(0)!.getElement().id; + // No active row is advertised (or painted) on initial render — nothing before interaction. + expect(grid.getAttribute('aria-activedescendant')).toBeNull(); + fireEvent.focus(grid); + expect(grid.getAttribute('aria-activedescendant')).toBe(firstRowId); + fireEvent.blur(grid); + expect(grid.getAttribute('aria-activedescendant')).toBeNull(); + }); + + test('focusing a descendant control does not advertise an active descendant on the grid', () => { + const { container, wrapper } = renderTable(makeLogs(20), { expandable: true }); + const grid = getGrid(container); + const toggle = wrapper.findExpandToggle(0)!.getElement(); + // focusin bubbles to the grid, but the target===currentTarget guard means a descendant + // control gaining focus must not activate the grid's active-row indication. + fireEvent.focus(toggle); + expect(grid.getAttribute('aria-activedescendant')).toBeNull(); + }); }); describe('two-mode filter accessibility', () => { diff --git a/src/virtual-table/__tests__/virtual-table.test.tsx b/src/virtual-table/__tests__/virtual-table.test.tsx index 0647452ded..16c45299e2 100644 --- a/src/virtual-table/__tests__/virtual-table.test.tsx +++ b/src/virtual-table/__tests__/virtual-table.test.tsx @@ -73,6 +73,35 @@ describe('VirtualTable (F2-A1)', () => { expect(wrapper.findRowByIndex(499)).toBeNull(); }); + test('does not re-fire onVisibleRangeChange in a loop when the consumer passes a fresh inline handler each render (F-LOOP regression)', () => { + // The handler identity changes every render AND it stores a new object in state (mirrors the + // standard dev page's `onVisibleRangeChange={e => setVisibleRange(e.detail)}`). Before the + // stable-callback fix this looped: the visible-range effect listed the handler in its deps, so + // fire -> setState -> re-render -> new handler identity -> effect re-runs -> fire..., throwing + // "Maximum update depth exceeded". The fix bounds the fire count to genuine range changes. + const fireCounts = { n: 0 }; + function Harness() { + const [, setRange] = React.useState<{ firstIndex: number; lastIndex: number } | null>(null); + return ( + log.id} + viewConfig={standardConfig} + ariaLabels={{ gridLabel: 'Log events' }} + onVisibleRangeChange={event => { + fireCounts.n += 1; + setRange(event.detail); + }} + /> + ); + } + render(); + // Fires for the initial window only (the window does not change without scrolling), not once + // per render. A loop would blow past this bound long before the test could assert. + expect(fireCounts.n).toBeGreaterThanOrEqual(1); + expect(fireCounts.n).toBeLessThanOrEqual(3); + }); + test('sets full-dataset aria-rowcount (header counted once) and aria-colcount', () => { const { grid } = renderTable({}, 500); expect(grid().getAttribute('aria-rowcount')).toBe('501'); diff --git a/src/virtual-table/index.tsx b/src/virtual-table/index.tsx index 256f4d722b..1e47a0e26b 100644 --- a/src/virtual-table/index.tsx +++ b/src/virtual-table/index.tsx @@ -18,7 +18,6 @@ export { VirtualTableProps }; export default function VirtualTable({ estimatedRowHeight = 23, follow = false, - resizableColumns = false, stickyHeader = true, sortingDescending = false, loading = false, @@ -26,13 +25,12 @@ export default function VirtualTable({ }: VirtualTableProps) { const overscan = props.overscan ?? (props.viewConfig.type === 'raw' ? 40 : 20); const baseComponentProps = useBaseComponent('VirtualTable', { - props: { view: props.viewConfig.type, estimatedRowHeight, overscan, follow, resizableColumns, stickyHeader }, + props: { view: props.viewConfig.type, estimatedRowHeight, overscan, follow, stickyHeader }, }); return ( extends BaseComponentProps { * view's column set / renderers. This is F2's opinionation made explicit — * VirtualTable ships three surfaces rather than being a raw grid: * - "standard" the results grid: dynamic columns, leading disclosure column, - * fixed layout with a stretch-last column, keyboard-operable column - * resize with persisted widths (no per-column sort — sort is a - * patterns-view capability, CW-9). + * fixed layout with a stretch-last column and static per-column + * widths (no per-column sort — sort is a patterns-view capability, + * CW-9). * - "patterns" the aggregation grid: its own column set, per-column sort, * diff/compare mode, and a shared histogram y-scale computed across * the FULL dataset. @@ -52,6 +52,22 @@ export interface VirtualTableProps extends BaseComponentProps { // --- Virtualization ------------------------------------------------------ + /** + * Bounds the scroll viewport to a fixed px block-size. Windowing REQUIRES a bounded + * viewport: without a `height`/`maxHeight` here (or a height-constrained parent, since + * the root is a flex column and the scroll container flexes to fill it) the scroll + * container reports the full content height and every row mounts. Use `height` for a + * fixed viewport that always scrolls. + */ + height?: number; + + /** + * Bounds the scroll viewport to a maximum px block-size: the table grows with its + * content up to this height, then windows + scrolls. Alternative to `height` for a + * grow-then-scroll viewport. See `height` for why a bound is required to window. + */ + maxHeight?: number; + /** * Estimated collapsed row height in px, used to size the scroll runway before a row * is measured. Defaults to the logs density; CloudWatch standard/patterns use 23 @@ -168,14 +184,7 @@ export interface VirtualTableProps extends BaseComponentProps { /** Fires on sort control activation (keyboard-operable — not pointer-only). */ onSortingChange?: NonCancelableEventHandler>; - /** - * Enables keyboard-operable column resize on the active view. VirtualTable owns a - * fixed table layout with a stretch-last column; widths persist per view. - * @defaultValue false - */ - resizableColumns?: boolean; - - /** Persisted per-column widths (by column id). */ + /** Static per-column widths in px (by column id); the stretch-last column omits it. */ columnWidths?: Record; /** Sticky header with horizontal scroll synced to the body. @defaultValue true */ diff --git a/src/virtual-table/internal.tsx b/src/virtual-table/internal.tsx index e2c625efec..f40508dfe6 100644 --- a/src/virtual-table/internal.tsx +++ b/src/virtual-table/internal.tsx @@ -3,8 +3,9 @@ import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import clsx from 'clsx'; -import { useUniqueId } from '@cloudscape-design/component-toolkit/internal'; +import { useStableCallback, useUniqueId } from '@cloudscape-design/component-toolkit/internal'; +import InternalIcon from '../icon/internal'; import { getBaseProps } from '../internal/base-component'; import { fireNonCancelableEvent } from '../internal/events'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; @@ -30,6 +31,8 @@ export default function InternalVirtualTable(props: InternalVirtualTableProps items, viewConfig, trackBy, + height, + maxHeight, estimatedRowHeight = 23, getRowHeight, getExpandedContent, @@ -103,13 +106,20 @@ export default function InternalVirtualTable(props: InternalVirtualTableProps scrollContainerRef, }); - // Fire the dataset-relative visible range for scroll-triggered prefetch (in an effect, - // never during render). + // Fire the dataset-relative visible range for scroll-triggered prefetch (in an effect, never + // during render). Held in a stable callback so a consumer passing a fresh inline handler each + // render cannot re-run this effect (which would loop: fire -> setState -> re-render -> new + // handler identity -> fire...). The effect depends only on the range indices. + const fireVisibleRangeChange = useStableCallback((firstIndex: number, lastIndex: number) => { + if (onVisibleRangeChange) { + fireNonCancelableEvent(onVisibleRangeChange, { firstIndex, lastIndex }); + } + }); useEffect(() => { - if (onVisibleRangeChange && model.firstIndex >= 0) { - fireNonCancelableEvent(onVisibleRangeChange, { firstIndex: model.firstIndex, lastIndex: model.lastIndex }); + if (model.firstIndex >= 0) { + fireVisibleRangeChange(model.firstIndex, model.lastIndex); } - }, [model.firstIndex, model.lastIndex, onVisibleRangeChange]); + }, [model.firstIndex, model.lastIndex, fireVisibleRangeChange]); // Built-in live tail: pin-to-newest on append while following; release on scroll-away. useLiveTail({ @@ -150,13 +160,19 @@ export default function InternalVirtualTable(props: InternalVirtualTableProps }, [visibleItems, trackBy]); const [activeId, setActiveId] = useState(undefined); + const [hasFocus, setHasFocus] = useState(false); const windowedDataIds = new Set( model.slots.filter(slot => slot.type === 'data').map(slot => trackBy(visibleItems[slot.index])) ); const effectiveActiveId = activeId && idToIndex.has(activeId) ? activeId : visibleItems.length ? trackBy(visibleItems[0]) : undefined; + // F-ACTIVE: gate the active-row indication on the grid actually holding focus. This single + // gate drives BOTH the visual .row-active outline and aria-activedescendant, so nothing is + // advertised or painted before any interaction (a seeded first row otherwise shows a ring on + // load). Keyboard nav still seeds from effectiveActiveId because it only fires while focused. + const gatedActiveId = hasFocus ? effectiveActiveId : undefined; const activeDescendant = - effectiveActiveId && windowedDataIds.has(effectiveActiveId) ? rowDomId(baseId, effectiveActiveId) : undefined; + gatedActiveId && windowedDataIds.has(gatedActiveId) ? rowDomId(baseId, gatedActiveId) : undefined; const moveActive = useCallback( (delta: number | 'first' | 'last') => { @@ -228,6 +244,9 @@ export default function InternalVirtualTable(props: InternalVirtualTableProps } setActiveId(trackBy(visibleItems[visibleIndex])); model.scrollToIndex(visibleIndex); + // Move keyboard focus to the grid so the revealed match becomes the active descendant + // (under the F-ACTIVE focus gate) and arrow keys continue from it. + scrollContainerRef.current?.focus(); }; useImperativeHandle( @@ -260,7 +279,7 @@ export default function InternalVirtualTable(props: InternalVirtualTableProps const showEmpty = !loading && visibleItems.length === 0; const regionLabel = (item: T) => i18nStrings?.expandedRegionLabel ?? ariaLabels?.expandRowLabel?.(item); - const renderDataRow = (index: number, start: number, auto: boolean) => { + const renderDataRow = (index: number, start: number, size: number, auto: boolean) => { const item = visibleItems[index]; const id = trackBy(item); const expanded = expansion.isExpanded(id); @@ -278,11 +297,19 @@ export default function InternalVirtualTable(props: InternalVirtualTableProps
{hasDisclosureColumn && ( @@ -294,7 +321,11 @@ export default function InternalVirtualTable(props: InternalVirtualTableProps aria-controls={expanded ? regionId : undefined} aria-label={expanded ? ariaLabels?.collapseRowLabel?.(item) : ariaLabels?.expandRowLabel?.(item)} onClick={() => expansion.toggle(item)} - /> + > + {/* F-DISC: decorative caret so the expand control is visible; the button keeps + its accessible name from aria-label (the icon is inert). */} + + )} {hasIndicatorColumn && ( @@ -337,7 +368,7 @@ export default function InternalVirtualTable(props: InternalVirtualTableProps ); }; - const renderExpandedRow = (index: number, start: number, auto: boolean) => { + const renderExpandedRow = (index: number, start: number, size: number, auto: boolean) => { const item = visibleItems[index]; const id = trackBy(item); const toggleId = `${baseId}-toggle-${id}`; @@ -349,7 +380,14 @@ export default function InternalVirtualTable(props: InternalVirtualTableProps role="row" aria-rowindex={index + 2} ref={model.measureRef('e:' + id, auto)} - style={{ position: 'absolute', insetBlockStart: start, insetInlineStart: 0, inlineSize: '100%' }} + style={{ + position: 'absolute', + insetBlockStart: start, + insetInlineStart: 0, + inlineSize: '100%', + // F-ROWH: a fixed-height expanded region clamps to its slot; measured regions stay auto. + blockSize: auto ? undefined : size, + }} > {/* Full-width gridcell keeps a valid grid child model; the arbitrary, non-tabular content lives in a labeled region inside it (design B2). A div (not a span) @@ -399,6 +437,7 @@ export default function InternalVirtualTable(props: InternalVirtualTableProps
) : ( column.header @@ -472,8 +533,8 @@ export default function InternalVirtualTable(props: InternalVirtualTableProps
{model.slots.map(slot => slot.type === 'data' - ? renderDataRow(slot.index, slot.start, slot.auto) - : renderExpandedRow(slot.index, slot.start, slot.auto) + ? renderDataRow(slot.index, slot.start, slot.size, slot.auto) + : renderExpandedRow(slot.index, slot.start, slot.size, slot.auto) )}
)} @@ -485,7 +546,7 @@ export default function InternalVirtualTable(props: InternalVirtualTableProps
)} - {showEmpty &&
{empty}
} + {showEmpty && empty !== undefined && empty !== null &&
{empty}
}
{appendMessage} diff --git a/src/virtual-table/styles.scss b/src/virtual-table/styles.scss index b6351794d1..369e963a5a 100644 --- a/src/virtual-table/styles.scss +++ b/src/virtual-table/styles.scss @@ -20,7 +20,10 @@ .root { @include styles.styles-reset; position: relative; - display: block; + // F-WINDOW: a height-owning flex column so the scroll container can flex to a bounded + // viewport (from height/maxHeight OR a height-constrained parent) and actually window. + 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; @@ -36,16 +39,30 @@ .follow-toggle { @include styles.styles-reset; cursor: pointer; + padding-inline: awsui.$space-scaled-xxs; + // B5: a visible pressed/active state, not aria-pressed alone. + &[aria-pressed='true'] { + background: awsui.$color-background-item-selected; + font-weight: awsui.$font-weight-heading-s; + } } .scroll-container { position: relative; + // F-WINDOW: fill a bounded root so clientHeight is the viewport, not the full content + // height; min-block-size:0 lets it shrink inside the flex column. A direct height/maxHeight + // (applied inline from the props) bounds it equivalently. + flex: 1 1 auto; + min-block-size: 0; // Native scrollbar only — no ::-webkit-scrollbar / overlay / synthetic scrollbar. overflow: auto; inline-size: 100%; - // The scroll container is the single tab stop (active-descendant keyboard model), so - // it carries the focus ring; the active row is indicated via .row-active. + // The scroll container is the single tab stop (active-descendant keyboard model), so it + // carries the focus ring; the active row is indicated via .row-active. The 2px/-2px inset + // (colour is tokenised) is deliberate over styles.focus-highlight: the mixin forces + // position:relative + a ::before ring, which an overflow:auto container clips and an + // absolutely-positioned windowed row cannot host. &:focus-visible { outline: 2px solid awsui.$color-border-item-focused; outline-offset: -2px; @@ -73,7 +90,9 @@ & > .header-rowgroup { position: sticky; inset-block-start: 0; - z-index: 1; + // Table sticky-header stacking convention (798–800 band); a bare literal because the + // reused token set has no dedicated sticky-band token. + z-index: 800; background: awsui.$color-background-table-header; } } @@ -135,7 +154,10 @@ } // Active descendant highlight: the container holds the tab stop, this row is the -// aria-activedescendant target moved by the arrow keys. +// aria-activedescendant target moved by the arrow keys. Applied (via .row-active in +// internal.tsx) only while the grid holds focus, so no ring paints on load (F-ACTIVE). +// The 2px/-2px inset (tokenised colour) is deliberate over styles.focus-highlight, which +// would force position:relative and break the row's required position:absolute windowing. .row-active { outline: 2px solid awsui.$color-border-item-focused; outline-offset: -2px; @@ -149,7 +171,9 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - padding-block: awsui.$space-scaled-xxs; + // Match Table body-cell vertical density ($space-scaled-xs). Only .cell changes; the + // header cell keeps $space-scaled-xxs, which already matches Table's header-cell. + padding-block: awsui.$space-scaled-xs; padding-inline: awsui.$space-scaled-l; box-sizing: border-box; } @@ -195,6 +219,11 @@ text-align: center; } +.empty { + // Muted empty-state text, matching Table's empty slot. + color: awsui.$color-text-empty; +} + // Materialised mark-in-place match-indicator column (INDICATOR_COL_WIDTH=25). Present // only while highlighting; counted as a real columnheader so aria-colindex stays coherent. .indicator-header, @@ -222,10 +251,17 @@ background: awsui.$color-background-item-selected; } -// Sortable header trigger: reuse Table's column-header typography so a sortable header -// matches Table's look rather than a bespoke bold button. +// Sortable header trigger: strip the native + isExpanded={expanded} + onExpandableItemToggle={() => expansion.toggle(item)} + expandButtonLabel={ariaLabels?.expandRowLabel?.(item)} + collapseButtonLabel={ariaLabels?.collapseRowLabel?.(item)} + id={toggleId} + ariaControls={expanded ? regionId : undefined} + /> )} {hasIndicatorColumn && ( - + {isFilterMatch && ( // Non-visual match conveyance (WCAG 1.4.1): a visually-hidden text label // carries the match to assistive tech; the visible marker is decorative. @@ -358,7 +465,6 @@ export default function InternalVirtualTable(props: InternalVirtualTableProps className={clsx(styles.cell, filterModel.isCellHighlighted(item, column.id) && styles['cell-highlight'])} role="gridcell" aria-colindex={dataColumnStart + columnIndex} - style={cellStyle(column, columnWidths, column.id === stretchColumnId)} > {column.cell(item, context)} @@ -385,6 +491,7 @@ export default function InternalVirtualTable(props: InternalVirtualTableProps insetBlockStart: start, insetInlineStart: 0, inlineSize: '100%', + gridTemplateColumns, // F-ROWH: a fixed-height expanded region clamps to its slot; measured regions stay auto. blockSize: auto ? undefined : size, }} @@ -482,17 +589,12 @@ export default function InternalVirtualTable(props: InternalVirtualTableProps }} >
-
+
{hasDisclosureColumn && ( )} {hasIndicatorColumn && ( - + )} {isRaw ? ( @@ -506,7 +608,7 @@ export default function InternalVirtualTable(props: InternalVirtualTableProps role="columnheader" aria-colindex={dataColumnStart + columnIndex} aria-sort={sort} - style={cellStyle(column, columnWidths, column.id === stretchColumnId)} + ref={node => registerHeaderCell(column.id, node)} > {sort ? (