@discourser/design-system 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/guidelines/Guidelines.md +120 -13
- package/guidelines/components/accordion.md +639 -0
- package/guidelines/components/avatar.md +945 -0
- package/guidelines/components/badge.md +667 -0
- package/guidelines/components/checkbox.md +583 -0
- package/guidelines/components/drawer.md +961 -0
- package/guidelines/components/heading.md +505 -0
- package/guidelines/components/popover.md +1200 -0
- package/guidelines/components/progress.md +773 -0
- package/guidelines/components/radio-group.md +757 -0
- package/guidelines/components/select.md +1155 -0
- package/guidelines/components/skeleton.md +726 -0
- package/guidelines/components/tabs.md +834 -0
- package/guidelines/components/textarea.md +425 -0
- package/guidelines/components/toast.md +707 -0
- package/guidelines/components/tooltip.md +832 -0
- package/guidelines/overview-components.md +56 -8
- package/package.json +1 -1
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
# Skeleton
|
|
2
|
+
|
|
3
|
+
**Purpose:** Loading placeholder component that displays a temporary gray placeholder while content is loading, providing visual feedback and reducing perceived loading time following Material Design 3 patterns.
|
|
4
|
+
|
|
5
|
+
## Import
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import {
|
|
9
|
+
Skeleton,
|
|
10
|
+
SkeletonCircle,
|
|
11
|
+
SkeletonText,
|
|
12
|
+
} from '@discourser/design-system';
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Component Variants
|
|
16
|
+
|
|
17
|
+
The Skeleton system provides three main components for different use cases:
|
|
18
|
+
|
|
19
|
+
| Component | Purpose | When to Use |
|
|
20
|
+
| ---------------- | ----------------------------- | ----------------------------------------- |
|
|
21
|
+
| `Skeleton` | Basic rectangular placeholder | General content blocks, images, cards |
|
|
22
|
+
| `SkeletonCircle` | Circular placeholder | Avatars, profile pictures, circular icons |
|
|
23
|
+
| `SkeletonText` | Multi-line text placeholder | Paragraphs, descriptions, text content |
|
|
24
|
+
|
|
25
|
+
## Animation Variants
|
|
26
|
+
|
|
27
|
+
| Variant | Visual Effect | Usage | When to Use |
|
|
28
|
+
| ------- | ---------------------- | ------------------------- | ---------------------------------------------- |
|
|
29
|
+
| `pulse` | Gentle opacity pulsing | Default loading state | Most loading scenarios, subtle feedback |
|
|
30
|
+
| `shine` | Shimmer/wave effect | Enhanced loading feedback | Premium feel, prominent loading states |
|
|
31
|
+
| `none` | No animation | Static placeholder | Reduced motion preference, minimal distraction |
|
|
32
|
+
|
|
33
|
+
### Visual Characteristics
|
|
34
|
+
|
|
35
|
+
- **pulse**: Smooth opacity fade in/out at 1.2s duration
|
|
36
|
+
- **shine**: Gradient wave moving across at 5s duration
|
|
37
|
+
- **none**: Static gray background without animation
|
|
38
|
+
|
|
39
|
+
## Props
|
|
40
|
+
|
|
41
|
+
### Skeleton
|
|
42
|
+
|
|
43
|
+
| Prop | Type | Default | Description |
|
|
44
|
+
| ----------- | ------------------------------ | --------- | ------------------------------------------ |
|
|
45
|
+
| `loading` | `boolean` | `true` | Whether to show skeleton or reveal content |
|
|
46
|
+
| `variant` | `'pulse' \| 'shine' \| 'none'` | `'pulse'` | Animation style |
|
|
47
|
+
| `width` | `string \| number` | - | Width of skeleton (CSS value) |
|
|
48
|
+
| `height` | `string \| number` | - | Height of skeleton (CSS value) |
|
|
49
|
+
| `circle` | `boolean` | `false` | Make skeleton circular |
|
|
50
|
+
| `className` | `string` | - | Additional CSS classes |
|
|
51
|
+
|
|
52
|
+
### SkeletonCircle
|
|
53
|
+
|
|
54
|
+
| Prop | Type | Default | Description |
|
|
55
|
+
| --------- | ------------------------------ | --------- | ------------------------------------------ |
|
|
56
|
+
| `loading` | `boolean` | `true` | Whether to show skeleton or reveal content |
|
|
57
|
+
| `variant` | `'pulse' \| 'shine' \| 'none'` | `'pulse'` | Animation style |
|
|
58
|
+
| `size` | `string \| number` | - | Circle size (width and height) |
|
|
59
|
+
|
|
60
|
+
**Note:** SkeletonCircle automatically sets `circle={true}` and renders as a circular skeleton.
|
|
61
|
+
|
|
62
|
+
### SkeletonText
|
|
63
|
+
|
|
64
|
+
| Prop | Type | Default | Description |
|
|
65
|
+
| ----------- | ------------------------------ | --------- | ------------------------------------------ |
|
|
66
|
+
| `loading` | `boolean` | `true` | Whether to show skeleton or reveal content |
|
|
67
|
+
| `variant` | `'pulse' \| 'shine' \| 'none'` | `'pulse'` | Animation style |
|
|
68
|
+
| `noOfLines` | `number` | `3` | Number of text lines to display |
|
|
69
|
+
| `gap` | `string` | - | Space between lines |
|
|
70
|
+
| `rootProps` | `StackProps` | - | Props for the Stack container |
|
|
71
|
+
|
|
72
|
+
**Note:** Last line is automatically set to 80% width (100% if only one line).
|
|
73
|
+
|
|
74
|
+
## Examples
|
|
75
|
+
|
|
76
|
+
### Basic Skeleton
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
// Simple rectangular skeleton
|
|
80
|
+
<Skeleton width="200px" height="20px" />
|
|
81
|
+
|
|
82
|
+
// Custom dimensions
|
|
83
|
+
<Skeleton width="100%" height="300px" />
|
|
84
|
+
|
|
85
|
+
// With explicit loading state
|
|
86
|
+
<Skeleton loading={isLoading} width="full" height="40px">
|
|
87
|
+
<Text>Loaded content appears here</Text>
|
|
88
|
+
</Skeleton>
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Skeleton Circle (Avatars)
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
// Basic avatar skeleton
|
|
95
|
+
<SkeletonCircle size="40px" />
|
|
96
|
+
|
|
97
|
+
// Large profile picture
|
|
98
|
+
<SkeletonCircle size="120px" />
|
|
99
|
+
|
|
100
|
+
// With content reveal
|
|
101
|
+
<SkeletonCircle loading={isLoading} size="48px">
|
|
102
|
+
<Avatar src={user.avatar} />
|
|
103
|
+
</SkeletonCircle>
|
|
104
|
+
|
|
105
|
+
// Multiple avatar sizes
|
|
106
|
+
<HStack gap="4">
|
|
107
|
+
<SkeletonCircle size="32px" />
|
|
108
|
+
<SkeletonCircle size="48px" />
|
|
109
|
+
<SkeletonCircle size="64px" />
|
|
110
|
+
</HStack>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Skeleton Text (Paragraphs)
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
// Default 3 lines
|
|
117
|
+
<SkeletonText />
|
|
118
|
+
|
|
119
|
+
// Custom number of lines
|
|
120
|
+
<SkeletonText noOfLines={5} />
|
|
121
|
+
|
|
122
|
+
// Single line
|
|
123
|
+
<SkeletonText noOfLines={1} />
|
|
124
|
+
|
|
125
|
+
// Custom gap between lines
|
|
126
|
+
<SkeletonText noOfLines={4} gap="3" />
|
|
127
|
+
|
|
128
|
+
// With content reveal
|
|
129
|
+
<SkeletonText loading={isLoading} noOfLines={3}>
|
|
130
|
+
<Text>{article.content}</Text>
|
|
131
|
+
</SkeletonText>
|
|
132
|
+
|
|
133
|
+
// Custom container props
|
|
134
|
+
<SkeletonText
|
|
135
|
+
noOfLines={3}
|
|
136
|
+
rootProps={{
|
|
137
|
+
maxWidth: '600px',
|
|
138
|
+
padding: '4',
|
|
139
|
+
}}
|
|
140
|
+
/>
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Animation Variants
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
// Pulse animation (default)
|
|
147
|
+
<Skeleton variant="pulse" width="200px" height="20px" />
|
|
148
|
+
|
|
149
|
+
// Shine animation
|
|
150
|
+
<Skeleton variant="shine" width="200px" height="20px" />
|
|
151
|
+
|
|
152
|
+
// No animation
|
|
153
|
+
<Skeleton variant="none" width="200px" height="20px" />
|
|
154
|
+
|
|
155
|
+
// Respect reduced motion preference
|
|
156
|
+
<Skeleton
|
|
157
|
+
variant={prefersReducedMotion ? 'none' : 'shine'}
|
|
158
|
+
width="full"
|
|
159
|
+
height="40px"
|
|
160
|
+
/>
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Loading State Control
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
167
|
+
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
fetchData().then(() => setIsLoading(false));
|
|
170
|
+
}, []);
|
|
171
|
+
|
|
172
|
+
// Skeleton disappears when loading is false
|
|
173
|
+
<Skeleton loading={isLoading} width="full" height="200px">
|
|
174
|
+
<Image src={data.imageUrl} alt={data.title} />
|
|
175
|
+
</Skeleton>
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Common Patterns
|
|
179
|
+
|
|
180
|
+
### User Profile Card
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
184
|
+
|
|
185
|
+
<Card>
|
|
186
|
+
<HStack gap="4" align="start">
|
|
187
|
+
<SkeletonCircle loading={isLoading} size="64px">
|
|
188
|
+
<Avatar src={user.avatar} size="lg" />
|
|
189
|
+
</SkeletonCircle>
|
|
190
|
+
|
|
191
|
+
<Stack flex="1" gap="2">
|
|
192
|
+
<Skeleton loading={isLoading} width="150px" height="20px">
|
|
193
|
+
<Heading size="md">{user.name}</Heading>
|
|
194
|
+
</Skeleton>
|
|
195
|
+
|
|
196
|
+
<Skeleton loading={isLoading} width="200px" height="16px">
|
|
197
|
+
<Text color="fg.muted">{user.email}</Text>
|
|
198
|
+
</Skeleton>
|
|
199
|
+
|
|
200
|
+
<SkeletonText loading={isLoading} noOfLines={2}>
|
|
201
|
+
<Text>{user.bio}</Text>
|
|
202
|
+
</SkeletonText>
|
|
203
|
+
</Stack>
|
|
204
|
+
</HStack>
|
|
205
|
+
</Card>
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Article List
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
function ArticleListSkeleton() {
|
|
212
|
+
return (
|
|
213
|
+
<Stack gap="6">
|
|
214
|
+
{[...Array(3)].map((_, index) => (
|
|
215
|
+
<Card key={index}>
|
|
216
|
+
<Stack gap="3">
|
|
217
|
+
{/* Cover image */}
|
|
218
|
+
<Skeleton width="full" height="200px" />
|
|
219
|
+
|
|
220
|
+
{/* Title */}
|
|
221
|
+
<Skeleton width="80%" height="24px" />
|
|
222
|
+
|
|
223
|
+
{/* Meta info */}
|
|
224
|
+
<HStack gap="3">
|
|
225
|
+
<SkeletonCircle size="32px" />
|
|
226
|
+
<Stack gap="2" flex="1">
|
|
227
|
+
<Skeleton width="120px" height="14px" />
|
|
228
|
+
<Skeleton width="80px" height="12px" />
|
|
229
|
+
</Stack>
|
|
230
|
+
</HStack>
|
|
231
|
+
|
|
232
|
+
{/* Description */}
|
|
233
|
+
<SkeletonText noOfLines={3} />
|
|
234
|
+
</Stack>
|
|
235
|
+
</Card>
|
|
236
|
+
))}
|
|
237
|
+
</Stack>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function ArticleList() {
|
|
242
|
+
const { data, isLoading } = useArticles();
|
|
243
|
+
|
|
244
|
+
if (isLoading) return <ArticleListSkeleton />;
|
|
245
|
+
|
|
246
|
+
return (
|
|
247
|
+
<Stack gap="6">
|
|
248
|
+
{data.map((article) => (
|
|
249
|
+
<ArticleCard key={article.id} article={article} />
|
|
250
|
+
))}
|
|
251
|
+
</Stack>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Data Table
|
|
257
|
+
|
|
258
|
+
```typescript
|
|
259
|
+
function TableSkeleton({ rows = 5, columns = 4 }) {
|
|
260
|
+
return (
|
|
261
|
+
<Table>
|
|
262
|
+
<TableHead>
|
|
263
|
+
<TableRow>
|
|
264
|
+
{[...Array(columns)].map((_, i) => (
|
|
265
|
+
<TableHeader key={i}>
|
|
266
|
+
<Skeleton width="80px" height="16px" />
|
|
267
|
+
</TableHeader>
|
|
268
|
+
))}
|
|
269
|
+
</TableRow>
|
|
270
|
+
</TableHead>
|
|
271
|
+
<TableBody>
|
|
272
|
+
{[...Array(rows)].map((_, rowIndex) => (
|
|
273
|
+
<TableRow key={rowIndex}>
|
|
274
|
+
{[...Array(columns)].map((_, colIndex) => (
|
|
275
|
+
<TableCell key={colIndex}>
|
|
276
|
+
<Skeleton
|
|
277
|
+
width={colIndex === 0 ? '120px' : '80px'}
|
|
278
|
+
height="16px"
|
|
279
|
+
/>
|
|
280
|
+
</TableCell>
|
|
281
|
+
))}
|
|
282
|
+
</TableRow>
|
|
283
|
+
))}
|
|
284
|
+
</TableBody>
|
|
285
|
+
</Table>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### Comment Thread
|
|
291
|
+
|
|
292
|
+
```typescript
|
|
293
|
+
function CommentSkeleton() {
|
|
294
|
+
return (
|
|
295
|
+
<Stack gap="4">
|
|
296
|
+
{[...Array(3)].map((_, index) => (
|
|
297
|
+
<HStack key={index} gap="3" align="start">
|
|
298
|
+
<SkeletonCircle size="40px" />
|
|
299
|
+
|
|
300
|
+
<Stack flex="1" gap="2">
|
|
301
|
+
<HStack gap="2">
|
|
302
|
+
<Skeleton width="100px" height="16px" />
|
|
303
|
+
<Skeleton width="60px" height="14px" />
|
|
304
|
+
</HStack>
|
|
305
|
+
|
|
306
|
+
<SkeletonText noOfLines={2} />
|
|
307
|
+
|
|
308
|
+
<HStack gap="4">
|
|
309
|
+
<Skeleton width="40px" height="14px" />
|
|
310
|
+
<Skeleton width="40px" height="14px" />
|
|
311
|
+
</HStack>
|
|
312
|
+
</Stack>
|
|
313
|
+
</HStack>
|
|
314
|
+
))}
|
|
315
|
+
</Stack>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### Dashboard Statistics
|
|
321
|
+
|
|
322
|
+
```typescript
|
|
323
|
+
function StatsSkeleton() {
|
|
324
|
+
return (
|
|
325
|
+
<Grid columns={{ base: 1, md: 2, lg: 4 }} gap="4">
|
|
326
|
+
{[...Array(4)].map((_, index) => (
|
|
327
|
+
<Card key={index}>
|
|
328
|
+
<Stack gap="3">
|
|
329
|
+
<HStack justify="space-between">
|
|
330
|
+
<Skeleton width="80px" height="14px" />
|
|
331
|
+
<SkeletonCircle size="24px" />
|
|
332
|
+
</HStack>
|
|
333
|
+
|
|
334
|
+
<Skeleton width="100px" height="32px" />
|
|
335
|
+
|
|
336
|
+
<Skeleton width="120px" height="12px" />
|
|
337
|
+
</Stack>
|
|
338
|
+
</Card>
|
|
339
|
+
))}
|
|
340
|
+
</Grid>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### Product Grid
|
|
346
|
+
|
|
347
|
+
```typescript
|
|
348
|
+
function ProductGridSkeleton({ count = 8 }) {
|
|
349
|
+
return (
|
|
350
|
+
<Grid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} gap="6">
|
|
351
|
+
{[...Array(count)].map((_, index) => (
|
|
352
|
+
<Card key={index}>
|
|
353
|
+
<Stack gap="3">
|
|
354
|
+
{/* Product image */}
|
|
355
|
+
<Skeleton width="full" height="200px" />
|
|
356
|
+
|
|
357
|
+
{/* Product name */}
|
|
358
|
+
<Skeleton width="90%" height="20px" />
|
|
359
|
+
|
|
360
|
+
{/* Price */}
|
|
361
|
+
<Skeleton width="60px" height="24px" />
|
|
362
|
+
|
|
363
|
+
{/* Rating */}
|
|
364
|
+
<HStack gap="1">
|
|
365
|
+
{[...Array(5)].map((_, i) => (
|
|
366
|
+
<Skeleton key={i} width="16px" height="16px" />
|
|
367
|
+
))}
|
|
368
|
+
</HStack>
|
|
369
|
+
</Stack>
|
|
370
|
+
</Card>
|
|
371
|
+
))}
|
|
372
|
+
</Grid>
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### Chat Messages
|
|
378
|
+
|
|
379
|
+
```typescript
|
|
380
|
+
function ChatSkeleton() {
|
|
381
|
+
return (
|
|
382
|
+
<Stack gap="4">
|
|
383
|
+
{[...Array(5)].map((_, index) => {
|
|
384
|
+
const isOwnMessage = index % 2 === 0;
|
|
385
|
+
return (
|
|
386
|
+
<HStack
|
|
387
|
+
key={index}
|
|
388
|
+
gap="3"
|
|
389
|
+
justify={isOwnMessage ? 'flex-end' : 'flex-start'}
|
|
390
|
+
>
|
|
391
|
+
{!isOwnMessage && <SkeletonCircle size="32px" />}
|
|
392
|
+
|
|
393
|
+
<Stack gap="1" maxW="70%">
|
|
394
|
+
<Skeleton width="200px" height="16px" />
|
|
395
|
+
<Skeleton width="150px" height="12px" />
|
|
396
|
+
</Stack>
|
|
397
|
+
|
|
398
|
+
{isOwnMessage && <SkeletonCircle size="32px" />}
|
|
399
|
+
</HStack>
|
|
400
|
+
);
|
|
401
|
+
})}
|
|
402
|
+
</Stack>
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
### Form Fields
|
|
408
|
+
|
|
409
|
+
```typescript
|
|
410
|
+
function FormSkeleton() {
|
|
411
|
+
return (
|
|
412
|
+
<Stack gap="4">
|
|
413
|
+
{[...Array(4)].map((_, index) => (
|
|
414
|
+
<Stack key={index} gap="2">
|
|
415
|
+
<Skeleton width="100px" height="16px" />
|
|
416
|
+
<Skeleton width="full" height="40px" />
|
|
417
|
+
</Stack>
|
|
418
|
+
))}
|
|
419
|
+
|
|
420
|
+
<HStack gap="3" justify="flex-end">
|
|
421
|
+
<Skeleton width="80px" height="40px" />
|
|
422
|
+
<Skeleton width="100px" height="40px" />
|
|
423
|
+
</HStack>
|
|
424
|
+
</Stack>
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
## DO NOT
|
|
430
|
+
|
|
431
|
+
```typescript
|
|
432
|
+
// ❌ Don't use skeleton for instant content
|
|
433
|
+
<Skeleton loading={true} width="200px" height="20px">
|
|
434
|
+
<Text>Static text that loads instantly</Text>
|
|
435
|
+
</Skeleton> // No need for skeleton if content is immediate
|
|
436
|
+
|
|
437
|
+
// ❌ Don't forget to set dimensions
|
|
438
|
+
<Skeleton /> // No width or height specified
|
|
439
|
+
|
|
440
|
+
// ❌ Don't use skeleton for interactive elements
|
|
441
|
+
<Skeleton width="100px" height="40px">
|
|
442
|
+
<Button>Click me</Button>
|
|
443
|
+
</Skeleton> // Use disabled state instead
|
|
444
|
+
|
|
445
|
+
// ❌ Don't overuse animations
|
|
446
|
+
<div>
|
|
447
|
+
{[...Array(100)].map((_, i) => (
|
|
448
|
+
<Skeleton key={i} variant="shine" />
|
|
449
|
+
))} // Too many animations cause performance issues
|
|
450
|
+
</div>
|
|
451
|
+
|
|
452
|
+
// ❌ Don't use skeleton for error states
|
|
453
|
+
{error && <Skeleton width="full" height="200px" />}
|
|
454
|
+
// Show error message instead
|
|
455
|
+
|
|
456
|
+
// ❌ Don't mix skeleton with partial content
|
|
457
|
+
<Card>
|
|
458
|
+
<Skeleton width="200px" height="20px" />
|
|
459
|
+
<Text>This text is loaded</Text> // Inconsistent loading state
|
|
460
|
+
</Card>
|
|
461
|
+
|
|
462
|
+
// ✅ Always set dimensions for predictable layout
|
|
463
|
+
<Skeleton width="200px" height="20px" />
|
|
464
|
+
|
|
465
|
+
// ✅ Use loading state from data fetching
|
|
466
|
+
<Skeleton loading={isLoading} width="200px" height="20px">
|
|
467
|
+
<Text>{data.title}</Text>
|
|
468
|
+
</Skeleton>
|
|
469
|
+
|
|
470
|
+
// ✅ Show entire section as loading or loaded
|
|
471
|
+
{isLoading ? (
|
|
472
|
+
<CardSkeleton />
|
|
473
|
+
) : (
|
|
474
|
+
<Card>{content}</Card>
|
|
475
|
+
)}
|
|
476
|
+
|
|
477
|
+
// ✅ Use appropriate animation for context
|
|
478
|
+
<Skeleton
|
|
479
|
+
variant={prefersReducedMotion ? 'none' : 'pulse'}
|
|
480
|
+
width="full"
|
|
481
|
+
height="200px"
|
|
482
|
+
/>
|
|
483
|
+
|
|
484
|
+
// ✅ Show error state explicitly
|
|
485
|
+
{error ? (
|
|
486
|
+
<Alert variant="error">{error.message}</Alert>
|
|
487
|
+
) : isLoading ? (
|
|
488
|
+
<Skeleton width="full" height="200px" />
|
|
489
|
+
) : (
|
|
490
|
+
<Content data={data} />
|
|
491
|
+
)}
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
## Accessibility
|
|
495
|
+
|
|
496
|
+
The Skeleton component follows WCAG 2.1 Level AA standards:
|
|
497
|
+
|
|
498
|
+
- **Hidden Content**: Uses `visibility: hidden` for skeleton content to prevent screen reader announcement
|
|
499
|
+
- **Semantic Structure**: Maintains layout structure while loading
|
|
500
|
+
- **Reduced Motion**: Respects `prefers-reduced-motion` user preference
|
|
501
|
+
- **Color Independence**: Does not rely on color alone (uses animation)
|
|
502
|
+
- **No Focus Trap**: Skeleton elements are not focusable
|
|
503
|
+
- **Transparent Text**: Uses `color: transparent` to hide text during loading
|
|
504
|
+
|
|
505
|
+
### Accessibility Best Practices
|
|
506
|
+
|
|
507
|
+
```typescript
|
|
508
|
+
// ✅ Provide loading announcement
|
|
509
|
+
<div role="status" aria-live="polite" aria-busy={isLoading}>
|
|
510
|
+
{isLoading ? (
|
|
511
|
+
<SkeletonText noOfLines={3} />
|
|
512
|
+
) : (
|
|
513
|
+
<Text>{content}</Text>
|
|
514
|
+
)}
|
|
515
|
+
</div>
|
|
516
|
+
|
|
517
|
+
// ✅ Use aria-label for loading regions
|
|
518
|
+
<section aria-label="Loading articles" aria-busy={isLoading}>
|
|
519
|
+
{isLoading ? (
|
|
520
|
+
<ArticleListSkeleton />
|
|
521
|
+
) : (
|
|
522
|
+
<ArticleList articles={data} />
|
|
523
|
+
)}
|
|
524
|
+
</section>
|
|
525
|
+
|
|
526
|
+
// ✅ Respect reduced motion preference
|
|
527
|
+
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
|
|
528
|
+
|
|
529
|
+
<Skeleton
|
|
530
|
+
variant={prefersReducedMotion ? 'none' : 'pulse'}
|
|
531
|
+
width="200px"
|
|
532
|
+
height="20px"
|
|
533
|
+
/>
|
|
534
|
+
|
|
535
|
+
// ✅ Announce loading state changes
|
|
536
|
+
<div aria-live="polite">
|
|
537
|
+
{isLoading && <span className="sr-only">Loading content...</span>}
|
|
538
|
+
</div>
|
|
539
|
+
|
|
540
|
+
// ✅ Maintain focus management
|
|
541
|
+
function ContentSection() {
|
|
542
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
543
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
544
|
+
|
|
545
|
+
useEffect(() => {
|
|
546
|
+
if (!isLoading && contentRef.current) {
|
|
547
|
+
// Focus first interactive element after load
|
|
548
|
+
const firstButton = contentRef.current.querySelector('button');
|
|
549
|
+
firstButton?.focus();
|
|
550
|
+
}
|
|
551
|
+
}, [isLoading]);
|
|
552
|
+
|
|
553
|
+
return (
|
|
554
|
+
<div ref={contentRef}>
|
|
555
|
+
{isLoading ? <ContentSkeleton /> : <Content />}
|
|
556
|
+
</div>
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
## Animation Selection Guide
|
|
562
|
+
|
|
563
|
+
| Scenario | Recommended Variant | Reasoning |
|
|
564
|
+
| --------------------------- | ------------------- | -------------------------------------------- |
|
|
565
|
+
| General content loading | `pulse` | Subtle, not distracting, good for most cases |
|
|
566
|
+
| Premium/prominent content | `shine` | Enhanced visual feedback, modern feel |
|
|
567
|
+
| User prefers reduced motion | `none` | Respects accessibility preferences |
|
|
568
|
+
| Many simultaneous skeletons | `pulse` | Better performance than shine |
|
|
569
|
+
| Quick loading (under 500ms) | `none` | Prevents animation flash |
|
|
570
|
+
| Long loading (over 2s) | `shine` | Indicates activity, reduces perceived wait |
|
|
571
|
+
| Background/ambient loading | `pulse` | Less attention-grabbing |
|
|
572
|
+
|
|
573
|
+
## Performance Considerations
|
|
574
|
+
|
|
575
|
+
```typescript
|
|
576
|
+
// ✅ Use pulse for multiple skeletons (better performance)
|
|
577
|
+
<Grid columns={4}>
|
|
578
|
+
{[...Array(20)].map((_, i) => (
|
|
579
|
+
<Skeleton key={i} variant="pulse" width="full" height="200px" />
|
|
580
|
+
))}
|
|
581
|
+
</Grid>
|
|
582
|
+
|
|
583
|
+
// ⚠️ Be cautious with shine for many elements
|
|
584
|
+
<Grid columns={4}>
|
|
585
|
+
{[...Array(20)].map((_, i) => (
|
|
586
|
+
<Skeleton key={i} variant="shine" width="full" height="200px" />
|
|
587
|
+
))} // Can cause performance issues
|
|
588
|
+
</Grid>
|
|
589
|
+
|
|
590
|
+
// ✅ Use memo for skeleton components
|
|
591
|
+
const ProductSkeleton = memo(() => (
|
|
592
|
+
<Card>
|
|
593
|
+
<Stack gap="3">
|
|
594
|
+
<Skeleton width="full" height="200px" />
|
|
595
|
+
<Skeleton width="90%" height="20px" />
|
|
596
|
+
<Skeleton width="60px" height="24px" />
|
|
597
|
+
</Stack>
|
|
598
|
+
</Card>
|
|
599
|
+
));
|
|
600
|
+
|
|
601
|
+
// ✅ Virtualize long lists with skeletons
|
|
602
|
+
function VirtualizedList() {
|
|
603
|
+
return (
|
|
604
|
+
<VirtualList
|
|
605
|
+
items={isLoading ? Array(50).fill(null) : data}
|
|
606
|
+
renderItem={(item) =>
|
|
607
|
+
item === null ? <ItemSkeleton /> : <Item data={item} />
|
|
608
|
+
}
|
|
609
|
+
/>
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
## Responsive Considerations
|
|
615
|
+
|
|
616
|
+
```typescript
|
|
617
|
+
// Responsive skeleton dimensions
|
|
618
|
+
<Skeleton
|
|
619
|
+
width={{ base: 'full', md: '300px' }}
|
|
620
|
+
height={{ base: '150px', md: '200px' }}
|
|
621
|
+
/>
|
|
622
|
+
|
|
623
|
+
// Responsive text lines
|
|
624
|
+
<SkeletonText
|
|
625
|
+
noOfLines={{ base: 2, md: 3 }}
|
|
626
|
+
/>
|
|
627
|
+
|
|
628
|
+
// Responsive grid with skeletons
|
|
629
|
+
<Grid
|
|
630
|
+
columns={{ base: 1, sm: 2, md: 3, lg: 4 }}
|
|
631
|
+
gap="4"
|
|
632
|
+
>
|
|
633
|
+
{[...Array(8)].map((_, i) => (
|
|
634
|
+
<ProductSkeleton key={i} />
|
|
635
|
+
))}
|
|
636
|
+
</Grid>
|
|
637
|
+
|
|
638
|
+
// Mobile-optimized avatar size
|
|
639
|
+
<SkeletonCircle size={{ base: '48px', md: '64px' }} />
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
## Testing
|
|
643
|
+
|
|
644
|
+
When testing Skeleton components:
|
|
645
|
+
|
|
646
|
+
```typescript
|
|
647
|
+
import { render, screen } from '@testing-library/react';
|
|
648
|
+
import { Skeleton, SkeletonText, SkeletonCircle } from '@discourser/design-system';
|
|
649
|
+
|
|
650
|
+
test('shows skeleton when loading', () => {
|
|
651
|
+
render(
|
|
652
|
+
<Skeleton loading={true} width="200px" height="20px">
|
|
653
|
+
<Text>Content</Text>
|
|
654
|
+
</Skeleton>
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
// Skeleton should be visible
|
|
658
|
+
const skeleton = screen.getByTestId('skeleton'); // Add data-testid if needed
|
|
659
|
+
expect(skeleton).toBeInTheDocument();
|
|
660
|
+
|
|
661
|
+
// Content should be hidden
|
|
662
|
+
expect(screen.queryByText('Content')).not.toBeVisible();
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test('shows content when not loading', () => {
|
|
666
|
+
render(
|
|
667
|
+
<Skeleton loading={false} width="200px" height="20px">
|
|
668
|
+
<Text>Content</Text>
|
|
669
|
+
</Skeleton>
|
|
670
|
+
);
|
|
671
|
+
|
|
672
|
+
// Content should be visible
|
|
673
|
+
expect(screen.getByText('Content')).toBeVisible();
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
test('renders correct number of lines for SkeletonText', () => {
|
|
677
|
+
const { container } = render(<SkeletonText noOfLines={5} />);
|
|
678
|
+
|
|
679
|
+
// Should render 5 skeleton lines
|
|
680
|
+
const skeletonLines = container.querySelectorAll('[class*="skeleton"]');
|
|
681
|
+
expect(skeletonLines).toHaveLength(5);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test('SkeletonCircle renders as circular', () => {
|
|
685
|
+
const { container } = render(<SkeletonCircle size="48px" />);
|
|
686
|
+
|
|
687
|
+
const circle = container.firstChild;
|
|
688
|
+
expect(circle).toHaveStyle({
|
|
689
|
+
borderRadius: '9999px',
|
|
690
|
+
});
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test('respects loading state changes', () => {
|
|
694
|
+
const { rerender } = render(
|
|
695
|
+
<Skeleton loading={true} width="200px" height="20px">
|
|
696
|
+
<Text>Content</Text>
|
|
697
|
+
</Skeleton>
|
|
698
|
+
);
|
|
699
|
+
|
|
700
|
+
expect(screen.queryByText('Content')).not.toBeVisible();
|
|
701
|
+
|
|
702
|
+
rerender(
|
|
703
|
+
<Skeleton loading={false} width="200px" height="20px">
|
|
704
|
+
<Text>Content</Text>
|
|
705
|
+
</Skeleton>
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
expect(screen.getByText('Content')).toBeVisible();
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
test('applies correct animation variant', () => {
|
|
712
|
+
const { container } = render(
|
|
713
|
+
<Skeleton variant="shine" width="200px" height="20px" />
|
|
714
|
+
);
|
|
715
|
+
|
|
716
|
+
const skeleton = container.firstChild;
|
|
717
|
+
expect(skeleton).toHaveAttribute('data-variant', 'shine');
|
|
718
|
+
});
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
## Related Components
|
|
722
|
+
|
|
723
|
+
- **Spinner**: For small, inline loading indicators
|
|
724
|
+
- **Progress**: For determinate loading with progress indication
|
|
725
|
+
- **Toast**: For notifying users when content finishes loading
|
|
726
|
+
- **EmptyState**: For when no content is available after loading
|