diff --git a/.cursor/rules/api-routes.mdc b/.cursor/rules/api-routes.mdc new file mode 100644 index 0000000..79d428c --- /dev/null +++ b/.cursor/rules/api-routes.mdc @@ -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. diff --git a/.cursor/rules/component-props.mdc b/.cursor/rules/component-props.mdc new file mode 100644 index 0000000..d906ffc --- /dev/null +++ b/.cursor/rules/component-props.mdc @@ -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 ; +}; +``` + +## 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` (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: "" +()`. + +```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=""` on +the root element for quick DOM-to-design lookup. diff --git a/.cursor/rules/component-structure.mdc b/.cursor/rules/component-structure.mdc new file mode 100644 index 0000000..6bd9f32 --- /dev/null +++ b/.cursor/rules/component-structure.mdc @@ -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// + .types.ts // Public Props + internal ViewProps + .view.tsx // "use client"; pure render; exports memo(view) + .container.tsx // "use client"; memo; prop normalization & logic + index.tsx // re-exports default + public types +``` + +**Container** (`.container.tsx`): + +- Marked `"use client"`. +- Receives `Props`; normalizes PascalCase enums via + `lib/propNormalization.ts`; computes derived state (clamps, ids, bounds). +- Renders `<View />` with already-normalized `ViewProps`. +- Default export: `memo(Container)` with `.displayName = ""`. + +**View** (`.view.tsx`): + +- Marked `"use client"`. +- Pure render of `ViewProps`. No prop normalization, no data fetching, + no derived business logic. +- Default export: `memo(View)` with + `.displayName = "View"`. + +**Types** (`.types.ts`): + +- Export `Props` (consumer-facing, accepts PascalCase + lowercase). +- Export `ViewProps` (already-normalized shape the view consumes). +- Export any locally-defined value types (`SizeValue`, etc.). + +**Index** (`index.tsx`): + +```typescript +export { default } from "./.container"; +export type { Props } from "./.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`. diff --git a/.cursor/rules/create-flow.mdc b/.cursor/rules/create-flow.mdc new file mode 100644 index 0000000..e64c450 --- /dev/null +++ b/.cursor/rules/create-flow.mdc @@ -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//Screen.tsx` + where `` 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/.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. diff --git a/.cursor/rules/localization.mdc b/.cursor/rules/localization.mdc new file mode 100644 index 0000000..467d5bd --- /dev/null +++ b/.cursor/rules/localization.mdc @@ -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/.json` for single-file areas (`common.json`, + `navigation.json`, `metadata.json`). +- `messages/en//.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"`). diff --git a/.cursor/rules/storybook.mdc b/.cursor/rules/storybook.mdc new file mode 100644 index 0000000..a73b31c --- /dev/null +++ b/.cursor/rules/storybook.mdc @@ -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 + +- `.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//MyComponent"; + +export default { + title: "Components//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//` (e.g. + `Components/Controls/Checkbox`). +- Pages → `Pages/` (folder: `stories/pages/`). +- Create flow shared pieces → `Create Flow/`. + +## `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/Page.stories.js` +entry. A new component without a story is considered incomplete. diff --git a/.cursor/rules/tailwind-styling.mdc b/.cursor/rules/tailwind-styling.mdc new file mode 100644 index 0000000..f3ff7c8 --- /dev/null +++ b/.cursor/rules/tailwind-styling.mdc @@ -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 +
+ Hello +
+ +// ❌ Inline style for a static value +
+ +// ✅ Tailwind + token +
+``` diff --git a/.cursor/rules/testing.mdc b/.cursor/rules/testing.mdc new file mode 100644 index 0000000..b956e09 --- /dev/null +++ b/.cursor/rules/testing.mdc @@ -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/.test.tsx` | Design-system component tests. | +| `tests/pages/.test.jsx` | Page / screen integration tests. | +| `tests/unit/.test.{ts,js}` | Pure logic — utilities, reducers, hooks without DOM. | +| `tests/contexts/.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 = { + 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. diff --git a/app/components/controls/Incrementer/Incrementer.container.tsx b/app/components/controls/Incrementer/Incrementer.container.tsx new file mode 100644 index 0000000..56e9624 --- /dev/null +++ b/app/components/controls/Incrementer/Incrementer.container.tsx @@ -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 ( + + ); +}; + +IncrementerContainer.displayName = "Incrementer"; + +export default memo(IncrementerContainer); diff --git a/app/components/controls/Incrementer/Incrementer.types.ts b/app/components/controls/Incrementer/Incrementer.types.ts new file mode 100644 index 0000000..3f1d2a0 --- /dev/null +++ b/app/components/controls/Incrementer/Incrementer.types.ts @@ -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; +} diff --git a/app/components/controls/Incrementer/Incrementer.tsx b/app/components/controls/Incrementer/Incrementer.view.tsx similarity index 52% rename from app/components/controls/Incrementer/Incrementer.tsx rename to app/components/controls/Incrementer/Incrementer.view.tsx index 8549036..6a57b62 100644 --- a/app/components/controls/Incrementer/Incrementer.tsx +++ b/app/components/controls/Incrementer/Incrementer.view.tsx @@ -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({ >