mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user