diff --git a/.changeset/table-row-expansion-glide.md b/.changeset/table-row-expansion-glide.md new file mode 100644 index 0000000000000..a602be810a05b --- /dev/null +++ b/.changeset/table-row-expansion-glide.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] Table: `useTableRowExpansion` takes an `animatedLayout` handle from `useAnimatedLayout`. With it, opening or closing a row's panel glides the rows after it to their new places instead of jumping, and each row's divider moves with its row. Under Reduce Motion the rows fade in place on a touch screen and change at once with a mouse; in a table with column lines (`dividers="columns"` or `"grid"`) they change at once. + +@cixzhang diff --git a/apps/storybook/stories/TableRowExpansion.stories.tsx b/apps/storybook/stories/TableRowExpansion.stories.tsx index 4f7532f233775..d7bf28e6449df 100644 --- a/apps/storybook/stories/TableRowExpansion.stories.tsx +++ b/apps/storybook/stories/TableRowExpansion.stories.tsx @@ -1,13 +1,15 @@ // Copyright (c) Meta Platforms, Inc. and affiliates. -import {useState} from 'react'; +import React, {useMemo, useRef, useState} from 'react'; import type {Meta, StoryObj} from '@storybook/react'; import { Table, useTableRowExpansion, + useTableStickyColumns, pixel, proportional, } from '@astryxdesign/core/Table'; +import {useAnimatedLayout} from '@astryxdesign/core/hooks'; import type {TableColumn} from '@astryxdesign/core/Table'; import {Theme} from '@astryxdesign/core/theme'; import {neutralTheme} from '@astryxdesign/theme-neutral'; @@ -205,3 +207,126 @@ export const NotAllRowsExpandable: Story = { ); }, }; + +const manyOrders: Order[] = Array.from({length: 2000}, (_, index) => ({ + ...orders[index % orders.length], + id: `ord-${String(index + 1).padStart(4, '0')}`, +})); +const sevenOrders = manyOrders.slice(0, 7); + +/** + * With an `animatedLayout` handle, opening or closing a row's panel glides the + * rows after it instead of jumping them, the panel plays in on the same + * motion, and each row's divider moves with its row. The Customer column is + * pinned and the table scrolls horizontally, so the pinned cells' own + * dividers move too. Under Reduce Motion, and with column lines (the + * `dividers` control at "columns" or "grid"), the rows change at once. The + * `themed` control sets a blue theme border and an app's red border on the + * tbody's third row; `rowBorder` sets an app's 3 px green border on the + * fourth order's row itself (`tr`), which glides with the row too; `edgeBorder` + * sets one on the tbody's bottom (`tbody`) or as the top of the fifth order's + * cells (`top`), each of which wins the collapse and glides with its row; + * `wideBorder` sets a theme's 2 px green divider on every row. + */ +export const GlidingRows: StoryObj<{ + themed?: boolean; + rowBorder?: boolean; + wideBorder?: boolean; + edgeBorder?: 'none' | 'tbody' | 'top'; + dividers?: 'rows' | 'columns' | 'grid'; +}> = { + args: { + themed: false, + rowBorder: false, + wideBorder: false, + edgeBorder: 'none', + dividers: 'rows', + }, + render: ({themed, rowBorder, wideBorder, edgeBorder, dividers}) => { + const containerRef = useRef(null); + const animatedLayout = useAnimatedLayout({containerRef}); + const [expandedKeys, setExpandedKeys] = useState>( + () => new Set(), + ); + const expansion = useTableRowExpansion({ + expandedKeys, + onToggle: key => setExpandedKeys(prev => toggleKey(prev, key)), + getRowKey: item => item.id, + renderExpanded: item => , + animatedLayout, + }); + const sticky = useTableStickyColumns({startKeys: ['customer']}); + const wideColumns = useMemo( + () => columns.map(column => ({...column, width: pixel(220)})), + [], + ); + const style: React.CSSProperties & Record = { + maxWidth: 640, + ...(themed === true ? {'--color-border': 'rgb(37, 99, 235)'} : {}), + ...(wideBorder === true + ? {'--color-border': 'rgb(22, 163, 74)', '--border-width': '2px'} + : {}), + }; + return ( +
+ {themed && ( + + )} + {rowBorder && ( + + )} + {edgeBorder === 'tbody' && ( + + )} + {edgeBorder === 'top' && ( + + )} + +

Content after the table glides too.

+ + ); + }, +}; + +/** + * A long table (2,000 rows, not virtualized) with an `animatedLayout` + * handle: a glide moves only the rows near the visible part, so opening a + * panel costs the same as in a short table. + */ +export const GlidingRowsLongTable: StoryObj<{glide?: boolean}> = { + args: {glide: true}, + render: ({glide}) => { + const containerRef = useRef(null); + const animatedLayout = useAnimatedLayout({containerRef}); + const [expandedKeys, setExpandedKeys] = useState>( + () => new Set(), + ); + const expansion = useTableRowExpansion({ + expandedKeys, + onToggle: key => setExpandedKeys(prev => toggleKey(prev, key)), + getRowKey: item => item.id, + renderExpanded: item => , + animatedLayout: glide === false ? undefined : animatedLayout, + }); + return ( +
+
+ + ); + }, +}; diff --git a/packages/core/src/Table/TableCell.tsx b/packages/core/src/Table/TableCell.tsx index b5b0076e0ff19..4cd6021877910 100644 --- a/packages/core/src/Table/TableCell.tsx +++ b/packages/core/src/Table/TableCell.tsx @@ -38,6 +38,7 @@ import { mergeXStyle, } from './useTableCellStyles'; import {mergeProps} from '../utils'; +import {glidingDividerStyles} from './glidingDivider'; import {themeProps} from '../utils/themeProps'; /** Props for TableCell — thin `. + // While a glide moves the row, its divider moves with it. + rowStyles.push(glidingDividerStyles.row); } if (xstyle) { diff --git a/packages/core/src/Table/__tests__/TableRowGlide.a11y.browser.spec.ts b/packages/core/src/Table/__tests__/TableRowGlide.a11y.browser.spec.ts new file mode 100644 index 0000000000000..91eaa09694e66 --- /dev/null +++ b/packages/core/src/Table/__tests__/TableRowGlide.a11y.browser.spec.ts @@ -0,0 +1,382 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// @pr-a11y scope ^packages/core/src/Table/ ^packages/core/src/motion/ ^apps/storybook/stories/TableRowExpansion + +/** + * @file TableRowGlide.a11y.browser.spec.ts + * @input Built TableRowExpansion stories in real Chromium and WebKit + * @output Browser evidence for the row-expansion glide (component:Table FR14, + * FR15): with an `animatedLayout` handle the rows after a detail panel + * glide and each divider moves with its row, pinned cells included, the + * columns keep their widths, and an open panel draws its divider; without + * a handle the rows change at once + * @position Regression proof for useTableRowExpansion's animatedLayout + * adoption and glidingDivider.ts + */ + +import {expect, test, type Page} from '@playwright/test'; +import { + DEFAULT_STORYBOOK_DIR, + serveStorybook, + type StaticServer, +} from '@astryxdesign/a11y-spec/storybook'; + +let storybook: StaticServer; +test.beforeAll(async () => { + storybook = await serveStorybook( + process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR, + ); +}); +test.afterAll(async () => { + await storybook?.close(); +}); + +async function mount(page: Page, story: string, args = '', timeout = 5000) { + await page.goto( + `${storybook.origin}/iframe.html?id=core-tablerowexpansion--${story}&viewMode=story${args}`, + ); + await expect(page.locator('[data-expansion-toggle]').first()).toBeVisible({ + timeout, + }); +} + +/** + * Toggles a row and holds every animation `ms` into its run, so the frame + * can be read. + */ +async function toggleAndHold(page: Page, index: number, ms: number) { + return page.evaluate( + async ({index, ms}) => { + ( + document.querySelectorAll('[data-expansion-toggle]')[index] as + HTMLElement | undefined + )?.click(); + await new Promise(resolve => requestAnimationFrame(resolve)); + // The glide's own motion on rows (script animations), not a CSS + // transition a row's hover or press state runs. + const rows = document + .getAnimations() + .filter( + animation => + !(animation instanceof CSSTransition) && + !(animation instanceof CSSAnimation) && + (animation.effect as KeyframeEffect | null)?.target?.nodeName === + 'TR', + ); + for (const animation of document.getAnimations()) { + animation.pause(); + animation.currentTime = ms; + } + return rows.length; + }, + {index, ms}, + ); +} + +type Rgb = [number, number, number]; + +/** The darkest pixel within 3 px of `y` at `x`. */ +async function darkestNear(page: Page, x: number, y: number): Promise { + const shot = await page.screenshot({ + clip: {x, y: y - 3, width: 1, height: 7}, + scale: 'css', + }); + return page.evaluate(async b64 => { + const image = new Image(); + image.src = `data:image/png;base64,${b64}`; + await image.decode(); + const canvas = document.createElement('canvas'); + canvas.width = image.width; + canvas.height = image.height; + const context = canvas.getContext('2d'); + if (context == null) { + return [255, 255, 255]; + } + context.drawImage(image, 0, 0); + const data = context.getImageData(0, 0, image.width, image.height).data; + let darkest: [number, number, number] = [255, 255, 255]; + for (let i = 0; i < data.length; i += 4) { + if ( + data[i] + data[i + 1] + data[i + 2] < + darkest[0] + darkest[1] + darkest[2] + ) { + darkest = [data[i], data[i + 1], data[i + 2]]; + } + } + return darkest; + }, shot.toString('base64')); +} + +/** Where a body row's divider is now, under its pinned and an unpinned cell. */ +async function rowBottom(page: Page, index: number) { + return page.evaluate(i => { + const row = document.querySelectorAll( + '.astryx-table-scroll-wrapper tbody tr', + )[i] as HTMLElement; + const pinned = row.children[0].getBoundingClientRect(); + const last = row.children[row.children.length - 1].getBoundingClientRect(); + return { + y: Math.round(pinned.bottom), + pinnedX: Math.round(pinned.left + pinned.width / 2), + unpinnedX: Math.round((pinned.right + last.right) / 2), + }; + }, index); +} + +const near = (a: Rgb, b: Rgb, tolerance: number) => + a.every((channel, i) => Math.abs(channel - b[i]) <= tolerance); + +const columnWidths = async (page: Page) => + page.evaluate(() => + [...document.querySelectorAll('thead th')] + .map(th => Math.round(th.getBoundingClientRect().width)) + .join('/'), + ); + +test('without a handle the rows change at once', async ({page}) => { + // 2,000 rows: a slow runner takes more than the default 5 s to render it. + await mount(page, 'gliding-rows-long-table', '&args=glide:!false', 30_000); + expect(await toggleAndHold(page, 1, 90)).toBe(0); +}); + +test('mid-glide each divider moves with its row, pinned cells included', async ({ + page, +}) => { + await mount(page, 'gliding-rows'); + for (const action of ['opening', 'closing'] as const) { + expect(await toggleAndHold(page, 0, 90), action).toBeGreaterThan(0); + // Every row after the panel but the last (which has no divider), where + // it shows now (its glide included): a line under its pinned cell as + // under the rest of the row. A divider left at its static place leaves + // nothing here. + const rows = await page.evaluate( + () => + document.querySelectorAll('.astryx-table-scroll-wrapper tbody tr') + .length, + ); + for (let row = action === 'opening' ? 2 : 1; row < rows - 1; row++) { + const {y, pinnedX, unpinnedX} = await rowBottom(page, row); + const pinned = await darkestNear(page, pinnedX, y); + const unpinned = await darkestNear(page, unpinnedX, y); + expect(unpinned[0], `${action}, row ${row}`).toBeLessThan(248); + expect(near(pinned, unpinned, 8), `${action}, row ${row}, pinned`).toBe( + true, + ); + } + await page.evaluate(() => { + for (const animation of document.getAnimations()) { + animation.finish(); + } + }); + await page.waitForTimeout(100); + } +}); + +test("a theme's divider and an app's override move with their rows, pinned cells included", async ({ + page, +}) => { + await mount(page, 'gliding-rows', '&args=themed:!true'); + expect(await toggleAndHold(page, 0, 90)).toBeGreaterThan(0); + // The story's red is on the tbody's third row, which is the first after + // the open panel (row 2); the next (row 3) draws the theme's blue. + for (const [row, isBlue] of [ + [2, false], + [3, true], + ] as const) { + const {y, pinnedX, unpinnedX} = await rowBottom(page, row); + for (const [where, x] of [ + ['pinned', pinnedX], + ['unpinned', unpinnedX], + ] as const) { + const [r, , b] = await darkestNear(page, x, y); + expect(isBlue ? b - r : r - b, `row ${row}, ${where}`).toBeGreaterThan( + 60, + ); + } + } +}); + +test("an app's border on a row itself moves with the row, pinned cells included, and leaves nothing at its static place", async ({ + page, +}) => { + await mount(page, 'gliding-rows', '&args=rowBorder:!true'); + expect(await toggleAndHold(page, 0, 90)).toBeGreaterThan(0); + // The green border is on the fourth order's row, which glides down: the + // open panel sits above it, so it is the tbody's fifth row now. + const {y, pinnedX, unpinnedX} = await rowBottom(page, 4); + for (const [where, x] of [ + ['pinned', pinnedX], + ['unpinned', unpinnedX], + ] as const) { + const [r, g] = await darkestNear(page, x, y); + expect(g - r, `moving line, ${where}`).toBeGreaterThan(60); + } + // Where the row lands, the line is not drawn yet. + const travel = await page.evaluate(() => { + const row = document.querySelectorAll( + '.astryx-table-scroll-wrapper tbody tr', + )[4] as HTMLElement; + const parts = getComputedStyle(row).translate.split(' '); + return Number.parseFloat(parts[1] ?? '0') || 0; + }); + expect(Math.abs(travel)).toBeGreaterThan(4); + const [r, g] = await darkestNear(page, unpinnedX, Math.round(y - travel)); + expect(g - r, 'static place').toBeLessThan(30); +}); + +/** The rows of green pixels down one column of the page at `x`. */ +async function greenRowsAt(page: Page, x: number): Promise { + const shot = await page.screenshot({ + clip: {x, y: 0, width: 1, height: 720}, + scale: 'css', + }); + return page.evaluate(async b64 => { + const image = new Image(); + image.src = `data:image/png;base64,${b64}`; + await image.decode(); + const canvas = document.createElement('canvas'); + canvas.width = image.width; + canvas.height = image.height; + const context = canvas.getContext('2d'); + if (context == null) { + return []; + } + context.drawImage(image, 0, 0); + const data = context.getImageData(0, 0, 1, image.height).data; + const rows: number[] = []; + for (let y = 0; y < image.height; y += 1) { + const [r, g, b] = [data[y * 4], data[y * 4 + 1], data[y * 4 + 2]]; + if (g - r > 60 && g - b > 30) { + rows.push(y); + } + } + return rows; + }, shot.toString('base64')); +} + +for (const [name, args] of [ + ["a row's own 3 px border", '&args=rowBorder:!true'], + ["a theme's 2 px divider", '&args=wideBorder:!true'], +] as const) { + test(`${name} glides level across the pinned and scrolling cells`, async ({ + page, + }) => { + await mount(page, 'gliding-rows', args); + expect(await toggleAndHold(page, 0, 90)).toBeGreaterThan(0); + const {pinnedX, unpinnedX} = await rowBottom(page, 4); + const pinned = await greenRowsAt(page, pinnedX); + const unpinned = await greenRowsAt(page, unpinnedX); + expect(pinned.length).toBeGreaterThan(0); + expect(unpinned.length).toBeGreaterThan(0); + // One line, at one height on both sides of the pinned edge: not even a + // 1 px step. + expect(Math.min(...pinned)).toBe(Math.min(...unpinned)); + expect(Math.max(...pinned)).toBe(Math.max(...unpinned)); + }); +} + +for (const edge of ['tbody', 'top'] as const) { + test(`a divider the collapse takes from ${edge === 'tbody' ? "the body's bottom border" : "the next row's top border"} is drawn once while it glides`, async ({ + page, + }) => { + await mount(page, 'gliding-rows', `&args=edgeBorder:${edge}`); + const {unpinnedX} = await rowBottom(page, 0); + expect((await greenRowsAt(page, unpinnedX)).length).toBeGreaterThan(0); + expect(await toggleAndHold(page, 0, 90)).toBeGreaterThan(0); + const rows = await greenRowsAt(page, unpinnedX); + // One 3 px line, moving: none left at the static place. + expect(rows.length).toBeGreaterThan(0); + expect(rows.length).toBeLessThanOrEqual(4); + }); +} + +test('the chevron turns on the glide; column lines and Reduce Motion change at once', async ({ + page, +}) => { + await mount(page, 'gliding-rows'); + await page.locator('[data-expansion-toggle]').first().click(); + const timing = await page + .locator('[data-expansion-toggle] > *') + .first() + .evaluate(glyph => + (glyph as HTMLElement).style.getPropertyValue( + 'transition-timing-function', + ), + ); + expect(timing.startsWith('linear(')).toBe(true); + + for (const dividers of ['columns', 'grid']) { + await mount(page, 'gliding-rows', `&args=dividers:${dividers}`); + expect(await toggleAndHold(page, 0, 90), dividers).toBe(0); + } + + await page.emulateMedia({reducedMotion: 'reduce'}); + await mount(page, 'gliding-rows'); + expect(await toggleAndHold(page, 0, 90)).toBe(0); +}); + +test('under Reduce Motion a finger sees the rows fade in place, not move', async ({ + browser, +}) => { + const context = await browser.newContext({ + hasTouch: true, + isMobile: true, + reducedMotion: 'reduce', + viewport: {width: 390, height: 800}, + }); + const page = await context.newPage(); + await mount(page, 'gliding-rows'); + await toggleAndHold(page, 0, 0); + // The size-change module's Reduce Motion under a coarse pointer: a short + // fade at the new place (design:reduced-motion DR2), never a translate. + const moves = await page.evaluate( + () => + document + .getAnimations() + .filter(animation => !(animation instanceof CSSTransition)) + .flatMap( + animation => + (animation.effect as KeyframeEffect | null)?.getKeyframes() ?? [], + ) + .filter( + keyframe => keyframe.translate != null || keyframe.transform != null, + ).length, + ); + expect(moves).toBe(0); + await context.close(); +}); + +test('the columns keep their widths through a glide', async ({page}) => { + await mount(page, 'gliding-rows'); + // A table its columns do not fill, so they share the spare width. + await page.evaluate(() => { + let box = document.querySelector( + '.astryx-table-scroll-wrapper', + ); + for (let i = 0; i < 6 && box != null; i++, box = box.parentElement) { + box.style.maxWidth = 'none'; + } + const table = document.querySelector('table'); + if (table != null) { + table.style.width = '1200px'; + } + }); + const before = await columnWidths(page); + expect(await toggleAndHold(page, 0, 90)).toBeGreaterThan(0); + expect(await columnWidths(page)).toBe(before); +}); + +test('an open panel draws the divider its row hands down', async ({page}) => { + await mount(page, 'gliding-rows'); + await page.locator('[data-expansion-toggle]').first().click(); + await page.waitForTimeout(800); + const {y, unpinnedX} = await page.evaluate(() => { + const panel = document + .querySelector('tr[data-expansion-panel]') + ?.getBoundingClientRect(); + return { + y: Math.round(panel?.bottom ?? 0), + unpinnedX: Math.round(((panel?.left ?? 0) + (panel?.right ?? 0)) / 2), + }; + }); + expect((await darkestNear(page, unpinnedX, y))[0]).toBeLessThan(245); +}); diff --git a/packages/core/src/Table/glidingDivider.ts b/packages/core/src/Table/glidingDivider.ts new file mode 100644 index 0000000000000..e1d071508bc38 --- /dev/null +++ b/packages/core/src/Table/glidingDivider.ts @@ -0,0 +1,336 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file glidingDivider.ts + * @input Table rows a row glide moves (Web Animations on the ``), their + * computed borders, StyleX + * @output Exports `glidingDividerStyles` (a row's stand-in divider while it + * glides), `markGlidingRows` (marks the moving rows for that glide and + * carries each row's own border onto the stand-in) and `rowEdge` + * @position Table internals. With collapsed borders the table paints every + * border at its cell's static place, so a row that moves leaves its divider + * behind. For the glide only, the moving row's cells are marked: their + * borders go transparent (their width stays, so nothing reflows) and one of + * them draws the same divider across the whole row in its ::after, which + * moves with the row. A pinned cell draws its own over it + * (plugins/stickyColumns). + * + * SYNC: When modified, update TableCell.tsx, plugins/rowExpansion and + * plugins/stickyColumns. + */ + +import * as stylex from '@stylexjs/stylex'; +import {colorVars} from '../theme/tokens.stylex'; +import {collapsedBottom, type BottomEdge} from './rowDivider'; + +export type {BottomEdge} from './rowDivider'; + +/** On a row while a glide moves it. */ +export const GLIDING_ATTRIBUTE = 'data-astryx-row-gliding'; +const GLIDING = '[data-astryx-row-gliding]'; +/** + * On each cell of a moving row; `line` on the one that draws the row's + * divider. On the cells themselves, so a table nested in a moving row keeps + * its own dividers. + */ +const CELL_ATTRIBUTE = 'data-astryx-cell-gliding'; +const CELL_GLIDING = '[data-astryx-cell-gliding]'; +const CELL_LINE = '[data-astryx-cell-gliding="line"]'; + +/** On a pinned (sticky) cell: plugins/stickyColumns. */ +const PINNED_ATTRIBUTE = 'data-astryx-table-pinned'; + +/** + * A row's divider, carried on the row for its glide. Names no cell sets, so + * a pinned cell's own carry (plugins/stickyColumns) cannot shadow them. + */ +export const GLIDE_DIVIDER_WIDTH = '--_table-row-glide-width'; +export const GLIDE_DIVIDER_STYLE = '--_table-row-glide-style'; +export const GLIDE_DIVIDER_COLOR = '--_table-row-glide-color'; + +export const glidingDividerStyles = stylex.create({ + /** + * On every cell with a row divider: it goes transparent while the row + * moves, and the row's line cell draws the divider across the row's bottom + * edge, where the collapsed border sits. The line is positioned against the + * row (the cell is not positioned), so the cell's own overflow does not clip + * it, and it is a box inside a cell, not a box in the row, so the table's + * column widths do not change. + */ + cell: { + borderBottomColor: { + default: colorVars['--color-border'], + [CELL_GLIDING]: 'transparent', + }, + // Every declaration only on the line cell: a pinned cell's own ::after + // (its scroll shadow) stays untouched. + '::after': { + content: {default: null, [CELL_LINE]: '""'}, + position: {default: null, [CELL_LINE]: 'absolute'}, + insetInline: {default: null, [CELL_LINE]: 0}, + // Where the collapsed edge rests below the row's padding box (a wide + // border, or the row's own): measured when the row is marked. + bottom: { + default: null, + [CELL_LINE]: 'calc(-1 * var(--_table-row-glide-offset, 0px))', + }, + height: {default: null, [CELL_LINE]: 0}, + borderBottomWidth: { + default: null, + [CELL_LINE]: `var(${GLIDE_DIVIDER_WIDTH}, 0px)`, + }, + borderBottomStyle: { + default: null, + [CELL_LINE]: `var(${GLIDE_DIVIDER_STYLE}, solid)`, + }, + borderBottomColor: { + default: null, + [CELL_LINE]: `var(${GLIDE_DIVIDER_COLOR}, ${colorVars['--color-border']})`, + }, + pointerEvents: {default: null, [CELL_LINE]: 'none'}, + }, + }, + /** + * After `cell` on a detail panel's cell: its divider color is the one its + * row hands down (plugins/rowExpansion). + */ + panelCell: { + borderBottomColor: { + default: `var(--_table-panel-divider-color, ${colorVars['--color-border']})`, + [CELL_GLIDING]: 'transparent', + }, + }, + /** + * On every row: while it moves it is the line's containing block. Its + * motion (a `translate`) makes it one; `position: relative` keeps it so + * for a panel playing in, which does not move. + */ + row: { + position: { + default: null, + [GLIDING]: 'relative', + }, + }, +}); + +/** The batch that marked a row last: only that batch unmarks it. */ +const markedBy = new WeakMap(); +/** + * A marked element's own inline border colors (bottom, or top for the row + * below and its cells), given back when it is unmarked. + */ +const inlineTopOf = new WeakMap(); +/** The edge sources a row hid when it was marked, given back at unmark. */ +const sourcesOf = new WeakMap< + Element, + {bottoms: HTMLElement[]; tops: HTMLElement[]} +>(); +/** A marked cell's own inline border color, given back when it is unmarked. */ +const inlineColorOf = new WeakMap(); + +/** + * A row's own bottom border, for its stand-in while it moves; null for a row + * already carrying one. Read where the glide reads the layout (`animate`'s + * `beforeMove`), before anything starts moving. + */ +/** + * The elements whose borders the collapse can take a row's divider from + * (`rowDivider.ts` collapsedBottom): the row's own bottom, the row below's + * top and its cells' tops, and at a section edge the row groups' borders. + */ +function edgeSourcesOf(row: HTMLElement): { + bottoms: HTMLElement[]; + tops: HTMLElement[]; +} { + const bottoms: HTMLElement[] = [row]; + const tops: HTMLElement[] = []; + const group = row.parentElement; + const next = + row.nextElementSibling ?? + group?.nextElementSibling?.firstElementChild ?? + null; + if (next instanceof HTMLElement) { + tops.push(next); + for (const cell of next.children) { + if (cell instanceof HTMLElement) { + tops.push(cell); + } + } + } + if (row.nextElementSibling == null && group instanceof HTMLElement) { + bottoms.push(group); + const nextGroup = group.nextElementSibling; + if (nextGroup instanceof HTMLElement) { + tops.push(nextGroup); + } + } + return {bottoms, tops}; +} + +export function rowEdge(row: Element): BottomEdge | null { + if (row.hasAttribute(GLIDING_ATTRIBUTE) || row.lastElementChild == null) { + return null; + } + return collapsedBottom(row.lastElementChild as HTMLTableCellElement); +} + +/** + * Marks the rows a glide is moving around `changed`, and `entering` (a row + * playing its entrance, with that entrance), so their cells draw a divider + * that moves with them; unmarks them in one batch when that motion ends. A + * glide moves a contiguous run of rows next to the change and stops near the + * visible part, so this walks out from the changed row each way and stops at + * still rows: it never visits the rest of a long table. It asks the motion + * module which rows glide and takes the borders read before the glide + * started (`edges`), so it reads nothing from the layout: it only writes. + * Resolves when the motion has ended. + */ +export async function markGlidingRows( + changed: HTMLElement, + edges: ReadonlyMap, + entering: {row: HTMLElement; animation: Animation} | null, + glideOf: (element: Element) => Animation | null, +): Promise { + const rows = new Map(); + const glide = glideOf(changed); + if (glide != null) { + rows.set(changed, glide); + } + for (const step of [ + 'nextElementSibling', + 'previousElementSibling', + ] as const) { + let row = changed[step]; + // The changed row's own panel enters rather than glides: look past it. + let stillInARow = 0; + while (row instanceof HTMLElement && stillInARow < 2) { + const animation = glideOf(row); + if (animation != null) { + rows.set(row, animation); + stillInARow = 0; + } else { + stillInARow += 1; + } + row = row[step]; + } + } + if (entering != null) { + rows.set(entering.row, entering.animation); + } + if (rows.size === 0) { + return; + } + const batch = Symbol('glide'); + // Where each row's divider rests, read before any write. The line hangs + // from the row's padding box (its containing block), which a row's own + // border shortens; the collapsed edge starts at a cell's padding-box + // bottom, which a wide border puts below it. The table paints that edge + // from the whole pixel at or above it, so the line starts there too. + const offsets = new Map(); + for (const row of rows.keys()) { + const edge = edges.get(row); + const cell = row.lastElementChild; + if (edge == null || !(cell instanceof HTMLElement)) { + continue; + } + const cellEdge = + cell.getBoundingClientRect().top + cell.clientTop + cell.clientHeight; + const rowPaddingEdge = + row.getBoundingClientRect().top + row.clientTop + row.clientHeight; + offsets.set(row, Math.floor(cellEdge) + edge.width - rowPaddingEdge); + } + for (const row of rows.keys()) { + const edge = edges.get(row); + if (edge != null && !row.hasAttribute(GLIDING_ATTRIBUTE)) { + row.style.setProperty(GLIDE_DIVIDER_WIDTH, `${edge.width}px`); + row.style.setProperty(GLIDE_DIVIDER_STYLE, edge.style); + row.style.setProperty(GLIDE_DIVIDER_COLOR, edge.color); + row.setAttribute(GLIDING_ATTRIBUTE, ''); + // Every edge the collapse could take the divider from is painted by the + // table at the row's static place: the row's own border, the row + // below's top and its cells' tops, and at a section edge the row + // groups' borders. They go transparent too, and the line carries the + // winner. + const sources = edgeSourcesOf(row); + sourcesOf.set(row, sources); + for (const element of sources.bottoms) { + if (!inlineColorOf.has(element)) { + inlineColorOf.set(element, element.style.borderBottomColor); + element.style.borderBottomColor = 'transparent'; + } + } + for (const element of sources.tops) { + if (!inlineTopOf.has(element)) { + inlineTopOf.set(element, element.style.borderTopColor); + element.style.borderTopColor = 'transparent'; + } + } + row.style.setProperty( + '--_table-row-glide-offset', + `${offsets.get(row) ?? 0}px`, + ); + // The table's last row can glide a pixel farther than the rows above + // it (its border collapses differently), so mid-glide it overlaps the + // row above. Each moving row is its own layer; the last one stays + // under the others so it cannot cover the divider above it. Both stay + // under a sticky header (z-index 3). + row.style.zIndex = row.nextElementSibling == null ? '0' : '1'; + // The first cell that scrolls with the row draws the line; a pinned + // cell is positioned (the line would be its width) and draws its own. + let hasLine = false; + for (const cell of row.children) { + const isLine: boolean = + !hasLine && !cell.hasAttribute(PINNED_ATTRIBUTE); + cell.setAttribute(CELL_ATTRIBUTE, isLine ? 'line' : ''); + hasLine ||= isLine; + // Inline, so a theme's `table-cell` color or an app's stylesheet on + // the cells, which outrank the stylesheet's rule, cannot keep the + // line at the row's static place. + if (cell instanceof HTMLElement) { + inlineColorOf.set(cell, cell.style.borderBottomColor); + cell.style.borderBottomColor = 'transparent'; + } + } + } + markedBy.set(row, batch); + } + await Promise.all( + [...rows.values()].map(async animation => { + await animation?.finished.catch(() => undefined); + }), + ); + // Writes only, and only for rows no later glide has taken over. + for (const row of rows.keys()) { + if (markedBy.get(row) !== batch) { + continue; + } + markedBy.delete(row); + row.removeAttribute(GLIDING_ATTRIBUTE); + row.style.removeProperty('z-index'); + const {bottoms, tops} = sourcesOf.get(row) ?? {bottoms: [], tops: []}; + sourcesOf.delete(row); + for (const element of bottoms) { + if (inlineColorOf.has(element)) { + element.style.borderBottomColor = inlineColorOf.get(element) ?? ''; + inlineColorOf.delete(element); + } + } + for (const element of tops) { + if (inlineTopOf.has(element)) { + element.style.borderTopColor = inlineTopOf.get(element) ?? ''; + inlineTopOf.delete(element); + } + } + row.style.removeProperty('--_table-row-glide-offset'); + for (const cell of row.children) { + cell.removeAttribute(CELL_ATTRIBUTE); + if (cell instanceof HTMLElement && inlineColorOf.has(cell)) { + cell.style.borderBottomColor = inlineColorOf.get(cell) ?? ''; + inlineColorOf.delete(cell); + } + } + row.style.removeProperty(GLIDE_DIVIDER_WIDTH); + row.style.removeProperty(GLIDE_DIVIDER_STYLE); + row.style.removeProperty(GLIDE_DIVIDER_COLOR); + } +} diff --git a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx new file mode 100644 index 0000000000000..7fcd16875fd8d --- /dev/null +++ b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.glide.test.tsx @@ -0,0 +1,280 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file useTableRowExpansion.glide.test.tsx + * @input useTableRowExpansion with and without an `animatedLayout` handle, a + * stubbed layout, recorded Web Animations + * @output Verifies that opening a row's panel glides the rows after it with + * a handle (the chevron on the same motion), changes nothing without one, + * and that a gliding row is marked for its moving divider only while it + * moves (`spec:AST-105` FR1, FR2) + * @position Table row-expansion adoption of the size-change module + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {render, screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {useRef, useState} from 'react'; +import {Table} from '../../Table'; +import {pixel} from '../../columnUtils'; +import type {TableColumn} from '../../types'; +import {useTableRowExpansion} from './useTableRowExpansion'; +import {useAnimatedLayout} from '../../../hooks/useAnimatedLayout'; +import {markGlidingRows, rowEdge} from '../../glidingDivider'; + +type Row = {id: string; name: string}; +const rows: Row[] = [ + {id: 'a', name: 'Alpha'}, + {id: 'b', name: 'Bravo'}, + {id: 'c', name: 'Charlie'}, +]; +const columns: TableColumn[] = [ + {key: 'name', header: 'Name', width: pixel(200)}, +]; + +let animated: Element[] = []; +const originalAnimate = Element.prototype.animate; + +beforeEach(() => { + animated = []; + Element.prototype.animate = function animate(this: Element) { + animated.push(this); + return { + cancel() {}, + addEventListener() {}, + finished: Promise.resolve(), + } as unknown as Animation; + }; +}); + +afterEach(() => { + Element.prototype.animate = originalAnimate; + vi.restoreAllMocks(); +}); + +function ExpandableTable({ + withHandle, + dividers, +}: { + withHandle: boolean; + dividers?: 'rows' | 'columns' | 'grid'; +}) { + const containerRef = useRef(null); + const animatedLayout = useAnimatedLayout({containerRef}); + const [expandedKeys, setExpandedKeys] = useState(() => new Set()); + const expansion = useTableRowExpansion({ + expandedKeys, + onToggle: key => + setExpandedKeys(previous => { + const next = new Set(previous); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }), + getRowKey: row => row.id, + renderExpanded: row =>

Details for {row.name}

, + animatedLayout: withHandle ? animatedLayout : undefined, + }); + return ( +
+
` wrapper */ @@ -234,6 +235,8 @@ export function TableCell({ dividerColumnStyles.cell, ), ...(hasContextMenu ? [contextMenuCellStyles.cell] : []), + // While a glide moves the row, its divider moves with it. + glidingDividerStyles.cell, ] : []; diff --git a/packages/core/src/Table/TableRow.tsx b/packages/core/src/Table/TableRow.tsx index 124aebf5ffe28..cc48f98c42115 100644 --- a/packages/core/src/Table/TableRow.tsx +++ b/packages/core/src/Table/TableRow.tsx @@ -21,6 +21,7 @@ import * as stylex from '@stylexjs/stylex'; import {colorVars, durationVars, easeVars} from '../theme/tokens.stylex'; import type {StyleXStyles} from '../theme/types'; import {tableRowMarker} from './table.stylex'; +import {glidingDividerStyles} from './glidingDivider'; import {TableContext} from './TableContext'; import {mergeProps} from '../utils'; import {themeProps} from '../utils/themeProps'; @@ -182,6 +183,8 @@ export function TableRow({ if (ctx.dividers === 'rows' || ctx.dividers === 'grid') { // Note: last-body-row border removal is handled by TableCell // to avoid affecting the header row in
+ + ); +} + +/** The area, and the row after the first, report a layout; it moves down. */ +function stubGeometry() { + vi.spyOn(screen.getByTestId('area'), 'getBoundingClientRect').mockReturnValue( + new DOMRect(0, 0, 400, 600), + ); + const next = document.querySelector('tr[data-expansion-key="b"]')!; + let reads = 0; + vi.spyOn(next, 'getBoundingClientRect').mockImplementation(() => { + reads += 1; + // The first read is the measurement before the panel opens. + return new DOMRect(0, reads === 1 ? 40 : 120, 400, 40); + }); + return next; +} + +const flush = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + +describe('useTableRowExpansion adopts animatedLayout (spec:AST-105 FR1, FR2)', () => { + it('glides the rows after an opening panel, with a handle', async () => { + const user = userEvent.setup(); + render(); + const next = stubGeometry(); + await user.click(screen.getAllByRole('button', {name: /expand/i})[0]); + await flush(); + expect(animated).toContain(next); + // The chevron turns on the glide's motion. + expect( + document + .querySelector('[data-expansion-toggle] > *') + ?.style.getPropertyValue('transition-timing-function'), + ).toMatch(/^linear\(/); + }); + + it.each(['columns', 'grid'] as const)( + 'changes everything at once with %s dividers, even with a handle (Table FR15)', + async dividers => { + const user = userEvent.setup(); + render(); + stubGeometry(); + await user.click(screen.getAllByRole('button', {name: /expand/i})[0]); + await flush(); + // No glide, no panel entrance, and the chevron keeps the theme's turn. + expect(animated).toEqual([]); + expect( + document + .querySelector('[data-expansion-toggle] > *') + ?.style.getPropertyValue('transition-timing-function'), + ).toBe(''); + }, + ); + + it.each(['columns', 'grid'] as const)( + 'takes the spring off a chevron when the table turns to %s dividers', + async dividers => { + const user = userEvent.setup(); + const {rerender} = render(); + stubGeometry(); + await user.click(screen.getAllByRole('button', {name: /expand/i})[0]); + await flush(); + const glyph = document.querySelector( + '[data-expansion-toggle] > *', + ); + expect( + glyph?.style.getPropertyValue('transition-timing-function'), + ).toMatch(/^linear\(/); + rerender(); + expect(glyph?.style.getPropertyValue('transition-timing-function')).toBe( + '', + ); + }, + ); + + it('changes nothing without a handle', async () => { + const user = userEvent.setup(); + render(); + stubGeometry(); + await user.click(screen.getAllByRole('button', {name: /expand/i})[0]); + await flush(); + expect(animated).toEqual([]); + }); +}); + +describe('an open panel carries its row divider (Table FR14)', () => { + it("takes the row's own cell border, a theme's or an app's, and the row's own border where it wins", async () => { + const user = userEvent.setup(); + render(); + const row = document.querySelector( + 'tr[data-expansion-key="a"]', + )!; + // An app's (or a theme's) border on the row's cells. + for (const cell of row.querySelectorAll('td')) { + cell.style.borderBottomColor = 'rgb(255, 0, 0)'; + cell.style.borderBottomStyle = 'dashed'; + } + await user.click(screen.getAllByRole('button', {name: /expand/i})[0]); + const panel = document.querySelector( + 'tr[data-expansion-panel] > td', + )!; + // The panel keeps its own width (a value, not inherited from any panel + // around it) while the row's own border does not win. + expect(panel.style.getPropertyValue('--_table-panel-divider-width')).toBe( + 'initial', + ); + expect(panel.style.getPropertyValue('--_table-panel-divider-color')).toBe( + 'rgb(255, 0, 0)', + ); + expect(panel.style.getPropertyValue('--_table-panel-divider-style')).toBe( + 'dashed', + ); + // An app's own border on the row wins the collapse when it is wider; + // the next commit (opening another row) reads it. + row.style.borderBottom = '3px solid rgb(0, 0, 255)'; + await user.click(screen.getAllByRole('button', {name: /expand/i})[0]); + expect(panel.style.getPropertyValue('--_table-panel-divider-width')).toBe( + '3px', + ); + expect(panel.style.getPropertyValue('--_table-panel-divider-color')).toBe( + 'rgb(0, 0, 255)', + ); + }); +}); + +describe('markGlidingRows', () => { + it('marks the moving rows and carries their divider while they move, and clears both after', async () => { + const body = document.createElement('tbody'); + // Each row: a pinned cell, then two that scroll with it. + const rows = [0, 1, 2, 3, 4].map(() => { + const row = document.createElement('tr'); + const pinned = document.createElement('td'); + pinned.setAttribute('data-astryx-table-pinned', ''); + row.append( + pinned, + document.createElement('td'), + document.createElement('td'), + ); + body.append(row); + return row; + }); + // A table nested in a moving row keeps its own dividers. + const nested = document.createElement('table'); + nested.innerHTML = ''; + rows[2].cells[1].append(nested); + const cellMarks = (row: HTMLTableRowElement) => + [...row.cells].map(cell => cell.getAttribute('data-astryx-cell-gliding')); + document.body.append(body); + let finish = () => {}; + const glide = { + finished: new Promise(resolve => { + finish = resolve; + }), + } as unknown as Animation; + // The change is row 1; rows 2 and 3 glide, row 4 is still. + const moving = new Set([rows[2], rows[3]]); + const glideOf = (element: Element) => + moving.has(element as HTMLTableRowElement) ? glide : null; + + const edges = new Map(rows.map(row => [row, rowEdge(row)!] as const)); + const done = markGlidingRows(rows[1], edges, null, glideOf); + expect( + rows.map(row => row.hasAttribute('data-astryx-row-gliding')), + ).toEqual([false, false, true, true, false]); + expect( + rows[2].style.getPropertyValue('--_table-row-glide-width'), + ).toBe('0px'); + // The first cell that scrolls draws the line; the pinned one draws its + // own; the nested table's cell is not marked. + expect(cellMarks(rows[2])).toEqual(['', 'line', '']); + expect(cellMarks(rows[4])).toEqual([null, null, null]); + expect( + nested.querySelector('td')?.hasAttribute('data-astryx-cell-gliding'), + ).toBe(false); + + finish(); + await done; + expect(rows.some(row => row.hasAttribute('data-astryx-row-gliding'))).toBe( + false, + ); + expect( + rows[2].style.getPropertyValue('--_table-row-glide-width'), + ).toBe(''); + expect(cellMarks(rows[2])).toEqual([null, null, null]); + body.remove(); + }); +}); diff --git a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx index 095a49fae8b73..3b31b162ce4f2 100644 --- a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +++ b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx @@ -19,6 +19,7 @@ */ import { + useCallback, useLayoutEffect, useMemo, useRef, @@ -53,7 +54,24 @@ import type { BodyRowRenderProps, BodyCellRenderProps, HeaderCellRenderProps, + ScrollWrapperRenderProps, } from '../../types'; +import {LayoutChangeSnapshot} from '../../../motion/LayoutChangeSnapshot'; +import { + applyLayoutChangeTransition, + glideOf, + playLayoutChange, + reportLayoutChange, + type AnimatedLayout, + type LayoutChange, +} from '../../../motion/animatedLayout'; +import { + GLIDING_ATTRIBUTE, + glidingDividerStyles, + markGlidingRows, + rowEdge, + type BottomEdge, +} from '../../glidingDivider'; // ============================================================================= // Config @@ -115,6 +133,13 @@ export interface UseTableRowExpansionConfig> { * @default false (only the chevron toggles expansion). */ hasRowClickExpansion?: boolean; + /** + * A handle from `useAnimatedLayout`. With it, opening or closing a row's + * panel glides the rows after it to their new places instead of jumping, + * and an opening panel plays its entrance on the same motion. Without it, + * rows jump, as before. + */ + animatedLayout?: AnimatedLayout; } // ============================================================================= @@ -222,12 +247,18 @@ const expansionStyles = stylex.create({ */ const dividerStyles = stylex.create({ expandedRowCell: { + '--_table-row-divider-width': '0px', borderBottomWidth: 0, }, // The row's divider, carried down: its row's themed or app-set border // reaches the panel through `--_table-panel-divider-*` on the panel's cell // (set after each commit, see ExpansionPanelCell), the tokens otherwise. panelCell: { + // The width this divider draws, for its stand-in during a glide. + '--_table-row-divider-width': { + default: `var(--_table-panel-divider-width, ${borderVars['--border-width']})`, + [stylex.when.ancestor(':last-child', tableRowMarker)]: '0px', + }, borderBottomWidth: { default: `var(--_table-panel-divider-width, ${borderVars['--border-width']})`, // Same rule TableCell uses: no trailing line under the last row of the @@ -319,6 +350,7 @@ function ExpansionChevron({ return (