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,49 @@
"use client";
import { memo } from "react";
import { CardView } from "./Card.view";
import type { CardProps } from "./Card.types";
const CardContainer = memo<CardProps>(
({
label,
supportText = "",
recommended = false,
selected = false,
orientation = "horizontal",
showInfoIcon = false,
id,
className = "",
onClick,
}) => {
const handleClick = () => {
if (onClick) onClick();
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleClick();
}
};
return (
<CardView
label={label}
supportText={supportText}
recommended={recommended}
selected={selected}
orientation={orientation}
showInfoIcon={showInfoIcon}
id={id}
className={className}
onClick={handleClick}
onKeyDown={handleKeyDown}
/>
);
},
);
CardContainer.displayName = "Card";
export default CardContainer;
+25
View File
@@ -0,0 +1,25 @@
export interface CardProps {
label: string;
supportText?: string;
recommended?: boolean;
selected?: boolean;
orientation: "horizontal" | "vertical";
showInfoIcon?: boolean;
/** Optional id for the card root (e.g. data-card-id for focus after modal close). */
id?: string;
className?: string;
onClick?: () => void;
}
export interface CardViewProps {
label: string;
supportText: string;
recommended: boolean;
selected: boolean;
orientation: "horizontal" | "vertical";
showInfoIcon: boolean;
id: string | undefined;
className: string;
onClick: () => void;
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void;
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import Tag from "../../utility/Tag";
import type { CardViewProps } from "./Card.types";
function InfoIcon() {
return (
<span
className="flex h-[var(--spacing-scale-016)] w-[var(--spacing-scale-016)] shrink-0 items-center justify-center rounded-full border border-[var(--color-content-invert-brand-secondary)] bg-transparent font-inter text-[10px] font-medium leading-none text-[var(--color-content-invert-brand-secondary)]"
aria-hidden
>
?
</span>
);
}
function CardTag({
recommended,
selected,
}: {
recommended: boolean;
selected: boolean;
}) {
if (selected) return <Tag variant="selected" />;
if (recommended) return <Tag variant="recommended" />;
return null;
}
export function CardView({
label,
supportText,
recommended,
selected,
orientation,
showInfoIcon,
id: cardId,
className,
onClick,
onKeyDown,
}: CardViewProps) {
const borderClass = "border border-[var(--color-border-default-primary)]";
const selectedBorder = selected
? "outline outline-2 outline-dashed outline-black outline-offset-[-2px]"
: "";
const baseClasses = `rounded-[var(--radius-measures-radius-small)] bg-[#FFFFFF] p-4 transition-all duration-200 cursor-pointer ${borderClass} ${selectedBorder} ${className}`;
if (orientation === "horizontal") {
return (
<div
{...(cardId ? { "data-card-id": cardId } : {})}
role="button"
tabIndex={0}
aria-label={supportText ? `${label}: ${supportText}` : label}
className={baseClasses}
onClick={onClick}
onKeyDown={onKeyDown}
>
<div className="flex flex-col gap-2 items-start w-full">
<CardTag recommended={recommended} selected={selected} />
<span className="font-inter text-base font-semibold leading-6 text-black w-full">
{label}
</span>
{supportText ? (
<p className="font-inter text-sm font-normal leading-5 text-black w-full">
{supportText}
</p>
) : null}
</div>
</div>
);
}
return (
<div
{...(cardId ? { "data-card-id": cardId } : {})}
role="button"
tabIndex={0}
aria-label={supportText ? `${label}: ${supportText}` : label}
className={`${baseClasses} flex flex-row items-center justify-between gap-4`}
onClick={onClick}
onKeyDown={onKeyDown}
>
<div className="min-w-0 flex-1 flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-inter text-base font-semibold leading-6 text-black">
{label}
</span>
{showInfoIcon ? <InfoIcon /> : null}
</div>
{supportText ? (
<p className="font-inter text-sm font-normal leading-5 text-black">
{supportText}
</p>
) : null}
</div>
<div className="shrink-0">
<CardTag recommended={recommended} selected={selected} />
</div>
</div>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Card.container";
export type { CardProps } from "./Card.types";
@@ -4,7 +4,7 @@ import { memo, forwardRef } from "react";
import { useComponentId, useFormField } from "../../../hooks";
import { TextAreaView } from "./TextArea.view";
import type { TextAreaProps } from "./TextArea.types";
import { normalizeInputState, normalizeSmallMediumLargeSize, normalizeLabelVariant } from "../../../../lib/propNormalization";
import { normalizeInputState, normalizeSmallMediumLargeSize, normalizeLabelVariant, normalizeTextAreaAppearance } from "../../../../lib/propNormalization";
const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
(
@@ -27,6 +27,7 @@ const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
textHint = false,
formHeader = true,
showHelpIcon = false,
appearance: appearanceProp = "default",
...props
},
ref,
@@ -35,6 +36,7 @@ const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
const size = normalizeSmallMediumLargeSize(sizeProp);
const labelVariant = normalizeLabelVariant(labelVariantProp);
const state = normalizeInputState(stateProp);
const appearance = normalizeTextAreaAppearance(appearanceProp);
// Generate unique ID for accessibility if not provided
const { id: textareaId, labelId } = useComponentId("textarea", id);
@@ -74,11 +76,26 @@ const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
},
};
// State styles
// State styles (embedded: Figma 20736-12668 borderless, darker grey block, white text)
const getStateStyles = (): {
textarea: string;
label: string;
} => {
if (appearance === "embedded") {
if (disabled) {
return {
textarea:
"border-0 bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] cursor-not-allowed opacity-60",
label: "text-[var(--color-content-default-secondary)]",
};
}
return {
textarea:
"border-0 bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-border-default-tertiary)] focus:ring-inset",
label: "text-[var(--color-content-default-secondary)]",
};
}
if (disabled) {
return {
textarea:
@@ -138,8 +155,8 @@ const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
: `${currentSize.label} font-inter`;
const textareaClasses = `
w-full border transition-all duration-200 ease-in-out
focus:outline-none focus:ring-0 resize-none
scrollbar-design w-full transition-all duration-200 ease-in-out resize-none
${appearance === "embedded" ? "rounded-[var(--radius-300,12px)]" : "border"}
${currentSize.textarea}
${stateStyles.textarea}
${className}
@@ -180,6 +197,7 @@ const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
textHint={textHint}
formHeader={formHeader}
showHelpIcon={showHelpIcon}
appearance={appearance}
{...props}
/>
);
@@ -3,6 +3,8 @@ import type { InputStateValue } from "../../../../lib/propNormalization";
export type TextAreaSizeValue = "small" | "medium" | "large" | "Small" | "Medium" | "Large";
export type TextAreaLabelVariantValue = "default" | "horizontal" | "Default" | "Horizontal";
export type TextAreaAppearanceValue = "default" | "embedded" | "Default" | "Embedded";
export interface TextAreaProps extends Omit<
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
"size" | "onChange" | "onFocus" | "onBlur"
@@ -47,6 +49,12 @@ export interface TextAreaProps extends Omit<
* @default false
*/
showHelpIcon?: boolean;
/**
* Visual appearance. "embedded" matches Create modal sections (Figma 20736-12668):
* borderless, darker grey background, white text. "default" is standard bordered input.
* @default "default"
*/
appearance?: TextAreaAppearanceValue;
}
export interface TextAreaViewProps {
@@ -73,4 +81,5 @@ export interface TextAreaViewProps {
textHint?: boolean;
formHeader?: boolean;
showHelpIcon?: boolean;
appearance?: "default" | "embedded";
}
@@ -24,6 +24,12 @@ export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
textHint = false,
formHeader = true,
showHelpIcon = false,
appearance: _appearance,
// Component-only props: do not pass to DOM
size: _size,
labelVariant: _labelVariant,
state: _state,
error: _error,
...props
},
ref,
@@ -10,6 +10,7 @@ const CreateContainer = memo<CreateProps>(
onClose,
title,
description,
headerContent,
children,
footerContent,
showBackButton = true,
@@ -113,6 +114,7 @@ const CreateContainer = memo<CreateProps>(
onClose={onClose}
title={title}
description={description}
headerContent={headerContent}
// eslint-disable-next-line react/no-children-prop
children={children}
footerContent={footerContent}
+5 -24
View File
@@ -1,8 +1,11 @@
export interface CreateProps {
isOpen: boolean;
onClose: () => void;
/** Default header: title + description. Omit to use title/description. */
title?: string;
description?: string;
/** Custom header slot. When set, replaces title/description for full control. */
headerContent?: React.ReactNode;
children?: React.ReactNode;
footerContent?: React.ReactNode;
showBackButton?: boolean;
@@ -17,35 +20,12 @@ export interface CreateProps {
className?: string;
ariaLabel?: string;
ariaLabelledBy?: string;
/**
* Whether to enable Create block array content type (Figma prop).
* @default false
*/
/** Figma / design alignment (unused in implementation). */
createBlockArray?: boolean;
/**
* Whether to enable Text input content type (Figma prop).
* @default false
*/
textInput?: boolean;
/**
* Whether to enable Text area content type (Figma prop).
* @default false
*/
textArea?: boolean;
/**
* Whether to enable Multi-select content type (Figma prop).
* @default false
*/
multiSelect?: boolean;
/**
* Whether to enable Upload content type (Figma prop).
* @default false
*/
upload?: boolean;
/**
* Whether to enable Proportion content type (Figma prop).
* @default false
*/
proportion?: boolean;
}
@@ -54,6 +34,7 @@ export interface CreateViewProps {
onClose: () => void;
title?: string;
description?: string;
headerContent?: React.ReactNode;
children?: React.ReactNode;
footerContent?: React.ReactNode;
showBackButton: boolean;
+12 -9
View File
@@ -11,6 +11,7 @@ export function CreateView({
onClose,
title,
description,
headerContent,
children,
footerContent,
showBackButton,
@@ -40,21 +41,23 @@ export function CreateView({
aria-hidden="true"
/>
{/* Create Dialog */}
{/* Create Dialog: max-h ensures modal fits viewport; content scrolls inside */}
<div
ref={createRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={`fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-[var(--color-surface-default-primary)] rounded-[var(--radius-500,20px)] shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] w-[560px] max-h-[728px] flex flex-col overflow-hidden z-[9999] ${className}`}
className={`fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-[var(--color-surface-default-primary)] rounded-[var(--radius-500,20px)] shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] w-[560px] max-h-[90vh] flex min-h-0 flex-col overflow-hidden z-[9999] ${className}`}
>
{/* Header with close buttons */}
<ModalHeader onClose={onClose} onMoreOptions={onClose} />
{/* Header Lockup Section (Sticky) */}
{(title || description) && (
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0 sticky top-[48px] z-[2]">
{/* Header: custom headerContent (when provided) or default title/description */}
{headerContent !== undefined ? (
<div className="shrink-0">{headerContent}</div>
) : (title || description) ? (
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
<ContentLockup
title={title}
description={description}
@@ -62,14 +65,14 @@ export function CreateView({
alignment="left"
/>
</div>
)}
) : null}
{/* Content Area (Scrollable) */}
<div className="flex flex-col gap-[var(--spacing-scale-024)] px-[24px] pb-[96px] overflow-x-clip overflow-y-auto relative shrink-0 flex-1">
{/* Content Area (scrollable when content overflows) */}
<div className="scrollbar-design flex min-h-0 flex-1 flex-col gap-[var(--spacing-scale-024)] overflow-x-clip overflow-y-auto px-[24px] pb-6 pt-0">
{children}
</div>
{/* Footer */}
{/* Footer (always visible at bottom of modal) */}
<ModalFooter
showBackButton={showBackButton}
showNextButton={showNextButton}
@@ -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";