diff --git a/packages/session-ui/component-tests/session-message-projection.spec.ts b/packages/session-ui/component-tests/session-message-projection.spec.ts index d1508722982..dbbd89a47d7 100644 --- a/packages/session-ui/component-tests/session-message-projection.spec.ts +++ b/packages/session-ui/component-tests/session-message-projection.spec.ts @@ -122,6 +122,43 @@ story.describe("message actions menu", () => { }) }) +story("keeps the footer in view while an outside click closes the actions menu", async ({ mount, page }) => { + const timeline = await mount("current-prompt-message--pending") + const footer = timeline.locator('[data-slot="user-message-copy-wrapper"]') + + await timeline.getByLabel("Delivery state").selectOption("delivered") + await timeline.locator('[data-component="user-message"]').hover() + await timeline.getByRole("button", { name: "More actions", exact: true }).click() + await expect(page.getByRole("menu")).toBeVisible() + await page.mouse.move(0, 0) + + // Kobalte drops the expanded state a task before it returns focus to the trigger, so record every frame from the + // click until a few frames after the menu has gone rather than the settled state. + const frames = await footer.evaluateHandle((element) => { + const opacities: string[] = [] + const closed = { frames: 0 } + + const done = new Promise((resolve) => { + const tick = () => { + opacities.push(getComputedStyle(element).opacity) + + if (!document.querySelector('[role="menu"]')) closed.frames += 1 + + if (closed.frames < 5) return requestAnimationFrame(tick) + + resolve(opacities) + } + + requestAnimationFrame(tick) + }) + + return { done } + }) + + await page.mouse.click(0, 0) + expect(new Set(await frames.evaluate((sampler) => sampler.done))).toEqual(new Set(["1"])) +}) + story("keeps pending steer actions usable after a request fails", async ({ mount }) => { const timeline = await mount("current-prompt-message--pending") const queue = timeline.getByRole("button", { name: "Move to queue", exact: true }) diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css index 07d640b314a..e120641612c 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -418,7 +418,7 @@ &:hover [data-slot="user-message-copy-wrapper"], &:focus-within [data-slot="user-message-copy-wrapper"], - [data-slot="user-message-copy-wrapper"]:has([data-expanded]), + [data-slot="user-message-copy-wrapper"]:has([data-expanded], [data-closing]), &:hover [data-slot="user-message-meta-tail"], &:focus-within [data-slot="user-message-meta-tail"] { opacity: 1; @@ -468,7 +468,7 @@ &:hover [data-slot="user-message-run-actions"], &:focus-within [data-slot="user-message-run-actions"], - [data-slot="user-message-run-actions"]:has([data-expanded]) { + [data-slot="user-message-run-actions"]:has([data-expanded], [data-closing]) { opacity: 1; pointer-events: auto; } diff --git a/packages/ui/src/navigation/menu/menu.tsx b/packages/ui/src/navigation/menu/menu.tsx index 966f10ee021..55576d4525b 100644 --- a/packages/ui/src/navigation/menu/menu.tsx +++ b/packages/ui/src/navigation/menu/menu.tsx @@ -1,5 +1,6 @@ -import { DropdownMenu } from "@kobalte/core/dropdown-menu" +import { DropdownMenu, type DropdownMenuTriggerProps } from "@kobalte/core/dropdown-menu" import { ContextMenu } from "@kobalte/core/context-menu" +import type { PolymorphicProps } from "@kobalte/core/polymorphic" import { createComputed, createContext, @@ -13,6 +14,7 @@ import { type ComponentProps, type JSX, type ParentProps, + type ValidComponent, } from "solid-js" import { createMenuState, @@ -316,6 +318,14 @@ function MenuSeparator(props: ComponentProps) { ) } +// Kobalte drops `data-expanded` as the menu closes but returns focus to the trigger a task later, so for a frame the +// trigger is neither expanded nor focused. `data-closing` covers that gap for UI revealed by either. +function MenuTrigger(props: PolymorphicProps>) { + const ctx = useMenuContext() + + return +} + function MenuContent(props: ComponentProps) { const ctx = useMenuContext() const [s, r] = splitProps(props, ["class", "classList", "onCloseAutoFocus", "onInteractOutside"]) @@ -439,7 +449,7 @@ const MenuContext = Object.assign(MenuContextRoot, { }) export const Menu = Object.assign(MenuRoot, { - Trigger: DropdownMenu.Trigger, + Trigger: MenuTrigger, Portal: DropdownMenu.Portal, Content: MenuContent, Item: MenuItem,