From ed9cb54758b4c9134866811bef35c607ac9a78aa Mon Sep 17 00:00:00 2001 From: Saul Dominguez Date: Wed, 26 Aug 2026 12:01:42 +0200 Subject: [PATCH] feat(F0Dialog): a footer action can be a route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A footer action was always a handler. But some of them LEAVE the dialog — "Go to post", "Open in Contracts" — and a place you cannot cmd-click is a place the reader has to come back from: no new tab, no copied link, no middle click. `DialogControls` already takes a `url` for its resource affordance, and says why in its own comment; footer actions could not. `primaryAction` and `secondaryAction` now take a route OR a handler, as a union, so a link cannot be declared without somewhere to go and a handler cannot be declared without something to do. Both together is a link that also reports the press. The footer spreads whichever it was given into `F0Button`, which has supported `href` all along. The multi-action (dropdown) form is unchanged: `ButtonDropdownItem` has no href, so a footer of several actions still carries handlers only. Co-Authored-By: Claude Opus 5 --- .../F0Dialog.footer-actions.test.tsx | 58 +++++++++++++++++++ .../F0Dialog/components/F0DialogFooter.tsx | 12 +++- packages/react/src/patterns/F0Dialog/types.ts | 22 +++++-- 3 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 packages/react/src/patterns/F0Dialog/__tests__/F0Dialog.footer-actions.test.tsx diff --git a/packages/react/src/patterns/F0Dialog/__tests__/F0Dialog.footer-actions.test.tsx b/packages/react/src/patterns/F0Dialog/__tests__/F0Dialog.footer-actions.test.tsx new file mode 100644 index 0000000000..8496c8a566 --- /dev/null +++ b/packages/react/src/patterns/F0Dialog/__tests__/F0Dialog.footer-actions.test.tsx @@ -0,0 +1,58 @@ +import { describe, expect, test, vi } from "vitest" + +import { screen, userEvent, zeroRender as render } from "@/testing/test-utils" + +import { F0Dialog } from "../index" + +/** + * A footer action that LEAVES the dialog should be a link: "Go to post" is a + * place, and a place you cannot cmd-click is a place the reader has to come + * back from. The ones that act on what the dialog is showing stay handlers. + */ +const dialog = (props: Record) => + render( + {}} header={{ title: "A post" }} {...props}> +

the post

+
+ ) + +describe("footer actions", () => { + test("draws a primary action with a route as a real link", () => { + dialog({ primaryAction: { label: "Go to post", href: "/posts/1" } }) + + expect(screen.getByRole("link", { name: "Go to post" })).toHaveAttribute( + "href", + "/posts/1" + ) + }) + + test("draws a secondary action with a route as a real link", () => { + dialog({ + secondaryAction: { label: "Go to community", href: "/communities/2" }, + }) + + expect( + screen.getByRole("link", { name: "Go to community" }) + ).toHaveAttribute("href", "/communities/2") + }) + + test("keeps a handler action a button", async () => { + const onClick = vi.fn() + dialog({ primaryAction: { label: "Mark as read", onClick } }) + + await userEvent.click(screen.getByRole("button", { name: "Mark as read" })) + + expect(onClick).toHaveBeenCalledTimes(1) + }) + + test("reports the press on a link that asked to hear about it", async () => { + const onClick = vi.fn() + dialog({ + primaryAction: { label: "Go to post", href: "/posts/1", onClick }, + }) + + await userEvent.click(screen.getByRole("link", { name: "Go to post" })) + + expect(onClick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/react/src/patterns/F0Dialog/components/F0DialogFooter.tsx b/packages/react/src/patterns/F0Dialog/components/F0DialogFooter.tsx index 4250b3edfe..63d99794d8 100644 --- a/packages/react/src/patterns/F0Dialog/components/F0DialogFooter.tsx +++ b/packages/react/src/patterns/F0Dialog/components/F0DialogFooter.tsx @@ -55,7 +55,10 @@ export const F0DialogFooter = ({ return ( void } + | { href?: never; onClick: () => void } + export type F0DialogPrimaryAction = { label: string icon?: IconType iconPosition?: "left" | "right" - onClick: () => void disabled?: boolean loading?: boolean -} +} & F0DialogActionTarget export type F0DialogSecondaryAction = { label: string icon?: IconType iconPosition?: "left" | "right" - onClick: () => void disabled?: boolean loading?: boolean -} +} & F0DialogActionTarget // Shared base for action items used in multi-action dropdowns. // Note: disabled/loading on items are reserved for future use —