@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
|
@@ -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>
|
package/registry.json
CHANGED
|
@@ -47,11 +47,14 @@
|
|
|
47
47
|
"name": "avatar",
|
|
48
48
|
"type": "registry:ui",
|
|
49
49
|
"title": "Avatar",
|
|
50
|
-
"description": "
|
|
50
|
+
"description": "A user's photo, initials or icon, in three sizes and two radii, with an optional border and presence dot. Falls back from photo to initials to icon on its own.",
|
|
51
51
|
"dependencies": [
|
|
52
|
-
"class-variance-authority"
|
|
52
|
+
"class-variance-authority",
|
|
53
|
+
"lucide-react"
|
|
54
|
+
],
|
|
55
|
+
"registryDependencies": [
|
|
56
|
+
"status-indicator"
|
|
53
57
|
],
|
|
54
|
-
"registryDependencies": [],
|
|
55
58
|
"files": [
|
|
56
59
|
{
|
|
57
60
|
"path": "registry/ui/avatar/avatar.tsx",
|
|
@@ -68,9 +71,10 @@
|
|
|
68
71
|
"name": "badge",
|
|
69
72
|
"type": "registry:ui",
|
|
70
73
|
"title": "Badge",
|
|
71
|
-
"description": "A
|
|
74
|
+
"description": "A compact inline label for status, category or metadata: solid, soft and outline types in six semantic tones, with an optional icon, avatar and close button.",
|
|
72
75
|
"dependencies": [
|
|
73
|
-
"class-variance-authority"
|
|
76
|
+
"class-variance-authority",
|
|
77
|
+
"lucide-react"
|
|
74
78
|
],
|
|
75
79
|
"registryDependencies": [],
|
|
76
80
|
"files": [
|
|
@@ -113,7 +117,8 @@
|
|
|
113
117
|
"description": "A button component with multiple variants, sizes, and loading state.",
|
|
114
118
|
"dependencies": [
|
|
115
119
|
"@radix-ui/react-slot",
|
|
116
|
-
"class-variance-authority"
|
|
120
|
+
"class-variance-authority",
|
|
121
|
+
"lucide-react"
|
|
117
122
|
],
|
|
118
123
|
"registryDependencies": [],
|
|
119
124
|
"files": [
|
|
@@ -152,7 +157,9 @@
|
|
|
152
157
|
"type": "registry:ui",
|
|
153
158
|
"title": "Checkbox",
|
|
154
159
|
"description": "An accessible checkbox for binary selections with controlled state.",
|
|
155
|
-
"dependencies": [
|
|
160
|
+
"dependencies": [
|
|
161
|
+
"lucide-react"
|
|
162
|
+
],
|
|
156
163
|
"registryDependencies": [],
|
|
157
164
|
"files": [
|
|
158
165
|
{
|
|
@@ -166,12 +173,35 @@
|
|
|
166
173
|
"forms"
|
|
167
174
|
]
|
|
168
175
|
},
|
|
176
|
+
{
|
|
177
|
+
"name": "radio",
|
|
178
|
+
"type": "registry:ui",
|
|
179
|
+
"title": "Radio",
|
|
180
|
+
"description": "An accessible radio for choosing one option from a set, with a plain and a bordered layout variant.",
|
|
181
|
+
"dependencies": [
|
|
182
|
+
"class-variance-authority"
|
|
183
|
+
],
|
|
184
|
+
"registryDependencies": [],
|
|
185
|
+
"files": [
|
|
186
|
+
{
|
|
187
|
+
"path": "registry/ui/radio/radio.tsx",
|
|
188
|
+
"type": "registry:ui",
|
|
189
|
+
"target": "components/ui/radio.tsx"
|
|
190
|
+
}
|
|
191
|
+
],
|
|
192
|
+
"categories": [
|
|
193
|
+
"primitives",
|
|
194
|
+
"forms"
|
|
195
|
+
]
|
|
196
|
+
},
|
|
169
197
|
{
|
|
170
198
|
"name": "combobox",
|
|
171
199
|
"type": "registry:ui",
|
|
172
200
|
"title": "Combobox",
|
|
173
201
|
"description": "A searchable combobox with single or multiple selection.",
|
|
174
|
-
"dependencies": [
|
|
202
|
+
"dependencies": [
|
|
203
|
+
"lucide-react"
|
|
204
|
+
],
|
|
175
205
|
"registryDependencies": [],
|
|
176
206
|
"files": [
|
|
177
207
|
{
|
|
@@ -209,7 +239,9 @@
|
|
|
209
239
|
"type": "registry:block",
|
|
210
240
|
"title": "Empty State",
|
|
211
241
|
"description": "Empty state component with icon, title, description, and actions.",
|
|
212
|
-
"dependencies": [
|
|
242
|
+
"dependencies": [
|
|
243
|
+
"lucide-react"
|
|
244
|
+
],
|
|
213
245
|
"registryDependencies": [],
|
|
214
246
|
"files": [
|
|
215
247
|
{
|
|
@@ -287,7 +319,9 @@
|
|
|
287
319
|
"type": "registry:block",
|
|
288
320
|
"title": "Navbar",
|
|
289
321
|
"description": "Responsive navigation bar with desktop links and mobile menu.",
|
|
290
|
-
"dependencies": [
|
|
322
|
+
"dependencies": [
|
|
323
|
+
"lucide-react"
|
|
324
|
+
],
|
|
291
325
|
"registryDependencies": [],
|
|
292
326
|
"files": [
|
|
293
327
|
{
|
|
@@ -325,7 +359,9 @@
|
|
|
325
359
|
"type": "registry:ui",
|
|
326
360
|
"title": "Select",
|
|
327
361
|
"description": "A select control for choosing one option from a list.",
|
|
328
|
-
"dependencies": [
|
|
362
|
+
"dependencies": [
|
|
363
|
+
"lucide-react"
|
|
364
|
+
],
|
|
329
365
|
"registryDependencies": [],
|
|
330
366
|
"files": [
|
|
331
367
|
{
|
|
@@ -365,7 +401,9 @@
|
|
|
365
401
|
"type": "registry:block",
|
|
366
402
|
"title": "Sidebar",
|
|
367
403
|
"description": "Responsive sidebar navigation with collapsible structure.",
|
|
368
|
-
"dependencies": [
|
|
404
|
+
"dependencies": [
|
|
405
|
+
"lucide-react"
|
|
406
|
+
],
|
|
369
407
|
"registryDependencies": [],
|
|
370
408
|
"files": [
|
|
371
409
|
{
|
|
@@ -383,7 +421,7 @@
|
|
|
383
421
|
"name": "skeleton",
|
|
384
422
|
"type": "registry:ui",
|
|
385
423
|
"title": "Skeleton",
|
|
386
|
-
"description": "Loading
|
|
424
|
+
"description": "Loading placeholders that replace shadcn's Skeleton: a plain block plus text, avatar, list item and card shapes that stay still under reduced motion.",
|
|
387
425
|
"dependencies": [],
|
|
388
426
|
"registryDependencies": [],
|
|
389
427
|
"files": [
|
|
@@ -424,7 +462,9 @@
|
|
|
424
462
|
"type": "registry:block",
|
|
425
463
|
"title": "Stats Card",
|
|
426
464
|
"description": "Display a metric with trend, icon, and supporting label.",
|
|
427
|
-
"dependencies": [
|
|
465
|
+
"dependencies": [
|
|
466
|
+
"lucide-react"
|
|
467
|
+
],
|
|
428
468
|
"registryDependencies": [],
|
|
429
469
|
"files": [
|
|
430
470
|
{
|
|
@@ -438,12 +478,35 @@
|
|
|
438
478
|
"application"
|
|
439
479
|
]
|
|
440
480
|
},
|
|
481
|
+
{
|
|
482
|
+
"name": "status-indicator",
|
|
483
|
+
"type": "registry:ui",
|
|
484
|
+
"title": "Status Indicator",
|
|
485
|
+
"description": "A presence dot — online, away, busy or offline — with an accessible name in the page's language, so colour never carries the status alone.",
|
|
486
|
+
"dependencies": [
|
|
487
|
+
"class-variance-authority"
|
|
488
|
+
],
|
|
489
|
+
"registryDependencies": [],
|
|
490
|
+
"files": [
|
|
491
|
+
{
|
|
492
|
+
"path": "registry/ui/status-indicator/status-indicator.tsx",
|
|
493
|
+
"type": "registry:ui",
|
|
494
|
+
"target": "components/ui/status-indicator.tsx"
|
|
495
|
+
}
|
|
496
|
+
],
|
|
497
|
+
"categories": [
|
|
498
|
+
"primitives",
|
|
499
|
+
"data-display"
|
|
500
|
+
]
|
|
501
|
+
},
|
|
441
502
|
{
|
|
442
503
|
"name": "switch",
|
|
443
504
|
"type": "registry:ui",
|
|
444
505
|
"title": "Switch",
|
|
445
|
-
"description": "An accessible switch for on-off settings.",
|
|
446
|
-
"dependencies": [
|
|
506
|
+
"description": "An accessible switch for on-off settings: solid and outline styles in three sizes, with the track before or after its label.",
|
|
507
|
+
"dependencies": [
|
|
508
|
+
"class-variance-authority"
|
|
509
|
+
],
|
|
447
510
|
"registryDependencies": [],
|
|
448
511
|
"files": [
|
|
449
512
|
{
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import plugin from "tailwindcss/plugin"
|
|
2
|
+
import type { Config } from "tailwindcss"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared typography preset — the single place the Fasla type system lives.
|
|
6
|
+
*
|
|
7
|
+
* Every value here is read from the `Tailwind En/*` and `Tailwind AR/*` text
|
|
8
|
+
* styles in the Fasla Figma file. Figma is the source of truth: nothing in this
|
|
9
|
+
* file is rounded, averaged or invented. If a value looks wrong, fix the style
|
|
10
|
+
* in Figma and mirror it here — never the other way round.
|
|
11
|
+
*
|
|
12
|
+
* Geist carries LTR, Cairo carries RTL. The two ramps are 1:1 by size and
|
|
13
|
+
* deliberately not 1:1 by line-height — Cairo needs more vertical room for
|
|
14
|
+
* descenders and diacritics.
|
|
15
|
+
*
|
|
16
|
+
* Line-height is delivered as a CSS variable per rung, so `[dir="rtl"]`
|
|
17
|
+
* re-points the whole ramp at once. That is what makes every `text-*` utility
|
|
18
|
+
* direction-aware without a single component knowing about it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Line-height as Figma states it: a px value against a px size.
|
|
23
|
+
*
|
|
24
|
+
* It is emitted as `calc(lineHeight / fontSize)` — a unitless ratio, so it
|
|
25
|
+
* stays correct when a rung is used at a responsive size, and exact, because
|
|
26
|
+
* CSS does the division at full precision. Eight of these ratios are
|
|
27
|
+
* non-terminating (18→26 is 1.444…), so writing them as decimals would mean
|
|
28
|
+
* rounding a source-of-truth value. calc() avoids that entirely.
|
|
29
|
+
*
|
|
30
|
+
* `null` means Figma has the rung on AUTO — the font's own metrics, which CSS
|
|
31
|
+
* spells `normal`. No rung is currently on AUTO: all 144 styles are bound, in
|
|
32
|
+
* both scripts, at every size. The mechanism stays because AUTO is a legitimate
|
|
33
|
+
* choice Figma can express, and a rung may return to it.
|
|
34
|
+
*/
|
|
35
|
+
const LEADING = {
|
|
36
|
+
// size En AR
|
|
37
|
+
// XXS is the one rung below Tailwind's stock scale. Figma binds En to
|
|
38
|
+
// `typography/line-height/L-neg-0,5` (14) and AR to `L-0` (16).
|
|
39
|
+
xxs: /* 10 */ [/* 14 */ 14, /* 16 */ 16],
|
|
40
|
+
xs: /* 12 */ [/* 16 */ 16, /* 20 */ 20],
|
|
41
|
+
sm: /* 14 */ [/* 20 */ 20, /* 24 */ 24],
|
|
42
|
+
base: /* 16 */ [/* 24 */ 24, /* 28 */ 28],
|
|
43
|
+
lg: /* 18 */ [/* 28 */ 28, /* 32 */ 32],
|
|
44
|
+
xl: /* 20 */ [/* 28 */ 28, /* 36 */ 36],
|
|
45
|
+
"2xl": /* 24 */ [/* 32 */ 32, /* 40 */ 40],
|
|
46
|
+
"3xl": /* 30 */ [/* 40 */ 40, /* 50 */ 50],
|
|
47
|
+
"4xl": /* 36 */ [/* 44 */ 44, /* 60 */ 60],
|
|
48
|
+
"5xl": /* 48 */ [/* 58 */ 58, /* 72 */ 72],
|
|
49
|
+
"6xl": /* 60 */ [/* 72 */ 72, /* 90 */ 90],
|
|
50
|
+
"7xl": /* 72 */ [/* 90 */ 90, /* 108 */ 108],
|
|
51
|
+
"8xl": /* 96 */ [/* 108 */ 108, /* 144 */ 144],
|
|
52
|
+
"9xl": /* 128 */ [/* 144 */ 144, /* 192 */ 192],
|
|
53
|
+
// `Extra/*` — two styles that sit outside the XXS–9XL ramp but are part of it.
|
|
54
|
+
// Weight, underline and case are left to the ordinary utilities: `Extra/Link`
|
|
55
|
+
// is 400 + underline, `Extra/List Header` is 500 + uppercase. Uppercase is
|
|
56
|
+
// English-only; Arabic has no uppercase and must never be given one.
|
|
57
|
+
link: /* 16 */ [/* 24 */ 24, /* 24 */ 24],
|
|
58
|
+
"list-header": /* 14 */ [/* 16 */ 16, /* 16 */ 16],
|
|
59
|
+
} as const satisfies Record<string, readonly [number | null, number | null]>
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Figma's size scale. From XS up it matches Tailwind's own scale 1:1; XXS
|
|
63
|
+
* (10px, `typography/font-size/xxs`) is Fasla's addition below it.
|
|
64
|
+
*/
|
|
65
|
+
const SIZE_PX = {
|
|
66
|
+
xxs: 10,
|
|
67
|
+
xs: 12,
|
|
68
|
+
sm: 14,
|
|
69
|
+
base: 16,
|
|
70
|
+
lg: 18,
|
|
71
|
+
xl: 20,
|
|
72
|
+
"2xl": 24,
|
|
73
|
+
"3xl": 30,
|
|
74
|
+
"4xl": 36,
|
|
75
|
+
"5xl": 48,
|
|
76
|
+
"6xl": 60,
|
|
77
|
+
"7xl": 72,
|
|
78
|
+
"8xl": 96,
|
|
79
|
+
"9xl": 128,
|
|
80
|
+
link: 16,
|
|
81
|
+
"list-header": 14,
|
|
82
|
+
} as const
|
|
83
|
+
|
|
84
|
+
type Rung = keyof typeof LEADING
|
|
85
|
+
|
|
86
|
+
const rungs = Object.keys(LEADING) as Rung[]
|
|
87
|
+
|
|
88
|
+
const rem = (px: number) => `${px / 16}rem`
|
|
89
|
+
const cssVar = (rung: Rung) => `--leading-${rung}`
|
|
90
|
+
|
|
91
|
+
/** Exact, unitless, and never rounded — CSS divides at full precision. */
|
|
92
|
+
const ratio = (leadingPx: number, sizePx: number) => `calc(${leadingPx} / ${sizePx})`
|
|
93
|
+
|
|
94
|
+
/** `text-xl` resolves its leading through the variable, never a literal. */
|
|
95
|
+
const fontSize = Object.fromEntries(
|
|
96
|
+
rungs.map((rung) => [rung, [rem(SIZE_PX[rung]), `var(${cssVar(rung)})`]])
|
|
97
|
+
) as Record<Rung, [string, string]>
|
|
98
|
+
|
|
99
|
+
const leadingFor = (index: 0 | 1) =>
|
|
100
|
+
Object.fromEntries(
|
|
101
|
+
rungs.map((rung) => {
|
|
102
|
+
const px = LEADING[rung][index]
|
|
103
|
+
return [cssVar(rung), px === null ? "normal" : ratio(px, SIZE_PX[rung])]
|
|
104
|
+
})
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Figma sets `letterSpacing` to 0% on all 144 text styles, both scripts, every
|
|
109
|
+
* weight — so the ramp declares no tracking and components should set none.
|
|
110
|
+
* Tailwind's stock `tracking-*` scale is left in place for the one sanctioned
|
|
111
|
+
* exception, the letter-spaced eyebrow that design/DESIGN.md documents as
|
|
112
|
+
* deliberately detached. Reach for it only for that.
|
|
113
|
+
*/
|
|
114
|
+
|
|
115
|
+
const preset = {
|
|
116
|
+
content: [],
|
|
117
|
+
theme: {
|
|
118
|
+
extend: {
|
|
119
|
+
fontFamily: {
|
|
120
|
+
// Brand V2.5 §14 — product tier.
|
|
121
|
+
sans: ["var(--font-geist-sans)", "system-ui", "sans-serif"],
|
|
122
|
+
mono: ["var(--font-geist-mono)", "ui-monospace", "monospace"],
|
|
123
|
+
// Brand V2.5 §14 — the Arabic face. Geist has no Arabic coverage.
|
|
124
|
+
arabic: ["var(--font-cairo)", "system-ui", "sans-serif"],
|
|
125
|
+
},
|
|
126
|
+
fontSize,
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
plugins: [
|
|
130
|
+
plugin(({ addBase }) => {
|
|
131
|
+
addBase({
|
|
132
|
+
":root": leadingFor(0),
|
|
133
|
+
// Source order matters: same specificity as `:root`, so this must win by
|
|
134
|
+
// coming second. `dir` is on a wrapper element, not <html>, in both apps —
|
|
135
|
+
// the variables cascade down from wherever it sits.
|
|
136
|
+
'[dir="rtl"]': leadingFor(1),
|
|
137
|
+
})
|
|
138
|
+
}),
|
|
139
|
+
],
|
|
140
|
+
} satisfies Config
|
|
141
|
+
|
|
142
|
+
export { LEADING, SIZE_PX }
|
|
143
|
+
export default preset
|