Card compact and expanded template

This commit is contained in:
adilallo
2026-02-11 22:02:10 -07:00
parent f60df15c2b
commit b2ed1d438c
44 changed files with 1920 additions and 48 deletions
@@ -0,0 +1,79 @@
"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";
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 = "",
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}
className={className}
/>
);
},
);
CardStackContainer.displayName = "CardStack";
export default CardStackContainer;
@@ -0,0 +1,35 @@
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;
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;
className: string;
}
@@ -0,0 +1,114 @@
"use client";
import HeaderLockup from "../../type/HeaderLockup";
import Card from "../../cards/Card";
import type { CardStackViewProps } from "./CardStack.types";
export function CardStackView({
cards,
selectedIds,
onCardSelect,
expanded,
onToggleExpand,
hasMore,
toggleLabel,
showLessLabel,
title,
description,
className,
}: CardStackViewProps) {
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);
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="L"
/>
</div>
) : null}
{expanded ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-6 w-full">
{cards.map((item) => (
<Card
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 under 640: single column, up to 5 recommended cards */}
<div className="flex flex-col gap-6 w-full 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="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}>
<Card
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>
);
}
@@ -0,0 +1,2 @@
export { default } from "./CardStack.container";
export type { CardStackProps, CardStackItem } from "./CardStack.types";
@@ -0,0 +1,10 @@
import type { ReactNode } from "react";
export interface ScrollbarProps {
/** Content to scroll. */
children: ReactNode;
/** Optional class name merged with scrollbar-design. */
className?: string;
/** Vertical scroll only, horizontal only, or both. @default "vertical" */
orientation?: "vertical" | "horizontal" | "both";
}
@@ -0,0 +1,23 @@
"use client";
import type { ScrollbarProps } from "./Scrollbar.types";
const overflowClass = {
vertical: "overflow-x-clip overflow-y-auto",
horizontal: "overflow-y-clip overflow-x-auto",
both: "overflow-auto",
} as const;
export function ScrollbarView({
children,
className = "",
orientation = "vertical",
}: ScrollbarProps) {
return (
<div
className={`scrollbar-design ${overflowClass[orientation]} ${className}`.trim()}
>
{children}
</div>
);
}
@@ -0,0 +1,5 @@
export { ScrollbarView as default } from "./Scrollbar.view";
export type { ScrollbarProps } from "./Scrollbar.types";
/** Class name to apply the design system scrollbar to any scrollable element (e.g. textarea, div). */
export const SCROLLBAR_DESIGN_CLASS = "scrollbar-design";
@@ -0,0 +1,25 @@
"use client";
import { memo } from "react";
import { TagView } from "./Tag.view";
import type { TagProps } from "./Tag.types";
const DEFAULT_LABELS: Record<TagProps["variant"], string> = {
recommended: "RECOMMENDED",
selected: "SELECTED",
};
const TagContainer = memo<TagProps>(
({ variant, children, className = "" }) => {
const content = children ?? DEFAULT_LABELS[variant];
return (
<TagView variant={variant} className={className}>
{content}
</TagView>
);
},
);
TagContainer.displayName = "Tag";
export default TagContainer;
+15
View File
@@ -0,0 +1,15 @@
export type TagVariant = "recommended" | "selected";
export interface TagProps {
/** Visual variant: recommended (yellow) or selected (dark) */
variant: TagVariant;
/** Tag text. Defaults to "RECOMMENDED" or "SELECTED" when not provided. */
children?: React.ReactNode;
className?: string;
}
export interface TagViewProps {
variant: TagVariant;
children: React.ReactNode;
className: string;
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import type { TagViewProps } from "./Tag.types";
/**
* Tag view Figma 17861-22238.
* Recommended: light yellow bg (#F6EEA7), dark text (#3F3F3F).
* Selected: dark bg (#3F3F3F), white text (#FFFFFF).
* Typography: Inter Medium 10px, line-height 12, uppercase.
*/
export function TagView({ variant, children, className }: TagViewProps) {
const isRecommended = variant === "recommended";
const bgClass = isRecommended
? "bg-[#F6EEA7]"
: "bg-[#3F3F3F]";
const textClass = isRecommended
? "text-[#3F3F3F]"
: "text-[#FFFFFF]";
return (
<span
className={`inline-flex items-center justify-center rounded px-2 py-0.5 font-inter text-[10px] font-medium uppercase leading-3 ${bgClass} ${textClass} ${className}`}
role="status"
>
{children}
</span>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Tag.container";
export type { TagProps, TagVariant } from "./Tag.types";