@smicolon/fasla-ui 0.2.4 → 0.4.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 +44 -20
- package/dist/index.cjs +319 -0
- package/dist/index.d.cts +5 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +92 -24
- package/dist/lib/utils.cjs +11 -0
- package/dist/lib/utils.d.cts +9 -0
- package/dist/motion/index.cjs +61 -0
- package/dist/motion/index.d.cts +33 -0
- package/dist/registry/index.cjs +179 -0
- package/dist/registry/index.d.cts +17 -0
- package/dist/registry/index.js +32 -3
- package/dist/tokens/index.cjs +77 -0
- package/dist/tokens/index.d.cts +124 -0
- package/dist/tokens/index.d.ts +75 -6
- package/dist/tokens/index.js +59 -20
- package/package.json +17 -7
- 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 +23 -0
- package/registry/blocks/empty-state/EmptyState.tsx +32 -33
- 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.test.tsx +39 -0
- package/registry/blocks/sidebar/Sidebar.tsx +36 -26
- package/registry/blocks/stats-card/StatsCard.tsx +12 -29
- package/registry/effects/animated-gradient/animated-gradient.tsx +19 -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 +70 -21
- package/registry/effects/typewriter-text/typewriter-text.tsx +39 -20
- package/registry/next14-tsconfig.test.ts +43 -0
- 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.test.tsx +23 -0
- package/registry/ui/checkbox/checkbox.tsx +16 -27
- package/registry/ui/combobox/combobox.test.tsx +72 -0
- package/registry/ui/combobox/combobox.tsx +34 -59
- package/registry/ui/input/input.test.tsx +3 -3
- package/registry/ui/input/input.tsx +5 -5
- 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/index.ts +1 -0
- package/registry/ui/skeleton/skeleton.test.tsx +113 -29
- package/registry/ui/skeleton/skeleton.tsx +101 -41
- 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 +79 -16
- package/tailwind-preset.ts +143 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { readFileSync } from "node:fs"
|
|
3
|
+
import { dirname, resolve } from "node:path"
|
|
4
|
+
import { fileURLToPath } from "node:url"
|
|
5
|
+
import { beforeAll, describe, it, expect } from "vitest"
|
|
6
|
+
import { render, screen } from "@testing-library/react"
|
|
7
|
+
import { StatusIndicator, type StatusIndicatorProps } from "./status-indicator"
|
|
8
|
+
|
|
9
|
+
const SOURCE = readFileSync(
|
|
10
|
+
resolve(dirname(fileURLToPath(import.meta.url)), "status-indicator.tsx"),
|
|
11
|
+
"utf8"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
function dot(props: Partial<StatusIndicatorProps> = {}) {
|
|
15
|
+
render(<StatusIndicator data-testid="dot" {...props} />)
|
|
16
|
+
return screen.getByTestId("dot")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The name a screen reader would hear: the visible (not display: none) runs. */
|
|
20
|
+
function spokenName(el: HTMLElement) {
|
|
21
|
+
return Array.from(el.querySelectorAll<HTMLElement>(".sr-only"))
|
|
22
|
+
.filter((run) => getComputedStyle(run).display !== "none")
|
|
23
|
+
.map((run) => run.textContent)
|
|
24
|
+
.join("")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/*
|
|
28
|
+
* The default name is two hidden runs, and CSS keeps the one matching the
|
|
29
|
+
* nearest `lang`. jsdom loads no Tailwind, so inject exactly the compiled
|
|
30
|
+
* rules the name depends on.
|
|
31
|
+
*/
|
|
32
|
+
beforeAll(() => {
|
|
33
|
+
const style = document.createElement("style")
|
|
34
|
+
style.textContent = [
|
|
35
|
+
".hidden { display: none }",
|
|
36
|
+
".\\[\\&\\:lang\\(ar\\)\\]\\:hidden:lang(ar) { display: none }",
|
|
37
|
+
".\\[\\&\\:lang\\(ar\\)\\]\\:inline:lang(ar) { display: inline }",
|
|
38
|
+
].join("\n")
|
|
39
|
+
document.head.append(style)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe("StatusIndicator", () => {
|
|
43
|
+
it("renders Figma's defaults: Online, 8px, a 2px background ring", () => {
|
|
44
|
+
const el = dot()
|
|
45
|
+
expect(el.tagName).toBe("SPAN")
|
|
46
|
+
expect(el).toHaveClass("bg-success", "size-2", "ring-2", "ring-background", "rounded-full")
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it.each([
|
|
50
|
+
["online", "bg-success"],
|
|
51
|
+
["away", "bg-warning"],
|
|
52
|
+
["busy", "bg-destructive"],
|
|
53
|
+
["offline", "bg-muted"],
|
|
54
|
+
] as const)("%s binds the Figma token %s", (status, fill) => {
|
|
55
|
+
expect(dot({ status })).toHaveClass(fill)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("draws the 4px dot with a 1px ring", () => {
|
|
59
|
+
const el = dot({ size: "4" })
|
|
60
|
+
expect(el).toHaveClass("size-1", "ring-1")
|
|
61
|
+
expect(el).not.toHaveClass("size-2", "ring-2")
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("keeps the ring outside the dot, so it never changes the footprint", () => {
|
|
65
|
+
// A border would add to the box; Figma's stroke is OUTSIDE.
|
|
66
|
+
expect(SOURCE).not.toMatch(/\bborder(-\d)?\b/)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("uses tokens only — no raw colour or pixel values", () => {
|
|
70
|
+
expect(SOURCE).not.toMatch(/#[0-9a-f]{3,8}\b|\[\d+px\]|rgb\(/i)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("merges a consumer className", () => {
|
|
74
|
+
expect(dot({ className: "absolute" })).toHaveClass("absolute", "bg-success")
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe("accessible name", () => {
|
|
78
|
+
it.each([
|
|
79
|
+
["online", "Online", "متصل"],
|
|
80
|
+
["away", "Away", "بعيد"],
|
|
81
|
+
["busy", "Busy", "مشغول"],
|
|
82
|
+
["offline", "Offline", "غير متصل"],
|
|
83
|
+
] as const)("%s is %s in English and %s in Arabic", (status, en, ar) => {
|
|
84
|
+
render(
|
|
85
|
+
<>
|
|
86
|
+
<div lang="en">
|
|
87
|
+
<StatusIndicator data-testid="en" status={status} />
|
|
88
|
+
</div>
|
|
89
|
+
<div lang="ar" dir="rtl">
|
|
90
|
+
<StatusIndicator data-testid="ar" status={status} />
|
|
91
|
+
</div>
|
|
92
|
+
</>
|
|
93
|
+
)
|
|
94
|
+
expect(spokenName(screen.getByTestId("en"))).toBe(en)
|
|
95
|
+
expect(spokenName(screen.getByTestId("ar"))).toBe(ar)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("follows the nearest lang, so an English island on an Arabic page stays English", () => {
|
|
99
|
+
render(
|
|
100
|
+
<div lang="ar">
|
|
101
|
+
<div lang="en">
|
|
102
|
+
<StatusIndicator data-testid="dot" status="busy" />
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
expect(spokenName(screen.getByTestId("dot"))).toBe("Busy")
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it("follows a live language change without remounting", () => {
|
|
110
|
+
const { container } = render(
|
|
111
|
+
<div lang="en">
|
|
112
|
+
<StatusIndicator data-testid="dot" status="away" />
|
|
113
|
+
</div>
|
|
114
|
+
)
|
|
115
|
+
expect(spokenName(screen.getByTestId("dot"))).toBe("Away")
|
|
116
|
+
container.firstElementChild!.setAttribute("lang", "ar")
|
|
117
|
+
expect(spokenName(screen.getByTestId("dot"))).toBe("بعيد")
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("takes an explicit label over the page language", () => {
|
|
121
|
+
render(
|
|
122
|
+
<div lang="ar">
|
|
123
|
+
<StatusIndicator data-testid="dot" status="online" label="Layla is online" />
|
|
124
|
+
</div>
|
|
125
|
+
)
|
|
126
|
+
const el = screen.getByTestId("dot")
|
|
127
|
+
expect(spokenName(el)).toBe("Layla is online")
|
|
128
|
+
expect(el.querySelectorAll(".sr-only")).toHaveLength(1)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("is silent with label=\"\", for when text beside it already says the status", () => {
|
|
132
|
+
render(
|
|
133
|
+
<div lang="ar">
|
|
134
|
+
<StatusIndicator data-testid="dot" status="busy" label="" />
|
|
135
|
+
</div>
|
|
136
|
+
)
|
|
137
|
+
const el = screen.getByTestId("dot")
|
|
138
|
+
expect(el.querySelectorAll(".sr-only")).toHaveLength(0)
|
|
139
|
+
expect(el.textContent).toBe("")
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it("puts the name in text, not in colour alone", () => {
|
|
143
|
+
const el = dot({ status: "offline" })
|
|
144
|
+
expect(el.textContent).toContain("Offline")
|
|
145
|
+
expect(el).not.toHaveAttribute("aria-hidden")
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cva } from "class-variance-authority"
|
|
3
|
+
import { cn } from "../../../src/lib/utils"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Built against the Figma `Status Indicator` set (323:390): Type × Size, 8
|
|
7
|
+
* variants. Every colour below is the token the set binds.
|
|
8
|
+
*
|
|
9
|
+
* The dot has no Direction axis: it is a circle, so there is nothing to mirror.
|
|
10
|
+
* Where it sits is the parent's job — Avatar pins it to the end corner.
|
|
11
|
+
*
|
|
12
|
+
* Figma's ring is a `theme/background` stroke drawn OUTSIDE the dot, 2px on the
|
|
13
|
+
* 8px dot and 1px on the 4px one. `ring-*` is a box-shadow, so it draws there
|
|
14
|
+
* too and never changes the dot's footprint.
|
|
15
|
+
*/
|
|
16
|
+
const statusIndicatorVariants = cva(
|
|
17
|
+
"relative inline-block shrink-0 rounded-full ring-background",
|
|
18
|
+
{
|
|
19
|
+
variants: {
|
|
20
|
+
status: {
|
|
21
|
+
online: "bg-success",
|
|
22
|
+
away: "bg-warning",
|
|
23
|
+
busy: "bg-destructive",
|
|
24
|
+
offline: "bg-muted",
|
|
25
|
+
},
|
|
26
|
+
size: {
|
|
27
|
+
"8": "size-2 ring-2",
|
|
28
|
+
"4": "size-1 ring-1",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
defaultVariants: {
|
|
32
|
+
status: "online",
|
|
33
|
+
size: "8",
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
/** The accessible name of each status, per script. Colour alone can't carry it. */
|
|
39
|
+
const STATUS_LABELS = {
|
|
40
|
+
en: { online: "Online", away: "Away", busy: "Busy", offline: "Offline" },
|
|
41
|
+
ar: { online: "متصل", away: "بعيد", busy: "مشغول", offline: "غير متصل" },
|
|
42
|
+
} as const
|
|
43
|
+
|
|
44
|
+
type Status = keyof typeof STATUS_LABELS.en
|
|
45
|
+
type Size = "8" | "4"
|
|
46
|
+
|
|
47
|
+
export interface StatusIndicatorProps extends React.HTMLAttributes<HTMLSpanElement> {
|
|
48
|
+
/** Figma `Type`. */
|
|
49
|
+
status?: Status
|
|
50
|
+
/** Figma `Size`, in px: `8`, or `4` for the smallest avatar. */
|
|
51
|
+
size?: Size
|
|
52
|
+
/**
|
|
53
|
+
* Accessible name — read by screen readers, never shown. Defaults to the
|
|
54
|
+
* status in the page's language: "Online" / "متصل", "Away" / "بعيد",
|
|
55
|
+
* "Busy" / "مشغول", "Offline" / "غير متصل". Pass `""` to silence the dot,
|
|
56
|
+
* for when text beside it already says the status.
|
|
57
|
+
*/
|
|
58
|
+
label?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const StatusIndicator = React.forwardRef<HTMLSpanElement, StatusIndicatorProps>(
|
|
62
|
+
({ className, status = "online", size = "8", label, ...props }, ref) => (
|
|
63
|
+
<span
|
|
64
|
+
ref={ref}
|
|
65
|
+
className={cn(statusIndicatorVariants({ status, size }), className)}
|
|
66
|
+
{...props}
|
|
67
|
+
>
|
|
68
|
+
{/*
|
|
69
|
+
* Without `label`, the name comes from these two hidden runs, and CSS
|
|
70
|
+
* keeps the one that matches the nearest `lang` — the same way Badge's
|
|
71
|
+
* close button names itself. It is right in the server HTML before any
|
|
72
|
+
* script runs, and follows a live language change.
|
|
73
|
+
*/}
|
|
74
|
+
{label === "" ? null : label ? (
|
|
75
|
+
<span className="sr-only">{label}</span>
|
|
76
|
+
) : (
|
|
77
|
+
<>
|
|
78
|
+
<span className="sr-only [&:lang(ar)]:hidden">{STATUS_LABELS.en[status]}</span>
|
|
79
|
+
<span className="sr-only hidden [&:lang(ar)]:inline">{STATUS_LABELS.ar[status]}</span>
|
|
80
|
+
</>
|
|
81
|
+
)}
|
|
82
|
+
</span>
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
StatusIndicator.displayName = "StatusIndicator"
|
|
86
|
+
|
|
87
|
+
export { StatusIndicator, statusIndicatorVariants, STATUS_LABELS }
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { Switch, type SwitchProps } from "./switch"
|
|
1
|
+
export { Switch, switchVariants, type SwitchProps } from "./switch"
|
|
@@ -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
|
+
})
|