diff --git a/build-tools/tasks/docs.js b/build-tools/tasks/docs.js index f777a43af0..f3dca13dcb 100644 --- a/build-tools/tasks/docs.js +++ b/build-tools/tasks/docs.js @@ -8,7 +8,12 @@ module.exports = function docs() { writeComponentsDocumentation({ outDir: path.join(workspace.apiDocsPath, 'components'), tsconfigPath: require.resolve('../../tsconfig.json'), - publicFilesGlob: 'src/*/index.tsx', + // VirtualTable (design-study cell F1-A2) is a compound-component namespace exported as an + // object literal ({ Root, Header, Body, Row, Cell, ExpandedContent }). The API documenter + // only supports a single-function default export per index.tsx, so it is excluded here + // pending documenter support for compound components (tracked as a PR follow-up). Its API + // is described in src/virtual-table/USAGE.md instead. + publicFilesGlob: 'src/!(virtual-table)/index.tsx', extraExports: { FileDropzone: ['useFilesDragging'], IconProvider: ['defineIcons', 'IconRegistry', 'IconMap'], 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..1c8e7943e6 --- /dev/null +++ b/pages/virtual-table/cloudwatch-patterns.page.tsx @@ -0,0 +1,458 @@ +// 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-A2 COMPOUND API. +// Same CloudWatch surface as the F1-A1 config-driven patterns demo, expressed through +// the compound tree (Root / Header / HeaderCell / Body row-template / Cell / +// ExpandedContent). See research/cloudwatch-requirements.md: +// - CW-6 R-EXPAND shape B: renders +// a pattern detail (histogram + token enumeration + pattern actions) — arbitrary, +// non-tabular, NOT the column set — measured at the patterns density (~150 px). +// - CW-9 per-column sort (a patterns-view capability; the standard view has none), +// declared via `sortingField` on the sortable HeaderCells, and column resize. +// - CW-11 compare/diff mode as a HeaderCell/column-set swap: a different set of +// HeaderCells + Cells (difference count, difference description) with its own +// sort. No diff-specific component API — the same compound tree, fewer/other cells. +// - 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 framing keeps this consumer-side: +// RowApi exposes only rowIndex/totalItemCount/isExpanded, never the windowed +// slice (design decision B3, the divergence from F2-A1 which owns the patterns +// view and surfaces the peak via CellContext.histogramPeak). +// 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} + + ))} +
+
+ + + +
+
+ ); +} + +// A column descriptor drives BOTH the HeaderCell (in Header) and the matched-by-columnId +// Cell (in the row template) so the two stay in sync — the compound API reconciles body +// Cells to the HeaderCell authority by columnId. `sortingField` marks a sortable column +// (per-column sort is a patterns-view capability, CW-9). +interface PatternColumn { + columnId: string; + header: React.ReactNode; + renderCell: (item: LogPattern) => React.ReactNode; + sortingField?: keyof LogPattern; + width?: number; + stretch?: boolean; +} + +export default function CloudWatchPatternsCompoundPage() { + const [diffMode, setDiffMode] = useState(false); + const [expandedItems, setExpandedItems] = useState>([]); + // The compound API reflects sort state by columnId (SortingDetail carries a columnId, + // not a column definition) and emits intent; the consumer applies the sort. + const [sortingColumnId, setSortingColumnId] = useState(); + 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 keeps this consumer-side; RowApi never exposes + // the windowed slice). + const histogramPeak = useMemo(() => computeGlobalHistogramPeak(basePatterns), [basePatterns]); + + // Normal-mode columns. The frequency cell closes over the shared peak. + const normalColumns = useMemo>( + () => [ + { + columnId: 'severity', + header: 'Severity', + renderCell: item => {item.severity}, + width: 110, + }, + { + columnId: 'matchingLogs', + header: 'Matching logs', + renderCell: item => item.matchingLogs.toLocaleString(), + sortingField: 'matchingLogs', + width: 130, + }, + { + columnId: 'sharePercent', + header: 'Share', + renderCell: item => `${item.sharePercent}%`, + sortingField: 'sharePercent', + width: 90, + }, + { + columnId: 'frequency', + // The sparkline carries a visually-hidden summary so the cell is not silent and + // stays consistent with its "Frequency" header for screen readers. + header: 'Frequency', + renderCell: item => ( + + ), + width: 140, + }, + { + columnId: 'lastSeen', + header: 'Last seen', + renderCell: item => formatTimeUtc(item.lastSeen), + sortingField: 'lastSeen', + width: 110, + }, + { + columnId: 'pattern', + header: 'Pattern', + renderCell: item => {item.pattern}, + stretch: true, + }, + ], + [histogramPeak] + ); + + // CW-11 compare/diff mode: a different HeaderCell/Cell set with its own sort. Selecting + // it swaps which compound cells render — no diff-specific component API. + const diffColumns = useMemo>( + () => [ + { + columnId: 'severity', + header: 'Severity', + renderCell: item => {item.severity}, + width: 110, + }, + { + columnId: 'diffCount', + header: '(Previous → Current) difference count', + renderCell: item => + item.diffCount >= 0 ? `+${item.diffCount.toLocaleString()}` : item.diffCount.toLocaleString(), + sortingField: 'diffCount', + width: 260, + }, + { + columnId: 'findingType', + header: 'Difference description', + renderCell: item => FINDING_LABELS[item.findingType], + sortingField: 'findingType', + width: 180, + }, + { + columnId: 'pattern', + header: 'Pattern', + renderCell: item => {item.pattern}, + stretch: true, + }, + ], + [] + ); + + const columns = diffMode ? diffColumns : normalColumns; + + // VirtualTable reflects sort state and emits intent; it does not sort data. The consumer + // applies the sort here (CW-9). The active column's `sortingField` is the data key. + const items = useMemo>(() => { + const activeColumn = columns.find(column => column.columnId === sortingColumnId); + const field = activeColumn?.sortingField; + if (!field) { + return basePatterns; + } + const sorted = [...basePatterns].sort((a, b) => { + const left = a[field]; + const right = b[field]; + if (typeof left === 'number' && typeof right === 'number') { + return left - right; + } + return String(left).localeCompare(String(right)); + }); + return sortingDescending ? sorted.reverse() : sorted; + }, [basePatterns, columns, sortingColumnId, sortingDescending]); + + const handleSortingChange = useCallback((detail: VirtualTableProps.SortingDetail) => { + setSortingColumnId(detail.columnId); + 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 columns. + setSortingColumnId(undefined); + setSortingDescending(false); + setDiffMode(value => !value); + }, []); + + return ( + <> +

VirtualTable — CloudWatch Logs Insights (Show patterns view, compound)

+

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

+
+ +
+ +
+ item.id} + estimatedRowHeight={23} + overscan={20} + resizableColumns={true} + sortingColumn={sortingColumnId ? { columnId: sortingColumnId } : undefined} + sortingDescending={sortingDescending} + onSortingChange={({ detail }) => handleSortingChange(detail)} + 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: columnId => { + const column = columns.find(candidate => candidate.columnId === columnId); + const label = typeof column?.header === 'string' ? column.header : columnId; + return `Sort by ${label}`; + }, + }} + > + {/* CW-9: sortable columns declare a `sortingField`; the sticky header keeps + the column labels visible while scrolling the windowed body. */} + + {columns.map(column => ( + + {column.header} + + ))} + + + {(item: LogPattern, api) => ( + + {columns.map(column => ( + + {column.renderCell(item)} + + ))} + {/* R-EXPAND shape B: declared unconditionally (Root mounts it only for + expanded rows), arbitrary non-tabular detail, measured (~150 px). */} + + + + + )} + + +
+
+ + ); +} diff --git a/pages/virtual-table/cloudwatch-raw.page.tsx b/pages/virtual-table/cloudwatch-raw.page.tsx new file mode 100644 index 0000000000..49e150662b --- /dev/null +++ b/pages/virtual-table/cloudwatch-raw.page.tsx @@ -0,0 +1,82 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import VirtualTable from '~components/virtual-table'; + +import ScreenshotArea from '../utils/screenshot-area'; + +// CloudWatch file / raw view — the third required surface (CW-15) on the F1-A2 +// COMPOUND API. This is the same compound tree reduced to its simplest shape: +// - a single column (one HeaderCell + one Cell) rendering the raw log line, +// - no child (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 (F1-A1 cw-standard NB5 carried +// forward; enabled by the F1-A2 core getRowHeight data-row measurement path). +// Demonstrates that all three CloudWatch surfaces are reachable from one compound +// API without a specialized "raw" mode. + +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 CloudWatchRawCompoundPage() { + const [items] = useState(() => Array.from({ length: LINE_COUNT }, (_, index) => makeLine(index))); + + return ( + <> +

VirtualTable — CloudWatch file / raw view (compound)

+

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

+ +
+ item.id} + estimatedRowHeight={RAW_LINE_HEIGHT} + getRowHeight={item => (item.text.length > 120 ? 'auto' : RAW_LINE_HEIGHT)} + overscan={FILE_VIEW_OVERSCAN} + ariaLabels={{ tableLabel: 'Raw log events' }} + > + + + Log events + + + + {(item: RawLine, api) => ( + + + + {item.text} + + + + )} + + +
+
+ + ); +} diff --git a/pages/virtual-table/cloudwatch-standard.page.tsx b/pages/virtual-table/cloudwatch-standard.page.tsx new file mode 100644 index 0000000000..a58ef89a2f --- /dev/null +++ b/pages/virtual-table/cloudwatch-standard.page.tsx @@ -0,0 +1,228 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useCallback, useEffect, useLayoutEffect, 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-A2 COMPOUND API. +// Same CloudWatch surface as the F1-A1 config-driven demo, expressed through the +// compound tree (Root / Header / HeaderCell / Body row-template / Cell / +// ExpandedContent). See research/cloudwatch-requirements.md: +// - CW-8 dynamic columns from arbitrary fields (rendered as HeaderCells + Cells) +// + the leading disclosure column materialised by Root; fixed layout with +// @message as the single stretch-last column. +// - CW-12 accurate visible-range reporting under variable heights. +// - CW-13 scroll-to-and-reveal a specific row (expand + scroll) via the ref. +// - 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: 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 (mapped to HeaderCells + Cells below), not a fixed +// three. @message is the single stretch-last column. +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 CloudWatchStandardCompoundPage() { + 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 + // (F1-A1 cw-standard B1 learning carried forward). + const wasPinnedRef = useRef(true); + + // Live tail (CW-7): the component exposes only mechanism (scrollToEnd / + // isPinnedToEnd); the follow *policy* lives here. + useEffect(() => { + if (!following) { + return; + } + // On (re)start, establish the pinned state so following=true actually tails from + // the current position: at mount the viewport sits at the top over the initial + // dataset, so isPinnedToEnd() is false and nothing would tail until the user + // scrolled down once. Seeding pinned + snapping to the newest row makes live tail + // engage immediately on load and on resume (B1 fix; folds NB2 snap-on-resume). + wasPinnedRef.current = true; + ref.current?.scrollToEnd(); + const timer = setInterval(() => { + // Capture pinned state BEFORE the append grows the runway (tolerance-independent), + // and generate the batch + ids OUTSIDE the state updater so the updater stays pure + // under React 18 StrictMode double-invoke (F1-A1 cw-standard B1/B2 learnings). + 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, compound)

+

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

+
+ + +
+ +
+ item.id} + estimatedRowHeight={23} + overscan={20} + resizableColumns={true} + 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`, + }} + > + {/* CW-8: dynamic columns declared as HeaderCells; @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. */} + + {DISPLAYED_FIELDS.map(field => ( + + {field} + + ))} + + @message + + + + {(item: LogRecord, api) => ( + + {DISPLAYED_FIELDS.map(field => ( + + {item.fields[field]} + + ))} + + {item.message} + + {/* R-EXPAND shape A: declared unconditionally (Root mounts it only for + expanded rows), arbitrary non-tabular detail, measured (~300 px). */} + + + + + )} + + +
+
+ + ); +} diff --git a/pages/virtual-table/simple.page.tsx b/pages/virtual-table/simple.page.tsx new file mode 100644 index 0000000000..70085b3e71 --- /dev/null +++ b/pages/virtual-table/simple.page.tsx @@ -0,0 +1,69 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import VirtualTable from '~components/virtual-table'; + +import ScreenshotArea from '../utils/screenshot-area'; + +interface Item { + id: string; + name: string; + status: string; + detail: string; +} + +const items: Item[] = Array.from({ length: 25 }, (_, index) => ({ + id: `row-${index}`, + name: `Resource ${index}`, + status: index % 2 === 0 ? 'Available' : 'Pending', + detail: `Extended detail for resource ${index}: arbitrary non-tabular expanded content.`, +})); + +export default function VirtualTableCompoundSimplePage() { + return ( + <> +

VirtualTable — compound scaffold

+

+ Generic compound-components VirtualTable (cell F1-A2). This dev page exercises the scaffolded compound shell — + Root/Header/HeaderCell/Body/Row/Cell/ExpandedContent — including a materialised disclosure column and a + first-class ExpandedContent child for arbitrary non-tabular row detail. Windowing, measurement, and the full + expansion/live-tail engine land in the core implementation unit. +

+ + item.id} + estimatedRowHeight={23} + defaultExpandedItems={['row-0']} + ariaLabels={{ + tableLabel: 'Resources', + expandButtonLabel: (item, expanded) => `${expanded ? 'Collapse' : 'Expand'} ${item.name}`, + expandedRegionLabel: item => `${item.name} details`, + }} + > + + Name + + Status + + + + {(item: Item, api) => ( + + {item.name} + {item.status} + +
+ Details +

{item.detail}

+
+
+
+ )} +
+
+
+ + ); +} 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..39c995db98 --- /dev/null +++ b/src/test-utils/dom/virtual-table/index.ts @@ -0,0 +1,51 @@ +// 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: the 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 variant too. + return this.find(`.${styles['expanded-row']}[aria-rowindex="${dataIndex + 2}"] .${styles['expanded-region']}`); + } + + findColumnHeaders(): Array { + return this.findAllByClassName(styles['header-cell']); + } + + /** The header row element (returned regardless of the `sticky` prop). */ + 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..7ec3629565 --- /dev/null +++ b/src/virtual-table/USAGE.md @@ -0,0 +1,271 @@ + + + +# 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 compound component. You assemble it from a small set of parts — +`VirtualTable.Root` wraps a `VirtualTable.Header` of `VirtualTable.HeaderCell`s and +a `VirtualTable.Body`. The body takes a row template, a function that returns a +`VirtualTable.Row` of `VirtualTable.Cell`s and an optional +`VirtualTable.ExpandedContent`. Root invokes that template only for the rows in +view and 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. The expanded content is a first-class part you compose + into the row, so it's a natural home for arbitrary detail. +- Use the compound shape when you want to own how each row and its expanded content + are assembled — the parts read like markup and keep complex expanded content next + to the row it belongs to. +- 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 + +Structure and columns + +- Declare each column once as a header cell, and match its body cell by the same + column id. The header set is the single source of truth for column order and + count, so the two always line up. +- Keep the row template a plain function of the row and its row info. Compose the + cells and the expanded content from those inputs, and don't reach for state or + effects inside it. +- 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 + +- Add the expanded content part to the row template whenever a row can expand, and + keep it there unconditionally — Root decides when to mount it. Leaving it out or + hiding it behind the row's open state disables 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, such as wrapping lines. +- Compute any cross-row scale, such as a shared chart maximum, once over the full + dataset so it stays stable as the user scrolls. The row info gives you the + dataset position and size, not the rows currently in view, so derive shared + values from your own full dataset. + +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 hand-write a row per item — the body + template is called only for the rows in view, and materializing every row defeats + the purpose. +- Don't put state, effects, or other hooks inside the row template. It runs inside + the windowed render loop for each visible row; keep hooks in the components the + template renders. +- 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 + +### Composition + +VirtualTable is assembled from parts rather than configured through one large prop +set. Root owns the grid state and the scroll container; the header cells declare +the columns; the body template describes a single row and its expanded content. +Because the parts nest like the markup they produce, complex rows — especially ones +with rich expanded content — stay readable and live next to the row they belong to. + +### 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. +The body template is invoked only for the rows in view. 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. You add it +as the expanded content part of the row template. 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 declared once by the header cells 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-A2.md`. VirtualTable is exported as a namespace of +compound parts: `VirtualTable.Root`, `Header`, `HeaderCell`, `Body`, `Row`, `Cell`, +and `ExpandedContent`. In summary: + +- Data and identity: `Root` takes `items` and a required `trackBy` — a stable key + is mandatory because row DOM is recycled. +- Columns: each `HeaderCell` declares a `columnId` and optional `stretch`, + `sortingField`, `width`, and `minWidth`; body `Cell`s bind to a header by + `columnId`. The header set is the single column authority. +- Body template: `Body` takes a function `(item, api) => ` that Root + invokes only for windowed rows. The `api` carries `rowIndex`, `totalItemCount`, + and `isExpanded` — the dataset position and size, not the window slice. +- Row expansion: add `ExpandedContent` (with an `estimatedHeight`, or a rare + `fixedHeight`) as a child of the row template to enable the leading disclosure + column and render arbitrary non-tabular detail; expansion state is on `Root` via + `expandedItems` / `defaultExpandedItems` / `onExpandChange`. +- Virtualization: `estimatedRowHeight`, `getRowHeight` (fixed px or `"auto"` to + measure a data row so wrapping lines window correctly), `overscan`, and + `onVisibleRangeChange`. +- 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: `Header`'s `sticky`, plus `Root`'s `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. + +Some behaviors worth calling out for implementers: + +- Row template contract: the body template must be a pure, hook-free function of + `(item, api)`, must forward both to its ``, and must declare + `` unconditionally when a row can expand — Root + decides when to mount it. Gating the expanded content on the row's open state + disables the disclosure column and expansion. +- Typing the template: because `Body` is its own component, the row `item` type + doesn't flow automatically from `Root`'s `items` through the namespace. Annotate + the template parameter (`(item: LogRecord, api) => …`) so the row reads with full + types. +- 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 compound table. +- `cloudwatch-standard.page.tsx` — the CloudWatch Logs Insights standard view: + dynamic columns, a stretch-last message column, expanded log-record detail as a + compound child, and consumer-composed live tail. +- `cloudwatch-raw.page.tsx` — the raw / file view: a single wrapping line column + with no expanded content, 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..ad757f7515 --- /dev/null +++ b/src/virtual-table/__tests__/use-expansion.test.tsx @@ -0,0 +1,73 @@ +// 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'; + +// Root owns the R-EXPAND expansion state for the compound VirtualTable. These tests pin the +// controlled/uncontrolled contract and the stable signature the windowing layout depends on. + +interface Item { + id: string; +} + +const trackBy = (item: Item) => item.id; +const a: Item = { id: 'a' }; + +describe('VirtualTable (F1-A2 compound) 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..ad68b09722 --- /dev/null +++ b/src/virtual-table/__tests__/use-virtual-model.test.tsx @@ -0,0 +1,189 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { act, renderHook } from '../../__tests__/render-hook'; +import { useVirtualModel } from '../use-virtual-model'; + +// The compound F1-A2 core drives the same windowing + opt-in measurement engine the +// config-driven cell uses: Root owns one inner scroll container, data rows are fixed at the +// density estimate (no measurement cost) unless getRowHeight opts them into 'auto', and only +// expanded regions / opted-in rows are measured, with anchor-based offset correction so +// measured growth above the fold does not shift the viewport. jsdom has no layout, so the +// DOM-driven paths are exercised with a controllable ResizeObserver and a fake scroll +// container whose scrollTop/clientHeight/scrollHeight are stubbed (viewport falls back to +// 600px when clientHeight is 0). + +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 (F1-A2 compound) useVirtualModel', () => { + test('windows a large dataset to far fewer rows than it holds', () => { + const items = makeItems(1000); + const { result } = renderModel({ items, estimatedRowHeight: 20, overscan: 5 }); + expect(result.current.slots.length).toBeGreaterThan(0); + expect(result.current.slots.length).toBeLessThan(items.length); + expect(result.current.firstIndex).toBe(0); + // Window is bounded: 600px viewport / 20px rows = 30 visible + 5 overscan = last data index 35. + expect(result.current.lastIndex).toBe(35); + expect(result.current.slots.every(slot => slot.type === 'data')).toBe(true); + // Fixed rows: total runway is a simple product, independent of the window. + expect(result.current.totalSize).toBe(1000 * 20); + }); + + test('recomputes the visible range when the container scrolls', () => { + const container = makeContainer(); + const { result } = renderModel({ items: makeItems(1000), estimatedRowHeight: 20, overscan: 5, container }); + expect(result.current.firstIndex).toBe(0); + + // Scroll to offset 4000 (row 200). The scroll listener re-reads scrollTop and the window + // recomputes: firstVisible 200 - 5 overscan = 195; lastVisible 230 + 5 = 235. + act(() => { + container.scrollTop = 4000; + container.dispatchEvent(new Event('scroll')); + }); + expect(result.current.firstIndex).toBe(195); + expect(result.current.lastIndex).toBe(235); + }); + + test('inserts an expanded slot immediately after its data row and grows the runway by its height', () => { + const items = makeItems(50); + const { result } = renderModel({ + items, + expandedIds: new Set(['0']), + expandedSignature: '0', + getExpandedRowHeight: () => 120, + }); + const expandedSlot = result.current.slots.find(slot => slot.type === 'expanded'); + expect(expandedSlot).toBeDefined(); + expect(expandedSlot!.index).toBe(0); + expect(result.current.totalSize).toBe(50 * 20 + 120); + }); + + test('does not observe fixed-height rows but does observe auto rows, and applies the measured height', () => { + const items = makeItems(10); + const { result } = renderModel({ items, estimatedRowHeight: 20, getRowHeight: () => 'auto' }); + expect(result.current.totalSize).toBe(10 * 20); + + const before = observers.length; + // observers[0] is the engine's own viewport ResizeObserver (attached on mount); `before` + // is captured after mount so any new observer here is the measurement one. A fixed row + // (auto=false) is never observed — it never pays measurement cost. + act(() => result.current.measureRef('d:0', false)(stubHeight(55))); + expect(observers.length).toBe(before); + + // An auto data row is observed (the F1-A2 getRowHeight='auto' path that lets the raw view + // wrap long lines); firing the observer applies the real height and reflows the runway. + act(() => result.current.measureRef('d:0', true)(stubHeight(55))); + expect(observers.length).toBe(before + 1); + act(() => observers[observers.length - 1].cb([], observers[observers.length - 1] as unknown as ResizeObserver)); + expect(result.current.totalSize).toBe(55 + 9 * 20); + }); + + test('scrollToIndex positions the container at the row start', () => { + const container = makeContainer(); + const { result } = renderModel({ items: makeItems(100), estimatedRowHeight: 20, container }); + act(() => result.current.scrollToIndex(10)); + expect(container.scrollTop).toBe(10 * 20); + }); + + test('scrollToEnd pins the container to the bottom of the runway', () => { + const container = makeContainer({ scrollHeight: 1234 }); + const { result } = renderModel({ items: makeItems(100), container }); + act(() => result.current.scrollToEnd()); + expect(container.scrollTop).toBe(1234); + }); + + test('isPinnedToEnd reflects whether the viewport is at the bottom edge', () => { + const pinned = makeContainer({ clientHeight: 100, scrollHeight: 100, scrollTop: 0 }); + expect(renderModel({ items: makeItems(100), container: pinned }).result.current.isPinnedToEnd()).toBe(true); + + const scrolledUp = makeContainer({ clientHeight: 100, scrollHeight: 1000, scrollTop: 0 }); + expect(renderModel({ items: makeItems(100), container: scrolledUp }).result.current.isPinnedToEnd()).toBe(false); + }); +}); diff --git a/src/virtual-table/__tests__/virtual-table-a11y.test.tsx b/src/virtual-table/__tests__/virtual-table-a11y.test.tsx new file mode 100644 index 0000000000..f5e1cab668 --- /dev/null +++ b/src/virtual-table/__tests__/virtual-table-a11y.test.tsx @@ -0,0 +1,447 @@ +// 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 COMPOUND VirtualTable (impl-F1-A2-tests-a11y). Placement mirrors the +// F1-A1 tests-a11y decision: the `__a11y__` directory is the node/puppeteer integ runner +// (`.ts`-only, tsconfig.integ), so axe + RTL keyboard/SR `.tsx` tests belong under +// `__tests__/` where the unit runner collects them — its testRegex +// `(/__tests__/.*(\.|/)test)\.[jt]sx?$` matches `virtual-table-a11y.test.tsx` and ts-jest +// transforms the `.tsx` via `tsconfig.unit.json`, so `-pr` runs it as part of `npm test`. +// +// The central a11y claim for the compound API is that the Root/Header/HeaderCell/Body/Row/ +// Cell/ExpandedContent composition produces the SAME accessibility tree as a single monolithic +// grid: one role="grid" that owns only rowgroups -> rows -> columnheader/gridcell, with the +// materialised disclosure column, full-dataset aria-rowcount/rowindex + aria-colcount/colindex +// coherence, a labeled expanded region, and non-row chrome (loading/empty/live-region) OUTSIDE +// the grid. Coverage: axe/HTML validity, the compound-produces-one-grid contract, row-granular +// keyboard nav via aria-activedescendant (incl. ArrowLeft/Right inert), sort-trigger a11y +// (keyboard-operable + accessible name + aria-sort), disclosure activation, Tab-in / Escape-out +// of the expanded region, live-append debounce, and focus-under-recycling. + +interface Item { + id: string; + name: string; + status: string; +} + +const makeItems = (n: number): Item[] => + Array.from({ length: n }, (_, i) => ({ id: `row-${i}`, name: `Resource ${i}`, status: i % 2 === 0 ? 'Up' : 'Down' })); + +// Stable identity so useLiveAnnouncement's effect does not re-run (and reset coalescing) on +// unrelated rerenders — the compound Root threads this straight into the live-region hook. +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}`, + activateSortLabel: columnId => `Sort by ${columnId}`, + appendAnnouncement, +}; + +interface TreeOptions { + expandable?: boolean; + sortable?: boolean; + singleColumn?: boolean; + estimatedRowHeight?: number; + overscan?: number; + getRowHeight?: (item: Item) => number | 'auto'; + expandedItems?: ReadonlyArray; + onExpandChange?: VirtualTableProps['onExpandChange']; + sortingColumn?: { columnId: string }; + sortingDescending?: boolean; + onSortingChange?: VirtualTableProps['onSortingChange']; + imperativeRef?: React.Ref; + loading?: boolean; + loadingText?: string; + empty?: React.ReactNode; +} + +// A single tree builder used for both initial render and rerender so ariaLabels (and thus the +// live-region hook identity) stays stable across renders. +function buildTree(items: Item[], options: TreeOptions = {}) { + const detail = (item: Item) => ( +
+

Log record {item.id}

+
+
Level
+
INFO
+
Message
+
{item.name}
+
+ +
+ ); + + return ( + item.id} + estimatedRowHeight={options.estimatedRowHeight ?? 23} + overscan={options.overscan} + getRowHeight={options.getRowHeight} + expandedItems={options.expandedItems} + onExpandChange={options.onExpandChange} + sortingColumn={options.sortingColumn} + sortingDescending={options.sortingDescending} + onSortingChange={options.onSortingChange} + imperativeRef={options.imperativeRef} + loading={options.loading} + loadingText={options.loadingText} + empty={options.empty} + ariaLabels={ariaLabels} + > + + + Name + + {!options.singleColumn && ( + + Status + + )} + + + {(item: Item, api) => ( + + {item.name} + {!options.singleColumn && {item.status}} + {/* ExpandedContent is declared UNCONDITIONALLY (not gated on api.isExpanded) per the + BodyProps contract; Root mounts the region only for expanded rows. */} + {options.expandable && ( + {detail(item)} + )} + + )} + + + ); +} + +function renderTable(items: Item[], options: TreeOptions = {}) { + const { container, rerender } = render(buildTree(items, options)); + const wrapper = createWrapper(container).findVirtualTable()!; + const update = (nextItems: Item[], nextOptions: TreeOptions = options) => rerender(buildTree(nextItems, nextOptions)); + return { container, wrapper, update }; +} + +function getGrid(container: HTMLElement): HTMLElement { + return container.querySelector('[role="grid"]') as HTMLElement; +} + +// Data columns (name + status); the disclosure column, when present, is counted on top. +const DATA_COLUMNS = 2; + +describe('VirtualTable F1-A2 a11y', () => { + describe('axe / HTML validity', () => { + test('validates a plain compound grid without expansion', async () => { + const { container } = renderTable(makeItems(20)); + await expect(container).toValidateA11y(); + }); + + test('validates a grid with a collapsed disclosure column', async () => { + const { container } = renderTable(makeItems(20), { expandable: true }); + await expect(container).toValidateA11y(); + }); + + test('validates a grid with an expanded, labeled region containing arbitrary content', async () => { + const { container } = renderTable(makeItems(20), { expandable: true, expandedItems: ['row-0', 'row-3'] }); + await expect(container).toValidateA11y(); + }); + + test('validates sortable headers (native sort triggers + aria-sort)', async () => { + const { container } = renderTable(makeItems(20), { sortable: true, sortingColumn: { columnId: 'name' } }); + await expect(container).toValidateA11y(); + }); + + test('validates the reduced single-column (file/raw) shape', async () => { + const { container } = renderTable(makeItems(50), { + singleColumn: true, + estimatedRowHeight: 20, + overscan: 40, + getRowHeight: item => (item.name.length > 120 ? 'auto' : 20), + }); + await expect(container).toValidateA11y(); + }); + + test('validates the empty and loading states', async () => { + const empty = renderTable([], { empty: No log events }); + await expect(empty.container).toValidateA11y(); + + const loading = renderTable([], { loading: true, loadingText: 'Loading log events' }); + await expect(loading.container).toValidateA11y(); + }); + }); + + describe('compound structure produces a single grid accessibility tree', () => { + test('the seven compound sub-components collapse to one grid -> rowgroup -> row tree', () => { + const { container } = renderTable(makeItems(20), { expandable: true, expandedItems: ['row-0'] }); + // Exactly one grid, regardless of how many compound elements were authored. + expect(container.querySelectorAll('[role="grid"]')).toHaveLength(1); + const grid = getGrid(container); + + // The grid owns ONLY rowgroups (grid -> rowgroup -> row -> columnheader/gridcell); a + // compound Header/Body still yields a header rowgroup + a body rowgroup, same as a + // monolithic grid. + const directChildren = Array.from(grid.children); + expect(directChildren.length).toBeGreaterThan(0); + directChildren.forEach(child => expect(child.getAttribute('role')).toBe('rowgroup')); + + // Every row lives under a rowgroup, and every columnheader/gridcell under a row. + grid.querySelectorAll('[role="row"]').forEach(row => { + expect(row.closest('[role="rowgroup"]')).not.toBeNull(); + }); + grid.querySelectorAll('[role="columnheader"], [role="gridcell"]').forEach(cell => { + expect(cell.closest('[role="row"]')).not.toBeNull(); + }); + }); + + test('non-row chrome (loading / live-region) is a sibling of the grid, never a grid child', () => { + const { wrapper } = renderTable([], { loading: true, loadingText: 'Loading' }); + const status = wrapper.find('[role="status"]')!.getElement(); + // A status node inside role=grid would be an invalid grid child; it must sit outside. + expect(status.closest('[role="grid"]')).toBeNull(); + + const live = renderTable(makeItems(5)).wrapper.findLiveRegion()!.getElement(); + expect(live.closest('[role="grid"]')).toBeNull(); + }); + }); + + describe('keyboard navigation (aria-activedescendant grid model)', () => { + test('the grid is a single always-present tab stop and seeds an active descendant', () => { + const { container, wrapper } = renderTable(makeItems(20)); + const grid = getGrid(container); + expect(grid.getAttribute('tabindex')).toBe('0'); + const firstRow = wrapper.findRowByIndex(0)!.getElement(); + expect(grid.getAttribute('aria-activedescendant')).toBe(firstRow.id); + expect(document.getElementById(firstRow.id)).not.toBeNull(); + }); + + test('Arrow/Home/End move the active row when the grid holds focus', () => { + const { container, wrapper } = renderTable(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(makeItems(20), { expandable: true, expandedItems: ['row-0'] }); + const grid = getGrid(container); + const before = grid.getAttribute('aria-activedescendant'); + + const innerButton = wrapper.findExpandedRegion(0)!.getElement().querySelector('button')!; + fireEvent.keyDown(innerButton, { key: 'ArrowDown' }); + + // onGridKeyDown bails unless the grid container itself is the event target, so arbitrary + // expanded content (goal 6) keeps its own arrow-key behaviour. + expect(grid.getAttribute('aria-activedescendant')).toBe(before); + }); + + test('ArrowLeft / ArrowRight are inert (deliberate row-granular grid, not 2D cell nav)', () => { + // Like F1-A1, the compound grid is deliberately row-granular: aria-activedescendant + // references a role="row" (never a gridcell) and onGridKeyDown handles only + // Up/Down/Home/End. A legitimate APG row-as-widget variant of role="grid"; the horizontal + // cell-navigation keys are intentionally inert. Carried to impl-F1-A2-docs as an explicit + // a11y contract note. + const { container, wrapper } = renderTable(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('sort trigger accessibility', () => { + test('sortable headers expose a keyboard-operable trigger with an accessible name; non-sortable do not', () => { + const { wrapper } = renderTable(makeItems(10), { sortable: true }); + const [nameHeader, statusHeader] = wrapper.findColumnHeaders(); + + const sortButton = nameHeader.find('button')!.getElement(); + // A native + ) : ( + children + )} +
+ ); +} + +// Body is a carrier for the row-template function child. Root reads its child and invokes it +// per windowed item; Body itself never renders (Root intercepts). +const Body = (() => null) as (props: VirtualTableProps.BodyProps) => React.ReactElement | null; + +function Cell({ columnId, children }: VirtualTableProps.CellProps) { + const ctx = useVirtualTableContext('Cell'); + return ( + + {children} + + ); +} + +// ExpandedContent is a descriptor Row reads and hoists into a sibling expanded row; it does +// not render on its own. +const ExpandedContent = (() => null) as (props: VirtualTableProps.ExpandedContentProps) => React.ReactElement | null; + +function Row({ item, api, children }: VirtualTableProps.RowProps) { + const ctx = useVirtualTableContext('Row'); + const id = ctx.trackBy(item); + const expanded = ctx.isExpanded(id); + const position = ctx.positionOf(id); + + // Partition the row template's children: keep Cells by columnId; an ExpandedContent child + // is hoisted into a sibling expanded row ("declared in the row, positioned as a sibling + // row"). Unknown columnIds are dev-warned and dropped (header<->body reconciliation). + const cellByColumnId = new Map(); + const childList = React.Children.toArray(children).filter(React.isValidElement); + const expandedContent = + (childList.find(child => child.type === ExpandedContent) as + | React.ReactElement + | undefined) ?? null; + childList.forEach(child => { + if (child.type === Cell) { + const columnId = (child.props as VirtualTableProps.CellProps).columnId; + if (ctx.columns.some(column => column.columnId === columnId)) { + cellByColumnId.set(columnId, child); + } else { + warnOnce('VirtualTable', `Cell with columnId "${columnId}" has no matching HeaderCell and was not rendered.`); + } + } + }); + + // Place cells in the header column order; a column with no Cell renders an empty gridcell so + // the row stays a coherent rectangle and aria-colindex never diverges from the header. + const orderedCells = ctx.columns.map(column => { + const cell = cellByColumnId.get(column.columnId); + if (cell) { + return {cell}; + } + return ( + + ); + }); + + const regionId = ctx.regionId(id); + const toggleId = ctx.toggleId(id); + // Data rows are the header row (aria-rowindex 1) + the 1-based dataset index -> api.rowIndex + 1. + const dataAriaRowIndex = api.rowIndex + 1; + const isActive = ctx.activeDescendantId === id; + // Only the arbitrary expanded region is measured; a fixedHeight opts out. + const expandedAuto = expandedContent ? expandedContent.props.fixedHeight === undefined : true; + // A row in the expanded set that rendered no ExpandedContent means the template gated + // ExpandedContent on api.isExpanded (see BodyProps): fail loudly instead of silently + // showing nothing (B1). + if (expanded && !expandedContent) { + warnOnce( + 'VirtualTable', + 'A row is expanded but its row template rendered no . ' + + 'Declare ExpandedContent unconditionally (do not gate it on api.isExpanded); Root mounts it only for expanded rows.' + ); + } + // Data rows are fixed unless the consumer opts this row into measurement via getRowHeight. + const dataAuto = ctx.getRowHeight ? ctx.getRowHeight(item) === 'auto' : false; + + const dataStyle: React.CSSProperties | undefined = position + ? { position: 'absolute', insetBlockStart: position.dataStart, insetInlineStart: 0, insetInlineEnd: 0 } + : undefined; + const expandedStyle: React.CSSProperties | undefined = + position && position.expandedStart !== undefined + ? { position: 'absolute', insetBlockStart: position.expandedStart, insetInlineStart: 0, insetInlineEnd: 0 } + : undefined; + + return ( + <> +
+ {ctx.hasDisclosureColumn && ( + + {expandedContent ? ( +
+ + {expanded && expandedContent && ( + // The expanded region belongs to its data row: it shares that row's aria-rowindex and + // is NOT added to aria-rowcount (design B1). A real row -> full-width gridcell -> + // labeled region keeps a valid grid child model (design B2). The row is measured so + // its variable height windows correctly. +
+ {/* A div (not a span) holds the arbitrary, possibly block-level expanded content — + carry-forward from the F1-A1 -pr HTML-validity fix. */} +
+
{ + if (event.key === 'Escape') { + event.stopPropagation(); + document.getElementById(toggleId)?.focus(); + } + }} + > + {expandedContent.props.children} +
+
+
+ )} + + ); +} + +// --- Column authority derivation ------------------------------------------------------ + +function deriveColumns(headerElement: React.ReactElement | null): DerivedColumn[] { + if (!headerElement) { + return []; + } + const columns: DerivedColumn[] = []; + React.Children.forEach(headerElement.props.children, child => { + if (React.isValidElement(child) && child.type === HeaderCell) { + const props = child.props as VirtualTableProps.HeaderCellProps; + columns.push({ + columnId: props.columnId, + stretch: props.stretch, + width: props.width, + minWidth: props.minWidth, + sortingField: props.sortingField, + }); + } + }); + return columns; +} + +// Fixed-layout column sizing: an explicit width pins the column; the stretch column fills +// remaining space; otherwise columns share space. +function cellStyle( + column: DerivedColumn | undefined, + columnWidths: Record | undefined, + stretch: boolean +): React.CSSProperties { + const width = (column && columnWidths?.[column.columnId]) ?? 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 }; +} + +// --- Root ----------------------------------------------------------------------------- + +interface InternalRootProps extends VirtualTableProps, InternalBaseComponentProps {} + +function InternalRoot({ + items, + trackBy, + role = 'grid', + estimatedRowHeight = 40, + getRowHeight, + overscan = 10, + onVisibleRangeChange, + header, + empty, + loading = false, + loadingText, + expandedItems, + defaultExpandedItems, + onExpandChange, + sortingColumn, + sortingDescending, + onSortingChange, + columnWidths, + ariaLabels, + imperativeRef, + children, + __internalRootRef, + ...props +}: InternalRootProps) { + const baseProps = getBaseProps(props); + const baseId = useUniqueId('virtual-table'); + const scrollContainerRef = useRef(null); + + // Partition the compound children into Header + Body. + const rootChildren = React.Children.toArray(children).filter(React.isValidElement); + const headerElement = + (rootChildren.find(child => child.type === Header) as + | React.ReactElement + | undefined) ?? null; + const bodyElement = + (rootChildren.find(child => child.type === Body) as + | React.ReactElement> + | undefined) ?? null; + + const expansion = useExpansion({ trackBy, expandedItems, defaultExpandedItems, onExpandChange }); + + // Probe the row template ONCE with the first item to read template-level facts that must be + // stable across windowing: whether the template renders an ExpandedContent child (enables + // the disclosure column) and the expanded region's height strategy (fixed vs measured, and + // the pre-measurement runway estimate — CloudWatch ~300 / ~150). Deriving these from a + // stable probe rather than the windowed row set stops the disclosure column from flickering + // as rows scroll in and out. + const probe = useMemo(() => { + if (!bodyElement || items.length === 0) { + return { + hasDisclosureColumn: false, + fixedHeight: undefined as number | undefined, + estimatedHeight: undefined as number | undefined, + }; + } + const element = bodyElement.props.children(items[0], { + rowIndex: 1, + totalItemCount: items.length, + isExpanded: false, + }); + let hasDisclosureColumn = false; + let fixedHeight: number | undefined; + let estimatedHeight: number | undefined; + React.Children.forEach(element.props.children, child => { + if (React.isValidElement(child) && child.type === ExpandedContent) { + hasDisclosureColumn = true; + const expandedProps = child.props as VirtualTableProps.ExpandedContentProps; + fixedHeight = expandedProps.fixedHeight; + estimatedHeight = expandedProps.estimatedHeight; + } + }); + return { hasDisclosureColumn, fixedHeight, estimatedHeight }; + }, [bodyElement, items]); + + const model = useVirtualModel({ + items, + trackBy, + expandedIds: expansion.expandedIds, + expandedSignature: expansion.expandedSignature, + estimatedRowHeight, + getRowHeight, + getExpandedRowHeight: () => probe.fixedHeight ?? 'auto', + defaultExpandedEstimate: probe.estimatedHeight, + overscan, + scrollContainerRef, + }); + + // Fire the visible-range change when the windowed data range moves. + useEffect(() => { + if (model.firstIndex >= 0) { + fireNonCancelableEvent(onVisibleRangeChange, { firstIndex: model.firstIndex, lastIndex: model.lastIndex }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [model.firstIndex, model.lastIndex]); + + const columns = deriveColumns(headerElement); + const hasDisclosureColumn = probe.hasDisclosureColumn; + const dataColumnStart = hasDisclosureColumn ? 2 : 1; + const columnCount = columns.length + (hasDisclosureColumn ? 1 : 0); + const stretchColumnId = [...columns].reverse().find(column => column.stretch)?.columnId; + const columnIndex = useMemo( + () => new Map(columns.map((column, index) => [column.columnId, dataColumnStart + index])), + // eslint-disable-next-line react-hooks/exhaustive-deps + [columns.map(column => column.columnId).join('\u0000'), dataColumnStart] + ); + + // Absolute positions for the windowed slots, keyed by row id. + const positions = useMemo(() => { + const map = new Map(); + for (const slot of model.slots) { + const id = trackBy(items[slot.index]); + const entry = map.get(id) ?? { dataStart: 0 }; + if (slot.type === 'data') { + entry.dataStart = slot.start; + } else { + entry.expandedStart = slot.start; + } + map.set(id, entry); + } + return map; + }, [model.slots, items, trackBy]); + + // Roving active descendant: the scroll container is the single tab stop and references the + // active row while it is windowed, so the grid is Tab-reachable at any scroll offset (design + // B3). Arrow/Home/End move the active row and scroll it into view. + const [activeId, setActiveId] = useState(null); + const effectiveActiveId = activeId ?? (items.length > 0 ? trackBy(items[0]) : null); + const windowedIds = useMemo(() => { + const set = new Set(); + for (const slot of model.slots) { + if (slot.type === 'data') { + set.add(trackBy(items[slot.index])); + } + } + return set; + }, [model.slots, items, trackBy]); + const activeDescendantId = effectiveActiveId && windowedIds.has(effectiveActiveId) ? effectiveActiveId : null; + + const moveActive = useCallback( + (nextIndex: number) => { + const clamped = Math.max(0, Math.min(items.length - 1, nextIndex)); + if (clamped < 0 || items.length === 0) { + return; + } + setActiveId(trackBy(items[clamped])); + model.scrollToIndex(clamped); + }, + [items, trackBy, model] + ); + + const onGridKeyDown = useCallback( + (event: React.KeyboardEvent) => { + // Only act when the grid container itself is focused; never steal Arrow/Home/End from + // interactive cell content or the arbitrary expanded region (design B2, MASTER goal 6). + if (event.target !== event.currentTarget) { + return; + } + const current = effectiveActiveId ? items.findIndex(item => trackBy(item) === effectiveActiveId) : -1; + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + moveActive(current + 1); + break; + case 'ArrowUp': + event.preventDefault(); + moveActive(current - 1); + break; + case 'Home': + event.preventDefault(); + moveActive(0); + break; + case 'End': + event.preventDefault(); + moveActive(items.length - 1); + break; + } + }, + [effectiveActiveId, items, trackBy, moveActive] + ); + + // Reflect-not-sort: a HeaderCell sort trigger computes the next sort intent (toggle + // direction on the active column, otherwise start ascending) and emits it; the consumer + // applies the sort to `items`. VirtualTable never reorders the data itself. + const handleSort = useCallback( + (columnId: string) => { + const nextDescending = sortingColumn?.columnId === columnId ? !sortingDescending : false; + fireNonCancelableEvent(onSortingChange, { columnId, sortingDescending: nextDescending }); + }, + [sortingColumn, sortingDescending, onSortingChange] + ); + + 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] + ); + + const liveMessage = useLiveAnnouncement(items.length, ariaLabels?.appendAnnouncement); + + // Invoke the row template ONLY for windowed data slots. + const rowElements = + bodyElement && !loading && items.length > 0 + ? model.slots + .filter(slot => slot.type === 'data') + .map(slot => { + const item = items[slot.index]; + const element = bodyElement!.props.children(item, { + rowIndex: slot.index + 1, + totalItemCount: items.length, + isExpanded: expansion.isExpanded(trackBy(item)), + }); + return React.cloneElement(element, { key: trackBy(item) }); + }) + : []; + + const contextValue: VirtualTableContextValue = { + baseId, + hasDisclosureColumn, + columnCount, + columns, + columnIndexOf: (columnId: string) => columnIndex.get(columnId) ?? dataColumnStart, + columnStyleOf: (columnId: string) => { + const column = columns.find(candidate => candidate.columnId === columnId); + return cellStyle(column, columnWidths, columnId === stretchColumnId); + }, + ariaSortOf: (columnId: string) => { + const column = columns.find(candidate => candidate.columnId === columnId); + if (!column?.sortingField) { + return undefined; + } + return sortingColumn?.columnId === columnId ? (sortingDescending ? 'descending' : 'ascending') : 'none'; + }, + onSort: handleSort, + activateSortLabel: ariaLabels?.activateSortLabel, + positionOf: (id: string) => positions.get(id), + measureRef: model.measureRef, + getRowHeight, + activeDescendantId, + ariaLabels: { + expandButtonLabel: ariaLabels?.expandButtonLabel, + expandedRegionLabel: ariaLabels?.expandedRegionLabel, + }, + trackBy, + isExpanded: expansion.isExpanded, + toggle: expansion.toggle, + rowDomId: (id: string) => `${baseId}-row-${id}`, + toggleId: (id: string) => `${baseId}-toggle-${id}`, + regionId: (id: string) => `${baseId}-region-${id}`, + }; + + const showEmpty = !loading && items.length === 0; + const headerSticky = headerElement?.props.sticky ?? false; + + 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. The scroll container is the single owned + windowing viewport and the single tab stop (aria-activedescendant roving, design B3). + */} +
+
+ {headerElement} +
+ + {!loading && !showEmpty && ( +
+ {rowElements} +
+ )} +
+ + {loading && ( +
+ {loadingText} +
+ )} + + {showEmpty &&
{empty}
} + +
+ {liveMessage} +
+
+
+ ); +} + +export { InternalRoot, Header, HeaderCell, Body, Row, Cell, ExpandedContent }; diff --git a/src/virtual-table/styles.scss b/src/virtual-table/styles.scss new file mode 100644 index 0000000000..130d593391 --- /dev/null +++ b/src/virtual-table/styles.scss @@ -0,0 +1,150 @@ +/* + 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%; + &:focus-visible { + outline: 2px solid awsui.$color-border-item-focused; + outline-offset: -2px; + } +} + +.header-rowgroup { + display: block; +} + +.header-row { + display: flex; +} + +// Sticky header is opt-in; the header stays inside the single scroll container so it +// scrolls horizontally with the body (CW-14). scaffold: gated wiring lands in core. +.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; +} + +// impl-F1-A2-core: the body is the windowing runway — Root sets its block-size to the full +// virtualized height and positions each windowed row absolutely at its measured offset. +.body { + display: block; + position: relative; + inline-size: 100%; +} + +.row, +.expanded-row { + display: flex; + inline-size: 100%; + box-sizing: border-box; +} + +.row { + align-items: center; + &:focus-visible { + outline: 2px solid awsui.$color-border-item-focused; + outline-offset: -2px; + } +} + +// The row referenced by aria-activedescendant while the grid container holds focus. +.row-active { + outline: 2px solid awsui.$color-border-item-focused; + outline-offset: -2px; +} + +.cell { + min-inline-size: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding-block: awsui.$space-scaled-xxs; + padding-inline: awsui.$space-scaled-xs; + box-sizing: border-box; +} + +.disclosure-button { + @include styles.styles-reset; + cursor: pointer; + inline-size: 100%; + block-size: 100%; +} + +// Sort trigger for a sortable HeaderCell: a native button (implicitly keyboard-operable) +// that fills the header cell and inherits the bold header typography. +.sort-button { + @include styles.styles-reset; + cursor: pointer; + display: inline-flex; + align-items: center; + inline-size: 100%; + text-align: start; + font-weight: awsui.$font-weight-bold; + &:focus-visible { + outline: 2px solid awsui.$color-border-item-focused; + outline-offset: -2px; + } +} + +.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..e4ed73a884 --- /dev/null +++ b/src/virtual-table/use-expansion.ts @@ -0,0 +1,68 @@ +// 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. Root owns +// this state (design: Root, not the sub-components, owns grid state). 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..5fd4dfddd3 --- /dev/null +++ b/src/virtual-table/use-live-announcement.ts @@ -0,0 +1,57 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { useEffect, useRef, useState } from 'react'; + +const APPEND_DEBOUNCE_MS = 500; + +// Coalesces streaming appends into a single polite announcement per burst. 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 zero-width space when the text repeats so the SR re-announces. + setMessage(prev => (prev === next ? next + '\u200b' : next)); + } + }, APPEND_DEBOUNCE_MS); + }, [totalCount, appendAnnouncement]); + + useEffect(() => { + return () => { + if (timer.current) { + clearTimeout(timer.current); + } + }; + }, []); + + return message; +} diff --git a/src/virtual-table/use-virtual-model.ts b/src/virtual-table/use-virtual-model.ts new file mode 100644 index 0000000000..e6e8bab6e8 --- /dev/null +++ b/src/virtual-table/use-virtual-model.ts @@ -0,0 +1,307 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; + +// Windowing + opt-in measurement engine for the compound VirtualTable (impl-F1-A2-core). +// This is the SAME behaviour/a11y engine the config-driven cell uses — Root owns a single +// inner scroll container (design B4), the body windows within it, data rows are fixed at +// `estimatedRowHeight` (23 at CloudWatch density), and only the expanded regions are +// measured (design B5) so a 100k fixed-row dataset never pays ResizeObserver cost. Measured +// growth of a region 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). The compound Root reads slot positions + `measureRef` from here and hands them +// to each windowed Row through context so the Row positions and measures its own DOM. + +const DEFAULT_EXPANDED_ESTIMATE = 200; +const DEFAULT_VIEWPORT = 600; + +// A row slot is either a data row or the expanded region row that follows it. The expanded +// slot is a real grid row in the DOM (design B2) but does not consume a data-row +// aria-rowindex (design B1) — the index sequence tracks data rows only. +export interface RowSlot { + type: 'data' | 'expanded'; + index: number; + key: string; + auto: boolean; +} + +export interface PositionedSlot extends RowSlot { + start: number; + size: number; +} + +interface UseVirtualModelParams { + items: ReadonlyArray; + trackBy: (item: T) => string; + expandedIds: ReadonlySet; + expandedSignature: string; + estimatedRowHeight: number; + /** Height strategy for a DATA row: a fixed px value or 'auto' (measured). Omit -> fixed at estimatedRowHeight. */ + getRowHeight?: (item: T) => number | 'auto'; + /** Height strategy for the expanded region of an item: a fixed px value or 'auto' (measured). */ + getExpandedRowHeight?: (item: T) => number | 'auto'; + /** Pre-measurement runway estimate for an 'auto' expanded region (CloudWatch ~300 / ~150). */ + defaultExpandedEstimate?: number; + overscan: number; + scrollContainerRef: React.RefObject; +} + +export interface VirtualModel { + slots: PositionedSlot[]; + totalSize: number; + firstIndex: number; + lastIndex: number; + measureRef: (key: string, auto: boolean) => (node: HTMLElement | null) => void; + scrollToEnd: () => void; + scrollToIndex: (index: number) => void; + isPinnedToEnd: () => boolean; +} + +// Largest i such that offsets[i] <= target (offsets is strictly non-decreasing). +function findFloorIndex(offsets: number[], target: number): number { + let lo = 0; + let hi = offsets.length - 1; + let result = 0; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (offsets[mid] <= target) { + result = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + return result; +} + +export function useVirtualModel({ + items, + trackBy, + expandedIds, + expandedSignature, + estimatedRowHeight, + getRowHeight, + getExpandedRowHeight, + defaultExpandedEstimate = DEFAULT_EXPANDED_ESTIMATE, + overscan, + scrollContainerRef, +}: UseVirtualModelParams): VirtualModel { + const [scrollTop, setScrollTop] = useState(0); + const [viewport, setViewport] = useState(DEFAULT_VIEWPORT); + const [measureVersion, setMeasureVersion] = useState(0); + + const measured = useRef(new Map()); + const observers = useRef(new Map()); + const anchor = useRef<{ id: string; top: number } | null>(null); + + const latest = useRef({ getRowHeight, getExpandedRowHeight, estimatedRowHeight, defaultExpandedEstimate }); + latest.current = { getRowHeight, getExpandedRowHeight, estimatedRowHeight, defaultExpandedEstimate }; + + const layout = useMemo(() => { + const { + getRowHeight: grh, + getExpandedRowHeight: gerh, + estimatedRowHeight: est, + defaultExpandedEstimate: expEst, + } = latest.current; + const slots: RowSlot[] = []; + const offsets: number[] = []; + const startById = new Map(); + let cursor = 0; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const id = trackBy(item); + + // Data rows are fixed at the density estimate by default (no measurement cost). When + // getRowHeight opts a row into 'auto', it is measured so variable / wrapping rows (e.g. + // the CloudWatch file/raw view) window at their real height; a numeric return pins it. + const rawData = grh ? grh(item) : est; + const dataAuto = rawData === 'auto'; + const dataSize = dataAuto ? (measured.current.get('d:' + id) ?? est) : (rawData as number); + slots.push({ type: 'data', index: i, key: 'd:' + id, auto: dataAuto }); + offsets.push(cursor); + startById.set(id, cursor); + cursor += dataSize; + + if (expandedIds.has(id)) { + const rawExp = gerh ? gerh(item) : 'auto'; + const expAuto = rawExp === 'auto'; + const expSize = expAuto ? (measured.current.get('e:' + id) ?? expEst) : (rawExp as number); + slots.push({ type: 'expanded', index: i, key: 'e:' + id, auto: expAuto }); + offsets.push(cursor); + cursor += expSize; + } + } + + return { slots, offsets, startById, totalSize: cursor }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items, expandedSignature, estimatedRowHeight, measureVersion, trackBy]); + + useEffect(() => { + const el = scrollContainerRef.current; + if (!el) { + return; + } + setViewport(el.clientHeight || DEFAULT_VIEWPORT); + setScrollTop(el.scrollTop); + const ro = new ResizeObserver(() => setViewport(el.clientHeight || DEFAULT_VIEWPORT)); + ro.observe(el); + return () => ro.disconnect(); + }, [scrollContainerRef]); + + const layoutRef = useRef(layout); + layoutRef.current = layout; + + useEffect(() => { + const el = scrollContainerRef.current; + if (!el) { + return; + } + const onScroll = () => { + const top = el.scrollTop; + setScrollTop(top); + const { slots, offsets } = layoutRef.current; + const idx = findFloorIndex(offsets, top); + for (let i = idx; i < slots.length; i++) { + if (slots[i].type === 'data') { + anchor.current = { id: slots[i].key.slice(2), top: offsets[i] - top }; + break; + } + } + }; + el.addEventListener('scroll', onScroll, { passive: true }); + return () => el.removeEventListener('scroll', onScroll); + }, [scrollContainerRef]); + + useLayoutEffect(() => { + const el = scrollContainerRef.current; + if (!el || !anchor.current) { + return; + } + const newStart = layout.startById.get(anchor.current.id); + if (newStart === undefined) { + return; + } + const desiredTop = newStart - anchor.current.top; + if (Math.abs(desiredTop - el.scrollTop) > 0.5) { + el.scrollTop = desiredTop; + setScrollTop(desiredTop); + } + }, [measureVersion, layout, scrollContainerRef]); + + const measureRef = useCallback( + (key: string, auto: boolean) => (node: HTMLElement | null) => { + const existing = observers.current.get(key); + if (!node) { + if (existing) { + existing.ro.disconnect(); + observers.current.delete(key); + } + return; + } + // Fixed-height slots are never observed — they never pay measurement cost. + if (!auto) { + return; + } + if (existing && existing.node === node) { + return; + } + if (existing) { + existing.ro.disconnect(); + } + const ro = new ResizeObserver(() => { + const h = node.getBoundingClientRect().height; + const prev = measured.current.get(key); + if (prev === undefined || Math.abs(prev - h) > 0.5) { + measured.current.set(key, h); + setMeasureVersion(v => v + 1); + } + }); + ro.observe(node); + observers.current.set(key, { node, ro }); + }, + [] + ); + + useEffect(() => { + const map = observers.current; + return () => { + map.forEach(({ ro }) => ro.disconnect()); + map.clear(); + }; + }, []); + + const { + slots: windowedSlots, + firstIndex, + lastIndex, + } = useMemo(() => { + const { slots, offsets, totalSize } = layout; + if (slots.length === 0) { + return { slots: [] as PositionedSlot[], firstIndex: -1, lastIndex: -1 }; + } + const effectiveViewport = viewport || DEFAULT_VIEWPORT; + const firstVisible = findFloorIndex(offsets, scrollTop); + const lastVisible = findFloorIndex(offsets, scrollTop + effectiveViewport); + const startIdx = Math.max(0, firstVisible - overscan); + const endIdx = Math.min(slots.length - 1, lastVisible + overscan); + + const positioned: PositionedSlot[] = []; + let firstData = -1; + let lastData = -1; + for (let i = startIdx; i <= endIdx; i++) { + const start = offsets[i]; + const size = (i + 1 < offsets.length ? offsets[i + 1] : totalSize) - start; + positioned.push({ ...slots[i], start, size }); + if (slots[i].type === 'data') { + if (firstData === -1) { + firstData = slots[i].index; + } + lastData = slots[i].index; + } + } + return { slots: positioned, firstIndex: firstData, lastIndex: lastData }; + }, [layout, scrollTop, viewport, overscan]); + + const scrollToEnd = useCallback(() => { + const el = scrollContainerRef.current; + if (el) { + el.scrollTop = el.scrollHeight; + } + }, [scrollContainerRef]); + + const scrollToIndex = useCallback( + (index: number) => { + const el = scrollContainerRef.current; + if (!el || index < 0 || index >= items.length) { + return; + } + const id = trackBy(items[index]); + const start = layout.startById.get(id); + if (start !== undefined) { + el.scrollTop = start; + } + }, + [scrollContainerRef, items, trackBy, layout] + ); + + const isPinnedToEnd = useCallback(() => { + const el = scrollContainerRef.current; + if (!el) { + return false; + } + return el.scrollHeight - (el.scrollTop + el.clientHeight) <= 1; + }, [scrollContainerRef]); + + return { + slots: windowedSlots, + totalSize: layout.totalSize, + firstIndex, + lastIndex, + measureRef, + scrollToEnd, + scrollToIndex, + isPinnedToEnd, + }; +}