Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/page-actions-dismiss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"blume": patch
---

Close the page-action dropdowns (Export, Open in chat, Connect to MCP) when clicking outside them or pressing Escape, instead of leaving the panel open until its own trigger is clicked again.
29 changes: 29 additions & 0 deletions apps/docs/e2e/site.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,32 @@ test.describe("custom pages", () => {
await expect(page.locator("header").first()).toBeVisible();
});
});

test.describe("page actions", () => {
test("dropdowns close on an outside click and on Escape", async ({
page,
}) => {
await page.goto("/docs/quickstart");
const actions = page.locator("[data-blume-page-actions]");
const dropdown = actions.locator("details").first();
const open = actions.locator("details[open]");

await dropdown.locator("summary").click();
await expect(open).toHaveCount(1);
await page.locator("#blume-content h1").click();
await expect(open).toHaveCount(0);

// Focus moves into the panel first, so the assertion below proves Escape
// restored it rather than just observing the click that opened the menu.
await dropdown.locator("summary").click();
await expect(open).toHaveCount(1);
const menuItem = dropdown
.locator("[data-blume-menu] :is(a, button)")
.first();
await menuItem.focus();
await expect(menuItem).toBeFocused();
await page.keyboard.press("Escape");
await expect(open).toHaveCount(0);
await expect(dropdown.locator("summary")).toBeFocused();
});
});
38 changes: 35 additions & 3 deletions packages/blume/src/components/layout/PageActions.astro
Original file line number Diff line number Diff line change
Expand Up @@ -329,22 +329,54 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
menu.classList.toggle("mb-1", flipUp);
};

const openDropdown = () =>
document.querySelector<HTMLDetailsElement>(
"[data-blume-page-actions] details[open]"
);

// rAF-coalesced so a live resize drag re-reads layout once per frame, not
// once per event, while the open menu still tracks the viewport. Registered
// once at module scope (this bundle runs once per real page load) and
// querying live, because client-router swaps replace the menu elements.
window.addEventListener(
"resize",
rafThrottle(() => {
const open = document.querySelector<HTMLDetailsElement>(
"[data-blume-page-actions] details[open]"
);
const open = openDropdown();
if (open) {
placeMenu(open);
}
})
);

// A native <details> only closes when its own summary is clicked, so an open
// panel would linger over the page. Dismiss it like a real menu: any pointer
// press outside the open dropdown closes it. Registered at module scope for
// the same reason as the resize handler above.
document.addEventListener("pointerdown", (event) => {
const open = openDropdown();
if (!open) {
return;
}
const target = event.target;
if (target instanceof Node && open.contains(target)) {
return;
}
open.open = false;
});

// Escape closes the menu and returns focus to the trigger that opened it.
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape") {
return;
}
const open = openDropdown();
if (!open) {
return;
}
open.open = false;
open.querySelector<HTMLElement>("summary")?.focus();
});

// Per-page setup, run on the initial load and again after every
// client-router swap: the swap rebuilds the actions menu from
// server-rendered markup, so every handler below binds to fresh elements.
Expand Down