` wrapping the label + incrementer. Use
+ * this to control the block's layout width (e.g. `w-full`).
+ */
+ blockClassName?: string;
+}
+
+export interface IncrementerBlockViewProps extends IncrementerProps {
+ label: string;
+ helpIcon: boolean;
+ helperText: boolean | string | undefined;
+ asterisk: boolean | undefined;
+ labelSize: InputLabelSizeValue;
+ palette: InputLabelPaletteValue;
+ blockClassName: string;
+}
diff --git a/app/components/controls/IncrementerBlock/IncrementerBlock.view.tsx b/app/components/controls/IncrementerBlock/IncrementerBlock.view.tsx
new file mode 100644
index 0000000..bc478fc
--- /dev/null
+++ b/app/components/controls/IncrementerBlock/IncrementerBlock.view.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import { memo } from "react";
+import Incrementer from "../Incrementer";
+import InputLabel from "../../utility/InputLabel";
+import type { IncrementerBlockViewProps } from "./IncrementerBlock.types";
+
+function IncrementerBlockView({
+ label,
+ helpIcon,
+ helperText,
+ asterisk,
+ labelSize,
+ palette,
+ blockClassName,
+ className,
+ ...incrementerProps
+}: IncrementerBlockViewProps) {
+ return (
+
+
+
+
+ );
+}
+
+IncrementerBlockView.displayName = "IncrementerBlockView";
+
+export default memo(IncrementerBlockView);
diff --git a/app/components/controls/IncrementerBlock/index.tsx b/app/components/controls/IncrementerBlock/index.tsx
new file mode 100644
index 0000000..4616cf1
--- /dev/null
+++ b/app/components/controls/IncrementerBlock/index.tsx
@@ -0,0 +1,2 @@
+export { default } from "./IncrementerBlock.container";
+export type { IncrementerBlockProps } from "./IncrementerBlock.types";
diff --git a/app/create/components/CreateFlowTwoColumnSelectShell.tsx b/app/create/components/CreateFlowTwoColumnSelectShell.tsx
index b792fde..c3f8363 100644
--- a/app/create/components/CreateFlowTwoColumnSelectShell.tsx
+++ b/app/create/components/CreateFlowTwoColumnSelectShell.tsx
@@ -27,7 +27,7 @@ interface CreateFlowTwoColumnSelectShellProps {
/**
* Two-column layout for create-flow select steps (community size/structure, core values) and
- * {@link RightRailScreen} (decision approaches). Below `lg` (1024px), one column + main scrolls.
+ * {@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).
*/
diff --git a/app/create/components/ModalTextAreaField.tsx b/app/create/components/ModalTextAreaField.tsx
index 0b13c24..1cd3676 100644
--- a/app/create/components/ModalTextAreaField.tsx
+++ b/app/create/components/ModalTextAreaField.tsx
@@ -6,7 +6,7 @@
* appearance — matching the Figma "Control / Text Area" pattern.
*/
-import { memo } from "react";
+import { memo, useId } from "react";
import TextArea from "../../components/controls/TextArea";
import InputLabel from "../../components/utility/InputLabel";
@@ -38,9 +38,18 @@ function ModalTextAreaFieldComponent({
disabled = false,
className = "",
}: ModalTextAreaFieldProps) {
+ const labelId = useId();
+
return (
);
diff --git a/app/create/screens/right-rail/DecisionApproachesScreen.tsx b/app/create/screens/right-rail/DecisionApproachesScreen.tsx
index 33612b0..052a2d3 100644
--- a/app/create/screens/right-rail/DecisionApproachesScreen.tsx
+++ b/app/create/screens/right-rail/DecisionApproachesScreen.tsx
@@ -17,7 +17,7 @@ import { useState, useCallback, useMemo } from "react";
import DecisionMakingSidebar from "../../../components/utility/DecisionMakingSidebar";
import CardStack from "../../../components/utility/CardStack";
import Create from "../../../components/modals/Create";
-import { IncrementerBlock } from "../../../components/controls/Incrementer";
+import IncrementerBlock from "../../../components/controls/IncrementerBlock";
import InlineTextButton from "../../../components/buttons/InlineTextButton";
import type { InfoMessageBoxItem } from "../../../components/utility/InfoMessageBox/InfoMessageBox.types";
import type { CardStackItem } from "../../../components/utility/CardStack/CardStack.types";
diff --git a/stories/buttons/InlineTextButton.stories.js b/stories/buttons/InlineTextButton.stories.js
new file mode 100644
index 0000000..ce7294c
--- /dev/null
+++ b/stories/buttons/InlineTextButton.stories.js
@@ -0,0 +1,57 @@
+import React from "react";
+import InlineTextButton from "../../app/components/buttons/InlineTextButton";
+
+export default {
+ title: "Components/Buttons/InlineTextButton",
+ component: InlineTextButton,
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component:
+ "Small text-styled button for mid-paragraph 'link'-like controls (expand, add, …). Inherits parent typography and renders with a tertiary-colored underline. Use `Button` for primary/secondary actions.",
+ },
+ },
+ },
+ argTypes: {
+ children: {
+ control: { type: "text" },
+ description: "Button label content.",
+ },
+ disabled: { control: { type: "boolean" } },
+ ariaLabel: { control: { type: "text" } },
+ onClick: { action: "clicked" },
+ },
+ tags: ["autodocs"],
+};
+
+export const Default = {
+ args: {
+ children: "Expand",
+ },
+};
+
+export const InParagraph = {
+ render: () => (
+
+ Share a bit more detail so the group can weigh in. You can always{" "}
+ {}}>expand this later{" "}
+ if you need more room.
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Typography is inherited from the parent, so the button sits naturally inside body copy.",
+ },
+ },
+ },
+};
+
+export const Disabled = {
+ args: {
+ children: "Expand",
+ disabled: true,
+ },
+};
diff --git a/stories/controls/Incrementer.stories.js b/stories/controls/Incrementer.stories.js
new file mode 100644
index 0000000..3e438e6
--- /dev/null
+++ b/stories/controls/Incrementer.stories.js
@@ -0,0 +1,101 @@
+import React from "react";
+import Incrementer from "../../app/components/controls/Incrementer";
+
+export default {
+ title: "Components/Controls/Incrementer",
+ component: Incrementer,
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component:
+ "Compact `[ - value + ]` row for numeric step input. Figma: `Control / Incrementer` (17857:30943). Pair with `IncrementerBlock` when you need a label above.",
+ },
+ },
+ },
+ argTypes: {
+ value: {
+ control: { type: "number" },
+ description: "Current numeric value.",
+ },
+ min: {
+ control: { type: "number" },
+ description: "Minimum value (default -Infinity).",
+ },
+ max: {
+ control: { type: "number" },
+ description: "Maximum value (default Infinity).",
+ },
+ step: {
+ control: { type: "number" },
+ description: "Amount added/subtracted per click.",
+ },
+ disabled: {
+ control: { type: "boolean" },
+ description: "Disable both step buttons.",
+ },
+ onChange: { action: "change" },
+ },
+ tags: ["autodocs"],
+};
+
+export const Default = {
+ render: (args) => {
+ const [value, setValue] = React.useState(args.value ?? 50);
+ return
;
+ },
+ args: {
+ value: 50,
+ },
+};
+
+export const WithBounds = {
+ render: (args) => {
+ const [value, setValue] = React.useState(50);
+ return
;
+ },
+ args: {
+ min: 0,
+ max: 100,
+ step: 10,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Clamped to `min`/`max`; the corresponding step button auto-disables at the bounds.",
+ },
+ },
+ },
+};
+
+export const PercentageFormatter = {
+ render: (args) => {
+ const [value, setValue] = React.useState(75);
+ return (
+
`${n}%`}
+ />
+ );
+ },
+ args: {
+ min: 0,
+ max: 100,
+ step: 5,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Use `formatValue` to render units alongside the number (e.g. `%`, `px`).",
+ },
+ },
+ },
+};
+
+export const Disabled = {
+ render: () => {}} disabled />,
+};
diff --git a/stories/controls/IncrementerBlock.stories.js b/stories/controls/IncrementerBlock.stories.js
new file mode 100644
index 0000000..a39bdbc
--- /dev/null
+++ b/stories/controls/IncrementerBlock.stories.js
@@ -0,0 +1,113 @@
+import React from "react";
+import IncrementerBlock from "../../app/components/controls/IncrementerBlock";
+
+export default {
+ title: "Components/Controls/IncrementerBlock",
+ component: IncrementerBlock,
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component:
+ "Labelled incrementer: pairs `InputLabel` with `Incrementer`. Figma: `Control / Incrementer Block` (19883:13283). Matches the grouped-field pattern used by `CheckboxGroup` / `RadioGroup`.",
+ },
+ },
+ },
+ argTypes: {
+ label: {
+ control: { type: "text" },
+ description: "Label rendered above the incrementer.",
+ },
+ helpIcon: {
+ control: { type: "boolean" },
+ description: "Show the help (?) icon next to the label.",
+ },
+ asterisk: {
+ control: { type: "boolean" },
+ description: "Show an asterisk indicating a required field.",
+ },
+ helperText: {
+ control: { type: "text" },
+ description:
+ "Helper text shown to the right of the label. Pass `true` to render the default 'Optional text'.",
+ },
+ labelSize: {
+ control: { type: "select" },
+ options: ["s", "m"],
+ description: "Size of the label (Figma prop).",
+ },
+ palette: {
+ control: { type: "select" },
+ options: ["default", "inverse"],
+ description: "Label palette.",
+ },
+ min: { control: { type: "number" } },
+ max: { control: { type: "number" } },
+ step: { control: { type: "number" } },
+ disabled: { control: { type: "boolean" } },
+ onChange: { action: "change" },
+ },
+ tags: ["autodocs"],
+};
+
+export const Default = {
+ render: (args) => {
+ const [value, setValue] = React.useState(args.value ?? 75);
+ return ;
+ },
+ args: {
+ label: "Consensus level",
+ helpIcon: true,
+ value: 75,
+ min: 0,
+ max: 100,
+ step: 5,
+ },
+};
+
+export const Required = {
+ render: () => {
+ const [value, setValue] = React.useState(50);
+ return (
+
+ );
+ },
+};
+
+export const WithFormattedValue = {
+ render: () => {
+ const [value, setValue] = React.useState(75);
+ return (
+ `${n}%`}
+ />
+ );
+ },
+};
+
+export const Disabled = {
+ render: () => (
+ {}}
+ disabled
+ />
+ ),
+};
diff --git a/stories/create-flow/ApplicableScopeField.stories.js b/stories/create-flow/ApplicableScopeField.stories.js
new file mode 100644
index 0000000..13c7377
--- /dev/null
+++ b/stories/create-flow/ApplicableScopeField.stories.js
@@ -0,0 +1,89 @@
+import React from "react";
+import ApplicableScopeField from "../../app/create/components/ApplicableScopeField";
+
+export default {
+ title: "Create Flow/ApplicableScopeField",
+ component: ApplicableScopeField,
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component:
+ "Shared 'Applicable Scope' field used by the `decision-approaches` and `conflict-management` create-flow modals. Pairs an `InputLabel` with a row of toggle-chips plus an inline pill input for adding new scope values.",
+ },
+ },
+ },
+ argTypes: {
+ label: { control: { type: "text" } },
+ addLabel: { control: { type: "text" } },
+ inputPlaceholder: { control: { type: "text" } },
+ onToggleScope: { action: "toggle" },
+ onAddScope: { action: "add" },
+ },
+ tags: ["autodocs"],
+};
+
+const INITIAL_SCOPES = ["Finance", "Operations", "Product", "People"];
+
+export const Default = {
+ render: (args) => {
+ const [scopes, setScopes] = React.useState(INITIAL_SCOPES);
+ const [selected, setSelected] = React.useState(["Finance"]);
+
+ return (
+
+
{
+ setSelected((prev) =>
+ prev.includes(scope)
+ ? prev.filter((s) => s !== scope)
+ : [...prev, scope],
+ );
+ }}
+ onAddScope={(scope) => setScopes((prev) => [...prev, scope])}
+ />
+
+ );
+ },
+ args: {
+ label: "Applicable Scope",
+ addLabel: "Add Applicable Scope",
+ },
+};
+
+export const Empty = {
+ render: () => {
+ const [scopes, setScopes] = React.useState([]);
+ const [selected, setSelected] = React.useState([]);
+
+ return (
+
+
+ setSelected((prev) =>
+ prev.includes(scope)
+ ? prev.filter((s) => s !== scope)
+ : [...prev, scope],
+ )
+ }
+ onAddScope={(scope) => setScopes((prev) => [...prev, scope])}
+ />
+
+ );
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "With no scopes yet — only the '+ Add' affordance is visible. Click it to reveal the pill text input.",
+ },
+ },
+ },
+};
diff --git a/stories/create-flow/ModalTextAreaField.stories.js b/stories/create-flow/ModalTextAreaField.stories.js
new file mode 100644
index 0000000..d557fbe
--- /dev/null
+++ b/stories/create-flow/ModalTextAreaField.stories.js
@@ -0,0 +1,72 @@
+import React from "react";
+import ModalTextAreaField from "../../app/create/components/ModalTextAreaField";
+
+export default {
+ title: "Create Flow/ModalTextAreaField",
+ component: ModalTextAreaField,
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component:
+ "Shared 'labelled text area' field used by every create-flow modal section. Pairs `InputLabel` (with help icon) with a `TextArea` set to the `embedded` appearance.",
+ },
+ },
+ },
+ argTypes: {
+ label: { control: { type: "text" } },
+ placeholder: { control: { type: "text" } },
+ rows: { control: { type: "number" } },
+ helpIcon: { control: { type: "boolean" } },
+ disabled: { control: { type: "boolean" } },
+ onChange: { action: "change" },
+ },
+ tags: ["autodocs"],
+};
+
+export const Default = {
+ render: (args) => {
+ const [value, setValue] = React.useState("");
+ return (
+
+
+
+ );
+ },
+ args: {
+ label: "Description",
+ helpIcon: true,
+ placeholder: "What does this rule cover?",
+ rows: 4,
+ },
+};
+
+export const WithValue = {
+ render: () => {
+ const [value, setValue] = React.useState(
+ "We decide together whenever a change would affect more than two teams.",
+ );
+ return (
+
+
+
+ );
+ },
+};
+
+export const Disabled = {
+ render: () => (
+
+ {}}
+ disabled
+ />
+
+ ),
+};
diff --git a/tests/components/ApplicableScopeField.test.tsx b/tests/components/ApplicableScopeField.test.tsx
new file mode 100644
index 0000000..0dee4b0
--- /dev/null
+++ b/tests/components/ApplicableScopeField.test.tsx
@@ -0,0 +1,122 @@
+import React from "react";
+import { describe, it, expect, vi } from "vitest";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import "@testing-library/jest-dom/vitest";
+import ApplicableScopeField from "../../app/create/components/ApplicableScopeField";
+import { componentTestSuite } from "../utils/componentTestSuite";
+import { renderWithProviders } from "../utils/test-utils";
+
+type ApplicableScopeFieldProps = React.ComponentProps<
+ typeof ApplicableScopeField
+>;
+
+componentTestSuite({
+ component: ApplicableScopeField,
+ name: "ApplicableScopeField",
+ props: {
+ label: "Applicable Scope",
+ addLabel: "Add Applicable Scope",
+ scopes: ["Finance", "Operations"],
+ selectedScopes: ["Finance"],
+ onToggleScope: () => {},
+ onAddScope: () => {},
+ } as ApplicableScopeFieldProps,
+ requiredProps: ["label", "addLabel"],
+ optionalProps: {
+ inputPlaceholder: "Enter scope",
+ },
+ primaryRole: "button",
+ testCases: {
+ renders: true,
+ accessibility: true,
+ keyboardNavigation: false,
+ disabledState: false,
+ errorState: false,
+ },
+});
+
+describe("ApplicableScopeField behavior", () => {
+ const baseProps: ApplicableScopeFieldProps = {
+ label: "Applicable Scope",
+ addLabel: "Add Applicable Scope",
+ scopes: ["Finance", "Operations", "Product"],
+ selectedScopes: ["Finance"],
+ onToggleScope: () => {},
+ onAddScope: () => {},
+ };
+
+ it("renders each scope as a chip", () => {
+ renderWithProviders();
+
+ expect(screen.getByRole("button", { name: /Deselect Finance/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /Select Operations/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /Select Product/i })).toBeInTheDocument();
+ });
+
+ it("calls onToggleScope when a chip is clicked", async () => {
+ const user = userEvent.setup();
+ const onToggleScope = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: /Select Operations/i }));
+ expect(onToggleScope).toHaveBeenCalledWith("Operations");
+ });
+
+ it("reveals the inline input when '+ Add' is clicked", async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ await user.click(screen.getByRole("button", { name: /Add Applicable Scope/i }));
+ expect(
+ screen.getByRole("textbox", { name: /Add Applicable Scope/i }),
+ ).toBeInTheDocument();
+ });
+
+ it("calls onAddScope with trimmed value on Enter", async () => {
+ const user = userEvent.setup();
+ const onAddScope = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: /Add Applicable Scope/i }));
+ const input = screen.getByRole("textbox", { name: /Add Applicable Scope/i });
+ await user.type(input, " People {Enter}");
+
+ expect(onAddScope).toHaveBeenCalledWith("People");
+ });
+
+ it("does not call onAddScope for duplicates already in scopes", async () => {
+ const user = userEvent.setup();
+ const onAddScope = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: /Add Applicable Scope/i }));
+ const input = screen.getByRole("textbox", { name: /Add Applicable Scope/i });
+ await user.type(input, "Finance{Enter}");
+
+ expect(onAddScope).not.toHaveBeenCalled();
+ });
+
+ it("dismisses the inline input on Escape without calling onAddScope", async () => {
+ const user = userEvent.setup();
+ const onAddScope = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: /Add Applicable Scope/i }));
+ const input = screen.getByRole("textbox", { name: /Add Applicable Scope/i });
+ await user.type(input, "People{Escape}");
+
+ expect(onAddScope).not.toHaveBeenCalled();
+ expect(
+ screen.queryByRole("textbox", { name: /Add Applicable Scope/i }),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/tests/components/Incrementer.test.tsx b/tests/components/Incrementer.test.tsx
new file mode 100644
index 0000000..5bbeefa
--- /dev/null
+++ b/tests/components/Incrementer.test.tsx
@@ -0,0 +1,125 @@
+import React from "react";
+import { describe, it, expect, vi } from "vitest";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import "@testing-library/jest-dom/vitest";
+import Incrementer from "../../app/components/controls/Incrementer";
+import { componentTestSuite } from "../utils/componentTestSuite";
+import { renderWithProviders } from "../utils/test-utils";
+
+type IncrementerProps = React.ComponentProps;
+
+componentTestSuite({
+ component: Incrementer,
+ name: "Incrementer",
+ props: {
+ value: 5,
+ onChange: () => {},
+ } as IncrementerProps,
+ requiredProps: ["value", "onChange"],
+ optionalProps: {
+ step: 1,
+ },
+ primaryRole: "button",
+ testCases: {
+ renders: true,
+ accessibility: true,
+ keyboardNavigation: false,
+ disabledState: false,
+ errorState: false,
+ },
+});
+
+describe("Incrementer behavior", () => {
+ it("calls onChange with value + step when increment is clicked", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: /increase/i }));
+ expect(onChange).toHaveBeenCalledWith(7);
+ });
+
+ it("calls onChange with value - step when decrement is clicked", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: /decrease/i }));
+ expect(onChange).toHaveBeenCalledWith(3);
+ });
+
+ it("disables the decrement button when value is at min", () => {
+ renderWithProviders(
+ {}} />,
+ );
+
+ expect(screen.getByRole("button", { name: /decrease/i })).toBeDisabled();
+ expect(screen.getByRole("button", { name: /increase/i })).not.toBeDisabled();
+ });
+
+ it("disables the increment button when value is at max", () => {
+ renderWithProviders(
+ {}} />,
+ );
+
+ expect(screen.getByRole("button", { name: /increase/i })).toBeDisabled();
+ expect(screen.getByRole("button", { name: /decrease/i })).not.toBeDisabled();
+ });
+
+ it("clamps the next value to min/max bounds", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: /increase/i }));
+ expect(onChange).toHaveBeenCalledWith(10);
+ });
+
+ it("renders the formatted value when formatValue is provided", () => {
+ renderWithProviders(
+ {}}
+ formatValue={(n) => `${n}%`}
+ />,
+ );
+
+ expect(screen.getByText("75%")).toBeInTheDocument();
+ });
+
+ it("disables both step buttons when disabled is true", () => {
+ renderWithProviders(
+ {}} disabled />,
+ );
+
+ expect(screen.getByRole("button", { name: /decrease/i })).toBeDisabled();
+ expect(screen.getByRole("button", { name: /increase/i })).toBeDisabled();
+ });
+
+ it("respects custom aria-labels", () => {
+ renderWithProviders(
+ {}}
+ decrementAriaLabel="Remove one"
+ incrementAriaLabel="Add one"
+ />,
+ );
+
+ expect(screen.getByRole("button", { name: "Remove one" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Add one" })).toBeInTheDocument();
+ });
+});
diff --git a/tests/components/IncrementerBlock.test.tsx b/tests/components/IncrementerBlock.test.tsx
new file mode 100644
index 0000000..f9225bc
--- /dev/null
+++ b/tests/components/IncrementerBlock.test.tsx
@@ -0,0 +1,93 @@
+import React from "react";
+import { describe, it, expect, vi } from "vitest";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import "@testing-library/jest-dom/vitest";
+import IncrementerBlock from "../../app/components/controls/IncrementerBlock";
+import { componentTestSuite } from "../utils/componentTestSuite";
+import { renderWithProviders } from "../utils/test-utils";
+
+type IncrementerBlockProps = React.ComponentProps;
+
+componentTestSuite({
+ component: IncrementerBlock,
+ name: "IncrementerBlock",
+ props: {
+ label: "Consensus level",
+ value: 50,
+ onChange: () => {},
+ } as IncrementerBlockProps,
+ requiredProps: ["label", "value", "onChange"],
+ optionalProps: {
+ helperText: "Optional",
+ },
+ primaryRole: "button",
+ testCases: {
+ renders: true,
+ accessibility: true,
+ keyboardNavigation: false,
+ disabledState: false,
+ errorState: false,
+ },
+});
+
+describe("IncrementerBlock composition", () => {
+ it("renders the label above the incrementer", () => {
+ renderWithProviders(
+ {}}
+ />,
+ );
+
+ expect(screen.getByText("Consensus level")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /increase/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /decrease/i })).toBeInTheDocument();
+ });
+
+ it("forwards incrementer props (step, min, max) to the inner control", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: /increase/i }));
+ expect(onChange).toHaveBeenCalledWith(60);
+ });
+
+ it("disables both step buttons when disabled is true", () => {
+ renderWithProviders(
+ {}}
+ disabled
+ />,
+ );
+
+ expect(screen.getByRole("button", { name: /decrease/i })).toBeDisabled();
+ expect(screen.getByRole("button", { name: /increase/i })).toBeDisabled();
+ });
+
+ it("renders helper text when provided", () => {
+ renderWithProviders(
+ {}}
+ />,
+ );
+
+ expect(screen.getByText("Required for proposal")).toBeInTheDocument();
+ });
+});
diff --git a/tests/components/InlineTextButton.test.tsx b/tests/components/InlineTextButton.test.tsx
new file mode 100644
index 0000000..452bdaa
--- /dev/null
+++ b/tests/components/InlineTextButton.test.tsx
@@ -0,0 +1,77 @@
+import React from "react";
+import { describe, it, expect, vi } from "vitest";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import "@testing-library/jest-dom/vitest";
+import InlineTextButton from "../../app/components/buttons/InlineTextButton";
+import { componentTestSuite } from "../utils/componentTestSuite";
+import { renderWithProviders } from "../utils/test-utils";
+
+type InlineTextButtonProps = React.ComponentProps;
+
+componentTestSuite({
+ component: InlineTextButton,
+ name: "InlineTextButton",
+ props: {
+ children: "Expand",
+ } as InlineTextButtonProps,
+ requiredProps: ["children"],
+ optionalProps: {
+ ariaLabel: "Expand description",
+ },
+ primaryRole: "button",
+ testCases: {
+ renders: true,
+ accessibility: true,
+ keyboardNavigation: true,
+ disabledState: true,
+ errorState: false,
+ },
+ states: {
+ disabledProps: { disabled: true },
+ },
+});
+
+describe("InlineTextButton behavior", () => {
+ it("fires onClick when clicked", async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ renderWithProviders(
+ Expand,
+ );
+
+ await user.click(screen.getByRole("button", { name: /expand/i }));
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not fire onClick when disabled", async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ renderWithProviders(
+
+ Expand
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: /expand/i }));
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ it("uses ariaLabel when provided", () => {
+ renderWithProviders(
+ Expand,
+ );
+
+ expect(
+ screen.getByRole("button", { name: "Expand description" }),
+ ).toBeInTheDocument();
+ });
+
+ it("defaults to type='button' to avoid accidental form submits", () => {
+ renderWithProviders(Expand);
+ expect(screen.getByRole("button", { name: /expand/i })).toHaveAttribute(
+ "type",
+ "button",
+ );
+ });
+});
diff --git a/tests/components/ModalTextAreaField.test.tsx b/tests/components/ModalTextAreaField.test.tsx
new file mode 100644
index 0000000..98a5bd9
--- /dev/null
+++ b/tests/components/ModalTextAreaField.test.tsx
@@ -0,0 +1,88 @@
+import React from "react";
+import { describe, it, expect, vi } from "vitest";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import "@testing-library/jest-dom/vitest";
+import ModalTextAreaField from "../../app/create/components/ModalTextAreaField";
+import { componentTestSuite } from "../utils/componentTestSuite";
+import { renderWithProviders } from "../utils/test-utils";
+
+type ModalTextAreaFieldProps = React.ComponentProps;
+
+componentTestSuite({
+ component: ModalTextAreaField,
+ name: "ModalTextAreaField",
+ props: {
+ label: "Description",
+ value: "",
+ onChange: () => {},
+ } as ModalTextAreaFieldProps,
+ requiredProps: ["label"],
+ optionalProps: {
+ placeholder: "What does this cover?",
+ },
+ primaryRole: "textbox",
+ testCases: {
+ renders: true,
+ accessibility: true,
+ keyboardNavigation: false,
+ disabledState: true,
+ errorState: false,
+ },
+ states: {
+ disabledProps: { disabled: true },
+ },
+});
+
+describe("ModalTextAreaField behavior", () => {
+ it("renders the label and a textbox wired together", () => {
+ renderWithProviders(
+ {}}
+ />,
+ );
+
+ expect(screen.getByText("Core principle")).toBeInTheDocument();
+ expect(
+ screen.getByRole("textbox", { name: /Core principle/i }),
+ ).toBeInTheDocument();
+ });
+
+ it("calls onChange with the new value string (not the event)", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ const textbox = screen.getByRole("textbox", { name: /Notes/i });
+ await user.type(textbox, "A");
+
+ expect(onChange).toHaveBeenCalledWith("A");
+ });
+
+ it("forwards placeholder and current value", () => {
+ renderWithProviders(
+ {}}
+ placeholder="Type here"
+ />,
+ );
+
+ const textbox = screen.getByRole("textbox", { name: /Notes/i });
+ expect(textbox).toHaveValue("hello");
+ expect(textbox).toHaveAttribute("placeholder", "Type here");
+ });
+
+ it("disables the textarea when disabled is true", () => {
+ renderWithProviders(
+ {}} disabled />,
+ );
+
+ expect(screen.getByRole("textbox", { name: /Notes/i })).toBeDisabled();
+ });
+});