Establish cursor rules
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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"`).
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
```
|
||||
@@ -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;
|
||||
}
|
||||
+17
-63
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user