From 20ea4a2f30c913837dd012f53c0a5db9935b006e Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:59:00 -0600 Subject: [PATCH] Give MultiSelect chips accessible names and start creation fields empty. Catalog chips now announce Select or Deselect so they are named for assistive tech. Method and value modals open blank; Generate draft inserts the catalog copy that used to be prefilled, and Customize still keeps the section headings. Co-authored-by: Cursor --- .../components/FinalReviewChipEditModal.tsx | 80 ++++++++++++------- .../create/components/ModalTextAreaField.tsx | 71 ++++++++++++++-- .../CommunicationMethodEditFields.tsx | 8 ++ .../ConflictManagementEditFields.tsx | 25 ++++++ .../methodEditFields/CoreValueEditFields.tsx | 12 +++ .../DecisionApproachEditFields.tsx | 16 ++++ .../MembershipMethodEditFields.tsx | 8 ++ .../card/CommunicationMethodsScreen.tsx | 5 +- .../screens/card/ConflictManagementScreen.tsx | 5 +- .../screens/card/MembershipMethodsScreen.tsx | 5 +- .../right-rail/DecisionApproachesScreen.tsx | 5 +- .../screens/select/CoreValuesSelectScreen.tsx | 13 +-- .../controls/Chip/Chip.container.tsx | 2 +- app/components/controls/Chip/Chip.view.tsx | 2 +- .../MultiSelect/MultiSelect.container.tsx | 2 + .../controls/MultiSelect/MultiSelect.types.ts | 2 + .../controls/MultiSelect/MultiSelect.view.tsx | 10 +++ .../controls/TextArea/TextArea.container.tsx | 19 ++++- .../controls/TextArea/TextArea.types.ts | 12 ++- lib/create/finalReviewChipPresets.ts | 47 ++++++++--- lib/create/methodCardWizardPrefill.ts | 52 ++---------- lib/propNormalization.ts | 6 +- messages/en/components/controlsChrome.json | 2 + messages/en/create/draftField.json | 6 ++ messages/en/index.ts | 2 + .../create-flow/ModalTextAreaField.stories.js | 21 ++++- tests/components/Chip.test.tsx | 7 ++ ...unicationMethodsScreenPersistence.test.tsx | 20 ++++- .../CommunityStructureSelectScreen.test.tsx | 14 ++++ .../CoreValuesSelectScreen.test.tsx | 32 ++++++-- tests/components/FinalReviewPage.test.tsx | 54 +++++++------ tests/components/ModalTextAreaField.test.tsx | 40 ++++++++++ tests/components/MultiSelect.test.tsx | 16 +++- tests/components/SelectPage.test.tsx | 10 +++ tests/unit/methodCardWizardPrefill.test.ts | 23 +++++- 35 files changed, 498 insertions(+), 156 deletions(-) create mode 100644 messages/en/create/draftField.json diff --git a/app/(app)/create/components/FinalReviewChipEditModal.tsx b/app/(app)/create/components/FinalReviewChipEditModal.tsx index 1d71585..6c51a7d 100644 --- a/app/(app)/create/components/FinalReviewChipEditModal.tsx +++ b/app/(app)/create/components/FinalReviewChipEditModal.tsx @@ -35,6 +35,10 @@ import { conflictManagementPresetFor, coreValuePresetFor, decisionApproachPresetFor, + emptyCommunicationDetails, + emptyConflictManagementDetails, + emptyDecisionApproachTextFields, + emptyMembershipDetails, membershipPresetFor, } from "../../../../lib/create/finalReviewChipPresets"; import { isCustomMethodCardId } from "../../../../lib/create/isCustomMethodCardId"; @@ -1129,6 +1133,7 @@ export function FinalReviewChipEditModal({ setDraft({ groupKey: "coreValues", value })} + generateDraft={coreValuePresetFor(target.overrideKey)} /> ))} {draft?.groupKey === "communication" && @@ -1156,6 +1161,7 @@ export function FinalReviewChipEditModal({ onChange={(value) => setDraft({ groupKey: "communication", value }) } + generateDraft={communicationPresetFor(target.overrideKey)} /> ))} {draft?.groupKey === "membership" && @@ -1183,6 +1189,7 @@ export function FinalReviewChipEditModal({ onChange={(value) => setDraft({ groupKey: "membership", value }) } + generateDraft={membershipPresetFor(target.overrideKey)} /> ))} {draft?.groupKey === "decisionApproaches" && @@ -1210,6 +1217,7 @@ export function FinalReviewChipEditModal({ onChange={(value) => setDraft({ groupKey: "decisionApproaches", value }) } + generateDraft={decisionApproachPresetFor(target.overrideKey)} /> ))} {draft?.groupKey === "conflictManagement" && @@ -1237,6 +1245,7 @@ export function FinalReviewChipEditModal({ onChange={(value) => setDraft({ groupKey: "conflictManagement", value }) } + generateDraft={conflictManagementPresetFor(target.overrideKey)} /> ))} @@ -1324,52 +1333,63 @@ function seedDraftForTarget( switch (target.groupKey) { case "coreValues": { const saved = state.coreValueDetailsByChipId?.[target.overrideKey]; - const preset = coreValuePresetFor(target.overrideKey); + if (saved) { + return { + groupKey: "coreValues", + value: { + meaning: saved.meaning, + signals: saved.signals, + ...(saved.supportText ? { supportText: saved.supportText } : {}), + }, + }; + } return { groupKey: "coreValues", - value: { - meaning: saved?.meaning ?? preset.meaning, - signals: saved?.signals ?? preset.signals, - ...(saved?.supportText ? { supportText: saved.supportText } : {}), - }, + value: { meaning: "", signals: "" }, }; } case "communication": { - const saved = - state.communicationMethodDetailsById?.[target.overrideKey] ?? - communicationPresetFor(target.overrideKey); - return { groupKey: "communication", value: { ...saved } }; + const saved = state.communicationMethodDetailsById?.[target.overrideKey]; + return { + groupKey: "communication", + value: { ...(saved ?? emptyCommunicationDetails()) }, + }; } case "membership": { - const saved = - state.membershipMethodDetailsById?.[target.overrideKey] ?? - membershipPresetFor(target.overrideKey); - return { groupKey: "membership", value: { ...saved } }; + const saved = state.membershipMethodDetailsById?.[target.overrideKey]; + return { + groupKey: "membership", + value: { ...(saved ?? emptyMembershipDetails()) }, + }; } case "decisionApproaches": { - const saved = - state.decisionApproachDetailsById?.[target.overrideKey] ?? - decisionApproachPresetFor(target.overrideKey); + const saved = state.decisionApproachDetailsById?.[target.overrideKey]; + const value = saved + ? { + ...saved, + applicableScope: [...saved.applicableScope], + selectedApplicableScope: [...saved.selectedApplicableScope], + } + : emptyDecisionApproachTextFields( + decisionApproachPresetFor(target.overrideKey), + ); return { groupKey: "decisionApproaches", - value: { - ...saved, - applicableScope: [...saved.applicableScope], - selectedApplicableScope: [...saved.selectedApplicableScope], - }, + value, }; } case "conflictManagement": { - const saved = - state.conflictManagementDetailsById?.[target.overrideKey] ?? - conflictManagementPresetFor(target.overrideKey); + const saved = state.conflictManagementDetailsById?.[target.overrideKey]; + const value = saved + ? { + ...saved, + applicableScope: [...saved.applicableScope], + selectedApplicableScope: [...saved.selectedApplicableScope], + } + : emptyConflictManagementDetails(); return { groupKey: "conflictManagement", - value: { - ...saved, - applicableScope: [...saved.applicableScope], - selectedApplicableScope: [...saved.selectedApplicableScope], - }, + value, }; } } diff --git a/app/(app)/create/components/ModalTextAreaField.tsx b/app/(app)/create/components/ModalTextAreaField.tsx index 6bd46c9..32495dc 100644 --- a/app/(app)/create/components/ModalTextAreaField.tsx +++ b/app/(app)/create/components/ModalTextAreaField.tsx @@ -1,10 +1,10 @@ "use client"; /** - * Shared "labelled text area" field used by every create flow modal section. - * Pairs an `InputLabel` with a `TextArea` set to the embedded appearance — - * matching the Figma "Control / Text Area" pattern. Section help marks stay - * off until tooltip behavior ships. + * Shared labelled text area for create-flow modal sections. + * Figma: "Step Modal" Text Area Section (23746:49780) when `generateDraftText` + * is set — tall compose field + Generate draft control. Otherwise the + * embedded Control / Text Area pattern. */ import { memo, useId } from "react"; @@ -26,9 +26,40 @@ export interface ModalTextAreaFieldProps { placeholder?: string; /** Disable the field. */ disabled?: boolean; + /** + * Catalog/preset copy inserted by Generate draft. When a non-empty string + * is passed, the Figma compose field + ghost control are shown. + */ + generateDraftText?: string; + /** Visible label for the Generate draft control. */ + generateDraftLabel?: string; + /** Hint beside Generate draft. */ + generateDraftHint?: string; className?: string; } +function PlusGlyph() { + return ( + + + + ); +} + function ModalTextAreaFieldComponent({ label, helpIcon = false, @@ -37,12 +68,21 @@ function ModalTextAreaFieldComponent({ rows = 4, placeholder, disabled = false, + generateDraftText, + generateDraftLabel, + generateDraftHint, className = "", }: ModalTextAreaFieldProps) { const labelId = useId(); + const draftSource = generateDraftText?.trim() ?? ""; + const showGenerateDraft = + draftSource.length > 0 && Boolean(generateDraftLabel); + const compose = showGenerateDraft; return ( -
+
onChange(e.target.value)} size="large" - rows={rows} - appearance="embedded" + rows={compose ? 12 : rows} + appearance={compose ? "compose" : "embedded"} placeholder={placeholder} disabled={disabled} aria-labelledby={labelId} /> + {showGenerateDraft && !disabled ? ( +
+ + {generateDraftHint ? ( +

+ {generateDraftHint} +

+ ) : null} +
+ ) : null}
); } diff --git a/app/(app)/create/components/methodEditFields/CommunicationMethodEditFields.tsx b/app/(app)/create/components/methodEditFields/CommunicationMethodEditFields.tsx index 5a42ed2..fdc100a 100644 --- a/app/(app)/create/components/methodEditFields/CommunicationMethodEditFields.tsx +++ b/app/(app)/create/components/methodEditFields/CommunicationMethodEditFields.tsx @@ -17,6 +17,8 @@ export interface CommunicationMethodEditFieldsProps { onChange: (_next: CommunicationMethodDetailEntry) => void; /** When true, fields are not editable (view mode). */ readOnly?: boolean; + /** Catalog copy inserted by Generate draft. */ + generateDraft?: CommunicationMethodDetailEntry; } const FIELDS: ReadonlyArray = [ @@ -29,9 +31,11 @@ function CommunicationMethodEditFieldsComponent({ value, onChange, readOnly = false, + generateDraft, }: CommunicationMethodEditFieldsProps) { const m = useMessages(); const t = m.create.customRule.communication; + const draftField = m.create.draftField; const patch = useCallback( ( @@ -53,6 +57,10 @@ function CommunicationMethodEditFieldsComponent({ value={value[field]} onChange={(v) => patch(field, v)} disabled={readOnly} + placeholder={draftField.placeholder} + generateDraftText={generateDraft?.[field]} + generateDraftLabel={draftField.generateDraft} + generateDraftHint={draftField.generateDraftHint} /> ))}
diff --git a/app/(app)/create/components/methodEditFields/ConflictManagementEditFields.tsx b/app/(app)/create/components/methodEditFields/ConflictManagementEditFields.tsx index 0f6a987..970eda3 100644 --- a/app/(app)/create/components/methodEditFields/ConflictManagementEditFields.tsx +++ b/app/(app)/create/components/methodEditFields/ConflictManagementEditFields.tsx @@ -38,15 +38,19 @@ export interface ConflictManagementEditFieldsProps { value: ConflictManagementDetailEntry; onChange: (_next: ConflictManagementDetailEntry) => void; readOnly?: boolean; + /** Catalog copy inserted by Generate draft. */ + generateDraft?: ConflictManagementDetailEntry; } function ConflictManagementEditFieldsComponent({ value, onChange, readOnly = false, + generateDraft, }: ConflictManagementEditFieldsProps) { const m = useMessages(); const t = m.create.customRule.conflictManagement; + const draftField = m.create.draftField; const patch = useCallback( ( @@ -65,6 +69,10 @@ function ConflictManagementEditFieldsComponent({ value={value.corePrinciple} onChange={(v) => patch("corePrinciple", v)} disabled={readOnly} + placeholder={draftField.placeholder} + generateDraftText={generateDraft?.corePrinciple} + generateDraftLabel={draftField.generateDraft} + generateDraftHint={draftField.generateDraftHint} /> onChange(conflictDetailWithScopeTextarea(value, v))} rows={4} disabled={readOnly} + generateDraftText={ + generateDraft && generateDraft.applicableScope.length > 0 + ? generateDraft.applicableScope.join("\n") + : generateDraft + ? conflictScopeTextareaValue(generateDraft) + : undefined + } + generateDraftLabel={draftField.generateDraft} + generateDraftHint={draftField.generateDraftHint} /> patch("processProtocol", v)} disabled={readOnly} + placeholder={draftField.placeholder} + generateDraftText={generateDraft?.processProtocol} + generateDraftLabel={draftField.generateDraft} + generateDraftHint={draftField.generateDraftHint} /> patch("restorationFallbacks", v)} disabled={readOnly} + placeholder={draftField.placeholder} + generateDraftText={generateDraft?.restorationFallbacks} + generateDraftLabel={draftField.generateDraft} + generateDraftHint={draftField.generateDraftHint} />
); diff --git a/app/(app)/create/components/methodEditFields/CoreValueEditFields.tsx b/app/(app)/create/components/methodEditFields/CoreValueEditFields.tsx index 2fc8d23..22da817 100644 --- a/app/(app)/create/components/methodEditFields/CoreValueEditFields.tsx +++ b/app/(app)/create/components/methodEditFields/CoreValueEditFields.tsx @@ -17,15 +17,19 @@ export interface CoreValueEditFieldsProps { onChange: (_next: CoreValueDetailEntry) => void; /** Disable meaning/signals. Facet and final-review callers omit this so fields stay editable on open. */ readOnly?: boolean; + /** Catalog copy inserted by Generate draft. */ + generateDraft?: CoreValueDetailEntry; } function CoreValueEditFieldsComponent({ value, onChange, readOnly = false, + generateDraft, }: CoreValueEditFieldsProps) { const m = useMessages(); const t = m.create.customRule.coreValues.detailModal; + const draftField = m.create.draftField; const patch = useCallback( ( @@ -45,6 +49,10 @@ function CoreValueEditFieldsComponent({ onChange={(v) => patch("meaning", v)} rows={4} disabled={readOnly} + placeholder={draftField.placeholder} + generateDraftText={generateDraft?.meaning} + generateDraftLabel={draftField.generateDraft} + generateDraftHint={draftField.generateDraftHint} /> patch("signals", v)} rows={4} disabled={readOnly} + placeholder={draftField.placeholder} + generateDraftText={generateDraft?.signals} + generateDraftLabel={draftField.generateDraft} + generateDraftHint={draftField.generateDraftHint} /> ); diff --git a/app/(app)/create/components/methodEditFields/DecisionApproachEditFields.tsx b/app/(app)/create/components/methodEditFields/DecisionApproachEditFields.tsx index 326aa64..bc3a22d 100644 --- a/app/(app)/create/components/methodEditFields/DecisionApproachEditFields.tsx +++ b/app/(app)/create/components/methodEditFields/DecisionApproachEditFields.tsx @@ -19,6 +19,8 @@ export interface DecisionApproachEditFieldsProps { value: DecisionApproachDetailEntry; onChange: (_next: DecisionApproachDetailEntry) => void; readOnly?: boolean; + /** Catalog copy inserted by Generate draft on text fields. */ + generateDraft?: DecisionApproachDetailEntry; } const CONSENSUS_LEVEL_MIN = 0; @@ -29,9 +31,11 @@ function DecisionApproachEditFieldsComponent({ value, onChange, readOnly = false, + generateDraft, }: DecisionApproachEditFieldsProps) { const m = useMessages(); const t = m.create.customRule.decisionApproaches; + const draftField = m.create.draftField; const scopes = useMemo( () => withDecisionApproachKeyResourceScopes(value.applicableScope), @@ -55,6 +59,10 @@ function DecisionApproachEditFieldsComponent({ value={value.corePrinciple} onChange={(v) => patch("corePrinciple", v)} disabled={readOnly} + placeholder={draftField.placeholder} + generateDraftText={generateDraft?.corePrinciple} + generateDraftLabel={draftField.generateDraft} + generateDraftHint={draftField.generateDraftHint} /> patch("stepByStepInstructions", v)} disabled={readOnly} + placeholder={draftField.placeholder} + generateDraftText={generateDraft?.stepByStepInstructions} + generateDraftLabel={draftField.generateDraft} + generateDraftHint={draftField.generateDraftHint} /> {value.consensusLevel !== undefined ? ( patch("objectionsDeadlocks", v)} disabled={readOnly} + placeholder={draftField.placeholder} + generateDraftText={generateDraft?.objectionsDeadlocks} + generateDraftLabel={draftField.generateDraft} + generateDraftHint={draftField.generateDraftHint} /> ); diff --git a/app/(app)/create/components/methodEditFields/MembershipMethodEditFields.tsx b/app/(app)/create/components/methodEditFields/MembershipMethodEditFields.tsx index aea2d55..c28c835 100644 --- a/app/(app)/create/components/methodEditFields/MembershipMethodEditFields.tsx +++ b/app/(app)/create/components/methodEditFields/MembershipMethodEditFields.tsx @@ -16,6 +16,8 @@ export interface MembershipMethodEditFieldsProps { value: MembershipMethodDetailEntry; onChange: (_next: MembershipMethodDetailEntry) => void; readOnly?: boolean; + /** Catalog copy inserted by Generate draft. */ + generateDraft?: MembershipMethodDetailEntry; } const FIELDS: ReadonlyArray = [ @@ -28,9 +30,11 @@ function MembershipMethodEditFieldsComponent({ value, onChange, readOnly = false, + generateDraft, }: MembershipMethodEditFieldsProps) { const m = useMessages(); const t = m.create.customRule.membership; + const draftField = m.create.draftField; const patch = useCallback( ( @@ -52,6 +56,10 @@ function MembershipMethodEditFieldsComponent({ value={value[field]} onChange={(v) => patch(field, v)} disabled={readOnly} + placeholder={draftField.placeholder} + generateDraftText={generateDraft?.[field]} + generateDraftLabel={draftField.generateDraft} + generateDraftHint={draftField.generateDraftHint} /> ))} diff --git a/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx b/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx index 22279ed..543e058 100644 --- a/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx +++ b/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx @@ -29,7 +29,7 @@ import InlineTextButton from "../../../../components/buttons/InlineTextButton"; import { CommunicationMethodEditFields } from "../../components/methodEditFields"; import CustomMethodCardWizard from "../../components/CustomMethodCardWizard"; import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; -import { communicationPresetFor } from "../../../../../lib/create/finalReviewChipPresets"; +import { communicationPresetFor, emptyCommunicationDetails } from "../../../../../lib/create/finalReviewChipPresets"; import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom"; import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder"; @@ -146,7 +146,7 @@ export function CommunicationMethodsScreen() { if (saved) { return { ...saved }; } - return communicationPresetFor(id); + return emptyCommunicationDetails(); }, [state.communicationMethodDetailsById], ); @@ -756,6 +756,7 @@ export function CommunicationMethodsScreen() { ) ) : null} diff --git a/app/(app)/create/screens/card/ConflictManagementScreen.tsx b/app/(app)/create/screens/card/ConflictManagementScreen.tsx index eb8f408..9f0fe70 100644 --- a/app/(app)/create/screens/card/ConflictManagementScreen.tsx +++ b/app/(app)/create/screens/card/ConflictManagementScreen.tsx @@ -26,7 +26,7 @@ import InlineTextButton from "../../../../components/buttons/InlineTextButton"; import { ConflictManagementEditFields } from "../../components/methodEditFields"; import CustomMethodCardWizard from "../../components/CustomMethodCardWizard"; import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; -import { conflictManagementPresetFor } from "../../../../../lib/create/finalReviewChipPresets"; +import { conflictManagementPresetFor, emptyConflictManagementDetails } from "../../../../../lib/create/finalReviewChipPresets"; import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom"; import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder"; @@ -147,7 +147,7 @@ export function ConflictManagementScreen() { selectedApplicableScope: [...saved.selectedApplicableScope], }; } - return conflictManagementPresetFor(id); + return emptyConflictManagementDetails(); }, [state.conflictManagementDetailsById], ); @@ -757,6 +757,7 @@ export function ConflictManagementScreen() { ) ) : null} diff --git a/app/(app)/create/screens/card/MembershipMethodsScreen.tsx b/app/(app)/create/screens/card/MembershipMethodsScreen.tsx index 75530e0..2ced426 100644 --- a/app/(app)/create/screens/card/MembershipMethodsScreen.tsx +++ b/app/(app)/create/screens/card/MembershipMethodsScreen.tsx @@ -27,7 +27,7 @@ import InlineTextButton from "../../../../components/buttons/InlineTextButton"; import { MembershipMethodEditFields } from "../../components/methodEditFields"; import CustomMethodCardWizard from "../../components/CustomMethodCardWizard"; import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; -import { membershipPresetFor } from "../../../../../lib/create/finalReviewChipPresets"; +import { membershipPresetFor, emptyMembershipDetails } from "../../../../../lib/create/finalReviewChipPresets"; import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom"; import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder"; @@ -144,7 +144,7 @@ export function MembershipMethodsScreen() { if (saved) { return { ...saved }; } - return membershipPresetFor(id); + return emptyMembershipDetails(); }, [state.membershipMethodDetailsById], ); @@ -750,6 +750,7 @@ export function MembershipMethodsScreen() { ) ) : null} diff --git a/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx b/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx index 1541a29..dff798e 100644 --- a/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx +++ b/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx @@ -33,7 +33,7 @@ import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoCo import { DecisionApproachEditFields } from "../../components/methodEditFields"; import CustomMethodCardWizard from "../../components/CustomMethodCardWizard"; import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; -import { decisionApproachPresetFor } from "../../../../../lib/create/finalReviewChipPresets"; +import { decisionApproachPresetFor, emptyDecisionApproachTextFields } from "../../../../../lib/create/finalReviewChipPresets"; import { applyDecisionApproachKeyResources, decisionApproachKeyResourceCheckboxIds, @@ -186,7 +186,7 @@ export function DecisionApproachesScreen() { if (saved) { return structuredClone(saved); } - return decisionApproachPresetFor(id); + return emptyDecisionApproachTextFields(decisionApproachPresetFor(id)); }, [state.decisionApproachDetailsById], ); @@ -840,6 +840,7 @@ export function DecisionApproachesScreen() { ) ) : null} diff --git a/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx b/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx index 97e75fd..d47d7b9 100644 --- a/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx +++ b/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx @@ -183,16 +183,16 @@ export function CoreValuesSelectScreen() { ); const getInitialTexts = useCallback( - (chipId: string, valueLabel: string): CoreValueDetailEntry => { + (chipId: string, _valueLabel: string): CoreValueDetailEntry => { const saved = state.coreValueDetailsByChipId?.[chipId]; - const preset = getPresetTexts(valueLabel); + if (!saved) return EMPTY_DETAIL; return { - meaning: saved?.meaning ?? preset.meaning, - signals: saved?.signals ?? preset.signals, - ...(saved?.supportText ? { supportText: saved.supportText } : {}), + meaning: saved.meaning, + signals: saved.signals, + ...(saved.supportText ? { supportText: saved.supportText } : {}), }; }, - [state.coreValueDetailsByChipId, getPresetTexts], + [state.coreValueDetailsByChipId], ); const openModal = useCallback( @@ -799,6 +799,7 @@ export function CoreValuesSelectScreen() { )} diff --git a/app/components/controls/Chip/Chip.container.tsx b/app/components/controls/Chip/Chip.container.tsx index c126e61..cb3bcc6 100644 --- a/app/components/controls/Chip/Chip.container.tsx +++ b/app/components/controls/Chip/Chip.container.tsx @@ -93,7 +93,7 @@ const ChipContainer = memo( onInputChange={isCustom ? setInputValue : undefined} onInputKeyDown={isCustom ? handleKeyDown : undefined} inputRef={isCustom ? inputRef : undefined} - ariaLabel={ariaLabel} + ariaLabel={ariaLabel ?? (isCustom ? undefined : label)} confirmAriaLabel={t("chipConfirm")} typeToAddPlaceholder={t("chipTypeToAdd")} closeAriaLabel={t("chipClose")} diff --git a/app/components/controls/Chip/Chip.view.tsx b/app/components/controls/Chip/Chip.view.tsx index 7b5f7c8..7117720 100644 --- a/app/components/controls/Chip/Chip.view.tsx +++ b/app/components/controls/Chip/Chip.view.tsx @@ -156,7 +156,7 @@ function ChipView({ }; const sharedA11y = { - "aria-label": ariaLabel, + ...(ariaLabel ? { "aria-label": ariaLabel } : {}), ...(isToggle ? { "aria-pressed": isSelected } : {}), }; diff --git a/app/components/controls/MultiSelect/MultiSelect.container.tsx b/app/components/controls/MultiSelect/MultiSelect.container.tsx index e033f4d..ca18f24 100644 --- a/app/components/controls/MultiSelect/MultiSelect.container.tsx +++ b/app/components/controls/MultiSelect/MultiSelect.container.tsx @@ -46,6 +46,8 @@ const MultiSelectContainer = memo( addButtonAriaLabel={ addButtonText || t("multiSelectAddFallback") } + chipSelectAriaLabel={t("chipSelect")} + chipDeselectAriaLabel={t("chipDeselect")} formHeader={formHeader} onCustomChipConfirm={onCustomChipConfirm} onCustomChipClose={onCustomChipClose} diff --git a/app/components/controls/MultiSelect/MultiSelect.types.ts b/app/components/controls/MultiSelect/MultiSelect.types.ts index 599d6f9..dfcceaa 100644 --- a/app/components/controls/MultiSelect/MultiSelect.types.ts +++ b/app/components/controls/MultiSelect/MultiSelect.types.ts @@ -85,6 +85,8 @@ export interface MultiSelectViewProps { addButton: boolean; addButtonText?: string; addButtonAriaLabel: string; + chipSelectAriaLabel: string; + chipDeselectAriaLabel: string; formHeader: boolean; onCustomChipConfirm?: (chipId: string, value: string) => void; onCustomChipClose?: (chipId: string) => void; diff --git a/app/components/controls/MultiSelect/MultiSelect.view.tsx b/app/components/controls/MultiSelect/MultiSelect.view.tsx index 412bf08..2eb3448 100644 --- a/app/components/controls/MultiSelect/MultiSelect.view.tsx +++ b/app/components/controls/MultiSelect/MultiSelect.view.tsx @@ -16,6 +16,8 @@ function MultiSelectView({ addButton, addButtonText, addButtonAriaLabel, + chipSelectAriaLabel, + chipDeselectAriaLabel, formHeader = true, onCustomChipConfirm, onCustomChipClose, @@ -88,6 +90,14 @@ function MultiSelectView({ palette={palette} size={chipSize} disabled={chipDisabled} + ariaLabel={ + isCustom || !option.label + ? undefined + : (isSelected + ? chipDeselectAriaLabel + : chipSelectAriaLabel + ).replace("{label}", option.label) + } onClick={() => { if (!isCustom && !chipDisabled && onChipClick) { onChipClick(option.id); diff --git a/app/components/controls/TextArea/TextArea.container.tsx b/app/components/controls/TextArea/TextArea.container.tsx index 044ee1a..f363456 100644 --- a/app/components/controls/TextArea/TextArea.container.tsx +++ b/app/components/controls/TextArea/TextArea.container.tsx @@ -86,6 +86,21 @@ const TextAreaContainer = forwardRef( textarea: string; label: string; } => { + if (appearance === "compose") { + if (disabled) { + return { + textarea: + "border-0 bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-tertiary,#b4b4b4)] cursor-not-allowed opacity-60 min-h-[356px] h-[356px] px-[var(--measures-spacing-300,12px)] py-[var(--space-250,10px)]", + label: "text-[var(--color-content-default-secondary)]", + }; + } + return { + textarea: + "border-0 bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] placeholder:text-[var(--color-content-default-tertiary,#b4b4b4)] min-h-[356px] h-[356px] px-[var(--measures-spacing-300,12px)] py-[var(--space-250,10px)] focus:outline-none focus:ring-0", + label: "text-[var(--color-content-default-secondary)]", + }; + } + if (appearance === "embedded") { if (disabled) { return { @@ -161,8 +176,8 @@ const TextAreaContainer = forwardRef( const textareaClasses = ` scrollbar-design w-full transition-all duration-200 ease-in-out resize-none - ${appearance === "embedded" ? "rounded-[var(--radius-300,12px)]" : "border"} - ${currentSize.textarea} + ${appearance === "embedded" || appearance === "compose" ? "rounded-[var(--radius-300,12px)]" : "border"} + ${appearance === "compose" ? "text-medium-paragraph" : currentSize.textarea} ${stateStyles.textarea} ${className} `.trim(); diff --git a/app/components/controls/TextArea/TextArea.types.ts b/app/components/controls/TextArea/TextArea.types.ts index 812a35a..c02c14b 100644 --- a/app/components/controls/TextArea/TextArea.types.ts +++ b/app/components/controls/TextArea/TextArea.types.ts @@ -1,9 +1,12 @@ -import type { InputStateValue } from "../../../../lib/propNormalization"; +import type { + InputStateValue, + TextAreaAppearanceValue, +} from "../../../../lib/propNormalization"; export type TextAreaSizeValue = "small" | "medium" | "large"; export type TextAreaLabelVariantValue = "default" | "horizontal"; -export type TextAreaAppearanceValue = "default" | "embedded"; +export type { TextAreaAppearanceValue }; export interface TextAreaProps extends Omit< React.TextareaHTMLAttributes, @@ -49,7 +52,8 @@ export interface TextAreaProps extends Omit< /** * Visual appearance. "embedded" matches Create modal sections (Figma 20736-12668): * borderless, darker grey background, tertiary text in default (primary on - * focus). "default" is the standard bordered input. + * focus). "compose" is the tall empty creation field (Figma 23746:49780). + * "default" is the standard bordered input. * @default "default" */ appearance?: TextAreaAppearanceValue; @@ -79,7 +83,7 @@ export interface TextAreaViewProps { textHint?: boolean | string; formHeader?: boolean; showHelpIcon?: boolean; - appearance?: "default" | "embedded"; + appearance?: TextAreaAppearanceValue; helpIconAlt: string; hintDefault: string; } diff --git a/lib/create/finalReviewChipPresets.ts b/lib/create/finalReviewChipPresets.ts index 48c90f3..15a4336 100644 --- a/lib/create/finalReviewChipPresets.ts +++ b/lib/create/finalReviewChipPresets.ts @@ -14,11 +14,10 @@ import type { } from "../../app/(app)/create/types"; /** - * Per-method preset defaults shipped in `messages/en/create/customRule/*.json`. - * Used to seed the final-review edit modal when the user has no saved - * override yet, and to merge onto overrides when emitting the published rule - * document so every method carries a complete section payload even if the - * author only edited a subset of fields. + * Catalog copy for method/value sections. Generate draft inserts these into + * empty creation-modal fields. Publish still merges them onto saved overrides + * so every method carries a complete section payload when the author only + * edited a subset of fields. */ type CustomRuleMethodRow = { id: string; @@ -96,6 +95,10 @@ export function communicationPresetFor( }; } +export function emptyCommunicationDetails(): CommunicationMethodDetailEntry { + return { corePrinciple: "", logisticsAdmin: "", codeOfConduct: "" }; +} + export function membershipPresetFor(id: string): MembershipMethodDetailEntry { const method = findMethod(membershipMessages, id); const s = method?.sections ?? {}; @@ -106,6 +109,10 @@ export function membershipPresetFor(id: string): MembershipMethodDetailEntry { }; } +export function emptyMembershipDetails(): MembershipMethodDetailEntry { + return { eligibility: "", joiningProcess: "", expectations: "" }; +} + /** Default consensus level used when a **catalog** preset omits a value. */ export const DECISION_CONSENSUS_LEVEL_DEFAULT = 75; @@ -132,6 +139,20 @@ export function decisionApproachPresetFor( return entry; } +/** Catalog scopes/consensus kept; text fields start empty for Generate draft. */ +export function emptyDecisionApproachTextFields( + preset: DecisionApproachDetailEntry, +): DecisionApproachDetailEntry { + return { + ...preset, + corePrinciple: "", + stepByStepInstructions: "", + objectionsDeadlocks: "", + applicableScope: [...preset.applicableScope], + selectedApplicableScope: [...preset.selectedApplicableScope], + }; +} + export function conflictManagementPresetFor( id: string, ): ConflictManagementDetailEntry { @@ -146,11 +167,19 @@ export function conflictManagementPresetFor( }; } +export function emptyConflictManagementDetails(): ConflictManagementDetailEntry { + return { + corePrinciple: "", + applicableScope: [], + selectedApplicableScope: [], + processProtocol: "", + restorationFallbacks: "", + }; +} + /** - * Preset meaning/signals for a core value chip. Mirrors `CoreValuesSelectScreen`'s - * `getInitialTexts` so the final-review edit modal opens with the same - * preset copy the select screen would have shown — without requiring the - * user to have opened the select-screen modal first. + * Preset meaning/signals for a core value chip. Used by Generate draft and + * by publish/display merge when the author has not saved an override. * * Lookup order: * 1. Numeric chip id → 1-based index into `coreValues.json` `values[]` diff --git a/lib/create/methodCardWizardPrefill.ts b/lib/create/methodCardWizardPrefill.ts index 278e881..4eefee8 100644 --- a/lib/create/methodCardWizardPrefill.ts +++ b/lib/create/methodCardWizardPrefill.ts @@ -89,46 +89,6 @@ function clampPercent(n: number): number { return Math.min(100, Math.max(1, Math.round(n))); } -function facetPrefillHasContent(prefill: MethodCardWizardFacetPrefill): boolean { - switch (prefill.group) { - case "communication": - return ( - hasTrimmedText(prefill.draft.corePrinciple) || - hasTrimmedText(prefill.draft.logisticsAdmin) || - hasTrimmedText(prefill.draft.codeOfConduct) - ); - case "membership": - return ( - hasTrimmedText(prefill.draft.eligibility) || - hasTrimmedText(prefill.draft.joiningProcess) || - hasTrimmedText(prefill.draft.expectations) - ); - case "decisionApproaches": - return ( - hasTrimmedText(prefill.draft.corePrinciple) || - hasTrimmedText(prefill.draft.stepByStepInstructions) || - hasTrimmedText(prefill.draft.objectionsDeadlocks) || - prefill.draft.applicableScope.length > 0 || - prefill.draft.selectedApplicableScope.length > 0 - ); - case "conflictManagement": - return ( - hasTrimmedText(prefill.draft.corePrinciple) || - hasTrimmedText(prefill.draft.processProtocol) || - hasTrimmedText(prefill.draft.restorationFallbacks) || - formatConflictApplicableScopeForTextarea( - prefill.draft.selectedApplicableScope, - prefill.draft.applicableScope, - ).trim().length > 0 - ); - case "coreValues": - return ( - hasTrimmedText(prefill.draft.meaning) || - hasTrimmedText(prefill.draft.signals) - ); - } -} - function mapFacetPrefillToWizardFieldBlocks( prefill: MethodCardWizardFacetPrefill, ): CustomMethodCardFieldBlock[] { @@ -198,7 +158,11 @@ function mapFacetPrefillToWizardFieldBlocks( ); if ( typeof prefill.draft.consensusLevel === "number" && - facetPrefillHasContent(prefill) + (hasTrimmedText(prefill.draft.corePrinciple) || + hasTrimmedText(prefill.draft.stepByStepInstructions) || + hasTrimmedText(prefill.draft.objectionsDeadlocks) || + prefill.draft.applicableScope.length > 0 || + prefill.draft.selectedApplicableScope.length > 0) ) { blocks.push({ kind: "proportion", @@ -260,14 +224,12 @@ function mapFacetPrefillToWizardFieldBlocks( /** * Map a method card's facet editors onto wizard field blocks so Customize - * can show and edit the same sections on step 3. + * can show and edit the same sections on step 3. Empty copy still yields + * the catalog headings so authors can fill them in the wizard. */ export function facetDetailsToWizardFieldBlocks( prefill: MethodCardWizardFacetPrefill, ): CustomMethodCardFieldBlock[] { - if (!facetPrefillHasContent(prefill)) { - return []; - } return mapFacetPrefillToWizardFieldBlocks(prefill); } diff --git a/lib/propNormalization.ts b/lib/propNormalization.ts index 94b6470..33b98cc 100644 --- a/lib/propNormalization.ts +++ b/lib/propNormalization.ts @@ -178,7 +178,11 @@ export type ToggleGroupPositionValue = export const LABEL_VARIANT_OPTIONS = ["default", "horizontal"] as const; export type LabelVariantValue = (typeof LABEL_VARIANT_OPTIONS)[number]; -export const TEXT_AREA_APPEARANCE_OPTIONS = ["default", "embedded"] as const; +export const TEXT_AREA_APPEARANCE_OPTIONS = [ + "default", + "embedded", + "compose", +] as const; export type TextAreaAppearanceValue = (typeof TEXT_AREA_APPEARANCE_OPTIONS)[number]; diff --git a/messages/en/components/controlsChrome.json b/messages/en/components/controlsChrome.json index 802d73f..fb2d25c 100644 --- a/messages/en/components/controlsChrome.json +++ b/messages/en/components/controlsChrome.json @@ -5,6 +5,8 @@ "chipConfirm": "Confirm", "chipTypeToAdd": "Type to add", "chipClose": "Close", + "chipSelect": "Select {label}", + "chipDeselect": "Deselect {label}", "selectPlaceholder": "Choose an option", "selectAriaLabel": "Select an option", "multiSelectAddDefault": "Add organization type", diff --git a/messages/en/create/draftField.json b/messages/en/create/draftField.json new file mode 100644 index 0000000..29e3b3e --- /dev/null +++ b/messages/en/create/draftField.json @@ -0,0 +1,6 @@ +{ + "_comment": "Generate-draft control on creation-modal text fields (Figma Step Modal 23746:49780).", + "generateDraft": "Generate draft", + "generateDraftHint": "Get a first draft for this section", + "placeholder": "Describe how this method should work" +} diff --git a/messages/en/index.ts b/messages/en/index.ts index 487d1b3..2e1671a 100644 --- a/messages/en/index.ts +++ b/messages/en/index.ts @@ -69,6 +69,7 @@ import createTopNav from "./create/topNav.json"; import createDraftHydration from "./create/draftHydration.json"; import createTemplateReview from "./create/templateReview.json"; import createUpload from "./create/upload.json"; +import createDraftField from "./create/draftField.json"; export default { common, @@ -138,6 +139,7 @@ export default { draftHydration: createDraftHydration, templateReview: createTemplateReview, upload: createUpload, + draftField: createDraftField, }, navigation, metadata, diff --git a/stories/create-flow/ModalTextAreaField.stories.js b/stories/create-flow/ModalTextAreaField.stories.js index 6376558..efd8ee7 100644 --- a/stories/create-flow/ModalTextAreaField.stories.js +++ b/stories/create-flow/ModalTextAreaField.stories.js @@ -9,7 +9,7 @@ export default { docs: { description: { component: - "Shared 'labelled text area' field used by every create-flow modal section. Pairs `InputLabel` with a `TextArea` set to the `embedded` appearance. Section help marks stay off until tooltip behavior ships.", + "Shared labelled text area for create-flow modal sections. With `generateDraftText`, matches Figma Step Modal (23746:49780): tall compose field plus Generate draft.", }, }, }, @@ -58,6 +58,25 @@ export const WithValue = { }, }; +export const GenerateDraft = { + render: () => { + const [value, setValue] = React.useState(""); + return ( +
+ +
+ ); + }, +}; + export const Disabled = { render: () => (
diff --git a/tests/components/Chip.test.tsx b/tests/components/Chip.test.tsx index 4a06089..ed8139e 100644 --- a/tests/components/Chip.test.tsx +++ b/tests/components/Chip.test.tsx @@ -34,6 +34,13 @@ const config: ComponentTestSuiteConfig = { describe("Chip", () => { componentTestSuite(config); + it("uses the visible label as the accessible name when ariaLabel is omitted", () => { + render(); + expect( + screen.getByRole("button", { name: "Worker cooperative" }), + ).toHaveAttribute("aria-label", "Worker cooperative"); + }); + it("exposes aria-pressed false when unselected", () => { render(); const chip = screen.getByRole("button", { name: "Worker cooperative" }); diff --git a/tests/components/CommunicationMethodsScreenPersistence.test.tsx b/tests/components/CommunicationMethodsScreenPersistence.test.tsx index bd32324..9f54800 100644 --- a/tests/components/CommunicationMethodsScreenPersistence.test.tsx +++ b/tests/components/CommunicationMethodsScreenPersistence.test.tsx @@ -63,7 +63,7 @@ function ScreenWithStateProbe({ * and `buildPublishPayload` for the read side. */ describe("CommunicationMethodsScreen — Add Platform persistence", () => { - it("seeds the modal from preset and persists edits + selection on Confirm", async () => { + it("starts empty and Generate draft inserts the catalog preset", async () => { let latest: CreateFlowState = {}; render( { const textboxes = within(dialog).getAllByRole("textbox"); expect(textboxes.length).toBe(3); const corePrincipleField = textboxes[0] as HTMLTextAreaElement; - // Preset corePrinciple must seed into the first body textarea so the user - // edits a real starting point rather than an empty field. - expect(corePrincipleField.value.length).toBeGreaterThan(0); + expect(corePrincipleField.value).toBe(""); + + const generateButtons = within(dialog).getAllByRole("button", { + name: "Generate draft", + }); + expect(generateButtons.length).toBe(3); + fireEvent.click(generateButtons[0]); + expect(corePrincipleField.value).toBe( + communicationPresetFor("signal").corePrinciple, + ); fireEvent.change(corePrincipleField, { target: { value: "Custom principle" } }); fireEvent.click( @@ -466,6 +473,11 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => { screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0], ); const dialog = await screen.findByRole("dialog"); + for (const button of within(dialog).getAllByRole("button", { + name: "Generate draft", + })) { + fireEvent.click(button); + } fireEvent.click(within(dialog).getByRole("button", { name: "More options" })); fireEvent.click(screen.getByRole("menuitem", { name: "Customize" })); diff --git a/tests/components/CommunityStructureSelectScreen.test.tsx b/tests/components/CommunityStructureSelectScreen.test.tsx index dac1366..24eb460 100644 --- a/tests/components/CommunityStructureSelectScreen.test.tsx +++ b/tests/components/CommunityStructureSelectScreen.test.tsx @@ -16,4 +16,18 @@ describe("CommunityStructureSelectScreen", () => { ).toBeInTheDocument(); expect(screen.queryByAltText("Help")).not.toBeInTheDocument(); }); + + it("gives structure chips Select/Deselect accessible names", () => { + renderWithProviders(); + + expect( + screen.getByRole("button", { name: "Select Worker cooperative" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Select Local" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Select Early stage" }), + ).toBeInTheDocument(); + }); }); diff --git a/tests/components/CoreValuesSelectScreen.test.tsx b/tests/components/CoreValuesSelectScreen.test.tsx index 93832c0..a6ca29d 100644 --- a/tests/components/CoreValuesSelectScreen.test.tsx +++ b/tests/components/CoreValuesSelectScreen.test.tsx @@ -41,6 +41,26 @@ describe("CoreValuesSelectScreen", () => { ).not.toBeInTheDocument(); }); + it("gives value chips Select/Deselect accessible names", () => { + renderWithProviders(); + expect( + screen.getByRole("button", { name: "Select Accessibility" }), + ).toBeInTheDocument(); + }); + + it("starts the value modal empty and Generate draft inserts catalog copy", async () => { + renderWithProviders(); + fireEvent.click(screen.getByRole("button", { name: "Select Accessibility" })); + const dialog = await screen.findByRole("dialog"); + const fields = within(dialog).getAllByRole("textbox") as HTMLTextAreaElement[]; + expect(fields[0].value).toBe(""); + expect(fields[1].value).toBe(""); + fireEvent.click( + within(dialog).getAllByRole("button", { name: "Generate draft" })[0], + ); + expect(fields[0].value).toMatch(/everyone can use/i); + }); + it("closes a pending value without confirm when fields are unchanged", async () => { renderWithProviders(); fireEvent.click(screen.getByText("Accessibility")); @@ -289,12 +309,10 @@ describe("CoreValuesSelectScreen", () => { */ function countCustomChips(label: string) { return screen - .queryAllByRole("button", { name: label }) - .filter( - (el) => - !el.closest('[role="dialog"]') && - (el.getAttribute("aria-label") ?? "") !== `Remove ${label}`, - ).length; + .queryAllByRole("button", { + name: new RegExp(`^(Select|Deselect) ${label}$`), + }) + .filter((el) => !el.closest('[role="dialog"]')).length; } async function completeAddValueWizard(label: string) { @@ -417,7 +435,7 @@ describe("CoreValuesSelectScreen", () => { ), ).toBeInTheDocument(); expect( - screen.getByRole("button", { name: "Anti-oppression" }), + screen.getByRole("button", { name: "Select Anti-oppression" }), ).toBeDisabled(); expect(screen.getByRole("button", { name: "Add value" })).toBeDisabled(); expect(screen.getByRole("button", { name: /^add$/i })).toBeDisabled(); diff --git a/tests/components/FinalReviewPage.test.tsx b/tests/components/FinalReviewPage.test.tsx index f9ef64a..d33ecd5 100644 --- a/tests/components/FinalReviewPage.test.tsx +++ b/tests/components/FinalReviewPage.test.tsx @@ -12,6 +12,10 @@ import { FinalReviewScreen } from "../../app/(app)/create/screens/review/FinalRe import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext"; import type { CreateFlowState } from "../../app/(app)/create/types"; +function catalogChipName(label: string) { + return new RegExp(`^(Select|Deselect) ${label}$`); +} + async function confirmDiscardCustomizeEdits() { fireEvent.click( await screen.findByRole("button", { name: "Discard" }), @@ -232,7 +236,7 @@ describe("FinalReviewScreen — chip detail modal", () => { it("opens the chip modal when a chip is clicked, matching the preset copy", async () => { render(); - const signalChip = await screen.findByRole("button", { name: "Signal" }); + const signalChip = await screen.findByRole("button", { name: catalogChipName("Signal") }); fireEvent.click(signalChip); // Modal subtitle is the `supportText` from communication.json for the @@ -273,7 +277,7 @@ describe("FinalReviewScreen — chip detail modal", () => { ); fireEvent.click( - await screen.findByRole("button", { name: "Video Meetings" }), + await screen.findByRole("button", { name: catalogChipName("Video Meetings") }), ); const dialog = await screen.findByRole("dialog"); expect(within(dialog).getAllByRole("textbox")).toHaveLength(3); @@ -287,7 +291,7 @@ describe("FinalReviewScreen — chip detail modal", () => { it("method chip modal kebab offers Customize but not Duplicate", async () => { render(); - fireEvent.click(await screen.findByRole("button", { name: "Signal" })); + fireEvent.click(await screen.findByRole("button", { name: catalogChipName("Signal") })); const dialog = await screen.findByRole("dialog"); fireEvent.click( within(dialog).getByRole("button", { name: /more options/i }), @@ -317,7 +321,7 @@ describe("FinalReviewScreen — chip detail modal", () => { } render(); fireEvent.click( - await screen.findByRole("button", { name: "Accessibility" }), + await screen.findByRole("button", { name: catalogChipName("Accessibility") }), ); const dialog = await screen.findByRole("dialog"); fireEvent.click( @@ -348,7 +352,7 @@ describe("FinalReviewScreen — chip detail modal", () => { } render(); fireEvent.click( - await screen.findByRole("button", { name: "Accessibility" }), + await screen.findByRole("button", { name: catalogChipName("Accessibility") }), ); const dialog = await screen.findByRole("dialog"); fireEvent.click( @@ -366,7 +370,7 @@ describe("FinalReviewScreen — chip detail modal", () => { it("opens method chip modal editable, with Save disabled until a field changes", async () => { render(); - fireEvent.click(await screen.findByRole("button", { name: "Signal" })); + fireEvent.click(await screen.findByRole("button", { name: catalogChipName("Signal") })); const dialog = await screen.findByRole("dialog"); const saveButton = within(dialog).getByRole("button", { name: "Save" }); @@ -400,7 +404,7 @@ describe("FinalReviewScreen — chip detail modal", () => { } render(); - const chip = await screen.findByRole("button", { name: "Accessibility" }); + const chip = await screen.findByRole("button", { name: catalogChipName("Accessibility") }); fireEvent.click(chip); await waitFor(() => { @@ -430,7 +434,7 @@ describe("FinalReviewScreen — chip detail modal", () => { render(); fireEvent.click( - await screen.findByRole("button", { name: "Accessibility" }), + await screen.findByRole("button", { name: catalogChipName("Accessibility") }), ); const dialog = await screen.findByRole("dialog"); expect( @@ -468,7 +472,7 @@ describe("FinalReviewScreen — chip detail modal", () => { render(); fireEvent.click( - await screen.findByRole("button", { name: "Accessibility" }), + await screen.findByRole("button", { name: catalogChipName("Accessibility") }), ); const dialog = await screen.findByRole("dialog"); expect( @@ -514,7 +518,7 @@ describe("FinalReviewScreen — chip detail modal", () => { ); fireEvent.click( - await screen.findByRole("button", { name: "Accessibility" }), + await screen.findByRole("button", { name: catalogChipName("Accessibility") }), ); const valuesDialog = await screen.findByRole("dialog"); expect(within(valuesDialog).getAllByRole("textbox").length).toBeGreaterThan( @@ -542,7 +546,7 @@ describe("FinalReviewScreen — chip detail modal", () => { />, ); - fireEvent.click(await screen.findByRole("button", { name: "Signal" })); + fireEvent.click(await screen.findByRole("button", { name: catalogChipName("Signal") })); const methodDialog = await screen.findByRole("dialog"); expect(within(methodDialog).getAllByRole("textbox").length).toBeGreaterThan( 0, @@ -567,7 +571,7 @@ describe("FinalReviewScreen — chip detail modal", () => { />, ); - fireEvent.click(await screen.findByRole("button", { name: "Signal" })); + fireEvent.click(await screen.findByRole("button", { name: catalogChipName("Signal") })); const dialog = await screen.findByRole("dialog"); expect( within(dialog).getByRole("button", { name: "Remove" }), @@ -595,7 +599,7 @@ describe("FinalReviewScreen — chip detail modal", () => { />, ); - fireEvent.click(await screen.findByRole("button", { name: "Signal" })); + fireEvent.click(await screen.findByRole("button", { name: catalogChipName("Signal") })); const dialog = await screen.findByRole("dialog"); fireEvent.click(within(dialog).getByRole("button", { name: "Remove" })); await waitFor(() => { @@ -633,7 +637,7 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => { ); void latest; - fireEvent.click(await screen.findByRole("button", { name: "Signal" })); + fireEvent.click(await screen.findByRole("button", { name: catalogChipName("Signal") })); const dialog = await screen.findByRole("dialog"); const saveButton = within(dialog).getByRole("button", { name: "Save" }); expect(saveButton).toBeDisabled(); @@ -663,7 +667,7 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => { />, ); - fireEvent.click(await screen.findByRole("button", { name: "Signal" })); + fireEvent.click(await screen.findByRole("button", { name: catalogChipName("Signal") })); const dialog = await screen.findByRole("dialog"); const principleField = within(dialog).getByRole("textbox", { name: /core principle/i, @@ -691,7 +695,7 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => { />, ); - fireEvent.click(await screen.findByRole("button", { name: "Signal" })); + fireEvent.click(await screen.findByRole("button", { name: catalogChipName("Signal") })); const dialog = await screen.findByRole("dialog"); const principleField = within(dialog).getByRole("textbox", { name: /core principle/i, @@ -719,7 +723,7 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => { />, ); - fireEvent.click(await screen.findByRole("button", { name: "Signal" })); + fireEvent.click(await screen.findByRole("button", { name: catalogChipName("Signal") })); const dialog = await screen.findByRole("dialog"); expect(dispatchBeforeUnload()).toBe(false); const principleField = within(dialog).getByRole("textbox", { @@ -750,7 +754,7 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => { ); fireEvent.click( - await screen.findByRole("button", { name: "Custom Comm" }), + await screen.findByRole("button", { name: catalogChipName("Custom Comm") }), ); const dialog = await screen.findByRole("dialog"); expect( @@ -784,9 +788,9 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => { ); expect( - await screen.findByRole("button", { name: "Garden IRC" }), + await screen.findByRole("button", { name: catalogChipName("Garden IRC") }), ).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Signal" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: catalogChipName("Signal") })).toBeInTheDocument(); }); it("shows editable field blocks for user-authored communication chips when configured", async () => { @@ -818,7 +822,7 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => { ); fireEvent.click( - await screen.findByRole("button", { name: "Custom Comm" }), + await screen.findByRole("button", { name: catalogChipName("Custom Comm") }), ); const dialog = await screen.findByRole("dialog"); expect( @@ -861,7 +865,7 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => { ); fireEvent.click( - await screen.findByRole("button", { name: "Custom Comm" }), + await screen.findByRole("button", { name: catalogChipName("Custom Comm") }), ); const dialog = await screen.findByRole("dialog"); const notesField = within(dialog).getByRole("textbox", { name: /notes/i }); @@ -887,7 +891,7 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => { />, ); - fireEvent.click(await screen.findByRole("button", { name: "Signal" })); + fireEvent.click(await screen.findByRole("button", { name: catalogChipName("Signal") })); const dialog = await screen.findByRole("dialog"); fireEvent.click( within(dialog).getByRole("button", { name: /more options/i }), @@ -921,7 +925,7 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => { />, ); - fireEvent.click(await screen.findByRole("button", { name: "Signal" })); + fireEvent.click(await screen.findByRole("button", { name: catalogChipName("Signal") })); const dialog = await screen.findByRole("dialog"); fireEvent.click( within(dialog).getByRole("button", { name: /more options/i }), @@ -957,7 +961,7 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => { />, ); - fireEvent.click(await screen.findByRole("button", { name: "Signal" })); + fireEvent.click(await screen.findByRole("button", { name: catalogChipName("Signal") })); const dialog = await screen.findByRole("dialog"); fireEvent.click( within(dialog).getByRole("button", { name: /more options/i }), diff --git a/tests/components/ModalTextAreaField.test.tsx b/tests/components/ModalTextAreaField.test.tsx index 996aae0..8065f69 100644 --- a/tests/components/ModalTextAreaField.test.tsx +++ b/tests/components/ModalTextAreaField.test.tsx @@ -110,4 +110,44 @@ describe("ModalTextAreaField behavior", () => { expect(screen.getByAltText("Help")).toBeInTheDocument(); }); + + it("starts empty and Generate draft inserts the provided catalog copy", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithProviders( + , + ); + + expect(screen.getByRole("textbox", { name: /Core principle/i })).toHaveValue( + "", + ); + expect( + screen.getByText("Get a first draft for this section"), + ).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Generate draft" })); + expect(onChange).toHaveBeenCalledWith("Catalog first draft"); + }); + + it("hides Generate draft when there is no catalog copy", () => { + renderWithProviders( + {}} + generateDraftText="" + generateDraftLabel="Generate draft" + />, + ); + + expect( + screen.queryByRole("button", { name: "Generate draft" }), + ).not.toBeInTheDocument(); + }); }); diff --git a/tests/components/MultiSelect.test.tsx b/tests/components/MultiSelect.test.tsx index a504924..7d5b9b9 100644 --- a/tests/components/MultiSelect.test.tsx +++ b/tests/components/MultiSelect.test.tsx @@ -62,6 +62,16 @@ describe("MultiSelect – behaviour specifics", () => { expect(screen.getByText("Option 2")).toBeInTheDocument(); }); + it("names toggle chips Select/Deselect so they are not unnamed buttons", () => { + render(); + expect( + screen.getByRole("button", { name: "Select Option 1" }), + ).toHaveAttribute("aria-pressed", "false"); + expect( + screen.getByRole("button", { name: "Deselect Option 2" }), + ).toHaveAttribute("aria-pressed", "true"); + }); + it("calls onChipClick when a chip is clicked", async () => { const handleChipClick = vi.fn(); render( @@ -151,11 +161,11 @@ describe("MultiSelect – behaviour specifics", () => { expect(screen.getByText("2 of 2")).toBeInTheDocument(); expect(screen.getByText("Limit reached")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "One" })).toBeEnabled(); - expect(screen.getByRole("button", { name: "Three" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Deselect One" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "Select Three" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Add option" })).toBeDisabled(); - await userEvent.click(screen.getByRole("button", { name: "Three" })); + await userEvent.click(screen.getByRole("button", { name: "Select Three" })); await userEvent.click(screen.getByRole("button", { name: "Add option" })); expect(handleChipClick).not.toHaveBeenCalled(); expect(handleAddClick).not.toHaveBeenCalled(); diff --git a/tests/components/SelectPage.test.tsx b/tests/components/SelectPage.test.tsx index c940303..def5888 100644 --- a/tests/components/SelectPage.test.tsx +++ b/tests/components/SelectPage.test.tsx @@ -18,4 +18,14 @@ describe("CommunitySizeSelectScreen", () => { expect(screen.getByText("1 member")).toBeInTheDocument(); expect(screen.getByText("2-5 members")).toBeInTheDocument(); }); + + it("gives size chips Select/Deselect accessible names", () => { + render(); + expect( + screen.getByRole("button", { name: "Select 1 member" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Select 2-5 members" }), + ).toBeInTheDocument(); + }); }); diff --git a/tests/unit/methodCardWizardPrefill.test.ts b/tests/unit/methodCardWizardPrefill.test.ts index 3f9c5ca..8e9d67f 100644 --- a/tests/unit/methodCardWizardPrefill.test.ts +++ b/tests/unit/methodCardWizardPrefill.test.ts @@ -177,7 +177,7 @@ describe("buildMethodCardWizardInitialValues", () => { }); describe("facetDetailsToWizardFieldBlocks", () => { - it("returns no blocks when facet copy is empty", () => { + it("keeps catalog field headings when facet copy is empty", () => { expect( facetDetailsToWizardFieldBlocks({ group: "communication", @@ -188,7 +188,26 @@ describe("facetDetailsToWizardFieldBlocks", () => { }, headings: commHeadings, }), - ).toEqual([]); + ).toEqual([ + { + kind: "text", + id: "facet-corePrinciple", + blockTitle: "Core Principle & Scope", + placeholderText: " ", + }, + { + kind: "text", + id: "facet-logisticsAdmin", + blockTitle: "Logistics, Admin & Norms", + placeholderText: "", + }, + { + kind: "text", + id: "facet-codeOfConduct", + blockTitle: "Code of Conduct", + placeholderText: "", + }, + ]); }); it("maps decision scope chips to a badge block", () => {