diff --git a/src/components/extensions/extension-filters.tsx b/src/components/extensions/extension-filters.tsx index 10afb7cb..b9842d5f 100644 --- a/src/components/extensions/extension-filters.tsx +++ b/src/components/extensions/extension-filters.tsx @@ -155,8 +155,10 @@ export function ExtensionFilters() { return (
- {/* Filters: kind pills + result count + dropdowns + search */} -
+ {/* Filters: kind pills + result count + dropdowns + search. Wraps on + narrow windows: the bar sits above the scroll container, so anything + past the right edge would otherwise be clipped with no way to reach it. */} +
{kinds.map((kind) => ( + )} +
diff --git a/src/components/layout/__tests__/update-dialogs.test.tsx b/src/components/layout/__tests__/update-dialogs.test.tsx new file mode 100644 index 00000000..a94b1525 --- /dev/null +++ b/src/components/layout/__tests__/update-dialogs.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it } from "vitest"; +import { useUpdateStore } from "@/stores/update-store"; +import { useWebUpdateStore } from "@/stores/web-update-store"; +import { UpdateDialog } from "../update-dialog"; +import { WebUpdateDialog } from "../web-update-dialog"; + +const available = { version: "9.9.9", body: "notes" }; + +describe.each([ + { + name: "UpdateDialog", + Dialog: UpdateDialog, + open: () => useUpdateStore.setState({ showChangelog: true, available }), + isOpen: () => useUpdateStore.getState().showChangelog, + }, + { + name: "WebUpdateDialog", + Dialog: WebUpdateDialog, + open: () => useWebUpdateStore.setState({ showDialog: true, available }), + isOpen: () => useWebUpdateStore.getState().showDialog, + }, +])("$name keyboard handling", ({ Dialog, open, isOpen }) => { + beforeEach(() => open()); + + it("moves focus into the dialog and keeps Tab cycling inside it", async () => { + const user = userEvent.setup(); + render(); + const dialog = screen.getByRole("dialog"); + expect(dialog.contains(document.activeElement)).toBe(true); + + const focusable = dialog.querySelectorAll("button, a[href]"); + focusable[focusable.length - 1].focus(); + await user.tab(); + expect(document.activeElement).toBe(focusable[0]); + }); + + it("closes on Escape", async () => { + const user = userEvent.setup(); + render(); + await user.keyboard("{Escape}"); + expect(isOpen()).toBe(false); + expect(screen.queryByRole("dialog")).toBeNull(); + }); +}); diff --git a/src/components/layout/update-dialog.tsx b/src/components/layout/update-dialog.tsx index 04f6cb81..d0c98954 100644 --- a/src/components/layout/update-dialog.tsx +++ b/src/components/layout/update-dialog.tsx @@ -1,5 +1,8 @@ import { Download, Loader2, X } from "lucide-react"; +import { useRef } from "react"; import { useTranslation } from "react-i18next"; +import { useEscape } from "@/hooks/use-escape"; +import { useFocusTrap } from "@/hooks/use-focus-trap"; import { localizeChangelog } from "@/lib/i18n/changelog"; import { useUpdateStore } from "@/stores/update-store"; import { ChangelogMarkdown } from "./changelog-markdown"; @@ -13,7 +16,12 @@ export function UpdateDialog() { const dismissUpdate = useUpdateStore((s) => s.dismissUpdate); const confirmUpdate = useUpdateStore((s) => s.confirmUpdate); - if (!showChangelog || !available) return null; + const open = showChangelog && available !== null; + const dlgRef = useRef(null); + useFocusTrap(dlgRef, open); + useEscape(dismissDialog, open); + + if (!open) return null; return (
@@ -24,6 +32,7 @@ export function UpdateDialog() { {/* Dialog */}
s.dismissDialog); const dismissUpdate = useWebUpdateStore((s) => s.dismissUpdate); - if (!showDialog || !available) return null; + const open = showDialog && available !== null; + const dlgRef = useRef(null); + useFocusTrap(dlgRef, open); + useEscape(dismissDialog, open); + + if (!open) return null; return (
@@ -23,6 +31,7 @@ export function WebUpdateDialog() {
-
-
+ {/* The step column is narrower than the full row; `justify-center` on + the wrapper lets the fixed-width row overflow it evenly on both sides. */} +
+
{AGENT_ORDER.map((name, i) => { const s = SCATTER_POSITIONS[name]; return ( @@ -366,7 +396,7 @@ function StepWelcome() { : "none", }} > - +
); })} diff --git a/src/components/shared/agent-card.tsx b/src/components/shared/agent-card.tsx index 982f1535..461ad1dd 100644 --- a/src/components/shared/agent-card.tsx +++ b/src/components/shared/agent-card.tsx @@ -24,7 +24,7 @@ const CLICK_DURATIONS: Partial> = { openclaw: 3050, // shake and wake scatter share one 1.6s track penguin: 1650, - // slide together, hold, puff away, pause and fade back: 3s plus the last leg's delay + // the tetris replay is one 3s clock: four pieces, flash, settle, pick-up pi: 3200, }; @@ -47,12 +47,15 @@ export function AgentCard({ agent }: AgentCardProps) { > {/* dsh dives below its box on hover; clip around the mascot (with top headroom for the leap and the spout) so the whale vanishes - above the label instead of sliding over the text */} + above the label instead of sliding over the text. pi's tetris + pieces fall in from the card's top edge, so clip there. */}