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:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
Reference in New Issue
Block a user