Card compact and expanded template
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 2–3 and 4–5) */}
|
||||
<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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./Tag.container";
|
||||
export type { TagProps, TagVariant } from "./Tag.types";
|
||||
@@ -17,8 +17,7 @@ const VALID_STEPS: CreateFlowStep[] = [
|
||||
"select",
|
||||
"upload",
|
||||
"review",
|
||||
"compact-cards",
|
||||
"expanded-cards",
|
||||
"cards",
|
||||
"right-rail",
|
||||
"final-review",
|
||||
"completed",
|
||||
|
||||
@@ -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 (non–add-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>
|
||||
);
|
||||
}
|
||||
@@ -29,8 +29,7 @@ function CreateFlowLayoutContent({ children }: { children: ReactNode }) {
|
||||
"select",
|
||||
"upload",
|
||||
"review",
|
||||
"compact-cards",
|
||||
"expanded-cards",
|
||||
"cards",
|
||||
"right-rail",
|
||||
"final-review",
|
||||
"completed",
|
||||
|
||||
@@ -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
@@ -14,8 +14,7 @@ export type CreateFlowStep =
|
||||
| "select"
|
||||
| "upload"
|
||||
| "review"
|
||||
| "compact-cards"
|
||||
| "expanded-cards"
|
||||
| "cards"
|
||||
| "right-rail"
|
||||
| "final-review"
|
||||
| "completed";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.)
|
||||
*/
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user