From a86452058096001e60894d504035ba453c1e7c47 Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Thu, 23 Jul 2026 13:59:58 +0000 Subject: [PATCH 1/2] feat: add hierarchical sticky rows to Table (WIP) --- pages/table/hierarchical-sticky-rows.page.tsx | 114 ++++++++++++++++++ .../__snapshots__/documenter.test.ts.snap | 5 + .../__tests__/sticky-ancestor-rows.test.tsx | 90 ++++++++++++++ src/table/interfaces.tsx | 15 +++ src/table/internal.tsx | 17 ++- src/table/sticky-ancestor-rows/index.ts | 97 +++++++++++++++ src/table/styles.scss | 15 +++ 7 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 pages/table/hierarchical-sticky-rows.page.tsx create mode 100644 src/table/__tests__/sticky-ancestor-rows.test.tsx create mode 100644 src/table/sticky-ancestor-rows/index.ts diff --git a/pages/table/hierarchical-sticky-rows.page.tsx b/pages/table/hierarchical-sticky-rows.page.tsx new file mode 100644 index 0000000000..21157b346f --- /dev/null +++ b/pages/table/hierarchical-sticky-rows.page.tsx @@ -0,0 +1,114 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useMemo, useState } from 'react'; + +import { Box, Checkbox, Header, SpaceBetween, Table, TableProps } from '~components'; + +// v0 dev page for AWSUI-59346 — Hierarchical sticky rows. +// Demonstrates: expanded ancestor rows stay pinned below the sticky header while +// scrolling through their descendants, stacking by hierarchy level. + +interface Node { + id: string; + name: string; + type: string; + size: number; + children?: Node[]; +} + +// Build a deep, wide tree so the page is scrollable and the sticky behavior is visible. +function makeTree(): Node[] { + const roots: Node[] = []; + for (let a = 1; a <= 4; a++) { + const level1: Node = { id: `A${a}`, name: `Region ${a}`, type: 'region', size: 0, children: [] }; + for (let b = 1; b <= 4; b++) { + const level2: Node = { + id: `A${a}-B${b}`, + name: `Availability zone ${a}.${b}`, + type: 'az', + size: 0, + children: [], + }; + for (let c = 1; c <= 6; c++) { + const level3: Node = { + id: `A${a}-B${b}-C${c}`, + name: `Cluster ${a}.${b}.${c}`, + type: 'cluster', + size: 0, + children: [], + }; + for (let d = 1; d <= 6; d++) { + level3.children!.push({ + id: `A${a}-B${b}-C${c}-D${d}`, + name: `Instance ${a}.${b}.${c}.${d}`, + type: 'instance', + size: d * 8, + }); + } + level2.children!.push(level3); + } + level1.children!.push(level2); + } + roots.push(level1); + } + return roots; +} + +function flattenExpanded(nodes: Node[]): Node[] { + return nodes.reduce( + (acc, node) => [...acc, node, ...(node.children ? flattenExpanded(node.children) : [])], + [] + ); +} + +export default function HierarchicalStickyRowsPage() { + const tree = useMemo(makeTree, []); + const allNodes = useMemo(() => flattenExpanded(tree), [tree]); + const [enabled, setEnabled] = useState(true); + // Start fully expanded so the sticky stacking is immediately visible. + const [expandedItems, setExpandedItems] = useState(() => + allNodes.filter(n => n.children && n.children.length) + ); + + const expandableRows: TableProps.ExpandableRows = { + getItemChildren: item => item.children ?? [], + isItemExpandable: item => !!item.children && item.children.length > 0, + expandedItems, + onExpandableItemToggle: ({ detail }) => { + setExpandedItems(prev => + detail.expanded ? [...prev, detail.item] : prev.filter(item => item.id !== detail.item.id) + ); + }, + stickyAncestorRows: enabled, + }; + + return ( + + +
+ Hierarchical sticky rows +
+ setEnabled(detail.checked)}> + Enable hierarchical sticky rows (stickyAncestorRows) + + + item.name, isRowHeader: true }, + { id: 'type', header: 'Type', cell: item => item.type }, + { id: 'size', header: 'Size', cell: item => item.size }, + ]} + header={
Resources
} + /> + + + ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index b54c1b56ab..4bb9683481 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -28713,6 +28713,11 @@ The value is passed as \`selectedItemsCount\` property to the \`ariaLabels.allIt "optional": true, "type": "TableProps.OnGroupSelectionChange", }, + { + "name": "stickyAncestorRows", + "optional": true, + "type": "boolean", + }, { "name": "totalItemsCount", "optional": true, diff --git a/src/table/__tests__/sticky-ancestor-rows.test.tsx b/src/table/__tests__/sticky-ancestor-rows.test.tsx new file mode 100644 index 0000000000..391c007c98 --- /dev/null +++ b/src/table/__tests__/sticky-ancestor-rows.test.tsx @@ -0,0 +1,90 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import Table, { TableProps } from '../../../lib/components/table'; +import { + DEFAULT_STICKY_ANCESTOR_ROW_HEIGHT, + getStickyAncestorRowOffset, +} from '../../../lib/components/table/sticky-ancestor-rows'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +import tableStyles from '../../../lib/components/table/styles.css.js'; + +describe('getStickyAncestorRowOffset', () => { + test('top-level rows sit flush against the header offset', () => { + expect(getStickyAncestorRowOffset({ level: 1, headerOffset: 50, rowHeight: 40 })).toBe(50); + }); + + test('each deeper level stacks one row height below the previous', () => { + expect(getStickyAncestorRowOffset({ level: 2, headerOffset: 50, rowHeight: 40 })).toBe(90); + expect(getStickyAncestorRowOffset({ level: 3, headerOffset: 50, rowHeight: 40 })).toBe(130); + }); + + test('normalizes non-positive / fractional levels to level 1', () => { + expect(getStickyAncestorRowOffset({ level: 0, headerOffset: 10, rowHeight: 40 })).toBe(10); + expect(getStickyAncestorRowOffset({ level: -5, headerOffset: 10, rowHeight: 40 })).toBe(10); + expect(getStickyAncestorRowOffset({ level: 1.9, headerOffset: 10, rowHeight: 40 })).toBe(10); + }); + + test('clamps negative row height to zero', () => { + expect(getStickyAncestorRowOffset({ level: 3, headerOffset: 10, rowHeight: -40 })).toBe(10); + }); + + test('exposes a sane default row height', () => { + expect(DEFAULT_STICKY_ANCESTOR_ROW_HEIGHT).toBeGreaterThan(0); + }); +}); + +interface Item { + name: string; + children?: Item[]; +} + +const items: Item[] = [ + { name: 'Parent A', children: [{ name: 'Child A1' }, { name: 'Child A2' }] }, + { name: 'Parent B', children: [{ name: 'Child B1' }] }, +]; + +function renderTable(stickyAncestorRows: boolean) { + const expandableRows: TableProps.ExpandableRows = { + getItemChildren: item => item.children ?? [], + isItemExpandable: item => !!item.children, + expandedItems: [items[0]], + onExpandableItemToggle: () => {}, + stickyAncestorRows, + }; + const { container } = render( +
item.name }]} + items={items} + trackBy="name" + expandableRows={expandableRows} + /> + ); + return createWrapper(container).findTable()!; +} + +describe('stickyAncestorRows rendering', () => { + test('applies the sticky-ancestor-row class to expanded parent rows', () => { + const table = renderTable(true); + // Row 1 = expanded "Parent A". + const parentRow = table.findRows()[0].getElement(); + expect(parentRow.className).toContain(tableStyles['sticky-ancestor-row']); + }); + + test('does not apply the sticky class to leaf/child rows', () => { + const table = renderTable(true); + // Row 2 = "Child A1" (leaf, not expanded). + const childRow = table.findRows()[1].getElement(); + expect(childRow.className).not.toContain(tableStyles['sticky-ancestor-row']); + }); + + test('does not apply the sticky class when the feature is disabled', () => { + const table = renderTable(false); + const parentRow = table.findRows()[0].getElement(); + expect(parentRow.className).not.toContain(tableStyles['sticky-ancestor-row']); + }); +}); diff --git a/src/table/interfaces.tsx b/src/table/interfaces.tsx index e00ef8fc17..14c9892a56 100644 --- a/src/table/interfaces.tsx +++ b/src/table/interfaces.tsx @@ -668,6 +668,21 @@ export namespace TableProps { totalItemsCount?: number; getSelectedItemsCount?: (item: T) => number; totalSelectedItemsCount?: number; + + /** + * When set to `true`, ancestor (expanded parent) rows remain stuck to the top of the + * table while the user scrolls through their descendant rows, similar to sticky section + * headers. As the user scrolls deeper, each expanded parent level stacks below the + * sticky header, reflecting the user's current position in the hierarchy. + * + * This is an experimental, opt-in enhancement (v0) built on top of the browser's native + * `position: sticky`. It is only effective together with `stickyHeader`. Row height is + * currently approximated, so rows with wrapped content may overlap slightly. + * + * Do not use `stickyAncestorRows` conditionally. Instead, keep its value constant during + * the component lifecycle. + */ + stickyAncestorRows?: boolean; } export type OnExpandableItemToggle = NonCancelableEventHandler>; diff --git a/src/table/internal.tsx b/src/table/internal.tsx index d58a59f1f9..7024b12197 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -51,6 +51,7 @@ import { focusMarkers, useSelection, useSelectionFocusMove } from './selection'; import { TableBodySelectionCell } from './selection/selection-cell'; import { useGroupSelection } from './selection/use-group-selection'; import { SkeletonRows } from './skeleton-rows'; +import { useStickyAncestorRows } from './sticky-ancestor-rows'; import { useStickyColumns } from './sticky-columns'; import StickyHeader, { StickyHeaderRef } from './sticky-header'; import { StickyScrollbar } from './sticky-scrollbar'; @@ -193,6 +194,11 @@ const InternalTable = React.forwardRef( const secondaryWrapperRef = React.useRef(null); const theadRef = useRef(null); + const { getStickyAncestorRowProps } = useStickyAncestorRows({ + enabled: !!externalExpandableRows?.stickyAncestorRows && !!stickyHeader, + headerRef: theadRef, + headerVerticalOffset: stickyHeaderVerticalOffset, + }); const stickyHeaderRef = React.useRef(null); const scrollbarRef = React.useRef(null); const { cancelEdit, ...cellEditing } = useCellEditing({ onCancel: onEditCancel, onSubmit: submitEdit }); @@ -670,10 +676,19 @@ const InternalTable = React.forwardRef( }; if (row.type === 'data') { const rowId = `${getTableItemKey(row.item)}`; + const stickyAncestorRowProps = getStickyAncestorRowProps( + rowExpandableProps?.level ?? 1, + !!rowExpandableProps?.isExpanded + ); return ( { // When an element inside table row receives focus we want to adjust the scroll. // However, that behavior is unwanted when the focus is received as result of a click diff --git a/src/table/sticky-ancestor-rows/index.ts b/src/table/sticky-ancestor-rows/index.ts new file mode 100644 index 0000000000..17042e6043 --- /dev/null +++ b/src/table/sticky-ancestor-rows/index.ts @@ -0,0 +1,97 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { RefObject, useCallback, useState } from 'react'; + +import { useResizeObserver } from '@cloudscape-design/component-toolkit/internal'; + +import styles from '../styles.css.js'; + +/** + * Default estimate (in px) of a single ancestor row height. Used to stack nested + * sticky ancestor rows below one another. This is a v0 approximation: rows with + * wrapped content or custom vertical alignment may be taller. Precise per-row + * measurement is tracked as follow-up work. + */ +export const DEFAULT_STICKY_ANCESTOR_ROW_HEIGHT = 40; + +export interface StickyAncestorRowOffsetProps { + /** 1-based hierarchy level of the row (top-level rows are level 1). */ + level: number; + /** Combined offset (in px) from the top of the scroll container to the first sticky row. */ + headerOffset: number; + /** Estimated height (in px) of a single ancestor row. */ + rowHeight: number; +} + +/** + * Pure helper that computes the `inset-block-start` (top) offset for a sticky ancestor + * row so that each hierarchy level stacks directly below the sticky header and any + * shallower ancestors. Level 1 sits flush against the header; each additional level + * is pushed down by one row height. + */ +export function getStickyAncestorRowOffset({ level, headerOffset, rowHeight }: StickyAncestorRowOffsetProps): number { + const normalizedLevel = Math.max(1, Math.floor(level)); + const normalizedRowHeight = Math.max(0, rowHeight); + return headerOffset + (normalizedLevel - 1) * normalizedRowHeight; +} + +export interface StickyAncestorRowProps { + className?: string; + style?: { insetBlockStart: number; zIndex: number }; +} + +export interface UseStickyAncestorRowsProps { + /** Whether the feature is enabled (opt-in via `expandableRows.stickyAncestorRows`). */ + enabled: boolean; + /** Ref to the (primary) table header element used to measure the sticky header height. */ + headerRef: RefObject; + /** Additional vertical offset from the top, e.g. `stickyHeaderVerticalOffset`. */ + headerVerticalOffset?: number; + /** Estimated ancestor row height, defaults to {@link DEFAULT_STICKY_ANCESTOR_ROW_HEIGHT}. */ + rowHeight?: number; +} + +/** + * v0 hook wiring hierarchical sticky ancestor rows. It measures the header height + * (reusing the existing sticky-header thead ref) and returns a factory that produces + * the class name and inline offset styles for a given expandable row. + * + * The heavy lifting is delegated to the browser's native `position: sticky`: an + * expanded parent row stays pinned while its descendants scroll and is naturally + * released once the next sibling at the same level reaches the top. + */ +export function useStickyAncestorRows({ + enabled, + headerRef, + headerVerticalOffset = 0, + rowHeight = DEFAULT_STICKY_ANCESTOR_ROW_HEIGHT, +}: UseStickyAncestorRowsProps) { + const [headerHeight, setHeaderHeight] = useState(0); + + useResizeObserver(headerRef, entry => { + if (enabled) { + setHeaderHeight(entry.borderBoxHeight); + } + }); + + const getStickyAncestorRowProps = useCallback( + (level: number, isSticky: boolean): StickyAncestorRowProps => { + if (!enabled || !isSticky) { + return {}; + } + const insetBlockStart = getStickyAncestorRowOffset({ + level, + headerOffset: headerVerticalOffset + headerHeight, + rowHeight, + }); + return { + className: styles['sticky-ancestor-row'], + // Deeper levels get a slightly lower z-index so shallower ancestors win any overlap. + style: { insetBlockStart, zIndex: 798 - Math.max(0, Math.floor(level) - 1) }, + }; + }, + [enabled, headerHeight, headerVerticalOffset, rowHeight] + ); + + return { getStickyAncestorRowProps }; +} diff --git a/src/table/styles.scss b/src/table/styles.scss index 2585b019ab..77d24d6c8c 100644 --- a/src/table/styles.scss +++ b/src/table/styles.scss @@ -233,6 +233,21 @@ filter search icon. /* used in test-utils */ } +// v0: Hierarchical sticky rows (AWSUI-59346). +// An expanded ancestor row stays pinned below the sticky header while the user +// scrolls through its descendants. The inset-block-start offset and z-index are +// supplied inline per row (see sticky-ancestor-rows/index.ts) so nested levels +// stack. Cells get a background so scrolled content does not bleed through. +.sticky-ancestor-row { + @supports (position: sticky) { + position: sticky; + + > * { + background: awsui.$color-background-container-content; + } + } +} + .skeleton-loading-cell { padding-block: 0; padding-inline: 0; From 44282a51addc55bfe32dd42148e70ed7efc1abf4 Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Thu, 23 Jul 2026 16:06:32 +0000 Subject: [PATCH 2/2] fix: target body-cell instead of universal selector for sticky ancestor rows --- src/table/styles.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/table/styles.scss b/src/table/styles.scss index 77d24d6c8c..d541dbbb1d 100644 --- a/src/table/styles.scss +++ b/src/table/styles.scss @@ -242,7 +242,7 @@ filter search icon. @supports (position: sticky) { position: sticky; - > * { + > .body-cell { background: awsui.$color-background-container-content; } }