From 21ee74d24cc632c93c4d3225f0ea2cee220628cc Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Sat, 18 Jul 2026 00:29:19 +0000 Subject: [PATCH] feat: add VirtualTable component (virtualization-first, config-driven) Add a new additive component that coexists with Table, aimed at large and streaming datasets: - First-class row virtualization with a single owned scroll container, opt-in per-row measurement, and incremental scroll-offset correction. - Arbitrary non-tabular row expansion (render slot) with measured variable expanded-row heights and a valid grid-child disclosure a11y model. - Full-dataset ARIA grid contract (aria-rowcount/rowindex, aria-colcount/ colindex incl. a materialised disclosure column) under windowing. - Config-driven props API mirroring Table where it transfers. - CloudWatch Logs Insights demo pages (standard, patterns, raw/file views), unit + a11y tests, usage docs, and a test-utils wrapper. --- build-tools/utils/pluralize.js | 1 + .../cloudwatch-patterns.page.tsx | 399 ++++++++++++++++++ pages/virtual-table/cloudwatch-raw.page.tsx | 80 ++++ .../cloudwatch-standard.page.tsx | 211 +++++++++ pages/virtual-table/simple.page.tsx | 43 ++ src/test-utils/dom/virtual-table/index.ts | 55 +++ src/virtual-table/USAGE.md | 220 ++++++++++ .../__tests__/use-expansion.test.tsx | 70 +++ .../__tests__/use-virtual-model.test.tsx | 185 ++++++++ .../__tests__/virtual-table-a11y.test.tsx | 338 +++++++++++++++ .../__tests__/virtual-table.test.tsx | 128 ++++++ src/virtual-table/index.tsx | 41 ++ src/virtual-table/interfaces.ts | 219 ++++++++++ src/virtual-table/internal.tsx | 381 +++++++++++++++++ src/virtual-table/styles.scss | 127 ++++++ src/virtual-table/use-expansion.ts | 67 +++ src/virtual-table/use-live-announcement.ts | 58 +++ src/virtual-table/use-virtual-model.ts | 312 ++++++++++++++ 18 files changed, 2935 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-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-live-announcement.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..77277ba25a --- /dev/null +++ b/pages/virtual-table/cloudwatch-patterns.page.tsx @@ -0,0 +1,399 @@ +// 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 F1-A1 config-driven API. +// 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) +// and column resize / persisted widths. +// - CW-11 compare/diff mode as a columnDefinitions swap: a different column set +// (difference count, difference description) with its own sort. +// - Shared cross-row histogram scale computed ONCE consumer-side over the full +// dataset (computeGlobalHistogramPeak) and closed over by the frequency +// cell, so the y-scale is stable across scroll. The F1-A1 API keeps this +// consumer-side (CellContext exposes only rowIndex/totalItemCount/ +// isExpanded, never the windowed slice), matching 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). +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. +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 CloudWatchPatternsPage() { + 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, closed over by the frequency cell (F1-A1 keeps this consumer-side). + const histogramPeak = useMemo(() => computeGlobalHistogramPeak(basePatterns), [basePatterns]); + + // VirtualTable reflects sort state and emits intent; it does not sort data. The + // consumer 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 closes over the shared peak. + 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 cross-row peak via closure, not from cell context. The + // sparkline carries a visually-hidden summary so the cell is not silent (B1). + cell: item => ( + + ), + 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. Selecting it + // is a plain columnDefinitions swap — no diff-specific component API. + 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.SortingDetail) => { + 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)

+

+ {items.length.toLocaleString()} patterns · {diffMode ? 'compare / diff mode' : 'normal mode'} +

+
+ +
+ +
+ + items={items} + trackBy={item => item.id} + columnDefinitions={columnDefinitions} + estimatedRowHeight={23} + overscan={20} + stickyHeader={true} + resizableColumns={true} + sortingColumn={sortingColumn} + sortingDescending={sortingDescending} + onSortingChange={({ detail }) => handleSortingChange(detail)} + getExpandedContent={item => } + getExpandedRowHeight={() => 'auto'} + expandedItems={expandedItems} + onExpandChange={({ detail }) => setExpandedItems(detail.expandedItems)} + ariaLabels={{ + tableLabel: 'CloudWatch log patterns', + expandButtonLabel: (item, expanded) => `${expanded ? 'Collapse' : 'Expand'} pattern: ${item.pattern}`, + expandedRegionLabel: item => `Pattern detail for ${item.pattern}`, + activateSortLabel: column => `Sort by ${String(column.header)}`, + }} + /> +
+
+ + ); +} diff --git a/pages/virtual-table/cloudwatch-raw.page.tsx b/pages/virtual-table/cloudwatch-raw.page.tsx new file mode 100644 index 0000000000..2c3e12791d --- /dev/null +++ b/pages/virtual-table/cloudwatch-raw.page.tsx @@ -0,0 +1,80 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useMemo, useState } from 'react'; + +import VirtualTable, { VirtualTableProps } from '~components/virtual-table'; + +import ScreenshotArea from '../utils/screenshot-area'; + +// CloudWatch file / raw view — the third required surface (CW-15). +// This is the SAME F1-A1 API reduced to its simplest shape: +// - a single column rendering the raw log line, +// - no getExpandedContent (expansion off, so no disclosure column), +// - estimatedRowHeight = 20 (RAW_LINE_HEIGHT), overscan = 40 (FILE_VIEW_OVERSCAN), +// - getRowHeight returns 'auto' only for wrapping candidates (long lines span +// more than one 20 px line box); short lines keep the fixed 20 px baseline so +// the observer is not attached to every windowed line (NB5). +// Demonstrates that all three CloudWatch surfaces are reachable from one API +// without a specialized "raw" mode in the component. + +interface RawLine { + id: string; + text: string; +} + +const RAW_LINE_HEIGHT = 20; +const FILE_VIEW_OVERSCAN = 40; +const LINE_COUNT = 5000; + +function makeLine(index: number): RawLine { + // Every ~7th line is long enough to wrap, exercising measured variable heights. + const long = index % 7 === 0; + const base = `2024-06-01T12:00:${String(index % 60).padStart(2, '0')}.123Z [INFO] request ${index} completed`; + return { + id: `line-${index}`, + text: long + ? `${base} — payload=${'x'.repeat(220)} (truncation off, wraps across multiple ${RAW_LINE_HEIGHT}px line boxes)` + : base, + }; +} + +export default function CloudWatchRawPage() { + const [items] = useState(() => Array.from({ length: LINE_COUNT }, (_, index) => makeLine(index))); + + const columnDefinitions = useMemo>>( + () => [ + { + id: 'line', + header: 'Log events', + cell: item => ( + + {item.text} + + ), + isStretch: true, + }, + ], + [] + ); + + return ( + <> +

VirtualTable — CloudWatch file / raw view

+

{items.length.toLocaleString()} raw log lines · wrapping, expansion off

+ +
+ + items={items} + trackBy={item => item.id} + columnDefinitions={columnDefinitions} + estimatedRowHeight={RAW_LINE_HEIGHT} + getRowHeight={item => (item.text.length > 120 ? 'auto' : RAW_LINE_HEIGHT)} + overscan={FILE_VIEW_OVERSCAN} + stickyHeader={true} + ariaLabels={{ tableLabel: '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..1d6eb43110 --- /dev/null +++ b/pages/virtual-table/cloudwatch-standard.page.tsx @@ -0,0 +1,211 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useCallback, useEffect, useLayoutEffect, 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 F1-A1 config-driven API. +// Demonstrates (see research/cloudwatch-requirements.md): +// - CW-8 dynamic columns from arbitrary fields + leading disclosure column, +// fixed layout with stretch-last (@message). +// - CW-12 accurate visible-range reporting under variable heights. +// - CW-13 scroll-to-and-reveal a specific row (expand + scroll). +// - CW-7 consumer-composed live tail (pin to newest, release on manual scroll) +// built on the imperative ref — the component owns no follow policy. +// - R-EXPAND shape A: an expanded row renders the full log record as arbitrary, +// non-tabular key/value detail (NOT the column set), measured (~300 px). +// Density is preserved: 23 px collapsed rows, ~300 px expanded estimate, overscan 20. + +const LOG_LEVELS = ['INFO', 'WARN', 'ERROR', 'DEBUG'] as const; +type LogLevel = (typeof LOG_LEVELS)[number]; + +interface LogRecord { + id: string; + timestamp: number; + level: LogLevel; + message: string; + // Arbitrary discovered fields — the standard view surfaces a subset as columns + // and the full record in the expanded detail. + fields: Record; +} + +const REQUEST_IDS = ['a1b2c3d4', 'e5f6a7b8', 'c9d0e1f2', '3a4b5c6d']; + +function makeRecord(index: number): LogRecord { + const level = LOG_LEVELS[index % LOG_LEVELS.length]; + const requestId = REQUEST_IDS[index % REQUEST_IDS.length]; + return { + id: `log-${index}`, + timestamp: 1_700_000_000_000 + index * 137, + level, + message: + level === 'ERROR' + ? `Handler failed: DynamoDB throttled after 3 retries (requestId=${requestId})` + : `Processed event ${index} for requestId=${requestId} in ${8 + (index % 40)}ms`, + fields: { + '@timestamp': new Date(1_700_000_000_000 + index * 137).toISOString(), + '@logStream': `2024/06/01/[$LATEST]${requestId}`, + '@requestId': requestId, + level, + durationMs: String(8 + (index % 40)), + billedMs: String(10 + (index % 40)), + memoryUsedMB: String(64 + (index % 128)), + }, + }; +} + +const INITIAL_COUNT = 2000; + +// CW-8: the standard view surfaces a consumer-chosen subset of discovered fields +// as columns; the set is dynamic (derived below), not a fixed three. +const DISPLAYED_FIELDS = ['@timestamp', 'level', '@requestId', 'durationMs']; + +// Arbitrary, non-tabular expanded content (R-EXPAND shape A): a key/value detail +// list plus the raw JSON record. Deliberately not the column layout. +function LogRecordDetail({ record }: { record: LogRecord }) { + const entries = Object.entries(record.fields); + return ( +
+
+ {entries.map(([key, value]) => ( + + {key} + {value} + + ))} +
+
+ Raw record (JSON) +
+          {JSON.stringify({ id: record.id, message: record.message, ...record.fields }, null, 2)}
+        
+
+
+ ); +} + +export default function CloudWatchStandardPage() { + const [items, setItems] = useState(() => + Array.from({ length: INITIAL_COUNT }, (_, index) => makeRecord(index)) + ); + const [expandedItems, setExpandedItems] = useState>([]); + const [following, setFollowing] = useState(true); + const [visibleRange, setVisibleRange] = useState({ firstIndex: 0, lastIndex: 0 }); + + const ref = useRef(null); + const nextIndexRef = useRef(INITIAL_COUNT); + // Pinned state is captured BEFORE each append so the follow decision does not + // depend on the core's "at bottom edge" tolerance after the runway grows (B1). + const wasPinnedRef = useRef(true); + + // CW-8: columns are derived dynamically from the discovered result fields the + // consumer chose to display — not a fixed set. The leading disclosure column is + // materialised by the component; @message is the single stretch-last column. + // No sortingField: per-column sort is a patterns-view capability (CW-9); the + // standard view has no per-column sort. + const columnDefinitions = useMemo>>( + () => [ + ...DISPLAYED_FIELDS.map(field => ({ + id: field, + header: field, + cell: (item: LogRecord) => {item.fields[field]}, + width: field === '@timestamp' ? 210 : 120, + })), + { + id: '@message', + header: '@message', + cell: (item: LogRecord) => {item.message}, + isStretch: true, + }, + ], + [] + ); + + // Live tail (CW-7): the component exposes only mechanism (scrollToEnd / + // isPinnedToEnd); the follow *policy* lives here. + useEffect(() => { + if (!following) { + return; + } + const timer = setInterval(() => { + // Capture whether we were pinned BEFORE the append grows the runway, so the + // decision is independent of any core scroll tolerance (B1). Generate the + // batch and its ids OUTSIDE the state updater so the updater stays pure + // under React 18 StrictMode double-invoke (B2). + wasPinnedRef.current = ref.current?.isPinnedToEnd() !== false; + 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); + }, [following]); + + // Re-pin to the newest row after the appended rows have laid out, but only if + // the viewport was pinned at capture time. A manual scroll away makes the next + // tick capture wasPinnedRef=false, naturally releasing follow. + useLayoutEffect(() => { + if (following && wasPinnedRef.current) { + ref.current?.scrollToEnd(); + } + }, [items, following]); + + const handleExpandChange = useCallback( + (detail: VirtualTableProps.ExpandChangeDetail) => setExpandedItems(detail.expandedItems), + [] + ); + + // CW-13: reveal (expand + scroll) an arbitrary row by id. + const revealFirstError = useCallback(() => { + const target = items.find(item => item.level === 'ERROR'); + if (target) { + ref.current?.scrollToItem(target.id, { reveal: true }); + } + }, [items]); + + return ( + <> +

VirtualTable — CloudWatch Logs Insights (standard view)

+

+ {items.length.toLocaleString()} log records · showing rows {visibleRange.firstIndex}–{visibleRange.lastIndex} · + live tail {following ? 'on' : 'paused'} +

+
+ + +
+ +
+ + items={items} + trackBy={item => item.id} + columnDefinitions={columnDefinitions} + estimatedRowHeight={23} + overscan={20} + stickyHeader={true} + resizableColumns={true} + getExpandedContent={item => } + getExpandedRowHeight={() => 'auto'} + expandedItems={expandedItems} + onExpandChange={({ detail }) => handleExpandChange(detail)} + onVisibleRangeChange={({ detail }) => setVisibleRange(detail)} + imperativeRef={ref} + ariaLabels={{ + tableLabel: 'CloudWatch log records', + expandButtonLabel: (item, expanded) => + `${expanded ? 'Collapse' : 'Expand'} record ${item.fields['@requestId']}`, + expandedRegionLabel: item => `Log record detail for ${item.fields['@requestId']}`, + appendAnnouncement: ({ addedCount, totalCount }) => `${addedCount} new log records, ${totalCount} total`, + }} + /> +
+
+ + ); +} diff --git a/pages/virtual-table/simple.page.tsx b/pages/virtual-table/simple.page.tsx new file mode 100644 index 0000000000..fc3779714d --- /dev/null +++ b/pages/virtual-table/simple.page.tsx @@ -0,0 +1,43 @@ +// 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 Item { + id: string; + name: string; + status: string; +} + +const items: Item[] = Array.from({ length: 25 }, (_, index) => ({ + id: `row-${index}`, + name: `Resource ${index}`, + status: index % 2 === 0 ? 'Available' : 'Pending', +})); + +export default function VirtualTableSimplePage() { + return ( + <> +

VirtualTable — scaffold

+

+ Config-driven generic VirtualTable (cell F1-A1). This dev page exercises the scaffolded shell; virtualization, + measurement, and row expansion land in the core implementation unit. +

+ + item.id} + estimatedRowHeight={23} + columnDefinitions={[ + { id: 'name', header: 'Name', cell: item => item.name, isStretch: true }, + { id: 'status', header: 'Status', cell: item => item.status }, + ]} + ariaLabels={{ tableLabel: 'Resources' }} + /> + + + ); +} 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..886a95e119 --- /dev/null +++ b/src/test-utils/dom/virtual-table/index.ts @@ -0,0 +1,55 @@ +// 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`. + * Indexing this way (not by position in the windowed array) is correct under + * virtualization. + */ + 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 the row is 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. Renamed from `findStickyHeader` — it returns the header + * row regardless of the `stickyHeader` prop, so it must not imply sticky state. + */ + findHeaderRow(): ElementWrapper | null { + return this.findByClassName(styles['header-row']); + } + + /** 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..d862343185 --- /dev/null +++ b/src/virtual-table/USAGE.md @@ -0,0 +1,220 @@ + + + +# VirtualTable usage + +VirtualTable is a virtualization-first table for large and streaming datasets. It +renders only the rows in view, so it stays responsive with tens of thousands of +rows and with data that appends over time, such as a live log stream. It's a new, +additive component that coexists with Table — reach for it when the dataset is +large, streaming, or needs rows that expand into content laid out differently from +the columns. + +VirtualTable is a config-driven component: you describe the columns, the data, and +the optional expanded content, and it handles windowing, measurement, keyboard +interaction, and announcements for you. + +## When to use + +- Use VirtualTable when the dataset is large enough that rendering every row hurts + responsiveness, or when rows arrive continuously and the view needs to keep up. +- Use VirtualTable when rows expand into detail content that doesn't match the + column layout — for example a key-value record, a raw or formatted log line, or a + small chart with actions. +- 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 surface and doesn't offer + those. + +## General guidelines + +### Do + +Layout and columns + +- Set the estimated row height to match your content density so the scrollbar is + accurate before rows are measured. +- Give one column the room to stretch so the table fills its container, and keep + the other columns sized to their content. +- Turn on column resizing when users need to see long values, and persist the + widths users choose so their layout survives a reload. +- Keep the number of columns 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: + key-value detail, a raw record, a small visualization, or nested panels. +- 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 panels of different sizes all scroll smoothly. + +Large and streaming data + +- Append new rows to the end of the dataset for the live-tail case, and compose + stick-to-bottom following on top of the scroll-anchoring controls so the view + releases the moment a user scrolls up to read. +- 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. +- 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 tables 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 table name, the + expand and collapse control label, and the expanded region name. These names are + part of the component's accessibility contract; without them the table, 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 ranking from the visible rows — + it will jump as the user scrolls. Compute it over the full dataset. +- 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 dataset — a + fast-appending stream is usually left unsorted. +- Don't leave the table, its expand controls, or its expanded regions 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. The +expanded region is a first-class part of the table'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 and streaming + +Appending rows to the dataset is the streaming path. VirtualTable reports the +visible range as it changes and exposes controls to pin the view to the newest row +and to check whether it's currently pinned, so you can build stick-to-bottom +following that releases when the user scrolls up. New-row announcements are batched +and summarized so a fast stream doesn't flood assistive technology. + +### Columns, sorting, and resizing + +Columns are defined once and can be resized, with widths reported so you can +persist them. VirtualTable reflects sort state and reports the user's sort intent, +but it doesn't reorder the data itself — 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 table content so it scans quickly. Keep it short, consistent, and specific +to the row. + +### Table name + +- Give the table 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*. + +### 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. + +### 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-F1-A1.md`. In summary: + +- Data and identity: `items`, `columnDefinitions`, and a required `trackBy` — a + stable key is mandatory because row DOM is recycled. +- Virtualization: `estimatedRowHeight`, `getRowHeight` (fixed px or `"auto"` to + measure), `overscan`, and `onVisibleRangeChange`. +- Row expansion: `getExpandedContent` (arbitrary non-tabular content, enables the + leading disclosure column), `expandedItems` / `defaultExpandedItems` / + `onExpandChange`, and `getExpandedRowHeight`. +- Columns, sorting, sizing: `resizableColumns`, `columnWidths` / + `onColumnWidthsChange`, `columnLayout`, and `sortingColumn` / + `sortingDescending` / `onSortingChange` (VirtualTable reflects sort state and + emits intent; it doesn't sort the data). +- Header and states: `stickyHeader`, `header`, `empty`, `loading` / `loadingText`. +- Accessibility: `ariaLabels` (`tableLabel`, `expandButtonLabel`, + `expandedRegionLabel`, `appendAnnouncement`, `activateSortLabel`) and `role`. +- Imperative surface: `imperativeRef` exposing `scrollToEnd`, `scrollToItem`, and + `isPinnedToEnd` for scroll anchoring and live tail. + +Two 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: `ariaLabels.tableLabel`, `expandButtonLabel`, and + `expandedRegionLabel` are required for a complete accessible experience. Omitting + them leaves the table, its disclosure controls, or its expanded regions without + an accessible name. + +## Examples + +Runnable dev pages in `pages/virtual-table/`: + +- `simple.page.tsx` — the minimal starting point: a basic table. +- `cloudwatch-standard.page.tsx` — the CloudWatch Logs Insights standard view: + dynamic columns, a stretch-last message column, expanded log-record detail, and + consumer-composed live tail. +- `cloudwatch-raw.page.tsx` — the raw / file view: a single wrapping line column + 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, 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..9d5830f134 --- /dev/null +++ b/src/virtual-table/__tests__/use-expansion.test.tsx @@ -0,0 +1,70 @@ +// 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'; + +interface Item { + id: string; +} + +const trackBy = (item: Item) => item.id; +const a: Item = { id: 'a' }; + +describe('VirtualTable 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-virtual-model.test.tsx b/src/virtual-table/__tests__/use-virtual-model.test.tsx new file mode 100644 index 0000000000..decf1f42ca --- /dev/null +++ b/src/virtual-table/__tests__/use-virtual-model.test.tsx @@ -0,0 +1,185 @@ +// 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'; + +// jsdom has no layout engine, so the engine's DOM-driven paths (viewport height, +// element measurement) are exercised with a controllable ResizeObserver and a fake +// scroll container whose scrollTop/clientHeight/scrollHeight are stubbed. The default +// viewport falls back to 600px when clientHeight is 0 (see use-virtual-model). + +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 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', () => { + 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); + // Runway grows by exactly the expanded region height. + 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 row is observed; firing the observer applies the real height. + 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..c6be405c7d --- /dev/null +++ b/src/virtual-table/__tests__/virtual-table-a11y.test.tsx @@ -0,0 +1,338 @@ +// 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 config-driven VirtualTable (impl-F1-A1-tests-a11y). They run in +// jsdom under the unit config (the `__a11y__` directory is the node/puppeteer integ +// runner, `.ts`-only, so axe + RTL keyboard/SR tests belong here, matching the repo's +// existing `*-a11y.test.tsx` convention). The unit runner collects this file: 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: axe/HTML validity, row-granular keyboard nav via +// aria-activedescendant (incl. ArrowLeft/Right inert), disclosure activation, Tab-in / +// Escape-out of the expanded region, live-append debounce, focus-under-recycling, and +// full-dataset aria-rowcount/rowindex + aria-colcount/colindex coherence (incl. the +// materialised disclosure column) under windowing. + +interface Item { + id: string; + name: string; +} + +const makeItems = (n: number): Item[] => Array.from({ length: n }, (_, i) => ({ id: String(i), name: `row ${i}` })); + +const columnDefinitions: VirtualTableProps['columnDefinitions'] = [ + { id: 'name', header: 'Name', cell: item => item.name }, + { id: 'index', header: 'Index', cell: (item, context) => String(context.rowIndex) }, +]; + +// Stable identity so useLiveAnnouncement's effect does not re-run on unrelated rerenders. +const appendAnnouncement = ({ addedCount, totalCount }: { addedCount: number; totalCount: number }) => + `${addedCount} new log events, ${totalCount} total`; + +const ariaLabels: VirtualTableProps.AriaLabels = { + tableLabel: 'Log events', + expandButtonLabel: (item, expanded) => `${expanded ? 'Collapse' : 'Expand'} details for ${item.name}`, + expandedRegionLabel: item => `Details for ${item.name}`, + appendAnnouncement, +}; + +const expandedContent = (item: Item) => ( +
+

Log record {item.id}

+
+
Level
+
INFO
+
+ +
+); + +function renderTable(props: Partial> & { items: Item[] }) { + const { container, rerender } = render( + item.id} columnDefinitions={columnDefinitions} ariaLabels={ariaLabels} {...props} /> + ); + const wrapper = createWrapper(container).findVirtualTable()!; + return { container, rerender, wrapper }; +} + +function getGrid(container: HTMLElement): HTMLElement { + return container.querySelector('[role="grid"]') as HTMLElement; +} + +describe('VirtualTable a11y', () => { + describe('axe / HTML validity', () => { + test('validates a plain grid without expansion', async () => { + const { container } = renderTable({ items: makeItems(20) }); + await expect(container).toValidateA11y(); + }); + + test('validates a grid with a collapsed disclosure column', async () => { + const { container } = renderTable({ items: makeItems(20), getExpandedContent: expandedContent }); + await expect(container).toValidateA11y(); + }); + + test('validates a grid with an expanded, labeled region containing arbitrary content', async () => { + const { container } = renderTable({ + items: makeItems(20), + getExpandedContent: expandedContent, + expandedItems: ['0', '3'], + }); + await expect(container).toValidateA11y(); + }); + + test('validates the reduced single-column (file/raw) shape', async () => { + const { container } = renderTable({ + items: makeItems(50), + columnDefinitions: [{ id: 'line', header: 'Log line', cell: item => item.name, isStretch: true }], + estimatedRowHeight: 20, + overscan: 40, + }); + await expect(container).toValidateA11y(); + }); + + test('validates the empty and loading states', async () => { + const empty = renderTable({ items: [], empty: No log events }); + await expect(empty.container).toValidateA11y(); + + const loading = renderTable({ items: [], loading: true, loadingText: 'Loading log events' }); + await expect(loading.container).toValidateA11y(); + }); + }); + + 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({ items: makeItems(20) }); + const grid = getGrid(container); + expect(grid.getAttribute('tabindex')).toBe('0'); + // Active descendant defaults to the first row and references a rendered element. + 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({ items: makeItems(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({ + items: makeItems(20), + getExpandedContent: expandedContent, + 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' }); + + // The grid only acts on keydown from the container itself, so content inside the + // region keeps its own arrow-key behaviour and the active row is unchanged. + expect(grid.getAttribute('aria-activedescendant')).toBe(before); + }); + + test('ArrowLeft / ArrowRight are inert (deliberate row-granular grid, not 2D cell nav)', () => { + // F1-A1 deliberately ships a row-granular grid: aria-activedescendant references a + // role="row" (never a gridcell), and onGridKeyDown handles only Up/Down/Home/End. + // This is a legitimate APG row-as-widget variant of role="grid"; the horizontal + // cell-navigation keys the 2D grid pattern would imply are intentionally inert. + // Carried to impl-F1-A1-docs as an explicit a11y contract note (row-, not cell-, + // granular navigation) so consumers are not surprised. + const { container, wrapper } = renderTable({ items: makeItems(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('disclosure + expanded region wiring', () => { + test('the disclosure control is a labeled button reflecting aria-expanded / aria-controls', () => { + const { wrapper } = renderTable({ items: makeItems(10), getExpandedContent: expandedContent }); + const toggle = wrapper.findExpandToggle(0)!.getElement(); + + expect(toggle.tagName).toBe('BUTTON'); + expect(toggle.getAttribute('aria-label')).toBe('Expand details for row 0'); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + expect(toggle.getAttribute('aria-controls')).toBeNull(); + + fireEvent.click(toggle); + + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + 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({ + items: makeItems(10), + getExpandedContent: expandedContent, + expandedItems: ['2'], + }); + const region = wrapper.findExpandedRegion(2)!.getElement(); + expect(region.getAttribute('aria-label')).toBe('Details for row 2'); + }); + + test('Escape inside the expanded region returns focus to its disclosure trigger', () => { + const { wrapper } = renderTable({ + items: makeItems(10), + getExpandedContent: expandedContent, + 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({ + items: makeItems(10), + getExpandedContent: expandedContent, + expandedItems: ['0'], + }); + const region = wrapper.findExpandedRegion(0)!.getElement(); + const innerButton = region.querySelector('button') as HTMLButtonElement; + + // jsdom cannot perform real Tab traversal, but 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', () => { + const { rerender, wrapper } = renderTable({ items: makeItems(10) }); + const live = wrapper.findLiveRegion()!.getElement(); + expect(live.getAttribute('aria-live')).toBe('polite'); + expect(live.textContent).toBe(''); + + const props = { + trackBy: (item: Item) => item.id, + columnDefinitions, + ariaLabels, + }; + // Two appends within the debounce window collapse into a single announcement. + rerender(); + rerender(); + expect(live.textContent).toBe(''); + + act(() => { + jest.advanceTimersByTime(500); + }); + expect(live.textContent).toContain('5 new log events'); + expect(live.textContent).toContain('15 total'); + }); + + test('does not announce when the dataset shrinks or is replaced', () => { + const props = { trackBy: (item: Item) => item.id, columnDefinitions, ariaLabels }; + const { rerender, wrapper } = renderTable({ items: makeItems(10) }); + const live = wrapper.findLiveRegion()!.getElement(); + + rerender(); + 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 props = { trackBy: (item: Item) => item.id, columnDefinitions, ariaLabels }; + const { rerender, wrapper } = renderTable({ items: makeItems(20), getExpandedContent: expandedContent }); + 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). + rerender(); + 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({ items: makeItems(500), getExpandedContent: expandedContent }); + const grid = getGrid(container); + expect(grid.getAttribute('aria-rowcount')).toBe('501'); + expect(grid.getAttribute('aria-colcount')).toBe(String(columnDefinitions.length + 1)); + }); + + test('the header row is aria-rowindex 1 with the disclosure columnheader at aria-colindex 1', () => { + const { wrapper } = renderTable({ items: makeItems(500), getExpandedContent: expandedContent }); + 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({ items: makeItems(500), getExpandedContent: expandedContent }); + 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('the expanded row shares its data row index and spans all columns without changing aria-rowcount', () => { + const { container, wrapper } = renderTable({ + items: makeItems(500), + getExpandedContent: expandedContent, + 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(columnDefinitions.length + 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..b32ebb3c20 --- /dev/null +++ b/src/virtual-table/__tests__/virtual-table.test.tsx @@ -0,0 +1,128 @@ +// 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'; + +interface Item { + id: string; + name: string; +} + +const makeItems = (n: number): Item[] => Array.from({ length: n }, (_, i) => ({ id: String(i), name: `row ${i}` })); + +const columnDefinitions: VirtualTableProps['columnDefinitions'] = [ + { id: 'name', header: 'Name', cell: item => item.name }, + { id: 'index', header: 'Index', cell: (item, context) => String(context.rowIndex) }, +]; + +function renderTable(props: Partial> & { items: Item[] }) { + const { container } = render( + item.id} columnDefinitions={columnDefinitions} {...props} /> + ); + const wrapper = createWrapper(container).findVirtualTable()!; + return { container, wrapper }; +} + +describe('VirtualTable', () => { + test('renders the root element and is discoverable via the test-utils wrapper', () => { + const { container, wrapper } = renderTable({ items: makeItems(2) }); + expect(container.firstChild).toHaveClass(styles.root); + expect(wrapper).not.toBeNull(); + expect(wrapper.findColumnHeaders()).toHaveLength(columnDefinitions.length); + }); + + test('windows a large dataset: renders only a subset and returns null for rows outside the window', () => { + const { wrapper } = renderTable({ items: makeItems(500), estimatedRowHeight: 20, overscan: 10 }); + const rendered = wrapper.findRows().length; + expect(rendered).toBeGreaterThan(0); + expect(rendered).toBeLessThan(500); + expect(wrapper.findRowByIndex(0)).not.toBeNull(); + expect(wrapper.findRowByIndex(499)).toBeNull(); + }); + + test('exposes full-dataset grid semantics (aria-rowcount counts the header once, aria-colcount the data columns)', () => { + const { container } = renderTable({ items: makeItems(500) }); + const grid = container.querySelector('[role="grid"]')!; + expect(grid.getAttribute('aria-rowcount')).toBe('501'); + expect(grid.getAttribute('aria-colcount')).toBe(String(columnDefinitions.length)); + }); + + test('adds the materialised disclosure column only when getExpandedContent is supplied', () => { + const withoutExpansion = renderTable({ items: makeItems(10) }); + expect(withoutExpansion.wrapper.findExpandToggle(0)).toBeNull(); + + const withExpansion = renderTable({ + items: makeItems(10), + getExpandedContent: item =>
detail {item.id}
, + }); + const grid = withExpansion.container.querySelector('[role="grid"]')!; + expect(grid.getAttribute('aria-colcount')).toBe(String(columnDefinitions.length + 1)); + expect(withExpansion.wrapper.findExpandToggle(0)).not.toBeNull(); + }); + + test('toggling the disclosure control (uncontrolled) reveals the expanded region and fires onExpandChange', () => { + const onExpandChange = jest.fn(); + const { wrapper } = renderTable({ + items: makeItems(10), + getExpandedContent: item =>
detail {item.id}
, + onExpandChange, + }); + expect(wrapper.findExpandedRegion(0)).toBeNull(); + fireEvent.click(wrapper.findExpandToggle(0)!.getElement()); + expect(onExpandChange).toHaveBeenCalled(); + expect(wrapper.findExpandedRegion(0)).not.toBeNull(); + }); + + test('controlled expandedItems renders the labeled expanded region for the given row', () => { + const { wrapper } = renderTable({ + items: makeItems(500), + expandedItems: ['2'], + getExpandedContent: item =>
detail {item.id}
, + ariaLabels: { expandedRegionLabel: item => `Details for ${item.id}` }, + }); + const region = wrapper.findExpandedRegion(2); + expect(region).not.toBeNull(); + expect(region!.getElement().getAttribute('role')).toBe('region'); + expect(region!.getElement().getAttribute('aria-label')).toBe('Details for 2'); + }); + + test('exposes the imperative handle (scrollToEnd / scrollToItem / isPinnedToEnd)', () => { + const ref = React.createRef(); + render( + item.id} + columnDefinitions={columnDefinitions} + imperativeRef={ref} + /> + ); + expect(typeof ref.current!.scrollToEnd).toBe('function'); + expect(typeof ref.current!.scrollToItem).toBe('function'); + expect(typeof ref.current!.isPinnedToEnd()).toBe('boolean'); + }); + + test('renders a polite live-append region', () => { + const { wrapper } = renderTable({ items: makeItems(2) }); + 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({ items: [], empty: No logs }); + expect(wrapper.findRows()).toHaveLength(0); + expect(container.textContent).toContain('No logs'); + }); + + test('renders a status region while loading', () => { + const { container } = renderTable({ items: [], loading: true, loadingText: 'Loading logs' }); + const status = container.querySelector('[role="status"]'); + expect(status).not.toBeNull(); + expect(status!.textContent).toContain('Loading logs'); + }); +}); diff --git a/src/virtual-table/index.tsx b/src/virtual-table/index.tsx new file mode 100644 index 0000000000..cf050deb55 --- /dev/null +++ b/src/virtual-table/index.tsx @@ -0,0 +1,41 @@ +// 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 }; + +export default function VirtualTable({ + estimatedRowHeight = 40, + overscan = 10, + columnLayout = 'fixed', + role = 'grid', + loading = false, + resizableColumns = false, + stickyHeader = false, + ...props +}: VirtualTableProps) { + const baseComponentProps = useBaseComponent('VirtualTable', { + props: { estimatedRowHeight, overscan, columnLayout, role, 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..5d13deb079 --- /dev/null +++ b/src/virtual-table/interfaces.ts @@ -0,0 +1,219 @@ +// 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 F1-A1): generic, config-driven, virtualization-first table, +// additive to and coexisting with `Table`. This file is the public API surface; +// the windowing engine, measurement, and R-EXPAND a11y wiring land in +// impl-F1-A1-core. See deliverables/design/design-F1-A1.md. +export interface VirtualTableProps extends BaseComponentProps { + /** + * The full dataset to render. VirtualTable windows this to the visible range; + * the array itself is never fully rendered to the DOM. Appending to this array + * is the streaming/live path (see `imperativeRef` and `onVisibleRangeChange`). + */ + items: ReadonlyArray; + + /** + * Column configuration. Mirrors Table's `ColumnDefinition` so the model transfers. + * `cell` receives row-relative context (dataset row index, total count, expansion) + * in addition to the item; cross-row scale is computed consumer-side over `items`. + */ + columnDefinitions: ReadonlyArray>; + + /** + * 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. + */ + trackBy: (item: T) => string; + + // --- Virtualization ------------------------------------------------------ + + /** + * Estimated collapsed row height in px, used to size the scroll runway before a + * row is measured. Set to the design's row density (CloudWatch: 23). + * @defaultValue 40 + */ + estimatedRowHeight?: number; + + /** + * Per-row height strategy. Return a fixed px height, or `"auto"` to opt that row + * into measurement. Omitting the prop means every row is fixed at + * `estimatedRowHeight` — the cheap large-dataset default. + */ + getRowHeight?: (item: T) => number | 'auto'; + + /** + * Number of rows rendered beyond the visible range on each side, to avoid blank + * flashes on fast scroll. + * @defaultValue 10 + */ + overscan?: number; + + /** + * Fires when the windowed visible range changes. `firstIndex`/`lastIndex` index + * into `items`. + */ + onVisibleRangeChange?: NonCancelableEventHandler; + + // --- Row expansion (R-EXPAND) -------------------------------------------- + + /** + * Renders arbitrary, non-tabular content for an expanded row (key/value detail, + * raw/JSON record, nested panels). Presence of this prop enables the leading + * disclosure column. The returned node is not constrained to the column set. + */ + getExpandedContent?: (item: T) => React.ReactNode; + + /** Controlled set of expanded row ids (as returned by `trackBy`). */ + expandedItems?: ReadonlyArray; + + /** Uncontrolled initial expansion. @defaultValue [] */ + defaultExpandedItems?: ReadonlyArray; + + /** Fires on a disclosure toggle with the row and the resulting expanded set. */ + onExpandChange?: NonCancelableEventHandler>; + + /** + * Height strategy for the expanded region, mirroring `getRowHeight`. + * @defaultValue () => 'auto' + */ + getExpandedRowHeight?: (item: T) => number | 'auto'; + + // --- Columns / sorting / sizing ------------------------------------------ + + /** Enables column resize handles. Emits via `onColumnWidthsChange`. @defaultValue false */ + resizableColumns?: boolean; + + /** Controlled per-column widths (px), keyed by column id. Uncontrolled if omitted. */ + columnWidths?: Record; + onColumnWidthsChange?: NonCancelableEventHandler; + + /** + * Column layout. `"fixed"` (default) applies fixed table layout with per-cell + * truncation and stretch-last-column. `"auto"` sizes to content. + * @defaultValue "fixed" + */ + columnLayout?: VirtualTableProps.ColumnLayout; + + sortingColumn?: VirtualTableProps.ColumnDefinition; + sortingDescending?: boolean; + /** VirtualTable does not sort data; it reflects sort state and emits intent. */ + onSortingChange?: NonCancelableEventHandler>; + + // --- Header / chrome ----------------------------------------------------- + + /** Renders a sticky header with horizontal scroll synced to the body. @defaultValue false */ + stickyHeader?: boolean; + + /** Header slot above the grid (title, counter, actions), like Table's `header`. */ + header?: React.ReactNode; + + /** Rendered when `items` is empty. */ + empty?: React.ReactNode; + + /** Loading state for the whole grid. */ + loading?: boolean; + loadingText?: string; + + // --- Accessibility ------------------------------------------------------- + + /** + * Localized accessibility strings and label functions. + * @i18n + */ + ariaLabels?: VirtualTableProps.AriaLabels; + + /** + * Semantic role of the grid. `"grid"` (default) for interactive grids with + * keyboard cell/row navigation; `"table"` for static, non-interactive data. + * @defaultValue "grid" + */ + role?: VirtualTableProps.Role; + + // --- Imperative surface -------------------------------------------------- + + /** Imperative handle for scroll anchoring, live tail, and reveal. */ + imperativeRef?: React.Ref; +} + +export namespace VirtualTableProps { + export type Role = 'grid' | 'table'; + export type ColumnLayout = 'fixed' | 'auto'; + + export interface ColumnDefinition { + id: string; + header: React.ReactNode; + /** + * Cell renderer. `context` exposes row-relative state (full dataset row index, + * total count, expansion) beyond the item itself. Cross-row scale is computed by + * the consumer over the full `items` array, not derived from `context`. + */ + cell: (item: T, context: CellContext) => React.ReactNode; + sortingField?: string; + width?: number; + minWidth?: number; + /** + * Marks the single column whose extra space stretches to fill. If multiple + * columns set this, the last-declared wins (single stretch target). + */ + isStretch?: boolean; + ariaLabel?: (data: { sorted: boolean; descending: boolean }) => string; + } + + export interface CellContext { + /** Full row index into the complete dataset; matches the row's `aria-rowindex`. */ + rowIndex: number; + /** Total dataset length (equals `aria-rowcount`). */ + totalItemCount: number; + isExpanded: boolean; + // NB: the windowed item slice is deliberately not exposed here; cross-row scale + // must be computed over the full `items` array consumer-side so it does not jump + // as the window changes on scroll. + } + + export interface VisibleRangeDetail { + firstIndex: number; + lastIndex: number; + } + export interface ExpandChangeDetail { + item: T; + expanded: boolean; + expandedItems: string[]; + } + export interface SortingDetail { + sortingColumn: ColumnDefinition; + sortingDescending: boolean; + } + export interface ColumnWidthsDetail { + widths: Record; + } + + export interface AriaLabels { + tableLabel?: string; + /** Label for a row's disclosure trigger, given expansion state. */ + expandButtonLabel?: (item: T, expanded: boolean) => string; + /** Accessible name for an expanded region, tying it to its row. */ + expandedRegionLabel?: (item: T) => string; + /** + * Message announced (politely) when rows are appended during live streaming. + * Appends are coalesced by the component (debounced burst, one summarized + * message per batch). Returning undefined suppresses the announcement. + */ + appendAnnouncement?: (detail: { addedCount: number; totalCount: number }) => string | undefined; + activateSortLabel?: (column: ColumnDefinition) => string; + } + + export interface Ref { + /** Pin the viewport to the last row (compose stick-to-bottom live tail on top). */ + scrollToEnd(): void; + /** Scroll a row into view; optionally expand it (`reveal`). Any highlight is consumer-composed. */ + scrollToItem(id: string, options?: { reveal?: boolean }): void; + /** True when the viewport is pinned to the last row (at the bottom edge). */ + isPinnedToEnd(): boolean; + } +} diff --git a/src/virtual-table/internal.tsx b/src/virtual-table/internal.tsx new file mode 100644 index 0000000000..f1b4eaa1dc --- /dev/null +++ b/src/virtual-table/internal.tsx @@ -0,0 +1,381 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useCallback, useEffect, useImperativeHandle, 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 { useLiveAnnouncement } from './use-live-announcement'; +import { useVirtualModel } from './use-virtual-model'; + +import styles from './styles.css.js'; + +interface InternalVirtualTableProps extends VirtualTableProps, InternalBaseComponentProps {} + +// impl-F1-A1-core: the config-driven VirtualTable render path. The scaffold's +// non-windowed placeholder body is replaced by the windowing engine +// (use-virtual-model), controlled/uncontrolled expansion (use-expansion), and the +// debounced live-append surface (use-live-announcement). The DOM keeps a valid grid +// child model (grid -> rowgroup -> row -> columnheader/gridcell, design B2), sets +// full-dataset aria-rowcount/aria-rowindex + aria-colcount/aria-colindex (design B1), +// and attaches the arbitrary R-EXPAND content inside a real expanded row so it windows +// with a measured variable height. +export default function InternalVirtualTable({ + items, + columnDefinitions, + trackBy, + role = 'grid', + header, + empty, + loading = false, + loadingText, + estimatedRowHeight = 40, + getRowHeight, + getExpandedRowHeight, + overscan = 10, + onVisibleRangeChange, + getExpandedContent, + expandedItems, + defaultExpandedItems, + onExpandChange, + columnWidths, + stickyHeader = false, + sortingColumn, + sortingDescending = false, + ariaLabels, + imperativeRef, + __internalRootRef, + ...props +}: InternalVirtualTableProps) { + const baseProps = getBaseProps(props); + // Deferred beyond core (declared in interfaces, intentionally not wired here): + // resizableColumns / columnLayout='auto' / onColumnWidthsChange / onSortingChange. + // They fall into ...props and getBaseProps drops unknown props (no DOM leak); the + // resize + auto-layout + sort-emit behaviours land in impl-F1-A1-cw-* / later units. + const baseId = useUniqueId('virtual-table'); + const scrollRef = useRef(null); + + const showEmpty = !loading && items.length === 0; + const hasDisclosureColumn = !!getExpandedContent; + const dataColumnStart = hasDisclosureColumn ? 2 : 1; + const columnCount = columnDefinitions.length + (hasDisclosureColumn ? 1 : 0); + // Single stretch target: if several columns set isStretch, the last-declared wins (CW-8). + const stretchColumnId = [...columnDefinitions].reverse().find(column => column.isStretch)?.id; + + const expansion = useExpansion({ trackBy, expandedItems, defaultExpandedItems, onExpandChange }); + + const model = useVirtualModel({ + items, + trackBy, + expandedIds: expansion.expandedIds, + expandedSignature: expansion.expandedSignature, + estimatedRowHeight, + getRowHeight, + getExpandedRowHeight, + overscan, + scrollContainerRef: scrollRef, + }); + + const liveMessage = useLiveAnnouncement(items.length, ariaLabels?.appendAnnouncement); + + // Surface the accurate windowed range (variable heights make a scrollTop estimate + // unreliable, so the measured range is emitted here). + const lastRange = useRef<{ first: number; last: number }>({ first: -1, last: -1 }); + useEffect(() => { + if (model.firstIndex < 0) { + return; + } + if (model.firstIndex !== lastRange.current.first || model.lastIndex !== lastRange.current.last) { + lastRange.current = { first: model.firstIndex, last: model.lastIndex }; + fireNonCancelableEvent(onVisibleRangeChange, { firstIndex: model.firstIndex, lastIndex: model.lastIndex }); + } + }, [model.firstIndex, model.lastIndex, onVisibleRangeChange]); + + useImperativeHandle( + imperativeRef, + () => ({ + scrollToEnd: () => model.scrollToEnd(), + scrollToItem: (id: string, options?: { reveal?: boolean }) => { + const index = items.findIndex(item => trackBy(item) === id); + if (index < 0) { + return; + } + if (options?.reveal) { + expansion.expand(items[index]); + } + model.scrollToIndex(index); + }, + isPinnedToEnd: () => model.isPinnedToEnd(), + }), + [model, items, trackBy, expansion] + ); + + // Keyboard navigation uses the aria-activedescendant model rather than roving + // tabindex on rows: the scroll container is the single, always-present tab stop + // (tabIndex 0), so Tab reaches the grid at ANY scroll offset — including live-tail + // pinned-to-end, where the default active row is far above the window (design B3, + // WCAG 2.1.1). The active row is referenced by aria-activedescendant only while it + // is within the rendered window, so the reference is always valid; Arrow/Home/End + // move the active row and scroll it into view (which renders it, updating the + // reference). Cell-level (2D) arrow navigation and focus-restore-on-recycle coverage + // are exercised in impl-F1-A1-tests-a11y. + const [activeId, setActiveId] = useState(null); + const effectiveActiveId = activeId ?? (items.length > 0 ? trackBy(items[0]) : null); + const rowDomId = (id: string) => `${baseId}-row-${id}`; + + // Ids of the data rows currently in the window; used to keep aria-activedescendant + // pointing only at a rendered element (an out-of-window reference would be invalid). + const windowedDataIds = new Set( + model.slots.filter(slot => slot.type === 'data').map(slot => trackBy(items[slot.index])) + ); + const activeDescendantId = + effectiveActiveId && windowedDataIds.has(effectiveActiveId) ? rowDomId(effectiveActiveId) : undefined; + + const moveActive = useCallback( + (delta: number | 'start' | 'end') => { + if (items.length === 0) { + return; + } + const currentIndex = Math.max( + 0, + items.findIndex(item => trackBy(item) === effectiveActiveId) + ); + let nextIndex: number; + if (delta === 'start') { + nextIndex = 0; + } else if (delta === 'end') { + nextIndex = items.length - 1; + } else { + nextIndex = Math.min(items.length - 1, Math.max(0, currentIndex + delta)); + } + setActiveId(trackBy(items[nextIndex])); + model.scrollToIndex(nextIndex); + }, + [items, trackBy, effectiveActiveId, model] + ); + + const onGridKeyDown = useCallback( + (event: React.KeyboardEvent) => { + // Only act when the grid container itself holds focus. Keys originating from + // focusable content inside a cell or the expanded region — which is Tab-in / + // Escape-out and excluded from arrow-grid navigation (design B2 / MASTER goal 6) + // — belong to that content, so the grid must not hijack Arrow/Home/End there. + 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('start'); + break; + case 'End': + event.preventDefault(); + moveActive('end'); + break; + } + }, + [moveActive] + ); + + const renderDataRow = (index: number, start: number, size: number, auto: boolean) => { + const item = items[index]; + const id = trackBy(item); + const expanded = expansion.isExpanded(id); + const regionId = `${baseId}-region-${id}`; + const toggleId = `${baseId}-toggle-${id}`; + const context: VirtualTableProps.CellContext = { + rowIndex: index, + totalItemCount: items.length, + isExpanded: expanded, + }; + return ( +
+ {hasDisclosureColumn && ( + +
+ ); + }; + + const renderExpandedRow = (index: number, start: number, size: number, auto: boolean) => { + const item = items[index]; + const id = trackBy(item); + const regionId = `${baseId}-region-${id}`; + const toggleId = `${baseId}-toggle-${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) is used because the expanded content is arbitrary and + may include block-level elements, which are not permitted inside a span. */} +
+
{ + if (event.key === 'Escape') { + event.stopPropagation(); + document.getElementById(toggleId)?.focus(); + } + }} + > + {getExpandedContent?.(item)} +
+
+
+ ); + }; + + return ( +
+ {header &&
{header}
} + + {/* + The grid owns only rowgroups; header and body rows sit in their own rowgroup + (grid -> rowgroup -> row -> columnheader/gridcell). Non-row chrome — loading + (role=status), empty state, and the live-append region — are siblings of the + grid under .root, never grid-owned children. + */} +
+
+
+ {hasDisclosureColumn && ( + // Materialised leading disclosure column: an accessible, visually hidden + // columnheader counted at aria-colindex 1 so data columns start at 2 in + // both header and body and the two never diverge (design B1 / F1-A2 B1). + + )} + {columnDefinitions.map((column, columnIndex) => { + const sorted = sortingColumn?.id === column.id; + return ( + + {column.header} + + ); + })} +
+
+ + {!loading && !showEmpty && ( +
+ {model.slots.map(slot => + slot.type === 'data' + ? renderDataRow(slot.index, slot.start, slot.size, slot.auto) + : renderExpandedRow(slot.index, slot.start, slot.size, slot.auto) + )} +
+ )} +
+ + {loading && ( +
+ {loadingText} +
+ )} + + {showEmpty &&
{empty}
} + + {/* Polite, debounced live-append region (design: one summarized message per burst). */} +
+ {liveMessage} +
+
+ ); +} + +// 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..b403a63942 --- /dev/null +++ b/src/virtual-table/styles.scss @@ -0,0 +1,127 @@ +/* + 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%; +} + +.header { + margin-block-end: awsui.$space-scaled-s; +} + +.scroll-container { + position: relative; + overflow: auto; + inline-size: 100%; +} + +.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: its block-size is the full virtualized height and +// windowed rows are absolutely positioned within it at their computed offsets. +.body { + display: block; + position: relative; + inline-size: 100%; +} + +.row, +.expanded-row { + display: flex; + position: absolute; + inset-inline: 0; + inline-size: 100%; + box-sizing: border-box; +} + +.row { + align-items: center; + &:focus-visible { + 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; +} + +.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..b8c1919fc4 --- /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. +// Exposes a stable signature so the windowing layout only recomputes when the set of +// expanded ids actually changes, not on every parent 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-live-announcement.ts b/src/virtual-table/use-live-announcement.ts new file mode 100644 index 0000000000..5779898fb7 --- /dev/null +++ b/src/virtual-table/use-live-announcement.ts @@ -0,0 +1,58 @@ +// 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. The +// consumer decides WHAT to announce (via ariaLabels.appendAnnouncement); the +// component owns the debounce and the aria-live region so a high-rate live tail +// announces "N new log events" once per ~500ms 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 space when the text repeats so the SR re-announces. + setMessage(prev => (prev === next ? next + '\u200b' : next)); + } + }, APPEND_DEBOUNCE_MS); + }, [totalCount, appendAnnouncement]); + + useEffect( + () => () => { + if (timer.current) { + clearTimeout(timer.current); + } + }, + [] + ); + + return message; +} diff --git a/src/virtual-table/use-virtual-model.ts b/src/virtual-table/use-virtual-model.ts new file mode 100644 index 0000000000..f5ba05d51a --- /dev/null +++ b/src/virtual-table/use-virtual-model.ts @@ -0,0 +1,312 @@ +// 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 VirtualTable (impl-F1-A1-core). +// Owns a single inner scroll container (design B4): the grid is the scroll root and +// the body windows within it. Rows default to a fixed `estimatedRowHeight`; only rows +// whose height strategy resolves to "auto" are observed (design B5) so a 100k +// fixed-row dataset never pays ResizeObserver cost. Measured growth of rows above the +// fold is corrected by re-anchoring on the first visible row's identity, so scroll +// position stays put as variable/expanded heights are discovered (no CW-3 drift). + +// Pre-measurement placeholder used to size the runway for an expanded region for one +// frame before its real height is observed. NOT a CloudWatch density constant — CW's +// 300/150 come from the consumer (getExpandedRowHeight) or from measurement. +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; + getRowHeight?: (item: T) => number | 'auto'; + getExpandedRowHeight?: (item: T) => number | 'auto'; + 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, + 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()); + // Anchor recorded on scroll: the first visible data row's id and its distance from + // the top of the viewport. Used to correct scroll offset after measurement. + const anchor = useRef<{ id: string; top: number } | null>(null); + + // Latest height strategies read without forcing the layout memo to depend on their + // (often unstable) function identity; layout recomputes on data/expansion/measure + // changes, which is when heights actually change. + const latest = useRef({ getRowHeight, getExpandedRowHeight, estimatedRowHeight }); + latest.current = { getRowHeight, getExpandedRowHeight, estimatedRowHeight }; + + // Layout is O(items.length) and recomputes when items / expansion / measurements + // change. For the fixed-row large-data default there are no measurement bumps, so it + // runs once per data change; a fully-'auto' dataset re-runs on each measurement burst + // (the documented opt-in-measurement tradeoff, design B5 — keep the fixed-row fast + // path as the large-data guidance in -docs). + const layout = useMemo(() => { + const { getRowHeight: grh, getExpandedRowHeight: gerh, estimatedRowHeight: est } = 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); + + 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) ?? DEFAULT_EXPANDED_ESTIMATE) : (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]); + + // Observe the container's own height so windowing tracks viewport resize. + 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]); + + // Read `layout` through a ref so the scroll listener attaches exactly once instead + // of re-subscribing on every layout identity change (NB4). + const layoutRef = useRef(layout); + layoutRef.current = layout; + + // Track scroll position and record the anchor row (first visible data row) so a + // later measurement of a row above it can re-anchor without a visible jump. + 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]); + + // Offset correction: after measured heights change, keep the anchored row visually + // fixed by adjusting scrollTop by the delta introduced above it (design B5). + 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 rows 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, + }; +}