diff --git a/packages/react/src/components/F0Card/F0CardRow.tsx b/packages/react/src/components/F0Card/F0CardRow.tsx new file mode 100644 index 0000000000..d179c9855c --- /dev/null +++ b/packages/react/src/components/F0Card/F0CardRow.tsx @@ -0,0 +1,272 @@ +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, + type CardRowStatus, + 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 (wraps across multiple + * lines when long). + */ + 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 + + /** + * 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 + + /** + * 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. + */ + 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, + status, + inactive = false, + compact = false, + link, + fullHeight = false, + alert, + onClick, + disableOverlayLink = false, + stackAt = "never", + }, + ref + ) { + const hasAlert = !!alert && alert.visible !== false + + const body = ( + + {link && !disableOverlayLink && ( + +   + + )} + +
+
+ {avatar && } +
+ + {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..0eee94215a --- /dev/null +++ b/packages/react/src/components/F0Card/__stories__/F0CardRow.stories.tsx @@ -0,0 +1,377 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" + +import image from "@storybook-static/avatars/person04.jpg" +import { fn } from "storybook/test" + +import { Briefcase, Check, Cross, 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 (wraps when long).", + }, + 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", + stackAt: "md", + rejectAction: { label: "Reject", onClick: fn() }, + confirmAction: { label: "Approve", onClick: fn() }, + }, +} + +/** + * Resolved state of a confirm/reject row: once a decision is made, pass `status` + * 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: { icon: Check, variant: "positive", label: "Accepted" }, + }, +} + +export const Rejected: 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 + * 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..e1d191026e --- /dev/null +++ b/packages/react/src/components/F0Card/__tests__/F0CardRow.test.tsx @@ -0,0 +1,263 @@ +import { describe, expect, it, vi } from "vitest" +import "@testing-library/jest-dom/vitest" +import { Briefcase, Check, Cross } from "@/icons/app" +import { + zeroRender as render, + screen, + userEvent, + 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" + +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.getByRole("button", { name: "Open" })) + 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.getByRole("link", { name: "View more" }) + 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.getByRole("button", { name: "Confirm" })) + expect(onConfirm).toHaveBeenCalledTimes(1) + }) + + it("calls rejectAction.onClick", async () => { + const user = userEvent.setup() + const onReject = vi.fn() + + render() + + await user.click(screen.getByRole("button", { name: "Reject" })) + expect(onReject).toHaveBeenCalledTimes(1) + }) + + it("replaces the standard actions", () => { + render( + + ) + + expect( + screen.getByRole("button", { name: "Confirm" }) + ).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Reject" })).toBeInTheDocument() + expect( + screen.queryByRole("button", { name: "Open" }) + ).not.toBeInTheDocument() + }) + }) + + describe("status (resolved state)", () => { + it("renders the status icon by its accessible label", () => { + render( + + ) + + expect(screen.getByRole("img", { name: "Accepted" })).toBeInTheDocument() + }) + + it("takes precedence over the action props", () => { + render( + + ) + + expect(screen.getByRole("img", { name: "Rejected" })).toBeInTheDocument() + expect( + screen.queryByRole("button", { name: "Open" }) + ).not.toBeInTheDocument() + expect( + 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", () => { + 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 (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/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..352a120d3e --- /dev/null +++ b/packages/react/src/components/F0Card/components/CardRowActions.tsx @@ -0,0 +1,339 @@ +import { F0Icon, type IconType } from "@/components/F0Icon" +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" +import { + ButtonGroup, + type ButtonGroupButton, + type ButtonGroupSecondaryItem, + type ButtonGroupSecondaryLink, +} from "@/ui/ButtonGroup" + +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", +} + +/** + * 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 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: "@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", +} + +// 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 = { + 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 +} + +/** + * 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. */ + variant: StatusVariant + /** Accessible label; the icon carries meaning, so this is required. */ + label: string +} + +// 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", +} + +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 + /** + * 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 + /** Container breakpoint at which the actions drop to their own line. */ + stackAt?: CardRowStackAt +} + +/** + * 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. + * + * 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 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, + secondaryActions, + otherActions, + confirmAction, + rejectAction, + status, + compact = false, + stackAt = "never", +}: CardRowActionsProps) { + const size = compact ? "sm" : "md" + + // Resolved state: a status tag replaces the actions. It's informational, so + // no click-stop / z-index — a row-level overlay link stays clickable through + // it. The outer flex drops it to its own line when stacked, with the footer. + if (status) { + return ( +
+ +
+ ) + } + + 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: 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 variant = (hideLabel: boolean) => { + const reject: ButtonGroupButton | undefined = rejectAction + ? { + id: "reject", + icon: Cross, + label: rejectAction.label ?? "Reject", + 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 ( + + ) + } + + 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 ( + <> + {inline} + {/* Labelled, on its own line below the breakpoint. Width left to the + flex column's stretch so the `-mx-4` footer bleeds symmetrically. */} +
e.stopPropagation()} + > + {variant(false)} +
+ + ) + } + + 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 primary: ButtonGroupButton | undefined = primaryAction + ? { + id: "primary", + label: primaryAction.label, + icon: primaryAction.icon, + onClick: primaryAction.onClick, + } + : undefined + + const hasAnyAction = + !!primary || + (Array.isArray(secondaryActions) + ? secondaryActions.length > 0 + : !!secondaryActions) || + (otherActions?.length ?? 0) > 0 + + if (!hasAnyAction) { + return null + } + + return wrap( + + ) +} 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"