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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions apps/marketing/src/constants/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/angular/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions packages/core/CELL_ANIMATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
179 changes: 172 additions & 7 deletions packages/core/src/core/SimpleTableVanilla.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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) => {
Expand All @@ -219,6 +325,7 @@ export class SimpleTableVanilla {
this.config.rowGrouping,
);
this.expandedDepthsManager.subscribe((depths) => {
this.beginAccordionAnimation("vertical");
this.expandedDepths = depths;
this.render("expandedDepthsManager");
});
Expand Down Expand Up @@ -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");
});
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -572,27 +683,37 @@ 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<Accessor>) => {
this.beginAccordionAnimation("horizontal");
this.collapsedHeaders = headers;
},
setCollapsedRows: (
rowsOrUpdater: Map<string, number> | ((prev: Map<string, number>) => Map<string, number>),
) => {
this.beginAccordionAnimation("vertical");
this.collapsedRows =
typeof rowsOrUpdater === "function" ? rowsOrUpdater(this.collapsedRows) : rowsOrUpdater;
this.render("expansion");
},
setExpandedRows: (
rowsOrUpdater: Map<string, number> | ((prev: Map<string, number>) => Map<string, number>),
) => {
this.beginAccordionAnimation("vertical");
this.expandedRows =
typeof rowsOrUpdater === "function" ? rowsOrUpdater(this.expandedRows) : rowsOrUpdater;
this.render("expansion");
Expand All @@ -602,6 +723,12 @@ export class SimpleTableVanilla {
| Map<string | number, any>
| ((prev: Map<string | number, any>) => Map<string | number, any>),
) => {
// 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");
Expand Down Expand Up @@ -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
Expand All @@ -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();

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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");
},
Expand Down
Loading
Loading