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-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;

@ -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<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 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;
};
});
</script>
<Tabs.Indicator
{...rest}
bind:ref
style={mergedStyle}
data-ready={geom.ready ? '' : undefined}
/>
<Tabs.Indicator {...rest} bind:ref style={mergedStyle} data-ready={geom.ready ? '' : undefined} />

@ -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();
});
</script>

@ -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);

@ -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)',

@ -4,22 +4,24 @@ type Getter<T> = () => 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<HTMLElement | null>, 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();
};
});

Loading…
Cancel
Save

Powered by TurnKey Linux.