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:
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