App reorganization
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Shared "Applicable Scope" field used by the `decision-approaches` and
|
||||
* `conflict-management` create flow modals. Pairs an `InputLabel` with a
|
||||
* horizontally-wrapping list of toggle-chips plus an inline "+ Add" affordance
|
||||
* that reveals a pill text input for creating new scope values.
|
||||
*/
|
||||
|
||||
import { memo, useState } from "react";
|
||||
import Chip from "../../../components/controls/Chip";
|
||||
import InputLabel from "../../../components/utility/InputLabel";
|
||||
|
||||
export interface ApplicableScopeFieldProps {
|
||||
/** Label rendered above the capsule row. */
|
||||
label: string;
|
||||
/** Text for the "+ Add …" affordance (e.g. "Add Applicable Scope"). */
|
||||
addLabel: string;
|
||||
/**
|
||||
* The full list of chip values shown to the user. Each value is a unique
|
||||
* string (chip label).
|
||||
*/
|
||||
scopes: string[];
|
||||
/** Values currently toggled on (rendered in the Chip "Selected" state). */
|
||||
selectedScopes: string[];
|
||||
/** Fired when a chip is clicked; caller toggles inclusion in `selectedScopes`. */
|
||||
onToggleScope: (_scope: string) => void;
|
||||
/**
|
||||
* Fired when the user submits a new scope via the inline input. Duplicate
|
||||
* values (already in `scopes`) are filtered out before the callback fires.
|
||||
*/
|
||||
onAddScope: (_scope: string) => void;
|
||||
/**
|
||||
* Optional placeholder for the inline input. Defaults to `addLabel`.
|
||||
*/
|
||||
inputPlaceholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function ApplicableScopeFieldComponent({
|
||||
label,
|
||||
addLabel,
|
||||
scopes,
|
||||
selectedScopes,
|
||||
onToggleScope,
|
||||
onAddScope,
|
||||
inputPlaceholder,
|
||||
className = "",
|
||||
}: ApplicableScopeFieldProps) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
const submitDraft = () => {
|
||||
const trimmed = draft.trim();
|
||||
if (!trimmed) {
|
||||
setIsAdding(false);
|
||||
setDraft("");
|
||||
return;
|
||||
}
|
||||
if (!scopes.includes(trimmed)) {
|
||||
onAddScope(trimmed);
|
||||
}
|
||||
setDraft("");
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-2 ${className}`.trim()}>
|
||||
<InputLabel label={label} helpIcon size="s" palette="default" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{scopes.map((scope) => {
|
||||
const isSelected = selectedScopes.includes(scope);
|
||||
return (
|
||||
<Chip
|
||||
key={scope}
|
||||
label={scope}
|
||||
state={isSelected ? "selected" : "disabled"}
|
||||
palette="default"
|
||||
size="s"
|
||||
disabled={false}
|
||||
onClick={() => onToggleScope(scope)}
|
||||
ariaLabel={`${isSelected ? "Deselect" : "Select"} ${scope}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isAdding ? (
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={submitDraft}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submitDraft();
|
||||
} else if (e.key === "Escape") {
|
||||
setDraft("");
|
||||
setIsAdding(false);
|
||||
}
|
||||
}}
|
||||
placeholder={inputPlaceholder ?? addLabel}
|
||||
aria-label={inputPlaceholder ?? addLabel}
|
||||
className="h-[30px] rounded-[9999px] border border-[var(--color-border-default-tertiary)] bg-transparent px-3 font-inter text-[length:var(--sizing-300,12px)] font-medium leading-[14px] text-[color:var(--color-content-default-primary)] outline-none placeholder:text-[color:var(--color-content-default-tertiary)] focus-visible:border-[var(--color-border-default-brand-primary)]"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAdding(true)}
|
||||
className="inline-flex items-center gap-[var(--measures-spacing-050,2px)] rounded-[var(--measures-radius-full,9999px)] px-[var(--space-250,10px)] py-[var(--measures-spacing-200,8px)] font-inter text-[length:var(--sizing-300,12px)] font-medium leading-[14px] text-[color:var(--color-content-default-primary)] hover:bg-[var(--color-surface-default-secondary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-transparent"
|
||||
>
|
||||
<AddGlyph />
|
||||
{addLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddGlyph() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
viewBox="0 0 24 24"
|
||||
className="block size-[14px]"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 5v14M5 12h14"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
ApplicableScopeFieldComponent.displayName = "ApplicableScopeField";
|
||||
|
||||
export default memo(ApplicableScopeFieldComponent);
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import HeaderLockup from "../../../components/type/HeaderLockup";
|
||||
import type { HeaderLockupProps } from "../../../components/type/HeaderLockup/HeaderLockup.types";
|
||||
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
|
||||
|
||||
export type CreateFlowHeaderLockupProps = Omit<HeaderLockupProps, "size"> & {
|
||||
/** Omit for responsive `M` below `md`, `L` at/above `md` (matches `--breakpoint-md`). */
|
||||
size?: HeaderLockupProps["size"];
|
||||
};
|
||||
|
||||
/**
|
||||
* Create-flow HeaderLockup: **`L` at/above `md`**, `M` below unless `size` is passed explicitly.
|
||||
*/
|
||||
export function CreateFlowHeaderLockup({
|
||||
size: sizeProp,
|
||||
...rest
|
||||
}: CreateFlowHeaderLockupProps) {
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const size = sizeProp ?? (mdUp ? "L" : "M");
|
||||
return <HeaderLockup {...rest} size={size} />;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { CreateFlowHeaderLockup } from "./CreateFlowHeaderLockup";
|
||||
import { CreateFlowStepShell } from "./CreateFlowStepShell";
|
||||
import {
|
||||
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
|
||||
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
|
||||
} from "./createFlowLayoutTokens";
|
||||
|
||||
/** Shared `RuleCard` / template card chrome: width + radius; padding comes from `RuleCard` (L+expanded = 24px). */
|
||||
export const CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS =
|
||||
"w-full min-w-0 rounded-[12px] md:rounded-[24px] md:max-w-[640px]";
|
||||
|
||||
type CreateFlowLockupCardStepShellProps = {
|
||||
lockupTitle: string;
|
||||
lockupDescription?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/** Final-review layout: `wideGrid`, two columns from `md:`, column widths from `createFlowLayoutTokens`. */
|
||||
export function CreateFlowLockupCardStepShell({
|
||||
lockupTitle,
|
||||
lockupDescription,
|
||||
children,
|
||||
}: CreateFlowLockupCardStepShellProps) {
|
||||
return (
|
||||
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
|
||||
<div
|
||||
className={`mx-auto flex w-full min-w-0 flex-col gap-4 md:grid md:w-full md:grid-cols-2 md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
|
||||
>
|
||||
<div
|
||||
className={`flex min-w-0 flex-col justify-start md:justify-center ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
||||
>
|
||||
<CreateFlowHeaderLockup
|
||||
title={lockupTitle}
|
||||
description={lockupDescription}
|
||||
justification="left"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`flex min-w-0 flex-col items-stretch ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type CreateFlowStepShellVariant =
|
||||
| "centeredNarrow"
|
||||
| "centeredNarrowBottomPad"
|
||||
| "wideGrid"
|
||||
| "wideGridLoosePadding"
|
||||
| "bare";
|
||||
|
||||
/** Semantic top padding below create-flow top nav (applied at all breakpoints; name is legacy). */
|
||||
export type CreateFlowContentTopBelowMd = "none" | "space-1400" | "space-800";
|
||||
|
||||
const outerByVariant: Record<CreateFlowStepShellVariant, string> = {
|
||||
centeredNarrow:
|
||||
"flex w-full min-w-0 flex-col items-center px-5 md:px-16",
|
||||
centeredNarrowBottomPad:
|
||||
"flex w-full min-w-0 flex-col items-center px-5 pb-28 md:px-[var(--measures-spacing-1800,64px)] md:pb-32",
|
||||
/** Wide two-column steps; 1328px = two 640px columns + 48px gutter. */
|
||||
wideGrid: "w-full min-w-0 max-w-[1328px] shrink-0 px-5 md:px-12",
|
||||
/** Create Community review + card grid (Figma Flow — Review `19706:12135`): max width 1440. */
|
||||
wideGridLoosePadding:
|
||||
"w-full min-w-0 max-w-[1440px] shrink-0 px-5 md:px-16",
|
||||
bare: "w-full min-w-0",
|
||||
};
|
||||
|
||||
const contentTopBelowMdClass: Record<CreateFlowContentTopBelowMd, string> = {
|
||||
none: "",
|
||||
"space-1400": "pt-[var(--space-1400)]",
|
||||
"space-800": "pt-[var(--space-800)]",
|
||||
};
|
||||
|
||||
interface CreateFlowStepShellProps {
|
||||
children: ReactNode;
|
||||
variant?: CreateFlowStepShellVariant;
|
||||
/** Top spacing below top chrome (`CreateFlowTextFieldScreen` defaults to `space-1400`). */
|
||||
contentTopBelowMd?: CreateFlowContentTopBelowMd;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared horizontal padding and width constraints for create-flow step pages.
|
||||
* Horizontal padding uses Tailwind `md:` so it tracks `--breakpoint-md` (640px in `app/tailwind.css`).
|
||||
*/
|
||||
export function CreateFlowStepShell({
|
||||
children,
|
||||
variant = "centeredNarrow",
|
||||
contentTopBelowMd = "none",
|
||||
className = "",
|
||||
}: CreateFlowStepShellProps) {
|
||||
const topClass = contentTopBelowMdClass[contentTopBelowMd];
|
||||
return (
|
||||
<div
|
||||
className={`${outerByVariant[variant]} ${topClass} ${className}`.trim()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
CreateFlowStepShell,
|
||||
type CreateFlowContentTopBelowMd,
|
||||
} from "./CreateFlowStepShell";
|
||||
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "./createFlowLayoutTokens";
|
||||
|
||||
export type CreateFlowSelectShellLgVerticalAlign = "center" | "start";
|
||||
|
||||
interface CreateFlowTwoColumnSelectShellProps {
|
||||
header: ReactNode;
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Top padding below create-flow chrome. Select steps use `space-1400`; right-rail uses `space-800`
|
||||
* (Figma Flow — Right Rail).
|
||||
*/
|
||||
contentTopBelowMd?: CreateFlowContentTopBelowMd;
|
||||
/**
|
||||
* At `lg+`, layout variant: `"center"` = vertically centered pair (community size/structure).
|
||||
* `"start"` = top-weighted layout with a scrollable right column (core values, right-rail): uses `items-stretch`
|
||||
* so the right column gets a bounded height; `items-start` would grow with content and break scroll.
|
||||
*/
|
||||
lgVerticalAlign?: CreateFlowSelectShellLgVerticalAlign;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-column layout for create-flow select steps (community size/structure, core values) and
|
||||
* {@link DecisionApproachesScreen} (decision approaches). Below `lg` (1024px), one column + main scrolls.
|
||||
* At `lg+`, mirrors {@link CompletedScreen}: static header column + scrollable controls column
|
||||
* (`min-h-0` + `overflow-y-auto` height chain; see completed page right rail).
|
||||
*/
|
||||
export function CreateFlowTwoColumnSelectShell({
|
||||
header,
|
||||
children,
|
||||
contentTopBelowMd = "space-1400",
|
||||
lgVerticalAlign = "center",
|
||||
}: CreateFlowTwoColumnSelectShellProps) {
|
||||
/** `stretch` is required for `min-h-0` + `overflow-y-auto` on the right column. */
|
||||
const rowLgCrossAlignClass =
|
||||
lgVerticalAlign === "start" ? "lg:items-stretch" : "lg:items-center";
|
||||
|
||||
const leftLgMainJustifyClass =
|
||||
lgVerticalAlign === "start" ? "lg:justify-start" : "lg:justify-center";
|
||||
|
||||
return (
|
||||
<CreateFlowStepShell
|
||||
variant="centeredNarrow"
|
||||
contentTopBelowMd={contentTopBelowMd}
|
||||
className={
|
||||
/* Below `lg`: natural height — same as legacy select screens (main scrolls). */
|
||||
/* At `lg+`: fill main + clip so only the right column scrolls (CompletedScreen pattern). */
|
||||
"w-full min-w-0 max-lg:flex-none lg:min-h-0 lg:h-full lg:max-h-full lg:flex-1 lg:overflow-hidden lg:items-stretch lg:self-stretch"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-400,16px)] md:max-w-[640px] " +
|
||||
"max-lg:flex-none lg:max-h-full lg:max-w-[1328px] lg:min-h-0 lg:flex-1 lg:flex-row lg:flex-nowrap " +
|
||||
`${rowLgCrossAlignClass} lg:justify-center lg:gap-[var(--measures-spacing-1200,48px)] lg:overflow-hidden`
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
`flex w-full min-w-0 shrink-0 flex-col items-start gap-[var(--measures-spacing-200,8px)] ` +
|
||||
`lg:flex-1 ${leftLgMainJustifyClass} lg:py-[12px] lg:max-w-[640px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`
|
||||
}
|
||||
>
|
||||
{header}
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
`scrollbar-hide relative flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-800,32px)] ` +
|
||||
`overflow-x-hidden lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pb-[var(--measures-spacing-300,12px)] ` +
|
||||
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Shared "labelled text area" field used by every create flow modal section.
|
||||
* Pairs an `InputLabel` (with help icon) with a `TextArea` set to the embedded
|
||||
* appearance — matching the Figma "Control / Text Area" pattern.
|
||||
*/
|
||||
|
||||
import { memo, useId } from "react";
|
||||
import TextArea from "../../../components/controls/TextArea";
|
||||
import InputLabel from "../../../components/utility/InputLabel";
|
||||
|
||||
export interface ModalTextAreaFieldProps {
|
||||
/** Label rendered above the text area. */
|
||||
label: string;
|
||||
/** Show the help "?" icon next to the label (default `true`). */
|
||||
helpIcon?: boolean;
|
||||
/** Current text value. */
|
||||
value: string;
|
||||
/** Fired on every change with the new value (no event). */
|
||||
onChange: (_value: string) => void;
|
||||
/** Optional rows for the underlying `<textarea>` (default 4). */
|
||||
rows?: number;
|
||||
/** Optional placeholder. */
|
||||
placeholder?: string;
|
||||
/** Disable the field. */
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function ModalTextAreaFieldComponent({
|
||||
label,
|
||||
helpIcon = true,
|
||||
value,
|
||||
onChange,
|
||||
rows = 4,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
className = "",
|
||||
}: ModalTextAreaFieldProps) {
|
||||
const labelId = useId();
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-2 ${className}`.trim()}>
|
||||
<div id={labelId}>
|
||||
<InputLabel
|
||||
label={label}
|
||||
helpIcon={helpIcon}
|
||||
size="s"
|
||||
palette="default"
|
||||
/>
|
||||
</div>
|
||||
<TextArea
|
||||
formHeader={false}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
size="large"
|
||||
rows={rows}
|
||||
appearance="embedded"
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ModalTextAreaFieldComponent.displayName = "ModalTextAreaField";
|
||||
|
||||
export default memo(ModalTextAreaFieldComponent);
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Single column/section: full width under `md`, max 640px from `--breakpoint-md` up. */
|
||||
export const CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS =
|
||||
"w-full min-w-0 md:max-w-[640px]";
|
||||
|
||||
/** Grid cell: same cap as column max, centered when the track is wider than 640px. */
|
||||
export const CREATE_FLOW_MD_UP_GRID_CELL_CLASS =
|
||||
"w-full min-w-0 md:mx-auto md:max-w-[640px]";
|
||||
|
||||
/** Two 640px columns + `--measures-spacing-1200` (48px) gutter. */
|
||||
export const CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS = "md:max-w-[1328px]";
|
||||
|
||||
/**
|
||||
* Card-stack steps only (Figma compact card stack): wider than header lockup so the card grid /
|
||||
* pyramid fits (max 860px). Header lockup stays {@link CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}.
|
||||
* Card–card gap uses `gap-2` in `CardStack` (same on mobile and md+).
|
||||
*/
|
||||
export const CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS =
|
||||
"w-full min-w-0 md:max-w-[min(100%,860px)]";
|
||||
Reference in New Issue
Block a user