1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
import type { InputHTMLAttributes, ReactNode } from 'react'
import { Check, Minus } from 'lucide-react'
import { cn } from '@/lib/cn'
/**
* Checkbox / Radio / Switch
*
* All three keep a real, focusable native input in the DOM and paint the
* visible control with a sibling element. That keeps `aria-checked`, form
* submission, `:checked`, `:disabled` and keyboard behaviour native, while
* still allowing a token-driven appearance.
*
* The native input is positioned over the visual control rather than hidden
* with `display:none`, so the tap target is the full 44px row on touch.
*/
const controlBox = cn(
'pointer-events-none flex shrink-0 items-center justify-center border transition-colors duration-150 ease-standard',
'border-line-strong bg-surface text-accent-ink',
'peer-hover:border-accent',
'peer-checked:border-accent peer-checked:bg-accent',
'peer-disabled:opacity-50',
'peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--color-accent)]',
'peer-aria-[invalid=true]:border-danger',
// The tick/dot is a *descendant* of this box, not a sibling of the input, so
// the peer variant has to reach through with a child selector.
'[&>*]:opacity-0 peer-checked:[&>*]:opacity-100',
)
const nativeInput = cn(
'peer absolute inset-0 size-full cursor-pointer opacity-0 disabled:cursor-not-allowed',
)
export interface ChoiceProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
label: ReactNode
description?: ReactNode
/** `card` turns the whole row into a bordered, selectable surface. */
appearance?: 'inline' | 'card'
}
export function Checkbox({
label,
description,
appearance = 'inline',
className,
id,
indeterminate,
...props
}: ChoiceProps & { indeterminate?: boolean }) {
return (
<label
className={cn(
'group relative flex min-h-9 cursor-pointer items-start gap-2.5 text-sm',
appearance === 'card' &&
'min-h-11 rounded-md border border-line bg-surface p-3 has-[:checked]:border-accent has-[:checked]:bg-accent-soft has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60',
appearance === 'inline' && 'py-1.5',
className,
)}
htmlFor={id}
>
<span className="relative flex size-4.5 shrink-0 items-center justify-center">
<input
type="checkbox"
id={id}
className={nativeInput}
aria-checked={indeterminate ? 'mixed' : undefined}
{...props}
/>
<span className={cn(controlBox, 'size-4.5 rounded-sm')} aria-hidden="true">
{indeterminate ? (
<Minus className="size-3" strokeWidth={3} />
) : (
<Check className="size-3" strokeWidth={3} />
)}
</span>
</span>
<span className="min-w-0 flex-1 select-none">
<span className="block leading-snug font-medium text-ink">{label}</span>
{description ? (
<span className="mt-0.5 block text-xs leading-normal text-ink-muted">{description}</span>
) : null}
</span>
</label>
)
}
export function Radio({
label,
description,
appearance = 'inline',
className,
id,
...props
}: ChoiceProps) {
return (
<label
className={cn(
'group relative flex min-h-9 cursor-pointer items-start gap-2.5 text-sm',
appearance === 'card' &&
'min-h-11 rounded-md border border-line bg-surface p-3 has-[:checked]:border-accent has-[:checked]:bg-accent-soft has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60',
appearance === 'inline' && 'py-1.5',
className,
)}
htmlFor={id}
>
<span className="relative flex size-4.5 shrink-0 items-center justify-center">
<input type="radio" id={id} className={nativeInput} {...props} />
<span className={cn(controlBox, 'size-4.5 rounded-full')} aria-hidden="true">
<span className="size-1.5 rounded-full bg-current" />
</span>
</span>
<span className="min-w-0 flex-1 select-none">
<span className="block leading-snug font-medium text-ink">{label}</span>
{description ? (
<span className="mt-0.5 block text-xs leading-normal text-ink-muted">{description}</span>
) : null}
</span>
</label>
)
}
export interface SwitchProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'type'> {
label: ReactNode
description?: ReactNode
/** Places the switch on the trailing edge — the settings-row convention. */
align?: 'leading' | 'trailing'
}
export function Switch({
label,
description,
align = 'leading',
className,
id,
...props
}: SwitchProps) {
const control = (
<span className="relative inline-flex h-5 w-9 shrink-0 items-center">
<input type="checkbox" role="switch" id={id} className={nativeInput} {...props} />
<span
className={cn(
'pointer-events-none h-5 w-9 rounded-full border border-line-strong bg-surface-sunken transition-colors duration-150 ease-standard',
'peer-checked:border-accent peer-checked:bg-accent',
'peer-disabled:opacity-50',
'peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--color-accent)]',
)}
aria-hidden="true"
/>
<span
className={cn(
'pointer-events-none absolute left-0.5 size-4 rounded-full bg-surface shadow-sm transition-transform duration-150 ease-standard',
'border border-line peer-checked:translate-x-4 peer-checked:border-transparent',
)}
aria-hidden="true"
/>
</span>
)
return (
<label
className={cn(
'flex min-h-9 cursor-pointer items-start gap-3 py-1.5 text-sm has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60',
align === 'trailing' && 'justify-between',
className,
)}
htmlFor={id}
>
{align === 'leading' ? control : null}
<span className="min-w-0 flex-1 select-none">
<span className="block leading-snug font-medium text-ink">{label}</span>
{description ? (
<span className="mt-0.5 block text-xs leading-normal text-ink-muted">{description}</span>
) : null}
</span>
{align === 'trailing' ? control : null}
</label>
)
}