@smicolon/fasla-ui 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +91 -23
- package/dist/registry/index.js +31 -2
- package/dist/tokens/index.d.ts +75 -6
- package/dist/tokens/index.js +59 -20
- package/package.json +4 -1
- package/registry/blocks/app-shell/AppShell.tsx +1 -1
- package/registry/blocks/data-table/DataTable.test.tsx +61 -0
- package/registry/blocks/data-table/DataTable.tsx +53 -16
- package/registry/blocks/data-table/index.ts +1 -0
- package/registry/blocks/empty-state/EmptyState.test.tsx +12 -0
- package/registry/blocks/empty-state/EmptyState.tsx +22 -31
- package/registry/blocks/form-section/FormSection.tsx +1 -1
- package/registry/blocks/navbar/Navbar.test.tsx +12 -0
- package/registry/blocks/navbar/Navbar.tsx +8 -23
- package/registry/blocks/page-header/PageHeader.tsx +5 -2
- package/registry/blocks/sidebar/Sidebar.tsx +9 -16
- package/registry/blocks/stats-card/StatsCard.tsx +9 -27
- package/registry/effects/animated-gradient/animated-gradient.tsx +23 -8
- package/registry/effects/border-beam/border-beam.tsx +77 -53
- package/registry/effects/effects.test.tsx +266 -0
- package/registry/effects/glow-card/glow-card.tsx +68 -43
- package/registry/effects/shimmer-button/shimmer-button.tsx +62 -23
- package/registry/effects/spotlight/spotlight.tsx +73 -29
- package/registry/effects/text-reveal/index.ts +1 -0
- package/registry/effects/text-reveal/text-reveal.test.tsx +66 -0
- package/registry/effects/text-reveal/text-reveal.tsx +68 -21
- package/registry/effects/typewriter-text/typewriter-text.tsx +39 -20
- package/registry/text-props-undefined.test.tsx +73 -0
- package/registry/ui/avatar/avatar.test.tsx +316 -0
- package/registry/ui/avatar/avatar.tsx +235 -108
- package/registry/ui/avatar/index.ts +1 -1
- package/registry/ui/badge/badge.test.tsx +267 -32
- package/registry/ui/badge/badge.tsx +233 -21
- package/registry/ui/button/button.test.tsx +6 -0
- package/registry/ui/button/button.tsx +6 -23
- package/registry/ui/card/card.tsx +1 -1
- package/registry/ui/checkbox/checkbox.tsx +13 -26
- package/registry/ui/combobox/combobox.test.tsx +43 -0
- package/registry/ui/combobox/combobox.tsx +27 -55
- package/registry/ui/input/input.test.tsx +2 -2
- package/registry/ui/input/input.tsx +4 -4
- package/registry/ui/radio/index.ts +1 -0
- package/registry/ui/radio/radio.test.tsx +248 -0
- package/registry/ui/radio/radio.tsx +208 -0
- package/registry/ui/select/select.tsx +4 -14
- package/registry/ui/skeleton/skeleton.tsx +1 -1
- package/registry/ui/status-indicator/index.ts +6 -0
- package/registry/ui/status-indicator/status-indicator.test.tsx +148 -0
- package/registry/ui/status-indicator/status-indicator.tsx +87 -0
- package/registry/ui/switch/index.ts +1 -1
- package/registry/ui/switch/switch.test.tsx +323 -0
- package/registry/ui/switch/switch.tsx +254 -57
- package/registry/ui/textarea/textarea.tsx +1 -1
- package/registry.json +78 -15
- package/tailwind-preset.ts +143 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest"
|
|
2
|
+
import { render, screen, fireEvent } from "@testing-library/react"
|
|
3
|
+
import { Switch } from "./switch"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What these tests can and cannot prove.
|
|
7
|
+
*
|
|
8
|
+
* jsdom has no layout engine and no style resolution: it matches neither
|
|
9
|
+
* `:checked` inside `:has()` nor `:focus-visible`, applies no stylesheet, and
|
|
10
|
+
* every `getBoundingClientRect` is zero. So the visual tests below assert
|
|
11
|
+
* **class presence only** — that the component emits the right utility on the
|
|
12
|
+
* right element. Track and thumb geometry, the thumb's travel in both
|
|
13
|
+
* directions, the focus ring and the tokens in Light and Dark were measured in
|
|
14
|
+
* a real browser.
|
|
15
|
+
* Treat a green run here as "the contract is wired", not "it looks right".
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** label ▸ input ▸ [band ▸ track ▸ (thumb, focusRing), text] */
|
|
19
|
+
const parts = (input: HTMLElement) => {
|
|
20
|
+
const root = input.closest("label") as HTMLElement
|
|
21
|
+
const track = root.querySelector("[data-slot=track]") as HTMLElement
|
|
22
|
+
return {
|
|
23
|
+
root,
|
|
24
|
+
band: track.parentElement as HTMLElement,
|
|
25
|
+
track,
|
|
26
|
+
thumb: root.querySelector("[data-slot=thumb]") as HTMLElement,
|
|
27
|
+
focusRing: root.querySelector("[data-slot=focus-ring]") as HTMLElement,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const control = () => screen.getByRole("switch")
|
|
32
|
+
|
|
33
|
+
describe("Switch", () => {
|
|
34
|
+
it("renders a native checkbox exposed as a switch", () => {
|
|
35
|
+
render(<Switch aria-label="Wi-Fi" />)
|
|
36
|
+
expect(control()).toHaveAttribute("type", "checkbox")
|
|
37
|
+
expect(control()).toHaveAccessibleName("Wi-Fi")
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("is off by default and reflects defaultChecked", () => {
|
|
41
|
+
const { rerender } = render(<Switch aria-label="Wi-Fi" />)
|
|
42
|
+
expect(control()).not.toBeChecked()
|
|
43
|
+
|
|
44
|
+
rerender(<Switch aria-label="Wi-Fi" defaultChecked />)
|
|
45
|
+
expect(control()).toBeChecked()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("toggles uncontrolled from defaultChecked", () => {
|
|
49
|
+
render(<Switch label="Wi-Fi" defaultChecked />)
|
|
50
|
+
fireEvent.click(screen.getByText("Wi-Fi"))
|
|
51
|
+
expect(control()).not.toBeChecked()
|
|
52
|
+
fireEvent.click(screen.getByText("Wi-Fi"))
|
|
53
|
+
expect(control()).toBeChecked()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("follows a controlled checked prop", () => {
|
|
57
|
+
const handleChange = vi.fn()
|
|
58
|
+
const { rerender } = render(
|
|
59
|
+
<Switch label="Wi-Fi" checked={false} onChange={handleChange} />
|
|
60
|
+
)
|
|
61
|
+
fireEvent.click(control())
|
|
62
|
+
expect(handleChange).toHaveBeenCalledTimes(1)
|
|
63
|
+
expect(control()).not.toBeChecked()
|
|
64
|
+
|
|
65
|
+
rerender(<Switch label="Wi-Fi" checked onChange={handleChange} />)
|
|
66
|
+
expect(control()).toBeChecked()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("drives the thumb from the input itself, not from React props", () => {
|
|
70
|
+
// The thumb must move for `defaultChecked` and for a form reset alike, so
|
|
71
|
+
// its position comes from CSS reading the real input — never from a
|
|
72
|
+
// `data-state` derived from props.
|
|
73
|
+
render(<Switch label="Wi-Fi" defaultChecked />)
|
|
74
|
+
const { thumb } = parts(control())
|
|
75
|
+
expect(thumb).not.toHaveAttribute("data-state")
|
|
76
|
+
expect(thumb.className).toMatch(/group-has-\[:checked\]\/switch:start-\[/)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("sizes the track and thumb to the Figma set", () => {
|
|
80
|
+
const cases = [
|
|
81
|
+
["sm", ["h-[18px]", "w-8"], "size-4", "start-[15px]"],
|
|
82
|
+
["md", ["h-5", "w-10"], "size-[18px]", "start-[21px]"],
|
|
83
|
+
["lg", ["h-6", "w-12"], "size-[22px]", "start-[25px]"],
|
|
84
|
+
] as const
|
|
85
|
+
for (const [size, track, thumb, on] of cases) {
|
|
86
|
+
const { unmount } = render(<Switch size={size} aria-label="Wi-Fi" />)
|
|
87
|
+
const p = parts(control())
|
|
88
|
+
expect(p.track).toHaveClass(...track)
|
|
89
|
+
expect(p.thumb).toHaveClass(thumb, "start-px", "top-px")
|
|
90
|
+
expect(p.thumb).toHaveClass(`group-has-[:checked]/switch:${on}`)
|
|
91
|
+
// The focus ring lives inside the track and covers it exactly.
|
|
92
|
+
expect(p.focusRing.parentElement).toBe(p.track)
|
|
93
|
+
expect(p.focusRing).toHaveClass("absolute", "inset-0")
|
|
94
|
+
unmount()
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("defaults to md", () => {
|
|
99
|
+
render(<Switch aria-label="Wi-Fi" />)
|
|
100
|
+
expect(parts(control()).track).toHaveClass("h-5", "w-10")
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("positions the thumb with logical properties only", () => {
|
|
104
|
+
// No physical offsets and no `rtl:` overrides: `inset-inline-start` makes
|
|
105
|
+
// the thumb travel towards the end of the line in either direction.
|
|
106
|
+
for (const size of ["sm", "md", "lg"] as const) {
|
|
107
|
+
const { unmount } = render(<Switch size={size} aria-label="Wi-Fi" />)
|
|
108
|
+
const html = parts(control()).root.outerHTML
|
|
109
|
+
expect(html).not.toMatch(/\brtl:/)
|
|
110
|
+
expect(html).not.toMatch(/translate-x/)
|
|
111
|
+
expect(html).not.toMatch(/\b(left|right)-/)
|
|
112
|
+
unmount()
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it("paints the solid variant with Figma's tokens", () => {
|
|
117
|
+
render(<Switch aria-label="Wi-Fi" />)
|
|
118
|
+
const { track, thumb, focusRing } = parts(control())
|
|
119
|
+
expect(track).toHaveClass(
|
|
120
|
+
"bg-input",
|
|
121
|
+
"group-has-[:checked]/switch:bg-primary",
|
|
122
|
+
"shadow-sm"
|
|
123
|
+
)
|
|
124
|
+
// Disabled keeps this thumb too: only the root's opacity changes.
|
|
125
|
+
expect(thumb).toHaveClass("bg-primary-foreground")
|
|
126
|
+
expect(thumb.className).not.toMatch(/:disabled/)
|
|
127
|
+
// Focus: a 1px ring stroke inside the track.
|
|
128
|
+
expect(focusRing).toHaveClass("border", "border-ring")
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("paints the outline variant with Figma's tokens", () => {
|
|
132
|
+
render(<Switch variant="outline" aria-label="Wi-Fi" />)
|
|
133
|
+
const { track, thumb, focusRing } = parts(control())
|
|
134
|
+
// A Figma OUTSIDE stroke is a CSS outline: painted, but not in the box.
|
|
135
|
+
expect(track).toHaveClass(
|
|
136
|
+
"bg-background",
|
|
137
|
+
"outline",
|
|
138
|
+
"outline-1",
|
|
139
|
+
"outline-input",
|
|
140
|
+
"group-has-[:checked]/switch:outline-primary"
|
|
141
|
+
)
|
|
142
|
+
expect(track).not.toHaveClass("border", "shadow-sm")
|
|
143
|
+
expect(thumb).toHaveClass("bg-input", "group-has-[:checked]/switch:bg-primary")
|
|
144
|
+
// Focus replaces the stroke with `ring`, except an on switch keeps primary.
|
|
145
|
+
expect(focusRing).toHaveClass(
|
|
146
|
+
"outline-ring",
|
|
147
|
+
"group-has-[:checked]/switch:outline-primary"
|
|
148
|
+
)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it("draws focus as a ring-coloured halo, relayed from the input by its sibling band", () => {
|
|
152
|
+
// The band is the input's direct sibling, so a plain peer-focus-visible
|
|
153
|
+
// (the form the Storybook pseudo-states addon can force) sets a variable
|
|
154
|
+
// on it; the ring inside the track reads that variable as its opacity.
|
|
155
|
+
for (const variant of ["solid", "outline"] as const) {
|
|
156
|
+
const { unmount } = render(<Switch variant={variant} aria-label="Wi-Fi" />)
|
|
157
|
+
const { band, focusRing } = parts(control())
|
|
158
|
+
expect(control().nextElementSibling).toBe(band)
|
|
159
|
+
expect(band).toHaveClass("peer-focus-visible:[--sw-focus:1]")
|
|
160
|
+
expect(focusRing).toHaveClass(
|
|
161
|
+
"ring-[3px]",
|
|
162
|
+
"ring-ring/50",
|
|
163
|
+
"opacity-[var(--sw-focus,0)]",
|
|
164
|
+
"rounded-full"
|
|
165
|
+
)
|
|
166
|
+
unmount()
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it("keeps the focus ring on the track when the root is padded", () => {
|
|
171
|
+
// Padding passed through className used to push the track away from a ring
|
|
172
|
+
// positioned on the root. Inside the track, it moves with the track.
|
|
173
|
+
render(<Switch layout="label-first" label="Wi-Fi" className="p-4" />)
|
|
174
|
+
const { track, focusRing } = parts(control())
|
|
175
|
+
expect(track.contains(focusRing)).toBe(true)
|
|
176
|
+
expect(focusRing.className).not.toMatch(/\b(start|end|top)-/)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it("drives focus without a has-[] utility", () => {
|
|
180
|
+
// `:has()` is invisible to the pseudo-state renderer the Storybook states
|
|
181
|
+
// grid uses. Checked and disabled are real attributes, so `:has()` is fine
|
|
182
|
+
// for them; focus must stay `peer`-driven.
|
|
183
|
+
render(<Switch label="Wi-Fi" />)
|
|
184
|
+
const html = parts(control()).root.outerHTML
|
|
185
|
+
expect(html).not.toMatch(/has-\[:focus/)
|
|
186
|
+
expect(html).not.toMatch(/focus-within/)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it("puts the track first by default, and last for label-first", () => {
|
|
190
|
+
const { rerender } = render(<Switch label="Wi-Fi" description="Detail" />)
|
|
191
|
+
let { root, band } = parts(control())
|
|
192
|
+
// input, band, text
|
|
193
|
+
expect(root.children[1]).toBe(band)
|
|
194
|
+
expect(root).toHaveClass("inline-flex")
|
|
195
|
+
|
|
196
|
+
rerender(<Switch layout="label-first" label="Wi-Fi" description="Detail" />)
|
|
197
|
+
;({ root, band } = parts(control()))
|
|
198
|
+
expect(root.children[2]).toBe(band)
|
|
199
|
+
expect(root).toHaveClass("flex", "justify-between")
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it("sets the label and description in Figma's text styles", () => {
|
|
203
|
+
// The ramp owns the leading (20px English, 24px Arabic), so neither line
|
|
204
|
+
// may carry a leading or a fixed height of its own.
|
|
205
|
+
render(<Switch label="Wi-Fi" description="Detail" />)
|
|
206
|
+
const label = screen.getByText("Wi-Fi")
|
|
207
|
+
const description = screen.getByText("Detail")
|
|
208
|
+
expect(label).toHaveClass("text-sm", "font-medium", "text-foreground")
|
|
209
|
+
expect(description).toHaveClass("text-xs", "font-light", "text-muted-foreground")
|
|
210
|
+
for (const el of [label, description]) {
|
|
211
|
+
expect(el.className).not.toMatch(/\bleading-/)
|
|
212
|
+
expect(el.className).not.toMatch(/\bh-\d/)
|
|
213
|
+
}
|
|
214
|
+
// Figma's 2px label-to-description gap.
|
|
215
|
+
expect(label.parentElement).toHaveClass("gap-0.5")
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it("dims the whole component when disabled", () => {
|
|
219
|
+
render(<Switch label="Wi-Fi" disabled />)
|
|
220
|
+
expect(control()).toBeDisabled()
|
|
221
|
+
expect(control().closest("label")).toHaveClass(
|
|
222
|
+
"has-[:disabled]:opacity-50",
|
|
223
|
+
"has-[:disabled]:pointer-events-none"
|
|
224
|
+
)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it("associates the label so clicking it toggles the switch", () => {
|
|
228
|
+
render(<Switch label="Airplane mode" />)
|
|
229
|
+
fireEvent.click(screen.getByText("Airplane mode"))
|
|
230
|
+
expect(control()).toBeChecked()
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it("links the description via aria-describedby", () => {
|
|
234
|
+
render(<Switch label="Notifications" description="Receive push notifications" />)
|
|
235
|
+
expect(control()).toHaveAccessibleDescription("Receive push notifications")
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it("names the switch with the label alone, not the description", () => {
|
|
239
|
+
// The description sits inside the wrapping <label>; without
|
|
240
|
+
// aria-labelledby it would be read as part of the name and again as the
|
|
241
|
+
// description.
|
|
242
|
+
render(<Switch label="Notifications" description="Receive push notifications" />)
|
|
243
|
+
expect(control()).toHaveAccessibleName("Notifications")
|
|
244
|
+
expect(control()).toHaveAccessibleDescription("Receive push notifications")
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it("keeps a caller's aria-label as the name", () => {
|
|
248
|
+
render(<Switch label="Wi-Fi" aria-label="Wireless networking" />)
|
|
249
|
+
expect(control()).toHaveAccessibleName("Wireless networking")
|
|
250
|
+
expect(control()).not.toHaveAttribute("aria-labelledby")
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it("does not read a description twice when it is the only text", () => {
|
|
254
|
+
render(<Switch description="Receive push notifications" />)
|
|
255
|
+
expect(control()).not.toHaveAttribute("aria-describedby")
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it("joins aria-describedby ids without merging them as classes", () => {
|
|
259
|
+
// `cn` would drop "text-sm" as a conflicting Tailwind class.
|
|
260
|
+
render(
|
|
261
|
+
<>
|
|
262
|
+
<span id="text-sm">Small print</span>
|
|
263
|
+
<span id="text-lg">Large print</span>
|
|
264
|
+
<Switch label="Wi-Fi" description="Detail" aria-describedby="text-sm text-lg" />
|
|
265
|
+
</>
|
|
266
|
+
)
|
|
267
|
+
expect(control().getAttribute("aria-describedby")).toMatch(/-description text-sm text-lg$/)
|
|
268
|
+
expect(control()).toHaveAccessibleDescription("Detail Small print Large print")
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it("keeps a caller's aria-describedby alongside the description", () => {
|
|
272
|
+
render(
|
|
273
|
+
<>
|
|
274
|
+
<span id="hint">Hint</span>
|
|
275
|
+
<Switch label="Wi-Fi" description="Detail" aria-describedby="hint" />
|
|
276
|
+
</>
|
|
277
|
+
)
|
|
278
|
+
expect(control()).toHaveAccessibleDescription("Detail Hint")
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it("keeps the track and focus ring out of the accessibility tree", () => {
|
|
282
|
+
render(<Switch label="Wi-Fi" />)
|
|
283
|
+
const { band, focusRing } = parts(control())
|
|
284
|
+
expect(band).toHaveAttribute("aria-hidden", "true")
|
|
285
|
+
expect(band.contains(focusRing)).toBe(true)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it("renders no text column when label and description are omitted", () => {
|
|
289
|
+
render(<Switch aria-label="Wi-Fi" />)
|
|
290
|
+
// input and band — no text column.
|
|
291
|
+
expect(control().parentElement?.childElementCount).toBe(2)
|
|
292
|
+
expect(screen.queryByText(/./)).toBeNull()
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it("keeps a stable id across renders and honours a given one", () => {
|
|
296
|
+
const { rerender } = render(<Switch label="Wi-Fi" />)
|
|
297
|
+
const first = control().id
|
|
298
|
+
rerender(<Switch label="Wi-Fi" />)
|
|
299
|
+
expect(control().id).toBe(first)
|
|
300
|
+
|
|
301
|
+
rerender(<Switch label="Wi-Fi" id="wifi" />)
|
|
302
|
+
expect(control().id).toBe("wifi")
|
|
303
|
+
expect(screen.getByText("Wi-Fi").closest("label")).toHaveAttribute("for", "wifi")
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it("applies custom className to the root", () => {
|
|
307
|
+
render(<Switch label="Wi-Fi" className="custom-class" />)
|
|
308
|
+
expect(control().closest("label")).toHaveClass("custom-class")
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it("forwards ref to the input", () => {
|
|
312
|
+
const ref = { current: null as HTMLInputElement | null }
|
|
313
|
+
render(<Switch aria-label="Wi-Fi" ref={ref} />)
|
|
314
|
+
expect(ref.current).toBe(control())
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it("rejects null as a variant", () => {
|
|
318
|
+
// @ts-expect-error `variant` is declared on SwitchProps rather than
|
|
319
|
+
// inherited from CVA's VariantProps, which would widen it with `| null`.
|
|
320
|
+
render(<Switch aria-label="Wi-Fi" variant={null} />)
|
|
321
|
+
expect(control()).toBeInTheDocument()
|
|
322
|
+
})
|
|
323
|
+
})
|
|
@@ -1,89 +1,286 @@
|
|
|
1
1
|
import * as React from "react"
|
|
2
|
+
import { cva } from "class-variance-authority"
|
|
2
3
|
import { cn } from "../../../src/lib/utils"
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Root layout. Figma's two Types:
|
|
7
|
+
* - `control-first` (the default): track, then label. The description sits
|
|
8
|
+
* under the label, so it is indented by the track width plus the 8px gap,
|
|
9
|
+
* exactly as Figma's 40/48/56px indent.
|
|
10
|
+
* - `label-first`: label at the start, track pushed to the end — a settings
|
|
11
|
+
* row. It fills its container; the description stays under the label.
|
|
12
|
+
*
|
|
13
|
+
* `group/switch` is a *named* group so the `:has(:checked)` lookups below can
|
|
14
|
+
* never be answered by an unrelated checkbox in some outer `.group`.
|
|
15
|
+
*
|
|
16
|
+
* Disabled dims the whole component, label and description included — Figma
|
|
17
|
+
* puts 50% opacity on the variant root, not on the track.
|
|
18
|
+
*/
|
|
19
|
+
const switchVariants = cva(
|
|
20
|
+
"group/switch cursor-pointer items-start gap-2 has-[:disabled]:pointer-events-none has-[:disabled]:opacity-50",
|
|
21
|
+
{
|
|
22
|
+
variants: {
|
|
23
|
+
layout: {
|
|
24
|
+
"control-first": "inline-flex",
|
|
25
|
+
"label-first": "flex justify-between",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
defaultVariants: {
|
|
29
|
+
layout: "control-first",
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
)
|
|
13
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Geometry per size, straight from the Figma set: track, thumb, and where the
|
|
36
|
+
* thumb sits when on. The thumb is always inset 1px, so "on" is the track width
|
|
37
|
+
* less the thumb less 1px.
|
|
38
|
+
*
|
|
39
|
+
* `--sw-h` is the track height. With `--sw-line` (one line of label text, see
|
|
40
|
+
* `LINE` below) it sizes the band the track is centred in, and the label's
|
|
41
|
+
* padding. Every class is written out literally so Tailwind can find it.
|
|
42
|
+
*/
|
|
14
43
|
const sizeClasses = {
|
|
15
44
|
sm: {
|
|
16
|
-
|
|
17
|
-
|
|
45
|
+
root: "[--sw-h:18px]",
|
|
46
|
+
track: "h-[18px] w-8",
|
|
47
|
+
thumb: "size-4 group-has-[:checked]/switch:start-[15px]",
|
|
18
48
|
},
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
49
|
+
md: {
|
|
50
|
+
root: "[--sw-h:20px]",
|
|
51
|
+
track: "h-5 w-10",
|
|
52
|
+
thumb: "size-[18px] group-has-[:checked]/switch:start-[21px]",
|
|
22
53
|
},
|
|
23
54
|
lg: {
|
|
24
|
-
|
|
25
|
-
|
|
55
|
+
root: "[--sw-h:24px]",
|
|
56
|
+
track: "h-6 w-12",
|
|
57
|
+
thumb: "size-[22px] group-has-[:checked]/switch:start-[25px]",
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Colours per Figma Style. All `☾ Mode` tokens, so Dark follows on its own.
|
|
63
|
+
*
|
|
64
|
+
* - solid: track `input` → `primary`, thumb `primary-foreground`, and Figma's
|
|
65
|
+
* `shadow/default/xs` on the *track* (Tailwind's `shadow-sm` is the same
|
|
66
|
+
* 0 1 2 0 black/5%). Disabled keeps the same thumb; only the opacity changes.
|
|
67
|
+
* - outline: track `background` with a 1px stroke *outside* it, `input` →
|
|
68
|
+
* `primary`; the thumb takes the stroke colour. A CSS outline is the exact
|
|
69
|
+
* model of a Figma OUTSIDE stroke: painted, but not part of the box, so the
|
|
70
|
+
* track keeps Figma's size and the thumb its 1px inset.
|
|
71
|
+
*
|
|
72
|
+
* `focus` is the focus-ring element (see below). Figma draws focus as a 1px
|
|
73
|
+
* stroke in `ring` — inside the track for solid, replacing the outside stroke
|
|
74
|
+
* for outline, where an *on* outline keeps its `primary` stroke — plus a 3px
|
|
75
|
+
* halo. Figma's halo colour is a raw #a1a1aa at 50%; this uses the `ring`
|
|
76
|
+
* token at 50%, which is the same grey in Light and follows the mode in Dark.
|
|
77
|
+
*/
|
|
78
|
+
const variantClasses = {
|
|
79
|
+
solid: {
|
|
80
|
+
track: "bg-input shadow-sm group-has-[:checked]/switch:bg-primary",
|
|
81
|
+
thumb: "bg-primary-foreground",
|
|
82
|
+
focus: "border border-ring",
|
|
83
|
+
},
|
|
84
|
+
outline: {
|
|
85
|
+
track:
|
|
86
|
+
"bg-background outline outline-1 outline-input group-has-[:checked]/switch:outline-primary",
|
|
87
|
+
thumb: "bg-input group-has-[:checked]/switch:bg-primary",
|
|
88
|
+
focus: "outline outline-1 outline-ring group-has-[:checked]/switch:outline-primary",
|
|
26
89
|
},
|
|
27
90
|
}
|
|
28
91
|
|
|
92
|
+
/**
|
|
93
|
+
* One line of `text-sm` label, in whichever script is rendering — 20px for
|
|
94
|
+
* Geist, 24px for Cairo. The ramp re-points `--leading-sm` under `[dir="rtl"]`,
|
|
95
|
+
* so nothing here is direction-specific. The fallback is English's 20 / 14,
|
|
96
|
+
* for a project without the Fasla preset. Same derivation as Radio's LINE_BOX.
|
|
97
|
+
*/
|
|
98
|
+
const LINE = "[--sw-line:calc(var(--leading-sm,calc(20/14))*0.875rem)]"
|
|
99
|
+
|
|
100
|
+
/** The taller of one label line and the track. */
|
|
101
|
+
const BAND_HEIGHT = "h-[max(var(--sw-line),var(--sw-h))]"
|
|
102
|
+
|
|
103
|
+
export interface SwitchProps
|
|
104
|
+
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "size"> {
|
|
105
|
+
/**
|
|
106
|
+
* Visual treatment: a filled track, or an outlined one. Declared here rather
|
|
107
|
+
* than inherited from `VariantProps`, which widens every variant with
|
|
108
|
+
* `| null` and shows a meaningless third option in Storybook.
|
|
109
|
+
*/
|
|
110
|
+
variant?: "solid" | "outline"
|
|
111
|
+
/** Track before the label, or label first with the track at the end. */
|
|
112
|
+
layout?: "control-first" | "label-first"
|
|
113
|
+
/** Size variant */
|
|
114
|
+
size?: "sm" | "md" | "lg"
|
|
115
|
+
/** Label text. Without it (or `description`) pass `aria-label` for an accessible name. */
|
|
116
|
+
label?: string
|
|
117
|
+
/** Secondary line below the label */
|
|
118
|
+
description?: string
|
|
119
|
+
}
|
|
120
|
+
|
|
29
121
|
const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
|
|
30
|
-
(
|
|
31
|
-
|
|
32
|
-
|
|
122
|
+
(
|
|
123
|
+
{
|
|
124
|
+
className,
|
|
125
|
+
variant = "solid",
|
|
126
|
+
layout = "control-first",
|
|
127
|
+
size = "md",
|
|
128
|
+
label,
|
|
129
|
+
description,
|
|
130
|
+
id,
|
|
131
|
+
"aria-label": ariaLabel,
|
|
132
|
+
"aria-labelledby": ariaLabelledBy,
|
|
133
|
+
"aria-describedby": ariaDescribedBy,
|
|
134
|
+
...props
|
|
135
|
+
},
|
|
136
|
+
ref
|
|
137
|
+
) => {
|
|
138
|
+
const generatedId = React.useId()
|
|
139
|
+
const inputId = id || generatedId
|
|
140
|
+
const labelId = `${inputId}-label`
|
|
141
|
+
const descriptionId = `${inputId}-description`
|
|
142
|
+
/*
|
|
143
|
+
* The wrapping <label> would name the switch with *all* its text, label
|
|
144
|
+
* and description together, while `aria-describedby` reads the description
|
|
145
|
+
* again. So the name points at the label alone, unless the caller named
|
|
146
|
+
* the switch themselves; and the description is linked only when the name
|
|
147
|
+
* comes from somewhere else, or it would be the name and the description.
|
|
148
|
+
*/
|
|
149
|
+
const labelledBy = ariaLabelledBy ?? (label && !ariaLabel ? labelId : undefined)
|
|
150
|
+
const isNamedElsewhere = Boolean(label || ariaLabel || ariaLabelledBy)
|
|
151
|
+
// Plain join: these are ids, and `cn` would merge "text-sm text-lg" as classes.
|
|
152
|
+
const describedBy =
|
|
153
|
+
[description && isNamedElsewhere ? descriptionId : undefined, ariaDescribedBy]
|
|
154
|
+
.filter(Boolean)
|
|
155
|
+
.join(" ") || undefined
|
|
156
|
+
// Fall back to the defaults for an untyped caller passing null or a typo.
|
|
157
|
+
const sizes = sizeClasses[size] ?? sizeClasses.md
|
|
158
|
+
const colors = variantClasses[variant] ?? variantClasses.solid
|
|
159
|
+
const hasText = Boolean(label || description)
|
|
160
|
+
|
|
161
|
+
/*
|
|
162
|
+
* The track sits in a band one label line tall — or the track's height, if
|
|
163
|
+
* that is taller — and centres itself in it. So it aligns with the *first*
|
|
164
|
+
* line of a wrapped label instead of drifting to the middle of the block.
|
|
165
|
+
*
|
|
166
|
+
* The band is a *direct sibling of the input*, so a plain
|
|
167
|
+
* `peer-focus-visible` sets `--sw-focus` on it, and the focus ring inside
|
|
168
|
+
* the track reads that variable as its opacity. That is the one selector
|
|
169
|
+
* form `storybook-addon-pseudo-states` can force — it cannot force
|
|
170
|
+
* `:has(:focus-visible)` — and keeping the ring inside the track means it
|
|
171
|
+
* follows the track wherever padding on the root moves it.
|
|
172
|
+
*/
|
|
173
|
+
const band = (
|
|
174
|
+
<span
|
|
175
|
+
aria-hidden="true"
|
|
176
|
+
className={cn(
|
|
177
|
+
"flex shrink-0 items-center peer-focus-visible:[--sw-focus:1]",
|
|
178
|
+
BAND_HEIGHT
|
|
179
|
+
)}
|
|
180
|
+
>
|
|
181
|
+
<span
|
|
182
|
+
data-slot="track"
|
|
183
|
+
className={cn(
|
|
184
|
+
"relative block rounded-full transition-colors motion-reduce:transition-none",
|
|
185
|
+
sizes.track,
|
|
186
|
+
colors.track
|
|
187
|
+
)}
|
|
188
|
+
>
|
|
189
|
+
{/*
|
|
190
|
+
* Positioned with `inset-inline-start`, a logical property, so the
|
|
191
|
+
* thumb travels towards the end of the line in either direction with
|
|
192
|
+
* no RTL-specific class: `start-px` is left in LTR and right in RTL.
|
|
193
|
+
*/}
|
|
194
|
+
<span
|
|
195
|
+
data-slot="thumb"
|
|
196
|
+
className={cn(
|
|
197
|
+
"absolute start-px top-px block rounded-full transition-[inset-inline-start,background-color] motion-reduce:transition-none",
|
|
198
|
+
sizes.thumb,
|
|
199
|
+
colors.thumb
|
|
200
|
+
)}
|
|
201
|
+
/>
|
|
202
|
+
{/*
|
|
203
|
+
* Laid exactly over the track. Figma draws focus as a 1px `ring`
|
|
204
|
+
* stroke (inside for solid, replacing the outside stroke for
|
|
205
|
+
* outline) plus a 3px halo; painted after the thumb, it never
|
|
206
|
+
* overlaps it, because the thumb is inset by that same 1px.
|
|
207
|
+
*/}
|
|
208
|
+
<span
|
|
209
|
+
data-slot="focus-ring"
|
|
210
|
+
className={cn(
|
|
211
|
+
"pointer-events-none absolute inset-0 rounded-full opacity-[var(--sw-focus,0)] ring-[3px] ring-ring/50",
|
|
212
|
+
colors.focus
|
|
213
|
+
)}
|
|
214
|
+
/>
|
|
215
|
+
</span>
|
|
216
|
+
</span>
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
const text = hasText && (
|
|
220
|
+
<span
|
|
221
|
+
className={cn(
|
|
222
|
+
"flex min-w-0 flex-col gap-0.5",
|
|
223
|
+
layout === "label-first" && "flex-1"
|
|
224
|
+
)}
|
|
225
|
+
>
|
|
226
|
+
{label && (
|
|
227
|
+
// Padded so a single line centres on a track taller than the line
|
|
228
|
+
// (lg in English: 24px track, 20px line). The padding is zero
|
|
229
|
+
// whenever the line is the taller of the two.
|
|
230
|
+
<span
|
|
231
|
+
id={labelId}
|
|
232
|
+
className="py-[max(0px,calc((var(--sw-h)-var(--sw-line))/2))] text-sm font-medium text-foreground"
|
|
233
|
+
>
|
|
234
|
+
{label}
|
|
235
|
+
</span>
|
|
236
|
+
)}
|
|
237
|
+
{description && (
|
|
238
|
+
<span
|
|
239
|
+
id={descriptionId}
|
|
240
|
+
className="text-xs font-light text-muted-foreground"
|
|
241
|
+
>
|
|
242
|
+
{description}
|
|
243
|
+
</span>
|
|
244
|
+
)}
|
|
245
|
+
</span>
|
|
246
|
+
)
|
|
33
247
|
|
|
34
|
-
|
|
248
|
+
return (
|
|
35
249
|
<label
|
|
36
250
|
htmlFor={inputId}
|
|
37
251
|
className={cn(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"bg-input has-[:checked]:bg-primary",
|
|
42
|
-
sizes.track,
|
|
252
|
+
switchVariants({ layout }),
|
|
253
|
+
LINE,
|
|
254
|
+
sizes.root,
|
|
43
255
|
className
|
|
44
256
|
)}
|
|
45
257
|
>
|
|
46
258
|
<input
|
|
47
259
|
type="checkbox"
|
|
260
|
+
role="switch"
|
|
48
261
|
ref={ref}
|
|
49
262
|
id={inputId}
|
|
50
|
-
className="sr-only"
|
|
263
|
+
className="peer sr-only"
|
|
264
|
+
aria-label={ariaLabel}
|
|
265
|
+
aria-labelledby={labelledBy}
|
|
266
|
+
aria-describedby={describedBy}
|
|
51
267
|
{...props}
|
|
52
268
|
/>
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
269
|
+
{layout === "label-first" ? (
|
|
270
|
+
<>
|
|
271
|
+
{text}
|
|
272
|
+
{band}
|
|
273
|
+
</>
|
|
274
|
+
) : (
|
|
275
|
+
<>
|
|
276
|
+
{band}
|
|
277
|
+
{text}
|
|
278
|
+
</>
|
|
279
|
+
)}
|
|
60
280
|
</label>
|
|
61
281
|
)
|
|
62
|
-
|
|
63
|
-
if (!label && !description) {
|
|
64
|
-
return switchElement
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return (
|
|
68
|
-
<div className="flex items-center justify-between gap-4">
|
|
69
|
-
<div className="grid gap-1">
|
|
70
|
-
{label && (
|
|
71
|
-
<label
|
|
72
|
-
htmlFor={inputId}
|
|
73
|
-
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
74
|
-
>
|
|
75
|
-
{label}
|
|
76
|
-
</label>
|
|
77
|
-
)}
|
|
78
|
-
{description && (
|
|
79
|
-
<p className="text-xs text-muted-foreground">{description}</p>
|
|
80
|
-
)}
|
|
81
|
-
</div>
|
|
82
|
-
{switchElement}
|
|
83
|
-
</div>
|
|
84
|
-
)
|
|
85
282
|
}
|
|
86
283
|
)
|
|
87
284
|
Switch.displayName = "Switch"
|
|
88
285
|
|
|
89
|
-
export { Switch }
|
|
286
|
+
export { Switch, switchVariants }
|
|
@@ -61,7 +61,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
|
61
61
|
return (
|
|
62
62
|
<div className="relative">
|
|
63
63
|
{textarea}
|
|
64
|
-
<div className="absolute bottom-2
|
|
64
|
+
<div className="absolute bottom-2 end-3 text-xs text-muted-foreground">
|
|
65
65
|
{maxLength ? `${count}/${maxLength}` : count}
|
|
66
66
|
</div>
|
|
67
67
|
</div>
|