Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions pages/table/hierarchical-sticky-rows.page.tsx
Original file line number Diff line number Diff line change
@@ -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<Node[]>(
(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<Node[]>(() =>
allNodes.filter(n => n.children && n.children.length)
);

const expandableRows: TableProps.ExpandableRows<Node> = {
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 (
<Box padding="l">
<SpaceBetween size="l">
<Header
variant="h1"
description="AWSUI-59346 v0 — expanded parent rows stick below the sticky header while scrolling."
>
Hierarchical sticky rows
</Header>
<Checkbox checked={enabled} onChange={({ detail }) => setEnabled(detail.checked)}>
Enable hierarchical sticky rows (stickyAncestorRows)
</Checkbox>

<Table
stickyHeader={true}
variant="container"
trackBy="id"
items={tree}
expandableRows={expandableRows}
columnDefinitions={[
{ id: 'name', header: 'Name', cell: item => item.name, isRowHeader: true },
{ id: 'type', header: 'Type', cell: item => item.type },
{ id: 'size', header: 'Size', cell: item => item.size },
]}
header={<Header counter={`(${allNodes.length})`}>Resources</Header>}
/>
</SpaceBetween>
</Box>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -28713,6 +28713,11 @@ The value is passed as \`selectedItemsCount\` property to the \`ariaLabels.allIt
"optional": true,
"type": "TableProps.OnGroupSelectionChange<T>",
},
{
"name": "stickyAncestorRows",
"optional": true,
"type": "boolean",
},
{
"name": "totalItemsCount",
"optional": true,
Expand Down
90 changes: 90 additions & 0 deletions src/table/__tests__/sticky-ancestor-rows.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Item> = {
getItemChildren: item => item.children ?? [],
isItemExpandable: item => !!item.children,
expandedItems: [items[0]],
onExpandableItemToggle: () => {},
stickyAncestorRows,
};
const { container } = render(
<Table
stickyHeader={true}
columnDefinitions={[{ id: 'name', header: 'Name', cell: item => 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']);
});
});
15 changes: 15 additions & 0 deletions src/table/interfaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = NonCancelableEventHandler<ExpandableItemToggleDetail<T>>;
Expand Down
17 changes: 16 additions & 1 deletion src/table/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -193,6 +194,11 @@ const InternalTable = React.forwardRef(

const secondaryWrapperRef = React.useRef<HTMLDivElement>(null);
const theadRef = useRef<HTMLTableRowElement>(null);
const { getStickyAncestorRowProps } = useStickyAncestorRows({
enabled: !!externalExpandableRows?.stickyAncestorRows && !!stickyHeader,
headerRef: theadRef,
headerVerticalOffset: stickyHeaderVerticalOffset,
});
const stickyHeaderRef = React.useRef<StickyHeaderRef>(null);
const scrollbarRef = React.useRef<HTMLDivElement>(null);
const { cancelEdit, ...cellEditing } = useCellEditing({ onCancel: onEditCancel, onSubmit: submitEdit });
Expand Down Expand Up @@ -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 (
<tr
key={rowId}
className={clsx(styles.row, sharedCellProps.isSelected && styles['row-selected'])}
className={clsx(
styles.row,
sharedCellProps.isSelected && styles['row-selected'],
stickyAncestorRowProps.className
)}
style={stickyAncestorRowProps.style}
onFocus={({ currentTarget }) => {
// 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
Expand Down
97 changes: 97 additions & 0 deletions src/table/sticky-ancestor-rows/index.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>;
/** 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 };
}
Loading
Loading