@xyhp915/slack-base-ui 0.0.1 → 0.0.3

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.
Files changed (54) hide show
  1. package/README.md +220 -4
  2. package/agents/slack-base-ui/SKILL.md +137 -0
  3. package/agents/slack-base-ui/checklists/style-review.md +56 -0
  4. package/agents/slack-base-ui/templates/consumer-setup.md +109 -0
  5. package/agents/slack-base-ui/templates/slack-theme.css +152 -0
  6. package/libs/Dialog.d.ts +73 -0
  7. package/libs/Dialog.d.ts.map +1 -1
  8. package/libs/Popover.d.ts +69 -0
  9. package/libs/Popover.d.ts.map +1 -1
  10. package/libs/index.d.ts +4 -4
  11. package/libs/index.d.ts.map +1 -1
  12. package/libs/index.js +2885 -2718
  13. package/package.json +1 -1
  14. package/src/App.css +7 -0
  15. package/src/App.tsx +18 -0
  16. package/src/assets/react.svg +1 -0
  17. package/src/components/AlertDialog.tsx +185 -0
  18. package/src/components/AutoComplete.tsx +311 -0
  19. package/src/components/Avatar.tsx +70 -0
  20. package/src/components/Badge.tsx +48 -0
  21. package/src/components/Button.tsx +53 -0
  22. package/src/components/Checkbox.tsx +109 -0
  23. package/src/components/ContextMenu.tsx +393 -0
  24. package/src/components/Dialog.tsx +371 -0
  25. package/src/components/Form.tsx +409 -0
  26. package/src/components/IconButton.tsx +49 -0
  27. package/src/components/Input.tsx +56 -0
  28. package/src/components/Loading.tsx +123 -0
  29. package/src/components/Menu.tsx +368 -0
  30. package/src/components/Popover.tsx +367 -0
  31. package/src/components/Progress.tsx +89 -0
  32. package/src/components/Radio.tsx +137 -0
  33. package/src/components/Select.tsx +177 -0
  34. package/src/components/Switch.tsx +116 -0
  35. package/src/components/Tabs.tsx +128 -0
  36. package/src/components/Toast.tsx +149 -0
  37. package/src/components/Tooltip.tsx +46 -0
  38. package/src/components/index.ts +186 -0
  39. package/src/context/ThemeContext.tsx +53 -0
  40. package/src/context/useTheme.ts +11 -0
  41. package/src/examples/slack-clone/SlackApp.tsx +94 -0
  42. package/src/examples/slack-clone/components/ChannelHeader.tsx +34 -0
  43. package/src/examples/slack-clone/components/Composer.tsx +42 -0
  44. package/src/examples/slack-clone/components/Message.tsx +97 -0
  45. package/src/examples/slack-clone/components/UserProfile.tsx +78 -0
  46. package/src/examples/slack-clone/layout/Layout.tsx +27 -0
  47. package/src/examples/slack-clone/layout/Sidebar.tsx +67 -0
  48. package/src/examples/slack-clone/layout/SidebarItem.tsx +57 -0
  49. package/src/examples/slack-clone/layout/TopBar.tsx +30 -0
  50. package/src/index.css +240 -0
  51. package/src/main.tsx +22 -0
  52. package/src/pages/ComponentShowcase.tsx +1964 -0
  53. package/src/pages/Dashboard.tsx +87 -0
  54. package/src/pages/QuickStartDemo.tsx +262 -0
@@ -0,0 +1,409 @@
1
+ import clsx from 'clsx'
2
+ import React from 'react'
3
+
4
+ // Form Context for managing form state
5
+ interface FormContextValue {
6
+ errors: Record<string, string>;
7
+ touched: Record<string, boolean>;
8
+ values: Record<string, any>;
9
+ setFieldValue: (name: string, value: any) => void;
10
+ setFieldTouched: (name: string, touched: boolean) => void;
11
+ setFieldError: (name: string, error: string) => void;
12
+ }
13
+
14
+ const FormContext = React.createContext<FormContextValue | null>(null)
15
+
16
+ export const useFormContext = () => {
17
+ const context = React.useContext(FormContext)
18
+ if (!context) {
19
+ throw new Error('Form components must be used within a Form component')
20
+ }
21
+ return context
22
+ }
23
+
24
+ // Main Form Component
25
+ export interface FormProps extends Omit<React.FormHTMLAttributes<HTMLFormElement>, 'onSubmit'> {
26
+ children: React.ReactNode;
27
+ onSubmit: (values: Record<string, any>) => void | Promise<void>;
28
+ initialValues?: Record<string, any>;
29
+ validate?: (values: Record<string, any>) => Record<string, string>;
30
+ }
31
+
32
+ export const Form = ({
33
+ children,
34
+ onSubmit,
35
+ initialValues = {},
36
+ validate,
37
+ className,
38
+ ...props
39
+ }: FormProps) => {
40
+ const [values, setValues] = React.useState<Record<string, any>>(initialValues)
41
+ const [errors, setErrors] = React.useState<Record<string, string>>({})
42
+ const [touched, setTouched] = React.useState<Record<string, boolean>>({})
43
+
44
+ const setFieldValue = React.useCallback((name: string, value: any) => {
45
+ setValues(prev => ({ ...prev, [name]: value }))
46
+ }, [])
47
+
48
+ const setFieldTouched = React.useCallback((name: string, isTouched: boolean) => {
49
+ setTouched(prev => ({ ...prev, [name]: isTouched }))
50
+ }, [])
51
+
52
+ const setFieldError = React.useCallback((name: string, error: string) => {
53
+ setErrors(prev => ({ ...prev, [name]: error }))
54
+ }, [])
55
+
56
+ const handleSubmit = async (e: React.FormEvent) => {
57
+ e.preventDefault()
58
+
59
+ // Validate all fields
60
+ const validationErrors = validate?.(values) ?? {}
61
+ setErrors(validationErrors)
62
+
63
+ // Mark all fields as touched
64
+ const allTouched = Object.keys(values).reduce((acc, key) => {
65
+ acc[key] = true
66
+ return acc
67
+ }, {} as Record<string, boolean>)
68
+ setTouched(allTouched)
69
+
70
+ // If no errors, submit
71
+ if (Object.keys(validationErrors).length === 0) {
72
+ try {
73
+ await onSubmit(values)
74
+ } catch (error) {
75
+ console.error('Form submission error:', error)
76
+ }
77
+ }
78
+ }
79
+
80
+ const contextValue: FormContextValue = {
81
+ errors,
82
+ touched,
83
+ values,
84
+ setFieldValue,
85
+ setFieldTouched,
86
+ setFieldError,
87
+ }
88
+
89
+ return (
90
+ <FormContext.Provider value={contextValue}>
91
+ <form onSubmit={handleSubmit} className={clsx('space-y-4', className)} {...props}>
92
+ {children}
93
+ </form>
94
+ </FormContext.Provider>
95
+ )
96
+ }
97
+
98
+ Form.displayName = 'Form'
99
+
100
+ // FormField Component
101
+ export interface FormFieldProps {
102
+ name: string;
103
+ label?: string;
104
+ children: (props: {
105
+ value: any;
106
+ onChange: (value: any) => void;
107
+ onBlur: () => void;
108
+ error?: string;
109
+ touched: boolean;
110
+ }) => React.ReactNode;
111
+ required?: boolean;
112
+ className?: string;
113
+ }
114
+
115
+ export const FormField = ({ name, label, children, required, className }: FormFieldProps) => {
116
+ const { values, errors, touched, setFieldValue, setFieldTouched } = useFormContext()
117
+
118
+ const value = values[name] ?? ''
119
+ const error = touched[name] ? errors[name] : undefined
120
+ const isTouched = touched[name] ?? false
121
+
122
+ const handleChange = (newValue: any) => {
123
+ setFieldValue(name, newValue)
124
+ }
125
+
126
+ const handleBlur = () => {
127
+ setFieldTouched(name, true)
128
+ }
129
+
130
+ return (
131
+ <div className={clsx('flex flex-col gap-1.5', className)}>
132
+ {label && (
133
+ <label htmlFor={name} className="text-[14px] font-semibold text-(--text-primary)">
134
+ {label}
135
+ {required && <span className="text-(--danger) ml-1">*</span>}
136
+ </label>
137
+ )}
138
+ {children({ value, onChange: handleChange, onBlur: handleBlur, error, touched: isTouched })}
139
+ </div>
140
+ )
141
+ }
142
+
143
+ FormField.displayName = 'FormField'
144
+
145
+ // FormInput Component - Wrapper around Input
146
+ export interface FormInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'name' | 'value' | 'onChange'> {
147
+ name: string;
148
+ label?: string;
149
+ required?: boolean;
150
+ fullWidth?: boolean;
151
+ }
152
+
153
+ export const FormInput = React.forwardRef<HTMLInputElement, FormInputProps>(
154
+ ({ name, label, required, fullWidth, className, ...props }, ref) => {
155
+ const { values, errors, touched, setFieldValue, setFieldTouched } = useFormContext()
156
+
157
+ const value = values[name] ?? ''
158
+ const error = touched[name] ? errors[name] : undefined
159
+
160
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
161
+ setFieldValue(name, e.target.value)
162
+ }
163
+
164
+ const handleBlur = () => {
165
+ setFieldTouched(name, true)
166
+ }
167
+
168
+ return (
169
+ <div className={clsx('flex flex-col gap-1.5', fullWidth && 'w-full')}>
170
+ {label && (
171
+ <label htmlFor={name} className="text-[14px] font-semibold text-(--text-primary)">
172
+ {label}
173
+ {required && <span className="text-(--danger) ml-1">*</span>}
174
+ </label>
175
+ )}
176
+ <input
177
+ ref={ref}
178
+ id={name}
179
+ name={name}
180
+ value={value}
181
+ onChange={handleChange}
182
+ onBlur={handleBlur}
183
+ className={clsx(
184
+ 'appearance-none block w-full rounded-md border text-[15px] leading-[1.4] shadow-sm',
185
+ 'px-3 py-2 bg-(--bg-primary) text-(--text-primary) placeholder-(--text-muted)',
186
+ 'transition-[border-color,box-shadow,background-color] focus:outline-none',
187
+ 'focus:border-(--accent)/70',
188
+ 'disabled:bg-(--bg-primary) disabled:text-(--text-muted) disabled:cursor-not-allowed',
189
+ error
190
+ ? 'border-(--danger) focus:border-(--danger) focus:shadow-[0_0_0_2px_var(--danger)]'
191
+ : 'border-(--border-light) hover:border-(--text-primary)/30',
192
+ className,
193
+ )}
194
+ {...props}
195
+ />
196
+ {error && (
197
+ <span className="text-[12px] font-medium text-(--danger) flex items-center gap-1 leading-tight">
198
+ ⚠️ {error}
199
+ </span>
200
+ )}
201
+ </div>
202
+ )
203
+ }
204
+ )
205
+
206
+ FormInput.displayName = 'FormInput'
207
+
208
+ // FormTextarea Component
209
+ export interface FormTextareaProps extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'name' | 'value' | 'onChange'> {
210
+ name: string;
211
+ label?: string;
212
+ required?: boolean;
213
+ fullWidth?: boolean;
214
+ }
215
+
216
+ export const FormTextarea = React.forwardRef<HTMLTextAreaElement, FormTextareaProps>(
217
+ ({ name, label, required, fullWidth, className, ...props }, ref) => {
218
+ const { values, errors, touched, setFieldValue, setFieldTouched } = useFormContext()
219
+
220
+ const value = values[name] ?? ''
221
+ const error = touched[name] ? errors[name] : undefined
222
+
223
+ const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
224
+ setFieldValue(name, e.target.value)
225
+ }
226
+
227
+ const handleBlur = () => {
228
+ setFieldTouched(name, true)
229
+ }
230
+
231
+ return (
232
+ <div className={clsx('flex flex-col gap-1.5', fullWidth && 'w-full')}>
233
+ {label && (
234
+ <label htmlFor={name} className="text-[14px] font-semibold text-(--text-primary)">
235
+ {label}
236
+ {required && <span className="text-(--danger) ml-1">*</span>}
237
+ </label>
238
+ )}
239
+ <textarea
240
+ ref={ref}
241
+ id={name}
242
+ name={name}
243
+ value={value}
244
+ onChange={handleChange}
245
+ onBlur={handleBlur}
246
+ className={clsx(
247
+ 'appearance-none block w-full rounded-md border text-[15px] leading-[1.4] shadow-sm',
248
+ 'px-3 py-2 bg-(--bg-primary) text-(--text-primary) placeholder-(--text-muted)',
249
+ 'transition-[border-color,box-shadow,background-color] focus:outline-none',
250
+ 'focus:border-(--accent)/70',
251
+ 'disabled:bg-(--bg-primary) disabled:text-(--text-muted) disabled:cursor-not-allowed',
252
+ 'resize-y min-h-20',
253
+ error
254
+ ? 'border-(--danger) focus:border-(--danger) focus:shadow-[0_0_0_2px_var(--danger)]'
255
+ : 'border-(--border-light) hover:border-(--text-primary)/30',
256
+ className,
257
+ )}
258
+ {...props}
259
+ />
260
+ {error && (
261
+ <span className="text-[12px] font-medium text-(--danger) flex items-center gap-1 leading-tight">
262
+ ⚠️ {error}
263
+ </span>
264
+ )}
265
+ </div>
266
+ )
267
+ }
268
+ )
269
+
270
+ FormTextarea.displayName = 'FormTextarea'
271
+
272
+ // FormSelect Component
273
+ export interface FormSelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'name' | 'value' | 'onChange'> {
274
+ name: string;
275
+ label?: string;
276
+ required?: boolean;
277
+ fullWidth?: boolean;
278
+ options: Array<{ value: string; label: string }>;
279
+ }
280
+
281
+ export const FormSelect = React.forwardRef<HTMLSelectElement, FormSelectProps>(
282
+ ({ name, label, required, fullWidth, options, className, ...props }, ref) => {
283
+ const { values, errors, touched, setFieldValue, setFieldTouched } = useFormContext()
284
+
285
+ const value = values[name] ?? ''
286
+ const error = touched[name] ? errors[name] : undefined
287
+
288
+ const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
289
+ setFieldValue(name, e.target.value)
290
+ }
291
+
292
+ const handleBlur = () => {
293
+ setFieldTouched(name, true)
294
+ }
295
+
296
+ return (
297
+ <div className={clsx('flex flex-col gap-1.5', fullWidth && 'w-full')}>
298
+ {label && (
299
+ <label htmlFor={name} className="text-[14px] font-semibold text-(--text-primary)">
300
+ {label}
301
+ {required && <span className="text-(--danger) ml-1">*</span>}
302
+ </label>
303
+ )}
304
+ <select
305
+ ref={ref}
306
+ id={name}
307
+ name={name}
308
+ value={value}
309
+ onChange={handleChange}
310
+ onBlur={handleBlur}
311
+ className={clsx(
312
+ 'appearance-none block w-full rounded-md border text-[15px] leading-[1.4] shadow-sm',
313
+ 'px-3 py-2 bg-(--bg-primary) text-(--text-primary)',
314
+ 'transition-[border-color,box-shadow,background-color] focus:outline-none',
315
+ 'focus:border-(--accent)/70',
316
+ 'disabled:bg-(--bg-primary) disabled:text-(--text-muted) disabled:cursor-not-allowed',
317
+ 'cursor-pointer',
318
+ error
319
+ ? 'border-(--danger) focus:border-(--danger) focus:shadow-[0_0_0_2px_var(--danger)]'
320
+ : 'border-(--border-light) hover:border-(--text-primary)/30',
321
+ className,
322
+ )}
323
+ {...props}
324
+ >
325
+ <option value="">Select an option</option>
326
+ {options.map((option) => (
327
+ <option key={option.value} value={option.value}>
328
+ {option.label}
329
+ </option>
330
+ ))}
331
+ </select>
332
+ {error && (
333
+ <span className="text-[12px] font-medium text-(--danger) flex items-center gap-1 leading-tight">
334
+ ⚠️ {error}
335
+ </span>
336
+ )}
337
+ </div>
338
+ )
339
+ }
340
+ )
341
+
342
+ FormSelect.displayName = 'FormSelect'
343
+
344
+ // FormCheckbox Component
345
+ export interface FormCheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'name' | 'type' | 'checked' | 'onChange'> {
346
+ name: string;
347
+ label: string;
348
+ }
349
+
350
+ export const FormCheckbox = React.forwardRef<HTMLInputElement, FormCheckboxProps>(
351
+ ({ name, label, className, ...props }, ref) => {
352
+ const { values, setFieldValue } = useFormContext()
353
+
354
+ const checked = values[name] ?? false
355
+
356
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
357
+ setFieldValue(name, e.target.checked)
358
+ }
359
+
360
+ return (
361
+ <div className="flex items-center gap-2">
362
+ <input
363
+ ref={ref}
364
+ id={name}
365
+ name={name}
366
+ type="checkbox"
367
+ checked={checked}
368
+ onChange={handleChange}
369
+ className={clsx(
370
+ 'w-4 h-4 rounded border-(--border-light) bg-(--bg-primary)',
371
+ 'text-(--slack-blue) focus:ring-2 focus:ring-(--slack-blue) focus:ring-offset-1',
372
+ 'cursor-pointer transition-colors',
373
+ 'disabled:cursor-not-allowed disabled:opacity-50',
374
+ className,
375
+ )}
376
+ {...props}
377
+ />
378
+ <label
379
+ htmlFor={name}
380
+ className="text-[15px] text-(--text-primary) cursor-pointer select-none"
381
+ >
382
+ {label}
383
+ </label>
384
+ </div>
385
+ )
386
+ }
387
+ )
388
+
389
+ FormCheckbox.displayName = 'FormCheckbox'
390
+
391
+ // FormActions Component - For submit and cancel buttons
392
+ export interface FormActionsProps {
393
+ children: React.ReactNode;
394
+ className?: string;
395
+ }
396
+
397
+ export const FormActions = ({ children, className }: FormActionsProps) => {
398
+ return (
399
+ <div className={clsx('flex items-center justify-end gap-3 pt-4', className)}>
400
+ {children}
401
+ </div>
402
+ )
403
+ }
404
+
405
+ FormActions.displayName = 'FormActions'
406
+
407
+
408
+
409
+
@@ -0,0 +1,49 @@
1
+ import clsx from 'clsx'
2
+ import { Button as BaseButton } from '@base-ui/react'
3
+ import type { ButtonProps as BaseButtonProps } from '@base-ui/react'
4
+ import React from 'react'
5
+
6
+ export interface IconButtonProps extends BaseButtonProps {
7
+ size?: 'sm' | 'md' | 'lg';
8
+ variant?: 'ghost' | 'filled';
9
+ active?: boolean;
10
+ }
11
+
12
+ export const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
13
+ ({ className, size = 'md', variant = 'ghost', active, children, ...props }, ref) => {
14
+
15
+ // Icon Button Styles (usually meant for tooltips or toolbar actions)
16
+ const baseStyles = 'inline-flex items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--focus-ring) disabled:opacity-50'
17
+
18
+ const variants = {
19
+ ghost: 'bg-transparent text-(--text-secondary) hover:bg-(--bg-hover) hover:text-(--text-primary)',
20
+ filled: 'bg-(--bg-secondary) text-(--text-primary) hover:bg-(--bg-hover)',
21
+ }
22
+
23
+ const activeStyles = active ? 'bg-(--bg-hover) text-(--accent)' : ''
24
+
25
+ const sizes = {
26
+ sm: 'h-7 w-7 p-1',
27
+ md: 'h-9 w-9 p-1.5',
28
+ lg: 'h-11 w-11 p-2',
29
+ }
30
+
31
+ return (
32
+ <BaseButton
33
+ ref={ref}
34
+ className={clsx(
35
+ baseStyles,
36
+ variants[variant],
37
+ sizes[size],
38
+ activeStyles,
39
+ className,
40
+ )}
41
+ {...props}
42
+ >
43
+ {children}
44
+ </BaseButton>
45
+ )
46
+ },
47
+ )
48
+
49
+ IconButton.displayName = 'IconButton'
@@ -0,0 +1,56 @@
1
+ import clsx from 'clsx'
2
+ import { Input as BaseInput } from '@base-ui/react'
3
+ import React from 'react'
4
+
5
+ export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
6
+ label?: string;
7
+ error?: string;
8
+ fullWidth?: boolean;
9
+ }
10
+
11
+ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
12
+ ({ className, label, error, fullWidth, id, ...props }, ref) => {
13
+ const generatedId = React.useId()
14
+ const inputId = id ?? generatedId
15
+
16
+ return (
17
+ <div className={clsx('flex flex-col gap-1.5', fullWidth && 'w-full')}>
18
+ {label && (
19
+ <label
20
+ htmlFor={inputId}
21
+ className="text-[14px] font-semibold text-(--text-primary)"
22
+ >
23
+ {label}
24
+ </label>
25
+ )}
26
+
27
+ <div className="relative">
28
+ <BaseInput
29
+ ref={ref}
30
+ id={inputId}
31
+ className={clsx(
32
+ 'appearance-none block w-full rounded-md border text-[15px] leading-[1.4] shadow-sm',
33
+ 'px-3 py-2 bg-(--bg-primary) text-(--text-primary) placeholder-(--text-muted)',
34
+ 'transition-[border-color,box-shadow,background-color] focus:outline-none',
35
+ 'focus:border-(--accent)/70',
36
+ 'disabled:bg-(--bg-primary) disabled:text-(--text-muted) disabled:cursor-not-allowed',
37
+ error
38
+ ? 'border-(--danger) focus:border-(--danger) focus:shadow-[0_0_0_2px_var(--danger)]'
39
+ : 'border-(--border-light) hover:border-(--text-primary)/30',
40
+ className,
41
+ )}
42
+ {...props}
43
+ />
44
+ </div>
45
+
46
+ {error && (
47
+ <span className="text-[12px] font-medium text-(--danger) flex items-center gap-1 leading-tight">
48
+ ⚠️ {error}
49
+ </span>
50
+ )}
51
+ </div>
52
+ )
53
+ },
54
+ )
55
+
56
+ Input.displayName = 'Input'
@@ -0,0 +1,123 @@
1
+ import React from 'react'
2
+ import clsx from 'clsx'
3
+
4
+ export type LoadingVariant = 'spinner' | 'dots' | 'bar'
5
+ export type LoadingSize = 'sm' | 'md' | 'lg'
6
+
7
+ export interface LoadingProps {
8
+ variant?: LoadingVariant
9
+ size?: LoadingSize
10
+ /** Optional label shown next to the indicator */
11
+ label?: string
12
+ /** Center within its container */
13
+ centered?: boolean
14
+ className?: string
15
+ /** Override color via Tailwind / CSS class */
16
+ color?: string
17
+ }
18
+
19
+ const spinnerSize: Record<LoadingSize, string> = {
20
+ sm: 'w-4 h-4 border-2',
21
+ md: 'w-6 h-6 border-2',
22
+ lg: 'w-9 h-9 border-[3px]',
23
+ }
24
+
25
+ const dotSize: Record<LoadingSize, string> = {
26
+ sm: 'w-1.5 h-1.5',
27
+ md: 'w-2.5 h-2.5',
28
+ lg: 'w-3.5 h-3.5',
29
+ }
30
+
31
+ const labelSize: Record<LoadingSize, string> = {
32
+ sm: 'text-[12px]',
33
+ md: 'text-[14px]',
34
+ lg: 'text-[16px]',
35
+ }
36
+
37
+ const barHeight: Record<LoadingSize, string> = {
38
+ sm: 'h-0.5',
39
+ md: 'h-1',
40
+ lg: 'h-1.5',
41
+ }
42
+
43
+ // Spinner variant ─────────────────────────────────────────────────────────────
44
+ const Spinner: React.FC<{ size: LoadingSize; color?: string }> = ({ size, color }) => (
45
+ <span
46
+ role="status"
47
+ aria-label="Loading"
48
+ className={clsx(
49
+ 'inline-block rounded-full animate-spin',
50
+ 'border-current border-t-transparent',
51
+ spinnerSize[size],
52
+ color ?? 'text-(--accent)',
53
+ )}
54
+ />
55
+ )
56
+
57
+ // Dots variant ────────────────────────────────────────────────────────────────
58
+ const Dots: React.FC<{ size: LoadingSize; color?: string }> = ({ size, color }) => (
59
+ <span role="status" aria-label="Loading" className="inline-flex items-center gap-1">
60
+ {[0, 1, 2].map((i) => (
61
+ <span
62
+ key={i}
63
+ className={clsx(
64
+ 'inline-block rounded-full animate-bounce',
65
+ dotSize[size],
66
+ color ?? 'bg-(--accent)',
67
+ )}
68
+ style={{ animationDelay: `${i * 0.15}s` }}
69
+ />
70
+ ))}
71
+ </span>
72
+ )
73
+
74
+ // Bar variant ─────────────────────────────────────────────────────────────────
75
+ const Bar: React.FC<{ size: LoadingSize; color?: string }> = ({ size, color }) => (
76
+ <span
77
+ role="status"
78
+ aria-label="Loading"
79
+ className={clsx('relative block w-full overflow-hidden rounded-full bg-(--bg-hover)', barHeight[size])}
80
+ >
81
+ <span
82
+ className={clsx(
83
+ 'absolute inset-y-0 w-1/3 rounded-full',
84
+ 'animate-[loading-bar_1.4s_ease-in-out_infinite]',
85
+ color ?? 'bg-(--accent)',
86
+ )}
87
+ />
88
+ </span>
89
+ )
90
+
91
+ // Main component ──────────────────────────────────────────────────────────────
92
+ export const Loading = React.forwardRef<HTMLDivElement, LoadingProps>(
93
+ ({ variant = 'spinner', size = 'md', label, centered, className, color }, ref) => {
94
+ const indicator =
95
+ variant === 'dots' ? (
96
+ <Dots size={size} color={color} />
97
+ ) : variant === 'bar' ? (
98
+ <Bar size={size} color={color} />
99
+ ) : (
100
+ <Spinner size={size} color={color} />
101
+ )
102
+
103
+ return (
104
+ <div
105
+ ref={ref}
106
+ className={clsx(
107
+ 'inline-flex items-center gap-2',
108
+ centered && 'w-full justify-center',
109
+ variant === 'bar' && 'w-full flex-col items-stretch gap-1.5',
110
+ className,
111
+ )}
112
+ >
113
+ {indicator}
114
+ {label && (
115
+ <span className={clsx('text-(--text-secondary)', labelSize[size])}>{label}</span>
116
+ )}
117
+ </div>
118
+ )
119
+ },
120
+ )
121
+
122
+ Loading.displayName = 'Loading'
123
+