@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.
- package/README.md +220 -4
- package/agents/slack-base-ui/SKILL.md +137 -0
- package/agents/slack-base-ui/checklists/style-review.md +56 -0
- package/agents/slack-base-ui/templates/consumer-setup.md +109 -0
- package/agents/slack-base-ui/templates/slack-theme.css +152 -0
- package/libs/Dialog.d.ts +73 -0
- package/libs/Dialog.d.ts.map +1 -1
- package/libs/Popover.d.ts +69 -0
- package/libs/Popover.d.ts.map +1 -1
- package/libs/index.d.ts +4 -4
- package/libs/index.d.ts.map +1 -1
- package/libs/index.js +2885 -2718
- package/package.json +1 -1
- package/src/App.css +7 -0
- package/src/App.tsx +18 -0
- package/src/assets/react.svg +1 -0
- package/src/components/AlertDialog.tsx +185 -0
- package/src/components/AutoComplete.tsx +311 -0
- package/src/components/Avatar.tsx +70 -0
- package/src/components/Badge.tsx +48 -0
- package/src/components/Button.tsx +53 -0
- package/src/components/Checkbox.tsx +109 -0
- package/src/components/ContextMenu.tsx +393 -0
- package/src/components/Dialog.tsx +371 -0
- package/src/components/Form.tsx +409 -0
- package/src/components/IconButton.tsx +49 -0
- package/src/components/Input.tsx +56 -0
- package/src/components/Loading.tsx +123 -0
- package/src/components/Menu.tsx +368 -0
- package/src/components/Popover.tsx +367 -0
- package/src/components/Progress.tsx +89 -0
- package/src/components/Radio.tsx +137 -0
- package/src/components/Select.tsx +177 -0
- package/src/components/Switch.tsx +116 -0
- package/src/components/Tabs.tsx +128 -0
- package/src/components/Toast.tsx +149 -0
- package/src/components/Tooltip.tsx +46 -0
- package/src/components/index.ts +186 -0
- package/src/context/ThemeContext.tsx +53 -0
- package/src/context/useTheme.ts +11 -0
- package/src/examples/slack-clone/SlackApp.tsx +94 -0
- package/src/examples/slack-clone/components/ChannelHeader.tsx +34 -0
- package/src/examples/slack-clone/components/Composer.tsx +42 -0
- package/src/examples/slack-clone/components/Message.tsx +97 -0
- package/src/examples/slack-clone/components/UserProfile.tsx +78 -0
- package/src/examples/slack-clone/layout/Layout.tsx +27 -0
- package/src/examples/slack-clone/layout/Sidebar.tsx +67 -0
- package/src/examples/slack-clone/layout/SidebarItem.tsx +57 -0
- package/src/examples/slack-clone/layout/TopBar.tsx +30 -0
- package/src/index.css +240 -0
- package/src/main.tsx +22 -0
- package/src/pages/ComponentShowcase.tsx +1964 -0
- package/src/pages/Dashboard.tsx +87 -0
- 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
|
+
|