fix(ui): sidebar collapse keeps pointer tooltips quiet (#128070)

* fix(ui): keep pointer focus tooltips quiet

* test(ui): model keyboard tooltip focus

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>

---------

Co-authored-by: RoboClaw <309084314+roboclaw-bot@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
ClawSweeper
2026-08-22 18:43:04 -07:00
committed by GitHub
parent 2f3f540045
commit 08c39dd6cf
5 changed files with 85 additions and 73 deletions
+33 -1
View File
@@ -5,6 +5,7 @@ import { installNativeTitleGuard } from "./tooltip.ts";
type TooltipElement = HTMLElement & {
content: string;
openOnClick: boolean;
readonly updateComplete: Promise<boolean>;
};
@@ -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 () => {
+25 -72
View File
@@ -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`<slot></slot>`;
}
@@ -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() {
@@ -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))
@@ -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 });
@@ -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");