Implement core value modals

This commit is contained in:
adilallo
2026-04-15 23:13:28 -06:00
parent beae150f02
commit eedb70f9f3
15 changed files with 806 additions and 101 deletions
@@ -25,6 +25,7 @@ const CreateContainer = memo<CreateProps>(
className = "",
ariaLabel,
ariaLabelledBy,
backdropVariant = "default",
}) => {
const createRef = useRef<HTMLDivElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
@@ -132,6 +133,7 @@ const CreateContainer = memo<CreateProps>(
ariaLabelledBy={ariaLabelledBy}
createRef={createRef}
overlayRef={overlayRef}
backdropVariant={backdropVariant}
/>
);
},
@@ -27,6 +27,11 @@ export interface CreateProps {
multiSelect?: boolean;
upload?: boolean;
proportion?: boolean;
/**
* Backdrop behind the dialog. `loginYellow` matches the Login modals blurred brand overlay.
* @default "default"
*/
backdropVariant?: "default" | "loginYellow";
}
export interface CreateViewProps {
@@ -51,4 +56,5 @@ export interface CreateViewProps {
ariaLabelledBy?: string;
createRef: React.RefObject<HTMLDivElement>;
overlayRef: React.RefObject<HTMLDivElement>;
backdropVariant: "default" | "loginYellow";
}
+11 -1
View File
@@ -6,6 +6,15 @@ import ModalFooter from "../../utility/ModalFooter";
import ModalHeader from "../../utility/ModalHeader";
import type { CreateViewProps } from "./Create.types";
const backdropOverlayClasses: Record<
CreateViewProps["backdropVariant"],
string
> = {
default: "fixed inset-0 bg-black/50 z-[9998]",
loginYellow:
"fixed inset-0 z-[9998] bg-[var(--color-surface-inverse-brand-primary)]/85 backdrop-blur-md supports-[backdrop-filter]:bg-[var(--color-surface-inverse-brand-primary)]/75",
};
export function CreateView({
isOpen,
onClose,
@@ -28,6 +37,7 @@ export function CreateView({
ariaLabelledBy,
createRef,
overlayRef,
backdropVariant,
}: CreateViewProps) {
if (!isOpen) return null;
@@ -36,7 +46,7 @@ export function CreateView({
{/* Overlay */}
<div
ref={overlayRef}
className="fixed inset-0 bg-black/50 z-[9998]"
className={backdropOverlayClasses[backdropVariant]}
onClick={onClose}
aria-hidden="true"
/>
+31 -3
View File
@@ -20,6 +20,11 @@ import {
readAnonymousCreateFlowState,
writeAnonymousCreateFlowState,
} from "../utils/anonymousDraftStorage";
import {
clearCoreValueDetailsLocalStorage,
readCoreValueDetailsFromLocalStorage,
writeCoreValueDetailsToLocalStorage,
} from "../utils/coreValueDetailsLocalStorage";
const CreateFlowContext = createContext<CreateFlowContextValue | null>(null);
@@ -41,9 +46,20 @@ export function CreateFlowProvider({
initialStep = null,
enableAnonymousPersistence = false,
}: CreateFlowProviderProps) {
const [state, setState] = useState<CreateFlowState>(() =>
enableAnonymousPersistence ? readAnonymousCreateFlowState() : {},
);
const [state, setState] = useState<CreateFlowState>(() => {
const base = enableAnonymousPersistence
? readAnonymousCreateFlowState()
: {};
const storedDetails = readCoreValueDetailsFromLocalStorage();
if (Object.keys(storedDetails).length === 0) return base;
return {
...base,
coreValueDetailsByChipId: {
...storedDetails,
...(base.coreValueDetailsByChipId ?? {}),
},
};
});
const [interactionTouched, setInteractionTouched] = useState(false);
const [currentStep] = useState<CreateFlowStep | null>(initialStep);
const prevPersistRef = useRef(enableAnonymousPersistence);
@@ -72,6 +88,11 @@ export function CreateFlowProvider({
writeAnonymousCreateFlowState(state);
}, [state, enableAnonymousPersistence]);
/** Meaning/signals for core values: survives refresh for signed-in users; merged with anonymous draft when both exist. */
useEffect(() => {
writeCoreValueDetailsToLocalStorage(state.coreValueDetailsByChipId);
}, [state.coreValueDetailsByChipId]);
const markCreateFlowInteraction = useCallback(() => {
setInteractionTouched(true);
}, []);
@@ -85,6 +106,12 @@ export function CreateFlowProvider({
...updates.communityStructureChipSnapshots,
};
}
if (updates.coreValueDetailsByChipId !== undefined) {
merged.coreValueDetailsByChipId = {
...(prevState.coreValueDetailsByChipId ?? {}),
...updates.coreValueDetailsByChipId,
};
}
return merged;
});
}, []);
@@ -97,6 +124,7 @@ export function CreateFlowProvider({
setState({});
setInteractionTouched(false);
clearAnonymousCreateFlowStorage();
clearCoreValueDetailsLocalStorage();
}, []);
const contextValue: CreateFlowContextValue = {
@@ -1,8 +1,11 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import MultiSelect from "../../../components/controls/MultiSelect";
import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types";
import TextArea from "../../../components/controls/TextArea";
import Create from "../../../components/modals/Create";
import ContentLockup from "../../../components/type/ContentLockup";
import { useMessages } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import type { CommunityStructureChipSnapshotRow } from "../../types";
@@ -11,10 +14,38 @@ import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoCo
const MAX_CORE_VALUES = 5;
function chipRowsFromLabels(rows: readonly string[]): ChipOption[] {
return rows.map((label, i) => ({
type ModalSession = "pending" | "editing";
/** Row in `coreValues.json` `values` — string (legacy) or `{ label, meaning, signals }`. */
type CoreValuePresetJson =
| string
| { label: string; meaning?: string; signals?: string };
type CoreValuePreset = {
label: string;
meaning: string;
signals: string;
};
function normalizeCoreValuePresets(
values: readonly CoreValuePresetJson[],
): CoreValuePreset[] {
return values.map((v) => {
if (typeof v === "string") {
return { label: v, meaning: "", signals: "" };
}
return {
label: v.label,
meaning: typeof v.meaning === "string" ? v.meaning : "",
signals: typeof v.signals === "string" ? v.signals : "",
};
});
}
function chipRowsFromPresets(presets: readonly CoreValuePreset[]): ChipOption[] {
return presets.map((row, i) => ({
id: String(i + 1),
label,
label: row.label,
state: "Unselected" as const,
}));
}
@@ -69,7 +100,11 @@ function snapshotRowsToChipOptions(
export function CoreValuesSelectScreen() {
const m = useMessages();
const cv = m.create.coreValues;
const presetLabels = cv.values;
const presets = useMemo(
() => normalizeCoreValuePresets(cv.values as CoreValuePresetJson[]),
[cv.values],
);
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
const [coreValueOptions, setCoreValueOptions] = useState<ChipOption[]>(
@@ -77,12 +112,19 @@ export function CoreValuesSelectScreen() {
const fromSnap = snapshotRowsToChipOptions(state.coreValuesChipsSnapshot);
if (fromSnap) return fromSnap;
return applySavedSelection(
chipRowsFromLabels(presetLabels),
chipRowsFromPresets(presets),
state.selectedCoreValueIds,
);
},
);
const [activeModalChipId, setActiveModalChipId] = useState<string | null>(
null,
);
const [modalSession, setModalSession] = useState<ModalSession | null>(null);
const [draftMeaning, setDraftMeaning] = useState("");
const [draftSignals, setDraftSignals] = useState("");
useEffect(() => {
const fromSnap = snapshotRowsToChipOptions(state.coreValuesChipsSnapshot);
if (fromSnap) {
@@ -94,40 +136,121 @@ export function CoreValuesSelectScreen() {
);
}, [state.coreValuesChipsSnapshot, state.selectedCoreValueIds]);
const persistCoreValues = useCallback(
/** Sync chips to create-flow draft. Never call `updateState` from inside a `setCoreValueOptions` updater — defer with `queueMicrotask`. */
const syncCoreValuesToDraft = useCallback(
(next: ChipOption[]) => {
markCreateFlowInteraction();
setCoreValueOptions(next);
updateState({
selectedCoreValueIds: selectedIdsFromOptions(next),
coreValuesChipsSnapshot: chipOptionsToSnapshotRows(next),
});
},
[markCreateFlowInteraction, updateState],
[updateState],
);
const persistCoreValues = useCallback(
(next: ChipOption[]) => {
markCreateFlowInteraction();
setCoreValueOptions(next);
syncCoreValuesToDraft(next);
},
[markCreateFlowInteraction, syncCoreValuesToDraft],
);
/** Default meaning/signals from `coreValues.json` `values` for each preset label. */
const getPresetTexts = useCallback(
(valueLabel: string): { meaning: string; signals: string } => {
const row = presets.find((p) => p.label === valueLabel);
if (!row) return { meaning: "", signals: "" };
return { meaning: row.meaning, signals: row.signals };
},
[presets],
);
const getInitialTexts = useCallback(
(chipId: string, valueLabel: string) => {
const saved = state.coreValueDetailsByChipId?.[chipId];
const preset = getPresetTexts(valueLabel);
return {
meaning: saved?.meaning ?? preset.meaning,
signals: saved?.signals ?? preset.signals,
};
},
[state.coreValueDetailsByChipId, getPresetTexts],
);
const openModal = useCallback(
(chipId: string, session: ModalSession, valueLabel: string) => {
const initial = getInitialTexts(chipId, valueLabel);
setDraftMeaning(initial.meaning);
setDraftSignals(initial.signals);
setActiveModalChipId(chipId);
setModalSession(session);
markCreateFlowInteraction();
},
[getInitialTexts, markCreateFlowInteraction],
);
const handleModalDismiss = useCallback(() => {
if (activeModalChipId && modalSession === "pending") {
const next = coreValueOptions.map((opt) =>
opt.id === activeModalChipId
? { ...opt, state: "Unselected" as const }
: opt,
);
persistCoreValues(next);
}
setActiveModalChipId(null);
setModalSession(null);
}, [activeModalChipId, modalSession, coreValueOptions, persistCoreValues]);
const handleModalConfirm = useCallback(() => {
if (!activeModalChipId) return;
markCreateFlowInteraction();
updateState({
coreValueDetailsByChipId: {
[activeModalChipId]: {
meaning: draftMeaning,
signals: draftSignals,
},
},
});
setActiveModalChipId(null);
setModalSession(null);
}, [
activeModalChipId,
draftMeaning,
draftSignals,
markCreateFlowInteraction,
updateState,
]);
const handleChipClick = (chipId: string) => {
const target = coreValueOptions.find((o) => o.id === chipId);
if (!target || target.state === "Custom") return;
const willSelect = target.state !== "Selected";
const selectedCount = coreValueOptions.filter(
(o) => o.state === "Selected",
).length;
if (willSelect && selectedCount >= MAX_CORE_VALUES) return;
if (target.state === "Selected") {
const next: ChipOption[] = coreValueOptions.map((opt) =>
opt.id === chipId
? { ...opt, state: "Unselected" as const }
: opt,
);
persistCoreValues(next);
return;
}
if (selectedCount >= MAX_CORE_VALUES) return;
const next: ChipOption[] = coreValueOptions.map((opt) =>
opt.id === chipId
? {
...opt,
state:
opt.state === "Selected"
? ("Unselected" as const)
: ("Selected" as const),
}
? { ...opt, state: "Selected" as const }
: opt,
);
persistCoreValues(next);
openModal(chipId, "pending", target.label);
};
const addHandlers = {
@@ -138,24 +261,37 @@ export function CoreValuesSelectScreen() {
...prev,
{ id: crypto.randomUUID(), label: "", state: "Custom" },
];
updateState({
selectedCoreValueIds: selectedIdsFromOptions(next),
coreValuesChipsSnapshot: chipOptionsToSnapshotRows(next),
});
queueMicrotask(() => syncCoreValuesToDraft(next));
return next;
});
},
onCustomChipConfirm: (chipId: string, value: string) => {
markCreateFlowInteraction();
setCoreValueOptions((prev) => {
const next = prev.map((opt) =>
const withLabel = prev.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: "Unselected" as const }
: opt,
);
updateState({
selectedCoreValueIds: selectedIdsFromOptions(next),
coreValuesChipsSnapshot: chipOptionsToSnapshotRows(next),
const selectedCount = withLabel.filter(
(o) => o.state === "Selected",
).length;
const canSelect = selectedCount < MAX_CORE_VALUES;
const next = canSelect
? withLabel.map((opt) =>
opt.id === chipId
? { ...opt, state: "Selected" as const }
: opt,
)
: withLabel;
queueMicrotask(() => {
syncCoreValuesToDraft(next);
if (canSelect) {
openModal(chipId, "pending", value);
} else {
openModal(chipId, "editing", value);
}
});
return next;
});
@@ -164,15 +300,15 @@ export function CoreValuesSelectScreen() {
markCreateFlowInteraction();
setCoreValueOptions((prev) => {
const next = prev.filter((o) => o.id !== chipId);
updateState({
selectedCoreValueIds: selectedIdsFromOptions(next),
coreValuesChipsSnapshot: chipOptionsToSnapshotRows(next),
});
queueMicrotask(() => syncCoreValuesToDraft(next));
return next;
});
},
};
const modalChipLabel =
coreValueOptions.find((o) => o.id === activeModalChipId)?.label ?? "";
const description = (
<>
<span className="leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)]">
@@ -192,6 +328,8 @@ export function CoreValuesSelectScreen() {
</>
);
const detailModal = cv.detailModal;
return (
<CreateFlowTwoColumnSelectShell
lgVerticalAlign="start"
@@ -214,6 +352,50 @@ export function CoreValuesSelectScreen() {
addButton
addButtonText={cv.multiSelect.addButtonText}
/>
{detailModal && (
<Create
isOpen={activeModalChipId !== null}
onClose={handleModalDismiss}
backdropVariant="loginYellow"
headerContent={
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
<ContentLockup
title={modalChipLabel}
description={detailModal.subtitle}
variant="modal"
alignment="left"
/>
</div>
}
showBackButton={false}
showNextButton
onNext={handleModalConfirm}
nextButtonText={detailModal.addValueButton}
ariaLabel={modalChipLabel || "Core value details"}
>
<div className="flex flex-col gap-[var(--measures-spacing-600,24px)] pb-2">
<TextArea
label={detailModal.meaningLabel}
showHelpIcon
appearance="embedded"
size="medium"
value={draftMeaning}
onChange={(e) => setDraftMeaning(e.target.value)}
rows={4}
/>
<TextArea
label={detailModal.signalsLabel}
showHelpIcon
appearance="embedded"
size="medium"
value={draftSignals}
onChange={(e) => setDraftSignals(e.target.value)}
rows={4}
/>
</div>
</Create>
)}
</CreateFlowTwoColumnSelectShell>
);
}
+8
View File
@@ -42,6 +42,12 @@ export type CommunityStructureChipSnapshotRow = {
state?: string;
};
/** Meaning + violation signals copy for a core value chip (draft + publish). */
export type CoreValueDetailEntry = {
meaning: string;
signals: string;
};
/**
* Flow state for inputs across create-flow steps.
* Validated on `PUT /api/drafts/me` via `createFlowStateSchema` (Zod + JSON safety checks).
@@ -75,6 +81,8 @@ export interface CreateFlowState {
selectedCoreValueIds?: string[];
/** Full chip rows for core values (custom labels). */
coreValuesChipsSnapshot?: CommunityStructureChipSnapshotRow[];
/** User-authored detail text keyed by chip id (preset ids or custom UUIDs). */
coreValueDetailsByChipId?: Record<string, CoreValueDetailEntry>;
currentStep?: CreateFlowStep;
/** Section drafts; structure will tighten as steps persist real shapes. */
sections?: Record<string, unknown>[];
@@ -0,0 +1,57 @@
import type { CoreValueDetailEntry } from "../types";
/** Persists meaning/signals per chip id across refresh (esp. signed-in create flow, in-memory only). */
export const CORE_VALUE_DETAILS_STORAGE_KEY =
"create-flow-core-value-details" as const;
export function readCoreValueDetailsFromLocalStorage(): Record<
string,
CoreValueDetailEntry
> {
if (typeof window === "undefined") return {};
try {
const raw = window.localStorage.getItem(CORE_VALUE_DETAILS_STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object") return {};
const out: Record<string, CoreValueDetailEntry> = {};
for (const [k, v] of Object.entries(parsed)) {
if (!v || typeof v !== "object") continue;
const o = v as Record<string, unknown>;
if (typeof o.meaning !== "string" || typeof o.signals !== "string") {
continue;
}
out[k] = { meaning: o.meaning, signals: o.signals };
}
return out;
} catch {
return {};
}
}
export function writeCoreValueDetailsToLocalStorage(
value: Record<string, CoreValueDetailEntry> | undefined,
): void {
if (typeof window === "undefined") return;
try {
if (!value || Object.keys(value).length === 0) {
window.localStorage.removeItem(CORE_VALUE_DETAILS_STORAGE_KEY);
return;
}
window.localStorage.setItem(
CORE_VALUE_DETAILS_STORAGE_KEY,
JSON.stringify(value),
);
} catch {
// quota / private mode
}
}
export function clearCoreValueDetailsLocalStorage(): void {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(CORE_VALUE_DETAILS_STORAGE_KEY);
} catch {
// ignore
}
}