diff --git a/packages/angular/package.json b/packages/angular/package.json index ef108d487..4c024aaf3 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/angular", - "version": "3.0.0-beta.18", + "version": "3.0.0-beta.19", "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 8c11936e0..9fa4e185f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "simple-table-core", - "version": "3.0.0-beta.18", + "version": "3.0.0-beta.19", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/src/index.d.ts", 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/styles/base.css b/packages/core/src/styles/base.css index 7ca554ab0..0bc94409e 100644 --- a/packages/core/src/styles/base.css +++ b/packages/core/src/styles/base.css @@ -1494,21 +1494,25 @@ input { cursor: default; } -/* Position variants */ -.st-dropdown-bottom-left { - margin-top: 4px; +/* 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; } -.st-dropdown-bottom-right { - margin-top: 4px; -} - -.st-dropdown-top-left { - margin-bottom: 4px; +/* 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, +.st-dropdown-top-left, .st-dropdown-top-right { - margin-bottom: 4px; + margin: 0; } /* Dropdown items */ @@ -1768,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 */ @@ -1825,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); } @@ -1924,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); @@ -1948,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/styles/themes/modern-dark.css b/packages/core/src/styles/themes/modern-dark.css index 164d99d87..a296f7823 100644 --- a/packages/core/src/styles/themes/modern-dark.css +++ b/packages/core/src/styles/themes/modern-dark.css @@ -5,7 +5,7 @@ * - Dark gray base with lighter text for reduced eye strain * - Hover states with subtle brightness changes * - Generous padding (12px) for better readability - * - Rounded corners (8px) for a softer appearance + * - Rounded corners (4px) for a clean, compact appearance * - Modern blue accent (#60a5fa) for interactive elements (lighter for dark mode) * - High contrast text for accessibility * - Lightweight visual hierarchy @@ -14,7 +14,7 @@ */ .theme-modern-dark { /* Layout/Structure variables - Tighter, more compact */ - --st-border-radius: 8px; + --st-border-radius: 4px; --st-cell-padding: 12px; /* Spacing variables - Reduced for cleaner look */ @@ -205,7 +205,7 @@ /* Cleaner pagination buttons - more compact */ .theme-modern-dark .st-page-btn { - border-radius: 6px; + border-radius: 4px; font-size: 13px; font-weight: 500; color: #d1d5db; @@ -229,7 +229,7 @@ /* Next/Prev buttons */ .theme-modern-dark .st-next-prev-btn { padding: 6px 8px; - border-radius: 6px; + border-radius: 4px; transition: all 0.15s ease; margin-left: 4px; flex-shrink: 0; diff --git a/packages/core/src/styles/themes/modern-light.css b/packages/core/src/styles/themes/modern-light.css index 8a8baeb5b..08b1a218a 100644 --- a/packages/core/src/styles/themes/modern-light.css +++ b/packages/core/src/styles/themes/modern-light.css @@ -5,7 +5,7 @@ * - White backgrounds with minimal color variation * - Hover states instead of alternating row colors * - Generous padding (12px) for better readability - * - Rounded corners (8px) for a softer appearance + * - Rounded corners (4px) for a clean, compact appearance * - Modern blue accent (#3b82f6) for interactive elements * - High contrast text (#111827) for accessibility * - Lightweight visual hierarchy @@ -14,7 +14,7 @@ */ .theme-modern-light { /* Layout/Structure variables - Tighter, more compact */ - --st-border-radius: 8px; + --st-border-radius: 4px; --st-cell-padding: 12px; /* Spacing variables - Reduced for cleaner look */ @@ -205,7 +205,7 @@ /* Cleaner pagination buttons - more compact */ .theme-modern-light .st-page-btn { - border-radius: 6px; + border-radius: 4px; font-size: 13px; font-weight: 500; color: #374151; @@ -229,7 +229,7 @@ /* Next/Prev buttons */ .theme-modern-light .st-next-prev-btn { padding: 6px 8px; - border-radius: 6px; + border-radius: 4px; transition: all 0.15s ease; margin-left: 4px; flex-shrink: 0; diff --git a/packages/core/src/utils/filters/createCustomSelect.ts b/packages/core/src/utils/filters/createCustomSelect.ts index b2d55d3a0..c68ef1489 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"); @@ -106,6 +109,7 @@ export const createCustomSelect = (options: CreateCustomSelectOptions) => { children: optionsContainer, containerRef, mainBodyRef, + anchorElement: trigger, onClose: () => { setOpen(false); }, @@ -116,7 +120,14 @@ export const createCustomSelect = (options: CreateCustomSelectOptions) => { container.appendChild(dropdown.element); + const syncValueFromSelection = (optionValue: string) => { + value = optionValue; + const opt = selectOptions.find((o) => o.value === value); + valueSpan.textContent = opt ? opt.label : placeholder; + }; + const handleOptionClick = (optionValue: string) => { + syncValueFromSelection(optionValue); onChange(optionValue); setOpen(false); focusedIndex = -1; @@ -152,7 +163,9 @@ export const createCustomSelect = (options: CreateCustomSelectOptions) => { case "Enter": event.preventDefault(); if (focusedIndex >= 0) { - onChange(selectOptions[focusedIndex].value); + const v = selectOptions[focusedIndex].value; + syncValueFromSelection(v); + onChange(v); setOpen(false); focusedIndex = -1; renderOptions(); 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..9d5310f27 100644 --- a/packages/core/src/utils/filters/createDropdown.ts +++ b/packages/core/src/utils/filters/createDropdown.ts @@ -1,33 +1,70 @@ +/** + * 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"; + /** + * 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 => + 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", + 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`; } + if (maxWidth) { + dropdownElement.style.maxWidth = `${maxWidth}px`; + } dropdownElement.addEventListener("click", (e) => e.stopPropagation()); dropdownElement.addEventListener("mousedown", (e) => e.stopPropagation()); @@ -35,21 +72,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 +123,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,45 +135,49 @@ 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"; } } - dropdownElement.className = `st-dropdown-content st-dropdown-${verticalPosition}-${horizontalPosition}`; + dropdownElement.className = `st-dropdown-content st-dropdown-${verticalPosition}-${horizontalPosition}${placementClassSuffix()}`; dropdownElement.style.visibility = "visible"; }); }; @@ -139,13 +193,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) => { @@ -155,6 +214,13 @@ export const createDropdown = (options: CreateDropdownOptions) => { } }; + /** React Dropdown only ran outside logic on Enter for keydown; running on every key broke nested menus. */ + const handleOutsideKeydown = (event: KeyboardEvent) => { + if (event.key === "Enter") { + handleClickOutside(event); + } + }; + const setOpen = (newOpen: boolean) => { open = newOpen; if (open) { @@ -162,13 +228,13 @@ export const createDropdown = (options: CreateDropdownOptions) => { calculatePosition(); window.addEventListener("scroll", handleScroll, true); document.addEventListener("mousedown", handleClickOutside, true); - document.addEventListener("keydown", handleClickOutside, true); + document.addEventListener("keydown", handleOutsideKeydown, true); document.addEventListener("keydown", handleEscKey); } else { dropdownElement.style.display = "none"; window.removeEventListener("scroll", handleScroll, true); document.removeEventListener("mousedown", handleClickOutside, true); - document.removeEventListener("keydown", handleClickOutside, true); + document.removeEventListener("keydown", handleOutsideKeydown, true); document.removeEventListener("keydown", handleEscKey); } }; @@ -180,6 +246,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,9 +260,18 @@ 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; + 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/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