Implement modals across create flow
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
|
||||
export interface InlineTextButtonProps {
|
||||
/**
|
||||
* Button label content.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Click handler.
|
||||
*/
|
||||
onClick?: (_event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
/**
|
||||
* Extra class names. Use `className` to override typography/color when the
|
||||
* button must inherit parent font-size/leading (e.g. mid-paragraph usage).
|
||||
*/
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
}
|
||||
|
||||
/**
|
||||
* Small text-styled button for in-paragraph "link"-like controls (expand,
|
||||
* add, etc.). The Figma "link" treatment is a tertiary-colored underline with
|
||||
* a 3px underline-offset and inherited typography, which sits between a real
|
||||
* anchor and a styled `Button`.
|
||||
*
|
||||
* Use this anywhere a `<button>` is needed inline with body copy — do not use
|
||||
* for primary/secondary actions (reach for `Button` instead).
|
||||
*/
|
||||
function InlineTextButtonComponent({
|
||||
children,
|
||||
onClick,
|
||||
className = "",
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
}: InlineTextButtonProps) {
|
||||
const baseClasses =
|
||||
"cursor-pointer border-none bg-transparent p-0 font-inter font-normal text-[length:inherit] leading-[inherit] text-[color:var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)] disabled:cursor-not-allowed disabled:opacity-60";
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
className={`${baseClasses} ${className}`.trim()}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
InlineTextButtonComponent.displayName = "InlineTextButton";
|
||||
|
||||
export default memo(InlineTextButtonComponent);
|
||||
@@ -33,6 +33,13 @@ export interface ChipProps {
|
||||
*/
|
||||
size?: ChipSizeValue;
|
||||
className?: string;
|
||||
/**
|
||||
* Whether the chip should be non-interactive. Defaults to `true` when
|
||||
* `state === "disabled"` to preserve historical behavior. Pass
|
||||
* `disabled={false}` alongside `state="Disabled"` to render the dimmed
|
||||
* "disabled" visual while keeping the chip clickable — useful for toggle
|
||||
* groups where the unselected state is the disabled Figma visual.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
/**
|
||||
|
||||
@@ -20,7 +20,10 @@ function ChipView({
|
||||
inputRef,
|
||||
ariaLabel,
|
||||
}: ChipViewProps) {
|
||||
const isDisabled = disabled || state === "disabled";
|
||||
// The container is the source of truth for `disabled`. This allows
|
||||
// `state="disabled"` to be used purely as a visual (for toggle-group chips
|
||||
// that look dimmed while remaining clickable) by passing `disabled={false}`.
|
||||
const isDisabled = disabled ?? false;
|
||||
const isSelected = state === "selected";
|
||||
const isCustom = state === "custom";
|
||||
|
||||
@@ -57,11 +60,13 @@ function ChipView({
|
||||
} else if (state === "disabled") {
|
||||
background = "bg-[var(--color-surface-default-secondary,#141414)]"; // dark background
|
||||
border = "border-none";
|
||||
textColor = "text-[color:var(--color-content-default-tertiary,#b4b4b4)]";
|
||||
// Per Figma (node 19839:13842) disabled uses invert-tertiary for the
|
||||
// strongly dimmed look, not default-tertiary.
|
||||
textColor = "text-[color:var(--color-content-invert-tertiary,#2d2d2d)]";
|
||||
} else if (isSelected) {
|
||||
background = "bg-[var(--color-surface-inverse-brandaccent,#fdfaa8)]"; // yellow selected
|
||||
background = "bg-[var(--color-surface-invert-brand-primary,#fefcc9)]"; // yellow selected
|
||||
border = `${borderWidth} border-[var(--color-border-default-brand-primary,#fdfaa8)]`;
|
||||
textColor = "text-[color:var(--color-content-inverse-primary,black)]";
|
||||
textColor = "text-[color:var(--color-content-invert-primary,black)]";
|
||||
} else {
|
||||
// Unselected default
|
||||
background =
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
|
||||
export interface IncrementerProps {
|
||||
value: number;
|
||||
/** Minimum value (default `-Infinity`). */
|
||||
min?: number;
|
||||
/** Maximum value (default `Infinity`). */
|
||||
max?: number;
|
||||
/** Step size applied to +/- actions (default `1`). */
|
||||
step?: number;
|
||||
onChange: (_next: number) => void;
|
||||
/**
|
||||
* Optional formatter for the displayed value. Receives the raw number and
|
||||
* should return the rendered content. Default: `String(value)`.
|
||||
*/
|
||||
formatValue?: (_value: number) => React.ReactNode;
|
||||
/**
|
||||
* When true, the whole incrementer is non-interactive and the value renders
|
||||
* in the "inactive" (tertiary) color per Figma.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/** Accessible label for the decrement button (default "Decrease"). */
|
||||
decrementAriaLabel?: string;
|
||||
/** Accessible label for the increment button (default "Increase"). */
|
||||
incrementAriaLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const STEP_BUTTON_CLASSES =
|
||||
"bg-[var(--color-surface-default-secondary,#141414)] text-[var(--color-content-default-primary,#fff)] inline-flex shrink-0 items-center justify-center overflow-clip rounded-[var(--measures-radius-full,9999px)] px-[var(--space-200,8px)] py-[var(--measures-spacing-150,6px)] transition-[background,color,transform] duration-200 ease-in-out hover:scale-[1.02] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary,#fff)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary,#000)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100";
|
||||
|
||||
function MinusIcon() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
viewBox="0 0 24 24"
|
||||
className="block size-[12px]"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5 12h14"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PlusIcon() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
viewBox="0 0 24 24"
|
||||
className="block size-[12px]"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 5v14M5 12h14"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Figma: "Control / Incrementer" (`17857:30943`). A compact `[ - value + ]`
|
||||
* row used for numeric step inputs (e.g. a percentage setting).
|
||||
*
|
||||
* For a labelled variant that matches "Control / Incrementer Block"
|
||||
* (`19883:13283`), compose with {@link IncrementerBlock} instead.
|
||||
*/
|
||||
function IncrementerComponent({
|
||||
value,
|
||||
min = Number.NEGATIVE_INFINITY,
|
||||
max = Number.POSITIVE_INFINITY,
|
||||
step = 1,
|
||||
onChange,
|
||||
formatValue,
|
||||
disabled = false,
|
||||
decrementAriaLabel = "Decrease",
|
||||
incrementAriaLabel = "Increase",
|
||||
className = "",
|
||||
}: IncrementerProps) {
|
||||
const clampedValue = Math.min(Math.max(value, min), max);
|
||||
const atMin = clampedValue <= min;
|
||||
const atMax = clampedValue >= max;
|
||||
|
||||
const decrement = () => {
|
||||
if (disabled || atMin) return;
|
||||
onChange(Math.max(min, clampedValue - step));
|
||||
};
|
||||
const increment = () => {
|
||||
if (disabled || atMax) return;
|
||||
onChange(Math.min(max, clampedValue + step));
|
||||
};
|
||||
|
||||
const valueColor = disabled
|
||||
? "text-[color:var(--color-content-default-tertiary,#b4b4b4)]"
|
||||
: "text-[color:var(--color-content-default-primary,#fff)]";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex items-center gap-[16px] ${className}`.trim()}
|
||||
data-figma-node="17857:30943"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={decrement}
|
||||
disabled={disabled || atMin}
|
||||
aria-label={decrementAriaLabel}
|
||||
className={STEP_BUTTON_CLASSES}
|
||||
>
|
||||
<MinusIcon />
|
||||
</button>
|
||||
<span
|
||||
aria-live="polite"
|
||||
className={`shrink-0 whitespace-nowrap font-inter text-[length:var(--sizing-350,14px)] font-medium leading-[18px] ${valueColor}`}
|
||||
>
|
||||
{formatValue ? formatValue(clampedValue) : clampedValue}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={increment}
|
||||
disabled={disabled || atMax}
|
||||
aria-label={incrementAriaLabel}
|
||||
className={STEP_BUTTON_CLASSES}
|
||||
>
|
||||
<PlusIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
IncrementerComponent.displayName = "Incrementer";
|
||||
|
||||
export default memo(IncrementerComponent);
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import Incrementer, { type IncrementerProps } from "./Incrementer";
|
||||
import InputLabel from "../../utility/InputLabel";
|
||||
import type {
|
||||
InputLabelPaletteValue,
|
||||
InputLabelSizeValue,
|
||||
} from "../../utility/InputLabel/InputLabel.types";
|
||||
|
||||
export interface IncrementerBlockProps extends IncrementerProps {
|
||||
/** Label text displayed above the incrementer. */
|
||||
label: string;
|
||||
/** Show the help "?" icon next to the label. Defaults to `true`. */
|
||||
helpIcon?: boolean;
|
||||
/**
|
||||
* Helper text shown to the right of the label. Pass a string or `true` to
|
||||
* render the default "Optional text".
|
||||
*/
|
||||
helperText?: boolean | string;
|
||||
/** Show an asterisk indicating a required field. */
|
||||
asterisk?: boolean;
|
||||
/**
|
||||
* Size of the label (`"s"` or `"m"`). Defaults to `"s"` to match the Figma
|
||||
* "Incrementer Block" spec.
|
||||
*/
|
||||
labelSize?: InputLabelSizeValue;
|
||||
/** Palette. Defaults to `"default"`. */
|
||||
palette?: InputLabelPaletteValue;
|
||||
/**
|
||||
* Class applied to the root `<div>` wrapping the label + incrementer. Use
|
||||
* this to control the block's layout width (e.g. `w-full`).
|
||||
*/
|
||||
blockClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Figma: "Control / Incrementer Block" (`19883:13283`). An `InputLabel` plus
|
||||
* an {@link Incrementer} row, stacked with a 12px gap.
|
||||
*/
|
||||
function IncrementerBlockComponent({
|
||||
label,
|
||||
helpIcon = true,
|
||||
helperText,
|
||||
asterisk,
|
||||
labelSize = "s",
|
||||
palette = "default",
|
||||
blockClassName = "",
|
||||
className,
|
||||
...incrementerProps
|
||||
}: IncrementerBlockProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-start gap-[var(--measures-spacing-300,12px)] py-[8px] ${blockClassName}`.trim()}
|
||||
data-figma-node="19883:13283"
|
||||
>
|
||||
<InputLabel
|
||||
label={label}
|
||||
helpIcon={helpIcon}
|
||||
helperText={helperText}
|
||||
asterisk={asterisk}
|
||||
size={labelSize}
|
||||
palette={palette}
|
||||
/>
|
||||
<Incrementer {...incrementerProps} className={className} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
IncrementerBlockComponent.displayName = "IncrementerBlock";
|
||||
|
||||
export default memo(IncrementerBlockComponent);
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default } from "./Incrementer";
|
||||
export { default as Incrementer } from "./Incrementer";
|
||||
export { default as IncrementerBlock } from "./IncrementerBlock";
|
||||
export type { IncrementerProps } from "./Incrementer";
|
||||
export type { IncrementerBlockProps } from "./IncrementerBlock";
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user