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:
@@ -26,8 +26,17 @@ Components (use Tailwind utilities or var() references)
|
||||
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
|
||||
- **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)
|
||||
- **Shadows**: `--shadow-*` (default, md)
|
||||
|
||||
@@ -40,7 +49,8 @@ Declaring `--color-primary: #2563eb` inside `@theme` in `tokens.css` automatical
|
||||
|
||||
### `src/components/ui/` — Primitives
|
||||
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
|
||||
- Dialog, Tooltip, Popover
|
||||
|
||||
@@ -55,6 +65,17 @@ Page-level structural components.
|
||||
- AppShell (header + sidebar + content area)
|
||||
- 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
|
||||
@@ -62,6 +83,7 @@ Page-level structural components.
|
||||
- **Primary**: Tailwind utility classes
|
||||
- **Conditional classes**: `cn()` from `@/lib/utils` (clsx + tailwind-merge)
|
||||
- **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)
|
||||
- **Class ordering**: Enforced by `prettier-plugin-tailwindcss`
|
||||
|
||||
|
||||
19
CLAUDE.md
19
CLAUDE.md
@@ -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.
|
||||
|
||||
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`)
|
||||
- Form controls: `--color-control-{role}` (e.g., `--color-control-border`, `--color-control-checked`)
|
||||
- Radii: `--radius-{size}` (e.g., `--radius-default`, `--radius-lg`)
|
||||
- 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.
|
||||
- Never use inline styles except for truly dynamic values.
|
||||
- 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
|
||||
- Every component MUST have a Storybook story file.
|
||||
|
||||
55
plans/input.md
Normal file
55
plans/input.md
Normal 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
90
plans/toggle-controls.md
Normal 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
|
||||
@@ -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. **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.
|
||||
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.
|
||||
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`.
|
||||
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.
|
||||
7. **Visual verify** — `get_screenshot` from Figma vs Storybook preview. Compare side-by-side.
|
||||
8. **Link** — `add_code_connect_map` to link the Figma component to the React component.
|
||||
9. **Embed** — Add `addon-designs` parameter to the story pointing back to the Figma source.
|
||||
7. **Visual verify** — Playwright screenshots of Storybook stories to confirm rendering.
|
||||
8. **Embed** — If a Figma reference exists, add `addon-designs` parameter to the story for side-by-side comparison.
|
||||
|
||||
### 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.
|
||||
|
||||
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:
|
||||
Every completed component has:
|
||||
- A React implementation with typed props and Tailwind styling
|
||||
- Storybook stories with autodocs, variants, and a11y coverage
|
||||
- A Figma representation using bound tokens/variables
|
||||
- A Code Connect link bridging the two
|
||||
- An `addon-designs` embed in its story for side-by-side comparison
|
||||
- An `addon-designs` embed in its story if a Figma reference exists
|
||||
|
||||
---
|
||||
|
||||
@@ -79,22 +61,7 @@ Both addons are installed and registered in `.storybook/main.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Code Connect — The Bridge Layer
|
||||
|
||||
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
|
||||
## Skills and Tooling
|
||||
|
||||
### 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:
|
||||
|
||||
```
|
||||
[ ] Design reference exists (Figma frame OR code-first build)
|
||||
[ ] Tokens checked (get_variable_defs vs tokens.css — flag gaps)
|
||||
[ ] Design reference provided (Figma link, screenshot, or description)
|
||||
[ ] Tokens checked (reference vs tokens.css — flag gaps)
|
||||
[ ] Existing components checked (Storybook MCP list-all-documentation)
|
||||
[ ] React component built (TypeScript, Tailwind, cn(), forwardRef)
|
||||
[ ] Stories written (default + variants + edge cases + autodocs)
|
||||
[ ] Tests pass (Storybook MCP run-story-tests, including a11y)
|
||||
[ ] Visual verified (Figma screenshot vs Storybook side-by-side)
|
||||
[ ] Figma representation exists (designed by Richie OR pushed via use_figma)
|
||||
[ ] Code Connect linked (add_code_connect_map, label: React)
|
||||
[ ] Figma embed in story (addon-designs parameter)
|
||||
[ ] /simplify run
|
||||
[ ] Visual verified (Playwright screenshots of Storybook)
|
||||
[ ] Figma embed in story (addon-designs parameter, if reference exists)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
- **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
|
||||
|
||||
120
src/components/ui/Checkbox/Checkbox.stories.tsx
Normal file
120
src/components/ui/Checkbox/Checkbox.stories.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
143
src/components/ui/Checkbox/Checkbox.tsx
Normal file
143
src/components/ui/Checkbox/Checkbox.tsx
Normal 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'
|
||||
2
src/components/ui/Checkbox/index.ts
Normal file
2
src/components/ui/Checkbox/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Checkbox } from './Checkbox'
|
||||
export type { CheckboxProps } from './Checkbox'
|
||||
324
src/components/ui/Input/Input.stories.tsx
Normal file
324
src/components/ui/Input/Input.stories.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
193
src/components/ui/Input/Input.tsx
Normal file
193
src/components/ui/Input/Input.tsx
Normal 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'
|
||||
2
src/components/ui/Input/index.ts
Normal file
2
src/components/ui/Input/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Input } from './Input'
|
||||
export type { InputProps } from './Input'
|
||||
138
src/components/ui/Radio/Radio.stories.tsx
Normal file
138
src/components/ui/Radio/Radio.stories.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
205
src/components/ui/Radio/Radio.tsx
Normal file
205
src/components/ui/Radio/Radio.tsx
Normal 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'
|
||||
2
src/components/ui/Radio/index.ts
Normal file
2
src/components/ui/Radio/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Radio, RadioGroup } from './Radio'
|
||||
export type { RadioProps, RadioGroupProps } from './Radio'
|
||||
85
src/components/ui/Switch/Switch.stories.tsx
Normal file
85
src/components/ui/Switch/Switch.stories.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
89
src/components/ui/Switch/Switch.tsx
Normal file
89
src/components/ui/Switch/Switch.tsx
Normal 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'
|
||||
2
src/components/ui/Switch/index.ts
Normal file
2
src/components/ui/Switch/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Switch } from './Switch'
|
||||
export type { SwitchProps } from './Switch'
|
||||
@@ -70,6 +70,18 @@
|
||||
--color-bg: var(--color-off-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-sm: 4px;
|
||||
--radius-default: 6px;
|
||||
|
||||
Reference in New Issue
Block a user