diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 4a05fd31..097821a5 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -711,3 +711,4 @@ of a full-width panel and nothing on the right. | 2026-10-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it | | 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules | | 2026-10-06 | `player` focus | A dialog that is a player opens focused on its panel (the YouTube player's rule, now `Modal`'s too); on the media element itself, Space and the arrows stay the element's own | `close`, or the media element | On the ✕ Space closed the player; on the element the browser's own controls acted on the same key as the player's (one Space played and paused, measured in Chrome) | +| 2026-10-08 | Captions on touch | A `Tooltip` opens for a mouse and a key's focus only, never for a finger | A caption on tap; a long-press caption | On an iPad the tap that showed the caption was taken as a hover and lost its click (every rail button took two taps), and no finger sends the mouseleave that hides it; the rail's labels setting names the buttons on touch | diff --git a/frontend/src/components/ui/Tooltip.layer.dom.test.tsx b/frontend/src/components/ui/Tooltip.layer.dom.test.tsx index 17660911..9846b5b6 100644 --- a/frontend/src/components/ui/Tooltip.layer.dom.test.tsx +++ b/frontend/src/components/ui/Tooltip.layer.dom.test.tsx @@ -64,3 +64,41 @@ describe("an open tooltip", () => { await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull()); }); }); + +// On an iPad a tap's synthetic mouseenter showed the caption; WebKit then took the tap as a hover and +// swallowed its click (every rail button needed two taps), and with no mouseleave the caption stayed. +describe("a tooltip under a finger", () => { + class PointerEventStub extends MouseEvent { + pointerType: string; + constructor(type: string, init: PointerEventInit = {}) { + super(type, init); + this.pointerType = init.pointerType ?? ""; + } + } + const hover = (trigger: HTMLElement, pointerType: string) => + act(() => { + trigger.dispatchEvent(new PointerEventStub("pointerdown", { bubbles: true, pointerType })); + trigger.dispatchEvent(new PointerEventStub("pointerenter", { pointerType })); + fireEvent.mouseEnter(trigger); + fireEvent.mouseMove(trigger); + }); + const mount = () => { + render( + + + , + ); + return screen.getByText("icon").parentElement as HTMLElement; + }; + + it("does not open from a tap", async () => { + hover(mount(), "touch"); + await new Promise((r) => setTimeout(r, 50)); + expect(screen.queryByRole("tooltip")).toBeNull(); + }); + + it("still opens under a mouse", async () => { + hover(mount(), "mouse"); + expect(await screen.findByRole("tooltip")).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/ui/Tooltip.tsx b/frontend/src/components/ui/Tooltip.tsx index a383ea25..6ce58ff6 100644 --- a/frontend/src/components/ui/Tooltip.tsx +++ b/frontend/src/components/ui/Tooltip.tsx @@ -37,6 +37,8 @@ export default function Tooltip({ // Show/hide on contact by default (as the pre-platform tooltip did); `openDelay` adds a // rest-before-show for labels that shouldn't flash on a fast sweep across an icon row. hoverDelay: { open: openDelay, close: 0 }, + // A caption is for a mouse (and a key's focus). Opened by a tap it cost touch the tap itself. + mouseHoverOnly: true, }); const { setReference } = pop.refs; diff --git a/frontend/src/components/ui/anchoredPopoverHooks.ts b/frontend/src/components/ui/anchoredPopoverHooks.ts index a00c55e7..0ac6a6c6 100644 --- a/frontend/src/components/ui/anchoredPopoverHooks.ts +++ b/frontend/src/components/ui/anchoredPopoverHooks.ts @@ -62,6 +62,11 @@ export interface AnchoredPopoverOptions { * preview (ui/MediaPreview): a keyboard walk down a grid would otherwise pop a panel at every stop, * and a tap is the card's play. */ pointerOnly?: boolean; + /** Hover mode only: never opened by a finger, still by a mouse and a key's focus. A caption over a + * control (ui/Tooltip): on an iPad the tap's synthetic mouseenter showed it, WebKit then took that + * first tap as a hover and swallowed its click (every rail button needed two taps), and with no + * mouseleave from a finger the caption stayed up. */ + mouseHoverOnly?: boolean; /** Index of the item that is currently CHOSEN, for a single-choice menu/listbox. Opening then * lands the roving focus on it instead of the first item — the behaviour a "pick one of these * modes" menu needs. Leave unset for action menus and multi-select toggle lists. */ @@ -166,7 +171,7 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) { move: false, // A hover-card is a pointer affordance either way; a hover-ALSO menu must ignore touch, or the // synthetic mouseenter a tap emits would race the click toggle (open, then immediately shut). - mouseOnly: !isHover || !!opts.pointerOnly, + mouseOnly: !isHover || !!opts.pointerOnly || !!opts.mouseHoverOnly, // A submenu wants a shorter intent delay than a hover card: the pointer is already inside a // menu, so the "did they mean it" question is mostly answered. delay: opts.hoverDelay ?? (isHover ? { open: 120, close: 140 } : { open: 90, close: 120 }),