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

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

+

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

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

VirtualTable — CloudWatch raw log view (F2-A1)

+

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

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

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

+

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

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

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

+

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

+ + item.id} + estimatedRowHeight={23} + viewConfig={{ + type: 'standard', + columnDefinitions: [ + { id: 'timestamp', header: 'Timestamp', width: 210, cell: item => item.timestamp }, + { id: 'level', header: 'Level', width: 90, cell: item => item.level }, + { id: 'message', header: 'Message', isStretch: true, cell: item => item.message }, + ], + }} + expandedContentPreset="log-record" + getExpandedContent={item =>
Detail for {item.id}
} + defaultExpandedItems={['log-2']} + ariaLabels={{ + gridLabel: 'Log events', + expandRowLabel: item => `Show detail for ${item.id}`, + collapseRowLabel: item => `Hide detail for ${item.id}`, + }} + /> +
+ + ); +} diff --git a/src/__tests__/required-props-for-components.ts b/src/__tests__/required-props-for-components.ts index 3430c62d2d..0936aed235 100644 --- a/src/__tests__/required-props-for-components.ts +++ b/src/__tests__/required-props-for-components.ts @@ -6,6 +6,11 @@ export { defaultSplitPanelContextProps } from '../split-panel/__tests__/helpers' const defaultProps: Record> = { tabs: { tabs: [] }, table: { columnDefinitions: [] }, + 'virtual-table': { + items: [], + viewConfig: { type: 'standard', columnDefinitions: [] }, + trackBy: () => '', + }, cards: { cardDefinition: {} }, autosuggest: { options: [], enteredPrefix: '' }, 'anchor-navigation': { anchors: [] }, diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 65987a3dc6..6cf82eaf87 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -33703,6 +33703,855 @@ specified in the \`i18nStrings\` property.", } `; +exports[`Components definition for virtual-table matches the snapshot: virtual-table 1`] = ` +{ + "dashCaseName": "virtual-table", + "events": [ + { + "cancelable": false, + "description": "Fires when a row's expansion is toggled (keyboard or pointer).", + "detailInlineType": { + "name": "VirtualTableProps.ExpandChangeDetail", + "properties": [ + { + "name": "expanded", + "optional": false, + "type": "boolean", + }, + { + "name": "expandedItems", + "optional": false, + "type": "ReadonlyArray", + }, + { + "inlineType": { + "name": "NonNullable", + "type": "union", + "values": [ + "T", + "{}", + ], + }, + "name": "item", + "optional": false, + "type": "NonNullable", + }, + ], + "type": "object", + }, + "detailType": "VirtualTableProps.ExpandChangeDetail", + "name": "onExpandChange", + }, + { + "cancelable": false, + "description": "Fires when the follow state should change — on user scroll-away (false) or on +reaching bottom / pressing the follow control (true).", + "detailInlineType": { + "name": "VirtualTableProps.FollowChangeDetail", + "properties": [ + { + "name": "follow", + "optional": false, + "type": "boolean", + }, + { + "inlineType": { + "name": ""control" | "scroll-away" | "reached-bottom"", + "type": "union", + "values": [ + "control", + "scroll-away", + "reached-bottom", + ], + }, + "name": "reason", + "optional": false, + "type": "string", + }, + ], + "type": "object", + }, + "detailType": "VirtualTableProps.FollowChangeDetail", + "name": "onFollowChange", + }, + { + "cancelable": false, + "description": "Fires on sort control activation (keyboard-operable — not pointer-only).", + "detailInlineType": { + "name": "VirtualTableProps.SortingState", + "properties": [ + { + "inlineType": { + "name": "VirtualTableProps.ColumnDefinition", + "properties": [ + { + "inlineType": { + "name": "(item: T, context: VirtualTableProps.CellContext) => React.ReactNode", + "parameters": [ + { + "name": "item", + "type": "T", + }, + { + "name": "context", + "type": "VirtualTableProps.CellContext", + }, + ], + "returnType": "React.ReactNode", + "type": "function", + }, + "name": "cell", + "optional": false, + "type": "(item: T, context: VirtualTableProps.CellContext) => React.ReactNode", + }, + { + "name": "header", + "optional": true, + "type": "React.ReactNode", + }, + { + "name": "id", + "optional": false, + "type": "string", + }, + { + "name": "isStretch", + "optional": true, + "type": "boolean", + }, + { + "name": "minWidth", + "optional": true, + "type": "number", + }, + { + "inlineType": { + "name": "(a: T, b: T) => number", + "parameters": [ + { + "name": "a", + "type": "T", + }, + { + "name": "b", + "type": "T", + }, + ], + "returnType": "number", + "type": "function", + }, + "name": "sortingComparator", + "optional": true, + "type": "((a: T, b: T) => number)", + }, + { + "name": "sortingField", + "optional": true, + "type": "string", + }, + { + "name": "width", + "optional": true, + "type": "number", + }, + ], + "type": "object", + }, + "name": "sortingColumn", + "optional": false, + "type": "VirtualTableProps.ColumnDefinition", + }, + { + "name": "sortingDescending", + "optional": false, + "type": "boolean", + }, + ], + "type": "object", + }, + "detailType": "VirtualTableProps.SortingState", + "name": "onSortingChange", + }, + { + "cancelable": false, + "description": "Fires when the windowed visible range changes. Variable heights break a linear +scrollTop estimate, so the accurate dataset-relative range is surfaced here for +scroll-triggered prefetch. \`firstIndex\`/\`lastIndex\` index into \`items\`.", + "detailInlineType": { + "name": "VirtualTableProps.VisibleRangeDetail", + "properties": [ + { + "name": "firstIndex", + "optional": false, + "type": "number", + }, + { + "name": "lastIndex", + "optional": false, + "type": "number", + }, + ], + "type": "object", + }, + "detailType": "VirtualTableProps.VisibleRangeDetail", + "name": "onVisibleRangeChange", + }, + ], + "functions": [ + { + "description": "Whether the view is currently pinned to the last row.", + "name": "isPinnedToEnd", + "parameters": [], + "returnType": "boolean", + }, + { + "description": "Expand + highlight + scroll a row into view (CW-13 reveal). In \`mark-in-place\` +mode (or with no filter) the row is present, so it is expanded, highlighted, and +scrolled into view. If a \`subset\` filter currently hides the target row, reveal +is a no-op for that row — the console should clear or widen the filter first +(VirtualTable does not silently drop a consumer-owned filter).", + "name": "revealItem", + "parameters": [ + { + "name": "key", + "type": "string", + }, + ], + "returnType": "void", + }, + { + "description": "Scroll to and (optionally) pin to the newest row.", + "name": "scrollToEnd", + "parameters": [ + { + "name": "options", + "type": "{ pin?: boolean | undefined; }", + }, + ], + "returnType": "void", + }, + { + "description": "Scroll a row (by trackBy key) into view.", + "name": "scrollToItem", + "parameters": [ + { + "name": "key", + "type": "string", + }, + ], + "returnType": "void", + }, + ], + "name": "VirtualTable", + "properties": [ + { + "description": "Accessibility labels and the ARIA/announcement string set: the grid label, +per-view labels, the expand/collapse trigger label, the follow toggle label, +filter-count and append announcement templates, and loading/error text.", + "i18nTag": true, + "inlineType": { + "name": "VirtualTableProps.AriaLabels", + "properties": [ + { + "inlineType": { + "name": "(item: T) => string", + "parameters": [ + { + "name": "item", + "type": "T", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "collapseRowLabel", + "optional": true, + "type": "((item: T) => string)", + }, + { + "inlineType": { + "name": "(item: T) => string", + "parameters": [ + { + "name": "item", + "type": "T", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "expandRowLabel", + "optional": true, + "type": "((item: T) => string)", + }, + { + "inlineType": { + "name": "(item: T) => string", + "parameters": [ + { + "name": "item", + "type": "T", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "filterMatchLabel", + "optional": true, + "type": "((item: T) => string)", + }, + { + "name": "followToggleLabel", + "optional": true, + "type": "string", + }, + { + "name": "gridLabel", + "optional": false, + "type": "string", + }, + { + "name": "matchNavigationLabel", + "optional": true, + "type": "string", + }, + { + "name": "nextMatchLabel", + "optional": true, + "type": "string", + }, + { + "name": "previousMatchLabel", + "optional": true, + "type": "string", + }, + { + "inlineType": { + "name": "(view: VirtualTableProps.View) => string", + "parameters": [ + { + "name": "view", + "type": "VirtualTableProps.View", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "viewLabel", + "optional": true, + "type": "((view: VirtualTableProps.View) => string)", + }, + ], + "type": "object", + }, + "name": "ariaLabels", + "optional": true, + "type": "VirtualTableProps.AriaLabels", + }, + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "description": "Static per-column widths in px (by column id); the stretch-last column omits it.", + "name": "columnWidths", + "optional": true, + "type": "Record", + }, + { + "description": "Uncontrolled initial set of expanded row keys.", + "name": "defaultExpandedItems", + "optional": true, + "type": "ReadonlyArray", + }, + { + "defaultValue": "23", + "description": "Estimated collapsed row height in px, used to size the scroll runway before a row +is measured. Defaults to the logs density; CloudWatch standard/patterns use 23 +(ROW_HEIGHT), raw uses 20 (RAW_LINE_HEIGHT).", + "name": "estimatedRowHeight", + "optional": true, + "type": "number", + }, + { + "description": "Selects a built-in expanded-content layout, used as the default under +\`getExpandedContent\`. "log-record" = shape A (~300 px key/value detail); +"pattern-detail" = shape B (~150 px histogram + tokens + actions). Returning +custom content from \`getExpandedContent\` overrides it for that row (override-seam).", + "inlineType": { + "name": "VirtualTableProps.ExpandedContentPreset", + "type": "union", + "values": [ + "log-record", + "pattern-detail", + ], + }, + "name": "expandedContentPreset", + "optional": true, + "type": "string", + }, + { + "description": "Controlled set of expanded row keys (trackBy values).", + "name": "expandedItems", + "optional": true, + "type": "ReadonlyArray", + }, + { + "description": "The two-mode logs filter (CW-10), packaged as a component feature with the console +supplying only the predicate (override-seam). VirtualTable owns the mechanism: +match-indicator column, per-cell highlight styling, total-vs-filtered counts, +keyboard next/previous-match, and a NON-VISUAL match conveyance (text/ARIA on the +indicator, not colour alone; WCAG 1.4.1). + - mode "subset" renders only matching rows (filtered count). + - mode "mark-in-place" renders all rows, marks matches in place + a match + indicator column, so context is preserved. +Omitting \`filter\` disables filtering entirely.", + "inlineType": { + "name": "VirtualTableProps.Filter", + "properties": [ + { + "inlineType": { + "name": "(item: T, columnId: string) => boolean", + "parameters": [ + { + "name": "item", + "type": "T", + }, + { + "name": "columnId", + "type": "string", + }, + ], + "returnType": "boolean", + "type": "function", + }, + "name": "highlight", + "optional": true, + "type": "((item: T, columnId: string) => boolean)", + }, + { + "inlineType": { + "name": ""subset" | "mark-in-place"", + "type": "union", + "values": [ + "subset", + "mark-in-place", + ], + }, + "name": "mode", + "optional": false, + "type": "string", + }, + { + "inlineType": { + "name": "(item: T) => boolean", + "parameters": [ + { + "name": "item", + "type": "T", + }, + ], + "returnType": "boolean", + "type": "function", + }, + "name": "predicate", + "optional": false, + "type": "(item: T) => boolean", + }, + ], + "type": "object", + }, + "name": "filter", + "optional": true, + "type": "VirtualTableProps.Filter", + }, + { + "defaultValue": "false", + "description": "Controlled follow (stick-to-bottom) state. When true, appended rows pin the +viewport to newest; when the user scrolls away VirtualTable calls +\`onFollowChange(false)\` and stops auto-scrolling. Controlled by design +(override-seam): the console owns follow policy, the component owns the +pin-to-newest mechanics, scroll-anchoring, and the accessible append announcement.", + "name": "follow", + "optional": true, + "type": "boolean", + }, + { + "description": "Renders arbitrary, non-tabular content for an expanded row. This is the R-EXPAND +SUBSTRATE (goal 6): whatever it returns is hosted in the measured expanded region +and inherits the full disclosure a11y + measurement contract. The two built-in +presets (shape A log-record detail, shape B pattern detail) are DEFAULTS layered +on this slot via \`expandedContentPreset\` — they are not a closed set. Presence of +this prop (or a preset) enables the leading disclosure column.", + "inlineType": { + "name": "(item: T, context: VirtualTableProps.RowContext) => React.ReactNode", + "parameters": [ + { + "name": "item", + "type": "T", + }, + { + "name": "context", + "type": "VirtualTableProps.RowContext", + }, + ], + "returnType": "React.ReactNode", + "type": "function", + }, + "name": "getExpandedContent", + "optional": true, + "type": "((item: T, context: VirtualTableProps.RowContext) => React.ReactNode)", + }, + { + "description": "Estimated expanded height in px per row, seeding the runway before the expanded +region is measured. Defaults follow the active preset (300 log-record, 150 +pattern-detail); the real height is always measured.", + "inlineType": { + "name": "(item: T) => number", + "parameters": [ + { + "name": "item", + "type": "T", + }, + ], + "returnType": "number", + "type": "function", + }, + "name": "getExpandedRowHeight", + "optional": true, + "type": "((item: T) => number)", + }, + { + "description": "Per-row height strategy. Return a fixed px height, or \`"auto"\` to opt that row +into measurement (ResizeObserver-backed, measure-on-first-window-entry with a +cached height and incremental offset correction). Omitting the prop means every +collapsed row is fixed at \`estimatedRowHeight\`; expanded rows are always measured.", + "inlineType": { + "name": "(item: T) => number | "auto"", + "parameters": [ + { + "name": "item", + "type": "T", + }, + ], + "returnType": "number | "auto"", + "type": "function", + }, + "name": "getRowHeight", + "optional": true, + "type": "((item: T) => number | "auto")", + }, + { + "description": "Bounds the scroll viewport to a fixed px block-size. Windowing REQUIRES a bounded +viewport: without a \`height\`/\`maxHeight\` here (or a height-constrained parent, since +the root is a flex column and the scroll container flexes to fill it) the scroll +container reports the full content height and every row mounts. Use \`height\` for a +fixed viewport that always scrolls.", + "name": "height", + "optional": true, + "type": "number", + }, + { + "inlineType": { + "name": "VirtualTableProps.I18nStrings", + "properties": [ + { + "inlineType": { + "name": "(count: number) => string", + "parameters": [ + { + "name": "count", + "type": "number", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "appendAnnouncementText", + "optional": true, + "type": "((count: number) => string)", + }, + { + "name": "expandedRegionLabel", + "optional": true, + "type": "string", + }, + { + "inlineType": { + "name": "(matched: number, total: number) => string", + "parameters": [ + { + "name": "matched", + "type": "number", + }, + { + "name": "total", + "type": "number", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "filterCountText", + "optional": true, + "type": "((matched: number, total: number) => string)", + }, + { + "name": "loadingText", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "i18nStrings", + "optional": true, + "type": "VirtualTableProps.I18nStrings", + }, + { + "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, +use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + { + "description": "Imperative handle for consumer-composed streaming controls the component does not +render (e.g. a "jump to newest" button in console chrome): scroll to newest, +scroll a specific row into view, reveal (expand + highlight) a row, and query +whether the view is pinned to the end.", + "inlineType": { + "name": "((instance: VirtualTableProps.Ref | null) => void) | React.RefObject", + "type": "union", + "values": [ + "(instance: VirtualTableProps.Ref | null) => void", + "React.RefObject", + ], + }, + "name": "imperativeRef", + "optional": true, + "type": "((instance: VirtualTableProps.Ref | null) => void) | React.RefObject", + }, + { + "description": "The full dataset for the current view. VirtualTable windows this to the visible +range; the array is never fully rendered. Appending to it is the streaming/live +path (see \`follow\`, \`onVisibleRangeChange\`, and the imperative ref).", + "name": "items", + "optional": false, + "type": "ReadonlyArray", + }, + { + "defaultValue": "false", + "description": "Loading state (initial fetch). SR-announced via i18nStrings.", + "name": "loading", + "optional": true, + "type": "boolean", + }, + { + "description": "Loading text rendered/announced while \`loading\` is true.", + "name": "loadingText", + "optional": true, + "type": "string", + }, + { + "description": "Bounds the scroll viewport to a maximum px block-size: the table grows with its +content up to this height, then windows + scrolls. Alternative to \`height\` for a +grow-then-scroll viewport. See \`height\` for why a bound is required to window.", + "name": "maxHeight", + "optional": true, + "type": "number", + }, + { + "description": "Rows rendered beyond the visible range on each side. CloudWatch uses 20 for the +grid views and 40 for the raw view (TABLE_VIEW_OVERSCAN / FILE_VIEW_OVERSCAN).", + "name": "overscan", + "optional": true, + "type": "number", + }, + { + "description": "Optional accessible summary for a batch of appended rows, announced politely and +rate-limited (~500 ms debounce, summarized) on the component-owned aria-live +region during tail. When omitted, a default count-based message is used from +\`i18nStrings\`.", + "inlineType": { + "name": "(detail: VirtualTableProps.AppendDetail) => string", + "parameters": [ + { + "name": "detail", + "type": "VirtualTableProps.AppendDetail", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "renderAppendAnnouncement", + "optional": true, + "type": "((detail: VirtualTableProps.AppendDetail) => string)", + }, + { + "description": "Column currently sorted by (from the active view's column set).", + "inlineType": { + "name": "VirtualTableProps.ColumnDefinition", + "properties": [ + { + "inlineType": { + "name": "(item: T, context: VirtualTableProps.CellContext) => React.ReactNode", + "parameters": [ + { + "name": "item", + "type": "T", + }, + { + "name": "context", + "type": "VirtualTableProps.CellContext", + }, + ], + "returnType": "React.ReactNode", + "type": "function", + }, + "name": "cell", + "optional": false, + "type": "(item: T, context: VirtualTableProps.CellContext) => React.ReactNode", + }, + { + "name": "header", + "optional": true, + "type": "React.ReactNode", + }, + { + "name": "id", + "optional": false, + "type": "string", + }, + { + "name": "isStretch", + "optional": true, + "type": "boolean", + }, + { + "name": "minWidth", + "optional": true, + "type": "number", + }, + { + "inlineType": { + "name": "(a: T, b: T) => number", + "parameters": [ + { + "name": "a", + "type": "T", + }, + { + "name": "b", + "type": "T", + }, + ], + "returnType": "number", + "type": "function", + }, + "name": "sortingComparator", + "optional": true, + "type": "((a: T, b: T) => number)", + }, + { + "name": "sortingField", + "optional": true, + "type": "string", + }, + { + "name": "width", + "optional": true, + "type": "number", + }, + ], + "type": "object", + }, + "name": "sortingColumn", + "optional": true, + "type": "VirtualTableProps.ColumnDefinition", + }, + { + "defaultValue": "false", + "description": "Sort direction.", + "name": "sortingDescending", + "optional": true, + "type": "boolean", + }, + { + "defaultValue": "true", + "description": "Sticky header with horizontal scroll synced to the body.", + "name": "stickyHeader", + "optional": true, + "type": "boolean", + }, + { + "description": "Stable identity per row. REQUIRED (unlike Table, where it is optional): row +virtualization recycles DOM nodes, so a stable key is mandatory for correct focus +retention, expansion state, and measurement caching across recycling and live +append.", + "inlineType": { + "name": "(item: T) => string", + "parameters": [ + { + "name": "item", + "type": "T", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "trackBy", + "optional": false, + "type": "(item: T) => string", + }, + { + "description": "Per-view configuration AND the single source of truth for which built-in surface +is active: the \`type\` discriminant selects the view, and the body supplies that +view's column set / renderers. This is F2's opinionation made explicit — +VirtualTable ships three surfaces rather than being a raw grid: + - "standard" the results grid: dynamic columns, leading disclosure column, + fixed layout with a stretch-last column and static per-column + widths (no per-column sort — sort is a patterns-view capability, + CW-9). + - "patterns" the aggregation grid: its own column set, per-column sort, + diff/compare mode, and a shared histogram y-scale computed across + the FULL dataset. + - "raw" the monospaced raw log-line view (single column, no expansion), at + RAW_LINE_HEIGHT density. +The console owns the tab/segmented control that swaps the config; VirtualTable +owns each view's grid, virtualization, a11y, and expansion. Kept as one object +(rather than a flat columnDefinitions array) so a console can hold all three view +configs and swap without re-shaping props.", + "inlineType": { + "name": "VirtualTableProps.ViewConfig", + "type": "union", + "values": [ + "VirtualTableProps.StandardViewConfig", + "VirtualTableProps.PatternsViewConfig", + "VirtualTableProps.RawViewConfig", + ], + }, + "name": "viewConfig", + "optional": false, + "type": "VirtualTableProps.ViewConfig", + }, + ], + "regions": [ + { + "description": "Rendered when \`items\` is empty for the active view.", + "isDefault": false, + "name": "empty", + }, + ], + "releaseStatus": "stable", +} +`; + exports[`Components definition for wizard matches the snapshot: wizard 1`] = ` { "dashCaseName": "wizard", @@ -46281,6 +47130,164 @@ Supported options: ], "name": "TutorialTaskWrapper", }, + { + "methods": [ + { + "name": "findColumnHeaders", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "Array", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + { + "description": "The labeled expanded region for a data row, or null if not expanded/windowed.", + "name": "findExpandedRegion", + "parameters": [ + { + "flags": { + "isOptional": false, + }, + "name": "dataIndex", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "The disclosure toggle button for a data row, or null if outside the window.", + "name": "findExpandToggle", + "parameters": [ + { + "flags": { + "isOptional": false, + }, + "name": "dataIndex", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "The live-tail follow toggle, or null when the live-tail seam is not wired.", + "name": "findFollowToggle", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "The header row element (returned regardless of the stickyHeader prop).", + "name": "findHeaderRow", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "The polite append aria-live node.", + "name": "findLiveRegion", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "The total-vs-filtered match-count region, or null when no filter is set. Rendered +for both filter modes (subset shows the filtered length, mark-in-place the match +count) under the \`match-count\` class.", + "name": "findMatchCount", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Returns null for rows outside the current window (by design). Locates by the row's +\`aria-rowindex\`, which is the full-dataset index offset by the header row: header +is \`aria-rowindex=1\`, so data row \`dataIndex\` is \`aria-rowindex=dataIndex+2\`.", + "name": "findRowByIndex", + "parameters": [ + { + "flags": { + "isOptional": false, + }, + "name": "dataIndex", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Rendered (windowed) rows only.", + "name": "findRows", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "Array", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + ], + "name": "VirtualTableWrapper", + }, { "methods": [ { @@ -55138,6 +56145,129 @@ Supported options: ], "name": "TutorialTaskWrapper", }, + { + "methods": [ + { + "name": "findColumnHeaders", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + { + "description": "The labeled expanded region for a data row, or null if not expanded/windowed.", + "name": "findExpandedRegion", + "parameters": [ + { + "flags": { + "isOptional": false, + }, + "name": "dataIndex", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "The disclosure toggle button for a data row, or null if outside the window.", + "name": "findExpandToggle", + "parameters": [ + { + "flags": { + "isOptional": false, + }, + "name": "dataIndex", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "The live-tail follow toggle, or null when the live-tail seam is not wired.", + "name": "findFollowToggle", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "The header row element (returned regardless of the stickyHeader prop).", + "name": "findHeaderRow", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "The polite append aria-live node.", + "name": "findLiveRegion", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "The total-vs-filtered match-count region, or null when no filter is set. Rendered +for both filter modes (subset shows the filtered length, mark-in-place the match +count) under the \`match-count\` class.", + "name": "findMatchCount", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Returns null for rows outside the current window (by design). Locates by the row's +\`aria-rowindex\`, which is the full-dataset index offset by the header row: header +is \`aria-rowindex=1\`, so data row \`dataIndex\` is \`aria-rowindex=dataIndex+2\`.", + "name": "findRowByIndex", + "parameters": [ + { + "flags": { + "isOptional": false, + }, + "name": "dataIndex", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Rendered (windowed) rows only.", + "name": "findRows", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + ], + "name": "VirtualTableWrapper", + }, { "methods": [ { diff --git a/src/internal/components/expand-toggle-button/index.tsx b/src/internal/components/expand-toggle-button/index.tsx index 6aa2953247..8f9cfed6ca 100644 --- a/src/internal/components/expand-toggle-button/index.tsx +++ b/src/internal/components/expand-toggle-button/index.tsx @@ -18,6 +18,8 @@ export function ExpandToggleButton({ customIcon, className, disableFocusHighlight, + id, + ariaControls, }: { isExpanded?: boolean; onExpandableItemToggle?: () => void; @@ -26,6 +28,8 @@ export function ExpandToggleButton({ customIcon?: React.ReactNode; className?: string; disableFocusHighlight?: boolean; + id?: string; + ariaControls?: string; }) { const buttonRef = useRef(null); const { tabIndex } = useSingleTabStopNavigation(buttonRef); @@ -34,9 +38,11 @@ export function ExpandToggleButton({ + +); + +type Overrides = Partial> & { expandable?: boolean }; + +function buildTree(items: Log[], overrides: Overrides = {}) { + const { expandable, ...props } = overrides; + return ( + log.id} + viewConfig={standardConfig} + estimatedRowHeight={23} + getExpandedContent={expandable ? detail : undefined} + getExpandedRowHeight={expandable ? () => 300 : undefined} + ariaLabels={ariaLabels} + i18nStrings={i18nStrings} + {...props} + /> + ); +} + +function renderTable(items: Log[], overrides: Overrides = {}) { + const { container, rerender } = render(buildTree(items, overrides)); + const wrapper = createWrapper(container).findVirtualTable()!; + const update = (nextItems: Log[], nextOverrides: Overrides = overrides) => + rerender(buildTree(nextItems, nextOverrides)); + return { container, wrapper, update }; +} + +function getGrid(container: HTMLElement): HTMLElement { + return container.querySelector('[role="grid"]') as HTMLElement; +} + +describe('VirtualTable F2-A1 a11y', () => { + describe('axe / HTML validity', () => { + test('validates the plain standard view (no expansion, no filter)', async () => { + const { container } = renderTable(makeLogs(20)); + await expect(container).toValidateA11y(); + }); + + test('validates a standard view with a collapsed disclosure column', async () => { + const { container } = renderTable(makeLogs(20), { expandable: true }); + await expect(container).toValidateA11y(); + }); + + test('validates a standard view with an expanded, labeled region containing arbitrary content', async () => { + // A single expanded row keeps the region name unique (i18nStrings.expandedRegionLabel is + // a shared string; simultaneously-expanded rows would repeat a landmark name). + const { container } = renderTable(makeLogs(20), { expandable: true, expandedItems: ['3'] }); + await expect(container).toValidateA11y(); + }); + + test('validates the mark-in-place filter (indicator column + match navigation + follow toggle)', async () => { + const { container } = renderTable(makeLogs(20), { + filter: { mode: 'mark-in-place', predicate: log => log.level === 'ERROR', highlight: () => true }, + follow: true, + onFollowChange: () => {}, + }); + await expect(container).toValidateA11y(); + }); + + test('validates the subset filter view', async () => { + const { container } = renderTable(makeLogs(20), { + filter: { mode: 'subset', predicate: log => log.level === 'ERROR' }, + }); + await expect(container).toValidateA11y(); + }); + + test('validates the patterns view with sortable headers (native sort triggers + aria-sort)', async () => { + const { container } = renderTable(makeLogs(20), { + viewConfig: patternsConfig, + sortingColumn: patternsColumns[1], + }); + await expect(container).toValidateA11y(); + }); + + test('validates the raw view (single monospaced column)', async () => { + const { container } = renderTable(makeLogs(50), { + viewConfig: rawConfig, + estimatedRowHeight: 20, + overscan: 40, + getRowHeight: log => (log.message.length > 120 ? 'auto' : 20), + }); + await expect(container).toValidateA11y(); + }); + + test('validates the empty and loading states', async () => { + const empty = renderTable([], { empty: No log events }); + await expect(empty.container).toValidateA11y(); + + const loading = renderTable([], { loading: true }); + await expect(loading.container).toValidateA11y(); + }); + }); + + describe('grid accessibility tree', () => { + test('exactly one role=grid that owns only rowgroups -> rows -> columnheader/gridcell', () => { + const { container } = renderTable(makeLogs(20), { expandable: true, expandedItems: ['0'] }); + expect(container.querySelectorAll('[role="grid"]')).toHaveLength(1); + const grid = getGrid(container); + + const directChildren = Array.from(grid.children); + expect(directChildren.length).toBeGreaterThan(0); + directChildren.forEach(child => expect(child.getAttribute('role')).toBe('rowgroup')); + + grid.querySelectorAll('[role="row"]').forEach(row => { + expect(row.closest('[role="rowgroup"]')).not.toBeNull(); + }); + grid.querySelectorAll('[role="columnheader"], [role="gridcell"]').forEach(cell => { + expect(cell.closest('[role="row"]')).not.toBeNull(); + }); + }); + + test('non-row chrome (loading / live-region) is a sibling of the grid, never a grid child', () => { + const { wrapper } = renderTable([], { loading: true }); + const status = wrapper.find('[role="status"]')!.getElement(); + expect(status.closest('[role="grid"]')).toBeNull(); + + const live = renderTable(makeLogs(5)).wrapper.findLiveRegion()!.getElement(); + expect(live.closest('[role="grid"]')).toBeNull(); + }); + }); + + describe('keyboard navigation (aria-activedescendant grid model)', () => { + test('the grid is a single always-present tab stop and seeds an active descendant once focused', () => { + const { container, wrapper } = renderTable(makeLogs(20)); + const grid = getGrid(container); + expect(grid.getAttribute('tabindex')).toBe('0'); + const firstRow = wrapper.findRowByIndex(0)!.getElement(); + // F-ACTIVE: no active descendant is advertised until the grid actually holds focus. + expect(grid.getAttribute('aria-activedescendant')).toBeNull(); + fireEvent.focus(grid); + expect(grid.getAttribute('aria-activedescendant')).toBe(firstRow.id); + expect(document.getElementById(firstRow.id)).not.toBeNull(); + }); + + test('Arrow/Home/End move the active row when the grid holds focus', () => { + const { container, wrapper } = renderTable(makeLogs(20)); + const grid = getGrid(container); + fireEvent.focus(grid); + + fireEvent.keyDown(grid, { key: 'ArrowDown' }); + expect(grid.getAttribute('aria-activedescendant')).toBe(wrapper.findRowByIndex(1)!.getElement().id); + + fireEvent.keyDown(grid, { key: 'End' }); + expect(grid.getAttribute('aria-activedescendant')).toBe(wrapper.findRowByIndex(19)!.getElement().id); + + fireEvent.keyDown(grid, { key: 'Home' }); + expect(grid.getAttribute('aria-activedescendant')).toBe(wrapper.findRowByIndex(0)!.getElement().id); + }); + + test('does not hijack arrow keys originating inside the expanded region', () => { + const { container, wrapper } = renderTable(makeLogs(20), { expandable: true, expandedItems: ['0'] }); + const grid = getGrid(container); + fireEvent.focus(grid); + const before = grid.getAttribute('aria-activedescendant'); + + const innerButton = wrapper.findExpandedRegion(0)!.getElement().querySelector('button')!; + fireEvent.keyDown(innerButton, { key: 'ArrowDown' }); + + // onGridKeyDown bails unless the grid container itself is the event target, so arbitrary + // expanded content (goal 6) keeps its own arrow-key behaviour. + expect(grid.getAttribute('aria-activedescendant')).toBe(before); + }); + + test('ArrowLeft / ArrowRight are inert (deliberate row-granular grid, not 2D cell nav)', () => { + // Like the F1 threads, the grid is deliberately row-granular: aria-activedescendant + // references a role="row" (never a gridcell) and onGridKeyDown handles only + // Up/Down/Home/End — a legitimate APG row-as-widget variant of role="grid". The + // horizontal cell-navigation keys are intentionally inert; carried to impl-F2-A1-docs + // as an explicit a11y contract note. + const { container, wrapper } = renderTable(makeLogs(20)); + const grid = getGrid(container); + fireEvent.focus(grid); + + 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); + }); + + test('advertises no active descendant until the grid holds focus, and clears it on blur (F-ACTIVE)', () => { + const { container, wrapper } = renderTable(makeLogs(20)); + const grid = getGrid(container); + const firstRowId = wrapper.findRowByIndex(0)!.getElement().id; + // No active row is advertised (or painted) on initial render — nothing before interaction. + expect(grid.getAttribute('aria-activedescendant')).toBeNull(); + fireEvent.focus(grid); + expect(grid.getAttribute('aria-activedescendant')).toBe(firstRowId); + fireEvent.blur(grid); + expect(grid.getAttribute('aria-activedescendant')).toBeNull(); + }); + + test('focusing a descendant control does not advertise an active descendant on the grid', () => { + const { container, wrapper } = renderTable(makeLogs(20), { expandable: true }); + const grid = getGrid(container); + const toggle = wrapper.findExpandToggle(0)!.getElement(); + // focusin bubbles to the grid, but the target===currentTarget guard means a descendant + // control gaining focus must not activate the grid's active-row indication. + fireEvent.focus(toggle); + expect(grid.getAttribute('aria-activedescendant')).toBeNull(); + }); + }); + + describe('two-mode filter accessibility', () => { + test('mark-in-place conveys a match non-visually (visually-hidden text + decorative marker), WCAG 1.4.1', () => { + const { wrapper } = renderTable(makeLogs(20), { + filter: { mode: 'mark-in-place', predicate: log => log.level === 'ERROR' }, + }); + // Row 1 is ERROR (odd index) => a match; row 0 is INFO => not a match. In mark-in-place + // every row still renders (context preserved). + const matchRow = wrapper.findRowByIndex(1)!.getElement(); + const matchIndicator = matchRow.querySelectorAll('[role="gridcell"]')[0]; + // The visible marker is decorative... + expect(matchIndicator.querySelector('[aria-hidden="true"]')).not.toBeNull(); + // ...and the match is carried by text for assistive tech (not colour alone). + expect(matchIndicator.textContent).toContain('Matches filter: message 1'); + + const nonMatchRow = wrapper.findRowByIndex(0)!.getElement(); + const nonMatchIndicator = nonMatchRow.querySelectorAll('[role="gridcell"]')[0]; + expect(nonMatchIndicator.textContent).toBe(''); + }); + + test('match navigation exposes a named group with keyboard-operable prev/next controls', () => { + const { container, wrapper } = renderTable(makeLogs(20), { + filter: { mode: 'mark-in-place', predicate: log => log.level === 'ERROR' }, + }); + const group = container.querySelector('[role="group"]')!; + expect(group.getAttribute('aria-label')).toBe('Filter match navigation'); + + const buttons = Array.from(group.querySelectorAll('button')); + expect(buttons).toHaveLength(2); + expect(buttons[0].tagName).toBe('BUTTON'); // native = implicitly keyboard-operable + expect(buttons[0].getAttribute('aria-label')).toBe('Previous match'); + expect(buttons[1].getAttribute('aria-label')).toBe('Next match'); + // The visible chevron glyphs are decorative, so the accessible name is the aria-label only. + expect(buttons[0].querySelector('[aria-hidden="true"]')).not.toBeNull(); + + // Activating next-match moves the active row to a matching (odd-index) row. + const grid = getGrid(container); + fireEvent.click(buttons[1]); + expect(grid.getAttribute('aria-activedescendant')).toBe(wrapper.findRowByIndex(3)!.getElement().id); + }); + + test('activating previous-match reveals a matching row, wrapping backward cyclically', () => { + const { container, wrapper } = renderTable(makeLogs(20), { + filter: { mode: 'mark-in-place', predicate: log => log.level === 'ERROR' }, + }); + const group = container.querySelector('[role="group"]')!; + const prevButton = Array.from(group.querySelectorAll('button'))[0]; + const grid = getGrid(container); + // ERROR rows are the odd indices 1,3,...,19 (ten matches). From the initial cursor, + // previous-match steps backward and wraps to the last match => data row 19, exercising + // the prev keyboard reveal end-to-end (symmetric to the next-match assertion above). + fireEvent.click(prevButton); + expect(grid.getAttribute('aria-activedescendant')).toBe(wrapper.findRowByIndex(19)!.getElement().id); + }); + + test('subset mode renders no indicator column and no match navigation, and reports the filtered count', () => { + const { container, wrapper } = renderTable(makeLogs(20), { + filter: { mode: 'subset', predicate: log => log.level === 'ERROR' }, + }); + // No mark-in-place indicator column => data columns start at aria-colindex 1. + expect(getGrid(container).getAttribute('aria-colcount')).toBe(String(DATA_COLUMNS)); + expect(wrapper.findColumnHeaders()[0].getElement().getAttribute('aria-colindex')).toBe('1'); + // No match-navigation group in subset mode. + expect(container.querySelector('[role="group"]')).toBeNull(); + // The count region reports the filtered length (10 ERROR of 20). + expect(wrapper.findMatchCount()!.getElement()).toHaveTextContent('10 of 20 matches'); + }); + + test('the indicator column materialises + shifts data colindex coherently when mark-in-place toggles on and off', () => { + const { container, wrapper, update } = renderTable(makeLogs(20)); + // No filter: 3 data columns, first data column at colindex 1. + expect(getGrid(container).getAttribute('aria-colcount')).toBe(String(DATA_COLUMNS)); + expect(wrapper.findColumnHeaders()[0].getElement().getAttribute('aria-colindex')).toBe('1'); + + // Toggle on mark-in-place: the indicator column is counted (colindex 1) and every data + // column shifts down by one, coherently. + update(makeLogs(20), { filter: { mode: 'mark-in-place', predicate: log => log.level === 'ERROR' } }); + expect(getGrid(container).getAttribute('aria-colcount')).toBe(String(DATA_COLUMNS + 1)); + expect(wrapper.findColumnHeaders()[0].getElement().getAttribute('aria-colindex')).toBe('2'); + const indicatorHeader = getGrid(container).querySelectorAll('[role="columnheader"]')[0]; + expect(indicatorHeader.getAttribute('aria-colindex')).toBe('1'); + + // Toggle back off: the indicator column disappears and the counts revert coherently. + update(makeLogs(20), {}); + expect(getGrid(container).getAttribute('aria-colcount')).toBe(String(DATA_COLUMNS)); + expect(wrapper.findColumnHeaders()[0].getElement().getAttribute('aria-colindex')).toBe('1'); + }); + }); + + describe('patterns view sort-trigger accessibility', () => { + test('sortable headers expose a keyboard-operable trigger named by the header; non-sortable do not', () => { + const { wrapper } = renderTable(makeLogs(10), { viewConfig: patternsConfig }); + const [severity, count] = wrapper.findColumnHeaders(); + + // Sortable "count" column: a native (keyboard-operable) trigger whose accessible name is + // the column header, plus aria-sort. + const sortButton = count.getElement().querySelector('button')!; + expect(sortButton).not.toBeNull(); + expect(sortButton.tagName).toBe('BUTTON'); + expect(sortButton.textContent).toBe('Count'); + expect(count.getElement().getAttribute('aria-sort')).toBe('none'); + + // Non-sortable "severity" column: no trigger, no aria-sort attribute. + expect(severity.getElement().querySelector('button')).toBeNull(); + expect(severity.getElement().getAttribute('aria-sort')).toBeNull(); + }); + + test('aria-sort reflects the active column and direction', () => { + const { wrapper } = renderTable(makeLogs(10), { + viewConfig: patternsConfig, + sortingColumn: patternsColumns[1], + sortingDescending: true, + }); + expect(wrapper.findColumnHeaders()[1].getElement().getAttribute('aria-sort')).toBe('descending'); + }); + }); + + describe('disclosure + expanded region wiring', () => { + test('the disclosure control is a labeled button reflecting aria-expanded / aria-controls', () => { + const { wrapper } = renderTable(makeLogs(10), { expandable: true }); + const toggle = wrapper.findExpandToggle(0)!.getElement(); + + expect(toggle.tagName).toBe('BUTTON'); + expect(toggle.getAttribute('aria-label')).toBe('Expand details for message 0'); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + expect(toggle.getAttribute('aria-controls')).toBeNull(); + + fireEvent.click(toggle); + + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + expect(toggle.getAttribute('aria-label')).toBe('Collapse details for message 0'); + const region = wrapper.findExpandedRegion(0)!.getElement(); + expect(toggle.getAttribute('aria-controls')).toBe(region.id); + expect(region.getAttribute('role')).toBe('region'); + }); + + test('the expanded region carries the consumer-supplied accessible name', () => { + const { wrapper } = renderTable(makeLogs(10), { expandable: true, expandedItems: ['2'] }); + const region = wrapper.findExpandedRegion(2)!.getElement(); + expect(region.getAttribute('aria-label')).toBe('Log record detail'); + }); + + test('Escape inside the expanded region returns focus to its disclosure trigger', () => { + const { wrapper } = renderTable(makeLogs(10), { expandable: true, expandedItems: ['0'] }); + const toggle = wrapper.findExpandToggle(0)!.getElement(); + const innerButton = wrapper.findExpandedRegion(0)!.getElement().querySelector('button')!; + + innerButton.focus(); + expect(document.activeElement).toBe(innerButton); + + fireEvent.keyDown(innerButton, { key: 'Escape' }); + expect(document.activeElement).toBe(toggle); + }); + + test('the expanded region content is reachable (Tab-in half: not aria-hidden / inert / disabled)', () => { + const { wrapper } = renderTable(makeLogs(10), { expandable: true, expandedItems: ['0'] }); + const region = wrapper.findExpandedRegion(0)!.getElement(); + const innerButton = region.querySelector('button') as HTMLButtonElement; + + // jsdom cannot perform real Tab traversal; the Tab-in half of the contract is that the + // region's interactive content is genuinely focusable — not disabled and not inside an + // aria-hidden or inert ancestor that would remove it from the tab order. + expect(innerButton.closest('[aria-hidden="true"]')).toBeNull(); + expect(innerButton.closest('[inert]')).toBeNull(); + expect(innerButton.hasAttribute('disabled')).toBe(false); + innerButton.focus(); + expect(document.activeElement).toBe(innerButton); + }); + }); + + describe('live-append announcement', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + test('coalesces a burst of appends into one debounced polite message (rate-limited)', () => { + const { wrapper, update } = renderTable(makeLogs(10)); + const live = wrapper.findLiveRegion()!.getElement(); + // Polite (not assertive): the tail does not interrupt the SR on every batch — the + // rate-limiting half of the reduced-interruption contract. The auto-scroll itself is an + // instant scrollTop assignment in use-live-tail (reduced-motion-safe by construction); + // jsdom cannot assert scroll smoothness, so the covered surface here is the announcement. + expect(live.getAttribute('aria-live')).toBe('polite'); + expect(live.textContent).toBe(''); + + // Two appends within the debounce window collapse into a single announcement. + update(makeLogs(13)); + update(makeLogs(15)); + expect(live.textContent).toBe(''); + + act(() => { + jest.advanceTimersByTime(500); + }); + expect(live.textContent).toContain('5 new log events'); + }); + + test('does not announce when the dataset shrinks or is replaced', () => { + const { wrapper, update } = renderTable(makeLogs(10)); + const live = wrapper.findLiveRegion()!.getElement(); + + update(makeLogs(4)); + act(() => { + jest.advanceTimersByTime(500); + }); + expect(live.textContent).toBe(''); + }); + }); + + describe('focus under row recycling', () => { + test('keeps focus on a control when rows are appended (trackBy-keyed identity)', () => { + const { wrapper, update } = renderTable(makeLogs(20), { expandable: true }); + const toggle = wrapper.findExpandToggle(0)!.getElement(); + + toggle.focus(); + expect(document.activeElement).toBe(toggle); + + // Appending rows must not remount the still-windowed row 0, so its focused disclosure + // control retains focus (recycling is keyed by trackBy identity). + update(makeLogs(30), { expandable: true }); + expect(document.activeElement).toBe(wrapper.findExpandToggle(0)!.getElement()); + }); + }); + + describe('full-dataset ARIA coherence under windowing', () => { + test('aria-rowcount counts the header once and aria-colcount counts the disclosure column', () => { + const { container } = renderTable(makeLogs(500), { expandable: true }); + const grid = getGrid(container); + expect(grid.getAttribute('aria-rowcount')).toBe('501'); + expect(grid.getAttribute('aria-colcount')).toBe(String(DATA_COLUMNS + 1)); + }); + + test('the header row is aria-rowindex 1 with the disclosure columnheader at aria-colindex 1', () => { + const { wrapper } = renderTable(makeLogs(500), { expandable: true }); + const header = wrapper.findHeaderRow()!.getElement(); + expect(header.getAttribute('aria-rowindex')).toBe('1'); + + const headers = header.querySelectorAll('[role="columnheader"]'); + expect(headers[0].getAttribute('aria-colindex')).toBe('1'); // materialised disclosure column + expect(headers[1].getAttribute('aria-colindex')).toBe('2'); // first data column + }); + + test('data rows carry a full-dataset aria-rowindex and disclosure=1 / data-from-2 colindex', () => { + const { wrapper } = renderTable(makeLogs(500), { expandable: true }); + const row0 = wrapper.findRowByIndex(0)!.getElement(); + expect(row0.getAttribute('aria-rowindex')).toBe('2'); // header is 1, so data index 0 -> 2 + + const cells = row0.querySelectorAll('[role="gridcell"]'); + expect(cells[0].getAttribute('aria-colindex')).toBe('1'); // disclosure cell + expect(cells[1].getAttribute('aria-colindex')).toBe('2'); // first data cell + }); + + test('subset filter makes aria-rowcount the filtered length', () => { + const { container } = renderTable(makeLogs(100), { + filter: { mode: 'subset', predicate: log => log.level === 'ERROR' }, + }); + // 50 ERROR of 100, + the header row. + expect(getGrid(container).getAttribute('aria-rowcount')).toBe('51'); + }); + + test('the expanded row shares its data row index and spans all columns without changing aria-rowcount', () => { + const { container, wrapper } = renderTable(makeLogs(500), { expandable: true, expandedItems: ['0'] }); + // Expanding a row does not add to the row count (design B1). + expect(getGrid(container).getAttribute('aria-rowcount')).toBe('501'); + + const region = wrapper.findExpandedRegion(0)!.getElement(); + const expandedRow = region.closest('[role="row"]')!; + expect(expandedRow.getAttribute('aria-rowindex')).toBe('2'); // shares its data row's index + + const expandedCell = expandedRow.querySelector('[role="gridcell"]')!; + expect(expandedCell.getAttribute('aria-colindex')).toBe('1'); + expect(expandedCell.getAttribute('aria-colspan')).toBe(String(DATA_COLUMNS + 1)); + }); + }); +}); diff --git a/src/virtual-table/__tests__/virtual-table.test.tsx b/src/virtual-table/__tests__/virtual-table.test.tsx new file mode 100644 index 0000000000..16c45299e2 --- /dev/null +++ b/src/virtual-table/__tests__/virtual-table.test.tsx @@ -0,0 +1,290 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; + +import createWrapper from '../../../lib/components/test-utils/dom'; +import VirtualTable, { VirtualTableProps } from '../../../lib/components/virtual-table'; + +import styles from '../../../lib/components/virtual-table/styles.css.js'; + +// impl-F2-A1-tests-unit: exercises the logs-specialized config-driven VirtualTable through +// the built library + test-utils wrapper. Scope is the RESOLVED public API + engine +// behaviour (windowing, view/viewConfig switching, the two-mode filter's materialised +// indicator column and coherent colindex recompute, R-EXPAND lazy mount, per-column sort +// activation, the live-tail seam, imperative ref); axe/keyboard/SR audits are +// impl-F2-A1-tests-a11y scope and intentionally not duplicated here. Component tests import +// the built lib, so they compile/run after the -pr build (as with the F1 threads). + +interface Log { + id: string; + timestamp: string; + level: string; + message: string; +} + +const makeLogs = (n: number): Log[] => + Array.from({ length: n }, (_, i) => ({ + id: String(i), + timestamp: `t${i}`, + level: i % 2 === 1 ? 'ERROR' : 'INFO', + message: `message ${i}`, + })); + +const standardColumns: ReadonlyArray> = [ + { id: 'timestamp', header: 'Timestamp', width: 200, cell: (log: Log) => log.timestamp }, + { id: 'message', header: 'Message', isStretch: true, cell: (log: Log) => log.message }, +]; +const standardConfig: VirtualTableProps.StandardViewConfig = { + type: 'standard', + columnDefinitions: standardColumns, +}; + +function renderTable(props: Partial> = {}, itemCount = 3) { + const { container } = render( + log.id} + viewConfig={standardConfig} + ariaLabels={{ gridLabel: 'Log events' }} + {...props} + /> + ); + const wrapper = createWrapper(container).findVirtualTable()!; + const grid = () => wrapper.findByClassName(styles['scroll-container'])!.getElement(); + return { container, wrapper, grid }; +} + +describe('VirtualTable (F2-A1)', () => { + test('renders the root element, is discoverable, and renders the standard view columns', () => { + const { container, wrapper } = renderTable(); + expect(container.firstChild).toHaveClass(styles.root); + expect(wrapper).not.toBeNull(); + expect(wrapper.findColumnHeaders()).toHaveLength(standardColumns.length); + expect(wrapper.findRows().length).toBeGreaterThan(0); + }); + + test('windows a large dataset and returns null for rows outside the window', () => { + const { wrapper } = renderTable({}, 500); + expect(wrapper.findRows().length).toBeLessThan(500); + expect(wrapper.findRowByIndex(0)).not.toBeNull(); + // Deep row is outside the top window (viewport 600 / 23px + overscan 20), so the wrapper + // returns null by design — windowing, not a missing row. + expect(wrapper.findRowByIndex(499)).toBeNull(); + }); + + test('does not re-fire onVisibleRangeChange in a loop when the consumer passes a fresh inline handler each render (F-LOOP regression)', () => { + // The handler identity changes every render AND it stores a new object in state (mirrors the + // standard dev page's `onVisibleRangeChange={e => setVisibleRange(e.detail)}`). Before the + // stable-callback fix this looped: the visible-range effect listed the handler in its deps, so + // fire -> setState -> re-render -> new handler identity -> effect re-runs -> fire..., throwing + // "Maximum update depth exceeded". The fix bounds the fire count to genuine range changes. + const fireCounts = { n: 0 }; + function Harness() { + const [, setRange] = React.useState<{ firstIndex: number; lastIndex: number } | null>(null); + return ( + log.id} + viewConfig={standardConfig} + ariaLabels={{ gridLabel: 'Log events' }} + onVisibleRangeChange={event => { + fireCounts.n += 1; + setRange(event.detail); + }} + /> + ); + } + render(); + // Fires for the initial window only (the window does not change without scrolling), not once + // per render. A loop would blow past this bound long before the test could assert. + expect(fireCounts.n).toBeGreaterThanOrEqual(1); + expect(fireCounts.n).toBeLessThanOrEqual(3); + }); + + test('sets full-dataset aria-rowcount (header counted once) and aria-colcount', () => { + const { grid } = renderTable({}, 500); + expect(grid().getAttribute('aria-rowcount')).toBe('501'); + expect(grid().getAttribute('aria-colcount')).toBe('2'); + }); + + test('materialises a counted leading disclosure column only when expansion is enabled', () => { + const expandable = renderTable( + { + getExpandedContent: (log: Log) =>
detail-{log.id}
, + i18nStrings: { expandedRegionLabel: 'Log record detail' }, + }, + 10 + ); + expect(expandable.grid().getAttribute('aria-colcount')).toBe('3'); + // Disclosure occupies aria-colindex 1, so data columns start at 2 in both header and body. + expect(expandable.wrapper.findColumnHeaders()[0].getElement().getAttribute('aria-colindex')).toBe('2'); + expect(expandable.wrapper.findExpandToggle(0)).not.toBeNull(); + + const plain = renderTable({}, 10); + expect(plain.grid().getAttribute('aria-colcount')).toBe('2'); + expect(plain.wrapper.findColumnHeaders()[0].getElement().getAttribute('aria-colindex')).toBe('1'); + expect(plain.wrapper.findExpandToggle(0)).toBeNull(); + }); + + test('subset filter makes aria-rowcount the filtered length', () => { + // 100 logs, ERROR on odd indices -> 50 matches; subset renders only matches. + const { grid, wrapper } = renderTable( + { + filter: { mode: 'subset', predicate: (log: Log) => log.level === 'ERROR' }, + i18nStrings: { filterCountText: (matched, total) => `${matched} of ${total}` }, + }, + 100 + ); + expect(grid().getAttribute('aria-rowcount')).toBe('51'); + // The match-count region renders for a filtered table (folds NB2; exercises findMatchCount). + const matchCount = wrapper.findMatchCount(); + expect(matchCount).not.toBeNull(); + expect(matchCount!.getElement()).toHaveTextContent('50 of 100'); + }); + + test('mark-in-place filter materialises + counts the match-indicator column, shifting data colindex coherently', () => { + const marked = renderTable( + { filter: { mode: 'mark-in-place', predicate: (log: Log) => log.level === 'ERROR' } }, + 10 + ); + // +1 indicator column over the plain 2-column standard view. + expect(marked.grid().getAttribute('aria-colcount')).toBe('3'); + expect(marked.wrapper.findColumnHeaders()[0].getElement().getAttribute('aria-colindex')).toBe('2'); + + // Disclosure + indicator both present: disclosure=1, indicator=2, data columns from 3. + const both = renderTable( + { + getExpandedContent: (log: Log) =>
detail-{log.id}
, + filter: { mode: 'mark-in-place', predicate: (log: Log) => log.level === 'ERROR' }, + }, + 10 + ); + expect(both.grid().getAttribute('aria-colcount')).toBe('4'); + expect(both.wrapper.findColumnHeaders()[0].getElement().getAttribute('aria-colindex')).toBe('3'); + }); + + test('renders the expanded region only for expanded rows (lazy mount)', () => { + const { wrapper, container } = renderTable( + { + getExpandedContent: (log: Log) =>
detail-{log.id}
, + expandedItems: ['5'], + i18nStrings: { expandedRegionLabel: 'Log record detail' }, + }, + 50 + ); + const region = wrapper.findExpandedRegion(5); + expect(region).not.toBeNull(); + expect(region!.getElement().getAttribute('aria-label')).toBe('Log record detail'); + expect(container.textContent).toContain('detail-5'); + // A collapsed (but windowed) row does not build its expanded subtree. + expect(wrapper.findExpandedRegion(0)).toBeNull(); + expect(container.textContent).not.toContain('detail-0'); + }); + + test('uncontrolled disclosure toggle expands the row and fires onExpandChange', () => { + const onExpandChange = jest.fn(); + const { wrapper } = renderTable( + { + getExpandedContent: (log: Log) =>
detail-{log.id}
, + onExpandChange, + i18nStrings: { expandedRegionLabel: 'Detail' }, + }, + 10 + ); + expect(wrapper.findExpandedRegion(0)).toBeNull(); + fireEvent.click(wrapper.findExpandToggle(0)!.getElement()); + expect(onExpandChange).toHaveBeenCalledWith( + expect.objectContaining({ detail: expect.objectContaining({ expanded: true, expandedItems: ['0'] }) }) + ); + expect(wrapper.findExpandedRegion(0)).not.toBeNull(); + }); + + describe('patterns view sorting (CW-9, patterns-only, reflect-not-sort)', () => { + const patternsColumns: ReadonlyArray> = [ + { id: 'severity', header: 'Severity', cell: (log: Log) => log.level }, + { id: 'count', header: 'Count', sortingField: 'count', cell: (log: Log) => log.message }, + ]; + const patternsConfig: VirtualTableProps.PatternsViewConfig = { + type: 'patterns', + columnDefinitions: patternsColumns, + }; + + test('renders a keyboard-operable sort trigger only on sortable columns', () => { + const onSortingChange = jest.fn(); + const { wrapper } = renderTable({ viewConfig: patternsConfig, onSortingChange }, 10); + const headers = wrapper.findColumnHeaders(); + + // Non-sortable column: no aria-sort, no trigger. + expect(headers[0].getElement().getAttribute('aria-sort')).toBeNull(); + expect(headers[0].getElement().querySelector('button')).toBeNull(); + + // Sortable column: aria-sort 'none' when inactive, activation fires onSortingChange. + expect(headers[1].getElement().getAttribute('aria-sort')).toBe('none'); + const sortButton = headers[1].getElement().querySelector('button')!; + expect(sortButton).not.toBeNull(); + fireEvent.click(sortButton); + expect(onSortingChange).toHaveBeenCalledWith( + expect.objectContaining({ detail: expect.objectContaining({ sortingDescending: false }) }) + ); + }); + + test('reflects the active sort column and direction via aria-sort', () => { + const { wrapper } = renderTable( + { viewConfig: patternsConfig, sortingColumn: patternsColumns[1], sortingDescending: false }, + 10 + ); + expect(wrapper.findColumnHeaders()[1].getElement().getAttribute('aria-sort')).toBe('ascending'); + }); + }); + + test('renders the raw view as a single-column surface with no disclosure column', () => { + const rawConfig: VirtualTableProps.RawViewConfig = { type: 'raw', renderLine: (log: Log) => log.message }; + const { wrapper, grid } = renderTable({ viewConfig: rawConfig }, 10); + expect(wrapper.findExpandToggle(0)).toBeNull(); + expect(grid().getAttribute('aria-colcount')).toBe('1'); + }); + + test('exposes the imperative streaming handle', () => { + const ref = React.createRef(); + renderTable({ imperativeRef: ref }, 10); + expect(typeof ref.current!.scrollToEnd).toBe('function'); + expect(typeof ref.current!.scrollToItem).toBe('function'); + expect(typeof ref.current!.revealItem).toBe('function'); + expect(typeof ref.current!.isPinnedToEnd()).toBe('boolean'); + }); + + test('renders the follow toggle when the live-tail seam is wired and reflects follow state', () => { + const onFollowChange = jest.fn(); + const { wrapper } = renderTable( + { follow: true, onFollowChange, ariaLabels: { gridLabel: 'Log events', followToggleLabel: 'Follow' } }, + 10 + ); + const toggle = wrapper.findFollowToggle(); + expect(toggle).not.toBeNull(); + expect(toggle!.getElement().getAttribute('aria-pressed')).toBe('true'); + fireEvent.click(toggle!.getElement()); + expect(onFollowChange).toHaveBeenCalledWith( + expect.objectContaining({ detail: expect.objectContaining({ follow: false, reason: 'control' }) }) + ); + }); + + test('renders a polite append aria-live region', () => { + const { wrapper } = renderTable({}, 3); + const live = wrapper.findLiveRegion(); + expect(live).not.toBeNull(); + expect(live!.getElement().getAttribute('aria-live')).toBe('polite'); + }); + + test('renders the empty slot when there are no items', () => { + const { wrapper, container } = renderTable({ empty: No log events }, 0); + expect(wrapper.findRows()).toHaveLength(0); + expect(container.textContent).toContain('No log events'); + }); + + test('renders a status role while loading', () => { + const { container } = renderTable({ loading: true, loadingText: 'Loading log events' }, 0); + expect(container.querySelector('[role="status"]')).not.toBeNull(); + expect(container.textContent).toContain('Loading log events'); + }); +}); diff --git a/src/virtual-table/index.tsx b/src/virtual-table/index.tsx new file mode 100644 index 0000000000..1e47a0e26b --- /dev/null +++ b/src/virtual-table/index.tsx @@ -0,0 +1,44 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { VirtualTableProps } from './interfaces'; +import InternalVirtualTable from './internal'; + +export { VirtualTableProps }; + +// Config-driven, logs-specialized VirtualTable (cell F2-A1). One component driven by a +// required `viewConfig` whose `type` discriminant selects the surface. The raw view +// defaults its overscan +// to FILE_VIEW_OVERSCAN (40) when the consumer does not set it; the grid views default +// to TABLE_VIEW_OVERSCAN (20). +export default function VirtualTable({ + estimatedRowHeight = 23, + follow = false, + stickyHeader = true, + sortingDescending = false, + loading = false, + ...props +}: VirtualTableProps) { + const overscan = props.overscan ?? (props.viewConfig.type === 'raw' ? 40 : 20); + const baseComponentProps = useBaseComponent('VirtualTable', { + props: { view: props.viewConfig.type, estimatedRowHeight, overscan, follow, stickyHeader }, + }); + return ( + + ); +} + +applyDisplayName(VirtualTable, 'VirtualTable'); diff --git a/src/virtual-table/interfaces.ts b/src/virtual-table/interfaces.ts new file mode 100644 index 0000000000..1de062e4fa --- /dev/null +++ b/src/virtual-table/interfaces.ts @@ -0,0 +1,378 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { BaseComponentProps } from '../types/base-component'; +import { NonCancelableEventHandler } from '../types/events'; + +// VirtualTable (cell F2-A1): a logs/streaming-specialized, config-driven table, +// additive to and coexisting with `Table`. A required `viewConfig` object whose `type` +// discriminant selects the built-in surface (standard / patterns / raw) supplies that +// view's columns and renderers. This file is the public API surface; +// the windowing engine, measurement, live-tail mechanics, the two-mode filter, and +// the R-EXPAND a11y wiring land in impl-F2-A1-core. See +// deliverables/design/design-F2-A1.md. +export interface VirtualTableProps extends BaseComponentProps { + /** + * The full dataset for the current view. VirtualTable windows this to the visible + * range; the array is never fully rendered. Appending to it is the streaming/live + * path (see `follow`, `onVisibleRangeChange`, and the imperative ref). + */ + items: ReadonlyArray; + + /** + * Per-view configuration AND the single source of truth for which built-in surface + * is active: the `type` discriminant selects the view, and the body supplies that + * view's column set / renderers. This is F2's opinionation made explicit — + * VirtualTable ships three surfaces rather than being a raw grid: + * - "standard" the results grid: dynamic columns, leading disclosure column, + * fixed layout with a stretch-last column and static per-column + * widths (no per-column sort — sort is a patterns-view capability, + * CW-9). + * - "patterns" the aggregation grid: its own column set, per-column sort, + * diff/compare mode, and a shared histogram y-scale computed across + * the FULL dataset. + * - "raw" the monospaced raw log-line view (single column, no expansion), at + * RAW_LINE_HEIGHT density. + * The console owns the tab/segmented control that swaps the config; VirtualTable + * owns each view's grid, virtualization, a11y, and expansion. Kept as one object + * (rather than a flat columnDefinitions array) so a console can hold all three view + * configs and swap without re-shaping props. + */ + viewConfig: VirtualTableProps.ViewConfig; + + /** + * Stable identity per row. REQUIRED (unlike Table, where it is optional): row + * virtualization recycles DOM nodes, so a stable key is mandatory for correct focus + * retention, expansion state, and measurement caching across recycling and live + * append. + * @displayname track by + */ + trackBy: (item: T) => string; + + // --- Virtualization ------------------------------------------------------ + + /** + * Bounds the scroll viewport to a fixed px block-size. Windowing REQUIRES a bounded + * viewport: without a `height`/`maxHeight` here (or a height-constrained parent, since + * the root is a flex column and the scroll container flexes to fill it) the scroll + * container reports the full content height and every row mounts. Use `height` for a + * fixed viewport that always scrolls. + */ + height?: number; + + /** + * Bounds the scroll viewport to a maximum px block-size: the table grows with its + * content up to this height, then windows + scrolls. Alternative to `height` for a + * grow-then-scroll viewport. See `height` for why a bound is required to window. + */ + maxHeight?: number; + + /** + * Estimated collapsed row height in px, used to size the scroll runway before a row + * is measured. Defaults to the logs density; CloudWatch standard/patterns use 23 + * (ROW_HEIGHT), raw uses 20 (RAW_LINE_HEIGHT). + * @defaultValue 23 + */ + estimatedRowHeight?: number; + + /** + * Per-row height strategy. Return a fixed px height, or `"auto"` to opt that row + * into measurement (ResizeObserver-backed, measure-on-first-window-entry with a + * cached height and incremental offset correction). Omitting the prop means every + * collapsed row is fixed at `estimatedRowHeight`; expanded rows are always measured. + */ + getRowHeight?: (item: T) => number | 'auto'; + + /** + * Rows rendered beyond the visible range on each side. CloudWatch uses 20 for the + * grid views and 40 for the raw view (TABLE_VIEW_OVERSCAN / FILE_VIEW_OVERSCAN). + * @defaultValue 20 + */ + overscan?: number; + + /** + * Fires when the windowed visible range changes. Variable heights break a linear + * scrollTop estimate, so the accurate dataset-relative range is surfaced here for + * scroll-triggered prefetch. `firstIndex`/`lastIndex` index into `items`. + */ + onVisibleRangeChange?: NonCancelableEventHandler; + + // --- Live tail (built-in, F2 headline feature) --------------------------- + + /** + * Controlled follow (stick-to-bottom) state. When true, appended rows pin the + * viewport to newest; when the user scrolls away VirtualTable calls + * `onFollowChange(false)` and stops auto-scrolling. Controlled by design + * (override-seam): the console owns follow policy, the component owns the + * pin-to-newest mechanics, scroll-anchoring, and the accessible append announcement. + * @defaultValue false + */ + follow?: boolean; + + /** + * Fires when the follow state should change — on user scroll-away (false) or on + * reaching bottom / pressing the follow control (true). + */ + onFollowChange?: NonCancelableEventHandler; + + /** + * Optional accessible summary for a batch of appended rows, announced politely and + * rate-limited (~500 ms debounce, summarized) on the component-owned aria-live + * region during tail. When omitted, a default count-based message is used from + * `i18nStrings`. + */ + renderAppendAnnouncement?: (detail: VirtualTableProps.AppendDetail) => string; + + // --- Row expansion (R-EXPAND) -------------------------------------------- + + /** + * Renders arbitrary, non-tabular content for an expanded row. This is the R-EXPAND + * SUBSTRATE (goal 6): whatever it returns is hosted in the measured expanded region + * and inherits the full disclosure a11y + measurement contract. The two built-in + * presets (shape A log-record detail, shape B pattern detail) are DEFAULTS layered + * on this slot via `expandedContentPreset` — they are not a closed set. Presence of + * this prop (or a preset) enables the leading disclosure column. + */ + getExpandedContent?: (item: T, context: VirtualTableProps.RowContext) => React.ReactNode; + + /** + * Selects a built-in expanded-content layout, used as the default under + * `getExpandedContent`. "log-record" = shape A (~300 px key/value detail); + * "pattern-detail" = shape B (~150 px histogram + tokens + actions). Returning + * custom content from `getExpandedContent` overrides it for that row (override-seam). + */ + expandedContentPreset?: VirtualTableProps.ExpandedContentPreset; + + /** + * Estimated expanded height in px per row, seeding the runway before the expanded + * region is measured. Defaults follow the active preset (300 log-record, 150 + * pattern-detail); the real height is always measured. + */ + getExpandedRowHeight?: (item: T) => number; + + /** Controlled set of expanded row keys (trackBy values). */ + expandedItems?: ReadonlyArray; + + /** Uncontrolled initial set of expanded row keys. @defaultValue [] */ + defaultExpandedItems?: ReadonlyArray; + + /** Fires when a row's expansion is toggled (keyboard or pointer). */ + onExpandChange?: NonCancelableEventHandler>; + + // --- Two-mode filter (built-in) ------------------------------------------ + + /** + * The two-mode logs filter (CW-10), packaged as a component feature with the console + * supplying only the predicate (override-seam). VirtualTable owns the mechanism: + * match-indicator column, per-cell highlight styling, total-vs-filtered counts, + * keyboard next/previous-match, and a NON-VISUAL match conveyance (text/ARIA on the + * indicator, not colour alone; WCAG 1.4.1). + * - mode "subset" renders only matching rows (filtered count). + * - mode "mark-in-place" renders all rows, marks matches in place + a match + * indicator column, so context is preserved. + * Omitting `filter` disables filtering entirely. + */ + filter?: VirtualTableProps.Filter; + + // --- Columns / sort / layout (per-view surface, mirrors Table) ----------- + + /** Column currently sorted by (from the active view's column set). */ + sortingColumn?: VirtualTableProps.ColumnDefinition; + /** Sort direction. @defaultValue false */ + sortingDescending?: boolean; + /** Fires on sort control activation (keyboard-operable — not pointer-only). */ + onSortingChange?: NonCancelableEventHandler>; + + /** + * Controlled per-column widths in px (by column id); the stretch column omits it. Also + * the controlled source for `resizableColumns` — omit for uncontrolled resize. + */ + columnWidths?: Record; + + /** + * Enables draggable column resize handles on data-column headers (standard / patterns + * views). Freeze-on-first-resize: the first drag snapshots every column's rendered width to + * px so flexible columns become fixed and alignment stays stable; a `minWidth` clamps each + * column. Controlled via `columnWidths` + `onColumnWidthsChange`, or uncontrolled if both + * are omitted. @defaultValue false + */ + resizableColumns?: boolean; + /** Fires with the full width map (px, by column id) after a resize drag. */ + onColumnWidthsChange?: NonCancelableEventHandler; + + /** Sticky header with horizontal scroll synced to the body. @defaultValue true */ + stickyHeader?: boolean; + + // --- State / labels ------------------------------------------------------ + + /** Rendered when `items` is empty for the active view. */ + empty?: React.ReactNode; + /** Loading state (initial fetch). SR-announced via i18nStrings. */ + loading?: boolean; + /** Loading text rendered/announced while `loading` is true. */ + loadingText?: string; + + /** + * Accessibility labels and the ARIA/announcement string set: the grid label, + * per-view labels, the expand/collapse trigger label, the follow toggle label, + * filter-count and append announcement templates, and loading/error text. + * @i18n + */ + ariaLabels?: VirtualTableProps.AriaLabels; + i18nStrings?: VirtualTableProps.I18nStrings; + + /** + * Imperative handle for consumer-composed streaming controls the component does not + * render (e.g. a "jump to newest" button in console chrome): scroll to newest, + * scroll a specific row into view, reveal (expand + highlight) a row, and query + * whether the view is pinned to the end. + */ + imperativeRef?: React.Ref; +} + +export namespace VirtualTableProps { + export type View = 'standard' | 'patterns' | 'raw'; + export type ExpandedContentPreset = 'log-record' | 'pattern-detail'; + + export interface ColumnDefinition { + id: string; + header: React.ReactNode; + /** + * Cell renderer. Receives the item and CellContext. CellContext exposes row + * position and expansion, NOT the windowed slice. Cross-row scale (the patterns + * histogram peak) is delivered as the single resolved scalar + * `CellContext.histogramPeak` — populated only for `view="patterns"` — because it + * must be stable across scroll and computed over the full dataset. + */ + cell: (item: T, context: CellContext) => React.ReactNode; + sortingField?: string; + sortingComparator?: (a: T, b: T) => number; + /** Fixed layout width in px; the single stretch-last column omits it (last wins). */ + width?: number; + isStretch?: boolean; + minWidth?: number; + } + + export interface CellContext { + /** Dataset-relative index of this row (not window-relative). */ + rowIndex: number; + totalItemCount: number; + isExpanded: boolean; + /** True when this cell is a filter match in mark-in-place mode. */ + isFilterMatch: boolean; + /** + * The shared histogram y-scale peak across the FULL dataset. Populated ONLY for + * `view="patterns"` from the consumer-supplied `PatternsViewConfig.histogramPeak`; + * `undefined` when that config value is omitted, and for standard/raw views. The + * cell reads it here rather than recomputing over the windowed slice, so the + * y-scale is stable across scroll (B3 preserved). Only this single resolved scalar + * crosses the boundary — the windowed item slice does not. + */ + histogramPeak?: number; + } + + export interface RowContext { + rowIndex: number; + totalItemCount: number; + } + + export type ViewConfig = StandardViewConfig | PatternsViewConfig | RawViewConfig; + + export interface StandardViewConfig { + type: 'standard'; + columnDefinitions: ReadonlyArray>; + } + + export interface PatternsViewConfig { + type: 'patterns'; + columnDefinitions: ReadonlyArray>; + /** + * The shared histogram peak across the FULL dataset, supplied by the consumer. + * VirtualTable does not read per-item histogram buckets, so it cannot compute this + * itself: compute it once over `items` and pass it here. The component surfaces it + * to the patterns cell via `CellContext.histogramPeak` so the y-scale is shared and + * stable across scroll. When omitted, `CellContext.histogramPeak` is `undefined`. + * Always a full-dataset value, never window-scoped. + */ + histogramPeak?: number; + /** Enables diff/compare mode (an alternate patterns column set + row pairing). */ + diffMode?: boolean; + } + + export interface RawViewConfig { + type: 'raw'; + /** Single monospaced line renderer at RAW_LINE_HEIGHT; no columns, no expansion. */ + renderLine: (item: T) => React.ReactNode; + } + + export interface Filter { + mode: 'subset' | 'mark-in-place'; + predicate: (item: T) => boolean; + /** Optional per-cell highlight, applied only in mark-in-place mode. */ + highlight?: (item: T, columnId: string) => boolean; + } + + export interface VisibleRangeDetail { + firstIndex: number; + lastIndex: number; + } + export interface FollowChangeDetail { + follow: boolean; + reason: 'scroll-away' | 'reached-bottom' | 'control'; + } + export interface ExpandChangeDetail { + item: T; + expanded: boolean; + expandedItems: ReadonlyArray; + } + export interface AppendDetail { + appendedCount: number; + } + export interface SortingState { + sortingColumn: ColumnDefinition; + sortingDescending: boolean; + } + export interface ColumnWidthsDetail { + widths: Record; + } + + export interface Ref { + /** Scroll to and (optionally) pin to the newest row. */ + scrollToEnd(options?: { pin?: boolean }): void; + /** Scroll a row (by trackBy key) into view. */ + scrollToItem(key: string): void; + /** + * Expand + highlight + scroll a row into view (CW-13 reveal). In `mark-in-place` + * mode (or with no filter) the row is present, so it is expanded, highlighted, and + * scrolled into view. If a `subset` filter currently hides the target row, reveal + * is a no-op for that row — the console should clear or widen the filter first + * (VirtualTable does not silently drop a consumer-owned filter). + */ + revealItem(key: string): void; + /** Whether the view is currently pinned to the last row. */ + isPinnedToEnd(): boolean; + } + + export interface AriaLabels { + gridLabel: string; + viewLabel?: (view: View) => string; + expandRowLabel?: (item: T) => string; + collapseRowLabel?: (item: T) => string; + followToggleLabel?: string; + filterMatchLabel?: (item: T) => string; + /** Accessible name for the match-navigation group (mark-in-place filter). */ + matchNavigationLabel?: string; + /** Accessible name for the previous-match control (mark-in-place filter). */ + previousMatchLabel?: string; + /** Accessible name for the next-match control (mark-in-place filter). */ + nextMatchLabel?: string; + } + + export interface I18nStrings { + loadingText?: string; + filterCountText?: (matched: number, total: number) => string; + appendAnnouncementText?: (count: number) => string; + expandedRegionLabel?: string; + } +} diff --git a/src/virtual-table/internal.tsx b/src/virtual-table/internal.tsx new file mode 100644 index 0000000000..635dcbca5b --- /dev/null +++ b/src/virtual-table/internal.tsx @@ -0,0 +1,677 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import clsx from 'clsx'; + +import { useStableCallback, useUniqueId } from '@cloudscape-design/component-toolkit/internal'; + +import InternalIcon from '../icon/internal'; +import { getBaseProps } from '../internal/base-component'; +import { ExpandToggleButton } from '../internal/components/expand-toggle-button'; +import { fireNonCancelableEvent } from '../internal/events'; +import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { VirtualTableProps } from './interfaces'; +import { useExpansion } from './use-expansion'; +import { INDICATOR_COL_WIDTH, useFilter } from './use-filter'; +import { useLiveAnnouncement } from './use-live-announcement'; +import { useLiveTail } from './use-live-tail'; +import { useVirtualModel } from './use-virtual-model'; + +import styles from './styles.css.js'; + +interface InternalVirtualTableProps extends VirtualTableProps, InternalBaseComponentProps {} + +// impl-F2-A1-core: the opinionated logs core. The `viewConfig.type` discriminant selects +// the built-in surface (standard / patterns / raw); the shared windowing engine, live +// tail, two-mode filter, R-EXPAND substrate, and full-dataset a11y are wired here per +// design-F2-A1.md (Chorus rfjLh1FFEJ0m). Override-seam throughout: the component owns the +// mechanism + a11y contract, the console owns policy (follow state, filter predicate, +// sort application, view swap). +export default function InternalVirtualTable(props: InternalVirtualTableProps) { + const { + items, + viewConfig, + trackBy, + height, + maxHeight, + estimatedRowHeight = 23, + getRowHeight, + getExpandedContent, + expandedContentPreset, + getExpandedRowHeight, + expandedItems, + defaultExpandedItems, + onExpandChange, + follow = false, + onFollowChange, + renderAppendAnnouncement, + filter, + sortingColumn, + sortingDescending = false, + onSortingChange, + stickyHeader = true, + columnWidths, + resizableColumns = false, + onColumnWidthsChange, + empty, + loading = false, + loadingText, + ariaLabels, + i18nStrings, + imperativeRef, + onVisibleRangeChange, + __internalRootRef, + ...rest + } = props; + const baseProps = getBaseProps(rest); + const baseId = useUniqueId('virtual-table'); + const scrollContainerRef = useRef(null); + + const isRaw = viewConfig.type === 'raw'; + const columns = viewConfig.type === 'raw' ? [] : viewConfig.columnDefinitions; + const renderLine = viewConfig.type === 'raw' ? viewConfig.renderLine : undefined; + const overscan = props.overscan ?? (isRaw ? 40 : 20); + + // Two-mode filter: subset returns only matching rows (so the windowed dataset — and + // aria-rowcount — is the filtered length); mark-in-place returns all rows + the match + // set + a materialised indicator column. + const filterModel = useFilter({ items, trackBy, filter }); + const visibleItems = filterModel.visibleItems; + + const hasDisclosureColumn = !isRaw && (!!getExpandedContent || !!expandedContentPreset); + const hasIndicatorColumn = filterModel.hasIndicatorColumn; + + // Column-index accounting: disclosure = 1 (when present), match indicator = next (when + // mark-in-place highlighting is active), data columns follow. columnCount and every + // aria-colindex recompute coherently when the indicator column appears/disappears. + let nextColIndex = 1; + const disclosureColIndex = hasDisclosureColumn ? nextColIndex++ : undefined; + const indicatorColIndex = hasIndicatorColumn ? nextColIndex++ : undefined; + const dataColumnStart = nextColIndex; + const dataColumnCount = isRaw ? 1 : columns.length; + const columnCount = dataColumnCount + (hasDisclosureColumn ? 1 : 0) + (hasIndicatorColumn ? 1 : 0); + + // Single stretch target: if several columns set isStretch, the last-declared wins (CW-8). + const stretchColumnId = [...columns].reverse().find(column => column.isStretch)?.id; + + // --- Shared column-track layout + resize (ported from F3-A2) ------------------------------ + // ONE grid-template-columns is computed from the active view's columns (+ the leading + // disclosure track and the mark-in-place indicator track when present) and applied + // identically to the header row and every body row, so columns align across rows + // content-independently — the same template governs standard, patterns, and raw. Track + // rules: disclosure = a leading `auto` track (resolves to the fixed disclosure inline-size); + // indicator = a fixed px track (INDICATOR_COL_WIDTH); a resized/explicit-width data column = + // `px`; the stretch column and any width-less column = `minmax(px, 1fr)`. A + // resize map entry takes precedence over a declared width. + const isWidthControlled = columnWidths !== undefined; + const [uncontrolledWidths, setUncontrolledWidths] = useState>({}); + const widths = isWidthControlled ? columnWidths! : uncontrolledWidths; + + const gridTemplateColumns = (() => { + const tracks: string[] = []; + if (hasDisclosureColumn) { + tracks.push('auto'); + } + if (hasIndicatorColumn) { + tracks.push(`${INDICATOR_COL_WIDTH}px`); + } + if (isRaw) { + tracks.push('minmax(0px, 1fr)'); + } else { + for (const column of columns) { + const resized = widths[column.id]; + if (resized !== undefined) { + tracks.push(`${Math.max(resized, column.minWidth ?? 0)}px`); + } else if (column.id === stretchColumnId) { + tracks.push(`minmax(${column.minWidth ?? 0}px, 1fr)`); + } else if (column.width !== undefined) { + tracks.push(`${column.width}px`); + } else { + tracks.push(`minmax(${column.minWidth ?? 0}px, 1fr)`); + } + } + } + return tracks.join(' '); + })(); + + // Refs so the pointer-drag handlers always read the latest widths / minWidths / cell nodes + // without re-subscribing listeners on every render. + const headerCellRefs = useRef(new Map()); + const widthsRef = useRef(widths); + widthsRef.current = widths; + const minWidthByColumn = useRef(new Map()); + minWidthByColumn.current = new Map(columns.map(column => [column.id, column.minWidth])); + + const registerHeaderCell = useCallback((columnId: string, node: HTMLElement | null) => { + if (node) { + headerCellRefs.current.set(columnId, node); + } else { + headerCellRefs.current.delete(columnId); + } + }, []); + + const applyWidths = useCallback( + (next: Record) => { + if (!isWidthControlled) { + setUncontrolledWidths(next); + } + if (onColumnWidthsChange) { + fireNonCancelableEvent(onColumnWidthsChange, { widths: next }); + } + }, + [isWidthControlled, onColumnWidthsChange] + ); + + const resizeState = useRef<{ columnId: string; startX: number; startWidth: number } | null>(null); + const startColumnResize = useCallback( + (columnId: string, event: React.PointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + // Freeze-on-first-resize: snapshot the CURRENT rendered px width of every column so the + // flexible (1fr) tracks become fixed px, making the drag predictable and alignment-safe. + const frozen: Record = { ...widthsRef.current }; + let needSnapshot = false; + headerCellRefs.current.forEach((node, id) => { + if (frozen[id] === undefined) { + frozen[id] = Math.round(node.getBoundingClientRect().width); + needSnapshot = true; + } + }); + const startWidth = + frozen[columnId] ?? Math.round(headerCellRefs.current.get(columnId)?.getBoundingClientRect().width ?? 0); + resizeState.current = { columnId, startX: event.clientX, startWidth }; + if (needSnapshot) { + applyWidths(frozen); + } + const onMove = (moveEvent: PointerEvent) => { + const state = resizeState.current; + if (!state) { + return; + } + const min = minWidthByColumn.current.get(state.columnId) ?? 0; + const next = Math.max(min, Math.round(state.startWidth + (moveEvent.clientX - state.startX))); + applyWidths({ ...widthsRef.current, [state.columnId]: next }); + }; + const onUp = () => { + resizeState.current = null; + document.removeEventListener('pointermove', onMove); + document.removeEventListener('pointerup', onUp); + }; + document.addEventListener('pointermove', onMove); + document.addEventListener('pointerup', onUp); + }, + [applyWidths] + ); + + // Component-owned expansion + shared windowing engine over the (possibly filtered) view. + const expansion = useExpansion({ trackBy, expandedItems, defaultExpandedItems, onExpandChange }); + const model = useVirtualModel({ + items: visibleItems, + trackBy, + expandedIds: expansion.expandedIds, + expandedSignature: expansion.expandedSignature, + estimatedRowHeight, + getRowHeight, + getExpandedRowHeight, + defaultExpandedEstimate: presetExpandedHeight(expandedContentPreset), + overscan, + scrollContainerRef, + }); + + // Fire the dataset-relative visible range for scroll-triggered prefetch (in an effect, never + // during render). Held in a stable callback so a consumer passing a fresh inline handler each + // render cannot re-run this effect (which would loop: fire -> setState -> re-render -> new + // handler identity -> fire...). The effect depends only on the range indices. + const fireVisibleRangeChange = useStableCallback((firstIndex: number, lastIndex: number) => { + if (onVisibleRangeChange) { + fireNonCancelableEvent(onVisibleRangeChange, { firstIndex, lastIndex }); + } + }); + useEffect(() => { + if (model.firstIndex >= 0) { + fireVisibleRangeChange(model.firstIndex, model.lastIndex); + } + }, [model.firstIndex, model.lastIndex, fireVisibleRangeChange]); + + // Built-in live tail: pin-to-newest on append while following; release on scroll-away. + useLiveTail({ + follow, + itemCount: visibleItems.length, + scrollContainerRef, + scrollToEnd: model.scrollToEnd, + isPinnedToEnd: model.isPinnedToEnd, + onFollowChange, + }); + + // Debounced polite append announcement, keyed on the full dataset count. + const appendMessage = useLiveAnnouncement( + items.length, + renderAppendAnnouncement + ? detail => renderAppendAnnouncement({ appendedCount: detail.addedCount }) + : i18nStrings?.appendAnnouncementText + ? detail => i18nStrings.appendAnnouncementText!(detail.addedCount) + : undefined + ); + + // The resolved shared histogram y-scale for the patterns view: consumer-supplied peak + // across the FULL dataset when given. NOTE (panel/-cw-patterns): a component-computed + // default peak needs a histogram-value accessor the config does not yet expose, so the + // core surfaces only the supplied value; -cw-patterns supplies it and this seam is + // flagged for the panel rather than invented here. + const histogramPeak = viewConfig.type === 'patterns' ? viewConfig.histogramPeak : undefined; + + // Active-descendant keyboard model: the scroll container is the single always-present + // tab stop (reachable at any offset, incl. live-tail pinned-to-end); arrows move an + // active ROW (row-granular grid, matching the F1 pinned deviation) and never steal keys + // from interactive cell content or the expanded region (onGridKeyDown bails unless the + // grid itself is focused). + const idToIndex = useMemo(() => { + const map = new Map(); + visibleItems.forEach((item, index) => map.set(trackBy(item), index)); + return map; + }, [visibleItems, trackBy]); + + const [activeId, setActiveId] = useState(undefined); + const [hasFocus, setHasFocus] = useState(false); + const windowedDataIds = new Set( + model.slots.filter(slot => slot.type === 'data').map(slot => trackBy(visibleItems[slot.index])) + ); + const effectiveActiveId = + activeId && idToIndex.has(activeId) ? activeId : visibleItems.length ? trackBy(visibleItems[0]) : undefined; + // F-ACTIVE: gate the active-row indication on the grid actually holding focus. This single + // gate drives BOTH the visual .row-active outline and aria-activedescendant, so nothing is + // advertised or painted before any interaction (a seeded first row otherwise shows a ring on + // load). Keyboard nav still seeds from effectiveActiveId because it only fires while focused. + const gatedActiveId = hasFocus ? effectiveActiveId : undefined; + const activeDescendant = + gatedActiveId && windowedDataIds.has(gatedActiveId) ? rowDomId(baseId, gatedActiveId) : undefined; + + const moveActive = useCallback( + (delta: number | 'first' | 'last') => { + if (!visibleItems.length) { + return; + } + const currentIndex = effectiveActiveId ? (idToIndex.get(effectiveActiveId) ?? 0) : 0; + let target: number; + if (delta === 'first') { + target = 0; + } else if (delta === 'last') { + target = visibleItems.length - 1; + } else { + target = Math.min(visibleItems.length - 1, Math.max(0, currentIndex + delta)); + } + setActiveId(trackBy(visibleItems[target])); + model.scrollToIndex(target); + }, + [visibleItems, effectiveActiveId, idToIndex, trackBy, model] + ); + + const onGridKeyDown = (event: React.KeyboardEvent) => { + if (event.target !== event.currentTarget) { + return; + } + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + moveActive(1); + break; + case 'ArrowUp': + event.preventDefault(); + moveActive(-1); + break; + case 'Home': + event.preventDefault(); + moveActive('first'); + break; + case 'End': + event.preventDefault(); + moveActive('last'); + break; + } + }; + + // Reflect-not-sort: the component reflects sort intent + fires onSortingChange; the + // console applies the sort to `items`. Sort is a patterns-view capability (CW-9). + const handleSort = (column: VirtualTableProps.ColumnDefinition) => { + const active = sortingColumn?.id === column.id; + const nextDescending = active ? !sortingDescending : false; + fireNonCancelableEvent(onSortingChange, { sortingColumn: column, sortingDescending: nextDescending }); + }; + const ariaSortOf = ( + column: VirtualTableProps.ColumnDefinition + ): 'ascending' | 'descending' | 'none' | undefined => { + if (!column.sortingField) { + return undefined; + } + if (sortingColumn?.id === column.id) { + return sortingDescending ? 'descending' : 'ascending'; + } + return 'none'; + }; + + // Match navigation (mark-in-place): keyboard-operable next/previous-match reveal. + const revealMatch = (visibleIndex: number) => { + if (visibleIndex < 0) { + return; + } + setActiveId(trackBy(visibleItems[visibleIndex])); + model.scrollToIndex(visibleIndex); + // Move keyboard focus to the grid so the revealed match becomes the active descendant + // (under the F-ACTIVE focus gate) and arrow keys continue from it. + scrollContainerRef.current?.focus(); + }; + + useImperativeHandle( + imperativeRef, + () => ({ + scrollToEnd: () => model.scrollToEnd(), + scrollToItem: (key: string) => { + const index = idToIndex.get(key); + if (index !== undefined) { + model.scrollToIndex(index); + } + }, + // CW-13 reveal: expand + scroll a row into view. If a subset filter has hidden the + // row it is not in visibleItems and cannot be scrolled to; clearing a + // consumer-owned subset filter needs a console hook — flagged for the panel. + revealItem: (key: string) => { + const index = idToIndex.get(key); + if (index === undefined) { + return; + } + expansion.expand(visibleItems[index]); + setActiveId(key); + model.scrollToIndex(index); + }, + isPinnedToEnd: () => model.isPinnedToEnd(), + }), + [model, idToIndex, visibleItems, expansion] + ); + + const showEmpty = !loading && visibleItems.length === 0; + const regionLabel = (item: T) => i18nStrings?.expandedRegionLabel ?? ariaLabels?.expandRowLabel?.(item); + + const renderDataRow = (index: number, start: number, size: number, auto: boolean) => { + const item = visibleItems[index]; + const id = trackBy(item); + const expanded = expansion.isExpanded(id); + const isFilterMatch = filterModel.isMatch(id); + const toggleId = `${baseId}-toggle-${id}`; + const regionId = `${baseId}-region-${id}`; + const context: VirtualTableProps.CellContext = { + rowIndex: index, + totalItemCount: visibleItems.length, + isExpanded: expanded, + isFilterMatch, + histogramPeak, + }; + return ( +
+ {hasDisclosureColumn && ( + + {/* Reuse the shared ExpandToggleButton — same rotating caret + button a11y as Table's + expandable rows. The disclosure-button class is retained so test-utils + findExpandToggle still resolves it; id + aria-controls tie it to the region. */} + expansion.toggle(item)} + expandButtonLabel={ariaLabels?.expandRowLabel?.(item)} + collapseButtonLabel={ariaLabels?.collapseRowLabel?.(item)} + id={toggleId} + ariaControls={expanded ? regionId : undefined} + /> + + )} + {hasIndicatorColumn && ( + + {isFilterMatch && ( + // Non-visual match conveyance (WCAG 1.4.1): a visually-hidden text label + // carries the match to assistive tech; the visible marker is decorative. + <> + + + {ariaLabels?.filterMatchLabel?.(item) ?? 'Filter match'} + + + )} + + )} + {isRaw ? ( + + {renderLine?.(item)} + + ) : ( + columns.map((column, columnIndex) => ( + + {column.cell(item, context)} + + )) + )} +
+ ); + }; + + const renderExpandedRow = (index: number, start: number, size: number, auto: boolean) => { + const item = visibleItems[index]; + const id = trackBy(item); + const toggleId = `${baseId}-toggle-${id}`; + const regionId = `${baseId}-region-${id}`; + return ( +
+ {/* Full-width gridcell keeps a valid grid child model; the arbitrary, non-tabular + content lives in a labeled region inside it (design B2). A div (not a span) + hosts content that may include block-level elements. */} +
+
{ + if (event.key === 'Escape') { + event.stopPropagation(); + document.getElementById(toggleId)?.focus(); + } + }} + > + {getExpandedContent?.(item, { rowIndex: index, totalItemCount: visibleItems.length })} +
+
+
+ ); + }; + + return ( +
+ {(onFollowChange || filter) && ( +
+ {onFollowChange && ( + + )} + {/* Total-vs-filtered count is shown for BOTH modes (subset uses the filtered + length, mark-in-place the match count); match navigation is mark-in-place only. */} + {filter && ( + + {i18nStrings?.filterCountText?.(filterModel.matchedCount, filterModel.totalCount)} + + )} + {filter && filterModel.hasIndicatorColumn && ( +
+ + +
+ )} +
+ )} + +
{ + // F-ACTIVE: only the grid container itself holding focus advertises an active row. + // focusin bubbles, so guard against a descendant control (disclosure / expanded + // content) false-activating the grid. + if (event.target === event.currentTarget) { + setHasFocus(true); + } + }} + onBlur={event => { + if (event.target === event.currentTarget) { + setHasFocus(false); + } + }} + > +
+
+ {hasDisclosureColumn && ( + + )} + {hasIndicatorColumn && ( + + )} + {isRaw ? ( + + ) : ( + columns.map((column, columnIndex) => { + const sort = ariaSortOf(column); + return ( + registerHeaderCell(column.id, node)} + > + {sort ? ( + + ) : ( + column.header + )} + {resizableColumns && ( + // Pointer-only resize affordance pinned to the header cell's inline-end edge + // (aria-hidden — the columnheader keeps its role); drives the shared template. + + ); + }) + )} +
+
+ + {!loading && !showEmpty && ( +
+ {model.slots.map(slot => + slot.type === 'data' + ? renderDataRow(slot.index, slot.start, slot.size, slot.auto) + : renderExpandedRow(slot.index, slot.start, slot.size, slot.auto) + )} +
+ )} +
+ + {loading && ( +
+ {loadingText ?? i18nStrings?.loadingText} +
+ )} + + {showEmpty && empty !== undefined && empty !== null &&
{empty}
} + +
+ {appendMessage} +
+
+ ); +} + +function rowDomId(baseId: string, id: string): string { + return `${baseId}-row-${id}`; +} + +// Default pre-measurement runway for an expanded region, seeded by the active preset +// (shape A log-record ~300, shape B pattern-detail ~150). The real height is measured. +function presetExpandedHeight(preset?: VirtualTableProps.ExpandedContentPreset): number { + return preset === 'pattern-detail' ? 150 : 300; +} diff --git a/src/virtual-table/styles.scss b/src/virtual-table/styles.scss new file mode 100644 index 0000000000..5956127d51 --- /dev/null +++ b/src/virtual-table/styles.scss @@ -0,0 +1,355 @@ +/* + 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; + +// VirtualTable (F2-A1, logs-specialized) is styled to visually match the existing +// Cloudscape `Table` BY DEFAULT. Every colour / border / spacing value below is a +// Cloudscape design token REUSED from the Table component styles +// (src/table/header-cell/styles.scss and src/table/body-cell/styles.scss) rather +// than a bespoke value, so the look tracks Table + visual refresh automatically. +// The virtualization viewport (.scroll-container) scrolls with the NATIVE browser +// scrollbar — there is no custom / synthetic / overlay scrollbar anywhere. +// The logs-specialized affordances (toolbar / live-tail follow toggle / mark-in-place +// indicator column / match navigation / sortable header button) reuse the same Table +// tokens so they read as part of the same surface. + +.root { + @include styles.styles-reset; + position: relative; + // F-WINDOW: a height-owning flex column so the scroll container can flex to a bounded + // viewport (from height/maxHeight OR a height-constrained parent) and actually window. + display: flex; + flex-direction: column; + inline-size: 100%; + // Sit on the container content surface, like a Table inside a Container. + background: awsui.$color-background-container-content; +} + +.toolbar { + display: flex; + align-items: center; + gap: awsui.$space-scaled-xs; + margin-block-end: awsui.$space-scaled-s; +} + +.follow-toggle { + @include styles.styles-reset; + cursor: pointer; + padding-inline: awsui.$space-scaled-xxs; + // B5: a visible pressed/active state, not aria-pressed alone. + &[aria-pressed='true'] { + background: awsui.$color-background-item-selected; + font-weight: awsui.$font-weight-heading-s; + } +} + +.scroll-container { + position: relative; + // F-WINDOW: fill a bounded root so clientHeight is the viewport, not the full content + // height; min-block-size:0 lets it shrink inside the flex column. A direct height/maxHeight + // (applied inline from the props) bounds it equivalently. + flex: 1 1 auto; + min-block-size: 0; + // Native scrollbar only — no ::-webkit-scrollbar / overlay / synthetic scrollbar. + overflow: auto; + inline-size: 100%; + + // The scroll container is the single tab stop (active-descendant keyboard model), so it + // carries the focus ring; the active row is indicated via .row-active. The 2px/-2px inset + // (colour is tokenised) is deliberate over styles.focus-highlight: the mixin forces + // position:relative + a ::before ring, which an overflow:auto container clips and an + // absolutely-positioned windowed row cannot host. + &:focus-visible { + outline: 2px solid awsui.$color-border-item-focused; + outline-offset: -2px; + } +} + +.header-rowgroup { + display: block; +} + +// Header treatment matches Table's thead: table-header surface + a default divider +// under the header row (Table: border-block-end divider-list-width solid +// color-border-divider-default). A grid (not flex) so the shared column template governs +// every column's width identically to the body rows (columns align across rows +// content-independently); the template is applied inline from internal.tsx. +.header-row { + display: grid; + background: awsui.$color-background-table-header; + border-block-end: awsui.$border-divider-list-width solid awsui.$color-border-divider-default; +} + +// Sticky header is opt-in: only pin the header row when the consumer sets stickyHeader. +// The header stays inside the single scroll container so it scrolls horizontally with +// the body (header <-> body sync, CW-14) while pinning vertically. Its surface matches +// the non-sticky header (Table sticky-header look). +.sticky-header { + & > .header-rowgroup { + position: sticky; + inset-block-start: 0; + // Table sticky-header stacking convention (798–800 band); a bare literal because the + // reused token set has no dedicated sticky-band token. + z-index: 800; + background: awsui.$color-background-table-header; + } +} + +.header-cell { + position: relative; + min-inline-size: 0; + color: awsui.$color-text-column-header; + font-weight: awsui.$font-weight-heading-s; + line-height: awsui.$line-height-heading-xs; + // Table cell rhythm: dense vertical padding, space-scaled-l inline padding. + padding-block: awsui.$space-scaled-xxs; + padding-inline: awsui.$space-scaled-l; + box-sizing: border-box; +} + +// Column resize affordance (resizableColumns): a pointer-only handle pinned to the header +// cell's inline-end edge (aria-hidden — it adds no semantics; the columnheader keeps its +// role). Dragging it drives the shared column template's width map. It renders a VISIBLE +// vertical divider on the trailing edge to match Table's header resizer: a thin rule in the +// default divider colour that switches to the interactive-divider colour on hover/active. +.resize-handle { + position: absolute; + inset-block: 0; + inset-inline-end: 0; + // Comfortable grab area hanging just inside the trailing edge; the visible rule is drawn on + // the edge itself via ::after so the hit target is wider than the 1px divider. + inline-size: awsui.$space-m; + cursor: col-resize; + user-select: none; + z-index: 1; + + &::after { + content: ''; + position: absolute; + inset-inline-end: 0; + // Small block gap top/bottom, like Table's column divider. + inset-block: awsui.$space-xxs; + inline-size: 0; + border-inline-start: awsui.$border-divider-list-width solid awsui.$color-border-divider-default; + box-sizing: border-box; + } + + &:hover::after, + &:active::after { + border-inline-start-color: awsui.$color-border-divider-interactive-default; + } +} + +// Fixed disclosure column. It carries an explicit inline-size (the $space-xl token) so the +// shared grid template's leading `auto` track resolves to the SAME width in the header and +// every body row. +.disclosure-header, +.disclosure-cell { + inline-size: awsui.$space-xl; + display: flex; + align-items: center; + justify-content: center; +} + +// The body is the scroll runway. The core sizes it to the full virtualized height and +// absolutely positions the windowed rows within it via inline styles (position/offset +// set in internal.tsx), so this class only provides the runway box. +.body { + display: block; + position: relative; + inline-size: 100%; +} + +// Header and body rows are CSS grids driven by the SAME grid-template-columns (applied inline +// from internal.tsx), so every column aligns across rows deterministically. Rows stay +// absolutely positioned vertically in the runway (windowing unchanged) — grid governs only +// the horizontal column axis. +.row, +.expanded-row { + display: grid; + inline-size: 100%; + box-sizing: border-box; +} + +.row { + align-items: center; + // Table's per-row divider: the border sits on the row (not the cell) so it always + // lands at the row boundary regardless of cell padding / fixed windowed row height. + border-block-end: awsui.$border-divider-list-width solid awsui.$color-border-divider-secondary; + + // Selected-row surface, matching Table's selected row (color-background-item-selected + // + selected border). F2-A1 has no selection prop today; this is an inert style hook + // so a marked row reads identically to Table if a consumer sets the class. + &.row-selected { + background: awsui.$color-background-item-selected; + border-block: awsui.$border-width-item-selected solid awsui.$color-border-item-selected; + } + + &:focus-visible { + outline: 2px solid awsui.$color-border-item-focused; + outline-offset: -2px; + } +} + +// Active descendant highlight: the container holds the tab stop, this row is the +// aria-activedescendant target moved by the arrow keys. Applied (via .row-active in +// internal.tsx) only while the grid holds focus, so no ring paints on load (F-ACTIVE). +// The 2px/-2px inset (tokenised colour) is deliberate over styles.focus-highlight, which +// would force position:relative and break the row's required position:absolute windowing. +.row-active { + outline: 2px solid awsui.$color-border-item-focused; + outline-offset: -2px; +} + +.cell { + min-inline-size: 0; + // Default: single line, truncate with an ellipsis. This matches Table's default + // (wrapLines = false). Wrapping is available via the cell-wrap hook on measured rows + // (getRowHeight: 'auto'), mirroring Table's opt-in wrapLines. + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + // Match Table body-cell vertical density ($space-scaled-xs). Only .cell changes; the + // header cell keeps $space-scaled-xxs, which already matches Table's header-cell. + padding-block: awsui.$space-scaled-xs; + padding-inline: awsui.$space-scaled-l; + box-sizing: border-box; +} + +// Wrapping affordance (Table wrapLines parity). Apply on cells in rows that opt into +// measured heights (getRowHeight: 'auto') so wrapped content is measured and windowed +// correctly — e.g. the raw/file line renderer. +.cell-wrap { + white-space: normal; + overflow-wrap: break-word; + text-overflow: clip; +} + +.disclosure-button { + @include styles.styles-reset; + cursor: pointer; + inline-size: 100%; + block-size: 100%; +} + +.expanded-cell { + // Span every column track (incl. disclosure + indicator) so the region is full width. + grid-column: 1 / -1; + min-inline-size: 0; + box-sizing: border-box; +} + +// The expanded region sits on the container content surface with a divider below, so an +// expanded row reads as a detail panel attached to its data row (Table expandable-rows +// look), while holding arbitrary non-tabular content (R-EXPAND shapes A/B). +.expanded-region { + display: block; + inline-size: 100%; + box-sizing: border-box; + background: awsui.$color-background-container-content; + border-block-end: awsui.$border-divider-list-width solid awsui.$color-border-divider-secondary; + padding-block: awsui.$space-scaled-s; + padding-inline: awsui.$space-scaled-l; +} + +.loading, +.empty { + padding-block: awsui.$space-scaled-l; + text-align: center; +} + +.empty { + // Muted empty-state text, matching Table's empty slot. + color: awsui.$color-text-empty; +} + +// Materialised mark-in-place match-indicator column (INDICATOR_COL_WIDTH=25). Present only +// while highlighting; sized by the shared grid template's fixed px track. Counted as a real +// columnheader so aria-colindex stays coherent. +.indicator-header, +.indicator-cell { + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; +} + +.indicator-marker { + inline-size: 6px; + block-size: 6px; + border-start-start-radius: 50%; + border-start-end-radius: 50%; + border-end-start-radius: 50%; + border-end-end-radius: 50%; + background: awsui.$color-border-item-focused; +} + +// A cell whose consumer-supplied highlight predicate matches in mark-in-place mode. The +// non-visual match conveyance lives on the indicator column; this is a visual aid only. +.cell-highlight { + background: awsui.$color-background-item-selected; +} + +// Sortable header trigger: strip the native