@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,208 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cva } from "class-variance-authority"
|
|
3
|
+
import { cn } from "../../../src/lib/utils"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The height of one line of label text, in whichever script is rendering.
|
|
7
|
+
*
|
|
8
|
+
* The type ramp publishes `--leading-sm` as a *unitless ratio* (`calc(20 / 14)`),
|
|
9
|
+
* not a length, so the line box is ratio × font-size. Writing it this way makes
|
|
10
|
+
* the control direction-aware for free: under `[dir="rtl"]` the ramp re-points
|
|
11
|
+
* `--leading-sm` at Cairo's 24px leading and the control re-centres itself with
|
|
12
|
+
* no RTL-specific class. The fallback, English's 20 / 14, is for a project that
|
|
13
|
+
* does not load the Fasla preset, where `--leading-sm` does not exist.
|
|
14
|
+
*
|
|
15
|
+
* The label deliberately has no fixed height. Figma pins its label boxes to
|
|
16
|
+
* 16/20/24px, which is a defect there — at `sm` a 16px box holds 20px English
|
|
17
|
+
* and 24px Arabic text — and it must not be mirrored here. The ramp sets the
|
|
18
|
+
* leading; nothing else does.
|
|
19
|
+
*/
|
|
20
|
+
const LINE_BOX = "h-[calc(var(--leading-sm,calc(20/14))*0.875rem)]"
|
|
21
|
+
|
|
22
|
+
const radioVariants = cva(
|
|
23
|
+
"group relative inline-flex cursor-pointer items-start gap-3 has-[:disabled]:pointer-events-none has-[:disabled]:opacity-50",
|
|
24
|
+
{
|
|
25
|
+
variants: {
|
|
26
|
+
variant: {
|
|
27
|
+
default: "",
|
|
28
|
+
// In Layout the whole card is the target, so hover fills it and focus
|
|
29
|
+
// rings it. The card ships with no fill, so hover *adds* one.
|
|
30
|
+
layout:
|
|
31
|
+
"rounded-md border p-2 transition-colors motion-reduce:transition-none hover:bg-accent",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
defaultVariants: {
|
|
35
|
+
variant: "default",
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Control, dot and hover halo per size. The dot is always the control less 8px;
|
|
42
|
+
* the halo is always the control plus 8px.
|
|
43
|
+
*/
|
|
44
|
+
const sizeClasses = {
|
|
45
|
+
sm: { control: "size-4", dot: "size-2", halo: "size-6" },
|
|
46
|
+
md: { control: "size-5", dot: "size-3", halo: "size-7" },
|
|
47
|
+
lg: { control: "size-6", dot: "size-4", halo: "size-8" },
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RadioProps
|
|
51
|
+
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "size"> {
|
|
52
|
+
/**
|
|
53
|
+
* Visual treatment. `layout` wraps the control and text in a bordered card.
|
|
54
|
+
* Declared here rather than inherited from `VariantProps`, which widens every
|
|
55
|
+
* variant with `| null` and shows a meaningless third option in Storybook.
|
|
56
|
+
*/
|
|
57
|
+
variant?: "default" | "layout"
|
|
58
|
+
/** Label text. Without it (or `description`) pass `aria-label` for an accessible name. */
|
|
59
|
+
label?: string
|
|
60
|
+
/** Secondary line below the label */
|
|
61
|
+
description?: string
|
|
62
|
+
/** Size variant */
|
|
63
|
+
size?: "sm" | "md" | "lg"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const Radio = React.forwardRef<HTMLInputElement, RadioProps>(
|
|
67
|
+
(
|
|
68
|
+
{
|
|
69
|
+
className,
|
|
70
|
+
variant = "default",
|
|
71
|
+
size = "md",
|
|
72
|
+
label,
|
|
73
|
+
description,
|
|
74
|
+
id,
|
|
75
|
+
"aria-describedby": ariaDescribedBy,
|
|
76
|
+
...props
|
|
77
|
+
},
|
|
78
|
+
ref
|
|
79
|
+
) => {
|
|
80
|
+
const generatedId = React.useId()
|
|
81
|
+
const inputId = id || generatedId
|
|
82
|
+
const descriptionId = `${inputId}-description`
|
|
83
|
+
const sizes = sizeClasses[size]
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<label
|
|
87
|
+
htmlFor={inputId}
|
|
88
|
+
className={cn(radioVariants({ variant }), className)}
|
|
89
|
+
>
|
|
90
|
+
<input
|
|
91
|
+
type="radio"
|
|
92
|
+
ref={ref}
|
|
93
|
+
id={inputId}
|
|
94
|
+
className="peer sr-only"
|
|
95
|
+
aria-describedby={
|
|
96
|
+
description ? cn(descriptionId, ariaDescribedBy) : ariaDescribedBy
|
|
97
|
+
}
|
|
98
|
+
{...props}
|
|
99
|
+
/>
|
|
100
|
+
{/*
|
|
101
|
+
* The control sits in a band exactly one line tall and centres itself
|
|
102
|
+
* in it, so it aligns optically with the *first* line of the label
|
|
103
|
+
* rather than drifting to the middle of a wrapped block. At `lg` the
|
|
104
|
+
* 24px control is taller than a 20px English line and overflows the
|
|
105
|
+
* band by 2px a side — deliberate, and well inside the card's 8px
|
|
106
|
+
* padding.
|
|
107
|
+
*/}
|
|
108
|
+
{/*
|
|
109
|
+
* Focus rings are real elements, exactly as Figma models them — an
|
|
110
|
+
* ellipse at −2,−2 for Default and a rounded rectangle at −1,−1 for
|
|
111
|
+
* Layout, rather than a property of the control or the card.
|
|
112
|
+
*
|
|
113
|
+
* Each is a *direct sibling of the input*, so a plain
|
|
114
|
+
* `peer-focus-visible:opacity-100` drives it. That matters beyond
|
|
115
|
+
* tidiness: `:has()` and arbitrary-variant selectors both resolve in a
|
|
116
|
+
* browser but are invisible to the pseudo-state renderer the Storybook
|
|
117
|
+
* states grid uses, so the grid would quietly stop matching the
|
|
118
|
+
* component. A simple class on a sibling is the one form that survives.
|
|
119
|
+
*/}
|
|
120
|
+
{variant === "layout" && (
|
|
121
|
+
<span
|
|
122
|
+
aria-hidden="true"
|
|
123
|
+
// −2px, not −1px: an absolutely positioned child is offset from the
|
|
124
|
+
// *padding* box, which already sits 1px inside the 1px border. −1
|
|
125
|
+
// would land the ring exactly on the border with no gap at all.
|
|
126
|
+
className="pointer-events-none absolute -inset-[2px] rounded-[calc(var(--radius)-1px)] opacity-0 ring-2 ring-ring peer-focus-visible:opacity-100"
|
|
127
|
+
/>
|
|
128
|
+
)}
|
|
129
|
+
{variant === "default" && (
|
|
130
|
+
<span
|
|
131
|
+
aria-hidden="true"
|
|
132
|
+
className={cn(
|
|
133
|
+
"pointer-events-none absolute start-0 top-0 flex items-center opacity-0 peer-focus-visible:opacity-100",
|
|
134
|
+
LINE_BOX
|
|
135
|
+
)}
|
|
136
|
+
>
|
|
137
|
+
<span
|
|
138
|
+
className={cn(
|
|
139
|
+
"rounded-full ring-2 ring-ring ring-offset-2",
|
|
140
|
+
sizes.control
|
|
141
|
+
)}
|
|
142
|
+
/>
|
|
143
|
+
</span>
|
|
144
|
+
)}
|
|
145
|
+
<span
|
|
146
|
+
aria-hidden="true"
|
|
147
|
+
className={cn("relative flex shrink-0 items-center", LINE_BOX)}
|
|
148
|
+
>
|
|
149
|
+
{/*
|
|
150
|
+
* Hover halo — Default only; in Layout the card fills instead. Kept
|
|
151
|
+
* absolute so it can never affect layout, which is the constraint the
|
|
152
|
+
* Figma set is built on: every Interaction variant has identical
|
|
153
|
+
* frame sizes.
|
|
154
|
+
*/}
|
|
155
|
+
{variant === "default" && (
|
|
156
|
+
<span
|
|
157
|
+
className={cn(
|
|
158
|
+
"pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-accent opacity-0 transition-opacity motion-reduce:transition-none group-hover:opacity-100",
|
|
159
|
+
sizes.halo
|
|
160
|
+
)}
|
|
161
|
+
/>
|
|
162
|
+
)}
|
|
163
|
+
<span
|
|
164
|
+
data-slot="control"
|
|
165
|
+
className={cn(
|
|
166
|
+
"relative flex shrink-0 items-center justify-center rounded-full border border-input",
|
|
167
|
+
sizes.control
|
|
168
|
+
)}
|
|
169
|
+
>
|
|
170
|
+
<span
|
|
171
|
+
className={cn(
|
|
172
|
+
// Figma paints the dot with theme/primary — the strong neutral
|
|
173
|
+
// (#0a0a0a light, #fafafa dark). Both apps now declare exactly
|
|
174
|
+
// that, so `bg-primary` is the honest token again. This read
|
|
175
|
+
// `bg-foreground` until the token sync, as a workaround for the
|
|
176
|
+
// docs app having repurposed `--primary` as the brand red.
|
|
177
|
+
// Figma gives primary and foreground the same value in both
|
|
178
|
+
// modes, so this is a rename, not a visual change.
|
|
179
|
+
"scale-0 rounded-full bg-primary transition-transform motion-reduce:transition-none group-has-[:checked]:scale-100",
|
|
180
|
+
sizes.dot
|
|
181
|
+
)}
|
|
182
|
+
/>
|
|
183
|
+
</span>
|
|
184
|
+
</span>
|
|
185
|
+
{(label || description) && (
|
|
186
|
+
<span className="flex min-w-0 flex-col gap-2">
|
|
187
|
+
{label && (
|
|
188
|
+
<span className="text-sm font-medium text-foreground">
|
|
189
|
+
{label}
|
|
190
|
+
</span>
|
|
191
|
+
)}
|
|
192
|
+
{description && (
|
|
193
|
+
<span
|
|
194
|
+
id={descriptionId}
|
|
195
|
+
className="text-sm text-muted-foreground"
|
|
196
|
+
>
|
|
197
|
+
{description}
|
|
198
|
+
</span>
|
|
199
|
+
)}
|
|
200
|
+
</span>
|
|
201
|
+
)}
|
|
202
|
+
</label>
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
Radio.displayName = "Radio"
|
|
207
|
+
|
|
208
|
+
export { Radio, radioVariants }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from "react"
|
|
4
|
+
import { ChevronDownIcon } from "lucide-react"
|
|
4
5
|
import { cn } from "../../../src/lib/utils"
|
|
5
6
|
|
|
6
7
|
export interface SelectOption {
|
|
@@ -51,7 +52,7 @@ const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
|
|
51
52
|
? "border-destructive focus-visible:ring-destructive"
|
|
52
53
|
: "border-input",
|
|
53
54
|
sizeClasses[selectSize],
|
|
54
|
-
"
|
|
55
|
+
"pe-10",
|
|
55
56
|
className
|
|
56
57
|
)}
|
|
57
58
|
{...props}
|
|
@@ -71,19 +72,8 @@ const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
|
|
71
72
|
</option>
|
|
72
73
|
))}
|
|
73
74
|
</select>
|
|
74
|
-
<div className="pointer-events-none absolute inset-y-0
|
|
75
|
-
<
|
|
76
|
-
width="16"
|
|
77
|
-
height="16"
|
|
78
|
-
viewBox="0 0 24 24"
|
|
79
|
-
fill="none"
|
|
80
|
-
stroke="currentColor"
|
|
81
|
-
strokeWidth="2"
|
|
82
|
-
strokeLinecap="round"
|
|
83
|
-
strokeLinejoin="round"
|
|
84
|
-
>
|
|
85
|
-
<polyline points="6 9 12 15 18 9" />
|
|
86
|
-
</svg>
|
|
75
|
+
<div className="pointer-events-none absolute inset-y-0 end-0 flex items-center pe-3 text-muted-foreground">
|
|
76
|
+
<ChevronDownIcon size={16} strokeWidth={1.5} />
|
|
87
77
|
</div>
|
|
88
78
|
</div>
|
|
89
79
|
)
|
|
@@ -71,7 +71,7 @@ function SkeletonCard({ className, ...props }: SkeletonProps) {
|
|
|
71
71
|
)}
|
|
72
72
|
{...props}
|
|
73
73
|
>
|
|
74
|
-
<div className="flex items-center
|
|
74
|
+
<div className="flex items-center gap-4">
|
|
75
75
|
<SkeletonAvatar />
|
|
76
76
|
<div className="space-y-2 flex-1">
|
|
77
77
|
<Skeleton className="h-4 w-1/2" />
|
|
@@ -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"
|