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";
|
"use client";
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
|
import type { IncrementerViewProps } from "./Incrementer.types";
|
||||||
export interface IncrementerProps {
|
|
||||||
value: number;
|
|
||||||
/** Minimum value (default `-Infinity`). */
|
|
||||||
min?: number;
|
|
||||||
/** Maximum value (default `Infinity`). */
|
|
||||||
max?: number;
|
|
||||||
/** Step size applied to +/- actions (default `1`). */
|
|
||||||
step?: number;
|
|
||||||
onChange: (_next: number) => void;
|
|
||||||
/**
|
|
||||||
* Optional formatter for the displayed value. Receives the raw number and
|
|
||||||
* should return the rendered content. Default: `String(value)`.
|
|
||||||
*/
|
|
||||||
formatValue?: (_value: number) => React.ReactNode;
|
|
||||||
/**
|
|
||||||
* When true, the whole incrementer is non-interactive and the value renders
|
|
||||||
* in the "inactive" (tertiary) color per Figma.
|
|
||||||
*/
|
|
||||||
disabled?: boolean;
|
|
||||||
/** Accessible label for the decrement button (default "Decrease"). */
|
|
||||||
decrementAriaLabel?: string;
|
|
||||||
/** Accessible label for the increment button (default "Increase"). */
|
|
||||||
incrementAriaLabel?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STEP_BUTTON_CLASSES =
|
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";
|
"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() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function IncrementerView({
|
||||||
* Figma: "Control / Incrementer" (`17857:30943`). A compact `[ - value + ]`
|
displayValue,
|
||||||
* row used for numeric step inputs (e.g. a percentage setting).
|
disabled,
|
||||||
*
|
atMin,
|
||||||
* For a labelled variant that matches "Control / Incrementer Block"
|
atMax,
|
||||||
* (`19883:13283`), compose with {@link IncrementerBlock} instead.
|
onDecrement,
|
||||||
*/
|
onIncrement,
|
||||||
function IncrementerComponent({
|
decrementAriaLabel,
|
||||||
value,
|
incrementAriaLabel,
|
||||||
min = Number.NEGATIVE_INFINITY,
|
className,
|
||||||
max = Number.POSITIVE_INFINITY,
|
}: IncrementerViewProps) {
|
||||||
step = 1,
|
|
||||||
onChange,
|
|
||||||
formatValue,
|
|
||||||
disabled = false,
|
|
||||||
decrementAriaLabel = "Decrease",
|
|
||||||
incrementAriaLabel = "Increase",
|
|
||||||
className = "",
|
|
||||||
}: IncrementerProps) {
|
|
||||||
const clampedValue = Math.min(Math.max(value, min), max);
|
|
||||||
const atMin = clampedValue <= min;
|
|
||||||
const atMax = clampedValue >= max;
|
|
||||||
|
|
||||||
const decrement = () => {
|
|
||||||
if (disabled || atMin) return;
|
|
||||||
onChange(Math.max(min, clampedValue - step));
|
|
||||||
};
|
|
||||||
const increment = () => {
|
|
||||||
if (disabled || atMax) return;
|
|
||||||
onChange(Math.min(max, clampedValue + step));
|
|
||||||
};
|
|
||||||
|
|
||||||
const valueColor = disabled
|
const valueColor = disabled
|
||||||
? "text-[color:var(--color-content-default-tertiary,#b4b4b4)]"
|
? "text-[color:var(--color-content-default-tertiary,#b4b4b4)]"
|
||||||
: "text-[color:var(--color-content-default-primary,#fff)]";
|
: "text-[color:var(--color-content-default-primary,#fff)]";
|
||||||
@@ -112,7 +66,7 @@ function IncrementerComponent({
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={decrement}
|
onClick={onDecrement}
|
||||||
disabled={disabled || atMin}
|
disabled={disabled || atMin}
|
||||||
aria-label={decrementAriaLabel}
|
aria-label={decrementAriaLabel}
|
||||||
className={STEP_BUTTON_CLASSES}
|
className={STEP_BUTTON_CLASSES}
|
||||||
@@ -123,11 +77,11 @@ function IncrementerComponent({
|
|||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
className={`shrink-0 whitespace-nowrap font-inter text-[length:var(--sizing-350,14px)] font-medium leading-[18px] ${valueColor}`}
|
className={`shrink-0 whitespace-nowrap font-inter text-[length:var(--sizing-350,14px)] font-medium leading-[18px] ${valueColor}`}
|
||||||
>
|
>
|
||||||
{formatValue ? formatValue(clampedValue) : clampedValue}
|
{displayValue}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={increment}
|
onClick={onIncrement}
|
||||||
disabled={disabled || atMax}
|
disabled={disabled || atMax}
|
||||||
aria-label={incrementAriaLabel}
|
aria-label={incrementAriaLabel}
|
||||||
className={STEP_BUTTON_CLASSES}
|
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 } from "./Incrementer.container";
|
||||||
export { default as Incrementer } from "./Incrementer";
|
export type {
|
||||||
export { default as IncrementerBlock } from "./IncrementerBlock";
|
IncrementerProps,
|
||||||
export type { IncrementerProps } from "./Incrementer";
|
IncrementerViewProps,
|
||||||
export type { IncrementerBlockProps } from "./IncrementerBlock";
|
} 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
|
* 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
|
* At `lg+`, mirrors {@link CompletedScreen}: static header column + scrollable controls column
|
||||||
* (`min-h-0` + `overflow-y-auto` height chain; see completed page right rail).
|
* (`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.
|
* appearance — matching the Figma "Control / Text Area" pattern.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo, useId } from "react";
|
||||||
import TextArea from "../../components/controls/TextArea";
|
import TextArea from "../../components/controls/TextArea";
|
||||||
import InputLabel from "../../components/utility/InputLabel";
|
import InputLabel from "../../components/utility/InputLabel";
|
||||||
|
|
||||||
@@ -38,9 +38,18 @@ function ModalTextAreaFieldComponent({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
className = "",
|
className = "",
|
||||||
}: ModalTextAreaFieldProps) {
|
}: ModalTextAreaFieldProps) {
|
||||||
|
const labelId = useId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col gap-2 ${className}`.trim()}>
|
<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
|
<TextArea
|
||||||
formHeader={false}
|
formHeader={false}
|
||||||
value={value}
|
value={value}
|
||||||
@@ -50,6 +59,7 @@ function ModalTextAreaFieldComponent({
|
|||||||
appearance="embedded"
|
appearance="embedded"
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
aria-labelledby={labelId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { useState, useCallback, useMemo } from "react";
|
|||||||
import DecisionMakingSidebar from "../../../components/utility/DecisionMakingSidebar";
|
import DecisionMakingSidebar from "../../../components/utility/DecisionMakingSidebar";
|
||||||
import CardStack from "../../../components/utility/CardStack";
|
import CardStack from "../../../components/utility/CardStack";
|
||||||
import Create from "../../../components/modals/Create";
|
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 InlineTextButton from "../../../components/buttons/InlineTextButton";
|
||||||
import type { InfoMessageBoxItem } from "../../../components/utility/InfoMessageBox/InfoMessageBox.types";
|
import type { InfoMessageBoxItem } from "../../../components/utility/InfoMessageBox/InfoMessageBox.types";
|
||||||
import type { CardStackItem } from "../../../components/utility/CardStack/CardStack.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