Show seven cards on the four method decks and open each as a routing overlay of its parts, so groups write from empty fields with Generate draft, search the full deck, and see unselected scope chips with enough contrast.

This commit is contained in:
adilallo committed 2026-11-05 22:43:30 -07:00
1 parent dea7dd7fbd
commit 46656f52d3
28 files changed
+1349 -318

No files matched your search

@@ -0,0 +1,144 @@
"use client";
/**
* Figma: Overlay / Modal / Card (24973:1852) with Has Steps on. A deck card
* opens as a routing view of its parts; each row opens that field's Step
* Modal with Generate draft on an empty field.
*/
import { memo, useCallback, useEffect, useState, type ReactNode } from "react";
import Create from "../../../components/modals/Create";
import { useMessages } from "../../../contexts/MessagesContext";
import type { ModalHeaderMenuItem } from "../../../components/modals/ModalHeader/ModalHeader.types";
import { ValueDialogStepCard } from "./ValueDialogStepCard";
export type MethodCardPolicyStep = {
id: string;
title: string;
emptyDescription: string;
done: boolean;
summary: string;
renderEditor: () => ReactNode;
};
export type MethodCardPolicyDialogProps = {
isOpen: boolean;
cardLabel: string;
description?: string;
editing: boolean;
steps: MethodCardPolicyStep[];
onClose: () => void;
onConfirm: () => void;
onDeselect?: () => void;
kebabTriggerAriaLabel?: string;
kebabMenuAriaLabel?: string;
kebabMenuItems?: ModalHeaderMenuItem[];
ariaLabel?: string;
/** Customized field-blocks body. Replaces the routing steps. */
children?: ReactNode;
};
function MethodCardPolicyDialogComponent({
isOpen,
cardLabel,
description = "",
editing,
steps,
onClose,
onConfirm,
onDeselect,
kebabTriggerAriaLabel,
kebabMenuAriaLabel,
kebabMenuItems,
ariaLabel,
children,
}: MethodCardPolicyDialogProps) {
const m = useMessages();
const t = m.create.customRule.cardDialog;
const [activeStepId, setActiveStepId] = useState<string | null>(null);
useEffect(() => {
if (!isOpen) {
setActiveStepId(null);
}
}, [isOpen]);
const activeStep =
activeStepId == null
? null
: (steps.find((step) => step.id === activeStepId) ?? null);
const useCustomBody = Boolean(children);
const showSteps = !useCustomBody && activeStep == null;
const handleRequestClose = useCallback(() => {
if (activeStepId != null) {
setActiveStepId(null);
return;
}
onClose();
}, [activeStepId, onClose]);
const handleNext = useCallback(() => {
if (activeStepId != null) {
setActiveStepId(null);
return;
}
onConfirm();
}, [activeStepId, onConfirm]);
const title = activeStep ? activeStep.title : cardLabel;
const primaryLabel = activeStepId != null || editing ? t.save : t.addToRule;
return (
<Create
isOpen={isOpen}
onClose={handleRequestClose}
title={title}
description={activeStep ? undefined : description || undefined}
showBackButton={false}
showCancelButton
showNextButton
showRemoveButton={editing && activeStepId == null}
onCancel={handleRequestClose}
onNext={handleNext}
onRemove={onDeselect}
cancelButtonText={t.cancel}
nextButtonText={primaryLabel}
removeButtonText={t.deselect}
stepper={false}
backdropVariant="blurredYellow"
kebabTriggerAriaLabel={kebabTriggerAriaLabel}
kebabMenuAriaLabel={kebabMenuAriaLabel}
kebabMenuItems={
activeStepId == null && kebabMenuItems && kebabMenuItems.length > 0
? kebabMenuItems
: undefined
}
ariaLabel={ariaLabel || cardLabel}
>
{useCustomBody ? (
children
) : showSteps ? (
<div className="flex w-full flex-col gap-[8px]">
{steps.map((step, index) => (
<ValueDialogStepCard
key={step.id}
stepNumber={index + 1}
stepBadgeLabel={t.stepBadge}
title={step.title}
description={step.done ? step.summary : step.emptyDescription}
done={step.done}
onClick={() => setActiveStepId(step.id)}
/>
))}
</div>
) : (
activeStep?.renderEditor()
)}
</Create>
);
}
MethodCardPolicyDialogComponent.displayName = "MethodCardPolicyDialog";
export const MethodCardPolicyDialog = memo(MethodCardPolicyDialogComponent);
@@ -0,0 +1,325 @@
"use client";
import IncrementerBlock from "../../../components/controls/IncrementerBlock";
import { useMessages } from "../../../contexts/MessagesContext";
import { formatConflictApplicableScopeForTextarea } from "../../../../lib/create/ruleSectionsFromMethodSelections";
import { withDecisionApproachKeyResourceScopes } from "../../../../lib/create/decisionApproachKeyResources";
import type {
CommunicationMethodDetailEntry,
ConflictManagementDetailEntry,
DecisionApproachDetailEntry,
MembershipMethodDetailEntry,
} from "../types";
import ApplicableScopeField from "./ApplicableScopeField";
import type { MethodCardPolicyStep } from "./MethodCardPolicyDialog";
import ModalTextAreaField from "./ModalTextAreaField";
const CONSENSUS_LEVEL_MIN = 0;
const CONSENSUS_LEVEL_MAX = 100;
const CONSENSUS_LEVEL_STEP = 5;
function textStep(args: {
id: string;
title: string;
emptyDescription: string;
value: string;
onChange: (next: string) => void;
generateDraftText?: string;
generateDraftLabel: string;
generateDraftHint: string;
filledHint: string;
placeholder: string;
}): MethodCardPolicyStep {
const done = args.value.trim().length > 0;
return {
id: args.id,
title: args.title,
emptyDescription: args.emptyDescription,
done,
summary: args.value,
renderEditor: () => (
<ModalTextAreaField
hideLabel
label={args.title}
value={args.value}
onChange={args.onChange}
placeholder={args.placeholder}
generateDraftText={args.generateDraftText}
generateDraftLabel={args.generateDraftLabel}
generateDraftHint={args.generateDraftHint}
filledHint={args.filledHint}
autoFocus
/>
),
};
}
function usePolicyFieldCopy() {
const m = useMessages();
return {
dialog: m.create.customRule.cardDialog,
draftField: m.create.draftField,
};
}
export function useCommunicationPolicySteps(
value: CommunicationMethodDetailEntry,
onChange: (next: CommunicationMethodDetailEntry) => void,
generateDraft?: CommunicationMethodDetailEntry,
): MethodCardPolicyStep[] {
const { dialog, draftField } = usePolicyFieldCopy();
const headings = useMessages().create.customRule.communication.sectionHeadings;
const fields: Array<keyof CommunicationMethodDetailEntry> = [
"corePrinciple",
"logisticsAdmin",
"codeOfConduct",
];
return fields.map((field) =>
textStep({
id: field,
title: headings[field],
emptyDescription: dialog.emptyStepDescription,
value: value[field],
onChange: (next) => onChange({ ...value, [field]: next }),
generateDraftText: generateDraft?.[field],
generateDraftLabel: draftField.generateDraft,
generateDraftHint: draftField.generateDraftHint,
filledHint: dialog.filledHint,
placeholder: draftField.placeholder,
}),
);
}
export function useMembershipPolicySteps(
value: MembershipMethodDetailEntry,
onChange: (next: MembershipMethodDetailEntry) => void,
generateDraft?: MembershipMethodDetailEntry,
): MethodCardPolicyStep[] {
const { dialog, draftField } = usePolicyFieldCopy();
const headings = useMessages().create.customRule.membership.sectionHeadings;
const fields: Array<keyof MembershipMethodDetailEntry> = [
"eligibility",
"joiningProcess",
"expectations",
];
return fields.map((field) =>
textStep({
id: field,
title: headings[field],
emptyDescription: dialog.emptyStepDescription,
value: value[field],
onChange: (next) => onChange({ ...value, [field]: next }),
generateDraftText: generateDraft?.[field],
generateDraftLabel: draftField.generateDraft,
generateDraftHint: draftField.generateDraftHint,
filledHint: dialog.filledHint,
placeholder: draftField.placeholder,
}),
);
}
export function useConflictPolicySteps(
value: ConflictManagementDetailEntry,
onChange: (next: ConflictManagementDetailEntry) => void,
generateDraft?: ConflictManagementDetailEntry,
): MethodCardPolicyStep[] {
const { dialog, draftField } = usePolicyFieldCopy();
const t = useMessages().create.customRule.conflictManagement;
const scopeText = formatConflictApplicableScopeForTextarea(
value.selectedApplicableScope,
value.applicableScope,
);
const generateScope =
generateDraft && generateDraft.applicableScope.length > 0
? generateDraft.applicableScope.join("\n")
: generateDraft
? formatConflictApplicableScopeForTextarea(
generateDraft.selectedApplicableScope,
generateDraft.applicableScope,
)
: undefined;
return [
textStep({
id: "corePrinciple",
title: t.sectionHeadings.corePrinciple,
emptyDescription: dialog.emptyStepDescription,
value: value.corePrinciple,
onChange: (next) => onChange({ ...value, corePrinciple: next }),
generateDraftText: generateDraft?.corePrinciple,
generateDraftLabel: draftField.generateDraft,
generateDraftHint: draftField.generateDraftHint,
filledHint: dialog.filledHint,
placeholder: draftField.placeholder,
}),
textStep({
id: "applicableScope",
title: t.sectionHeadings.applicableScope,
emptyDescription: t.applicableScopePlaceholder,
value: scopeText,
onChange: (next) => {
const lines = next
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
onChange({
...value,
applicableScope: lines,
selectedApplicableScope: [...lines],
});
},
generateDraftText: generateScope,
generateDraftLabel: draftField.generateDraft,
generateDraftHint: draftField.generateDraftHint,
filledHint: dialog.filledHint,
placeholder: t.applicableScopePlaceholder,
}),
textStep({
id: "processProtocol",
title: t.sectionHeadings.processProtocol,
emptyDescription: dialog.emptyStepDescription,
value: value.processProtocol,
onChange: (next) => onChange({ ...value, processProtocol: next }),
generateDraftText: generateDraft?.processProtocol,
generateDraftLabel: draftField.generateDraft,
generateDraftHint: draftField.generateDraftHint,
filledHint: dialog.filledHint,
placeholder: draftField.placeholder,
}),
textStep({
id: "restorationFallbacks",
title: t.sectionHeadings.restorationFallbacks,
emptyDescription: dialog.emptyStepDescription,
value: value.restorationFallbacks,
onChange: (next) => onChange({ ...value, restorationFallbacks: next }),
generateDraftText: generateDraft?.restorationFallbacks,
generateDraftLabel: draftField.generateDraft,
generateDraftHint: draftField.generateDraftHint,
filledHint: dialog.filledHint,
placeholder: draftField.placeholder,
}),
];
}
export function useDecisionApproachPolicySteps(
value: DecisionApproachDetailEntry,
onChange: (next: DecisionApproachDetailEntry) => void,
generateDraft?: DecisionApproachDetailEntry,
): MethodCardPolicyStep[] {
const { dialog, draftField } = usePolicyFieldCopy();
const t = useMessages().create.customRule.decisionApproaches;
const scopes = withDecisionApproachKeyResourceScopes(value.applicableScope);
const selectedSummary = value.selectedApplicableScope.join(", ");
const consensusStep: MethodCardPolicyStep = {
id: "consensusLevel",
title: t.sectionHeadings.consensusLevel,
emptyDescription: t.consensusLevelDisplay.supermajorityShare,
done: true,
summary:
value.consensusLevel === undefined
? t.consensusLevelDisplay.supermajorityShare
: value.consensusLevel === 0
? t.consensusLevelDisplay.noSetShare
: `${value.consensusLevel}%`,
renderEditor: () =>
value.consensusLevel === undefined ? (
<ModalTextAreaField
hideLabel
label={t.sectionHeadings.consensusLevel}
value={t.consensusLevelDisplay.supermajorityShare}
onChange={() => {}}
disabled
rows={1}
/>
) : (
<IncrementerBlock
label={t.sectionHeadings.consensusLevel}
helpIcon={false}
value={value.consensusLevel}
min={CONSENSUS_LEVEL_MIN}
max={CONSENSUS_LEVEL_MAX}
step={CONSENSUS_LEVEL_STEP}
onChange={(next) => onChange({ ...value, consensusLevel: next })}
formatValue={(v) =>
v === 0 ? t.consensusLevelDisplay.noSetShare : `${v}%`
}
decrementAriaLabel={dialog.decreaseConsensusLevel}
incrementAriaLabel={dialog.increaseConsensusLevel}
/>
),
};
const scopeStep: MethodCardPolicyStep = {
id: "applicableScope",
title: t.sectionHeadings.applicableScope,
emptyDescription: dialog.scopeEmptyDescription,
done: value.selectedApplicableScope.length > 0,
summary: selectedSummary,
renderEditor: () => (
<ApplicableScopeField
label={t.sectionHeadings.applicableScope}
addLabel={t.scopeAddButtonLabel}
scopes={scopes}
selectedScopes={value.selectedApplicableScope}
onToggleScope={(scope) =>
onChange({
...value,
selectedApplicableScope: value.selectedApplicableScope.includes(
scope,
)
? value.selectedApplicableScope.filter((item) => item !== scope)
: [...value.selectedApplicableScope, scope],
})
}
onAddScope={(scope) =>
onChange({
...value,
applicableScope: [...value.applicableScope, scope],
})
}
/>
),
};
return [
textStep({
id: "corePrinciple",
title: t.sectionHeadings.corePrinciple,
emptyDescription: dialog.emptyStepDescription,
value: value.corePrinciple,
onChange: (next) => onChange({ ...value, corePrinciple: next }),
generateDraftText: generateDraft?.corePrinciple,
generateDraftLabel: draftField.generateDraft,
generateDraftHint: draftField.generateDraftHint,
filledHint: dialog.filledHint,
placeholder: draftField.placeholder,
}),
scopeStep,
textStep({
id: "stepByStepInstructions",
title: t.sectionHeadings.stepByStepInstructions,
emptyDescription: dialog.emptyStepDescription,
value: value.stepByStepInstructions,
onChange: (next) => onChange({ ...value, stepByStepInstructions: next }),
generateDraftText: generateDraft?.stepByStepInstructions,
generateDraftLabel: draftField.generateDraft,
generateDraftHint: draftField.generateDraftHint,
filledHint: dialog.filledHint,
placeholder: draftField.placeholder,
}),
consensusStep,
textStep({
id: "objectionsDeadlocks",
title: t.sectionHeadings.objectionsDeadlocks,
emptyDescription: dialog.emptyStepDescription,
value: value.objectionsDeadlocks,
onChange: (next) => onChange({ ...value, objectionsDeadlocks: next }),
generateDraftText: generateDraft?.objectionsDeadlocks,
generateDraftLabel: draftField.generateDraft,
generateDraftHint: draftField.generateDraftHint,
filledHint: dialog.filledHint,
placeholder: draftField.placeholder,
}),
];
}
@@ -14,7 +14,10 @@ import {
deckData,
recommendDeck,
} from "../../../../lib/create/deckRecommend";
import type { GroupAnswers } from "../../../../lib/create/deckTypes";
import type {
GroupAnswers,
RecommendedCard,
} from "../../../../lib/create/deckTypes";
/**
* Card-deck section ids served by `/api/create-flow/methods` (CR-88 §9.2).
@@ -143,8 +146,13 @@ export function deriveCompactCards(
compactCardIds: string[];
recommendedIds: Set<string>;
orderedIds: string[];
fallback: boolean;
recommendedById: Map<string, RecommendedCard>;
} {
const recommendation = recommendDeck(deckData(section), answers, { limit });
const recommendedById = new Map(
recommendation.recommended.map((card) => [card.id, card]),
);
const orderedIds = recommendation.fallback
? recommendation.seeAll
: [
@@ -156,6 +164,8 @@ export function deriveCompactCards(
compactCardIds: orderedIds.slice(0, limit),
recommendedIds: new Set<string>(),
orderedIds,
fallback: true,
recommendedById,
};
}
return {
@@ -166,6 +176,8 @@ export function deriveCompactCards(
.map((card) => card.id),
),
orderedIds,
fallback: false,
recommendedById,
};
}
@@ -2,12 +2,15 @@
import { useMemo } from "react";
import { mergeCompactCardIdsWithPinnedSelected } from "../../../../lib/create/methodCardDisplayOrder";
import { formatRecommendationWhyLine } from "../../../../lib/create/formatRecommendationWhyLine";
import {
deriveCompactCards,
groupAnswersForRecommendations,
type RecommendationSection,
} from "./useFacetRecommendations";
import { deckData } from "../../../../lib/create/deckRecommend";
import { useCreateFlow } from "../context/CreateFlowContext";
import { useMessages } from "../../../contexts/MessagesContext";
type MethodEntry = { id: string; label: string; supportText: string };
@@ -25,9 +28,8 @@ function orderByIds<T extends { id: string }>(
}
/**
* Deck order and the compact stack come from the job rule. Current screens
* show five cards; a deck screen passes seven. Selected cards still pin
* into the compact stack.
* Deck order and the compact stack come from the job rule. Compact shows
* at most seven recommended cards. Selected cards still pin into the stack.
*/
export function useMethodCardDeckOrdering(
section: RecommendationSection,
@@ -35,13 +37,15 @@ export function useMethodCardDeckOrdering(
selectedIds: readonly string[],
) {
const { state } = useCreateFlow();
const copy = useMessages().create.customRule.recommendations;
const compactLimit = deckData(section).config.displayLimit;
const answers = useMemo(
() => groupAnswersForRecommendations(state),
[state],
);
const deck = useMemo(
() => deriveCompactCards(section, answers, 5),
[section, answers],
() => deriveCompactCards(section, answers, compactLimit),
[section, answers, compactLimit],
);
const rankedMethods = useMemo(
@@ -59,25 +63,57 @@ export function useMethodCardDeckOrdering(
deck.compactCardIds,
selectedIds,
selectionShowcaseActive,
5,
compactLimit,
),
[
rankedMethods,
deck.compactCardIds,
selectedIds,
selectionShowcaseActive,
compactLimit,
],
);
const compactIdSet = useMemo(
() => new Set(compactCardIds),
[compactCardIds],
);
const sampleCards = useMemo(
() =>
rankedMethods.map((entry) => ({
id: entry.id,
label: entry.label,
supportText: entry.supportText,
recommended: recommendedIds.has(entry.id),
})),
[rankedMethods, recommendedIds],
rankedMethods.map((entry) => {
const recommendation = deck.recommendedById.get(entry.id);
const whyLine =
recommendation && recommendation.tag !== "everyRuleHas"
? formatRecommendationWhyLine(recommendation.reason, answers, copy)
: "";
const alternativeId = recommendation?.alternative;
const alternativeMethod = alternativeId
? rankedMethods.find((method) => method.id === alternativeId) ??
methods.find((method) => method.id === alternativeId)
: undefined;
return {
id: entry.id,
label: entry.label,
supportText: entry.supportText,
compactSupportText:
whyLine && compactIdSet.has(entry.id) ? whyLine : undefined,
recommended: recommendedIds.has(entry.id),
alternative:
alternativeMethod && compactIdSet.has(entry.id)
? { id: alternativeMethod.id, label: alternativeMethod.label }
: undefined,
};
}),
[
answers,
compactIdSet,
copy,
deck.recommendedById,
methods,
rankedMethods,
recommendedIds,
],
);
const methodById = useMemo(
@@ -92,6 +128,8 @@ export function useMethodCardDeckOrdering(
recommendedIds,
sampleCards,
methodById,
showDeckLine: !deck.fallback,
compactLimit,
recommendationsReady: true,
};
}
@@ -8,11 +8,11 @@
* ({@link CreateFlowTwoColumnSelectShell}); options are {@link CardStack} with
* `layout="singleStack"` and {@link Selection} size `l`.
*
* Card click opens the Figma create modal (node `20246-15829`) with three
* editable sections rendered by {@link CommunicationMethodEditFields}. The
* primary action is **Add Platform** for an unselected card and **Save** for a
* selected card. **Remove** is a danger footer button on selected cards (and
* remains in the kebab) via {@link removeMethodCardFromFacetSelection}.
* Card click opens Overlay / Modal / Card as a routing view of its parts
* ({@link MethodCardPolicyDialog}). The primary action is **Add to Rule** for
* an unselected card and **Save** for a selected card. **Deselect** is the
* footer on selected cards (**Remove** stays in the kebab) via
* {@link removeMethodCardFromFacetSelection}.
*/
import { useState, useCallback, useMemo, useRef } from "react";
@@ -24,9 +24,9 @@ import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { CommunicationMethodEditFields } from "../../components/methodEditFields";
import { MethodCardPolicyDialog } from "../../components/MethodCardPolicyDialog";
import { useCommunicationPolicySteps } from "../../components/methodCardPolicySteps";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
import { communicationPresetFor, emptyCommunicationDetails } from "../../../../../lib/create/finalReviewChipPresets";
@@ -105,7 +105,8 @@ export function CommunicationMethodsScreen() {
[comm.methods, selectedIds, state.customMethodCardMetaById],
);
const { sampleCards, compactCardIds, methodById, recommendationsReady } =
const rec = m.create.customRule.recommendations;
const { sampleCards, compactCardIds, methodById, recommendationsReady, showDeckLine, compactLimit } =
useMethodCardDeckOrdering(
"communication",
mergedMethods,
@@ -188,10 +189,14 @@ export function CommunicationMethodsScreen() {
},
[markCreateFlowInteraction],
);
const communicationSteps = useCommunicationPolicySteps(
pendingDraft ?? emptyCommunicationDetails(),
handleDraftChange,
pendingCardId ? communicationPresetFor(pendingCardId) : undefined,
);
const isSelectedCardModal =
pendingCardId !== null && selectedIds.includes(pendingCardId);
const showMethodModalPrimary = true;
const customFacetDetailsMatchPreset = useMemo(() => {
if (!pendingCardId || !pendingDraft) return false;
@@ -712,57 +717,52 @@ export function CommunicationMethodsScreen() {
}}
hasMore={true}
toggleLabel={comm.page.seeAllLink}
compactRecommendedLimit={5}
compactRecommendedLimit={compactLimit}
compactCardIds={compactCardIds}
layout="singleStack"
deckLine={showDeckLine ? rec.deckLine : undefined}
searchPlaceholder={rec.searchPlaceholder}
alternativePrefix={rec.alternative}
onAlternativeSelect={handleCardClick}
className="w-full lg:h-full lg:min-h-0 lg:flex-1"
/>
)}
</div>
</CreateFlowTwoColumnSelectShell>
<Create
<MethodCardPolicyDialog
isOpen={createModalOpen}
onClose={handleCreateModalClose}
onNext={handleCreateModalPrimary}
title={modalConfig.title}
cardLabel={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={false}
showNextButton={showMethodModalPrimary}
showRemoveButton={isSelectedCardModal}
onRemove={handleRemoveSelectedFromModal}
backdropVariant="blurredYellow"
editing={isSelectedCardModal}
steps={communicationSteps}
onClose={handleCreateModalClose}
onConfirm={handleCreateModalPrimary}
onDeselect={
isSelectedCardModal ? handleRemoveSelectedFromModal : undefined
}
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
kebabMenuItems={kebabMenuItems}
>
{pendingCardId && pendingDraft ? (
modalUsesWizardFieldBlocksBody ? (
<CustomMethodCardModalBody
cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={
draftFieldBlocks !== null ? draftFieldBlocks : undefined
}
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
showPolicyContentLockupWhenNoBlocks={
draftFieldBlocks === null || draftFieldBlocks.length === 0
}
onFieldBlocksChange={
draftFieldBlocks === null
? undefined
: (next) => setDraftFieldBlocks(next)
}
/>
) : (
<CommunicationMethodEditFields
value={pendingDraft}
onChange={handleDraftChange}
generateDraft={communicationPresetFor(pendingCardId)}
/>
)
{pendingCardId && modalUsesWizardFieldBlocksBody ? (
<CustomMethodCardModalBody
cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={
draftFieldBlocks !== null ? draftFieldBlocks : undefined
}
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
showPolicyContentLockupWhenNoBlocks={
draftFieldBlocks === null || draftFieldBlocks.length === 0
}
onFieldBlocksChange={
draftFieldBlocks === null
? undefined
: (next) => setDraftFieldBlocks(next)
}
/>
) : null}
</Create>
</MethodCardPolicyDialog>
<CustomMethodCardWizard
isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard}
@@ -7,12 +7,9 @@
* Page chrome is the shared question-and-options shell
* (`CreateFlowTwoColumnSelectShell`); options are CardStack `singleStack`.
*
* Card click opens the Figma "Add Approach" create modal (node `20874-172292`)
* with four controls rendered by {@link ConflictManagementEditFields}: Core
* Principle, Applicable Scope (text area), Process Protocol, and Restoration
* & Fallbacks. The same field set is reused on `/create/final-review` — see
* `FinalReviewChipEditModal`. Confirm persists both the chip selection and
* any user edits as a `conflictManagementDetailsById[id]` override.
* Card click opens Overlay / Modal / Card as a routing view of its parts
* ({@link MethodCardPolicyDialog}). Confirm persists the chip selection and
* any edits as a `conflictManagementDetailsById[id]` override.
*/
import { useState, useCallback, useMemo, useRef } from "react";
@@ -24,9 +21,9 @@ import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { ConflictManagementEditFields } from "../../components/methodEditFields";
import { MethodCardPolicyDialog } from "../../components/MethodCardPolicyDialog";
import { useConflictPolicySteps } from "../../components/methodCardPolicySteps";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
import { conflictManagementPresetFor, emptyConflictManagementDetails } from "../../../../../lib/create/finalReviewChipPresets";
@@ -105,7 +102,8 @@ export function ConflictManagementScreen() {
[cm.methods, selectedIds, state.customMethodCardMetaById],
);
const { sampleCards, compactCardIds, methodById, recommendationsReady } =
const rec = m.create.customRule.recommendations;
const { sampleCards, compactCardIds, methodById, recommendationsReady, showDeckLine, compactLimit } =
useMethodCardDeckOrdering(
"conflictManagement",
mergedMethods,
@@ -192,10 +190,14 @@ export function ConflictManagementScreen() {
},
[markCreateFlowInteraction],
);
const conflictSteps = useConflictPolicySteps(
pendingDraft ?? emptyConflictManagementDetails(),
handleDraftChange,
pendingCardId ? conflictManagementPresetFor(pendingCardId) : undefined,
);
const isSelectedCardModal =
pendingCardId !== null && selectedIds.includes(pendingCardId);
const showMethodModalPrimary = true;
const customFacetDetailsMatchPreset = useMemo(() => {
if (!pendingCardId || !pendingDraft) return false;
@@ -716,57 +718,52 @@ export function ConflictManagementScreen() {
}}
hasMore={true}
toggleLabel={cm.page.seeAllLink}
compactRecommendedLimit={5}
compactRecommendedLimit={compactLimit}
compactCardIds={compactCardIds}
layout="singleStack"
deckLine={showDeckLine ? rec.deckLine : undefined}
searchPlaceholder={rec.searchPlaceholder}
alternativePrefix={rec.alternative}
onAlternativeSelect={handleCardClick}
className="w-full lg:h-full lg:min-h-0 lg:flex-1"
/>
)}
</div>
</CreateFlowTwoColumnSelectShell>
<Create
<MethodCardPolicyDialog
isOpen={createModalOpen}
onClose={handleCreateModalClose}
onNext={handleCreateModalPrimary}
title={modalConfig.title}
cardLabel={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={false}
showNextButton={showMethodModalPrimary}
showRemoveButton={isSelectedCardModal}
onRemove={handleRemoveSelectedFromModal}
backdropVariant="blurredYellow"
editing={isSelectedCardModal}
steps={conflictSteps}
onClose={handleCreateModalClose}
onConfirm={handleCreateModalPrimary}
onDeselect={
isSelectedCardModal ? handleRemoveSelectedFromModal : undefined
}
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
kebabMenuItems={kebabMenuItems}
>
{pendingCardId && pendingDraft ? (
modalUsesWizardFieldBlocksBody ? (
<CustomMethodCardModalBody
cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={
draftFieldBlocks !== null ? draftFieldBlocks : undefined
}
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
showPolicyContentLockupWhenNoBlocks={
draftFieldBlocks === null || draftFieldBlocks.length === 0
}
onFieldBlocksChange={
draftFieldBlocks === null
? undefined
: (next) => setDraftFieldBlocks(next)
}
/>
) : (
<ConflictManagementEditFields
value={pendingDraft}
onChange={handleDraftChange}
generateDraft={conflictManagementPresetFor(pendingCardId)}
/>
)
{pendingCardId && modalUsesWizardFieldBlocksBody ? (
<CustomMethodCardModalBody
cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={
draftFieldBlocks !== null ? draftFieldBlocks : undefined
}
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
showPolicyContentLockupWhenNoBlocks={
draftFieldBlocks === null || draftFieldBlocks.length === 0
}
onFieldBlocksChange={
draftFieldBlocks === null
? undefined
: (next) => setDraftFieldBlocks(next)
}
/>
) : null}
</Create>
</MethodCardPolicyDialog>
<CustomMethodCardWizard
isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard}
@@ -7,13 +7,9 @@
* Page chrome is the shared question-and-options shell
* (`CreateFlowTwoColumnSelectShell`); options are CardStack `singleStack`.
*
* Card click opens the Figma create modal (node `20858-13948`) with three
* editable sections rendered by {@link MembershipMethodEditFields}. The same
* field set is reused on `/create/final-review` — see `FinalReviewChipEditModal`.
* Confirm persists both the chip selection and any user edits as a
* `membershipMethodDetailsById[id]` override; section defaults come from
* `messages/en/create/customRule/membership.json` and will be replaced with
* DB-driven content.
* Card click opens Overlay / Modal / Card as a routing view of its parts
* ({@link MethodCardPolicyDialog}). Confirm persists the chip selection and
* any edits as a `membershipMethodDetailsById[id]` override.
*/
import { useState, useCallback, useMemo, useRef } from "react";
@@ -25,9 +21,9 @@ import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { MembershipMethodEditFields } from "../../components/methodEditFields";
import { MethodCardPolicyDialog } from "../../components/MethodCardPolicyDialog";
import { useMembershipPolicySteps } from "../../components/methodCardPolicySteps";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
import { membershipPresetFor, emptyMembershipDetails } from "../../../../../lib/create/finalReviewChipPresets";
@@ -106,7 +102,8 @@ export function MembershipMethodsScreen() {
[mem.methods, selectedIds, state.customMethodCardMetaById],
);
const { sampleCards, compactCardIds, methodById, recommendationsReady } =
const rec = m.create.customRule.recommendations;
const { sampleCards, compactCardIds, methodById, recommendationsReady, showDeckLine, compactLimit } =
useMethodCardDeckOrdering(
"membership",
mergedMethods,
@@ -189,10 +186,14 @@ export function MembershipMethodsScreen() {
},
[markCreateFlowInteraction],
);
const membershipSteps = useMembershipPolicySteps(
pendingDraft ?? emptyMembershipDetails(),
handleDraftChange,
pendingCardId ? membershipPresetFor(pendingCardId) : undefined,
);
const isSelectedCardModal =
pendingCardId !== null && selectedIds.includes(pendingCardId);
const showMethodModalPrimary = true;
const customFacetDetailsMatchPreset = useMemo(() => {
if (!pendingCardId || !pendingDraft) return false;
@@ -709,57 +710,52 @@ export function MembershipMethodsScreen() {
}}
hasMore={true}
toggleLabel={mem.page.seeAllLink}
compactRecommendedLimit={5}
compactRecommendedLimit={compactLimit}
compactCardIds={compactCardIds}
layout="singleStack"
deckLine={showDeckLine ? rec.deckLine : undefined}
searchPlaceholder={rec.searchPlaceholder}
alternativePrefix={rec.alternative}
onAlternativeSelect={handleCardClick}
className="w-full lg:h-full lg:min-h-0 lg:flex-1"
/>
)}
</div>
</CreateFlowTwoColumnSelectShell>
<Create
<MethodCardPolicyDialog
isOpen={createModalOpen}
onClose={handleCreateModalClose}
onNext={handleCreateModalPrimary}
title={modalConfig.title}
cardLabel={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={false}
showNextButton={showMethodModalPrimary}
showRemoveButton={isSelectedCardModal}
onRemove={handleRemoveSelectedFromModal}
backdropVariant="blurredYellow"
editing={isSelectedCardModal}
steps={membershipSteps}
onClose={handleCreateModalClose}
onConfirm={handleCreateModalPrimary}
onDeselect={
isSelectedCardModal ? handleRemoveSelectedFromModal : undefined
}
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
kebabMenuItems={kebabMenuItems}
>
{pendingCardId && pendingDraft ? (
modalUsesWizardFieldBlocksBody ? (
<CustomMethodCardModalBody
cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={
draftFieldBlocks !== null ? draftFieldBlocks : undefined
}
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
showPolicyContentLockupWhenNoBlocks={
draftFieldBlocks === null || draftFieldBlocks.length === 0
}
onFieldBlocksChange={
draftFieldBlocks === null
? undefined
: (next) => setDraftFieldBlocks(next)
}
/>
) : (
<MembershipMethodEditFields
value={pendingDraft}
onChange={handleDraftChange}
generateDraft={membershipPresetFor(pendingCardId)}
/>
)
{pendingCardId && modalUsesWizardFieldBlocksBody ? (
<CustomMethodCardModalBody
cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={
draftFieldBlocks !== null ? draftFieldBlocks : undefined
}
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
showPolicyContentLockupWhenNoBlocks={
draftFieldBlocks === null || draftFieldBlocks.length === 0
}
onFieldBlocksChange={
draftFieldBlocks === null
? undefined
: (next) => setDraftFieldBlocks(next)
}
/>
) : null}
</Create>
</MethodCardPolicyDialog>
<CustomMethodCardWizard
isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard}
@@ -7,20 +7,17 @@
* Layout matches {@link CreateFlowTwoColumnSelectShell}: one column below `lg` (1024px), two columns
* at `lg+` with a scrollable rail — same breakpoint and height chain as select steps, distinct content.
*
* Card click opens the Figma "Add Approach" create modal (node `20870-72155`) with five controls
* rendered by {@link DecisionApproachEditFields}: Core Principle, Applicable Scope, Step-by-Step
* Instructions, Consensus Level, and Objections & Deadlocks. The same field set is reused on
* `/create/final-review` — see `FinalReviewChipEditModal`. Confirm persists both the chip
* selection and any user edits as a `decisionApproachDetailsById[id]` override; section
* defaults come from `messages/en/create/customRule/decisionApproaches.json` and will be
* replaced with DB-driven content.
* Card click opens Overlay / Modal / Card as a routing view of its parts
* ({@link MethodCardPolicyDialog}). Confirm persists the chip selection and
* any edits as a `decisionApproachDetailsById[id]` override.
*/
import { useState, useCallback, useMemo, useRef } from "react";
import CardStack from "../../../../components/cards/CardStack";
import HeaderLockup from "../../../../components/type/HeaderLockup";
import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { MethodCardPolicyDialog } from "../../components/MethodCardPolicyDialog";
import { useDecisionApproachPolicySteps } from "../../components/methodCardPolicySteps";
import InfoMessageBox from "../../../../components/controls/InfoMessageBox";
import type { InfoMessageBoxItem } from "../../../../components/controls/InfoMessageBox/InfoMessageBox.types";
import { useMessages } from "../../../../contexts/MessagesContext";
@@ -30,7 +27,6 @@ import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import { DecisionApproachEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
import { decisionApproachPresetFor, emptyDecisionApproachTextFields } from "../../../../../lib/create/finalReviewChipPresets";
@@ -130,7 +126,8 @@ export function DecisionApproachesScreen() {
[da.methods, selectedIds, state.customMethodCardMetaById],
);
const { sampleCards, compactCardIds, methodById, recommendationsReady } =
const rec = m.create.customRule.recommendations;
const { sampleCards, compactCardIds, methodById, recommendationsReady, showDeckLine, compactLimit } =
useMethodCardDeckOrdering(
"decisionApproaches",
mergedMethods,
@@ -237,10 +234,17 @@ export function DecisionApproachesScreen() {
},
[markCreateFlowInteraction],
);
const decisionSteps = useDecisionApproachPolicySteps(
pendingDraft ??
emptyDecisionApproachTextFields(
decisionApproachPresetFor(pendingCardId ?? ""),
),
handleDraftChange,
pendingCardId ? decisionApproachPresetFor(pendingCardId) : undefined,
);
const isSelectedCardModal =
pendingCardId !== null && selectedIds.includes(pendingCardId);
const showMethodModalPrimary = true;
const customFacetDetailsMatchPreset = useMemo(() => {
if (!pendingCardId || !pendingDraft) return false;
@@ -794,57 +798,52 @@ export function DecisionApproachesScreen() {
)
}
layout="singleStack"
compactRecommendedLimit={5}
compactRecommendedLimit={compactLimit}
compactCardIds={compactCardIds}
deckLine={showDeckLine ? rec.deckLine : undefined}
searchPlaceholder={rec.searchPlaceholder}
alternativePrefix={rec.alternative}
onAlternativeSelect={handleCardSelect}
className="w-full lg:h-full lg:min-h-0 lg:flex-1"
headerLockupSize={mdUp ? "L" : "M"}
/>
)}
</div>
<Create
<MethodCardPolicyDialog
isOpen={createModalOpen}
onClose={handleCreateModalClose}
onNext={handleCreateModalPrimary}
title={modalConfig.title}
cardLabel={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={false}
showNextButton={showMethodModalPrimary}
showRemoveButton={isSelectedCardModal}
onRemove={handleRemoveSelectedFromModal}
backdropVariant="blurredYellow"
editing={isSelectedCardModal}
steps={decisionSteps}
onClose={handleCreateModalClose}
onConfirm={handleCreateModalPrimary}
onDeselect={
isSelectedCardModal ? handleRemoveSelectedFromModal : undefined
}
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
kebabMenuItems={kebabMenuItems}
>
{pendingCardId && pendingDraft ? (
modalUsesWizardFieldBlocksBody ? (
<CustomMethodCardModalBody
cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={
draftFieldBlocks !== null ? draftFieldBlocks : undefined
}
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
showPolicyContentLockupWhenNoBlocks={
draftFieldBlocks === null || draftFieldBlocks.length === 0
}
onFieldBlocksChange={
draftFieldBlocks === null
? undefined
: (next) => setDraftFieldBlocks(next)
}
/>
) : (
<DecisionApproachEditFields
value={pendingDraft}
onChange={handleDraftChange}
generateDraft={decisionApproachPresetFor(pendingCardId)}
/>
)
{pendingCardId && modalUsesWizardFieldBlocksBody ? (
<CustomMethodCardModalBody
cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={
draftFieldBlocks !== null ? draftFieldBlocks : undefined
}
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
showPolicyContentLockupWhenNoBlocks={
draftFieldBlocks === null || draftFieldBlocks.length === 0
}
onFieldBlocksChange={
draftFieldBlocks === null
? undefined
: (next) => setDraftFieldBlocks(next)
}
/>
) : null}
</Create>
</MethodCardPolicyDialog>
</CreateFlowTwoColumnSelectShell>
<CustomMethodCardWizard
isOpen={addCustomWizardOpen}
@@ -1,6 +1,6 @@
"use client";
import { memo, useCallback, useState } from "react";
import { memo, useCallback, useEffect, useState } from "react";
import { useTranslation } from "../../../contexts/MessagesContext";
import { CardStackView } from "./CardStack.view";
import type { CardStackProps } from "./CardStack.types";
@@ -29,7 +29,7 @@ const CardStackContainer = memo<CardStackProps>(
title = "",
description = "",
layout = "default",
compactRecommendedLimit = 5,
compactRecommendedLimit = 7,
compactCardIds,
compactDesktopLayout: compactDesktopLayoutProp = "grid",
headerLockupSize,
@@ -39,16 +39,25 @@ const CardStackContainer = memo<CardStackProps>(
addCardLabel = "",
addCardAriaLabel = "",
onAddCard,
deckLine = "",
searchPlaceholder = "",
alternativePrefix = "",
onAlternativeSelect,
}) => {
const t = useTranslation("controlsChrome");
const [internalExpanded, setInternalExpanded] = useState(false);
const [internalSelectedIds, setInternalSelectedIds] = useState<string[]>(
[],
);
const [searchQuery, setSearchQuery] = useState("");
const expanded =
controlledExpanded !== undefined ? controlledExpanded : internalExpanded;
useEffect(() => {
if (!expanded) setSearchQuery("");
}, [expanded]);
const handleToggleExpand = useCallback(() => {
if (controlledOnToggleExpand) {
controlledOnToggleExpand();
@@ -102,6 +111,12 @@ const CardStackContainer = memo<CardStackProps>(
addCardLabel={addCardLabel}
addCardAriaLabel={addCardAriaLabel}
onAddCard={onAddCard}
deckLine={deckLine}
searchPlaceholder={searchPlaceholder}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
alternativePrefix={alternativePrefix}
onAlternativeSelect={onAlternativeSelect}
/>
);
},
@@ -6,6 +6,10 @@ export interface CardStackItem {
label: string;
supportText?: string;
recommended?: boolean;
/** Compact-seven why line; See all keeps `supportText`. */
compactSupportText?: string;
/** One alternative per job, shown under the compact card. */
alternative?: { id: string; label: string };
}
export interface CardStackProps {
@@ -23,9 +27,16 @@ export interface CardStackProps {
/** "default" = compact grid/column + expanded 2-col 142px tiles; "singleStack" = always one column, expand shows more in same stack */
layout?: "default" | "singleStack";
/**
* Max recommended cards in compact (non-expanded) mode. Default 5; Figma compact stack uses 3.
* Max recommended cards in compact (non-expanded) mode. Default 7.
*/
compactRecommendedLimit?: number;
/** Line above the compact seven, in place of a tag on every card. */
deckLine?: string;
/** See-all search field placeholder. */
searchPlaceholder?: string;
/** Prefix for the compact alternative row. */
alternativePrefix?: string;
onAlternativeSelect?: (id: string) => void;
/**
* Optional explicit list of card ids to render in the compact slot, in
* order. When provided, this overrides the default
@@ -78,4 +89,10 @@ export interface CardStackViewProps {
addCardLabel: string;
addCardAriaLabel: string;
onAddCard?: () => void;
deckLine: string;
searchPlaceholder: string;
searchQuery: string;
onSearchQueryChange: (value: string) => void;
alternativePrefix: string;
onAlternativeSelect?: (id: string) => void;
}
@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
import HeaderLockup from "../../type/HeaderLockup";
import type { HeaderLockupSizeValue } from "../../type/HeaderLockup/HeaderLockup.types";
import Selection from "../Selection";
import type { CardStackViewProps } from "./CardStack.types";
import type { CardStackItem, CardStackViewProps } from "./CardStack.types";
/** Figma Card / CardSelection on Compact + Expanded Card Stack (`281×142`). */
const FIGMA_CARD_SELECTION_TILE_CLASS =
@@ -38,6 +38,87 @@ function CardStackHeaderLockup({
);
}
function matchesSearch(item: CardStackItem, query: string): boolean {
if (!query) return true;
const haystack = `${item.label} ${item.supportText ?? ""}`.toLowerCase();
return haystack.includes(query.toLowerCase());
}
function SearchGlyph() {
return (
<svg
aria-hidden
viewBox="0 0 24 24"
className="pointer-events-none absolute right-3 top-1/2 size-6 -translate-y-1/2 text-[var(--color-content-default-tertiary)]"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="1.5" />
<path
d="M20 20l-3.5-3.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
}
function StackCard({
item,
expanded,
selected,
size,
className,
onSelect,
onAlternativeSelect,
alternativePrefix,
}: {
item: CardStackItem;
expanded: boolean;
selected: boolean;
size: "m" | "l";
className?: string;
onSelect: (id: string) => void;
onAlternativeSelect?: (id: string) => void;
alternativePrefix: string;
}) {
const supportText =
!expanded && item.compactSupportText
? item.compactSupportText
: item.supportText;
const recommended = Boolean(expanded && item.recommended);
const alternative =
!expanded && item.alternative && onAlternativeSelect ? (
<p className="w-full text-x-small-paragraph text-[var(--color-content-invert-tertiary)]">
{alternativePrefix}{" "}
<button
type="button"
className="underline text-[var(--color-content-invert-primary)]"
onClick={() => onAlternativeSelect(item.alternative!.id)}
>
{item.alternative.label}
</button>
</p>
) : null;
return (
<div className="flex w-full flex-col gap-1">
<Selection
id={item.id}
label={item.label}
supportText={supportText}
recommended={recommended}
selected={selected}
size={size}
showInfoIcon={false}
className={className}
onClick={() => onSelect(item.id)}
/>
{alternative}
</div>
);
}
function CardStackAddCardButton({
label,
ariaLabel,
@@ -98,6 +179,12 @@ export function CardStackView({
addCardLabel,
addCardAriaLabel,
onAddCard,
deckLine,
searchPlaceholder,
searchQuery,
onSearchQueryChange,
alternativePrefix,
onAlternativeSelect,
}: CardStackViewProps) {
const addTile =
showAddCard && onAddCard && addCardLabel.length > 0 ? (
@@ -130,7 +217,9 @@ export function CardStackView({
// At lg+ the cards scroll inside this column and the See all / Show less
// control stays pinned below (Figma Flow — Right Rail).
if (layout === "singleStack") {
const displayedCards = expanded ? cards : compactCards;
const displayedCards = (expanded ? cards : compactCards).filter((item) =>
expanded ? matchesSearch(item, searchQuery) : true,
);
return (
<div
className={`flex w-full min-w-0 flex-col gap-6 lg:h-full lg:max-h-full lg:min-h-0 lg:flex-1 lg:overflow-hidden ${className}`}
@@ -142,18 +231,35 @@ export function CardStackView({
size={lockupSize}
wrapperClassName="min-w-0 shrink-0"
/>
{!expanded && deckLine ? (
<p className="shrink-0 text-x-small-paragraph text-[var(--color-content-default-tertiary)]">
{deckLine}
</p>
) : null}
{expanded && searchPlaceholder ? (
<div className="relative w-full shrink-0">
<input
type="search"
value={searchQuery}
onChange={(event) => onSearchQueryChange(event.target.value)}
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
className="h-10 w-full rounded-[var(--radius-200)] border border-[var(--color-border-default-control)] bg-[var(--color-surface-default-secondary)] px-3 py-2.5 pr-11 text-medium-paragraph text-[var(--color-content-default-primary)] placeholder:text-[var(--color-content-default-tertiary)] outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)]"
/>
<SearchGlyph />
</div>
) : null}
<div className="flex w-full min-w-0 flex-col gap-2 lg:min-h-0 lg:flex-1 lg:overflow-y-auto scrollbar-design">
{displayedCards.map((item) => (
<Selection
<StackCard
key={item.id}
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
item={item}
expanded={expanded}
selected={isSelected(item.id)}
size="l"
showInfoIcon={false}
onClick={() => onCardSelect(item.id)}
onSelect={onCardSelect}
onAlternativeSelect={onAlternativeSelect}
alternativePrefix={alternativePrefix}
/>
))}
{addTile}
+1 -1
View File
@@ -72,7 +72,7 @@ function chipSurfaceStyle(
return paint(
"transparent",
"var(--color-content-default-brand-primary)",
"var(--color-border-default-control)",
"var(--color-content-default-tertiary)",
);
}