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;
|
||||
|
||||
Reference in New Issue
Block a user