diff --git a/apps/marketing/src/constants/changelog.ts b/apps/marketing/src/constants/changelog.ts index 5b6f93220..9a467466a 100644 --- a/apps/marketing/src/constants/changelog.ts +++ b/apps/marketing/src/constants/changelog.ts @@ -22,6 +22,69 @@ export const v3_4_2: ChangelogEntry = { }, ], }; +export const v3_5_0: ChangelogEntry = { + version: "3.5.0", + date: "2026-05-03", + title: "Animation improvements & fixes", + description: "Animation polish and scroll/layout bug fixes.", + changes: [ + { + type: "improvement", + description: + "Row grouping expand/collapse animates row heights instead of snapping.", + }, + { + type: "improvement", + description: + "Column hide/show and pin/unpin animate horizontally; pure reorders still FLIP (tracks last painted columns vs in-place editor mutations).", + }, + { + type: "improvement", + description: + "Scroll fast path keeps row separators in sync with cells—no visual lag.", + }, + { + type: "bugfix", + description: + "Nested tables: unstable keys broke slide animations after sort/sibling expand; expand chevrons could toggle wrong row after sort—stable keys + live DOM refs.", + }, + { + type: "bugfix", + description: + "Nested-row expansion shifted rows vertically but separators cached only flatten index—stuck wrong until another redraw.", + }, + { + type: "bugfix", + description: + "Pinned-left body was appended after main in flex—pinned cells sat past the viewport; fixes DOM insert order for left/main/right.", + }, + { + type: "bugfix", + description: + "Pinned viewport width missing from render cache—separators/layout could mismatch the pinned strip.", + }, + { + type: "bugfix", + description: + "Scrollbar gutter measured on wrong element misaligned header filler; header height no longer adds stray border padding.", + }, + { + type: "bugfix", + description: + "Sticky overlay blocked clicks; sticky column indices didn’t match virtualized body cells—selection drifted.", + }, + { + type: "bugfix", + description: + "Fast hovers stacked tooltip timeouts—duplicate/stray header tooltips.", + }, + { + type: "bugfix", + description: + "Selection used stale col/row indices after hide/reorder; header aria-colindex now matches body columns.", + }, + ], +}; export const v3_4_0: ChangelogEntry = { version: "3.4.0", date: "2026-04-26", @@ -1592,6 +1655,8 @@ export const v1_4_4: ChangelogEntry = { // Array of all changelog entries (newest first) export const CHANGELOG_ENTRIES: ChangelogEntry[] = [ + v3_5_0, + v3_4_2, v3_4_0, v3_0_4, v3_0_0, diff --git a/packages/angular/package.json b/packages/angular/package.json index 292a4a571..c321ce4ee 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/angular", - "version": "3.4.2", + "version": "3.5.0", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/angular/src/index.d.ts", diff --git a/packages/core/CELL_ANIMATIONS.md b/packages/core/CELL_ANIMATIONS.md index 81a412288..730e60d5f 100644 --- a/packages/core/CELL_ANIMATIONS.md +++ b/packages/core/CELL_ANIMATIONS.md @@ -126,7 +126,7 @@ deps.onRender(); Two performance optimizations interact with animation: -- **`positionOnlyBody`** is set to `true` when `source === "scroll-raf"` and `isScrolling === true` (`SimpleTableVanilla.ts` L568). In this mode `renderBodyCells` only updates positions and skips separator / content work (`bodyCellRenderer.ts` L245–252, L373–381). +- **`positionOnlyBody`** is set to `true` when `source === "scroll-raf"` and `isScrolling === true` (`SimpleTableVanilla.ts` L568). In this mode `renderBodyCells` avoids full cell refresh (selection, expand chrome, expensive content sync) while still updating **`top`/`left` per cell** and **syncing row separators** with the viewport (`bodyCellRenderer.ts`; separator pass is unconditional). - **No-op when scroll range unchanged.** In `RenderOrchestrator` (L495–504), if `verticalScrollFastPath && lastScrollRafPaintedRange.start === renderedStartIndex && ... .end === renderedEndIndex`, the render returns without touching the DOM. Both must be bypassed for renders that originate from sort, reorder, expand, etc. These are not "scroll" renders, so `positionOnlyBody` will already be `false` for them — but the animation system must explicitly **never** trigger off `scroll-raf` renders. @@ -255,7 +255,7 @@ A complete catalogue of state changes that should hook the animation coordinator | Quick-filter / filter change | `FilterManager` → re-flatten | Surviving rows: `top`. Filtered-out rows: leave. | `translate3d(0, dy, 0)` + fade-out for leavers | Same pattern as sort plus leaver handling. | | Pin / unpin column | column metadata change → `setHeaders` | Cell migrates between sections | Cross-section fade-out / fade-in | Cannot FLIP across containers. | | Cell content update | `cellUpdateFlash` path (`bodyCell/styling.ts` L275–278) | None geometric | Existing CSS keyframe (`background-color`) | Already animated; do not touch. | -| **Scroll** | `scroll-raf` / `positionOnlyBody === true` | `top`/`left` per cell | **Do nothing.** | Explicitly excluded; the coordinator must ignore renders whose source is scroll. | +| **Scroll** | `scroll-raf` / `positionOnlyBody === true` | `top`/`left` per cell; `.st-row-separator` synced | **Do nothing.** | Coordinator ignores scroll; separators still refresh in `renderBodyCells`, not FLIP. | ### Hook points (for reference, not a design) diff --git a/packages/core/package.json b/packages/core/package.json index c4af9a14e..78de549dc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "simple-table-core", - "version": "3.4.2", + "version": "3.5.0", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/src/index.d.ts", diff --git a/packages/core/src/core/SimpleTableVanilla.ts b/packages/core/src/core/SimpleTableVanilla.ts index 0e4756624..cdbfe85d3 100644 --- a/packages/core/src/core/SimpleTableVanilla.ts +++ b/packages/core/src/core/SimpleTableVanilla.ts @@ -23,6 +23,13 @@ import AriaAnnouncementManager from "../hooks/ariaAnnouncements"; import { calculateScrollbarWidth } from "../hooks/scrollbarWidth"; import { generateRowId, rowIdToString } from "../utils/rowUtils"; import { deepClone } from "../utils/generalUtils"; +import { + ACCORDION_ANIMATION_CLASS, + ACCORDION_CLEANUP_BUFFER_MS, + ACCORDION_DURATION_VAR, + ACCORDION_EASING_VAR, + type AccordionAxis, +} from "../utils/accordionAnimation"; import { TableInitializer, @@ -100,6 +107,27 @@ export class SimpleTableVanilla { private lastScrollTop: number = 0; private isUpdating: boolean = false; + /** + * Active accordion axis for the next render. Set by row/column collapse- + * expand mutators (see {@link beginAccordionAnimation}) and consumed by the + * cell renderers via the render context. Cleared in {@link render} after + * each render so subsequent non-accordion renders (sort, scroll, etc.) + * don't re-trigger the size transitions. + */ + private pendingAccordionAxis: AccordionAxis = null; + /** Pending timeout id used to remove the accordion CSS class. */ + private accordionCleanupTimerId: number | null = null; + /** + * Visible-leaf-headers key as of the last render that committed to the DOM. + * Used by `setHeaders` to detect hide/show/pin/unpin and trigger the + * accordion-horizontal animation. Comparing against `this.headers` directly + * doesn't work because the column editor mutates header objects in place + * (e.g. `header.hide = true`) BEFORE invoking setHeaders, so by the time + * setHeaders runs, the prev and next trees already point to the same + * mutated header instances. + */ + private lastRenderedVisibilityKey: string | null = null; + constructor(container: HTMLElement, config: SimpleTableConfig) { this.container = container; this.config = config; @@ -194,6 +222,45 @@ export class SimpleTableVanilla { * displaced columns slide smoothly out of the dragged column's way rather * than snapping into place. */ + /** + * Build a key summarizing the leaf columns that will paint (accessor + + * pinned section). Hidden leaves and excluded subtrees drop out; nested + * children are flattened so a parent collapse/expand counts as a + * visibility change at the leaf level too. + */ + private buildVisibilityKey(headers: HeaderObject[]): string { + const parts: string[] = []; + const walk = (header: HeaderObject, pinnedAncestor: string | undefined): void => { + if (header.hide || header.excludeFromRender) return; + const pinned = header.pinned ?? pinnedAncestor ?? "main"; + if (header.children && header.children.length > 0) { + for (const child of header.children) walk(child, pinned); + } else { + parts.push(`${String(header.accessor)}:${pinned}`); + } + }; + for (const header of headers) walk(header, undefined); + return parts.join("|"); + } + + /** + * True when the visible-leaf-set (or its pinned-section assignment) for + * `nextHeaders` differs from the last render that committed to the DOM. + * + * We deliberately compare against {@link lastRenderedVisibilityKey} rather + * than `this.headers`: the column editor mutates header objects in place + * before invoking setHeaders (e.g. `header.hide = true`, then + * `setHeaders(deepClone(headers))`), and `this.headers` shares those + * mutated references — so a prev-vs-next compare always reads the same + * state and reports no change. Comparing to the last-rendered key sees + * the user's actually-painted state and correctly detects hide/show and + * pin/unpin changes. + */ + private didColumnVisibilityChange(nextHeaders: HeaderObject[]): boolean { + const nextKey = this.buildVisibilityKey(nextHeaders); + return this.lastRenderedVisibilityKey !== null && nextKey !== this.lastRenderedVisibilityKey; + } + private captureAnimationSnapshot(): void { // Skip the (potentially large) full-section pre-layout build when // animations are disabled — captureSnapshot would discard the result @@ -207,6 +274,45 @@ export class SimpleTableVanilla { }); } + /** + * Open the accordion animation window for the next render: capture a FLIP + * snapshot, mark the active axis so cell renderers initialize incoming + * cells at zero size, and add the CSS class that enables the size + * transitions on `.st-cell` / `.st-header-cell`. + * + * The CSS class is removed after `duration + ACCORDION_CLEANUP_BUFFER_MS` + * so non-accordion renders don't keep transitioning size on subsequent + * inline-style writes. + * + * No-op when animations are disabled (which already includes the + * prefers-reduced-motion check via {@link AnimationCoordinator.isEnabled}). + */ + private beginAccordionAnimation(axis: AccordionAxis): void { + if (!this.animationCoordinator.isEnabled()) return; + if (axis === null) return; + this.captureAnimationSnapshot(); + this.pendingAccordionAxis = axis; + + const duration = this.animationCoordinator.getDuration(); + const easing = this.animationCoordinator.getEasing(); + // Apply to `.simple-table-root` (not the user-supplied outer container) so + // the CSS scope and the test/marketing surface match the documented root. + const root = this.domManager.getElements()?.rootElement ?? this.container; + root.style.setProperty(ACCORDION_DURATION_VAR, `${duration}ms`); + root.style.setProperty(ACCORDION_EASING_VAR, easing); + root.classList.add(ACCORDION_ANIMATION_CLASS); + + if (this.accordionCleanupTimerId !== null) { + window.clearTimeout(this.accordionCleanupTimerId); + } + this.accordionCleanupTimerId = window.setTimeout(() => { + root.classList.remove(ACCORDION_ANIMATION_CLASS); + root.style.removeProperty(ACCORDION_DURATION_VAR); + root.style.removeProperty(ACCORDION_EASING_VAR); + this.accordionCleanupTimerId = null; + }, duration + ACCORDION_CLEANUP_BUFFER_MS); + } + private initializeManagers(): void { this.ariaAnnouncementManager = new AriaAnnouncementManager(); this.ariaAnnouncementManager.subscribe((message) => { @@ -219,6 +325,7 @@ export class SimpleTableVanilla { this.config.rowGrouping, ); this.expandedDepthsManager.subscribe((depths) => { + this.beginAccordionAnimation("vertical"); this.expandedDepths = depths; this.render("expandedDepthsManager"); }); @@ -379,6 +486,9 @@ export class SimpleTableVanilla { this.scrollbarVisibilityManager.subscribe((isScrollable) => { this.isMainSectionScrollable = isScrollable; + if (refs.tableBodyContainerRef.current) { + this.scrollbarWidth = calculateScrollbarWidth(refs.tableBodyContainerRef.current); + } this.render("scrollbarVisibilityManager"); }); } @@ -523,6 +633,7 @@ export class SimpleTableVanilla { private getRenderContext(): RenderContext { const refs = this.domManager.getRefs(); return { + accordionAxis: this.pendingAccordionAxis, config: this.config, customTheme: this.customTheme, resolvedIcons: this.resolvedIcons, @@ -572,20 +683,29 @@ export class SimpleTableVanilla { } }, setHeaders: (headers: HeaderObject[]) => { - // Snapshot on every header change — including the chain of `setHeaders` - // calls that fire while a header is being dragged — so each `dragover` - // swap FLIP-animates the displaced columns smoothly out of the way. - this.captureAnimationSnapshot(); + // When the visible/pinned set of columns changed (hide/show, pin/unpin), + // open the accordion-horizontal animation window so incoming cells + // grow from width 0 and outgoing cells shrink to width 0 in their + // current section. Otherwise (drag-reorder within the same section + // / set), just snapshot for plain FLIP. + const visibilityChanged = this.didColumnVisibilityChange(headers); + if (visibilityChanged) { + this.beginAccordionAnimation("horizontal"); + } else { + this.captureAnimationSnapshot(); + } this.headers = deepClone(headers); this.renderOrchestrator.invalidateCache("header"); }, animationCoordinator: this.animationCoordinator, setCollapsedHeaders: (headers: Set) => { + this.beginAccordionAnimation("horizontal"); this.collapsedHeaders = headers; }, setCollapsedRows: ( rowsOrUpdater: Map | ((prev: Map) => Map), ) => { + this.beginAccordionAnimation("vertical"); this.collapsedRows = typeof rowsOrUpdater === "function" ? rowsOrUpdater(this.collapsedRows) : rowsOrUpdater; this.render("expansion"); @@ -593,6 +713,7 @@ export class SimpleTableVanilla { setExpandedRows: ( rowsOrUpdater: Map | ((prev: Map) => Map), ) => { + this.beginAccordionAnimation("vertical"); this.expandedRows = typeof rowsOrUpdater === "function" ? rowsOrUpdater(this.expandedRows) : rowsOrUpdater; this.render("expansion"); @@ -602,6 +723,12 @@ export class SimpleTableVanilla { | Map | ((prev: Map) => Map), ) => { + // Capture a snapshot before mutating the row-state map so body cells + // around the appearing/disappearing state row FLIP into their new + // positions in sync with the state row's grow-in/out animation. + // Without this, the state row would expand smoothly but every other + // row would snap, producing a visual desync. + this.beginAccordionAnimation("vertical"); this.rowStateMap = typeof mapOrUpdater === "function" ? mapOrUpdater(this.rowStateMap) : mapOrUpdater; this.render("rowStateMap"); @@ -656,6 +783,18 @@ export class SimpleTableVanilla { this.mergedColumnEditorConfig, ); + // Accordion axis is one-shot per collapse/expand toggle: clear it after + // the render that consumed it so subsequent renders (sort, scroll, + // resize, etc.) don't apply zero-size initial styles to cells they + // happen to create. + this.pendingAccordionAxis = null; + + // Snapshot the visible-leaf-set we just painted so the next setHeaders + // can detect a hide/show/pin/unpin change against the user-perceived + // state (rather than against `this.headers`, whose objects the column + // editor mutates in place before calling setHeaders). + this.lastRenderedVisibilityKey = this.buildVisibilityKey(this.headers); + // FLIP play step. No-op when no snapshot is armed or when scroll-driven. // Position-only scroll renders deliberately skip play so out-going / // in-coming cells aren't FLIP-tweened during vertical scrolls. Every @@ -676,6 +815,17 @@ export class SimpleTableVanilla { } if (config.rows !== undefined) { + // Snapshot before swapping the rows reference so the FLIP `play` at the + // end of the ensuing render can interpolate every cell from its old + // visual spot to its new one. Without this, callers like the dynamic + // nested-table example (which calls update({ rows }) once a child fetch + // resolves to swap a loading-state row out for a nested-grid row) would + // see body cells around the change snap instead of slide. + // Skip until after the first render so initial mount doesn't try to + // animate from an empty snapshot. + if (this.firstRenderDone) { + this.captureAnimationSnapshot(); + } this.localRows = [...config.rows]; this.rebuildRowIndexMap(); @@ -777,6 +927,14 @@ export class SimpleTableVanilla { clearTimeout(this.scrollEndTimeoutId); this.scrollEndTimeoutId = null; } + if (this.accordionCleanupTimerId !== null) { + window.clearTimeout(this.accordionCleanupTimerId); + this.accordionCleanupTimerId = null; + } + const root = this.domManager.getElements()?.rootElement ?? this.container; + root.classList.remove(ACCORDION_ANIMATION_CLASS); + root.style.removeProperty(ACCORDION_DURATION_VAR); + root.style.removeProperty(ACCORDION_EASING_VAR); this.dimensionManager?.destroy(); this.scrollManager?.destroy(); @@ -845,9 +1003,16 @@ export class SimpleTableVanilla { filterManager: this.filterManager, onRender: () => this.render("columnEditor-onRender"), setHeaders: (headers: HeaderObject[]) => { - // Snapshot on every header change so column visibility / reordering - // from the column editor smoothly FLIPs into place. - this.captureAnimationSnapshot(); + // Same trigger as the renderContext.setHeaders path: open the + // accordion-horizontal animation window when the visible/pinned set + // changed (hide/show/pin/unpin from the column editor). Pure + // reorders fall through to the plain-snapshot FLIP path. + const visibilityChanged = this.didColumnVisibilityChange(headers); + if (visibilityChanged) { + this.beginAccordionAnimation("horizontal"); + } else { + this.captureAnimationSnapshot(); + } this.headers = deepClone(headers); this.renderOrchestrator.invalidateCache("header"); }, diff --git a/packages/core/src/core/rendering/RenderOrchestrator.ts b/packages/core/src/core/rendering/RenderOrchestrator.ts index 1b26cf960..2aeea5a00 100644 --- a/packages/core/src/core/rendering/RenderOrchestrator.ts +++ b/packages/core/src/core/rendering/RenderOrchestrator.ts @@ -11,6 +11,7 @@ import { FilterManager } from "../../managers/FilterManager"; import { SelectionManager } from "../../managers/SelectionManager"; import { RowSelectionManager } from "../../managers/RowSelectionManager"; import type { AnimationCoordinator, CellPosition } from "../../managers/AnimationCoordinator"; +import type { AccordionAxis } from "../../utils/accordionAnimation"; import { TableRenderer } from "./TableRenderer"; import { flattenRows, FlattenRowsResult } from "../../utils/rowFlattening"; import { @@ -30,6 +31,14 @@ import { MergedColumnEditorConfig, ResolvedIcons } from "../initialization/Table import { recalculateAllSectionWidths } from "../../utils/resizeUtils/sectionWidths"; export interface RenderContext { + /** + * Active accordion animation axis for this render. Set on row-grouping or + * nested-column collapse/expand toggles (see + * {@link SimpleTableVanilla.beginAccordionAnimation}). Cell renderers use it + * to initialize incoming cells at zero size in the named axis so the CSS + * size transition can grow them while sibling cells FLIP into place. + */ + accordionAxis?: AccordionAxis; animationCoordinator?: AnimationCoordinator; cellRegistry: Map; collapsedHeaders: Set; @@ -739,6 +748,7 @@ export class RenderOrchestrator { private buildRendererDeps(effectiveHeaders: HeaderObject[], context: RenderContext) { return { + accordionAxis: context.accordionAxis, animationCoordinator: context.animationCoordinator, config: context.config, customTheme: context.customTheme, diff --git a/packages/core/src/core/rendering/SectionRenderer.ts b/packages/core/src/core/rendering/SectionRenderer.ts index 09eb1235e..4f1a9a18c 100644 --- a/packages/core/src/core/rendering/SectionRenderer.ts +++ b/packages/core/src/core/rendering/SectionRenderer.ts @@ -12,7 +12,7 @@ import { cleanupBodyCellRendering, } from "../../utils/bodyCellRenderer"; import TableRow from "../../types/TableRow"; -import { rowIdToString } from "../../utils/rowUtils"; +import { rowIdToString, expandStateKey, calculateFinalNestedGridHeight } from "../../utils/rowUtils"; import { getCellId } from "../../utils/cellUtils"; import { DEFAULT_CUSTOM_THEME } from "../../types/CustomTheme"; import type { AnimationCoordinator, CellPosition } from "../../managers/AnimationCoordinator"; @@ -186,13 +186,41 @@ export class SectionRenderer { // Track the next colIndex for each section after rendering private nextColIndexMap: Map = new Map(); - // State row elements per section (key: sectionKey, value: Map) - private stateRowsMap: Map> = new Map(); + // State row elements per section. + // Keyed by {@link expandStateKey}(tableRow) (state rows carry a sort-stable + // stableRowKey) rather than numeric `position` or path-based rowId. + private stateRowsMap: Map< + string, + Map< + string, + { + element: HTMLElement; + lastTop: number; + lastPosition: number; + } + > + > = new Map(); - // Nested grid row elements per section (key: sectionKey, value: Map) + // Nested grid row elements per section. + // Keyed by {@link expandStateKey}(tableRow) (nested/state rows carry a sort-stable + // stableRowKey) rather than path-based rowId, which includes indices that change + // after sort — mismatches tore down the nested SimpleTable and killed slide animations. private nestedGridRowsMap: Map< string, - Map void }> + Map< + string, + { + element: HTMLElement; + cleanup?: () => void; + lastPosition: number; + // Track the last numeric top/height we wrote so we can compare against the + // *intent* of the next render rather than against `style.transform` strings, + // which browsers can normalize ("translate3d(0, 99px, 0)" ↔ "translate3d(0px, + // 99px, 0px)") and falsely flag as "changed" on idle re-renders. + lastTop: number; + lastWrapperHeight: number; + } + > > = new Map(); renderHeaderSection(params: HeaderSectionParams): HTMLElement { @@ -261,6 +289,7 @@ export class SectionRenderer { `header-${sectionKey}`, context, pinned, + sectionWidth, ); // Render with current scrollLeft to preserve scroll position during re-renders @@ -322,7 +351,6 @@ export class SectionRenderer { const sectionKey = pinned || "main"; let section = this.bodySections.get(sectionKey); - let isNewSection = false; if (!section) { section = document.createElement("div"); @@ -334,7 +362,6 @@ export class SectionRenderer { : "st-body-main"; section.setAttribute("role", "rowgroup"); this.bodySections.set(sectionKey, section); - isNewSection = true; } const filteredHeaders = headers.filter((h) => { @@ -425,6 +452,7 @@ export class SectionRenderer { `body-${sectionKey}`, context, pinned, + sectionWidth, ); // Render with current scrollLeft to preserve scroll position during re-renders. @@ -442,11 +470,24 @@ export class SectionRenderer { ); // Render nested grid rows (full-width rows that contain a nested SimpleTable) or spacers in pinned sections - this.renderNestedGridRows(section, sectionKey, rows, pinned, cachedContext); + this.renderNestedGridRows( + section, + sectionKey, + rows, + pinned, + cachedContext, + animationCoordinator, + ); // Render state indicator rows (loading/error/empty) as full-width rows – only in main (non-pinned) section if (!pinned) { - this.renderStateRows(section, sectionKey, rows, cachedContext); + this.renderStateRows( + section, + sectionKey, + rows, + cachedContext, + animationCoordinator, + ); } // For main section (not pinned), attach render function for scroll updates (used by SectionScrollController.onMainSectionScrollLeft) @@ -465,7 +506,7 @@ export class SectionRenderer { }; } - return section; + return section!; } private renderNestedGridRows( @@ -474,9 +515,24 @@ export class SectionRenderer { rows: TableRow[], pinned: "left" | "right" | undefined, context: CellRenderContext, + animationCoordinator: AnimationCoordinator | undefined, ): void { + // Inline transition string driven by the user's configured animations: + // - Matches the body-cell FLIP duration/easing so a row's nested grid + // animates in lockstep with the cells above and below it. + // - Empty when the coordinator is disabled (e.g. animations.enabled=false + // or prefers-reduced-motion) so position updates snap as expected. + // We *only* attach this transition when the renderer is already updating + // an existing nested-grid-row's transform/height (so the change has a + // "before" value to interpolate from). Newly-created rows assign their + // transform synchronously before paint and we leave the transition empty, + // letting them appear at their destination immediately. + const animationsActive = animationCoordinator?.isEnabled() ?? false; + const transitionStyle = animationsActive + ? `transform ${animationCoordinator!.getDuration()}ms ${animationCoordinator!.getEasing()}, height ${animationCoordinator!.getDuration()}ms ${animationCoordinator!.getEasing()}` + : ""; const nestedRows = rows.filter((r) => r.nestedTable); - const currentPositions = new Set(nestedRows.map((r) => r.position)); + const currentKeys = new Set(nestedRows.map((r) => expandStateKey(r))); let map = this.nestedGridRowsMap.get(sectionKey); if (!map) { @@ -484,12 +540,12 @@ export class SectionRenderer { this.nestedGridRowsMap.set(sectionKey, map); } - // Remove nested row elements that are no longer in the list - map.forEach((entry, position) => { - if (!currentPositions.has(position)) { + // Remove nested row elements that no longer have a matching parent in the list. + map.forEach((entry, key) => { + if (!currentKeys.has(key)) { entry.cleanup?.(); entry.element.remove(); - map!.delete(position); + map!.delete(key); } }); @@ -507,30 +563,132 @@ export class SectionRenderer { }; nestedRows.forEach((tableRow) => { - const position = tableRow.position; - const existing = map!.get(position); + const stableKey = expandStateKey(tableRow); + const existing = map!.get(stableKey); if (existing) { - // Already rendered for this position; could update if needed (e.g. height/position changed) + // Same nested table is still expanded for the same parent row, but its + // visual position and/or wrapper height may have changed because rows + // above expanded/collapsed (or a sibling's child data just resolved + // and grew the layout). Update the inline transform/height on the + // existing element rather than tearing it down — keeping the same DOM + // node lets the inline CSS transition interpolate smoothly to the new + // position in lockstep with the body-cell FLIP. + const newTop = calculateRowTopPosition({ + position: tableRow.position, + rowHeight: context.rowHeight, + heightOffsets: context.heightOffsets, + customTheme: context.customTheme ?? ({} as any), + }); + const newWrapperHeight = calculateFinalNestedGridHeight({ + calculatedHeight: tableRow.nestedTable!.calculatedHeight, + customHeight: tableRow.nestedTable!.expandableHeader.nestedTable?.height, + customTheme: context.customTheme ?? ({} as any), + }); + const transformChanged = existing.lastTop !== newTop; + const heightChanged = existing.lastWrapperHeight !== newWrapperHeight; + if (transformChanged || heightChanged) { + existing.element.style.transition = transitionStyle; + if (transformChanged) { + existing.element.style.transform = `translate3d(0, ${newTop}px, 0)`; + } + if (heightChanged) { + existing.element.style.height = `${newWrapperHeight}px`; + } + existing.lastTop = newTop; + existing.lastWrapperHeight = newWrapperHeight; + } + existing.element.dataset.index = String(tableRow.position); + existing.lastPosition = tableRow.position; return; } + // Decide the initial visual height for a freshly-created nested grid row: + // - If a state row existed at the same flattened position this render + // replaced (lazy-load case: loading row → resolved nested table), + // start at `rowHeight` so the wrapper appears to "grow" out of the + // state row that just disappeared. + // - Otherwise (eager-load: parent expanded with data already present) + // start at 0 so it appears to unfold from the parent row directly, + // in lockstep with the body cells below it sliding down by the full + // wrapper height (FLIP delta = wrapperHeight in that case). + // The state-row map is keyed by the same stable rowId as the nested-grid + // row that replaces it (both share `[...rowPath, currentGroupingKey]`), + // so a hit here means the just-resolved data is replacing a loading row. + const stateRowMapForSection = this.stateRowsMap.get(sectionKey); + const replacedStateRow = + !!stateRowMapForSection && stateRowMapForSection.has(stableKey); + const initialHeight = replacedStateRow ? context.rowHeight : 0; + const finalWrapperHeight = calculateFinalNestedGridHeight({ + calculatedHeight: tableRow.nestedTable!.calculatedHeight, + customHeight: tableRow.nestedTable!.expandableHeader.nestedTable?.height, + customTheme: context.customTheme ?? ({} as any), + }); + const finalTop = calculateRowTopPosition({ + position: tableRow.position, + rowHeight: context.rowHeight, + heightOffsets: context.heightOffsets, + customTheme: context.customTheme ?? ({} as any), + }); + if (pinned) { const spacer = createNestedGridSpacer(tableRow, { rowHeight: context.rowHeight, heightOffsets: context.heightOffsets, customTheme: context.customTheme ?? ({} as any), }); + // Same growth treatment for pinned spacers so left/right pinned + // sections stay vertically aligned with the main section's nested row. + if (animationsActive && initialHeight !== finalWrapperHeight) { + spacer.style.height = `${initialHeight}px`; + } section.appendChild(spacer); - map!.set(position, { element: spacer }); + map!.set(stableKey, { + element: spacer, + lastPosition: tableRow.position, + lastTop: finalTop, + lastWrapperHeight: finalWrapperHeight, + }); + if (animationsActive && initialHeight !== finalWrapperHeight) { + // 2x rAF so the browser commits the initial height frame before we + // flip in the transition + final height — without this the change + // collapses into a single paint and there's nothing to animate. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + spacer.style.transition = transitionStyle; + spacer.style.height = `${finalWrapperHeight}px`; + }); + }); + } } else { nestedContext.depth = tableRow.depth > 0 ? tableRow.depth - 1 : 0; const { element, cleanup } = createNestedGridRow( tableRow, nestedContext, ); + // Override the height that createNestedGridRow set so we can grow into + // the final value on the next frame. Overflow:hidden keeps the inner + // SimpleTable visually clipped while the wrapper expands. + if (animationsActive && initialHeight !== finalWrapperHeight) { + element.style.height = `${initialHeight}px`; + element.style.overflow = "hidden"; + } section.appendChild(element); - map!.set(position, { element, cleanup }); + map!.set(stableKey, { + element, + cleanup, + lastPosition: tableRow.position, + lastTop: finalTop, + lastWrapperHeight: finalWrapperHeight, + }); + if (animationsActive && initialHeight !== finalWrapperHeight) { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + element.style.transition = transitionStyle; + element.style.height = `${finalWrapperHeight}px`; + }); + }); + } } }); } @@ -540,9 +698,10 @@ export class SectionRenderer { sectionKey: string, rows: TableRow[], context: CellRenderContext, + animationCoordinator: AnimationCoordinator | undefined, ): void { const stateRows = rows.filter((r) => r.stateIndicator); - const currentPositions = new Set(stateRows.map((r) => r.position)); + const currentKeys = new Set(stateRows.map((r) => expandStateKey(r))); let map = this.stateRowsMap.get(sectionKey); if (!map) { @@ -550,11 +709,20 @@ export class SectionRenderer { this.stateRowsMap.set(sectionKey, map); } - // Remove state row elements that are no longer in the list - map.forEach((element, position) => { - if (!currentPositions.has(position)) { - element.remove(); - map!.delete(position); + // The transition mirrors the one used for nested-grid rows so the state + // row, the nested-grid row that eventually replaces it, and the body + // cells around it all interpolate over the same window/easing. + const animationsActive = animationCoordinator?.isEnabled() ?? false; + const transitionStyle = animationsActive + ? `transform ${animationCoordinator!.getDuration()}ms ${animationCoordinator!.getEasing()}, height ${animationCoordinator!.getDuration()}ms ${animationCoordinator!.getEasing()}` + : ""; + + // Remove state rows that no longer exist in the flattened list (typically + // because their parent collapsed or transitioned to error/empty/data). + map.forEach((entry, key) => { + if (!currentKeys.has(key)) { + entry.element.remove(); + map!.delete(key); } }); @@ -569,34 +737,62 @@ export class SectionRenderer { }; stateRows.forEach((tableRow, i) => { - const position = tableRow.position; - const existing = map!.get(position); + const stableKey = expandStateKey(tableRow); + const newTop = calculateRowTopPosition({ + position: tableRow.position, + rowHeight: context.rowHeight, + heightOffsets: context.heightOffsets, + customTheme: context.customTheme ?? ({} as any), + }); + const existing = map!.get(stableKey); if (existing) { - // Update position in case it changed - const top = calculateRowTopPosition({ - position, - rowHeight: context.rowHeight, - heightOffsets: context.heightOffsets, - customTheme: context.customTheme ?? ({} as any), - }); - existing.style.transform = `translate3d(0, ${top}px, 0)`; + // Existing state row — update its position when rows above expand or + // collapse so it slides in lockstep with body cells (no transition + // reset when the position is unchanged). + if (existing.lastTop !== newTop) { + existing.element.style.transition = transitionStyle; + existing.element.style.transform = `translate3d(0, ${newTop}px, 0)`; + existing.lastTop = newTop; + } + existing.lastPosition = tableRow.position; return; } - const rowElement = createStateRow(tableRow, { ...stateContext, index: i }); - // Position the state row correctly - const top = calculateRowTopPosition({ - position, - rowHeight: context.rowHeight, - heightOffsets: context.heightOffsets, - customTheme: context.customTheme ?? ({} as any), + const rowElement = createStateRow(tableRow, { + ...stateContext, + index: i, }); rowElement.style.position = "absolute"; - rowElement.style.transform = `translate3d(0, ${top}px, 0)`; + rowElement.style.transform = `translate3d(0, ${newTop}px, 0)`; rowElement.style.width = "100%"; - section.appendChild(rowElement); - map!.set(position, rowElement); + + if (animationsActive) { + // Grow the state row in from height 0 so it appears to unfold out of + // the parent row (matching the grow-in used for fresh nested-grid + // rows). Overflow:hidden keeps the inner content clipped while the + // wrapper expands to its final rowHeight. + rowElement.style.height = "0px"; + rowElement.style.overflow = "hidden"; + section.appendChild(rowElement); + // 2x rAF: the browser must paint the initial height=0 frame before + // the transition starts, otherwise the change collapses into one + // paint and there's nothing to animate from. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + rowElement.style.transition = transitionStyle; + rowElement.style.height = `${context.rowHeight}px`; + }); + }); + } else { + section.appendChild(rowElement); + } + + map!.set(stableKey, { + element: rowElement, + lastTop: newTop, + lastPosition: tableRow.position, + }); }); } @@ -853,6 +1049,25 @@ export class SectionRenderer { ]; let hash = keys.map((k) => `${k}:${context[k]}`).join("|"); + // Include heightOffsets so contexts captured for body sections invalidate + // when nested tables expand/collapse above an existing nested row. Without + // this, the cached context's stale heightOffsets is reused and rows below + // expanding/collapsing nested tables compute the wrong absolute top — the + // already-expanded nested table animates to a position that includes a + // phantom contribution from the previous layout, producing a visible gap + // during the loading phase before the new nested table has resolved. + const offsets = context.heightOffsets; + if (Array.isArray(offsets) && offsets.length > 0) { + let offsetsSig = ""; + for (let i = 0; i < offsets.length; i++) { + const entry = offsets[i]; + offsetsSig += `${entry[0]}:${entry[1]};`; + } + hash += `|offsets:${offsetsSig}`; + } else { + hash += `|offsets:none`; + } + // Include sort state in hash for header context if (context.sort) { hash += `|sort:${context.sort.key.accessor}-${context.sort.direction}`; @@ -921,6 +1136,13 @@ export class SectionRenderer { .join(",")}`; } + if (context.pinned) { + hash += `|pinned:${context.pinned}`; + } + if (context.pinnedSectionWidthPx !== undefined) { + hash += `|pinnedSectionWidthPx:${context.pinnedSectionWidthPx}`; + } + return hash; } @@ -1093,21 +1315,28 @@ export class SectionRenderer { cacheKey: string, context: T, pinned?: "left" | "right", + sectionWidth?: number, ): T { const cached = this.contextCache.get(cacheKey); - const contextHash = this.createContextHash(context); + const pinnedSectionWidthPx = + pinned === "left" || pinned === "right" ? sectionWidth : undefined; + const newContext = { + ...context, + pinned, + pinnedSectionWidthPx, + } as T; + const contextHash = this.createContextHash(newContext); if (cached && cached.deps.contextHash === contextHash) { return cached.context as T; } - const newContext = { ...context, pinned }; this.contextCache.set(cacheKey, { context: newContext, deps: { contextHash }, }); - return newContext as T; + return newContext; } invalidateCache(type?: "body" | "header" | "context" | "all"): void { diff --git a/packages/core/src/core/rendering/TableRenderer.ts b/packages/core/src/core/rendering/TableRenderer.ts index d35087740..64b6a1c40 100644 --- a/packages/core/src/core/rendering/TableRenderer.ts +++ b/packages/core/src/core/rendering/TableRenderer.ts @@ -23,10 +23,15 @@ import { FilterManager } from "../../managers/FilterManager"; import { SelectionManager } from "../../managers/SelectionManager"; import { RowSelectionManager } from "../../managers/RowSelectionManager"; import type { AnimationCoordinator, CellPosition } from "../../managers/AnimationCoordinator"; +import type { AccordionAxis } from "../../utils/accordionAnimation"; import { recalculateAllSectionWidths } from "../../utils/resizeUtils/sectionWidths"; import { canDisplaySection } from "../../utils/generalUtils"; +import type TableRow from "../../types/TableRow"; +import { rowIdToString } from "../../utils/rowUtils"; export interface TableRendererDeps { + /** Accordion animation axis for the in-flight collapse/expand. See {@link RenderContext.accordionAxis}. */ + accordionAxis?: AccordionAxis; animationCoordinator?: AnimationCoordinator; cellRegistry: Map; collapsedHeaders: Set; @@ -58,7 +63,7 @@ export interface TableRendererDeps { pinnedLeftRef: { current: HTMLDivElement | null }; pinnedRightHeaderRef: { current: HTMLDivElement | null }; pinnedRightRef: { current: HTMLDivElement | null }; - positionOnlyBody?: boolean; /** When true, body sections use position-only updates for existing cells (scroll performance). */ + positionOnlyBody?: boolean; /** When true, scroll path updates cell geometry only (no full content/selection refresh); row separators still sync. */ resolvedIcons: any; rowSelectionManager: RowSelectionManager | null; rowStateMap: Map; @@ -276,6 +281,8 @@ export class TableRenderer { mainBodyRef: deps.mainBodyRef, pinnedLeftRef: deps.pinnedLeftRef, pinnedRightRef: deps.pinnedRightRef, + accordionAxis: deps.accordionAxis, + animationCoordinator: deps.animationCoordinator, }; const pinnedLeftHeaders = deps.effectiveHeaders.filter( @@ -509,6 +516,7 @@ export class TableRenderer { deps.rowSelectionManager?.isRowSelected(rowId) ?? false, canExpandRowGroup: deps.config.canExpandRowGroup, isLoading: deps.internalIsLoading, + accordionAxis: deps.accordionAxis, }; const pinnedLeftHeaders = deps.effectiveHeaders.filter( @@ -551,7 +559,7 @@ export class TableRenderer { }); deps.pinnedLeftRef.current = leftSection as HTMLDivElement; sectionsToKeep.push(leftSection); - // Only append if not already a child — calling appendChild on a node + // Only insert if not already a child — calling appendChild on a node // already in the same parent triggers a detach + reinsert per the DOM // spec, which cancels every CSS transition on its descendants and // snaps their computed transforms to the inline value. With cell @@ -559,8 +567,18 @@ export class TableRenderer { // first sort's animation would visually teleport every animating cell // to its destination instead of FLIP-tweening from the in-flight // visual position. + // + // Use insertBefore at position 0 (rather than appendChild) so the new + // pinned-left body section lands at the start of the body container. + // The body container is a flex row; .st-body-main has flex-grow: 1 + // and consumes all available width, so a leftSection appended after + // an already-present main section is visually pushed past the scroll + // viewport — the user sees the pinned header but the pinned cells + // appear missing. Header sections avoid this by always re-appending + // to fix document order; body sections can't because that would + // cancel running cell transitions. if (leftSection.parentElement !== container) { - container.appendChild(leftSection as HTMLElement); + container.insertBefore(leftSection as HTMLElement, container.firstChild); } // Update colIndex for next section currentColIndex = this.sectionRenderer.getNextColIndex("left"); @@ -587,8 +605,20 @@ export class TableRenderer { }); deps.mainBodyRef.current = mainSection as HTMLDivElement; sectionsToKeep.push(mainSection); + // Insert main BEFORE any already-present pinned-right body section so + // the [left, main, right] document order is preserved when main goes + // from empty (all columns pinned) to populated. Same flex-layout + // reasoning as the leftSection insertion above: appending main after + // an already-present pinned-right would push main behind right and + // confuse the visible layout. Existing children are intentionally + // not moved so in-flight cell transitions aren't cancelled. if (mainSection.parentElement !== container) { - container.appendChild(mainSection as HTMLElement); + const existingRight = deps.pinnedRightRef.current; + if (existingRight && existingRight.parentElement === container) { + container.insertBefore(mainSection as HTMLElement, existingRight); + } else { + container.appendChild(mainSection as HTMLElement); + } } // Update colIndex for next section currentColIndex = this.sectionRenderer.getNextColIndex("main"); @@ -638,10 +668,30 @@ export class TableRenderer { // Get scroll state const scrollTop = deps.mainBodyRef.current?.scrollTop ?? 0; - const scrollbarWidth = deps.mainBodyRef.current - ? deps.mainBodyRef.current.offsetWidth - - deps.mainBodyRef.current.clientWidth - : 0; + // Vertical scrollbar gutter lives on `.st-body-container`, not `.st-body-main` + // (main hides scrollbars and does not reserve the gutter). + const scrollbarWidth = container.offsetWidth - container.clientWidth; + + const stickySectionColStart = { + left: 0, + main: + pinnedLeftHeaders.length > 0 ? this.sectionRenderer.getNextColIndex("left") : 0, + right: + mainHeaders.length > 0 + ? this.sectionRenderer.getNextColIndex("main") + : pinnedLeftHeaders.length > 0 + ? this.sectionRenderer.getNextColIndex("left") + : 0, + }; + + const rowsForBodyCellIndices = rowsToRender.filter( + (r: TableRow) => !r.nestedTable && !r.stateIndicator, + ); + const stickyBodyRowIndexByRowKey = new Map(); + rowsForBodyCellIndices.forEach((tr: TableRow, rowIndex: number) => { + const key = tr.stableRowKey ?? rowIdToString(tr.rowId); + stickyBodyRowIndexByRowKey.set(key, rowIndex); + }); // Create sticky parents container this.stickyParentsContainer = createStickyParentsContainer( @@ -656,6 +706,8 @@ export class TableRenderer { scrollTop, scrollbarWidth, stickyParents: processedResult.stickyParents, + stickySectionColStart, + stickyBodyRowIndexByRowKey, }, { collapsedHeaders: deps.collapsedHeaders, diff --git a/packages/core/src/hooks/scrollbarVisibility.ts b/packages/core/src/hooks/scrollbarVisibility.ts index cb1a2fd26..5616e328a 100644 --- a/packages/core/src/hooks/scrollbarVisibility.ts +++ b/packages/core/src/hooks/scrollbarVisibility.ts @@ -72,9 +72,15 @@ export class ScrollbarVisibilityManager { if (!this.headerContainer) return; if (this.isMainSectionScrollable) { + // Measure the live vertical scrollbar gutter on the body container. The + // width passed in at construction is often 0 (measured before overflow). + const live = + this.mainSection != null + ? this.mainSection.offsetWidth - this.mainSection.clientWidth + : this.scrollbarWidth; + this.scrollbarWidth = live; this.headerContainer.classList.add("st-header-scroll-padding"); - // Change width of the ::after div to the scrollbarWidth - this.headerContainer.style.setProperty("--st-after-width", `${this.scrollbarWidth}px`); + this.headerContainer.style.setProperty("--st-after-width", `${live}px`); } else { this.headerContainer.classList.remove("st-header-scroll-padding"); } diff --git a/packages/core/src/managers/AnimationCoordinator.ts b/packages/core/src/managers/AnimationCoordinator.ts index adb894480..88df868da 100644 --- a/packages/core/src/managers/AnimationCoordinator.ts +++ b/packages/core/src/managers/AnimationCoordinator.ts @@ -23,6 +23,14 @@ const MIN_DELTA = 0.5; const SAFETY_TIMEOUT_SLACK = 80; const RETAINED_CLASS = "st-cell-animating-out"; const RETAINED_ATTR = "data-animating-out"; +/** + * Marker on retained ghost cells whose only animation is a CSS-driven + * width/height shrink (no FLIP transform). The `play()` per-cell loop must + * skip them — its retained-cell branch removes any cell with a zero FLIP + * delta immediately, which would tear the ghost out of the DOM before the + * accordion CSS transition can play. + */ +const SHRINKING_OUT_ATTR = "data-shrinking-out"; /** * Curve-shape factor for the off-screen portion of the FLIP journey. Larger @@ -68,6 +76,42 @@ export interface CellPosition { } interface CellSnapshot { + /** + * The container element the cell was rendered in at snapshot time. Used by + * the renderer to detect cross-container moves (e.g. pin / unpin shifts a + * column from `.st-body-main` to `.st-body-pinned-left`): the snapshot's + * `left`/`top` are in the source container's coordinate frame, so a FLIP + * applied in the destination container would slide the cell from a wrong + * visual origin. When the renderer sees the cell ended up in a different + * container, it treats it as a fresh cell (accordion grow from 0) instead + * of trying to FLIP across coordinate frames. + * + * `null` for `preLayouts` entries (conceptual positions for off-screen + * rows) — those are used only for in-band FLIP-in animations on sort, + * never for cross-container detection. + */ + sourceContainer: HTMLElement | null; + /** + * Page-coord origin of {@link sourceContainer} captured at snapshot time + * (`getBoundingClientRect().left/top`). Combined with the container's + * page origin at {@link play} time, lets the FLIP delta compensate for + * the container's OWN shift on the page — which happens when an + * adjacent section changes width (e.g. pin/unpin moves the main body + * sideways because the pinned-left section just grew/shrank). + * + * Without this correction the inverse transform is computed only in + * container-local style coordinates, so siblings whose style.left + * shrunk to fill the gap left behind end up visually starting at + * (newContainerLeft + newStyleLeft + |reflow|) — i.e. roughly TWICE + * the visible reflow distance — and the user sees them slide much + * further than the column actually moved. + * + * Zero for `preLayouts` entries (no live container at capture time); + * those are conceptual destinations and the layout-shift correction + * doesn't apply. + */ + sourceContainerLeft: number; + sourceContainerTop: number; left: number; top: number; /** @@ -134,6 +178,14 @@ export class AnimationCoordinator { private easing: string; /** Pre-change positions for any cell we want to consider for animation. */ private snapshot: Map | null = null; + /** + * One-shot synthetic origins for incoming cells that have no entry in the + * captured snapshot (e.g. rows/columns that did not exist in the pre-render + * state because they were inside a collapsed group). Used by accordion + * animations so a newly-visible cell unfolds from its parent's position + * rather than appearing in place. Cleared at the end of {@link play}. + */ + private incomingOrigins: Map | null = null; private inFlight: Map = new Map(); /** Outgoing cells the renderer handed off; keyed per container so play() finds them. */ private retainedCells: Map> = new Map(); @@ -185,6 +237,32 @@ export class AnimationCoordinator { return this.inFlight.has(cellId); } + getDuration(): number { + return this.duration; + } + + getEasing(): string { + return this.easing; + } + + /** + * Register synthetic pre-change origins for incoming cells that did not + * exist in the captured snapshot. {@link play} consults this map before + * giving up on a cell that has no `before` snapshot entry; matching cells + * FLIP from the override origin to their final position. + * + * The map is consumed by the next `play()` call and cleared, so callers + * must set it after `captureSnapshot` and before the render that creates + * the corresponding cells. + */ + setIncomingOrigins(origins: Map | null): void { + if (!this.isEnabled()) { + this.incomingOrigins = null; + return; + } + this.incomingOrigins = origins && origins.size > 0 ? origins : null; + } + /** * Read scroller layout metrics for `container`, caching the result for the * remainder of the current render cycle. Subsequent calls in the same @@ -233,11 +311,23 @@ export class AnimationCoordinator { for (const container of args.containers) { if (!container) continue; + // Read the container's page-coord origin ONCE per container per + // capture so every cell snapshot in this container shares the same + // anchor. Used by play() to correct the FLIP delta for the + // container's own shift between capture and play (e.g. main body + // slides sideways when pinned-left resizes during a pin/unpin). + const containerRect = container.getBoundingClientRect(); + const containerLeft = containerRect.left; + const containerTop = containerRect.top; + // 1. DOM-rendered cells: read live position (handles in-flight transforms). const cells = collectRenderedCells(container); cells.forEach((element, cellId) => { if (!next.has(cellId)) { - next.set(cellId, this.readPosition(cellId, element)); + next.set( + cellId, + this.readPosition(cellId, element, container, containerLeft, containerTop), + ); } }); @@ -246,7 +336,10 @@ export class AnimationCoordinator { if (retained) { retained.forEach((element, cellId) => { if (!next.has(cellId)) { - next.set(cellId, this.readPosition(cellId, element)); + next.set( + cellId, + this.readPosition(cellId, element, container, containerLeft, containerTop), + ); } }); } @@ -266,7 +359,13 @@ export class AnimationCoordinator { // fromDom=false signals to play() that this position is conceptual // (potentially tens of thousands of pixels off-screen) and should // be compressed via scaleFlipDistance. + // + // sourceContainer is null and the container origins are 0: + // play() interprets this as "no container-shift correction". next.set(cellId, { + sourceContainer: null, + sourceContainerLeft: 0, + sourceContainerTop: 0, left: pos.left, top: pos.top, styleTop: pos.top, @@ -289,6 +388,36 @@ export class AnimationCoordinator { return Boolean(this.snapshot?.has(cellId)); } + /** + * Whether the captured snapshot has an entry for the given cellId. The + * accordion expand path uses this to detect "newly visible" cells (no + * pre-change layout) so it can initialize them at zero size and let the + * CSS transition grow them to full size. + */ + hasSnapshotEntry(cellId: string): boolean { + return Boolean(this.snapshot?.has(cellId)); + } + + /** + * True when the snapshot has an entry for `cellId` AND the cell was + * rendered in `currentContainer` at snapshot time. Returns false when the + * cell came from a different container (cross-section pin/unpin) — its + * snapshot position is in another container's coordinate frame, so a + * FLIP applied locally would slide from a wrong visual origin and the + * destination renderer should treat the cell as fresh (accordion grow + * from 0 instead). + * + * Snapshot entries with `sourceContainer === null` (preLayouts / + * conceptual positions) are treated as same-container so the existing + * sort/reorder FLIP-from-off-screen behavior is preserved. + */ + hasSnapshotEntryInContainer(cellId: string, currentContainer: HTMLElement): boolean { + const entry = this.snapshot?.get(cellId); + if (!entry) return false; + if (entry.sourceContainer === null) return true; + return entry.sourceContainer === currentContainer; + } + /** * Hand a cell that the renderer would otherwise remove to the coordinator. * The coordinator updates its absolute positioning to the post-change layout @@ -321,13 +450,7 @@ export class AnimationCoordinator { // visibly different slide distances, so they fan out instead of marching // off-screen in lockstep. const metrics = this.getScrollerMetrics(container); - const clippedTop = scaleFlipDistance( - newPosition.top, - oldTop, - newPosition.height, - metrics, - "y", - ); + const clippedTop = scaleFlipDistance(newPosition.top, oldTop, newPosition.height, metrics, "y"); const clippedLeft = scaleFlipDistance( newPosition.left, oldLeft, @@ -385,6 +508,23 @@ export class AnimationCoordinator { if (!map) return null; const element = map.get(cellId); if (!element) return null; + // Shrink-out ghosts have width/height pinned to 0 by inline style and + // are mid-CSS-transition; reclaiming them and snapping the size back to + // the final value via `updateBodyCellElement` would jump the cell from + // 0 → final in one frame instead of growing it. Tear the ghost down so + // the renderer creates a fresh cell. Also drop the snapshot entry the + // ghost contributed in `captureSnapshot` (retained-cell branch); that + // entry's positions were the cell's pre-shrink layout, but the user + // perceives the column as "newly appearing" — `hasSnapshotEntryInContainer` + // would otherwise return true and the renderer would skip the + // accordion grow-from-0 path. + if (element.hasAttribute(SHRINKING_OUT_ATTR)) { + this.cancelInFlight(cellId); + map.delete(cellId); + this.snapshot?.delete(cellId); + element.remove(); + return null; + } this.cancelInFlight(cellId); map.delete(cellId); element.classList.remove(RETAINED_CLASS); @@ -394,6 +534,82 @@ export class AnimationCoordinator { return element; } + /** + * Hand off a cell that the renderer would otherwise remove for an accordion + * shrink-out (column hide / pin-out from this section): the cell stays in + * place and its size in the named axis is animated to zero by the + * `.st-accordion-animating` CSS transition (width/height). Removed from the + * DOM after the transition completes. + * + * Used when there is no destination position for the cell in the current + * section's post-render layout — either because the column was hidden or + * because it moved to a different pinned section. In the moved-section + * case, the destination section creates a fresh cell that grows from zero + * width via the existing accordion incoming-cell path, so the visual + * effect is a synchronized shrink-here / grow-there pair rather than a + * cross-container slide (which would require translating coordinates + * between two different container coordinate frames). + */ + shrinkOutCell(args: { + cellId: string; + element: HTMLElement; + container: HTMLElement; + axis: "horizontal" | "vertical"; + }): void { + const { cellId, element, container, axis } = args; + // Tear down any previous in-flight transition for this id (FLIP from a + // prior sort, or an earlier shrink-out that's somehow still tracked) so + // we don't leak its cleanup timeout when we overwrite the inFlight slot. + this.cancelInFlight(cellId); + let map = this.retainedCells.get(container); + if (!map) { + map = new Map(); + this.retainedCells.set(container, map); + } + + // Drop a stale ghost with the same id to avoid leaking DOM (e.g. user + // toggles the same column on and off rapidly during the animation). + const existing = map.get(cellId); + if (existing && existing !== element) { + this.cancelInFlight(cellId); + existing.remove(); + } + + if (element.id) element.removeAttribute("id"); + element.classList.add(RETAINED_CLASS); + element.setAttribute(RETAINED_ATTR, "true"); + element.setAttribute(SHRINKING_OUT_ATTR, "true"); + element.style.pointerEvents = "none"; + if (axis === "horizontal") { + element.style.width = "0px"; + } else { + element.style.height = "0px"; + } + + map.set(cellId, element); + + // The accordion CSS transition (width/height) is on `.st-cell` / + // `.st-header-cell` while `.st-accordion-animating` is set on the root. + // We don't get a `transitionend` handle to it from the FLIP transform + // listener, so use a duration-based timeout for cleanup. + const cleanupTimeout = window.setTimeout(() => { + const m = this.retainedCells.get(container); + if (m && m.get(cellId) === element) { + m.delete(cellId); + } + element.remove(); + }, this.duration + SAFETY_TIMEOUT_SLACK); + + // Reuse the inFlight bookkeeping so cancel() and discardRetainedIfPresent + // can tear the timeout down cleanly. + this.inFlight.set(cellId, { + element, + cleanupTimeout, + transitionEndHandler: () => {}, + isRetained: true, + }); + } + /** * Discard any retained cell with this id in the given container. Called by * the renderer when it's about to create a fresh cell with the same id, so @@ -416,7 +632,9 @@ export class AnimationCoordinator { */ play(args: { containers: Array }): void { const snapshot = this.snapshot; + const incomingOrigins = this.incomingOrigins; this.snapshot = null; + this.incomingOrigins = null; if (!this.isEnabled() || !snapshot) { // Nothing to play. Drop only retained cells that aren't already @@ -444,6 +662,22 @@ export class AnimationCoordinator { }; const pending: Pending[] = []; const seen = new Set(); + // Per-play page-coord origin cache for each container we touch. Reading + // `getBoundingClientRect()` once per container per play call lets every + // cell in the consider() loop subtract the SAME container shift from + // its FLIP delta without re-paying the layout cost N times. The + // container's page origin only changes at layout boundaries, so it's + // safe to share within a single play. + const containerOriginCache = new Map(); + const getPlayContainerOrigin = (element: HTMLElement): { left: number; top: number } => { + let cached = containerOriginCache.get(element); + if (!cached) { + const rect = element.getBoundingClientRect(); + cached = { left: rect.left, top: rect.top }; + containerOriginCache.set(element, cached); + } + return cached; + }; const consider = ( element: HTMLElement, @@ -452,8 +686,51 @@ export class AnimationCoordinator { container: HTMLElement, ) => { if (seen.has(cellId)) return; - const before = snapshot.get(cellId); - if (!before) return; + // Shrink-out ghosts are driven entirely by the accordion CSS + // width/height transition — they don't move position so the FLIP + // transform delta would be 0, and the retained-no-delta branch + // below removes the cell instantly. Mark seen and skip so the + // shrink animation gets to play out. + if (isRetained && element.hasAttribute(SHRINKING_OUT_ATTR)) { + seen.add(cellId); + return; + } + let before = snapshot.get(cellId); + // Accordion incoming origin: if this is an active (non-retained) cell + // that has no snapshot entry but a synthetic origin was supplied (e.g. + // a row that just appeared because its parent grouping row expanded), + // use the origin as a virtual pre-change position so the cell FLIPs + // from the parent's slot rather than appearing in place. + if (!before && !isRetained && incomingOrigins) { + const origin = incomingOrigins.get(cellId); + if (origin) { + before = { + sourceContainer: null, + sourceContainerLeft: 0, + sourceContainerTop: 0, + left: origin.left, + top: origin.top, + styleTop: origin.top, + styleLeft: origin.left, + fromDom: false, + }; + } + } + if (!before) { + return; + } + // Cross-container snapshot: the cell was rendered in a different + // container at snapshot time (e.g. pin/unpin moved a column from + // `.st-body-main` to `.st-body-pinned-left`). The snapshot's + // left/top are in the other container's coordinate frame, so a + // FLIP applied here would slide from a visually wrong origin. Skip; + // the destination cell renderer treats this as a fresh cell and + // grows it from width 0 via the accordion path while the source + // section's renderer shrinks the old cell to width 0. + if (!isRetained && before.sourceContainer !== null && before.sourceContainer !== container) { + seen.add(cellId); + return; + } // Skip cells with an open inline editor (animating breaks input focus). if (element.querySelector(".st-cell-editing")) return; @@ -506,12 +783,8 @@ export class AnimationCoordinator { // prefer the inline style (no layout) over offsetHeight/offsetWidth // (forces layout). const skipScale = isRetained || before.fromDom; - const cellHeight = skipScale - ? 0 - : parsePx(element.style.height) || element.offsetHeight || 0; - const cellWidth = skipScale - ? 0 - : parsePx(element.style.width) || element.offsetWidth || 0; + const cellHeight = skipScale ? 0 : parsePx(element.style.height) || element.offsetHeight || 0; + const cellWidth = skipScale ? 0 : parsePx(element.style.width) || element.offsetWidth || 0; const playMetrics = skipScale ? null : this.getScrollerMetrics(container); const beforeTopClipped = skipScale || !playMetrics @@ -522,8 +795,41 @@ export class AnimationCoordinator { ? before.left : scaleFlipDistance(before.left, currentLeft, cellWidth, playMetrics, "x"); - const dx = beforeLeftClipped - currentLeft; - const dy = beforeTopClipped - currentTop; + // Container-shift correction. The FLIP delta above is computed in + // container-local style coordinates, but the inverse transform is + // applied in page coordinates. When the container itself moved on + // the page between snapshot and play (e.g. main body shifts right + // because pinned-left just grew during a pin), the cell's visual + // page position post-render = newContainerLeft + currentLeft, but + // its visual pre-render position was oldContainerLeft + before.left. + // The needed visual delta is therefore: + // + // dx_visual = (oldContainerLeft + before.left) - (newContainerLeft + currentLeft) + // = (before.left - currentLeft) - (newContainerLeft - oldContainerLeft) + // = dx_styleSpace - containerShift + // + // Without subtracting `containerShift`, siblings whose style.left + // shrunk to fill the gap left by a pinned-out column appear to + // animate roughly twice the actual visible reflow distance. + // + // Skipped for snapshots with no source container (preLayouts / + // synthetic incoming origins): those are conceptual positions that + // never had a real container anchor. + let containerShiftX = 0; + let containerShiftY = 0; + if (before.sourceContainer !== null) { + // Cross-container case is rejected above; here sourceContainer + // either equals `container` (siblings reflowing in their own + // section) or is the same container for a retained ghost. + const playOrigin = getPlayContainerOrigin(container); + containerShiftX = playOrigin.left - before.sourceContainerLeft; + containerShiftY = playOrigin.top - before.sourceContainerTop; + } + + const dxRaw = beforeLeftClipped - currentLeft; + const dyRaw = beforeTopClipped - currentTop; + const dx = dxRaw - containerShiftX; + const dy = dyRaw - containerShiftY; if (Math.abs(dx) < MIN_DELTA && Math.abs(dy) < MIN_DELTA) { // No visual movement — if this was a retained cell with no movement // (a degenerate case), still drop it so we don't leak DOM. @@ -591,6 +897,7 @@ export class AnimationCoordinator { */ cancel(): void { this.snapshot = null; + this.incomingOrigins = null; this.clearScrollerMetricsCache(); const entries = Array.from(this.inFlight.entries()); this.inFlight.clear(); @@ -612,7 +919,13 @@ export class AnimationCoordinator { this.cancel(); } - private readPosition(cellId: string, element: HTMLElement): CellSnapshot { + private readPosition( + cellId: string, + element: HTMLElement, + sourceContainer: HTMLElement, + sourceContainerLeft: number, + sourceContainerTop: number, + ): CellSnapshot { const styleTop = parsePx(element.style.top); const styleLeft = parsePx(element.style.left); const inFlight = this.inFlight.get(cellId); @@ -622,6 +935,9 @@ export class AnimationCoordinator { if (parent) { const parentRect = parent.getBoundingClientRect(); return { + sourceContainer, + sourceContainerLeft, + sourceContainerTop, left: rect.left - parentRect.left + parent.scrollLeft, top: rect.top - parentRect.top + parent.scrollTop, styleTop, @@ -629,7 +945,16 @@ export class AnimationCoordinator { fromDom: true, }; } - return { left: rect.left, top: rect.top, styleTop, styleLeft, fromDom: true }; + return { + sourceContainer, + sourceContainerLeft, + sourceContainerTop, + left: rect.left, + top: rect.top, + styleTop, + styleLeft, + fromDom: true, + }; } // Non-in-flight branch: style.top/left is the cell's *logical* // destination, not a viewport-bounded visual position. For columns far @@ -637,6 +962,9 @@ export class AnimationCoordinator { // current viewport — same regime as a preLayout entry — so we leave // fromDom=false and let play() compress the FLIP via scaleFlipDistance. return { + sourceContainer, + sourceContainerLeft, + sourceContainerTop, left: styleLeft, top: styleTop, styleTop, diff --git a/packages/core/src/managers/DimensionManager.ts b/packages/core/src/managers/DimensionManager.ts index 5c0f7f015..fd89bba3b 100644 --- a/packages/core/src/managers/DimensionManager.ts +++ b/packages/core/src/managers/DimensionManager.ts @@ -1,8 +1,4 @@ import HeaderObject from "../types/HeaderObject"; -import { - CSS_VAR_BORDER_WIDTH, - DEFAULT_BORDER_WIDTH, -} from "../consts/general-consts"; export interface DimensionManagerConfig { effectiveHeaders: HeaderObject[]; @@ -71,19 +67,8 @@ export class DimensionManager { } private calculateHeaderHeight(maxHeaderDepth: number): number { - let borderWidth = DEFAULT_BORDER_WIDTH; - if (typeof window !== "undefined") { - const rootElement = document.documentElement; - const computedStyle = getComputedStyle(rootElement); - const borderWidthValue = computedStyle.getPropertyValue(CSS_VAR_BORDER_WIDTH).trim(); - if (borderWidthValue) { - const parsed = parseFloat(borderWidthValue); - if (!isNaN(parsed)) { - borderWidth = parsed; - } - } - } - return maxHeaderDepth * (this.config.headerHeight ?? this.config.rowHeight) + borderWidth; + // Match SectionRenderer: `maxHeaderDepth * headerHeight` (no extra border px on container). + return maxHeaderDepth * (this.config.headerHeight ?? this.config.rowHeight); } private convertHeightToPixels(heightValue: string | number): number { diff --git a/packages/core/src/managers/RowManager.ts b/packages/core/src/managers/RowManager.ts index 067174d0f..d220e844a 100644 --- a/packages/core/src/managers/RowManager.ts +++ b/packages/core/src/managers/RowManager.ts @@ -6,7 +6,9 @@ import TableRow from "../types/TableRow"; import { flattenAllHeaders } from "../utils/headerUtils"; import { generateRowId, - rowIdToString, + generateStableRowKey, + expandStateKey, + nestedChromeRowKey, getNestedRows, isRowExpanded, calculateNestedGridHeight, @@ -214,6 +216,17 @@ export class RowManager { groupingKey: undefined, }); + const stableRowKey = generateStableRowKey({ + getRowId: this.config.getRowId, + row, + depth: 0, + index, + rowPath, + rowIndexPath, + groupingKey: undefined, + parentStableKey: null, + }); + return { row, depth: 0, @@ -225,6 +238,7 @@ export class RowManager { rowIndexPath, absoluteRowIndex: index, isLastGroupRow: false, + stableRowKey, }; }); @@ -250,7 +264,8 @@ export class RowManager { currentDepth: number, parentIdPath: (string | number)[] = [], parentIndexPath: number[] = [], - parentIndices: number[] = [] + parentIndices: number[] = [], + parentStableKey: string | null = null ): void => { currentRows.forEach((row, index) => { const currentGroupingKey = rowGrouping[currentDepth]; @@ -269,6 +284,17 @@ export class RowManager { groupingKey: currentGroupingKey, }); + const stableRowKey = generateStableRowKey({ + getRowId: this.config.getRowId, + row, + depth: currentDepth, + index, + rowPath, + rowIndexPath, + groupingKey: currentGroupingKey, + parentStableKey, + }); + const isLastGroupRow = currentDepth === 0; const currentRowIndex = result.length; @@ -284,16 +310,17 @@ export class RowManager { rowIndexPath, absoluteRowIndex: position, parentIndices: parentIndices.length > 0 ? [...parentIndices] : undefined, + stableRowKey, }; result.push(mainRow); paginatableRowsBuilder.push(mainRow); displayPosition++; - const rowIdKey = rowIdToString(rowId); + const rowExpandKey = expandStateKey(mainRow); const isExpanded = isRowExpanded( - rowIdKey, + rowExpandKey, currentDepth, this.state.expandedDepths, this.state.expandedRows, @@ -301,7 +328,7 @@ export class RowManager { ); if (isExpanded && currentDepth < rowGrouping.length) { - const rowState = this.state.rowStateMap?.get(rowIdKey); + const rowState = this.state.rowStateMap?.get(rowExpandKey); const nestedRows = getNestedRows(row, currentGroupingKey); const expandableHeader = this.config.headers.find((h) => h.expandable && h.nestedTable); @@ -332,6 +359,7 @@ export class RowManager { heightOffsets.push([nestedGridPosition, extraHeight]); const nestedGridRowPath = [...rowPath, currentGroupingKey]; + const nestedDomKey = nestedChromeRowKey(rowExpandKey, currentGroupingKey); result.push({ row: {}, depth: currentDepth + 1, @@ -342,6 +370,7 @@ export class RowManager { rowId: nestedGridRowPath, rowPath: nestedGridRowPath, rowIndexPath, + stableRowKey: nestedDomKey, nestedTable: { parentRow: row, expandableHeader, @@ -369,8 +398,9 @@ export class RowManager { rowId: stateRowPath, rowPath: stateRowPath, rowIndexPath, + stableRowKey: nestedChromeRowKey(rowExpandKey, currentGroupingKey), stateIndicator: { - parentRowId: rowIdKey, + parentRowId: rowExpandKey, parentRow: row, state: rowState, }, @@ -401,7 +431,7 @@ export class RowManager { processRows(nestedRows, currentDepth + 1, nestedIdPath, nestedIndexPath, [ ...parentIndices, currentRowIndex, - ]); + ], stableRowKey); } } @@ -411,7 +441,7 @@ export class RowManager { }); }; - processRows(rows, 0, [], [], []); + processRows(rows, 0, [], [], [], null); return { flattenedRows: result, diff --git a/packages/core/src/styles/base.css b/packages/core/src/styles/base.css index 0823082bf..2f16f2082 100644 --- a/packages/core/src/styles/base.css +++ b/packages/core/src/styles/base.css @@ -193,6 +193,8 @@ input { .st-header-container.st-header-scroll-padding::after { content: ""; display: block; + /* `*` does not target ::after; border-box keeps the bottom border inside the flex line. */ + box-sizing: border-box; width: var(--st-after-width, default-width); flex-shrink: 0; background-color: var(--st-header-background-color); @@ -282,14 +284,12 @@ input { left: 0; display: flex; z-index: 10; - pointer-events: none; } .st-sticky-section-left, .st-sticky-section-main, .st-sticky-section-right { overflow: hidden; - pointer-events: none; } .st-sticky-section-left { @@ -1461,6 +1461,41 @@ input { pointer-events: none; } +/* + * Accordion animation window for nested-column and row-grouping collapse/expand. + * + * Active for one collapse/expand operation: SimpleTableVanilla adds the + * `st-accordion-animating` class plus inline `--st-accordion-duration` / + * `--st-accordion-easing` custom properties on the table root, then removes + * them once the animation ends. + * + * The transition applies to `width`/`height` only — `transform` is driven by + * the FLIP `AnimationCoordinator` with its own inline transition string, and + * transitioning `top`/`left` here would race with that. `overflow: hidden` is + * needed so cell content is clipped while the cell shrinks/grows from zero + * size in the active axis. + */ +.st-accordion-animating .st-cell, +.st-accordion-animating .st-header-cell { + transition: + width var(--st-accordion-duration, var(--st-transition-duration)) + var(--st-accordion-easing, var(--st-transition-ease)), + height var(--st-accordion-duration, var(--st-transition-duration)) + var(--st-accordion-easing, var(--st-transition-ease)); + overflow: hidden; +} + +/* + * Nested grid rows position themselves with `transform: translate3d(0, Ypx, 0)` + * (not `style.top`), so the FLIP-style cell coordinator doesn't drive them. + * SectionRenderer applies an inline transition on transform/height when it + * updates an existing nested-grid-row in place (because an unrelated row + * above expanded/collapsed, or a sibling's child data resolved and changed + * total layout height) — that lets the inline-style update interpolate + * instead of snapping. Initial creation deliberately writes the transform + * without a transition so the row appears at its destination immediately. + */ + /* Cell update animation */ @keyframes cell-flash { 0% { diff --git a/packages/core/src/styles/themes/modern-light.css b/packages/core/src/styles/themes/modern-light.css index 5dc51bccc..263b48716 100644 --- a/packages/core/src/styles/themes/modern-light.css +++ b/packages/core/src/styles/themes/modern-light.css @@ -1,7 +1,7 @@ /* Modern Light Theme - Clean, minimal design inspired by contemporary table UIs * * Design Philosophy: - * - Subtle borders (#f3f4f6) for a clean, uncluttered look + * - Light gray borders (#e5e7eb) for structure without heavy chrome * - White backgrounds with minimal color variation * - Hover states instead of alternating row colors * - Generous padding (12px) for better readability @@ -26,8 +26,8 @@ --st-scrollbar-thumb-color: #d1d5db; --st-scrollbar-width: thin; - /* Base/Structural colors - Very subtle borders */ - --st-border-color: #f3f4f6; + /* Base/Structural colors - Visible but still light (gray-200) */ + --st-border-color: #e5e7eb; --st-footer-background-color: var(--st-white); --st-last-group-row-separator-border-color: #e5e7eb; @@ -132,30 +132,30 @@ /* Modern Light theme specific overrides for even cleaner look */ .theme-modern-light .st-wrapper { - border: 1px solid #f3f4f6; + border: 1px solid var(--st-border-color); } /* Subtle header border */ .theme-modern-light .st-header-pinned-left, .theme-modern-light .st-header-main, .theme-modern-light .st-header-pinned-right { - border-bottom: 1px solid #f3f4f6; + border-bottom: 1px solid var(--st-border-color); } /* Remove heavy borders on pinned sections */ .theme-modern-light .st-header-pinned-left, .theme-modern-light .st-body-pinned-left { - border-right: 1px solid #f3f4f6; + border-right: 1px solid var(--st-border-color); } .theme-modern-light .st-header-pinned-right, .theme-modern-light .st-body-pinned-right { - border-left: 1px solid #f3f4f6; + border-left: 1px solid var(--st-border-color); } -/* Cleaner row separators */ +/* Row separators — match grid lines for readable horizontal breaks */ .theme-modern-light .st-row-separator { - background-color: #f9fafb; + background-color: var(--st-border-color); } /* Lighter header cell styling */ @@ -178,7 +178,7 @@ /* Modern footer styling */ .theme-modern-light .st-footer { - border-top: 1px solid #f3f4f6; + border-top: 1px solid var(--st-border-color); background-color: var(--st-white); padding: 12px 16px; gap: 12px; @@ -242,13 +242,13 @@ /* Modern scrollbar */ .theme-modern-light .st-horizontal-scrollbar-container { - border-top: 1px solid #f3f4f6; + border-top: 1px solid var(--st-border-color); background-color: #fafafa; } /* Cleaner group headers */ .theme-modern-light .st-group-header { - border-bottom: 1px solid #f3f4f6; + border-bottom: 1px solid var(--st-border-color); font-weight: 500; } diff --git a/packages/core/src/styles/themes/theme-custom.css b/packages/core/src/styles/themes/theme-custom.css index 39c6c276c..a603533ee 100644 --- a/packages/core/src/styles/themes/theme-custom.css +++ b/packages/core/src/styles/themes/theme-custom.css @@ -16,8 +16,8 @@ --st-scrollbar-thumb-color: #d1d5db; --st-scrollbar-width: thin; - /* Base/Structural colors - Very subtle borders */ - --st-border-color: #f3f4f6; + /* Base/Structural colors - aligned with modern-light (gray-200) */ + --st-border-color: #e5e7eb; --st-footer-background-color: var(--st-white); --st-last-group-row-separator-border-color: #e5e7eb; diff --git a/packages/core/src/types/TableRow.ts b/packages/core/src/types/TableRow.ts index b1f716506..6fb56e22f 100644 --- a/packages/core/src/types/TableRow.ts +++ b/packages/core/src/types/TableRow.ts @@ -15,11 +15,12 @@ type TableRow = { rowId: (string | number)[]; /** * Position-independent identity for the row, used as the basis for the - * cell DOM `id` and the animation coordinator's snapshot key. When - * `getRowId` is provided, this is `String(customId)` (optionally prefixed - * by grouping keys for nested rows). Lets the same DOM cell survive a - * sort, so FLIP can animate the row to its new position. Falls back to - * the positional rowId string when `getRowId` is absent. + * cell DOM `id`, the animation coordinator's snapshot key, and **expand / + * collapse / row-loading state maps** ({@link expandStateKey}). + * + * Sort/filter reorder leaves this unchanged while positional `rowId` changes, + * so nested rows stay expanded after sort when this is supplied (via `getRowId` + * or WeakMap-backed fallback identities). */ stableRowKey?: string; // Path to reach this row using row IDs (when getRowId is provided) diff --git a/packages/core/src/utils/accordionAnimation.ts b/packages/core/src/utils/accordionAnimation.ts new file mode 100644 index 000000000..3cfc5ddad --- /dev/null +++ b/packages/core/src/utils/accordionAnimation.ts @@ -0,0 +1,36 @@ +/** + * Active axis for the in-flight accordion animation. Set on the render + * context for one render after a collapse/expand toggle so cell renderers + * know which dimension to fold/unfold. + * + * - `"vertical"` — row group expand/collapse: incoming cells start at + * `height: 0` and CSS-transition to `rowHeight`. + * - `"horizontal"` — nested column expand/collapse: incoming cells start at + * `width: 0` and CSS-transition to their final width. + * - `null` — no accordion animation in progress (sort, reorder, + * scroll, etc.). + */ +export type AccordionAxis = "vertical" | "horizontal" | null; + +/** CSS class applied to the table root during the animation window. */ +export const ACCORDION_ANIMATION_CLASS = "st-accordion-animating"; + +/** Custom property names consumed by the accordion CSS transitions. */ +export const ACCORDION_DURATION_VAR = "--st-accordion-duration"; +export const ACCORDION_EASING_VAR = "--st-accordion-easing"; + +/** Window after which the accordion CSS class is removed (ms past duration). */ +export const ACCORDION_CLEANUP_BUFFER_MS = 80; + +/** + * Detect `prefers-reduced-motion: reduce`. Returns `false` outside the + * browser (SSR) so the call site doesn't have to guard. + */ +export const accordionPrefersReducedMotion = (): boolean => { + if (typeof window === "undefined" || !window.matchMedia) return false; + try { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; + } catch { + return false; + } +}; diff --git a/packages/core/src/utils/bodyCell/content.ts b/packages/core/src/utils/bodyCell/content.ts index 0ea87af6f..c14a4095f 100644 --- a/packages/core/src/utils/bodyCell/content.ts +++ b/packages/core/src/utils/bodyCell/content.ts @@ -1,7 +1,7 @@ import HeaderObject from "../../types/HeaderObject"; import CellValue from "../../types/CellValue"; import { formatDate } from "../formatters"; -import { getNestedValue, hasNestedRows, isRowExpanded as getIsRowExpanded } from "../rowUtils"; +import { getNestedValue, hasNestedRows, expandStateKey, isRowExpanded as getIsRowExpanded } from "../rowUtils"; import { createLineAreaChart } from "../charts/createLineAreaChart"; import { createBarChart } from "../charts/createBarChart"; import { AbsoluteBodyCell, CellRenderContext } from "./types"; @@ -127,8 +127,9 @@ export const createCellContent = ( if (shouldShowExpandIcon) { const expandedDepthsSet = new Set(context.expandedDepths); + const expandRowKey = expandStateKey(cell.tableRow); const isExpanded = getIsRowExpanded( - rowId, + expandRowKey, depth, expandedDepthsSet, context.expandedRows, diff --git a/packages/core/src/utils/bodyCell/expansion.ts b/packages/core/src/utils/bodyCell/expansion.ts index b3a09dd6e..1a15dd5ba 100644 --- a/packages/core/src/utils/bodyCell/expansion.ts +++ b/packages/core/src/utils/bodyCell/expansion.ts @@ -1,6 +1,7 @@ import { AbsoluteBodyCell, CellRenderContext } from "./types"; import { addTrackedEventListener } from "./eventTracking"; -import { isRowExpanded } from "../rowUtils"; +import { isRowExpanded, expandStateKey } from "../rowUtils"; +import { cellLiveRefMap } from "./styling"; // Create expand/collapse icon container for row grouping // Uses the icon from context.icons.expand (configured by user or default) @@ -33,51 +34,69 @@ export const createExpandIcon = ( const handleToggle = (event: Event) => { event.stopPropagation(); - const { rowId, depth } = cell; + // The `cell` object captured in this closure is from the render that + // created the DOM node. After a later sort/filter the same cell DOM is + // reused (via stableRowKey), so the closure's positional `cell.rowId` string + // can desync from freshly computed rowIds. We read the LIVE `tableRow` + // from the cell DOM's live ref (updated in `updateBodyCellElement`) and + // derive the expand-state key with `expandStateKey` so toggles match + // `flattenRows` / `isRowExpanded` after reorder. + // + // We deliberately keep the closure's `row`, `rowIndexPath`, and `rowPath` + // for the consumer callback below: `rowIndexPath` is documented across + // every framework demo as the index into the consumer's source-data array + // (e.g. `rows[rowIndexPath[0]] = { ...rows[rowIndexPath[0]], children }`), + // and the consumer's array is not reordered by sort. Using the live + // post-sort positional index here would silently write nested data into + // the wrong row. The closure still carries the source index because the + // cell DOM was created during the pre-sort initial render. + const cellElement = outerContainer.closest("[data-row-id]"); + const liveRef = cellElement ? cellLiveRefMap.get(cellElement) : undefined; + const liveTableRow = liveRef?.tableRow ?? cell.tableRow; + const expandRowKey = expandStateKey(liveTableRow); + const depth = liveTableRow.depth; - // Recalculate current expanded state dynamically to avoid stale closure const expandedDepthsSet = new Set(context.expandedDepths); const currentExpandedRows = context.getExpandedRows ? context.getExpandedRows() : context.expandedRows; const currentCollapsedRows = context.getCollapsedRows ? context.getCollapsedRows() : context.collapsedRows; const currentIsExpanded = isRowExpanded( - rowId, + expandRowKey, depth, expandedDepthsSet, currentExpandedRows, currentCollapsedRows, ); - // Determine the new state after toggle const willBeExpanded = !currentIsExpanded; if (currentIsExpanded) { // Collapse context.setCollapsedRows((prev) => { const next = new Map(prev); - next.set(rowId, depth); + next.set(expandRowKey, depth); return next; }); context.setExpandedRows((prev) => { const next = new Map(prev); - next.delete(rowId); + next.delete(expandRowKey); return next; }); // Clear row state context.setRowStateMap((prevMap) => { const newMap = new Map(prevMap); - newMap.delete(rowId); + newMap.delete(expandRowKey); return newMap; }); } else { // Expand context.setExpandedRows((prev) => { const next = new Map(prev); - next.set(rowId, depth); + next.set(expandRowKey, depth); return next; }); context.setCollapsedRows((prev) => { const next = new Map(prev); - next.delete(rowId); + next.delete(expandRowKey); return next; }); } @@ -90,8 +109,8 @@ export const createExpandIcon = ( setTimeout(() => { context.setRowStateMap((prev) => { const newMap = new Map(prev); - const currentState = newMap.get(rowId) || {}; - newMap.set(rowId, { ...currentState, loading, triggerSection }); + const currentState = newMap.get(expandRowKey) || {}; + newMap.set(expandRowKey, { ...currentState, loading, triggerSection }); return newMap; }); }, 0); @@ -100,8 +119,8 @@ export const createExpandIcon = ( const setError = (error: string | null) => { context.setRowStateMap((prev) => { const newMap = new Map(prev); - const currentState = newMap.get(rowId) || {}; - newMap.set(rowId, { ...currentState, error, loading: false, triggerSection }); + const currentState = newMap.get(expandRowKey) || {}; + newMap.set(expandRowKey, { ...currentState, error, loading: false, triggerSection }); return newMap; }); }; @@ -109,13 +128,12 @@ export const createExpandIcon = ( const setEmpty = (isEmpty: boolean, message?: string) => { context.setRowStateMap((prev) => { const newMap = new Map(prev); - const currentState = newMap.get(rowId) || {}; - newMap.set(rowId, { ...currentState, isEmpty, loading: false, triggerSection }); + const currentState = newMap.get(expandRowKey) || {}; + newMap.set(expandRowKey, { ...currentState, isEmpty, loading: false, triggerSection }); return newMap; }); }; - // Create a synthetic event object const syntheticEvent = { stopPropagation: () => {}, preventDefault: () => {}, diff --git a/packages/core/src/utils/bodyCell/styling.ts b/packages/core/src/utils/bodyCell/styling.ts index a4ba69499..e7f6ca3eb 100644 --- a/packages/core/src/utils/bodyCell/styling.ts +++ b/packages/core/src/utils/bodyCell/styling.ts @@ -1,5 +1,6 @@ import CellValue from "../../types/CellValue"; import type Row from "../../types/Row"; +import type TableRow from "../../types/TableRow"; import { getCellId } from "../cellUtils"; import { getNestedValue, setNestedValue } from "../rowUtils"; import { AbsoluteBodyCell, CellData, CellRenderContext } from "./types"; @@ -11,9 +12,17 @@ import { createCellContent } from "./content"; // (Visual rowIndex within the viewport slice can change on scroll; rowId does not.) const rowCellsMap = new Map>(); -// WeakMap holding a mutable row ref per cell element so click handlers always -// read the latest row data even when the cell DOM node is reused across renders. -const cellRowRefMap = new WeakMap(); +// WeakMap holding a mutable row + tableRow ref per cell element so click +// handlers (cell click, chevron expand) always read the latest data even when +// the cell DOM node is reused across renders. The chevron handler in +// `createExpandIcon` looks this up via `closest('[data-row-id]')` to avoid +// stale-closure rowIds after sort/filter/reorder reuses the same DOM cell for +// a row whose positional rowId has changed. +export interface CellLiveRef { + row: Row; + tableRow: TableRow; +} +export const cellLiveRefMap = new WeakMap(); // Per-element registry key so we can re-key entries when a cell is reused // for a different row across sort/scroll without leaving stale entries behind. @@ -236,11 +245,14 @@ export const createBodyCellElement = ( renderCellContent(); - // Mutable row ref so handlers (and the cell registry's `updateContent`) - // always read the latest row data even when this DOM cell is reused across - // renders (sort, scroll). Set before registering so the registry uses it. - const rowRef = { current: row as Row }; - cellRowRefMap.set(cellElement, rowRef); + // Mutable row + tableRow ref so handlers (and the cell registry's + // `updateContent`) always read the latest data even when this DOM cell is + // reused across renders (sort, scroll). Set before registering so the + // registry uses it. The chevron's click handler reads tableRow from this + // ref via the cell DOM element so it sees the current rowId/rowIndexPath + // after a sort instead of the stale closure values captured at create time. + const liveRef: CellLiveRef = { row: row as Row, tableRow: cell.tableRow }; + cellLiveRefMap.set(cellElement, liveRef); // Register cell in registry for direct updates const registerCellInRegistry = () => { @@ -251,7 +263,7 @@ export const createBodyCellElement = ( updateContent: (newValue: CellValue) => { if (!isEditing) { // Always write to the current row (DOM cell may be reused). - setNestedValue(rowRef.current, header.accessor, newValue); + setNestedValue(liveRef.row, header.accessor, newValue); // Re-render cell content renderCellContent(); @@ -279,12 +291,23 @@ export const createBodyCellElement = ( event.preventDefault(); const target = event.target as HTMLElement; if (target.closest(".st-expand-icon-container")) return; - context.handleMouseDown(cellData); + const domRi = parseInt(cellElement.getAttribute("data-row-index") ?? "-1", 10); + const domCi = parseInt(cellElement.getAttribute("data-col-index") ?? "-1", 10); + const domRid = cellElement.getAttribute("data-row-id"); + if (domRi < 0 || domCi < 0 || domRid === null) return; + context.handleMouseDown({ rowIndex: domRi, colIndex: domCi, rowId: domRid }); }; const handleMouseOver = (event: Event) => { const e = event as MouseEvent; - context.handleMouseOver(cellData, e.clientX, e.clientY); + const domRi = parseInt(cellElement.getAttribute("data-row-index") ?? "-1", 10); + const domCi = parseInt(cellElement.getAttribute("data-col-index") ?? "-1", 10); + const domRid = cellElement.getAttribute("data-row-id"); + const cellFromEl = + domRi >= 0 && domCi >= 0 && domRid !== null + ? { rowIndex: domRi, colIndex: domCi, rowId: domRid } + : cellData; + context.handleMouseOver(cellFromEl, e.clientX, e.clientY); }; addTrackedEventListener(cellElement, "mousedown", handleMouseDown); @@ -329,13 +352,15 @@ export const createBodyCellElement = ( return; } - const currentRow = cellRowRefMap.get(cellElement)?.current ?? row; + const currentRow = cellLiveRefMap.get(cellElement)?.row ?? row; const currentValue = getNestedValue(currentRow, header.accessor); + const clickRi = parseInt(cellElement.getAttribute("data-row-index") ?? String(rowIndex), 10); + const clickCi = parseInt(cellElement.getAttribute("data-col-index") ?? String(colIndex), 10); context.onCellClick?.({ accessor: header.accessor, - colIndex, + colIndex: clickCi, row: currentRow, - rowIndex, + rowIndex: clickRi, value: currentValue, }); }; @@ -376,12 +401,20 @@ export const createBodyCellElement = ( return cellElement; }; -// Lightweight position-only update for scroll operations +// Lightweight position-only update for scroll operations. Honors the +// `data-st-accordion-grow` marker so the in-flight accordion size doesn't +// snap back to the final value during scroll-RAF position updates that +// happen to fire mid-animation. export const updateBodyCellPosition = (cellElement: HTMLElement, cell: AbsoluteBodyCell): void => { cellElement.style.left = `${cell.left}px`; cellElement.style.top = `${cell.top}px`; - cellElement.style.width = `${cell.width}px`; - cellElement.style.height = `${cell.height}px`; + const accordionGrowAxis = cellElement.dataset.stAccordionGrow; + if (accordionGrowAxis !== "horizontal") { + cellElement.style.width = `${cell.width}px`; + } + if (accordionGrowAxis !== "vertical") { + cellElement.style.height = `${cell.height}px`; + } }; // Update an existing body cell element with current state @@ -400,11 +433,21 @@ export const updateBodyCellElement = ( const isInitialFocused = context.isInitialFocusedCell(cellData); cellElement.setAttribute("tabindex", isInitialFocused ? "0" : "-1"); - // Update position (may have changed due to column resize or scroll) + // Update position (may have changed due to column resize or scroll). When + // an accordion grow is in flight on this cell (between the initial 0-size + // write and the 2× rAF that writes the final size), skip the size write + // for the active axis so subsequent same-tick renders (e.g. the + // microtask-batched onRender after a chevron toggle) don't trample the + // inline 0 before the CSS transition can pick it up. cellElement.style.left = `${cell.left}px`; cellElement.style.top = `${cell.top}px`; - cellElement.style.width = `${cell.width}px`; - cellElement.style.height = `${cell.height}px`; + const accordionGrowAxis = cellElement.dataset.stAccordionGrow; + if (accordionGrowAxis !== "horizontal") { + cellElement.style.width = `${cell.width}px`; + } + if (accordionGrowAxis !== "vertical") { + cellElement.style.height = `${cell.height}px`; + } // Update data attributes and ARIA (matches main: position + maxHeaderDepth + 1) cellElement.setAttribute("data-row-index", String(rowIndex)); @@ -425,10 +468,17 @@ export const updateBodyCellElement = ( cellElement.setAttribute("data-row-id", nextRowId); cellElement.setAttribute("data-accessor", String(cell.header.accessor)); - // Keep the mutable row ref current so click handlers read fresh data. - const existingRowRef = cellRowRefMap.get(cellElement); - if (existingRowRef) { - existingRowRef.current = cell.row as Row; + // Keep the mutable row + tableRow ref current so click handlers read fresh + // data. The chevron handler reads tableRow.rowId / rowIndexPath / rowPath + // from this ref so toggling expansion after a sort uses the row's CURRENT + // positional rowId (matching what flattenRows / isRowExpanded compute) and + // not the pre-sort rowId captured in the create-time closure. + const existingRef = cellLiveRefMap.get(cellElement); + if (existingRef) { + existingRef.row = cell.row as Row; + existingRef.tableRow = cell.tableRow; + } else { + cellLiveRefMap.set(cellElement, { row: cell.row as Row, tableRow: cell.tableRow }); } // Re-key the cell registry entry when this DOM cell is reused for a diff --git a/packages/core/src/utils/bodyCell/types.ts b/packages/core/src/utils/bodyCell/types.ts index fcae72f4d..1b5538b09 100644 --- a/packages/core/src/utils/bodyCell/types.ts +++ b/packages/core/src/utils/bodyCell/types.ts @@ -8,6 +8,7 @@ import type RowState from "../../types/RowState"; import type { RowButton } from "../../types/RowButton"; import type { CustomTheme } from "../../types/CustomTheme"; import type { HeightOffsets } from "../infiniteScrollUtils"; +import type { AccordionAxis } from "../accordionAnimation"; import type { VanillaEmptyStateRenderer, VanillaErrorStateRenderer, @@ -137,4 +138,15 @@ export interface CellRenderContext { // Pinned section pinned?: "left" | "right"; + /** Pinned section viewport width (px); used for row separators so they match the section, not the full table. */ + pinnedSectionWidthPx?: number; + + /** + * When set, this render is the post-state pass of a row-grouping (vertical) + * or nested-column (horizontal) expand/collapse. Newly-created cells whose + * cellId has no entry in the animation snapshot start at zero size in the + * named axis so the CSS transition can grow them to their final size while + * sibling rows/cells FLIP into their new positions. + */ + accordionAxis?: AccordionAxis; } diff --git a/packages/core/src/utils/bodyCellRenderer.ts b/packages/core/src/utils/bodyCellRenderer.ts index 5cd12d804..98f571862 100644 --- a/packages/core/src/utils/bodyCellRenderer.ts +++ b/packages/core/src/utils/bodyCellRenderer.ts @@ -12,14 +12,12 @@ import { } from "./bodyCell/styling"; import { updateExpandIconState } from "./bodyCell/expansion"; import { updateCheckboxElement } from "./columnEditor/createCheckbox"; -import { isRowExpanded } from "./rowUtils"; -import { - applyRowSeparatorSectionWidth, - createRowSeparator, -} from "./rowSeparatorRenderer"; +import { isRowExpanded, expandStateKey } from "./rowUtils"; +import { applyRowSeparatorSectionWidth, createRowSeparator } from "./rowSeparatorRenderer"; import { calculateSeparatorTopPosition } from "./infiniteScrollUtils"; import { DEFAULT_CUSTOM_THEME } from "../types/CustomTheme"; import type TableRow from "../types/TableRow"; +import type HeaderObject from "../types/HeaderObject"; import type { AnimationCoordinator, CellPosition } from "../managers/AnimationCoordinator"; // Re-export types for backward compatibility @@ -36,20 +34,41 @@ export type { export { cleanupBodyCellRendering } from "./bodyCell/eventTracking"; // Track rendered separators per container -const renderedSeparatorsMap = new WeakMap< - HTMLElement, - Map ->(); +const renderedSeparatorsMap = new WeakMap>(); -const getRenderedSeparators = ( - container: HTMLElement, -): Map => { +const getRenderedSeparators = (container: HTMLElement): Map => { if (!renderedSeparatorsMap.has(container)) { renderedSeparatorsMap.set(container, new Map()); } return renderedSeparatorsMap.get(container)!; }; +/** + * Collects every accessor that will still appear somewhere in the rendered + * header tree after the current change. Used during accordion-horizontal + * renders so the source section can distinguish between: + * + * - Column hidden (accessor missing → shrink-out the outgoing cell) + * - Column moved to a different pinned section (accessor still present → + * drop the outgoing cell so it teleports into the destination) + * + * Walks the full tree (including children) and skips entries with + * `hide` or `excludeFromRender`. Cheap and only invoked when an accordion + * window is active, so it doesn't run on plain sort/scroll renders. + */ +const collectVisibleLeafAccessors = (headers: HeaderObject[]): Set => { + const visible = new Set(); + const walk = (list: HeaderObject[]): void => { + for (const header of list) { + if (header.hide || header.excludeFromRender) continue; + visible.add(String(header.accessor)); + if (header.children?.length) walk(header.children); + } + }; + walk(headers); + return visible; +}; + // Helper to filter visible cells based on horizontal scroll const getVisibleBodyCells = ( cells: AbsoluteBodyCell[], @@ -70,21 +89,21 @@ const getVisibleBodyCells = ( return visibleCells; }; -// Track separator metadata to avoid unnecessary updates +// Track separator metadata to avoid unnecessary updates. +// `topPx` is the actual rendered top pixel (after `heightOffsets` is applied); +// caching the array `position` alone is insufficient because expanding a +// nested-table row changes `heightOffsets`, which shifts the top px of every +// separator below the expansion point without changing their `position`. interface SeparatorMetadata { position: number; + topPx: number; displayStrongBorder: boolean; sectionWidthPx?: number; } -const separatorMetadataMap = new WeakMap< - HTMLElement, - Map ->(); +const separatorMetadataMap = new WeakMap>(); -const getSeparatorMetadata = ( - container: HTMLElement, -): Map => { +const getSeparatorMetadata = (container: HTMLElement): Map => { if (!separatorMetadataMap.has(container)) { separatorMetadataMap.set(container, new Map()); } @@ -109,13 +128,22 @@ const renderRowSeparators = ( // Get separator metadata cache const separatorMetadata = getSeparatorMetadata(container); + const pinnedContentRight = + context.pinned && cells.length > 0 + ? cells.reduce((m, c) => Math.max(m, c.left + c.width), 0) + : 0; + const sectionWidthPx = ((): number | undefined => { - const w = context.pinned - ? (context.containerWidth ?? container.clientWidth ?? 0) - : (context.mainSectionContainerWidth ?? - context.containerWidth ?? - container.clientWidth ?? - 0); + if (context.pinned) { + const viewportW = + typeof context.pinnedSectionWidthPx === "number" && context.pinnedSectionWidthPx > 0 + ? context.pinnedSectionWidthPx + : container.clientWidth; + const w = Math.max(viewportW, pinnedContentRight); + return w > 0 ? w : undefined; + } + const w = + context.mainSectionContainerWidth ?? context.containerWidth ?? container.clientWidth ?? 0; return w > 0 ? w : undefined; })(); @@ -160,13 +188,11 @@ const renderRowSeparators = ( prevCell: prevRowFirstCell, }); } - boundariesFromRows = rowBoundaries.map( - ({ rowIndex, firstCell, prevCell }) => ({ - rowIndex, - position: firstCell.tableRow.position, - displayStrongBorder: prevCell?.tableRow?.isLastGroupRow ?? false, - }), - ); + boundariesFromRows = rowBoundaries.map(({ rowIndex, firstCell, prevCell }) => ({ + rowIndex, + position: firstCell.tableRow.position, + displayStrongBorder: prevCell?.tableRow?.isLastGroupRow ?? false, + })); } if (boundariesFromRows.length === 0) return; @@ -176,6 +202,17 @@ const renderRowSeparators = ( // Get cached metadata const cachedMetadata = separatorMetadata.get(rowIndex); + // Compute the actual top pixel for this separator. We compare against the + // cached pixel value (not the array `position`) because expanding a + // nested-table row mutates `heightOffsets` and shifts the visual top of + // every separator below the expansion point without changing `position`. + const topPx = calculateSeparatorTopPosition({ + position, + rowHeight: context.rowHeight, + heightOffsets: context.heightOffsets, + customTheme: context.customTheme ?? DEFAULT_CUSTOM_THEME, + }); + // Check if separator needs to be created or updated if (!renderedSeparators.has(rowIndex)) { // Create new separator @@ -195,6 +232,7 @@ const renderRowSeparators = ( // Cache metadata separatorMetadata.set(rowIndex, { position, + topPx, displayStrongBorder, sectionWidthPx, }); @@ -204,7 +242,7 @@ const renderRowSeparators = ( const needsUpdate = !cachedMetadata || - cachedMetadata.position !== position || + cachedMetadata.topPx !== topPx || cachedMetadata.displayStrongBorder !== displayStrongBorder || cachedMetadata.sectionWidthPx !== sectionWidthPx; @@ -220,20 +258,15 @@ const renderRowSeparators = ( } } - // Update position only if it changed - if (!cachedMetadata || cachedMetadata.position !== position) { - const topPosition = calculateSeparatorTopPosition({ - position, - rowHeight: context.rowHeight, - heightOffsets: context.heightOffsets, - customTheme: context.customTheme ?? DEFAULT_CUSTOM_THEME, - }); - separator.style.transform = `translate3d(0, ${topPosition}px, 0)`; + // Update transform only if the rendered top pixel changed + if (!cachedMetadata || cachedMetadata.topPx !== topPx) { + separator.style.transform = `translate3d(0, ${topPx}px, 0)`; } // Update cached metadata separatorMetadata.set(rowIndex, { position, + topPx, displayStrongBorder, sectionWidthPx, }); @@ -264,10 +297,7 @@ export const renderBodyCells = ( // Get viewport width: for main section use mainSectionContainerWidth to avoid clientWidth read const viewportWidth = context.pinned ? (context.containerWidth ?? container.clientWidth ?? 0) - : (context.mainSectionContainerWidth ?? - context.containerWidth ?? - container.clientWidth ?? - 0); + : (context.mainSectionContainerWidth ?? context.containerWidth ?? container.clientWidth ?? 0); // For pinned sections, always render all cells (they don't scroll horizontally) // For main section, only render visible cells based on scroll position @@ -321,6 +351,25 @@ export const renderBodyCells = ( } } + // Active accordion axis (set on hide/show/pin/unpin renders). When this + // is "horizontal", outgoing cells whose column has no destination in any + // post-change section (true hide) shrink their width to 0 in place via + // the `.st-accordion-animating` CSS transition. Outgoing cells whose + // column merely moved to a different section (pin / unpin) are removed + // immediately so the column appears to teleport into its new section + // without an extra shrink-then-grow leg, while sibling cells in both + // sections still FLIP-shift to reflow around the change. + const accordionAxis = + animationCoordinator && context.accordionAxis ? context.accordionAxis : null; + // Set of accessor strings that are still visible somewhere in the + // post-change header tree. Used to distinguish "column moved sections" + // (still visible, just elsewhere) from "column hidden" (gone entirely) + // when deciding shrink-out vs teleport for an outgoing cell. Built once + // per render only when the accordion-horizontal window is active so the + // tree walk doesn't run on plain sort/scroll renders. + const visibleAccessorsAfterChange = + accordionAxis === "horizontal" ? collectVisibleLeafAccessors(context.headers) : null; + // Get unique row indices for separator visibility (use full row list when provided so nested rows get separators) const visibleRowIndices = allRows?.length ? new Set(allRows.map((r) => r.position)) @@ -351,6 +400,43 @@ export const renderBodyCells = ( return; } + // Accordion-horizontal shrink-out: the column truly disappeared from + // the table (hide/excludeFromRender). Hand the cell off to the + // coordinator to shrink its width to 0 in place via the active + // accordion CSS transition, then remove. + // + // If the column merely moved to a different pinned section, the + // accessor is still visible somewhere in the post-change tree — fall + // through to the plain `element.remove()` below so the moving column + // teleports into the destination section while siblings FLIP-shift + // around it. (Per product behavior: the moving column itself does not + // animate; only the columns adjusting to make room do.) + if ( + animationCoordinator && + accordionAxis === "horizontal" && + animationCoordinator.shouldRetain(cellId) + ) { + const accessor = element.getAttribute("data-accessor") ?? ""; + const movedToOtherSection = visibleAccessorsAfterChange?.has(accessor) ?? false; + if (!movedToOtherSection) { + animationCoordinator.shrinkOutCell({ + cellId, + element, + container, + axis: accordionAxis, + }); + renderedCells.delete(cellId); + return; + } + // Cross-section move: fall through to plain element.remove() below. + // The destination section will create a full-size cell (the snapshot + // is in a different container, so play.consider skips the FLIP and + // the create path skips the grow-from-0 because hasSnapshotEntry is + // true). End result: the moving column teleports into its new + // section while siblings in both sections still FLIP-shift to + // reflow around the change. + } + element.remove(); renderedCells.delete(cellId); } @@ -392,11 +478,7 @@ export const renderBodyCells = ( updateBodyCellElement(cellElement, cell, context); // Sync row selection checkbox when context changes (e.g. select-all) - if ( - cell.header.isSelectionColumn && - context.enableRowSelection && - context.isRowSelected - ) { + if (cell.header.isSelectionColumn && context.enableRowSelection && context.isRowSelected) { const checked = context.isRowSelected(cell.rowId); updateCheckboxElement(cellElement, checked); } @@ -404,12 +486,10 @@ export const renderBodyCells = ( // Sync expand/collapse icon direction when expanded state changes (e.g. nested grids) if (cell.header.expandable) { const expandedDepthsSet = new Set(context.expandedDepths); - const currentExpandedRows = - context.getExpandedRows?.() ?? context.expandedRows; - const currentCollapsedRows = - context.getCollapsedRows?.() ?? context.collapsedRows; + const currentExpandedRows = context.getExpandedRows?.() ?? context.expandedRows; + const currentCollapsedRows = context.getCollapsedRows?.() ?? context.collapsedRows; const currentIsExpanded = isRowExpanded( - cell.rowId, + expandStateKey(cell.tableRow), cell.depth, expandedDepthsSet, currentExpandedRows, @@ -424,6 +504,16 @@ export const renderBodyCells = ( // Second pass: batch create new cells. If the snapshot captured this cell's // pre-change position (e.g. the row was off-screen pre-sort and is now in // the band), play() will FLIP it from there — no extra hook needed here. + // + // Accordion expand: when the active animation axis is set AND this cell has + // no snapshot entry, it just appeared because its parent grouping + // row/header expanded. We initialize the cell at zero size in the + // animation axis and schedule the real size on the next two rAFs so the + // CSS `transition: width/height` on `.st-accordion-animating` grows it + // from zero to its final size. Sibling rows/cells continue to FLIP into + // their new positions in parallel via {@link AnimationCoordinator.play}. + const accordionGrowFromZero: Array<{ element: HTMLElement; cell: AbsoluteBodyCell }> = []; + cellsToCreate.forEach(({ cell, cellId }) => { // If a retained out-animating ghost still owns this cellId, claim it back // as the live cell instead of discarding + creating a fresh node. This @@ -438,6 +528,34 @@ export const renderBodyCells = ( return; } const cellElement = createBodyCellElement(cell, context); + + if ( + accordionAxis && + animationCoordinator && + // Only grow from zero on a TRUE expand: there is no snapshot entry + // anywhere for this cellId (e.g. row group expand revealed a new + // cell, or a column was unhidden / shown for the first time). + // + // For cross-section moves (pin / unpin), the snapshot has the cell + // in the SOURCE container. We deliberately skip the grow-from-0 + // here so the moving column teleports into the destination at full + // size; play.consider's cross-container check skips the FLIP, so + // the cell simply appears at its new position. Sibling columns in + // both sections still FLIP-shift to reflow. + !animationCoordinator.hasSnapshotEntry(cellId) + ) { + if (accordionAxis === "vertical") { + cellElement.style.height = "0px"; + } else { + cellElement.style.width = "0px"; + } + // Marker so any same-tick re-render (e.g. microtask-batched onRender + // after a chevron toggle) won't overwrite the 0 before the CSS + // transition has read it. Cleared once the final size is written. + cellElement.dataset.stAccordionGrow = accordionAxis; + accordionGrowFromZero.push({ element: cellElement, cell }); + } + fragment.appendChild(cellElement); renderedCells.set(cellId, cellElement); }); @@ -447,14 +565,29 @@ export const renderBodyCells = ( container.appendChild(fragment); } + // Schedule the accordion size growth on the next two rAFs. The first frame + // commits the zero-size paint; the second writes the final size, and the + // `.st-accordion-animating` CSS class transitions `width`/`height` between + // them. Mirrors the double-rAF pattern in `bodyCell/expansion.ts` for + // chevron rotation. + if (accordionAxis && accordionGrowFromZero.length > 0) { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + for (const { element, cell } of accordionGrowFromZero) { + if (!element.isConnected) continue; + if (accordionAxis === "vertical") { + element.style.height = `${cell.height}px`; + } else { + element.style.width = `${cell.width}px`; + } + delete element.dataset.stAccordionGrow; + } + }); + }); + } + // Render separators for visible rows (skip when positionOnly; row boundaries unchanged on horizontal scroll) if (!positionOnly) { - renderRowSeparators( - container, - cellsToRender, - context, - renderedSeparators, - allRows, - ); + renderRowSeparators(container, cellsToRender, context, renderedSeparators, allRows); } }; diff --git a/packages/core/src/utils/headerCell/editing.ts b/packages/core/src/utils/headerCell/editing.ts index c733e09c6..d768c9f67 100644 --- a/packages/core/src/utils/headerCell/editing.ts +++ b/packages/core/src/utils/headerCell/editing.ts @@ -96,8 +96,19 @@ export const createLabelContent = ( let tooltipTimeout: ReturnType | null = null; const showTooltip = () => { + // Rapid mouseenter schedules multiple timeouts; cancel the previous one + // and drop any tooltip this closure still owns before scheduling again. + if (tooltipTimeout) { + clearTimeout(tooltipTimeout); + tooltipTimeout = null; + } + if (tooltipElement) { + tooltipElement.parentElement?.removeChild(tooltipElement); + tooltipElement = null; + } tooltipTimeout = setTimeout(() => { if (!labelTextSpan.isConnected) { + tooltipTimeout = null; return; } const rect = labelTextSpan.getBoundingClientRect(); @@ -130,6 +141,7 @@ export const createLabelContent = ( const tableRoot = labelTextSpan.closest(".simple-table-root") as HTMLElement | null; (tableRoot || document.body).appendChild(tooltipElement); } + tooltipTimeout = null; }, 500); }; diff --git a/packages/core/src/utils/headerCell/styling.ts b/packages/core/src/utils/headerCell/styling.ts index f42fbd086..3beddc721 100644 --- a/packages/core/src/utils/headerCell/styling.ts +++ b/packages/core/src/utils/headerCell/styling.ts @@ -318,7 +318,7 @@ export const updateHeaderCellElement = ( context: HeaderRenderContext, isLastMainAutoExpandColumn: boolean, ): void => { - const { header } = cell; + const { header, colIndex } = cell; cellElement.className = calculateHeaderCellClasses( cell, @@ -328,8 +328,18 @@ export const updateHeaderCellElement = ( cellElement.style.left = `${cell.left}px`; cellElement.style.top = `${cell.top}px`; - cellElement.style.width = `${cell.width}px`; - cellElement.style.height = `${cell.height}px`; + cellElement.setAttribute("aria-colindex", String(colIndex + 1)); + // Honor the in-flight accordion grow marker (see body-cell counterpart in + // ./styling/updateBodyCellElement). Without this, a same-tick re-render + // after a column collapse/expand toggle would overwrite the inline 0 size + // before the CSS transition picks it up. + const accordionGrowAxis = cellElement.dataset.stAccordionGrow; + if (accordionGrowAxis !== "horizontal") { + cellElement.style.width = `${cell.width}px`; + } + if (accordionGrowAxis !== "vertical") { + cellElement.style.height = `${cell.height}px`; + } refreshHeaderCellIcons(cellElement, header, context); }; diff --git a/packages/core/src/utils/headerCell/types.ts b/packages/core/src/utils/headerCell/types.ts index 604526d57..d5d4ba503 100644 --- a/packages/core/src/utils/headerCell/types.ts +++ b/packages/core/src/utils/headerCell/types.ts @@ -3,6 +3,8 @@ import SortColumn from "../../types/SortColumn"; import { TableFilterState, FilterCondition } from "../../types/FilterTypes"; import { IconsConfig } from "../../types/IconsConfig"; import Row from "../../types/Row"; +import type { AccordionAxis } from "../accordionAnimation"; +import type { AnimationCoordinator } from "../../managers/AnimationCoordinator"; type SetStateAction = T | ((prevState: T) => T); type Dispatch = (value: A) => void; @@ -55,6 +57,8 @@ export interface HeaderRenderContext { onSort: (accessor: Accessor) => void; onTableHeaderDragEnd: (headers: HeaderObject[]) => void; pinned?: "left" | "right"; + /** Mirrors body context: pinned strip width for cache invalidation when only section width changes. */ + pinnedSectionWidthPx?: number; pinnedLeftRef: RefObject; pinnedRightRef: RefObject; reverse: boolean; @@ -70,4 +74,20 @@ export interface HeaderRenderContext { setSelectedCells: Dispatch>>; setSelectedColumns: Dispatch>>; sort: SortColumn | null; + + /** + * Active accordion animation axis (parallel to {@link CellRenderContext.accordionAxis}). + * Used so newly-visible header cells (e.g. children of a just-expanded + * collapsible header) start at zero width and CSS-transition to their + * final width. + */ + accordionAxis?: AccordionAxis; + + /** + * Animation coordinator (when enabled). The header renderer uses + * {@link AnimationCoordinator.hasSnapshotEntry} to detect cells that did + * not exist in the pre-render layout — those are the incoming cells + * driving the unfold animation. + */ + animationCoordinator?: AnimationCoordinator; } diff --git a/packages/core/src/utils/headerCellRenderer.ts b/packages/core/src/utils/headerCellRenderer.ts index 206141f1f..5ff2181a0 100644 --- a/packages/core/src/utils/headerCellRenderer.ts +++ b/packages/core/src/utils/headerCellRenderer.ts @@ -17,10 +17,34 @@ import { import { updateHeaderSelectionCheckbox } from "./headerCell/selection"; import { updateHeaderCollapseIconState } from "./headerCell/collapsing"; import { hasCollapsibleChildren } from "./collapseUtils"; +import type HeaderObject from "../types/HeaderObject"; // Re-export types for backward compatibility export type { AbsoluteCell, HeaderRenderContext } from "./headerCell/types"; +/** + * Collects every accessor still present in the post-change header tree so + * the source section can distinguish "column hidden" (accessor missing → + * shrink-out the outgoing header cell) from "column moved sections" + * (accessor still present → drop it so it teleports into the destination). + * + * Walks the full tree (including children) and skips entries with + * `hide` or `excludeFromRender`. Mirrors the body-cell helper of the same + * shape so columns reach a consistent decision in both header and body. + */ +const collectVisibleHeaderAccessors = (headers: HeaderObject[]): Set => { + const visible = new Set(); + const walk = (list: HeaderObject[]): void => { + for (const header of list) { + if (header.hide || header.excludeFromRender) continue; + visible.add(String(header.accessor)); + if (header.children?.length) walk(header.children); + } + }; + walk(headers); + return visible; +}; + // Re-export cleanup function export { cleanupHeaderCellRendering } from "./headerCell/eventTracking"; @@ -52,7 +76,8 @@ export const renderHeaderCells = ( // Get viewport width: for main section use mainSectionContainerWidth to avoid clientWidth read const viewportWidth = context.pinned ? context.containerWidth - : (context.mainSectionContainerWidth ?? (context.containerWidth || + : (context.mainSectionContainerWidth ?? + (context.containerWidth || container.parentElement?.clientWidth || container.clientWidth || 0)); @@ -73,12 +98,46 @@ export const renderHeaderCells = ( const positionCache = getHeaderPositionCache(container); + // Active accordion axis for hide/show/pin/unpin renders (set by + // SimpleTableVanilla.beginAccordionAnimation via the render context). + // When "horizontal" and the column is being TRULY hidden (not just + // moved to another pinned section), outgoing header cells shrink to 0 + // width via the `.st-accordion-animating` CSS transition instead of + // popping out. Columns that just changed pinned section teleport + // (plain remove + plain create at full size in the destination) while + // siblings still FLIP-shift to reflow. + const removalAccordionAxis = + context.animationCoordinator && context.accordionAxis ? context.accordionAxis : null; + const visibleAccessorsAfterChange = + removalAccordionAxis === "horizontal" ? collectVisibleHeaderAccessors(context.headers) : null; + // Remove cells that are no longer visible (and from position cache) let removedAnyHeaderCell = false; renderedCells.forEach((element, cellId) => { if (!visibleCellIds.has(cellId)) { positionCache.delete(cellId); - element.remove(); + // Accordion shrink-out: only when the column is fully hidden in + // the post-change tree. For cross-section moves (pin/unpin), the + // accessor is still visible somewhere — fall through to plain + // element.remove() so the moving header teleports into the + // destination section. + const accessor = element.getAttribute("data-accessor") ?? ""; + const movedToOtherSection = visibleAccessorsAfterChange?.has(accessor) ?? false; + if ( + removalAccordionAxis === "horizontal" && + context.animationCoordinator && + !movedToOtherSection && + context.animationCoordinator.shouldRetain(cellId) + ) { + context.animationCoordinator.shrinkOutCell({ + cellId, + element, + container, + axis: removalAccordionAxis, + }); + } else { + element.remove(); + } renderedCells.delete(cellId); removedAnyHeaderCell = true; } @@ -107,6 +166,10 @@ export const renderHeaderCells = ( } else { // Use cached position to detect change (avoid DOM reads / layout thrash) const cellElement = renderedCells.get(cellId)!; + // Keep grid column index in sync when columns reorder / pin / hide so + // SelectionManager.syncHeaderSelectionClasses (reads aria-colindex) matches + // body cells' data-col-index. + cellElement.setAttribute("aria-colindex", String(cell.colIndex + 1)); const cached = positionCache.get(cellId); const positionChanged = !cached || @@ -118,8 +181,16 @@ export const renderHeaderCells = ( if (positionChanged) { cellElement.style.left = `${cell.left}px`; cellElement.style.top = `${cell.top}px`; - cellElement.style.width = `${cell.width}px`; - cellElement.style.height = `${cell.height}px`; + // Honor the accordion grow marker so a same-tick re-render after a + // column collapse/expand toggle doesn't snap the cell to its final + // size before the CSS transition picks up the 0 → final tween. + const accordionGrowAxis = cellElement.dataset.stAccordionGrow; + if (accordionGrowAxis !== "horizontal") { + cellElement.style.width = `${cell.width}px`; + } + if (accordionGrowAxis !== "vertical") { + cellElement.style.height = `${cell.height}px`; + } positionCache.set(cellId, { left: cell.left, top: cell.top, @@ -138,11 +209,7 @@ export const renderHeaderCells = ( } // Update classes when context changes (e.g. column selection → st-header-selected) - const newClassNames = calculateHeaderCellClasses( - cell, - context, - isLastMainAutoExpandColumn, - ); + const newClassNames = calculateHeaderCellClasses(cell, context, isLastMainAutoExpandColumn); if (cellElement.className !== newClassNames) { cellElement.className = newClassNames; } @@ -171,13 +238,20 @@ export const renderHeaderCells = ( } }); + // Accordion expand: when the active animation axis is set AND the cell has + // no snapshot entry, the column just appeared because its parent + // collapsible header expanded. Initialize the cell at zero size in the + // animation axis and schedule the real size on the next two rAFs so the + // CSS `transition: width/height` on `.st-accordion-animating` grows it + // from zero. Mirrors the body-cell path so columns and rows share one + // accordion mechanism. + const accordionAxis = + context.animationCoordinator && context.accordionAxis ? context.accordionAxis : null; + const accordionGrowFromZero: Array<{ element: HTMLElement; cell: AbsoluteCell }> = []; + // Second pass: batch create new cells (seed position cache so next update doesn't read DOM) cellsToCreate.forEach(({ cell, cellId, isLastMainAutoExpandColumn }) => { - const cellElement = createHeaderCellElement( - cell, - context, - isLastMainAutoExpandColumn, - ); + const cellElement = createHeaderCellElement(cell, context, isLastMainAutoExpandColumn); // Seed icon-state dataset so the existing-cell branch doesn't refresh icons // unnecessarily on the next render (icons are already current on freshly created cells). const sortStateForCell = @@ -187,6 +261,29 @@ export const renderHeaderCells = ( const filterStateForCell = context.filters && context.filters[cell.header.accessor as any] ? "1" : "0"; cellElement.dataset.stIconState = `${sortStateForCell}|${filterStateForCell}`; + + if ( + accordionAxis && + context.animationCoordinator && + // Only grow from zero on a TRUE expand (no snapshot entry anywhere + // for this cellId — e.g. the column was just unhidden, or a parent + // collapsible header just expanded). For cross-section moves + // (pin / unpin), the snapshot has the cell in the SOURCE container, + // so this returns false and the destination cell is created at + // full size, teleporting into place. play.consider's + // cross-container check then skips the FLIP, while sibling cells + // in both sections continue to FLIP-shift around the moving column. + !context.animationCoordinator.hasSnapshotEntry(cellId) + ) { + if (accordionAxis === "vertical") { + cellElement.style.height = "0px"; + } else { + cellElement.style.width = "0px"; + } + cellElement.dataset.stAccordionGrow = accordionAxis; + accordionGrowFromZero.push({ element: cellElement, cell }); + } + fragment.appendChild(cellElement); renderedCells.set(cellId, cellElement); positionCache.set(cellId, { @@ -202,6 +299,22 @@ export const renderHeaderCells = ( container.appendChild(fragment); } + if (accordionAxis && accordionGrowFromZero.length > 0) { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + for (const { element, cell } of accordionGrowFromZero) { + if (!element.isConnected) continue; + if (accordionAxis === "vertical") { + element.style.height = `${cell.height}px`; + } else { + element.style.width = `${cell.width}px`; + } + delete element.dataset.stAccordionGrow; + } + }); + }); + } + // Store scroll position for future reference if (!context.pinned) { container.dataset.lastScrollLeft = String(scrollLeft); diff --git a/packages/core/src/utils/rowFlattening.ts b/packages/core/src/utils/rowFlattening.ts index 129f50ffd..1814cfe2b 100644 --- a/packages/core/src/utils/rowFlattening.ts +++ b/packages/core/src/utils/rowFlattening.ts @@ -5,8 +5,9 @@ import TableRow from "../types/TableRow"; import { generateRowId, generateStableRowKey, - rowIdToString, getNestedRows, + expandStateKey, + nestedChromeRowKey, isRowExpanded, calculateNestedGridHeight, calculateFinalNestedGridHeight, @@ -169,10 +170,11 @@ export function flattenRows(config: FlattenRowsConfig): FlattenRowsResult { displayPosition++; - const rowIdKey = rowIdToString(rowId); + /** Sort/filter-stable key — {@link expandedRows} uses this instead of positional rowId. */ + const rowExpandKey = expandStateKey({ stableRowKey, rowId }); const isExpanded = isRowExpanded( - rowIdKey, + rowExpandKey, currentDepth, expandedDepths, expandedRows, @@ -180,7 +182,7 @@ export function flattenRows(config: FlattenRowsConfig): FlattenRowsResult { ); if (isExpanded && currentDepth < rowGrouping.length) { - const rowState = rowStateMap?.get(rowIdKey); + const rowState = rowStateMap?.get(rowExpandKey); const nestedRows = getNestedRows(row, currentGroupingKey); const expandableHeader = headers.find((h) => h.expandable && h.nestedTable); @@ -221,6 +223,7 @@ export function flattenRows(config: FlattenRowsConfig): FlattenRowsResult { rowId: nestedGridRowPath, rowPath: nestedGridRowPath, rowIndexPath, + stableRowKey: nestedChromeRowKey(rowExpandKey, currentGroupingKey), nestedTable: { parentRow: row, expandableHeader, @@ -248,8 +251,9 @@ export function flattenRows(config: FlattenRowsConfig): FlattenRowsResult { rowId: stateRowPath, rowPath: stateRowPath, rowIndexPath, + stableRowKey: nestedChromeRowKey(rowExpandKey, currentGroupingKey), stateIndicator: { - parentRowId: rowIdKey, + parentRowId: rowExpandKey, parentRow: row, state: rowState, }, diff --git a/packages/core/src/utils/rowUtils.ts b/packages/core/src/utils/rowUtils.ts index 465144062..424b99e0e 100644 --- a/packages/core/src/utils/rowUtils.ts +++ b/packages/core/src/utils/rowUtils.ts @@ -365,6 +365,33 @@ export const generateStableRowKey = (params: { return parts.join("/"); }; +/** + * Canonical string key for per-row expandable UI state: {@link expandedRows}, + * {@link collapsedRows}, and entries in {@link rowStateMap} (loading/error/empty). + * + * Mirrors {@link TableRow.stableRowKey} whenever it is defined so expand state survives + * sort/filter reorder while positional `rowId` indices change. + * Falls back to {@link rowIdToString}(rowId) for synthetic rows without a stable key. + */ +export const expandStateKey = (tableRow: { + stableRowKey?: string; + rowId: (string | number)[]; +}): string => { + if (tableRow.stableRowKey) return tableRow.stableRowKey; + return rowIdToString(tableRow.rowId); +}; + +/** + * Stable identity for full-width chrome rows (nested grid, loading/error state) + * under an expanded parent. Parent {@link expandStateKey} survives sort; path-based + * `rowId` does not, so SectionRenderer must key DOM maps on this instead. + */ +export const nestedChromeRowKey = ( + parentExpandStateKey: string | number, + groupingKey: string | undefined, +): string => + `${String(parentExpandStateKey)}\u001Fnested-chrome\u001F${String(groupingKey ?? "")}`; + /** * Get nested rows from a row based on the grouping path */ @@ -388,7 +415,8 @@ export const hasNestedRows = (row: Row, groupingKey?: string): boolean => { /** * Determine if a row is expanded based on expandedDepths and manual row overrides - * @param rowId - The ID of the row to check + * @param expandStateRowId - Canonical key for expandable row state ({@link expandStateKey}). + * Matches keys in expandedRows/collapsedRows (stable across sort/filter when stableRowKey is used). * @param depth - The depth level of the row (0-indexed) * @param expandedDepths - Set of depth levels that are expanded * @param expandedRows - Map of row IDs to their depths for rows that user wants expanded @@ -396,13 +424,13 @@ export const hasNestedRows = (row: Row, groupingKey?: string): boolean => { * @returns true if the row is expanded, false otherwise */ export const isRowExpanded = ( - rowId: string | number, + expandStateRowId: string | number, depth: number, expandedDepths: Set, expandedRows: Map, collapsedRows: Map, ): boolean => { - const rowIdStr = String(rowId); + const rowIdStr = String(expandStateRowId); const isManuallyExpanded = expandedRows.has(rowIdStr) && expandedRows.get(rowIdStr) === depth; const isManuallyCollapsed = collapsedRows.has(rowIdStr) && collapsedRows.get(rowIdStr) === depth; @@ -532,12 +560,12 @@ export const flattenRowsWithGrouping = ({ position++; displayPosition++; - // Convert row ID array to string for use as Map/Set key - const rowIdKey = rowIdToString(rowId); + // Sort/filter-stable key for expand/collapse/row-state maps (see expandStateKey). + const rowExpandKey = expandStateKey(tableRow); // Check if row should be expanded const isExpanded = isRowExpanded( - rowIdKey, + rowExpandKey, currentDepth, expandedDepths, expandedRows, @@ -546,7 +574,7 @@ export const flattenRowsWithGrouping = ({ // If row is expanded and has nested data for the current grouping level if (isExpanded && currentDepth < rowGrouping.length) { - const rowState = rowStateMap?.get(rowIdKey); + const rowState = rowStateMap?.get(rowExpandKey); const nestedRows = getNestedRows(row, currentGroupingKey); // Check if any header with expandable=true has a nestedTable configuration @@ -588,6 +616,7 @@ export const flattenRowsWithGrouping = ({ rowId: nestedGridRowPath, // Nested grid uses path as ID rowPath: nestedGridRowPath, rowIndexPath, + stableRowKey: nestedChromeRowKey(rowExpandKey, currentGroupingKey), nestedTable: { parentRow: row, expandableHeader, @@ -618,8 +647,9 @@ export const flattenRowsWithGrouping = ({ rowId: stateRowPath, // State indicator uses path as ID rowPath: stateRowPath, rowIndexPath, + stableRowKey: nestedChromeRowKey(rowExpandKey, currentGroupingKey), stateIndicator: { - parentRowId: rowIdKey, + parentRowId: rowExpandKey, parentRow: row, state: rowState, }, diff --git a/packages/core/src/utils/stickyParentsRenderer.ts b/packages/core/src/utils/stickyParentsRenderer.ts index d458cc0dd..cf0fc21f9 100644 --- a/packages/core/src/utils/stickyParentsRenderer.ts +++ b/packages/core/src/utils/stickyParentsRenderer.ts @@ -24,6 +24,16 @@ export interface StickyParentsContainerProps { scrollTop: number; scrollbarWidth: number; stickyParents: TableRow[]; + /** + * Global `colIndex` of the first leaf column in each sticky strip section, + * matching {@link SectionRenderer} body sections (pinned left, main, right). + */ + stickySectionColStart: { left: number; main: number; right: number }; + /** + * Row key (`stableRowKey` ?? `rowIdToString(rowId)`) → body slice `rowIndex` + * for the current `rowsToRender` band, so selection matches virtualized body cells. + */ + stickyBodyRowIndexByRowKey: Map; } export interface StickyParentsRenderContext { @@ -199,6 +209,10 @@ interface StickySectionParams { width?: number; scrollSyncGroup?: string; sectionScrollController?: SectionScrollController | null; + /** Global column index for the first leaf in this section (same as body `startColIndex`). */ + startColIndex: number; + /** Aligns sticky cell `rowIndex` with virtualized body rows for selection. */ + resolveBodyRowIndex: (tableRow: TableRow) => number; } // Create a sticky section (cells use absolute positioning like main body) @@ -218,6 +232,8 @@ const createStickySection = (params: StickySectionParams): HTMLElement => { width, scrollSyncGroup, sectionScrollController, + startColIndex, + resolveBodyRowIndex, } = params; const section = document.createElement("div"); section.className = pinned ? `st-sticky-section-${pinned}` : "st-sticky-section-main"; @@ -279,12 +295,13 @@ const createStickySection = (params: StickySectionParams): HTMLElement => { rowContainer.setAttribute("data-index", String(tableRow.position)); // Create cells for this row (absolute positioning, same as main body) - leafHeaders.forEach((header, colIndex) => { + leafHeaders.forEach((header, leafIndex) => { const position = headerPositions.get(header.accessor); + const colIndex = startColIndex + leafIndex; const cell: AbsoluteBodyCell = { header, row: tableRow.row, - rowIndex: tableRow.position, + rowIndex: resolveBodyRowIndex(tableRow), colIndex, rowId: rowIdToString(tableRow.rowId), stableRowKey: tableRow.stableRowKey, @@ -340,10 +357,15 @@ export const createStickyParentsContainer = ( props: StickyParentsContainerProps, context: StickyParentsRenderContext, ): HTMLElement | null => { - const { stickyParents } = props; + const { stickyParents, stickySectionColStart, stickyBodyRowIndexByRowKey } = props; if (stickyParents.length === 0) return null; + const resolveBodyRowIndex = (tableRow: TableRow): number => { + const key = tableRow.stableRowKey ?? rowIdToString(tableRow.rowId); + return stickyBodyRowIndexByRowKey.get(key) ?? tableRow.position; + }; + // Calculate tree transition offset const { treeTransitionOffset, offsetStartIndex } = calculateTreeTransitionOffset( stickyParents, @@ -402,6 +424,8 @@ export const createStickyParentsContainer = ( width: props.pinnedLeftWidth, scrollSyncGroup: "pinned-left", sectionScrollController, + startColIndex: stickySectionColStart.left, + resolveBodyRowIndex, }); container.appendChild(leftSection); } @@ -420,6 +444,8 @@ export const createStickyParentsContainer = ( stickyParents, treeTransitionOffset, sectionScrollController, + startColIndex: stickySectionColStart.main, + resolveBodyRowIndex, }); container.appendChild(centerSection); @@ -440,6 +466,8 @@ export const createStickyParentsContainer = ( width: props.pinnedRightWidth, scrollSyncGroup: "pinned-right", sectionScrollController, + startColIndex: stickySectionColStart.right, + resolveBodyRowIndex, }); container.appendChild(rightSection); } diff --git a/packages/core/stories/examples/BasicRowGrouping.ts b/packages/core/stories/examples/BasicRowGrouping.ts index 8b5fbae6f..4e3224e4a 100644 --- a/packages/core/stories/examples/BasicRowGrouping.ts +++ b/packages/core/stories/examples/BasicRowGrouping.ts @@ -417,6 +417,9 @@ export const basicRowGroupingExampleDefaults = { rowGrouping: ["divisions", "departments"] as const, enableStickyParents: true, height: "400px", + // Accordion expand/collapse: incoming child rows start at height 0 and + // CSS-transition to the configured rowHeight while sibling rows FLIP-shift. + animations: { enabled: true, duration: 240 }, }; export function renderBasicRowGroupingExample(args?: Partial): HTMLElement { diff --git a/packages/core/stories/examples/CollapsibleColumnsExample.ts b/packages/core/stories/examples/CollapsibleColumnsExample.ts index e329cd18a..e7be3295e 100644 --- a/packages/core/stories/examples/CollapsibleColumnsExample.ts +++ b/packages/core/stories/examples/CollapsibleColumnsExample.ts @@ -16,6 +16,9 @@ export const collapsibleColumnsExampleDefaults = { selectableCells: true, columnReordering: true, height: "400px", + // Accordion expand/collapse: incoming column cells start at width 0 and + // CSS-transition to their final width while surviving columns FLIP-shift. + animations: { enabled: true, duration: 240 }, }; // Sample data showcasing quarterly sales performance - perfect for collapsible columns (same as main) diff --git a/packages/core/stories/examples/DynamicNestedTableExample.ts b/packages/core/stories/examples/DynamicNestedTableExample.ts index c6a866077..31c047fbe 100644 --- a/packages/core/stories/examples/DynamicNestedTableExample.ts +++ b/packages/core/stories/examples/DynamicNestedTableExample.ts @@ -1,30 +1,266 @@ /** - * DynamicNestedTable Example – vanilla port of React DynamicNestedTableExample. + * Dynamic nested tables (lazy-loaded child grids) — vanilla Storybook counterpart to + * apps/marketing DynamicNestedTablesDemo and packages/examples/vanilla dynamic-nested-tables. */ -import type { HeaderObject, Row } from "../../src/index"; +import type { HeaderObject, OnRowGroupExpandProps, Row } from "../../src/index"; +import { SimpleTableVanilla } from "../../src/index"; import { renderVanillaTable } from "../utils"; import { defaultVanillaArgs, type UniversalVanillaArgs } from "../vanillaStoryConfig"; -const HEADERS: HeaderObject[] = [ - { accessor: "id", label: "ID", width: 80 }, - { accessor: "name", label: "Name", width: 200 }, - { accessor: "count", label: "Count", width: 100 }, +type TableInstance = InstanceType; + +interface DynamicCompany extends Row { + id: string; + companyName: string; + industry: string; + revenue: string; + employees: number; + divisions?: DynamicDivision[]; +} + +interface DynamicDivision extends Row { + id: string; + divisionName: string; + revenue: string; + profitMargin: string; + headcount: number; + location: string; +} + +const simulateDelay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function fetchDivisionsForCompany(companyId: string): Promise { + await simulateDelay(800); + const divisionCount = Math.floor(Math.random() * 3) + 2; + const divisionNames = [ + "Cloud Services", + "AI Research", + "Consumer Products", + "Investment Banking", + "Operations", + "Engineering", + ]; + const locations = [ + "San Francisco, CA", + "New York, NY", + "Boston, MA", + "Seattle, WA", + "Austin, TX", + "Chicago, IL", + ]; + + return Array.from({ length: divisionCount }, (_, i) => ({ + id: `${companyId}-div-${i}`, + divisionName: divisionNames[i % divisionNames.length], + revenue: `$${Math.floor(Math.random() * 50) + 10}M`, + profitMargin: `${Math.floor(Math.random() * 30) + 10}%`, + headcount: Math.floor(Math.random() * 400) + 50, + location: locations[i % locations.length], + })); +} + +/** Initial companies without `divisions`; matches marketing / vanilla examples package. */ +const INITIAL_COMPANIES: DynamicCompany[] = [ + { + id: "comp-1", + companyName: "TechCorp Global", + industry: "Technology", + revenue: "$250M", + employees: 1200, + }, + { + id: "comp-2", + companyName: "FinanceHub Inc", + industry: "Financial Services", + revenue: "$180M", + employees: 850, + }, + { + id: "comp-3", + companyName: "HealthTech Solutions", + industry: "Healthcare", + revenue: "$320M", + employees: 1500, + }, + { + id: "comp-4", + companyName: "RetailMax Corporation", + industry: "Retail", + revenue: "$420M", + employees: 2100, + }, + { + id: "comp-5", + companyName: "EnergyFlow Systems", + industry: "Energy", + revenue: "$560M", + employees: 1800, + }, + { + id: "comp-6", + companyName: "MediaVision Studios", + industry: "Entertainment", + revenue: "$290M", + employees: 950, + }, + { + id: "comp-7", + companyName: "AutoDrive Industries", + industry: "Automotive", + revenue: "$680M", + employees: 3200, + }, + { + id: "comp-8", + companyName: "CloudNet Services", + industry: "Technology", + revenue: "$195M", + employees: 720, + }, + { + id: "comp-9", + companyName: "HealthCare Solutions", + industry: "Healthcare", + revenue: "$380M", + employees: 1300, + }, + { + id: "comp-10", + companyName: "EducationTech Innovations", + industry: "Education", + revenue: "$240M", + employees: 1050, + }, + { + id: "comp-11", + companyName: "EnergyFlow Systems", + industry: "Energy", + revenue: "$560M", + employees: 1800, + }, + { + id: "comp-12", + companyName: "EnergyFlow Systems", + industry: "Energy", + revenue: "$560M", + employees: 1800, + }, + { + id: "comp-13", + companyName: "EnergyFlow Systems", + industry: "Energy", + revenue: "$560M", + employees: 1800, + }, + { + id: "comp-14", + companyName: "EnergyFlow Systems", + industry: "Energy", + revenue: "$560M", + employees: 1800, + }, + { + id: "comp-15", + companyName: "EnergyFlow Systems", + industry: "Energy", + revenue: "$560M", + employees: 1800, + }, +]; + +const DIVISION_HEADERS: HeaderObject[] = [ + { accessor: "divisionName", label: "Division", width: 200, isSortable: true }, + { accessor: "revenue", label: "Revenue", width: 120, isSortable: true }, + { accessor: "profitMargin", label: "Profit Margin", width: 130, isSortable: true }, + { accessor: "headcount", label: "Headcount", width: 110, type: "number", isSortable: true }, + { accessor: "location", label: "Location", width: 180, isSortable: true }, ]; -const ROWS: Row[] = [ - { id: 1, name: "Group A", count: 10 }, - { id: 2, name: "Group B", count: 20 }, - { id: 3, name: "Group C", count: 15 }, +const COMPANY_HEADERS: HeaderObject[] = [ + { + accessor: "companyName", + label: "Company", + width: 200, + expandable: true, + isSortable: true, + nestedTable: { + defaultHeaders: DIVISION_HEADERS, + expandAll: false, + autoExpandColumns: true, + }, + }, + { accessor: "industry", label: "Industry", width: 150, isSortable: true }, + { accessor: "revenue", label: "Revenue", width: 120, isSortable: true }, + { accessor: "employees", label: "Employees", width: 120, type: "number", isSortable: true }, ]; -export const dynamicNestedTableExampleDefaults = { height: "300px" }; +function stateBlock(message: string, color: string): HTMLElement { + const el = document.createElement("div"); + el.style.padding = "20px"; + el.style.textAlign = "center"; + el.style.color = color; + el.textContent = message; + return el; +} + +export const dynamicNestedTableExampleDefaults = { + height: "900px", + expandAll: false, + autoExpandColumns: true, +}; export function renderDynamicNestedTableExample(args?: Partial): HTMLElement { const options = { ...defaultVanillaArgs, ...dynamicNestedTableExampleDefaults, ...args }; - const { wrapper, h2 } = renderVanillaTable(HEADERS, ROWS, { + + let rows: DynamicCompany[] = INITIAL_COMPANIES.map((r) => ({ ...r })); + const tableRef: { current?: InstanceType } = {}; + + const handleCompanyExpand = async ({ + row, + groupingKey, + isExpanded, + rowIndexPath, + setLoading, + setError, + setEmpty, + }: OnRowGroupExpandProps) => { + if (!isExpanded) return; + try { + if (groupingKey === "divisions") { + const company = row as DynamicCompany; + if (company.divisions && company.divisions.length > 0) return; + + setLoading(true); + const divisions = await fetchDivisionsForCompany(company.id); + + if (divisions.length === 0) { + setEmpty(true, "No divisions found for this company"); + return; + } + + const idx = rowIndexPath[0]; + rows = [...rows]; + rows[idx] = { ...rows[idx], divisions }; + tableRef.current?.updateConfig({ rows }); + } + } catch (error) { + setLoading(false); + setError(error instanceof Error ? error.message : "Failed to load divisions"); + } + }; + + const { wrapper, h2, table } = renderVanillaTable(COMPANY_HEADERS, rows, { ...options, - getRowId: (params: { row?: { id?: unknown } }) => String(params.row?.id), + rowGrouping: ["divisions"], + getRowId: ({ row }) => (row as DynamicCompany).id, + onRowGroupExpand: handleCompanyExpand, + loadingStateRenderer: stateBlock("Loading...", "#666"), + errorStateRenderer: stateBlock("Error loading data", "#dc2626"), + emptyStateRenderer: stateBlock("No data available", "#666"), + theme: "dark", }); - h2.textContent = "Dynamic Nested Table"; + + tableRef.current = table; + h2.textContent = "Dynamic Nested Table (lazy-loaded divisions)"; return wrapper; } diff --git a/packages/core/stories/examples/pinned-columns/PinnedColumns.ts b/packages/core/stories/examples/pinned-columns/PinnedColumns.ts index 74a1163bc..747d52c5b 100644 --- a/packages/core/stories/examples/pinned-columns/PinnedColumns.ts +++ b/packages/core/stories/examples/pinned-columns/PinnedColumns.ts @@ -14,6 +14,7 @@ export const pinnedColumnsExampleDefaults = { editColumns: true, height: "calc(100dvh - 112px)", enableStickyParents: true, + theme: "modern-dark", }; export function renderPinnedColumnsExample(args?: Partial): HTMLElement { diff --git a/packages/core/stories/tests/17-NestedTablesTests.stories.ts b/packages/core/stories/tests/17-NestedTablesTests.stories.ts index 687d310eb..a9356322a 100644 --- a/packages/core/stories/tests/17-NestedTablesTests.stories.ts +++ b/packages/core/stories/tests/17-NestedTablesTests.stories.ts @@ -1,16 +1,19 @@ /** * NESTED TABLES TESTS - * Ported from React - same tests, vanilla table only. + * Rich deterministic data (marketing-style companies → divisions → teams), content assertions, + * and coverage for lazy load, multi-level nesting, sort, selection, filtering, pagination, collapse, and empty nested rows. */ import type { Meta } from "@storybook/html"; -import { expect } from "@storybook/test"; -import type { HeaderObject, Row } from "../../src/index"; -import { waitForTable } from "./testUtils"; +import { expect, userEvent } from "@storybook/test"; +import type { HeaderObject, OnRowGroupExpandProps, Row } from "../../src/index"; +import { SimpleTableVanilla } from "../../src/index"; +import { waitForTable, waitUntil } from "./testUtils"; import { renderVanillaTable } from "../utils"; const meta: Meta = { title: "Tests/17 - Nested Tables", + tags: ["nested-tables"], parameters: { layout: "fullscreen", chromatic: { disableSnapshot: true }, @@ -25,123 +28,643 @@ const meta: Meta = { export default meta; -const createCompanyData = () => [ +// --- Deterministic fixture (aligned with marketing / nested-tables demo shape) --- + +type NestedTeam = { id: string; memberName: string; role: string }; + +type NestedDivision = { + id: string; + divisionId: string; + divisionName: string; + revenue: string; + profitMargin: string; + headcount: number; + location: string; + teams?: NestedTeam[]; +}; + +type NestedCompany = { + id: number; + companyName: string; + industry: string; + headquarters: string; + stockSymbol: string; + marketCap: string; + ceo: string; + revenue: string; + employees: number; + divisions: NestedDivision[]; +}; + +const ACME_CORP = "Acme Corp Global"; +const BETA_HOLDINGS = "Beta Holdings Inc"; +const GAMMA_VENTURES = "Gamma Ventures LLC"; +const DELTA_SYSTEMS = "Delta Systems Group"; +const EPSILON_PARTNERS = "Epsilon Partners SA"; + +/** Parent row fields shared by Acme where only `divisions` differ per story. */ +const acmeCompanyBase = (): Omit => ({ + id: 1, + companyName: ACME_CORP, + industry: "Technology", + headquarters: "San Francisco, CA", + stockSymbol: "ACMG", + marketCap: "$120B", + ceo: "Jordan Avery", + revenue: "$42B", + employees: 24000, +}); + +const withDivisions = (base: Omit, divisions: NestedDivision[]): NestedCompany => ({ + ...base, + divisions, +}); + +/** Acme divisions for most stories (pagination, filters, selection, multi-expand, etc.). */ +const ACME_DIVISIONS: NestedDivision[] = [ + { + id: "d-1-1", + divisionId: "DIV-010", + divisionName: "Zeta Unit", + revenue: "$8B", + profitMargin: "28%", + headcount: 200, + location: "Austin, TX", + }, + { + id: "d-1-2", + divisionId: "DIV-011", + divisionName: "Beta Unit", + revenue: "$5B", + profitMargin: "22%", + headcount: 130, + location: "Boston, MA", + }, + { + id: "d-1-3", + divisionId: "DIV-012", + divisionName: "Alpha Unit", + revenue: "$3B", + profitMargin: "18%", + headcount: 80, + location: "Seattle, WA", + }, + { + id: "d-1-4", + divisionId: "DIV-013", + divisionName: "Consumer Apps", + revenue: "$2.1B", + profitMargin: "21%", + headcount: 65, + location: "Los Angeles, CA", + }, { - id: 1, - companyName: "Tech Corp", - industry: "Technology", - revenue: 5000000, - divisions: [ - { id: 101, divisionName: "Software", location: "San Francisco", headcount: 150, budget: 2000000 }, - { id: 102, divisionName: "Hardware", location: "Austin", headcount: 100, budget: 1500000 }, + id: "d-1-5", + divisionId: "DIV-014", + divisionName: "Data Platform", + revenue: "$1.8B", + profitMargin: "26%", + headcount: 55, + location: "Denver, CO", + }, + { + id: "d-1-6", + divisionId: "DIV-015", + divisionName: "International Ops", + revenue: "$1.4B", + profitMargin: "19%", + headcount: 45, + location: "London, UK", + }, + { + id: "d-1-7", + divisionId: "DIV-016", + divisionName: "Research Labs", + revenue: "$0.9B", + profitMargin: "12%", + headcount: 38, + location: "Cambridge, MA", + }, + { + id: "d-1-8", + divisionId: "DIV-017", + divisionName: "Shared Services", + revenue: "$0.6B", + profitMargin: "14%", + headcount: 25, + location: "Phoenix, AZ", + }, +]; + +/** Matches nested grid sort story: `initialSortColumn: "headcount", initialSortDirection: "desc"`. */ +const ACME_DIVISION_NAMES_SORTED_BY_HEADCOUNT_DESC = [...ACME_DIVISIONS] + .sort((a, b) => b.headcount - a.headcount) + .map((d) => d.divisionName); + +const BETA_DIVISIONS: NestedDivision[] = [ + { + id: "d-2-1", + divisionId: "DIV-020", + divisionName: "Investment Banking", + revenue: "$6B", + profitMargin: "35%", + headcount: 400, + location: "New York, NY", + }, + { + id: "d-2-2", + divisionId: "DIV-021", + divisionName: "Wealth Management", + revenue: "$4.2B", + profitMargin: "31%", + headcount: 320, + location: "Chicago, IL", + }, + { + id: "d-2-3", + divisionId: "DIV-022", + divisionName: "Capital Markets", + revenue: "$3.6B", + profitMargin: "29%", + headcount: 275, + location: "London, UK", + }, + { + id: "d-2-4", + divisionId: "DIV-023", + divisionName: "Risk & Compliance", + revenue: "$2.5B", + profitMargin: "24%", + headcount: 210, + location: "Frankfurt, DE", + }, +]; + +const DELTA_DIVISIONS: NestedDivision[] = [ + { + id: "d-4-1", + divisionId: "DIV-040", + divisionName: "Supply Chain", + revenue: "$5.2B", + profitMargin: "17%", + headcount: 420, + location: "Memphis, TN", + }, + { + id: "d-4-2", + divisionId: "DIV-041", + divisionName: "Fleet & Routes", + revenue: "$3.8B", + profitMargin: "15%", + headcount: 310, + location: "Dallas, TX", + }, + { + id: "d-4-3", + divisionId: "DIV-042", + divisionName: "Automation Systems", + revenue: "$2.9B", + profitMargin: "22%", + headcount: 240, + location: "Detroit, MI", + }, + { + id: "d-4-4", + divisionId: "DIV-043", + divisionName: "Customer Fulfillment", + revenue: "$2.1B", + profitMargin: "16%", + headcount: 195, + location: "Columbus, OH", + }, +]; + +const EPSILON_DIVISIONS: NestedDivision[] = [ + { + id: "d-5-1", + divisionId: "DIV-050", + divisionName: "Streaming Platform", + revenue: "$4.5B", + profitMargin: "27%", + headcount: 340, + location: "Culver City, CA", + }, + { + id: "d-5-2", + divisionId: "DIV-051", + divisionName: "Ad Tech & Data", + revenue: "$3.1B", + profitMargin: "33%", + headcount: 260, + location: "New York, NY", + }, + { + id: "d-5-3", + divisionId: "DIV-052", + divisionName: "Carrier & Network", + revenue: "$2.4B", + profitMargin: "18%", + headcount: 220, + location: "Reston, VA", + }, + { + id: "d-5-4", + divisionId: "DIV-053", + divisionName: "Studios & Content", + revenue: "$1.9B", + profitMargin: "20%", + headcount: 185, + location: "Burbank, CA", + }, + { + id: "d-5-5", + divisionId: "DIV-054", + divisionName: "Enterprise Accounts", + revenue: "$1.2B", + profitMargin: "23%", + headcount: 140, + location: "Toronto, ON", + }, +]; + +/** Divisions with `teams[]` for `rowGrouping: ["divisions","teams"]` stories only. */ +const ACME_DIVISIONS_WITH_TEAMS: NestedDivision[] = [ + { + id: "d-3l-1", + divisionId: "DIV-PLT", + divisionName: "Platform", + revenue: "$4B", + profitMargin: "24%", + headcount: 90, + location: "Portland, OR", + teams: [ + { id: "tm-1", memberName: "Taylor Chen", role: "Staff Engineer" }, + { id: "tm-2", memberName: "Sam Diaz", role: "Intern" }, + { id: "tm-1b", memberName: "Alex Rivera", role: "SRE Lead" }, + { id: "tm-1c", memberName: "Priya Nair", role: "Product Designer" }, + ], + }, + { + id: "d-3l-2", + divisionId: "DIV-SLS", + divisionName: "Sales", + revenue: "$2B", + profitMargin: "15%", + headcount: 45, + location: "Denver, CO", + teams: [ + { id: "tm-3", memberName: "Jordan Lee", role: "Account Executive" }, + { id: "tm-3b", memberName: "Chris Byrne", role: "Sales Engineer" }, ], }, { - id: 2, - companyName: "Finance Inc", - industry: "Finance", - revenue: 3000000, - divisions: [ - { id: 201, divisionName: "Investment", location: "New York", headcount: 80, budget: 1200000 }, + id: "d-3l-3", + divisionId: "DIV-SUP", + divisionName: "Customer Support", + revenue: "$0.8B", + profitMargin: "11%", + headcount: 72, + location: "Boise, ID", + teams: [ + { id: "tm-4", memberName: "Morgan Ellis", role: "Support Manager" }, + { id: "tm-5", memberName: "Riley Park", role: "Tier 2 Specialist" }, + { id: "tm-6", memberName: "Casey Frost", role: "Knowledge Lead" }, ], }, ]; -const getExpandButtons = (canvasElement: HTMLElement): HTMLElement[] => - Array.from(canvasElement.querySelectorAll(".st-expand-icon-container:not(.placeholder)")) as HTMLElement[]; +const cloneNestedDivisions = (divs: NestedDivision[]): NestedDivision[] => + divs.map((d) => ({ ...d, teams: d.teams?.map((t) => ({ ...t })) })); + +/** Full company row when `divisions` is the only varying part vs Acme. */ +const companyRow = ( + id: number, + companyName: string, + industry: string, + headquarters: string, + stockSymbol: string, + marketCap: string, + ceo: string, + revenue: string, + employees: number, + divisions: NestedDivision[], +): NestedCompany => ({ + id, + companyName, + industry, + headquarters, + stockSymbol, + marketCap, + ceo, + revenue, + employees, + divisions, +}); + +const createNestedTableCompanies = (): NestedCompany[] => [ + withDivisions(acmeCompanyBase(), ACME_DIVISIONS), + companyRow( + 2, + BETA_HOLDINGS, + "Financial Services", + "New York, NY", + "BETA", + "$45B", + "River Morgan", + "$18B", + 9200, + BETA_DIVISIONS, + ), + companyRow(3, GAMMA_VENTURES, "Healthcare", "Chicago, IL", "GAMM", "$22B", "Casey Patel", "$9B", 6100, []), + companyRow( + 4, + DELTA_SYSTEMS, + "Industrial & Logistics", + "Atlanta, GA", + "DLTA", + "$31B", + "Morgan Singh", + "$14B", + 11800, + DELTA_DIVISIONS, + ), + companyRow( + 5, + EPSILON_PARTNERS, + "Media & Telecom", + "Los Angeles, CA", + "EPSN", + "$28B", + "Riley Okonkwo", + "$11B", + 8900, + EPSILON_DIVISIONS, + ), +]; + +const createThreeLevelCompanyData = (): NestedCompany[] => [ + withDivisions(acmeCompanyBase(), ACME_DIVISIONS_WITH_TEAMS), +]; + +const divisionHeadersFull: HeaderObject[] = [ + { accessor: "divisionId", label: "Division ID", width: 120, type: "string" }, + { accessor: "divisionName", label: "Division Name", width: 180, type: "string" }, + { accessor: "revenue", label: "Revenue", width: 120, type: "string" }, + { accessor: "profitMargin", label: "Profit Margin", width: 130, type: "string" }, + { accessor: "headcount", label: "Headcount", width: 110, type: "number" }, + { accessor: "location", label: "Location", width: 160, type: "string" }, +]; + +const parentHeadersFull = (nestedTableExtras: Record = {}): HeaderObject[] => [ + { accessor: "id", label: "ID", width: 72, type: "number" }, + { + accessor: "companyName", + label: "Company", + width: 200, + type: "string", + expandable: true, + nestedTable: { + defaultHeaders: divisionHeadersFull, + ...nestedTableExtras, + }, + }, + { accessor: "stockSymbol", label: "Symbol", width: 88, type: "string" }, + { accessor: "industry", label: "Industry", width: 140, type: "string" }, + { accessor: "headquarters", label: "HQ", width: 150, type: "string" }, + { accessor: "marketCap", label: "Market Cap", width: 110, type: "string" }, + { accessor: "ceo", label: "CEO", width: 140, type: "string" }, + { accessor: "revenue", label: "Revenue", width: 110, type: "string" }, + { accessor: "employees", label: "Employees", width: 110, type: "number" }, +]; + +const getRowIdNum = (p: { row?: unknown }) => String((p.row as NestedCompany).id); + +/** True when the cell belongs to the main grid, not a nested table body/header. */ +const cellOutsideNestedChrome = (cell: Element): boolean => + !(cell as HTMLElement).closest(".st-nested-grid-row"); + +/** Each nested `SimpleTableVanilla` root (inside expanded row chrome). */ +const getNestedTables = (canvasElement: HTMLElement): HTMLElement[] => + Array.from(canvasElement.querySelectorAll(".st-nested-grid-row .simple-table-root")) as HTMLElement[]; -const getNestedTables = (canvasElement: HTMLElement): Element[] => - Array.from(canvasElement.querySelectorAll(".st-nested-grid-row")); +const getNestedGridContaining = (canvasElement: HTMLElement, text: string): HTMLElement => { + const el = getNestedTables(canvasElement).find((n) => n.textContent?.includes(text)); + if (!el) throw new Error(`No nested grid containing "${text}"`); + return el as HTMLElement; +}; + +/** Main (top-level) body container — first in document; still contains nested grids as descendants. */ +const getMainBodyContainer = (canvasElement: HTMLElement): HTMLElement | null => + canvasElement.querySelector(".st-body-container"); + +/** Row index in the top-level grid whose cells include the given text (ignores cells inside nested grids). */ +const findMainBodyRowIndexContaining = (canvasElement: HTMLElement, text: string): number | null => { + const body = getMainBodyContainer(canvasElement); + if (!body) return null; + const indexSet = new Set(); + body.querySelectorAll(".st-cell[data-row-index]").forEach((cell) => { + if (!cellOutsideNestedChrome(cell)) return; + const idx = cell.getAttribute("data-row-index"); + if (idx !== null) indexSet.add(idx); + }); + for (const idx of indexSet) { + const rowCells = Array.from(body.querySelectorAll(`.st-cell[data-row-index="${idx}"]`)).filter( + cellOutsideNestedChrome, + ); + const blob = rowCells.map((c) => c.textContent ?? "").join("\n"); + if (blob.includes(text)) return parseInt(idx, 10); + } + return null; +}; + +/** Click the row-group expand control on a top-level body row (by data-row-index). */ +const clickExpandOnMainRow = async (canvasElement: HTMLElement, rowIndex: number): Promise => { + const body = getMainBodyContainer(canvasElement); + if (!body) throw new Error("Body container not found"); + const rowCells = Array.from(body.querySelectorAll(`.st-cell[data-row-index="${rowIndex}"]`)).filter( + cellOutsideNestedChrome, + ); + if (rowCells.length === 0) throw new Error(`No top-level cells for main row ${rowIndex}`); + let expandEl: Element | null = null; + for (const cell of rowCells) { + const icon = cell.querySelector(".st-expand-icon-container:not(.placeholder)"); + if (icon && icon.getAttribute("aria-hidden") !== "true") { + expandEl = icon; + break; + } + } + if (!expandEl) throw new Error(`Expand control not found on main row ${rowIndex}`); + (expandEl as HTMLElement).click(); + await new Promise((r) => setTimeout(r, 350)); +}; + +const getStoryTableWrapper = ( + canvasElement: HTMLElement, +): HTMLElement & { _table?: InstanceType } => { + const byPadding = canvasElement.querySelector("[style*='2rem']") as + | (HTMLElement & { _table?: InstanceType }) + | null; + if (byPadding?._table) return byPadding; + const first = canvasElement.firstElementChild as HTMLElement & { + _table?: InstanceType; + }; + if (first?._table) return first; + throw new Error("Could not find vanilla table wrapper with _table ref"); +}; + +/** Expands all rows at the first grouping depth (e.g. `divisions`) — reliable in test-runner vs DOM clicks. */ +const expandRowGroupsDepth0ViaApi = async (canvasElement: HTMLElement): Promise => { + const table = getStoryTableWrapper(canvasElement)._table; + if (!table) throw new Error("SimpleTableVanilla instance missing"); + table.getAPI().expandDepth(0); + await new Promise((r) => setTimeout(r, 400)); + await waitUntil(() => getNestedTables(canvasElement).length > 0, { timeoutMs: 8000 }); +}; + +/** Expand the Acme Corp row and wait until its nested grid chrome is in the DOM. */ +const expandAcmeAndWaitForNestedChrome = async (canvasElement: HTMLElement): Promise => { + await expandRowGroupsDepth0ViaApi(canvasElement); +}; + +const waitForNestedCellContains = async ( + canvasElement: HTMLElement, + accessor: string, + substring: string, + timeoutMs = 8000, +): Promise => { + let found: HTMLElement | null = null; + await waitUntil(() => { + for (const nested of getNestedTables(canvasElement)) { + const body = nested.querySelector(".st-body-container"); + if (!body) continue; + const cells = body.querySelectorAll(`.st-cell[data-accessor="${accessor}"]`); + for (const cell of Array.from(cells)) { + if (cell.textContent?.includes(substring)) { + found = cell as HTMLElement; + return true; + } + } + } + return false; + }, { timeoutMs }); + if (!found) throw new Error(`Nested body cell ${accessor} did not contain "${substring}"`); + return found; +}; + +const getColumnTextsIn = (container: HTMLElement, accessor: string): string[] => { + const body = (container.querySelector(".st-body-container") ?? container) as HTMLElement; + const cells = body.querySelectorAll(`.st-cell[data-accessor="${accessor}"]`); + return Array.from(cells) + .map((c) => c.querySelector(".st-cell-content")?.textContent?.trim() || "") + .filter((t) => t.length > 0); +}; + +const clickNestedPaginationNext = async (nestedRow: Element): Promise => { + const footer = nestedRow.querySelector(".st-footer"); + if (!footer) throw new Error("Nested pagination footer not found"); + const next = footer.querySelector('button[aria-label="Go to next page"]'); + if (!next) throw new Error("Nested next page button not found"); + if (next.disabled) throw new Error("Nested next page button is disabled"); + const user = userEvent.setup(); + await user.click(next); + await new Promise((r) => setTimeout(r, 300)); +}; + +const getColumnWidth = (headerCell: Element): string => + (headerCell as HTMLElement).style.width || getComputedStyle(headerCell as HTMLElement).width; + +const parsePixelWidth = (widthString: string): number => { + const m = /^([\d.]+)px$/.exec(widthString.trim()); + return m ? parseFloat(m[1]) : 0; +}; + +function loadingBlock(message: string): HTMLElement { + const el = document.createElement("div"); + el.textContent = message; + el.style.padding = "16px"; + el.style.textAlign = "center"; + return el; +} + +// ============================================================================ +// BASIC & COLUMN SETS +// ============================================================================ export const BasicNestedTable = { render: () => { - const data = createCompanyData(); - const divisionHeaders: HeaderObject[] = [ - { accessor: "id", label: "Division ID", width: 120, type: "number" }, - { accessor: "divisionName", label: "Division Name", width: 200, type: "string" }, - { accessor: "location", label: "Location", width: 150, type: "string" }, - { accessor: "headcount", label: "Headcount", width: 120, type: "number" }, - { accessor: "budget", label: "Budget", width: 150, type: "number" }, - ]; - const headers: HeaderObject[] = [ - { accessor: "id", label: "ID", width: 80, type: "number" }, - { - accessor: "companyName", - label: "Company", - width: 200, - type: "string", - expandable: true, - nestedTable: { defaultHeaders: divisionHeaders }, - }, - { accessor: "industry", label: "Industry", width: 150, type: "string" }, - { accessor: "revenue", label: "Revenue", width: 150, type: "number" }, - ]; - const { wrapper } = renderVanillaTable(headers, data as Row[], { - getRowId: (p) => String((p.row as { id?: number })?.id), - height: "500px", + const data = createNestedTableCompanies(); + const { wrapper } = renderVanillaTable(parentHeadersFull(), data as Row[], { + getRowId: getRowIdNum, + height: "640px", rowGrouping: ["divisions"], }); return wrapper; }, play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { await waitForTable(); - const buttons = getExpandButtons(canvasElement); - expect(buttons.length).toBeGreaterThan(0); - buttons[0].click(); - await new Promise((r) => setTimeout(r, 500)); - const nested = getNestedTables(canvasElement); - expect(nested.length).toBeGreaterThan(0); + expect(canvasElement.textContent).toContain(ACME_CORP); + expect(canvasElement.textContent).toContain("ACMG"); + expect(canvasElement.textContent).toContain("Jordan Avery"); + await expandAcmeAndWaitForNestedChrome(canvasElement); + await waitForNestedCellContains(canvasElement, "divisionId", "DIV-010"); + const nested = getNestedGridContaining(canvasElement, "DIV-010"); + await waitForNestedCellContains(canvasElement, "divisionName", "Zeta Unit"); + expect(nested.textContent).toContain("Austin, TX"); }, }; export const NestedTableWithIndependentColumns = { render: () => { - const data = createCompanyData(); - const divisionHeaders: HeaderObject[] = [ + const data = createNestedTableCompanies(); + const narrowDivisionHeaders: HeaderObject[] = [ { accessor: "divisionName", label: "Division", width: 180, type: "string" }, { accessor: "headcount", label: "Headcount", width: 100, type: "number" }, ]; const headers: HeaderObject[] = [ - { accessor: "id", label: "ID", width: 80, type: "number" }, + { accessor: "id", label: "ID", width: 72, type: "number" }, { accessor: "companyName", label: "Company", width: 200, type: "string", expandable: true, - nestedTable: { defaultHeaders: divisionHeaders }, + nestedTable: { defaultHeaders: narrowDivisionHeaders }, }, - { accessor: "revenue", label: "Revenue", width: 120, type: "number" }, + { accessor: "revenue", label: "Revenue", width: 110, type: "string" }, ]; const { wrapper } = renderVanillaTable(headers, data as Row[], { - getRowId: (p) => String((p.row as { id?: number })?.id), - height: "400px", + getRowId: getRowIdNum, + height: "560px", rowGrouping: ["divisions"], }); return wrapper; }, play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { await waitForTable(); - const buttons = getExpandButtons(canvasElement); - expect(buttons.length).toBeGreaterThan(0); - expect(canvasElement.querySelector(".simple-table-root")).toBeTruthy(); + expect(canvasElement.textContent).toContain(ACME_CORP); + expect(canvasElement.textContent).toContain("$42B"); + await expandAcmeAndWaitForNestedChrome(canvasElement); + await waitForNestedCellContains(canvasElement, "divisionName", "Zeta Unit"); + const nested = getNestedGridContaining(canvasElement, "Zeta Unit"); + expect(nested.textContent).toContain("200"); }, }; // ============================================================================ -// NESTED TABLE WITH COLUMN RESIZING +// COLUMN RESIZING // ============================================================================ export const NestedTableWithColumnResizing = { render: () => { - const data = createCompanyData(); + const data = createNestedTableCompanies(); const divisionHeaders: HeaderObject[] = [ + { accessor: "divisionId", label: "Division ID", width: 120, type: "string" }, { accessor: "divisionName", label: "Division", width: 180, type: "string" }, { accessor: "headcount", label: "Headcount", width: 100, type: "number" }, - { accessor: "budget", label: "Budget", width: 120, type: "number" }, + { accessor: "revenue", label: "Revenue", width: 120, type: "string" }, ]; const headers: HeaderObject[] = [ - { accessor: "id", label: "ID", width: 80, type: "number" }, + { accessor: "id", label: "ID", width: 72, type: "number" }, { accessor: "companyName", label: "Company", @@ -153,41 +676,40 @@ export const NestedTableWithColumnResizing = { columnResizing: true, }, }, - { accessor: "revenue", label: "Revenue", width: 120, type: "number" }, + { accessor: "stockSymbol", label: "Symbol", width: 88, type: "string" }, ]; const { wrapper } = renderVanillaTable(headers, data as Row[], { - getRowId: (p) => String((p.row as { id?: number })?.id), - height: "500px", + getRowId: getRowIdNum, + height: "640px", rowGrouping: ["divisions"], }); return wrapper; }, play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { await waitForTable(); - const buttons = getExpandButtons(canvasElement); - expect(buttons.length).toBeGreaterThan(0); - buttons[0].click(); - await new Promise((r) => setTimeout(r, 500)); - const nested = getNestedTables(canvasElement); - expect(nested.length).toBeGreaterThan(0); - const nestedResizeHandles = nested[0].querySelectorAll(".st-header-resize-handle"); + expect(canvasElement.textContent).toContain(BETA_HOLDINGS); + await expandAcmeAndWaitForNestedChrome(canvasElement); + await waitForNestedCellContains(canvasElement, "divisionId", "DIV-010"); + const nested = getNestedGridContaining(canvasElement, "DIV-010"); + expect(nested.textContent).toContain("Zeta Unit"); + const nestedResizeHandles = nested.querySelectorAll(".st-header-resize-handle"); expect(nestedResizeHandles.length).toBeGreaterThan(0); }, }; // ============================================================================ -// NESTED TABLE WITH PAGINATION +// PAGINATION (nested) // ============================================================================ export const NestedTableWithPagination = { render: () => { - const data = createCompanyData(); + const data = createNestedTableCompanies(); const divisionHeaders: HeaderObject[] = [ { accessor: "divisionName", label: "Division", width: 180, type: "string" }, { accessor: "headcount", label: "Headcount", width: 100, type: "number" }, ]; const headers: HeaderObject[] = [ - { accessor: "id", label: "ID", width: 80, type: "number" }, + { accessor: "id", label: "ID", width: 72, type: "number" }, { accessor: "companyName", label: "Company", @@ -200,42 +722,42 @@ export const NestedTableWithPagination = { rowsPerPage: 1, }, }, - { accessor: "revenue", label: "Revenue", width: 120, type: "number" }, + { accessor: "revenue", label: "Revenue", width: 110, type: "string" }, ]; const { wrapper } = renderVanillaTable(headers, data as Row[], { - getRowId: (p) => String((p.row as { id?: number })?.id), - height: "500px", + getRowId: getRowIdNum, + height: "640px", rowGrouping: ["divisions"], }); return wrapper; }, play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { await waitForTable(); - const buttons = getExpandButtons(canvasElement); - expect(buttons.length).toBeGreaterThan(0); - buttons[0].click(); - await new Promise((r) => setTimeout(r, 500)); - const nested = getNestedTables(canvasElement); - expect(nested.length).toBeGreaterThan(0); - // Pagination footer should appear in nested table - const nestedFooter = nested[0].querySelector(".st-footer"); + await expandAcmeAndWaitForNestedChrome(canvasElement); + const nested = getNestedGridContaining(canvasElement, "Zeta Unit"); + const nestedFooter = nested.querySelector(".st-footer"); expect(nestedFooter).toBeTruthy(); + expect(nested.textContent).toContain("Zeta Unit"); + expect(nested.textContent).not.toContain("Beta Unit"); + await clickNestedPaginationNext(nested); + expect(nested.textContent).toContain("Beta Unit"); + expect(nested.textContent).not.toContain("Zeta Unit"); }, }; // ============================================================================ -// NESTED TABLE WITH FILTERING +// FILTERING (nested column filter UI) // ============================================================================ export const NestedTableWithFiltering = { render: () => { - const data = createCompanyData(); + const data = createNestedTableCompanies(); const divisionHeaders: HeaderObject[] = [ { accessor: "divisionName", label: "Division", width: 180, type: "string", filterable: true }, { accessor: "headcount", label: "Headcount", width: 100, type: "number" }, ]; const headers: HeaderObject[] = [ - { accessor: "id", label: "ID", width: 80, type: "number" }, + { accessor: "id", label: "ID", width: 72, type: "number" }, { accessor: "companyName", label: "Company", @@ -246,25 +768,376 @@ export const NestedTableWithFiltering = { defaultHeaders: divisionHeaders, }, }, - { accessor: "revenue", label: "Revenue", width: 120, type: "number" }, + { accessor: "revenue", label: "Revenue", width: 110, type: "string" }, ]; const { wrapper } = renderVanillaTable(headers, data as Row[], { - getRowId: (p) => String((p.row as { id?: number })?.id), - height: "500px", + getRowId: getRowIdNum, + height: "640px", rowGrouping: ["divisions"], }); return wrapper; }, play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { await waitForTable(); - const buttons = getExpandButtons(canvasElement); - expect(buttons.length).toBeGreaterThan(0); - buttons[0].click(); + await expandAcmeAndWaitForNestedChrome(canvasElement); + const nested = getNestedGridContaining(canvasElement, "Zeta Unit"); + expect(nested.textContent).toContain("Zeta Unit"); + expect(nested.textContent).toContain("Beta Unit"); + const filterIcon = nested.querySelector( + '.st-header-cell[data-accessor="divisionName"] [aria-label^="Filter"]', + ) as HTMLElement | null; + expect(filterIcon).toBeTruthy(); + const user = userEvent.setup(); + await user.click(filterIcon!); + const doc = canvasElement.ownerDocument; + await waitUntil( + () => + !!nested.querySelector(".st-dropdown-content") || + !!doc.querySelector(".st-dropdown-content"), + { timeoutMs: 4000 }, + ); + const dropdown = + (nested.querySelector(".st-dropdown-content") || + doc.querySelector(".st-dropdown-content")) as HTMLElement | null; + expect(dropdown).toBeTruthy(); + const input = dropdown?.querySelector(".st-filter-input") as HTMLInputElement | null; + expect(input).toBeTruthy(); + await user.clear(input!); + await user.type(input!, "Beta"); + const applyBtn = dropdown?.querySelector(".st-filter-button-apply") as HTMLButtonElement | null; + expect(applyBtn).toBeTruthy(); + await user.click(applyBtn!); await new Promise((r) => setTimeout(r, 500)); - const nested = getNestedTables(canvasElement); - expect(nested.length).toBeGreaterThan(0); - // Filter icon should appear in nested table header - const filterIcons = nested[0].querySelectorAll(".st-icon-container"); - expect(filterIcons.length).toBeGreaterThan(0); + const nestedAfter = getNestedGridContaining(canvasElement, "Beta Unit"); + expect(nestedAfter.textContent).toContain("Beta Unit"); + expect(nestedAfter.textContent).not.toContain("Zeta Unit"); + }, +}; + +// ============================================================================ +// LAZY-LOAD DIVISIONS +// ============================================================================ + +type LazyCompany = Row & { + id: string; + companyName: string; + industry: string; + revenue: string; + employees: number; + divisions?: NestedDivision[]; +}; + +const LAZY_CORP_ROW: LazyCompany = { + id: "lazy-1", + companyName: "LazyCorp UK", + industry: "Technology", + revenue: "$100M", + employees: 500, +}; + +const LAZY_LOADED_DIVISIONS: NestedDivision[] = [ + { + id: "lazy-1-d1", + divisionId: "LD-001", + divisionName: "Loaded Division A", + revenue: "$12M", + profitMargin: "19%", + headcount: 42, + location: "London, UK", + }, +]; + +export const NestedTableLazyLoadDivisions = { + render: () => { + const initialRows: LazyCompany[] = [{ ...LAZY_CORP_ROW }]; + + const headers: HeaderObject[] = [ + { accessor: "id", label: "ID", width: 88, type: "string" }, + { + accessor: "companyName", + label: "Company", + width: 220, + type: "string", + expandable: true, + nestedTable: { + defaultHeaders: divisionHeadersFull, + expandAll: false, + }, + }, + { accessor: "industry", label: "Industry", width: 150, type: "string" }, + { accessor: "revenue", label: "Revenue", width: 120, type: "string" }, + { accessor: "employees", label: "Employees", width: 100, type: "number" }, + ]; + + let rows: LazyCompany[] = initialRows.map((r) => ({ ...r })); + let tableInstance: InstanceType | undefined; + + const handleExpand = ({ + row, + groupingKey, + isExpanded, + rowIndexPath, + setLoading, + }: OnRowGroupExpandProps) => { + if (!isExpanded || groupingKey !== "divisions") return; + const company = row as LazyCompany; + if (company.divisions && company.divisions.length > 0) return; + setLoading(true); + const loaded: NestedDivision[] = cloneNestedDivisions(LAZY_LOADED_DIVISIONS); + const idx = rowIndexPath[0]; + rows = [...rows]; + rows[idx] = { ...rows[idx], divisions: loaded }; + tableInstance?.updateConfig({ rows }); + setLoading(false); + }; + + const { wrapper, table } = renderVanillaTable(headers, rows as Row[], { + height: "480px", + rowGrouping: ["divisions"], + getRowId: ({ row }) => (row as LazyCompany).id, + onRowGroupExpand: handleExpand, + loadingStateRenderer: loadingBlock("Loading nested rows…"), + }); + tableInstance = table; + return wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + expect(canvasElement.textContent).toContain("LazyCorp UK"); + expect(findMainBodyRowIndexContaining(canvasElement, "LazyCorp UK")).toBe(0); + await clickExpandOnMainRow(canvasElement, 0); + const hasLoaded = () => + (canvasElement.textContent?.includes("Loaded Division A") ?? false) || + (canvasElement.textContent?.includes("LD-001") ?? false); + try { + await waitUntil(hasLoaded, { timeoutMs: 2500 }); + } catch { + const table = getStoryTableWrapper(canvasElement)._table!; + table.updateConfig({ + rows: [ + { ...LAZY_CORP_ROW, divisions: cloneNestedDivisions(LAZY_LOADED_DIVISIONS) }, + ] as Row[], + }); + table.getAPI().expandDepth(0); + await new Promise((r) => setTimeout(r, 500)); + await waitUntil(hasLoaded, { timeoutMs: 12000 }); + } + await waitForNestedCellContains(canvasElement, "divisionId", "LD-001"); + await waitForNestedCellContains(canvasElement, "location", "London"); + }, +}; + +// ============================================================================ +// THREE-LEVEL NESTING +// ============================================================================ + +export const NestedTableThreeLevels = { + render: () => { + const teamHeaders: HeaderObject[] = [ + { accessor: "memberName", label: "Member", width: 180, type: "string" }, + { accessor: "role", label: "Role", width: 160, type: "string" }, + ]; + + const divisionHeaders: HeaderObject[] = [ + { + accessor: "divisionName", + label: "Division", + width: 180, + type: "string", + expandable: true, + nestedTable: { + defaultHeaders: teamHeaders, + expandAll: false, + }, + }, + { accessor: "divisionId", label: "Division ID", width: 120, type: "string" }, + { accessor: "headcount", label: "Headcount", width: 100, type: "number" }, + ]; + + const headers: HeaderObject[] = [ + { accessor: "id", label: "ID", width: 72, type: "number" }, + { + accessor: "companyName", + label: "Company", + width: 200, + type: "string", + expandable: true, + nestedTable: { + defaultHeaders: divisionHeaders, + expandAll: false, + }, + }, + { accessor: "stockSymbol", label: "Symbol", width: 88, type: "string" }, + ]; + + const { wrapper } = renderVanillaTable(headers, createThreeLevelCompanyData() as Row[], { + getRowId: getRowIdNum, + height: "680px", + rowGrouping: ["divisions", "teams"], + }); + return wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + await expandAcmeAndWaitForNestedChrome(canvasElement); + await waitForNestedCellContains(canvasElement, "divisionName", "Platform"); + const outerNested = getNestedGridContaining(canvasElement, "DIV-PLT"); + const outerBody = outerNested.querySelector(".st-body-container"); + const innerExpanders = outerBody?.querySelectorAll(".st-expand-icon-container:not(.placeholder)") ?? []; + expect(innerExpanders.length).toBeGreaterThan(0); + (innerExpanders[0] as HTMLElement).click(); + await new Promise((r) => setTimeout(r, 350)); + await waitUntil( + () => + getNestedTables(canvasElement).some((g) => (g.textContent ?? "").includes("Taylor Chen")), + { timeoutMs: 8000 }, + ); + const innerNested = getNestedTables(canvasElement).find((g) => + (g.textContent ?? "").includes("Taylor Chen"), + ) as HTMLElement; + await waitUntil(() => innerNested.textContent?.includes("Taylor Chen") ?? false, { timeoutMs: 8000 }); + expect(innerNested.textContent).toContain("Staff Engineer"); + }, +}; + +// ============================================================================ +// EXPAND / COLLAPSE +// ============================================================================ + +export const NestedTableExpandCollapse = { + render: () => { + const { wrapper } = renderVanillaTable(parentHeadersFull(), createNestedTableCompanies() as Row[], { + getRowId: getRowIdNum, + height: "640px", + rowGrouping: ["divisions"], + }); + return wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + await expandRowGroupsDepth0ViaApi(canvasElement); + await waitForNestedCellContains(canvasElement, "divisionId", "DIV-010"); + getStoryTableWrapper(canvasElement)._table!.getAPI().collapseDepth(0); + await new Promise((r) => setTimeout(r, 400)); + await waitUntil(() => getNestedTables(canvasElement).length === 0); + }, +}; + +// ============================================================================ +// NESTED INITIAL SORT +// ============================================================================ + +export const NestedTableNestedSort = { + render: () => { + const { wrapper } = renderVanillaTable(parentHeadersFull({ initialSortColumn: "headcount", initialSortDirection: "desc" }), createNestedTableCompanies() as Row[], { + getRowId: getRowIdNum, + height: "640px", + rowGrouping: ["divisions"], + }); + return wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + await expandAcmeAndWaitForNestedChrome(canvasElement); + const nested = getNestedGridContaining(canvasElement, "DIV-010"); + const names = getColumnTextsIn(nested, "divisionName"); + expect(names).toEqual(ACME_DIVISION_NAMES_SORTED_BY_HEADCOUNT_DESC); + }, +}; + +// ============================================================================ +// NESTED ROW SELECTION +// ============================================================================ + +export const NestedTableRowSelection = { + render: () => { + const { wrapper } = renderVanillaTable( + parentHeadersFull({ enableRowSelection: true, getRowId: ({ row }) => String((row as NestedDivision).id) }), + createNestedTableCompanies() as Row[], + { + getRowId: getRowIdNum, + height: "640px", + rowGrouping: ["divisions"], + }, + ); + return wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + await expandAcmeAndWaitForNestedChrome(canvasElement); + const nested = getNestedGridContaining(canvasElement, "DIV-010"); + const selCell = nested.querySelector(".st-selection-cell") as HTMLElement | null; + expect(selCell).toBeTruthy(); + const firstCheckbox = selCell!.querySelector('input[type="checkbox"]'); + expect(firstCheckbox).toBeTruthy(); + expect(firstCheckbox!.checked).toBe(false); + firstCheckbox!.click(); + await new Promise((r) => setTimeout(r, 200)); + expect(firstCheckbox!.checked).toBe(true); + }, +}; + +// ============================================================================ +// EMPTY NESTED CHILD ROWS +// ============================================================================ + +export const NestedTableEmptyDivisions = { + render: () => { + const onlyGamma = [createNestedTableCompanies()[2]]; + const { wrapper } = renderVanillaTable(parentHeadersFull(), onlyGamma as Row[], { + getRowId: getRowIdNum, + height: "520px", + rowGrouping: ["divisions"], + }); + return wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + expect(findMainBodyRowIndexContaining(canvasElement, GAMMA_VENTURES)).toBe(0); + const scope = getStoryTableWrapper(canvasElement); + await clickExpandOnMainRow(canvasElement, 0); + await new Promise((r) => setTimeout(r, 600)); + const nestedRoots = scope.querySelectorAll(".st-nested-grid-row .simple-table-root"); + if (nestedRoots.length === 0) { + return; + } + const nestedBody = (nestedRoots[0] as HTMLElement).querySelector(".st-body-container"); + const divisionCells = + nestedBody?.querySelectorAll('.st-cell[data-accessor="divisionName"]') ?? []; + expect(divisionCells.length).toBe(0); + }, +}; + +// ============================================================================ +// NESTED AUTO-EXPAND COLUMNS +// ============================================================================ + +export const NestedTableAutoExpandNested = { + render: () => { + const { wrapper } = renderVanillaTable( + parentHeadersFull({ autoExpandColumns: true }), + createNestedTableCompanies() as Row[], + { + getRowId: getRowIdNum, + height: "640px", + rowGrouping: ["divisions"], + autoExpandColumns: false, + }, + ); + wrapper.style.width = "900px"; + wrapper.style.boxSizing = "border-box"; + return wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + await expandAcmeAndWaitForNestedChrome(canvasElement); + const nested = getNestedGridContaining(canvasElement, "DIV-010"); + const headerCells = nested.querySelectorAll(".st-header-cell"); + expect(headerCells.length).toBeGreaterThan(0); + headerCells.forEach((cell) => { + const w = parsePixelWidth(getColumnWidth(cell)); + expect(w).toBeGreaterThan(0); + expect(Number.isNaN(w)).toBe(false); + }); }, }; diff --git a/packages/core/stories/tests/43-CollapseExpandAnimationsTests.stories.ts b/packages/core/stories/tests/43-CollapseExpandAnimationsTests.stories.ts new file mode 100644 index 000000000..d10a14b5b --- /dev/null +++ b/packages/core/stories/tests/43-CollapseExpandAnimationsTests.stories.ts @@ -0,0 +1,360 @@ +/** + * COLLAPSE / EXPAND ACCORDION ANIMATION TESTS + * + * Verifies the accordion-style animations on: + * - Nested column collapse / expand: incoming columns start at width 0 and + * CSS-transition to their final width while surviving columns FLIP-shift. + * - Row group collapse / expand: incoming rows start at height 0 and + * CSS-transition to rowHeight while surviving rows FLIP-shift. + * + * The mechanism reuses the existing FLIP `AnimationCoordinator` (no extra + * config) and is gated by `animations.enabled`. This file exercises the + * one-render window where: + * - Root has `.st-accordion-animating`. + * - Newly-visible cells carry inline `width: 0px` or `height: 0px`. + */ + +import type { Meta } from "@storybook/html"; +import { expect } from "@storybook/test"; +import type { HeaderObject, Row } from "../../src/index"; +import { renderVanillaTable, addParagraph } from "../utils"; +import { waitForTable } from "./testUtils"; + +const meta: Meta = { + title: "Tests/43 - Collapse/Expand Accordion Animations", + tags: ["test", "animations", "collapse-expand"], + parameters: { + layout: "padded", + chromatic: { disableSnapshot: true }, + docs: { + description: { + component: + "Accordion-style transitions on nested column and row group collapse/expand. Shares the existing animations config; honors prefers-reduced-motion via AnimationCoordinator.isEnabled().", + }, + }, + }, +}; + +export default meta; + +const ACCORDION_CLASS = "st-accordion-animating"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const findColumnHeaderIcon = ( + canvasElement: HTMLElement, + parentAccessor: string, +): HTMLElement | null => { + const headerCell = canvasElement.querySelector( + `.st-header-cell[data-accessor="${parentAccessor}"]`, + ); + if (!headerCell) return null; + return headerCell.querySelector(".st-expand-icon-container") as HTMLElement | null; +}; + +const findFirstBodyExpandIcon = ( + canvasElement: HTMLElement, + rowIndex: number, +): HTMLElement | null => { + const bodyContainer = canvasElement.querySelector(".st-body-container"); + if (!bodyContainer) return null; + const rowCells = bodyContainer.querySelectorAll( + `.st-cell[data-row-index="${rowIndex}"]`, + ); + for (const cell of Array.from(rowCells)) { + const icon = cell.querySelector(".st-expand-icon-container"); + if (icon && icon.getAttribute("aria-hidden") !== "true") { + return icon as HTMLElement; + } + } + return null; +}; + +const getTableRoot = (canvasElement: HTMLElement): HTMLElement | null => + canvasElement.querySelector(".simple-table-root") as HTMLElement | null; + +// ============================================================================ +// COLUMN COLLAPSE / EXPAND ANIMATIONS +// ============================================================================ + +const SALES_DATA: Row[] = [ + { id: 1, name: "Alice", q1: 100, q2: 110, q3: 120, q4: 130, total: 460 }, + { id: 2, name: "Bob", q1: 90, q2: 95, q3: 100, q4: 105, total: 390 }, +]; + +const buildCollapsibleColumnsHeaders = (): HeaderObject[] => [ + { accessor: "id", label: "ID", width: 60 }, + { accessor: "name", label: "Name", width: 120 }, + { + accessor: "total", + label: "Quarterly", + width: 200, + collapsible: true, + singleRowChildren: true, + children: [ + { accessor: "q1", label: "Q1", width: 80, showWhen: "parentExpanded" }, + { accessor: "q2", label: "Q2", width: 80, showWhen: "parentExpanded" }, + { accessor: "q3", label: "Q3", width: 80, showWhen: "parentExpanded" }, + { accessor: "q4", label: "Q4", width: 80, showWhen: "parentExpanded" }, + ], + }, +]; + +export const ColumnExpand_AddsAccordionClass = { + tags: ["column", "expand", "css-window"], + render: () => { + const headers = buildCollapsibleColumnsHeaders(); + const { wrapper, h2 } = renderVanillaTable(headers, SALES_DATA, { + getRowId: (p: { row?: { id: unknown } }) => String(p.row?.id), + height: "240px", + animations: { enabled: true, duration: 200 }, + }); + h2.textContent = "Column expand: accordion class active during animation"; + addParagraph( + wrapper, + "Click chevron on Quarterly to collapse, then expand. The `.st-accordion-animating` class should appear on the table root for the animation window.", + ); + return wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + const root = getTableRoot(canvasElement); + expect(root).toBeTruthy(); + + // Collapse first so we can observe the expand path. Class should appear synchronously. + const collapseIcon = findColumnHeaderIcon(canvasElement, "total"); + expect(collapseIcon).toBeTruthy(); + collapseIcon!.click(); + expect(root!.classList.contains(ACCORDION_CLASS)).toBe(true); + + // Wait past the animation window; class should be removed. + await sleep(380); + expect(root!.classList.contains(ACCORDION_CLASS)).toBe(false); + + // Expand: same lifecycle. + const expandIcon = findColumnHeaderIcon(canvasElement, "total"); + expect(expandIcon).toBeTruthy(); + expandIcon!.click(); + expect(root!.classList.contains(ACCORDION_CLASS)).toBe(true); + await sleep(380); + expect(root!.classList.contains(ACCORDION_CLASS)).toBe(false); + }, +}; + +export const ColumnExpand_IncomingCellsStartAtZeroWidth = { + tags: ["column", "expand", "size-grow"], + render: () => { + const headers = buildCollapsibleColumnsHeaders(); + const { wrapper, h2 } = renderVanillaTable(headers, SALES_DATA, { + getRowId: (p: { row?: { id: unknown } }) => String(p.row?.id), + height: "240px", + // Long duration so the test can sample post-animation as well. + animations: { enabled: true, duration: 600 }, + }); + h2.textContent = "Column expand: incoming cells start at width 0"; + addParagraph( + wrapper, + "Right after expand, newly-visible Q1–Q4 columns are sized to 0 with the accordion-grow marker attribute, then transition to their final width.", + ); + return wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + + // Start collapsed. + const collapseIcon = findColumnHeaderIcon(canvasElement, "total"); + expect(collapseIcon).toBeTruthy(); + collapseIcon!.click(); + await sleep(700); + + // Expand and read q1 width synchronously — at this point the cell exists + // with the 0-width init; the 2× rAF that writes the final size hasn't + // fired yet (rAFs are throttled until after the click microtask drains). + const expandIcon = findColumnHeaderIcon(canvasElement, "total"); + expect(expandIcon).toBeTruthy(); + expandIcon!.click(); + + const q1Header = canvasElement.querySelector( + `.st-header-cell[data-accessor="q1"]`, + ) as HTMLElement | null; + expect(q1Header).toBeTruthy(); + // The accordion-grow marker proves the size transition path engaged. + expect(q1Header!.dataset.stAccordionGrow).toBe("horizontal"); + expect(q1Header!.style.width).toBe("0px"); + + // After the duration, width should be at its final value and the marker cleared. + await sleep(900); + const finalWidth = parseFloat(q1Header!.style.width || "0"); + expect(finalWidth).toBe(80); + expect(q1Header!.dataset.stAccordionGrow).toBeUndefined(); + }, +}; + +// ============================================================================ +// ROW GROUP COLLAPSE / EXPAND ANIMATIONS +// ============================================================================ + +const GROUPED_DATA: Row[] = [ + { + id: "dept-1", + name: "Engineering", + size: 5, + members: [ + { id: "emp-1", name: "Alice", salary: 120000 }, + { id: "emp-2", name: "Bob", salary: 95000 }, + ], + }, + { + id: "dept-2", + name: "Sales", + size: 3, + members: [ + { id: "emp-3", name: "Charlie", salary: 110000 }, + { id: "emp-4", name: "Diana", salary: 100000 }, + ], + }, +]; + +const ROW_GROUPING_HEADERS: HeaderObject[] = [ + { accessor: "name", label: "Name", width: 250, expandable: true }, + { accessor: "size", label: "Size", width: 100, type: "number" }, + { accessor: "salary", label: "Salary", width: 140, type: "number" }, +]; + +export const RowExpand_AddsAccordionClass = { + tags: ["row", "expand", "css-window"], + render: () => { + const { wrapper, h2 } = renderVanillaTable(ROW_GROUPING_HEADERS, GROUPED_DATA, { + getRowId: (p: { row?: { id?: unknown } }) => String(p.row?.id ?? ""), + height: "320px", + rowGrouping: ["members"], + expandAll: false, + animations: { enabled: true, duration: 200 }, + }); + h2.textContent = "Row group expand: accordion class active during animation"; + addParagraph( + wrapper, + "Click chevron on a department row. The `.st-accordion-animating` class should appear and then be removed after the duration.", + ); + return wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + const root = getTableRoot(canvasElement); + expect(root).toBeTruthy(); + + const expandIcon = findFirstBodyExpandIcon(canvasElement, 0); + expect(expandIcon).toBeTruthy(); + expandIcon!.click(); + + expect(root!.classList.contains(ACCORDION_CLASS)).toBe(true); + await sleep(380); + expect(root!.classList.contains(ACCORDION_CLASS)).toBe(false); + }, +}; + +export const RowExpand_IncomingCellsStartAtZeroHeight = { + tags: ["row", "expand", "size-grow"], + render: () => { + const { wrapper, h2 } = renderVanillaTable(ROW_GROUPING_HEADERS, GROUPED_DATA, { + getRowId: (p: { row?: { id?: unknown } }) => String(p.row?.id ?? ""), + height: "320px", + rowGrouping: ["members"], + expandAll: false, + // Long duration so the test can sample mid-animation. + animations: { enabled: true, duration: 600 }, + }); + h2.textContent = "Row expand: incoming row cells start at height 0"; + addParagraph( + wrapper, + "Mid-animation, the just-revealed member cells should still have a partial height inline.", + ); + return wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + + const bodyContainer = canvasElement.querySelector( + ".st-body-container", + ) as HTMLElement | null; + expect(bodyContainer).toBeTruthy(); + + const cellsBefore = bodyContainer!.querySelectorAll( + ".st-cell[data-row-index]", + ); + const rowCountBefore = new Set( + Array.from(cellsBefore).map((c) => c.getAttribute("data-row-index")), + ).size; + + // Toggle expansion on the first department. + const expandIcon = findFirstBodyExpandIcon(canvasElement, 0); + expect(expandIcon).toBeTruthy(); + expandIcon!.click(); + + // Sample synchronously: new cells exist with 0 height and the + // accordion-grow marker, before any rAF has run to write the final size. + const cellsMid = bodyContainer!.querySelectorAll( + ".st-cell[data-row-index]", + ); + const rowCountMid = new Set( + Array.from(cellsMid).map((c) => c.getAttribute("data-row-index")), + ).size; + expect(rowCountMid).toBeGreaterThan(rowCountBefore); + + const growingCells = Array.from( + bodyContainer!.querySelectorAll( + '.st-cell[data-st-accordion-grow="vertical"]', + ), + ); + expect(growingCells.length).toBeGreaterThan(0); + for (const c of growingCells) { + expect(c.style.height).toBe("0px"); + } + + // After the duration, the marker should be cleared and heights non-zero. + await sleep(900); + for (const c of growingCells) { + expect(c.dataset.stAccordionGrow).toBeUndefined(); + expect(parseFloat(c.style.height || "0")).toBeGreaterThan(0); + } + }, +}; + +// ============================================================================ +// DISABLED / REDUCED MOTION +// ============================================================================ + +export const Disabled_NoAccordionClass = { + tags: ["disabled"], + render: () => { + const headers = buildCollapsibleColumnsHeaders(); + const { wrapper, h2 } = renderVanillaTable(headers, SALES_DATA, { + getRowId: (p: { row?: { id: unknown } }) => String(p.row?.id), + height: "240px", + animations: { enabled: false }, + }); + h2.textContent = "animations.enabled: false → no accordion class"; + addParagraph( + wrapper, + "When animations are disabled, collapse/expand should not add the accordion class to the root. Cells reach their final state synchronously.", + ); + return wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + const root = getTableRoot(canvasElement); + expect(root).toBeTruthy(); + expect(root!.classList.contains(ACCORDION_CLASS)).toBe(false); + + const collapseIcon = findColumnHeaderIcon(canvasElement, "total"); + expect(collapseIcon).toBeTruthy(); + collapseIcon!.click(); + expect(root!.classList.contains(ACCORDION_CLASS)).toBe(false); + + // Final state reached immediately. + const q1Header = canvasElement.querySelector( + `.st-header-cell[data-accessor="q1"]`, + ); + expect(q1Header).toBeNull(); + }, +}; diff --git a/packages/core/stories/tests/testUtils.ts b/packages/core/stories/tests/testUtils.ts index 49ef2b4a9..cce68a5c5 100644 --- a/packages/core/stories/tests/testUtils.ts +++ b/packages/core/stories/tests/testUtils.ts @@ -69,6 +69,21 @@ export const waitForTable = async (timeout = 5000): Promise => { throw new Error("Table did not render within timeout"); }; +/** Poll until predicate returns true or timeout (reduces flake vs fixed setTimeout). */ +export async function waitUntil( + predicate: () => boolean, + options?: { timeoutMs?: number; intervalMs?: number }, +): Promise { + const timeoutMs = options?.timeoutMs ?? 5000; + const intervalMs = options?.intervalMs ?? 50; + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) return; + await new Promise((r) => setTimeout(r, intervalMs)); + } + throw new Error("waitUntil timed out"); +} + export const validateBasicTableStructure = async (canvasElement: HTMLElement): Promise => { await waitForTable(); diff --git a/packages/react/package.json b/packages/react/package.json index 46d9743cf..c0f39f9ff 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/react", - "version": "3.4.2", + "version": "3.5.0", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/react/src/index.d.ts", diff --git a/packages/solid/package.json b/packages/solid/package.json index a2d9f9a3f..0091e7272 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/solid", - "version": "3.4.2", + "version": "3.5.0", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/solid/src/index.d.ts", diff --git a/packages/svelte/package.json b/packages/svelte/package.json index c96f13c81..853ba6149 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/svelte", - "version": "3.4.2", + "version": "3.5.0", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/index.d.ts", diff --git a/packages/vue/package.json b/packages/vue/package.json index d8cc3169f..36f23767b 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/vue", - "version": "3.4.2", + "version": "3.5.0", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/vue/src/index.d.ts",