Create custom flow UI

This commit is contained in:
adilallo
2026-04-17 22:25:24 -06:00
parent eedb70f9f3
commit 36dcb79870
38 changed files with 1227 additions and 250 deletions
@@ -10,6 +10,7 @@ export type ProportionBarState =
| "2-0"
| "2-1"
| "2-2"
| "2-3"
| "3-0"
| "3-1"
| "3-2";
@@ -20,7 +21,9 @@ export interface ProportionBarProps {
progress?: ProportionBarState;
className?: string;
/**
* `segmented` (Figma: create-flow footer): pill-shaped partial fills inside each segment.
* Kept for backwards compatibility. Both `default` and `segmented` render the
* same fill geometry (square leading edges, matching Figma). Future variants
* can differentiate here without API changes.
*/
variant?: ProportionBarVariant;
}
@@ -1,24 +1,41 @@
import type { ProportionBarViewProps } from "./ProportionBar.types";
/**
* Per-step fill ratio for the second (middle) segment at `2-X` progress states.
* Values are taken directly from Figma (`17861:33241`, `18861:15250`, `21434:17632`)
* and are intentionally non-uniform — they are NOT `X/3`.
*/
const SECOND_SEGMENT_FILL_RATIO: Record<number, number> = {
0: 0,
1: 1 / 4,
2: 1 / 2,
3: 3 / 4,
};
function getSecondSegmentFillRatio(partial: number): number {
return SECOND_SEGMENT_FILL_RATIO[partial] ?? 0;
}
export function ProportionBarView({
progress,
className,
barClasses,
variant,
// `variant` is kept in the prop API for callers, but both `default` and
// `segmented` now render identical fill geometry (square leading edges).
variant: _variant,
}: ProportionBarViewProps) {
// Proportion bar type
const [fullSegments, partialSegment] = progress.split("-").map(Number);
const segmented = variant === "segmented";
// Calculate total progress:
// - For 1-X: first section is (X+1)/6 filled
// - For 2-X: first section full, second section X/3 filled
// - For 2-X: first section full, second section filled per Figma ratios (see `SECOND_SEGMENT_FILL_RATIO`)
// - For 3-X: first two sections full, third section X/3 filled
// Max is 3 full segments = 9 units
let totalProgress = 0;
if (fullSegments === 1) {
totalProgress = (partialSegment + 1) / 6; // 1/6 to 6/6 of first section
} else if (fullSegments === 2) {
totalProgress = 1 + partialSegment / 3; // 1 full + 0/3 to 2/3 of second
totalProgress = 1 + getSecondSegmentFillRatio(partialSegment);
} else if (fullSegments === 3) {
totalProgress = 2 + partialSegment / 3; // 2 full + 0/3 to 2/3 of third
}
@@ -55,33 +72,32 @@ export function ProportionBarView({
</div>
{/* Fill layer - always show 3 sections, fill amount varies */}
{/*
The leading (right) edge of every partial fill is a straight (square) edge —
only the outermost left/right edges of the whole bar can round to match the
background capsule.
*/}
<div className="absolute inset-0 flex gap-[var(--spacing-scale-008)] px-[4px] overflow-hidden">
{/* First section - for 1-X: (X+1)/6 filled, for 2-X and 3-X: fully filled */}
<div className="flex-1 h-full relative">
{fullSegments === 1 ? (
<div
className={`absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)] rounded-l-[var(--radius-full)] ${
segmented && partialSegment < 5
? "rounded-r-[var(--radius-full)]"
: ""
}`.trim()}
className="absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)] rounded-l-[var(--radius-full)]"
style={{ width: `${((partialSegment + 1) / 6) * 100}%` }}
/>
) : fullSegments >= 2 ? (
<div className="absolute inset-0 bg-[var(--color-content-default-brand-primary)] rounded-l-[var(--radius-full)]" />
) : null}
</div>
{/* Second section - for 2-X: X/3 filled, for 3-X: fully filled, otherwise empty */}
{/* Second section for 2-X: Figma ratio fill (see `SECOND_SEGMENT_FILL_RATIO`); for 3-X: full; otherwise empty. */}
<div className="flex-1 h-full relative">
{fullSegments === 2 ? (
partialSegment > 0 ? (
<div
className={`absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)] ${
segmented
? "rounded-l-[var(--radius-full)] rounded-r-[var(--radius-full)]"
: ""
}`.trim()}
style={{ width: `${(partialSegment / 3) * 100}%` }}
className="absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)]"
style={{
width: `${getSecondSegmentFillRatio(partialSegment) * 100}%`,
}}
/>
) : null
) : fullSegments >= 3 ? (
@@ -89,16 +105,12 @@ export function ProportionBarView({
) : null}
</div>
{/* Third section - for 3-X: X/3 filled, otherwise empty */}
{/* Round right corner when at 100% (third section fully filled, partialSegment === 3) */}
{/* Round right corner only when the fill reaches the absolute right edge of the bar (partialSegment >= 3) */}
<div className="flex-1 h-full relative">
{fullSegments === 3 && partialSegment > 0 ? (
<div
className={`absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)] ${
segmented
? "rounded-l-[var(--radius-full)] rounded-r-[var(--radius-full)]"
: partialSegment >= 3
? "rounded-r-[var(--radius-full)]"
: ""
partialSegment >= 3 ? "rounded-r-[var(--radius-full)]" : ""
}`.trim()}
style={{ width: `${Math.min((partialSegment / 3) * 100, 100)}%` }}
/>
@@ -21,7 +21,10 @@ const CardStackContainer = memo<CardStackProps>(
title = "",
description = "",
layout = "default",
compactRecommendedLimit = 5,
compactDesktopLayout: compactDesktopLayoutProp = "grid",
headerLockupSize,
toggleAlignment = "center",
className = "",
}) => {
const [internalExpanded, setInternalExpanded] = useState(false);
@@ -75,7 +78,10 @@ const CardStackContainer = memo<CardStackProps>(
title={title}
description={description}
layout={layout}
compactRecommendedLimit={compactRecommendedLimit}
compactDesktopLayout={compactDesktopLayoutProp}
headerLockupSize={headerLockupSize}
toggleAlignment={toggleAlignment}
className={className}
/>
);
@@ -21,8 +21,19 @@ export interface CardStackProps {
description?: string;
/** "default" = compact grid/column + expanded grid; "singleStack" = always one column, expand shows more in same stack */
layout?: "default" | "singleStack";
/**
* Max recommended cards in compact (non-expanded) mode. Default 5; Figma compact stack uses 3.
*/
compactRecommendedLimit?: number;
/**
* At `md+`, how compact recommended cards are laid out. `flexWrap` matches Figma Flow — Compact Card Stack (three cards in a row).
* `pyramidFive` = two rows (3 + 2) centered for five recommended cards (membership step).
*/
compactDesktopLayout?: "grid" | "flexWrap" | "pyramidFive";
/** Optional title/description lockup size (create-flow passes `md`-matched `L`/`M`). Defaults to `L`. */
headerLockupSize?: HeaderLockupSizeValue;
/** Alignment of the expand/collapse control in `singleStack` layout (Figma right-rail: end). */
toggleAlignment?: "center" | "end";
className?: string;
}
@@ -38,6 +49,9 @@ export interface CardStackViewProps {
title: string;
description: string;
layout: "default" | "singleStack";
compactRecommendedLimit: number;
compactDesktopLayout: "grid" | "flexWrap" | "pyramidFive";
headerLockupSize: HeaderLockupSizeValue | undefined;
toggleAlignment: "center" | "end";
className: string;
}
@@ -16,13 +16,18 @@ export function CardStackView({
title,
description,
layout,
compactRecommendedLimit,
compactDesktopLayout,
headerLockupSize,
toggleAlignment,
className,
}: CardStackViewProps) {
const lockupSize = headerLockupSize ?? "L";
const isSelected = (id: string) => selectedIds.includes(id);
// Compact: recommended only (up to 5). Expanded: all cards.
const compactCards = cards.filter((c) => c.recommended ?? false).slice(0, 5);
// Compact: recommended only (default up to 5). Expanded: all cards.
const compactCards = cards
.filter((c) => c.recommended ?? false)
.slice(0, compactRecommendedLimit);
// Single stack: always one column; expand reveals more in same stack (scrollable)
if (layout === "singleStack") {
@@ -39,7 +44,7 @@ export function CardStackView({
/>
</div>
) : null}
<div className="flex flex-col gap-[8px] w-full min-w-0">
<div className="flex w-full min-w-0 flex-col gap-2">
{displayedCards.map((item) => (
<Card
key={item.id}
@@ -58,7 +63,9 @@ export function CardStackView({
<button
type="button"
onClick={onToggleExpand}
className="font-inter text-base font-normal leading-6 text-[var(--color-gray-000)] underline hover:opacity-90 focus:outline-none self-center cursor-pointer"
className={`font-inter text-base font-normal leading-6 text-[var(--color-gray-000)] underline hover:opacity-90 focus:outline-none cursor-pointer ${
toggleAlignment === "end" ? "self-end" : "self-center"
}`}
>
{expanded ? showLessLabel : toggleLabel}
</button>
@@ -81,7 +88,7 @@ export function CardStackView({
) : null}
{expanded ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-6 w-full">
<div className="mx-auto grid w-full max-w-[min(100%,860px)] grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2">
{cards.map((item) => (
<Card
key={item.id}
@@ -96,10 +103,215 @@ export function CardStackView({
/>
))}
</div>
) : compactDesktopLayout === "pyramidFive" ? (
<>
<div className="flex w-full flex-col gap-2 md:hidden">
{compactCards.map((item) => (
<Card
key={item.id}
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
selected={isSelected(item.id)}
orientation="horizontal"
showInfoIcon={false}
className="min-h-[142px]"
onClick={() => onCardSelect(item.id)}
/>
))}
</div>
<div className="mx-auto hidden w-full max-w-[min(100%,860px)] md:block">
{/*
lg+: fixed 3 + 2 rows (no flex-wrap on the top row — avoids 2+1+2 when the first row wraps).
mdlg: same shell as the 3-card step — each row is `flex justify-center gap-2` so cards
stay a tight cluster with gap-2 until lg expands to the 3+2 pyramid.
*/}
<div className="hidden flex-col gap-2 lg:flex">
<div className="flex justify-center gap-2">
{compactCards.slice(0, 3).map((item) => (
<Card
key={item.id}
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
selected={isSelected(item.id)}
orientation="horizontal"
showInfoIcon={false}
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
onClick={() => onCardSelect(item.id)}
/>
))}
</div>
{compactCards.length > 3 ? (
<div className="flex justify-center gap-2">
{compactCards
.slice(3, compactRecommendedLimit)
.map((item) => (
<Card
key={item.id}
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
selected={isSelected(item.id)}
orientation="horizontal"
showInfoIcon={false}
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
onClick={() => onCardSelect(item.id)}
/>
))}
</div>
) : null}
</div>
<div className="hidden flex-col gap-2 md:flex lg:hidden">
<div className="flex justify-center gap-2">
{compactCards.slice(0, 2).map((item) => (
<Card
key={item.id}
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
selected={isSelected(item.id)}
orientation="horizontal"
showInfoIcon={false}
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
onClick={() => onCardSelect(item.id)}
/>
))}
</div>
<div className="flex justify-center gap-2">
{compactCards.slice(2, 4).map((item) => (
<Card
key={item.id}
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
selected={isSelected(item.id)}
orientation="horizontal"
showInfoIcon={false}
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
onClick={() => onCardSelect(item.id)}
/>
))}
</div>
{compactCards[4] ? (
<div className="flex justify-center gap-2">
<Card
id={compactCards[4].id}
label={compactCards[4].label}
supportText={compactCards[4].supportText}
recommended={compactCards[4].recommended ?? false}
selected={isSelected(compactCards[4].id)}
orientation="horizontal"
showInfoIcon={false}
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
onClick={() => onCardSelect(compactCards[4].id)}
/>
</div>
) : null}
</div>
</div>
</>
) : compactDesktopLayout === "flexWrap" ? (
<>
<div className="flex w-full flex-col gap-2 md:hidden">
{compactCards.map((item) => (
<Card
key={item.id}
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
selected={isSelected(item.id)}
orientation="horizontal"
showInfoIcon={false}
className="min-h-[142px]"
onClick={() => onCardSelect(item.id)}
/>
))}
</div>
{/* mdlg: pyramid (2 + 1), each row centered; lg+: one centered row (not edge-to-edge in a 2-col grid) */}
{compactCards.length === 3 ? (
<>
<div className="mx-auto hidden w-full max-w-[min(100%,860px)] flex-col gap-2 md:flex lg:hidden">
<div className="flex justify-center gap-2">
{compactCards.slice(0, 2).map((item) => (
<Card
key={item.id}
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
selected={isSelected(item.id)}
orientation="horizontal"
showInfoIcon={false}
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
onClick={() => onCardSelect(item.id)}
/>
))}
</div>
<div className="flex justify-center">
<Card
id={compactCards[2].id}
label={compactCards[2].label}
supportText={compactCards[2].supportText}
recommended={compactCards[2].recommended ?? false}
selected={isSelected(compactCards[2].id)}
orientation="horizontal"
showInfoIcon={false}
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
onClick={() => onCardSelect(compactCards[2].id)}
/>
</div>
</div>
<div className="mx-auto hidden w-full max-w-[min(100%,860px)] flex-wrap justify-center gap-2 lg:flex">
{compactCards.map((item) => (
<Card
key={item.id}
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
selected={isSelected(item.id)}
orientation="horizontal"
showInfoIcon={false}
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
onClick={() => onCardSelect(item.id)}
/>
))}
</div>
</>
) : (
<div className="mx-auto hidden w-full max-w-[min(100%,860px)] flex-wrap justify-center gap-2 md:flex">
{compactCards.map((item) => (
<div
key={item.id}
className="flex w-full min-w-0 shrink-0 justify-center md:w-[281px] md:max-w-[281px]"
>
<Card
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
selected={isSelected(item.id)}
orientation="horizontal"
showInfoIcon={false}
className="h-[142px] min-h-[142px] max-h-[142px] w-full max-w-[281px]"
onClick={() => onCardSelect(item.id)}
/>
</div>
))}
</div>
)}
</>
) : (
<>
{/* Compact under 640: single column, up to 5 recommended cards */}
<div className="flex flex-col gap-6 w-full md:hidden">
<div className="flex w-full flex-col gap-2 md:hidden">
{compactCards.map((item) => (
<Card
key={item.id}