diff --git a/ui/src/components/tooltip.test.ts b/ui/src/components/tooltip.test.ts index 3ac39f5ffde1..a79b95ee6950 100644 --- a/ui/src/components/tooltip.test.ts +++ b/ui/src/components/tooltip.test.ts @@ -5,6 +5,7 @@ import { installNativeTitleGuard } from "./tooltip.ts"; type TooltipElement = HTMLElement & { content: string; + openOnClick: boolean; readonly updateComplete: Promise; }; @@ -47,6 +48,12 @@ function dispatchMousePointer(target: EventTarget, type: "pointerenter" | "point target.dispatchEvent(event); } +function dispatchTouchPointer(target: EventTarget, type: "pointerdown" | "pointerup") { + const event = new MouseEvent(type, { bubbles: true }); + Object.defineProperty(event, "pointerType", { value: "touch" }); + target.dispatchEvent(event); +} + function hoverTrigger(trigger: HTMLElement) { dispatchMousePointer(trigger, "pointerenter"); } @@ -250,7 +257,7 @@ describe("openclaw-tooltip", () => { expectOpenCount(1); }); - it("does not reopen from pointer-origin focus", async () => { + it("does not reopen from pointer-origin focus after activation settles", async () => { const provider = createProvider(); const { tooltip, trigger } = createTooltip("Pointer tooltip"); provider.append(tooltip); @@ -262,9 +269,34 @@ describe("openclaw-tooltip", () => { const pointerDown = new MouseEvent("pointerdown", { bubbles: true }); Object.defineProperty(pointerDown, "pointerType", { value: "mouse" }); trigger.dispatchEvent(pointerDown); + trigger.dispatchEvent(new MouseEvent("pointerup", { bubbles: true })); + trigger.click(); focusTrigger(trigger); expectOpenCount(0); + + document.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Tab" })); + focusTrigger(trigger); + expectOpenCount(1); + }); + + it("keeps touch hints explicit through open-on-click", async () => { + const provider = createProvider(); + const action = createTooltip("Action tooltip"); + const reveal = createTooltip("Reveal tooltip"); + reveal.tooltip.openOnClick = true; + provider.append(action.tooltip, reveal.tooltip); + document.body.append(provider); + await Promise.all([action.tooltip.updateComplete, reveal.tooltip.updateComplete]); + + dispatchTouchPointer(action.trigger, "pointerdown"); + vi.advanceTimersByTime(450); + expectOpenCount(0); + + dispatchTouchPointer(reveal.trigger, "pointerdown"); + dispatchTouchPointer(reveal.trigger, "pointerup"); + reveal.trigger.click(); + expectOpenCount(1); }); it("keeps the accessible description in the trigger document tree", async () => { diff --git a/ui/src/components/tooltip.ts b/ui/src/components/tooltip.ts index a880c07a606c..b95e0c1af3a8 100644 --- a/ui/src/components/tooltip.ts +++ b/ui/src/components/tooltip.ts @@ -9,10 +9,7 @@ import { OpenClawLitElement } from "../lit/openclaw-element.ts"; const DESCRIBABLE_SELECTOR = 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'; const HOVER_DELAY = 150; -const TOUCH_DELAY = 450; -const TOUCH_VISIBLE = 900; const SKIP_DELAY = 300; -const MOVE_LIMIT = 10; const RICH_CONTENT_CLOSE_DELAY = 100; let nextTooltipId = 0; @@ -151,18 +148,25 @@ export function installNativeTitleGuard(ownerDocument: Document) { class TooltipProvider extends OpenClawLitElement { @property({ type: Number }) delay = HOVER_DELAY; @property({ type: Number }) skipDelay = SKIP_DELAY; - @property({ type: Number }) touchDelay = TOUCH_DELAY; private activeTooltip: Tooltip | null = null; delayed = true; + private focusInput: "keyboard" | "pointer" = "keyboard"; private skipDelayTimer: number | null = null; override connectedCallback() { super.connectedCallback(); this.style.display = "contents"; + this.focusInput = "keyboard"; + // Pointer focus can arrive after an action re-renders. Keep modality at + // the provider so delayed focus cannot reopen the action's tooltip. + this.ownerDocument.addEventListener("keydown", this.handleDocumentKeyDown, true); + this.ownerDocument.addEventListener("pointerdown", this.handleDocumentPointerDown, true); } override disconnectedCallback() { + this.ownerDocument.removeEventListener("keydown", this.handleDocumentKeyDown, true); + this.ownerDocument.removeEventListener("pointerdown", this.handleDocumentPointerDown, true); const activeTooltip = this.activeTooltip; this.activeTooltip = null; activeTooltip?.closeFromProvider(); @@ -171,6 +175,10 @@ class TooltipProvider extends OpenClawLitElement { super.disconnectedCallback(); } + focusOpensTooltip() { + return this.focusInput === "keyboard"; + } + openTooltip(tooltip: Tooltip) { if (this.activeTooltip && this.activeTooltip !== tooltip) { this.activeTooltip.closeFromProvider(); @@ -203,6 +211,16 @@ class TooltipProvider extends OpenClawLitElement { } } + private readonly handleDocumentKeyDown = (event: KeyboardEvent) => { + if (!["Alt", "Control", "Meta", "Shift"].includes(event.key)) { + this.focusInput = "keyboard"; + } + }; + + private readonly handleDocumentPointerDown = () => { + this.focusInput = "pointer"; + }; + override render() { return html``; } @@ -224,12 +242,8 @@ class Tooltip extends OpenClawLitElement { private describedElement: HTMLElement | null = null; private openTimer: number | null = null; private closeTimer: number | null = null; - private touchTimer: number | null = null; - private touchCloseTimer: number | null = null; - private touchStart: { x: number; y: number } | null = null; private triggerHovered = false; private contentHovered = false; - private suppressPointerFocus = false; private describedBy: string | null = null; private descriptionCaptured = false; private descriptionElement: HTMLSpanElement | null = null; @@ -324,8 +338,6 @@ class Tooltip extends OpenClawLitElement { trigger.addEventListener("pointerenter", this.handlePointerEnter); trigger.addEventListener("pointerleave", this.handlePointerLeave); trigger.addEventListener("pointerdown", this.handlePointerDown); - trigger.addEventListener("pointermove", this.handlePointerMove); - trigger.addEventListener("pointerup", this.handlePointerUp); trigger.addEventListener("pointercancel", this.handlePointerCancel); trigger.addEventListener("focusin", this.handleFocusIn); trigger.addEventListener("focusout", this.handleFocusOut); @@ -343,15 +355,11 @@ class Tooltip extends OpenClawLitElement { trigger.removeEventListener("pointerenter", this.handlePointerEnter); trigger.removeEventListener("pointerleave", this.handlePointerLeave); trigger.removeEventListener("pointerdown", this.handlePointerDown); - trigger.removeEventListener("pointermove", this.handlePointerMove); - trigger.removeEventListener("pointerup", this.handlePointerUp); trigger.removeEventListener("pointercancel", this.handlePointerCancel); trigger.removeEventListener("focusin", this.handleFocusIn); trigger.removeEventListener("focusout", this.handleFocusOut); trigger.removeEventListener("click", this.handleClick, true); trigger.removeEventListener("keydown", this.handleKeyDown); - document.removeEventListener("pointerup", this.handleDocumentPointerUp); - this.suppressPointerFocus = false; this.restoreDescription(); this.triggerElement = null; } @@ -403,53 +411,15 @@ class Tooltip extends OpenClawLitElement { } }; - private readonly handlePointerDown = (event: PointerEvent) => { - if (event.pointerType !== "touch") { - this.suppressPointerFocus = true; - document.removeEventListener("pointerup", this.handleDocumentPointerUp); - document.addEventListener("pointerup", this.handleDocumentPointerUp, { once: true }); - this.close(); - return; - } - this.clearTimers(); - this.touchStart = { x: event.clientX, y: event.clientY }; - this.touchTimer = window.setTimeout( - () => { - this.touchTimer = null; - this.show(); - }, - Math.max(0, this.tooltipProvider?.touchDelay ?? TOUCH_DELAY), - ); - }; - - private readonly handlePointerMove = (event: PointerEvent) => { - if ( - event.pointerType === "touch" && - this.touchStart && - Math.hypot(event.clientX - this.touchStart.x, event.clientY - this.touchStart.y) > MOVE_LIMIT - ) { - this.close(); - } - }; - - private readonly handlePointerUp = (event: PointerEvent) => { - if (event.pointerType !== "touch") { - this.handleDocumentPointerUp(); - return; - } - this.clearTouchTimer(); - this.touchStart = null; - if (this.webAwesomeTooltip?.open) { - this.touchCloseTimer = window.setTimeout(() => this.close(), TOUCH_VISIBLE); - } + private readonly handlePointerDown = () => { + this.close(); }; private readonly handlePointerCancel = () => { - this.handleDocumentPointerUp(); this.close(); }; private readonly handleFocusIn = () => { - if (!this.suppressPointerFocus) { + if (this.tooltipProvider?.focusOpensTooltip() !== false) { this.show(); } }; @@ -474,10 +444,6 @@ class Tooltip extends OpenClawLitElement { } this.close(); }; - private readonly handleDocumentPointerUp = () => { - document.removeEventListener("pointerup", this.handleDocumentPointerUp); - this.suppressPointerFocus = false; - }; private readonly handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { this.close(); @@ -517,7 +483,6 @@ class Tooltip extends OpenClawLitElement { this.clearTimers(); this.triggerHovered = false; this.contentHovered = false; - this.touchStart = null; if (this.webAwesomeTooltip?.open) { this.webAwesomeTooltip.open = false; } @@ -599,13 +564,6 @@ class Tooltip extends OpenClawLitElement { this.descriptionCaptured = false; } - private clearTouchTimer() { - if (this.touchTimer !== null) { - window.clearTimeout(this.touchTimer); - this.touchTimer = null; - } - } - private clearCloseTimer() { if (this.closeTimer !== null) { window.clearTimeout(this.closeTimer); @@ -649,11 +607,6 @@ class Tooltip extends OpenClawLitElement { this.triggerHovered = false; this.contentHovered = false; } - this.clearTouchTimer(); - if (this.touchCloseTimer !== null) { - window.clearTimeout(this.touchCloseTimer); - this.touchCloseTimer = null; - } } private get richContentText() { diff --git a/ui/src/e2e/device-scope-upgrade.e2e.test.ts b/ui/src/e2e/device-scope-upgrade.e2e.test.ts index 8b7fbbb804ce..045460ef500d 100644 --- a/ui/src/e2e/device-scope-upgrade.e2e.test.ts +++ b/ui/src/e2e/device-scope-upgrade.e2e.test.ts @@ -384,6 +384,7 @@ describeControlUiE2e("Control UI live device scope upgrade", () => { .toBe(true); const browse = page.getByRole("button", { name: "Browse folders" }); await expect.poll(() => browse.isDisabled()).toBe(true); + await page.keyboard.press("Tab"); await browse.focus(); await expect .poll(() => browse.evaluate((element) => element === document.activeElement)) diff --git a/ui/src/e2e/native-nav-sidebar-toggle.e2e.test.ts b/ui/src/e2e/native-nav-sidebar-toggle.e2e.test.ts index 6afa7641603f..844ef474ae33 100644 --- a/ui/src/e2e/native-nav-sidebar-toggle.e2e.test.ts +++ b/ui/src/e2e/native-nav-sidebar-toggle.e2e.test.ts @@ -61,6 +61,7 @@ suite.define(() => { async function openPage(options: { colorScheme?: "dark" | "light"; + hasTouch?: boolean; height?: number; nativeNav?: boolean; scenario?: ControlUiMockGatewayScenario; @@ -69,6 +70,7 @@ suite.define(() => { }) { context = await suite.browser.newContext({ colorScheme: options.colorScheme, + hasTouch: options.hasTouch, locale: "en-US", serviceWorkers: "block", viewport: { height: options.height ?? 900, width: options.width ?? 1280 }, @@ -154,6 +156,28 @@ suite.define(() => { await expect.poll(() => toggle.getAttribute("aria-label")).toBe("Collapse sidebar"); }); + it("keeps pointer-triggered sidebar focus from opening its tooltip", async () => { + const page = await openPage({ hasTouch: true, nativeNav: false }); + const toggle = page.locator(".shell-chrome-controls__nav-toggle"); + const tooltip = toggle.locator("xpath=..").locator("wa-tooltip"); + await expect.poll(() => toggle.getAttribute("aria-label")).toBe("Collapse sidebar"); + + // Safari does not focus buttons on tap. Reproduce that ordering so the + // shell's post-collapse focus, rather than the pointer itself, owns focus. + await toggle.evaluate((element) => { + for (const type of ["pointerdown", "pointerup"]) { + element.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerType: "touch" })); + } + (element as HTMLElement).click(); + }); + + await expect.poll(() => toggle.getAttribute("aria-label")).toBe("Expand sidebar"); + await expect + .poll(() => toggle.evaluate((element) => element === document.activeElement)) + .toBe(true); + await expect.poll(() => tooltip.getAttribute("open")).toBeNull(); + }); + it("hides the web chrome cluster when the native titlebar toggle is present", async () => { const page = await openPage({ nativeNav: true }); diff --git a/ui/src/e2e/usage-cost-analysis.e2e.test.ts b/ui/src/e2e/usage-cost-analysis.e2e.test.ts index 7681c3ce2dd0..5153cd74374c 100644 --- a/ui/src/e2e/usage-cost-analysis.e2e.test.ts +++ b/ui/src/e2e/usage-cost-analysis.e2e.test.ts @@ -546,6 +546,7 @@ suite.define(() => { await page.mouse.move(1, 1); await expect.poll(() => messagesTooltip.getAttribute("open")).toBeNull(); + await page.keyboard.press("Tab"); await messagesHint.focus(); await expect.poll(() => messagesTooltip.getAttribute("open")).toBe(""); await page.getByRole("button", { name: "Cost", exact: true }).focus(); @@ -558,6 +559,7 @@ suite.define(() => { .toContain("Total user and assistant messages in range."); await page.getByRole("button", { name: "Cost", exact: true }).click(); await expect.poll(() => messagesTooltip.getAttribute("open")).toBeNull(); + await page.keyboard.press("Tab"); await messagesHint.focus(); await expect.poll(() => messagesTooltip.getAttribute("open")).toBe(""); await messagesHint.press("Escape");