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
+73 -4
View File
@@ -1,9 +1,10 @@
"use client";
import { useState } from "react";
import RuleCard from "../components/cards/RuleCard";
import Chip from "../components/controls/Chip";
import MultiSelect from "../components/controls/MultiSelect";
import RuleCard from "../../components/cards/RuleCard";
import Card from "../../components/cards/Card";
import Chip from "../../components/controls/Chip";
import MultiSelect from "../../components/controls/MultiSelect";
import Image from "next/image";
import { getAssetPath } from "../../../lib/assetUtils";
@@ -466,7 +467,7 @@ export default function ComponentsPreview() {
Component Preview
</h1>
<p className="font-inter text-[18px] leading-[24px] text-[var(--color-content-default-secondary)]">
RuleCard and Chip component examples - states, palettes, sizes, and interactions
RuleCard, Card, and Chip component examples - states, palettes, sizes, and interactions
</p>
</header>
@@ -633,6 +634,74 @@ export default function ComponentsPreview() {
</div>
</section>
{/* Card Component - Create flow selection card variants */}
<section className="space-y-[var(--spacing-scale-024)]">
<h2 className="font-bricolage-grotesque text-[32px] leading-[40px] font-bold text-[var(--color-content-default-primary)]">
Card Component
</h2>
<div className="bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-300,12px)] p-[var(--spacing-scale-032)] space-y-[var(--spacing-scale-024)]">
<p className="font-inter text-[18px] leading-[24px] text-[var(--color-content-default-secondary)]">
Horizontal and vertical orientations with recommended and selected states.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl">
<div className="space-y-2">
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)]">
Horizontal + Recommended
</h3>
<Card
label="Label"
supportText="Members vote to resolve a dispute democratically."
recommended={true}
selected={false}
orientation="horizontal"
onClick={() => console.log("Card clicked")}
/>
</div>
<div className="space-y-2">
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)]">
Horizontal + Selected
</h3>
<Card
label="Label"
supportText="Members vote to resolve a dispute democratically."
recommended={false}
selected={true}
orientation="horizontal"
onClick={() => console.log("Card clicked")}
/>
</div>
<div className="space-y-2">
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)]">
Vertical + Recommended
</h3>
<Card
label="Label"
supportText="Invite-only"
recommended={true}
selected={false}
orientation="vertical"
showInfoIcon={true}
onClick={() => console.log("Card clicked")}
/>
</div>
<div className="space-y-2">
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)]">
Vertical + Selected
</h3>
<Card
label="Label"
supportText="Invite-only"
recommended={false}
selected={true}
orientation="vertical"
showInfoIcon={true}
onClick={() => console.log("Card clicked")}
/>
</div>
</div>
</div>
</section>
{/* Collapsed State - Large */}
<section className="space-y-[var(--spacing-scale-024)]">
<h2 className="font-bricolage-grotesque text-[32px] leading-[40px] font-bold text-[var(--color-content-default-primary)]">
@@ -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";
+1 -2
View File
@@ -17,8 +17,7 @@ const VALID_STEPS: CreateFlowStep[] = [
"select",
"upload",
"review",
"compact-cards",
"expanded-cards",
"cards",
"right-rail",
"final-review",
"completed",
+299
View File
@@ -0,0 +1,299 @@
"use client";
import { useState, useCallback } from "react";
import HeaderLockup from "../../components/type/HeaderLockup";
import CardStack from "../../components/utility/CardStack";
import Create from "../../components/modals/Create";
import TextArea from "../../components/controls/TextArea";
const COMPACT_TITLE = "How should this community communicate with each-other?";
const COMPACT_DESCRIPTION =
"You can select multiple methods for different needs or add your own";
const EXPANDED_TITLE =
"What method should this community use to communicate with eachother?";
const EXPANDED_DESCRIPTION = COMPACT_DESCRIPTION;
/** Create is a shell; which variant shows is determined by which card was clicked; we pass different props and children by pendingCardId. */
/** Card ids for "Add platform" Create modal variants. */
const IN_PERSON_CARD_ID = "in-person-meetings";
const SIGNAL_CARD_ID = "signal";
const VIDEO_MEETINGS_CARD_ID = "video-meetings";
/** Copy for the default confirm modal (nonadd-platform cards). */
const CONFIRM_MODAL = {
title: "Confirm selection",
description: "Confirm to select this option.",
nextButtonText: "Confirm",
showBackButton: false,
currentStep: undefined,
totalSteps: undefined,
} as const;
/**
* "Add platform" variants share the same header pattern and "Add Platform" button.
* Each has its own title, description, and body (three TextArea sections).
*/
const ADD_PLATFORM_MODALS: Record<
string,
{ title: string; description: string; nextButtonText: string }
> = {
[IN_PERSON_CARD_ID]: {
title: "In-Person Meetings",
description:
"Physical gatherings for high-bandwidth communication and relationship building.",
nextButtonText: "Add Platform",
},
[SIGNAL_CARD_ID]: {
title: "Signal",
description:
"End-to-end encrypted messaging ideal for small, security-minded groups",
nextButtonText: "Add Platform",
},
[VIDEO_MEETINGS_CARD_ID]: {
title: "Video Meetings",
description:
"Synchronous video calls for remote face-to-face interaction.",
nextButtonText: "Add Platform",
},
};
const SECTION_KEYS = [
"Core Principle & Scope",
"Logistics, Admin & Norms",
"Code of Conduct",
] as const;
type SectionKey = (typeof SECTION_KEYS)[number];
/** Default section text per platform (Figma 20647-18271, 20647-18273, 20736-12668). */
const ADD_PLATFORM_SECTION_DEFAULTS: Record<
string,
Record<SectionKey, string>
> = {
[IN_PERSON_CARD_ID]: {
"Core Principle & Scope": `We value the highest bandwidth of communication, physical presence, to build trust that digital tools cannot match. Consequently, we reserve this high-trust space for annual retreats, strategic planning, and high-stakes interpersonal repair where body language is essential.`,
"Logistics, Admin & Norms": `Logistics focus on physical accessibility, venue security, and travel equity. Organizers control entry via keys or door staff. Culturally, participants are expected to maintain mission focus and adhere strictly to the itinerary to respect everyone's time. Side conversations or distracting behaviors that derail the agenda are discouraged.`,
"Code of Conduct": `We aspire to operate within these principles. We don't need to see eye to eye on everything, but we believe the world can be improved by collective action. Aspire to do no harm to members of this community. Violence or physical intimidation will not be tolerated. We have a zero-tolerance policy for racism, sexism, and bigotry.`,
},
[SIGNAL_CARD_ID]: {
"Core Principle & Scope": `We use Signal for all operational communication. To keep our workspace organized, official channels are prepended with an emoji (e.g., 🤓). Public channels are open to all volunteers, while Core Channels are restricted to coordinators. All Core Members are designated as admins to share the technical workload.`,
"Logistics, Admin & Norms": `We encourage direct messages to build friendship, but all operational logistics must happen in group channels. To respect everyone's time, use "Emoji Reactions" (👍, ♥️) to acknowledge messages rather than typing "thanks," which triggers notifications for everyone. Text is a poor medium for nuance: if a conversation needs more context, move it to a call or in person.`,
"Code of Conduct": `This space relies on collective responsibility. Posting content that attracts unwanted legal attention or exposes members' real-world identities without consent is prohibited. We aspire to do no harm by practicing strict operational security. Intentionally leaking information violates our safety. We have a zero-tolerance policy for harassment or abuse.`,
},
[VIDEO_MEETINGS_CARD_ID]: {
"Core Principle & Scope": `We prioritize synchronous connection to read facial expressions without the barrier of travel, using this tool for weekly syncs and quick consensus checks that benefit from real-time debate before moving to a vote.`,
"Logistics, Admin & Norms": `The host manages technical security via waiting rooms to prevent intrusion. Culturally, the focus is on maximizing the value of synchronous time. Norms include muting when not speaking, using the "Raise Hand" feature to queue, and utilizing the chat box for non-interruptive side comments. Distractions should be minimized.`,
"Code of Conduct": `We have a zero-tolerance policy for racism, sexism, and bigotry, whether spoken or shared in the chat. We aspire to do no harm. "Zoom-bombing" or broadcasting graphic content is prohibited. Willfully spreading obviously false information will not be tolerated. Do not discuss sensitive data that could attract legal or security risk.`,
},
};
/**
* Section with heading + info icon and an editable TextArea.
* This variant uses TextArea only (no TextInput); design is "Add Signal" / "Add Video Meetings".
*/
function CreateModalSection({
title,
value,
onChange,
}: {
title: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold leading-tight text-[var(--color-content-default-primary)]">
{title}
</h3>
<span
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-[var(--color-content-invert-brand-secondary)] bg-transparent text-[10px] font-medium leading-none text-[var(--color-content-invert-brand-secondary)]"
aria-hidden
>
?
</span>
</div>
<TextArea
formHeader={false}
value={value}
onChange={(e) => onChange(e.target.value)}
size="large"
rows={6}
appearance="embedded"
/>
</div>
);
}
/** Body for any "Add platform" modal: three editable sections (TextArea only). */
function AddPlatformModalContent({ platformCardId }: { platformCardId: string }) {
const defaults = ADD_PLATFORM_SECTION_DEFAULTS[platformCardId];
const [sectionValues, setSectionValues] = useState<Record<SectionKey, string>>(
defaults ?? {
"Core Principle & Scope": "",
"Logistics, Admin & Norms": "",
"Code of Conduct": "",
},
);
const updateSection = useCallback((key: SectionKey, value: string) => {
setSectionValues((prev) => ({ ...prev, [key]: value }));
}, []);
if (!defaults) return null;
return (
<div className="flex flex-col gap-6">
{SECTION_KEYS.map((key) => (
<CreateModalSection
key={key}
title={key}
value={sectionValues[key]}
onChange={(v) => updateSection(key, v)}
/>
))}
</div>
);
}
/** Communication method cards (Figma 20246-15828). First three are recommended. */
const SAMPLE_CARDS = [
{
id: IN_PERSON_CARD_ID,
label: "In-Person Meetings",
supportText:
"Physical gatherings for high-bandwidth communication and relationship building.",
recommended: true,
},
{
id: SIGNAL_CARD_ID,
label: "Signal",
supportText:
"Encrypted messaging for high-security, private coordination.",
recommended: true,
},
{
id: VIDEO_MEETINGS_CARD_ID,
label: "Video Meetings",
supportText:
"Synchronous video calls for remote face-to-face interaction.",
recommended: true,
},
{
id: "4",
label: "Label",
supportText:
"Collaborative work to reach a resolution that all parties can agree upon.",
recommended: true,
},
{
id: "5",
label: "Label",
supportText:
"Structured sessions where parties collaboratively resolve disputes.",
recommended: true,
},
{
id: "6",
label: "Label",
supportText: "Members vote to resolve a dispute democratically.",
recommended: true,
},
{
id: "7",
label: "Label",
supportText: "Invite-only",
recommended: true,
},
];
/** Whether this card id uses the "Add platform" modal (shared header, platform-specific body). */
function isAddPlatformCard(cardId: string | null): cardId is string {
return cardId !== null && cardId in ADD_PLATFORM_MODALS;
}
/** Resolve Create modal header/buttons: Add platform variant or default confirm. */
function getCreateModalConfig(pendingCardId: string | null) {
if (isAddPlatformCard(pendingCardId)) {
return {
...ADD_PLATFORM_MODALS[pendingCardId],
showBackButton: false,
currentStep: undefined,
totalSteps: undefined,
};
}
return CONFIRM_MODAL;
}
/** Create flow card stack step: compact grid with optional expand to full list. */
export default function CardsPage() {
const [expanded, setExpanded] = useState(false);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
const title = expanded ? EXPANDED_TITLE : COMPACT_TITLE;
const description = expanded ? EXPANDED_DESCRIPTION : COMPACT_DESCRIPTION;
const modalConfig = getCreateModalConfig(pendingCardId);
const handleCardClick = useCallback((id: string) => {
setPendingCardId(id);
setCreateModalOpen(true);
}, []);
const handleCreateModalClose = useCallback(() => {
setCreateModalOpen(false);
setPendingCardId(null);
}, []);
const handleCreateModalConfirm = useCallback(() => {
if (pendingCardId) {
setSelectedIds((prev) =>
prev.includes(pendingCardId) ? prev : [...prev, pendingCardId],
);
}
setCreateModalOpen(false);
setPendingCardId(null);
}, [pendingCardId]);
return (
<div className="w-full max-w-[1280px] shrink-0 px-5 md:px-16">
<div className="flex w-full flex-col gap-6 min-w-0">
<div className="min-w-0">
<HeaderLockup
title={title}
description={description}
justification="center"
size="L"
/>
</div>
<div className="min-w-0 w-full">
<CardStack
cards={SAMPLE_CARDS}
selectedIds={selectedIds}
onCardSelect={handleCardClick}
expanded={expanded}
onToggleExpand={() => setExpanded((prev) => !prev)}
hasMore={true}
/>
</div>
</div>
<Create
isOpen={createModalOpen}
onClose={handleCreateModalClose}
onNext={handleCreateModalConfirm}
title={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={modalConfig.showBackButton}
currentStep={modalConfig.currentStep}
totalSteps={modalConfig.totalSteps}
>
{isAddPlatformCard(pendingCardId) ? (
<AddPlatformModalContent platformCardId={pendingCardId} />
) : null}
</Create>
</div>
);
}
+1 -2
View File
@@ -29,8 +29,7 @@ function CreateFlowLayoutContent({ children }: { children: ReactNode }) {
"select",
"upload",
"review",
"compact-cards",
"expanded-cards",
"cards",
"right-rail",
"final-review",
"completed",
+1 -1
View File
@@ -3,7 +3,7 @@
import HeaderLockup from "../../components/type/HeaderLockup";
import RuleCard from "../../components/cards/RuleCard";
/** Mid-flow review step (after upload, before compact-cards). */
/** Mid-flow review step (after upload, before cards). */
export default function ReviewPage() {
return (
<div className="w-full max-w-[1280px] shrink-0 px-5 md:px-16">
+1 -2
View File
@@ -14,8 +14,7 @@ export type CreateFlowStep =
| "select"
| "upload"
| "review"
| "compact-cards"
| "expanded-cards"
| "cards"
| "right-rail"
| "final-review"
| "completed";
+30
View File
@@ -15,6 +15,36 @@
display: none; /* Safari and Chrome */
}
/* Design system scrollbar (Figma node 20612-36521): dark track + thumb with states */
.scrollbar-design {
scrollbar-width: thin; /* Firefox: narrow scrollbar */
scrollbar-color: #545B64 #292D32; /* Firefox: thumb track */
}
.scrollbar-design::-webkit-scrollbar {
width: 16px;
height: 16px;
}
.scrollbar-design::-webkit-scrollbar-track {
background: #292D32;
}
.scrollbar-design::-webkit-scrollbar-thumb {
background: #545B64;
border-radius: 4px;
border: 4px solid #292D32; /* visual padding: thumb appears 8px within 16px track */
background-clip: padding-box;
}
.scrollbar-design::-webkit-scrollbar-thumb:hover {
background: #787F8A;
border-width: 2px; /* hover: thumb expands to 12px */
}
.scrollbar-design::-webkit-scrollbar-thumb:active {
background: #3F434C;
border-width: 2px;
}
.scrollbar-design::-webkit-scrollbar-corner {
background: #292D32;
}
@theme inline {
/* Custom breakpoints */
--breakpoint-xsm: 429px;
+12
View File
@@ -502,6 +502,18 @@ export function normalizeLabelVariant(
return defaultValue;
}
/**
* Normalize TextArea appearance prop (default/embedded; Figma: Default/Embedded).
*/
export function normalizeTextAreaAppearance(
value: string | undefined,
defaultValue: "default" = "default"
): "default" | "embedded" {
if (!value) return defaultValue;
const n = value.toLowerCase();
return n === "embedded" ? "embedded" : "default";
}
/**
* Normalize small/medium/large size prop values (for SelectInput, TextArea, etc.)
*/
+82
View File
@@ -0,0 +1,82 @@
{
"_comment": "Create flow communication step: page, cards, and add-platform modals",
"page": {
"compactTitle": "How should this community communicate with each-other?",
"compactDescription": "You can select multiple methods for different needs or add your own",
"expandedTitle": "What method should this community use to communicate with eachother?",
"expandedDescription": "You can select multiple methods for different needs or add your own",
"seeAllLink": "See all communication approaches"
},
"confirmModal": {
"title": "Confirm selection",
"description": "Confirm to select this option.",
"nextButtonText": "Confirm"
},
"addPlatform": {
"nextButtonText": "Add Platform"
},
"sectionHeadings": {
"corePrinciple": "Core Principle & Scope",
"logisticsAdmin": "Logistics, Admin & Norms",
"codeOfConduct": "Code of Conduct"
},
"cards": {
"in-person-meetings": {
"label": "In-Person Meetings",
"supportText": "Physical gatherings for high-bandwidth communication and relationship building."
},
"signal": {
"label": "Signal",
"supportText": "Encrypted messaging for high-security, private coordination."
},
"video-meetings": {
"label": "Video Meetings",
"supportText": "Synchronous video calls for remote face-to-face interaction."
},
"4": {
"label": "Label",
"supportText": "Collaborative work to reach a resolution that all parties can agree upon."
},
"5": {
"label": "Label",
"supportText": "Structured sessions where parties collaboratively resolve disputes."
},
"6": {
"label": "Label",
"supportText": "Members vote to resolve a dispute democratically."
},
"7": {
"label": "Label",
"supportText": "Invite-only"
}
},
"modals": {
"in-person-meetings": {
"title": "In-Person Meetings",
"description": "Physical gatherings for high-bandwidth communication and relationship building.",
"sections": {
"corePrinciple": "We value the highest bandwidth of communication, physical presence, to build trust that digital tools cannot match. Consequently, we reserve this high-trust space for annual retreats, strategic planning, and high-stakes interpersonal repair where body language is essential.",
"logisticsAdmin": "Logistics focus on physical accessibility, venue security, and travel equity. Organizers control entry via keys or door staff. Culturally, participants are expected to maintain mission focus and adhere strictly to the itinerary to respect everyone's time. Side conversations or distracting behaviors that derail the agenda are discouraged.",
"codeOfConduct": "We aspire to operate within these principles. We don't need to see eye to eye on everything, but we believe the world can be improved by collective action. Aspire to do no harm to members of this community. Violence or physical intimidation will not be tolerated. We have a zero-tolerance policy for racism, sexism, and bigotry."
}
},
"signal": {
"title": "Signal",
"description": "End-to-end encrypted messaging ideal for small, security-minded groups",
"sections": {
"corePrinciple": "We use Signal for all operational communication. To keep our workspace organized, official channels are prepended with an emoji (e.g., 🤓). Public channels are open to all volunteers, while Core Channels are restricted to coordinators. All Core Members are designated as admins to share the technical workload.",
"logisticsAdmin": "We encourage direct messages to build friendship, but all operational logistics must happen in group channels. To respect everyone's time, use \"Emoji Reactions\" (👍, ♥️) to acknowledge messages rather than typing \"thanks,\" which triggers notifications for everyone. Text is a poor medium for nuance: if a conversation needs more context, move it to a call or in person.",
"codeOfConduct": "This space relies on collective responsibility. Posting content that attracts unwanted legal attention or exposes members' real-world identities without consent is prohibited. We aspire to do no harm by practicing strict operational security. Intentionally leaking information violates our safety. We have a zero-tolerance policy for harassment or abuse."
}
},
"video-meetings": {
"title": "Video Meetings",
"description": "Synchronous video calls for remote face-to-face interaction.",
"sections": {
"corePrinciple": "We prioritize synchronous connection to read facial expressions without the barrier of travel, using this tool for weekly syncs and quick consensus checks that benefit from real-time debate before moving to a vote.",
"logisticsAdmin": "The host manages technical security via waiting rooms to prevent intrusion. Culturally, the focus is on maximizing the value of synchronous time. Norms include muting when not speaking, using the \"Raise Hand\" feature to queue, and utilizing the chat box for non-interruptive side comments. Distractions should be minimized.",
"codeOfConduct": "We have a zero-tolerance policy for racism, sexism, and bigotry, whether spoken or shared in the chat. We aspire to do no harm. \"Zoom-bombing\" or broadcasting graphic content is prohibited. Willfully spreading obviously false information will not be tolerated. Do not discuss sensitive data that could attract legal or security risk."
}
}
}
}
+4
View File
@@ -15,6 +15,7 @@ import home from "./pages/home.json";
import learn from "./pages/learn.json";
import navigation from "./navigation.json";
import metadata from "./metadata.json";
import communication from "./create/communication.json";
export default {
common,
@@ -34,6 +35,9 @@ export default {
home,
learn,
},
create: {
communication,
},
navigation,
metadata,
};
+165
View File
@@ -0,0 +1,165 @@
import Card from "../../app/components/cards/Card";
export default {
title: "Components/Cards/Card",
component: Card,
parameters: {
layout: "centered",
docs: {
description: {
component:
"Create flow selection card with support text, recommended/selected states, and horizontal or vertical orientation. Use for communication approaches and similar choices.",
},
},
},
argTypes: {
label: {
control: { type: "text" },
description: "Primary label text",
},
supportText: {
control: { type: "text" },
description: "Supporting description below the label",
},
recommended: {
control: { type: "boolean" },
description: "Show yellow RECOMMENDED pill",
},
selected: {
control: { type: "boolean" },
description: "Show black SELECTED pill and dotted border",
},
orientation: {
control: { type: "select" },
options: ["horizontal", "vertical"],
description: "Layout orientation",
},
showInfoIcon: {
control: { type: "boolean" },
description: "Show info icon next to label (typically in vertical)",
},
onClick: { action: "clicked" },
},
tags: ["autodocs"],
};
export const Default = {
args: {
label: "Label",
supportText:
"Members vote to resolve a dispute democratically.",
recommended: true,
selected: false,
orientation: "horizontal",
showInfoIcon: false,
},
};
export const HorizontalRecommended = {
args: {
label: "Label",
supportText:
"Collaborative work to reach a resolution that all parties can agree upon.",
recommended: true,
selected: false,
orientation: "horizontal",
},
};
export const HorizontalSelected = {
args: {
label: "Label",
supportText:
"Members vote to resolve a dispute democratically.",
recommended: false,
selected: true,
orientation: "horizontal",
},
};
export const VerticalRecommended = {
args: {
label: "Label",
supportText: "Invite-only",
recommended: true,
selected: false,
orientation: "vertical",
showInfoIcon: true,
},
};
export const VerticalSelected = {
args: {
label: "Label",
supportText: "Invite-only",
recommended: false,
selected: true,
orientation: "vertical",
showInfoIcon: true,
},
};
export const AllVariants = {
render: () => (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl">
<div className="space-y-2">
<p className="font-inter text-sm font-medium text-gray-600">
Horizontal + Recommended
</p>
<Card
label="Label"
supportText="Members vote to resolve a dispute democratically."
recommended={true}
selected={false}
orientation="horizontal"
/>
</div>
<div className="space-y-2">
<p className="font-inter text-sm font-medium text-gray-600">
Horizontal + Selected
</p>
<Card
label="Label"
supportText="Members vote to resolve a dispute democratically."
recommended={false}
selected={true}
orientation="horizontal"
/>
</div>
<div className="space-y-2">
<p className="font-inter text-sm font-medium text-gray-600">
Vertical + Recommended
</p>
<Card
label="Label"
supportText="Invite-only"
recommended={true}
selected={false}
orientation="vertical"
showInfoIcon={true}
/>
</div>
<div className="space-y-2">
<p className="font-inter text-sm font-medium text-gray-600">
Vertical + Selected
</p>
<Card
label="Label"
supportText="Invite-only"
recommended={false}
selected={true}
orientation="vertical"
showInfoIcon={true}
/>
</div>
</div>
),
parameters: {
docs: {
description: {
story:
"All four variants: horizontal/vertical × recommended/selected.",
},
},
},
};
+10
View File
@@ -70,6 +70,16 @@ Large.args = {
value: "",
};
export const Embedded = Template.bind({});
Embedded.args = {
label: "Section content",
placeholder: "Enter text...",
value: "Embedded appearance used in create-flow modals: borderless, darker grey block.",
appearance: "embedded",
size: "large",
rows: 4,
};
export const HorizontalLabel = Template.bind({});
HorizontalLabel.args = {
labelVariant: "horizontal",
+139
View File
@@ -0,0 +1,139 @@
import CardStack from "../../app/components/utility/CardStack";
const SAMPLE_CARDS = [
{
id: "1",
label: "Label",
supportText:
"Collaborative work to reach a resolution that all parties can agree upon.",
recommended: true,
},
{
id: "2",
label: "Label",
supportText:
"Structured sessions where parties collaboratively resolve disputes.",
recommended: true,
},
{
id: "3",
label: "Label",
supportText: "Members vote to resolve a dispute democratically.",
recommended: true,
},
{
id: "4",
label: "Label",
supportText: "Arbitrators are chosen specifically for a particular case.",
recommended: true,
},
{
id: "5",
label: "Label",
supportText:
"Encouraging direct, respectful dialogue between those involved.",
recommended: true,
},
{
id: "6",
label: "Label",
supportText: "Invite-only",
recommended: true,
},
];
export default {
title: "Create Flow/CardStack",
component: CardStack,
parameters: {
layout: "centered",
docs: {
description: {
component:
"Card stack for the create flow: compact grid or expanded list with toggle. Uses Card components; toggle visible only when hasMore is true.",
},
},
},
argTypes: {
expanded: {
control: { type: "boolean" },
description: "Expanded (list) vs compact (grid) mode",
},
hasMore: {
control: { type: "boolean" },
description: "Whether to show the See all / Show less toggle",
},
},
tags: ["autodocs"],
};
export const Default = {
args: {
cards: SAMPLE_CARDS,
hasMore: true,
title: "How should this community communicate with each-other?",
description:
"You can select multiple methods for different needs or add your own",
},
parameters: {
docs: {
description: {
story: "Compact grid with sample cards and See all toggle.",
},
},
},
};
export const Expanded = {
args: {
cards: SAMPLE_CARDS,
expanded: true,
hasMore: true,
title:
"What method should this community use to communicate with eachother?",
description:
"You can select multiple methods for different needs or add your own",
},
parameters: {
docs: {
description: {
story: "Expanded list layout with vertical cards and Show less toggle.",
},
},
},
};
export const WithSelection = {
args: {
cards: SAMPLE_CARDS,
selectedId: "2",
hasMore: true,
title: "How should this community communicate with each-other?",
description:
"You can select multiple methods for different needs or add your own",
},
parameters: {
docs: {
description: {
story: "Second card is selected; click cards to change selection.",
},
},
},
};
export const NoToggle = {
args: {
cards: SAMPLE_CARDS.slice(0, 3),
hasMore: false,
title: "How should this community communicate with each-other?",
description:
"You can select multiple methods for different needs or add your own",
},
parameters: {
docs: {
description: {
story: "When hasMore is false, the See all toggle is hidden.",
},
},
},
};
+16
View File
@@ -132,6 +132,22 @@ Step3.args = {
totalSteps: 3,
};
export const WithCustomHeader = Template.bind({});
WithCustomHeader.args = {
isOpen: true,
headerContent: <div className="text-lg font-semibold">Custom header</div>,
children: (
<div className="space-y-4">
<p className="text-[var(--color-content-default-primary)]">
When headerContent is provided, the default title and description are not shown.
</p>
</div>
),
showBackButton: false,
showNextButton: true,
nextButtonText: "Continue",
};
export const WithoutFooter = Template.bind({});
WithoutFooter.args = {
isOpen: true,
+95
View File
@@ -0,0 +1,95 @@
import Scrollbar from "../../app/components/utility/Scrollbar";
const tallContent = (
<div style={{ height: 400 }}>
<p>Line 1</p>
<p>Line 2</p>
<p>Line 3</p>
<p>Line 4</p>
<p>Line 5</p>
<p>Line 6</p>
<p>Line 7</p>
<p>Line 8</p>
<p>Line 9</p>
<p>Line 10</p>
</div>
);
const wideContent = (
<div style={{ display: "flex", width: 800, gap: 16 }}>
<span>Item A</span>
<span>Item B</span>
<span>Item C</span>
<span>Item D</span>
<span>Item E</span>
</div>
);
export default {
title: "Components/Utility/Scrollbar",
component: Scrollbar,
parameters: {
layout: "centered",
docs: {
description: {
component:
"A scrollable container that applies the design system scrollbar styling. Supports vertical, horizontal, or both overflow.",
},
},
},
argTypes: {
orientation: {
control: { type: "select" },
options: ["vertical", "horizontal", "both"],
description: "Scroll direction",
},
},
};
export const Default = {
args: {
children: tallContent,
orientation: "vertical",
},
decorators: [
(Story) => (
<div style={{ width: 300, maxHeight: 200, border: "1px solid #ccc" }}>
<Story />
</div>
),
],
};
export const Horizontal = {
args: {
children: wideContent,
orientation: "horizontal",
},
decorators: [
(Story) => (
<div style={{ width: 300, overflow: "hidden" }}>
<Story />
</div>
),
],
};
export const Both = {
args: {
children: (
<div style={{ width: 400, height: 400 }}>
<div style={{ width: 500, height: 500, padding: 8 }}>
Scroll both directions. Content is larger than the container.
</div>
</div>
),
orientation: "both",
},
decorators: [
(Story) => (
<div style={{ width: 300, height: 200, border: "1px solid #ccc" }}>
<Story />
</div>
),
],
};
+45
View File
@@ -0,0 +1,45 @@
import Tag from "../../app/components/utility/Tag";
export default {
title: "Components/Utility/Tag",
component: Tag,
parameters: {
layout: "centered",
docs: {
description: {
component:
"Small status tag with recommended (yellow) or selected (dark) variant. Default labels are RECOMMENDED and SELECTED; pass children for custom text.",
},
},
},
argTypes: {
variant: {
control: { type: "select" },
options: ["recommended", "selected"],
description: "Visual variant",
},
children: {
control: { type: "text" },
description: "Custom label (omit to use default RECOMMENDED/SELECTED)",
},
},
};
export const Recommended = {
args: {
variant: "recommended",
},
};
export const Selected = {
args: {
variant: "selected",
},
};
export const CustomLabel = {
args: {
variant: "recommended",
children: "Custom label",
},
};
+14
View File
@@ -133,6 +133,20 @@ describe("Create", () => {
expect(stepper).toHaveAttribute("aria-valuemax", "5");
});
it("renders custom header when headerContent is provided", () => {
renderWithProviders(
<Create
{...defaultProps}
title="Default Title"
description="Default description"
headerContent={<span>Custom header</span>}
/>,
);
expect(screen.getByText("Custom header")).toBeInTheDocument();
expect(screen.queryByText("Default Title")).not.toBeInTheDocument();
expect(screen.queryByText("Default description")).not.toBeInTheDocument();
});
it("renders custom footer content", () => {
renderWithProviders(
<Create
+15
View File
@@ -1,6 +1,10 @@
import React from "react";
import { describe, it, expect } from "vitest";
import { screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import TextArea from "../../app/components/controls/TextArea";
import { componentTestSuite } from "../utils/componentTestSuite";
import { renderWithProviders } from "../utils/test-utils";
type TextAreaProps = React.ComponentProps<typeof TextArea>;
@@ -28,3 +32,14 @@ componentTestSuite<TextAreaProps>({
errorProps: { error: true },
},
});
describe("TextArea appearance", () => {
it("renders with appearance embedded and applies borderless styling", () => {
renderWithProviders(
<TextArea label="Notes" value="Some text" appearance="embedded" />,
);
const textarea = screen.getByRole("textbox", { name: /notes/i });
expect(textarea).toBeInTheDocument();
expect(textarea).toHaveClass("border-0");
});
});
+68
View File
@@ -0,0 +1,68 @@
import {
renderWithProviders as render,
screen,
cleanup,
within,
} from "../utils/test-utils";
import userEvent from "@testing-library/user-event";
import { describe, test, expect, afterEach } from "vitest";
import CardsPage from "../../app/create/cards/page";
afterEach(() => {
cleanup();
});
describe("Create flow cards page", () => {
test("clicking a card opens the Create modal", async () => {
const user = userEvent.setup();
render(<CardsPage />);
const signalCards = screen.getAllByRole("button", {
name: /Signal: Encrypted messaging/,
});
await user.click(signalCards[0]);
const dialog = screen.getByRole("dialog");
expect(dialog).toBeInTheDocument();
expect(within(dialog).getByText("Signal")).toBeInTheDocument();
expect(within(dialog).getByText("Add Platform")).toBeInTheDocument();
});
test("renders without error", () => {
render(<CardsPage />);
expect(
screen.getByText("How should this community communicate with each-other?"),
).toBeInTheDocument();
});
test("renders HeaderLockup and CardStack content", () => {
render(<CardsPage />);
expect(
screen.getByText(
"You can select multiple methods for different needs or add your own",
),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "See all communication approaches" }),
).toBeInTheDocument();
});
test("toggle expands and shows Show less", async () => {
const user = userEvent.setup();
render(<CardsPage />);
const toggle = screen.getByRole("button", {
name: "See all communication approaches",
});
await user.click(toggle);
expect(screen.getByRole("button", { name: "Show less" })).toBeInTheDocument();
expect(
screen.getByText(
"What method should this community use to communicate with eachother?",
),
).toBeInTheDocument();
});
});
+105
View File
@@ -0,0 +1,105 @@
import {
renderWithProviders as render,
screen,
fireEvent,
} from "../utils/test-utils";
import { describe, it, expect, vi } from "vitest";
import Card from "../../app/components/cards/Card";
describe("Card Component", () => {
const defaultProps = {
label: "Label",
supportText: "Support text here",
orientation: "horizontal",
};
it("renders label and supportText", () => {
render(<Card {...defaultProps} />);
expect(screen.getByText("Label")).toBeInTheDocument();
expect(screen.getByText("Support text here")).toBeInTheDocument();
});
it("renders RECOMMENDED pill when recommended is true", () => {
render(<Card {...defaultProps} recommended={true} />);
expect(screen.getByText("RECOMMENDED")).toBeInTheDocument();
});
it("does not render RECOMMENDED pill when recommended is false", () => {
render(<Card {...defaultProps} recommended={false} />);
expect(screen.queryByText("RECOMMENDED")).not.toBeInTheDocument();
});
it("renders SELECTED pill and inset dashed outline when selected is true", () => {
render(<Card {...defaultProps} selected={true} />);
expect(screen.getByText("SELECTED")).toBeInTheDocument();
const card = screen.getByRole("button");
expect(card).toHaveClass("outline-dashed");
});
it("applies horizontal layout by default", () => {
render(<Card {...defaultProps} />);
expect(screen.getByText("Label")).toBeInTheDocument();
expect(screen.getByText("Support text here")).toBeInTheDocument();
});
it("applies vertical layout when orientation is vertical", () => {
render(<Card {...defaultProps} orientation="vertical" />);
const card = screen.getByRole("button");
expect(card).toHaveClass("flex-row");
});
it("handles click events", () => {
const handleClick = vi.fn();
render(<Card {...defaultProps} onClick={handleClick} />);
const card = screen.getByRole("button");
fireEvent.click(card);
expect(handleClick).toHaveBeenCalledTimes(1);
});
it("handles keyboard events", () => {
const handleClick = vi.fn();
render(<Card {...defaultProps} onClick={handleClick} />);
const card = screen.getByRole("button");
fireEvent.keyDown(card, { key: "Enter" });
expect(handleClick).toHaveBeenCalledTimes(1);
fireEvent.keyDown(card, { key: " " });
expect(handleClick).toHaveBeenCalledTimes(2);
});
it("renders with custom className", () => {
const customClass = "custom-card";
render(<Card {...defaultProps} className={customClass} />);
const card = screen.getByRole("button");
expect(card).toHaveClass(customClass);
});
it("renders with proper accessibility attributes", () => {
render(<Card {...defaultProps} />);
const card = screen.getByRole("button");
expect(card).toHaveAttribute(
"aria-label",
"Label: Support text here",
);
expect(card).toHaveAttribute("tabIndex", "0");
});
it("renders without supportText", () => {
render(<Card label="Label only" orientation="horizontal" />);
expect(screen.getByText("Label only")).toBeInTheDocument();
expect(screen.getByRole("button")).toHaveAttribute("aria-label", "Label only");
});
});
+101
View File
@@ -0,0 +1,101 @@
import {
renderWithProviders as render,
screen,
cleanup,
fireEvent,
} from "../utils/test-utils";
import userEvent from "@testing-library/user-event";
import { vi, describe, test, expect, afterEach } from "vitest";
import CardStack from "../../app/components/utility/CardStack";
const SAMPLE_CARDS = [
{ id: "1", label: "Option A", supportText: "Description A", recommended: true },
{ id: "2", label: "Option B", supportText: "Description B", recommended: false },
{ id: "3", label: "Option C", supportText: "Description C", recommended: true },
];
afterEach(() => {
cleanup();
});
describe("CardStack Component", () => {
test("renders header when title is provided", () => {
render(
<CardStack
cards={SAMPLE_CARDS}
title="How should this community communicate?"
description="Pick one or more."
/>,
);
expect(
screen.getByText("How should this community communicate?"),
).toBeInTheDocument();
expect(screen.getByText("Pick one or more.")).toBeInTheDocument();
});
test("renders up to 5 recommended cards in compact (grid) mode", () => {
render(<CardStack cards={SAMPLE_CARDS} expanded={false} />);
expect(screen.getAllByText("Option A").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Option C").length).toBeGreaterThanOrEqual(1);
expect(screen.queryByText("Option B")).not.toBeInTheDocument();
});
test("renders all cards in expanded (list) mode", () => {
render(<CardStack cards={SAMPLE_CARDS} expanded={true} />);
expect(screen.getByText("Option A")).toBeInTheDocument();
expect(screen.getByText("Option B")).toBeInTheDocument();
expect(screen.getByText("Option C")).toBeInTheDocument();
});
test("shows See all toggle when hasMore is true", () => {
render(<CardStack cards={SAMPLE_CARDS} hasMore={true} />);
expect(
screen.getByRole("button", { name: "See all communication approaches" }),
).toBeInTheDocument();
});
test("does not show toggle when hasMore is false", () => {
render(<CardStack cards={SAMPLE_CARDS} hasMore={false} />);
expect(
screen.queryByRole("button", { name: "See all communication approaches" }),
).not.toBeInTheDocument();
});
test("toggle expands when clicked", async () => {
const user = userEvent.setup();
render(<CardStack cards={SAMPLE_CARDS} hasMore={true} />);
const toggle = screen.getByRole("button", {
name: "See all communication approaches",
});
await user.click(toggle);
expect(
screen.getByRole("button", { name: "Show less" }),
).toBeInTheDocument();
});
test("calls onCardSelect when a card is clicked", () => {
const onCardSelect = vi.fn();
render(
<CardStack cards={SAMPLE_CARDS} onCardSelect={onCardSelect} />,
);
const cardButtons = screen.getAllByRole("button", {
name: "Option A: Description A",
});
fireEvent.click(cardButtons[0]);
expect(onCardSelect).toHaveBeenCalledWith("1");
});
test("renders with selectedId", () => {
render(<CardStack cards={SAMPLE_CARDS} selectedId="1" />);
expect(screen.getAllByText("SELECTED").length).toBeGreaterThanOrEqual(1);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, test, expect } from "vitest";
import { screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { renderWithProviders } from "../utils/test-utils";
import Scrollbar from "../../app/components/utility/Scrollbar";
describe("Scrollbar", () => {
test("renders children", () => {
renderWithProviders(
<Scrollbar>
<span>Scrollable content</span>
</Scrollbar>,
);
expect(screen.getByText("Scrollable content")).toBeInTheDocument();
});
test("wrapper has scrollbar-design class and overflow-y-auto for default orientation", () => {
const { container } = renderWithProviders(
<Scrollbar>
<div>Content</div>
</Scrollbar>,
);
const wrapper = container.firstChild;
expect(wrapper).toHaveClass("scrollbar-design");
expect(wrapper).toHaveClass("overflow-y-auto");
});
test("applies horizontal overflow when orientation is horizontal", () => {
const { container } = renderWithProviders(
<Scrollbar orientation="horizontal">
<div>Content</div>
</Scrollbar>,
);
const wrapper = container.firstChild;
expect(wrapper).toHaveClass("scrollbar-design");
expect(wrapper).toHaveClass("overflow-x-auto");
});
test("applies overflow-auto when orientation is both", () => {
const { container } = renderWithProviders(
<Scrollbar orientation="both">
<div>Content</div>
</Scrollbar>,
);
const wrapper = container.firstChild;
expect(wrapper).toHaveClass("scrollbar-design");
expect(wrapper).toHaveClass("overflow-auto");
});
});
+23
View File
@@ -0,0 +1,23 @@
import { describe, test, expect } from "vitest";
import { screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { renderWithProviders } from "../utils/test-utils";
import Tag from "../../app/components/utility/Tag";
describe("Tag", () => {
test("renders with variant recommended and shows default label RECOMMENDED", () => {
renderWithProviders(<Tag variant="recommended" />);
expect(screen.getByText("RECOMMENDED")).toBeInTheDocument();
});
test("renders with variant selected and shows default label SELECTED", () => {
renderWithProviders(<Tag variant="selected" />);
expect(screen.getByText("SELECTED")).toBeInTheDocument();
});
test("renders custom children when provided", () => {
renderWithProviders(<Tag variant="recommended">Custom label</Tag>);
expect(screen.getByText("Custom label")).toBeInTheDocument();
expect(screen.queryByText("RECOMMENDED")).not.toBeInTheDocument();
});
});