Implement core-values screen

This commit is contained in:
adilallo
2026-04-15 22:14:46 -06:00
parent b15f0d6226
commit beae150f02
16 changed files with 460 additions and 77 deletions
+38 -29
View File
@@ -302,6 +302,11 @@ function CreateFlowLayoutContent({
const isRightRailStep = currentStep === "right-rail";
const isFinalReviewStep = currentStep === "final-review";
const isCardsStep = currentStep === "cards";
/** Two-column select steps: at `lg+` only the right column scrolls; main must not scroll the full page. */
const isSelectSplitScrollStep =
currentStep === "community-size" ||
currentStep === "community-structure" ||
currentStep === "core-values";
const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1;
/** At `md+`, main cross-axis: center by default; exceptions stay top-aligned (see product spec). */
@@ -309,9 +314,11 @@ function CreateFlowLayoutContent({
? "items-stretch overflow-y-auto md:overflow-hidden"
: isRightRailStep
? "items-stretch overflow-hidden"
: isFinalReviewStep || isCardsStep || isTemplateReviewRoute
? "items-start justify-center overflow-y-auto"
: "items-start justify-center overflow-y-auto md:items-center";
: isSelectSplitScrollStep
? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden"
: isFinalReviewStep || isCardsStep || isTemplateReviewRoute
? "items-start justify-center overflow-y-auto"
: "items-start justify-center overflow-y-auto md:items-center";
const isTextStep = createFlowStepUsesCenteredTextLayout(currentStep);
const mainMaxMdJustify =
@@ -479,32 +486,18 @@ function CreateFlowLayoutContent({
</Button>
</div>
) : currentStep === "community-name" && nextStep ? (
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
<Button
buttonType="outline"
palette="inverse"
size="xsmall"
disabled={isPublishing}
className={footerPrimaryButtonClass}
onClick={() => {
goToNextStep();
}}
>
{footer.next}
</Button>
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={isPublishing}
className={footerPrimaryButtonClass}
onClick={() => {
goToNextStep();
}}
>
{footer.confirmName}
</Button>
</div>
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={isPublishing}
className={footerPrimaryButtonClass}
onClick={() => {
goToNextStep();
}}
>
{footer.confirmName}
</Button>
) : currentStep === "community-save" && nextStep ? (
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
<Button
@@ -566,6 +559,22 @@ function CreateFlowLayoutContent({
{footer.createFromTemplate}
</Button>
</div>
) : currentStep === "core-values" && nextStep ? (
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={
isPublishing ||
(state.selectedCoreValueIds?.length ?? 0) === 0
}
className={footerPrimaryButtonClass}
onClick={() => {
goToNextStep();
}}
>
{footer.confirmCoreValues}
</Button>
) : nextStep ? (
<Button
buttonType="filled"
@@ -0,0 +1,75 @@
"use client";
import type { ReactNode } from "react";
import { CreateFlowStepShell } from "./CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "./createFlowLayoutTokens";
export type CreateFlowSelectShellLgVerticalAlign = "center" | "start";
interface CreateFlowTwoColumnSelectShellProps {
header: ReactNode;
children: ReactNode;
/**
* At `lg+`, layout variant: `"center"` = vertically centered pair (community size/structure).
* `"start"` = top-weighted layout with a scrollable right column (core values): uses `items-stretch`
* so the right column gets a bounded height; `items-start` would grow with content and break scroll.
*/
lgVerticalAlign?: CreateFlowSelectShellLgVerticalAlign;
}
/**
* Two-column layout for create-flow select steps (community size/structure, core values).
* Below `lg`, layout and scrolling match the previous single-column behavior (full page scroll).
* At `lg+`, mirrors {@link CompletedScreen}: static header column + scrollable controls column
* (`min-h-0` + `overflow-y-auto` height chain; see completed page right rail).
*/
export function CreateFlowTwoColumnSelectShell({
header,
children,
lgVerticalAlign = "center",
}: CreateFlowTwoColumnSelectShellProps) {
/** `stretch` is required for `min-h-0` + `overflow-y-auto` on the right column. */
const rowLgCrossAlignClass =
lgVerticalAlign === "start" ? "lg:items-stretch" : "lg:items-center";
const leftLgMainJustifyClass =
lgVerticalAlign === "start" ? "lg:justify-start" : "lg:justify-center";
return (
<CreateFlowStepShell
variant="centeredNarrow"
contentTopBelowMd="space-1400"
className={
/* Below `lg`: natural height — same as legacy select screens (main scrolls). */
/* At `lg+`: fill main + clip so only the right column scrolls (CompletedScreen pattern). */
"w-full min-w-0 max-lg:flex-none lg:min-h-0 lg:h-full lg:max-h-full lg:flex-1 lg:overflow-hidden lg:items-stretch lg:self-stretch"
}
>
<div
className={
"flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-400,16px)] md:max-w-[640px] " +
"max-lg:flex-none lg:max-h-full lg:max-w-[1328px] lg:min-h-0 lg:flex-1 lg:flex-row lg:flex-nowrap " +
`${rowLgCrossAlignClass} lg:justify-center lg:gap-[var(--measures-spacing-1200,48px)] lg:overflow-hidden`
}
>
<div
className={
`flex w-full min-w-0 shrink-0 flex-col items-start gap-[var(--measures-spacing-200,8px)] ` +
`lg:flex-1 ${leftLgMainJustifyClass} lg:py-[12px] lg:max-w-[640px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`
}
>
{header}
</div>
<div
className={
`scrollbar-hide relative flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-800,32px)] ` +
`overflow-x-hidden lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pb-[var(--measures-spacing-300,12px)] ` +
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS
}
>
{children}
</div>
</div>
</CreateFlowStepShell>
);
}
@@ -6,6 +6,7 @@ import { InformationalScreen } from "./informational/InformationalScreen";
import { CreateFlowTextFieldScreen } from "./text/CreateFlowTextFieldScreen";
import { CommunitySizeSelectScreen } from "./select/CommunitySizeSelectScreen";
import { CommunityStructureSelectScreen } from "./select/CommunityStructureSelectScreen";
import { CoreValuesSelectScreen } from "./select/CoreValuesSelectScreen";
import { ConfirmStakeholdersScreen } from "./select/ConfirmStakeholdersScreen";
import { CommunityUploadScreen } from "./upload/CommunityUploadScreen";
import { CommunityReviewScreen } from "./review/CommunityReviewScreen";
@@ -62,6 +63,8 @@ export function CreateFlowScreenView({
);
case "review":
return <CommunityReviewScreen />;
case "core-values":
return <CoreValuesSelectScreen />;
case "cards":
return <CardsScreen />;
case "right-rail":
@@ -6,8 +6,7 @@ import type { ChipOption } from "../../../components/controls/MultiSelect/MultiS
import { useMessages } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
function chipRowsFromLabels(
rows: readonly { label: string }[],
@@ -92,26 +91,16 @@ export function CommunitySizeSelectScreen() {
);
return (
<CreateFlowStepShell
variant="centeredNarrow"
contentTopBelowMd="space-1400"
<CreateFlowTwoColumnSelectShell
header={
<CreateFlowHeaderLockup
title={cs.header.title}
description={cs.header.description}
justification="left"
/>
}
>
<div className="flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-400,16px)] md:max-w-[640px] lg:max-w-[1328px] lg:flex-row lg:flex-nowrap lg:items-center lg:justify-center lg:gap-[var(--measures-spacing-1200,48px)]">
<div
className={`flex flex-col items-start gap-[var(--measures-spacing-200,8px)] lg:flex-1 lg:justify-center lg:py-[12px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
>
<CreateFlowHeaderLockup
title={cs.header.title}
description={cs.header.description}
justification="left"
/>
</div>
<div
className={`flex flex-col items-start gap-[var(--measures-spacing-800,32px)] lg:flex-1 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
>
{multiSelectBlock}
</div>
</div>
</CreateFlowStepShell>
{multiSelectBlock}
</CreateFlowTwoColumnSelectShell>
);
}
@@ -13,8 +13,7 @@ import { useMessages } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import type { CommunityStructureChipSnapshotRow } from "../../types";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
function createListCustomHandlers(
setList: Dispatch<SetStateAction<ChipOption[]>>,
@@ -336,26 +335,16 @@ export function CommunityStructureSelectScreen() {
);
return (
<CreateFlowStepShell
variant="centeredNarrow"
contentTopBelowMd="space-1400"
<CreateFlowTwoColumnSelectShell
header={
<CreateFlowHeaderLockup
title={cs.header.title}
description={cs.header.description}
justification="left"
/>
}
>
<div className="flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-400,16px)] md:max-w-[640px] lg:max-w-[1328px] lg:flex-row lg:flex-nowrap lg:items-center lg:justify-center lg:gap-[var(--measures-spacing-1200,48px)]">
<div
className={`flex flex-col items-start gap-[var(--measures-spacing-200,8px)] lg:flex-1 lg:justify-center lg:py-[12px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
>
<CreateFlowHeaderLockup
title={cs.header.title}
description={cs.header.description}
justification="left"
/>
</div>
<div
className={`flex flex-col items-start gap-[var(--measures-spacing-800,32px)] lg:flex-1 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
>
{multiSelectBlock}
</div>
</div>
</CreateFlowStepShell>
{multiSelectBlock}
</CreateFlowTwoColumnSelectShell>
);
}
@@ -0,0 +1,219 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import MultiSelect from "../../../components/controls/MultiSelect";
import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types";
import { useMessages } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import type { CommunityStructureChipSnapshotRow } from "../../types";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
const MAX_CORE_VALUES = 5;
function chipRowsFromLabels(rows: readonly string[]): ChipOption[] {
return rows.map((label, i) => ({
id: String(i + 1),
label,
state: "Unselected" as const,
}));
}
function applySavedSelection(
options: ChipOption[],
saved: string[] | undefined,
): ChipOption[] {
const selected = new Set(saved ?? []);
return options.map((opt) =>
opt.state === "Custom"
? opt
: {
...opt,
state: selected.has(opt.id)
? ("Selected" as const)
: ("Unselected" as const),
},
);
}
function selectedIdsFromOptions(options: ChipOption[]): string[] {
return options
.filter((o) => o.state === "Selected")
.map((o) => o.id);
}
function chipOptionsToSnapshotRows(
options: ChipOption[],
): CommunityStructureChipSnapshotRow[] {
return options.map((o) => ({
id: o.id,
label: o.label,
...(o.state !== undefined ? { state: o.state } : {}),
}));
}
function snapshotRowsToChipOptions(
rows: CommunityStructureChipSnapshotRow[] | undefined,
): ChipOption[] | null {
if (!Array.isArray(rows) || rows.length === 0) return null;
return rows.map((r) => ({
id: r.id,
label: r.label,
...(r.state !== undefined
? { state: r.state as ChipOption["state"] }
: {}),
}));
}
/** Create Custom — Core Values (Figma `20264:68378`). Up to five selections; preset list + custom chips. */
export function CoreValuesSelectScreen() {
const m = useMessages();
const cv = m.create.coreValues;
const presetLabels = cv.values;
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
const [coreValueOptions, setCoreValueOptions] = useState<ChipOption[]>(
() => {
const fromSnap = snapshotRowsToChipOptions(state.coreValuesChipsSnapshot);
if (fromSnap) return fromSnap;
return applySavedSelection(
chipRowsFromLabels(presetLabels),
state.selectedCoreValueIds,
);
},
);
useEffect(() => {
const fromSnap = snapshotRowsToChipOptions(state.coreValuesChipsSnapshot);
if (fromSnap) {
setCoreValueOptions(fromSnap);
return;
}
setCoreValueOptions((prev) =>
applySavedSelection(prev, state.selectedCoreValueIds),
);
}, [state.coreValuesChipsSnapshot, state.selectedCoreValueIds]);
const persistCoreValues = useCallback(
(next: ChipOption[]) => {
markCreateFlowInteraction();
setCoreValueOptions(next);
updateState({
selectedCoreValueIds: selectedIdsFromOptions(next),
coreValuesChipsSnapshot: chipOptionsToSnapshotRows(next),
});
},
[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;
const next: ChipOption[] = coreValueOptions.map((opt) =>
opt.id === chipId
? {
...opt,
state:
opt.state === "Selected"
? ("Unselected" as const)
: ("Selected" as const),
}
: opt,
);
persistCoreValues(next);
};
const addHandlers = {
onAddClick: () => {
markCreateFlowInteraction();
setCoreValueOptions((prev) => {
const next: ChipOption[] = [
...prev,
{ id: crypto.randomUUID(), label: "", state: "Custom" },
];
updateState({
selectedCoreValueIds: selectedIdsFromOptions(next),
coreValuesChipsSnapshot: chipOptionsToSnapshotRows(next),
});
return next;
});
},
onCustomChipConfirm: (chipId: string, value: string) => {
markCreateFlowInteraction();
setCoreValueOptions((prev) => {
const next = prev.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: "Unselected" as const }
: opt,
);
updateState({
selectedCoreValueIds: selectedIdsFromOptions(next),
coreValuesChipsSnapshot: chipOptionsToSnapshotRows(next),
});
return next;
});
},
onCustomChipClose: (chipId: string) => {
markCreateFlowInteraction();
setCoreValueOptions((prev) => {
const next = prev.filter((o) => o.id !== chipId);
updateState({
selectedCoreValueIds: selectedIdsFromOptions(next),
coreValuesChipsSnapshot: chipOptionsToSnapshotRows(next),
});
return next;
});
},
};
const description = (
<>
<span className="leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)]">
{cv.header.descriptionLead}{" "}
</span>
<button
type="button"
onClick={addHandlers.onAddClick}
className="cursor-pointer font-inter font-normal leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] hover:opacity-90"
>
{cv.header.addLink}
</button>
<span className="leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)]">
{" "}
{cv.header.descriptionTrail}
</span>
</>
);
return (
<CreateFlowTwoColumnSelectShell
lgVerticalAlign="start"
header={
<CreateFlowHeaderLockup
title={cv.header.title}
description={description}
justification="left"
/>
}
>
<MultiSelect
formHeader={false}
size="M"
options={coreValueOptions}
onChipClick={handleChipClick}
onAddClick={addHandlers.onAddClick}
onCustomChipConfirm={addHandlers.onCustomChipConfirm}
onCustomChipClose={addHandlers.onCustomChipClose}
addButton
addButtonText={cv.multiSelect.addButtonText}
/>
</CreateFlowTwoColumnSelectShell>
);
}
+5
View File
@@ -18,6 +18,7 @@ export type CreateFlowStep =
| "community-upload"
| "community-save"
| "review"
| "core-values"
| "cards"
| "right-rail"
| "confirm-stakeholders"
@@ -70,6 +71,10 @@ export interface CreateFlowState {
scale?: CommunityStructureChipSnapshotRow[];
maturity?: CommunityStructureChipSnapshotRow[];
};
/** Create Custom — core values step (max five `selectedCoreValueIds`). */
selectedCoreValueIds?: string[];
/** Full chip rows for core values (custom labels). */
coreValuesChipsSnapshot?: CommunityStructureChipSnapshotRow[];
currentStep?: CreateFlowStep;
/** Section drafts; structure will tighten as steps persist real shapes. */
sections?: Record<string, unknown>[];
@@ -15,6 +15,7 @@ const PROPORTION_BY_STEP_INDEX: readonly ProportionBarState[] = [
"1-5", // community-upload
"2-0", // community-save
"2-0", // review (Figma Flow — Review `19706:12135`: same segment fill as end of Create Community)
"2-0", // core-values (same segment as review / end of Create Community)
"2-2", // cards
"3-0", // right-rail
"3-1", // confirm-stakeholders
@@ -84,6 +84,12 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record<
messageNamespace: "create.review",
centeredBodyBelowMd: false,
},
"core-values": {
layoutKind: "select",
figmaNodeId: "20264-68378",
messageNamespace: "create.coreValues",
centeredBodyBelowMd: false,
},
cards: {
layoutKind: "card",
figmaNodeId: "TBD-cards",
+1
View File
@@ -20,6 +20,7 @@ export const FLOW_STEP_ORDER: readonly CreateFlowStep[] = [
"community-upload",
"community-save",
"review",
"core-values",
"cards",
"right-rail",
"confirm-stakeholders",
@@ -51,6 +51,10 @@ export const createFlowStateSchema = z
selectedMaturityIds: z.array(z.string()).optional(),
communityStructureChipSnapshots:
communityStructureChipSnapshotsSchema.optional(),
selectedCoreValueIds: z.array(z.string()).max(200).optional(),
coreValuesChipsSnapshot: z
.array(communityStructureChipSnapshotRowSchema)
.optional(),
currentStep: createFlowStepSchema.optional(),
sections: z.array(z.unknown()).optional(),
stakeholders: z.array(z.unknown()).optional(),
+75
View File
@@ -0,0 +1,75 @@
{
"header": {
"title": "Choose up to five Core Values",
"descriptionLead": "What does the community hold most dear? You can also combine or",
"addLink": "add",
"descriptionTrail": "new values to the list."
},
"multiSelect": {
"addButtonText": "Add value"
},
"values": [
"Accessibility",
"Accountability",
"Adaptability",
"Agency",
"Altruism",
"Anti-oppression",
"Autonomy",
"Capacity Building",
"Collaboration",
"Common Ownership",
"Community Care",
"Conflict Resolution",
"Consent",
"Consensus",
"Constructive Feedback",
"Cooperation",
"Copyleft",
"Decentralization",
"Direct Action",
"Diversity",
"Documentation",
"Education",
"Empathy",
"Empowerment",
"Equity",
"Experimentation",
"Fairness",
"Fair Remuneration",
"Flexibility",
"Forkability",
"Freedom",
"Freedom of Information",
"Generosity",
"Harm Reduction",
"Holism",
"Holocracy",
"Honesty",
"Horizontalism",
"Humility",
"Inclusion",
"Inclusivity",
"Independence",
"Innovation",
"Integrity",
"Interdependence",
"Interoperability",
"Intersectionality",
"Justice",
"Knowledge Sharing",
"Labor Rights",
"Leadership",
"Learning",
"Liberty",
"Localism",
"Maintenance",
"Mentorship",
"Meritocracy",
"Mutual Aid",
"Non-violence",
"Open Source",
"Openness",
"Participation"
]
}
+2 -1
View File
@@ -10,5 +10,6 @@
"confirmDescription": "Confirm description",
"confirmMembers": "Confirm members",
"finalizeCommunityRule": "Finalize CommunityRule",
"confirmStakeholders": "Confirm Stakeholders"
"confirmStakeholders": "Confirm Stakeholders",
"confirmCoreValues": "Confirm values"
}
+2
View File
@@ -27,6 +27,7 @@ import createCommunityStructure from "./create/communityStructure.json";
import createCommunityUpload from "./create/communityUpload.json";
import createCommunitySave from "./create/communitySave.json";
import createReview from "./create/review.json";
import createCoreValues from "./create/coreValues.json";
import createConfirmStakeholders from "./create/confirmStakeholders.json";
import createFinalReview from "./create/finalReview.json";
import createCompleted from "./create/completed.json";
@@ -68,6 +69,7 @@ export default {
communityUpload: createCommunityUpload,
communitySave: createCommunitySave,
review: createReview,
coreValues: createCoreValues,
confirmStakeholders: createConfirmStakeholders,
finalReview: createFinalReview,
completed: createCompleted,
@@ -17,10 +17,13 @@ describe("getProportionBarProgressForCreateFlowStep", () => {
);
});
it("uses 2-0 on community-save and review (end of Create Community segment)", () => {
it("uses 2-0 on community-save, review, and core-values (Create Community segment / same fill)", () => {
expect(getProportionBarProgressForCreateFlowStep("community-save")).toBe(
"2-0",
);
expect(getProportionBarProgressForCreateFlowStep("review")).toBe("2-0");
expect(getProportionBarProgressForCreateFlowStep("core-values")).toBe(
"2-0",
);
});
});
+3 -2
View File
@@ -36,6 +36,7 @@ describe("flowSteps", () => {
it("isValidStep reflects FLOW_STEP_ORDER membership", () => {
expect(isValidStep("community-size")).toBe(true);
expect(isValidStep("confirm-stakeholders")).toBe(true);
expect(isValidStep("core-values")).toBe(true);
expect(isValidStep("nope")).toBe(false);
expect(isValidStep(null)).toBe(false);
});
@@ -65,7 +66,7 @@ describe("flowSteps", () => {
it("skipCommunitySave does not change steps outside the save segment", () => {
const opts = { skipCommunitySave: true } as const;
expect(getNextStep("community-size", opts)).toBe("community-upload");
expect(getNextStep("review", opts)).toBe("cards");
expect(getPreviousStep("cards", opts)).toBe("review");
expect(getNextStep("review", opts)).toBe("core-values");
expect(getPreviousStep("cards", opts)).toBe("core-values");
});
});