diff --git a/app/components/buttons/InlineTextButton.tsx b/app/components/buttons/InlineTextButton.tsx new file mode 100644 index 0000000..c9a1364 --- /dev/null +++ b/app/components/buttons/InlineTextButton.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { memo } from "react"; + +export interface InlineTextButtonProps { + /** + * Button label content. + */ + children: React.ReactNode; + /** + * Click handler. + */ + onClick?: (_event: React.MouseEvent) => void; + /** + * Extra class names. Use `className` to override typography/color when the + * button must inherit parent font-size/leading (e.g. mid-paragraph usage). + */ + className?: string; + disabled?: boolean; + ariaLabel?: string; + type?: "button" | "submit" | "reset"; +} + +/** + * Small text-styled button for in-paragraph "link"-like controls (expand, + * add, etc.). The Figma "link" treatment is a tertiary-colored underline with + * a 3px underline-offset and inherited typography, which sits between a real + * anchor and a styled `Button`. + * + * Use this anywhere a ` + ); +} + +InlineTextButtonComponent.displayName = "InlineTextButton"; + +export default memo(InlineTextButtonComponent); diff --git a/app/components/controls/Chip/Chip.types.ts b/app/components/controls/Chip/Chip.types.ts index dab6332..7b91bf9 100644 --- a/app/components/controls/Chip/Chip.types.ts +++ b/app/components/controls/Chip/Chip.types.ts @@ -33,6 +33,13 @@ export interface ChipProps { */ size?: ChipSizeValue; className?: string; + /** + * Whether the chip should be non-interactive. Defaults to `true` when + * `state === "disabled"` to preserve historical behavior. Pass + * `disabled={false}` alongside `state="Disabled"` to render the dimmed + * "disabled" visual while keeping the chip clickable — useful for toggle + * groups where the unselected state is the disabled Figma visual. + */ disabled?: boolean; onClick?: (event: React.MouseEvent) => void; /** diff --git a/app/components/controls/Chip/Chip.view.tsx b/app/components/controls/Chip/Chip.view.tsx index a393411..6b11c84 100644 --- a/app/components/controls/Chip/Chip.view.tsx +++ b/app/components/controls/Chip/Chip.view.tsx @@ -20,7 +20,10 @@ function ChipView({ inputRef, ariaLabel, }: ChipViewProps) { - const isDisabled = disabled || state === "disabled"; + // The container is the source of truth for `disabled`. This allows + // `state="disabled"` to be used purely as a visual (for toggle-group chips + // that look dimmed while remaining clickable) by passing `disabled={false}`. + const isDisabled = disabled ?? false; const isSelected = state === "selected"; const isCustom = state === "custom"; @@ -57,11 +60,13 @@ function ChipView({ } else if (state === "disabled") { background = "bg-[var(--color-surface-default-secondary,#141414)]"; // dark background border = "border-none"; - textColor = "text-[color:var(--color-content-default-tertiary,#b4b4b4)]"; + // Per Figma (node 19839:13842) disabled uses invert-tertiary for the + // strongly dimmed look, not default-tertiary. + textColor = "text-[color:var(--color-content-invert-tertiary,#2d2d2d)]"; } else if (isSelected) { - background = "bg-[var(--color-surface-inverse-brandaccent,#fdfaa8)]"; // yellow selected + background = "bg-[var(--color-surface-invert-brand-primary,#fefcc9)]"; // yellow selected border = `${borderWidth} border-[var(--color-border-default-brand-primary,#fdfaa8)]`; - textColor = "text-[color:var(--color-content-inverse-primary,black)]"; + textColor = "text-[color:var(--color-content-invert-primary,black)]"; } else { // Unselected default background = diff --git a/app/components/controls/Incrementer/Incrementer.tsx b/app/components/controls/Incrementer/Incrementer.tsx new file mode 100644 index 0000000..8549036 --- /dev/null +++ b/app/components/controls/Incrementer/Incrementer.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { memo } from "react"; + +export interface IncrementerProps { + value: number; + /** Minimum value (default `-Infinity`). */ + min?: number; + /** Maximum value (default `Infinity`). */ + max?: number; + /** Step size applied to +/- actions (default `1`). */ + step?: number; + onChange: (_next: number) => void; + /** + * Optional formatter for the displayed value. Receives the raw number and + * should return the rendered content. Default: `String(value)`. + */ + formatValue?: (_value: number) => React.ReactNode; + /** + * When true, the whole incrementer is non-interactive and the value renders + * in the "inactive" (tertiary) color per Figma. + */ + disabled?: boolean; + /** Accessible label for the decrement button (default "Decrease"). */ + decrementAriaLabel?: string; + /** Accessible label for the increment button (default "Increase"). */ + incrementAriaLabel?: string; + className?: string; +} + +const STEP_BUTTON_CLASSES = + "bg-[var(--color-surface-default-secondary,#141414)] text-[var(--color-content-default-primary,#fff)] inline-flex shrink-0 items-center justify-center overflow-clip rounded-[var(--measures-radius-full,9999px)] px-[var(--space-200,8px)] py-[var(--measures-spacing-150,6px)] transition-[background,color,transform] duration-200 ease-in-out hover:scale-[1.02] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary,#fff)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary,#000)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100"; + +function MinusIcon() { + return ( + + + + ); +} + +function PlusIcon() { + return ( + + + + ); +} + +/** + * Figma: "Control / Incrementer" (`17857:30943`). A compact `[ - value + ]` + * row used for numeric step inputs (e.g. a percentage setting). + * + * For a labelled variant that matches "Control / Incrementer Block" + * (`19883:13283`), compose with {@link IncrementerBlock} instead. + */ +function IncrementerComponent({ + value, + min = Number.NEGATIVE_INFINITY, + max = Number.POSITIVE_INFINITY, + step = 1, + onChange, + formatValue, + disabled = false, + decrementAriaLabel = "Decrease", + incrementAriaLabel = "Increase", + className = "", +}: IncrementerProps) { + const clampedValue = Math.min(Math.max(value, min), max); + const atMin = clampedValue <= min; + const atMax = clampedValue >= max; + + const decrement = () => { + if (disabled || atMin) return; + onChange(Math.max(min, clampedValue - step)); + }; + const increment = () => { + if (disabled || atMax) return; + onChange(Math.min(max, clampedValue + step)); + }; + + const valueColor = disabled + ? "text-[color:var(--color-content-default-tertiary,#b4b4b4)]" + : "text-[color:var(--color-content-default-primary,#fff)]"; + + return ( +
+ + + {formatValue ? formatValue(clampedValue) : clampedValue} + + +
+ ); +} + +IncrementerComponent.displayName = "Incrementer"; + +export default memo(IncrementerComponent); diff --git a/app/components/controls/Incrementer/IncrementerBlock.tsx b/app/components/controls/Incrementer/IncrementerBlock.tsx new file mode 100644 index 0000000..5cd261b --- /dev/null +++ b/app/components/controls/Incrementer/IncrementerBlock.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { memo } from "react"; +import Incrementer, { type IncrementerProps } from "./Incrementer"; +import InputLabel from "../../utility/InputLabel"; +import type { + InputLabelPaletteValue, + InputLabelSizeValue, +} from "../../utility/InputLabel/InputLabel.types"; + +export interface IncrementerBlockProps extends IncrementerProps { + /** Label text displayed above the incrementer. */ + label: string; + /** Show the help "?" icon next to the label. Defaults to `true`. */ + helpIcon?: boolean; + /** + * Helper text shown to the right of the label. Pass a string or `true` to + * render the default "Optional text". + */ + helperText?: boolean | string; + /** Show an asterisk indicating a required field. */ + asterisk?: boolean; + /** + * Size of the label (`"s"` or `"m"`). Defaults to `"s"` to match the Figma + * "Incrementer Block" spec. + */ + labelSize?: InputLabelSizeValue; + /** Palette. Defaults to `"default"`. */ + palette?: InputLabelPaletteValue; + /** + * Class applied to the root `
` wrapping the label + incrementer. Use + * this to control the block's layout width (e.g. `w-full`). + */ + blockClassName?: string; +} + +/** + * Figma: "Control / Incrementer Block" (`19883:13283`). An `InputLabel` plus + * an {@link Incrementer} row, stacked with a 12px gap. + */ +function IncrementerBlockComponent({ + label, + helpIcon = true, + helperText, + asterisk, + labelSize = "s", + palette = "default", + blockClassName = "", + className, + ...incrementerProps +}: IncrementerBlockProps) { + return ( +
+ + +
+ ); +} + +IncrementerBlockComponent.displayName = "IncrementerBlock"; + +export default memo(IncrementerBlockComponent); diff --git a/app/components/controls/Incrementer/index.tsx b/app/components/controls/Incrementer/index.tsx new file mode 100644 index 0000000..c454d77 --- /dev/null +++ b/app/components/controls/Incrementer/index.tsx @@ -0,0 +1,5 @@ +export { default } from "./Incrementer"; +export { default as Incrementer } from "./Incrementer"; +export { default as IncrementerBlock } from "./IncrementerBlock"; +export type { IncrementerProps } from "./Incrementer"; +export type { IncrementerBlockProps } from "./IncrementerBlock"; diff --git a/app/create/components/ApplicableScopeField.tsx b/app/create/components/ApplicableScopeField.tsx new file mode 100644 index 0000000..730bd93 --- /dev/null +++ b/app/create/components/ApplicableScopeField.tsx @@ -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 ( +
+ +
+ {scopes.map((scope) => { + const isSelected = selectedScopes.includes(scope); + return ( + onToggleScope(scope)} + ariaLabel={`${isSelected ? "Deselect" : "Select"} ${scope}`} + /> + ); + })} + {isAdding ? ( + 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)]" + /> + ) : ( + + )} +
+
+ ); +} + +function AddGlyph() { + return ( + + + + ); +} + +ApplicableScopeFieldComponent.displayName = "ApplicableScopeField"; + +export default memo(ApplicableScopeFieldComponent); diff --git a/app/create/components/ModalTextAreaField.tsx b/app/create/components/ModalTextAreaField.tsx new file mode 100644 index 0000000..0b13c24 --- /dev/null +++ b/app/create/components/ModalTextAreaField.tsx @@ -0,0 +1,60 @@ +"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 } 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 `