From f096cf84a4ae8fd42dfe29311b805efaee72dd50 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:35:57 -0500 Subject: [PATCH 1/8] Adapter first render fix --- packages/core/src/core/dom/DOMManager.ts | 6 + .../core/src/managers/DimensionManager.ts | 17 ++ packages/core/src/utils/resizeUtils/index.ts | 38 ++- .../spreadsheet/SpreadsheetExample.ts | 289 ++++++++++++++++++ .../examples/spreadsheet/spreadsheet-demo.css | 46 +++ packages/react/src/SimpleTable.tsx | 29 +- 6 files changed, 412 insertions(+), 13 deletions(-) create mode 100644 packages/core/stories/examples/spreadsheet/SpreadsheetExample.ts create mode 100644 packages/core/stories/examples/spreadsheet/spreadsheet-demo.css diff --git a/packages/core/src/core/dom/DOMManager.ts b/packages/core/src/core/dom/DOMManager.ts index b28999e14..513e00b2d 100644 --- a/packages/core/src/core/dom/DOMManager.ts +++ b/packages/core/src/core/dom/DOMManager.ts @@ -1,3 +1,4 @@ +import { COLUMN_EDIT_WIDTH } from "../../consts/general-consts"; import { SimpleTableConfig } from "../../types/SimpleTableConfig"; export interface DOMElements { @@ -60,6 +61,11 @@ export class DOMManager { const content = document.createElement("div"); content.className = "st-content"; + // Match RenderOrchestrator so DimensionManager's first clientWidth read (before any render) + // already excludes the column editor strip when editColumns is on. + content.style.width = config.editColumns + ? `calc(100% - ${COLUMN_EDIT_WIDTH}px)` + : "100%"; const headerContainer = document.createElement("div"); headerContainer.className = "st-header-container"; diff --git a/packages/core/src/managers/DimensionManager.ts b/packages/core/src/managers/DimensionManager.ts index 1e03d24ee..0627673e4 100644 --- a/packages/core/src/managers/DimensionManager.ts +++ b/packages/core/src/managers/DimensionManager.ts @@ -166,6 +166,23 @@ export class DimensionManager { this.resizeObserver = new ResizeObserver(updateContainerWidth); this.resizeObserver.observe(containerElement); + + // Width updates from RO are deferred to rAF above; without a synchronous read, + // state stays 0 until the next frame (breaks autoExpand resize and stale header + // context). Reading here is outside the RO callback, so Chromium RO loop issues + // do not apply. + this.applyContainerWidthSync(containerElement); + } + + private applyContainerWidthSync(containerElement: HTMLElement): void { + const w = containerElement.clientWidth; + if (w > 0 && w !== this.state.containerWidth) { + this.state = { + ...this.state, + containerWidth: w, + }; + this.notifySubscribers(); + } } updateConfig(config: Partial): void { diff --git a/packages/core/src/utils/resizeUtils/index.ts b/packages/core/src/utils/resizeUtils/index.ts index 8eeb5a622..c6704d0e3 100644 --- a/packages/core/src/utils/resizeUtils/index.ts +++ b/packages/core/src/utils/resizeUtils/index.ts @@ -13,6 +13,26 @@ import { calculateMaxHeaderWidth } from "./maxWidth"; import { handleParentHeaderResize } from "./parentHeaderResize"; import { handleResizeWithAutoExpand } from "./autoExpandResize"; +/** + * Header resize handlers may capture an old `containerWidth` (e.g. 0) from when the + * cell was created. When the manager still reports 0, read the grid viewport from + * the DOM, scoped via mainBodyRef to this table instance. + */ +const resolveContainerWidthForResize = ( + fromContext: number, + mainBodyRef: HandleResizeStartProps["mainBodyRef"], +): number => { + if (fromContext > 0) return fromContext; + const main = mainBodyRef?.current; + if (!main) return 0; + const root = main.closest(".simple-table-root"); + const bodyContainer = root?.querySelector(".st-body-container"); + if (bodyContainer instanceof HTMLElement) { + return bodyContainer.clientWidth; + } + return main.clientWidth; +}; + /** * Handler for when resize dragging starts */ @@ -23,6 +43,7 @@ export const handleResizeStart = ({ event, header, headers, + mainBodyRef, onColumnWidthChange, reverse = false, setHeaders, @@ -35,6 +56,11 @@ export const handleResizeStart = ({ if (!header || header.hide) return; + const effectiveContainerWidth = resolveContainerWidthForResize( + containerWidth, + mainBodyRef, + ); + // Set resizing state to true setIsResizing(true); @@ -74,11 +100,11 @@ export const handleResizeStart = ({ initialWidthsMap.set(h.accessor as string, width); }); - // Calculate widths of pinned sections using the passed containerWidth - if (containerWidth > 0) { + // Calculate widths of pinned sections using the effective container width + if (effectiveContainerWidth > 0) { const { leftWidth, rightWidth, mainWidth } = recalculateAllSectionWidths({ headers, - containerWidth, + containerWidth: effectiveContainerWidth, collapsedHeaders, }); @@ -92,7 +118,7 @@ export const handleResizeStart = ({ sectionWidth = mainWidth; } - initialMainAvailable = containerWidth - leftWidth - rightWidth; + initialMainAvailable = effectiveContainerWidth - leftWidth - rightWidth; } } @@ -103,7 +129,7 @@ export const handleResizeStart = ({ const mainInitialWidths = new Map(); let mainLeafHeaders: HeaderObject[] = []; - if (autoExpandColumns && rootPinned && containerWidth > 0) { + if (autoExpandColumns && rootPinned && effectiveContainerWidth > 0) { const sectionLeafs = getAllVisibleLeafHeaders( headers.filter((h) => h.pinned === rootPinned), collapsedHeaders, @@ -160,7 +186,7 @@ export const handleResizeStart = ({ handleResizeWithAutoExpand({ childrenToResize, collapsedHeaders, - containerWidth, + containerWidth: effectiveContainerWidth, delta, headers, initialWidthsMap, diff --git a/packages/core/stories/examples/spreadsheet/SpreadsheetExample.ts b/packages/core/stories/examples/spreadsheet/SpreadsheetExample.ts new file mode 100644 index 000000000..46ba4adc2 --- /dev/null +++ b/packages/core/stories/examples/spreadsheet/SpreadsheetExample.ts @@ -0,0 +1,289 @@ +/** + * Spreadsheet Example — vanilla port of simple-table-marketing SpreadsheetExample. + * Loan-style columns, amortization recalculation on edit, and dynamic “Add column”. + */ +import type { CellChangeProps, HeaderObject, Row } from "../../../src/index"; +import { SimpleTableVanilla } from "../../../src/index"; +import { defaultVanillaArgs, type UniversalVanillaArgs } from "../../vanillaStoryConfig"; +import "./spreadsheet-demo.css"; + +const BASE_SPREADSHEET_COLUMNS = 6; + +function generateSpreadsheetRows(count: number): Row[] { + const data: Row[] = []; + for (let i = 0; i < count; i++) { + const principal = Math.floor(Math.random() * 490000) + 10000; + const interestRate = parseFloat((Math.random() * 5 + 3).toFixed(2)); + const monthlyRate = interestRate / 100 / 12; + const numMonths = 360; + const monthlyPayment = + (principal * (monthlyRate * Math.pow(1 + monthlyRate, numMonths))) / + (Math.pow(1 + monthlyRate, numMonths) - 1); + const paymentsMade = Math.floor(Math.random() * 120); + const remainingBalance = + (principal * + (Math.pow(1 + monthlyRate, numMonths) - Math.pow(1 + monthlyRate, paymentsMade))) / + (Math.pow(1 + monthlyRate, numMonths) - 1); + const totalPaid = monthlyPayment * paymentsMade; + const principalReduction = principal - Math.max(0, remainingBalance); + const interestPaid = totalPaid - principalReduction; + + data.push({ + id: i + 1, + principal: parseFloat(principal.toFixed(2)), + interestRate: parseFloat(interestRate.toFixed(2)), + monthlyPayment: parseFloat(monthlyPayment.toFixed(2)), + remainingBalance: parseFloat(Math.max(0, remainingBalance).toFixed(2)), + totalPaid: parseFloat(totalPaid.toFixed(2)), + interestPaid: parseFloat(Math.max(0, interestPaid).toFixed(2)), + }); + } + return data; +} + +function getSpreadsheetHeaders(additionalColumns: HeaderObject[] = []): HeaderObject[] { + return [ + { + accessor: "principal", + label: "Principal", + width: "1fr", + minWidth: 100, + align: "right", + isEditable: true, + type: "number", + aggregation: { type: "sum" }, + }, + { + accessor: "interestRate", + label: "Interest Rate %", + width: "1fr", + minWidth: 110, + align: "right", + isEditable: true, + type: "number", + aggregation: { type: "average" }, + }, + { + accessor: "monthlyPayment", + label: "Monthly Payment", + width: "1fr", + minWidth: 120, + align: "right", + isEditable: true, + type: "number", + aggregation: { type: "sum" }, + }, + { + accessor: "remainingBalance", + label: "Remaining Balance", + width: "1fr", + minWidth: 130, + align: "right", + isEditable: true, + type: "number", + aggregation: { type: "sum" }, + }, + { + accessor: "totalPaid", + label: "Total Paid", + width: "1fr", + minWidth: 110, + align: "right", + isEditable: true, + type: "number", + aggregation: { type: "sum" }, + }, + { + accessor: "interestPaid", + label: "Interest Paid", + width: "1fr", + minWidth: 110, + align: "right", + isEditable: true, + type: "number", + aggregation: { type: "sum" }, + }, + ...additionalColumns, + ]; +} + +function applyAmortizationEdit( + item: Row, + accessor: string, + newValue: CellChangeProps["newValue"], +): Row { + const updatedItem: Row = { + ...item, + [accessor]: newValue, + }; + + if (!["principal", "interestRate", "monthlyPayment"].includes(accessor)) { + return updatedItem; + } + + const principal = + accessor === "principal" + ? parseFloat(String(newValue)) || 0 + : typeof item.principal === "number" + ? item.principal + : 0; + const interestRate = + accessor === "interestRate" + ? parseFloat(String(newValue)) || 0 + : typeof item.interestRate === "number" + ? item.interestRate + : 0; + const monthlyPayment = + accessor === "monthlyPayment" + ? parseFloat(String(newValue)) || 0 + : typeof item.monthlyPayment === "number" + ? item.monthlyPayment + : 0; + + const monthlyRate = interestRate / 100 / 12; + const numMonths = 360; + + let calculatedPayment = monthlyPayment; + if (accessor === "principal" || accessor === "interestRate") { + if (monthlyRate > 0 && principal > 0) { + calculatedPayment = + (principal * monthlyRate * Math.pow(1 + monthlyRate, numMonths)) / + (Math.pow(1 + monthlyRate, numMonths) - 1); + updatedItem.monthlyPayment = parseFloat(calculatedPayment.toFixed(2)); + } + } + + const totalPaidValue = typeof item.totalPaid === "number" ? item.totalPaid : 0; + const estimatedPaymentsMade = Math.max( + 0, + Math.min(120, Math.floor(totalPaidValue / calculatedPayment)), + ); + + let remainingBalance = principal; + if (monthlyRate > 0 && calculatedPayment > 0) { + remainingBalance = + principal * + ((Math.pow(1 + monthlyRate, numMonths) - Math.pow(1 + monthlyRate, estimatedPaymentsMade)) / + (Math.pow(1 + monthlyRate, numMonths) - 1)); + } + + const totalPaid = calculatedPayment * estimatedPaymentsMade; + const principalReduction = principal - Math.max(0, remainingBalance); + const interestPaid = totalPaid - principalReduction; + + updatedItem.remainingBalance = parseFloat(Math.max(0, remainingBalance).toFixed(2)); + updatedItem.totalPaid = parseFloat(totalPaid.toFixed(2)); + updatedItem.interestPaid = parseFloat(Math.max(0, interestPaid).toFixed(2)); + + return updatedItem; +} + +export const spreadsheetExampleDefaults: Partial = { + columnReordering: true, + columnResizing: true, + selectableCells: true, + selectableColumns: true, + useOddEvenRowBackground: true, + height: "70vh", + theme: "light", +}; + +export function renderSpreadsheetExample(args?: Partial): HTMLElement { + const options = { ...defaultVanillaArgs, ...spreadsheetExampleDefaults, ...args }; + + const wrapper = document.createElement("div"); + wrapper.style.padding = "2rem"; + + const h2 = document.createElement("h2"); + h2.style.marginBottom = "1rem"; + h2.textContent = "Spreadsheet"; + wrapper.appendChild(h2); + + const outer = document.createElement("div"); + outer.className = "spreadsheet-container"; + wrapper.appendChild(outer); + + const tableContainer = document.createElement("div"); + outer.appendChild(tableContainer); + + const state = { + rows: generateSpreadsheetRows(100), + additionalColumns: [] as HeaderObject[], + }; + + let tableRef: SimpleTableVanilla | null = null; + + const buildHeaders = (): HeaderObject[] => { + const baseHeaders = getSpreadsheetHeaders(state.additionalColumns); + const actionsColumn: HeaderObject = { + accessor: "actions", + label: "", + width: 100, + minWidth: 100, + filterable: false, + type: "other", + disableReorder: true, + headerRenderer: () => { + const wrap = document.createElement("div"); + wrap.style.display = "flex"; + wrap.style.justifyContent = "center"; + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "spreadsheet-add-column-btn"; + btn.textContent = "+ Add Column"; + btn.addEventListener("click", () => { + const nextIndex = BASE_SPREADSHEET_COLUMNS + state.additionalColumns.length + 1; + const accessor = `column${nextIndex}`; + const newColumn: HeaderObject = { + accessor, + label: `Column ${nextIndex}`, + width: 120, + minWidth: 80, + type: "number", + align: "right", + isEditable: true, + aggregation: { type: "sum" }, + }; + state.additionalColumns = [...state.additionalColumns, newColumn]; + state.rows = state.rows.map((r) => ({ ...r, [accessor]: 0 })); + tableRef?.update({ + defaultHeaders: buildHeaders(), + rows: state.rows, + }); + }); + wrap.appendChild(btn); + return wrap; + }, + }; + return [...baseHeaders, actionsColumn]; + }; + + const handleCellEdit = (props: CellChangeProps) => { + const { accessor, newValue, row } = props; + const acc = String(accessor); + if (acc === "actions") return; + + state.rows = state.rows.map((item) => { + if (item.id !== row.id) return item; + return applyAmortizationEdit(item, acc, newValue); + }); + tableRef?.update({ rows: state.rows }); + }; + + const table = new SimpleTableVanilla(tableContainer, { + ...options, + defaultHeaders: buildHeaders(), + enableHeaderEditing: true, + enableRowSelection: true, + onCellEdit: handleCellEdit, + customTheme: { ...(options.customTheme ?? {}), rowHeight: 22 }, + rows: state.rows, + getRowId: (params: { row?: { id?: unknown } }) => String(params.row?.id), + }); + table.mount(); + tableRef = table; + + (wrapper as HTMLElement & { _table?: SimpleTableVanilla })._table = table; + + return wrapper; +} diff --git a/packages/core/stories/examples/spreadsheet/spreadsheet-demo.css b/packages/core/stories/examples/spreadsheet/spreadsheet-demo.css new file mode 100644 index 000000000..56018083a --- /dev/null +++ b/packages/core/stories/examples/spreadsheet/spreadsheet-demo.css @@ -0,0 +1,46 @@ +/* Spreadsheet demo — compact styling (vanilla Storybook port of marketing example) */ + +.spreadsheet-container .st-cell-content { + font-size: 13px !important; +} + +.spreadsheet-container .st-header-label { + font-size: 13px !important; + font-weight: 500 !important; +} + +.spreadsheet-container .simple-table-cell { + padding: 4px 8px !important; +} + +.spreadsheet-container .simple-table { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, + sans-serif; +} + +.spreadsheet-container .st-header-resize-handle { + height: 10px !important; +} + +.spreadsheet-container .st-checkbox-custom { + min-height: 14px !important; + min-width: 14px !important; + max-height: 14px !important; + max-width: 14px !important; +} + +.spreadsheet-container .spreadsheet-add-column-btn { + color: #fff; + border: none; + padding: 4px 10px; + border-radius: 4px; + cursor: pointer; + font-size: 11px; + font-weight: 500; + white-space: nowrap; + background: #2563eb; +} + +.spreadsheet-container .spreadsheet-add-column-btn:hover { + background: #1d4ed8; +} diff --git a/packages/react/src/SimpleTable.tsx b/packages/react/src/SimpleTable.tsx index b6066e990..943499997 100644 --- a/packages/react/src/SimpleTable.tsx +++ b/packages/react/src/SimpleTable.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef } from "react"; import { SimpleTableVanilla } from "simple-table-core"; -import type { TableAPI } from "simple-table-core"; +import type { SimpleTableConfig, TableAPI } from "simple-table-core"; import { buildVanillaConfig } from "./buildVanillaConfig"; import type { SimpleTableReactProps, TableInstance } from "./types"; @@ -34,6 +34,10 @@ const SimpleTable = React.forwardRef( // SimpleTableVanilla class — so the component stays decoupled from // internal implementation details. const instanceRef = useRef(null); + /** Last `defaultHeaders` array reference applied via `update` (full config). */ + const syncedDefaultHeadersRef = useRef( + undefined, + ); // forwardRef omits `ref` from props at the type level; cast it back so // buildVanillaConfig receives the complete SimpleTableReactProps shape. @@ -43,10 +47,7 @@ const SimpleTable = React.forwardRef( useEffect(() => { if (!containerRef.current) return; - const instance = new SimpleTableVanilla( - containerRef.current, - buildVanillaConfig(reactProps) - ); + const instance = new SimpleTableVanilla(containerRef.current, buildVanillaConfig(reactProps)); instance.mount(); instanceRef.current = instance; @@ -62,6 +63,7 @@ const SimpleTable = React.forwardRef( return () => { instance.destroy(); instanceRef.current = null; + syncedDefaultHeadersRef.current = undefined; if (ref && typeof ref !== "function") { ref.current = null; } @@ -70,12 +72,25 @@ const SimpleTable = React.forwardRef( }, []); // Sync prop changes to the vanilla instance after every render. + // When `defaultHeaders` keeps the same reference, omit it so core does not + // reset internal header state (widths, reorder results). New reference = new columns. useEffect(() => { - instanceRef.current?.update(buildVanillaConfig(reactProps)); + const instance = instanceRef.current; + if (!instance) return; + + const fullConfig = buildVanillaConfig(reactProps); + if (syncedDefaultHeadersRef.current !== reactProps.defaultHeaders) { + syncedDefaultHeadersRef.current = reactProps.defaultHeaders; + instance.update(fullConfig); + return; + } + + const { defaultHeaders: _headers, ...rest } = fullConfig; + instance.update(rest as Partial); }); return
; - } + }, ); SimpleTable.displayName = "SimpleTable"; From 0900810e664fe51a3a56610d5d348cbd5c96a2ad Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:50:03 -0500 Subject: [PATCH 2/8] Dropdown fixes --- packages/core/src/styles/base.css | 19 +--- .../src/utils/filters/createCustomSelect.ts | 1 + .../src/utils/filters/createDateFilter.ts | 1 + .../core/src/utils/filters/createDropdown.ts | 101 +++++++++++++----- .../core/src/utils/headerCell/filtering.ts | 5 +- 5 files changed, 84 insertions(+), 43 deletions(-) diff --git a/packages/core/src/styles/base.css b/packages/core/src/styles/base.css index 7ca554ab0..4990a79cb 100644 --- a/packages/core/src/styles/base.css +++ b/packages/core/src/styles/base.css @@ -1494,21 +1494,12 @@ input { cursor: default; } -/* Position variants */ -.st-dropdown-bottom-left { - margin-top: 4px; -} - -.st-dropdown-bottom-right { - margin-top: 4px; -} - -.st-dropdown-top-left { - margin-bottom: 4px; -} - +/* Position variants — vertical offset comes from JS (React Dropdown parity: +4px only) */ +.st-dropdown-bottom-left, +.st-dropdown-bottom-right, +.st-dropdown-top-left, .st-dropdown-top-right { - margin-bottom: 4px; + margin: 0; } /* Dropdown items */ diff --git a/packages/core/src/utils/filters/createCustomSelect.ts b/packages/core/src/utils/filters/createCustomSelect.ts index b2d55d3a0..297f20fcd 100644 --- a/packages/core/src/utils/filters/createCustomSelect.ts +++ b/packages/core/src/utils/filters/createCustomSelect.ts @@ -106,6 +106,7 @@ export const createCustomSelect = (options: CreateCustomSelectOptions) => { children: optionsContainer, containerRef, mainBodyRef, + anchorElement: trigger, onClose: () => { setOpen(false); }, diff --git a/packages/core/src/utils/filters/createDateFilter.ts b/packages/core/src/utils/filters/createDateFilter.ts index 8a54cb369..7ee1b7b5e 100644 --- a/packages/core/src/utils/filters/createDateFilter.ts +++ b/packages/core/src/utils/filters/createDateFilter.ts @@ -156,6 +156,7 @@ export const createDateFilter = (options: CreateDateFilterOptions) => { children: picker.element, containerRef, mainBodyRef, + anchorElement: input, onClose: () => { isOpen = false; }, diff --git a/packages/core/src/utils/filters/createDropdown.ts b/packages/core/src/utils/filters/createDropdown.ts index 5d4795eab..a8ee3d73a 100644 --- a/packages/core/src/utils/filters/createDropdown.ts +++ b/packages/core/src/utils/filters/createDropdown.ts @@ -1,23 +1,40 @@ +/** + * Filter / filter-UI dropdown positioning (React-era parity): + * - Fixed: portaled under `.simple-table-root`, anchored to `anchorElement` (e.g. filter icon) so + * `overflow: hidden` on `.st-header-cell` does not clip the panel. Same top/left +4px and + * container-based flip as legacy React Dropdown.tsx. + * - Absolute: stays under caller’s parent (e.g. `.st-custom-select`); use `anchorElement` for the + * trigger rect vs `position: relative` parent (React CustomSelect pattern). + */ + export interface CreateDropdownOptions { children: HTMLElement; containerRef?: HTMLElement; mainBodyRef?: HTMLElement; + /** Rect used for placement. Required for fixed portaling (e.g. filter icon); for absolute, pass the real trigger (button/input) when it differs from the dropdown parent. */ + anchorElement?: HTMLElement; onClose: () => void; open: boolean; overflow?: "auto" | "visible" | "hidden"; width?: number; + maxWidth?: number; positioning?: "fixed" | "absolute"; } +const resolveTableRoot = (el?: HTMLElement | null): HTMLElement | null => + el?.closest(".simple-table-root") ?? null; + export const createDropdown = (options: CreateDropdownOptions) => { let { children, containerRef, mainBodyRef, + anchorElement: anchorOption, onClose, open, overflow = "auto", width, + maxWidth, positioning = "fixed", } = options; @@ -28,6 +45,9 @@ export const createDropdown = (options: CreateDropdownOptions) => { if (width) { dropdownElement.style.width = `${width}px`; } + if (maxWidth) { + dropdownElement.style.maxWidth = `${maxWidth}px`; + } dropdownElement.addEventListener("click", (e) => e.stopPropagation()); dropdownElement.addEventListener("mousedown", (e) => e.stopPropagation()); @@ -35,21 +55,34 @@ export const createDropdown = (options: CreateDropdownOptions) => { dropdownElement.appendChild(children); - let triggerElement: HTMLElement | null = null; + let anchorElement: HTMLElement | undefined = anchorOption; + + if (positioning === "fixed") { + const root = + resolveTableRoot(anchorElement) ?? + resolveTableRoot(mainBodyRef ?? null) ?? + resolveTableRoot(containerRef ?? null) ?? + (document.querySelector(".simple-table-root") as HTMLElement | null); + (root ?? document.body).appendChild(dropdownElement); + } + + const effectiveAnchor = (): HTMLElement | null => { + if (anchorElement) return anchorElement; + return dropdownElement.parentElement; + }; const calculatePosition = () => { - if (!open || !dropdownElement.parentElement) return; + if (!open) return; + if (positioning === "absolute" && !dropdownElement.parentElement) return; + if (positioning === "fixed" && !dropdownElement.parentElement) return; dropdownElement.style.visibility = "hidden"; - if (!triggerElement) { - triggerElement = dropdownElement.parentElement; - } + const anchorEl = effectiveAnchor(); + if (!anchorEl) return; requestAnimationFrame(() => { - if (!triggerElement) return; - - const triggerRect = triggerElement.getBoundingClientRect(); + const anchorRect = anchorEl.getBoundingClientRect(); const dropdownHeight = dropdownElement.offsetHeight; const dropdownWidth = width || dropdownElement.offsetWidth; @@ -73,9 +106,9 @@ export const createDropdown = (options: CreateDropdownOptions) => { } as DOMRect; } - const spaceBottom = containerRect.bottom - triggerRect.bottom; - const spaceTop = triggerRect.top - containerRect.top; - const spaceRight = containerRect.right - triggerRect.right; + const spaceBottom = containerRect.bottom - anchorRect.bottom; + const spaceTop = anchorRect.top - containerRect.top; + const spaceRight = containerRect.right - anchorRect.right; let verticalPosition = "bottom"; if (dropdownHeight > spaceBottom && dropdownHeight <= spaceTop) { @@ -85,40 +118,44 @@ export const createDropdown = (options: CreateDropdownOptions) => { } let horizontalPosition = "left"; - if (dropdownWidth > spaceRight + triggerRect.width) { + if (dropdownWidth > spaceRight + anchorRect.width) { horizontalPosition = "right"; } if (positioning === "fixed") { if (verticalPosition === "bottom") { - dropdownElement.style.top = `${triggerRect.bottom + 4}px`; + dropdownElement.style.top = `${anchorRect.bottom + 4}px`; dropdownElement.style.bottom = "auto"; } else { - dropdownElement.style.bottom = `${window.innerHeight - triggerRect.top + 4}px`; + dropdownElement.style.bottom = `${window.innerHeight - anchorRect.top + 4}px`; dropdownElement.style.top = "auto"; } if (horizontalPosition === "left") { - dropdownElement.style.left = `${triggerRect.left}px`; + dropdownElement.style.left = `${anchorRect.left}px`; dropdownElement.style.right = "auto"; } else { - dropdownElement.style.right = `${window.innerWidth - triggerRect.right}px`; + dropdownElement.style.right = `${window.innerWidth - anchorRect.right}px`; dropdownElement.style.left = "auto"; } } else { + const positionParent = dropdownElement.parentElement; + if (!positionParent) return; + const pRect = positionParent.getBoundingClientRect(); + if (verticalPosition === "bottom") { - dropdownElement.style.top = `${triggerRect.height + 4}px`; + dropdownElement.style.top = `${anchorRect.bottom - pRect.top + 4}px`; dropdownElement.style.bottom = "auto"; } else { - dropdownElement.style.bottom = `${triggerRect.height + 4}px`; + dropdownElement.style.bottom = `${pRect.bottom - anchorRect.top + 4}px`; dropdownElement.style.top = "auto"; } if (horizontalPosition === "left") { - dropdownElement.style.left = "0"; + dropdownElement.style.left = `${anchorRect.left - pRect.left}px`; dropdownElement.style.right = "auto"; } else { - dropdownElement.style.right = "0"; + dropdownElement.style.right = `${pRect.right - anchorRect.right}px`; dropdownElement.style.left = "auto"; } } @@ -139,13 +176,18 @@ export const createDropdown = (options: CreateDropdownOptions) => { }; const handleClickOutside = (event: MouseEvent | KeyboardEvent) => { - if (dropdownElement && !dropdownElement.contains(event.target as Node)) { - const parentElement = dropdownElement.parentElement; - if (parentElement && !parentElement.contains(event.target as Node)) { - setOpen(false); - onClose?.(); - } + const target = event.target as Node; + if (dropdownElement.contains(target)) return; + + if (anchorElement?.contains(target)) return; + + if (positioning === "absolute") { + const host = dropdownElement.parentElement; + if (host?.contains(target)) return; } + + setOpen(false); + onClose?.(); }; const handleEscKey = (event: KeyboardEvent) => { @@ -180,6 +222,9 @@ export const createDropdown = (options: CreateDropdownOptions) => { } const update = (newOptions: Partial) => { + if (newOptions.anchorElement !== undefined) { + anchorElement = newOptions.anchorElement; + } if (newOptions.open !== undefined) { setOpen(newOptions.open); } @@ -191,6 +236,10 @@ export const createDropdown = (options: CreateDropdownOptions) => { width = newOptions.width; dropdownElement.style.width = width ? `${width}px` : "auto"; } + if (newOptions.maxWidth !== undefined) { + maxWidth = newOptions.maxWidth; + dropdownElement.style.maxWidth = maxWidth ? `${maxWidth}px` : ""; + } if (newOptions.overflow !== undefined) { overflow = newOptions.overflow; dropdownElement.style.overflow = overflow; diff --git a/packages/core/src/utils/headerCell/filtering.ts b/packages/core/src/utils/headerCell/filtering.ts index 6ae32e48d..77e1aae98 100644 --- a/packages/core/src/utils/headerCell/filtering.ts +++ b/packages/core/src/utils/headerCell/filtering.ts @@ -107,6 +107,7 @@ export const createFilterIcon = ( children: filterDropdownInstance.element, containerRef: containerElement, mainBodyRef: containerElement, + anchorElement: iconContainer, onClose: () => { isFilterDropdownOpen = false; iconContainer.setAttribute("aria-expanded", "false"); @@ -118,10 +119,8 @@ export const createFilterIcon = ( open: true, overflow: "auto", positioning: "fixed", - width: 280, + maxWidth: 280, }); - - iconContainer.appendChild(dropdownInstance.element); } else { if (dropdownInstance) { dropdownInstance.destroy(); From 54e170b2a5b19bf5441f74766c386273ea5083e0 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:55:22 -0500 Subject: [PATCH 3/8] Inner dropdown fixes --- packages/core/src/styles/base.css | 13 +++++++++ .../core/src/utils/filters/createDropdown.ts | 28 +++++++++++++++++-- .../core/src/utils/headerCell/filtering.ts | 2 +- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/core/src/styles/base.css b/packages/core/src/styles/base.css index 4990a79cb..565a6c2db 100644 --- a/packages/core/src/styles/base.css +++ b/packages/core/src/styles/base.css @@ -1494,6 +1494,19 @@ input { cursor: default; } +/* Filter popover (and similar): nested .st-dropdown-content menus must not be clipped by the shell. + overflow:auto + max-height on the parent creates a scrollport that clips absolutely positioned + children (operator CustomSelect, date picker, etc.). */ +.st-dropdown-content.st-dropdown-content--allow-descendant-overflow { + overflow: visible; + max-height: none; +} + +/* Nested filter UI panels (absolute) stack above the fixed filter shell */ +.st-filter-container .st-dropdown-content { + z-index: 101; +} + /* Position variants — vertical offset comes from JS (React Dropdown parity: +4px only) */ .st-dropdown-bottom-left, .st-dropdown-bottom-right, diff --git a/packages/core/src/utils/filters/createDropdown.ts b/packages/core/src/utils/filters/createDropdown.ts index a8ee3d73a..a868bcf65 100644 --- a/packages/core/src/utils/filters/createDropdown.ts +++ b/packages/core/src/utils/filters/createDropdown.ts @@ -19,6 +19,12 @@ export interface CreateDropdownOptions { width?: number; maxWidth?: number; positioning?: "fixed" | "absolute"; + /** + * When true, this panel does not clip overflowing descendants (e.g. nested operator menus inside + * the filter popover). Uses overflow: visible and drops max-height on the shell — see + * `.st-dropdown-content--allow-descendant-overflow` in base.css. + */ + allowDescendantOverflow?: boolean; } const resolveTableRoot = (el?: HTMLElement | null): HTMLElement | null => @@ -36,12 +42,23 @@ export const createDropdown = (options: CreateDropdownOptions) => { width, maxWidth, positioning = "fixed", + allowDescendantOverflow = false, } = options; + let allowDescendantOverflowFlag = allowDescendantOverflow; + + const descendantOverflowClass = "st-dropdown-content--allow-descendant-overflow"; + + const placementClassSuffix = () => + allowDescendantOverflowFlag ? ` ${descendantOverflowClass}` : ""; + const dropdownElement = document.createElement("div"); dropdownElement.className = "st-dropdown-content"; + if (allowDescendantOverflowFlag) { + dropdownElement.classList.add(descendantOverflowClass); + } dropdownElement.style.position = positioning; - dropdownElement.style.overflow = overflow; + dropdownElement.style.overflow = allowDescendantOverflowFlag ? "visible" : overflow; if (width) { dropdownElement.style.width = `${width}px`; } @@ -160,7 +177,7 @@ export const createDropdown = (options: CreateDropdownOptions) => { } } - dropdownElement.className = `st-dropdown-content st-dropdown-${verticalPosition}-${horizontalPosition}`; + dropdownElement.className = `st-dropdown-content st-dropdown-${verticalPosition}-${horizontalPosition}${placementClassSuffix()}`; dropdownElement.style.visibility = "visible"; }); }; @@ -242,7 +259,12 @@ export const createDropdown = (options: CreateDropdownOptions) => { } if (newOptions.overflow !== undefined) { overflow = newOptions.overflow; - dropdownElement.style.overflow = overflow; + dropdownElement.style.overflow = allowDescendantOverflowFlag ? "visible" : overflow; + } + if (newOptions.allowDescendantOverflow !== undefined) { + allowDescendantOverflowFlag = newOptions.allowDescendantOverflow; + dropdownElement.classList.toggle(descendantOverflowClass, allowDescendantOverflowFlag); + dropdownElement.style.overflow = allowDescendantOverflowFlag ? "visible" : overflow; } }; diff --git a/packages/core/src/utils/headerCell/filtering.ts b/packages/core/src/utils/headerCell/filtering.ts index 77e1aae98..2b7904d2e 100644 --- a/packages/core/src/utils/headerCell/filtering.ts +++ b/packages/core/src/utils/headerCell/filtering.ts @@ -117,9 +117,9 @@ export const createFilterIcon = ( } }, open: true, - overflow: "auto", positioning: "fixed", maxWidth: 280, + allowDescendantOverflow: true, }); } else { if (dropdownInstance) { From 6e60d7888949a097ca6a071365cd19779931f056 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:00:58 -0500 Subject: [PATCH 4/8] Button style fixes --- packages/core/src/styles/base.css | 25 ++++++++++- .../src/utils/filters/createCustomSelect.ts | 9 ++-- .../src/utils/filters/createEnumFilter.ts | 12 +++--- .../src/utils/filters/createFilterActions.ts | 42 ++++++++++++++----- 4 files changed, 66 insertions(+), 22 deletions(-) diff --git a/packages/core/src/styles/base.css b/packages/core/src/styles/base.css index 565a6c2db..0bc94409e 100644 --- a/packages/core/src/styles/base.css +++ b/packages/core/src/styles/base.css @@ -1772,12 +1772,19 @@ input { font-size: 0.9em; font-weight: 500; font-family: inherit; + appearance: none; + -webkit-appearance: none; transition: background-color var(--st-transition-duration) var(--st-transition-ease); } +/* Keyboard only — avoids a stuck ring after mouse click (especially on bordered Clear) */ .st-filter-button:focus { + outline: none; +} + +.st-filter-button:focus-visible { outline: 2px solid var(--st-focus-ring-color); - outline-offset: -2px; + outline-offset: 2px; } /* Apply Button */ @@ -1829,6 +1836,9 @@ input { justify-content: space-between; gap: var(--st-spacing-medium); outline: none; + appearance: none; + -webkit-appearance: none; + text-align: left; transition: border-color var(--st-transition-duration) var(--st-transition-ease); } @@ -1928,6 +1938,12 @@ input { overflow: auto; } +/* Enum rows: left-align checkbox + label (full-width row); default .st-checkbox-label is centered */ +.st-enum-filter-options .st-checkbox-label { + justify-content: flex-start; + width: 100%; +} + /* Select All checkbox styling */ .st-enum-select-all { padding-bottom: var(--st-spacing-small); @@ -1952,6 +1968,13 @@ input { user-select: none; } +.st-enum-filter-options .st-enum-option-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + /* No results message */ .st-enum-no-results { padding: var(--st-spacing-medium); diff --git a/packages/core/src/utils/filters/createCustomSelect.ts b/packages/core/src/utils/filters/createCustomSelect.ts index 297f20fcd..270611e94 100644 --- a/packages/core/src/utils/filters/createCustomSelect.ts +++ b/packages/core/src/utils/filters/createCustomSelect.ts @@ -64,11 +64,14 @@ export const createCustomSelect = (options: CreateCustomSelectOptions) => { const selectedOption = selectOptions.find((opt) => opt.value === value); valueSpan.textContent = selectedOption ? selectedOption.label : placeholder; - const arrowSpan = document.createElement("span"); - arrowSpan.innerHTML = SELECT_ICON_SVG; + // Match React CustomSelect: SVG is a direct child of the trigger so `.st-custom-select-arrow` + // is the flex item (flex-shrink, rotate) — not an unstyled wrapper span. + const iconTemplate = document.createElement("template"); + iconTemplate.innerHTML = SELECT_ICON_SVG.trim(); + const arrowIcon = iconTemplate.content.firstElementChild as SVGElement; trigger.appendChild(valueSpan); - trigger.appendChild(arrowSpan); + trigger.appendChild(arrowIcon); container.appendChild(trigger); const optionsContainer = document.createElement("div"); diff --git a/packages/core/src/utils/filters/createEnumFilter.ts b/packages/core/src/utils/filters/createEnumFilter.ts index 69311f0d8..8a055b768 100644 --- a/packages/core/src/utils/filters/createEnumFilter.ts +++ b/packages/core/src/utils/filters/createEnumFilter.ts @@ -57,8 +57,9 @@ export const createEnumFilter = (options: CreateEnumFilterOptions) => { selectAllLabel.className = "st-enum-option-label st-enum-select-all-label"; selectAllLabel.textContent = "Select All"; + // Match React EnumFilter: label text is a child of