mirror of
https://github.com/openclaw/openclaw.git
synced 2026-11-05 23:33:06 +00:00
refactor(ui): use native menus for lobster dismissal (#170568)
Replace the lobster dismissal dropdown with the owned Solid Menu while preserving its pointer anchor, dismissal actions, selectors, and lazy loading. Restore composer focus after keyboard dismissal and selection; retain outside interaction focus. Exact-head full CI, including Chromium/WebKit overlay checks and all UI/real-Gateway shards, passed. Paired startup is 342,335 to 342,328 bytes JS gzip with one CSS request; six inspected synthetic before/after frames are embedded in the PR.
This commit is contained in:
parent
95b3b97a4c
commit
8b1315ac0c
7 changed files with 227 additions and 142 deletions
|
|
@ -684,7 +684,9 @@ if (uiE2eSelection) {
|
|||
// Keep its browser-only contracts scoped to their interaction and style owners.
|
||||
const uiWebkitOwners = [
|
||||
"ui/vitest.config.ts",
|
||||
"ui/src/components/{web-awesome*,modal-dialog*,tooltip*,menu-*,overlay*,composer-menu*,dropdown-menu*,anchored-overlay*,textarea-token-anchor*,panel-tab-strip*,select-picker*,multi-select*,agent-select*}.{ts,tsx}",
|
||||
"ui/src/components/{web-awesome*,modal-dialog*,tooltip*,menu-*,overlay*,composer-menu*,dropdown-menu*,anchored-overlay*,textarea-token-anchor*,panel-tab-strip*,select-picker*,multi-select*,agent-select*,lobster-pet*,session-group-defaults-dialog*}.{ts,tsx}",
|
||||
"ui/src/app/app-shell-chrome.ts",
|
||||
"ui/src/pages/{cron/view-jobs-filter,plugins/settings-editor,plugins/settings-view}.{ts,tsx,css}",
|
||||
"ui/src/pages/chat/chat-composer*.{ts,tsx}",
|
||||
"ui/src/pages/chat/components/chat-{composer*,picker-overlay*,model-picker*,effort-picker*}.{ts,tsx}",
|
||||
"ui/src/pages/chat/{chat-pane-header,components/chat-pane-header*,components/chat-pane-versions-menu*}.{ts,tsx}",
|
||||
|
|
|
|||
|
|
@ -1,53 +1,83 @@
|
|||
import { Show } from "solid-js";
|
||||
import { createUniqueId, onCleanup } from "solid-js";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import type { JSX } from "../types/solid-elements.d.ts";
|
||||
import "./web-awesome.ts";
|
||||
import { Menu, type MenuHandle } from "./solid/menu.tsx";
|
||||
|
||||
export type LobsterPetDismissMenuPosition = { x: number; y: number };
|
||||
|
||||
export function LobsterPetDismissMenu(params: {
|
||||
position: LobsterPetDismissMenuPosition | null;
|
||||
position: LobsterPetDismissMenuPosition;
|
||||
onDismiss: (permanently: boolean) => void;
|
||||
onClose: () => void;
|
||||
}): JSX.Element {
|
||||
// Auto-size clamps the menu to whatever placement flip picks rather than
|
||||
// relocating it, so top-start is forced directly since the footer always
|
||||
// has clear room above — leaving it to flip risks a shrunk-in-place menu.
|
||||
const id = createUniqueId();
|
||||
let menu!: MenuHandle;
|
||||
let returnTarget: HTMLElement | undefined;
|
||||
let removeListeners: (() => void) | undefined;
|
||||
onCleanup(() => removeListeners?.());
|
||||
const restoreCapturedFocus = () => {
|
||||
const trigger = menu.overlay.trigger;
|
||||
if (!trigger || !returnTarget?.isConnected) {
|
||||
return;
|
||||
}
|
||||
const root = trigger.getRootNode();
|
||||
const active =
|
||||
root instanceof ShadowRoot ? root.activeElement : trigger.ownerDocument.activeElement;
|
||||
// Translate only the owner's trigger focus; outside interactions keep their target.
|
||||
if (active === trigger) {
|
||||
returnTarget.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Show when={params.position}>
|
||||
{(position) => (
|
||||
<wa-dropdown
|
||||
class="session-menu lobster-pet-dismiss-menu"
|
||||
prop:open={true}
|
||||
placement="top-start"
|
||||
prop:distance={0}
|
||||
aria-label={t("quickSettings.appearance.lobsterVisits")}
|
||||
onWa-select={(event: CustomEvent<{ item: { value?: string } }>) => {
|
||||
event.preventDefault();
|
||||
if (event.detail.item.value === "dismiss") {
|
||||
params.onDismiss(false);
|
||||
} else if (event.detail.item.value === "dismiss-permanently") {
|
||||
params.onDismiss(true);
|
||||
}
|
||||
}}
|
||||
onWa-after-hide={params.onClose}
|
||||
>
|
||||
<button
|
||||
slot="trigger"
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
aria-label={t("quickSettings.appearance.lobsterVisits")}
|
||||
style={`position: fixed; left: ${position().x}px; top: ${position().y}px; width: 1px; height: 1px; opacity: 0; pointer-events: none;`}
|
||||
/>
|
||||
<wa-dropdown-item class="session-menu__item" value="dismiss">
|
||||
{t("common.dismiss")}
|
||||
</wa-dropdown-item>
|
||||
<wa-dropdown-item class="session-menu__item" value="dismiss-permanently">
|
||||
{t("common.dismissAndDontShowAgain")}
|
||||
</wa-dropdown-item>
|
||||
</wa-dropdown>
|
||||
)}
|
||||
</Show>
|
||||
<div
|
||||
class="lobster-pet-dismiss-menu__position"
|
||||
style={`--lobster-pet-menu-x: ${params.position.x}px; --lobster-pet-menu-y: ${params.position.y}px;`}
|
||||
>
|
||||
<Menu
|
||||
id={id}
|
||||
class="session-menu lobster-pet-dismiss-menu"
|
||||
panelClass="lobster-pet-dismiss-menu__panel"
|
||||
triggerClass="lobster-pet-dismiss-menu__trigger"
|
||||
ref={(handle) => {
|
||||
menu = handle;
|
||||
// Menu binds its trigger before ref; it is only a hidden point anchor here.
|
||||
const trigger = handle.overlay.trigger!;
|
||||
trigger.tabIndex = -1;
|
||||
trigger.setAttribute("aria-hidden", "true");
|
||||
const root = trigger.getRootNode();
|
||||
const active =
|
||||
root instanceof ShadowRoot ? root.activeElement : trigger.ownerDocument.activeElement;
|
||||
returnTarget = active instanceof HTMLElement ? active : undefined;
|
||||
const surface = handle.overlay.surface;
|
||||
const close = () => params.onClose();
|
||||
surface.addEventListener("overlay-after-hide", close);
|
||||
trigger.addEventListener("focus", restoreCapturedFocus);
|
||||
removeListeners = () => {
|
||||
surface.removeEventListener("overlay-after-hide", close);
|
||||
trigger.removeEventListener("focus", restoreCapturedFocus);
|
||||
};
|
||||
if (!handle.open()) {
|
||||
params.onClose();
|
||||
}
|
||||
}}
|
||||
placement="top-start"
|
||||
label={t("quickSettings.appearance.lobsterVisits")}
|
||||
items={[
|
||||
{ id: "dismiss", label: t("common.dismiss"), class: "session-menu__item" },
|
||||
{
|
||||
id: "dismiss-permanently",
|
||||
label: t("common.dismissAndDontShowAgain"),
|
||||
class: "session-menu__item",
|
||||
},
|
||||
]}
|
||||
onSelect={(item, event) => {
|
||||
event.preventDefault();
|
||||
// Restore focus through the owner before dismissal removes this menu.
|
||||
if (menu.close()) {
|
||||
params.onDismiss(item.id === "dismiss-permanently");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,7 +195,13 @@ function Sprite(props: LobsterPetSceneProps & { twin: boolean }) {
|
|||
style={visual().style}
|
||||
aria-hidden="true"
|
||||
title={visual().title}
|
||||
onPointerDown={(event) => props.onPointerDown(event)}
|
||||
onPointerDown={(event) => {
|
||||
// Keep the composer focused before the context menu captures its return target.
|
||||
if (event.button === 2) {
|
||||
event.preventDefault();
|
||||
}
|
||||
props.onPointerDown(event);
|
||||
}}
|
||||
onPointerUp={(event) => props.onPointerUp(event)}
|
||||
onPointerCancel={() => props.onPointerCancel()}
|
||||
onPointerLeave={() => props.onPointerCancel()}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { waitForSolid } from "../test-helpers/solid-settle.ts";
|
||||
import { getLobsterdex, getLobsterdexEntries } from "./lobster-dex.ts";
|
||||
import { resolveLobsterPetMode, resolveLobsterRunOutcome } from "./lobster-pet-contract.ts";
|
||||
import {
|
||||
|
|
@ -30,11 +31,18 @@ async function advancePetFrame(element: LobsterPetElement): Promise<void> {
|
|||
}
|
||||
|
||||
async function settleDismissMenu(element: LobsterPetElement) {
|
||||
for (const child of element.querySelectorAll<HTMLElement & { updateComplete: Promise<boolean> }>(
|
||||
"wa-dropdown, wa-dropdown-item",
|
||||
)) {
|
||||
await child.updateComplete;
|
||||
}
|
||||
await waitForSolid(() => {
|
||||
const firstItem = element.querySelector('.lobster-pet-dismiss-menu [role="menuitem"]');
|
||||
expect(firstItem).not.toBeNull();
|
||||
expect(document.activeElement).toBe(firstItem);
|
||||
});
|
||||
}
|
||||
|
||||
function selectDismissal(element: LobsterPetElement, value: "dismiss" | "dismiss-permanently") {
|
||||
expectDefined(
|
||||
element.querySelector<HTMLButtonElement>(`[role="menuitem"][data-value="${value}"]`),
|
||||
`${value} menu item`,
|
||||
).click();
|
||||
}
|
||||
|
||||
function poke(element: LobsterPetElement): void {
|
||||
|
|
@ -78,10 +86,11 @@ async function startVigilOnlyRun(
|
|||
return element;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
document.body.replaceChildren();
|
||||
await Promise.resolve();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
document.body.innerHTML = "";
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
|
|
@ -326,10 +335,12 @@ describe("lobster pet element", () => {
|
|||
expect(getLobsterdexEntries().get(look.palette.id)?.name).toBeTruthy();
|
||||
});
|
||||
|
||||
it("anchors the dismiss menu at the raw pointer position with a top-start placement", async () => {
|
||||
it("anchors the dismiss menu at the raw pointer position and preserves outside focus", async () => {
|
||||
vi.useFakeTimers();
|
||||
const element = createPet(42);
|
||||
await arrive(element);
|
||||
const composer = element.parentElement!.querySelector<HTMLTextAreaElement>("textarea")!;
|
||||
composer.focus();
|
||||
|
||||
// Coordinates deliberately exceed a guessed 264x80 pre-clamp: a
|
||||
// reintroduced clamp would move the trigger away from these exact values.
|
||||
|
|
@ -342,17 +353,29 @@ describe("lobster pet element", () => {
|
|||
);
|
||||
await element.updateComplete;
|
||||
|
||||
const dropdown = element.querySelector(".lobster-pet-dismiss-menu");
|
||||
expect(dropdown?.getAttribute("placement")).toBe("top-start");
|
||||
const trigger = element.querySelector<HTMLElement>('[slot="trigger"]');
|
||||
expect(trigger?.style.left).toBe(`${clientX}px`);
|
||||
expect(trigger?.style.top).toBe(`${clientY}px`);
|
||||
await settleDismissMenu(element);
|
||||
const menu = element.querySelector(".lobster-pet-dismiss-menu__panel");
|
||||
expect(menu?.getAttribute("data-placement")).toBe("top-start");
|
||||
const trigger = element.querySelector<HTMLElement>(
|
||||
".lobster-pet-dismiss-menu .oc-menu-trigger",
|
||||
);
|
||||
const position = trigger?.closest<HTMLElement>(".lobster-pet-dismiss-menu__position");
|
||||
expect(position?.style.getPropertyValue("--lobster-pet-menu-x")).toBe(`${clientX}px`);
|
||||
expect(position?.style.getPropertyValue("--lobster-pet-menu-y")).toBe(`${clientY}px`);
|
||||
expect(trigger?.tabIndex).toBe(-1);
|
||||
expect(trigger?.getAttribute("aria-hidden")).toBe("true");
|
||||
composer.focus();
|
||||
await Promise.resolve();
|
||||
expect(menu?.matches(":popover-open")).toBe(false);
|
||||
expect(document.activeElement).toBe(composer);
|
||||
});
|
||||
|
||||
it("offers a temporary dismissal from the right-click menu", async () => {
|
||||
vi.useFakeTimers();
|
||||
const element = createPet(42);
|
||||
await arrive(element);
|
||||
const composer = element.parentElement!.querySelector<HTMLTextAreaElement>("textarea")!;
|
||||
composer.focus();
|
||||
|
||||
const sprite = element.querySelector(".lobster-pet");
|
||||
sprite?.dispatchEvent(new MouseEvent("pointerdown", { button: 2 }));
|
||||
|
|
@ -363,15 +386,29 @@ describe("lobster pet element", () => {
|
|||
expect(shoo.defaultPrevented).toBe(true);
|
||||
expect(spritePresent(element)).toBe(true);
|
||||
expect(spriteClasses(element)).not.toContain("lobster-pet--act-startle");
|
||||
await settleDismissMenu(element);
|
||||
expect(
|
||||
[...element.querySelectorAll(".lobster-pet-dismiss-menu wa-dropdown-item")].map((item) =>
|
||||
[...element.querySelectorAll('.lobster-pet-dismiss-menu [role="menuitem"]')].map((item) =>
|
||||
item.textContent?.trim(),
|
||||
),
|
||||
).toEqual(["Dismiss", "Dismiss and don't show again"]);
|
||||
|
||||
await settleDismissMenu(element);
|
||||
element.querySelector<HTMLElement>('wa-dropdown-item[value="dismiss"]')?.click();
|
||||
const veto = (event: Event) => event.preventDefault();
|
||||
document.addEventListener("overlay-hide", veto);
|
||||
try {
|
||||
selectDismissal(element, "dismiss");
|
||||
await element.updateComplete;
|
||||
expect(spriteClasses(element)).not.toContain("lobster-pet--away");
|
||||
expect(element.querySelector('[role="menu"]')?.matches(":popover-open")).toBe(true);
|
||||
expect(document.activeElement).toBe(
|
||||
element.querySelector('.lobster-pet-dismiss-menu [role="menuitem"]'),
|
||||
);
|
||||
} finally {
|
||||
document.removeEventListener("overlay-hide", veto);
|
||||
}
|
||||
selectDismissal(element, "dismiss");
|
||||
await element.updateComplete;
|
||||
expect(document.activeElement).toBe(composer);
|
||||
|
||||
const gone = await advanceUntil(element, () => !spritePresent(element), 5_000);
|
||||
expect(gone).toBe(true);
|
||||
|
|
@ -395,7 +432,7 @@ describe("lobster pet element", () => {
|
|||
?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
|
||||
await element.updateComplete;
|
||||
await settleDismissMenu(element);
|
||||
element.querySelector<HTMLElement>('wa-dropdown-item[value="dismiss-permanently"]')?.click();
|
||||
selectDismissal(element, "dismiss-permanently");
|
||||
await element.updateComplete;
|
||||
|
||||
const persistedSettings = Array.from({ length: localStorage.length }, (_, index) => {
|
||||
|
|
@ -494,7 +531,7 @@ describe("lobster pet element", () => {
|
|||
?.dispatchEvent(new MouseEvent("contextmenu", { cancelable: true, bubbles: true }));
|
||||
await element.updateComplete;
|
||||
await settleDismissMenu(element);
|
||||
element.querySelector<HTMLElement>('wa-dropdown-item[value="dismiss"]')?.click();
|
||||
selectDismissal(element, "dismiss");
|
||||
await element.updateComplete;
|
||||
const raw = JSON.parse(
|
||||
localStorage.getItem("openclaw.control.lobsterpet.familiarity.v1") ?? "{}",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { createEffect, createSignal, onCleanup, onSettled } from "solid-js";
|
||||
import { createEffect, createSignal, lazy, Loading, onCleanup, onSettled, Show } from "solid-js";
|
||||
import { defineSolidBridge } from "../lit/solid-bridge.ts";
|
||||
import type { LobsterPetProps } from "./lobster-pet-contract.ts";
|
||||
import { LobsterPetController } from "./lobster-pet-controller.ts";
|
||||
import { LobsterPetDismissMenu } from "./lobster-pet-dismiss-menu.tsx";
|
||||
import { LobsterPetScene } from "./lobster-pet-scene-view.tsx";
|
||||
|
||||
const LobsterPetDismissMenu = lazy(() => import("./lobster-pet-dismiss-menu.tsx"), {
|
||||
export: "LobsterPetDismissMenu",
|
||||
});
|
||||
|
||||
// A bridge remount retires the view, not the host's once-per-load visitor history.
|
||||
const pets = new WeakMap<HTMLElement, LobsterPetController>();
|
||||
|
||||
|
|
@ -40,11 +43,17 @@ export const LobsterPet = defineSolidBridge<LobsterPetProps>(
|
|||
return (
|
||||
<>
|
||||
<LobsterPetScene scene={scene()} />
|
||||
<LobsterPetDismissMenu
|
||||
position={position()}
|
||||
onDismiss={(permanently) => pet.dismiss(permanently)}
|
||||
onClose={() => pet.closeMenu()}
|
||||
/>
|
||||
<Show when={position()}>
|
||||
{(menuPosition) => (
|
||||
<Loading>
|
||||
<LobsterPetDismissMenu
|
||||
position={menuPosition()}
|
||||
onDismiss={(permanently) => pet.dismiss(permanently)}
|
||||
onClose={() => pet.closeMenu()}
|
||||
/>
|
||||
</Loading>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -67,67 +67,34 @@ async function configureRealComposerPet(currentPage: Page, seed: number) {
|
|||
* the viewport. */
|
||||
async function measureDismissMenu(currentPage: Page) {
|
||||
return await currentPage.evaluate(() => {
|
||||
const dropdown = document.querySelector("wa-dropdown.lobster-pet-dismiss-menu");
|
||||
if (!dropdown?.shadowRoot) {
|
||||
throw new Error("dismiss menu dropdown not found");
|
||||
}
|
||||
const menu = dropdown.shadowRoot.querySelector<HTMLElement>("#menu");
|
||||
const popup = dropdown.shadowRoot.querySelector("wa-popup");
|
||||
const popupBox = popup?.shadowRoot?.querySelector<HTMLElement>(".popup") ?? null;
|
||||
const menu = document.querySelector<HTMLElement>(".lobster-pet-dismiss-menu__panel");
|
||||
if (!menu) {
|
||||
throw new Error("dismiss menu #menu part not found");
|
||||
throw new Error("dismiss menu not found");
|
||||
}
|
||||
const menuStyle = getComputedStyle(menu);
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
const host = document.querySelector<HTMLElement>("openclaw-lobster-pet");
|
||||
const hostStyle = host ? getComputedStyle(host) : null;
|
||||
const hostRect = host?.getBoundingClientRect() ?? null;
|
||||
const firstItem = dropdown.querySelector("wa-dropdown-item");
|
||||
const firstItemLabel = firstItem?.shadowRoot?.querySelector<HTMLElement>("#label") ?? null;
|
||||
const firstItem = menu.querySelector<HTMLElement>('[role="menuitem"]');
|
||||
const firstItemLabel = firstItem?.querySelector<HTMLElement>(".oc-menu-item__label");
|
||||
const anchorRect = document
|
||||
.querySelector(".lobster-pet-dismiss-menu__trigger")
|
||||
?.getBoundingClientRect();
|
||||
return {
|
||||
// The item's rendered label size comes from the app's own
|
||||
// .session-menu__item light-DOM class (ui/src/styles/layout.css),
|
||||
// which sets an explicit font-size from --control-ui-text-scale —
|
||||
// not from wa-dropdown-item's shadow styles. Captured here so tests
|
||||
// can prove the text-scale token actually reached the label instead
|
||||
// of trusting an unrelated Web Awesome token override.
|
||||
firstItemFontSizePx: firstItem
|
||||
? Number.parseFloat(getComputedStyle(firstItem).fontSize)
|
||||
: null,
|
||||
// The label's own unclamped box height: .session-menu__item's
|
||||
// min-height design floor (28px, ui/src/styles/base.css) keeps a
|
||||
// single short line and a single modestly-scaled line at the same
|
||||
// 28px row height, so the row height alone can't tell a real
|
||||
// font-size increase from a no-op. The label's own height isn't
|
||||
// floor-clamped and grows with font-size regardless.
|
||||
firstItemLabelHeightPx: firstItemLabel?.getBoundingClientRect().height ?? null,
|
||||
scrollHeight: menu.scrollHeight,
|
||||
clientHeight: menu.clientHeight,
|
||||
offsetHeight: menu.offsetHeight,
|
||||
overflowPx: menu.scrollHeight - menu.clientHeight,
|
||||
scrollbarWidthPx: menu.offsetWidth - menu.clientWidth,
|
||||
computedMaxHeight: menuStyle.maxHeight,
|
||||
computedOverflowY: menuStyle.overflowY,
|
||||
autoSizeAvailableHeight: popup
|
||||
? getComputedStyle(popup).getPropertyValue("--auto-size-available-height").trim()
|
||||
: "(no wa-popup)",
|
||||
resolvedPlacement: popup?.getAttribute("data-current-placement") ?? "(none)",
|
||||
anchorTop: dropdown.querySelector<HTMLElement>('[slot="trigger"]')?.style.top ?? "(none)",
|
||||
viewportHeight: window.innerHeight,
|
||||
viewportWidth: window.innerWidth,
|
||||
menuLeft: menuRect.left,
|
||||
menuRight: menuRect.right,
|
||||
menuTop: menuRect.top,
|
||||
menuBottom: menuRect.bottom,
|
||||
popupIsTopLayer: popupBox ? popupBox.matches(":popover-open") : null,
|
||||
hasOuterMenuSurface: dropdown.closest("openclaw-menu-surface") !== null,
|
||||
hostHeight: hostStyle?.height ?? null,
|
||||
hostOverflow: hostStyle?.overflow ?? null,
|
||||
anchorLeft: anchorRect?.left,
|
||||
anchorTop: anchorRect?.top,
|
||||
popupIsTopLayer: menu.matches(":popover-open"),
|
||||
hasOuterMenuSurface: menu.closest("openclaw-menu-surface") !== null,
|
||||
hostParentClass: host?.parentElement?.className ?? null,
|
||||
hostBottom: hostRect?.bottom ?? null,
|
||||
itemHeights: [...dropdown.querySelectorAll("wa-dropdown-item")].map(
|
||||
(item) => (item as HTMLElement).offsetHeight,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -137,18 +104,8 @@ async function measureDismissMenu(currentPage: Page) {
|
|||
// way ui/src/app/bootstrap.ts:79 applies it at runtime.
|
||||
const ENLARGED_TEXT_SCALE = "1.4";
|
||||
|
||||
/** Rewrites the two dismissal items to long, wide labels and applies the
|
||||
* real "enlarged type scale" setting. The dismiss menu's items render
|
||||
* through the app's own `.session-menu__item` light-DOM class
|
||||
* (ui/src/styles/layout.css), which sets an explicit
|
||||
* `font-size: calc(13px * var(--control-ui-text-scale))` — that wins over
|
||||
* wa-dropdown-item's inherited shadow-DOM styles, which never set
|
||||
* font-size themselves (confirmed in the installed Web Awesome 3.10.0
|
||||
* dropdown-item styles). So --control-ui-text-scale, not any
|
||||
* --wa-font-size-* token, is the lever that actually changes what the
|
||||
* operator sees. Stands in for a locale whose strings and type-scale are
|
||||
* both larger than the pinned English default, without depending on the
|
||||
* app's network-loaded locale chunks. */
|
||||
/** Long labels and the real Appearance text-scale setting exercise the
|
||||
* owned menu's wrapping without depending on network-loaded locale chunks. */
|
||||
async function useOversizedDismissLabels(currentPage: Page) {
|
||||
await currentPage.evaluate((scale) => {
|
||||
document.documentElement.style.setProperty("--control-ui-text-scale", scale);
|
||||
|
|
@ -157,7 +114,7 @@ async function useOversizedDismissLabels(currentPage: Page) {
|
|||
"Permanently suppress every future lobster visit notice across all workspaces",
|
||||
];
|
||||
document
|
||||
.querySelectorAll("wa-dropdown.lobster-pet-dismiss-menu wa-dropdown-item")
|
||||
.querySelectorAll(".lobster-pet-dismiss-menu__panel .oc-menu-item__label")
|
||||
.forEach((item, index) => {
|
||||
item.textContent = labels[index] ?? item.textContent;
|
||||
});
|
||||
|
|
@ -170,8 +127,19 @@ suite.define(() => {
|
|||
await configureRealComposerPet(page, 42);
|
||||
const sprite = page.locator(".lobster-pet");
|
||||
await sprite.waitFor();
|
||||
// Native dismissal completion needs live animation frames.
|
||||
await page.clock.resume();
|
||||
const composer = page.locator(".new-session-page__message");
|
||||
await composer.focus();
|
||||
await sprite.click({ button: "right" });
|
||||
await page.getByText("Dismiss and don't show again", { exact: true }).waitFor();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.getByRole("menuitem", { name: "Dismiss", exact: true })
|
||||
.evaluate((item) => item === document.activeElement),
|
||||
)
|
||||
.toBe(true);
|
||||
const measurement = await measureDismissMenu(page);
|
||||
expect(measurement).toMatchObject({
|
||||
overflowPx: 0,
|
||||
|
|
@ -182,8 +150,23 @@ suite.define(() => {
|
|||
expect(measurement.menuTop).toBeGreaterThanOrEqual(0);
|
||||
expect(measurement.menuBottom).toBeLessThanOrEqual(measurement.viewportHeight);
|
||||
expect(await page.locator("openclaw-app-sidebar openclaw-lobster-pet").count()).toBe(0);
|
||||
await page.getByText("Dismiss", { exact: true }).click();
|
||||
await page.clock.runFor(350);
|
||||
const panel = page.locator(".lobster-pet-dismiss-menu__panel");
|
||||
await page.keyboard.press("Escape");
|
||||
expect(await composer.evaluate((input) => input === document.activeElement)).toBe(true);
|
||||
await panel.waitFor({ state: "detached" });
|
||||
expect(await sprite.count()).toBe(1);
|
||||
|
||||
await sprite.click({ button: "right" });
|
||||
await panel.waitFor();
|
||||
await composer.click();
|
||||
await panel.waitFor({ state: "detached" });
|
||||
expect(await composer.evaluate((input) => input === document.activeElement)).toBe(true);
|
||||
expect(await sprite.count()).toBe(1);
|
||||
|
||||
await sprite.click({ button: "right" });
|
||||
await panel.waitFor();
|
||||
await page.keyboard.press("Enter");
|
||||
expect(await composer.evaluate((input) => input === document.activeElement)).toBe(true);
|
||||
await expect.poll(() => sprite.count()).toBe(0);
|
||||
}));
|
||||
|
||||
|
|
@ -194,16 +177,13 @@ suite.define(() => {
|
|||
await sprite.waitFor();
|
||||
|
||||
await sprite.click({ button: "right" });
|
||||
await page.locator("wa-dropdown.lobster-pet-dismiss-menu").waitFor();
|
||||
await page.locator(".lobster-pet-dismiss-menu__panel").waitFor();
|
||||
await page.getByText("Dismiss and don't show again", { exact: true }).waitFor();
|
||||
const baseline = await measureDismissMenu(page);
|
||||
|
||||
await useOversizedDismissLabels(page);
|
||||
|
||||
// Web Awesome's popup tracks anchor/floating element size with a
|
||||
// ResizeObserver, so the label swap above reflows the popup
|
||||
// asynchronously; poll for settlement instead of asserting immediately
|
||||
// after the DOM write.
|
||||
// Allow CSS anchor positioning to settle after the labels reflow.
|
||||
await expect
|
||||
.poll(async () => (await measureDismissMenu(page)).overflowPx, {
|
||||
message: "menu did not settle",
|
||||
|
|
@ -231,15 +211,13 @@ suite.define(() => {
|
|||
await sprite.waitFor();
|
||||
|
||||
await sprite.click({ button: "right" });
|
||||
await shortPage.locator("wa-dropdown.lobster-pet-dismiss-menu").waitFor();
|
||||
await shortPage.locator(".lobster-pet-dismiss-menu__panel").waitFor();
|
||||
await shortPage.getByText("Dismiss and don't show again", { exact: true }).waitFor();
|
||||
|
||||
const measurement = await measureDismissMenu(shortPage);
|
||||
|
||||
// At this height even the "top" placement may run short, so Web
|
||||
// Awesome's own vertical auto-size may still shrink the menu in place
|
||||
// (an accepted last-resort fallback). What must hold regardless is
|
||||
// that the popup itself never renders outside the viewport.
|
||||
// The native anchor can flip or constrain the menu at short heights;
|
||||
// either placement must stay within the viewport.
|
||||
expect(measurement.menuTop).toBeGreaterThanOrEqual(0);
|
||||
expect(measurement.menuBottom).toBeLessThanOrEqual(measurement.viewportHeight);
|
||||
});
|
||||
|
|
@ -256,17 +234,24 @@ suite.define(() => {
|
|||
const point = await sprite.evaluate(
|
||||
(element, direction) => ({
|
||||
clientX: direction === "left" ? 12 : innerWidth - 12,
|
||||
clientY: element.getBoundingClientRect().top + 12,
|
||||
clientY: Math.trunc(element.getBoundingClientRect().top + 12),
|
||||
}),
|
||||
edge,
|
||||
);
|
||||
await sprite.dispatchEvent("contextmenu", point);
|
||||
await page.locator("wa-dropdown.lobster-pet-dismiss-menu").waitFor();
|
||||
await sprite.evaluate((element, position) => {
|
||||
element.dispatchEvent(
|
||||
new MouseEvent("contextmenu", { ...position, bubbles: true, cancelable: true }),
|
||||
);
|
||||
}, point);
|
||||
await page.locator(".lobster-pet-dismiss-menu__panel").waitFor();
|
||||
await page.clock.runFor(150);
|
||||
|
||||
const measurement = await measureDismissMenu(page);
|
||||
|
||||
expect(measurement.menuLeft).toBeGreaterThanOrEqual(0);
|
||||
expect(measurement.menuRight).toBeLessThanOrEqual(measurement.viewportWidth);
|
||||
expect(measurement.anchorLeft).toBeCloseTo(point.clientX);
|
||||
expect(measurement.anchorTop).toBeCloseTo(point.clientY);
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -279,7 +264,7 @@ suite.define(() => {
|
|||
await sprite.waitFor();
|
||||
|
||||
await sprite.click({ button: "right" });
|
||||
await darkPage.locator("wa-dropdown.lobster-pet-dismiss-menu").waitFor();
|
||||
await darkPage.locator(".lobster-pet-dismiss-menu__panel").waitFor();
|
||||
await darkPage.getByText("Dismiss and don't show again", { exact: true }).waitFor();
|
||||
|
||||
const measurement = await measureDismissMenu(darkPage);
|
||||
|
|
|
|||
|
|
@ -121,10 +121,26 @@ openclaw-lobster-pet .lobster-bottle {
|
|||
}
|
||||
|
||||
/* Restore hit testing beneath the pointer-inert decorative pet host. */
|
||||
wa-dropdown.lobster-pet-dismiss-menu {
|
||||
.lobster-pet-dismiss-menu {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.lobster-pet-dismiss-menu__panel {
|
||||
--oc-overlay-distance: 0px;
|
||||
}
|
||||
|
||||
.lobster-pet-dismiss-menu__trigger {
|
||||
position: fixed;
|
||||
left: var(--lobster-pet-menu-x);
|
||||
top: var(--lobster-pet-menu-y);
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.lobster-pet--elder {
|
||||
transition: left 2.6s ease-in-out;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue