@discourser/design-system 0.3.1 → 0.5.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 +12 -4
- package/dist/styles.css +5126 -0
- package/guidelines/Guidelines.md +92 -41
- package/guidelines/components/accordion.md +732 -0
- package/guidelines/components/avatar.md +1015 -0
- package/guidelines/components/badge.md +728 -0
- package/guidelines/components/button.md +75 -40
- package/guidelines/components/card.md +84 -25
- package/guidelines/components/checkbox.md +671 -0
- package/guidelines/components/dialog.md +619 -31
- package/guidelines/components/drawer.md +1616 -0
- package/guidelines/components/heading.md +576 -0
- package/guidelines/components/icon-button.md +92 -37
- package/guidelines/components/input-addon.md +685 -0
- package/guidelines/components/input-group.md +830 -0
- package/guidelines/components/input.md +92 -37
- package/guidelines/components/popover.md +1271 -0
- package/guidelines/components/progress.md +836 -0
- package/guidelines/components/radio-group.md +852 -0
- package/guidelines/components/select.md +1662 -0
- package/guidelines/components/skeleton.md +802 -0
- package/guidelines/components/slider.md +911 -0
- package/guidelines/components/spinner.md +783 -0
- package/guidelines/components/switch.md +105 -38
- package/guidelines/components/tabs.md +1488 -0
- package/guidelines/components/textarea.md +495 -0
- package/guidelines/components/toast.md +784 -0
- package/guidelines/components/tooltip.md +912 -0
- package/guidelines/design-tokens/colors.md +309 -72
- package/guidelines/design-tokens/elevation.md +615 -45
- package/guidelines/design-tokens/spacing.md +654 -74
- package/guidelines/design-tokens/typography.md +432 -50
- package/guidelines/overview-components.md +60 -8
- package/guidelines/overview-imports.md +314 -0
- package/guidelines/overview-patterns.md +3852 -0
- package/package.json +4 -2
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
# Spinner
|
|
2
|
+
|
|
3
|
+
**Purpose:** Loading indicator that provides visual feedback during asynchronous operations following Material Design 3 patterns.
|
|
4
|
+
|
|
5
|
+
## When to Use This Component
|
|
6
|
+
|
|
7
|
+
Use Spinner when you need to **indicate loading or processing states** to users during async operations (data fetching, form submission, page loading).
|
|
8
|
+
|
|
9
|
+
**Decision Tree:**
|
|
10
|
+
|
|
11
|
+
| Scenario | Use This | Why |
|
|
12
|
+
| ------------------------------------------- | -------------------- | ------------------------------------- |
|
|
13
|
+
| Loading data from API | Spinner ✅ | Indicates async operation in progress |
|
|
14
|
+
| Button action in progress (submitting form) | Spinner in Button ✅ | Shows action is processing |
|
|
15
|
+
| Page/section loading | Spinner ✅ | Feedback while content loads |
|
|
16
|
+
| Progress with known duration/percentage | ProgressBar | Shows specific progress amount |
|
|
17
|
+
| Multi-step process with defined steps | Stepper | Shows progress through steps |
|
|
18
|
+
| Indefinite wait (unknown duration) | Spinner ✅ | Best for unknown duration |
|
|
19
|
+
| Background process (non-blocking) | Toast or Badge | Don't block user interaction |
|
|
20
|
+
|
|
21
|
+
**Component Comparison:**
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
// ✅ Use Spinner for loading data
|
|
25
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
26
|
+
|
|
27
|
+
{isLoading ? (
|
|
28
|
+
<div className={css({ display: 'flex', justifyContent: 'center', py: 'lg' })}>
|
|
29
|
+
<Spinner size="lg" />
|
|
30
|
+
</div>
|
|
31
|
+
) : (
|
|
32
|
+
<DataTable data={data} />
|
|
33
|
+
)}
|
|
34
|
+
|
|
35
|
+
// ✅ Use Spinner in buttons during submission
|
|
36
|
+
<Button disabled={isSubmitting}>
|
|
37
|
+
{isSubmitting && <Spinner size="sm" />}
|
|
38
|
+
{isSubmitting ? 'Submitting...' : 'Submit'}
|
|
39
|
+
</Button>
|
|
40
|
+
|
|
41
|
+
// ✅ Use Spinner inline with text
|
|
42
|
+
<div className={css({ display: 'flex', alignItems: 'center', gap: 'sm' })}>
|
|
43
|
+
<Spinner size="sm" />
|
|
44
|
+
<span>Loading your data...</span>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
// ❌ Don't use Spinner for progress with percentage
|
|
48
|
+
<Spinner size="md" />
|
|
49
|
+
<p>Loading... 45%</p> // Wrong - should show progress bar
|
|
50
|
+
|
|
51
|
+
<ProgressBar value={45} max={100}>
|
|
52
|
+
<ProgressBar.Label>Loading... 45%</ProgressBar.Label>
|
|
53
|
+
<ProgressBar.Track>
|
|
54
|
+
<ProgressBar.Range />
|
|
55
|
+
</ProgressBar.Track>
|
|
56
|
+
</ProgressBar> // Correct
|
|
57
|
+
|
|
58
|
+
// ❌ Don't use Spinner for multi-step processes
|
|
59
|
+
<Spinner size="md" />
|
|
60
|
+
<p>Step 2 of 5</p> // Wrong - should show stepper
|
|
61
|
+
|
|
62
|
+
<Stepper currentStep={2} totalSteps={5}>
|
|
63
|
+
{/* Steps */}
|
|
64
|
+
</Stepper> // Correct
|
|
65
|
+
|
|
66
|
+
// ❌ Don't use Spinner alone without context
|
|
67
|
+
<Spinner size="md" /> // Wrong - user doesn't know what's loading
|
|
68
|
+
|
|
69
|
+
<div>
|
|
70
|
+
<Spinner size="md" />
|
|
71
|
+
<p>Loading products...</p> // Correct - provides context
|
|
72
|
+
</div>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Import
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
import { Spinner } from '@discourser/design-system';
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Sizes
|
|
82
|
+
|
|
83
|
+
| Size | Dimension | Usage |
|
|
84
|
+
| --------- | --------- | ------------------------------------------- |
|
|
85
|
+
| `inherit` | 1em | Inherits parent font size, inline with text |
|
|
86
|
+
| `xs` | 12px | Extra small, inline icons |
|
|
87
|
+
| `sm` | 16px | Small buttons, compact UI |
|
|
88
|
+
| `md` | 20px | Default, most use cases |
|
|
89
|
+
| `lg` | 24px | Larger buttons, sections |
|
|
90
|
+
| `xl` | 28px | Prominent loading states |
|
|
91
|
+
| `2xl` | 32px | Full page loading, hero sections |
|
|
92
|
+
|
|
93
|
+
**Recommendation:** Use `md` for most cases. Use `sm` for buttons. Use `lg` or larger for full-page loading.
|
|
94
|
+
|
|
95
|
+
## Props
|
|
96
|
+
|
|
97
|
+
| Prop | Type | Default | Description |
|
|
98
|
+
| ----------- | ------------------------------------------------------------ | ------- | ---------------------- |
|
|
99
|
+
| `size` | `'inherit' \| 'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| '2xl'` | `'md'` | Spinner size |
|
|
100
|
+
| `className` | `string` | - | Additional CSS classes |
|
|
101
|
+
|
|
102
|
+
**Note:** Spinner extends `HTMLAttributes<HTMLSpanElement>`, so all standard HTML span attributes are supported. The spinner inherits `currentColor` for its color.
|
|
103
|
+
|
|
104
|
+
## Examples
|
|
105
|
+
|
|
106
|
+
### Basic Usage
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
import { Spinner } from '@discourser/design-system';
|
|
110
|
+
|
|
111
|
+
// Default spinner
|
|
112
|
+
<Spinner />
|
|
113
|
+
|
|
114
|
+
// Medium spinner (explicit)
|
|
115
|
+
<Spinner size="md" />
|
|
116
|
+
|
|
117
|
+
// With accessible label
|
|
118
|
+
<Spinner role="status" aria-label="Loading" />
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Different Sizes
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
// Inherit size (matches text size)
|
|
125
|
+
<p className={css({ fontSize: '24px' })}>
|
|
126
|
+
Loading <Spinner size="inherit" />
|
|
127
|
+
</p>
|
|
128
|
+
|
|
129
|
+
// Extra small
|
|
130
|
+
<Spinner size="xs" />
|
|
131
|
+
|
|
132
|
+
// Small
|
|
133
|
+
<Spinner size="sm" />
|
|
134
|
+
|
|
135
|
+
// Medium (default)
|
|
136
|
+
<Spinner size="md" />
|
|
137
|
+
|
|
138
|
+
// Large
|
|
139
|
+
<Spinner size="lg" />
|
|
140
|
+
|
|
141
|
+
// Extra large
|
|
142
|
+
<Spinner size="xl" />
|
|
143
|
+
|
|
144
|
+
// 2X Large
|
|
145
|
+
<Spinner size="2xl" />
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### In Buttons
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
import { Button, Spinner } from '@discourser/design-system';
|
|
152
|
+
|
|
153
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
154
|
+
|
|
155
|
+
// Loading state in button
|
|
156
|
+
<Button disabled={isLoading}>
|
|
157
|
+
{isLoading && <Spinner size="sm" />}
|
|
158
|
+
{isLoading ? 'Loading...' : 'Load Data'}
|
|
159
|
+
</Button>
|
|
160
|
+
|
|
161
|
+
// Spinner only (icon button style)
|
|
162
|
+
<Button disabled={isLoading} aria-label={isLoading ? 'Loading' : 'Submit'}>
|
|
163
|
+
{isLoading ? <Spinner size="sm" /> : <SendIcon />}
|
|
164
|
+
</Button>
|
|
165
|
+
|
|
166
|
+
// With left icon slot
|
|
167
|
+
<Button leftIcon={isLoading ? <Spinner size="sm" /> : null} disabled={isLoading}>
|
|
168
|
+
{isLoading ? 'Submitting...' : 'Submit Form'}
|
|
169
|
+
</Button>
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Loading States
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
// Page loading
|
|
176
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
177
|
+
|
|
178
|
+
{isLoading ? (
|
|
179
|
+
<div className={css({
|
|
180
|
+
display: 'flex',
|
|
181
|
+
justifyContent: 'center',
|
|
182
|
+
alignItems: 'center',
|
|
183
|
+
height: '400px'
|
|
184
|
+
})}>
|
|
185
|
+
<Spinner size="xl" />
|
|
186
|
+
</div>
|
|
187
|
+
) : (
|
|
188
|
+
<Content />
|
|
189
|
+
)}
|
|
190
|
+
|
|
191
|
+
// Section loading
|
|
192
|
+
{isLoadingSection ? (
|
|
193
|
+
<div className={css({ display: 'flex', justifyContent: 'center', py: 'lg' })}>
|
|
194
|
+
<Spinner size="lg" />
|
|
195
|
+
</div>
|
|
196
|
+
) : (
|
|
197
|
+
<Section />
|
|
198
|
+
)}
|
|
199
|
+
|
|
200
|
+
// Inline loading
|
|
201
|
+
<div className={css({ display: 'flex', alignItems: 'center', gap: 'sm' })}>
|
|
202
|
+
<Spinner size="sm" />
|
|
203
|
+
<span>Loading your messages...</span>
|
|
204
|
+
</div>
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### With Context Message
|
|
208
|
+
|
|
209
|
+
```typescript
|
|
210
|
+
// Centered with message
|
|
211
|
+
<div className={css({
|
|
212
|
+
display: 'flex',
|
|
213
|
+
flexDirection: 'column',
|
|
214
|
+
alignItems: 'center',
|
|
215
|
+
gap: 'md',
|
|
216
|
+
py: 'xl'
|
|
217
|
+
})}>
|
|
218
|
+
<Spinner size="xl" />
|
|
219
|
+
<p className={css({ color: 'fg.muted' })}>Loading your data...</p>
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
// Inline with message
|
|
223
|
+
<div className={css({ display: 'flex', alignItems: 'center', gap: 'sm' })}>
|
|
224
|
+
<Spinner size="md" />
|
|
225
|
+
<span>Fetching products...</span>
|
|
226
|
+
</div>
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Color Variations
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
// Inherits text color (default behavior)
|
|
233
|
+
<div className={css({ color: 'primary' })}>
|
|
234
|
+
<Spinner size="md" />
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
// With custom color
|
|
238
|
+
<div className={css({ color: 'success' })}>
|
|
239
|
+
<Spinner size="md" />
|
|
240
|
+
<span>Success! Loading...</span>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
// Error state
|
|
244
|
+
<div className={css({ color: 'error' })}>
|
|
245
|
+
<Spinner size="md" />
|
|
246
|
+
<span>Retrying...</span>
|
|
247
|
+
</div>
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### In Cards
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
import { Card } from '@discourser/design-system';
|
|
254
|
+
|
|
255
|
+
<Card.Root>
|
|
256
|
+
<Card.Header>
|
|
257
|
+
<Card.Title>User Statistics</Card.Title>
|
|
258
|
+
</Card.Header>
|
|
259
|
+
<Card.Body>
|
|
260
|
+
{isLoading ? (
|
|
261
|
+
<div className={css({
|
|
262
|
+
display: 'flex',
|
|
263
|
+
justifyContent: 'center',
|
|
264
|
+
alignItems: 'center',
|
|
265
|
+
minHeight: '200px'
|
|
266
|
+
})}>
|
|
267
|
+
<Spinner size="lg" />
|
|
268
|
+
</div>
|
|
269
|
+
) : (
|
|
270
|
+
<Statistics data={data} />
|
|
271
|
+
)}
|
|
272
|
+
</Card.Body>
|
|
273
|
+
</Card.Root>
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### In Modals/Dialogs
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
import { Dialog, Spinner } from '@discourser/design-system';
|
|
280
|
+
|
|
281
|
+
<Dialog.Root open={isOpen}>
|
|
282
|
+
<Dialog.Content>
|
|
283
|
+
<Dialog.Header>
|
|
284
|
+
<Dialog.Title>Loading Data</Dialog.Title>
|
|
285
|
+
</Dialog.Header>
|
|
286
|
+
<Dialog.Body>
|
|
287
|
+
<div className={css({
|
|
288
|
+
display: 'flex',
|
|
289
|
+
flexDirection: 'column',
|
|
290
|
+
alignItems: 'center',
|
|
291
|
+
gap: 'md',
|
|
292
|
+
py: 'xl'
|
|
293
|
+
})}>
|
|
294
|
+
<Spinner size="xl" />
|
|
295
|
+
<p>Please wait while we fetch your information...</p>
|
|
296
|
+
</div>
|
|
297
|
+
</Dialog.Body>
|
|
298
|
+
</Dialog.Content>
|
|
299
|
+
</Dialog.Root>
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### In Input Groups
|
|
303
|
+
|
|
304
|
+
```typescript
|
|
305
|
+
import { InputGroup, Input, Spinner } from '@discourser/design-system';
|
|
306
|
+
|
|
307
|
+
const [isSearching, setIsSearching] = useState(false);
|
|
308
|
+
|
|
309
|
+
<InputGroup.Root size="md">
|
|
310
|
+
<InputGroup.Element>
|
|
311
|
+
{isSearching ? (
|
|
312
|
+
<Spinner size="sm" />
|
|
313
|
+
) : (
|
|
314
|
+
<SearchIcon />
|
|
315
|
+
)}
|
|
316
|
+
</InputGroup.Element>
|
|
317
|
+
<Input placeholder="Search..." />
|
|
318
|
+
</InputGroup.Root>
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### In Lists
|
|
322
|
+
|
|
323
|
+
```typescript
|
|
324
|
+
// Loading list item
|
|
325
|
+
<ul className={css({ display: 'flex', flexDirection: 'column', gap: 'sm' })}>
|
|
326
|
+
<li>Item 1</li>
|
|
327
|
+
<li>Item 2</li>
|
|
328
|
+
{isLoadingMore && (
|
|
329
|
+
<li className={css({ display: 'flex', justifyContent: 'center', py: 'md' })}>
|
|
330
|
+
<Spinner size="md" />
|
|
331
|
+
</li>
|
|
332
|
+
)}
|
|
333
|
+
</ul>
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
### Full Page Loading
|
|
337
|
+
|
|
338
|
+
```typescript
|
|
339
|
+
// Overlay loading screen
|
|
340
|
+
{isLoadingPage && (
|
|
341
|
+
<div className={css({
|
|
342
|
+
position: 'fixed',
|
|
343
|
+
inset: 0,
|
|
344
|
+
display: 'flex',
|
|
345
|
+
flexDirection: 'column',
|
|
346
|
+
justifyContent: 'center',
|
|
347
|
+
alignItems: 'center',
|
|
348
|
+
gap: 'lg',
|
|
349
|
+
bg: 'rgba(0, 0, 0, 0.5)',
|
|
350
|
+
zIndex: 9999
|
|
351
|
+
})}>
|
|
352
|
+
<Spinner size="2xl" className={css({ color: 'white' })} />
|
|
353
|
+
<p className={css({ color: 'white', fontSize: 'lg' })}>Loading application...</p>
|
|
354
|
+
</div>
|
|
355
|
+
)}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
## Common Patterns
|
|
359
|
+
|
|
360
|
+
### Async Data Fetching
|
|
361
|
+
|
|
362
|
+
```typescript
|
|
363
|
+
const [data, setData] = useState(null);
|
|
364
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
365
|
+
const [error, setError] = useState(null);
|
|
366
|
+
|
|
367
|
+
useEffect(() => {
|
|
368
|
+
async function fetchData() {
|
|
369
|
+
try {
|
|
370
|
+
setIsLoading(true);
|
|
371
|
+
const response = await fetch('/api/data');
|
|
372
|
+
const result = await response.json();
|
|
373
|
+
setData(result);
|
|
374
|
+
} catch (err) {
|
|
375
|
+
setError(err.message);
|
|
376
|
+
} finally {
|
|
377
|
+
setIsLoading(false);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
fetchData();
|
|
382
|
+
}, []);
|
|
383
|
+
|
|
384
|
+
return (
|
|
385
|
+
<div>
|
|
386
|
+
{isLoading && (
|
|
387
|
+
<div className={css({ display: 'flex', justifyContent: 'center', py: 'xl' })}>
|
|
388
|
+
<Spinner size="lg" />
|
|
389
|
+
</div>
|
|
390
|
+
)}
|
|
391
|
+
{error && <p className={css({ color: 'error' })}>Error: {error}</p>}
|
|
392
|
+
{data && <DataDisplay data={data} />}
|
|
393
|
+
</div>
|
|
394
|
+
);
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
### Form Submission
|
|
398
|
+
|
|
399
|
+
```typescript
|
|
400
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
401
|
+
|
|
402
|
+
const handleSubmit = async (e: FormEvent) => {
|
|
403
|
+
e.preventDefault();
|
|
404
|
+
setIsSubmitting(true);
|
|
405
|
+
|
|
406
|
+
try {
|
|
407
|
+
await submitForm(formData);
|
|
408
|
+
toast.success('Form submitted successfully!');
|
|
409
|
+
} catch (error) {
|
|
410
|
+
toast.error('Failed to submit form');
|
|
411
|
+
} finally {
|
|
412
|
+
setIsSubmitting(false);
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
<form onSubmit={handleSubmit}>
|
|
417
|
+
<Input label="Name" name="name" />
|
|
418
|
+
<Input label="Email" name="email" type="email" />
|
|
419
|
+
|
|
420
|
+
<Button type="submit" disabled={isSubmitting}>
|
|
421
|
+
{isSubmitting && <Spinner size="sm" />}
|
|
422
|
+
{isSubmitting ? 'Submitting...' : 'Submit'}
|
|
423
|
+
</Button>
|
|
424
|
+
</form>
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
### Infinite Scroll Loading
|
|
428
|
+
|
|
429
|
+
```typescript
|
|
430
|
+
const [items, setItems] = useState([]);
|
|
431
|
+
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
432
|
+
const [hasMore, setHasMore] = useState(true);
|
|
433
|
+
|
|
434
|
+
const loadMore = async () => {
|
|
435
|
+
if (isLoadingMore || !hasMore) return;
|
|
436
|
+
|
|
437
|
+
setIsLoadingMore(true);
|
|
438
|
+
try {
|
|
439
|
+
const newItems = await fetchMoreItems();
|
|
440
|
+
setItems([...items, ...newItems]);
|
|
441
|
+
setHasMore(newItems.length > 0);
|
|
442
|
+
} catch (error) {
|
|
443
|
+
console.error('Failed to load more items');
|
|
444
|
+
} finally {
|
|
445
|
+
setIsLoadingMore(false);
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
<div>
|
|
450
|
+
<div className={css({ display: 'grid', gap: 'md' })}>
|
|
451
|
+
{items.map(item => (
|
|
452
|
+
<ItemCard key={item.id} item={item} />
|
|
453
|
+
))}
|
|
454
|
+
</div>
|
|
455
|
+
|
|
456
|
+
{isLoadingMore && (
|
|
457
|
+
<div className={css({ display: 'flex', justifyContent: 'center', py: 'lg' })}>
|
|
458
|
+
<Spinner size="lg" />
|
|
459
|
+
</div>
|
|
460
|
+
)}
|
|
461
|
+
|
|
462
|
+
{hasMore && !isLoadingMore && (
|
|
463
|
+
<Button variant="outlined" onClick={loadMore}>
|
|
464
|
+
Load More
|
|
465
|
+
</Button>
|
|
466
|
+
)}
|
|
467
|
+
</div>
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
### Search with Debounce
|
|
471
|
+
|
|
472
|
+
```typescript
|
|
473
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
474
|
+
const [isSearching, setIsSearching] = useState(false);
|
|
475
|
+
const [results, setResults] = useState([]);
|
|
476
|
+
|
|
477
|
+
useEffect(() => {
|
|
478
|
+
if (!searchQuery) {
|
|
479
|
+
setResults([]);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
setIsSearching(true);
|
|
484
|
+
|
|
485
|
+
const debounceTimer = setTimeout(async () => {
|
|
486
|
+
try {
|
|
487
|
+
const data = await searchAPI(searchQuery);
|
|
488
|
+
setResults(data);
|
|
489
|
+
} catch (error) {
|
|
490
|
+
console.error('Search failed');
|
|
491
|
+
} finally {
|
|
492
|
+
setIsSearching(false);
|
|
493
|
+
}
|
|
494
|
+
}, 300);
|
|
495
|
+
|
|
496
|
+
return () => clearTimeout(debounceTimer);
|
|
497
|
+
}, [searchQuery]);
|
|
498
|
+
|
|
499
|
+
<div>
|
|
500
|
+
<InputGroup.Root size="md">
|
|
501
|
+
<InputGroup.Element>
|
|
502
|
+
{isSearching ? (
|
|
503
|
+
<Spinner size="sm" />
|
|
504
|
+
) : (
|
|
505
|
+
<SearchIcon />
|
|
506
|
+
)}
|
|
507
|
+
</InputGroup.Element>
|
|
508
|
+
<Input
|
|
509
|
+
placeholder="Search..."
|
|
510
|
+
value={searchQuery}
|
|
511
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
512
|
+
/>
|
|
513
|
+
</InputGroup.Root>
|
|
514
|
+
|
|
515
|
+
<div className={css({ mt: 'md' })}>
|
|
516
|
+
{results.map(result => (
|
|
517
|
+
<ResultItem key={result.id} result={result} />
|
|
518
|
+
))}
|
|
519
|
+
</div>
|
|
520
|
+
</div>
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
### Skeleton Loading Alternative
|
|
524
|
+
|
|
525
|
+
```typescript
|
|
526
|
+
// Use spinner for simple loading
|
|
527
|
+
{isLoading ? (
|
|
528
|
+
<div className={css({ display: 'flex', justifyContent: 'center', py: 'lg' })}>
|
|
529
|
+
<Spinner size="lg" />
|
|
530
|
+
</div>
|
|
531
|
+
) : (
|
|
532
|
+
<Content />
|
|
533
|
+
)}
|
|
534
|
+
|
|
535
|
+
// Or use skeleton for better UX (shows layout)
|
|
536
|
+
{isLoading ? (
|
|
537
|
+
<Skeleton>
|
|
538
|
+
<Skeleton.Text lines={3} />
|
|
539
|
+
<Skeleton.Rectangle height="200px" />
|
|
540
|
+
</Skeleton>
|
|
541
|
+
) : (
|
|
542
|
+
<Content />
|
|
543
|
+
)}
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
## DO NOT
|
|
547
|
+
|
|
548
|
+
```typescript
|
|
549
|
+
// ❌ Don't use Spinner without context
|
|
550
|
+
<Spinner size="md" /> // Wrong - user doesn't know what's loading
|
|
551
|
+
|
|
552
|
+
// ✅ Provide context
|
|
553
|
+
<div className={css({ display: 'flex', alignItems: 'center', gap: 'sm' })}>
|
|
554
|
+
<Spinner size="md" />
|
|
555
|
+
<span>Loading products...</span>
|
|
556
|
+
</div>
|
|
557
|
+
|
|
558
|
+
// ❌ Don't use Spinner for progress with percentage
|
|
559
|
+
<Spinner size="md" />
|
|
560
|
+
<p>45% complete</p> // Wrong - use ProgressBar
|
|
561
|
+
|
|
562
|
+
// ✅ Use ProgressBar for known progress
|
|
563
|
+
<ProgressBar value={45} max={100} />
|
|
564
|
+
|
|
565
|
+
// ❌ Don't override animation with inline styles
|
|
566
|
+
<Spinner style={{ animation: 'none' }} /> // Wrong - breaks functionality
|
|
567
|
+
|
|
568
|
+
// ❌ Don't use wrong size for context
|
|
569
|
+
<Button>
|
|
570
|
+
<Spinner size="2xl" /> // Wrong - too large for button
|
|
571
|
+
Submit
|
|
572
|
+
</Button>
|
|
573
|
+
|
|
574
|
+
// ✅ Match size to context
|
|
575
|
+
<Button>
|
|
576
|
+
<Spinner size="sm" />
|
|
577
|
+
Submit
|
|
578
|
+
</Button>
|
|
579
|
+
|
|
580
|
+
// ❌ Don't use multiple spinners for single operation
|
|
581
|
+
<div>
|
|
582
|
+
<Spinner size="md" />
|
|
583
|
+
<Spinner size="lg" /> // Wrong - confusing
|
|
584
|
+
<p>Loading...</p>
|
|
585
|
+
</div>
|
|
586
|
+
|
|
587
|
+
// ✅ Use one spinner per loading state
|
|
588
|
+
<div>
|
|
589
|
+
<Spinner size="lg" />
|
|
590
|
+
<p>Loading...</p>
|
|
591
|
+
</div>
|
|
592
|
+
|
|
593
|
+
// ❌ Don't show spinner without disabling interaction
|
|
594
|
+
<Button onClick={handleClick}>
|
|
595
|
+
<Spinner size="sm" />
|
|
596
|
+
Submit
|
|
597
|
+
</Button> // Wrong - button still clickable
|
|
598
|
+
|
|
599
|
+
// ✅ Disable during loading
|
|
600
|
+
<Button onClick={handleClick} disabled={isLoading}>
|
|
601
|
+
{isLoading && <Spinner size="sm" />}
|
|
602
|
+
{isLoading ? 'Submitting...' : 'Submit'}
|
|
603
|
+
</Button>
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
## Accessibility
|
|
607
|
+
|
|
608
|
+
The Spinner component follows WCAG 2.1 Level AA standards:
|
|
609
|
+
|
|
610
|
+
- **ARIA Attributes**: Use `role="status"` for loading announcements
|
|
611
|
+
- **Screen Reader Labels**: Provide `aria-label` to describe what's loading
|
|
612
|
+
- **Live Regions**: Consider `aria-live` for dynamic updates
|
|
613
|
+
- **Focus Management**: Don't trap focus during loading
|
|
614
|
+
- **Timeout Considerations**: Provide way to cancel long operations
|
|
615
|
+
|
|
616
|
+
### Accessibility Best Practices
|
|
617
|
+
|
|
618
|
+
```typescript
|
|
619
|
+
// ✅ Provide status role and label
|
|
620
|
+
<Spinner role="status" aria-label="Loading content" />
|
|
621
|
+
|
|
622
|
+
// ✅ Use with descriptive text
|
|
623
|
+
<div role="status" aria-live="polite">
|
|
624
|
+
<Spinner size="md" />
|
|
625
|
+
<span>Loading your dashboard...</span>
|
|
626
|
+
</div>
|
|
627
|
+
|
|
628
|
+
// ✅ Announce loading to screen readers
|
|
629
|
+
<div>
|
|
630
|
+
<Spinner role="status" aria-label="Searching products" />
|
|
631
|
+
<span className="sr-only">Searching products, please wait...</span>
|
|
632
|
+
</div>
|
|
633
|
+
|
|
634
|
+
// ✅ Provide cancel option for long operations
|
|
635
|
+
<div>
|
|
636
|
+
<Spinner size="lg" />
|
|
637
|
+
<p>This might take a few minutes...</p>
|
|
638
|
+
<Button variant="outlined" onClick={handleCancel}>
|
|
639
|
+
Cancel
|
|
640
|
+
</Button>
|
|
641
|
+
</div>
|
|
642
|
+
|
|
643
|
+
// ✅ Update aria-label dynamically
|
|
644
|
+
<Spinner
|
|
645
|
+
role="status"
|
|
646
|
+
aria-label={`Loading step ${currentStep} of ${totalSteps}`}
|
|
647
|
+
/>
|
|
648
|
+
|
|
649
|
+
// ✅ Hide decorative spinners from screen readers
|
|
650
|
+
<Spinner aria-hidden="true" />
|
|
651
|
+
<p>Loading...</p> // Text provides context instead
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
## Size Selection Guide
|
|
655
|
+
|
|
656
|
+
| Scenario | Recommended Size | Reasoning |
|
|
657
|
+
| ------------------ | ---------------- | ---------------------------- |
|
|
658
|
+
| Inline with text | `inherit` | Matches text size |
|
|
659
|
+
| Small buttons | `sm` | Fits button padding |
|
|
660
|
+
| Medium buttons | `sm` or `md` | Proportional to button |
|
|
661
|
+
| Large buttons | `md` or `lg` | Matches larger button |
|
|
662
|
+
| Input fields | `sm` | Fits input height |
|
|
663
|
+
| Cards/sections | `lg` | Visible but not overwhelming |
|
|
664
|
+
| Full page loading | `xl` or `2xl` | Prominent, clear indication |
|
|
665
|
+
| Icon size elements | `xs` to `sm` | Matches icon sizing |
|
|
666
|
+
|
|
667
|
+
## State Behaviors
|
|
668
|
+
|
|
669
|
+
| State | Visual Change | Behavior |
|
|
670
|
+
| ------------ | -------------------------------- | ------------------------- |
|
|
671
|
+
| **Loading** | Continuous rotation | Indicates ongoing process |
|
|
672
|
+
| **Complete** | Hidden/removed | Operation finished |
|
|
673
|
+
| **Error** | Replaced with error icon/message | Operation failed |
|
|
674
|
+
|
|
675
|
+
## Responsive Considerations
|
|
676
|
+
|
|
677
|
+
```typescript
|
|
678
|
+
// Responsive sizing
|
|
679
|
+
<Spinner size={{ base: 'lg', md: 'md' }} />
|
|
680
|
+
|
|
681
|
+
// Responsive loading layout
|
|
682
|
+
<div className={css({
|
|
683
|
+
display: 'flex',
|
|
684
|
+
flexDirection: { base: 'column', md: 'row' },
|
|
685
|
+
alignItems: 'center',
|
|
686
|
+
gap: 'md'
|
|
687
|
+
})}>
|
|
688
|
+
<Spinner size={{ base: 'xl', md: 'lg' }} />
|
|
689
|
+
<p>Loading your content...</p>
|
|
690
|
+
</div>
|
|
691
|
+
|
|
692
|
+
// Mobile-optimized full-page loading
|
|
693
|
+
<div className={css({
|
|
694
|
+
position: 'fixed',
|
|
695
|
+
inset: 0,
|
|
696
|
+
display: 'flex',
|
|
697
|
+
justifyContent: 'center',
|
|
698
|
+
alignItems: 'center',
|
|
699
|
+
bg: 'bg.default'
|
|
700
|
+
})}>
|
|
701
|
+
<Spinner size={{ base: '2xl', md: 'xl' }} />
|
|
702
|
+
</div>
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
## Testing
|
|
706
|
+
|
|
707
|
+
```typescript
|
|
708
|
+
import { render, screen } from '@testing-library/react';
|
|
709
|
+
|
|
710
|
+
test('spinner renders with correct role', () => {
|
|
711
|
+
render(<Spinner role="status" aria-label="Loading" />);
|
|
712
|
+
|
|
713
|
+
const spinner = screen.getByRole('status');
|
|
714
|
+
expect(spinner).toBeInTheDocument();
|
|
715
|
+
expect(spinner).toHaveAttribute('aria-label', 'Loading');
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
test('spinner shows during loading', () => {
|
|
719
|
+
const { rerender } = render(
|
|
720
|
+
<div>
|
|
721
|
+
{true && <Spinner data-testid="spinner" />}
|
|
722
|
+
<p>Content</p>
|
|
723
|
+
</div>
|
|
724
|
+
);
|
|
725
|
+
|
|
726
|
+
expect(screen.getByTestId('spinner')).toBeInTheDocument();
|
|
727
|
+
|
|
728
|
+
rerender(
|
|
729
|
+
<div>
|
|
730
|
+
{false && <Spinner data-testid="spinner" />}
|
|
731
|
+
<p>Content</p>
|
|
732
|
+
</div>
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
test('button is disabled while spinner is shown', () => {
|
|
739
|
+
const isLoading = true;
|
|
740
|
+
|
|
741
|
+
render(
|
|
742
|
+
<Button disabled={isLoading}>
|
|
743
|
+
{isLoading && <Spinner size="sm" />}
|
|
744
|
+
{isLoading ? 'Loading...' : 'Submit'}
|
|
745
|
+
</Button>
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
const button = screen.getByRole('button');
|
|
749
|
+
expect(button).toBeDisabled();
|
|
750
|
+
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
|
751
|
+
});
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
## Performance Considerations
|
|
755
|
+
|
|
756
|
+
```typescript
|
|
757
|
+
// ✅ Conditional rendering for better performance
|
|
758
|
+
{isLoading && <Spinner size="md" />}
|
|
759
|
+
|
|
760
|
+
// ✅ Avoid unnecessary re-renders
|
|
761
|
+
const MemoizedSpinner = memo(Spinner);
|
|
762
|
+
|
|
763
|
+
// ✅ Show spinner only after delay (avoid flash for quick operations)
|
|
764
|
+
const [showSpinner, setShowSpinner] = useState(false);
|
|
765
|
+
|
|
766
|
+
useEffect(() => {
|
|
767
|
+
if (isLoading) {
|
|
768
|
+
const timer = setTimeout(() => setShowSpinner(true), 300);
|
|
769
|
+
return () => clearTimeout(timer);
|
|
770
|
+
} else {
|
|
771
|
+
setShowSpinner(false);
|
|
772
|
+
}
|
|
773
|
+
}, [isLoading]);
|
|
774
|
+
|
|
775
|
+
return showSpinner ? <Spinner size="md" /> : null;
|
|
776
|
+
```
|
|
777
|
+
|
|
778
|
+
## Related Components
|
|
779
|
+
|
|
780
|
+
- **ProgressBar** - For operations with known progress percentage
|
|
781
|
+
- **Skeleton** - For placeholder content during loading
|
|
782
|
+
- **Button** - Often contains spinner during loading states
|
|
783
|
+
- **Toast** - For background operation notifications
|