pejay-ui 1.0.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/LICENSE +21 -0
- package/README.md +0 -0
- package/bin/cli.js +379 -0
- package/package.json +52 -0
- package/registry.json +350 -0
- package/templates/button/Button.tsx +156 -0
- package/templates/button/index.ts +2 -0
- package/templates/button/tooltip.tsx +124 -0
- package/templates/form/amount-input.tsx +252 -0
- package/templates/form/checkbox-group.tsx +235 -0
- package/templates/form/checkbox.tsx +148 -0
- package/templates/form/date-picker.tsx +647 -0
- package/templates/form/date-range-picker.tsx +1039 -0
- package/templates/form/email-input.tsx +55 -0
- package/templates/form/file-input.tsx +380 -0
- package/templates/form/index.ts +22 -0
- package/templates/form/input.tsx +255 -0
- package/templates/form/number-input.tsx +186 -0
- package/templates/form/password-input.tsx +233 -0
- package/templates/form/phone-input.tsx +82 -0
- package/templates/form/radio-group.tsx +191 -0
- package/templates/form/radio.tsx +157 -0
- package/templates/form/range-slider.tsx +210 -0
- package/templates/form/switch.tsx +134 -0
- package/templates/form/textarea.tsx +253 -0
- package/templates/form/time-picker.tsx +435 -0
- package/templates/form/time-range-picker.tsx +526 -0
- package/templates/form/url-input.tsx +81 -0
- package/templates/select-dropdown/index.ts +4 -0
- package/templates/select-dropdown/multiselect-input.tsx +687 -0
- package/templates/select-dropdown/select-input.tsx +565 -0
- package/utils/cn.ts +6 -0
package/registry.json
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
{
|
|
2
|
+
"button": {
|
|
3
|
+
"name": "Button",
|
|
4
|
+
"category": "button",
|
|
5
|
+
"files": [
|
|
6
|
+
"templates/button/Button.tsx",
|
|
7
|
+
"templates/button/tooltip.tsx"
|
|
8
|
+
],
|
|
9
|
+
"utils": [
|
|
10
|
+
"cn.ts"
|
|
11
|
+
],
|
|
12
|
+
"peerDependencies": [
|
|
13
|
+
"clsx",
|
|
14
|
+
"tailwind-merge"
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"form/input": {
|
|
18
|
+
"name": "Input",
|
|
19
|
+
"category": "form",
|
|
20
|
+
"files": [
|
|
21
|
+
"templates/form/input.tsx"
|
|
22
|
+
],
|
|
23
|
+
"utils": [
|
|
24
|
+
"cn.ts"
|
|
25
|
+
],
|
|
26
|
+
"peerDependencies": [
|
|
27
|
+
"clsx",
|
|
28
|
+
"tailwind-merge",
|
|
29
|
+
"lucide-react"
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
"form/amount-input": {
|
|
33
|
+
"name": "AmountInput",
|
|
34
|
+
"category": "form",
|
|
35
|
+
"files": [
|
|
36
|
+
"templates/form/amount-input.tsx"
|
|
37
|
+
],
|
|
38
|
+
"utils": [
|
|
39
|
+
"cn.ts"
|
|
40
|
+
],
|
|
41
|
+
"peerDependencies": [
|
|
42
|
+
"clsx",
|
|
43
|
+
"tailwind-merge",
|
|
44
|
+
"lucide-react"
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
"form/checkbox": {
|
|
48
|
+
"name": "Checkbox",
|
|
49
|
+
"category": "form",
|
|
50
|
+
"files": [
|
|
51
|
+
"templates/form/checkbox.tsx"
|
|
52
|
+
],
|
|
53
|
+
"utils": [
|
|
54
|
+
"cn.ts"
|
|
55
|
+
],
|
|
56
|
+
"peerDependencies": [
|
|
57
|
+
"clsx",
|
|
58
|
+
"tailwind-merge",
|
|
59
|
+
"lucide-react"
|
|
60
|
+
]
|
|
61
|
+
},
|
|
62
|
+
"form/checkbox-group": {
|
|
63
|
+
"name": "CheckboxGroup",
|
|
64
|
+
"category": "form",
|
|
65
|
+
"files": [
|
|
66
|
+
"templates/form/checkbox-group.tsx"
|
|
67
|
+
],
|
|
68
|
+
"utils": [
|
|
69
|
+
"cn.ts"
|
|
70
|
+
],
|
|
71
|
+
"peerDependencies": [
|
|
72
|
+
"clsx",
|
|
73
|
+
"tailwind-merge"
|
|
74
|
+
],
|
|
75
|
+
"dependencies": [
|
|
76
|
+
"form/checkbox"
|
|
77
|
+
]
|
|
78
|
+
},
|
|
79
|
+
"form/date-picker": {
|
|
80
|
+
"name": "DatePicker",
|
|
81
|
+
"category": "form",
|
|
82
|
+
"files": [
|
|
83
|
+
"templates/form/date-picker.tsx"
|
|
84
|
+
],
|
|
85
|
+
"utils": [
|
|
86
|
+
"cn.ts"
|
|
87
|
+
],
|
|
88
|
+
"peerDependencies": [
|
|
89
|
+
"clsx",
|
|
90
|
+
"tailwind-merge",
|
|
91
|
+
"lucide-react",
|
|
92
|
+
"@floating-ui/react"
|
|
93
|
+
],
|
|
94
|
+
"dependencies": [
|
|
95
|
+
"select-dropdown/select-input"
|
|
96
|
+
]
|
|
97
|
+
},
|
|
98
|
+
"form/date-range-picker": {
|
|
99
|
+
"name": "DateRangePicker",
|
|
100
|
+
"category": "form",
|
|
101
|
+
"files": [
|
|
102
|
+
"templates/form/date-range-picker.tsx"
|
|
103
|
+
],
|
|
104
|
+
"utils": [
|
|
105
|
+
"cn.ts"
|
|
106
|
+
],
|
|
107
|
+
"peerDependencies": [
|
|
108
|
+
"clsx",
|
|
109
|
+
"tailwind-merge",
|
|
110
|
+
"lucide-react",
|
|
111
|
+
"@floating-ui/react"
|
|
112
|
+
],
|
|
113
|
+
"dependencies": [
|
|
114
|
+
"select-dropdown/select-input"
|
|
115
|
+
]
|
|
116
|
+
},
|
|
117
|
+
"form/email-input": {
|
|
118
|
+
"name": "EmailInput",
|
|
119
|
+
"category": "form",
|
|
120
|
+
"files": [
|
|
121
|
+
"templates/form/email-input.tsx"
|
|
122
|
+
],
|
|
123
|
+
"utils": [
|
|
124
|
+
"cn.ts"
|
|
125
|
+
],
|
|
126
|
+
"peerDependencies": [
|
|
127
|
+
"clsx",
|
|
128
|
+
"tailwind-merge",
|
|
129
|
+
"lucide-react"
|
|
130
|
+
]
|
|
131
|
+
},
|
|
132
|
+
"form/file-input": {
|
|
133
|
+
"name": "FileInput",
|
|
134
|
+
"category": "form",
|
|
135
|
+
"files": [
|
|
136
|
+
"templates/form/file-input.tsx"
|
|
137
|
+
],
|
|
138
|
+
"utils": [
|
|
139
|
+
"cn.ts"
|
|
140
|
+
],
|
|
141
|
+
"peerDependencies": [
|
|
142
|
+
"clsx",
|
|
143
|
+
"tailwind-merge",
|
|
144
|
+
"lucide-react"
|
|
145
|
+
]
|
|
146
|
+
},
|
|
147
|
+
"form/number-input": {
|
|
148
|
+
"name": "NumberInput",
|
|
149
|
+
"category": "form",
|
|
150
|
+
"files": [
|
|
151
|
+
"templates/form/number-input.tsx"
|
|
152
|
+
],
|
|
153
|
+
"utils": [
|
|
154
|
+
"cn.ts"
|
|
155
|
+
],
|
|
156
|
+
"peerDependencies": [
|
|
157
|
+
"clsx",
|
|
158
|
+
"tailwind-merge",
|
|
159
|
+
"lucide-react"
|
|
160
|
+
]
|
|
161
|
+
},
|
|
162
|
+
"form/password-input": {
|
|
163
|
+
"name": "PasswordInput",
|
|
164
|
+
"category": "form",
|
|
165
|
+
"files": [
|
|
166
|
+
"templates/form/password-input.tsx"
|
|
167
|
+
],
|
|
168
|
+
"utils": [
|
|
169
|
+
"cn.ts"
|
|
170
|
+
],
|
|
171
|
+
"peerDependencies": [
|
|
172
|
+
"clsx",
|
|
173
|
+
"tailwind-merge",
|
|
174
|
+
"lucide-react"
|
|
175
|
+
]
|
|
176
|
+
},
|
|
177
|
+
"form/phone-input": {
|
|
178
|
+
"name": "PhoneInput",
|
|
179
|
+
"category": "form",
|
|
180
|
+
"files": [
|
|
181
|
+
"templates/form/phone-input.tsx"
|
|
182
|
+
],
|
|
183
|
+
"utils": [
|
|
184
|
+
"cn.ts"
|
|
185
|
+
],
|
|
186
|
+
"peerDependencies": [
|
|
187
|
+
"clsx",
|
|
188
|
+
"tailwind-merge",
|
|
189
|
+
"lucide-react"
|
|
190
|
+
]
|
|
191
|
+
},
|
|
192
|
+
"form/radio": {
|
|
193
|
+
"name": "Radio",
|
|
194
|
+
"category": "form",
|
|
195
|
+
"files": [
|
|
196
|
+
"templates/form/radio.tsx"
|
|
197
|
+
],
|
|
198
|
+
"utils": [
|
|
199
|
+
"cn.ts"
|
|
200
|
+
],
|
|
201
|
+
"peerDependencies": [
|
|
202
|
+
"clsx",
|
|
203
|
+
"tailwind-merge"
|
|
204
|
+
]
|
|
205
|
+
},
|
|
206
|
+
"form/radio-group": {
|
|
207
|
+
"name": "RadioGroup",
|
|
208
|
+
"category": "form",
|
|
209
|
+
"files": [
|
|
210
|
+
"templates/form/radio-group.tsx"
|
|
211
|
+
],
|
|
212
|
+
"utils": [
|
|
213
|
+
"cn.ts"
|
|
214
|
+
],
|
|
215
|
+
"peerDependencies": [
|
|
216
|
+
"clsx",
|
|
217
|
+
"tailwind-merge"
|
|
218
|
+
],
|
|
219
|
+
"dependencies": [
|
|
220
|
+
"form/radio"
|
|
221
|
+
]
|
|
222
|
+
},
|
|
223
|
+
"form/range-slider": {
|
|
224
|
+
"name": "RangeSlider",
|
|
225
|
+
"category": "form",
|
|
226
|
+
"files": [
|
|
227
|
+
"templates/form/range-slider.tsx"
|
|
228
|
+
],
|
|
229
|
+
"utils": [
|
|
230
|
+
"cn.ts"
|
|
231
|
+
],
|
|
232
|
+
"peerDependencies": [
|
|
233
|
+
"clsx",
|
|
234
|
+
"tailwind-merge"
|
|
235
|
+
]
|
|
236
|
+
},
|
|
237
|
+
"form/switch": {
|
|
238
|
+
"name": "Switch",
|
|
239
|
+
"category": "form",
|
|
240
|
+
"files": [
|
|
241
|
+
"templates/form/switch.tsx"
|
|
242
|
+
],
|
|
243
|
+
"utils": [
|
|
244
|
+
"cn.ts"
|
|
245
|
+
],
|
|
246
|
+
"peerDependencies": [
|
|
247
|
+
"clsx",
|
|
248
|
+
"tailwind-merge"
|
|
249
|
+
]
|
|
250
|
+
},
|
|
251
|
+
"form/textarea": {
|
|
252
|
+
"name": "Textarea",
|
|
253
|
+
"category": "form",
|
|
254
|
+
"files": [
|
|
255
|
+
"templates/form/textarea.tsx"
|
|
256
|
+
],
|
|
257
|
+
"utils": [
|
|
258
|
+
"cn.ts"
|
|
259
|
+
],
|
|
260
|
+
"peerDependencies": [
|
|
261
|
+
"clsx",
|
|
262
|
+
"tailwind-merge"
|
|
263
|
+
]
|
|
264
|
+
},
|
|
265
|
+
"form/time-picker": {
|
|
266
|
+
"name": "TimePicker",
|
|
267
|
+
"category": "form",
|
|
268
|
+
"files": [
|
|
269
|
+
"templates/form/time-picker.tsx"
|
|
270
|
+
],
|
|
271
|
+
"utils": [
|
|
272
|
+
"cn.ts"
|
|
273
|
+
],
|
|
274
|
+
"peerDependencies": [
|
|
275
|
+
"clsx",
|
|
276
|
+
"tailwind-merge",
|
|
277
|
+
"lucide-react",
|
|
278
|
+
"@floating-ui/react"
|
|
279
|
+
],
|
|
280
|
+
"dependencies": [
|
|
281
|
+
"select-dropdown/select-input"
|
|
282
|
+
]
|
|
283
|
+
},
|
|
284
|
+
"form/time-range-picker": {
|
|
285
|
+
"name": "TimeRangePicker",
|
|
286
|
+
"category": "form",
|
|
287
|
+
"files": [
|
|
288
|
+
"templates/form/time-range-picker.tsx"
|
|
289
|
+
],
|
|
290
|
+
"utils": [
|
|
291
|
+
"cn.ts"
|
|
292
|
+
],
|
|
293
|
+
"peerDependencies": [
|
|
294
|
+
"clsx",
|
|
295
|
+
"tailwind-merge",
|
|
296
|
+
"lucide-react",
|
|
297
|
+
"@floating-ui/react"
|
|
298
|
+
],
|
|
299
|
+
"dependencies": [
|
|
300
|
+
"select-dropdown/select-input"
|
|
301
|
+
]
|
|
302
|
+
},
|
|
303
|
+
"form/url-input": {
|
|
304
|
+
"name": "UrlInput",
|
|
305
|
+
"category": "form",
|
|
306
|
+
"files": [
|
|
307
|
+
"templates/form/url-input.tsx"
|
|
308
|
+
],
|
|
309
|
+
"utils": [
|
|
310
|
+
"cn.ts"
|
|
311
|
+
],
|
|
312
|
+
"peerDependencies": [
|
|
313
|
+
"clsx",
|
|
314
|
+
"tailwind-merge",
|
|
315
|
+
"lucide-react"
|
|
316
|
+
]
|
|
317
|
+
},
|
|
318
|
+
"dropdown/select-input": {
|
|
319
|
+
"name": "SelectInput",
|
|
320
|
+
"category": "select-dropdown",
|
|
321
|
+
"files": [
|
|
322
|
+
"templates/select-dropdown/select-input.tsx"
|
|
323
|
+
],
|
|
324
|
+
"utils": [
|
|
325
|
+
"cn.ts"
|
|
326
|
+
],
|
|
327
|
+
"peerDependencies": [
|
|
328
|
+
"clsx",
|
|
329
|
+
"tailwind-merge",
|
|
330
|
+
"lucide-react",
|
|
331
|
+
"@floating-ui/react"
|
|
332
|
+
]
|
|
333
|
+
},
|
|
334
|
+
"dropdown/multiselect-input": {
|
|
335
|
+
"name": "MultiselectInput",
|
|
336
|
+
"category": "select-dropdown",
|
|
337
|
+
"files": [
|
|
338
|
+
"templates/select-dropdown/multiselect-input.tsx"
|
|
339
|
+
],
|
|
340
|
+
"utils": [
|
|
341
|
+
"cn.ts"
|
|
342
|
+
],
|
|
343
|
+
"peerDependencies": [
|
|
344
|
+
"clsx",
|
|
345
|
+
"tailwind-merge",
|
|
346
|
+
"lucide-react",
|
|
347
|
+
"@floating-ui/react"
|
|
348
|
+
]
|
|
349
|
+
}
|
|
350
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "@/utils/cn";
|
|
3
|
+
|
|
4
|
+
import { Tooltip } from "./tooltip";
|
|
5
|
+
|
|
6
|
+
/* ─────────────────────────────────────────────
|
|
7
|
+
Types
|
|
8
|
+
───────────────────────────────────────────── */
|
|
9
|
+
|
|
10
|
+
export type ButtonVariant =
|
|
11
|
+
/* ── Solid (filled bg, white text) ──────── */
|
|
12
|
+
| "primary"
|
|
13
|
+
| "danger"
|
|
14
|
+
| "success"
|
|
15
|
+
| "warning"
|
|
16
|
+
| "black"
|
|
17
|
+
| "white"
|
|
18
|
+
/* ── Soft (coloured text, tinted bg) ────── */
|
|
19
|
+
| "primary-soft"
|
|
20
|
+
| "danger-soft"
|
|
21
|
+
| "success-soft"
|
|
22
|
+
| "warning-soft"
|
|
23
|
+
| "black-soft"
|
|
24
|
+
| "white-soft"
|
|
25
|
+
/* ── Ghost (transparent bg → soft on hover) ─ */
|
|
26
|
+
| "primary-ghost"
|
|
27
|
+
| "danger-ghost"
|
|
28
|
+
| "success-ghost"
|
|
29
|
+
| "warning-ghost"
|
|
30
|
+
| "black-ghost"
|
|
31
|
+
| "white-ghost";
|
|
32
|
+
export type RoundedStyle = "full" | "lg" | "md" | "sm" | "none";
|
|
33
|
+
|
|
34
|
+
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
35
|
+
children?: React.ReactNode;
|
|
36
|
+
variant?: ButtonVariant;
|
|
37
|
+
rounded?: RoundedStyle;
|
|
38
|
+
/** Removes all hover and active state classes from the variant. */
|
|
39
|
+
disableHoverEffect?: boolean;
|
|
40
|
+
/** When true the button is disabled and shows the loader or fallback text. */
|
|
41
|
+
isLoading?: boolean;
|
|
42
|
+
/** Custom loader — pass any React node (e.g. a spinner component). */
|
|
43
|
+
loader?: React.ReactNode;
|
|
44
|
+
/** Sets width to 100% of the parent container. */
|
|
45
|
+
fullWidth?: boolean;
|
|
46
|
+
/** Optional content to show inside a hover tooltip. */
|
|
47
|
+
tooltipContent?: React.ReactNode | string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
/* ─────────────────────────────────────────────
|
|
52
|
+
Maps
|
|
53
|
+
───────────────────────────────────────────── */
|
|
54
|
+
|
|
55
|
+
/*
|
|
56
|
+
* Solid variants — filled background, white (or black for 'white') text.
|
|
57
|
+
* Soft variants — coloured text + bg-current/10. Tint always matches text.
|
|
58
|
+
* Ghost variants — transparent bg, coloured text. On hover, bg-current/10
|
|
59
|
+
* fades in (same soft tint), giving a clean "reveal on hover" effect.
|
|
60
|
+
* bg-transparent → hover:bg-current/10 → active:bg-current/15
|
|
61
|
+
*/
|
|
62
|
+
const variantMap: Record<ButtonVariant, string> = {
|
|
63
|
+
/* ── Solid ─────────────────────────────────────────── */
|
|
64
|
+
primary: "bg-sky-500 hover:bg-sky-600 active:bg-sky-700 text-white",
|
|
65
|
+
danger: "bg-red-600 hover:bg-red-700 active:bg-red-800 text-white",
|
|
66
|
+
success: "bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white",
|
|
67
|
+
warning: "bg-amber-500 hover:bg-amber-600 active:bg-amber-700 text-white",
|
|
68
|
+
black: "bg-black hover:bg-black/80 active:bg-black/70 text-white",
|
|
69
|
+
white: "bg-white hover:bg-white/80 active:bg-white/70 text-black",
|
|
70
|
+
|
|
71
|
+
/* ── Soft (coloured text, always-visible tint) ───────── */
|
|
72
|
+
"primary-soft": "text-sky-500 bg-current/10 hover:bg-current/15 active:bg-current/20",
|
|
73
|
+
"danger-soft": "text-red-600 bg-current/10 hover:bg-current/15 active:bg-current/20",
|
|
74
|
+
"success-soft": "text-emerald-600 bg-current/10 hover:bg-current/15 active:bg-current/20",
|
|
75
|
+
"warning-soft": "text-amber-500 bg-current/10 hover:bg-current/15 active:bg-current/20",
|
|
76
|
+
"black-soft": "text-black bg-current/10 hover:bg-current/15 active:bg-current/20",
|
|
77
|
+
"white-soft": "text-white bg-current/10 hover:bg-current/15 active:bg-current/20",
|
|
78
|
+
|
|
79
|
+
/* ── Ghost (transparent → soft tint on hover) ──────── */
|
|
80
|
+
"primary-ghost": "text-sky-500 bg-transparent hover:bg-current/10 active:bg-current/15",
|
|
81
|
+
"danger-ghost": "text-red-600 bg-transparent hover:bg-current/10 active:bg-current/15",
|
|
82
|
+
"success-ghost": "text-emerald-600 bg-transparent hover:bg-current/10 active:bg-current/15",
|
|
83
|
+
"warning-ghost": "text-amber-500 bg-transparent hover:bg-current/10 active:bg-current/15",
|
|
84
|
+
"black-ghost": "text-black bg-transparent hover:bg-current/10 active:bg-current/15",
|
|
85
|
+
"white-ghost": "text-white bg-transparent hover:bg-current/10 active:bg-current/15",
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const roundedMap: Record<RoundedStyle, string> = {
|
|
89
|
+
full: "rounded-full",
|
|
90
|
+
lg: "rounded-lg",
|
|
91
|
+
md: "rounded-md",
|
|
92
|
+
sm: "rounded-sm",
|
|
93
|
+
none: "rounded-none",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/* Strip hover: and active: modifier classes so the button stays visually static */
|
|
97
|
+
const stripInteractive = (classes: string) =>
|
|
98
|
+
classes
|
|
99
|
+
.split(" ")
|
|
100
|
+
.filter((c) => !c.startsWith("hover:") && !c.startsWith("active:"))
|
|
101
|
+
.join(" ");
|
|
102
|
+
|
|
103
|
+
export const Button = ({
|
|
104
|
+
variant = "primary",
|
|
105
|
+
rounded = "lg",
|
|
106
|
+
disableHoverEffect = false,
|
|
107
|
+
isLoading = false,
|
|
108
|
+
loader,
|
|
109
|
+
fullWidth = false,
|
|
110
|
+
tooltipContent,
|
|
111
|
+
className,
|
|
112
|
+
children,
|
|
113
|
+
type = "button",
|
|
114
|
+
disabled,
|
|
115
|
+
...props
|
|
116
|
+
}: ButtonProps) => {
|
|
117
|
+
/* Resolve variant classes, stripping hover/active when disableHoverEffect is set */
|
|
118
|
+
const variantClasses = disableHoverEffect
|
|
119
|
+
? stripInteractive(variantMap[variant])
|
|
120
|
+
: variantMap[variant];
|
|
121
|
+
|
|
122
|
+
/* Render content: loader node / fallback text / normal children */
|
|
123
|
+
const content = isLoading
|
|
124
|
+
? (loader ?? <span className="text-sm font-medium">Loading…</span>)
|
|
125
|
+
: children;
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<Tooltip content={tooltipContent}>
|
|
129
|
+
<button
|
|
130
|
+
type={type}
|
|
131
|
+
disabled={disabled || isLoading}
|
|
132
|
+
className={cn(
|
|
133
|
+
/* Base layout & typography */
|
|
134
|
+
"inline-flex items-center justify-center gap-2 px-5 h-9",
|
|
135
|
+
"whitespace-nowrap text-sm font-medium",
|
|
136
|
+
/* Smooth state transitions */
|
|
137
|
+
"transition-colors duration-150 cursor-pointer",
|
|
138
|
+
/* Keyboard focus ring (Accessibility) */
|
|
139
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-sky-500",
|
|
140
|
+
/* Disabled state (covers isLoading too) */
|
|
141
|
+
"disabled:opacity-50 disabled:cursor-not-allowed",
|
|
142
|
+
/* Full width utility */
|
|
143
|
+
fullWidth && "w-full",
|
|
144
|
+
variantClasses,
|
|
145
|
+
roundedMap[rounded],
|
|
146
|
+
className,
|
|
147
|
+
)}
|
|
148
|
+
{...props}
|
|
149
|
+
>
|
|
150
|
+
{content}
|
|
151
|
+
</button>
|
|
152
|
+
</Tooltip>
|
|
153
|
+
);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import {
|
|
3
|
+
useFloating,
|
|
4
|
+
autoUpdate,
|
|
5
|
+
offset,
|
|
6
|
+
flip,
|
|
7
|
+
shift,
|
|
8
|
+
useHover,
|
|
9
|
+
useFocus,
|
|
10
|
+
useDismiss,
|
|
11
|
+
useRole,
|
|
12
|
+
useInteractions,
|
|
13
|
+
FloatingPortal,
|
|
14
|
+
type Placement,
|
|
15
|
+
} from "@floating-ui/react";
|
|
16
|
+
import { cn } from "@/utils/cn";
|
|
17
|
+
|
|
18
|
+
interface TooltipProps {
|
|
19
|
+
children: React.ReactNode | string;
|
|
20
|
+
content?: React.ReactNode | string | null | undefined;
|
|
21
|
+
className?: string;
|
|
22
|
+
/** Position of the tooltip relative to the trigger. Defaults to "top" */
|
|
23
|
+
direction?: Placement;
|
|
24
|
+
/** Whether the tooltip should be disabled */
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
/** Custom class for the reference wrapper element */
|
|
27
|
+
wrapperClassName?: string;
|
|
28
|
+
/** If true, the reference wrapper spans 100% width instead of shrink-wrapping */
|
|
29
|
+
fullWidth?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A custom Tooltip component built with Floating UI for professional positioning
|
|
35
|
+
* and Portals to avoid container clipping (overflow: hidden).
|
|
36
|
+
*/
|
|
37
|
+
export const Tooltip = ({
|
|
38
|
+
children,
|
|
39
|
+
content = null,
|
|
40
|
+
className,
|
|
41
|
+
direction = "top",
|
|
42
|
+
disabled = false,
|
|
43
|
+
wrapperClassName,
|
|
44
|
+
fullWidth = false,
|
|
45
|
+
}: TooltipProps) => {
|
|
46
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
47
|
+
|
|
48
|
+
// Force close tooltip when it becomes disabled or content is removed
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const isContentEmpty = !content || (typeof content === "string" && content.trim() === "");
|
|
51
|
+
if (disabled || isContentEmpty) {
|
|
52
|
+
setIsOpen(false);
|
|
53
|
+
}
|
|
54
|
+
}, [disabled, content]);
|
|
55
|
+
|
|
56
|
+
// 1. Setup Floating UI logic
|
|
57
|
+
const { refs, floatingStyles, context } = useFloating({
|
|
58
|
+
open: isOpen && !disabled && !!content,
|
|
59
|
+
onOpenChange: setIsOpen,
|
|
60
|
+
placement: direction,
|
|
61
|
+
whileElementsMounted: autoUpdate,
|
|
62
|
+
middleware: [
|
|
63
|
+
offset(16), // Matches Spotify's gap
|
|
64
|
+
flip({ fallbackAxisSideDirection: "start" }), // Flips if hits screen edge
|
|
65
|
+
shift({ padding: 5 }), // Shifts slightly if hitting side edge
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// 2. Setup Interactions
|
|
70
|
+
const hover = useHover(context, { move: false, delay: 50 });
|
|
71
|
+
const focus = useFocus(context);
|
|
72
|
+
const dismiss = useDismiss(context);
|
|
73
|
+
const role = useRole(context, { role: "tooltip" });
|
|
74
|
+
|
|
75
|
+
const { getReferenceProps, getFloatingProps } = useInteractions([
|
|
76
|
+
hover,
|
|
77
|
+
focus,
|
|
78
|
+
dismiss,
|
|
79
|
+
role,
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
if (disabled) {
|
|
83
|
+
return <>{children}</>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// If disabled or no content provided, just return the trigger without the wrapper div
|
|
87
|
+
if (!content || (typeof content === "string" && content.trim() === "")) {
|
|
88
|
+
return <>{children}</>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<>
|
|
93
|
+
{/* Trigger element - wrapped in a div to serve as the reference point */}
|
|
94
|
+
<div
|
|
95
|
+
ref={refs.setReference}
|
|
96
|
+
{...getReferenceProps()}
|
|
97
|
+
className={cn(
|
|
98
|
+
fullWidth ? "w-full flex items-center min-w-0" : "w-fit inline-flex items-center min-w-0",
|
|
99
|
+
wrapperClassName
|
|
100
|
+
)}
|
|
101
|
+
>
|
|
102
|
+
{children}
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
{/* Floating element - rendered in a Portal to break out of overflow:hidden parents */}
|
|
106
|
+
{isOpen && (
|
|
107
|
+
<FloatingPortal>
|
|
108
|
+
<div
|
|
109
|
+
ref={refs.setFloating}
|
|
110
|
+
style={floatingStyles}
|
|
111
|
+
{...getFloatingProps()}
|
|
112
|
+
className={cn(
|
|
113
|
+
"z-9999 px-2 py-1 text-xs font-medium text-black bg-gray-100 rounded-sm max-w-xs whitespace-normal break-words pointer-events-none",
|
|
114
|
+
isOpen ? "opacity-100 scale-100" : "opacity-0 scale-95",
|
|
115
|
+
className,
|
|
116
|
+
)}
|
|
117
|
+
>
|
|
118
|
+
{content}
|
|
119
|
+
</div>
|
|
120
|
+
</FloatingPortal>
|
|
121
|
+
)}
|
|
122
|
+
</>
|
|
123
|
+
);
|
|
124
|
+
};
|