Add Input, Checkbox, Radio, and Switch form components with semantic token layer

Build four form primitives from Figma references with brand-aligned creative
decisions: restrained press states (scale-95 instead of highlight splashes),
clean iconless Switch, and consistent error states with inline warning icons.

Introduce form-control semantic tokens (--color-control-*) in tokens.css so
all form components share a single source for borders, checked states, focus
rings, labels, and errors. Retrofit Input to use these tokens instead of
direct palette references.

Update CLAUDE.md and ARCHITECTURE.md with token layer documentation, token
discipline rule (no palette references in components), and component tier
decision criteria.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 14:00:56 +10:00
parent 0e1b06b376
commit 07be9d7314
18 changed files with 1523 additions and 57 deletions

View File

@@ -26,8 +26,17 @@ Components (use Tailwind utilities or var() references)
Storybook (visual verification) Storybook (visual verification)
``` ```
### Token Layers
Tokens are structured in three layers:
1. **Palette** — raw colour values (`--color-blue-01`, `--color-grey-03`). Not used directly in components.
2. **Semantic** — purpose-based aliases (`--color-primary`, `--color-error`, `--color-text`). General UI usage.
3. **Form control** — shared interactive-state tokens for all form components (`--color-control-border`, `--color-control-checked`, `--color-control-label`, etc.). Ensures consistent styling across Input, Checkbox, Radio, Switch, Select, and future form primitives.
### Token Categories ### Token Categories
- **Colours**: `--color-*` (bg, surface, border, text, primary, success, warning, error) - **Palette colours**: `--color-{palette}-{shade}` (e.g., `--color-blue-01`, `--color-grey-03`)
- **Semantic colours**: `--color-{purpose}` (e.g., `--color-primary`, `--color-error`, `--color-text`)
- **Form control colours**: `--color-control-{role}` (e.g., `--color-control-border`, `--color-control-checked`)
- **Radii**: `--radius-*` (sm, default, lg, full) - **Radii**: `--radius-*` (sm, default, lg, full)
- **Shadows**: `--shadow-*` (default, md) - **Shadows**: `--shadow-*` (default, md)
@@ -40,7 +49,8 @@ Declaring `--color-primary: #2563eb` inside `@theme` in `tokens.css` automatical
### `src/components/ui/` — Primitives ### `src/components/ui/` — Primitives
Atomic, reusable building blocks. Each is self-contained with no domain logic. Atomic, reusable building blocks. Each is self-contained with no domain logic.
- Button, Input, Textarea, Select - Button, Input, Checkbox, Radio/RadioGroup, Switch
- Textarea, Select
- Card, Badge, Tag - Card, Badge, Tag
- Dialog, Tooltip, Popover - Dialog, Tooltip, Popover
@@ -55,6 +65,17 @@ Page-level structural components.
- AppShell (header + sidebar + content area) - AppShell (header + sidebar + content area)
- PageHeader - PageHeader
### Which Tier Does a Component Belong To?
| Question | If yes → |
|---|---|
| Does it wrap a single native element or a single interaction pattern (button, input, toggle)? | **ui/** (primitive) |
| Does it compose 2+ primitives into a reusable unit (e.g., a search bar = Input + Button)? | **composite/** |
| Does it carry domain-specific naming or logic (e.g., ThemeCard, ParticipantRow)? | **composite/** |
| Does it define a page-level region or shell (header, sidebar, content area)? | **layout/** |
When in doubt: start in `ui/`. Promote to `composite/` when a component begins importing other `ui/` components. If the `composite/` directory grows beyond ~15 components, consider splitting it into `molecules/` (generic compositions) and `organisms/` (domain-aware compositions).
--- ---
## 4. Styling Approach ## 4. Styling Approach
@@ -62,6 +83,7 @@ Page-level structural components.
- **Primary**: Tailwind utility classes - **Primary**: Tailwind utility classes
- **Conditional classes**: `cn()` from `@/lib/utils` (clsx + tailwind-merge) - **Conditional classes**: `cn()` from `@/lib/utils` (clsx + tailwind-merge)
- **Token values**: Always from `src/tokens/tokens.css`, never hardcoded - **Token values**: Always from `src/tokens/tokens.css`, never hardcoded
- **Token discipline**: Components reference semantic or form-control tokens, not palette tokens. If the needed semantic token doesn't exist, add it to `tokens.css` before using a raw palette value.
- **No CSS modules, no styled-components, no inline styles** (except truly dynamic values) - **No CSS modules, no styled-components, no inline styles** (except truly dynamic values)
- **Class ordering**: Enforced by `prettier-plugin-tailwindcss` - **Class ordering**: Enforced by `prettier-plugin-tailwindcss`

View File

@@ -45,8 +45,24 @@ All design tokens live in `src/tokens/tokens.css` as a `@theme` block. This is t
Use Tailwind utilities (`bg-primary`, `text-error`, `rounded-default`, etc.) or CSS variables (`var(--color-primary)`) when utilities don't cover the case. Use Tailwind utilities (`bg-primary`, `text-error`, `rounded-default`, etc.) or CSS variables (`var(--color-primary)`) when utilities don't cover the case.
Token naming convention: ### Token Layers
Tokens are organised in three layers, from raw to consumable:
1. **Palette** — raw colour values: `--color-blue-01`, `--color-grey-03`, etc. Never reference these directly in components.
2. **Semantic** — purpose-based aliases: `--color-primary`, `--color-error`, `--color-text`, etc. Use these for general UI.
3. **Form control** — shared interactive-state tokens for all form components (Input, Checkbox, Radio, Switch, Select, etc.):
- `--color-control-border` / `--color-control-border-hover`
- `--color-control-checked` / `--color-control-checked-hover`
- `--color-control-focus-ring`
- `--color-control-label` / `--color-control-description` / `--color-control-error`
- `--color-control-bg` / `--color-control-bg-readonly`
**Rule**: Components must reference semantic or form-control tokens, not palette tokens. If you need a colour that has no semantic token, add one — don't reach for the palette directly.
### Token Naming Convention
- Colours: `--color-{name}` (e.g., `--color-primary`, `--color-text-secondary`) - Colours: `--color-{name}` (e.g., `--color-primary`, `--color-text-secondary`)
- Form controls: `--color-control-{role}` (e.g., `--color-control-border`, `--color-control-checked`)
- Radii: `--radius-{size}` (e.g., `--radius-default`, `--radius-lg`) - Radii: `--radius-{size}` (e.g., `--radius-default`, `--radius-lg`)
- Shadows: `--shadow-{size}` (e.g., `--shadow-default`, `--shadow-md`) - Shadows: `--shadow-{size}` (e.g., `--shadow-default`, `--shadow-md`)
@@ -78,6 +94,7 @@ src/components/ui/Button/
- Use the `cn()` utility from `@/lib/utils` for conditional classes. - Use the `cn()` utility from `@/lib/utils` for conditional classes.
- Never use inline styles except for truly dynamic values. - Never use inline styles except for truly dynamic values.
- Never use CSS modules or styled-components. - Never use CSS modules or styled-components.
- **Token discipline**: Reference semantic or form-control tokens (`text-control-label`, `border-control-border`), never palette tokens (`text-blue-01`, `border-grey-03`) in component code. If the right semantic token doesn't exist, add one to `tokens.css` first.
### Stories ### Stories
- Every component MUST have a Storybook story file. - Every component MUST have a Storybook story file.

55
plans/input.md Normal file
View File

@@ -0,0 +1,55 @@
# Input Component Plan
## Figma Reference
https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=22-3845
## Design Style (from Figma)
- Outlined text field with label overlapping top border (Material-style notch)
- Label: 14px bold blue-01, sits on top of border with white background
- Input: 16px regular grey-01, placeholder at 50% opacity
- Border radius: 4px (rounded-sm)
- Optional left/right icon slots (24px)
- Supportive text below: hint (left) + character count (right), 14px grey-02
## Sizes
| Size | Input height | Text | Maps to |
|------|-------------|------|---------|
| default | 48px (h-12) | text-body (16px) | Figma "Comfortable" |
| compact | 40px (h-10) | text-small (14px) | Figma "Compact" |
## States (best practice, not 1:1 Figma)
| State | Border | Label | Hint |
|-------|--------|-------|------|
| Default | grey-03 (1px) | blue-01 | grey-02 |
| Hover | grey-01 (1px) | blue-01 | grey-02 |
| Focus | blue-01 (2px) | blue-01 | grey-02 |
| Error | red-02 (1px) | red-02 | red-02 (shows error message) |
| Error+Focus | red-02 (2px) | red-02 | red-02 |
| Disabled | grey-03 at 50% opacity | grey-02 | grey-02 at 50% opacity |
## Props
```ts
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label: string
hint?: string
error?: string
size?: 'default' | 'compact'
leftIcon?: React.ReactNode
rightIcon?: React.ReactNode
}
```
- `error` takes precedence over `hint` — when set, replaces hint text and turns red
- `maxLength` (native attribute) triggers character counter display
- `disabled` and `readOnly` use native attributes, styled accordingly
## Accessibility
- `<label>` bound to `<input>` via `useId()` fallback
- `aria-describedby` links hint/error text to the input
- `aria-invalid="true"` when error is set
- Keyboard fully native (no custom handling needed)
## Figma issues mitigated
1. Default border 38% opacity too faint → using grey-03
2. 10 redundant state variants → CSS pseudo-classes
3. Dark mode variants → skipped
4. Chips/file input states → separate components

90
plans/toggle-controls.md Normal file
View File

@@ -0,0 +1,90 @@
# Toggle Controls: Checkbox, Radio, Switch
## Figma References
- Checkbox: `node-id=33-5043`
- Radio: `node-id=33-5188`
- Switch: `node-id=33-5337`
## What I'm Taking from Figma
- **Sizing**: 20px checkbox/radio icon inside a clickable area, 16px body text labels
- **Colours**: blue-01 (`#002664`) for checked state, grey-03 for unchecked border, grey-01 for labels
- **Layout**: icon + label in a horizontal row with 8px gap
- **Disabled**: 60% opacity
## What I'm Changing (Per Your Brief)
### Pressed/Active States (Checkbox & Radio)
The Figma designs use a large circular background highlight on press — feels heavy for a form control. Instead:
- **Hover**: subtle border colour shift to blue-01 (unchecked) or slight darkening (checked)
- **Active/pressed**: brief scale-down (`scale-95`) on the control — tactile without a big splashy highlight
- **Focus-visible**: 2px blue-04 ring offset for keyboard nav (accessibility)
### Switch
Figma has icons inside the thumb (check/X). Removing those per your request. My approach:
- **Track**: 44px wide x 24px tall, rounded-full. Off = grey-03, On = blue-01
- **Thumb**: 18px white circle, smooth slide transition (150ms)
- **Hover**: track lightens/darkens slightly
- **Focus-visible**: ring around the track
- Clean, minimal look that matches the Input component's rounded-[4px] + blue-01 brand
### Indeterminate (Checkbox Only)
Keeping the indeterminate state (dash icon) — useful for "select all" patterns. Same blue-01 fill as checked.
## Props
### Checkbox
```ts
interface CheckboxProps {
label?: string
description?: string // secondary text below label
checked?: boolean
indeterminate?: boolean
disabled?: boolean
error?: string
onChange?: (checked: boolean) => void
}
```
### Radio
```ts
interface RadioProps {
label?: string
description?: string
value: string
disabled?: boolean
}
interface RadioGroupProps {
label?: string // group legend
description?: string
value?: string
defaultValue?: string
error?: string
disabled?: boolean
orientation?: 'vertical' | 'horizontal'
onChange?: (value: string) => void
children: React.ReactNode // Radio items
}
```
### Switch
```ts
interface SwitchProps {
label?: string
description?: string
checked?: boolean
disabled?: boolean
onChange?: (checked: boolean) => void
}
```
## Accessibility
- Checkbox: hidden native `<input type="checkbox">` with visual overlay, `aria-checked`, supports indeterminate
- Radio: native `<input type="radio">` within a `<fieldset>`/`<legend>` group, arrow key navigation
- Switch: `role="switch"` with `aria-checked`, toggled via Space/Enter
- All: focus-visible ring, disabled state, proper label association
## Stories (per component)
- Default, Checked, WithDescription, Disabled, DisabledChecked, WithError, AllStates
- Radio adds: RadioGroup, Horizontal, WithError
- Switch adds: Default, On, WithDescription, Disabled, AllStates

View File

@@ -6,45 +6,27 @@ We have three tools — Figma (design), code (React/Tailwind/TypeScript), and St
--- ---
## Two Entry Points, One Loop ## Workflow: Code-First with Figma Reference
Components can start from either direction — the key is that both converge on the same verification and linking steps. Components are built in code and verified in Storybook. Figma serves as a **visual reference** — Richie may provide Figma links or screenshots to guide design intent, but we don't create or maintain Figma representations of each component as part of the build process. Figma design can be added later if needed.
### Entry A: Design-First (Figma → React) ### Build Steps
Richie has designed a component in Figma. We implement it in code. 1. **Reference** — Richie provides design intent: a Figma link, screenshot, description, or existing component to match. Use `get_design_context` / `get_screenshot` to inspect Figma references when provided.
2. **Check tokens** — Cross-check the reference against `tokens.css`. Flag any gaps (missing colours, spacing, etc.) before building.
1. **Inspect**`get_design_context` on the Figma node returns reference code, a screenshot, and metadata.
2. **Extract tokens**`get_variable_defs` pulls colours, spacing, typography values. Cross-check against `tokens.css` to confirm tokens exist or flag gaps.
3. **Check existing components** — Storybook MCP `get-documentation` lists what we've already built. Reuse before rebuilding. 3. **Check existing components** — Storybook MCP `get-documentation` lists what we've already built. Reuse before rebuilding.
4. **Build** — React component with TypeScript props, Tailwind utilities via `cn()`, tokens from `tokens.css`. 4. **Build** — React component with TypeScript props, Tailwind utilities via `cn()`, tokens from `tokens.css`.
5. **Story** — Write stories using Storybook MCP `get-storybook-story-instructions` to match current conventions. Cover default, variants, edge cases. 5. **Story** — Write stories covering default, variants, and edge cases. Use `tags: ['autodocs']`.
6. **Test** — Storybook MCP `run-story-tests` for rendering + a11y. Fix violations before moving on. 6. **Test** — Storybook MCP `run-story-tests` for rendering + a11y. Fix violations before moving on.
7. **Visual verify**`get_screenshot` from Figma vs Storybook preview. Compare side-by-side. 7. **Visual verify**Playwright screenshots of Storybook stories to confirm rendering.
8. **Link**`add_code_connect_map` to link the Figma component to the React component. 8. **Embed**If a Figma reference exists, add `addon-designs` parameter to the story for side-by-side comparison.
9. **Embed** — Add `addon-designs` parameter to the story pointing back to the Figma source.
### Entry B: Code-First (React → Figma) ### The Finished State
Claude builds a component from a description or reference. We push it to Figma for Richie to review. Every completed component has:
1. **Build** — React component in code, using design tokens from `tokens.css`.
2. **Story** — Write stories, verify in Storybook (same as steps 5-6 above).
3. **Push to Figma**`use_figma` to create the component/frame/variants. The claude2figma skills enforce token binding here — no hardcoded values.
4. **Verify**`get_screenshot` to confirm it landed correctly.
5. **Link**`add_code_connect_map` to bridge it back.
6. **Embed** — Add `addon-designs` parameter to the story.
We used this flow when creating the typography specimen frame.
### The Shared End State
Regardless of entry point, every finished component has:
- A React implementation with typed props and Tailwind styling - A React implementation with typed props and Tailwind styling
- Storybook stories with autodocs, variants, and a11y coverage - Storybook stories with autodocs, variants, and a11y coverage
- A Figma representation using bound tokens/variables - An `addon-designs` embed in its story if a Figma reference exists
- A Code Connect link bridging the two
- An `addon-designs` embed in its story for side-by-side comparison
--- ---
@@ -79,22 +61,7 @@ Both addons are installed and registered in `.storybook/main.ts`.
--- ---
## Code Connect — The Bridge Layer ## Skills and Tooling
Code Connect is the most important piece we haven't set up yet. It links Figma components to their React implementations, so:
- `get_design_context` returns our actual component code instead of generic markup
- Designers see real implementation details in Figma's Dev Mode
- The mapping grows as we build — each new component strengthens the bridge
**Setup:**
1. As we build each component, call `add_code_connect_map` with label "React"
2. For existing components, use `get_code_connect_suggestions` to get AI-suggested mappings
3. Periodically run `get_code_connect_map` to audit coverage
---
## Skills and Tooling to Add
### Adopt now ### Adopt now
@@ -127,24 +94,22 @@ Code Connect is the most important piece we haven't set up yet. It links Figma c
Regardless of entry point, every component should pass through these gates: Regardless of entry point, every component should pass through these gates:
``` ```
[ ] Design reference exists (Figma frame OR code-first build) [ ] Design reference provided (Figma link, screenshot, or description)
[ ] Tokens checked (get_variable_defs vs tokens.css — flag gaps) [ ] Tokens checked (reference vs tokens.css — flag gaps)
[ ] Existing components checked (Storybook MCP list-all-documentation) [ ] Existing components checked (Storybook MCP list-all-documentation)
[ ] React component built (TypeScript, Tailwind, cn(), forwardRef) [ ] React component built (TypeScript, Tailwind, cn(), forwardRef)
[ ] Stories written (default + variants + edge cases + autodocs) [ ] Stories written (default + variants + edge cases + autodocs)
[ ] Tests pass (Storybook MCP run-story-tests, including a11y) [ ] Tests pass (Storybook MCP run-story-tests, including a11y)
[ ] Visual verified (Figma screenshot vs Storybook side-by-side) [ ] Visual verified (Playwright screenshots of Storybook)
[ ] Figma representation exists (designed by Richie OR pushed via use_figma) [ ] Figma embed in story (addon-designs parameter, if reference exists)
[ ] Code Connect linked (add_code_connect_map, label: React)
[ ] Figma embed in story (addon-designs parameter)
[ ] /simplify run
``` ```
--- ---
## Decisions Made ## Decisions Made
- **claude2figma skills** — Installed (4 skills in `.claude/skills/`) - **Code-first workflow** — Build in React + Storybook, use Figma as visual reference only (not as a build target)
- **claude2figma skills** — Installed but deferred; will use when/if pushing designs to Figma later
- **Storybook addons** — `addon-designs` + `addon-mcp` installed and registered - **Storybook addons** — `addon-designs` + `addon-mcp` installed and registered
- **Code Connect** — Link as we build, regardless of entry point - **Code Connect** — Deferred (requires Org/Enterprise Figma plan)
- **Build order** — Start with primitives, work up to composites then layouts - **Build order** — Start with primitives, work up to composites then layouts

View File

@@ -0,0 +1,120 @@
import { useState } from 'react'
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Checkbox } from './Checkbox'
const meta: Meta<typeof Checkbox> = {
title: 'UI/Checkbox',
component: Checkbox,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
description: { control: 'text' },
error: { control: 'text' },
disabled: { control: 'boolean' },
indeterminate: { control: 'boolean' },
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=33-5043',
},
},
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
label: 'Accept terms and conditions',
},
}
export const Checked: Story = {
args: {
label: 'Accept terms and conditions',
defaultChecked: true,
},
}
export const WithDescription: Story = {
args: {
label: 'Email notifications',
description: 'Receive updates about your research projects via email.',
},
}
export const WithError: Story = {
args: {
label: 'I agree to the privacy policy',
error: 'You must agree before continuing',
},
}
export const Disabled: Story = {
args: {
label: 'Unavailable option',
disabled: true,
},
}
export const DisabledChecked: Story = {
args: {
label: 'Locked setting',
disabled: true,
defaultChecked: true,
},
}
const IndeterminateExample = () => {
const [items, setItems] = useState([true, false, true])
const allChecked = items.every(Boolean)
const someChecked = items.some(Boolean) && !allChecked
return (
<div className="flex flex-col gap-3">
<Checkbox
label="Select all"
checked={allChecked}
indeterminate={someChecked}
onChange={() => setItems(allChecked ? [false, false, false] : [true, true, true])}
/>
<div className="ml-6 flex flex-col gap-2">
{['Survey responses', 'Interview transcripts', 'Field notes'].map((name, i) => (
<Checkbox
key={name}
label={name}
checked={items[i]}
onChange={() => {
const next = [...items]
next[i] = !next[i]
setItems(next)
}}
/>
))}
</div>
</div>
)
}
export const Indeterminate: Story = {
render: () => <IndeterminateExample />,
}
export const AllStates: Story = {
render: () => (
<div className="flex flex-col gap-4">
<Checkbox label="Unchecked" />
<Checkbox label="Checked" defaultChecked />
<Checkbox label="With description" description="Additional context for this option." />
<Checkbox
label="Checked with description"
description="This option is currently enabled."
defaultChecked
/>
<Checkbox label="Error" error="This field is required" />
<Checkbox label="Disabled" disabled />
<Checkbox label="Disabled checked" disabled defaultChecked />
</div>
),
}

View File

@@ -0,0 +1,143 @@
import { forwardRef, useId, useRef, useEffect, type InputHTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
export interface CheckboxProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'size'> {
label?: string
description?: string
error?: string
indeterminate?: boolean
}
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
(
{
label,
description,
error,
indeterminate = false,
disabled,
className,
id: idProp,
...props
},
forwardedRef,
) => {
const autoId = useId()
const id = idProp ?? autoId
const descriptionId = `${id}-description`
const errorId = `${id}-error`
const internalRef = useRef<HTMLInputElement>(null)
const hasError = !!error
useEffect(() => {
const el = internalRef.current
if (el) el.indeterminate = indeterminate
}, [indeterminate])
const describedBy =
[description ? descriptionId : undefined, hasError ? errorId : undefined]
.filter(Boolean)
.join(' ') || undefined
return (
<div className={cn('flex gap-2', className)}>
<div className="flex h-6 items-center">
<input
ref={(node) => {
(internalRef as React.MutableRefObject<HTMLInputElement | null>).current = node
if (typeof forwardedRef === 'function') forwardedRef(node)
else if (forwardedRef) forwardedRef.current = node
}}
type="checkbox"
id={id}
disabled={disabled}
aria-invalid={hasError || undefined}
aria-describedby={describedBy}
className={cn(
'peer size-5 cursor-pointer appearance-none rounded-[3px] border-2 border-control-border bg-control-bg transition-colors',
'hover:border-control-border-hover',
'checked:border-control-checked checked:bg-control-checked',
'checked:hover:border-control-checked-hover checked:hover:bg-control-checked-hover',
'indeterminate:border-control-checked indeterminate:bg-control-checked',
'indeterminate:hover:border-control-checked-hover indeterminate:hover:bg-control-checked-hover',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-control-focus-ring focus-visible:ring-offset-1',
'active:scale-95',
'disabled:pointer-events-none disabled:opacity-50',
hasError && 'border-control-error checked:border-control-error checked:bg-control-error',
)}
{...props}
/>
<svg
className="pointer-events-none absolute size-5 text-white opacity-0 peer-checked:opacity-100 peer-indeterminate:hidden"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polyline points="4.5 10.5 8 14 15.5 6.5" />
</svg>
<svg
className="pointer-events-none absolute size-5 text-white opacity-0 peer-indeterminate:opacity-100"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
aria-hidden="true"
>
<line x1="5" y1="10" x2="15" y2="10" />
</svg>
</div>
{(label || description || hasError) && (
<div className="flex flex-col gap-0.5 pt-px">
{label && (
<label
htmlFor={id}
className={cn(
'cursor-pointer text-body font-normal text-grey-01',
disabled && 'pointer-events-none opacity-50',
)}
>
{label}
</label>
)}
{description && (
<p
id={descriptionId}
className={cn('text-small text-text', disabled && 'opacity-50')}
>
{description}
</p>
)}
{hasError && (
<div id={errorId} className="flex items-center gap-1 text-small text-control-error">
<svg
className="size-4 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<p>{error}</p>
</div>
)}
</div>
)}
</div>
)
},
)
Checkbox.displayName = 'Checkbox'

View File

@@ -0,0 +1,2 @@
export { Checkbox } from './Checkbox'
export type { CheckboxProps } from './Checkbox'

View File

@@ -0,0 +1,324 @@
import { useState } from 'react'
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Input } from './Input'
const meta: Meta<typeof Input> = {
title: 'UI/Input',
component: Input,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
description: { control: 'text' },
hint: { control: 'text' },
error: { control: 'text' },
variant: {
control: 'select',
options: ['outlined', 'stacked'],
},
size: {
control: 'select',
options: ['default', 'compact'],
},
disabled: { control: 'boolean' },
readOnly: { control: 'boolean' },
placeholder: { control: 'text' },
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=22-3845',
},
},
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
label: 'Label',
placeholder: 'Placeholder',
},
}
export const WithHint: Story = {
args: {
label: 'Email',
placeholder: 'you@example.com',
hint: 'We will never share your email',
},
}
export const WithValue: Story = {
args: {
label: 'Full name',
defaultValue: 'Jane Smith',
},
}
export const WithError: Story = {
args: {
label: 'Email',
defaultValue: 'not-an-email',
error: 'Please enter a valid email address',
},
}
const SearchIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
)
const MailIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<rect width="20" height="16" x="2" y="4" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg>
)
const ChevronDownIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
)
export const WithLeftIcon: Story = {
args: {
label: 'Search',
placeholder: 'Search...',
leftIcon: <SearchIcon />,
},
}
export const WithRightIcon: Story = {
args: {
label: 'Category',
placeholder: 'Select...',
rightIcon: <ChevronDownIcon />,
},
}
export const WithBothIcons: Story = {
args: {
label: 'Email',
placeholder: 'you@example.com',
leftIcon: <MailIcon />,
rightIcon: <ChevronDownIcon />,
},
}
export const Compact: Story = {
args: {
label: 'Label',
placeholder: 'Placeholder',
size: 'compact',
},
}
export const Disabled: Story = {
args: {
label: 'Label',
placeholder: 'Placeholder',
disabled: true,
},
}
export const DisabledWithValue: Story = {
args: {
label: 'Full name',
defaultValue: 'Jane Smith',
disabled: true,
},
}
export const ReadOnly: Story = {
args: {
label: 'Account ID',
defaultValue: 'ACC-2024-001',
readOnly: true,
},
}
const CharacterCountExample = () => {
const [value, setValue] = useState('')
return (
<Input
label="Bio"
placeholder="Tell us about yourself"
hint="Keep it brief"
maxLength={250}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
)
}
export const WithCharacterCount: Story = {
render: () => <CharacterCountExample />,
}
export const AllStates: Story = {
render: () => (
<div className="flex max-w-sm flex-col gap-6">
<Input label="Default" placeholder="Placeholder" />
<Input label="With hint" placeholder="Placeholder" hint="Helpful hint text" />
<Input label="With value" defaultValue="Some value" />
<Input label="Error" defaultValue="Bad value" error="This field is required" />
<Input label="Disabled" placeholder="Placeholder" disabled />
<Input label="Disabled with value" defaultValue="Jane Smith" disabled />
<Input label="Read only" defaultValue="ACC-2024-001" readOnly />
</div>
),
}
export const AllSizes: Story = {
render: () => (
<div className="flex max-w-sm flex-col gap-6">
<Input label="Default size" placeholder="48px height" size="default" />
<Input label="Compact size" placeholder="40px height" size="compact" />
</div>
),
}
export const WithIcons: Story = {
render: () => (
<div className="flex max-w-sm flex-col gap-6">
<Input label="Search" placeholder="Search..." leftIcon={<SearchIcon />} />
<Input
label="Email"
placeholder="you@example.com"
leftIcon={<MailIcon />}
rightIcon={<ChevronDownIcon />}
/>
<Input
label="Search (compact)"
placeholder="Search..."
leftIcon={<SearchIcon />}
size="compact"
/>
</div>
),
}
// --- Stacked variant ---
export const Stacked: Story = {
args: {
label: 'Full name',
placeholder: 'Enter your full name',
variant: 'stacked',
},
}
export const StackedWithDescription: Story = {
args: {
label: 'Project title',
description: 'Choose a clear, descriptive name for your research project.',
placeholder: 'e.g. Student Wellbeing Survey 2026',
variant: 'stacked',
},
}
export const StackedWithHint: Story = {
args: {
label: 'Email address',
description: 'Your department email is preferred.',
placeholder: 'you@education.nsw.gov.au',
hint: 'We will use this for project notifications',
variant: 'stacked',
},
}
export const StackedWithError: Story = {
args: {
label: 'Email address',
description: 'Your department email is preferred.',
defaultValue: 'not-valid',
error: 'Please enter a valid email address',
variant: 'stacked',
},
}
export const StackedWithIcons: Story = {
render: () => (
<div className="flex max-w-sm flex-col gap-6">
<Input
variant="stacked"
label="Search participants"
description="Find by name or ID."
placeholder="Search..."
leftIcon={<SearchIcon />}
/>
<Input
variant="stacked"
label="Contact email"
placeholder="you@example.com"
leftIcon={<MailIcon />}
/>
</div>
),
}
export const StackedAllStates: Story = {
render: () => (
<div className="flex max-w-sm flex-col gap-6">
<Input variant="stacked" label="Default" placeholder="Placeholder" />
<Input
variant="stacked"
label="With description"
description="A short description of the field."
placeholder="Placeholder"
/>
<Input
variant="stacked"
label="With hint"
description="Description text here."
placeholder="Placeholder"
hint="Helpful hint text"
/>
<Input variant="stacked" label="With value" defaultValue="Some value" />
<Input
variant="stacked"
label="Error"
description="Description text here."
defaultValue="Bad value"
error="This field is required"
/>
<Input variant="stacked" label="Disabled" placeholder="Placeholder" disabled />
<Input
variant="stacked"
label="Disabled with description"
description="This field cannot be edited."
defaultValue="Jane Smith"
disabled
/>
<Input variant="stacked" label="Read only" defaultValue="ACC-2024-001" readOnly />
</div>
),
}

View File

@@ -0,0 +1,193 @@
import { forwardRef, useId, type InputHTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label: string
description?: string
hint?: string
error?: string
variant?: 'outlined' | 'stacked'
size?: 'default' | 'compact'
leftIcon?: React.ReactNode
rightIcon?: React.ReactNode
}
const sizeStyles: Record<string, { container: string; input: string; icon: string }> = {
default: {
container: 'h-12 gap-2',
input: 'text-body',
icon: 'size-6',
},
compact: {
container: 'h-10 gap-2',
input: 'text-small',
icon: 'size-5',
},
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
(
{
label,
description,
hint,
error,
variant = 'outlined',
size = 'default',
leftIcon,
rightIcon,
disabled,
readOnly,
maxLength,
value,
defaultValue,
className,
id: idProp,
...props
},
ref,
) => {
const autoId = useId()
const id = idProp ?? autoId
const descriptionId = `${id}-description`
const hintId = `${id}-hint`
const hasError = !!error
const supportiveText = error || hint
const styles = sizeStyles[size]
const isStacked = variant === 'stacked'
const currentLength =
maxLength != null && typeof value === 'string' ? value.length : undefined
const describedBy = [
description && isStacked ? descriptionId : undefined,
supportiveText ? hintId : undefined,
]
.filter(Boolean)
.join(' ') || undefined
return (
<div className={cn('flex w-full flex-col', isStacked ? 'gap-1.5' : 'gap-1 pt-2', className)}>
{isStacked && (
<div className="flex flex-col gap-0.5">
<label
htmlFor={id}
className={cn(
'text-small font-bold',
hasError ? 'text-control-error' : 'text-control-label',
disabled && 'text-control-description',
)}
>
{label}
</label>
{description && (
<p
id={descriptionId}
className={cn('text-small text-grey-01', disabled && 'opacity-50')}
>
{description}
</p>
)}
</div>
)}
<div
className={cn(
'relative flex items-center rounded-[4px] border bg-control-bg px-3 transition-colors',
styles.container,
hasError
? 'border-control-error focus-within:border-2 focus-within:border-control-error focus-within:px-[11px]'
: 'border-control-border hover:border-control-border-hover focus-within:border-2 focus-within:border-control-checked focus-within:px-[11px]',
disabled && 'pointer-events-none border-control-border/50 bg-control-bg/50',
readOnly && 'border-transparent bg-control-bg-readonly',
)}
>
{!isStacked && (
<label
htmlFor={id}
className={cn(
'absolute left-2 top-0 z-10 -translate-y-1/2 bg-control-bg px-1 text-small font-bold leading-none',
hasError ? 'text-control-error' : 'text-control-label',
disabled && 'text-control-description',
)}
>
{label}
</label>
)}
{leftIcon && (
<span
className={cn('inline-flex shrink-0 items-center justify-center text-grey-01 [&>svg]:size-full', styles.icon)}
>
{leftIcon}
</span>
)}
<input
ref={ref}
id={id}
disabled={disabled}
readOnly={readOnly}
maxLength={maxLength}
value={value}
defaultValue={defaultValue}
aria-invalid={hasError || undefined}
aria-describedby={describedBy}
className={cn(
'min-w-0 flex-1 bg-transparent font-normal text-grey-01 outline-none',
'placeholder:text-grey-01/50',
styles.input,
disabled && 'text-grey-01/50',
)}
{...props}
/>
{rightIcon && (
<span
className={cn('inline-flex shrink-0 items-center justify-center text-grey-01 [&>svg]:size-full', styles.icon)}
>
{rightIcon}
</span>
)}
</div>
{(supportiveText || currentLength != null) && (
<div
id={hintId}
className={cn(
'flex items-center gap-1 text-small',
hasError ? 'text-control-error' : 'text-control-description',
disabled && 'opacity-50',
)}
>
{hasError && (
<svg
className="size-4 shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
)}
{supportiveText && <p className="flex-1">{supportiveText}</p>}
{currentLength != null && (
<p className="shrink-0 text-right">
{currentLength}/{maxLength}
</p>
)}
</div>
)}
</div>
)
},
)
Input.displayName = 'Input'

View File

@@ -0,0 +1,2 @@
export { Input } from './Input'
export type { InputProps } from './Input'

View File

@@ -0,0 +1,138 @@
import { useState } from 'react'
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Radio, RadioGroup } from './Radio'
const meta: Meta<typeof RadioGroup> = {
title: 'UI/Radio',
component: RadioGroup,
tags: ['autodocs'],
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=33-5188',
},
},
}
export default meta
type Story = StoryObj<typeof meta>
const ControlledExample = () => {
const [value, setValue] = useState('email')
return (
<RadioGroup label="Notification method" value={value} onChange={setValue}>
<Radio value="email" label="Email" />
<Radio value="sms" label="SMS" />
<Radio value="push" label="Push notification" />
</RadioGroup>
)
}
export const Default: Story = {
render: () => <ControlledExample />,
}
export const WithDescriptions: Story = {
render: () => {
const [value, setValue] = useState('standard')
return (
<RadioGroup
label="Export format"
description="Choose how your data will be exported."
value={value}
onChange={setValue}
>
<Radio
value="standard"
label="Standard CSV"
description="Comma-separated values, compatible with most tools."
/>
<Radio
value="excel"
label="Excel workbook"
description="Formatted spreadsheet with multiple sheets."
/>
<Radio
value="json"
label="JSON"
description="Machine-readable format for programmatic access."
/>
</RadioGroup>
)
},
}
export const Horizontal: Story = {
render: () => {
const [value, setValue] = useState('all')
return (
<RadioGroup
label="Filter by status"
orientation="horizontal"
value={value}
onChange={setValue}
>
<Radio value="all" label="All" />
<Radio value="active" label="Active" />
<Radio value="archived" label="Archived" />
</RadioGroup>
)
},
}
export const WithError: Story = {
render: () => (
<RadioGroup label="Participant type" error="Please select a participant type">
<Radio value="student" label="Student" />
<Radio value="teacher" label="Teacher" />
<Radio value="parent" label="Parent/carer" />
</RadioGroup>
),
}
export const Disabled: Story = {
render: () => (
<RadioGroup label="Plan" disabled defaultValue="free">
<Radio value="free" label="Free" />
<Radio value="pro" label="Professional" />
<Radio value="enterprise" label="Enterprise" />
</RadioGroup>
),
}
export const AllStates: Story = {
render: () => (
<div className="flex flex-col gap-8">
<RadioGroup label="Default group" defaultValue="a">
<Radio value="a" label="Option A" />
<Radio value="b" label="Option B" />
<Radio value="c" label="Option C" />
</RadioGroup>
<RadioGroup
label="With descriptions"
description="Pick one of the following."
defaultValue="x"
>
<Radio value="x" label="Option X" description="Description for option X." />
<Radio value="y" label="Option Y" description="Description for option Y." />
</RadioGroup>
<RadioGroup label="With error" error="Selection required">
<Radio value="1" label="Choice 1" />
<Radio value="2" label="Choice 2" />
</RadioGroup>
<RadioGroup label="Disabled" disabled defaultValue="on">
<Radio value="on" label="Enabled" />
<Radio value="off" label="Disabled" />
</RadioGroup>
<RadioGroup label="Horizontal" orientation="horizontal" defaultValue="left">
<Radio value="left" label="Left" />
<Radio value="center" label="Centre" />
<Radio value="right" label="Right" />
</RadioGroup>
</div>
),
}

View File

@@ -0,0 +1,205 @@
import {
createContext,
forwardRef,
useContext,
useId,
type InputHTMLAttributes,
} from 'react'
import { cn } from '@/lib/utils'
interface RadioGroupContextValue {
name: string
value?: string
disabled?: boolean
hasError?: boolean
onChange?: (value: string) => void
}
const RadioGroupContext = createContext<RadioGroupContextValue | null>(null)
export interface RadioGroupProps {
label?: string
description?: string
error?: string
value?: string
defaultValue?: string
disabled?: boolean
orientation?: 'vertical' | 'horizontal'
name?: string
onChange?: (value: string) => void
children: React.ReactNode
className?: string
}
export const RadioGroup = forwardRef<HTMLFieldSetElement, RadioGroupProps>(
(
{
label,
description,
error,
value,
defaultValue,
disabled,
orientation = 'vertical',
name: nameProp,
onChange,
children,
className,
},
ref,
) => {
const autoId = useId()
const name = nameProp ?? autoId
const descriptionId = `${name}-description`
const errorId = `${name}-error`
const hasError = !!error
return (
<RadioGroupContext.Provider value={{ name, value: value ?? defaultValue, disabled, hasError, onChange }}>
<fieldset
ref={ref}
disabled={disabled}
className={cn('flex flex-col gap-1.5', className)}
aria-describedby={
[description ? descriptionId : undefined, hasError ? errorId : undefined]
.filter(Boolean)
.join(' ') || undefined
}
>
{(label || description) && (
<div className="mb-1 flex flex-col gap-0.5">
{label && (
<legend
className={cn(
'text-small font-bold',
hasError ? 'text-control-error' : 'text-control-label',
disabled && 'opacity-50',
)}
>
{label}
</legend>
)}
{description && (
<p
id={descriptionId}
className={cn('text-small text-text', disabled && 'opacity-50')}
>
{description}
</p>
)}
</div>
)}
<div
className={cn(
'flex gap-3',
orientation === 'vertical' ? 'flex-col' : 'flex-row flex-wrap',
)}
role="radiogroup"
>
{children}
</div>
{hasError && (
<div id={errorId} className="flex items-center gap-1 text-small text-control-error">
<svg
className="size-4 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<p>{error}</p>
</div>
)}
</fieldset>
</RadioGroupContext.Provider>
)
},
)
RadioGroup.displayName = 'RadioGroup'
export interface RadioProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'size'> {
label?: string
description?: string
value: string
}
export const Radio = forwardRef<HTMLInputElement, RadioProps>(
({ label, description, value, disabled: disabledProp, className, id: idProp, ...props }, ref) => {
const autoId = useId()
const id = idProp ?? autoId
const descriptionId = `${id}-description`
const group = useContext(RadioGroupContext)
const name = group?.name
const isChecked = group?.value != null ? group.value === value : undefined
const disabled = disabledProp ?? group?.disabled
const hasError = group?.hasError
const handleChange = () => {
group?.onChange?.(value)
}
return (
<div className={cn('flex gap-2', className)}>
<div className="flex h-6 items-center">
<input
ref={ref}
type="radio"
id={id}
name={name}
value={value}
checked={isChecked}
disabled={disabled}
onChange={handleChange}
aria-describedby={description ? descriptionId : undefined}
className={cn(
'peer size-5 cursor-pointer appearance-none rounded-full border-2 border-control-border bg-control-bg transition-colors',
'hover:border-control-border-hover',
'checked:border-[6px] checked:border-control-checked',
'checked:hover:border-control-checked-hover',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-control-focus-ring focus-visible:ring-offset-1',
'active:scale-95',
'disabled:pointer-events-none disabled:opacity-50',
hasError && 'border-control-error checked:border-control-error',
)}
{...props}
/>
</div>
{(label || description) && (
<div className="flex flex-col gap-0.5 pt-px">
{label && (
<label
htmlFor={id}
className={cn(
'cursor-pointer text-body font-normal text-grey-01',
disabled && 'pointer-events-none opacity-50',
)}
>
{label}
</label>
)}
{description && (
<p
id={descriptionId}
className={cn('text-small text-text', disabled && 'opacity-50')}
>
{description}
</p>
)}
</div>
)}
</div>
)
},
)
Radio.displayName = 'Radio'

View File

@@ -0,0 +1,2 @@
export { Radio, RadioGroup } from './Radio'
export type { RadioProps, RadioGroupProps } from './Radio'

View File

@@ -0,0 +1,85 @@
import { useState } from 'react'
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Switch } from './Switch'
const meta: Meta<typeof Switch> = {
title: 'UI/Switch',
component: Switch,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
description: { control: 'text' },
checked: { control: 'boolean' },
disabled: { control: 'boolean' },
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=33-5337',
},
},
}
export default meta
type Story = StoryObj<typeof meta>
const ControlledSwitch = (props: React.ComponentProps<typeof Switch>) => {
const [checked, setChecked] = useState(props.checked ?? false)
return <Switch {...props} checked={checked} onChange={setChecked} />
}
export const Default: Story = {
render: () => <ControlledSwitch label="Enable notifications" />,
}
export const On: Story = {
render: () => <ControlledSwitch label="Enable notifications" checked />,
}
export const WithDescription: Story = {
render: () => (
<ControlledSwitch
label="Auto-save responses"
description="Automatically save participant responses as they are entered."
checked
/>
),
}
export const Disabled: Story = {
render: () => (
<div className="flex flex-col gap-4">
<Switch label="Disabled off" disabled />
<Switch label="Disabled on" disabled checked />
</div>
),
}
export const Standalone: Story = {
render: () => (
<div className="flex items-center gap-4">
<span className="text-body text-grey-01">Dark mode</span>
<ControlledSwitch aria-label="Toggle dark mode" />
</div>
),
}
export const AllStates: Story = {
render: () => (
<div className="flex flex-col gap-4">
<ControlledSwitch label="Off" />
<ControlledSwitch label="On" checked />
<ControlledSwitch
label="With description"
description="Additional context about this setting."
/>
<ControlledSwitch
label="On with description"
description="This feature is currently enabled."
checked
/>
<Switch label="Disabled off" disabled />
<Switch label="Disabled on" disabled checked />
</div>
),
}

View File

@@ -0,0 +1,89 @@
import { forwardRef, useId, type ButtonHTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
export interface SwitchProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onChange' | 'role'> {
label?: string
description?: string
checked?: boolean
disabled?: boolean
onChange?: (checked: boolean) => void
}
export const Switch = forwardRef<HTMLButtonElement, SwitchProps>(
(
{
label,
description,
checked = false,
disabled,
onChange,
className,
id: idProp,
...props
},
ref,
) => {
const autoId = useId()
const id = idProp ?? autoId
const descriptionId = `${id}-description`
return (
<div className={cn('flex items-start gap-3', className)}>
<button
ref={ref}
id={id}
type="button"
role="switch"
aria-checked={checked}
aria-describedby={description ? descriptionId : undefined}
disabled={disabled}
onClick={() => onChange?.(!checked)}
className={cn(
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors duration-150',
checked ? 'bg-control-checked' : 'bg-control-border',
!disabled && checked && 'hover:bg-control-checked-hover',
!disabled && !checked && 'hover:bg-control-border-hover',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-control-focus-ring focus-visible:ring-offset-2',
'active:scale-[0.97]',
'disabled:pointer-events-none disabled:opacity-50',
)}
{...props}
>
<span
className={cn(
'pointer-events-none inline-block size-[18px] rounded-full bg-white shadow-default transition-transform duration-150',
checked ? 'translate-x-[22px]' : 'translate-x-[3px]',
)}
/>
</button>
{(label || description) && (
<div className="flex flex-col gap-0.5 pt-px">
{label && (
<label
htmlFor={id}
className={cn(
'cursor-pointer text-body font-normal text-grey-01',
disabled && 'pointer-events-none opacity-50',
)}
>
{label}
</label>
)}
{description && (
<p
id={descriptionId}
className={cn('text-small text-text', disabled && 'opacity-50')}
>
{description}
</p>
)}
</div>
)}
</div>
)
},
)
Switch.displayName = 'Switch'

View File

@@ -0,0 +1,2 @@
export { Switch } from './Switch'
export type { SwitchProps } from './Switch'

View File

@@ -70,6 +70,18 @@
--color-bg: var(--color-off-white); --color-bg: var(--color-off-white);
--color-surface: var(--color-white); --color-surface: var(--color-white);
/* Form Controls */
--color-control-border: var(--color-grey-03);
--color-control-border-hover: var(--color-grey-01);
--color-control-checked: var(--color-blue-01);
--color-control-checked-hover: var(--color-blue-02);
--color-control-focus-ring: var(--color-blue-04);
--color-control-label: var(--color-blue-01);
--color-control-description: var(--color-grey-02);
--color-control-error: var(--color-red-02);
--color-control-bg: var(--color-white);
--color-control-bg-readonly: var(--color-off-white);
/* Radius */ /* Radius */
--radius-sm: 4px; --radius-sm: 4px;
--radius-default: 6px; --radius-default: 6px;