From 44fe111ca09bc54bd25eeb0ab84f60e5501b5d47 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 2 Jun 2026 16:07:22 +0200 Subject: [PATCH] fix(eidos): dropdown-menu z-index + finish ActiveDom canonization (tabs, resize-observer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bleed-through: the portaled [data-dropdown-menu-content] had z-index:auto, so any positioned page element with a positive z-index (e.g. a selected [data-toggle-group-item], z-index 1) painted THROUGH it. In the Words inspector the Add-block menu showed the Typography toggle chips ("Predeterminada" / "Normal") bleeding through. Added `content-z: 80` to the dropdown-menu recipe (matches `select`, its sibling click-triggered overlay) + `z-index: var(--dropdown-menu-content-z, 80)` on content + sub-content; regenerated base.css. The soma floating layer reads this computed z-index and mirrors it onto the positioner wrapper. The rest of the menu family (context-menu / menubar / navigation-menu) shares the same latent gap — deferred. Canonization (finishes the dom.raf rollout — zero raw requestAnimationFrame left in src/uix): - tabs-indicator.svelte: requestAnimationFrame/cancelAnimationFrame + new MutationObserver + new ResizeObserver -> eidos.dom.raf / observeMutation / observeResize (disposer pattern via ActiveEidos.require). - tabs.svelte: new ResizeObserver -> eidos.dom.observeResize. - resize-observer.svelte.ts: requestFrame/cancelFrame (raw handle) -> dom.raf (disposer); fixed the stale "Debounces via requestAnimationFrame" comment. Browser-verified in the Words demo: column Add-block menu is opaque and on top (elementFromPoint at the chip centers returns menu items, inMenu:true); the menu options are collapsed + localized (one Encabezado, one Lista con viñetas); empty-column delete works (column persists addressable with 0 children). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dropdown-menu/dropdown-menu.css | 5 ++ .../components/tabs/tabs-indicator.svelte | 57 ++++++++++--------- src/uix/eidos/components/tabs/tabs.svelte | 6 +- src/uix/eidos/generated/base.css | 1 + src/uix/eidos/lib/recipes/base.ts | 6 ++ src/uix/soma/layers/resize-observer.svelte.ts | 12 ++-- 6 files changed, 52 insertions(+), 35 deletions(-) diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu.css b/src/uix/eidos/components/dropdown-menu/dropdown-menu.css index 3d66605f7..0063a1fa3 100644 --- a/src/uix/eidos/components/dropdown-menu/dropdown-menu.css +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu.css @@ -26,6 +26,11 @@ [data-dropdown-menu-content], [data-dropdown-menu-sub-content] { + /* Top overlay band so the portaled panel paints above positioned page + * content (e.g. selected toggle-group chips with z-index 1). The soma + * floating layer reads this computed z-index and mirrors it onto the + * positioner wrapper. */ + z-index: var(--dropdown-menu-content-z, 80); min-inline-size: var(--dropdown-menu-content-min-width, 12rem); max-block-size: var(--dropdown-menu-content-max-height, min(60vh, 22rem)); overflow-y: auto; diff --git a/src/uix/eidos/components/tabs/tabs-indicator.svelte b/src/uix/eidos/components/tabs/tabs-indicator.svelte index 7b743e5c3..f722cd9cf 100644 --- a/src/uix/eidos/components/tabs/tabs-indicator.svelte +++ b/src/uix/eidos/components/tabs/tabs-indicator.svelte @@ -16,18 +16,25 @@ * snaps to the active trigger). */ import { composeInlineStyle } from '$uix/eidos/lib/style'; + import { ActiveEidos } from '$uix/eidos'; import * as Tabs from '$soma/components/tabs'; import type { TabsIndicatorProps } from './types'; let { ref = $bindable(null), style, ...rest }: TabsIndicatorProps = $props(); + const eidos = ActiveEidos.require(); + let geom = $state({ x: 0, y: 0, w: 0, h: 0, ready: false }); - let rafId: number | null = null; + // Disposer for the pending measure frame (null when none scheduled); + // doubles as the coalescing guard. Routed through `eidos.dom.raf` (the + // canonical ActiveDom frame scheduler — iframe / popup / happy-dom safe) + // instead of a bare `requestAnimationFrame`. + let cancelFrame: (() => void) | null = null; function measure() { - if (rafId !== null) return; - rafId = requestAnimationFrame(() => { - rafId = null; + if (cancelFrame !== null) return; + cancelFrame = eidos.dom.raf(() => { + cancelFrame = null; const el = ref; if (!el) return; const list = el.parentElement; @@ -43,7 +50,7 @@ h: triggerRect.height, ready: true }; - }); + }, ref); } const geometryStyle = $derived( @@ -61,34 +68,30 @@ measure(); - const mo = new MutationObserver(() => measure()); - mo.observe(list, { - subtree: true, - attributes: true, - attributeFilter: ['data-state', 'data-value'] - }); - - const ro = new ResizeObserver(() => measure()); - ro.observe(list); + // All DOM observation routed through ActiveDom (iframe / popup safe, + // cleanup tracked) instead of bare `new MutationObserver` / + // `new ResizeObserver`. Each returns its own disposer. + const disposers = [ + eidos.dom.observeMutation(list, () => measure(), { + subtree: true, + attributes: true, + attributeFilter: ['data-state', 'data-value'] + }), + eidos.dom.observeResize(list, () => measure()) + ]; for (const child of Array.from(list.children)) { if (child === el) continue; - if (child instanceof HTMLElement) ro.observe(child); + if (child instanceof HTMLElement) { + disposers.push(eidos.dom.observeResize(child, () => measure())); + } } return () => { - mo.disconnect(); - ro.disconnect(); - if (rafId !== null) { - cancelAnimationFrame(rafId); - rafId = null; - } + for (const dispose of disposers) dispose?.(); + cancelFrame?.(); + cancelFrame = null; }; }); - + diff --git a/src/uix/eidos/components/tabs/tabs.svelte b/src/uix/eidos/components/tabs/tabs.svelte index 4473a746e..1d53b9118 100644 --- a/src/uix/eidos/components/tabs/tabs.svelte +++ b/src/uix/eidos/components/tabs/tabs.svelte @@ -79,13 +79,13 @@ isCollapsed = false; return; } - const ro = new ResizeObserver((entries) => { + // Routed through ActiveDom (iframe / popup safe, cleanup tracked) + // instead of a bare `new ResizeObserver`. + return eidos.dom.observeResize(el, (entries) => { for (const entry of entries) { isCollapsed = entry.contentRect.width < px; } }); - ro.observe(el); - return () => ro.disconnect(); }); diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index df54ea400..3ee593a5e 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -3064,6 +3064,7 @@ --splitter-transition-ease: var(--ease-default); --navigation-menu-indicator-w: 0px; --navigation-menu-indicator-x: 0px; + --dropdown-menu-content-z: 80; --dropdown-menu-content-min-width: 12rem; --dropdown-menu-content-max-height: min(60vh, 22rem); --dropdown-menu-content-padding: var(--space-1); diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts index 93a3c49be..6b19d037c 100644 --- a/src/uix/eidos/lib/recipes/base.ts +++ b/src/uix/eidos/lib/recipes/base.ts @@ -4037,6 +4037,12 @@ export const THEME_BASE_RECIPE_TOKENS = { // DropdownMenu — panel chrome + item rows. Mirror tokens to context-menu. // ───────────────────────────────────────────────────────────────────── 'dropdown-menu': { + // Floating menu panel stacks in the top overlay band (matches + // `select` — both are click-triggered list overlays). Without this + // the portaled panel inherits `z-index: auto` and any positioned + // page element with a positive z-index (e.g. a selected + // `[data-toggle-group-item]`, z-index 1) paints THROUGH it. + 'content-z': '80', 'content-min-width': '12rem', 'content-max-height': 'min(60vh, 22rem)', 'content-padding': 'var(--space-1)', diff --git a/src/uix/soma/layers/resize-observer.svelte.ts b/src/uix/soma/layers/resize-observer.svelte.ts index 2c0adba44..f51137c69 100644 --- a/src/uix/soma/layers/resize-observer.svelte.ts +++ b/src/uix/soma/layers/resize-observer.svelte.ts @@ -4,22 +4,24 @@ type Getter = () => T; /** * Wraps native ResizeObserver with Svelte $effect lifecycle. - * Debounces via requestAnimationFrame. + * Debounces via `dom.raf` (the canonical ActiveDom frame scheduler — + * iframe / popup / happy-dom safe; returns a disposer rather than a raw + * handle, so the per-resize frame is cancelled cleanly). */ export class ResizeObserver$ { constructor(dom: ActiveDom, node: Getter, onResize: () => void) { $effect(() => { - let rAF = 0; + let cancelFrame: (() => void) | null = null; const el = node(); if (!el) return; const cleanupResize = dom.observeResize(el, () => { - dom.cancelFrame(rAF, el); - rAF = dom.requestFrame(onResize, el); + cancelFrame?.(); + cancelFrame = dom.raf(onResize, el); }); return () => { - dom.cancelFrame(rAF, el); + cancelFrame?.(); cleanupResize(); }; });