Implement modals across create flow

This commit is contained in:
adilallo
2026-04-17 23:45:29 -06:00
parent 36dcb79870
commit 4854c49c4a
21 changed files with 2089 additions and 318 deletions
@@ -0,0 +1,142 @@
"use client";
/**
* Shared "Applicable Scope" field used by the `decision-approaches` and
* `conflict-management` create flow modals. Pairs an `InputLabel` with a
* horizontally-wrapping list of toggle-chips plus an inline "+ Add" affordance
* that reveals a pill text input for creating new scope values.
*/
import { memo, useState } from "react";
import Chip from "../../components/controls/Chip";
import InputLabel from "../../components/utility/InputLabel";
export interface ApplicableScopeFieldProps {
/** Label rendered above the capsule row. */
label: string;
/** Text for the "+ Add …" affordance (e.g. "Add Applicable Scope"). */
addLabel: string;
/**
* The full list of chip values shown to the user. Each value is a unique
* string (chip label).
*/
scopes: string[];
/** Values currently toggled on (rendered in the Chip "Selected" state). */
selectedScopes: string[];
/** Fired when a chip is clicked; caller toggles inclusion in `selectedScopes`. */
onToggleScope: (_scope: string) => void;
/**
* Fired when the user submits a new scope via the inline input. Duplicate
* values (already in `scopes`) are filtered out before the callback fires.
*/
onAddScope: (_scope: string) => void;
/**
* Optional placeholder for the inline input. Defaults to `addLabel`.
*/
inputPlaceholder?: string;
className?: string;
}
function ApplicableScopeFieldComponent({
label,
addLabel,
scopes,
selectedScopes,
onToggleScope,
onAddScope,
inputPlaceholder,
className = "",
}: ApplicableScopeFieldProps) {
const [draft, setDraft] = useState("");
const [isAdding, setIsAdding] = useState(false);
const submitDraft = () => {
const trimmed = draft.trim();
if (!trimmed) {
setIsAdding(false);
setDraft("");
return;
}
if (!scopes.includes(trimmed)) {
onAddScope(trimmed);
}
setDraft("");
setIsAdding(false);
};
return (
<div className={`flex flex-col gap-2 ${className}`.trim()}>
<InputLabel label={label} helpIcon size="s" palette="default" />
<div className="flex flex-wrap items-center gap-2">
{scopes.map((scope) => {
const isSelected = selectedScopes.includes(scope);
return (
<Chip
key={scope}
label={scope}
state={isSelected ? "Selected" : "Disabled"}
palette="Default"
size="S"
disabled={false}
onClick={() => onToggleScope(scope)}
ariaLabel={`${isSelected ? "Deselect" : "Select"} ${scope}`}
/>
);
})}
{isAdding ? (
<input
type="text"
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={submitDraft}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submitDraft();
} else if (e.key === "Escape") {
setDraft("");
setIsAdding(false);
}
}}
placeholder={inputPlaceholder ?? addLabel}
aria-label={inputPlaceholder ?? addLabel}
className="h-[30px] rounded-[9999px] border border-[var(--color-border-default-tertiary)] bg-transparent px-3 font-inter text-[length:var(--sizing-300,12px)] font-medium leading-[14px] text-[color:var(--color-content-default-primary)] outline-none placeholder:text-[color:var(--color-content-default-tertiary)] focus-visible:border-[var(--color-border-default-brand-primary)]"
/>
) : (
<button
type="button"
onClick={() => setIsAdding(true)}
className="inline-flex items-center gap-[var(--measures-spacing-050,2px)] rounded-[var(--measures-radius-full,9999px)] px-[var(--space-250,10px)] py-[var(--measures-spacing-200,8px)] font-inter text-[length:var(--sizing-300,12px)] font-medium leading-[14px] text-[color:var(--color-content-default-primary)] hover:bg-[var(--color-surface-default-secondary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-transparent"
>
<AddGlyph />
{addLabel}
</button>
)}
</div>
</div>
);
}
function AddGlyph() {
return (
<svg
aria-hidden
viewBox="0 0 24 24"
className="block size-[14px]"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 5v14M5 12h14"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}
ApplicableScopeFieldComponent.displayName = "ApplicableScopeField";
export default memo(ApplicableScopeFieldComponent);
@@ -0,0 +1,60 @@
"use client";
/**
* Shared "labelled text area" field used by every create flow modal section.
* Pairs an `InputLabel` (with help icon) with a `TextArea` set to the embedded
* appearance — matching the Figma "Control / Text Area" pattern.
*/
import { memo } from "react";
import TextArea from "../../components/controls/TextArea";
import InputLabel from "../../components/utility/InputLabel";
export interface ModalTextAreaFieldProps {
/** Label rendered above the text area. */
label: string;
/** Show the help "?" icon next to the label (default `true`). */
helpIcon?: boolean;
/** Current text value. */
value: string;
/** Fired on every change with the new value (no event). */
onChange: (_value: string) => void;
/** Optional rows for the underlying `<textarea>` (default 4). */
rows?: number;
/** Optional placeholder. */
placeholder?: string;
/** Disable the field. */
disabled?: boolean;
className?: string;
}
function ModalTextAreaFieldComponent({
label,
helpIcon = true,
value,
onChange,
rows = 4,
placeholder,
disabled = false,
className = "",
}: ModalTextAreaFieldProps) {
return (
<div className={`flex flex-col gap-2 ${className}`.trim()}>
<InputLabel label={label} helpIcon={helpIcon} size="s" palette="default" />
<TextArea
formHeader={false}
value={value}
onChange={(e) => onChange(e.target.value)}
size="large"
rows={rows}
appearance="embedded"
placeholder={placeholder}
disabled={disabled}
/>
</div>
);
}
ModalTextAreaFieldComponent.displayName = "ModalTextAreaField";
export default memo(ModalTextAreaFieldComponent);
+2 -2
View File
@@ -14,7 +14,7 @@ import { FinalReviewScreen } from "./review/FinalReviewScreen";
import { CommunicationMethodsScreen } from "./card/CommunicationMethodsScreen";
import { MembershipMethodsScreen } from "./card/MembershipMethodsScreen";
import { ConflictManagementScreen } from "./card/ConflictManagementScreen";
import { RightRailScreen } from "./right-rail/RightRailScreen";
import { DecisionApproachesScreen } from "./right-rail/DecisionApproachesScreen";
import { CompletedScreen } from "./completed/CompletedScreen";
/**
@@ -76,7 +76,7 @@ export function CreateFlowScreenView({
case "membership-methods":
return <MembershipMethodsScreen />;
case "decision-approaches":
return <RightRailScreen />;
return <DecisionApproachesScreen />;
case "conflict-management":
return <ConflictManagementScreen />;
case "confirm-stakeholders":
@@ -16,23 +16,18 @@ import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../components/utility/CardStack";
import Create from "../../../components/modals/Create";
import TextArea from "../../../components/controls/TextArea";
import InlineTextButton from "../../../components/buttons/InlineTextButton";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
import ModalTextAreaField from "../../components/ModalTextAreaField";
const IN_PERSON_CARD_ID = "in-person-meetings";
const SIGNAL_CARD_ID = "signal";
const VIDEO_MEETINGS_CARD_ID = "video-meetings";
const ADD_PLATFORM_CARD_IDS = [
IN_PERSON_CARD_ID,
SIGNAL_CARD_ID,
VIDEO_MEETINGS_CARD_ID,
] as const;
const SECTION_FIELDS = [
"corePrinciple",
"logisticsAdmin",
@@ -50,40 +45,6 @@ const COMMUNICATION_CARD_ORDER = [
"7",
] as const;
function CreateModalSection({
title,
value: _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>
);
}
function AddPlatformModalContent({
platformCardId,
}: {
@@ -92,15 +53,15 @@ function AddPlatformModalContent({
const { markCreateFlowInteraction } = useCreateFlow();
const m = useMessages();
const comm = m.create.communication;
const modal = comm.modals[platformCardId as keyof typeof comm.modals];
const defaults =
modal && "sections" in modal
? modal.sections
: {
corePrinciple: "",
logisticsAdmin: "",
codeOfConduct: "",
};
const modal =
platformCardId in comm.modals
? comm.modals[platformCardId as keyof typeof comm.modals]
: null;
const defaults = modal?.sections ?? {
corePrinciple: "",
logisticsAdmin: "",
codeOfConduct: "",
};
const [sectionValues, setSectionValues] = useState<
Record<SectionField, string>
@@ -118,14 +79,13 @@ function AddPlatformModalContent({
[markCreateFlowInteraction],
);
if (!modal || !("sections" in modal)) return null;
return (
<div className="flex flex-col gap-6">
{SECTION_FIELDS.map((field) => (
<CreateModalSection
<ModalTextAreaField
key={field}
title={comm.sectionHeadings[field]}
label={comm.sectionHeadings[field]}
rows={6}
value={sectionValues[field]}
onChange={(v) => updateSection(field, v)}
/>
@@ -134,13 +94,6 @@ function AddPlatformModalContent({
);
}
function isAddPlatformCard(cardId: string | null): boolean {
return (
cardId !== null &&
(ADD_PLATFORM_CARD_IDS as readonly string[]).includes(cardId)
);
}
export function CommunicationMethodsScreen() {
const m = useMessages();
const comm = m.create.communication;
@@ -180,42 +133,55 @@ export function CommunicationMethodsScreen() {
) : (
<>
{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)]"
<InlineTextButton
onClick={() => {
markCreateFlowInteraction();
setExpanded(true);
}}
>
{comm.page.compactDescriptionLinkLabel}
</button>
</InlineTextButton>
{comm.page.compactDescriptionAfter}
</>
);
const modalConfig =
pendingCardId && pendingCardId in comm.modals
? (() => {
const modal =
comm.modals[pendingCardId as keyof typeof comm.modals];
return {
title: modal.title,
description: modal.description,
nextButtonText: comm.addPlatform.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
})()
: {
title: comm.confirmModal.title,
description: comm.confirmModal.description,
nextButtonText: comm.confirmModal.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
const modalConfig = (() => {
if (!pendingCardId) {
return {
title: comm.confirmModal.title,
description: comm.confirmModal.description,
nextButtonText: comm.confirmModal.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
}
if (pendingCardId in comm.modals) {
const modal = comm.modals[pendingCardId as keyof typeof comm.modals];
return {
title: modal.title,
description: modal.description,
nextButtonText: comm.addPlatform.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
}
const cardRow =
pendingCardId in comm.cards
? comm.cards[pendingCardId as keyof typeof comm.cards]
: null;
return {
title: cardRow?.label ?? comm.confirmModal.title,
description: cardRow?.supportText ?? comm.confirmModal.description,
nextButtonText: comm.addPlatform.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
})();
const handleCardClick = useCallback(
(id: string) => {
@@ -286,8 +252,9 @@ export function CommunicationMethodsScreen() {
showBackButton={modalConfig.showBackButton}
currentStep={modalConfig.currentStep}
totalSteps={modalConfig.totalSteps}
backdropVariant="loginYellow"
>
{isAddPlatformCard(pendingCardId) && pendingCardId ? (
{pendingCardId ? (
<AddPlatformModalContent
key={pendingCardId}
platformCardId={pendingCardId}
@@ -3,6 +3,12 @@
/**
* `conflict-management` step — Figma compact card stack (node `20879-15979`).
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["conflict-management"]`.
*
* Card click opens the Figma "Add Approach" create modal (node `20874-172292`) with four
* controls: Core Principle, Applicable Scope (capsules), Process Protocol, and Restoration
* & Fallbacks. Section defaults are sourced from
* `messages/en/create/conflictManagement.json` and will be replaced with DB-driven
* content; labels are hard-coded per the Figma design.
*/
import { useState, useCallback, useMemo } from "react";
@@ -12,11 +18,14 @@ import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../components/utility/CardStack";
import Create from "../../../components/modals/Create";
import InlineTextButton from "../../../components/buttons/InlineTextButton";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
import ModalTextAreaField from "../../components/ModalTextAreaField";
import ApplicableScopeField from "../../components/ApplicableScopeField";
const CONFLICT_CARD_ORDER = [
"peer-mediation",
@@ -29,6 +38,92 @@ const CONFLICT_CARD_ORDER = [
"8",
] as const;
type ConflictModalSections = {
corePrinciple: string;
applicableScope: string[];
selectedApplicableScope: string[];
processProtocol: string;
restorationFallbacks: string;
};
function AddConflictApproachModalContent({
approachCardId,
}: {
approachCardId: string;
}) {
const { markCreateFlowInteraction } = useCreateFlow();
const m = useMessages();
const cm = m.create.conflictManagement;
const modal =
approachCardId in cm.modals
? cm.modals[approachCardId as keyof typeof cm.modals]
: null;
const modalSections = modal?.sections;
const defaults: ConflictModalSections = {
corePrinciple: modalSections?.corePrinciple ?? "",
applicableScope: modalSections?.applicableScope ?? [],
selectedApplicableScope: [],
processProtocol: modalSections?.processProtocol ?? "",
restorationFallbacks: modalSections?.restorationFallbacks ?? "",
};
const [sections, setSections] = useState<ConflictModalSections>(() => ({
corePrinciple: defaults.corePrinciple,
applicableScope: [...defaults.applicableScope],
selectedApplicableScope: [...defaults.selectedApplicableScope],
processProtocol: defaults.processProtocol,
restorationFallbacks: defaults.restorationFallbacks,
}));
const patch = useCallback(
<K extends keyof ConflictModalSections>(
key: K,
value: ConflictModalSections[K],
) => {
markCreateFlowInteraction();
setSections((prev) => ({ ...prev, [key]: value }));
},
[markCreateFlowInteraction],
);
return (
<div className="flex flex-col gap-6">
<ModalTextAreaField
label={cm.sectionHeadings.corePrinciple}
value={sections.corePrinciple}
onChange={(v) => patch("corePrinciple", v)}
/>
<ApplicableScopeField
label={cm.sectionHeadings.applicableScope}
addLabel={cm.scopeAddButtonLabel}
scopes={sections.applicableScope}
selectedScopes={sections.selectedApplicableScope}
onToggleScope={(scope) =>
patch(
"selectedApplicableScope",
sections.selectedApplicableScope.includes(scope)
? sections.selectedApplicableScope.filter((s) => s !== scope)
: [...sections.selectedApplicableScope, scope],
)
}
onAddScope={(scope) =>
patch("applicableScope", [...sections.applicableScope, scope])
}
/>
<ModalTextAreaField
label={cm.sectionHeadings.processProtocol}
value={sections.processProtocol}
onChange={(v) => patch("processProtocol", v)}
/>
<ModalTextAreaField
label={cm.sectionHeadings.restorationFallbacks}
value={sections.restorationFallbacks}
onChange={(v) => patch("restorationFallbacks", v)}
/>
</div>
);
}
export function ConflictManagementScreen() {
const m = useMessages();
const cm = m.create.conflictManagement;
@@ -68,41 +163,55 @@ export function ConflictManagementScreen() {
) : (
<>
{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)]"
<InlineTextButton
onClick={() => {
markCreateFlowInteraction();
setExpanded(true);
}}
>
{cm.page.compactDescriptionLinkLabel}
</button>
</InlineTextButton>
{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 modalConfig = (() => {
if (!pendingCardId) {
return {
title: cm.confirmModal.title,
description: cm.confirmModal.description,
nextButtonText: cm.confirmModal.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
}
if (pendingCardId in cm.modals) {
const modal = cm.modals[pendingCardId as keyof typeof cm.modals];
return {
title: modal.title,
description: modal.description,
nextButtonText: cm.addApproach.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
}
const cardRow =
pendingCardId in cm.cards
? cm.cards[pendingCardId as keyof typeof cm.cards]
: null;
return {
title: cardRow?.label ?? cm.confirmModal.title,
description: cardRow?.supportText ?? cm.confirmModal.description,
nextButtonText: cm.addApproach.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
})();
const handleCardClick = useCallback(
(id: string) => {
@@ -173,7 +282,15 @@ export function ConflictManagementScreen() {
showBackButton={modalConfig.showBackButton}
currentStep={modalConfig.currentStep}
totalSteps={modalConfig.totalSteps}
/>
backdropVariant="loginYellow"
>
{pendingCardId ? (
<AddConflictApproachModalContent
key={pendingCardId}
approachCardId={pendingCardId}
/>
) : null}
</Create>
</CreateFlowStepShell>
);
}
@@ -3,6 +3,12 @@
/**
* `membership-methods` step — Figma compact card stack (node `20858-13947`).
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["membership-methods"]`.
*
* Card click opens the Figma create modal (node `20858-13948`) with three
* editable sections — Eligibility & Philosophy, Joining Process, and
* Expectations & Removal. Section defaults come from
* `messages/en/create/membership.json` and will be replaced with DB-driven
* content.
*/
import { useState, useCallback, useMemo } from "react";
@@ -12,11 +18,20 @@ import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../components/utility/CardStack";
import Create from "../../../components/modals/Create";
import InlineTextButton from "../../../components/buttons/InlineTextButton";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
import ModalTextAreaField from "../../components/ModalTextAreaField";
const SECTION_FIELDS = [
"eligibility",
"joiningProcess",
"expectations",
] as const;
type SectionField = (typeof SECTION_FIELDS)[number];
const MEMBERSHIP_CARD_ORDER = [
"open-access",
@@ -29,6 +44,55 @@ const MEMBERSHIP_CARD_ORDER = [
"8",
] as const;
function AddMembershipModalContent({
membershipCardId,
}: {
membershipCardId: string;
}) {
const { markCreateFlowInteraction } = useCreateFlow();
const m = useMessages();
const mem = m.create.membership;
const modal =
membershipCardId in mem.modals
? mem.modals[membershipCardId as keyof typeof mem.modals]
: null;
const defaults = modal?.sections ?? {
eligibility: "",
joiningProcess: "",
expectations: "",
};
const [sectionValues, setSectionValues] = useState<
Record<SectionField, string>
>(() => ({
eligibility: defaults.eligibility,
joiningProcess: defaults.joiningProcess,
expectations: defaults.expectations,
}));
const updateSection = useCallback(
(key: SectionField, value: string) => {
markCreateFlowInteraction();
setSectionValues((prev) => ({ ...prev, [key]: value }));
},
[markCreateFlowInteraction],
);
return (
<div className="flex flex-col gap-6">
{SECTION_FIELDS.map((field) => (
<ModalTextAreaField
key={field}
label={mem.sectionHeadings[field]}
rows={6}
value={sectionValues[field]}
onChange={(v) => updateSection(field, v)}
/>
))}
</div>
);
}
export function MembershipMethodsScreen() {
const m = useMessages();
const mem = m.create.membership;
@@ -68,41 +132,55 @@ export function MembershipMethodsScreen() {
) : (
<>
{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)]"
<InlineTextButton
onClick={() => {
markCreateFlowInteraction();
setExpanded(true);
}}
>
{mem.page.compactDescriptionLinkLabel}
</button>
</InlineTextButton>
{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 modalConfig = (() => {
if (!pendingCardId) {
return {
title: mem.confirmModal.title,
description: mem.confirmModal.description,
nextButtonText: mem.confirmModal.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
}
if (pendingCardId in mem.modals) {
const modal = mem.modals[pendingCardId as keyof typeof mem.modals];
return {
title: modal.title,
description: modal.description,
nextButtonText: mem.addPlatform.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
}
const cardRow =
pendingCardId in mem.cards
? mem.cards[pendingCardId as keyof typeof mem.cards]
: null;
return {
title: cardRow?.label ?? mem.confirmModal.title,
description: cardRow?.supportText ?? mem.confirmModal.description,
nextButtonText: mem.addPlatform.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
})();
const handleCardClick = useCallback(
(id: string) => {
@@ -173,7 +251,15 @@ export function MembershipMethodsScreen() {
showBackButton={modalConfig.showBackButton}
currentStep={modalConfig.currentStep}
totalSteps={modalConfig.totalSteps}
/>
backdropVariant="loginYellow"
>
{pendingCardId ? (
<AddMembershipModalContent
key={pendingCardId}
membershipCardId={pendingCardId}
/>
) : null}
</Create>
</CreateFlowStepShell>
);
}
@@ -0,0 +1,320 @@
"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.
*
* Card click opens the Figma "Add Approach" create modal (node `20870-72155`) with five controls:
* Core Principle, Applicable Scope, Step-by-Step Instructions, Consensus Level, and Objections &
* Deadlocks. Section defaults are sourced from `messages/en/create/rightRail.json` and will be
* replaced with DB-driven content; labels are hard-coded per the Figma design.
*/
import { useState, useCallback, useMemo } from "react";
import DecisionMakingSidebar from "../../../components/utility/DecisionMakingSidebar";
import CardStack from "../../../components/utility/CardStack";
import Create from "../../../components/modals/Create";
import { IncrementerBlock } from "../../../components/controls/Incrementer";
import InlineTextButton from "../../../components/buttons/InlineTextButton";
import type { InfoMessageBoxItem } from "../../../components/utility/InfoMessageBox/InfoMessageBox.types";
import type { CardStackItem } from "../../../components/utility/CardStack/CardStack.types";
import { useMessages } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import ModalTextAreaField from "../../components/ModalTextAreaField";
import ApplicableScopeField from "../../components/ApplicableScopeField";
const CONSENSUS_LEVEL_MIN = 0;
const CONSENSUS_LEVEL_MAX = 100;
const CONSENSUS_LEVEL_STEP = 5;
const CONSENSUS_LEVEL_DEFAULT = 75;
type RightRailModalSections = {
corePrinciple: string;
applicableScope: string[];
selectedApplicableScope: string[];
stepByStepInstructions: string;
consensusLevel: number;
objectionsDeadlocks: string;
};
function AddDecisionApproachModalContent({
approachCardId,
}: {
approachCardId: string;
}) {
const { markCreateFlowInteraction } = useCreateFlow();
const m = useMessages();
const rr = m.create.rightRail;
const modal =
approachCardId in rr.modals
? rr.modals[approachCardId as keyof typeof rr.modals]
: null;
const modalSections = modal?.sections;
const defaults: RightRailModalSections = {
corePrinciple: modalSections?.corePrinciple ?? "",
applicableScope: modalSections?.applicableScope ?? [],
selectedApplicableScope: [],
stepByStepInstructions: modalSections?.stepByStepInstructions ?? "",
consensusLevel: modalSections?.consensusLevel ?? CONSENSUS_LEVEL_DEFAULT,
objectionsDeadlocks: modalSections?.objectionsDeadlocks ?? "",
};
const [sections, setSections] = useState<RightRailModalSections>(() => ({
corePrinciple: defaults.corePrinciple,
applicableScope: [...defaults.applicableScope],
selectedApplicableScope: [...defaults.selectedApplicableScope],
stepByStepInstructions: defaults.stepByStepInstructions,
consensusLevel: defaults.consensusLevel,
objectionsDeadlocks: defaults.objectionsDeadlocks,
}));
const patch = useCallback(
<K extends keyof RightRailModalSections>(
key: K,
value: RightRailModalSections[K],
) => {
markCreateFlowInteraction();
setSections((prev) => ({ ...prev, [key]: value }));
},
[markCreateFlowInteraction],
);
return (
<div className="flex flex-col gap-6">
<ModalTextAreaField
label={rr.sectionHeadings.corePrinciple}
value={sections.corePrinciple}
onChange={(v) => patch("corePrinciple", v)}
/>
<ApplicableScopeField
label={rr.sectionHeadings.applicableScope}
addLabel={rr.scopeAddButtonLabel}
scopes={sections.applicableScope}
selectedScopes={sections.selectedApplicableScope}
onToggleScope={(scope) =>
patch(
"selectedApplicableScope",
sections.selectedApplicableScope.includes(scope)
? sections.selectedApplicableScope.filter((s) => s !== scope)
: [...sections.selectedApplicableScope, scope],
)
}
onAddScope={(scope) =>
patch("applicableScope", [...sections.applicableScope, scope])
}
/>
<ModalTextAreaField
label={rr.sectionHeadings.stepByStepInstructions}
value={sections.stepByStepInstructions}
onChange={(v) => patch("stepByStepInstructions", v)}
/>
<IncrementerBlock
label={rr.sectionHeadings.consensusLevel}
value={sections.consensusLevel}
min={CONSENSUS_LEVEL_MIN}
max={CONSENSUS_LEVEL_MAX}
step={CONSENSUS_LEVEL_STEP}
onChange={(next) => patch("consensusLevel", next)}
formatValue={(v) => `${v}%`}
decrementAriaLabel="Decrease consensus level"
incrementAriaLabel="Increase consensus level"
/>
<ModalTextAreaField
label={rr.sectionHeadings.objectionsDeadlocks}
value={sections.objectionsDeadlocks}
onChange={(v) => patch("objectionsDeadlocks", v)}
/>
</div>
);
}
export function DecisionApproachesScreen() {
const m = useMessages();
const rr = m.create.rightRail;
const mdUp = useCreateFlowMdUp();
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState<string[]>(
[],
);
const [expanded, setExpanded] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
const selectedIds = state.selectedDecisionApproachIds ?? [];
const setSelectedIds = useCallback(
(next: string[]) => {
updateState({ selectedDecisionApproachIds: next });
},
[updateState],
);
const messageBoxItems: InfoMessageBoxItem[] = useMemo(
() =>
rr.messageBox.items.map((item) => ({
id: item.id,
label: item.label,
})),
[rr.messageBox.items],
);
const sampleCards: CardStackItem[] = useMemo(
() =>
rr.cards.map((c) => ({
id: c.id,
label: c.label,
supportText: c.supportText,
recommended: c.recommended,
})),
[rr.cards],
);
const cardById = useMemo(
() => new Map(rr.cards.map((c) => [c.id, c])),
[rr.cards],
);
const sidebarDescription = (
<>
{rr.sidebar.descriptionBefore}
<InlineTextButton
onClick={() => {
markCreateFlowInteraction();
setExpanded(true);
}}
>
{rr.sidebar.descriptionLinkLabel}
</InlineTextButton>
{rr.sidebar.descriptionAfter}
</>
);
const handleMessageBoxCheckboxChange = useCallback(
(id: string, checked: boolean) => {
markCreateFlowInteraction();
setMessageBoxCheckedIds((prev) =>
checked ? [...prev, id] : prev.filter((x) => x !== id),
);
},
[markCreateFlowInteraction],
);
const handleCardSelect = useCallback(
(id: string) => {
markCreateFlowInteraction();
setPendingCardId(id);
setCreateModalOpen(true);
},
[markCreateFlowInteraction],
);
const handleToggleExpand = useCallback(() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}, [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]);
const modalConfig = (() => {
if (!pendingCardId) {
return {
title: rr.confirmModal.title,
description: rr.confirmModal.description,
nextButtonText: rr.confirmModal.nextButtonText,
};
}
if (pendingCardId in rr.modals) {
const modal = rr.modals[pendingCardId as keyof typeof rr.modals];
return {
title: modal.title,
description: modal.description,
nextButtonText: rr.addApproach.nextButtonText,
};
}
const card = cardById.get(pendingCardId);
return {
title: card?.label ?? rr.confirmModal.title,
description: card?.supportText ?? rr.confirmModal.description,
nextButtonText: rr.addApproach.nextButtonText,
};
})();
return (
<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>
<Create
isOpen={createModalOpen}
onClose={handleCreateModalClose}
onNext={handleCreateModalConfirm}
title={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={false}
backdropVariant="loginYellow"
>
{pendingCardId ? (
<AddDecisionApproachModalContent
key={pendingCardId}
approachCardId={pendingCardId}
/>
) : null}
</Create>
</CreateFlowTwoColumnSelectShell>
);
}
@@ -1,141 +0,0 @@
"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";
import type { InfoMessageBoxItem } from "../../../components/utility/InfoMessageBox/InfoMessageBox.types";
import type { CardStackItem } from "../../../components/utility/CardStack/CardStack.types";
import { useMessages } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
export function RightRailScreen() {
const m = useMessages();
const rr = m.create.rightRail;
const mdUp = useCreateFlowMdUp();
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = 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) => ({
id: item.id,
label: item.label,
})),
[rr.messageBox.items],
);
const sampleCards: CardStackItem[] = useMemo(
() =>
rr.cards.map((c) => ({
id: c.id,
label: c.label,
supportText: c.supportText,
recommended: c.recommended,
})),
[rr.cards],
);
const sidebarDescription = (
<>
{rr.sidebar.descriptionBefore}
<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}
</>
);
const handleMessageBoxCheckboxChange = useCallback(
(id: string, checked: boolean) => {
markCreateFlowInteraction();
setMessageBoxCheckedIds((prev) =>
checked ? [...prev, id] : prev.filter((x) => x !== id),
);
},
[markCreateFlowInteraction],
);
const handleCardSelect = useCallback(
(id: string) => {
markCreateFlowInteraction();
setSelectedIds(
selectedIds.includes(id)
? selectedIds.filter((x) => x !== id)
: [...selectedIds, id],
);
},
[markCreateFlowInteraction, selectedIds, setSelectedIds],
);
const handleToggleExpand = useCallback(() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}, [markCreateFlowInteraction]);
return (
<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>
</CreateFlowTwoColumnSelectShell>
);
}