From 553c5efeae978eda765b0720c01cd77dc3b52a8c Mon Sep 17 00:00:00 2001 From: fmw666 Date: Fri, 5 Jun 2026 14:03:52 +0800 Subject: [PATCH] fix(preview): portal compact toolbar overflow menu out of glass stacking context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mobile toolbar's ⋯ (More tools) popover appeared dead on tap: the menu opened but was painted entirely behind the preview iframe. Root cause is stacking, not state. The compact toolbar uses `backdrop-filter` (glass effect), which makes it its own stacking context. The overflow menu was an absolutely-positioned descendant, so its `z-index: 40` only ordered it *within* the toolbar. The sibling
(preview iframe) comes later in the DOM at the same root z-level and therefore painted on top of the trapped menu. Fix: portal the menu + backdrop to so they escape the toolbar's stacking context, and anchor the menu to the trigger's viewport rect with `position: fixed` (recomputed on resize/scroll). Verified at 375px width: menu now mounts under and is the topmost element at its own center point (elementFromPoint hits a menu button, not the iframe). tsc + eslint clean. Co-Authored-By: Claude Opus 4.8 --- .../src/shell/toolbar/CompactToolbar.tsx | 57 ++++++++++++++++--- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/packages/preview-site/src/shell/toolbar/CompactToolbar.tsx b/packages/preview-site/src/shell/toolbar/CompactToolbar.tsx index 8672d38..6461bfc 100644 --- a/packages/preview-site/src/shell/toolbar/CompactToolbar.tsx +++ b/packages/preview-site/src/shell/toolbar/CompactToolbar.tsx @@ -1,6 +1,7 @@ // Internal to the shell Toolbar — not part of the feature index barrel. -import { useState } from 'react'; +import { useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import type { FrameSize } from '../store'; import { FRAME_SIZES, FRAME_SIZE_LABELS } from './frame-sizes'; @@ -42,6 +43,39 @@ export function CompactToolbar({ reloadPreview, }: CompactToolbarProps) { const [menuOpen, setMenuOpen] = useState(false); + const triggerRef = useRef(null); + // Fixed-position anchor for the portalled popover, in viewport + // coordinates. `right` is the distance from the viewport's right edge + // so the menu stays flush with the trigger's right side. + const [anchor, setAnchor] = useState<{ top: number; right: number }>({ + top: 0, + right: 0, + }); + + // The toolbar uses `backdrop-filter`, which makes it its own stacking + // context. An absolutely-positioned popover nested inside is therefore + // trapped *behind* the sibling
/preview iframe no matter how high + // its z-index. We portal the menu to to escape that context, and + // anchor it to the trigger's viewport rect with `position: fixed`. + useLayoutEffect(() => { + if (!menuOpen) return; + const place = () => { + const el = triggerRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + setAnchor({ + top: rect.bottom + 6, + right: Math.max(12, window.innerWidth - rect.right), + }); + }; + place(); + window.addEventListener('resize', place); + window.addEventListener('scroll', place, true); + return () => { + window.removeEventListener('resize', place); + window.removeEventListener('scroll', place, true); + }; + }, [menuOpen]); function jump(target: string) { navigateInPreview(target); @@ -102,6 +136,7 @@ export function CompactToolbar({ three control clusters (tab strip / overflow / reload) all read as one consistent 40px-tall row. */} - {menuOpen && ( + {menuOpen && createPortal( <> {/* Backdrop swallows the next pointer down so a tap on page background closes the menu without firing on - whatever was underneath. */} + whatever was underneath. Portalled to alongside the + menu so both escape the toolbar's backdrop-filter stacking + context (otherwise the menu paints behind the preview + iframe and the trigger looks dead). */} - + , + document.body )} );