Custom add and create flow polish

This commit is contained in:
adilallo
2026-05-08 20:32:24 -06:00
parent 26bcd61ea3
commit 026a1e6d71
68 changed files with 6208 additions and 527 deletions
@@ -35,6 +35,8 @@ export interface ApplicableScopeFieldProps {
* Optional placeholder for the inline input. Defaults to `addLabel`.
*/
inputPlaceholder?: string;
/** When true, scope chips and add affordance are non-interactive. */
readOnly?: boolean;
className?: string;
}
@@ -46,6 +48,7 @@ function ApplicableScopeFieldComponent({
onToggleScope,
onAddScope,
inputPlaceholder,
readOnly = false,
className = "",
}: ApplicableScopeFieldProps) {
const [draft, setDraft] = useState("");
@@ -78,13 +81,13 @@ function ApplicableScopeFieldComponent({
state={isSelected ? "selected" : "disabled"}
palette="default"
size="s"
disabled={false}
onClick={() => onToggleScope(scope)}
disabled={readOnly}
onClick={() => !readOnly && onToggleScope(scope)}
ariaLabel={`${isSelected ? "Deselect" : "Select"} ${scope}`}
/>
);
})}
{isAdding ? (
{readOnly ? null : isAdding ? (
<input
type="text"
autoFocus
@@ -14,8 +14,8 @@ import { memo, useCallback, useRef, useState } from "react";
import { useMessages, useTranslation } from "../../../contexts/MessagesContext";
import Chip from "../../../components/controls/Chip";
import IncrementerBlock from "../../../components/controls/IncrementerBlock";
import InlineTextButton from "../../../components/buttons/InlineTextButton";
import Upload from "../../../components/controls/Upload";
import { getAssetPath } from "../../../../lib/assetUtils";
import ApplicableScopeField from "./ApplicableScopeField";
import InputLabel from "../../../components/type/InputLabel";
import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks";
@@ -44,7 +44,9 @@ function CustomMethodCardUploadBlockRow({
patch,
uploadFileInputAriaLabel,
uploadHint,
clearFileLabel,
clearPendingUploadAriaLabel,
clearPendingUploadTooltip,
uploadPreviewImageAlt,
noFileChosen,
}: {
block: Extract<CustomMethodCardFieldBlock, { kind: "upload" }>;
@@ -52,7 +54,9 @@ function CustomMethodCardUploadBlockRow({
patch: (_next: CustomMethodCardFieldBlock[]) => void;
uploadFileInputAriaLabel: string;
uploadHint: string;
clearFileLabel: string;
clearPendingUploadAriaLabel: string;
clearPendingUploadTooltip: string;
uploadPreviewImageAlt: string;
noFileChosen: string;
}) {
const uploadInputRef = useRef<HTMLInputElement | null>(null);
@@ -60,8 +64,17 @@ function CustomMethodCardUploadBlockRow({
const [busy, setBusy] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const displayName = block.fileName?.trim() ? block.fileName : noFileChosen;
const hasAsset = Boolean(block.assetUrl?.trim());
const previewAlt = block.fileName?.trim() || block.blockTitle || noFileChosen;
const assetUrlTrimmed = block.assetUrl?.trim() ?? "";
const hasAsset = assetUrlTrimmed.length > 0;
const clearUpload = () =>
patch(
mapBlockById(blocks, block.id, (b) =>
b.kind === "upload"
? { ...b, fileName: undefined, assetUrl: undefined }
: b,
),
);
return (
<div className="flex flex-col gap-2">
@@ -76,14 +89,6 @@ function CustomMethodCardUploadBlockRow({
{displayName}
</p>
) : null}
{hasAsset ? (
// eslint-disable-next-line @next/next/no-img-element -- same-origin upload URL
<img
src={block.assetUrl!.trim()}
alt={previewAlt}
className="max-h-[160px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
/>
) : null}
<input
ref={uploadInputRef}
type="file"
@@ -123,13 +128,41 @@ function CustomMethodCardUploadBlockRow({
})();
}}
/>
<Upload
active={!busy}
hintText={busy ? tUpload("uploading") : uploadHint}
onClick={() => {
if (!busy) uploadInputRef.current?.click();
}}
/>
{hasAsset ? (
<div className="relative inline-block max-w-full">
<button
type="button"
onClick={clearUpload}
className="absolute right-[8px] top-[8px] z-[1] flex h-[32px] w-[32px] cursor-pointer items-center justify-center rounded-full bg-[var(--color-surface-default-secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]"
aria-label={clearPendingUploadAriaLabel}
title={clearPendingUploadTooltip}
>
{/* eslint-disable-next-line @next/next/no-img-element -- matches ModalHeader close control */}
<img
src={getAssetPath("assets/Icon_Close.svg")}
alt=""
className="h-[16px] w-[16px]"
style={{
filter: "brightness(0) invert(1)",
}}
/>
</button>
{/* eslint-disable-next-line @next/next/no-img-element -- same-origin upload URL */}
<img
src={assetUrlTrimmed}
alt={uploadPreviewImageAlt}
className="max-h-[160px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
/>
</div>
) : (
<Upload
active={!busy}
hintText={busy ? tUpload("uploading") : uploadHint}
onClick={() => {
if (!busy) uploadInputRef.current?.click();
}}
/>
)}
{errorMessage ? (
<p
className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-s)] text-[var(--color-content-default-secondary)]"
@@ -138,21 +171,6 @@ function CustomMethodCardUploadBlockRow({
{errorMessage}
</p>
) : null}
{block.fileName?.trim() || block.assetUrl?.trim() ? (
<InlineTextButton
onClick={() =>
patch(
mapBlockById(blocks, block.id, (b) =>
b.kind === "upload"
? { ...b, fileName: undefined, assetUrl: undefined }
: b,
),
)
}
>
{clearFileLabel}
</InlineTextButton>
) : null}
</div>
);
}
@@ -297,7 +315,13 @@ function CustomMethodCardFieldBlocksSummaryComponent({
patch={patch}
uploadFileInputAriaLabel={fm.upload.uploadFileInputAriaLabel}
uploadHint={fm.upload.uploadHint}
clearFileLabel={em.clearFileLabel}
clearPendingUploadAriaLabel={
fm.upload.clearPendingUploadAriaLabel
}
clearPendingUploadTooltip={
fm.upload.clearPendingUploadTooltip
}
uploadPreviewImageAlt={fm.upload.uploadPreviewImageAlt}
noFileChosen={noFileChosen}
/>
)}
@@ -1,5 +1,7 @@
"use client";
import ContentLockup from "../../../components/type/ContentLockup";
import { useMessages } from "../../../contexts/MessagesContext";
import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks";
import type { CreateFlowState } from "../types";
import CustomMethodCardFieldBlocksSummary from "./CustomMethodCardFieldBlocksSummary";
@@ -12,12 +14,22 @@ export default function CustomMethodCardModalBody({
/** When set, used instead of `blocksById[cardId]` (e.g. final-review draft). */
blocksOverride,
onFieldBlocksChange,
policyMeta,
/**
* When false, omit {@link ContentLockup} for title/description (Customize mode:
* {@link MethodCardCustomizeModalHeader} already edits them). Summary line still shows.
* @default true
*/
showPolicyContentLockupWhenNoBlocks = true,
}: {
cardId: string;
blocksById: CreateFlowState["customMethodCardFieldBlocksById"];
blocksOverride?: CustomMethodCardFieldBlock[] | null;
onFieldBlocksChange?: (_blocks: CustomMethodCardFieldBlock[]) => void;
policyMeta?: { label: string; supportText: string };
showPolicyContentLockupWhenNoBlocks?: boolean;
}) {
const m = useMessages();
const blocks = blocksOverride ?? blocksById?.[cardId];
if (blocks && blocks.length > 0) {
return (
@@ -27,5 +39,30 @@ export default function CustomMethodCardModalBody({
/>
);
}
const label = policyMeta?.label?.trim() ?? "";
const support = policyMeta?.supportText?.trim() ?? "";
if (label.length > 0 || support.length > 0) {
const noFieldsHint = m.create.customRule.customMethodCardWizard.editModal
.noCustomFieldsYet;
return (
<div className="flex flex-col gap-4">
{showPolicyContentLockupWhenNoBlocks ? (
<ContentLockup
title={label.length > 0 ? label : undefined}
description={support.length > 0 ? support : undefined}
variant="modal"
alignment="left"
/>
) : null}
{noFieldsHint.trim().length > 0 ? (
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m,15px)] leading-[var(--line-height-body-m,22px)] text-[var(--color-content-default-secondary)]">
{noFieldsHint}
</p>
) : null}
</div>
);
}
return <CustomMethodCardPresetEditPlaceholder />;
}
@@ -8,6 +8,7 @@ import {
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants";
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
import type { ModalHeaderMenuItem } from "../../../../components/modals/ModalHeader/ModalHeader.types";
import { CustomMethodCardWizardView } from "./CustomMethodCardWizard.view";
import type { CustomMethodCardWizardProps } from "./CustomMethodCardWizard.types";
@@ -21,6 +22,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
const t = useTranslation("common");
const tUpload = useTranslation("create.upload");
const w = m.create.customRule.customMethodCardWizard;
const menuCopy = m.create.customRule.modalKebabMenu;
const copy = useMemo(
() => ({
@@ -228,6 +230,8 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
dismiss();
}, [dismiss, fieldTypeModal]);
const kebabMenuItems = useMemo<ModalHeaderMenuItem[]>(() => [], []);
const handleBack = useCallback(() => {
if (fieldTypeModal) {
setFieldTypeModal(null);
@@ -416,6 +420,9 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
stepper={!fieldTypeModal}
draftFieldBlocks={draftFieldBlocks}
onDraftFieldBlocksReorder={setDraftFieldBlocks}
kebabMoreOptionsAriaLabel={menuCopy.triggerAriaLabel}
kebabMenuAriaLabel={menuCopy.menuAriaLabel}
kebabMenuItems={kebabMenuItems}
/>
);
},
@@ -1,5 +1,6 @@
import type { RefObject } from "react";
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
import type { ModalHeaderMenuItem } from "../../../../components/modals/ModalHeader/ModalHeader.types";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
export interface CustomMethodCardWizardFieldBodiesCopy {
@@ -141,4 +142,7 @@ export interface CustomMethodCardWizardViewProps {
onBack: () => void;
onNext: () => void;
stepper: boolean;
kebabMoreOptionsAriaLabel: string;
kebabMenuAriaLabel: string;
kebabMenuItems: ModalHeaderMenuItem[];
}
@@ -35,6 +35,9 @@ function CustomMethodCardWizardViewComponent({
stepper,
draftFieldBlocks,
onDraftFieldBlocksReorder,
kebabMoreOptionsAriaLabel,
kebabMenuAriaLabel,
kebabMenuItems,
}: CustomMethodCardWizardViewProps) {
return (
<Create
@@ -52,6 +55,9 @@ function CustomMethodCardWizardViewComponent({
totalSteps={3}
stepper={stepper}
backdropVariant="blurredYellow"
kebabTriggerAriaLabel={kebabMoreOptionsAriaLabel}
kebabMenuAriaLabel={kebabMenuAriaLabel}
kebabMenuItems={kebabMenuItems}
>
{fieldTypeModal ? (
<CustomMethodCardWizardFieldBodiesView
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
"use client";
/**
* Editable policy title + description for method-card Create modals in Customize mode.
* View mode continues to use {@link ContentLockup} via the `Create` modal defaults.
*/
import TextInput from "../../../components/controls/TextInput";
import ModalTextAreaField from "./ModalTextAreaField";
export interface MethodCardCustomizeModalHeaderProps {
titleLabel: string;
descriptionLabel: string;
titleValue: string;
descriptionValue: string;
onTitleChange: (_value: string) => void;
onDescriptionChange: (_value: string) => void;
/** @default 3 */
descriptionRows?: number;
/** When false, only the policy title row is rendered (core values rename). */
showDescription?: boolean;
}
export default function MethodCardCustomizeModalHeader({
titleLabel,
descriptionLabel,
titleValue,
descriptionValue,
onTitleChange,
onDescriptionChange,
descriptionRows = 3,
showDescription = true,
}: MethodCardCustomizeModalHeaderProps) {
return (
<div className="bg-[var(--color-surface-default-primary)] flex shrink-0 flex-col gap-4 px-[24px] py-[12px]">
<TextInput
label={titleLabel}
value={titleValue}
onChange={(e) => onTitleChange(e.target.value)}
inputSize="medium"
/>
{showDescription ? (
<ModalTextAreaField
label={descriptionLabel}
value={descriptionValue}
onChange={onDescriptionChange}
rows={descriptionRows}
/>
) : null}
</div>
);
}
@@ -0,0 +1,51 @@
import type { ModalHeaderMenuItem } from "../../../components/modals/ModalHeader/ModalHeader.types";
export interface CustomRuleModalKebabMenuCopy {
items: {
customize: string;
duplicate: string;
remove: string;
};
saveEdits: string;
}
export interface CustomRuleModalKebabHandlers {
showCustomize?: boolean;
onCustomize?: () => void;
onDuplicate?: () => void;
showRemove?: boolean;
onRemove?: () => void;
}
export function buildCustomRuleModalKebabMenu(
copy: CustomRuleModalKebabMenuCopy,
handlers: CustomRuleModalKebabHandlers,
): ModalHeaderMenuItem[] {
const items: ModalHeaderMenuItem[] = [];
if (handlers.showCustomize && handlers.onCustomize) {
items.push({
id: "customize",
label: copy.items.customize,
leadingIcon: "custom",
onClick: handlers.onCustomize,
});
}
if (handlers.onDuplicate) {
items.push({
id: "duplicate",
label: copy.items.duplicate,
leadingIcon: "content_copy",
onClick: handlers.onDuplicate,
});
}
if (handlers.showRemove && handlers.onRemove) {
items.push({
id: "remove",
label: copy.items.remove,
leadingIcon: "warning",
variant: "destructive",
onClick: handlers.onRemove,
});
}
return items;
}
@@ -15,6 +15,8 @@ import type { CommunicationMethodDetailEntry } from "../../types";
export interface CommunicationMethodEditFieldsProps {
value: CommunicationMethodDetailEntry;
onChange: (_next: CommunicationMethodDetailEntry) => void;
/** When true, fields are not editable (view mode). */
readOnly?: boolean;
}
const FIELDS: ReadonlyArray<keyof CommunicationMethodDetailEntry> = [
@@ -26,6 +28,7 @@ const FIELDS: ReadonlyArray<keyof CommunicationMethodDetailEntry> = [
function CommunicationMethodEditFieldsComponent({
value,
onChange,
readOnly = false,
}: CommunicationMethodEditFieldsProps) {
const m = useMessages();
const t = m.create.customRule.communication;
@@ -49,6 +52,7 @@ function CommunicationMethodEditFieldsComponent({
rows={6}
value={value[field]}
onChange={(v) => patch(field, v)}
disabled={readOnly}
/>
))}
</div>
@@ -37,11 +37,13 @@ function conflictDetailWithScopeTextarea(
export interface ConflictManagementEditFieldsProps {
value: ConflictManagementDetailEntry;
onChange: (_next: ConflictManagementDetailEntry) => void;
readOnly?: boolean;
}
function ConflictManagementEditFieldsComponent({
value,
onChange,
readOnly = false,
}: ConflictManagementEditFieldsProps) {
const m = useMessages();
const t = m.create.customRule.conflictManagement;
@@ -62,6 +64,7 @@ function ConflictManagementEditFieldsComponent({
label={t.sectionHeadings.corePrinciple}
value={value.corePrinciple}
onChange={(v) => patch("corePrinciple", v)}
disabled={readOnly}
/>
<ModalTextAreaField
label={t.sectionHeadings.applicableScope}
@@ -69,16 +72,19 @@ function ConflictManagementEditFieldsComponent({
placeholder={t.applicableScopePlaceholder}
onChange={(v) => onChange(conflictDetailWithScopeTextarea(value, v))}
rows={4}
disabled={readOnly}
/>
<ModalTextAreaField
label={t.sectionHeadings.processProtocol}
value={value.processProtocol}
onChange={(v) => patch("processProtocol", v)}
disabled={readOnly}
/>
<ModalTextAreaField
label={t.sectionHeadings.restorationFallbacks}
value={value.restorationFallbacks}
onChange={(v) => patch("restorationFallbacks", v)}
disabled={readOnly}
/>
</div>
);
@@ -15,11 +15,14 @@ import type { CoreValueDetailEntry } from "../../types";
export interface CoreValueEditFieldsProps {
value: CoreValueDetailEntry;
onChange: (_next: CoreValueDetailEntry) => void;
/** View mode until the user taps **Customize**. */
readOnly?: boolean;
}
function CoreValueEditFieldsComponent({
value,
onChange,
readOnly = false,
}: CoreValueEditFieldsProps) {
const m = useMessages();
const t = m.create.customRule.coreValues.detailModal;
@@ -41,12 +44,14 @@ function CoreValueEditFieldsComponent({
value={value.meaning}
onChange={(v) => patch("meaning", v)}
rows={4}
disabled={readOnly}
/>
<ModalTextAreaField
label={t.signalsLabel}
value={value.signals}
onChange={(v) => patch("signals", v)}
rows={4}
disabled={readOnly}
/>
</div>
);
@@ -17,6 +17,7 @@ import type { DecisionApproachDetailEntry } from "../../types";
export interface DecisionApproachEditFieldsProps {
value: DecisionApproachDetailEntry;
onChange: (_next: DecisionApproachDetailEntry) => void;
readOnly?: boolean;
}
const CONSENSUS_LEVEL_MIN = 0;
@@ -26,6 +27,7 @@ const CONSENSUS_LEVEL_STEP = 5;
function DecisionApproachEditFieldsComponent({
value,
onChange,
readOnly = false,
}: DecisionApproachEditFieldsProps) {
const m = useMessages();
const t = m.create.customRule.decisionApproaches;
@@ -46,12 +48,14 @@ function DecisionApproachEditFieldsComponent({
label={t.sectionHeadings.corePrinciple}
value={value.corePrinciple}
onChange={(v) => patch("corePrinciple", v)}
disabled={readOnly}
/>
<ApplicableScopeField
label={t.sectionHeadings.applicableScope}
addLabel={t.scopeAddButtonLabel}
scopes={value.applicableScope}
selectedScopes={value.selectedApplicableScope}
readOnly={readOnly}
onToggleScope={(scope) =>
patch(
"selectedApplicableScope",
@@ -68,6 +72,7 @@ function DecisionApproachEditFieldsComponent({
label={t.sectionHeadings.stepByStepInstructions}
value={value.stepByStepInstructions}
onChange={(v) => patch("stepByStepInstructions", v)}
disabled={readOnly}
/>
<IncrementerBlock
label={t.sectionHeadings.consensusLevel}
@@ -79,11 +84,13 @@ function DecisionApproachEditFieldsComponent({
formatValue={(v) => `${v}%`}
decrementAriaLabel="Decrease consensus level"
incrementAriaLabel="Increase consensus level"
disabled={readOnly}
/>
<ModalTextAreaField
label={t.sectionHeadings.objectionsDeadlocks}
value={value.objectionsDeadlocks}
onChange={(v) => patch("objectionsDeadlocks", v)}
disabled={readOnly}
/>
</div>
);
@@ -15,6 +15,7 @@ import type { MembershipMethodDetailEntry } from "../../types";
export interface MembershipMethodEditFieldsProps {
value: MembershipMethodDetailEntry;
onChange: (_next: MembershipMethodDetailEntry) => void;
readOnly?: boolean;
}
const FIELDS: ReadonlyArray<keyof MembershipMethodDetailEntry> = [
@@ -26,6 +27,7 @@ const FIELDS: ReadonlyArray<keyof MembershipMethodDetailEntry> = [
function MembershipMethodEditFieldsComponent({
value,
onChange,
readOnly = false,
}: MembershipMethodEditFieldsProps) {
const m = useMessages();
const t = m.create.customRule.membership;
@@ -49,6 +51,7 @@ function MembershipMethodEditFieldsComponent({
rows={6}
value={value[field]}
onChange={(v) => patch(field, v)}
disabled={readOnly}
/>
))}
</div>