Half the editor was on the phone and none of it could be touched
Reported as "quite a few options don't function on mobile", and measured in a real touch emulation rather than a narrow window — the distinction matters, because two of the three causes need `pointer: coarse` to appear at all. Three causes, one theme: the chrome was built for a pointer that hovers. The toolbar reveals itself on :hover — flex-wrap: wrap, overflow: visible. A touchscreen never hovers, so that rule never fired and the row stayed clipped at overflow: hidden permanently. Fourteen of its twenty-three controls could not be reached by any gesture: every heading, both lists, all three alignments, link, image, table, outline, and both AI passes. The faded right edge promised there was more that way, and there was no way. It now scrolls sideways on a coarse pointer, keeping every control, exactly as .petal-chrome-strip does one line above it — the same trade that comment already argued for, applied to the row it was comparing itself to. That move was only safe once the panels could get out. Every dropdown in the editor chrome — tone, export, colour, highlight, size — was already broken on a phone for a subtler reason: `position: fixed` is relative to the viewport only while no ancestor establishes a containing block, and mask-image does, exactly like transform. Both scrollers fade their edges with a mask. So on any screen narrow enough for the fade to appear, the menu was pulled back inside the very box it was escaping, painted under the toolbar and untappable. The comment in anchoredMenu.ts said "nothing clips a fixed box unless an ancestor has a transform, and none of the editor's chrome does"; it was true when it was written and had quietly stopped being true. The panels now portal to <body>, where nothing above them can clip, stack over or contain them whatever the chrome does with masks later, and the outside-tap tests ask about both halves. The kitten took the last two. She covered "Español" and "I am learning Português" in the drawer, and "Hide falling petals" in the status bar outright — three sample points across it, all three landing on the cat. She already knows how to yield: useCardOverlap fades her for cards and for anything with the modal role, and the drawer is the one overlay that has neither, being navigation rather than something opened on purpose. It is named there now. The status bar is a different problem — it is always present, so yielding to it would mean fading forever — and the honest fix is to sit above it on a narrow screen instead of negotiating with it every frame. Measuring, not eyeballing: a sweep of every visible, enabled control reports zero off-screen and zero blocked, against fourteen and three before. Desktop is deliberately untouched — at rest the slim clipped line with its mask, on hover the wrapped row with all twenty-three buttons on screen — and the edge measurement ChromeStrip already did is now shared with the toolbar rather than written twice. Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
This commit is contained in:
@@ -6,15 +6,40 @@ import { useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
|
||||
// button's wrapper, which was fine until that wrapper became ChromeStrip — a
|
||||
// horizontal scroller, and so a box that clips what overflows it. An absolute
|
||||
// menu inside it is 36px tall and scrolls away with the pills. Positioning the
|
||||
// menu against the viewport instead takes it out of the strip's hands entirely:
|
||||
// nothing clips a fixed box unless an ancestor has a transform, and none of the
|
||||
// editor's chrome does.
|
||||
// menu against the viewport instead takes it out of the strip's hands entirely.
|
||||
//
|
||||
// Or rather: it does once the menu is also *portalled out* of it, which is the
|
||||
// part this originally got wrong. `position: fixed` is only relative to the
|
||||
// viewport while no ancestor establishes a containing block for it — and a
|
||||
// `mask-image` does, exactly like a transform. Both scrollers fade their edges
|
||||
// with a mask (that is how each says "there is more this way"), so on any screen
|
||||
// narrow enough for the fade to appear — i.e. every phone — the menu was pulled
|
||||
// back inside the very box it was trying to escape: painted underneath the
|
||||
// toolbar, and untappable. It looked open and did nothing.
|
||||
//
|
||||
// So the panel is rendered through a portal into <body>. Nothing above it can
|
||||
// clip it, stack over it, or contain it, whatever the chrome does with masks
|
||||
// later. `panelRef` is returned for the outside-tap test, which can no longer
|
||||
// rely on the panel being a DOM descendant of the trigger's wrapper.
|
||||
//
|
||||
// The trade is that a fixed box doesn't follow its anchor, so anything that
|
||||
// moves the button — the page scrolling under it, the strip scrolling, the
|
||||
// window resizing — has to re-place the menu.
|
||||
export function useAnchoredMenu(open: boolean, width: number) {
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
//
|
||||
// The element type is a parameter because the two kinds of caller anchor
|
||||
// against different things: the tone and export pills hand it their own
|
||||
// <button>, while the toolbar's popovers anchor against the wrapper that holds
|
||||
// trigger and panel together (it is that wrapper an outside-tap test already
|
||||
// asks about, so measuring anything else would be a second source of truth).
|
||||
export function useAnchoredMenu<T extends HTMLElement = HTMLButtonElement>(
|
||||
open: boolean,
|
||||
width: number,
|
||||
) {
|
||||
const triggerRef = useRef<T>(null)
|
||||
// The portalled panel. Attach it to the element the style is spread onto, so
|
||||
// an outside-tap test can ask "was this inside the menu?" of a node that is no
|
||||
// longer beneath the trigger in the tree.
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
// Nothing to place before the first measurement; keeping it off-screen rather
|
||||
// than at 0,0 means no flash in the top-left corner on open.
|
||||
const [style, setStyle] = useState<CSSProperties>({ position: 'fixed', top: -9999, left: -9999 })
|
||||
@@ -41,5 +66,5 @@ export function useAnchoredMenu(open: boolean, width: number) {
|
||||
}
|
||||
}, [open, width])
|
||||
|
||||
return { triggerRef, style }
|
||||
return { triggerRef, panelRef, style }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user