From ecad9e74f0c860580312aa43236e67e776ca3f44 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Mon, 8 Jun 2026 01:25:09 -0500 Subject: [PATCH 1/5] Custom header renderer fixes --- packages/core/src/utils/headerCell/styling.ts | 104 +++++++++++++----- packages/core/src/utils/headerCellRenderer.ts | 2 +- .../tests/25-HeaderRendererTests.stories.ts | 15 ++- 3 files changed, 91 insertions(+), 30 deletions(-) diff --git a/packages/core/src/utils/headerCell/styling.ts b/packages/core/src/utils/headerCell/styling.ts index 501ecfd4b..f156b0091 100644 --- a/packages/core/src/utils/headerCell/styling.ts +++ b/packages/core/src/utils/headerCell/styling.ts @@ -95,6 +95,53 @@ export const calculateHeaderCellClasses = ( .join(" "); }; +/** + * Renders a custom header's `headerRenderer` output into the `.st-header-label` + * element, passing freshly built sort/filter/collapse icons as `components`. + * + * Shared by initial creation ({@link createHeaderCellElement}) and in-place icon + * refresh ({@link refreshHeaderCellIcons}). The refresh path is what makes + * `components.sortIcon` appear (and stay current) when sorting toggles after the + * cell already exists: the sort icon only exists for the active sort column, so a + * custom header must be re-rendered with the new icon instead of being skipped. + */ +const renderHeaderRendererContent = ( + labelElement: HTMLElement, + header: AbsoluteCell["header"], + colIndex: number, + context: HeaderRenderContext, + icons: { + sortIcon: HTMLElement | null; + filterIcon: HTMLElement | null; + collapseIcon: HTMLElement | null; + }, +): void => { + const labelContent = createLabelContent(header, context); + + const renderedContent = header.headerRenderer!({ + accessor: header.accessor, + colIndex, + header, + components: { + sortIcon: icons.sortIcon || undefined, + filterIcon: icons.filterIcon || undefined, + collapseIcon: icons.collapseIcon || undefined, + labelContent, + }, + }); + + labelElement.innerHTML = ""; + + // The headerRenderer should return a DOM element (HTMLElement). The React + // adapter wraps React-based headerRenderers to convert them to DOM elements. + if (renderedContent instanceof HTMLElement) { + labelElement.appendChild(renderedContent); + } else { + // Fallback to default rendering if not a DOM element. + labelElement.appendChild(labelContent); + } +}; + export const createHeaderCellElement = ( cell: AbsoluteCell, context: HeaderRenderContext, @@ -178,28 +225,11 @@ export const createHeaderCellElement = ( labelElement.className = "st-header-label"; if (header.headerRenderer) { - const labelContent = createLabelContent(header, context); - - const renderedContent = header.headerRenderer({ - accessor: header.accessor, - colIndex, - header, - components: { - sortIcon: sortIcon || undefined, - filterIcon: filterIcon || undefined, - collapseIcon: collapseIcon || undefined, - labelContent: labelContent, - }, + renderHeaderRendererContent(labelElement, header, colIndex, context, { + sortIcon, + filterIcon, + collapseIcon, }); - - // The headerRenderer should return a DOM element (HTMLElement) - // The React adapter wraps React-based headerRenderers to convert them to DOM elements - if (renderedContent instanceof HTMLElement) { - labelElement.appendChild(renderedContent); - } else { - // Fallback to default rendering if not a DOM element - labelElement.appendChild(labelContent); - } } else { const labelContent = createLabelContent(header, context); labelElement.appendChild(labelContent); @@ -273,7 +303,29 @@ export const refreshHeaderCellIcons = ( cellElement: HTMLElement, header: AbsoluteCell["header"], context: HeaderRenderContext, + colIndex: number, ): void => { + const sortIcon = createSortIcon(header, context); + const filterIcon = createFilterIcon(header, context); + const collapseIcon = createCollapseIcon(header, context); + + // Custom headers own where the icons live (inside their own markup), so we + // can't surgically swap individual icon nodes. Re-run the renderer with the + // freshly built icons and replace the label content. Without this, a custom + // header's `components.sortIcon` never appears when the sort toggles after the + // cell was first created (the sort icon only exists for the active column). + if (header.headerRenderer) { + const labelElement = cellElement.querySelector(".st-header-label") as HTMLElement | null; + if (labelElement) { + renderHeaderRendererContent(labelElement, header, colIndex, context, { + sortIcon, + filterIcon, + collapseIcon, + }); + } + return; + } + const oldSortIcon = cellElement.querySelector('.st-icon-container[aria-label*="Sort"]'); const oldFilterIcon = cellElement.querySelector('.st-icon-container[aria-label*="Filter"]'); const oldCollapseIcon = cellElement.querySelector(".st-expand-icon-container"); @@ -282,15 +334,11 @@ export const refreshHeaderCellIcons = ( oldFilterIcon?.remove(); oldCollapseIcon?.remove(); - const sortIcon = createSortIcon(header, context); - const filterIcon = createFilterIcon(header, context); - const collapseIcon = createCollapseIcon(header, context); - - if (!header.headerRenderer && header.align === "right") { + if (header.align === "right") { if (collapseIcon) cellElement.insertBefore(collapseIcon, cellElement.firstChild); if (filterIcon) cellElement.insertBefore(filterIcon, cellElement.firstChild); if (sortIcon) cellElement.insertBefore(sortIcon, cellElement.firstChild); - } else if (!header.headerRenderer && header.align !== "right") { + } else { const resizeHandle = cellElement.querySelector(".st-header-resize-handle-container"); // In right-pinned cells the resize handle is the FIRST child (leading edge), // so the trailing icons should just be appended rather than inserted before it. @@ -350,5 +398,5 @@ export const updateHeaderCellElement = ( cellElement.style.height = `${cell.height}px`; } - refreshHeaderCellIcons(cellElement, header, context); + refreshHeaderCellIcons(cellElement, header, context, colIndex); }; diff --git a/packages/core/src/utils/headerCellRenderer.ts b/packages/core/src/utils/headerCellRenderer.ts index 5ff2181a0..a4c790d86 100644 --- a/packages/core/src/utils/headerCellRenderer.ts +++ b/packages/core/src/utils/headerCellRenderer.ts @@ -232,7 +232,7 @@ export const renderHeaderCells = ( context.filters && context.filters[cell.header.accessor as any] ? "1" : "0"; const iconStateKey = `${sortStateForCell}|${filterStateForCell}`; if (cellElement.dataset.stIconState !== iconStateKey) { - refreshHeaderCellIcons(cellElement, cell.header, context); + refreshHeaderCellIcons(cellElement, cell.header, context, cell.colIndex); cellElement.dataset.stIconState = iconStateKey; } } diff --git a/packages/core/stories/tests/25-HeaderRendererTests.stories.ts b/packages/core/stories/tests/25-HeaderRendererTests.stories.ts index 0e7193e43..6dbbdc770 100644 --- a/packages/core/stories/tests/25-HeaderRendererTests.stories.ts +++ b/packages/core/stories/tests/25-HeaderRendererTests.stories.ts @@ -129,6 +129,7 @@ export const HeaderRendererWithFilterIcon = { render: () => { const headers: HeaderObject[] = [ { + isSortable: true, accessor: "name", label: "Name", width: 200, @@ -143,18 +144,30 @@ export const HeaderRendererWithFilterIcon = { const label = components?.labelContent; if (label instanceof HTMLElement) wrap.appendChild(label); const filterIcon = components?.filterIcon; + const sortIcon = components?.sortIcon; + if (sortIcon instanceof HTMLElement) { + sortIcon.setAttribute("data-testid", "sort-icon-slot"); + wrap.appendChild(sortIcon); + } if (filterIcon instanceof HTMLElement) { filterIcon.setAttribute("data-testid", "filter-icon-slot"); wrap.appendChild(filterIcon); + } else if (typeof filterIcon === "string") { + wrap.appendChild(document.createTextNode(filterIcon)); } return wrap; }, }, - { accessor: "score", label: "Score", width: 100, type: "number" }, + { accessor: "score", label: "Score", width: 100, type: "number", isSortable: true }, ]; + const customFilterIcon = document.createElement("div"); + customFilterIcon.textContent = "Filter"; + const customSortIcon = document.createElement("div"); + customSortIcon.textContent = "Sort"; const { wrapper } = renderVanillaTable(headers, createData(), { getRowId: (p) => String(p.row?.id), height: "250px", + icons: { filter: customFilterIcon, sortUp: customSortIcon, sortDown: customSortIcon }, }); return wrapper; }, From b2305986222b7ce52a37c26a4f16a2f131fded6a Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Mon, 8 Jun 2026 01:29:01 -0500 Subject: [PATCH 2/5] New test --- .../tests/24-FooterRendererTests.stories.ts | 8 +- .../tests/25-HeaderRendererTests.stories.ts | 91 +++++++++++++++---- 2 files changed, 80 insertions(+), 19 deletions(-) diff --git a/packages/core/stories/tests/24-FooterRendererTests.stories.ts b/packages/core/stories/tests/24-FooterRendererTests.stories.ts index 00540c7ba..88b620a9e 100644 --- a/packages/core/stories/tests/24-FooterRendererTests.stories.ts +++ b/packages/core/stories/tests/24-FooterRendererTests.stories.ts @@ -28,8 +28,8 @@ const createData = (n: number) => Array.from({ length: n }, (_, i) => ({ id: i + 1, name: `Item ${i + 1}` })); const headers: HeaderObject[] = [ - { accessor: "id", label: "ID", width: 80, type: "number" }, - { accessor: "name", label: "Name", width: 150, type: "string" }, + { accessor: "id", label: "ID", width: 80, type: "number", isSortable: true }, + { accessor: "name", label: "Name", width: 150, type: "string", isSortable: true }, ]; export const DefaultFooterWithPagination = { @@ -176,7 +176,9 @@ export const FooterPrevDisabledOnFirstPage = { // On the first page the prev button should be disabled. // The footer renders page-number buttons first, then prev/next buttons, // so we select by aria-label to target the correct button. - const prevBtn = footer?.querySelector('button[aria-label="Go to previous page"]'); + const prevBtn = footer?.querySelector( + 'button[aria-label="Go to previous page"]', + ); expect(prevBtn).toBeTruthy(); expect(prevBtn?.disabled).toBe(true); }, diff --git a/packages/core/stories/tests/25-HeaderRendererTests.stories.ts b/packages/core/stories/tests/25-HeaderRendererTests.stories.ts index 6dbbdc770..67c08fb96 100644 --- a/packages/core/stories/tests/25-HeaderRendererTests.stories.ts +++ b/packages/core/stories/tests/25-HeaderRendererTests.stories.ts @@ -4,10 +4,10 @@ */ import type { Meta } from "@storybook/html"; -import { expect } from "@storybook/test"; +import { expect, userEvent } from "@storybook/test"; import { HeaderObject } from "../../src/index"; import type { HeaderRenderer } from "../../src/types/HeaderRendererProps"; -import { waitForTable } from "./testUtils"; +import { waitForTable, waitUntil } from "./testUtils"; import { renderVanillaTable } from "../utils"; const meta: Meta = { @@ -129,7 +129,6 @@ export const HeaderRendererWithFilterIcon = { render: () => { const headers: HeaderObject[] = [ { - isSortable: true, accessor: "name", label: "Name", width: 200, @@ -144,30 +143,18 @@ export const HeaderRendererWithFilterIcon = { const label = components?.labelContent; if (label instanceof HTMLElement) wrap.appendChild(label); const filterIcon = components?.filterIcon; - const sortIcon = components?.sortIcon; - if (sortIcon instanceof HTMLElement) { - sortIcon.setAttribute("data-testid", "sort-icon-slot"); - wrap.appendChild(sortIcon); - } if (filterIcon instanceof HTMLElement) { filterIcon.setAttribute("data-testid", "filter-icon-slot"); wrap.appendChild(filterIcon); - } else if (typeof filterIcon === "string") { - wrap.appendChild(document.createTextNode(filterIcon)); } return wrap; }, }, - { accessor: "score", label: "Score", width: 100, type: "number", isSortable: true }, + { accessor: "score", label: "Score", width: 100, type: "number" }, ]; - const customFilterIcon = document.createElement("div"); - customFilterIcon.textContent = "Filter"; - const customSortIcon = document.createElement("div"); - customSortIcon.textContent = "Sort"; const { wrapper } = renderVanillaTable(headers, createData(), { getRowId: (p) => String(p.row?.id), height: "250px", - icons: { filter: customFilterIcon, sortUp: customSortIcon, sortDown: customSortIcon }, }); return wrapper; }, @@ -187,6 +174,78 @@ export const HeaderRendererWithFilterIcon = { }, }; +// ============================================================================ +// HEADER RENDERER WITH SORT ICON COMPONENT +// Regression: a custom header that places `components.sortIcon` must show the +// sort icon once the column becomes sorted — even though the cell (and the +// headerRenderer) was first rendered while unsorted, when the icon did not yet +// exist. The cell takes the in-place "update" path on sort rather than being +// recreated, so the renderer must be re-run with the freshly built icon. +// ============================================================================ + +export const HeaderRendererSortIconAppearsOnSort = { + render: () => { + const headers: HeaderObject[] = [ + { + accessor: "name", + label: "Name", + width: 200, + type: "string", + isSortable: true, + headerRenderer: ({ components, header }) => { + const wrap = document.createElement("div"); + wrap.setAttribute("data-testid", "sortable-custom-header"); + wrap.style.display = "flex"; + wrap.style.alignItems = "center"; + wrap.style.gap = "4px"; + const label = components?.labelContent; + if (label instanceof HTMLElement) wrap.appendChild(label); + else wrap.appendChild(document.createTextNode(String(header.label))); + const sortIcon = components?.sortIcon; + if (sortIcon instanceof HTMLElement) { + sortIcon.setAttribute("data-testid", "custom-sort-icon-slot"); + wrap.appendChild(sortIcon); + } + return wrap; + }, + }, + { accessor: "score", label: "Score", width: 100, type: "number" }, + ]; + const { wrapper } = renderVanillaTable(headers, createData(), { + getRowId: (p) => String(p.row?.id), + height: "250px", + }); + return wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + + const customHeader = canvasElement.querySelector('[data-testid="sortable-custom-header"]'); + expect(customHeader).toBeTruthy(); + + // Initially unsorted: no sort icon should be present inside the custom header. + expect(canvasElement.querySelector('[data-testid="custom-sort-icon-slot"]')).toBeNull(); + + // Click the header label to sort the column. + const headerCell = (customHeader as HTMLElement).closest(".st-header-cell") as HTMLElement; + const labelEl = headerCell.querySelector(".st-header-label") as HTMLElement; + const user = userEvent.setup(); + await user.click(labelEl); + + // After sorting, the custom header must now render `components.sortIcon`. + // The renderer re-runs and replaces the label content, so re-query the + // current nodes rather than reusing the pre-sort references. + await waitUntil( + () => canvasElement.querySelector('[data-testid="custom-sort-icon-slot"]') !== null, + ); + const sortIconAfter = canvasElement.querySelector('[data-testid="custom-sort-icon-slot"]'); + expect(sortIconAfter).toBeTruthy(); + // The icon lives inside the custom header markup (not appended elsewhere). + const customHeaderAfter = canvasElement.querySelector('[data-testid="sortable-custom-header"]'); + expect(customHeaderAfter?.contains(sortIconAfter)).toBe(true); + }, +}; + // ============================================================================ // HEADER RENDERER WITH COLLAPSE ICON COMPONENT (collapsible column group) // ============================================================================ From 1c176fed010c47233d52de8dea90086c01bdb202 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Mon, 8 Jun 2026 01:48:29 -0500 Subject: [PATCH 3/5] example improvements --- .../examples/sales-example/SalesExample.ts | 60 +++++++++++- .../examples/sales-example/sales-headers.ts | 27 ++++++ .../react/src/demos/sales/SalesDemo.tsx | 93 ++++++++++++++++++- .../react/src/demos/sales/sales-headers.tsx | 17 +++- .../react/src/demos/sales/sales.demo-data.ts | 42 +++++++++ 5 files changed, 233 insertions(+), 6 deletions(-) diff --git a/packages/core/stories/examples/sales-example/SalesExample.ts b/packages/core/stories/examples/sales-example/SalesExample.ts index 93b675bff..e05c7a3f9 100644 --- a/packages/core/stories/examples/sales-example/SalesExample.ts +++ b/packages/core/stories/examples/sales-example/SalesExample.ts @@ -2,7 +2,7 @@ * SalesExample – vanilla port of React sales-example/SalesExample. * Uses same SALES_HEADERS and sales-data as React, with autoExpandColumns and enableRowSelection. */ -import type { Row } from "../../../src/index"; +import type { FooterRendererProps, Row } from "../../../src/index"; import { renderVanillaTable } from "../../utils"; import { defaultVanillaArgs, type UniversalVanillaArgs } from "../../vanillaStoryConfig"; import { SALES_HEADERS } from "./sales-headers"; @@ -16,12 +16,70 @@ export const salesExampleDefaults = { theme: "modern-dark" as const, height: "70dvh", editColumns: true, + shouldPaginate: true, + rowsPerPage: 40, + footerPosition: "top" as const, }; +function createSalesFooter(props: FooterRendererProps): HTMLElement { + const wrap = document.createElement("div"); + Object.assign(wrap.style, { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "12px 20px", + backgroundColor: "#111827", + borderBottom: "1px solid #374151", + color: "#d1d5db", + fontSize: "14px", + }); + + const info = document.createElement("span"); + info.style.fontWeight = "600"; + info.textContent = `Showing ${props.startRow}–${props.endRow} of ${props.totalRows} deals`; + wrap.appendChild(info); + + const controls = document.createElement("div"); + Object.assign(controls.style, { display: "flex", alignItems: "center", gap: "8px" }); + + const makeBtn = (label: string, onClick: () => void, disabled: boolean) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = label; + Object.assign(btn.style, { + padding: "6px 14px", + fontSize: "14px", + fontWeight: "500", + color: disabled ? "#6b7280" : "#d1d5db", + backgroundColor: "#1f2937", + border: "1px solid #374151", + borderRadius: "6px", + cursor: disabled ? "not-allowed" : "pointer", + }); + btn.disabled = disabled; + if (!disabled) btn.addEventListener("click", onClick); + return btn; + }; + + controls.appendChild(makeBtn("Previous", () => props.onPrevPage(), !props.hasPrevPage)); + + const pageInfo = document.createElement("span"); + pageInfo.style.minWidth = "90px"; + pageInfo.style.textAlign = "center"; + pageInfo.textContent = `Page ${props.currentPage} of ${props.totalPages}`; + controls.appendChild(pageInfo); + + controls.appendChild(makeBtn("Next", () => void props.onNextPage(), !props.hasNextPage)); + + wrap.appendChild(controls); + return wrap; +} + export function renderSalesExample(args?: Partial): HTMLElement { const options = { ...defaultVanillaArgs, ...salesExampleDefaults, ...args }; const { wrapper, h2 } = renderVanillaTable(SALES_HEADERS, salesData as Row[], { ...options, + footerRenderer: (props: FooterRendererProps) => createSalesFooter(props), getRowId: (params: { row?: { id?: unknown } }) => String(params.row?.id), }); h2.textContent = "Sales Example"; diff --git a/packages/core/stories/examples/sales-example/sales-headers.ts b/packages/core/stories/examples/sales-example/sales-headers.ts index f06450778..f2d4d5191 100644 --- a/packages/core/stories/examples/sales-example/sales-headers.ts +++ b/packages/core/stories/examples/sales-example/sales-headers.ts @@ -14,6 +14,33 @@ export const SALES_HEADERS: HeaderObject[] = [ isSortable: true, isEditable: true, type: "string", + headerRenderer: ({ header, components }) => { + const wrap = document.createElement("div"); + wrap.style.display = "flex"; + wrap.style.alignItems = "center"; + wrap.style.gap = "8px"; + + const icon = document.createElement("span"); + icon.textContent = "🧑‍💼"; + icon.setAttribute("aria-hidden", "true"); + + const label = components?.labelContent; + if (label instanceof HTMLElement) { + wrap.appendChild(icon); + wrap.appendChild(label); + } else { + const text = document.createElement("span"); + text.style.fontWeight = "700"; + text.textContent = String(header.label); + wrap.appendChild(icon); + wrap.appendChild(text); + } + + const sortIcon = components?.sortIcon; + if (sortIcon instanceof HTMLElement) wrap.appendChild(sortIcon); + + return wrap; + }, }, { pinned: "left", diff --git a/packages/examples/react/src/demos/sales/SalesDemo.tsx b/packages/examples/react/src/demos/sales/SalesDemo.tsx index cb1aae83a..f86e8df40 100644 --- a/packages/examples/react/src/demos/sales/SalesDemo.tsx +++ b/packages/examples/react/src/demos/sales/SalesDemo.tsx @@ -1,7 +1,7 @@ -import { useState, useEffect } from "react"; +import { useMemo, useState, useEffect } from "react"; import { SimpleTable } from "@simple-table/react"; -import type { Theme, CellChangeProps } from "@simple-table/react"; -import { salesSampleRows, type SalesRow } from "./sales.demo-data"; +import type { Theme, CellChangeProps, FooterRendererProps } from "@simple-table/react"; +import { generateSalesData, type SalesRow } from "./sales.demo-data"; import { SALES_HEADERS } from "./sales-headers"; import "@simple-table/react/styles.css"; @@ -11,8 +11,34 @@ function formatTableHeight(height?: string | number | null): string { return height; } +function getFooterColors(theme?: Theme) { + switch (theme) { + case "modern-dark": + case "dark": + return { + background: "#111827", + border: "#374151", + text: "#d1d5db", + buttonBg: "#1f2937", + buttonBorder: "#374151", + buttonActive: "#3b82f6", + buttonDisabled: "#6b7280", + }; + default: + return { + background: "#f8fafc", + border: "#e2e8f0", + text: "#475569", + buttonBg: "white", + buttonBorder: "#e2e8f0", + buttonActive: "#3b82f6", + buttonDisabled: "#cbd5e1", + }; + } +} + const SalesDemo = ({ height, theme }: { height?: string | number | null; theme?: Theme }) => { - const [data, setData] = useState(() => salesSampleRows.map((r) => ({ ...r }))); + const [data, setData] = useState(() => generateSalesData(240)); const [isMobile, setIsMobile] = useState(false); useEffect(() => { @@ -28,6 +54,61 @@ const SalesDemo = ({ height, theme }: { height?: string | number | null; theme?: ); }; + const colors = useMemo(() => getFooterColors(theme), [theme]); + + const renderFooter = ({ + currentPage, + startRow, + endRow, + totalRows, + totalPages, + hasPrevPage, + hasNextPage, + onPrevPage, + onNextPage, + }: FooterRendererProps) => { + const btnStyle = (disabled: boolean): React.CSSProperties => ({ + padding: "6px 14px", + fontSize: "14px", + fontWeight: 500, + color: disabled ? colors.buttonDisabled : colors.buttonActive, + backgroundColor: colors.buttonBg, + border: `1px solid ${colors.buttonBorder}`, + borderRadius: "6px", + cursor: disabled ? "not-allowed" : "pointer", + }); + + return ( +
+ + Showing {startRow}–{endRow} of {totalRows} deals + +
+ + + Page {currentPage} of {totalPages} + + +
+
+ ); + }; + return ( ); diff --git a/packages/examples/react/src/demos/sales/sales-headers.tsx b/packages/examples/react/src/demos/sales/sales-headers.tsx index afea23475..912ba308c 100644 --- a/packages/examples/react/src/demos/sales/sales-headers.tsx +++ b/packages/examples/react/src/demos/sales/sales-headers.tsx @@ -1,4 +1,9 @@ -import type { ReactHeaderObject, CellRendererProps, ValueGetterProps } from "@simple-table/react"; +import type { + ReactHeaderObject, + CellRendererProps, + HeaderRendererProps, + ValueGetterProps, +} from "@simple-table/react"; import type { CSSProperties, ReactNode } from "react"; type SuccessHighStyle = { color: string; fontWeight: "bold" }; @@ -240,6 +245,16 @@ export const SALES_HEADERS: ReactHeaderObject[] = [ isEditable: true, type: "string", tooltip: "Name of the sales representative", + // Custom header: person icon + the built-in label/sort slots so sorting still works. + headerRenderer: ({ header, components }: HeaderRendererProps) => ( +
+ + {components?.labelContent ?? {header.label}} + {components?.sortIcon} +
+ ), }, { accessor: "salesMetrics", diff --git a/packages/examples/react/src/demos/sales/sales.demo-data.ts b/packages/examples/react/src/demos/sales/sales.demo-data.ts index 7dce67a5e..9a4685d0a 100644 --- a/packages/examples/react/src/demos/sales/sales.demo-data.ts +++ b/packages/examples/react/src/demos/sales/sales.demo-data.ts @@ -52,3 +52,45 @@ const SALES_SAMPLE_INBOUND: SalesInboundRow[] = [ ]; export const salesSampleRows: SalesRow[] = processSalesData(SALES_SAMPLE_INBOUND); + +const FIRST_NAMES = [ + "Sophie", "Akira", "Thomas", "Valentina", "Isabella", "Emily", "Olivia", "Marcus", + "Nina", "James", "Elena", "Chen", "Priya", "Lars", "Amélie", "Diego", "Fatima", + "Henrik", "Yuki", "Grace", "Liam", "Noah", "Mia", "Lucas", "Aria", +]; + +const LAST_NAMES = [ + "Dubois", "Tanaka", "Müller", "Diaz", "Fernandez", "Davis", "Bennett", "Webb", + "Kowalski", "Okafor", "Rossi", "Wei", "Sharma", "Hansen", "Laurent", "Alvarez", + "Al-Farsi", "Berg", "Sato", "O'Malley", "Nguyen", "Schmidt", "Costa", "Ivanova", "Park", +]; + +const CATEGORIES = ["Software", "Hardware", "Services", "Consulting", "Training", "Support"]; + +const pick = (items: readonly T[]): T => items[Math.floor(Math.random() * items.length)]; + +const randomBetween = (min: number, max: number) => min + Math.random() * (max - min); + +const randomCloseDate = () => { + const start = new Date(2026, 0, 1).getTime(); + const end = new Date(2026, 11, 31).getTime(); + const date = new Date(start + Math.random() * (end - start)); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +}; + +/** Generates `count` randomized inbound sales rows, then processes derived fields. */ +export function generateSalesData(count = 240): SalesRow[] { + const inbound: SalesInboundRow[] = Array.from({ length: count }, (_, index) => ({ + id: `SALE-${index}`, + repName: `${pick(FIRST_NAMES)} ${pick(LAST_NAMES)}`, + dealSize: parseFloat(randomBetween(100, 250000).toFixed(2)), + isWon: Math.random() > 0.35, + profitMargin: parseFloat(randomBetween(0.25, 0.75).toFixed(2)), + closeDate: randomCloseDate(), + category: pick(CATEGORIES), + })); + return processSalesData(inbound); +} From 9966772264ee24a45ac75aecfc46227afcae0d3f Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Mon, 8 Jun 2026 02:20:29 -0500 Subject: [PATCH 4/5] Flip fix --- .../core/src/managers/AnimationCoordinator.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/core/src/managers/AnimationCoordinator.ts b/packages/core/src/managers/AnimationCoordinator.ts index 88df868da..f4ddf7725 100644 --- a/packages/core/src/managers/AnimationCoordinator.ts +++ b/packages/core/src/managers/AnimationCoordinator.ts @@ -816,14 +816,30 @@ export class AnimationCoordinator { // synthetic incoming origins): those are conceptual positions that // never had a real container anchor. let containerShiftX = 0; - let containerShiftY = 0; + const 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. + // + // Only the HORIZONTAL shift is corrected: the section panes are laid + // out side by side (pinned-left | main | pinned-right), so the only + // legitimate between-snapshot-and-play origin change is horizontal + // (e.g. pin/unpin grows pinned-left and slides main sideways). + // + // The VERTICAL origin is intentionally NOT corrected. A section's + // page-Y can transiently differ between snapshot and play without any + // real cell movement — most notably with `footerPosition: "top"`, + // where the footer is rendered by a framework adapter that commits its + // content on a later microtask. At play() time the top footer is + // momentarily empty (0px tall), so the header/body containers below it + // measure ~footerHeight higher than their final resting spot. Feeding + // that transient delta into the FLIP injected a phantom `dy` (the + // header text teleporting down by the footer height and animating back + // up). The footer settles before the next paint, so no real movement + // needs animating here. const playOrigin = getPlayContainerOrigin(container); containerShiftX = playOrigin.left - before.sourceContainerLeft; - containerShiftY = playOrigin.top - before.sourceContainerTop; } const dxRaw = beforeLeftClipped - currentLeft; From 3c2d503ed8190f3572c414a7b233950321844b21 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Mon, 8 Jun 2026 02:31:09 -0500 Subject: [PATCH 5/5] Marketing improvements --- apps/marketing/next.config.ts | 5 + .../src/app/case-studies/chartmetric/page.tsx | 4 +- .../examples/[framework]/[example]/page.tsx | 215 ------------------ .../src/app/examples/billing/page.tsx | 3 + apps/marketing/src/app/examples/hr/page.tsx | 3 + .../src/app/examples/infrastructure/page.tsx | 3 + .../marketing/src/app/examples/music/page.tsx | 3 + apps/marketing/src/app/sitemap.ts | 11 +- .../src/components/pages/HomeContent.tsx | 7 +- .../components/sections/CaseStudySection.tsx | 6 +- .../components/sections/FeaturesSection.tsx | 3 +- .../components/sections/ProductionSection.tsx | 2 +- apps/marketing/src/constants/changelog.ts | 18 ++ apps/marketing/src/middleware.ts | 14 ++ packages/angular/package.json | 2 +- packages/core/package.json | 2 +- packages/react/package.json | 2 +- packages/solid/package.json | 2 +- packages/svelte/package.json | 2 +- packages/vue/package.json | 2 +- 20 files changed, 66 insertions(+), 243 deletions(-) delete mode 100644 apps/marketing/src/app/examples/[framework]/[example]/page.tsx create mode 100644 apps/marketing/src/middleware.ts diff --git a/apps/marketing/next.config.ts b/apps/marketing/next.config.ts index b6718d660..ff954da6b 100644 --- a/apps/marketing/next.config.ts +++ b/apps/marketing/next.config.ts @@ -56,6 +56,11 @@ const config: NextConfig = { destination: "/examples/infrastructure", permanent: true, }, + { + source: "/examples/:framework(react|vue|angular|svelte|solid|vanilla)/:example", + destination: "/examples/:example", + permanent: true, + }, { source: "/examples", destination: "/examples/crm", diff --git a/apps/marketing/src/app/case-studies/chartmetric/page.tsx b/apps/marketing/src/app/case-studies/chartmetric/page.tsx index 802724a27..c1cefa419 100644 --- a/apps/marketing/src/app/case-studies/chartmetric/page.tsx +++ b/apps/marketing/src/app/case-studies/chartmetric/page.tsx @@ -114,7 +114,7 @@ export default function ChartMetricCaseStudyPage() {
-
99%
+
100%
Customization needs met
@@ -214,7 +214,7 @@ export default function ChartMetricCaseStudyPage() {

Simple Table was highly customizable—around{" "} - 99% of our needs were covered through built-in customization + 100% of our needs were covered through built-in customization options such as CSS variable overrides,{" "} custom icons, and custom headers. For edge cases, we were able to rely on global class selectors. diff --git a/apps/marketing/src/app/examples/[framework]/[example]/page.tsx b/apps/marketing/src/app/examples/[framework]/[example]/page.tsx deleted file mode 100644 index 768e2aed2..000000000 --- a/apps/marketing/src/app/examples/[framework]/[example]/page.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import type { Metadata } from "next"; -import Link from "next/link"; -import { notFound } from "next/navigation"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faBox, faCode, faRocket, faTerminal } from "@fortawesome/free-solid-svg-icons"; -import { - EXAMPLE_SLUGS, - FRAMEWORK_HUB_BY_ID, - FRAMEWORK_HUB_IDS, - type ExampleSlug, - type HubFrameworkId, -} from "@/constants/frameworkIntegrationHub"; -import { FRAMEWORK_INSTALL_COMMANDS } from "@/constants/strings/technical"; -import { SEO_STRINGS } from "@/constants/strings/seo"; -import { getStackBlitzUrl } from "@/utils/getStackBlitzUrl"; -import type { Framework } from "@/providers/FrameworkProvider"; -import BlogLayout from "@/components/BlogLayout"; - -type PageProps = { params: Promise<{ framework: string; example: string }> }; - -export function generateStaticParams() { - return FRAMEWORK_HUB_IDS.flatMap((framework) => - EXAMPLE_SLUGS.map((example) => ({ framework, example })) - ); -} - -function isHubId(value: string): value is HubFrameworkId { - return FRAMEWORK_HUB_IDS.includes(value as HubFrameworkId); -} - -function isExampleSlug(value: string): value is ExampleSlug { - return EXAMPLE_SLUGS.includes(value as ExampleSlug); -} - -const EXAMPLE_LABELS: Record = { - billing: "Billing & invoicing", - crm: "CRM leads management", - hr: "HR management", - infrastructure: "Infrastructure monitoring", - manufacturing: "Manufacturing dashboard", - music: "Music artist analytics", - sales: "Sales pipeline", -}; - -export async function generateMetadata({ params }: PageProps): Promise { - const { framework: rawFw, example: rawEx } = await params; - if (!isHubId(rawFw) || !isExampleSlug(rawEx)) { - return { title: "Example" }; - } - const fw = FRAMEWORK_HUB_BY_ID[rawFw]; - const exampleLabel = EXAMPLE_LABELS[rawEx]; - const baseSeo = SEO_STRINGS.examples[rawEx]; - const title = `${exampleLabel} for ${fw.label} | Simple Table`; - const description = `Build a ${exampleLabel.toLowerCase()} data grid with Simple Table on ${fw.label}. Install ${fw.npmPackage}, copy the idiomatic snippet, and run the live demo in StackBlitz. ${baseSeo.description}`; - const lower = fw.label.toLowerCase(); - return { - title, - description, - keywords: [ - `${lower} ${rawEx} table`, - `${lower} ${rawEx} data grid`, - `${lower} data grid example`, - `${rawEx} dashboard ${lower}`, - "simple-table", - fw.npmPackage, - ], - openGraph: { - title, - description, - type: "article", - images: [SEO_STRINGS.site.ogImage], - siteName: SEO_STRINGS.site.name, - }, - twitter: { - card: "summary_large_image", - title, - description, - creator: SEO_STRINGS.site.creator, - images: SEO_STRINGS.site.ogImage.url, - }, - alternates: { canonical: `/examples/${rawFw}/${rawEx}` }, - }; -} - -export default async function FrameworkExamplePage({ params }: PageProps) { - const { framework: rawFw, example: rawEx } = await params; - if (!isHubId(rawFw) || !isExampleSlug(rawEx)) notFound(); - const fw = FRAMEWORK_HUB_BY_ID[rawFw]; - const exampleLabel = EXAMPLE_LABELS[rawEx]; - const installCmd = FRAMEWORK_INSTALL_COMMANDS[rawFw as Framework]?.npm ?? `npm install ${fw.installPackages}`; - const stackBlitzUrl = getStackBlitzUrl(rawEx, rawFw as Framework); - const stackBlitzEmbedUrl = `${stackBlitzUrl}?embed=1&hideExplorer=1&hideNavigation=1&view=preview`; - - return ( - -

- -
-

- {exampleLabel} on {fw.label} -

-

- A real {exampleLabel.toLowerCase()} data grid built with Simple Table on {fw.label}. Install{" "} - - {fw.npmPackage} - - , copy the idiomatic snippet below, and open the live, editable demo in StackBlitz. -

-
- -
-
-

- - Install -

-

- Peer expectations: {fw.peerSummary} -

-
-            {installCmd}
-          
-
- -
-

- - Idiomatic {fw.label} usage -

-
-            {fw.minimalSnippet}
-          
-

- {fw.label} stylesheet import:{" "} - - {fw.stylesImport} - -

-
- -
-

- - Live, editable demo -

-

- The full {exampleLabel.toLowerCase()} data grid runs below as a {fw.label} project on - StackBlitz. Edit the code in-place, save, or fork it into your own workspace. -

-
-