fix(eidos): dropdown-menu z-index + finish ActiveDom canonization (tabs, resize-observer)

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) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 64e13d1dd6
commit 44fe111ca0

@ -26,6 +26,11 @@
[data-dropdown-menu-content], [data-dropdown-menu-content],
[data-dropdown-menu-sub-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); min-inline-size: var(--dropdown-menu-content-min-width, 12rem);
max-block-size: var(--dropdown-menu-content-max-height, min(60vh, 22rem)); max-block-size: var(--dropdown-menu-content-max-height, min(60vh, 22rem));
overflow-y: auto; overflow-y: auto;

@ -16,18 +16,25 @@
* snaps to the active trigger). * snaps to the active trigger).
*/ */
import { composeInlineStyle } from '$uix/eidos/lib/style'; import { composeInlineStyle } from '$uix/eidos/lib/style';
import { ActiveEidos } from '$uix/eidos';
import * as Tabs from '$soma/components/tabs'; import * as Tabs from '$soma/components/tabs';
import type { TabsIndicatorProps } from './types'; import type { TabsIndicatorProps } from './types';
let { ref = $bindable<HTMLElement | null>(null), style, ...rest }: TabsIndicatorProps = $props(); let { ref = $bindable<HTMLElement | null>(null), style, ...rest }: TabsIndicatorProps = $props();
const eidos = ActiveEidos.require();
let geom = $state({ x: 0, y: 0, w: 0, h: 0, ready: false }); 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() { function measure() {
if (rafId !== null) return; if (cancelFrame !== null) return;
rafId = requestAnimationFrame(() => { cancelFrame = eidos.dom.raf(() => {
rafId = null; cancelFrame = null;
const el = ref; const el = ref;
if (!el) return; if (!el) return;
const list = el.parentElement; const list = el.parentElement;
@ -43,7 +50,7 @@
h: triggerRect.height, h: triggerRect.height,
ready: true ready: true
}; };
}); }, ref);
} }
const geometryStyle = $derived( const geometryStyle = $derived(
@ -61,34 +68,30 @@
measure(); measure();
const mo = new MutationObserver(() => measure()); // All DOM observation routed through ActiveDom (iframe / popup safe,
mo.observe(list, { // cleanup tracked) instead of bare `new MutationObserver` /
subtree: true, // `new ResizeObserver`. Each returns its own disposer.
attributes: true, const disposers = [
attributeFilter: ['data-state', 'data-value'] eidos.dom.observeMutation(list, () => measure(), {
}); subtree: true,
attributes: true,
const ro = new ResizeObserver(() => measure()); attributeFilter: ['data-state', 'data-value']
ro.observe(list); }),
eidos.dom.observeResize(list, () => measure())
];
for (const child of Array.from(list.children)) { for (const child of Array.from(list.children)) {
if (child === el) continue; if (child === el) continue;
if (child instanceof HTMLElement) ro.observe(child); if (child instanceof HTMLElement) {
disposers.push(eidos.dom.observeResize(child, () => measure()));
}
} }
return () => { return () => {
mo.disconnect(); for (const dispose of disposers) dispose?.();
ro.disconnect(); cancelFrame?.();
if (rafId !== null) { cancelFrame = null;
cancelAnimationFrame(rafId);
rafId = null;
}
}; };
}); });
</script> </script>
<Tabs.Indicator <Tabs.Indicator {...rest} bind:ref style={mergedStyle} data-ready={geom.ready ? '' : undefined} />
{...rest}
bind:ref
style={mergedStyle}
data-ready={geom.ready ? '' : undefined}
/>

@ -79,13 +79,13 @@
isCollapsed = false; isCollapsed = false;
return; 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) { for (const entry of entries) {
isCollapsed = entry.contentRect.width < px; isCollapsed = entry.contentRect.width < px;
} }
}); });
ro.observe(el);
return () => ro.disconnect();
}); });
</script> </script>

@ -3064,6 +3064,7 @@
--splitter-transition-ease: var(--ease-default); --splitter-transition-ease: var(--ease-default);
--navigation-menu-indicator-w: 0px; --navigation-menu-indicator-w: 0px;
--navigation-menu-indicator-x: 0px; --navigation-menu-indicator-x: 0px;
--dropdown-menu-content-z: 80;
--dropdown-menu-content-min-width: 12rem; --dropdown-menu-content-min-width: 12rem;
--dropdown-menu-content-max-height: min(60vh, 22rem); --dropdown-menu-content-max-height: min(60vh, 22rem);
--dropdown-menu-content-padding: var(--space-1); --dropdown-menu-content-padding: var(--space-1);

@ -4037,6 +4037,12 @@ export const THEME_BASE_RECIPE_TOKENS = {
// DropdownMenu — panel chrome + item rows. Mirror tokens to context-menu. // DropdownMenu — panel chrome + item rows. Mirror tokens to context-menu.
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
'dropdown-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-min-width': '12rem',
'content-max-height': 'min(60vh, 22rem)', 'content-max-height': 'min(60vh, 22rem)',
'content-padding': 'var(--space-1)', 'content-padding': 'var(--space-1)',

@ -4,22 +4,24 @@ type Getter<T> = () => T;
/** /**
* Wraps native ResizeObserver with Svelte $effect lifecycle. * 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$ { export class ResizeObserver$ {
constructor(dom: ActiveDom, node: Getter<HTMLElement | null>, onResize: () => void) { constructor(dom: ActiveDom, node: Getter<HTMLElement | null>, onResize: () => void) {
$effect(() => { $effect(() => {
let rAF = 0; let cancelFrame: (() => void) | null = null;
const el = node(); const el = node();
if (!el) return; if (!el) return;
const cleanupResize = dom.observeResize(el, () => { const cleanupResize = dom.observeResize(el, () => {
dom.cancelFrame(rAF, el); cancelFrame?.();
rAF = dom.requestFrame(onResize, el); cancelFrame = dom.raf(onResize, el);
}); });
return () => { return () => {
dom.cancelFrame(rAF, el); cancelFrame?.();
cleanupResize(); cleanupResize();
}; };
}); });

Loading…
Cancel
Save

Powered by TurnKey Linux.