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() {
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
- A real {exampleLabel.toLowerCase()} data grid built with Simple Table on {fw.label}. Install{" "}
-
- Peer expectations: {fw.peerSummary}
-
- {fw.label} stylesheet import:{" "}
-
- 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.
-
-
- See all {fw.label} setup details →
-
-
- {exampleLabel} on {fw.label}
-
-
- {fw.npmPackage}
-
- , copy the idiomatic snippet below, and open the live, editable demo in StackBlitz.
-
-
-
-
- {installCmd}
-
-
-
-
- {fw.minimalSnippet}
-
- {fw.stylesImport}
-
-
-
-
- Other frameworks for this example
-
-
- Make your data handling look professional. Here's what our component brings to your - projects + 30+ features in the box, so you can stop gluing libraries together.
diff --git a/apps/marketing/src/components/sections/ProductionSection.tsx b/apps/marketing/src/components/sections/ProductionSection.tsx index 60178b3f0..b92900465 100644 --- a/apps/marketing/src/components/sections/ProductionSection.tsx +++ b/apps/marketing/src/components/sections/ProductionSection.tsx @@ -146,7 +146,7 @@ export default function ProductionSection() { Dedicated Support- Your feedback is important to us. Get timely technical support for your use cases. + Real humans, fast replies. Ask a question and actually get an answer.
diff --git a/apps/marketing/src/constants/changelog.ts b/apps/marketing/src/constants/changelog.ts index 6dcbd7aa5..1b9ff8188 100644 --- a/apps/marketing/src/constants/changelog.ts +++ b/apps/marketing/src/constants/changelog.ts @@ -10,6 +10,23 @@ export interface ChangelogEntry { link?: string; }[]; } +export const v3_6_4: ChangelogEntry = { + version: "3.6.4", + date: "2026-06-08", + title: "Animation improvements", + description: "Animation improvements.", + changes: [ + { + type: "improvement", + description: "FLIP animations for footerPosition: 'top'.", + }, + { + type: "bugfix", + description: "Custom headerRenderer fix.", + }, + ], +}; + export const v3_6_3: ChangelogEntry = { version: "3.6.3", date: "2026-05-31", @@ -1764,6 +1781,7 @@ export const v1_4_4: ChangelogEntry = { // Array of all changelog entries (newest first) export const CHANGELOG_ENTRIES: ChangelogEntry[] = [ + v3_6_4, v3_6_3, v3_6_2, v3_6_0, diff --git a/apps/marketing/src/middleware.ts b/apps/marketing/src/middleware.ts new file mode 100644 index 000000000..6568aff33 --- /dev/null +++ b/apps/marketing/src/middleware.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from "next/server"; + +export function middleware(req: NextRequest) { + const host = req.headers.get("host") ?? ""; + if (host === "simple-table.com") { + const url = req.nextUrl.clone(); + url.host = "www.simple-table.com"; + url.protocol = "https:"; + return NextResponse.redirect(url, 308); + } + return NextResponse.next(); +} + +export const config = { matcher: "/:path*" }; diff --git a/packages/angular/package.json b/packages/angular/package.json index b0a02b717..a5f08d7b7 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/angular", - "version": "3.6.3", + "version": "3.6.4", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/angular/src/index.d.ts", diff --git a/packages/core/package.json b/packages/core/package.json index 21bc8c3ab..82f6b6ec1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "simple-table-core", - "version": "3.6.3", + "version": "3.6.4", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/src/index.d.ts", 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; 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/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