Create custom flow UI

This commit is contained in:
adilallo
2026-04-17 22:25:24 -06:00
parent eedb70f9f3
commit 36dcb79870
38 changed files with 1227 additions and 250 deletions
+15 -5
View File
@@ -11,12 +11,18 @@ import { ConfirmStakeholdersScreen } from "./select/ConfirmStakeholdersScreen";
import { CommunityUploadScreen } from "./upload/CommunityUploadScreen";
import { CommunityReviewScreen } from "./review/CommunityReviewScreen";
import { FinalReviewScreen } from "./review/FinalReviewScreen";
import { CardsScreen } from "./card/CardsScreen";
import { CommunicationMethodsScreen } from "./card/CommunicationMethodsScreen";
import { MembershipMethodsScreen } from "./card/MembershipMethodsScreen";
import { ConflictManagementScreen } from "./card/ConflictManagementScreen";
import { RightRailScreen } from "./right-rail/RightRailScreen";
import { CompletedScreen } from "./completed/CompletedScreen";
/**
* Renders the create-flow screen for a validated `screenId` (URL segment under /create/).
* Maps each wizard `screenId` to its screen component.
*
* **Folder rule (Figma):** subfolders match `CREATE_FLOW_SCREEN_REGISTRY[].layoutKind`
* — `select/` (two-column chip flows), `card/` (compact card-stack steps), `text/`, etc.
* The URL segment (`communication-methods`) is not the folder name; see `createFlowScreenRegistry.ts`.
*/
export function CreateFlowScreenView({
screenId,
@@ -65,10 +71,14 @@ export function CreateFlowScreenView({
return <CommunityReviewScreen />;
case "core-values":
return <CoreValuesSelectScreen />;
case "cards":
return <CardsScreen />;
case "right-rail":
case "communication-methods":
return <CommunicationMethodsScreen />;
case "membership-methods":
return <MembershipMethodsScreen />;
case "decision-approaches":
return <RightRailScreen />;
case "conflict-management":
return <ConflictManagementScreen />;
case "confirm-stakeholders":
return <ConfirmStakeholdersScreen />;
case "final-review":
@@ -1,5 +1,14 @@
"use client";
/**
* `communication-methods` step Figma Flow Compact Card Stack (node `20246-15828`).
* Registry: `layoutKind: "card"` (`CREATE_FLOW_SCREEN_REGISTRY["communication-methods"]`).
*
* Lives under `screens/card/` (not `select/`): Figma **card stack** layout is a distinct shell from
* two-column chip **select** frames. Future card-stack steps get their own `*Screen.tsx` here and
* reuse `CardStack` / `CreateFlowStepShell` as needed.
*/
import { useState, useCallback, useMemo } from "react";
import { useMessages } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
@@ -9,7 +18,10 @@ import CardStack from "../../../components/utility/CardStack";
import Create from "../../../components/modals/Create";
import TextArea from "../../../components/controls/TextArea";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
const IN_PERSON_CARD_ID = "in-person-meetings";
const SIGNAL_CARD_ID = "signal";
@@ -129,16 +141,24 @@ function isAddPlatformCard(cardId: string | null): boolean {
);
}
export function CardsScreen() {
export function CommunicationMethodsScreen() {
const m = useMessages();
const comm = m.create.communication;
const mdUp = useCreateFlowMdUp();
const { markCreateFlowInteraction } = useCreateFlow();
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [expanded, setExpanded] = useState(false);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
const selectedIds = state.selectedCommunicationMethodIds ?? [];
const setSelectedIds = useCallback(
(next: string[]) => {
updateState({ selectedCommunicationMethodIds: next });
},
[updateState],
);
const sampleCards = useMemo(
() =>
COMMUNICATION_CARD_ORDER.map((id) => {
@@ -154,9 +174,25 @@ export function CardsScreen() {
);
const title = expanded ? comm.page.expandedTitle : comm.page.compactTitle;
const description = expanded
? comm.page.expandedDescription
: comm.page.compactDescription;
const description = expanded ? (
comm.page.expandedDescription
) : (
<>
{comm.page.compactDescriptionBefore}
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 font-inherit text-[length:inherit] leading-[inherit] text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2 hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)]"
onClick={() => {
markCreateFlowInteraction();
setExpanded(true);
}}
>
{comm.page.compactDescriptionLinkLabel}
</button>
{comm.page.compactDescriptionAfter}
</>
);
const modalConfig =
pendingCardId && pendingCardId in comm.modals
@@ -198,13 +234,15 @@ export function CardsScreen() {
const handleCreateModalConfirm = useCallback(() => {
markCreateFlowInteraction();
if (pendingCardId) {
setSelectedIds((prev) =>
prev.includes(pendingCardId) ? prev : [...prev, pendingCardId],
setSelectedIds(
selectedIds.includes(pendingCardId)
? selectedIds
: [...selectedIds, pendingCardId],
);
}
setCreateModalOpen(false);
setPendingCardId(null);
}, [markCreateFlowInteraction, pendingCardId]);
}, [markCreateFlowInteraction, pendingCardId, selectedIds, setSelectedIds]);
return (
<CreateFlowStepShell
@@ -219,7 +257,7 @@ export function CardsScreen() {
justification="center"
/>
</div>
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
@@ -231,6 +269,8 @@ export function CardsScreen() {
}}
hasMore={true}
toggleLabel={comm.page.seeAllLink}
compactRecommendedLimit={3}
compactDesktopLayout="flexWrap"
headerLockupSize={mdUp ? "L" : "M"}
/>
</div>
@@ -0,0 +1,179 @@
"use client";
/**
* `conflict-management` step — Figma compact card stack (node `20879-15979`).
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["conflict-management"]`.
*/
import { useState, useCallback, useMemo } from "react";
import { useMessages } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../components/utility/CardStack";
import Create from "../../../components/modals/Create";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
const CONFLICT_CARD_ORDER = [
"peer-mediation",
"conflict-resolution-council",
"facilitated-negotiation",
"ad-hoc-arbitration",
"conflict-workshops",
"6",
"7",
"8",
] as const;
export function ConflictManagementScreen() {
const m = useMessages();
const cm = m.create.conflictManagement;
const mdUp = useCreateFlowMdUp();
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [expanded, setExpanded] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
const selectedIds = state.selectedConflictManagementIds ?? [];
const setSelectedIds = useCallback(
(next: string[]) => {
updateState({ selectedConflictManagementIds: next });
},
[updateState],
);
const sampleCards = useMemo(
() =>
CONFLICT_CARD_ORDER.map((id) => {
const row = cm.cards[id as keyof typeof cm.cards];
return {
id,
label: row.label,
supportText: row.supportText,
recommended: true,
};
}),
[cm],
);
const title = expanded ? cm.page.expandedTitle : cm.page.compactTitle;
const description = expanded ? (
cm.page.expandedDescription
) : (
<>
{cm.page.compactDescriptionBefore}
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 font-inherit text-[length:inherit] leading-[inherit] text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2 hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)]"
onClick={() => {
markCreateFlowInteraction();
setExpanded(true);
}}
>
{cm.page.compactDescriptionLinkLabel}
</button>
{cm.page.compactDescriptionAfter}
</>
);
const modalConfig =
pendingCardId && pendingCardId in cm.modals
? (() => {
const modal = cm.modals[pendingCardId as keyof typeof cm.modals];
return {
title: modal.title,
description: modal.description,
nextButtonText: cm.confirmModal.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
})()
: {
title: cm.confirmModal.title,
description: cm.confirmModal.description,
nextButtonText: cm.confirmModal.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
const handleCardClick = useCallback(
(id: string) => {
markCreateFlowInteraction();
setPendingCardId(id);
setCreateModalOpen(true);
},
[markCreateFlowInteraction],
);
const handleCreateModalClose = useCallback(() => {
setCreateModalOpen(false);
setPendingCardId(null);
}, []);
const handleCreateModalConfirm = useCallback(() => {
markCreateFlowInteraction();
if (pendingCardId) {
setSelectedIds(
selectedIds.includes(pendingCardId)
? selectedIds
: [...selectedIds, pendingCardId],
);
}
setCreateModalOpen(false);
setPendingCardId(null);
}, [markCreateFlowInteraction, pendingCardId, selectedIds, setSelectedIds]);
return (
<CreateFlowStepShell
variant="wideGridLoosePadding"
contentTopBelowMd="space-800"
>
<div className="flex w-full min-w-0 flex-col items-center gap-6">
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<CreateFlowHeaderLockup
title={title}
description={description}
justification="center"
/>
</div>
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardClick}
expanded={expanded}
onToggleExpand={() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
toggleLabel={cm.page.seeAllLink}
compactRecommendedLimit={5}
compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"}
/>
</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}
/>
</CreateFlowStepShell>
);
}
@@ -0,0 +1,179 @@
"use client";
/**
* `membership-methods` step — Figma compact card stack (node `20858-13947`).
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["membership-methods"]`.
*/
import { useState, useCallback, useMemo } from "react";
import { useMessages } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../components/utility/CardStack";
import Create from "../../../components/modals/Create";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
const MEMBERSHIP_CARD_ORDER = [
"open-access",
"orientation-required",
"invitation-only",
"contribution-based",
"mentorship",
"6",
"7",
"8",
] as const;
export function MembershipMethodsScreen() {
const m = useMessages();
const mem = m.create.membership;
const mdUp = useCreateFlowMdUp();
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [expanded, setExpanded] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
const selectedIds = state.selectedMembershipMethodIds ?? [];
const setSelectedIds = useCallback(
(next: string[]) => {
updateState({ selectedMembershipMethodIds: next });
},
[updateState],
);
const sampleCards = useMemo(
() =>
MEMBERSHIP_CARD_ORDER.map((id) => {
const row = mem.cards[id as keyof typeof mem.cards];
return {
id,
label: row.label,
supportText: row.supportText,
recommended: true,
};
}),
[mem],
);
const title = expanded ? mem.page.expandedTitle : mem.page.compactTitle;
const description = expanded ? (
mem.page.expandedDescription
) : (
<>
{mem.page.compactDescriptionBefore}
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 font-inherit text-[length:inherit] leading-[inherit] text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2 hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)]"
onClick={() => {
markCreateFlowInteraction();
setExpanded(true);
}}
>
{mem.page.compactDescriptionLinkLabel}
</button>
{mem.page.compactDescriptionAfter}
</>
);
const modalConfig =
pendingCardId && pendingCardId in mem.modals
? (() => {
const modal = mem.modals[pendingCardId as keyof typeof mem.modals];
return {
title: modal.title,
description: modal.description,
nextButtonText: mem.confirmModal.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
})()
: {
title: mem.confirmModal.title,
description: mem.confirmModal.description,
nextButtonText: mem.confirmModal.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
const handleCardClick = useCallback(
(id: string) => {
markCreateFlowInteraction();
setPendingCardId(id);
setCreateModalOpen(true);
},
[markCreateFlowInteraction],
);
const handleCreateModalClose = useCallback(() => {
setCreateModalOpen(false);
setPendingCardId(null);
}, []);
const handleCreateModalConfirm = useCallback(() => {
markCreateFlowInteraction();
if (pendingCardId) {
setSelectedIds(
selectedIds.includes(pendingCardId)
? selectedIds
: [...selectedIds, pendingCardId],
);
}
setCreateModalOpen(false);
setPendingCardId(null);
}, [markCreateFlowInteraction, pendingCardId, selectedIds, setSelectedIds]);
return (
<CreateFlowStepShell
variant="wideGridLoosePadding"
contentTopBelowMd="space-800"
>
<div className="flex w-full min-w-0 flex-col items-center gap-6">
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<CreateFlowHeaderLockup
title={title}
description={description}
justification="center"
/>
</div>
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardClick}
expanded={expanded}
onToggleExpand={() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
toggleLabel={mem.page.seeAllLink}
compactRecommendedLimit={5}
compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"}
/>
</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}
/>
</CreateFlowStepShell>
);
}
@@ -1,5 +1,13 @@
"use client";
/**
* `decision-approaches` step — Figma “Flow — Right Rail” (node `20523-23509`).
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["decision-approaches"]` (`layoutKind: "right-rail"`).
*
* Layout matches {@link CreateFlowTwoColumnSelectShell}: one column below `lg` (1024px), two columns
* at `lg+` with a scrollable rail — same breakpoint and height chain as select steps, distinct content.
*/
import { useState, useCallback, useMemo } from "react";
import DecisionMakingSidebar from "../../../components/utility/DecisionMakingSidebar";
import CardStack from "../../../components/utility/CardStack";
@@ -8,22 +16,27 @@ import type { CardStackItem } from "../../../components/utility/CardStack/CardSt
import { useMessages } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import {
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
} from "../../components/createFlowLayoutTokens";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
export function RightRailScreen() {
const m = useMessages();
const rr = m.create.rightRail;
const mdUp = useCreateFlowMdUp();
const { markCreateFlowInteraction } = useCreateFlow();
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState<string[]>(
[],
);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [expanded, setExpanded] = useState(false);
const selectedIds = state.selectedDecisionApproachIds ?? [];
const setSelectedIds = useCallback(
(next: string[]) => {
updateState({ selectedDecisionApproachIds: next });
},
[updateState],
);
const messageBoxItems: InfoMessageBoxItem[] = useMemo(
() =>
rr.messageBox.items.map((item) => ({
@@ -47,7 +60,16 @@ export function RightRailScreen() {
const sidebarDescription = (
<>
{rr.sidebar.descriptionBefore}
<span className="underline">{rr.sidebar.descriptionLink}</span>
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 font-inherit text-[length:inherit] leading-[inherit] text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2 hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)]"
onClick={() => {
markCreateFlowInteraction();
setExpanded(true);
}}
>
{rr.sidebar.descriptionLinkLabel}
</button>
{rr.sidebar.descriptionAfter}
</>
);
@@ -65,11 +87,13 @@ export function RightRailScreen() {
const handleCardSelect = useCallback(
(id: string) => {
markCreateFlowInteraction();
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
setSelectedIds(
selectedIds.includes(id)
? selectedIds.filter((x) => x !== id)
: [...selectedIds, id],
);
},
[markCreateFlowInteraction],
[markCreateFlowInteraction, selectedIds, setSelectedIds],
);
const handleToggleExpand = useCallback(() => {
@@ -78,48 +102,40 @@ export function RightRailScreen() {
}, [markCreateFlowInteraction]);
return (
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden md:h-full">
<div className="flex min-h-0 flex-1 overflow-hidden px-5 max-md:overflow-y-auto md:px-12">
<div
className={`mx-auto grid h-auto min-h-0 w-full shrink-0 grid-cols-1 gap-6 min-w-0 max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:justify-items-center md:gap-12 md:pb-8 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
>
<div
className={`flex flex-col items-stretch justify-start overflow-hidden md:justify-center ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
>
<DecisionMakingSidebar
title={rr.sidebar.title}
description={sidebarDescription}
messageBoxTitle={rr.messageBox.title}
messageBoxItems={messageBoxItems}
messageBoxCheckedIds={messageBoxCheckedIds}
onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
size={mdUp ? "L" : "M"}
justification={mdUp ? "left" : "center"}
/>
</div>
<div
className={`scrollbar-hide relative flex min-h-0 flex-col overflow-x-hidden max-md:overflow-visible md:overflow-y-auto ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
>
<div className="flex w-full min-w-0 flex-col items-center gap-6 py-0 md:pb-8">
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardSelect}
expanded={expanded}
onToggleExpand={handleToggleExpand}
hasMore={true}
toggleLabel={rr.cardStack.toggleSeeAll}
showLessLabel={rr.cardStack.toggleShowLess}
title={rr.cardStack.emptyTitle}
description={rr.cardStack.emptyDescription}
layout="singleStack"
className="w-full"
headerLockupSize={mdUp ? "L" : "M"}
/>
</div>
</div>
</div>
<CreateFlowTwoColumnSelectShell
contentTopBelowMd="space-800"
lgVerticalAlign="start"
header={
<DecisionMakingSidebar
title={rr.sidebar.title}
description={sidebarDescription}
messageBoxTitle={rr.messageBox.title}
messageBoxItems={messageBoxItems}
messageBoxCheckedIds={messageBoxCheckedIds}
onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
size={mdUp ? "L" : "M"}
justification={mdUp ? "left" : "center"}
/>
}
>
<div className="flex w-full min-w-0 flex-col items-stretch gap-6 py-0">
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardSelect}
expanded={expanded}
onToggleExpand={handleToggleExpand}
hasMore={true}
toggleLabel={rr.cardStack.toggleSeeAll}
showLessLabel={rr.cardStack.toggleShowLess}
title=""
description=""
layout="singleStack"
compactRecommendedLimit={5}
className="w-full"
headerLockupSize={mdUp ? "L" : "M"}
/>
</div>
</div>
</CreateFlowTwoColumnSelectShell>
);
}