Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1f37113
feat(ui): add ButtonGroup primitive and adopt it in card/dialog footers
warcos-fact Jun 3, 2026
1404136
Merge branch 'cocreation-patterns-definition' into feat/f0card-footer…
warcos-fact Jun 4, 2026
5c90f82
revert(ui): drop ButtonGroup adoption in card/dialog footers (keep pr…
warcos-fact Jun 4, 2026
bcc2e91
feat(ui): expand ButtonGroup into a documented layout primitive
warcos-fact Jun 4, 2026
8b5513a
refactor(ui): reorganize ButtonGroup docs into Trailing/Split/Reflowi…
warcos-fact Jun 4, 2026
5e44a74
feat(ui): add reverseOnStack variant to ButtonGroup
warcos-fact Jun 4, 2026
5994950
feat(ui): add F0CardOneLiner with avatar, alert, and left-overflow ac…
warcos-fact Jun 4, 2026
7a22853
feat(ui): add stackAt breakpoint and confirm/reject variant to F0Card…
warcos-fact Jun 4, 2026
11e8491
feat(ui): default F0CardOneLiner collapse to sm, add module avatar + …
warcos-fact Jun 4, 2026
17c58a2
fix(ui): address ButtonGroup review — fixed gap, real overflow menu, …
warcos-fact Jun 4, 2026
2c79e02
fix(ui): bump oneliner icon/module avatars to lg, fix sm breakpoint
warcos-fact Jun 4, 2026
9be60f7
fix(ui): default oneliner stackAt to never, hide fn() in action controls
warcos-fact Jun 4, 2026
5f12933
feat(ui): support alert/date/pulse avatars in F0CardOneLiner, show al…
warcos-fact Jun 5, 2026
88f2082
fix(ui): render all F0CardOneLiner avatars at lg uniformly
warcos-fact Jun 5, 2026
db3fb97
fix(ui): use the vertical ellipsis for ButtonGroup overflow menus
warcos-fact Jun 5, 2026
7f04546
chore(ui): stabilise and document F0CardOneLiner
warcos-fact Jun 5, 2026
084eb6c
refactor(ui): rename F0CardOneLiner to F0CardRow
warcos-fact Jun 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/react/.storybook/resolveImportPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ export function extractComponentName(
const candidates: string[] = []

if (fileName) {
// Story file basename, e.g. ".../F0CardOneLiner.stories.tsx" → "F0CardOneLiner".
// Checked first so a component co-located in another component's folder (e.g.
// F0CardOneLiner living under F0Card/__stories__/) resolves to its own export
// rather than the folder name.
const storyFile = fileName.match(/\/([^/]+)\.stories\.tsx?$/)
if (storyFile && storyFile[1] !== "index") candidates.push(storyFile[1])

const storiesDir = fileName.match(/\/([^/]+)\/__stories__\//)
if (storiesDir) candidates.push(storiesDir[1])

Expand Down
62 changes: 0 additions & 62 deletions packages/react/src/components/F0Card/CardInternal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
CardTitle,
} from "@/ui/Card"
import { Skeleton } from "@/ui/skeleton"
import { Text } from "@/ui/Text"

import { OneEllipsis } from "@/lib/OneEllipsis/OneEllipsis"
import {
Expand Down Expand Up @@ -132,16 +131,6 @@ export interface CardInternalProps {
*/
secondaryActions?: CardSecondaryAction[] | CardSecondaryLink

/**
* Renders the card as a single horizontal line — text on the left, actions on
* the right. It collapses to the stacked layout when the card itself becomes
* too narrow, driven by a container query on the card's own width (not the
* viewport), so it reacts correctly inside grids and columns.
* Only the title, description and actions are shown in this mode.
* @default false
*/
oneLiner?: boolean

/**
* Actions to display in the dropdown menu inside the card content
*/
Expand Down Expand Up @@ -233,7 +222,6 @@ export const CardInternal = forwardRef<HTMLDivElement, CardInternalProps>(
fullHeight = false,
disableOverlayLink = false,
alert,
oneLiner = false,
},
ref
) {
Expand All @@ -248,56 +236,6 @@ export const CardInternal = forwardRef<HTMLDivElement, CardInternalProps>(
e.stopPropagation()
}

if (oneLiner) {
return (
<Card
ref={ref}
className={cn(
"group relative @container bg-f1-background shadow-none transition-all",
compact && "p-3",
fullHeight && "h-full",
link &&
"focus-within:border-f1-border-hover focus-within:shadow-md hover:border-f1-border-hover hover:shadow-md"
)}
onClick={onClick}
data-testid="card"
>
{link && !disableOverlayLink && (
<F0Link
href={link}
variant="unstyled"
className={cn(
"z-1 absolute inset-0 block rounded-xl",
focusRing()
)}
aria-label={title}
ref={linkRef}
>
&nbsp;
</F0Link>
)}

<div className="flex flex-col @md:flex-row @md:items-center @md:justify-between @md:gap-4">
<div className="flex min-w-0 flex-col gap-0">
{title && (
<Text variant="body" content={title} className="font-medium" />
)}
{description && (
<Text variant="description" content={description} />
)}
</div>

<CardActions
oneLiner
primaryAction={primaryAction}
secondaryActions={secondaryActions}
compact={compact}
/>
</div>
</Card>
)
}

// The card body — extracted so it can be placed inside either the plain root
// or the alert wrapper without duplication.
const cardBody = (
Expand Down
248 changes: 248 additions & 0 deletions packages/react/src/components/F0Card/F0CardRow.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement, F0CardRowProps>(
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 = (
<Card
ref={hasAlert ? undefined : ref}
className={cn(
"group relative @container bg-f1-background shadow-none transition-all",
compact && "p-3",
fullHeight && "h-full",
link &&
"focus-within:border-f1-border-hover focus-within:shadow-md hover:border-f1-border-hover hover:shadow-md"
)}
style={
hasAlert
? {
borderColor: alertBorderColor[alert.variant],
borderWidth: "2px",
}
: undefined
}
onClick={onClick}
data-testid="card"
>
{link && !disableOverlayLink && (
<F0Link
href={link}
variant="unstyled"
className={cn("z-1 absolute inset-0 block rounded-xl", focusRing())}
aria-label={title}
>
&nbsp;
</F0Link>
)}

<div className={cardRowClassName[stackAt]}>
<div className="flex min-w-0 flex-row items-center gap-3">
{avatar && <CardAvatar avatar={avatar} size="lg" />}
<div className="flex min-w-0 flex-col gap-0">
{title && (
<Text
variant="body"
content={title}
className="font-medium"
ellipsis
/>
)}
{description && (
<Text variant="description" content={description} ellipsis />
)}
</div>
</div>

<CardRowActions
primaryAction={primaryAction}
secondaryActions={secondaryActions}
otherActions={otherActions}
confirmAction={confirmAction}
rejectAction={rejectAction}
compact={compact}
stackAt={stackAt}
/>
</div>
</Card>
)

if (hasAlert) {
return (
<CardAlertWrapper ref={ref} alert={alert} fullHeight={fullHeight}>
{body}
</CardAlertWrapper>
)
}

return body
}
)

F0CardRowBase.displayName = "F0CardRow"

const F0CardRowSkeleton = ({ compact = false }: { compact?: boolean }) => {
return (
<Card
className={cn(
"group relative bg-f1-background shadow-none",
compact && "p-3"
)}
aria-busy="true"
aria-live="polite"
>
<div className="flex flex-row items-center justify-between gap-4">
<div className="flex min-w-0 flex-row items-center gap-3">
<Skeleton
className={cn("h-10 w-10 rounded-full", compact && "h-8 w-8")}
/>
<div className="flex flex-col gap-1">
<Skeleton className="h-3 w-32 rounded-md" />
<Skeleton className="h-3 w-20 rounded-md" />
</div>
</div>
<Skeleton className="h-9 w-24 rounded-md" />
</div>
</Card>
)
}

export const F0CardRow = withDataTestId(
withSkeleton(F0CardRowBase, F0CardRowSkeleton)
)
30 changes: 0 additions & 30 deletions packages/react/src/components/F0Card/__stories__/Card.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,36 +269,6 @@ export const WithActionsAndLink: Story = {
},
}

export const OneLiner: Story = {
args: {
oneLiner: true,
title: "Do you want to proceed?",
primaryAction: {
label: "Confirm",
onClick: fn(),
},
secondaryActions: [
{
label: "Cancel",
onClick: fn(),
},
],
},
parameters: {
noMetaLayout: true,
docs: { story: { inline: false, height: "160px" } },
},
decorators: [
(Story) => (
<div className="flex h-[calc(100vh-32px)] w-full items-center justify-center">
<div className="w-[640px]">
<Story />
</div>
</div>
),
],
}

export const WithLink: Story = {
args: {
...Default.args,
Expand Down
Loading
Loading