Establish cursor rules

This commit is contained in:
adilallo
2026-04-18 09:33:24 -06:00
parent 4854c49c4a
commit f866d11ff8
30 changed files with 1711 additions and 144 deletions
+76
View File
@@ -0,0 +1,76 @@
---
description: App Router API handler conventions (Next.js + Prisma + Zod)
globs: app/api/**/*.ts,lib/server/**/*.ts
alwaysApply: false
---
# API route anatomy
Every DB-touching handler in `app/api/**/route.ts` follows the same skeleton.
Keep new routes within this shape so auth, config, and validation stay uniform.
1. **Config guard (first line of the handler).**
```typescript
if (!isDatabaseConfigured()) return dbUnavailable();
```
From `lib/server/env` + `lib/server/responses`. Returns a consistent 503
when `DATABASE_URL` is missing (local dev, preview builds).
2. **Auth (when the route requires a user).**
```typescript
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
```
From `lib/server/session`. Never read session cookies or tokens directly.
3. **Body parsing + validation (POST/PUT/PATCH).**
```typescript
const parsed = await readLimitedJson(request);
const result = mySchema.safeParse(parsed);
if (!result.success) return jsonFromZodError(result.error);
```
Helpers live in `lib/server/validation/{requestBody,zodHttp}.ts`. All
payload schemas belong in `lib/server/validation/*.ts` (today:
`createFlowSchemas.ts`) — colocate new schemas there rather than inline in
the route.
4. **Prisma access** via `import { prisma } from "lib/server/db"`. Do not
instantiate `PrismaClient` directly.
5. **Responses** via `NextResponse.json(...)`. Shared shapes (`dbUnavailable`)
live in `lib/server/responses.ts`; add new shared responses there when a
pattern repeats in two routes.
# Server-only isolation
`lib/server/*` is the server boundary. Anything that:
- imports `@prisma/client`,
- reads secrets from `env`,
- sends email, hashes tokens, or touches sessions
…lives under `lib/server/`. Never import `lib/server/*` from client
components, `app/components/**`, or any file marked `"use client"`. Shared
logic safe for both sides goes in `lib/*`.
# Deferred — follow existing code, don't invent
These areas are still settling. Match whatever the nearest route already does
instead of introducing new patterns:
- **Rate limiting.** `lib/server/rateLimit.ts` is an in-memory stopgap marked
for replacement. Reuse `rateLimitKey()` where limiting is needed; don't
design a new limiter.
- **Error response shape.** Currently `{ error: string }` + HTTP status. No
error codes yet — don't add a taxonomy until one is designed.
- **Pagination / filtering.** Only `rules/route.ts` paginates (`take` capped
at 100). Mirror it if you add list endpoints; don't invent cursors or
offset contracts unilaterally.
+59
View File
@@ -0,0 +1,59 @@
---
description: Figma ↔ codebase prop alignment & normalization for design-system components
globs: app/components/**/*.{ts,tsx}
alwaysApply: false
---
# Component prop alignment
Figma emits PascalCase enum values (`"Standard"`, `"Inverse"`); the codebase
stores lowercase (`"standard"`, `"inverse"`). Components must accept **both**,
normalize to lowercase internally, and never break existing consumer call
sites.
## Scope
Applies to enum-like string props: `variant`, `size`, `state`, `mode`, `type`,
`position`, `alignment`, `status`, `color`, `palette`. **Skip** for free-form
strings (URLs, classNames), booleans, numbers, or internal-only props.
## Pattern
```typescript
// Type accepts both formats
export type ComponentSizeValue = "small" | "medium" | "Small" | "Medium";
// Container normalizes via helpers from lib/propNormalization.ts
import { normalizeSize } from "../../../lib/propNormalization";
const ComponentContainer = ({ size: sizeProp = "small" }: Props) => {
const size = normalizeSize(sizeProp, SIZE_OPTIONS, "small");
return <ComponentView size={size} />;
};
```
## Normalizers
Use an existing helper from `lib/propNormalization.ts` before writing a new
one. Generic: `normalizeMode`, `normalizeState`, `normalizeInputState`,
`normalizeSize`, `normalizeAlignment`, `normalizeSmallMediumLargeSize`,
`normalizeLabelVariant`. Component-specific variants are named
`normalize<Component><Prop>` (e.g. `normalizeChipPalette`,
`normalizeButtonState`) — add one there rather than inlining a switch in your
container.
## Figma traceability
Every DS component's container docstring cites its Figma origin so designers
and engineers can jump between the two. Format: `Figma: "<Component Path>"
(<node-id>)`.
```typescript
/**
* Figma: "Control / Incrementer" (`17857:30943`). A compact [ - value + ]
* row used for numeric step inputs…
*/
```
When the view renders a distinct Figma node, add `data-figma-node="<id>"` on
the root element for quick DOM-to-design lookup.
+64
View File
@@ -0,0 +1,64 @@
---
description: File-structure conventions for design-system components
globs: app/components/**/*.{ts,tsx}
alwaysApply: false
---
# Component file structure
## Split-file pattern (default)
Anything in `app/components/controls/**` and `app/components/utility/**` uses
a **4-file split**, one folder per component:
```
app/components/controls/<Name>/
<Name>.types.ts // Public Props + internal ViewProps
<Name>.view.tsx // "use client"; pure render; exports memo(view)
<Name>.container.tsx // "use client"; memo; prop normalization & logic
index.tsx // re-exports default + public types
```
**Container** (`<Name>.container.tsx`):
- Marked `"use client"`.
- Receives `<Name>Props`; normalizes PascalCase enums via
`lib/propNormalization.ts`; computes derived state (clamps, ids, bounds).
- Renders `<<Name>View />` with already-normalized `<Name>ViewProps`.
- Default export: `memo(<Name>Container)` with `.displayName = "<Name>"`.
**View** (`<Name>.view.tsx`):
- Marked `"use client"`.
- Pure render of `<Name>ViewProps`. No prop normalization, no data fetching,
no derived business logic.
- Default export: `memo(<Name>View)` with
`.displayName = "<Name>View"`.
**Types** (`<Name>.types.ts`):
- Export `<Name>Props` (consumer-facing, accepts PascalCase + lowercase).
- Export `<Name>ViewProps` (already-normalized shape the view consumes).
- Export any locally-defined value types (`<Name>SizeValue`, etc.).
**Index** (`index.tsx`):
```typescript
export { default } from "./<Name>.container";
export type { <Name>Props } from "./<Name>.types";
```
## Single-file pattern (exception)
`app/components/buttons/*.tsx` and other trivially-presentational components
can stay as a single file when they have **no enum prop normalization and no
derived state** (e.g. `Button.tsx`, `InlineTextButton.tsx`). If you find
yourself adding state, enum normalization, or more than a handful of props,
promote it to the split pattern.
## Wrapper / group components
Related composites live in a **sibling folder**, not inside the base
component's folder — mirror `CheckboxGroup/` ↔ `Checkbox/`,
`IncrementerBlock/` ↔ `Incrementer/`, etc. Each gets its own 4-file split.
Consumers import from the folder's `index.tsx`.
+52
View File
@@ -0,0 +1,52 @@
---
description: Create-flow structure & design-system reuse guardrails
globs: app/create/**/*.{ts,tsx},messages/en/create/**/*.json
alwaysApply: false
---
# Create-flow guardrails
## Folder & file layout
- Screens live in `app/create/screens/<layoutKind>/<StepIdPascal>Screen.tsx`
where `<layoutKind>` mirrors `CreateFlowLayoutKind` (`card`, `select`,
`right-rail`, `completed`, …). File + export name is the **step id**, never
the layout kind (e.g. `DecisionApproachesScreen`, not `RightRailScreen`).
- Step id ↔ layout kind mapping is declared in
`app/create/utils/createFlowScreenRegistry.ts`. Never branch on layout kind
inside a screen — pick the matching shell (`CreateFlowStepShell` /
`CreateFlowTwoColumnSelectShell`).
- Shared create-flow pieces go in `app/create/components/` (layout shells,
field composites). Generic primitives go in `app/components/`.
## Use the design system — don't hand-roll
Reach for these before writing new markup:
| Need | Component |
| --- | --- |
| Labelled text-area section in a modal | `app/create/components/ModalTextAreaField` |
| Toggle-chip row + inline "+ Add" input | `app/create/components/ApplicableScopeField` |
| `[ value +]` numeric stepper (± label) | `app/components/controls/Incrementer` / `IncrementerBlock` |
| Mid-paragraph "expand / see all" link button | `app/components/buttons/InlineTextButton` |
| Help-icon + label above a control | `app/components/utility/InputLabel` (`helpIcon` prop) |
| Toggle chip (dim-but-clickable) | `Chip` with `state="Disabled" disabled={false}` |
| Card-click → structured creation modal | `Create` with `backdropVariant="loginYellow"` |
If a screen grows a 2nd inline copy of any pattern above, **extract a shared
component** rather than duplicate. Local section components inside a screen
file are a smell once they're used more than once.
## Copy & data
- Step copy lives in `messages/en/create/<step>.json`, wired into
`messages/en/index.ts` under the `create:` namespace (see
`localization.mdc` for the standard pattern).
- Modal `sections` defaults are DB-shaped seed placeholders, not UI
constants — expect replacement with live data.
## Interaction tracking
Every user interaction inside a create-flow screen must call
`markCreateFlowInteraction()` from `useCreateFlow()` before mutating state —
progress / footer logic depends on it.
+59
View File
@@ -0,0 +1,59 @@
---
description: Text localization via messages/ bundles and useMessages()
globs: messages/**/*.{ts,json}
alwaysApply: false
---
# Text localization
All user-visible copy lives in the typed messages bundle under `messages/en/`
and is read via `useMessages()` (fully typed) or `useTranslation()` (dot
notation). Never hard-code user-facing strings in components.
## File layout
- `messages/en/<area>.json` for single-file areas (`common.json`,
`navigation.json`, `metadata.json`).
- `messages/en/<folder>/<entry>.json` for areas with multiple buckets:
`components/*.json`, `pages/*.json`, `create/*.json`. One JSON per
component / page / create-flow step — don't shoehorn unrelated copy into
a shared file.
- Optional `"_comment"` at the top of a JSON documents the bundle's purpose.
## Registration — required
Every new JSON must be wired into `messages/en/index.ts`:
```typescript
import createConflictManagement from "./create/conflictManagement.json";
export default {
// …
create: {
conflictManagement: createConflictManagement,
},
};
```
The default export **is** the type source for `useMessages()`; skipping this
step means consumers can't read your strings and TypeScript won't flag the gap.
## Access pattern
```typescript
import { useMessages } from "../contexts/MessagesContext";
const m = useMessages();
const title = m.create.conflictManagement.page.compactTitle; // fully typed
```
Use `useTranslation(namespace)` only when you need dot-path lookup by dynamic
key; prefer direct property access for the type safety.
## Key conventions
- **Structural keys**: camelCase (`compactTitle`,
`sectionHeadings.corePrinciple`).
- **Content ids**: match the id consumers already use (card id, step id, URL
segment) — typically kebab-case (`"in-person-meetings"`,
`"peer-mediation"`).
+105
View File
@@ -0,0 +1,105 @@
---
description: Storybook story conventions — location, naming, titles, decorators
globs: stories/**/*.{js,jsx,ts,tsx,mdx},.storybook/**/*.{js,ts}
alwaysApply: false
---
# Where stories live
All stories live in the top-level `stories/` folder, mirroring the
`app/components/` directory tree:
| Source | Story location |
| --------------------------------- | -------------------------------------- |
| `app/components/controls/Chip` | `stories/controls/Chip.stories.js` |
| `app/components/buttons/Button` | `stories/buttons/Button.stories.js` |
| `app/create/screens/.../FooScreen`| `stories/pages/FooPage.stories.js` |
Do **not** colocate `*.stories.*` next to components. The Storybook config
(`.storybook/main.js`) only globs `stories/**`.
# File naming
- `<ComponentName>.stories.js` — matches 69/70 existing files.
- Use `.tsx` only when the story genuinely needs types (rare; prefer JS to
match the codebase convention).
- Variants get a suffix: `Button.visual.stories.js`,
`Footer.responsive.stories.js`.
# Default export shape (CSF2)
```javascript
import MyComponent from "../../app/components/<area>/MyComponent";
export default {
title: "Components/<SubFolder>/MyComponent",
component: MyComponent,
parameters: {
layout: "centered",
docs: {
description: {
component: "Short description of what the component is for.",
},
},
},
argTypes: {
variant: {
control: { type: "select" },
options: ["filled", "outline"],
description: "The variant (Figma prop)",
},
onClick: { action: "clicked" },
},
};
export const Default = { args: { variant: "filled" } };
```
## Title hierarchy
- Design-system components → `Components/<SubFolder>/<Name>` (e.g.
`Components/Controls/Checkbox`).
- Pages → `Pages/<PageName>` (folder: `stories/pages/`).
- Create flow shared pieces → `Create Flow/<Name>`.
## `argTypes`
For every Figma enum prop (`variant`, `size`, `state`, `mode`, `palette`,
…) expose a `select` control listing the **lowercase** option set — even
though the prop accepts PascalCase too. See `.cursor/rules/component-props.mdc`.
# Rely on the global preview — don't re-wrap
`.storybook/preview.js` already provides:
- `MessagesProvider` with `messages/en` → access copy via `useMessages()`
inside stories exactly like app code. Never hard-code user-facing strings.
- `app/globals.css` + `.font-inter` wrapper → design tokens and fonts are
already present.
Do **not** add your own `MessagesProvider`, font wrapper, or token setup in a
story. If you need a new global, update `preview.js`.
# Interaction tests (`play`)
Use `@storybook/test` for interaction assertions — not `@testing-library/*`
directly. This matches `Checkbox.stories.js` and stays compatible with the
Vitest portable-stories runner in `.storybook/vitest.setup.js`.
```javascript
import { within, userEvent, expect } from "@storybook/test";
export const Interactive = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("checkbox"));
expect(canvas.getByRole("checkbox")).toHaveAttribute("aria-checked", "true");
},
};
```
# Coverage expectation
Every new component in `app/components/**` ships with a story. Screens in
`app/create/screens/**` ship with a `stories/pages/<Name>Page.stories.js`
entry. A new component without a story is considered incomplete.
+37
View File
@@ -0,0 +1,37 @@
---
description: Tailwind-first styling for all React components
globs: app/**/*.{ts,tsx},stories/**/*.{ts,tsx}
alwaysApply: false
---
# Tailwind-first styling
Tailwind v4 is the default styling layer. Reach for utility classes + design
tokens **before** anything else.
## Priority
1. **Tailwind utilities** — `className="flex items-center gap-4 p-6 rounded-lg"`.
Use arbitrary values (`w-[200px]`) and responsive variants (`sm:`, `lg:`)
as needed. Design-token CSS variables go in arbitrary values:
`bg-[var(--color-surface-default-primary)]`.
2. **`style` prop** — only for values that truly change at runtime
(`style={{ width: `${dynamicPx}px` }}`).
3. **Custom / global CSS** — last resort. Justified for keyframes, third-party
overrides, dynamic-count CSS Grid, and similar cases Tailwind can't express.
4. **CSS-in-JS / CSS Modules** — don't introduce.
## Anti-patterns
```tsx
// ❌ Opaque class names bypass the design system
<div className="custom-container">
<span className="custom-text">Hello</span>
</div>
// ❌ Inline style for a static value
<div style={{ padding: 16, borderRadius: 8 }}>…</div>
// ✅ Tailwind + token
<div className="p-4 rounded-lg bg-[var(--color-surface-default-primary)]">…</div>
```
+72
View File
@@ -0,0 +1,72 @@
---
description: Test file layout & shared harnesses (vitest + Playwright)
globs: tests/**/*.{ts,tsx,js,jsx}
alwaysApply: false
---
# Testing conventions
## Runner split
- **Vitest** for unit, component, and page-level tests (`tests/components`,
`tests/pages`, `tests/unit`, `tests/contexts`, `tests/accessibility`).
Run via `npm test` or `npx vitest run`.
- **Playwright** for browser e2e and visual regression (`tests/e2e`).
Run via `npm run e2e`. Never put Playwright specs outside `tests/e2e/`.
## File layout
| Path | Use |
| --- | --- |
| `tests/components/<Name>.test.tsx` | Design-system component tests. |
| `tests/pages/<step>.test.jsx` | Page / screen integration tests. |
| `tests/unit/<fn>.test.{ts,js}` | Pure logic — utilities, reducers, hooks without DOM. |
| `tests/contexts/<Ctx>.test.tsx` | Context provider tests. |
| `tests/accessibility/` | `jest-axe` suites (unit) and `wcag-compliance.spec.ts` (e2e). |
| `tests/e2e/` | Playwright specs (user journeys, visual, performance). |
## Providers — always use `renderWithProviders`
`render` from `@testing-library/react` **skips** Messages/AuthModal/CreateFlow
providers. Import the wrapped version instead:
```typescript
import {
renderWithProviders as render,
screen,
} from "../utils/test-utils";
```
Raw `render` is only acceptable for pure-presentational components that read
none of those contexts.
## DS component suites
Reuse `componentTestSuite` for standard DS coverage (renders,
`jest-axe` a11y, keyboard navigation, disabled/error states) instead of
rewriting each check per component:
```typescript
import {
componentTestSuite,
type ComponentTestSuiteConfig,
} from "../utils/componentTestSuite";
const config: ComponentTestSuiteConfig<Props> = {
component: MyComponent,
name: "MyComponent",
props: baseProps,
primaryRole: "button",
testCases: { renders: true, accessibility: true, keyboardNavigation: true },
};
componentTestSuite(config);
```
Custom interaction tests live alongside the suite in the same file.
## Required imports
- `import "@testing-library/jest-dom/vitest";` — required for matcher types
(`toBeInTheDocument`, `toHaveAttribute`, etc.).
- `afterEach(() => cleanup())` in page-level test files where multiple
`render` calls run sequentially.
@@ -0,0 +1,57 @@
"use client";
import { memo, useCallback } from "react";
import IncrementerView from "./Incrementer.view";
import type { IncrementerProps } from "./Incrementer.types";
/**
* 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 `IncrementerBlock` instead.
*/
const IncrementerContainer = ({
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 handleDecrement = useCallback(() => {
if (disabled || atMin) return;
onChange(Math.max(min, clampedValue - step));
}, [disabled, atMin, onChange, min, clampedValue, step]);
const handleIncrement = useCallback(() => {
if (disabled || atMax) return;
onChange(Math.min(max, clampedValue + step));
}, [disabled, atMax, onChange, max, clampedValue, step]);
return (
<IncrementerView
displayValue={formatValue ? formatValue(clampedValue) : clampedValue}
disabled={disabled}
atMin={atMin}
atMax={atMax}
onDecrement={handleDecrement}
onIncrement={handleIncrement}
decrementAriaLabel={decrementAriaLabel}
incrementAriaLabel={incrementAriaLabel}
className={className}
/>
);
};
IncrementerContainer.displayName = "Incrementer";
export default memo(IncrementerContainer);
@@ -0,0 +1,37 @@
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;
}
export interface IncrementerViewProps {
displayValue: React.ReactNode;
disabled: boolean;
atMin: boolean;
atMax: boolean;
onDecrement: () => void;
onIncrement: () => void;
decrementAriaLabel: string;
incrementAriaLabel: string;
className: string;
}
@@ -1,32 +1,7 @@
"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;
}
import type { IncrementerViewProps } from "./Incrementer.types";
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";
@@ -69,38 +44,17 @@ function PlusIcon() {
);
}
/**
* 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));
};
function IncrementerView({
displayValue,
disabled,
atMin,
atMax,
onDecrement,
onIncrement,
decrementAriaLabel,
incrementAriaLabel,
className,
}: IncrementerViewProps) {
const valueColor = disabled
? "text-[color:var(--color-content-default-tertiary,#b4b4b4)]"
: "text-[color:var(--color-content-default-primary,#fff)]";
@@ -112,7 +66,7 @@ function IncrementerComponent({
>
<button
type="button"
onClick={decrement}
onClick={onDecrement}
disabled={disabled || atMin}
aria-label={decrementAriaLabel}
className={STEP_BUTTON_CLASSES}
@@ -123,11 +77,11 @@ function IncrementerComponent({
aria-live="polite"
className={`shrink-0 whitespace-nowrap font-inter text-[length:var(--sizing-350,14px)] font-medium leading-[18px] ${valueColor}`}
>
{formatValue ? formatValue(clampedValue) : clampedValue}
{displayValue}
</span>
<button
type="button"
onClick={increment}
onClick={onIncrement}
disabled={disabled || atMax}
aria-label={incrementAriaLabel}
className={STEP_BUTTON_CLASSES}
@@ -138,6 +92,6 @@ function IncrementerComponent({
);
}
IncrementerComponent.displayName = "Incrementer";
IncrementerView.displayName = "IncrementerView";
export default memo(IncrementerComponent);
export default memo(IncrementerView);
@@ -1,72 +0,0 @@
"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 `<div>` 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 (
<div
className={`flex flex-col items-start gap-[var(--measures-spacing-300,12px)] py-[8px] ${blockClassName}`.trim()}
data-figma-node="19883:13283"
>
<InputLabel
label={label}
helpIcon={helpIcon}
helperText={helperText}
asterisk={asterisk}
size={labelSize}
palette={palette}
/>
<Incrementer {...incrementerProps} className={className} />
</div>
);
}
IncrementerBlockComponent.displayName = "IncrementerBlock";
export default memo(IncrementerBlockComponent);
@@ -1,5 +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";
export { default } from "./Incrementer.container";
export type {
IncrementerProps,
IncrementerViewProps,
} from "./Incrementer.types";
@@ -0,0 +1,38 @@
"use client";
import { memo } from "react";
import IncrementerBlockView from "./IncrementerBlock.view";
import type { IncrementerBlockProps } from "./IncrementerBlock.types";
/**
* Figma: "Control / Incrementer Block" (`19883:13283`). An `InputLabel` plus
* an `Incrementer` row, stacked with a 12px gap. Consumers can pass any
* `IncrementerProps` alongside the label-specific props.
*/
const IncrementerBlockContainer = ({
label,
helpIcon = true,
helperText,
asterisk,
labelSize = "s",
palette = "default",
blockClassName = "",
...incrementerProps
}: IncrementerBlockProps) => {
return (
<IncrementerBlockView
label={label}
helpIcon={helpIcon}
helperText={helperText}
asterisk={asterisk}
labelSize={labelSize}
palette={palette}
blockClassName={blockClassName}
{...incrementerProps}
/>
);
};
IncrementerBlockContainer.displayName = "IncrementerBlock";
export default memo(IncrementerBlockContainer);
@@ -0,0 +1,41 @@
import type { IncrementerProps } from "../Incrementer/Incrementer.types";
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 `<div>` 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;
}
@@ -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 (
<div
className={`flex flex-col items-start gap-[var(--measures-spacing-300,12px)] py-[8px] ${blockClassName}`.trim()}
data-figma-node="19883:13283"
>
<InputLabel
label={label}
helpIcon={helpIcon}
helperText={helperText}
asterisk={asterisk}
size={labelSize}
palette={palette}
/>
<Incrementer {...incrementerProps} className={className} />
</div>
);
}
IncrementerBlockView.displayName = "IncrementerBlockView";
export default memo(IncrementerBlockView);
@@ -0,0 +1,2 @@
export { default } from "./IncrementerBlock.container";
export type { IncrementerBlockProps } from "./IncrementerBlock.types";
@@ -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).
*/
+12 -2
View File
@@ -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 (
<div className={`flex flex-col gap-2 ${className}`.trim()}>
<InputLabel label={label} helpIcon={helpIcon} size="s" palette="default" />
<div id={labelId}>
<InputLabel
label={label}
helpIcon={helpIcon}
size="s"
palette="default"
/>
</div>
<TextArea
formHeader={false}
value={value}
@@ -50,6 +59,7 @@ function ModalTextAreaFieldComponent({
appearance="embedded"
placeholder={placeholder}
disabled={disabled}
aria-labelledby={labelId}
/>
</div>
);
@@ -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";
@@ -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: () => (
<p className="max-w-md font-inter text-[14px] leading-[20px] text-[color:var(--color-content-default-primary,#fff)]">
Share a bit more detail so the group can weigh in. You can always{" "}
<InlineTextButton onClick={() => {}}>expand this later</InlineTextButton>{" "}
if you need more room.
</p>
),
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,
},
};
+101
View File
@@ -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 <Incrementer {...args} value={value} onChange={setValue} />;
},
args: {
value: 50,
},
};
export const WithBounds = {
render: (args) => {
const [value, setValue] = React.useState(50);
return <Incrementer {...args} value={value} onChange={setValue} />;
},
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 (
<Incrementer
{...args}
value={value}
onChange={setValue}
formatValue={(n) => `${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: () => <Incrementer value={50} onChange={() => {}} disabled />,
};
@@ -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 <IncrementerBlock {...args} value={value} onChange={setValue} />;
},
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 (
<IncrementerBlock
label="Quorum percentage"
asterisk
helperText="Required"
value={value}
onChange={setValue}
min={0}
max={100}
step={5}
/>
);
},
};
export const WithFormattedValue = {
render: () => {
const [value, setValue] = React.useState(75);
return (
<IncrementerBlock
label="Consensus level"
helperText="Optional"
value={value}
onChange={setValue}
min={0}
max={100}
step={5}
formatValue={(n) => `${n}%`}
/>
);
},
};
export const Disabled = {
render: () => (
<IncrementerBlock
label="Consensus level"
value={75}
onChange={() => {}}
disabled
/>
),
};
@@ -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 (
<div className="w-[520px]">
<ApplicableScopeField
{...args}
scopes={scopes}
selectedScopes={selected}
onToggleScope={(scope) => {
setSelected((prev) =>
prev.includes(scope)
? prev.filter((s) => s !== scope)
: [...prev, scope],
);
}}
onAddScope={(scope) => setScopes((prev) => [...prev, scope])}
/>
</div>
);
},
args: {
label: "Applicable Scope",
addLabel: "Add Applicable Scope",
},
};
export const Empty = {
render: () => {
const [scopes, setScopes] = React.useState([]);
const [selected, setSelected] = React.useState([]);
return (
<div className="w-[520px]">
<ApplicableScopeField
label="Applicable Scope"
addLabel="Add Applicable Scope"
scopes={scopes}
selectedScopes={selected}
onToggleScope={(scope) =>
setSelected((prev) =>
prev.includes(scope)
? prev.filter((s) => s !== scope)
: [...prev, scope],
)
}
onAddScope={(scope) => setScopes((prev) => [...prev, scope])}
/>
</div>
);
},
parameters: {
docs: {
description: {
story:
"With no scopes yet — only the '+ Add' affordance is visible. Click it to reveal the pill text input.",
},
},
},
};
@@ -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 (
<div className="w-[520px]">
<ModalTextAreaField {...args} value={value} onChange={setValue} />
</div>
);
},
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 (
<div className="w-[520px]">
<ModalTextAreaField
label="Core principle"
value={value}
onChange={setValue}
/>
</div>
);
},
};
export const Disabled = {
render: () => (
<div className="w-[520px]">
<ModalTextAreaField
label="Description"
value="Read-only content"
onChange={() => {}}
disabled
/>
</div>
),
};
@@ -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<ApplicableScopeFieldProps>({
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(<ApplicableScopeField {...baseProps} />);
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(
<ApplicableScopeField {...baseProps} onToggleScope={onToggleScope} />,
);
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(<ApplicableScopeField {...baseProps} />);
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(
<ApplicableScopeField {...baseProps} onAddScope={onAddScope} />,
);
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(
<ApplicableScopeField {...baseProps} onAddScope={onAddScope} />,
);
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(
<ApplicableScopeField {...baseProps} onAddScope={onAddScope} />,
);
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();
});
});
+125
View File
@@ -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<typeof Incrementer>;
componentTestSuite<IncrementerProps>({
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(
<Incrementer value={5} step={2} onChange={onChange} />,
);
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(
<Incrementer value={5} step={2} onChange={onChange} />,
);
await user.click(screen.getByRole("button", { name: /decrease/i }));
expect(onChange).toHaveBeenCalledWith(3);
});
it("disables the decrement button when value is at min", () => {
renderWithProviders(
<Incrementer value={0} min={0} max={10} onChange={() => {}} />,
);
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(
<Incrementer value={10} min={0} max={10} onChange={() => {}} />,
);
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(
<Incrementer
value={9}
step={5}
min={0}
max={10}
onChange={onChange}
/>,
);
await user.click(screen.getByRole("button", { name: /increase/i }));
expect(onChange).toHaveBeenCalledWith(10);
});
it("renders the formatted value when formatValue is provided", () => {
renderWithProviders(
<Incrementer
value={75}
onChange={() => {}}
formatValue={(n) => `${n}%`}
/>,
);
expect(screen.getByText("75%")).toBeInTheDocument();
});
it("disables both step buttons when disabled is true", () => {
renderWithProviders(
<Incrementer value={5} onChange={() => {}} disabled />,
);
expect(screen.getByRole("button", { name: /decrease/i })).toBeDisabled();
expect(screen.getByRole("button", { name: /increase/i })).toBeDisabled();
});
it("respects custom aria-labels", () => {
renderWithProviders(
<Incrementer
value={5}
onChange={() => {}}
decrementAriaLabel="Remove one"
incrementAriaLabel="Add one"
/>,
);
expect(screen.getByRole("button", { name: "Remove one" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add one" })).toBeInTheDocument();
});
});
@@ -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<typeof IncrementerBlock>;
componentTestSuite<IncrementerBlockProps>({
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(
<IncrementerBlock
label="Consensus level"
value={75}
onChange={() => {}}
/>,
);
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(
<IncrementerBlock
label="Quorum"
value={50}
step={10}
min={0}
max={100}
onChange={onChange}
/>,
);
await user.click(screen.getByRole("button", { name: /increase/i }));
expect(onChange).toHaveBeenCalledWith(60);
});
it("disables both step buttons when disabled is true", () => {
renderWithProviders(
<IncrementerBlock
label="Consensus level"
value={50}
onChange={() => {}}
disabled
/>,
);
expect(screen.getByRole("button", { name: /decrease/i })).toBeDisabled();
expect(screen.getByRole("button", { name: /increase/i })).toBeDisabled();
});
it("renders helper text when provided", () => {
renderWithProviders(
<IncrementerBlock
label="Quorum"
helperText="Required for proposal"
value={50}
onChange={() => {}}
/>,
);
expect(screen.getByText("Required for proposal")).toBeInTheDocument();
});
});
@@ -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<typeof InlineTextButton>;
componentTestSuite<InlineTextButtonProps>({
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(
<InlineTextButton onClick={onClick}>Expand</InlineTextButton>,
);
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(
<InlineTextButton onClick={onClick} disabled>
Expand
</InlineTextButton>,
);
await user.click(screen.getByRole("button", { name: /expand/i }));
expect(onClick).not.toHaveBeenCalled();
});
it("uses ariaLabel when provided", () => {
renderWithProviders(
<InlineTextButton ariaLabel="Expand description">Expand</InlineTextButton>,
);
expect(
screen.getByRole("button", { name: "Expand description" }),
).toBeInTheDocument();
});
it("defaults to type='button' to avoid accidental form submits", () => {
renderWithProviders(<InlineTextButton>Expand</InlineTextButton>);
expect(screen.getByRole("button", { name: /expand/i })).toHaveAttribute(
"type",
"button",
);
});
});
@@ -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<typeof ModalTextAreaField>;
componentTestSuite<ModalTextAreaFieldProps>({
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(
<ModalTextAreaField
label="Core principle"
value=""
onChange={() => {}}
/>,
);
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(
<ModalTextAreaField label="Notes" value="" onChange={onChange} />,
);
const textbox = screen.getByRole("textbox", { name: /Notes/i });
await user.type(textbox, "A");
expect(onChange).toHaveBeenCalledWith("A");
});
it("forwards placeholder and current value", () => {
renderWithProviders(
<ModalTextAreaField
label="Notes"
value="hello"
onChange={() => {}}
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(
<ModalTextAreaField label="Notes" value="" onChange={() => {}} disabled />,
);
expect(screen.getByRole("textbox", { name: /Notes/i })).toBeDisabled();
});
});