fix(ui): a caption never opens under a finger

On an iPad a tap's synthetic mouseenter opened the Tooltip; WebKit then took the
tap as a hover and swallowed its click, so every rail button needed two taps,
and with no mouseleave from a finger the caption stayed up. Tooltip now opens
for a mouse and a key's focus only (owner: no captions on touch).
This commit is contained in:
2026-10-08 02:45:48 +02:00
parent c9b8fe15a3
commit c3664826fe
4 changed files with 47 additions and 1 deletions
+1
View File
@@ -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 | 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-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-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 |
@@ -64,3 +64,41 @@ describe("an open tooltip", () => {
await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull()); 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(
<Tooltip hint="A hint" always>
<button>icon</button>
</Tooltip>,
);
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();
});
});
+2
View File
@@ -37,6 +37,8 @@ export default function Tooltip({
// Show/hide on contact by default (as the pre-platform tooltip did); `openDelay` adds a // 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. // rest-before-show for labels that shouldn't flash on a fast sweep across an icon row.
hoverDelay: { open: openDelay, close: 0 }, 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; const { setReference } = pop.refs;
@@ -62,6 +62,11 @@ export interface AnchoredPopoverOptions {
* preview (ui/MediaPreview): a keyboard walk down a grid would otherwise pop a panel at every stop, * 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. */ * and a tap is the card's play. */
pointerOnly?: boolean; 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 /** 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 * 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. */ * modes" menu needs. Leave unset for action menus and multi-select toggle lists. */
@@ -166,7 +171,7 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
move: false, move: false,
// A hover-card is a pointer affordance either way; a hover-ALSO menu must ignore touch, or the // 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). // 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 // 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. // menu, so the "did they mean it" question is mostly answered.
delay: opts.hoverDelay ?? (isHover ? { open: 120, close: 140 } : { open: 90, close: 120 }), delay: opts.hoverDelay ?? (isHover ? { open: 120, close: 140 } : { open: 90, close: 120 }),