From 3ca10f32924a12bf6b55597bcc9f2d7aa682db8b Mon Sep 17 00:00:00 2001 From: Marcos Medina Date: Fri, 5 Jun 2026 11:42:20 +0200 Subject: [PATCH 01/13] feat(ui): add F0CardRow component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F0CardRow is the horizontal, row-shaped counterpart to F0Card: an optional avatar on the left, a title with an optional description, and trailing actions on the right. Supports primary / secondary / overflow actions, an icon-only confirm/reject variant, an alert banner, and a `stackAt` container breakpoint that drops the actions onto their own line (folding secondary buttons into a left ⋯ menu) when the card is narrow. Net-new component with a unit test suite and autodocs (description + use cases). The F0HILActionConfirmation migration and removal of the experimental F0Card `oneLiner` variant stay with the cocreation PR. Co-Authored-By: Claude Opus 4.8 --- .../react/src/components/F0Card/F0CardRow.tsx | 248 +++++++++++++ .../F0Card/__stories__/F0CardRow.stories.tsx | 341 ++++++++++++++++++ .../F0Card/__tests__/F0CardRow.test.tsx | 204 +++++++++++ .../F0Card/components/CardAvatar.tsx | 70 +++- .../F0Card/components/CardRowActions.tsx | 307 ++++++++++++++++ .../react/src/components/F0Card/index.tsx | 1 + 6 files changed, 1163 insertions(+), 8 deletions(-) create mode 100644 packages/react/src/components/F0Card/F0CardRow.tsx create mode 100644 packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx create mode 100644 packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx create mode 100644 packages/react/src/components/F0Card/components/CardRowActions.tsx diff --git a/packages/react/src/components/F0Card/F0CardRow.tsx b/packages/react/src/components/F0Card/F0CardRow.tsx new file mode 100644 index 0000000000..b92cde7d67 --- /dev/null +++ b/packages/react/src/components/F0Card/F0CardRow.tsx @@ -0,0 +1,248 @@ +import { forwardRef } from "react" + +import { F0Link } from "@/components/F0Link" +import { DropdownItem } from "@/experimental/Navigation/Dropdown" +import { withDataTestId } from "@/lib/data-testid" +import { withSkeleton } from "@/lib/skeleton" +import { cn, focusRing } from "@/lib/utils" +import { Card } from "@/ui/Card" +import { Skeleton } from "@/ui/skeleton" +import { Text } from "@/ui/Text" + +import { + type CardPrimaryAction, + type CardSecondaryAction, + type CardSecondaryLink, +} from "./components/CardActions" +import { CardAlertWrapper, alertBorderColor } from "./components/CardAlert" +import { CardAvatar, type CardAvatarVariant } from "./components/CardAvatar" +import { + CardRowActions, + type CardRowConfirmAction, + type CardRowStackAt, + cardRowClassName, +} from "./components/CardRowActions" +import { type CardAlertProps } from "./types" + +export interface F0CardRowProps { + /** + * The primary line of text. + */ + title: string + + /** + * Optional secondary line shown beneath the title (single line, truncated). + */ + description?: string + + /** + * Optional avatar rendered at a fixed `lg` size on the left (the size is not + * configurable). Accepts any avatar type in the system: person, company, team, + * file, flag, icon, emoji, module, alert, date, pulse. Types without a `lg` + * variant (date, pulse) render at their intrinsic size. + */ + avatar?: CardAvatarVariant + + /** + * The primary action button, shown at the trailing edge of the row. + */ + primaryAction?: CardPrimaryAction + + /** + * Secondary actions (buttons) or a single link, shown before the primary action. + */ + secondaryActions?: CardSecondaryAction[] | CardSecondaryLink + + /** + * Overflow (⋯) menu actions, rendered as the trailing control of the row. + */ + otherActions?: DropdownItem[] + + /** + * Confirm/reject variant: renders an icon-only ✗ (reject) + ✓ (confirm) pair + * instead of the standard actions. Provide either or both. + */ + confirmAction?: CardRowConfirmAction + + /** + * Reject (✗) action of the confirm/reject variant. See {@link confirmAction}. + */ + rejectAction?: CardRowConfirmAction + + /** + * Compact layout: tighter padding and smaller controls. + */ + compact?: boolean + + /** + * Container width at which the actions drop to their own line (below it) vs. + * sit inline (at/above it). `never` keeps them inline at every width. + * @default "never" + */ + stackAt?: CardRowStackAt + + /** + * When set, the whole row becomes a link to this href. + */ + link?: string + + /** + * Stretch to fill the height of its container. + */ + fullHeight?: boolean + + /** + * Alert banner displayed above the row with a coloured header strip and matching + * border. Supports info, warning, critical and positive variants. + * Use `visible` + `onDismiss` for controlled dismiss behaviour. + */ + alert?: CardAlertProps + + /** + * Called when the row is clicked. + */ + onClick?: () => void + + /** + * Disables the full-row overlay link so a parent can manage drag-and-drop while + * still allowing click navigation via `onClick`. + */ + disableOverlayLink?: boolean +} + +/** + * A single-row card: optional avatar on the left, stacked title + description, + * and actions on the right. By default the actions stay inline at every width; + * set `stackAt` to drop them onto their own line below a container breakpoint + * (a container query on the card's width, not the viewport), so it reacts + * correctly inside grids and columns. + */ +const F0CardRowBase = forwardRef( + function F0CardRow( + { + title, + description, + avatar, + primaryAction, + secondaryActions, + otherActions, + confirmAction, + rejectAction, + compact = false, + link, + fullHeight = false, + alert, + onClick, + disableOverlayLink = false, + stackAt = "never", + }, + ref + ) { + const hasAlert = !!alert && alert.visible !== false + + const body = ( + + {link && !disableOverlayLink && ( + +   + + )} + +
+
+ {avatar && } +
+ {title && ( + + )} + {description && ( + + )} +
+
+ + +
+
+ ) + + if (hasAlert) { + return ( + + {body} + + ) + } + + return body + } +) + +F0CardRowBase.displayName = "F0CardRow" + +const F0CardRowSkeleton = ({ compact = false }: { compact?: boolean }) => { + return ( + +
+
+ +
+ + +
+
+ +
+
+ ) +} + +export const F0CardRow = withDataTestId( + withSkeleton(F0CardRowBase, F0CardRowSkeleton) +) diff --git a/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx b/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx new file mode 100644 index 0000000000..56a14f41b2 --- /dev/null +++ b/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx @@ -0,0 +1,341 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" + +import image from "@storybook-static/avatars/person04.jpg" +import { fn } from "storybook/test" + +import { Briefcase, Delete, Envelope } from "@/icons/app" + +import { F0CardRow } from "../F0CardRow" + +const meta: Meta = { + component: F0CardRow, + title: "Card/Row", + parameters: { + docs: { + description: { + component: [ + "`F0CardRow` is a compact, single-row card: an optional avatar on the left, a title with an optional description, and trailing actions on the right.", + "Use it for list rows, inline confirmations and dense layouts where a full `F0Card` is too heavy — e.g. a settings toggle row, a pending-approval item, or a selectable entity.", + "Actions stay inline at every width by default. Set stackAt to collapse them onto their own line below a container breakpoint — secondary buttons fold into a left ⋯ menu while the primary stays pinned. For an approve/reject row, use the icon-only confirmAction / rejectAction variant. The avatar renders at a fixed size and accepts any avatar type in the system.", + ] + .map((line) => `

${line}

`) + .join("\n"), + }, + story: { inline: false, height: "160px" }, + }, + }, + tags: ["autodocs", "stable"], + // Explicit argTypes: docgen can't infer props through the + // withDataTestId(withSkeleton(...)) wrapper, so we declare the controls here. + argTypes: { + title: { control: "text", description: "The primary line of text." }, + description: { + control: "text", + description: "Optional secondary line (single line, truncated).", + }, + avatar: { + control: "object", + description: + "Optional avatar rendered at md on the left. Any avatar type: person, team, company, file, flag, emoji, icon, module.", + }, + stackAt: { + control: "select", + options: ["sm", "md", "lg", "never"], + description: + "Container width at which the actions drop to their own line. `never` keeps them inline at every width.", + table: { defaultValue: { summary: "never" } }, + }, + compact: { + control: "boolean", + description: "Tighter padding and smaller controls.", + }, + fullHeight: { + control: "boolean", + description: "Stretch to fill the height of the container.", + }, + link: { + control: "text", + description: "When set, the whole row becomes a link to this href.", + }, + // Function-bearing props: disable the control so it doesn't dump the + // serialized mock fn() source. They still appear in the args table. + primaryAction: { + control: false, + description: "Primary action button, pinned at the trailing edge.", + }, + secondaryActions: { + control: false, + description: "Secondary actions (buttons) or a single link.", + }, + otherActions: { + control: false, + description: "Overflow (⋯) menu actions, kept in the left more-menu.", + }, + confirmAction: { + control: false, + description: + "Confirm (✓) icon-only action of the confirm/reject variant.", + }, + rejectAction: { + control: false, + description: "Reject (✗) icon-only action of the confirm/reject variant.", + }, + alert: { + control: false, + description: "Alert banner displayed above the row.", + }, + onClick: { + action: "clicked", + description: "Called when the row is clicked.", + }, + }, + decorators: [ + (Story, context) => { + if (context.parameters?.noMetaLayout) { + return + } + return ( +
+
+ +
+
+ ) + }, + ], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + title: "Do you want to proceed?", + primaryAction: { + label: "Confirm", + onClick: fn(), + }, + secondaryActions: [ + { + label: "Cancel", + onClick: fn(), + }, + ], + }, +} + +/** + * Confirm/reject variant: icon-only ✗ (reject) + ✓ (confirm) buttons instead of + * the standard actions. Useful for inline approve/reject rows. + */ +export const ConfirmReject: Story = { + args: { + avatar: { + type: "person", + firstName: "Jane", + lastName: "Cooper", + src: image, + }, + title: "Jane Cooper", + description: "Requested 3 days off", + rejectAction: { label: "Reject", onClick: fn() }, + confirmAction: { label: "Approve", onClick: fn() }, + }, +} + +/** + * Actions stay inline at every width by default (`stackAt: "never"`). Opt into a + * responsive collapse with `stackAt` (e.g. `"md"`): below that container width the + * actions drop onto their own line with a separator, and secondary buttons fold + * into the left ⋯. Resize the card narrower than ~448px to see it. + */ +export const Stacking: Story = { + args: { + avatar: { + type: "person", + firstName: "Jane", + lastName: "Cooper", + src: image, + }, + title: "Jane Cooper", + description: "Drops to its own line below @md", + stackAt: "md", + secondaryActions: [{ label: "Edit", onClick: fn() }], + otherActions: [ + { label: "Mail", icon: Envelope, onClick: fn() }, + { type: "separator" }, + { label: "Delete", icon: Delete, onClick: fn(), critical: true }, + ], + primaryAction: { label: "Open", onClick: fn() }, + }, +} + +export const WithAvatar: Story = { + args: { + avatar: { + type: "person", + firstName: "Jane", + lastName: "Cooper", + src: image, + }, + title: "Jane Cooper", + description: "Product designer", + primaryAction: { + label: "Open", + onClick: fn(), + }, + secondaryActions: [ + { + label: "Edit", + onClick: fn(), + }, + ], + otherActions: [ + { + label: "Mail", + icon: Envelope, + onClick: fn(), + }, + { type: "separator" }, + { + label: "Delete", + icon: Delete, + onClick: fn(), + critical: true, + }, + ], + }, +} + +export const WithAlert: Story = { + args: { + avatar: { + type: "person", + firstName: "Jane", + lastName: "Cooper", + src: image, + }, + title: "Jane Cooper", + description: "Contract ends in 3 days", + alert: { + variant: "warning", + title: "Action required", + }, + primaryAction: { + label: "Renew", + onClick: fn(), + }, + secondaryActions: [ + { + label: "Dismiss", + onClick: fn(), + }, + ], + }, + parameters: { + docs: { story: { inline: false, height: "200px" } }, + }, +} + +/** + * The `avatar` prop accepts every single-avatar type in the system — person, + * company, team, file, flag, icon, emoji, module, alert, date and pulse — each + * rendered at a single, fixed size on the left (the size is not configurable). + */ +export const AvatarTypes: Story = { + parameters: { + noMetaLayout: true, + docs: { story: { inline: false, height: "840px" } }, + }, + render: () => ( +
+ + + + + + + + + + + +
+ ), + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} diff --git a/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx b/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx new file mode 100644 index 0000000000..fe06446b9e --- /dev/null +++ b/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx @@ -0,0 +1,204 @@ +import { describe, expect, it, vi } from "vitest" +import "@testing-library/jest-dom/vitest" +import { Briefcase } from "@/icons/app" +import { + zeroRender as render, + screen, + userEvent, + waitFor, +} from "@/testing/test-utils" + +import type { CardSecondaryLink } from "../components/CardActions" +import type { CardAvatarVariant } from "../components/CardAvatar" + +import { F0CardRow } from "../F0CardRow" + +describe("F0CardRow", () => { + it("renders title and description", () => { + render() + + expect(screen.getByText("Jane Cooper")).toBeInTheDocument() + expect(screen.getByText("Product designer")).toBeInTheDocument() + }) + + it("renders an avatar when provided", () => { + render( + + ) + + expect(screen.getByTestId("card-avatar")).toBeInTheDocument() + }) + + it("renders every supported avatar type", () => { + const avatars: CardAvatarVariant[] = [ + { type: "person", firstName: "Jane", lastName: "Cooper" }, + { type: "company", name: "Acme Inc" }, + { type: "team", name: "Design" }, + { type: "file", file: { name: "contract.pdf", type: "application/pdf" } }, + { type: "flag", flag: "es" }, + { type: "icon", icon: Briefcase }, + { type: "emoji", emoji: "🚀" }, + { type: "module", module: "goals" }, + { type: "alert", variant: "warning" }, + { type: "date", date: new Date(2026, 5, 5) }, + { + type: "pulse", + firstName: "Jane", + lastName: "Cooper", + onPulseClick: vi.fn(), + }, + ] + + avatars.forEach((avatar) => { + const { unmount } = render() + expect(screen.getByTestId("card-avatar")).toBeInTheDocument() + unmount() + }) + }) + + it("renders as a clickable link when link is provided", () => { + render() + + const link = screen.getByRole("link", { name: "Linked row" }) + expect(link).toHaveAttribute("href", "/test-link") + }) + + it("calls onClick when the row is clicked", async () => { + const user = userEvent.setup() + const handleClick = vi.fn() + + render() + + await user.click(screen.getByText("Clickable")) + expect(handleClick).toHaveBeenCalledTimes(1) + }) + + it("calls primaryAction.onClick", async () => { + const user = userEvent.setup() + const onClick = vi.fn() + + render() + + await user.click(screen.getByTestId("primary-button")) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it("calls a secondary action's onClick", async () => { + const user = userEvent.setup() + const onEdit = vi.fn() + + render( + + ) + + await user.click(screen.getByRole("button", { name: "Edit" })) + expect(onEdit).toHaveBeenCalledTimes(1) + }) + + it("renders a secondary action link", () => { + const secondaryLink: CardSecondaryLink = { + label: "View more", + href: "/test-page", + target: "_blank", + } + + render() + + const link = screen.getByTestId("secondary-link") + expect(link).toHaveAttribute("href", "/test-page") + expect(link).toHaveAttribute("target", "_blank") + }) + + it("opens the overflow menu and triggers otherActions", async () => { + const user = userEvent.setup() + const onArchive = vi.fn() + + render( + + ) + + // With only otherActions and the default stackAt="never", the sole button is + // the overflow (⋯) trigger. + await user.click(screen.getByRole("button")) + await user.click(screen.getByRole("menuitem", { name: "Archive" })) + await waitFor(() => expect(onArchive).toHaveBeenCalledTimes(1)) + }) + + describe("confirm/reject variant", () => { + it("calls confirmAction.onClick", async () => { + const user = userEvent.setup() + const onConfirm = vi.fn() + + render() + + await user.click(screen.getByTestId("confirm-button")) + expect(onConfirm).toHaveBeenCalledTimes(1) + }) + + it("calls rejectAction.onClick", async () => { + const user = userEvent.setup() + const onReject = vi.fn() + + render() + + await user.click(screen.getByTestId("reject-button")) + expect(onReject).toHaveBeenCalledTimes(1) + }) + + it("replaces the standard actions", () => { + render( + + ) + + expect(screen.getByTestId("confirm-button")).toBeInTheDocument() + expect(screen.getByTestId("reject-button")).toBeInTheDocument() + expect(screen.queryByTestId("primary-button")).not.toBeInTheDocument() + }) + }) + + it("renders the alert banner when alert is provided", () => { + render( + + ) + + expect(screen.getByText("Action required")).toBeInTheDocument() + }) + + it("renders for every stackAt value", () => { + const values = ["sm", "md", "lg", "never"] as const + + values.forEach((stackAt) => { + const { unmount } = render( + + ) + + expect(screen.getByTestId("card")).toBeInTheDocument() + // The primary stays visible at every breakpoint (rendered in the inline + // cluster and, for non-"never", the stacked cluster too). + expect(screen.getAllByTestId("primary-button").length).toBeGreaterThan(0) + unmount() + }) + }) +}) diff --git a/packages/react/src/components/F0Card/components/CardAvatar.tsx b/packages/react/src/components/F0Card/components/CardAvatar.tsx index d151c8a305..92c0c5ceb5 100644 --- a/packages/react/src/components/F0Card/components/CardAvatar.tsx +++ b/packages/react/src/components/F0Card/components/CardAvatar.tsx @@ -1,7 +1,17 @@ import { AvatarVariant, F0Avatar } from "@/components/avatars/F0Avatar" +import { + type AlertAvatarProps, + F0AvatarAlert, +} from "@/components/avatars/F0AvatarAlert" +import { F0AvatarDate } from "@/components/avatars/F0AvatarDate" import { F0AvatarEmoji } from "@/components/avatars/F0AvatarEmoji" import { F0AvatarFile } from "@/components/avatars/F0AvatarFile" import { F0AvatarIcon } from "@/components/avatars/F0AvatarIcon" +import { + F0AvatarModule, + type ModuleId, +} from "@/components/avatars/F0AvatarModule" +import { F0AvatarPulse, type Pulse } from "@/components/avatars/F0AvatarPulse" import { IconType } from "@/components/F0Icon" import { cn } from "@/lib/utils" @@ -10,6 +20,19 @@ type CardAvatarVariant = | { type: "emoji"; emoji: string } | { type: "file"; file: File } | { type: "icon"; icon: IconType } + | { type: "module"; module: ModuleId } + | { type: "alert"; variant: AlertAvatarProps["type"] } + | { type: "date"; date: Date } + | { + type: "pulse" + firstName: string + lastName: string + src?: string + pulse?: Pulse + onPulseClick: () => void + } + +type CardAvatarSize = "sm" | "md" | "lg" interface CardAvatarProps { /** @@ -26,33 +49,64 @@ interface CardAvatarProps { * Whether the avatar is displayed in a compact layout */ compact?: boolean + + /** + * Explicit size override. When omitted, the size derives from `compact` + * (sm) or the default vertical layout (lg). Passing a size also signals + * inline usage (e.g. the card row) and drops the vertical margin. + */ + size?: CardAvatarSize } const AvatarRender = ({ avatar, - compact = false, + size, }: { avatar: CardAvatarVariant - compact?: boolean + size: CardAvatarSize }) => { if (avatar.type === "emoji") { - return + return } if (avatar.type === "file") { - return + return } if (avatar.type === "icon") { - return + return + } + if (avatar.type === "module") { + return + } + if (avatar.type === "alert") { + return + } + if (avatar.type === "date") { + // F0AvatarDate has a fixed intrinsic size (no size prop). + return + } + if (avatar.type === "pulse") { + // F0AvatarPulse has a fixed intrinsic size (no size prop). + return ( + + ) } - return + return } export function CardAvatar({ avatar, overlay = false, compact = false, + size, }: CardAvatarProps) { const isRounded = avatar.type === "person" + const resolvedSize: CardAvatarSize = size ?? (compact ? "sm" : "lg") return (
- +
) } diff --git a/packages/react/src/components/F0Card/components/CardRowActions.tsx b/packages/react/src/components/F0Card/components/CardRowActions.tsx new file mode 100644 index 0000000000..2531d68b86 --- /dev/null +++ b/packages/react/src/components/F0Card/components/CardRowActions.tsx @@ -0,0 +1,307 @@ +import { type Ref } from "react" + +import { F0Button } from "@/components/F0Button" +import { F0Link } from "@/components/F0Link" +import { Dropdown, type DropdownItem } from "@/experimental/Navigation/Dropdown" +import { Check, Cross } from "@/icons/app" +import { cn } from "@/lib/utils" +import { useOverflowCalculation } from "@/ui/OverflowList/useOverflowCalculation" + +import { + type CardPrimaryAction, + type CardSecondaryAction, + type CardSecondaryLink, +} from "./CardActions" + +// Pixel gap between the trailing controls — mirrors the `gap-2` used elsewhere. +const GAP = 8 + +/** + * Container breakpoint at which the card row switches between its inline and its + * stacked (actions-on-their-own-line) layout. `never` keeps it inline at every + * width. + */ +export type CardRowStackAt = "sm" | "md" | "lg" | "never" + +/** + * Outer row layout: a stacked column that becomes an inline row at the chosen + * container breakpoint. Exported so the card root and the actions share one source + * of truth (the breakpoint must match for the layout to stay coherent). + * Each value is a full static string so Tailwind's JIT can see the classes. + * + * Breakpoint mapping (ascending): the `"sm"` option uses Tailwind's `@xs` + * (24rem / 384px). f0-core overrides `@sm` to 40rem (640px) — larger than + * `@md` (28rem / 448px) — so using `@sm` here would (wrongly) stack *before* + * `md`. `@xs < @md < @lg` keeps sm < md < lg as expected. + */ +export const cardRowClassName: Record = { + sm: "flex flex-col @xs:flex-row @xs:items-center @xs:justify-between @xs:gap-4", + md: "flex flex-col @md:flex-row @md:items-center @md:justify-between @md:gap-4", + lg: "flex flex-col @lg:flex-row @lg:items-center @lg:justify-between @lg:gap-4", + never: "flex flex-row items-center justify-between gap-4", +} + +// Inline ("wide") cluster — shown only at/above the breakpoint. +const wideClusterVisibility: Record = { + sm: "hidden @xs:flex", + md: "hidden @md:flex", + lg: "hidden @lg:flex", + never: "flex", +} + +// Stacked ("narrow") cluster — shown only below the breakpoint. +const narrowClusterVisibility: Record = { + sm: "flex @xs:hidden", + md: "flex @md:hidden", + lg: "flex @lg:hidden", + never: "hidden", +} + +// Footer-style separator shown while the actions sit on their own stacked line; +// removed once they go inline at the breakpoint. +const stackedChrome: Record = { + sm: "-mx-4 mt-4 border-0 border-t border-solid border-t-f1-border-secondary px-4 pt-4 @xs:mx-0 @xs:mt-0 @xs:border-t-0 @xs:px-0 @xs:pt-0", + md: "-mx-4 mt-4 border-0 border-t border-solid border-t-f1-border-secondary px-4 pt-4 @md:mx-0 @md:mt-0 @md:border-t-0 @md:px-0 @md:pt-0", + lg: "-mx-4 mt-4 border-0 border-t border-solid border-t-f1-border-secondary px-4 pt-4 @lg:mx-0 @lg:mt-0 @lg:border-t-0 @lg:px-0 @lg:pt-0", + never: "", +} + +export interface CardRowConfirmAction { + onClick: () => void + /** Accessible label and tooltip. Defaults to "Confirm" / "Reject". */ + label?: string + disabled?: boolean +} + +interface CardRowActionsProps { + primaryAction?: CardPrimaryAction + secondaryActions?: CardSecondaryAction[] | CardSecondaryLink + /** Overflow (⋯) menu actions — always live in the left "more" menu. */ + otherActions?: DropdownItem[] + /** Confirm (✓) icon-only action — enables the confirm/reject variant. */ + confirmAction?: CardRowConfirmAction + /** Reject (✗) icon-only action — enables the confirm/reject variant. */ + rejectAction?: CardRowConfirmAction + compact?: boolean + /** Container breakpoint at which the actions drop to their own line. */ + stackAt?: CardRowStackAt +} + +/** + * Trailing actions for the card row. The "more" (⋯) menu sits on the LEFT and + * the primary stays pinned at the trailing edge. + * + * Two layouts, toggled by the card's container width at `stackAt`: + * - Wide (inline): all secondary buttons shown; the ⋯ holds only `otherActions`. + * - Narrow: the row drops onto its own full-width line; there the cluster IS + * width-bounded, so we measure it (same engine as `OverflowList`) and shed + * secondary buttons right→left into the left ⋯ as it tightens. + * + * Pass `confirmAction` / `rejectAction` for the icon-only confirm/reject variant + * (✗ then ✓), which replaces the standard actions. + */ +export function CardRowActions({ + primaryAction, + secondaryActions, + otherActions, + confirmAction, + rejectAction, + compact = false, + stackAt = "never", +}: CardRowActionsProps) { + const size = compact ? "sm" : "md" + + // Hook must run unconditionally, before any early return. + const secondaryArray = Array.isArray(secondaryActions) ? secondaryActions : [] + const { + containerRef, + measurementContainerRef, + customOverflowIndicatorRef, + visibleItems, + overflowItems, + isInitialized, + } = useOverflowCalculation(secondaryArray, GAP) + + const chrome = cn( + stackedChrome[stackAt], + stackAt !== "never" && compact && "mt-3 pt-3" + ) + + // Confirm/reject variant: icon-only outline buttons, reject (✗) then confirm (✓). + if (confirmAction || rejectAction) { + return ( +
+ {rejectAction && ( + { + e.stopPropagation() + rejectAction.onClick() + }} + data-testid="reject-button" + /> + )} + {confirmAction && ( + { + e.stopPropagation() + confirmAction.onClick() + }} + data-testid="confirm-button" + /> + )} +
+ ) + } + + const secondaryLink = + secondaryActions && !Array.isArray(secondaryActions) + ? secondaryActions + : undefined + const other = otherActions ?? [] + + // Before the first measurement, optimistically show everything so the row + // doesn't flash empty; the measured split takes over once initialized. + const shown = isInitialized ? visibleItems : secondaryArray + const overflowed = isInitialized ? overflowItems : [] + + const toDropdownItem = (action: CardSecondaryAction): DropdownItem => ({ + label: action.label, + icon: action.icon, + onClick: action.onClick, + }) + + // Narrow ⋯ menu = collapsed secondaries, then the always-present otherActions, + // divided by a separator when both are present. + const narrowMenu: DropdownItem[] = [ + ...overflowed.map(toDropdownItem), + ...(overflowed.length > 0 && other.length > 0 + ? [{ type: "separator" } as DropdownItem] + : []), + ...other, + ] + + const hasAnyAction = + primaryAction || + secondaryArray.length > 0 || + !!secondaryLink || + other.length > 0 + + if (!hasAnyAction) { + return null + } + + const renderSecondary = (action: CardSecondaryAction, index: number) => ( + { + e.stopPropagation() + action.onClick() + }} + /> + ) + + const more = (items: DropdownItem[], ref?: Ref) => + items.length > 0 ? ( +
e.stopPropagation()}> + +
+ ) : null + + const link = secondaryLink ? ( + e.stopPropagation()} + data-testid="secondary-link" + > + {secondaryLink.label} + + ) : null + + const primary = primaryAction ? ( + { + e.stopPropagation() + primaryAction.onClick() + }} + data-testid="primary-button" + /> + ) : null + + return ( + <> + {/* Wide (inline): all secondary buttons shown, ⋯ = otherActions. */} +
+ {more(other)} + {secondaryArray.map(renderSecondary)} + {link} + {primary} +
+ + {/* Narrow: own full-width line; measured left-overflow. Only rendered when + a breakpoint is set — with `never` the inline cluster above is enough, + and we avoid a hidden measurement subtree + its ResizeObserver. */} + {stackAt !== "never" && ( +
+
+ {/* Hidden measurement copy of every secondary button. */} + + + {more(narrowMenu, customOverflowIndicatorRef)} + {shown.map(renderSecondary)} +
+ + {link} + {primary} +
+ )} + + ) +} diff --git a/packages/react/src/components/F0Card/index.tsx b/packages/react/src/components/F0Card/index.tsx index 48429527ce..e8cf98759a 100644 --- a/packages/react/src/components/F0Card/index.tsx +++ b/packages/react/src/components/F0Card/index.tsx @@ -1 +1,2 @@ export * from "./F0Card" +export * from "./F0CardRow" From 696ac35d1fced8dfe9ed7fe045ac4da8cbc71d34 Mon Sep 17 00:00:00 2001 From: Marcos Medina Date: Fri, 5 Jun 2026 12:38:12 +0200 Subject: [PATCH 02/13] chore(ui): show F0CardRow as a top-level Storybook entry --- .../src/components/F0Card/__stories__/F0CardRow.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx b/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx index 56a14f41b2..bf96438d8a 100644 --- a/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx +++ b/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx @@ -9,7 +9,7 @@ import { F0CardRow } from "../F0CardRow" const meta: Meta = { component: F0CardRow, - title: "Card/Row", + title: "Card Row", parameters: { docs: { description: { From e9e8a4847a5e7c79db0ca2eab0900d6eda6aaa79 Mon Sep 17 00:00:00 2001 From: Marcos Medina Date: Fri, 5 Jun 2026 14:02:49 +0200 Subject: [PATCH 03/13] fix(ui): wrap F0CardRow title and description instead of truncating Drop the ellipsis prop from the title and description Text elements so long content wraps across multiple lines in all cases. Co-Authored-By: Claude Opus 4.8 --- packages/react/src/components/F0Card/F0CardRow.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/react/src/components/F0Card/F0CardRow.tsx b/packages/react/src/components/F0Card/F0CardRow.tsx index b92cde7d67..fbd6fc6a54 100644 --- a/packages/react/src/components/F0Card/F0CardRow.tsx +++ b/packages/react/src/components/F0Card/F0CardRow.tsx @@ -177,15 +177,10 @@ const F0CardRowBase = forwardRef( {avatar && }
{title && ( - + )} {description && ( - + )}
From 25381399cd626f7a3d39f24d62d0a1674940db88 Mon Sep 17 00:00:00 2001 From: Marcos Medina Date: Fri, 5 Jun 2026 14:06:51 +0200 Subject: [PATCH 04/13] docs(ui): update F0CardRow description docs to reflect wrapping Co-Authored-By: Claude Opus 4.8 --- packages/react/src/components/F0Card/F0CardRow.tsx | 3 ++- .../src/components/F0Card/__stories__/F0CardRow.stories.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react/src/components/F0Card/F0CardRow.tsx b/packages/react/src/components/F0Card/F0CardRow.tsx index fbd6fc6a54..9cd87fb917 100644 --- a/packages/react/src/components/F0Card/F0CardRow.tsx +++ b/packages/react/src/components/F0Card/F0CardRow.tsx @@ -31,7 +31,8 @@ export interface F0CardRowProps { title: string /** - * Optional secondary line shown beneath the title (single line, truncated). + * Optional secondary line shown beneath the title (wraps across multiple + * lines when long). */ description?: string diff --git a/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx b/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx index bf96438d8a..daef7e0604 100644 --- a/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx +++ b/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx @@ -31,7 +31,7 @@ const meta: Meta = { title: { control: "text", description: "The primary line of text." }, description: { control: "text", - description: "Optional secondary line (single line, truncated).", + description: "Optional secondary line (wraps when long).", }, avatar: { control: "object", From 43cfc4934765a8c7d360a21871bb13e77933b4b4 Mon Sep 17 00:00:00 2001 From: Marcos Medina Date: Mon, 8 Jun 2026 12:57:32 +0200 Subject: [PATCH 05/13] refactor(ui): adapt F0CardRow actions to ButtonGroup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the bespoke overflow/primary-secondary-other machinery in CardRowActions with a thin adapter over the data-driven ButtonGroup (#4339), which now owns the width-driven "⋯" overflow, the primary pinning, and the row/stacked layout. The card keeps its own concerns: stopping click propagation so actions don't trigger the row link, the stacked own-line footer hairline at the container breakpoint, and the confirm/reject icon variant (mapped to two pinned secondaries). The actions wrapper takes the remaining row space (flex-1) so ButtonGroup has a bound wider than its content to measure against; a shrink-to-fit container made it shed its tail into the menu. Co-Authored-By: Claude Opus 4.8 --- .../F0Card/__tests__/F0CardRow.test.tsx | 38 ++- .../F0Card/components/CardRowActions.tsx | 303 ++++++------------ 2 files changed, 132 insertions(+), 209 deletions(-) diff --git a/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx b/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx index fe06446b9e..7f8a25d82c 100644 --- a/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx +++ b/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx @@ -8,6 +8,21 @@ import { waitFor, } from "@/testing/test-utils" +// The actions delegate to ButtonGroup, whose width-driven overflow measures DOM +// it can't in jsdom (zero layout) — so it would shove every action into the "⋯" +// menu. Stub the measurement to keep everything visible, as a real browser would. +vi.mock("@/ui/OverflowList/useOverflowCalculation", () => ({ + useOverflowCalculation: (items: T[]) => ({ + containerRef: { current: null }, + overflowButtonRef: { current: null }, + customOverflowIndicatorRef: { current: null }, + measurementContainerRef: { current: null }, + visibleItems: items, + overflowItems: [], + isInitialized: true, + }), +})) + import type { CardSecondaryLink } from "../components/CardActions" import type { CardAvatarVariant } from "../components/CardAvatar" @@ -82,7 +97,7 @@ describe("F0CardRow", () => { render() - await user.click(screen.getByTestId("primary-button")) + await user.click(screen.getByRole("button", { name: "Open" })) expect(onClick).toHaveBeenCalledTimes(1) }) @@ -111,7 +126,7 @@ describe("F0CardRow", () => { render() - const link = screen.getByTestId("secondary-link") + const link = screen.getByRole("link", { name: "View more" }) expect(link).toHaveAttribute("href", "/test-page") expect(link).toHaveAttribute("target", "_blank") }) @@ -141,7 +156,7 @@ describe("F0CardRow", () => { render() - await user.click(screen.getByTestId("confirm-button")) + await user.click(screen.getByRole("button", { name: "Confirm" })) expect(onConfirm).toHaveBeenCalledTimes(1) }) @@ -151,7 +166,7 @@ describe("F0CardRow", () => { render() - await user.click(screen.getByTestId("reject-button")) + await user.click(screen.getByRole("button", { name: "Reject" })) expect(onReject).toHaveBeenCalledTimes(1) }) @@ -165,9 +180,13 @@ describe("F0CardRow", () => { /> ) - expect(screen.getByTestId("confirm-button")).toBeInTheDocument() - expect(screen.getByTestId("reject-button")).toBeInTheDocument() - expect(screen.queryByTestId("primary-button")).not.toBeInTheDocument() + expect( + screen.getByRole("button", { name: "Confirm" }) + ).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Reject" })).toBeInTheDocument() + expect( + screen.queryByRole("button", { name: "Open" }) + ).not.toBeInTheDocument() }) }) @@ -195,9 +214,8 @@ describe("F0CardRow", () => { ) expect(screen.getByTestId("card")).toBeInTheDocument() - // The primary stays visible at every breakpoint (rendered in the inline - // cluster and, for non-"never", the stacked cluster too). - expect(screen.getAllByTestId("primary-button").length).toBeGreaterThan(0) + // The primary stays visible (pinned at the trailing edge) at every breakpoint. + expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument() unmount() }) }) diff --git a/packages/react/src/components/F0Card/components/CardRowActions.tsx b/packages/react/src/components/F0Card/components/CardRowActions.tsx index 2531d68b86..3c75b6eb71 100644 --- a/packages/react/src/components/F0Card/components/CardRowActions.tsx +++ b/packages/react/src/components/F0Card/components/CardRowActions.tsx @@ -1,11 +1,12 @@ -import { type Ref } from "react" - -import { F0Button } from "@/components/F0Button" -import { F0Link } from "@/components/F0Link" -import { Dropdown, type DropdownItem } from "@/experimental/Navigation/Dropdown" +import { type DropdownItem } from "@/experimental/Navigation/Dropdown" import { Check, Cross } from "@/icons/app" import { cn } from "@/lib/utils" -import { useOverflowCalculation } from "@/ui/OverflowList/useOverflowCalculation" +import { + ButtonGroup, + type ButtonGroupButton, + type ButtonGroupSecondaryItem, + type ButtonGroupSecondaryLink, +} from "@/ui/ButtonGroup" import { type CardPrimaryAction, @@ -41,20 +42,20 @@ export const cardRowClassName: Record = { never: "flex flex-row items-center justify-between gap-4", } -// Inline ("wide") cluster — shown only at/above the breakpoint. -const wideClusterVisibility: Record = { - sm: "hidden @xs:flex", - md: "hidden @md:flex", - lg: "hidden @lg:flex", - never: "flex", -} - -// Stacked ("narrow") cluster — shown only below the breakpoint. -const narrowClusterVisibility: Record = { - sm: "flex @xs:hidden", - md: "flex @md:hidden", - lg: "flex @lg:hidden", - never: "hidden", +/** + * Width of the actions wrapper. `ButtonGroup` reserves the "⋯"-button width on + * top of its content, so a shrink-to-fit container would always shed the tail + * into the menu — it needs a bound *wider* than its content. Inline (at/above + * the breakpoint) we hand it the remaining row space via `flex-1`, with its own + * `justify-end` keeping the buttons at the trailing edge; once stacked it spans + * the full line instead (no `flex-1`, which would grow it vertically in the + * column). `never` is always inline. + */ +const actionsWidthClassName: Record = { + sm: "w-full @xs:w-auto @xs:min-w-0 @xs:flex-1", + md: "w-full @md:w-auto @md:min-w-0 @md:flex-1", + lg: "w-full @lg:w-auto @lg:min-w-0 @lg:flex-1", + never: "min-w-0 flex-1", } // Footer-style separator shown while the actions sit on their own stacked line; @@ -88,14 +89,17 @@ interface CardRowActionsProps { } /** - * Trailing actions for the card row. The "more" (⋯) menu sits on the LEFT and - * the primary stays pinned at the trailing edge. + * Trailing actions for the card row — a thin adapter over {@link ButtonGroup}. + * The data-driven `primaryAction` / `secondaryActions` / `otherActions` triplet + * maps straight through; `ButtonGroup` owns the row layout, the width-driven + * overflow into the "⋯" menu, and pinning the primary at the trailing edge. * - * Two layouts, toggled by the card's container width at `stackAt`: - * - Wide (inline): all secondary buttons shown; the ⋯ holds only `otherActions`. - * - Narrow: the row drops onto its own full-width line; there the cluster IS - * width-bounded, so we measure it (same engine as `OverflowList`) and shed - * secondary buttons right→left into the left ⋯ as it tightens. + * The card adds two things on top: + * - The wrapper stops click propagation so an action never triggers the row's + * own `onClick` / overlay-link navigation. + * - `stackAt` drops the cluster onto its own full-width line (with a footer + * hairline) below a container breakpoint; the breakpoint mapping is shared + * with the row root via {@link cardRowClassName}. * * Pass `confirmAction` / `rejectAction` for the icon-only confirm/reject variant * (✗ then ✓), which replaces the standard actions. @@ -111,197 +115,98 @@ export function CardRowActions({ }: CardRowActionsProps) { const size = compact ? "sm" : "md" - // Hook must run unconditionally, before any early return. - const secondaryArray = Array.isArray(secondaryActions) ? secondaryActions : [] - const { - containerRef, - measurementContainerRef, - customOverflowIndicatorRef, - visibleItems, - overflowItems, - isInitialized, - } = useOverflowCalculation(secondaryArray, GAP) - - const chrome = cn( + const wrapperClassName = cn( + "relative z-[1]", + actionsWidthClassName[stackAt], stackedChrome[stackAt], stackAt !== "never" && compact && "mt-3 pt-3" ) + const wrap = (group: React.ReactNode) => ( + // Keep action clicks from bubbling to the row's onClick / overlay link. +
e.stopPropagation()}> + {group} +
+ ) + // Confirm/reject variant: icon-only outline buttons, reject (✗) then confirm (✓). if (confirmAction || rejectAction) { - return ( -
- {rejectAction && ( - { - e.stopPropagation() - rejectAction.onClick() - }} - data-testid="reject-button" - /> - )} - {confirmAction && ( - { - e.stopPropagation() - confirmAction.onClick() - }} - data-testid="confirm-button" - /> - )} -
+ const variantActions: ButtonGroupButton[] = [] + if (rejectAction) { + variantActions.push({ + id: "reject", + icon: Cross, + label: rejectAction.label ?? "Reject", + hideLabel: true, + disabled: rejectAction.disabled, + onClick: rejectAction.onClick, + }) + } + if (confirmAction) { + variantActions.push({ + id: "confirm", + icon: Check, + label: confirmAction.label ?? "Confirm", + hideLabel: true, + disabled: confirmAction.disabled, + onClick: confirmAction.onClick, + }) + } + return wrap( + ) } - const secondaryLink = - secondaryActions && !Array.isArray(secondaryActions) - ? secondaryActions + const secondaryItems: + | ButtonGroupSecondaryItem[] + | ButtonGroupSecondaryLink + | undefined = Array.isArray(secondaryActions) + ? secondaryActions.map( + (action, index): ButtonGroupButton => ({ + id: `secondary-${index}`, + label: action.label, + icon: action.icon, + onClick: action.onClick, + }) + ) + : secondaryActions + ? { + label: secondaryActions.label, + // `CardSecondaryLink.href` is loosely typed as optional; a link always + // carries one in practice, so pass it through unchanged. + href: secondaryActions.href as string, + target: secondaryActions.target, + disabled: secondaryActions.disabled, + } : undefined - const other = otherActions ?? [] - - // Before the first measurement, optimistically show everything so the row - // doesn't flash empty; the measured split takes over once initialized. - const shown = isInitialized ? visibleItems : secondaryArray - const overflowed = isInitialized ? overflowItems : [] - - const toDropdownItem = (action: CardSecondaryAction): DropdownItem => ({ - label: action.label, - icon: action.icon, - onClick: action.onClick, - }) - // Narrow ⋯ menu = collapsed secondaries, then the always-present otherActions, - // divided by a separator when both are present. - const narrowMenu: DropdownItem[] = [ - ...overflowed.map(toDropdownItem), - ...(overflowed.length > 0 && other.length > 0 - ? [{ type: "separator" } as DropdownItem] - : []), - ...other, - ] + const primary: ButtonGroupButton | undefined = primaryAction + ? { + id: "primary", + label: primaryAction.label, + icon: primaryAction.icon, + onClick: primaryAction.onClick, + } + : undefined const hasAnyAction = - primaryAction || - secondaryArray.length > 0 || - !!secondaryLink || - other.length > 0 + !!primary || + (Array.isArray(secondaryActions) + ? secondaryActions.length > 0 + : !!secondaryActions) || + (otherActions?.length ?? 0) > 0 if (!hasAnyAction) { return null } - const renderSecondary = (action: CardSecondaryAction, index: number) => ( - { - e.stopPropagation() - action.onClick() - }} + gap={GAP} /> ) - - const more = (items: DropdownItem[], ref?: Ref) => - items.length > 0 ? ( -
e.stopPropagation()}> - -
- ) : null - - const link = secondaryLink ? ( - e.stopPropagation()} - data-testid="secondary-link" - > - {secondaryLink.label} - - ) : null - - const primary = primaryAction ? ( - { - e.stopPropagation() - primaryAction.onClick() - }} - data-testid="primary-button" - /> - ) : null - - return ( - <> - {/* Wide (inline): all secondary buttons shown, ⋯ = otherActions. */} -
- {more(other)} - {secondaryArray.map(renderSecondary)} - {link} - {primary} -
- - {/* Narrow: own full-width line; measured left-overflow. Only rendered when - a breakpoint is set — with `never` the inline cluster above is enough, - and we avoid a hidden measurement subtree + its ResizeObserver. */} - {stackAt !== "never" && ( -
-
- {/* Hidden measurement copy of every secondary button. */} - - - {more(narrowMenu, customOverflowIndicatorRef)} - {shown.map(renderSecondary)} -
- - {link} - {primary} -
- )} - - ) } From ef9d75cba484b6714a115e49d2f55bd2a71d24f6 Mon Sep 17 00:00:00 2001 From: Marcos Medina Date: Mon, 8 Jun 2026 13:17:09 +0200 Subject: [PATCH 06/13] feat(ui): make F0CardRow confirm a primary, label confirm/reject on stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The confirm (✓) action now renders as the solid primary instead of a second outline button; reject (✗) stays outline. While inline the pair stays icon-only, but once the row stacks onto its own line the buttons reveal their labels (default "Cancel" / "Confirm", or whatever the caller supplies) — rendered as a CSS-toggled labelled cluster since hideLabel is static and the stack is a container query ButtonGroup can't observe. Co-Authored-By: Claude Opus 4.8 --- .../F0Card/__stories__/F0CardRow.stories.tsx | 1 + .../F0Card/__tests__/F0CardRow.test.tsx | 4 +- .../F0Card/components/CardRowActions.tsx | 115 ++++++++++++++---- 3 files changed, 94 insertions(+), 26 deletions(-) diff --git a/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx b/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx index daef7e0604..1ccb0b38ef 100644 --- a/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx +++ b/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx @@ -138,6 +138,7 @@ export const ConfirmReject: Story = { }, title: "Jane Cooper", description: "Requested 3 days off", + stackAt: "md", rejectAction: { label: "Reject", onClick: fn() }, confirmAction: { label: "Approve", onClick: fn() }, }, diff --git a/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx b/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx index 7f8a25d82c..43092e1f68 100644 --- a/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx +++ b/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx @@ -166,7 +166,7 @@ describe("F0CardRow", () => { render() - await user.click(screen.getByRole("button", { name: "Reject" })) + await user.click(screen.getByRole("button", { name: "Cancel" })) expect(onReject).toHaveBeenCalledTimes(1) }) @@ -183,7 +183,7 @@ describe("F0CardRow", () => { expect( screen.getByRole("button", { name: "Confirm" }) ).toBeInTheDocument() - expect(screen.getByRole("button", { name: "Reject" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument() expect( screen.queryByRole("button", { name: "Open" }) ).not.toBeInTheDocument() diff --git a/packages/react/src/components/F0Card/components/CardRowActions.tsx b/packages/react/src/components/F0Card/components/CardRowActions.tsx index 3c75b6eb71..0b2f56f1d0 100644 --- a/packages/react/src/components/F0Card/components/CardRowActions.tsx +++ b/packages/react/src/components/F0Card/components/CardRowActions.tsx @@ -58,6 +58,24 @@ const actionsWidthClassName: Record = { never: "min-w-0 flex-1", } +// Visibility of the icon-only inline cluster — shown at/above the breakpoint +// (and always, for `never`). Pairs with `stackedClusterVisibility` below; only +// the confirm/reject variant renders both, to swap icon-only ↔ labelled on stack. +const inlineClusterVisibility: Record = { + sm: "hidden @xs:flex", + md: "hidden @md:flex", + lg: "hidden @lg:flex", + never: "flex", +} + +// Visibility of the labelled stacked cluster — shown only below the breakpoint. +const stackedClusterVisibility: Record = { + sm: "flex @xs:hidden", + md: "flex @md:hidden", + lg: "flex @lg:hidden", + never: "hidden", +} + // Footer-style separator shown while the actions sit on their own stacked line; // removed once they go inline at the breakpoint. const stackedChrome: Record = { @@ -101,8 +119,10 @@ interface CardRowActionsProps { * hairline) below a container breakpoint; the breakpoint mapping is shared * with the row root via {@link cardRowClassName}. * - * Pass `confirmAction` / `rejectAction` for the icon-only confirm/reject variant - * (✗ then ✓), which replaces the standard actions. + * Pass `confirmAction` / `rejectAction` for the confirm/reject variant — reject + * (✗, outline) then confirm (✓, solid primary), which replaces the standard + * actions. Icon-only while inline; the buttons reveal their labels once the row + * stacks onto its own line. */ export function CardRowActions({ primaryAction, @@ -129,31 +149,78 @@ export function CardRowActions({ ) - // Confirm/reject variant: icon-only outline buttons, reject (✗) then confirm (✓). + // Confirm/reject variant: reject (✗, outline) then confirm (✓, solid primary). + // Icon-only while inline; once the row stacks, the buttons drop onto their own + // line and reveal their labels. `hideLabel` is a static per-button prop and the + // stack is a container query (invisible to ButtonGroup), so we render both + // clusters and toggle them with CSS — mirroring the row root's breakpoint. if (confirmAction || rejectAction) { - const variantActions: ButtonGroupButton[] = [] - if (rejectAction) { - variantActions.push({ - id: "reject", - icon: Cross, - label: rejectAction.label ?? "Reject", - hideLabel: true, - disabled: rejectAction.disabled, - onClick: rejectAction.onClick, - }) + const variant = (hideLabel: boolean) => { + const reject: ButtonGroupButton | undefined = rejectAction + ? { + id: "reject", + icon: Cross, + label: rejectAction.label ?? "Cancel", + hideLabel, + disabled: rejectAction.disabled, + onClick: rejectAction.onClick, + } + : undefined + const confirm: ButtonGroupButton | undefined = confirmAction + ? { + id: "confirm", + icon: Check, + label: confirmAction.label ?? "Confirm", + hideLabel, + disabled: confirmAction.disabled, + onClick: confirmAction.onClick, + } + : undefined + return ( + + ) } - if (confirmAction) { - variantActions.push({ - id: "confirm", - icon: Check, - label: confirmAction.label ?? "Confirm", - hideLabel: true, - disabled: confirmAction.disabled, - onClick: confirmAction.onClick, - }) + + const inline = ( + // Icon-only, inline at the trailing edge. +
e.stopPropagation()} + > + {variant(true)} +
+ ) + + // `never` never stacks, so the labelled cluster (and its duplicate + // ButtonGroup) is skipped entirely. + if (stackAt === "never") { + return inline } - return wrap( - + + return ( + <> + {inline} + {/* Labelled, on its own full-width line below the breakpoint. */} +
e.stopPropagation()} + > + {variant(false)} +
+ ) } From fabd05efca350e4cc141f0cae8de1b1685a2ba6c Mon Sep 17 00:00:00 2001 From: Marcos Medina Date: Mon, 8 Jun 2026 13:20:29 +0200 Subject: [PATCH 07/13] feat(ui): default F0CardRow reject label to "Reject" Co-Authored-By: Claude Opus 4.8 --- .../react/src/components/F0Card/__tests__/F0CardRow.test.tsx | 4 ++-- .../react/src/components/F0Card/components/CardRowActions.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx b/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx index 43092e1f68..7f8a25d82c 100644 --- a/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx +++ b/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx @@ -166,7 +166,7 @@ describe("F0CardRow", () => { render() - await user.click(screen.getByRole("button", { name: "Cancel" })) + await user.click(screen.getByRole("button", { name: "Reject" })) expect(onReject).toHaveBeenCalledTimes(1) }) @@ -183,7 +183,7 @@ describe("F0CardRow", () => { expect( screen.getByRole("button", { name: "Confirm" }) ).toBeInTheDocument() - expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Reject" })).toBeInTheDocument() expect( screen.queryByRole("button", { name: "Open" }) ).not.toBeInTheDocument() diff --git a/packages/react/src/components/F0Card/components/CardRowActions.tsx b/packages/react/src/components/F0Card/components/CardRowActions.tsx index 0b2f56f1d0..6dbe377304 100644 --- a/packages/react/src/components/F0Card/components/CardRowActions.tsx +++ b/packages/react/src/components/F0Card/components/CardRowActions.tsx @@ -160,7 +160,7 @@ export function CardRowActions({ ? { id: "reject", icon: Cross, - label: rejectAction.label ?? "Cancel", + label: rejectAction.label ?? "Reject", hideLabel, disabled: rejectAction.disabled, onClick: rejectAction.onClick, From fbd0348df5bf129ed06122a4a1fe24833a317ea8 Mon Sep 17 00:00:00 2001 From: Marcos Medina Date: Mon, 8 Jun 2026 13:30:24 +0200 Subject: [PATCH 08/13] fix(ui): make F0CardRow stacked footer bleed symmetrically The stacked actions wrapper had both `w-full` and the `-mx-4` full-bleed: a negative right margin can't widen a fixed-width box, so the footer hairline clipped ~16px short on the right. Drop `w-full` and let the flex column stretch the wrapper instead, so `-mx-4`/`px-4` bleeds evenly to both card edges. Co-Authored-By: Claude Opus 4.8 --- .../F0Card/components/CardRowActions.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/react/src/components/F0Card/components/CardRowActions.tsx b/packages/react/src/components/F0Card/components/CardRowActions.tsx index 6dbe377304..cc3d83444c 100644 --- a/packages/react/src/components/F0Card/components/CardRowActions.tsx +++ b/packages/react/src/components/F0Card/components/CardRowActions.tsx @@ -47,14 +47,16 @@ export const cardRowClassName: Record = { * top of its content, so a shrink-to-fit container would always shed the tail * into the menu — it needs a bound *wider* than its content. Inline (at/above * the breakpoint) we hand it the remaining row space via `flex-1`, with its own - * `justify-end` keeping the buttons at the trailing edge; once stacked it spans - * the full line instead (no `flex-1`, which would grow it vertically in the - * column). `never` is always inline. + * `justify-end` keeping the buttons at the trailing edge. Once stacked we leave + * it `auto`: the flex column stretches it to full width, and crucially that lets + * the `-mx-4` full-bleed footer extend symmetrically — an explicit `w-full` + * would clip the right edge, since a negative right margin can't widen a + * fixed-width box. `never` is always inline. */ const actionsWidthClassName: Record = { - sm: "w-full @xs:w-auto @xs:min-w-0 @xs:flex-1", - md: "w-full @md:w-auto @md:min-w-0 @md:flex-1", - lg: "w-full @lg:w-auto @lg:min-w-0 @lg:flex-1", + sm: "@xs:min-w-0 @xs:flex-1", + md: "@md:min-w-0 @md:flex-1", + lg: "@lg:min-w-0 @lg:flex-1", never: "min-w-0 flex-1", } @@ -208,10 +210,11 @@ export function CardRowActions({ return ( <> {inline} - {/* Labelled, on its own full-width line below the breakpoint. */} + {/* Labelled, on its own line below the breakpoint. Width left to the + flex column's stretch so the `-mx-4` footer bleeds symmetrically. */}
Date: Mon, 8 Jun 2026 14:22:34 +0200 Subject: [PATCH 09/13] feat(ui): add F0CardRow resolved-state status slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a generic `status` prop (an F0TagStatus) rendered at the trailing edge in place of any actions — the resolved state of a confirm/reject row. It's informational, so no click-stop / z-index (a row-level overlay link stays clickable through it), and the outer flex drops it to its own line when stacked. The accepted/rejected -> positive/critical mapping is left to the caller; Accepted/Rejected stories demonstrate it. Co-Authored-By: Claude Opus 4.8 --- .../react/src/components/F0Card/F0CardRow.tsx | 11 +++++++ .../F0Card/__stories__/F0CardRow.stories.tsx | 33 +++++++++++++++++++ .../F0Card/__tests__/F0CardRow.test.tsx | 32 ++++++++++++++++++ .../F0Card/components/CardRowActions.tsx | 25 ++++++++++++++ 4 files changed, 101 insertions(+) diff --git a/packages/react/src/components/F0Card/F0CardRow.tsx b/packages/react/src/components/F0Card/F0CardRow.tsx index 9cd87fb917..0e887bd0e7 100644 --- a/packages/react/src/components/F0Card/F0CardRow.tsx +++ b/packages/react/src/components/F0Card/F0CardRow.tsx @@ -1,6 +1,7 @@ import { forwardRef } from "react" import { F0Link } from "@/components/F0Link" +import { type TagStatusProps } from "@/components/tags/F0TagStatus" import { DropdownItem } from "@/experimental/Navigation/Dropdown" import { withDataTestId } from "@/lib/data-testid" import { withSkeleton } from "@/lib/skeleton" @@ -70,6 +71,14 @@ export interface F0CardRowProps { */ rejectAction?: CardRowConfirmAction + /** + * Resolved-state status tag shown at the trailing edge in place of any + * actions — e.g. the `{ text: "Accepted", variant: "positive" }` / + * `{ text: "Rejected", variant: "critical" }` outcome of a confirm/reject row. + * Takes precedence over the action props. + */ + status?: TagStatusProps + /** * Compact layout: tighter padding and smaller controls. */ @@ -129,6 +138,7 @@ const F0CardRowBase = forwardRef( otherActions, confirmAction, rejectAction, + status, compact = false, link, fullHeight = false, @@ -192,6 +202,7 @@ const F0CardRowBase = forwardRef( otherActions={otherActions} confirmAction={confirmAction} rejectAction={rejectAction} + status={status} compact={compact} stackAt={stackAt} /> diff --git a/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx b/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx index 1ccb0b38ef..286112ef6c 100644 --- a/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx +++ b/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx @@ -144,6 +144,39 @@ export const ConfirmReject: Story = { }, } +/** + * Resolved state of a confirm/reject row: once a decision is made, pass `status` + * to swap the buttons for a status tag. The accepted/rejected → positive/critical + * mapping lives with the caller (here in the story). + */ +export const Accepted: Story = { + args: { + avatar: { + type: "person", + firstName: "Jane", + lastName: "Cooper", + src: image, + }, + title: "Jane Cooper", + description: "Requested 3 days off", + status: { text: "Accepted", variant: "positive" }, + }, +} + +export const Rejected: Story = { + args: { + avatar: { + type: "person", + firstName: "Jane", + lastName: "Cooper", + src: image, + }, + title: "Jane Cooper", + description: "Requested 3 days off", + status: { text: "Rejected", variant: "critical" }, + }, +} + /** * Actions stay inline at every width by default (`stackAt: "never"`). Opt into a * responsive collapse with `stackAt` (e.g. `"md"`): below that container width the diff --git a/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx b/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx index 7f8a25d82c..f6006f373c 100644 --- a/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx +++ b/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx @@ -190,6 +190,38 @@ describe("F0CardRow", () => { }) }) + describe("status (resolved state)", () => { + it("renders the status tag", () => { + render( + + ) + + expect(screen.getByText("Accepted")).toBeInTheDocument() + }) + + it("takes precedence over the action props", () => { + render( + + ) + + expect(screen.getByText("Rejected")).toBeInTheDocument() + expect( + screen.queryByRole("button", { name: "Open" }) + ).not.toBeInTheDocument() + expect( + screen.queryByRole("button", { name: "Edit" }) + ).not.toBeInTheDocument() + }) + }) + it("renders the alert banner when alert is provided", () => { render( + +
+ ) + } + const wrapperClassName = cn( "relative z-[1]", actionsWidthClassName[stackAt], From a401e1ec687426c6651a825910b352fd30de4dea Mon Sep 17 00:00:00 2001 From: Marcos Medina Date: Mon, 8 Jun 2026 14:26:37 +0200 Subject: [PATCH 10/13] feat(ui): add F0CardRow inactive (struck-through) state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an `inactive` prop that strikes through and dims the title (lighter foreground) and description, marking the row's subject as void/closed — e.g. a rejected request. Presentational and generic; the Rejected story pairs it with the critical status tag. Co-Authored-By: Claude Opus 4.8 --- .../react/src/components/F0Card/F0CardRow.tsx | 23 +++++++++++++++++-- .../F0Card/__stories__/F0CardRow.stories.tsx | 1 + .../F0Card/__tests__/F0CardRow.test.tsx | 9 ++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/react/src/components/F0Card/F0CardRow.tsx b/packages/react/src/components/F0Card/F0CardRow.tsx index 0e887bd0e7..2af68f2802 100644 --- a/packages/react/src/components/F0Card/F0CardRow.tsx +++ b/packages/react/src/components/F0Card/F0CardRow.tsx @@ -79,6 +79,13 @@ export interface F0CardRowProps { */ status?: TagStatusProps + /** + * Strikes through and dims the title/description, marking the row's subject as + * void or closed (e.g. a rejected request). Purely presentational — pair it + * with the matching `status` tag at the call site. + */ + inactive?: boolean + /** * Compact layout: tighter padding and smaller controls. */ @@ -139,6 +146,7 @@ const F0CardRowBase = forwardRef( confirmAction, rejectAction, status, + inactive = false, compact = false, link, fullHeight = false, @@ -188,10 +196,21 @@ const F0CardRowBase = forwardRef( {avatar && }
{title && ( - + )} {description && ( - + )}
diff --git a/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx b/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx index 286112ef6c..89228738f7 100644 --- a/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx +++ b/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx @@ -174,6 +174,7 @@ export const Rejected: Story = { title: "Jane Cooper", description: "Requested 3 days off", status: { text: "Rejected", variant: "critical" }, + inactive: true, }, } diff --git a/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx b/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx index f6006f373c..e7e46f7913 100644 --- a/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx +++ b/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx @@ -220,6 +220,15 @@ describe("F0CardRow", () => { screen.queryByRole("button", { name: "Edit" }) ).not.toBeInTheDocument() }) + + it("strikes through and dims the title when inactive", () => { + render() + + const title = screen.getByText("Void request") + expect(title).toHaveClass("line-through") + expect(title).toHaveClass("text-f1-foreground-secondary") + expect(screen.getByText("Details")).toHaveClass("line-through") + }) }) it("renders the alert banner when alert is provided", () => { From aaf18921db6a8631975c29a7da8ff774f3033345 Mon Sep 17 00:00:00 2001 From: Marcos Medina Date: Mon, 8 Jun 2026 14:39:04 +0200 Subject: [PATCH 11/13] feat(ui): add F0CardRow icon resolved-state option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow `status` to be a coloured icon instead of a tag — a discriminated union of TagStatusProps or { icon, variant, label }. Renders the plain accept/reject glyph (Check / Cross, not the circled variants) at lg size, coloured by variant, with the label exposed via role="img" + aria-label. Accepted/Rejected-icon stories demonstrate it. Co-Authored-By: Claude Opus 4.8 --- .../react/src/components/F0Card/F0CardRow.tsx | 11 ++-- .../F0Card/__stories__/F0CardRow.stories.tsx | 35 ++++++++++- .../F0Card/__tests__/F0CardRow.test.tsx | 13 ++++- .../F0Card/components/CardRowActions.tsx | 58 +++++++++++++++++-- 4 files changed, 104 insertions(+), 13 deletions(-) diff --git a/packages/react/src/components/F0Card/F0CardRow.tsx b/packages/react/src/components/F0Card/F0CardRow.tsx index 2af68f2802..c6c0b79397 100644 --- a/packages/react/src/components/F0Card/F0CardRow.tsx +++ b/packages/react/src/components/F0Card/F0CardRow.tsx @@ -1,7 +1,6 @@ import { forwardRef } from "react" import { F0Link } from "@/components/F0Link" -import { type TagStatusProps } from "@/components/tags/F0TagStatus" import { DropdownItem } from "@/experimental/Navigation/Dropdown" import { withDataTestId } from "@/lib/data-testid" import { withSkeleton } from "@/lib/skeleton" @@ -21,6 +20,7 @@ import { CardRowActions, type CardRowConfirmAction, type CardRowStackAt, + type CardRowStatus, cardRowClassName, } from "./components/CardRowActions" import { type CardAlertProps } from "./types" @@ -72,12 +72,13 @@ export interface F0CardRowProps { rejectAction?: CardRowConfirmAction /** - * Resolved-state status tag shown at the trailing edge in place of any - * actions — e.g. the `{ text: "Accepted", variant: "positive" }` / - * `{ text: "Rejected", variant: "critical" }` outcome of a confirm/reject row. + * Resolved-state indicator shown at the trailing edge in place of any actions + * — the outcome of a confirm/reject row. Either a status tag + * (`{ text: "Accepted", variant: "positive" }`) or a coloured icon + * (`{ icon: Check, variant: "positive", label: "Accepted" }`). * Takes precedence over the action props. */ - status?: TagStatusProps + status?: CardRowStatus /** * Strikes through and dims the title/description, marking the row's subject as diff --git a/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx b/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx index 89228738f7..9eda598ac6 100644 --- a/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx +++ b/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx @@ -3,7 +3,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite" import image from "@storybook-static/avatars/person04.jpg" import { fn } from "storybook/test" -import { Briefcase, Delete, Envelope } from "@/icons/app" +import { Briefcase, Check, Cross, Delete, Envelope } from "@/icons/app" import { F0CardRow } from "../F0CardRow" @@ -178,6 +178,39 @@ export const Rejected: Story = { }, } +/** + * The resolved state can also be shown as a coloured icon instead of a tag — + * pass `status` an `{ icon, variant, label }` (the `label` keeps it accessible). + */ +export const AcceptedIcon: Story = { + args: { + avatar: { + type: "person", + firstName: "Jane", + lastName: "Cooper", + src: image, + }, + title: "Jane Cooper", + description: "Requested 3 days off", + status: { icon: Check, variant: "positive", label: "Accepted" }, + }, +} + +export const RejectedIcon: Story = { + args: { + avatar: { + type: "person", + firstName: "Jane", + lastName: "Cooper", + src: image, + }, + title: "Jane Cooper", + description: "Requested 3 days off", + status: { icon: Cross, variant: "critical", label: "Rejected" }, + inactive: true, + }, +} + /** * Actions stay inline at every width by default (`stackAt: "never"`). Opt into a * responsive collapse with `stackAt` (e.g. `"md"`): below that container width the diff --git a/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx b/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx index e7e46f7913..0a40f74e92 100644 --- a/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx +++ b/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest" import "@testing-library/jest-dom/vitest" -import { Briefcase } from "@/icons/app" +import { Briefcase, Check } from "@/icons/app" import { zeroRender as render, screen, @@ -221,6 +221,17 @@ describe("F0CardRow", () => { ).not.toBeInTheDocument() }) + it("renders an icon status by its accessible label", () => { + render( + + ) + + expect(screen.getByRole("img", { name: "Accepted" })).toBeInTheDocument() + }) + it("strikes through and dims the title when inactive", () => { render() diff --git a/packages/react/src/components/F0Card/components/CardRowActions.tsx b/packages/react/src/components/F0Card/components/CardRowActions.tsx index 699e0e183e..aed85950ba 100644 --- a/packages/react/src/components/F0Card/components/CardRowActions.tsx +++ b/packages/react/src/components/F0Card/components/CardRowActions.tsx @@ -1,4 +1,9 @@ -import { F0TagStatus, type TagStatusProps } from "@/components/tags/F0TagStatus" +import { F0Icon, type IconType } from "@/components/F0Icon" +import { + F0TagStatus, + type StatusVariant, + type TagStatusProps, +} from "@/components/tags/F0TagStatus" import { type DropdownItem } from "@/experimental/Navigation/Dropdown" import { Check, Cross } from "@/icons/app" import { cn } from "@/lib/utils" @@ -95,6 +100,37 @@ export interface CardRowConfirmAction { disabled?: boolean } +/** Resolved-state indicator at the trailing edge: a coloured icon. */ +export interface CardRowStatusIcon { + /** The icon to render (e.g. `Check` for accepted, `Cross` for rejected). */ + icon: IconType + /** Colour family — same variants as the status tag. */ + variant: StatusVariant + /** Accessible label; the icon carries meaning, so this is required. */ + label: string +} + +/** + * Resolved state shown in place of the actions: either a {@link F0TagStatus} + * pill ({@link TagStatusProps}) or a {@link CardRowStatusIcon} coloured icon. + */ +export type CardRowStatus = TagStatusProps | CardRowStatusIcon + +// Status variant → F0Icon colour token (no "neutral" icon colour; map to secondary). +const statusIconColor: Record< + StatusVariant, + "secondary" | "info" | "positive" | "warning" | "critical" +> = { + neutral: "secondary", + info: "info", + positive: "positive", + warning: "warning", + critical: "critical", +} + +const isStatusIcon = (status: CardRowStatus): status is CardRowStatusIcon => + "icon" in status + interface CardRowActionsProps { primaryAction?: CardPrimaryAction secondaryActions?: CardSecondaryAction[] | CardSecondaryLink @@ -105,11 +141,11 @@ interface CardRowActionsProps { /** Reject (✗) icon-only action — enables the confirm/reject variant. */ rejectAction?: CardRowConfirmAction /** - * Resolved-state status tag shown at the trailing edge in place of any - * actions (e.g. the "Accepted" / "Rejected" outcome of a confirm/reject row). - * Takes precedence over every action prop. + * Resolved-state indicator shown at the trailing edge in place of any actions + * (e.g. the "Accepted" / "Rejected" outcome of a confirm/reject row) — a + * status tag or a coloured icon. Takes precedence over every action prop. */ - status?: TagStatusProps + status?: CardRowStatus compact?: boolean /** Container breakpoint at which the actions drop to their own line. */ stackAt?: CardRowStackAt @@ -157,7 +193,17 @@ export function CardRowActions({ stackAt !== "never" && compact && "mt-3 pt-3" )} > - + {isStatusIcon(status) ? ( + + ) : ( + + )} ) } From cbc64510543a4d883f7b4f5b820f677c7b7dc325 Mon Sep 17 00:00:00 2001 From: Marcos Medina Date: Mon, 8 Jun 2026 14:42:11 +0200 Subject: [PATCH 12/13] refactor(ui): make F0CardRow resolved status icon-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the status tag variant in favour of the coloured icon: `status` is now just `{ icon, variant, label }`, no longer a tag/icon union. Removes the F0TagStatus path; Accepted/Rejected stories render the Check/Cross glyph. Not a breaking change — F0CardRow has no consumers outside this PR. Co-Authored-By: Claude Opus 4.8 --- .../react/src/components/F0Card/F0CardRow.tsx | 7 ++- .../F0Card/__stories__/F0CardRow.stories.tsx | 38 ++------------- .../F0Card/__tests__/F0CardRow.test.tsx | 23 +++------ .../F0Card/components/CardRowActions.tsx | 48 +++++++------------ 4 files changed, 29 insertions(+), 87 deletions(-) diff --git a/packages/react/src/components/F0Card/F0CardRow.tsx b/packages/react/src/components/F0Card/F0CardRow.tsx index c6c0b79397..86092f0893 100644 --- a/packages/react/src/components/F0Card/F0CardRow.tsx +++ b/packages/react/src/components/F0Card/F0CardRow.tsx @@ -72,10 +72,9 @@ export interface F0CardRowProps { rejectAction?: CardRowConfirmAction /** - * Resolved-state indicator shown at the trailing edge in place of any actions - * — the outcome of a confirm/reject row. Either a status tag - * (`{ text: "Accepted", variant: "positive" }`) or a coloured icon - * (`{ icon: Check, variant: "positive", label: "Accepted" }`). + * Resolved-state icon shown at the trailing edge in place of any actions — the + * outcome of a confirm/reject row, e.g. + * `{ icon: Check, variant: "positive", label: "Accepted" }`. * Takes precedence over the action props. */ status?: CardRowStatus diff --git a/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx b/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx index 9eda598ac6..0eee94215a 100644 --- a/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx +++ b/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx @@ -146,43 +146,11 @@ export const ConfirmReject: Story = { /** * Resolved state of a confirm/reject row: once a decision is made, pass `status` - * to swap the buttons for a status tag. The accepted/rejected → positive/critical + * a coloured icon (`{ icon, variant, label }`, the `label` keeps it accessible) + * to swap the buttons for the outcome. The accepted/rejected → positive/critical * mapping lives with the caller (here in the story). */ export const Accepted: Story = { - args: { - avatar: { - type: "person", - firstName: "Jane", - lastName: "Cooper", - src: image, - }, - title: "Jane Cooper", - description: "Requested 3 days off", - status: { text: "Accepted", variant: "positive" }, - }, -} - -export const Rejected: Story = { - args: { - avatar: { - type: "person", - firstName: "Jane", - lastName: "Cooper", - src: image, - }, - title: "Jane Cooper", - description: "Requested 3 days off", - status: { text: "Rejected", variant: "critical" }, - inactive: true, - }, -} - -/** - * The resolved state can also be shown as a coloured icon instead of a tag — - * pass `status` an `{ icon, variant, label }` (the `label` keeps it accessible). - */ -export const AcceptedIcon: Story = { args: { avatar: { type: "person", @@ -196,7 +164,7 @@ export const AcceptedIcon: Story = { }, } -export const RejectedIcon: Story = { +export const Rejected: Story = { args: { avatar: { type: "person", diff --git a/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx b/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx index 0a40f74e92..e1d191026e 100644 --- a/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx +++ b/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest" import "@testing-library/jest-dom/vitest" -import { Briefcase, Check } from "@/icons/app" +import { Briefcase, Check, Cross } from "@/icons/app" import { zeroRender as render, screen, @@ -191,28 +191,28 @@ describe("F0CardRow", () => { }) describe("status (resolved state)", () => { - it("renders the status tag", () => { + it("renders the status icon by its accessible label", () => { render( ) - expect(screen.getByText("Accepted")).toBeInTheDocument() + expect(screen.getByRole("img", { name: "Accepted" })).toBeInTheDocument() }) it("takes precedence over the action props", () => { render( ) - expect(screen.getByText("Rejected")).toBeInTheDocument() + expect(screen.getByRole("img", { name: "Rejected" })).toBeInTheDocument() expect( screen.queryByRole("button", { name: "Open" }) ).not.toBeInTheDocument() @@ -221,17 +221,6 @@ describe("F0CardRow", () => { ).not.toBeInTheDocument() }) - it("renders an icon status by its accessible label", () => { - render( - - ) - - expect(screen.getByRole("img", { name: "Accepted" })).toBeInTheDocument() - }) - it("strikes through and dims the title when inactive", () => { render() diff --git a/packages/react/src/components/F0Card/components/CardRowActions.tsx b/packages/react/src/components/F0Card/components/CardRowActions.tsx index aed85950ba..352a120d3e 100644 --- a/packages/react/src/components/F0Card/components/CardRowActions.tsx +++ b/packages/react/src/components/F0Card/components/CardRowActions.tsx @@ -1,9 +1,5 @@ import { F0Icon, type IconType } from "@/components/F0Icon" -import { - F0TagStatus, - type StatusVariant, - type TagStatusProps, -} from "@/components/tags/F0TagStatus" +import { type StatusVariant } from "@/components/tags/F0TagStatus" import { type DropdownItem } from "@/experimental/Navigation/Dropdown" import { Check, Cross } from "@/icons/app" import { cn } from "@/lib/utils" @@ -100,22 +96,19 @@ export interface CardRowConfirmAction { disabled?: boolean } -/** Resolved-state indicator at the trailing edge: a coloured icon. */ -export interface CardRowStatusIcon { +/** + * Resolved state shown at the trailing edge in place of the actions: a coloured + * icon (e.g. `Check` for accepted, `Cross` for rejected) carrying the outcome. + */ +export interface CardRowStatus { /** The icon to render (e.g. `Check` for accepted, `Cross` for rejected). */ icon: IconType - /** Colour family — same variants as the status tag. */ + /** Colour family. */ variant: StatusVariant /** Accessible label; the icon carries meaning, so this is required. */ label: string } -/** - * Resolved state shown in place of the actions: either a {@link F0TagStatus} - * pill ({@link TagStatusProps}) or a {@link CardRowStatusIcon} coloured icon. - */ -export type CardRowStatus = TagStatusProps | CardRowStatusIcon - // Status variant → F0Icon colour token (no "neutral" icon colour; map to secondary). const statusIconColor: Record< StatusVariant, @@ -128,9 +121,6 @@ const statusIconColor: Record< critical: "critical", } -const isStatusIcon = (status: CardRowStatus): status is CardRowStatusIcon => - "icon" in status - interface CardRowActionsProps { primaryAction?: CardPrimaryAction secondaryActions?: CardSecondaryAction[] | CardSecondaryLink @@ -141,9 +131,9 @@ interface CardRowActionsProps { /** Reject (✗) icon-only action — enables the confirm/reject variant. */ rejectAction?: CardRowConfirmAction /** - * Resolved-state indicator shown at the trailing edge in place of any actions - * (e.g. the "Accepted" / "Rejected" outcome of a confirm/reject row) — a - * status tag or a coloured icon. Takes precedence over every action prop. + * Resolved-state icon shown at the trailing edge in place of any actions + * (e.g. the "Accepted" / "Rejected" outcome of a confirm/reject row). + * Takes precedence over every action prop. */ status?: CardRowStatus compact?: boolean @@ -193,17 +183,13 @@ export function CardRowActions({ stackAt !== "never" && compact && "mt-3 pt-3" )} > - {isStatusIcon(status) ? ( - - ) : ( - - )} + ) } From 27cb23bae1594cec557c2e5e8e8178e4ded7a7bd Mon Sep 17 00:00:00 2001 From: Marcos Medina Date: Mon, 8 Jun 2026 14:46:23 +0200 Subject: [PATCH 13/13] refactor(ui): drop redundant title guard in F0CardRow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `title` is a required prop, so the `{title && …}` check never short- circuits. Render it unconditionally. Addresses review feedback. Co-Authored-By: Claude Opus 4.8 --- .../react/src/components/F0Card/F0CardRow.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/react/src/components/F0Card/F0CardRow.tsx b/packages/react/src/components/F0Card/F0CardRow.tsx index 86092f0893..d179c9855c 100644 --- a/packages/react/src/components/F0Card/F0CardRow.tsx +++ b/packages/react/src/components/F0Card/F0CardRow.tsx @@ -195,16 +195,14 @@ const F0CardRowBase = forwardRef(
{avatar && }
- {title && ( - - )} + {description && (