Component cleanup

This commit is contained in:
adilallo
2026-04-29 07:20:16 -06:00
parent 252848eba9
commit e6127f1a3f
267 changed files with 2087 additions and 2196 deletions
@@ -0,0 +1,100 @@
"use client";
import { memo, useCallback, useState } from "react";
import { CardStackView } from "./CardStack.view";
import type { CardStackProps } from "./CardStack.types";
const DEFAULT_TOGGLE_LABEL = "See all communication approaches";
const DEFAULT_SHOW_LESS_LABEL = "Show less";
/**
* Figma: "Utility / CardStack"; canonical code under `cards/`.
* Selectable stack of cards with
* an optional "see all"/"show less" expand toggle.
*/
const CardStackContainer = memo<CardStackProps>(
({
cards,
selectedId: controlledSelectedId,
selectedIds: controlledSelectedIds,
onCardSelect: controlledOnCardSelect,
expanded: controlledExpanded,
onToggleExpand: controlledOnToggleExpand,
hasMore = true,
toggleLabel = DEFAULT_TOGGLE_LABEL,
showLessLabel = DEFAULT_SHOW_LESS_LABEL,
title = "",
description = "",
layout = "default",
compactRecommendedLimit = 5,
compactCardIds,
compactDesktopLayout: compactDesktopLayoutProp = "grid",
headerLockupSize,
toggleAlignment = "center",
className = "",
}) => {
const [internalExpanded, setInternalExpanded] = useState(false);
const [internalSelectedIds, setInternalSelectedIds] = useState<string[]>(
[],
);
const expanded =
controlledExpanded !== undefined ? controlledExpanded : internalExpanded;
const handleToggleExpand = useCallback(() => {
if (controlledOnToggleExpand) {
controlledOnToggleExpand();
} else {
setInternalExpanded((prev) => !prev);
}
}, [controlledOnToggleExpand]);
const selectedIds =
controlledSelectedIds !== undefined
? controlledSelectedIds
: controlledSelectedId !== undefined
? controlledSelectedId
? [controlledSelectedId]
: []
: internalSelectedIds;
const handleCardSelect = useCallback(
(id: string) => {
if (controlledOnCardSelect) {
controlledOnCardSelect(id);
} else {
setInternalSelectedIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
}
},
[controlledOnCardSelect],
);
return (
<CardStackView
cards={cards}
selectedIds={selectedIds}
onCardSelect={handleCardSelect}
expanded={expanded}
onToggleExpand={handleToggleExpand}
hasMore={hasMore}
toggleLabel={toggleLabel}
showLessLabel={showLessLabel}
title={title}
description={description}
layout={layout}
compactRecommendedLimit={compactRecommendedLimit}
compactCardIds={compactCardIds}
compactDesktopLayout={compactDesktopLayoutProp}
headerLockupSize={headerLockupSize}
toggleAlignment={toggleAlignment}
className={className}
/>
);
},
);
CardStackContainer.displayName = "CardStack";
export default CardStackContainer;
@@ -0,0 +1,68 @@
import type { HeaderLockupSizeValue } from "../../type/HeaderLockup/HeaderLockup.types";
export interface CardStackItem {
id: string;
label: string;
supportText?: string;
recommended?: boolean;
}
export interface CardStackProps {
cards: CardStackItem[];
selectedId?: string | null;
selectedIds?: string[];
onCardSelect?: (id: string) => void;
expanded?: boolean;
onToggleExpand?: () => void;
hasMore?: boolean;
toggleLabel?: string;
showLessLabel?: string;
title?: string;
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;
/**
* Optional explicit list of card ids to render in the compact slot, in
* order. When provided, this overrides the default
* `cards.filter(c => c.recommended)` selection — the `recommended` flag
* then only controls the visual "Recommended" badge. Used by the
* create-flow card-deck steps so facet scores can pick the compact set
* (and badge only the truly matched subset). Cards whose ids are not in
* `cards` are silently dropped.
*/
compactCardIds?: string[];
/**
* 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;
}
export interface CardStackViewProps {
cards: CardStackItem[];
selectedIds: string[];
onCardSelect: (id: string) => void;
expanded: boolean;
onToggleExpand: () => void;
hasMore: boolean;
toggleLabel: string;
showLessLabel: string;
title: string;
description: string;
layout: "default" | "singleStack";
compactRecommendedLimit: number;
compactCardIds: string[] | undefined;
compactDesktopLayout: "grid" | "flexWrap" | "pyramidFive";
headerLockupSize: HeaderLockupSizeValue | undefined;
toggleAlignment: "center" | "end";
className: string;
}
@@ -0,0 +1,383 @@
"use client";
import HeaderLockup from "../../type/HeaderLockup";
import Selection from "../Selection";
import type { CardStackViewProps } from "./CardStack.types";
export function CardStackView({
cards,
selectedIds,
onCardSelect,
expanded,
onToggleExpand,
hasMore,
toggleLabel,
showLessLabel,
title,
description,
layout,
compactRecommendedLimit,
compactCardIds,
compactDesktopLayout,
headerLockupSize,
toggleAlignment,
className,
}: CardStackViewProps) {
const lockupSize = headerLockupSize ?? "L";
const isSelected = (id: string) => selectedIds.includes(id);
// Compact: explicit `compactCardIds` (caller-driven, used by create-flow
// facet ranker) takes precedence over the legacy `recommended`-filter so
// the screen can show un-tagged cards in the compact slot when there is
// no facet signal yet (CR-88 §10).
const compactCards = (() => {
if (compactCardIds && compactCardIds.length > 0) {
const byId = new Map(cards.map((c) => [c.id, c]));
return compactCardIds
.map((id) => byId.get(id))
.filter((c): c is (typeof cards)[number] => c !== undefined)
.slice(0, compactRecommendedLimit);
}
return cards
.filter((c) => c.recommended ?? false)
.slice(0, compactRecommendedLimit);
})();
// Single stack: always one column; expand reveals more in same stack (scrollable)
if (layout === "singleStack") {
const displayedCards = expanded ? cards : compactCards;
return (
<div className={`flex w-full flex-col gap-6 min-w-0 ${className}`}>
{title || description ? (
<div className="min-w-0 shrink-0">
<HeaderLockup
title={title}
description={description}
justification="center"
size={lockupSize}
/>
</div>
) : null}
<div className="flex w-full min-w-0 flex-col gap-2">
{displayedCards.map((item) => (
<Selection
key={item.id}
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
selected={isSelected(item.id)}
orientation="vertical"
showInfoIcon={true}
onClick={() => onCardSelect(item.id)}
/>
))}
</div>
{hasMore ? (
<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 cursor-pointer ${
toggleAlignment === "end" ? "self-end" : "self-center"
}`}
>
{expanded ? showLessLabel : toggleLabel}
</button>
) : null}
</div>
);
}
return (
<div className={`flex w-full flex-col gap-6 min-w-0 ${className}`}>
{title || description ? (
<div className="min-w-0">
<HeaderLockup
title={title}
description={description}
justification="center"
size={lockupSize}
/>
</div>
) : null}
{expanded ? (
<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) => (
<Selection
key={item.id}
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
selected={isSelected(item.id)}
orientation="vertical"
showInfoIcon={true}
onClick={() => onCardSelect(item.id)}
/>
))}
</div>
) : compactDesktopLayout === "pyramidFive" ? (
<>
<div className="flex w-full flex-col gap-2 md:hidden">
{compactCards.map((item) => (
<Selection
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) => (
<Selection
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) => (
<Selection
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) => (
<Selection
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) => (
<Selection
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">
<Selection
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) => (
<Selection
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) => (
<Selection
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">
<Selection
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) => (
<Selection
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]"
>
<Selection
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 w-full flex-col gap-2 md:hidden">
{compactCards.map((item) => (
<Selection
key={item.id}
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
selected={isSelected(item.id)}
orientation="vertical"
showInfoIcon={true}
onClick={() => onCardSelect(item.id)}
/>
))}
</div>
{/* Compact 640+: 6-col grid so each card spans 2; second row centered (cols 23 and 45) */}
<div className="hidden md:grid grid-cols-6 gap-x-4 gap-y-6 w-full">
{compactCards.map((item, index) => {
const colClass =
index <= 2
? "md:col-span-2"
: index === 3 && compactCards.length === 4
? "md:col-start-3 md:col-span-2"
: index === 3
? "md:col-start-2 md:col-span-2"
: "md:col-start-4 md:col-span-2";
return (
<div key={item.id} className={colClass}>
<Selection
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
selected={isSelected(item.id)}
orientation="horizontal"
showInfoIcon={false}
onClick={() => onCardSelect(item.id)}
/>
</div>
);
})}
</div>
</>
)}
{hasMore ? (
<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"
>
{expanded ? showLessLabel : toggleLabel}
</button>
) : null}
</div>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./CardStack.container";
export type { CardStackProps, CardStackItem } from "./CardStack.types";