@discourser/design-system 0.4.0 → 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 +67 -123
- package/guidelines/components/accordion.md +93 -0
- package/guidelines/components/avatar.md +70 -0
- package/guidelines/components/badge.md +61 -0
- package/guidelines/components/button.md +75 -40
- package/guidelines/components/card.md +84 -25
- package/guidelines/components/checkbox.md +88 -0
- package/guidelines/components/dialog.md +619 -31
- package/guidelines/components/drawer.md +655 -0
- package/guidelines/components/heading.md +71 -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 +71 -0
- package/guidelines/components/progress.md +63 -0
- package/guidelines/components/radio-group.md +95 -0
- package/guidelines/components/select.md +507 -0
- package/guidelines/components/skeleton.md +76 -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 +654 -0
- package/guidelines/components/textarea.md +70 -0
- package/guidelines/components/toast.md +77 -0
- package/guidelines/components/tooltip.md +80 -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 +9 -5
- package/guidelines/overview-imports.md +314 -0
- package/guidelines/overview-patterns.md +3852 -0
- package/package.json +4 -2
|
@@ -0,0 +1,3852 @@
|
|
|
1
|
+
# Common UI Patterns
|
|
2
|
+
|
|
3
|
+
This guide demonstrates common patterns for combining components from the Discourser Design System to create complete user interfaces. These patterns help developers and AI tools understand how to build real-world UIs using the design system.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Form Patterns](#form-patterns)
|
|
8
|
+
- [Vertical Form (Default)](#vertical-form-default)
|
|
9
|
+
- [Horizontal Form (Compact)](#horizontal-form-compact)
|
|
10
|
+
- [Multi-Step Form](#multi-step-form)
|
|
11
|
+
- [Form with Inline Validation](#form-with-inline-validation)
|
|
12
|
+
- [Form with Field Dependencies](#form-with-field-dependencies)
|
|
13
|
+
- [Navigation Patterns](#navigation-patterns)
|
|
14
|
+
- [Sidebar Navigation](#sidebar-navigation)
|
|
15
|
+
- [Top Navigation Bar](#top-navigation-bar)
|
|
16
|
+
- [Tabbed Interface](#tabbed-interface)
|
|
17
|
+
- [Breadcrumb Navigation](#breadcrumb-navigation)
|
|
18
|
+
- [Feedback Patterns](#feedback-patterns)
|
|
19
|
+
- [Success Flow](#success-flow)
|
|
20
|
+
- [Error Handling](#error-handling)
|
|
21
|
+
- [Confirmation Dialogs](#confirmation-dialogs)
|
|
22
|
+
- [Inline Notifications](#inline-notifications)
|
|
23
|
+
- [Loading States](#loading-states)
|
|
24
|
+
- [Page Load](#page-load)
|
|
25
|
+
- [Partial Load (Section)](#partial-load-section)
|
|
26
|
+
- [Button Loading State](#button-loading-state)
|
|
27
|
+
- [Infinite Scroll](#infinite-scroll)
|
|
28
|
+
- [Data Display Patterns](#data-display-patterns)
|
|
29
|
+
- [Card Grid](#card-grid)
|
|
30
|
+
- [List with Actions](#list-with-actions)
|
|
31
|
+
- [List with Avatar](#list-with-avatar)
|
|
32
|
+
- [Expandable/Collapsible List](#expandablecollapsible-list)
|
|
33
|
+
- [Search & Filter Patterns](#search--filter-patterns)
|
|
34
|
+
- [Search Bar (Simple)](#search-bar-simple)
|
|
35
|
+
- [Search with Filters](#search-with-filters)
|
|
36
|
+
- [Search with Results](#search-with-results)
|
|
37
|
+
- [Authentication Patterns](#authentication-patterns)
|
|
38
|
+
- [Login Form](#login-form)
|
|
39
|
+
- [Sign Up Form](#sign-up-form)
|
|
40
|
+
- [Password Reset Flow](#password-reset-flow)
|
|
41
|
+
- [Settings Patterns](#settings-patterns)
|
|
42
|
+
- [Settings Panel](#settings-panel)
|
|
43
|
+
- [Profile Settings](#profile-settings)
|
|
44
|
+
- [Empty States](#empty-states)
|
|
45
|
+
- [No Data](#no-data)
|
|
46
|
+
- [No Search Results](#no-search-results)
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Form Patterns
|
|
51
|
+
|
|
52
|
+
### Vertical Form (Default)
|
|
53
|
+
|
|
54
|
+
**When to use:** Standard form layout for most use cases. Provides clear hierarchy and is mobile-friendly.
|
|
55
|
+
|
|
56
|
+
**Components used:** Input, Textarea, Select, Button, Toast
|
|
57
|
+
|
|
58
|
+
**Example:**
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
import { Input, Textarea, Button, toaster } from '@discourser/design-system';
|
|
62
|
+
import * as Select from '@discourser/design-system';
|
|
63
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
64
|
+
import { useState, FormEvent } from 'react';
|
|
65
|
+
|
|
66
|
+
function ContactForm() {
|
|
67
|
+
const [loading, setLoading] = useState(false);
|
|
68
|
+
const [formData, setFormData] = useState({
|
|
69
|
+
name: '',
|
|
70
|
+
email: '',
|
|
71
|
+
subject: '',
|
|
72
|
+
message: ''
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const handleSubmit = async (e: FormEvent) => {
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
setLoading(true);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
// Submit form data
|
|
81
|
+
await submitForm(formData);
|
|
82
|
+
|
|
83
|
+
toaster.create({
|
|
84
|
+
title: 'Message sent!',
|
|
85
|
+
description: "We'll get back to you soon.",
|
|
86
|
+
type: 'success'
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Reset form
|
|
90
|
+
setFormData({ name: '', email: '', subject: '', message: '' });
|
|
91
|
+
} catch (error) {
|
|
92
|
+
toaster.create({
|
|
93
|
+
title: 'Failed to send',
|
|
94
|
+
description: 'Please try again later.',
|
|
95
|
+
type: 'error'
|
|
96
|
+
});
|
|
97
|
+
} finally {
|
|
98
|
+
setLoading(false);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<form
|
|
104
|
+
onSubmit={handleSubmit}
|
|
105
|
+
className={css({
|
|
106
|
+
display: 'flex',
|
|
107
|
+
flexDirection: 'column',
|
|
108
|
+
gap: 'lg', // Spacing - 24px between fields
|
|
109
|
+
maxWidth: '500px',
|
|
110
|
+
mx: 'auto',
|
|
111
|
+
p: 'xl' // Spacing - 32px padding
|
|
112
|
+
})}
|
|
113
|
+
>
|
|
114
|
+
<Input
|
|
115
|
+
label="Name"
|
|
116
|
+
value={formData.name}
|
|
117
|
+
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
118
|
+
required
|
|
119
|
+
/>
|
|
120
|
+
|
|
121
|
+
<Input
|
|
122
|
+
label="Email"
|
|
123
|
+
type="email"
|
|
124
|
+
value={formData.email}
|
|
125
|
+
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
|
126
|
+
required
|
|
127
|
+
/>
|
|
128
|
+
|
|
129
|
+
<Input
|
|
130
|
+
label="Subject"
|
|
131
|
+
value={formData.subject}
|
|
132
|
+
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
|
|
133
|
+
required
|
|
134
|
+
/>
|
|
135
|
+
|
|
136
|
+
<Textarea
|
|
137
|
+
label="Message"
|
|
138
|
+
value={formData.message}
|
|
139
|
+
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
|
140
|
+
rows={5}
|
|
141
|
+
required
|
|
142
|
+
/>
|
|
143
|
+
|
|
144
|
+
<Button
|
|
145
|
+
type="submit"
|
|
146
|
+
variant="filled"
|
|
147
|
+
disabled={loading}
|
|
148
|
+
>
|
|
149
|
+
{loading ? 'Sending...' : 'Send Message'}
|
|
150
|
+
</Button>
|
|
151
|
+
</form>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Best practices:**
|
|
157
|
+
|
|
158
|
+
- Use `gap` for consistent spacing between form fields
|
|
159
|
+
- Always include labels for accessibility
|
|
160
|
+
- Provide loading states for async submissions
|
|
161
|
+
- Use Toast for success/error feedback
|
|
162
|
+
- Make forms responsive with `maxWidth`
|
|
163
|
+
|
|
164
|
+
**Accessibility:**
|
|
165
|
+
|
|
166
|
+
- All inputs have labels
|
|
167
|
+
- Submit button has clear text
|
|
168
|
+
- Loading state is communicated
|
|
169
|
+
- Form can be submitted with Enter key
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
### Horizontal Form (Compact)
|
|
174
|
+
|
|
175
|
+
**When to use:** Space-constrained layouts, filters, or inline editing where vertical space is limited.
|
|
176
|
+
|
|
177
|
+
**Components used:** Input, Button
|
|
178
|
+
|
|
179
|
+
**Example:**
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
import { Input, Button } from '@discourser/design-system';
|
|
183
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
184
|
+
import { useState } from 'react';
|
|
185
|
+
|
|
186
|
+
function InlineSearchForm() {
|
|
187
|
+
const [query, setQuery] = useState('');
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<form
|
|
191
|
+
className={css({
|
|
192
|
+
display: 'flex',
|
|
193
|
+
gap: 'md', // Spacing - 16px between elements
|
|
194
|
+
alignItems: 'flex-end', // Align button with input
|
|
195
|
+
flexWrap: 'wrap' // Stack on mobile if needed
|
|
196
|
+
})}
|
|
197
|
+
onSubmit={(e) => {
|
|
198
|
+
e.preventDefault();
|
|
199
|
+
handleSearch(query);
|
|
200
|
+
}}
|
|
201
|
+
>
|
|
202
|
+
<div className={css({ flex: 1, minWidth: '200px' })}>
|
|
203
|
+
<Input
|
|
204
|
+
label="Search query"
|
|
205
|
+
value={query}
|
|
206
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
207
|
+
placeholder="Enter keywords..."
|
|
208
|
+
size="sm"
|
|
209
|
+
/>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<Button
|
|
213
|
+
type="submit"
|
|
214
|
+
variant="filled"
|
|
215
|
+
size="sm"
|
|
216
|
+
>
|
|
217
|
+
Search
|
|
218
|
+
</Button>
|
|
219
|
+
</form>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
**Best practices:**
|
|
225
|
+
|
|
226
|
+
- Use `alignItems: 'flex-end'` to align button with input baseline
|
|
227
|
+
- Add `flexWrap: 'wrap'` for mobile responsiveness
|
|
228
|
+
- Keep labels visible (don't rely only on placeholders)
|
|
229
|
+
- Use `size="sm"` for more compact layouts
|
|
230
|
+
|
|
231
|
+
**Accessibility:**
|
|
232
|
+
|
|
233
|
+
- Labels are present even in horizontal layout
|
|
234
|
+
- Tab order follows visual order
|
|
235
|
+
- Works with keyboard navigation
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
### Multi-Step Form
|
|
240
|
+
|
|
241
|
+
**When to use:** Complex forms with many fields that benefit from being broken into logical sections.
|
|
242
|
+
|
|
243
|
+
**Components used:** Tabs, Input, Button, Progress
|
|
244
|
+
|
|
245
|
+
**Example:**
|
|
246
|
+
|
|
247
|
+
```typescript
|
|
248
|
+
import { Input, Textarea, Button, toaster } from '@discourser/design-system';
|
|
249
|
+
import * as Tabs from '@discourser/design-system';
|
|
250
|
+
import * as Progress from '@discourser/design-system';
|
|
251
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
252
|
+
import { useState } from 'react';
|
|
253
|
+
|
|
254
|
+
function MultiStepForm() {
|
|
255
|
+
const [currentStep, setCurrentStep] = useState(0);
|
|
256
|
+
const [formData, setFormData] = useState({
|
|
257
|
+
// Step 1: Personal info
|
|
258
|
+
name: '',
|
|
259
|
+
email: '',
|
|
260
|
+
// Step 2: Company info
|
|
261
|
+
company: '',
|
|
262
|
+
role: '',
|
|
263
|
+
// Step 3: Preferences
|
|
264
|
+
interests: ''
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const steps = ['Personal Info', 'Company Info', 'Preferences'];
|
|
268
|
+
const progress = ((currentStep + 1) / steps.length) * 100;
|
|
269
|
+
|
|
270
|
+
const handleNext = () => {
|
|
271
|
+
if (currentStep < steps.length - 1) {
|
|
272
|
+
setCurrentStep(currentStep + 1);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const handleBack = () => {
|
|
277
|
+
if (currentStep > 0) {
|
|
278
|
+
setCurrentStep(currentStep - 1);
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const handleSubmit = async () => {
|
|
283
|
+
try {
|
|
284
|
+
await submitForm(formData);
|
|
285
|
+
toaster.create({
|
|
286
|
+
title: 'Registration complete!',
|
|
287
|
+
description: 'Your account has been created.',
|
|
288
|
+
type: 'success'
|
|
289
|
+
});
|
|
290
|
+
} catch (error) {
|
|
291
|
+
toaster.create({
|
|
292
|
+
title: 'Registration failed',
|
|
293
|
+
description: 'Please try again.',
|
|
294
|
+
type: 'error'
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
return (
|
|
300
|
+
<div className={css({ maxWidth: '600px', mx: 'auto', p: 'xl' })}>
|
|
301
|
+
{/* Progress indicator */}
|
|
302
|
+
<div className={css({ mb: 'xl' })}>
|
|
303
|
+
<Progress.Root value={progress}>
|
|
304
|
+
<Progress.Track>
|
|
305
|
+
<Progress.Range />
|
|
306
|
+
</Progress.Track>
|
|
307
|
+
</Progress.Root>
|
|
308
|
+
<div className={css({
|
|
309
|
+
textStyle: 'labelMedium',
|
|
310
|
+
color: 'onSurfaceVariant',
|
|
311
|
+
mt: 'xs'
|
|
312
|
+
})}>
|
|
313
|
+
Step {currentStep + 1} of {steps.length}: {steps[currentStep]}
|
|
314
|
+
</div>
|
|
315
|
+
</div>
|
|
316
|
+
|
|
317
|
+
{/* Form content */}
|
|
318
|
+
<div className={css({
|
|
319
|
+
display: 'flex',
|
|
320
|
+
flexDirection: 'column',
|
|
321
|
+
gap: 'lg',
|
|
322
|
+
mb: 'xl'
|
|
323
|
+
})}>
|
|
324
|
+
{currentStep === 0 && (
|
|
325
|
+
<>
|
|
326
|
+
<Input
|
|
327
|
+
label="Full Name"
|
|
328
|
+
value={formData.name}
|
|
329
|
+
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
330
|
+
required
|
|
331
|
+
/>
|
|
332
|
+
<Input
|
|
333
|
+
label="Email"
|
|
334
|
+
type="email"
|
|
335
|
+
value={formData.email}
|
|
336
|
+
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
|
337
|
+
required
|
|
338
|
+
/>
|
|
339
|
+
</>
|
|
340
|
+
)}
|
|
341
|
+
|
|
342
|
+
{currentStep === 1 && (
|
|
343
|
+
<>
|
|
344
|
+
<Input
|
|
345
|
+
label="Company Name"
|
|
346
|
+
value={formData.company}
|
|
347
|
+
onChange={(e) => setFormData({ ...formData, company: e.target.value })}
|
|
348
|
+
required
|
|
349
|
+
/>
|
|
350
|
+
<Input
|
|
351
|
+
label="Your Role"
|
|
352
|
+
value={formData.role}
|
|
353
|
+
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
|
|
354
|
+
required
|
|
355
|
+
/>
|
|
356
|
+
</>
|
|
357
|
+
)}
|
|
358
|
+
|
|
359
|
+
{currentStep === 2 && (
|
|
360
|
+
<Textarea
|
|
361
|
+
label="What are you interested in?"
|
|
362
|
+
value={formData.interests}
|
|
363
|
+
onChange={(e) => setFormData({ ...formData, interests: e.target.value })}
|
|
364
|
+
rows={5}
|
|
365
|
+
/>
|
|
366
|
+
)}
|
|
367
|
+
</div>
|
|
368
|
+
|
|
369
|
+
{/* Navigation buttons */}
|
|
370
|
+
<div className={css({
|
|
371
|
+
display: 'flex',
|
|
372
|
+
gap: 'sm',
|
|
373
|
+
justifyContent: 'space-between'
|
|
374
|
+
})}>
|
|
375
|
+
<Button
|
|
376
|
+
variant="outlined"
|
|
377
|
+
onClick={handleBack}
|
|
378
|
+
disabled={currentStep === 0}
|
|
379
|
+
>
|
|
380
|
+
Back
|
|
381
|
+
</Button>
|
|
382
|
+
|
|
383
|
+
{currentStep < steps.length - 1 ? (
|
|
384
|
+
<Button variant="filled" onClick={handleNext}>
|
|
385
|
+
Next
|
|
386
|
+
</Button>
|
|
387
|
+
) : (
|
|
388
|
+
<Button variant="filled" onClick={handleSubmit}>
|
|
389
|
+
Submit
|
|
390
|
+
</Button>
|
|
391
|
+
)}
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
**Best practices:**
|
|
399
|
+
|
|
400
|
+
- Show progress indicator to communicate position
|
|
401
|
+
- Validate each step before allowing next
|
|
402
|
+
- Allow users to go back to previous steps
|
|
403
|
+
- Save progress automatically if possible
|
|
404
|
+
- Use clear step labels
|
|
405
|
+
|
|
406
|
+
**Accessibility:**
|
|
407
|
+
|
|
408
|
+
- Progress indicator announces current step
|
|
409
|
+
- Navigation buttons have clear labels
|
|
410
|
+
- Keyboard navigation works between steps
|
|
411
|
+
|
|
412
|
+
---
|
|
413
|
+
|
|
414
|
+
### Form with Inline Validation
|
|
415
|
+
|
|
416
|
+
**When to use:** Forms where immediate feedback helps users correct errors while filling out the form.
|
|
417
|
+
|
|
418
|
+
**Components used:** Input, Button, Toast
|
|
419
|
+
|
|
420
|
+
**Example:**
|
|
421
|
+
|
|
422
|
+
```typescript
|
|
423
|
+
import { Input, Button, toaster } from '@discourser/design-system';
|
|
424
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
425
|
+
import { useState } from 'react';
|
|
426
|
+
|
|
427
|
+
function ValidatedForm() {
|
|
428
|
+
const [formData, setFormData] = useState({
|
|
429
|
+
username: '',
|
|
430
|
+
email: '',
|
|
431
|
+
password: '',
|
|
432
|
+
confirmPassword: ''
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
436
|
+
const [touched, setTouched] = useState<Record<string, boolean>>({});
|
|
437
|
+
|
|
438
|
+
// Validation functions
|
|
439
|
+
const validateUsername = (value: string) => {
|
|
440
|
+
if (!value) return 'Username is required';
|
|
441
|
+
if (value.length < 3) return 'Username must be at least 3 characters';
|
|
442
|
+
if (!/^[a-zA-Z0-9_]+$/.test(value)) return 'Username can only contain letters, numbers, and underscores';
|
|
443
|
+
return '';
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const validateEmail = (value: string) => {
|
|
447
|
+
if (!value) return 'Email is required';
|
|
448
|
+
if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(value)) {
|
|
449
|
+
return 'Invalid email address';
|
|
450
|
+
}
|
|
451
|
+
return '';
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
const validatePassword = (value: string) => {
|
|
455
|
+
if (!value) return 'Password is required';
|
|
456
|
+
if (value.length < 8) return 'Password must be at least 8 characters';
|
|
457
|
+
if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(value)) {
|
|
458
|
+
return 'Password must contain uppercase, lowercase, and number';
|
|
459
|
+
}
|
|
460
|
+
return '';
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const validateConfirmPassword = (value: string) => {
|
|
464
|
+
if (!value) return 'Please confirm your password';
|
|
465
|
+
if (value !== formData.password) return 'Passwords do not match';
|
|
466
|
+
return '';
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
const handleBlur = (field: string) => {
|
|
470
|
+
setTouched({ ...touched, [field]: true });
|
|
471
|
+
|
|
472
|
+
let error = '';
|
|
473
|
+
switch (field) {
|
|
474
|
+
case 'username':
|
|
475
|
+
error = validateUsername(formData.username);
|
|
476
|
+
break;
|
|
477
|
+
case 'email':
|
|
478
|
+
error = validateEmail(formData.email);
|
|
479
|
+
break;
|
|
480
|
+
case 'password':
|
|
481
|
+
error = validatePassword(formData.password);
|
|
482
|
+
break;
|
|
483
|
+
case 'confirmPassword':
|
|
484
|
+
error = validateConfirmPassword(formData.confirmPassword);
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
setErrors({ ...errors, [field]: error });
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
492
|
+
e.preventDefault();
|
|
493
|
+
|
|
494
|
+
// Validate all fields
|
|
495
|
+
const newErrors = {
|
|
496
|
+
username: validateUsername(formData.username),
|
|
497
|
+
email: validateEmail(formData.email),
|
|
498
|
+
password: validatePassword(formData.password),
|
|
499
|
+
confirmPassword: validateConfirmPassword(formData.confirmPassword)
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
setErrors(newErrors);
|
|
503
|
+
setTouched({ username: true, email: true, password: true, confirmPassword: true });
|
|
504
|
+
|
|
505
|
+
const hasErrors = Object.values(newErrors).some(error => error !== '');
|
|
506
|
+
|
|
507
|
+
if (!hasErrors) {
|
|
508
|
+
try {
|
|
509
|
+
await registerUser(formData);
|
|
510
|
+
toaster.create({
|
|
511
|
+
title: 'Account created!',
|
|
512
|
+
description: 'Welcome to our platform.',
|
|
513
|
+
type: 'success'
|
|
514
|
+
});
|
|
515
|
+
} catch (error) {
|
|
516
|
+
toaster.create({
|
|
517
|
+
title: 'Registration failed',
|
|
518
|
+
description: 'Please try again.',
|
|
519
|
+
type: 'error'
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
return (
|
|
526
|
+
<form onSubmit={handleSubmit} className={css({
|
|
527
|
+
display: 'flex',
|
|
528
|
+
flexDirection: 'column',
|
|
529
|
+
gap: 'lg',
|
|
530
|
+
maxWidth: '400px',
|
|
531
|
+
mx: 'auto',
|
|
532
|
+
p: 'xl'
|
|
533
|
+
})}>
|
|
534
|
+
<Input
|
|
535
|
+
label="Username"
|
|
536
|
+
value={formData.username}
|
|
537
|
+
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
|
538
|
+
onBlur={() => handleBlur('username')}
|
|
539
|
+
errorText={touched.username ? errors.username : ''}
|
|
540
|
+
required
|
|
541
|
+
/>
|
|
542
|
+
|
|
543
|
+
<Input
|
|
544
|
+
label="Email"
|
|
545
|
+
type="email"
|
|
546
|
+
value={formData.email}
|
|
547
|
+
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
|
548
|
+
onBlur={() => handleBlur('email')}
|
|
549
|
+
errorText={touched.email ? errors.email : ''}
|
|
550
|
+
required
|
|
551
|
+
/>
|
|
552
|
+
|
|
553
|
+
<Input
|
|
554
|
+
label="Password"
|
|
555
|
+
type="password"
|
|
556
|
+
value={formData.password}
|
|
557
|
+
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
|
558
|
+
onBlur={() => handleBlur('password')}
|
|
559
|
+
errorText={touched.password ? errors.password : ''}
|
|
560
|
+
helperText="Must be 8+ characters with uppercase, lowercase, and number"
|
|
561
|
+
required
|
|
562
|
+
/>
|
|
563
|
+
|
|
564
|
+
<Input
|
|
565
|
+
label="Confirm Password"
|
|
566
|
+
type="password"
|
|
567
|
+
value={formData.confirmPassword}
|
|
568
|
+
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
|
569
|
+
onBlur={() => handleBlur('confirmPassword')}
|
|
570
|
+
errorText={touched.confirmPassword ? errors.confirmPassword : ''}
|
|
571
|
+
required
|
|
572
|
+
/>
|
|
573
|
+
|
|
574
|
+
<Button type="submit" variant="filled">
|
|
575
|
+
Create Account
|
|
576
|
+
</Button>
|
|
577
|
+
</form>
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
**Best practices:**
|
|
583
|
+
|
|
584
|
+
- Validate on blur, not on every keystroke
|
|
585
|
+
- Show errors only after user leaves field
|
|
586
|
+
- Provide helpful error messages
|
|
587
|
+
- Include helper text for complex requirements
|
|
588
|
+
- Validate all fields on submit
|
|
589
|
+
|
|
590
|
+
**Accessibility:**
|
|
591
|
+
|
|
592
|
+
- Error messages are announced to screen readers
|
|
593
|
+
- Errors are associated with inputs via ARIA
|
|
594
|
+
- Helper text provides guidance upfront
|
|
595
|
+
|
|
596
|
+
---
|
|
597
|
+
|
|
598
|
+
### Form with Field Dependencies
|
|
599
|
+
|
|
600
|
+
**When to use:** Forms where certain fields only appear or are required based on previous selections.
|
|
601
|
+
|
|
602
|
+
**Components used:** Input, Select, RadioGroup, Checkbox
|
|
603
|
+
|
|
604
|
+
**Example:**
|
|
605
|
+
|
|
606
|
+
```typescript
|
|
607
|
+
import { Input, Button } from '@discourser/design-system';
|
|
608
|
+
import * as Select from '@discourser/design-system';
|
|
609
|
+
import * as RadioGroup from '@discourser/design-system';
|
|
610
|
+
import * as Checkbox from '@discourser/design-system';
|
|
611
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
612
|
+
import { useState } from 'react';
|
|
613
|
+
import { createListCollection } from '@ark-ui/react';
|
|
614
|
+
|
|
615
|
+
function DependentFieldsForm() {
|
|
616
|
+
const [accountType, setAccountType] = useState('personal');
|
|
617
|
+
const [hasCompany, setHasCompany] = useState(false);
|
|
618
|
+
const [receiveNewsletter, setReceiveNewsletter] = useState(false);
|
|
619
|
+
|
|
620
|
+
const accountTypes = createListCollection({
|
|
621
|
+
items: [
|
|
622
|
+
{ label: 'Personal', value: 'personal' },
|
|
623
|
+
{ label: 'Business', value: 'business' }
|
|
624
|
+
]
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
return (
|
|
628
|
+
<form className={css({
|
|
629
|
+
display: 'flex',
|
|
630
|
+
flexDirection: 'column',
|
|
631
|
+
gap: 'lg',
|
|
632
|
+
maxWidth: '500px',
|
|
633
|
+
mx: 'auto',
|
|
634
|
+
p: 'xl'
|
|
635
|
+
})}>
|
|
636
|
+
{/* Account type selection */}
|
|
637
|
+
<RadioGroup.Root
|
|
638
|
+
value={accountType}
|
|
639
|
+
onValueChange={(details) => setAccountType(details.value)}
|
|
640
|
+
>
|
|
641
|
+
<RadioGroup.Label>Account Type</RadioGroup.Label>
|
|
642
|
+
<div className={css({ display: 'flex', flexDirection: 'column', gap: 'sm' })}>
|
|
643
|
+
<RadioGroup.Item value="personal">
|
|
644
|
+
<RadioGroup.ItemControl />
|
|
645
|
+
<RadioGroup.ItemText>Personal</RadioGroup.ItemText>
|
|
646
|
+
</RadioGroup.Item>
|
|
647
|
+
<RadioGroup.Item value="business">
|
|
648
|
+
<RadioGroup.ItemControl />
|
|
649
|
+
<RadioGroup.ItemText>Business</RadioGroup.ItemText>
|
|
650
|
+
</RadioGroup.Item>
|
|
651
|
+
</div>
|
|
652
|
+
</RadioGroup.Root>
|
|
653
|
+
|
|
654
|
+
{/* Show company field only for business accounts */}
|
|
655
|
+
{accountType === 'business' && (
|
|
656
|
+
<>
|
|
657
|
+
<Input
|
|
658
|
+
label="Company Name"
|
|
659
|
+
required
|
|
660
|
+
/>
|
|
661
|
+
<Input
|
|
662
|
+
label="Tax ID"
|
|
663
|
+
required
|
|
664
|
+
/>
|
|
665
|
+
</>
|
|
666
|
+
)}
|
|
667
|
+
|
|
668
|
+
{/* Standard fields */}
|
|
669
|
+
<Input
|
|
670
|
+
label="Full Name"
|
|
671
|
+
required
|
|
672
|
+
/>
|
|
673
|
+
|
|
674
|
+
<Input
|
|
675
|
+
label="Email"
|
|
676
|
+
type="email"
|
|
677
|
+
required
|
|
678
|
+
/>
|
|
679
|
+
|
|
680
|
+
{/* Newsletter subscription */}
|
|
681
|
+
<Checkbox.Root
|
|
682
|
+
checked={receiveNewsletter}
|
|
683
|
+
onCheckedChange={(details) => setReceiveNewsletter(details.checked === true)}
|
|
684
|
+
>
|
|
685
|
+
<Checkbox.Control>
|
|
686
|
+
<Checkbox.Indicator>✓</Checkbox.Indicator>
|
|
687
|
+
</Checkbox.Control>
|
|
688
|
+
<Checkbox.Label>Subscribe to newsletter</Checkbox.Label>
|
|
689
|
+
</Checkbox.Root>
|
|
690
|
+
|
|
691
|
+
{/* Show frequency only if subscribed */}
|
|
692
|
+
{receiveNewsletter && (
|
|
693
|
+
<Select.Root collection={createListCollection({
|
|
694
|
+
items: [
|
|
695
|
+
{ label: 'Daily', value: 'daily' },
|
|
696
|
+
{ label: 'Weekly', value: 'weekly' },
|
|
697
|
+
{ label: 'Monthly', value: 'monthly' }
|
|
698
|
+
]
|
|
699
|
+
})}>
|
|
700
|
+
<Select.Label>Newsletter Frequency</Select.Label>
|
|
701
|
+
<Select.Control>
|
|
702
|
+
<Select.Trigger>
|
|
703
|
+
<Select.ValueText placeholder="Select frequency" />
|
|
704
|
+
</Select.Trigger>
|
|
705
|
+
</Select.Control>
|
|
706
|
+
<Select.Positioner>
|
|
707
|
+
<Select.Content>
|
|
708
|
+
{accountTypes.items.map((item) => (
|
|
709
|
+
<Select.Item key={item.value} item={item}>
|
|
710
|
+
<Select.ItemText>{item.label}</Select.ItemText>
|
|
711
|
+
</Select.Item>
|
|
712
|
+
))}
|
|
713
|
+
</Select.Content>
|
|
714
|
+
</Select.Positioner>
|
|
715
|
+
</Select.Root>
|
|
716
|
+
)}
|
|
717
|
+
|
|
718
|
+
<Button type="submit" variant="filled">
|
|
719
|
+
Create Account
|
|
720
|
+
</Button>
|
|
721
|
+
</form>
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
**Best practices:**
|
|
727
|
+
|
|
728
|
+
- Clear dependent fields when parent changes
|
|
729
|
+
- Use smooth transitions when showing/hiding fields
|
|
730
|
+
- Validate dependent fields only when visible
|
|
731
|
+
- Provide clear context for conditional fields
|
|
732
|
+
|
|
733
|
+
**Accessibility:**
|
|
734
|
+
|
|
735
|
+
- ARIA live regions announce field changes
|
|
736
|
+
- Focus management when fields appear
|
|
737
|
+
- Clear relationship between parent and dependent fields
|
|
738
|
+
|
|
739
|
+
---
|
|
740
|
+
|
|
741
|
+
## Navigation Patterns
|
|
742
|
+
|
|
743
|
+
### Sidebar Navigation
|
|
744
|
+
|
|
745
|
+
**When to use:** Applications with multiple primary sections that need persistent navigation.
|
|
746
|
+
|
|
747
|
+
**Components used:** Drawer, IconButton, Button
|
|
748
|
+
|
|
749
|
+
**Example:**
|
|
750
|
+
|
|
751
|
+
```typescript
|
|
752
|
+
import { Button } from '@discourser/design-system';
|
|
753
|
+
import * as Drawer from '@discourser/design-system';
|
|
754
|
+
import * as IconButton from '@discourser/design-system';
|
|
755
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
756
|
+
import { useState } from 'react';
|
|
757
|
+
import { HomeIcon, ProjectsIcon, SettingsIcon, MenuIcon } from 'your-icon-library';
|
|
758
|
+
|
|
759
|
+
function SidebarNavigation() {
|
|
760
|
+
const [open, setOpen] = useState(false);
|
|
761
|
+
const [activeSection, setActiveSection] = useState('home');
|
|
762
|
+
|
|
763
|
+
const navItems = [
|
|
764
|
+
{ id: 'home', label: 'Home', icon: HomeIcon },
|
|
765
|
+
{ id: 'projects', label: 'Projects', icon: ProjectsIcon },
|
|
766
|
+
{ id: 'settings', label: 'Settings', icon: SettingsIcon }
|
|
767
|
+
];
|
|
768
|
+
|
|
769
|
+
return (
|
|
770
|
+
<>
|
|
771
|
+
{/* Mobile menu button */}
|
|
772
|
+
<IconButton.Root
|
|
773
|
+
variant="standard"
|
|
774
|
+
onClick={() => setOpen(true)}
|
|
775
|
+
className={css({ position: 'fixed', top: 'md', left: 'md', zIndex: 10 })}
|
|
776
|
+
>
|
|
777
|
+
<MenuIcon />
|
|
778
|
+
</IconButton.Root>
|
|
779
|
+
|
|
780
|
+
{/* Drawer */}
|
|
781
|
+
<Drawer.Root open={open} onOpenChange={(e) => setOpen(e.open)} placement="start">
|
|
782
|
+
<Drawer.Backdrop />
|
|
783
|
+
<Drawer.Positioner>
|
|
784
|
+
<Drawer.Content className={css({ width: '280px' })}>
|
|
785
|
+
<Drawer.Header>
|
|
786
|
+
<Drawer.Title>Menu</Drawer.Title>
|
|
787
|
+
<Drawer.CloseTrigger asChild>
|
|
788
|
+
<IconButton.Root variant="standard" size="sm">
|
|
789
|
+
✕
|
|
790
|
+
</IconButton.Root>
|
|
791
|
+
</Drawer.CloseTrigger>
|
|
792
|
+
</Drawer.Header>
|
|
793
|
+
|
|
794
|
+
<Drawer.Body className={css({
|
|
795
|
+
display: 'flex',
|
|
796
|
+
flexDirection: 'column',
|
|
797
|
+
gap: 'xs',
|
|
798
|
+
pt: 'md'
|
|
799
|
+
})}>
|
|
800
|
+
{navItems.map((item) => (
|
|
801
|
+
<Button
|
|
802
|
+
key={item.id}
|
|
803
|
+
variant={activeSection === item.id ? 'tonal' : 'text'}
|
|
804
|
+
onClick={() => {
|
|
805
|
+
setActiveSection(item.id);
|
|
806
|
+
setOpen(false);
|
|
807
|
+
}}
|
|
808
|
+
leftIcon={<item.icon />}
|
|
809
|
+
className={css({
|
|
810
|
+
justifyContent: 'flex-start',
|
|
811
|
+
width: '100%'
|
|
812
|
+
})}
|
|
813
|
+
>
|
|
814
|
+
{item.label}
|
|
815
|
+
</Button>
|
|
816
|
+
))}
|
|
817
|
+
</Drawer.Body>
|
|
818
|
+
|
|
819
|
+
<Drawer.Footer className={css({
|
|
820
|
+
borderTopWidth: '1px',
|
|
821
|
+
borderTopColor: 'outlineVariant',
|
|
822
|
+
pt: 'md'
|
|
823
|
+
})}>
|
|
824
|
+
<Button variant="outlined" className={css({ width: '100%' })}>
|
|
825
|
+
Sign Out
|
|
826
|
+
</Button>
|
|
827
|
+
</Drawer.Footer>
|
|
828
|
+
</Drawer.Content>
|
|
829
|
+
</Drawer.Positioner>
|
|
830
|
+
</Drawer.Root>
|
|
831
|
+
</>
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
**Best practices:**
|
|
837
|
+
|
|
838
|
+
- Use icons with labels for clarity
|
|
839
|
+
- Highlight active section
|
|
840
|
+
- Close drawer on mobile after selection
|
|
841
|
+
- Provide sign out option in footer
|
|
842
|
+
- Keep navigation items organized
|
|
843
|
+
|
|
844
|
+
**Accessibility:**
|
|
845
|
+
|
|
846
|
+
- Drawer traps focus when open
|
|
847
|
+
- Escape key closes drawer
|
|
848
|
+
- Active item is clearly indicated
|
|
849
|
+
- Screen readers announce drawer state
|
|
850
|
+
|
|
851
|
+
---
|
|
852
|
+
|
|
853
|
+
### Top Navigation Bar
|
|
854
|
+
|
|
855
|
+
**When to use:** Simple websites or apps with few primary sections that fit horizontally.
|
|
856
|
+
|
|
857
|
+
**Components used:** Button, IconButton, Avatar
|
|
858
|
+
|
|
859
|
+
**Example:**
|
|
860
|
+
|
|
861
|
+
```typescript
|
|
862
|
+
import { Button } from '@discourser/design-system';
|
|
863
|
+
import * as IconButton from '@discourser/design-system';
|
|
864
|
+
import * as Avatar from '@discourser/design-system';
|
|
865
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
866
|
+
|
|
867
|
+
function TopNavigation() {
|
|
868
|
+
const navItems = ['Home', 'Products', 'About', 'Contact'];
|
|
869
|
+
|
|
870
|
+
return (
|
|
871
|
+
<nav className={css({
|
|
872
|
+
display: 'flex',
|
|
873
|
+
alignItems: 'center',
|
|
874
|
+
justifyContent: 'space-between',
|
|
875
|
+
px: { base: 'md', lg: 'xl' },
|
|
876
|
+
py: 'md',
|
|
877
|
+
bg: 'surface',
|
|
878
|
+
borderBottomWidth: '1px',
|
|
879
|
+
borderBottomColor: 'outlineVariant',
|
|
880
|
+
position: 'sticky',
|
|
881
|
+
top: 0,
|
|
882
|
+
zIndex: 100
|
|
883
|
+
})}>
|
|
884
|
+
{/* Logo */}
|
|
885
|
+
<div className={css({
|
|
886
|
+
textStyle: 'titleLarge',
|
|
887
|
+
color: 'primary',
|
|
888
|
+
fontWeight: 'bold'
|
|
889
|
+
})}>
|
|
890
|
+
Brand
|
|
891
|
+
</div>
|
|
892
|
+
|
|
893
|
+
{/* Navigation links - hidden on mobile */}
|
|
894
|
+
<div className={css({
|
|
895
|
+
display: { base: 'none', md: 'flex' },
|
|
896
|
+
gap: 'sm',
|
|
897
|
+
alignItems: 'center'
|
|
898
|
+
})}>
|
|
899
|
+
{navItems.map((item) => (
|
|
900
|
+
<Button key={item} variant="text">
|
|
901
|
+
{item}
|
|
902
|
+
</Button>
|
|
903
|
+
))}
|
|
904
|
+
</div>
|
|
905
|
+
|
|
906
|
+
{/* User actions */}
|
|
907
|
+
<div className={css({ display: 'flex', gap: 'sm', alignItems: 'center' })}>
|
|
908
|
+
<IconButton.Root variant="standard">
|
|
909
|
+
<SearchIcon />
|
|
910
|
+
</IconButton.Root>
|
|
911
|
+
|
|
912
|
+
<Avatar.Root size="sm">
|
|
913
|
+
<Avatar.Image src="/user-avatar.jpg" alt="User" />
|
|
914
|
+
<Avatar.Fallback>JD</Avatar.Fallback>
|
|
915
|
+
</Avatar.Root>
|
|
916
|
+
</div>
|
|
917
|
+
</nav>
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
**Best practices:**
|
|
923
|
+
|
|
924
|
+
- Keep navigation items concise
|
|
925
|
+
- Use sticky positioning for accessibility
|
|
926
|
+
- Hide secondary items on mobile
|
|
927
|
+
- Include logo/brand on left
|
|
928
|
+
- Group user actions on right
|
|
929
|
+
|
|
930
|
+
**Accessibility:**
|
|
931
|
+
|
|
932
|
+
- Use semantic `nav` element
|
|
933
|
+
- Links have clear focus indicators
|
|
934
|
+
- Works with keyboard navigation
|
|
935
|
+
|
|
936
|
+
---
|
|
937
|
+
|
|
938
|
+
### Tabbed Interface
|
|
939
|
+
|
|
940
|
+
**When to use:** Content that can be organized into distinct categories or views.
|
|
941
|
+
|
|
942
|
+
**Components used:** Tabs, Card
|
|
943
|
+
|
|
944
|
+
**Example:**
|
|
945
|
+
|
|
946
|
+
```typescript
|
|
947
|
+
import { Card } from '@discourser/design-system';
|
|
948
|
+
import * as Tabs from '@discourser/design-system';
|
|
949
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
950
|
+
|
|
951
|
+
function TabbedInterface() {
|
|
952
|
+
return (
|
|
953
|
+
<Tabs.Root defaultValue="overview" className={css({ maxWidth: '800px', mx: 'auto' })}>
|
|
954
|
+
<Tabs.List className={css({ mb: 'lg' })}>
|
|
955
|
+
<Tabs.Trigger value="overview">Overview</Tabs.Trigger>
|
|
956
|
+
<Tabs.Trigger value="activity">Activity</Tabs.Trigger>
|
|
957
|
+
<Tabs.Trigger value="settings">Settings</Tabs.Trigger>
|
|
958
|
+
<Tabs.Indicator />
|
|
959
|
+
</Tabs.List>
|
|
960
|
+
|
|
961
|
+
<Tabs.Content value="overview">
|
|
962
|
+
<Card variant="elevated" className={css({ p: 'xl' })}>
|
|
963
|
+
<h2 className={css({ textStyle: 'headlineSmall', mb: 'md' })}>
|
|
964
|
+
Overview
|
|
965
|
+
</h2>
|
|
966
|
+
<p className={css({ textStyle: 'bodyMedium', color: 'onSurfaceVariant' })}>
|
|
967
|
+
Welcome to your dashboard. Here you'll find a summary of your account activity.
|
|
968
|
+
</p>
|
|
969
|
+
</Card>
|
|
970
|
+
</Tabs.Content>
|
|
971
|
+
|
|
972
|
+
<Tabs.Content value="activity">
|
|
973
|
+
<Card variant="elevated" className={css({ p: 'xl' })}>
|
|
974
|
+
<h2 className={css({ textStyle: 'headlineSmall', mb: 'md' })}>
|
|
975
|
+
Recent Activity
|
|
976
|
+
</h2>
|
|
977
|
+
<div className={css({ display: 'flex', flexDirection: 'column', gap: 'md' })}>
|
|
978
|
+
<div className={css({ textStyle: 'bodyMedium' })}>
|
|
979
|
+
Project updated - 2 hours ago
|
|
980
|
+
</div>
|
|
981
|
+
<div className={css({ textStyle: 'bodyMedium' })}>
|
|
982
|
+
Comment added - 5 hours ago
|
|
983
|
+
</div>
|
|
984
|
+
</div>
|
|
985
|
+
</Card>
|
|
986
|
+
</Tabs.Content>
|
|
987
|
+
|
|
988
|
+
<Tabs.Content value="settings">
|
|
989
|
+
<Card variant="elevated" className={css({ p: 'xl' })}>
|
|
990
|
+
<h2 className={css({ textStyle: 'headlineSmall', mb: 'md' })}>
|
|
991
|
+
Settings
|
|
992
|
+
</h2>
|
|
993
|
+
<p className={css({ textStyle: 'bodyMedium', color: 'onSurfaceVariant' })}>
|
|
994
|
+
Configure your account preferences.
|
|
995
|
+
</p>
|
|
996
|
+
</Card>
|
|
997
|
+
</Tabs.Content>
|
|
998
|
+
</Tabs.Root>
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
```
|
|
1002
|
+
|
|
1003
|
+
**Best practices:**
|
|
1004
|
+
|
|
1005
|
+
- Use for 3-6 related content sections
|
|
1006
|
+
- Keep tab labels short and clear
|
|
1007
|
+
- Load content lazily if expensive
|
|
1008
|
+
- Indicate active tab clearly
|
|
1009
|
+
|
|
1010
|
+
**Accessibility:**
|
|
1011
|
+
|
|
1012
|
+
- Tabs follow WAI-ARIA pattern
|
|
1013
|
+
- Arrow keys navigate between tabs
|
|
1014
|
+
- Active tab is indicated to screen readers
|
|
1015
|
+
|
|
1016
|
+
---
|
|
1017
|
+
|
|
1018
|
+
### Breadcrumb Navigation
|
|
1019
|
+
|
|
1020
|
+
**When to use:** Deep hierarchical navigation to show user's location and allow easy backtracking.
|
|
1021
|
+
|
|
1022
|
+
**Components used:** Button (text variant)
|
|
1023
|
+
|
|
1024
|
+
**Example:**
|
|
1025
|
+
|
|
1026
|
+
```typescript
|
|
1027
|
+
import { Button } from '@discourser/design-system';
|
|
1028
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
1029
|
+
import { ChevronRightIcon } from 'your-icon-library';
|
|
1030
|
+
|
|
1031
|
+
function BreadcrumbNavigation() {
|
|
1032
|
+
const breadcrumbs = [
|
|
1033
|
+
{ label: 'Home', href: '/' },
|
|
1034
|
+
{ label: 'Products', href: '/products' },
|
|
1035
|
+
{ label: 'Electronics', href: '/products/electronics' },
|
|
1036
|
+
{ label: 'Laptops', href: '/products/electronics/laptops' }
|
|
1037
|
+
];
|
|
1038
|
+
|
|
1039
|
+
return (
|
|
1040
|
+
<nav
|
|
1041
|
+
aria-label="Breadcrumb"
|
|
1042
|
+
className={css({
|
|
1043
|
+
display: 'flex',
|
|
1044
|
+
alignItems: 'center',
|
|
1045
|
+
flexWrap: 'wrap',
|
|
1046
|
+
gap: 'xs',
|
|
1047
|
+
py: 'md',
|
|
1048
|
+
px: { base: 'md', lg: 'xl' }
|
|
1049
|
+
})}
|
|
1050
|
+
>
|
|
1051
|
+
{breadcrumbs.map((crumb, index) => (
|
|
1052
|
+
<div key={crumb.href} className={css({ display: 'flex', alignItems: 'center', gap: 'xs' })}>
|
|
1053
|
+
{index === breadcrumbs.length - 1 ? (
|
|
1054
|
+
// Current page - not clickable
|
|
1055
|
+
<span className={css({
|
|
1056
|
+
textStyle: 'labelMedium',
|
|
1057
|
+
color: 'onSurface',
|
|
1058
|
+
fontWeight: 'medium'
|
|
1059
|
+
})}>
|
|
1060
|
+
{crumb.label}
|
|
1061
|
+
</span>
|
|
1062
|
+
) : (
|
|
1063
|
+
<>
|
|
1064
|
+
<Button
|
|
1065
|
+
variant="text"
|
|
1066
|
+
size="sm"
|
|
1067
|
+
onClick={() => window.location.href = crumb.href}
|
|
1068
|
+
className={css({ minWidth: 'auto' })}
|
|
1069
|
+
>
|
|
1070
|
+
{crumb.label}
|
|
1071
|
+
</Button>
|
|
1072
|
+
<ChevronRightIcon className={css({ color: 'onSurfaceVariant' })} />
|
|
1073
|
+
</>
|
|
1074
|
+
)}
|
|
1075
|
+
</div>
|
|
1076
|
+
))}
|
|
1077
|
+
</nav>
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
```
|
|
1081
|
+
|
|
1082
|
+
**Best practices:**
|
|
1083
|
+
|
|
1084
|
+
- Show full path to current page
|
|
1085
|
+
- Make all items except current clickable
|
|
1086
|
+
- Use chevron or slash as separator
|
|
1087
|
+
- Truncate on mobile if too long
|
|
1088
|
+
|
|
1089
|
+
**Accessibility:**
|
|
1090
|
+
|
|
1091
|
+
- Use `nav` with `aria-label="Breadcrumb"`
|
|
1092
|
+
- Current page is not a link
|
|
1093
|
+
- Screen readers understand hierarchy
|
|
1094
|
+
|
|
1095
|
+
---
|
|
1096
|
+
|
|
1097
|
+
## Feedback Patterns
|
|
1098
|
+
|
|
1099
|
+
### Success Flow
|
|
1100
|
+
|
|
1101
|
+
**When to use:** Confirming successful completion of user actions with visual feedback.
|
|
1102
|
+
|
|
1103
|
+
**Components used:** Toast, Progress, Button
|
|
1104
|
+
|
|
1105
|
+
**Example:**
|
|
1106
|
+
|
|
1107
|
+
```typescript
|
|
1108
|
+
import { Button, toaster } from '@discourser/design-system';
|
|
1109
|
+
import * as Progress from '@discourser/design-system';
|
|
1110
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
1111
|
+
import { useState } from 'react';
|
|
1112
|
+
|
|
1113
|
+
function SuccessFlow() {
|
|
1114
|
+
const [uploading, setUploading] = useState(false);
|
|
1115
|
+
const [progress, setProgress] = useState(0);
|
|
1116
|
+
|
|
1117
|
+
const handleUpload = async () => {
|
|
1118
|
+
setUploading(true);
|
|
1119
|
+
setProgress(0);
|
|
1120
|
+
|
|
1121
|
+
// Show loading toast
|
|
1122
|
+
const toastId = toaster.create({
|
|
1123
|
+
title: 'Uploading file...',
|
|
1124
|
+
description: 'Please wait while we process your file.',
|
|
1125
|
+
type: 'loading'
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
// Simulate upload progress
|
|
1129
|
+
const interval = setInterval(() => {
|
|
1130
|
+
setProgress((prev) => {
|
|
1131
|
+
if (prev >= 100) {
|
|
1132
|
+
clearInterval(interval);
|
|
1133
|
+
return 100;
|
|
1134
|
+
}
|
|
1135
|
+
return prev + 10;
|
|
1136
|
+
});
|
|
1137
|
+
}, 300);
|
|
1138
|
+
|
|
1139
|
+
// Wait for completion
|
|
1140
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
1141
|
+
|
|
1142
|
+
// Update to success
|
|
1143
|
+
toaster.update(toastId, {
|
|
1144
|
+
title: 'Upload complete!',
|
|
1145
|
+
description: 'Your file has been processed successfully.',
|
|
1146
|
+
type: 'success',
|
|
1147
|
+
duration: 3000
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
setUploading(false);
|
|
1151
|
+
setProgress(0);
|
|
1152
|
+
};
|
|
1153
|
+
|
|
1154
|
+
return (
|
|
1155
|
+
<div className={css({
|
|
1156
|
+
display: 'flex',
|
|
1157
|
+
flexDirection: 'column',
|
|
1158
|
+
gap: 'lg',
|
|
1159
|
+
maxWidth: '400px',
|
|
1160
|
+
mx: 'auto',
|
|
1161
|
+
p: 'xl'
|
|
1162
|
+
})}>
|
|
1163
|
+
<Button
|
|
1164
|
+
variant="filled"
|
|
1165
|
+
onClick={handleUpload}
|
|
1166
|
+
disabled={uploading}
|
|
1167
|
+
>
|
|
1168
|
+
{uploading ? 'Uploading...' : 'Upload File'}
|
|
1169
|
+
</Button>
|
|
1170
|
+
|
|
1171
|
+
{uploading && (
|
|
1172
|
+
<div>
|
|
1173
|
+
<Progress.Root value={progress}>
|
|
1174
|
+
<Progress.Track>
|
|
1175
|
+
<Progress.Range />
|
|
1176
|
+
</Progress.Track>
|
|
1177
|
+
</Progress.Root>
|
|
1178
|
+
<div className={css({
|
|
1179
|
+
textStyle: 'labelMedium',
|
|
1180
|
+
color: 'onSurfaceVariant',
|
|
1181
|
+
mt: 'xs'
|
|
1182
|
+
})}>
|
|
1183
|
+
{progress}% complete
|
|
1184
|
+
</div>
|
|
1185
|
+
</div>
|
|
1186
|
+
)}
|
|
1187
|
+
</div>
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
```
|
|
1191
|
+
|
|
1192
|
+
**Best practices:**
|
|
1193
|
+
|
|
1194
|
+
- Show loading state during async operations
|
|
1195
|
+
- Update with progress if available
|
|
1196
|
+
- Transition from loading to success smoothly
|
|
1197
|
+
- Provide clear success confirmation
|
|
1198
|
+
- Auto-dismiss success messages
|
|
1199
|
+
|
|
1200
|
+
**Accessibility:**
|
|
1201
|
+
|
|
1202
|
+
- Progress is announced to screen readers
|
|
1203
|
+
- Loading state is clearly communicated
|
|
1204
|
+
- Success message is announced
|
|
1205
|
+
|
|
1206
|
+
---
|
|
1207
|
+
|
|
1208
|
+
### Error Handling
|
|
1209
|
+
|
|
1210
|
+
**When to use:** Gracefully handling and communicating errors to users.
|
|
1211
|
+
|
|
1212
|
+
**Components used:** Input, Toast, Dialog
|
|
1213
|
+
|
|
1214
|
+
**Example:**
|
|
1215
|
+
|
|
1216
|
+
```typescript
|
|
1217
|
+
import { Input, Button, toaster } from '@discourser/design-system';
|
|
1218
|
+
import * as Dialog from '@discourser/design-system';
|
|
1219
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
1220
|
+
import { useState } from 'react';
|
|
1221
|
+
|
|
1222
|
+
function ErrorHandling() {
|
|
1223
|
+
const [email, setEmail] = useState('');
|
|
1224
|
+
const [error, setError] = useState('');
|
|
1225
|
+
const [criticalError, setCriticalError] = useState<string | null>(null);
|
|
1226
|
+
|
|
1227
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
1228
|
+
e.preventDefault();
|
|
1229
|
+
setError('');
|
|
1230
|
+
|
|
1231
|
+
try {
|
|
1232
|
+
await submitEmail(email);
|
|
1233
|
+
|
|
1234
|
+
// Show success
|
|
1235
|
+
toaster.create({
|
|
1236
|
+
title: 'Email sent!',
|
|
1237
|
+
description: 'Check your inbox for the confirmation link.',
|
|
1238
|
+
type: 'success'
|
|
1239
|
+
});
|
|
1240
|
+
} catch (err) {
|
|
1241
|
+
if (err.code === 'NETWORK_ERROR') {
|
|
1242
|
+
// Critical error - show dialog
|
|
1243
|
+
setCriticalError('Unable to connect to the server. Please check your internet connection and try again.');
|
|
1244
|
+
} else if (err.code === 'INVALID_EMAIL') {
|
|
1245
|
+
// Field-level error
|
|
1246
|
+
setError('This email address is not valid.');
|
|
1247
|
+
} else {
|
|
1248
|
+
// General error - show toast
|
|
1249
|
+
toaster.create({
|
|
1250
|
+
title: 'Something went wrong',
|
|
1251
|
+
description: 'Please try again later.',
|
|
1252
|
+
type: 'error',
|
|
1253
|
+
duration: 5000
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
};
|
|
1258
|
+
|
|
1259
|
+
return (
|
|
1260
|
+
<>
|
|
1261
|
+
<form onSubmit={handleSubmit} className={css({
|
|
1262
|
+
display: 'flex',
|
|
1263
|
+
flexDirection: 'column',
|
|
1264
|
+
gap: 'lg',
|
|
1265
|
+
maxWidth: '400px',
|
|
1266
|
+
mx: 'auto',
|
|
1267
|
+
p: 'xl'
|
|
1268
|
+
})}>
|
|
1269
|
+
<Input
|
|
1270
|
+
label="Email Address"
|
|
1271
|
+
type="email"
|
|
1272
|
+
value={email}
|
|
1273
|
+
onChange={(e) => {
|
|
1274
|
+
setEmail(e.target.value);
|
|
1275
|
+
setError(''); // Clear error on change
|
|
1276
|
+
}}
|
|
1277
|
+
errorText={error}
|
|
1278
|
+
required
|
|
1279
|
+
/>
|
|
1280
|
+
|
|
1281
|
+
<Button type="submit" variant="filled">
|
|
1282
|
+
Submit
|
|
1283
|
+
</Button>
|
|
1284
|
+
</form>
|
|
1285
|
+
|
|
1286
|
+
{/* Critical error dialog */}
|
|
1287
|
+
<Dialog.Root open={!!criticalError} onOpenChange={() => setCriticalError(null)}>
|
|
1288
|
+
<Dialog.Backdrop />
|
|
1289
|
+
<Dialog.Positioner>
|
|
1290
|
+
<Dialog.Content>
|
|
1291
|
+
<Dialog.Header>
|
|
1292
|
+
<Dialog.Title>Connection Error</Dialog.Title>
|
|
1293
|
+
</Dialog.Header>
|
|
1294
|
+
<Dialog.Body>
|
|
1295
|
+
<Dialog.Description>
|
|
1296
|
+
{criticalError}
|
|
1297
|
+
</Dialog.Description>
|
|
1298
|
+
</Dialog.Body>
|
|
1299
|
+
<Dialog.Footer className={css({ display: 'flex', gap: 'sm', justifyContent: 'flex-end' })}>
|
|
1300
|
+
<Button variant="filled" onClick={() => setCriticalError(null)}>
|
|
1301
|
+
OK
|
|
1302
|
+
</Button>
|
|
1303
|
+
</Dialog.Footer>
|
|
1304
|
+
<Dialog.CloseTrigger />
|
|
1305
|
+
</Dialog.Content>
|
|
1306
|
+
</Dialog.Positioner>
|
|
1307
|
+
</Dialog.Root>
|
|
1308
|
+
</>
|
|
1309
|
+
);
|
|
1310
|
+
}
|
|
1311
|
+
```
|
|
1312
|
+
|
|
1313
|
+
**Best practices:**
|
|
1314
|
+
|
|
1315
|
+
- Use inline errors for field validation
|
|
1316
|
+
- Use toasts for non-critical errors
|
|
1317
|
+
- Use dialogs for critical errors requiring action
|
|
1318
|
+
- Provide clear, actionable error messages
|
|
1319
|
+
- Allow retry when appropriate
|
|
1320
|
+
|
|
1321
|
+
**Accessibility:**
|
|
1322
|
+
|
|
1323
|
+
- Errors are announced to screen readers
|
|
1324
|
+
- Focus moves to error when critical
|
|
1325
|
+
- Error messages are associated with inputs
|
|
1326
|
+
|
|
1327
|
+
---
|
|
1328
|
+
|
|
1329
|
+
### Confirmation Dialogs
|
|
1330
|
+
|
|
1331
|
+
**When to use:** Destructive actions or important decisions that require explicit confirmation.
|
|
1332
|
+
|
|
1333
|
+
**Components used:** Dialog, Button
|
|
1334
|
+
|
|
1335
|
+
**Example:**
|
|
1336
|
+
|
|
1337
|
+
```typescript
|
|
1338
|
+
import { Button, toaster } from '@discourser/design-system';
|
|
1339
|
+
import * as Dialog from '@discourser/design-system';
|
|
1340
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
1341
|
+
import { useState } from 'react';
|
|
1342
|
+
|
|
1343
|
+
function ConfirmationDialog() {
|
|
1344
|
+
const [open, setOpen] = useState(false);
|
|
1345
|
+
const [loading, setLoading] = useState(false);
|
|
1346
|
+
|
|
1347
|
+
const handleDelete = async () => {
|
|
1348
|
+
setLoading(true);
|
|
1349
|
+
|
|
1350
|
+
try {
|
|
1351
|
+
await deleteItem();
|
|
1352
|
+
|
|
1353
|
+
setOpen(false);
|
|
1354
|
+
toaster.create({
|
|
1355
|
+
title: 'Item deleted',
|
|
1356
|
+
description: 'The item has been permanently deleted.',
|
|
1357
|
+
type: 'success'
|
|
1358
|
+
});
|
|
1359
|
+
} catch (error) {
|
|
1360
|
+
toaster.create({
|
|
1361
|
+
title: 'Delete failed',
|
|
1362
|
+
description: 'Unable to delete the item. Please try again.',
|
|
1363
|
+
type: 'error'
|
|
1364
|
+
});
|
|
1365
|
+
} finally {
|
|
1366
|
+
setLoading(false);
|
|
1367
|
+
}
|
|
1368
|
+
};
|
|
1369
|
+
|
|
1370
|
+
return (
|
|
1371
|
+
<>
|
|
1372
|
+
<Button variant="filled" onClick={() => setOpen(true)}>
|
|
1373
|
+
Delete Item
|
|
1374
|
+
</Button>
|
|
1375
|
+
|
|
1376
|
+
<Dialog.Root open={open} onOpenChange={(e) => setOpen(e.open)}>
|
|
1377
|
+
<Dialog.Backdrop />
|
|
1378
|
+
<Dialog.Positioner>
|
|
1379
|
+
<Dialog.Content>
|
|
1380
|
+
<Dialog.Header>
|
|
1381
|
+
<Dialog.Title>Confirm Deletion</Dialog.Title>
|
|
1382
|
+
</Dialog.Header>
|
|
1383
|
+
|
|
1384
|
+
<Dialog.Body>
|
|
1385
|
+
<Dialog.Description>
|
|
1386
|
+
Are you sure you want to delete this item? This action cannot be undone.
|
|
1387
|
+
</Dialog.Description>
|
|
1388
|
+
</Dialog.Body>
|
|
1389
|
+
|
|
1390
|
+
<Dialog.Footer className={css({
|
|
1391
|
+
display: 'flex',
|
|
1392
|
+
gap: 'sm',
|
|
1393
|
+
justifyContent: 'flex-end'
|
|
1394
|
+
})}>
|
|
1395
|
+
<Button
|
|
1396
|
+
variant="text"
|
|
1397
|
+
onClick={() => setOpen(false)}
|
|
1398
|
+
disabled={loading}
|
|
1399
|
+
>
|
|
1400
|
+
Cancel
|
|
1401
|
+
</Button>
|
|
1402
|
+
<Button
|
|
1403
|
+
variant="filled"
|
|
1404
|
+
onClick={handleDelete}
|
|
1405
|
+
disabled={loading}
|
|
1406
|
+
className={css({ bg: 'error', color: 'onError' })}
|
|
1407
|
+
>
|
|
1408
|
+
{loading ? 'Deleting...' : 'Delete'}
|
|
1409
|
+
</Button>
|
|
1410
|
+
</Dialog.Footer>
|
|
1411
|
+
|
|
1412
|
+
<Dialog.CloseTrigger />
|
|
1413
|
+
</Dialog.Content>
|
|
1414
|
+
</Dialog.Positioner>
|
|
1415
|
+
</Dialog.Root>
|
|
1416
|
+
</>
|
|
1417
|
+
);
|
|
1418
|
+
}
|
|
1419
|
+
```
|
|
1420
|
+
|
|
1421
|
+
**Best practices:**
|
|
1422
|
+
|
|
1423
|
+
- Use clear, specific titles
|
|
1424
|
+
- Explain consequences of action
|
|
1425
|
+
- Use warning colors for destructive actions
|
|
1426
|
+
- Provide both cancel and confirm options
|
|
1427
|
+
- Disable buttons during processing
|
|
1428
|
+
|
|
1429
|
+
**Accessibility:**
|
|
1430
|
+
|
|
1431
|
+
- Dialog traps focus
|
|
1432
|
+
- Escape key cancels
|
|
1433
|
+
- Focus returns to trigger after close
|
|
1434
|
+
|
|
1435
|
+
---
|
|
1436
|
+
|
|
1437
|
+
### Inline Notifications
|
|
1438
|
+
|
|
1439
|
+
**When to use:** Contextual feedback that needs to stay visible near related content.
|
|
1440
|
+
|
|
1441
|
+
**Components used:** Badge, Toast
|
|
1442
|
+
|
|
1443
|
+
**Example:**
|
|
1444
|
+
|
|
1445
|
+
```typescript
|
|
1446
|
+
import { Badge, Button, toaster } from '@discourser/design-system';
|
|
1447
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
1448
|
+
import { useState } from 'react';
|
|
1449
|
+
|
|
1450
|
+
function InlineNotifications() {
|
|
1451
|
+
const [notifications, setNotifications] = useState([
|
|
1452
|
+
{ id: 1, message: 'New comment on your post', read: false },
|
|
1453
|
+
{ id: 2, message: 'Your profile was viewed 10 times', read: false },
|
|
1454
|
+
{ id: 3, message: 'New follower: John Doe', read: true }
|
|
1455
|
+
]);
|
|
1456
|
+
|
|
1457
|
+
const unreadCount = notifications.filter(n => !n.read).length;
|
|
1458
|
+
|
|
1459
|
+
const markAsRead = (id: number) => {
|
|
1460
|
+
setNotifications(notifications.map(n =>
|
|
1461
|
+
n.id === id ? { ...n, read: true } : n
|
|
1462
|
+
));
|
|
1463
|
+
|
|
1464
|
+
toaster.create({
|
|
1465
|
+
title: 'Marked as read',
|
|
1466
|
+
type: 'success',
|
|
1467
|
+
duration: 2000
|
|
1468
|
+
});
|
|
1469
|
+
};
|
|
1470
|
+
|
|
1471
|
+
return (
|
|
1472
|
+
<div className={css({ maxWidth: '500px', mx: 'auto', p: 'xl' })}>
|
|
1473
|
+
<div className={css({
|
|
1474
|
+
display: 'flex',
|
|
1475
|
+
alignItems: 'center',
|
|
1476
|
+
gap: 'sm',
|
|
1477
|
+
mb: 'lg'
|
|
1478
|
+
})}>
|
|
1479
|
+
<h2 className={css({ textStyle: 'headlineSmall' })}>
|
|
1480
|
+
Notifications
|
|
1481
|
+
</h2>
|
|
1482
|
+
{unreadCount > 0 && (
|
|
1483
|
+
<Badge variant="solid">
|
|
1484
|
+
{unreadCount} new
|
|
1485
|
+
</Badge>
|
|
1486
|
+
)}
|
|
1487
|
+
</div>
|
|
1488
|
+
|
|
1489
|
+
<div className={css({
|
|
1490
|
+
display: 'flex',
|
|
1491
|
+
flexDirection: 'column',
|
|
1492
|
+
gap: 'sm'
|
|
1493
|
+
})}>
|
|
1494
|
+
{notifications.map((notification) => (
|
|
1495
|
+
<div
|
|
1496
|
+
key={notification.id}
|
|
1497
|
+
className={css({
|
|
1498
|
+
p: 'md',
|
|
1499
|
+
bg: notification.read ? 'surface' : 'secondaryContainer',
|
|
1500
|
+
borderRadius: 'l2',
|
|
1501
|
+
display: 'flex',
|
|
1502
|
+
alignItems: 'center',
|
|
1503
|
+
justifyContent: 'space-between',
|
|
1504
|
+
gap: 'md'
|
|
1505
|
+
})}
|
|
1506
|
+
>
|
|
1507
|
+
<div className={css({
|
|
1508
|
+
flex: 1,
|
|
1509
|
+
textStyle: 'bodyMedium',
|
|
1510
|
+
color: 'onSurface'
|
|
1511
|
+
})}>
|
|
1512
|
+
{notification.message}
|
|
1513
|
+
</div>
|
|
1514
|
+
|
|
1515
|
+
{!notification.read && (
|
|
1516
|
+
<Button
|
|
1517
|
+
variant="text"
|
|
1518
|
+
size="sm"
|
|
1519
|
+
onClick={() => markAsRead(notification.id)}
|
|
1520
|
+
>
|
|
1521
|
+
Mark read
|
|
1522
|
+
</Button>
|
|
1523
|
+
)}
|
|
1524
|
+
</div>
|
|
1525
|
+
))}
|
|
1526
|
+
</div>
|
|
1527
|
+
</div>
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1530
|
+
```
|
|
1531
|
+
|
|
1532
|
+
**Best practices:**
|
|
1533
|
+
|
|
1534
|
+
- Use badges to show count
|
|
1535
|
+
- Visually distinguish read/unread
|
|
1536
|
+
- Provide action buttons in context
|
|
1537
|
+
- Group related notifications
|
|
1538
|
+
- Keep messages concise
|
|
1539
|
+
|
|
1540
|
+
**Accessibility:**
|
|
1541
|
+
|
|
1542
|
+
- Unread count is announced
|
|
1543
|
+
- List structure is semantic
|
|
1544
|
+
- Actions have clear labels
|
|
1545
|
+
|
|
1546
|
+
---
|
|
1547
|
+
|
|
1548
|
+
## Loading States
|
|
1549
|
+
|
|
1550
|
+
### Page Load
|
|
1551
|
+
|
|
1552
|
+
**When to use:** Initial page load when fetching primary content.
|
|
1553
|
+
|
|
1554
|
+
**Components used:** Skeleton
|
|
1555
|
+
|
|
1556
|
+
**Example:**
|
|
1557
|
+
|
|
1558
|
+
```typescript
|
|
1559
|
+
import { Card } from '@discourser/design-system';
|
|
1560
|
+
import * as Skeleton from '@discourser/design-system';
|
|
1561
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
1562
|
+
import { useState, useEffect } from 'react';
|
|
1563
|
+
|
|
1564
|
+
function PageLoad() {
|
|
1565
|
+
const [loading, setLoading] = useState(true);
|
|
1566
|
+
const [data, setData] = useState(null);
|
|
1567
|
+
|
|
1568
|
+
useEffect(() => {
|
|
1569
|
+
// Simulate data fetching
|
|
1570
|
+
setTimeout(() => {
|
|
1571
|
+
setData({ title: 'Article Title', content: 'Article content...' });
|
|
1572
|
+
setLoading(false);
|
|
1573
|
+
}, 2000);
|
|
1574
|
+
}, []);
|
|
1575
|
+
|
|
1576
|
+
if (loading) {
|
|
1577
|
+
return (
|
|
1578
|
+
<div className={css({ maxWidth: '800px', mx: 'auto', p: 'xl' })}>
|
|
1579
|
+
<Card variant="elevated" className={css({ p: 'xl' })}>
|
|
1580
|
+
<Skeleton.Root>
|
|
1581
|
+
{/* Title skeleton */}
|
|
1582
|
+
<Skeleton.Item height="32px" width="70%" className={css({ mb: 'md' })} />
|
|
1583
|
+
|
|
1584
|
+
{/* Content skeleton */}
|
|
1585
|
+
<Skeleton.Item height="16px" width="100%" className={css({ mb: 'sm' })} />
|
|
1586
|
+
<Skeleton.Item height="16px" width="95%" className={css({ mb: 'sm' })} />
|
|
1587
|
+
<Skeleton.Item height="16px" width="90%" className={css({ mb: 'sm' })} />
|
|
1588
|
+
<Skeleton.Item height="16px" width="85%" />
|
|
1589
|
+
</Skeleton.Root>
|
|
1590
|
+
</Card>
|
|
1591
|
+
</div>
|
|
1592
|
+
);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
return (
|
|
1596
|
+
<div className={css({ maxWidth: '800px', mx: 'auto', p: 'xl' })}>
|
|
1597
|
+
<Card variant="elevated" className={css({ p: 'xl' })}>
|
|
1598
|
+
<h1 className={css({ textStyle: 'headlineLarge', mb: 'md' })}>
|
|
1599
|
+
{data.title}
|
|
1600
|
+
</h1>
|
|
1601
|
+
<p className={css({ textStyle: 'bodyMedium', color: 'onSurfaceVariant' })}>
|
|
1602
|
+
{data.content}
|
|
1603
|
+
</p>
|
|
1604
|
+
</Card>
|
|
1605
|
+
</div>
|
|
1606
|
+
);
|
|
1607
|
+
}
|
|
1608
|
+
```
|
|
1609
|
+
|
|
1610
|
+
**Best practices:**
|
|
1611
|
+
|
|
1612
|
+
- Match skeleton to actual content structure
|
|
1613
|
+
- Use appropriate widths for text lines
|
|
1614
|
+
- Animate skeletons for better UX
|
|
1615
|
+
- Show skeleton for minimum 300ms
|
|
1616
|
+
|
|
1617
|
+
**Accessibility:**
|
|
1618
|
+
|
|
1619
|
+
- Loading state is announced
|
|
1620
|
+
- Content structure is predictable
|
|
1621
|
+
|
|
1622
|
+
---
|
|
1623
|
+
|
|
1624
|
+
### Partial Load (Section)
|
|
1625
|
+
|
|
1626
|
+
**When to use:** Loading specific sections while keeping rest of page interactive.
|
|
1627
|
+
|
|
1628
|
+
**Components used:** Spinner, Card
|
|
1629
|
+
|
|
1630
|
+
**Example:**
|
|
1631
|
+
|
|
1632
|
+
```typescript
|
|
1633
|
+
import { Card, Spinner, Button } from '@discourser/design-system';
|
|
1634
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
1635
|
+
import { useState } from 'react';
|
|
1636
|
+
|
|
1637
|
+
function PartialLoad() {
|
|
1638
|
+
const [loading, setLoading] = useState(false);
|
|
1639
|
+
const [data, setData] = useState<string[]>([]);
|
|
1640
|
+
|
|
1641
|
+
const loadMore = async () => {
|
|
1642
|
+
setLoading(true);
|
|
1643
|
+
|
|
1644
|
+
// Simulate API call
|
|
1645
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
1646
|
+
|
|
1647
|
+
setData([...data, `Item ${data.length + 1}`, `Item ${data.length + 2}`]);
|
|
1648
|
+
setLoading(false);
|
|
1649
|
+
};
|
|
1650
|
+
|
|
1651
|
+
return (
|
|
1652
|
+
<div className={css({ maxWidth: '600px', mx: 'auto', p: 'xl' })}>
|
|
1653
|
+
<Card variant="elevated" className={css({ p: 'xl' })}>
|
|
1654
|
+
<h2 className={css({ textStyle: 'headlineSmall', mb: 'lg' })}>
|
|
1655
|
+
Content List
|
|
1656
|
+
</h2>
|
|
1657
|
+
|
|
1658
|
+
<div className={css({
|
|
1659
|
+
display: 'flex',
|
|
1660
|
+
flexDirection: 'column',
|
|
1661
|
+
gap: 'md',
|
|
1662
|
+
mb: 'lg'
|
|
1663
|
+
})}>
|
|
1664
|
+
{data.map((item, index) => (
|
|
1665
|
+
<div
|
|
1666
|
+
key={index}
|
|
1667
|
+
className={css({
|
|
1668
|
+
p: 'md',
|
|
1669
|
+
bg: 'surfaceContainerHighest',
|
|
1670
|
+
borderRadius: 'l2',
|
|
1671
|
+
textStyle: 'bodyMedium'
|
|
1672
|
+
})}
|
|
1673
|
+
>
|
|
1674
|
+
{item}
|
|
1675
|
+
</div>
|
|
1676
|
+
))}
|
|
1677
|
+
</div>
|
|
1678
|
+
|
|
1679
|
+
{loading ? (
|
|
1680
|
+
<div className={css({
|
|
1681
|
+
display: 'flex',
|
|
1682
|
+
justifyContent: 'center',
|
|
1683
|
+
alignItems: 'center',
|
|
1684
|
+
gap: 'sm',
|
|
1685
|
+
py: 'lg'
|
|
1686
|
+
})}>
|
|
1687
|
+
<Spinner size="md" />
|
|
1688
|
+
<span className={css({ textStyle: 'bodyMedium', color: 'onSurfaceVariant' })}>
|
|
1689
|
+
Loading more...
|
|
1690
|
+
</span>
|
|
1691
|
+
</div>
|
|
1692
|
+
) : (
|
|
1693
|
+
<Button variant="outlined" onClick={loadMore} className={css({ width: '100%' })}>
|
|
1694
|
+
Load More
|
|
1695
|
+
</Button>
|
|
1696
|
+
)}
|
|
1697
|
+
</Card>
|
|
1698
|
+
</div>
|
|
1699
|
+
);
|
|
1700
|
+
}
|
|
1701
|
+
```
|
|
1702
|
+
|
|
1703
|
+
**Best practices:**
|
|
1704
|
+
|
|
1705
|
+
- Show spinner in loading area only
|
|
1706
|
+
- Keep rest of page interactive
|
|
1707
|
+
- Provide loading text with spinner
|
|
1708
|
+
- Disable trigger while loading
|
|
1709
|
+
|
|
1710
|
+
**Accessibility:**
|
|
1711
|
+
|
|
1712
|
+
- Loading state is announced
|
|
1713
|
+
- Focus remains on page
|
|
1714
|
+
- Loading area is marked with ARIA
|
|
1715
|
+
|
|
1716
|
+
---
|
|
1717
|
+
|
|
1718
|
+
### Button Loading State
|
|
1719
|
+
|
|
1720
|
+
**When to use:** Async actions triggered by buttons.
|
|
1721
|
+
|
|
1722
|
+
**Components used:** Button, Spinner
|
|
1723
|
+
|
|
1724
|
+
**Example:**
|
|
1725
|
+
|
|
1726
|
+
```typescript
|
|
1727
|
+
import { Button, Spinner, toaster } from '@discourser/design-system';
|
|
1728
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
1729
|
+
import { useState } from 'react';
|
|
1730
|
+
|
|
1731
|
+
function ButtonLoadingState() {
|
|
1732
|
+
const [loading, setLoading] = useState(false);
|
|
1733
|
+
|
|
1734
|
+
const handleSubmit = async () => {
|
|
1735
|
+
setLoading(true);
|
|
1736
|
+
|
|
1737
|
+
try {
|
|
1738
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
1739
|
+
|
|
1740
|
+
toaster.create({
|
|
1741
|
+
title: 'Success!',
|
|
1742
|
+
description: 'Your changes have been saved.',
|
|
1743
|
+
type: 'success'
|
|
1744
|
+
});
|
|
1745
|
+
} catch (error) {
|
|
1746
|
+
toaster.create({
|
|
1747
|
+
title: 'Error',
|
|
1748
|
+
description: 'Failed to save changes.',
|
|
1749
|
+
type: 'error'
|
|
1750
|
+
});
|
|
1751
|
+
} finally {
|
|
1752
|
+
setLoading(false);
|
|
1753
|
+
}
|
|
1754
|
+
};
|
|
1755
|
+
|
|
1756
|
+
return (
|
|
1757
|
+
<div className={css({
|
|
1758
|
+
display: 'flex',
|
|
1759
|
+
flexDirection: 'column',
|
|
1760
|
+
gap: 'md',
|
|
1761
|
+
maxWidth: '400px',
|
|
1762
|
+
mx: 'auto',
|
|
1763
|
+
p: 'xl'
|
|
1764
|
+
})}>
|
|
1765
|
+
{/* Button with inline spinner */}
|
|
1766
|
+
<Button
|
|
1767
|
+
variant="filled"
|
|
1768
|
+
onClick={handleSubmit}
|
|
1769
|
+
disabled={loading}
|
|
1770
|
+
leftIcon={loading ? <Spinner size="sm" /> : undefined}
|
|
1771
|
+
>
|
|
1772
|
+
{loading ? 'Saving...' : 'Save Changes'}
|
|
1773
|
+
</Button>
|
|
1774
|
+
|
|
1775
|
+
{/* Alternative: Spinner replaces text */}
|
|
1776
|
+
<Button
|
|
1777
|
+
variant="outlined"
|
|
1778
|
+
onClick={handleSubmit}
|
|
1779
|
+
disabled={loading}
|
|
1780
|
+
>
|
|
1781
|
+
{loading ? (
|
|
1782
|
+
<div className={css({ display: 'flex', alignItems: 'center', gap: 'xs' })}>
|
|
1783
|
+
<Spinner size="sm" />
|
|
1784
|
+
<span>Processing</span>
|
|
1785
|
+
</div>
|
|
1786
|
+
) : (
|
|
1787
|
+
'Submit'
|
|
1788
|
+
)}
|
|
1789
|
+
</Button>
|
|
1790
|
+
|
|
1791
|
+
{/* Alternative: Just text change */}
|
|
1792
|
+
<Button
|
|
1793
|
+
variant="text"
|
|
1794
|
+
onClick={handleSubmit}
|
|
1795
|
+
disabled={loading}
|
|
1796
|
+
>
|
|
1797
|
+
{loading ? 'Loading...' : 'Click Me'}
|
|
1798
|
+
</Button>
|
|
1799
|
+
</div>
|
|
1800
|
+
);
|
|
1801
|
+
}
|
|
1802
|
+
```
|
|
1803
|
+
|
|
1804
|
+
**Best practices:**
|
|
1805
|
+
|
|
1806
|
+
- Disable button during loading
|
|
1807
|
+
- Show spinner or loading text
|
|
1808
|
+
- Keep button width stable
|
|
1809
|
+
- Use aria-busy attribute
|
|
1810
|
+
|
|
1811
|
+
**Accessibility:**
|
|
1812
|
+
|
|
1813
|
+
- Loading state is announced
|
|
1814
|
+
- Button is disabled and marked busy
|
|
1815
|
+
- Screen readers know action is in progress
|
|
1816
|
+
|
|
1817
|
+
---
|
|
1818
|
+
|
|
1819
|
+
### Infinite Scroll
|
|
1820
|
+
|
|
1821
|
+
**When to use:** Long lists that load more content as user scrolls.
|
|
1822
|
+
|
|
1823
|
+
**Components used:** Spinner, Card
|
|
1824
|
+
|
|
1825
|
+
**Example:**
|
|
1826
|
+
|
|
1827
|
+
```typescript
|
|
1828
|
+
import { Card, Spinner } from '@discourser/design-system';
|
|
1829
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
1830
|
+
import { useState, useEffect, useRef } from 'react';
|
|
1831
|
+
|
|
1832
|
+
function InfiniteScroll() {
|
|
1833
|
+
const [items, setItems] = useState<number[]>(Array.from({ length: 10 }, (_, i) => i));
|
|
1834
|
+
const [loading, setLoading] = useState(false);
|
|
1835
|
+
const [hasMore, setHasMore] = useState(true);
|
|
1836
|
+
const observerRef = useRef<HTMLDivElement>(null);
|
|
1837
|
+
|
|
1838
|
+
useEffect(() => {
|
|
1839
|
+
const observer = new IntersectionObserver(
|
|
1840
|
+
(entries) => {
|
|
1841
|
+
if (entries[0].isIntersecting && !loading && hasMore) {
|
|
1842
|
+
loadMore();
|
|
1843
|
+
}
|
|
1844
|
+
},
|
|
1845
|
+
{ threshold: 0.1 }
|
|
1846
|
+
);
|
|
1847
|
+
|
|
1848
|
+
if (observerRef.current) {
|
|
1849
|
+
observer.observe(observerRef.current);
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
return () => observer.disconnect();
|
|
1853
|
+
}, [loading, hasMore]);
|
|
1854
|
+
|
|
1855
|
+
const loadMore = async () => {
|
|
1856
|
+
setLoading(true);
|
|
1857
|
+
|
|
1858
|
+
// Simulate API call
|
|
1859
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
1860
|
+
|
|
1861
|
+
const newItems = Array.from({ length: 10 }, (_, i) => items.length + i);
|
|
1862
|
+
setItems([...items, ...newItems]);
|
|
1863
|
+
|
|
1864
|
+
// Stop after 50 items (demo)
|
|
1865
|
+
if (items.length >= 40) {
|
|
1866
|
+
setHasMore(false);
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
setLoading(false);
|
|
1870
|
+
};
|
|
1871
|
+
|
|
1872
|
+
return (
|
|
1873
|
+
<div className={css({ maxWidth: '600px', mx: 'auto', p: 'xl' })}>
|
|
1874
|
+
<div className={css({
|
|
1875
|
+
display: 'flex',
|
|
1876
|
+
flexDirection: 'column',
|
|
1877
|
+
gap: 'md'
|
|
1878
|
+
})}>
|
|
1879
|
+
{items.map((item) => (
|
|
1880
|
+
<Card key={item} variant="elevated" className={css({ p: 'md' })}>
|
|
1881
|
+
<div className={css({ textStyle: 'bodyMedium' })}>
|
|
1882
|
+
Item #{item + 1}
|
|
1883
|
+
</div>
|
|
1884
|
+
</Card>
|
|
1885
|
+
))}
|
|
1886
|
+
</div>
|
|
1887
|
+
|
|
1888
|
+
{/* Loading indicator */}
|
|
1889
|
+
{loading && (
|
|
1890
|
+
<div className={css({
|
|
1891
|
+
display: 'flex',
|
|
1892
|
+
justifyContent: 'center',
|
|
1893
|
+
alignItems: 'center',
|
|
1894
|
+
gap: 'sm',
|
|
1895
|
+
py: 'lg'
|
|
1896
|
+
})}>
|
|
1897
|
+
<Spinner size="md" />
|
|
1898
|
+
<span className={css({ textStyle: 'bodyMedium', color: 'onSurfaceVariant' })}>
|
|
1899
|
+
Loading more items...
|
|
1900
|
+
</span>
|
|
1901
|
+
</div>
|
|
1902
|
+
)}
|
|
1903
|
+
|
|
1904
|
+
{/* End message */}
|
|
1905
|
+
{!hasMore && (
|
|
1906
|
+
<div className={css({
|
|
1907
|
+
textAlign: 'center',
|
|
1908
|
+
py: 'lg',
|
|
1909
|
+
textStyle: 'bodyMedium',
|
|
1910
|
+
color: 'onSurfaceVariant'
|
|
1911
|
+
})}>
|
|
1912
|
+
No more items to load
|
|
1913
|
+
</div>
|
|
1914
|
+
)}
|
|
1915
|
+
|
|
1916
|
+
{/* Intersection observer target */}
|
|
1917
|
+
<div ref={observerRef} className={css({ height: '1px' })} />
|
|
1918
|
+
</div>
|
|
1919
|
+
);
|
|
1920
|
+
}
|
|
1921
|
+
```
|
|
1922
|
+
|
|
1923
|
+
**Best practices:**
|
|
1924
|
+
|
|
1925
|
+
- Use IntersectionObserver for performance
|
|
1926
|
+
- Show loading indicator while fetching
|
|
1927
|
+
- Indicate when no more items
|
|
1928
|
+
- Handle errors gracefully
|
|
1929
|
+
|
|
1930
|
+
**Accessibility:**
|
|
1931
|
+
|
|
1932
|
+
- Loading is announced
|
|
1933
|
+
- New content is accessible
|
|
1934
|
+
- End of list is communicated
|
|
1935
|
+
|
|
1936
|
+
---
|
|
1937
|
+
|
|
1938
|
+
## Data Display Patterns
|
|
1939
|
+
|
|
1940
|
+
### Card Grid
|
|
1941
|
+
|
|
1942
|
+
**When to use:** Displaying multiple items of equal importance in a grid layout.
|
|
1943
|
+
|
|
1944
|
+
**Components used:** Card, Badge, Button
|
|
1945
|
+
|
|
1946
|
+
**Example:**
|
|
1947
|
+
|
|
1948
|
+
```typescript
|
|
1949
|
+
import { Card, Badge, Button } from '@discourser/design-system';
|
|
1950
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
1951
|
+
|
|
1952
|
+
function CardGrid() {
|
|
1953
|
+
const products = [
|
|
1954
|
+
{ id: 1, name: 'Product A', price: '$29.99', status: 'new', image: '/product-a.jpg' },
|
|
1955
|
+
{ id: 2, name: 'Product B', price: '$39.99', status: 'sale', image: '/product-b.jpg' },
|
|
1956
|
+
{ id: 3, name: 'Product C', price: '$49.99', status: null, image: '/product-c.jpg' }
|
|
1957
|
+
];
|
|
1958
|
+
|
|
1959
|
+
return (
|
|
1960
|
+
<div className={css({
|
|
1961
|
+
display: 'grid',
|
|
1962
|
+
gridTemplateColumns: { base: '1fr', md: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' },
|
|
1963
|
+
gap: 'lg',
|
|
1964
|
+
p: { base: 'md', lg: 'xl' }
|
|
1965
|
+
})}>
|
|
1966
|
+
{products.map((product) => (
|
|
1967
|
+
<Card key={product.id} variant="elevated">
|
|
1968
|
+
<div className={css({
|
|
1969
|
+
p: 'lg',
|
|
1970
|
+
display: 'flex',
|
|
1971
|
+
flexDirection: 'column',
|
|
1972
|
+
gap: 'md'
|
|
1973
|
+
})}>
|
|
1974
|
+
{/* Image */}
|
|
1975
|
+
<div className={css({
|
|
1976
|
+
aspectRatio: '16/9',
|
|
1977
|
+
bg: 'surfaceContainerHighest',
|
|
1978
|
+
borderRadius: 'l2',
|
|
1979
|
+
overflow: 'hidden'
|
|
1980
|
+
})}>
|
|
1981
|
+
<img
|
|
1982
|
+
src={product.image}
|
|
1983
|
+
alt={product.name}
|
|
1984
|
+
className={css({ width: '100%', height: '100%', objectFit: 'cover' })}
|
|
1985
|
+
/>
|
|
1986
|
+
</div>
|
|
1987
|
+
|
|
1988
|
+
{/* Content */}
|
|
1989
|
+
<div className={css({ flex: 1 })}>
|
|
1990
|
+
<div className={css({
|
|
1991
|
+
display: 'flex',
|
|
1992
|
+
alignItems: 'center',
|
|
1993
|
+
gap: 'xs',
|
|
1994
|
+
mb: 'xs'
|
|
1995
|
+
})}>
|
|
1996
|
+
<h3 className={css({ textStyle: 'titleMedium', color: 'onSurface' })}>
|
|
1997
|
+
{product.name}
|
|
1998
|
+
</h3>
|
|
1999
|
+
{product.status && (
|
|
2000
|
+
<Badge variant={product.status === 'new' ? 'solid' : 'subtle'}>
|
|
2001
|
+
{product.status}
|
|
2002
|
+
</Badge>
|
|
2003
|
+
)}
|
|
2004
|
+
</div>
|
|
2005
|
+
|
|
2006
|
+
<p className={css({ textStyle: 'titleLarge', color: 'primary', mb: 'md' })}>
|
|
2007
|
+
{product.price}
|
|
2008
|
+
</p>
|
|
2009
|
+
</div>
|
|
2010
|
+
|
|
2011
|
+
{/* Action */}
|
|
2012
|
+
<Button variant="filled" className={css({ width: '100%' })}>
|
|
2013
|
+
Add to Cart
|
|
2014
|
+
</Button>
|
|
2015
|
+
</div>
|
|
2016
|
+
</Card>
|
|
2017
|
+
))}
|
|
2018
|
+
</div>
|
|
2019
|
+
);
|
|
2020
|
+
}
|
|
2021
|
+
```
|
|
2022
|
+
|
|
2023
|
+
**Best practices:**
|
|
2024
|
+
|
|
2025
|
+
- Use responsive grid columns
|
|
2026
|
+
- Keep card heights consistent
|
|
2027
|
+
- Use appropriate gap spacing
|
|
2028
|
+
- Include clear call-to-action
|
|
2029
|
+
- Optimize images for performance
|
|
2030
|
+
|
|
2031
|
+
**Accessibility:**
|
|
2032
|
+
|
|
2033
|
+
- Images have alt text
|
|
2034
|
+
- Cards are keyboard navigable
|
|
2035
|
+
- Interactive elements are focusable
|
|
2036
|
+
|
|
2037
|
+
---
|
|
2038
|
+
|
|
2039
|
+
### List with Actions
|
|
2040
|
+
|
|
2041
|
+
**When to use:** Lists where each item has associated actions (edit, delete, etc.).
|
|
2042
|
+
|
|
2043
|
+
**Components used:** Card, IconButton, Badge
|
|
2044
|
+
|
|
2045
|
+
**Example:**
|
|
2046
|
+
|
|
2047
|
+
```typescript
|
|
2048
|
+
import { Card, Badge } from '@discourser/design-system';
|
|
2049
|
+
import * as IconButton from '@discourser/design-system';
|
|
2050
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
2051
|
+
import { EditIcon, DeleteIcon, MoreIcon } from 'your-icon-library';
|
|
2052
|
+
|
|
2053
|
+
function ListWithActions() {
|
|
2054
|
+
const items = [
|
|
2055
|
+
{ id: 1, title: 'Project Alpha', status: 'active', updated: '2 hours ago' },
|
|
2056
|
+
{ id: 2, title: 'Project Beta', status: 'pending', updated: '1 day ago' },
|
|
2057
|
+
{ id: 3, title: 'Project Gamma', status: 'completed', updated: '3 days ago' }
|
|
2058
|
+
];
|
|
2059
|
+
|
|
2060
|
+
const statusVariant = {
|
|
2061
|
+
active: 'solid',
|
|
2062
|
+
pending: 'subtle',
|
|
2063
|
+
completed: 'outline'
|
|
2064
|
+
};
|
|
2065
|
+
|
|
2066
|
+
return (
|
|
2067
|
+
<div className={css({ maxWidth: '800px', mx: 'auto', p: 'xl' })}>
|
|
2068
|
+
<div className={css({
|
|
2069
|
+
display: 'flex',
|
|
2070
|
+
flexDirection: 'column',
|
|
2071
|
+
gap: 'md'
|
|
2072
|
+
})}>
|
|
2073
|
+
{items.map((item) => (
|
|
2074
|
+
<Card key={item.id} variant="elevated">
|
|
2075
|
+
<div className={css({
|
|
2076
|
+
p: 'md',
|
|
2077
|
+
display: 'flex',
|
|
2078
|
+
alignItems: 'center',
|
|
2079
|
+
gap: 'md'
|
|
2080
|
+
})}>
|
|
2081
|
+
{/* Content */}
|
|
2082
|
+
<div className={css({ flex: 1 })}>
|
|
2083
|
+
<div className={css({
|
|
2084
|
+
display: 'flex',
|
|
2085
|
+
alignItems: 'center',
|
|
2086
|
+
gap: 'sm',
|
|
2087
|
+
mb: 'xs'
|
|
2088
|
+
})}>
|
|
2089
|
+
<h3 className={css({ textStyle: 'titleMedium', color: 'onSurface' })}>
|
|
2090
|
+
{item.title}
|
|
2091
|
+
</h3>
|
|
2092
|
+
<Badge variant={statusVariant[item.status]}>
|
|
2093
|
+
{item.status}
|
|
2094
|
+
</Badge>
|
|
2095
|
+
</div>
|
|
2096
|
+
<p className={css({ textStyle: 'bodySmall', color: 'onSurfaceVariant' })}>
|
|
2097
|
+
Updated {item.updated}
|
|
2098
|
+
</p>
|
|
2099
|
+
</div>
|
|
2100
|
+
|
|
2101
|
+
{/* Actions */}
|
|
2102
|
+
<div className={css({ display: 'flex', gap: 'xs' })}>
|
|
2103
|
+
<IconButton.Root variant="standard" size="sm">
|
|
2104
|
+
<IconButton.Icon>
|
|
2105
|
+
<EditIcon />
|
|
2106
|
+
</IconButton.Icon>
|
|
2107
|
+
</IconButton.Root>
|
|
2108
|
+
|
|
2109
|
+
<IconButton.Root variant="standard" size="sm">
|
|
2110
|
+
<IconButton.Icon>
|
|
2111
|
+
<DeleteIcon />
|
|
2112
|
+
</IconButton.Icon>
|
|
2113
|
+
</IconButton.Root>
|
|
2114
|
+
|
|
2115
|
+
<IconButton.Root variant="standard" size="sm">
|
|
2116
|
+
<IconButton.Icon>
|
|
2117
|
+
<MoreIcon />
|
|
2118
|
+
</IconButton.Icon>
|
|
2119
|
+
</IconButton.Root>
|
|
2120
|
+
</div>
|
|
2121
|
+
</div>
|
|
2122
|
+
</Card>
|
|
2123
|
+
))}
|
|
2124
|
+
</div>
|
|
2125
|
+
</div>
|
|
2126
|
+
);
|
|
2127
|
+
}
|
|
2128
|
+
```
|
|
2129
|
+
|
|
2130
|
+
**Best practices:**
|
|
2131
|
+
|
|
2132
|
+
- Place actions on right side
|
|
2133
|
+
- Use icon buttons for space efficiency
|
|
2134
|
+
- Show actions on hover (desktop)
|
|
2135
|
+
- Always show on mobile
|
|
2136
|
+
- Confirm destructive actions
|
|
2137
|
+
|
|
2138
|
+
**Accessibility:**
|
|
2139
|
+
|
|
2140
|
+
- Icon buttons have aria-labels
|
|
2141
|
+
- Actions are keyboard accessible
|
|
2142
|
+
- Focus order is logical
|
|
2143
|
+
|
|
2144
|
+
---
|
|
2145
|
+
|
|
2146
|
+
### List with Avatar
|
|
2147
|
+
|
|
2148
|
+
**When to use:** Lists representing people or entities with profile images.
|
|
2149
|
+
|
|
2150
|
+
**Components used:** Avatar, Badge, Card
|
|
2151
|
+
|
|
2152
|
+
**Example:**
|
|
2153
|
+
|
|
2154
|
+
```typescript
|
|
2155
|
+
import { Card, Badge } from '@discourser/design-system';
|
|
2156
|
+
import * as Avatar from '@discourser/design-system';
|
|
2157
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
2158
|
+
|
|
2159
|
+
function ListWithAvatar() {
|
|
2160
|
+
const users = [
|
|
2161
|
+
{ id: 1, name: 'Jane Doe', email: 'jane@example.com', status: 'online', avatar: '/jane.jpg' },
|
|
2162
|
+
{ id: 2, name: 'John Smith', email: 'john@example.com', status: 'offline', avatar: null },
|
|
2163
|
+
{ id: 3, name: 'Alice Johnson', email: 'alice@example.com', status: 'away', avatar: '/alice.jpg' }
|
|
2164
|
+
];
|
|
2165
|
+
|
|
2166
|
+
const statusColor = {
|
|
2167
|
+
online: 'success',
|
|
2168
|
+
offline: 'onSurfaceVariant',
|
|
2169
|
+
away: 'warning'
|
|
2170
|
+
};
|
|
2171
|
+
|
|
2172
|
+
return (
|
|
2173
|
+
<div className={css({ maxWidth: '600px', mx: 'auto', p: 'xl' })}>
|
|
2174
|
+
<div className={css({
|
|
2175
|
+
display: 'flex',
|
|
2176
|
+
flexDirection: 'column',
|
|
2177
|
+
gap: 'sm'
|
|
2178
|
+
})}>
|
|
2179
|
+
{users.map((user) => (
|
|
2180
|
+
<Card key={user.id} variant="elevated">
|
|
2181
|
+
<div className={css({
|
|
2182
|
+
p: 'md',
|
|
2183
|
+
display: 'flex',
|
|
2184
|
+
alignItems: 'center',
|
|
2185
|
+
gap: 'md'
|
|
2186
|
+
})}>
|
|
2187
|
+
{/* Avatar with status */}
|
|
2188
|
+
<div className={css({ position: 'relative' })}>
|
|
2189
|
+
<Avatar.Root size="md">
|
|
2190
|
+
{user.avatar ? (
|
|
2191
|
+
<Avatar.Image src={user.avatar} alt={user.name} />
|
|
2192
|
+
) : (
|
|
2193
|
+
<Avatar.Fallback>
|
|
2194
|
+
{user.name.split(' ').map(n => n[0]).join('')}
|
|
2195
|
+
</Avatar.Fallback>
|
|
2196
|
+
)}
|
|
2197
|
+
</Avatar.Root>
|
|
2198
|
+
|
|
2199
|
+
{/* Status indicator */}
|
|
2200
|
+
<div className={css({
|
|
2201
|
+
position: 'absolute',
|
|
2202
|
+
bottom: 0,
|
|
2203
|
+
right: 0,
|
|
2204
|
+
width: '12px',
|
|
2205
|
+
height: '12px',
|
|
2206
|
+
borderRadius: 'full',
|
|
2207
|
+
bg: statusColor[user.status],
|
|
2208
|
+
border: '2px solid',
|
|
2209
|
+
borderColor: 'surface'
|
|
2210
|
+
})} />
|
|
2211
|
+
</div>
|
|
2212
|
+
|
|
2213
|
+
{/* User info */}
|
|
2214
|
+
<div className={css({ flex: 1 })}>
|
|
2215
|
+
<h3 className={css({ textStyle: 'titleMedium', color: 'onSurface', mb: 'xxs' })}>
|
|
2216
|
+
{user.name}
|
|
2217
|
+
</h3>
|
|
2218
|
+
<p className={css({ textStyle: 'bodySmall', color: 'onSurfaceVariant' })}>
|
|
2219
|
+
{user.email}
|
|
2220
|
+
</p>
|
|
2221
|
+
</div>
|
|
2222
|
+
|
|
2223
|
+
{/* Status badge */}
|
|
2224
|
+
<Badge variant="subtle">
|
|
2225
|
+
{user.status}
|
|
2226
|
+
</Badge>
|
|
2227
|
+
</div>
|
|
2228
|
+
</Card>
|
|
2229
|
+
))}
|
|
2230
|
+
</div>
|
|
2231
|
+
</div>
|
|
2232
|
+
);
|
|
2233
|
+
}
|
|
2234
|
+
```
|
|
2235
|
+
|
|
2236
|
+
**Best practices:**
|
|
2237
|
+
|
|
2238
|
+
- Use appropriate avatar size
|
|
2239
|
+
- Show status indicator when relevant
|
|
2240
|
+
- Include fallback for missing images
|
|
2241
|
+
- Keep information hierarchy clear
|
|
2242
|
+
- Add hover states for interactivity
|
|
2243
|
+
|
|
2244
|
+
**Accessibility:**
|
|
2245
|
+
|
|
2246
|
+
- Avatar images have alt text
|
|
2247
|
+
- Status is communicated via badge
|
|
2248
|
+
- Information is properly structured
|
|
2249
|
+
|
|
2250
|
+
---
|
|
2251
|
+
|
|
2252
|
+
### Expandable/Collapsible List
|
|
2253
|
+
|
|
2254
|
+
**When to use:** Long lists with detailed information that can be expanded on demand.
|
|
2255
|
+
|
|
2256
|
+
**Components used:** Accordion
|
|
2257
|
+
|
|
2258
|
+
**Example:**
|
|
2259
|
+
|
|
2260
|
+
```typescript
|
|
2261
|
+
import * as Accordion from '@discourser/design-system';
|
|
2262
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
2263
|
+
|
|
2264
|
+
function ExpandableList() {
|
|
2265
|
+
const faqs = [
|
|
2266
|
+
{
|
|
2267
|
+
id: '1',
|
|
2268
|
+
question: 'How do I reset my password?',
|
|
2269
|
+
answer: 'Click on the "Forgot Password" link on the login page. Enter your email address and we\'ll send you instructions to reset your password.'
|
|
2270
|
+
},
|
|
2271
|
+
{
|
|
2272
|
+
id: '2',
|
|
2273
|
+
question: 'What payment methods do you accept?',
|
|
2274
|
+
answer: 'We accept all major credit cards (Visa, MasterCard, American Express), PayPal, and bank transfers for enterprise customers.'
|
|
2275
|
+
},
|
|
2276
|
+
{
|
|
2277
|
+
id: '3',
|
|
2278
|
+
question: 'How can I contact support?',
|
|
2279
|
+
answer: 'You can reach our support team via email at support@example.com, through our live chat feature, or by phone at 1-800-123-4567 during business hours.'
|
|
2280
|
+
}
|
|
2281
|
+
];
|
|
2282
|
+
|
|
2283
|
+
return (
|
|
2284
|
+
<div className={css({ maxWidth: '800px', mx: 'auto', p: 'xl' })}>
|
|
2285
|
+
<h2 className={css({ textStyle: 'headlineMedium', mb: 'lg' })}>
|
|
2286
|
+
Frequently Asked Questions
|
|
2287
|
+
</h2>
|
|
2288
|
+
|
|
2289
|
+
<Accordion.Root multiple>
|
|
2290
|
+
{faqs.map((faq) => (
|
|
2291
|
+
<Accordion.Item key={faq.id} value={faq.id}>
|
|
2292
|
+
<Accordion.ItemTrigger className={css({
|
|
2293
|
+
py: 'md',
|
|
2294
|
+
px: 'lg',
|
|
2295
|
+
textStyle: 'titleMedium',
|
|
2296
|
+
color: 'onSurface',
|
|
2297
|
+
cursor: 'pointer',
|
|
2298
|
+
_hover: { bg: 'surfaceContainerHighest' },
|
|
2299
|
+
borderRadius: 'l2'
|
|
2300
|
+
})}>
|
|
2301
|
+
{faq.question}
|
|
2302
|
+
<Accordion.ItemIndicator>▼</Accordion.ItemIndicator>
|
|
2303
|
+
</Accordion.ItemTrigger>
|
|
2304
|
+
|
|
2305
|
+
<Accordion.ItemContent className={css({
|
|
2306
|
+
px: 'lg',
|
|
2307
|
+
pb: 'md'
|
|
2308
|
+
})}>
|
|
2309
|
+
<p className={css({
|
|
2310
|
+
textStyle: 'bodyMedium',
|
|
2311
|
+
color: 'onSurfaceVariant'
|
|
2312
|
+
})}>
|
|
2313
|
+
{faq.answer}
|
|
2314
|
+
</p>
|
|
2315
|
+
</Accordion.ItemContent>
|
|
2316
|
+
</Accordion.Item>
|
|
2317
|
+
))}
|
|
2318
|
+
</Accordion.Root>
|
|
2319
|
+
</div>
|
|
2320
|
+
);
|
|
2321
|
+
}
|
|
2322
|
+
```
|
|
2323
|
+
|
|
2324
|
+
**Best practices:**
|
|
2325
|
+
|
|
2326
|
+
- Use clear, descriptive titles
|
|
2327
|
+
- Support multiple open items for FAQs
|
|
2328
|
+
- Animate expand/collapse smoothly
|
|
2329
|
+
- Include visual indicator (arrow)
|
|
2330
|
+
- Keep content concise
|
|
2331
|
+
|
|
2332
|
+
**Accessibility:**
|
|
2333
|
+
|
|
2334
|
+
- Follows WAI-ARIA accordion pattern
|
|
2335
|
+
- Keyboard navigation (Tab, Enter, Arrow keys)
|
|
2336
|
+
- Screen readers announce expanded state
|
|
2337
|
+
|
|
2338
|
+
---
|
|
2339
|
+
|
|
2340
|
+
## Search & Filter Patterns
|
|
2341
|
+
|
|
2342
|
+
### Search Bar (Simple)
|
|
2343
|
+
|
|
2344
|
+
**When to use:** Basic keyword search without complex filtering needs.
|
|
2345
|
+
|
|
2346
|
+
**Components used:** InputGroup, Input, Button
|
|
2347
|
+
|
|
2348
|
+
**Example:**
|
|
2349
|
+
|
|
2350
|
+
```typescript
|
|
2351
|
+
import { Input, Button } from '@discourser/design-system';
|
|
2352
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
2353
|
+
import { useState } from 'react';
|
|
2354
|
+
import { SearchIcon } from 'your-icon-library';
|
|
2355
|
+
|
|
2356
|
+
function SearchBar() {
|
|
2357
|
+
const [query, setQuery] = useState('');
|
|
2358
|
+
|
|
2359
|
+
const handleSearch = (e: React.FormEvent) => {
|
|
2360
|
+
e.preventDefault();
|
|
2361
|
+
console.log('Searching for:', query);
|
|
2362
|
+
// Perform search
|
|
2363
|
+
};
|
|
2364
|
+
|
|
2365
|
+
const handleClear = () => {
|
|
2366
|
+
setQuery('');
|
|
2367
|
+
};
|
|
2368
|
+
|
|
2369
|
+
return (
|
|
2370
|
+
<form onSubmit={handleSearch} className={css({ maxWidth: '600px', mx: 'auto', p: 'xl' })}>
|
|
2371
|
+
<div className={css({ display: 'flex', gap: 'sm' })}>
|
|
2372
|
+
<div className={css({ flex: 1, position: 'relative' })}>
|
|
2373
|
+
<Input
|
|
2374
|
+
label="Search"
|
|
2375
|
+
value={query}
|
|
2376
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
2377
|
+
placeholder="Search products, articles, or docs..."
|
|
2378
|
+
className={css({ pr: query ? 'xxxl' : 'md' })}
|
|
2379
|
+
/>
|
|
2380
|
+
|
|
2381
|
+
{/* Clear button */}
|
|
2382
|
+
{query && (
|
|
2383
|
+
<button
|
|
2384
|
+
type="button"
|
|
2385
|
+
onClick={handleClear}
|
|
2386
|
+
className={css({
|
|
2387
|
+
position: 'absolute',
|
|
2388
|
+
right: 'xs',
|
|
2389
|
+
top: '50%',
|
|
2390
|
+
transform: 'translateY(-50%)',
|
|
2391
|
+
p: 'xs',
|
|
2392
|
+
color: 'onSurfaceVariant',
|
|
2393
|
+
cursor: 'pointer',
|
|
2394
|
+
borderRadius: 'full',
|
|
2395
|
+
_hover: { bg: 'surfaceContainerHighest' }
|
|
2396
|
+
})}
|
|
2397
|
+
aria-label="Clear search"
|
|
2398
|
+
>
|
|
2399
|
+
✕
|
|
2400
|
+
</button>
|
|
2401
|
+
)}
|
|
2402
|
+
</div>
|
|
2403
|
+
|
|
2404
|
+
<Button
|
|
2405
|
+
type="submit"
|
|
2406
|
+
variant="filled"
|
|
2407
|
+
leftIcon={<SearchIcon />}
|
|
2408
|
+
>
|
|
2409
|
+
Search
|
|
2410
|
+
</Button>
|
|
2411
|
+
</div>
|
|
2412
|
+
</form>
|
|
2413
|
+
);
|
|
2414
|
+
}
|
|
2415
|
+
```
|
|
2416
|
+
|
|
2417
|
+
**Best practices:**
|
|
2418
|
+
|
|
2419
|
+
- Include clear button when text present
|
|
2420
|
+
- Use search icon for recognition
|
|
2421
|
+
- Submit on Enter key
|
|
2422
|
+
- Provide placeholder text
|
|
2423
|
+
- Show recent searches (optional)
|
|
2424
|
+
|
|
2425
|
+
**Accessibility:**
|
|
2426
|
+
|
|
2427
|
+
- Label is present
|
|
2428
|
+
- Clear button has aria-label
|
|
2429
|
+
- Keyboard shortcuts work
|
|
2430
|
+
- Search icon is decorative
|
|
2431
|
+
|
|
2432
|
+
---
|
|
2433
|
+
|
|
2434
|
+
### Search with Filters
|
|
2435
|
+
|
|
2436
|
+
**When to use:** Search that needs additional filtering criteria (category, price range, etc.).
|
|
2437
|
+
|
|
2438
|
+
**Components used:** Input, Select, Checkbox, Button
|
|
2439
|
+
|
|
2440
|
+
**Example:**
|
|
2441
|
+
|
|
2442
|
+
```typescript
|
|
2443
|
+
import { Input, Button } from '@discourser/design-system';
|
|
2444
|
+
import * as Select from '@discourser/design-system';
|
|
2445
|
+
import * as Checkbox from '@discourser/design-system';
|
|
2446
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
2447
|
+
import { useState } from 'react';
|
|
2448
|
+
import { createListCollection } from '@ark-ui/react';
|
|
2449
|
+
|
|
2450
|
+
function SearchWithFilters() {
|
|
2451
|
+
const [query, setQuery] = useState('');
|
|
2452
|
+
const [category, setCategory] = useState('all');
|
|
2453
|
+
const [inStock, setInStock] = useState(false);
|
|
2454
|
+
const [onSale, setOnSale] = useState(false);
|
|
2455
|
+
|
|
2456
|
+
const categories = createListCollection({
|
|
2457
|
+
items: [
|
|
2458
|
+
{ label: 'All Categories', value: 'all' },
|
|
2459
|
+
{ label: 'Electronics', value: 'electronics' },
|
|
2460
|
+
{ label: 'Clothing', value: 'clothing' },
|
|
2461
|
+
{ label: 'Books', value: 'books' }
|
|
2462
|
+
]
|
|
2463
|
+
});
|
|
2464
|
+
|
|
2465
|
+
const handleSearch = (e: React.FormEvent) => {
|
|
2466
|
+
e.preventDefault();
|
|
2467
|
+
console.log({ query, category, inStock, onSale });
|
|
2468
|
+
// Perform filtered search
|
|
2469
|
+
};
|
|
2470
|
+
|
|
2471
|
+
return (
|
|
2472
|
+
<form onSubmit={handleSearch} className={css({
|
|
2473
|
+
maxWidth: '800px',
|
|
2474
|
+
mx: 'auto',
|
|
2475
|
+
p: 'xl'
|
|
2476
|
+
})}>
|
|
2477
|
+
<div className={css({
|
|
2478
|
+
display: 'flex',
|
|
2479
|
+
flexDirection: 'column',
|
|
2480
|
+
gap: 'md'
|
|
2481
|
+
})}>
|
|
2482
|
+
{/* Search input */}
|
|
2483
|
+
<Input
|
|
2484
|
+
label="Search"
|
|
2485
|
+
value={query}
|
|
2486
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
2487
|
+
placeholder="What are you looking for?"
|
|
2488
|
+
/>
|
|
2489
|
+
|
|
2490
|
+
{/* Filters */}
|
|
2491
|
+
<div className={css({
|
|
2492
|
+
display: 'grid',
|
|
2493
|
+
gridTemplateColumns: { base: '1fr', md: 'repeat(3, 1fr)' },
|
|
2494
|
+
gap: 'md'
|
|
2495
|
+
})}>
|
|
2496
|
+
{/* Category filter */}
|
|
2497
|
+
<Select.Root
|
|
2498
|
+
collection={categories}
|
|
2499
|
+
value={[category]}
|
|
2500
|
+
onValueChange={(details) => setCategory(details.value[0])}
|
|
2501
|
+
>
|
|
2502
|
+
<Select.Label>Category</Select.Label>
|
|
2503
|
+
<Select.Control>
|
|
2504
|
+
<Select.Trigger>
|
|
2505
|
+
<Select.ValueText placeholder="Select category" />
|
|
2506
|
+
</Select.Trigger>
|
|
2507
|
+
</Select.Control>
|
|
2508
|
+
<Select.Positioner>
|
|
2509
|
+
<Select.Content>
|
|
2510
|
+
{categories.items.map((item) => (
|
|
2511
|
+
<Select.Item key={item.value} item={item}>
|
|
2512
|
+
<Select.ItemText>{item.label}</Select.ItemText>
|
|
2513
|
+
</Select.Item>
|
|
2514
|
+
))}
|
|
2515
|
+
</Select.Content>
|
|
2516
|
+
</Select.Positioner>
|
|
2517
|
+
</Select.Root>
|
|
2518
|
+
|
|
2519
|
+
{/* Checkbox filters */}
|
|
2520
|
+
<div className={css({
|
|
2521
|
+
display: 'flex',
|
|
2522
|
+
flexDirection: 'column',
|
|
2523
|
+
gap: 'sm',
|
|
2524
|
+
justifyContent: 'center'
|
|
2525
|
+
})}>
|
|
2526
|
+
<Checkbox.Root
|
|
2527
|
+
checked={inStock}
|
|
2528
|
+
onCheckedChange={(details) => setInStock(details.checked === true)}
|
|
2529
|
+
>
|
|
2530
|
+
<Checkbox.Control>
|
|
2531
|
+
<Checkbox.Indicator>✓</Checkbox.Indicator>
|
|
2532
|
+
</Checkbox.Control>
|
|
2533
|
+
<Checkbox.Label>In Stock Only</Checkbox.Label>
|
|
2534
|
+
</Checkbox.Root>
|
|
2535
|
+
|
|
2536
|
+
<Checkbox.Root
|
|
2537
|
+
checked={onSale}
|
|
2538
|
+
onCheckedChange={(details) => setOnSale(details.checked === true)}
|
|
2539
|
+
>
|
|
2540
|
+
<Checkbox.Control>
|
|
2541
|
+
<Checkbox.Indicator>✓</Checkbox.Indicator>
|
|
2542
|
+
</Checkbox.Control>
|
|
2543
|
+
<Checkbox.Label>On Sale</Checkbox.Label>
|
|
2544
|
+
</Checkbox.Root>
|
|
2545
|
+
</div>
|
|
2546
|
+
|
|
2547
|
+
{/* Search button */}
|
|
2548
|
+
<div className={css({ display: 'flex', alignItems: 'flex-end' })}>
|
|
2549
|
+
<Button type="submit" variant="filled" className={css({ width: '100%' })}>
|
|
2550
|
+
Search
|
|
2551
|
+
</Button>
|
|
2552
|
+
</div>
|
|
2553
|
+
</div>
|
|
2554
|
+
</div>
|
|
2555
|
+
</form>
|
|
2556
|
+
);
|
|
2557
|
+
}
|
|
2558
|
+
```
|
|
2559
|
+
|
|
2560
|
+
**Best practices:**
|
|
2561
|
+
|
|
2562
|
+
- Group related filters together
|
|
2563
|
+
- Use appropriate filter types
|
|
2564
|
+
- Apply filters immediately or on submit
|
|
2565
|
+
- Show active filter count
|
|
2566
|
+
- Allow clearing all filters
|
|
2567
|
+
|
|
2568
|
+
**Accessibility:**
|
|
2569
|
+
|
|
2570
|
+
- All filters are labeled
|
|
2571
|
+
- Keyboard navigation works
|
|
2572
|
+
- Screen readers understand filter relationships
|
|
2573
|
+
|
|
2574
|
+
---
|
|
2575
|
+
|
|
2576
|
+
### Search with Results
|
|
2577
|
+
|
|
2578
|
+
**When to use:** Showing search results with loading and empty states.
|
|
2579
|
+
|
|
2580
|
+
**Components used:** Input, Card, Skeleton, Button
|
|
2581
|
+
|
|
2582
|
+
**Example:**
|
|
2583
|
+
|
|
2584
|
+
```typescript
|
|
2585
|
+
import { Input, Card, Button } from '@discourser/design-system';
|
|
2586
|
+
import * as Skeleton from '@discourser/design-system';
|
|
2587
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
2588
|
+
import { useState } from 'react';
|
|
2589
|
+
|
|
2590
|
+
function SearchWithResults() {
|
|
2591
|
+
const [query, setQuery] = useState('');
|
|
2592
|
+
const [loading, setLoading] = useState(false);
|
|
2593
|
+
const [results, setResults] = useState<any[]>([]);
|
|
2594
|
+
const [searched, setSearched] = useState(false);
|
|
2595
|
+
|
|
2596
|
+
const handleSearch = async (e: React.FormEvent) => {
|
|
2597
|
+
e.preventDefault();
|
|
2598
|
+
|
|
2599
|
+
if (!query.trim()) return;
|
|
2600
|
+
|
|
2601
|
+
setLoading(true);
|
|
2602
|
+
setSearched(true);
|
|
2603
|
+
|
|
2604
|
+
// Simulate API call
|
|
2605
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
2606
|
+
|
|
2607
|
+
// Mock results
|
|
2608
|
+
const mockResults = query.length > 0
|
|
2609
|
+
? [
|
|
2610
|
+
{ id: 1, title: `Result for "${query}" #1`, description: 'This is a relevant result...' },
|
|
2611
|
+
{ id: 2, title: `Result for "${query}" #2`, description: 'Another matching result...' }
|
|
2612
|
+
]
|
|
2613
|
+
: [];
|
|
2614
|
+
|
|
2615
|
+
setResults(mockResults);
|
|
2616
|
+
setLoading(false);
|
|
2617
|
+
};
|
|
2618
|
+
|
|
2619
|
+
return (
|
|
2620
|
+
<div className={css({ maxWidth: '800px', mx: 'auto', p: 'xl' })}>
|
|
2621
|
+
{/* Search form */}
|
|
2622
|
+
<form onSubmit={handleSearch} className={css({ mb: 'xl' })}>
|
|
2623
|
+
<div className={css({ display: 'flex', gap: 'sm' })}>
|
|
2624
|
+
<div className={css({ flex: 1 })}>
|
|
2625
|
+
<Input
|
|
2626
|
+
label="Search"
|
|
2627
|
+
value={query}
|
|
2628
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
2629
|
+
placeholder="Enter search term..."
|
|
2630
|
+
/>
|
|
2631
|
+
</div>
|
|
2632
|
+
<Button type="submit" variant="filled" disabled={loading}>
|
|
2633
|
+
{loading ? 'Searching...' : 'Search'}
|
|
2634
|
+
</Button>
|
|
2635
|
+
</div>
|
|
2636
|
+
</form>
|
|
2637
|
+
|
|
2638
|
+
{/* Loading state */}
|
|
2639
|
+
{loading && (
|
|
2640
|
+
<div className={css({ display: 'flex', flexDirection: 'column', gap: 'md' })}>
|
|
2641
|
+
{[1, 2, 3].map((i) => (
|
|
2642
|
+
<Card key={i} variant="elevated" className={css({ p: 'lg' })}>
|
|
2643
|
+
<Skeleton.Root>
|
|
2644
|
+
<Skeleton.Item height="24px" width="60%" className={css({ mb: 'sm' })} />
|
|
2645
|
+
<Skeleton.Item height="16px" width="100%" className={css({ mb: 'xs' })} />
|
|
2646
|
+
<Skeleton.Item height="16px" width="80%" />
|
|
2647
|
+
</Skeleton.Root>
|
|
2648
|
+
</Card>
|
|
2649
|
+
))}
|
|
2650
|
+
</div>
|
|
2651
|
+
)}
|
|
2652
|
+
|
|
2653
|
+
{/* Results */}
|
|
2654
|
+
{!loading && searched && (
|
|
2655
|
+
<>
|
|
2656
|
+
{results.length > 0 ? (
|
|
2657
|
+
<>
|
|
2658
|
+
<div className={css({
|
|
2659
|
+
textStyle: 'bodyMedium',
|
|
2660
|
+
color: 'onSurfaceVariant',
|
|
2661
|
+
mb: 'md'
|
|
2662
|
+
})}>
|
|
2663
|
+
Found {results.length} results for "{query}"
|
|
2664
|
+
</div>
|
|
2665
|
+
|
|
2666
|
+
<div className={css({ display: 'flex', flexDirection: 'column', gap: 'md' })}>
|
|
2667
|
+
{results.map((result) => (
|
|
2668
|
+
<Card key={result.id} variant="elevated" className={css({ p: 'lg' })}>
|
|
2669
|
+
<h3 className={css({ textStyle: 'titleMedium', color: 'primary', mb: 'xs' })}>
|
|
2670
|
+
{result.title}
|
|
2671
|
+
</h3>
|
|
2672
|
+
<p className={css({ textStyle: 'bodyMedium', color: 'onSurfaceVariant' })}>
|
|
2673
|
+
{result.description}
|
|
2674
|
+
</p>
|
|
2675
|
+
</Card>
|
|
2676
|
+
))}
|
|
2677
|
+
</div>
|
|
2678
|
+
</>
|
|
2679
|
+
) : (
|
|
2680
|
+
// Empty state
|
|
2681
|
+
<div className={css({
|
|
2682
|
+
textAlign: 'center',
|
|
2683
|
+
py: 'xxxl'
|
|
2684
|
+
})}>
|
|
2685
|
+
<div className={css({
|
|
2686
|
+
textStyle: 'headlineSmall',
|
|
2687
|
+
color: 'onSurface',
|
|
2688
|
+
mb: 'md'
|
|
2689
|
+
})}>
|
|
2690
|
+
No results found
|
|
2691
|
+
</div>
|
|
2692
|
+
<p className={css({
|
|
2693
|
+
textStyle: 'bodyMedium',
|
|
2694
|
+
color: 'onSurfaceVariant',
|
|
2695
|
+
mb: 'lg'
|
|
2696
|
+
})}>
|
|
2697
|
+
Try different keywords or check your spelling
|
|
2698
|
+
</p>
|
|
2699
|
+
<Button variant="outlined" onClick={() => setQuery('')}>
|
|
2700
|
+
Clear Search
|
|
2701
|
+
</Button>
|
|
2702
|
+
</div>
|
|
2703
|
+
)}
|
|
2704
|
+
</>
|
|
2705
|
+
)}
|
|
2706
|
+
</div>
|
|
2707
|
+
);
|
|
2708
|
+
}
|
|
2709
|
+
```
|
|
2710
|
+
|
|
2711
|
+
**Best practices:**
|
|
2712
|
+
|
|
2713
|
+
- Show loading skeleton while searching
|
|
2714
|
+
- Display result count
|
|
2715
|
+
- Provide empty state with guidance
|
|
2716
|
+
- Highlight search terms in results
|
|
2717
|
+
- Add pagination for many results
|
|
2718
|
+
|
|
2719
|
+
**Accessibility:**
|
|
2720
|
+
|
|
2721
|
+
- Results are announced to screen readers
|
|
2722
|
+
- Empty state provides clear guidance
|
|
2723
|
+
- Keyboard navigation works throughout
|
|
2724
|
+
|
|
2725
|
+
---
|
|
2726
|
+
|
|
2727
|
+
## Authentication Patterns
|
|
2728
|
+
|
|
2729
|
+
### Login Form
|
|
2730
|
+
|
|
2731
|
+
**When to use:** User authentication for accessing protected areas.
|
|
2732
|
+
|
|
2733
|
+
**Components used:** Input, Button, Switch, Toast
|
|
2734
|
+
|
|
2735
|
+
**Example:**
|
|
2736
|
+
|
|
2737
|
+
```typescript
|
|
2738
|
+
import { Input, Button, toaster } from '@discourser/design-system';
|
|
2739
|
+
import * as Switch from '@discourser/design-system';
|
|
2740
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
2741
|
+
import { useState } from 'react';
|
|
2742
|
+
|
|
2743
|
+
function LoginForm() {
|
|
2744
|
+
const [formData, setFormData] = useState({
|
|
2745
|
+
email: '',
|
|
2746
|
+
password: '',
|
|
2747
|
+
rememberMe: false
|
|
2748
|
+
});
|
|
2749
|
+
const [loading, setLoading] = useState(false);
|
|
2750
|
+
|
|
2751
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
2752
|
+
e.preventDefault();
|
|
2753
|
+
setLoading(true);
|
|
2754
|
+
|
|
2755
|
+
try {
|
|
2756
|
+
await loginUser(formData.email, formData.password, formData.rememberMe);
|
|
2757
|
+
|
|
2758
|
+
toaster.create({
|
|
2759
|
+
title: 'Welcome back!',
|
|
2760
|
+
description: 'You have successfully logged in.',
|
|
2761
|
+
type: 'success'
|
|
2762
|
+
});
|
|
2763
|
+
|
|
2764
|
+
// Redirect to dashboard
|
|
2765
|
+
} catch (error) {
|
|
2766
|
+
toaster.create({
|
|
2767
|
+
title: 'Login failed',
|
|
2768
|
+
description: 'Invalid email or password. Please try again.',
|
|
2769
|
+
type: 'error'
|
|
2770
|
+
});
|
|
2771
|
+
} finally {
|
|
2772
|
+
setLoading(false);
|
|
2773
|
+
}
|
|
2774
|
+
};
|
|
2775
|
+
|
|
2776
|
+
return (
|
|
2777
|
+
<div className={css({
|
|
2778
|
+
maxWidth: '400px',
|
|
2779
|
+
mx: 'auto',
|
|
2780
|
+
p: 'xl',
|
|
2781
|
+
mt: 'xxxl'
|
|
2782
|
+
})}>
|
|
2783
|
+
<div className={css({ textAlign: 'center', mb: 'xl' })}>
|
|
2784
|
+
<h1 className={css({ textStyle: 'headlineLarge', color: 'onSurface', mb: 'xs' })}>
|
|
2785
|
+
Welcome Back
|
|
2786
|
+
</h1>
|
|
2787
|
+
<p className={css({ textStyle: 'bodyMedium', color: 'onSurfaceVariant' })}>
|
|
2788
|
+
Sign in to your account to continue
|
|
2789
|
+
</p>
|
|
2790
|
+
</div>
|
|
2791
|
+
|
|
2792
|
+
<form onSubmit={handleSubmit} className={css({
|
|
2793
|
+
display: 'flex',
|
|
2794
|
+
flexDirection: 'column',
|
|
2795
|
+
gap: 'lg'
|
|
2796
|
+
})}>
|
|
2797
|
+
<Input
|
|
2798
|
+
label="Email"
|
|
2799
|
+
type="email"
|
|
2800
|
+
value={formData.email}
|
|
2801
|
+
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
|
2802
|
+
placeholder="you@example.com"
|
|
2803
|
+
required
|
|
2804
|
+
/>
|
|
2805
|
+
|
|
2806
|
+
<Input
|
|
2807
|
+
label="Password"
|
|
2808
|
+
type="password"
|
|
2809
|
+
value={formData.password}
|
|
2810
|
+
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
|
2811
|
+
required
|
|
2812
|
+
/>
|
|
2813
|
+
|
|
2814
|
+
<div className={css({
|
|
2815
|
+
display: 'flex',
|
|
2816
|
+
alignItems: 'center',
|
|
2817
|
+
justifyContent: 'space-between'
|
|
2818
|
+
})}>
|
|
2819
|
+
<Switch.Root
|
|
2820
|
+
checked={formData.rememberMe}
|
|
2821
|
+
onCheckedChange={(details) => setFormData({ ...formData, rememberMe: details.checked })}
|
|
2822
|
+
>
|
|
2823
|
+
<Switch.Label>Remember me</Switch.Label>
|
|
2824
|
+
<Switch.Control>
|
|
2825
|
+
<Switch.Thumb />
|
|
2826
|
+
</Switch.Control>
|
|
2827
|
+
</Switch.Root>
|
|
2828
|
+
|
|
2829
|
+
<Button variant="text" size="sm">
|
|
2830
|
+
Forgot password?
|
|
2831
|
+
</Button>
|
|
2832
|
+
</div>
|
|
2833
|
+
|
|
2834
|
+
<Button type="submit" variant="filled" disabled={loading}>
|
|
2835
|
+
{loading ? 'Signing in...' : 'Sign In'}
|
|
2836
|
+
</Button>
|
|
2837
|
+
|
|
2838
|
+
<div className={css({
|
|
2839
|
+
textAlign: 'center',
|
|
2840
|
+
textStyle: 'bodyMedium',
|
|
2841
|
+
color: 'onSurfaceVariant'
|
|
2842
|
+
})}>
|
|
2843
|
+
Don't have an account?{' '}
|
|
2844
|
+
<Button variant="text" size="sm">
|
|
2845
|
+
Sign up
|
|
2846
|
+
</Button>
|
|
2847
|
+
</div>
|
|
2848
|
+
</form>
|
|
2849
|
+
</div>
|
|
2850
|
+
);
|
|
2851
|
+
}
|
|
2852
|
+
```
|
|
2853
|
+
|
|
2854
|
+
**Best practices:**
|
|
2855
|
+
|
|
2856
|
+
- Use email/username and password fields
|
|
2857
|
+
- Include "Remember me" option
|
|
2858
|
+
- Provide "Forgot password" link
|
|
2859
|
+
- Show loading state during authentication
|
|
2860
|
+
- Link to sign up for new users
|
|
2861
|
+
- Use password type for security
|
|
2862
|
+
|
|
2863
|
+
**Accessibility:**
|
|
2864
|
+
|
|
2865
|
+
- All inputs are labeled
|
|
2866
|
+
- Form can be submitted with Enter
|
|
2867
|
+
- Error messages are clear
|
|
2868
|
+
- Focus management is correct
|
|
2869
|
+
|
|
2870
|
+
---
|
|
2871
|
+
|
|
2872
|
+
### Sign Up Form
|
|
2873
|
+
|
|
2874
|
+
**When to use:** New user registration with account creation.
|
|
2875
|
+
|
|
2876
|
+
**Components used:** Input, Checkbox, Button, Toast
|
|
2877
|
+
|
|
2878
|
+
**Example:**
|
|
2879
|
+
|
|
2880
|
+
```typescript
|
|
2881
|
+
import { Input, Button, toaster } from '@discourser/design-system';
|
|
2882
|
+
import * as Checkbox from '@discourser/design-system';
|
|
2883
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
2884
|
+
import { useState } from 'react';
|
|
2885
|
+
|
|
2886
|
+
function SignUpForm() {
|
|
2887
|
+
const [formData, setFormData] = useState({
|
|
2888
|
+
name: '',
|
|
2889
|
+
email: '',
|
|
2890
|
+
password: '',
|
|
2891
|
+
confirmPassword: '',
|
|
2892
|
+
agreeToTerms: false
|
|
2893
|
+
});
|
|
2894
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
2895
|
+
const [loading, setLoading] = useState(false);
|
|
2896
|
+
|
|
2897
|
+
const validateForm = () => {
|
|
2898
|
+
const newErrors: Record<string, string> = {};
|
|
2899
|
+
|
|
2900
|
+
if (!formData.name) newErrors.name = 'Name is required';
|
|
2901
|
+
if (!formData.email) newErrors.email = 'Email is required';
|
|
2902
|
+
if (!formData.password) newErrors.password = 'Password is required';
|
|
2903
|
+
else if (formData.password.length < 8) newErrors.password = 'Password must be at least 8 characters';
|
|
2904
|
+
|
|
2905
|
+
if (formData.password !== formData.confirmPassword) {
|
|
2906
|
+
newErrors.confirmPassword = 'Passwords do not match';
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
if (!formData.agreeToTerms) {
|
|
2910
|
+
newErrors.terms = 'You must agree to the terms and conditions';
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
setErrors(newErrors);
|
|
2914
|
+
return Object.keys(newErrors).length === 0;
|
|
2915
|
+
};
|
|
2916
|
+
|
|
2917
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
2918
|
+
e.preventDefault();
|
|
2919
|
+
|
|
2920
|
+
if (!validateForm()) {
|
|
2921
|
+
toaster.create({
|
|
2922
|
+
title: 'Validation error',
|
|
2923
|
+
description: 'Please check the form for errors.',
|
|
2924
|
+
type: 'error'
|
|
2925
|
+
});
|
|
2926
|
+
return;
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
setLoading(true);
|
|
2930
|
+
|
|
2931
|
+
try {
|
|
2932
|
+
await registerUser(formData);
|
|
2933
|
+
|
|
2934
|
+
toaster.create({
|
|
2935
|
+
title: 'Account created!',
|
|
2936
|
+
description: 'Welcome! Please check your email to verify your account.',
|
|
2937
|
+
type: 'success'
|
|
2938
|
+
});
|
|
2939
|
+
|
|
2940
|
+
// Redirect to email verification
|
|
2941
|
+
} catch (error) {
|
|
2942
|
+
toaster.create({
|
|
2943
|
+
title: 'Registration failed',
|
|
2944
|
+
description: 'Email already exists or server error.',
|
|
2945
|
+
type: 'error'
|
|
2946
|
+
});
|
|
2947
|
+
} finally {
|
|
2948
|
+
setLoading(false);
|
|
2949
|
+
}
|
|
2950
|
+
};
|
|
2951
|
+
|
|
2952
|
+
return (
|
|
2953
|
+
<div className={css({
|
|
2954
|
+
maxWidth: '450px',
|
|
2955
|
+
mx: 'auto',
|
|
2956
|
+
p: 'xl',
|
|
2957
|
+
mt: 'xxl'
|
|
2958
|
+
})}>
|
|
2959
|
+
<div className={css({ textAlign: 'center', mb: 'xl' })}>
|
|
2960
|
+
<h1 className={css({ textStyle: 'headlineLarge', color: 'onSurface', mb: 'xs' })}>
|
|
2961
|
+
Create Account
|
|
2962
|
+
</h1>
|
|
2963
|
+
<p className={css({ textStyle: 'bodyMedium', color: 'onSurfaceVariant' })}>
|
|
2964
|
+
Join us today and get started
|
|
2965
|
+
</p>
|
|
2966
|
+
</div>
|
|
2967
|
+
|
|
2968
|
+
<form onSubmit={handleSubmit} className={css({
|
|
2969
|
+
display: 'flex',
|
|
2970
|
+
flexDirection: 'column',
|
|
2971
|
+
gap: 'lg'
|
|
2972
|
+
})}>
|
|
2973
|
+
<Input
|
|
2974
|
+
label="Full Name"
|
|
2975
|
+
value={formData.name}
|
|
2976
|
+
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
2977
|
+
errorText={errors.name}
|
|
2978
|
+
required
|
|
2979
|
+
/>
|
|
2980
|
+
|
|
2981
|
+
<Input
|
|
2982
|
+
label="Email"
|
|
2983
|
+
type="email"
|
|
2984
|
+
value={formData.email}
|
|
2985
|
+
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
|
2986
|
+
errorText={errors.email}
|
|
2987
|
+
required
|
|
2988
|
+
/>
|
|
2989
|
+
|
|
2990
|
+
<Input
|
|
2991
|
+
label="Password"
|
|
2992
|
+
type="password"
|
|
2993
|
+
value={formData.password}
|
|
2994
|
+
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
|
2995
|
+
errorText={errors.password}
|
|
2996
|
+
helperText="Must be at least 8 characters"
|
|
2997
|
+
required
|
|
2998
|
+
/>
|
|
2999
|
+
|
|
3000
|
+
<Input
|
|
3001
|
+
label="Confirm Password"
|
|
3002
|
+
type="password"
|
|
3003
|
+
value={formData.confirmPassword}
|
|
3004
|
+
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
|
3005
|
+
errorText={errors.confirmPassword}
|
|
3006
|
+
required
|
|
3007
|
+
/>
|
|
3008
|
+
|
|
3009
|
+
<div>
|
|
3010
|
+
<Checkbox.Root
|
|
3011
|
+
checked={formData.agreeToTerms}
|
|
3012
|
+
onCheckedChange={(details) => setFormData({ ...formData, agreeToTerms: details.checked === true })}
|
|
3013
|
+
>
|
|
3014
|
+
<Checkbox.Control>
|
|
3015
|
+
<Checkbox.Indicator>✓</Checkbox.Indicator>
|
|
3016
|
+
</Checkbox.Control>
|
|
3017
|
+
<Checkbox.Label className={css({ textStyle: 'bodySmall' })}>
|
|
3018
|
+
I agree to the{' '}
|
|
3019
|
+
<Button variant="text" size="sm" className={css({ display: 'inline', p: 0 })}>
|
|
3020
|
+
Terms of Service
|
|
3021
|
+
</Button>
|
|
3022
|
+
{' '}and{' '}
|
|
3023
|
+
<Button variant="text" size="sm" className={css({ display: 'inline', p: 0 })}>
|
|
3024
|
+
Privacy Policy
|
|
3025
|
+
</Button>
|
|
3026
|
+
</Checkbox.Label>
|
|
3027
|
+
</Checkbox.Root>
|
|
3028
|
+
{errors.terms && (
|
|
3029
|
+
<p className={css({ textStyle: 'bodySmall', color: 'error', mt: 'xs' })}>
|
|
3030
|
+
{errors.terms}
|
|
3031
|
+
</p>
|
|
3032
|
+
)}
|
|
3033
|
+
</div>
|
|
3034
|
+
|
|
3035
|
+
<Button type="submit" variant="filled" disabled={loading}>
|
|
3036
|
+
{loading ? 'Creating account...' : 'Create Account'}
|
|
3037
|
+
</Button>
|
|
3038
|
+
|
|
3039
|
+
<div className={css({
|
|
3040
|
+
textAlign: 'center',
|
|
3041
|
+
textStyle: 'bodyMedium',
|
|
3042
|
+
color: 'onSurfaceVariant'
|
|
3043
|
+
})}>
|
|
3044
|
+
Already have an account?{' '}
|
|
3045
|
+
<Button variant="text" size="sm">
|
|
3046
|
+
Sign in
|
|
3047
|
+
</Button>
|
|
3048
|
+
</div>
|
|
3049
|
+
</form>
|
|
3050
|
+
</div>
|
|
3051
|
+
);
|
|
3052
|
+
}
|
|
3053
|
+
```
|
|
3054
|
+
|
|
3055
|
+
**Best practices:**
|
|
3056
|
+
|
|
3057
|
+
- Collect minimal required information
|
|
3058
|
+
- Validate password strength
|
|
3059
|
+
- Require password confirmation
|
|
3060
|
+
- Include terms acceptance checkbox
|
|
3061
|
+
- Show validation errors inline
|
|
3062
|
+
- Link to existing account login
|
|
3063
|
+
|
|
3064
|
+
**Accessibility:**
|
|
3065
|
+
|
|
3066
|
+
- All inputs labeled
|
|
3067
|
+
- Errors associated with inputs
|
|
3068
|
+
- Terms links are accessible
|
|
3069
|
+
- Form validates before submission
|
|
3070
|
+
|
|
3071
|
+
---
|
|
3072
|
+
|
|
3073
|
+
### Password Reset Flow
|
|
3074
|
+
|
|
3075
|
+
**When to use:** Allowing users to recover account access via email verification.
|
|
3076
|
+
|
|
3077
|
+
**Components used:** Input, Button, Toast
|
|
3078
|
+
|
|
3079
|
+
**Example:**
|
|
3080
|
+
|
|
3081
|
+
```typescript
|
|
3082
|
+
import { Input, Button, toaster } from '@discourser/design-system';
|
|
3083
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
3084
|
+
import { useState } from 'react';
|
|
3085
|
+
|
|
3086
|
+
function PasswordResetFlow() {
|
|
3087
|
+
const [step, setStep] = useState<'request' | 'sent' | 'reset'>('request');
|
|
3088
|
+
const [email, setEmail] = useState('');
|
|
3089
|
+
const [newPassword, setNewPassword] = useState('');
|
|
3090
|
+
const [confirmPassword, setConfirmPassword] = useState('');
|
|
3091
|
+
const [loading, setLoading] = useState(false);
|
|
3092
|
+
|
|
3093
|
+
const handleRequestReset = async (e: React.FormEvent) => {
|
|
3094
|
+
e.preventDefault();
|
|
3095
|
+
setLoading(true);
|
|
3096
|
+
|
|
3097
|
+
try {
|
|
3098
|
+
await requestPasswordReset(email);
|
|
3099
|
+
|
|
3100
|
+
toaster.create({
|
|
3101
|
+
title: 'Reset email sent',
|
|
3102
|
+
description: 'Check your inbox for password reset instructions.',
|
|
3103
|
+
type: 'success'
|
|
3104
|
+
});
|
|
3105
|
+
|
|
3106
|
+
setStep('sent');
|
|
3107
|
+
} catch (error) {
|
|
3108
|
+
toaster.create({
|
|
3109
|
+
title: 'Failed to send email',
|
|
3110
|
+
description: 'Please check your email address and try again.',
|
|
3111
|
+
type: 'error'
|
|
3112
|
+
});
|
|
3113
|
+
} finally {
|
|
3114
|
+
setLoading(false);
|
|
3115
|
+
}
|
|
3116
|
+
};
|
|
3117
|
+
|
|
3118
|
+
const handleResetPassword = async (e: React.FormEvent) => {
|
|
3119
|
+
e.preventDefault();
|
|
3120
|
+
|
|
3121
|
+
if (newPassword !== confirmPassword) {
|
|
3122
|
+
toaster.create({
|
|
3123
|
+
title: 'Passwords do not match',
|
|
3124
|
+
description: 'Please make sure both passwords are identical.',
|
|
3125
|
+
type: 'error'
|
|
3126
|
+
});
|
|
3127
|
+
return;
|
|
3128
|
+
}
|
|
3129
|
+
|
|
3130
|
+
setLoading(true);
|
|
3131
|
+
|
|
3132
|
+
try {
|
|
3133
|
+
await resetPassword(newPassword);
|
|
3134
|
+
|
|
3135
|
+
toaster.create({
|
|
3136
|
+
title: 'Password reset successful',
|
|
3137
|
+
description: 'You can now log in with your new password.',
|
|
3138
|
+
type: 'success'
|
|
3139
|
+
});
|
|
3140
|
+
|
|
3141
|
+
// Redirect to login
|
|
3142
|
+
} catch (error) {
|
|
3143
|
+
toaster.create({
|
|
3144
|
+
title: 'Reset failed',
|
|
3145
|
+
description: 'Your reset link may have expired.',
|
|
3146
|
+
type: 'error'
|
|
3147
|
+
});
|
|
3148
|
+
} finally {
|
|
3149
|
+
setLoading(false);
|
|
3150
|
+
}
|
|
3151
|
+
};
|
|
3152
|
+
|
|
3153
|
+
return (
|
|
3154
|
+
<div className={css({
|
|
3155
|
+
maxWidth: '400px',
|
|
3156
|
+
mx: 'auto',
|
|
3157
|
+
p: 'xl',
|
|
3158
|
+
mt: 'xxxl'
|
|
3159
|
+
})}>
|
|
3160
|
+
{step === 'request' && (
|
|
3161
|
+
<>
|
|
3162
|
+
<div className={css({ textAlign: 'center', mb: 'xl' })}>
|
|
3163
|
+
<h1 className={css({ textStyle: 'headlineLarge', color: 'onSurface', mb: 'xs' })}>
|
|
3164
|
+
Reset Password
|
|
3165
|
+
</h1>
|
|
3166
|
+
<p className={css({ textStyle: 'bodyMedium', color: 'onSurfaceVariant' })}>
|
|
3167
|
+
Enter your email to receive reset instructions
|
|
3168
|
+
</p>
|
|
3169
|
+
</div>
|
|
3170
|
+
|
|
3171
|
+
<form onSubmit={handleRequestReset} className={css({
|
|
3172
|
+
display: 'flex',
|
|
3173
|
+
flexDirection: 'column',
|
|
3174
|
+
gap: 'lg'
|
|
3175
|
+
})}>
|
|
3176
|
+
<Input
|
|
3177
|
+
label="Email"
|
|
3178
|
+
type="email"
|
|
3179
|
+
value={email}
|
|
3180
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
3181
|
+
placeholder="you@example.com"
|
|
3182
|
+
required
|
|
3183
|
+
/>
|
|
3184
|
+
|
|
3185
|
+
<Button type="submit" variant="filled" disabled={loading}>
|
|
3186
|
+
{loading ? 'Sending...' : 'Send Reset Link'}
|
|
3187
|
+
</Button>
|
|
3188
|
+
|
|
3189
|
+
<Button variant="text" onClick={() => window.location.href = '/login'}>
|
|
3190
|
+
Back to Login
|
|
3191
|
+
</Button>
|
|
3192
|
+
</form>
|
|
3193
|
+
</>
|
|
3194
|
+
)}
|
|
3195
|
+
|
|
3196
|
+
{step === 'sent' && (
|
|
3197
|
+
<div className={css({ textAlign: 'center' })}>
|
|
3198
|
+
<div className={css({
|
|
3199
|
+
width: '64px',
|
|
3200
|
+
height: '64px',
|
|
3201
|
+
bg: 'primaryContainer',
|
|
3202
|
+
borderRadius: 'full',
|
|
3203
|
+
display: 'flex',
|
|
3204
|
+
alignItems: 'center',
|
|
3205
|
+
justifyContent: 'center',
|
|
3206
|
+
mx: 'auto',
|
|
3207
|
+
mb: 'lg',
|
|
3208
|
+
textStyle: 'headlineLarge',
|
|
3209
|
+
color: 'onPrimaryContainer'
|
|
3210
|
+
})}>
|
|
3211
|
+
✓
|
|
3212
|
+
</div>
|
|
3213
|
+
|
|
3214
|
+
<h1 className={css({ textStyle: 'headlineMedium', color: 'onSurface', mb: 'md' })}>
|
|
3215
|
+
Check Your Email
|
|
3216
|
+
</h1>
|
|
3217
|
+
|
|
3218
|
+
<p className={css({ textStyle: 'bodyMedium', color: 'onSurfaceVariant', mb: 'lg' })}>
|
|
3219
|
+
We've sent password reset instructions to <strong>{email}</strong>
|
|
3220
|
+
</p>
|
|
3221
|
+
|
|
3222
|
+
<Button variant="outlined" onClick={() => setStep('request')}>
|
|
3223
|
+
Didn't receive email?
|
|
3224
|
+
</Button>
|
|
3225
|
+
</div>
|
|
3226
|
+
)}
|
|
3227
|
+
|
|
3228
|
+
{step === 'reset' && (
|
|
3229
|
+
<>
|
|
3230
|
+
<div className={css({ textAlign: 'center', mb: 'xl' })}>
|
|
3231
|
+
<h1 className={css({ textStyle: 'headlineLarge', color: 'onSurface', mb: 'xs' })}>
|
|
3232
|
+
Create New Password
|
|
3233
|
+
</h1>
|
|
3234
|
+
<p className={css({ textStyle: 'bodyMedium', color: 'onSurfaceVariant' })}>
|
|
3235
|
+
Enter your new password below
|
|
3236
|
+
</p>
|
|
3237
|
+
</div>
|
|
3238
|
+
|
|
3239
|
+
<form onSubmit={handleResetPassword} className={css({
|
|
3240
|
+
display: 'flex',
|
|
3241
|
+
flexDirection: 'column',
|
|
3242
|
+
gap: 'lg'
|
|
3243
|
+
})}>
|
|
3244
|
+
<Input
|
|
3245
|
+
label="New Password"
|
|
3246
|
+
type="password"
|
|
3247
|
+
value={newPassword}
|
|
3248
|
+
onChange={(e) => setNewPassword(e.target.value)}
|
|
3249
|
+
helperText="Must be at least 8 characters"
|
|
3250
|
+
required
|
|
3251
|
+
/>
|
|
3252
|
+
|
|
3253
|
+
<Input
|
|
3254
|
+
label="Confirm Password"
|
|
3255
|
+
type="password"
|
|
3256
|
+
value={confirmPassword}
|
|
3257
|
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
3258
|
+
required
|
|
3259
|
+
/>
|
|
3260
|
+
|
|
3261
|
+
<Button type="submit" variant="filled" disabled={loading}>
|
|
3262
|
+
{loading ? 'Resetting...' : 'Reset Password'}
|
|
3263
|
+
</Button>
|
|
3264
|
+
</form>
|
|
3265
|
+
</>
|
|
3266
|
+
)}
|
|
3267
|
+
</div>
|
|
3268
|
+
);
|
|
3269
|
+
}
|
|
3270
|
+
```
|
|
3271
|
+
|
|
3272
|
+
**Best practices:**
|
|
3273
|
+
|
|
3274
|
+
- Use multi-step flow
|
|
3275
|
+
- Send reset link to email
|
|
3276
|
+
- Show confirmation after email sent
|
|
3277
|
+
- Validate new password strength
|
|
3278
|
+
- Expire reset links after time
|
|
3279
|
+
- Confirm password match
|
|
3280
|
+
|
|
3281
|
+
**Accessibility:**
|
|
3282
|
+
|
|
3283
|
+
- Each step is clearly labeled
|
|
3284
|
+
- Status changes are announced
|
|
3285
|
+
- All inputs are accessible
|
|
3286
|
+
- Navigation is logical
|
|
3287
|
+
|
|
3288
|
+
---
|
|
3289
|
+
|
|
3290
|
+
## Settings Patterns
|
|
3291
|
+
|
|
3292
|
+
### Settings Panel
|
|
3293
|
+
|
|
3294
|
+
**When to use:** User preferences and configuration options.
|
|
3295
|
+
|
|
3296
|
+
**Components used:** Card, Switch, Select, Button
|
|
3297
|
+
|
|
3298
|
+
**Example:**
|
|
3299
|
+
|
|
3300
|
+
```typescript
|
|
3301
|
+
import { Card, Button, toaster } from '@discourser/design-system';
|
|
3302
|
+
import * as Switch from '@discourser/design-system';
|
|
3303
|
+
import * as Select from '@discourser/design-system';
|
|
3304
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
3305
|
+
import { useState } from 'react';
|
|
3306
|
+
import { createListCollection } from '@ark-ui/react';
|
|
3307
|
+
|
|
3308
|
+
function SettingsPanel() {
|
|
3309
|
+
const [settings, setSettings] = useState({
|
|
3310
|
+
notifications: true,
|
|
3311
|
+
darkMode: false,
|
|
3312
|
+
language: 'en',
|
|
3313
|
+
autoSave: true
|
|
3314
|
+
});
|
|
3315
|
+
|
|
3316
|
+
const languages = createListCollection({
|
|
3317
|
+
items: [
|
|
3318
|
+
{ label: 'English', value: 'en' },
|
|
3319
|
+
{ label: 'Spanish', value: 'es' },
|
|
3320
|
+
{ label: 'French', value: 'fr' }
|
|
3321
|
+
]
|
|
3322
|
+
});
|
|
3323
|
+
|
|
3324
|
+
const handleSave = () => {
|
|
3325
|
+
toaster.create({
|
|
3326
|
+
title: 'Settings saved',
|
|
3327
|
+
description: 'Your preferences have been updated.',
|
|
3328
|
+
type: 'success'
|
|
3329
|
+
});
|
|
3330
|
+
};
|
|
3331
|
+
|
|
3332
|
+
return (
|
|
3333
|
+
<div className={css({ maxWidth: '700px', mx: 'auto', p: 'xl' })}>
|
|
3334
|
+
<h1 className={css({ textStyle: 'headlineMedium', mb: 'xl' })}>
|
|
3335
|
+
Settings
|
|
3336
|
+
</h1>
|
|
3337
|
+
|
|
3338
|
+
<div className={css({ display: 'flex', flexDirection: 'column', gap: 'lg' })}>
|
|
3339
|
+
{/* Notifications section */}
|
|
3340
|
+
<Card variant="elevated" className={css({ p: 'lg' })}>
|
|
3341
|
+
<h2 className={css({ textStyle: 'titleLarge', mb: 'md' })}>
|
|
3342
|
+
Notifications
|
|
3343
|
+
</h2>
|
|
3344
|
+
|
|
3345
|
+
<div className={css({ display: 'flex', flexDirection: 'column', gap: 'md' })}>
|
|
3346
|
+
<div className={css({
|
|
3347
|
+
display: 'flex',
|
|
3348
|
+
justifyContent: 'space-between',
|
|
3349
|
+
alignItems: 'center'
|
|
3350
|
+
})}>
|
|
3351
|
+
<div>
|
|
3352
|
+
<div className={css({ textStyle: 'titleMedium', mb: 'xs' })}>
|
|
3353
|
+
Push Notifications
|
|
3354
|
+
</div>
|
|
3355
|
+
<div className={css({ textStyle: 'bodySmall', color: 'onSurfaceVariant' })}>
|
|
3356
|
+
Receive notifications about activity
|
|
3357
|
+
</div>
|
|
3358
|
+
</div>
|
|
3359
|
+
<Switch.Root
|
|
3360
|
+
checked={settings.notifications}
|
|
3361
|
+
onCheckedChange={(details) => setSettings({ ...settings, notifications: details.checked })}
|
|
3362
|
+
>
|
|
3363
|
+
<Switch.Control>
|
|
3364
|
+
<Switch.Thumb />
|
|
3365
|
+
</Switch.Control>
|
|
3366
|
+
</Switch.Root>
|
|
3367
|
+
</div>
|
|
3368
|
+
</div>
|
|
3369
|
+
</Card>
|
|
3370
|
+
|
|
3371
|
+
{/* Appearance section */}
|
|
3372
|
+
<Card variant="elevated" className={css({ p: 'lg' })}>
|
|
3373
|
+
<h2 className={css({ textStyle: 'titleLarge', mb: 'md' })}>
|
|
3374
|
+
Appearance
|
|
3375
|
+
</h2>
|
|
3376
|
+
|
|
3377
|
+
<div className={css({ display: 'flex', flexDirection: 'column', gap: 'md' })}>
|
|
3378
|
+
<div className={css({
|
|
3379
|
+
display: 'flex',
|
|
3380
|
+
justifyContent: 'space-between',
|
|
3381
|
+
alignItems: 'center'
|
|
3382
|
+
})}>
|
|
3383
|
+
<div>
|
|
3384
|
+
<div className={css({ textStyle: 'titleMedium', mb: 'xs' })}>
|
|
3385
|
+
Dark Mode
|
|
3386
|
+
</div>
|
|
3387
|
+
<div className={css({ textStyle: 'bodySmall', color: 'onSurfaceVariant' })}>
|
|
3388
|
+
Use dark theme throughout the app
|
|
3389
|
+
</div>
|
|
3390
|
+
</div>
|
|
3391
|
+
<Switch.Root
|
|
3392
|
+
checked={settings.darkMode}
|
|
3393
|
+
onCheckedChange={(details) => setSettings({ ...settings, darkMode: details.checked })}
|
|
3394
|
+
>
|
|
3395
|
+
<Switch.Control>
|
|
3396
|
+
<Switch.Thumb />
|
|
3397
|
+
</Switch.Control>
|
|
3398
|
+
</Switch.Root>
|
|
3399
|
+
</div>
|
|
3400
|
+
|
|
3401
|
+
<Select.Root
|
|
3402
|
+
collection={languages}
|
|
3403
|
+
value={[settings.language]}
|
|
3404
|
+
onValueChange={(details) => setSettings({ ...settings, language: details.value[0] })}
|
|
3405
|
+
>
|
|
3406
|
+
<Select.Label>Language</Select.Label>
|
|
3407
|
+
<Select.Control>
|
|
3408
|
+
<Select.Trigger>
|
|
3409
|
+
<Select.ValueText placeholder="Select language" />
|
|
3410
|
+
</Select.Trigger>
|
|
3411
|
+
</Select.Control>
|
|
3412
|
+
<Select.Positioner>
|
|
3413
|
+
<Select.Content>
|
|
3414
|
+
{languages.items.map((item) => (
|
|
3415
|
+
<Select.Item key={item.value} item={item}>
|
|
3416
|
+
<Select.ItemText>{item.label}</Select.ItemText>
|
|
3417
|
+
</Select.Item>
|
|
3418
|
+
))}
|
|
3419
|
+
</Select.Content>
|
|
3420
|
+
</Select.Positioner>
|
|
3421
|
+
</Select.Root>
|
|
3422
|
+
</div>
|
|
3423
|
+
</Card>
|
|
3424
|
+
|
|
3425
|
+
{/* Editor section */}
|
|
3426
|
+
<Card variant="elevated" className={css({ p: 'lg' })}>
|
|
3427
|
+
<h2 className={css({ textStyle: 'titleLarge', mb: 'md' })}>
|
|
3428
|
+
Editor
|
|
3429
|
+
</h2>
|
|
3430
|
+
|
|
3431
|
+
<div className={css({
|
|
3432
|
+
display: 'flex',
|
|
3433
|
+
justifyContent: 'space-between',
|
|
3434
|
+
alignItems: 'center'
|
|
3435
|
+
})}>
|
|
3436
|
+
<div>
|
|
3437
|
+
<div className={css({ textStyle: 'titleMedium', mb: 'xs' })}>
|
|
3438
|
+
Auto-save
|
|
3439
|
+
</div>
|
|
3440
|
+
<div className={css({ textStyle: 'bodySmall', color: 'onSurfaceVariant' })}>
|
|
3441
|
+
Automatically save your work
|
|
3442
|
+
</div>
|
|
3443
|
+
</div>
|
|
3444
|
+
<Switch.Root
|
|
3445
|
+
checked={settings.autoSave}
|
|
3446
|
+
onCheckedChange={(details) => setSettings({ ...settings, autoSave: details.checked })}
|
|
3447
|
+
>
|
|
3448
|
+
<Switch.Control>
|
|
3449
|
+
<Switch.Thumb />
|
|
3450
|
+
</Switch.Control>
|
|
3451
|
+
</Switch.Root>
|
|
3452
|
+
</div>
|
|
3453
|
+
</Card>
|
|
3454
|
+
|
|
3455
|
+
{/* Save button */}
|
|
3456
|
+
<Button variant="filled" onClick={handleSave}>
|
|
3457
|
+
Save Settings
|
|
3458
|
+
</Button>
|
|
3459
|
+
</div>
|
|
3460
|
+
</div>
|
|
3461
|
+
);
|
|
3462
|
+
}
|
|
3463
|
+
```
|
|
3464
|
+
|
|
3465
|
+
**Best practices:**
|
|
3466
|
+
|
|
3467
|
+
- Group related settings
|
|
3468
|
+
- Use cards for visual separation
|
|
3469
|
+
- Provide descriptions for clarity
|
|
3470
|
+
- Show save confirmation
|
|
3471
|
+
- Consider auto-save for better UX
|
|
3472
|
+
|
|
3473
|
+
**Accessibility:**
|
|
3474
|
+
|
|
3475
|
+
- Settings are clearly labeled
|
|
3476
|
+
- Toggle states are announced
|
|
3477
|
+
- Keyboard navigation works
|
|
3478
|
+
- Changes can be undone
|
|
3479
|
+
|
|
3480
|
+
---
|
|
3481
|
+
|
|
3482
|
+
### Profile Settings
|
|
3483
|
+
|
|
3484
|
+
**When to use:** User profile information editing.
|
|
3485
|
+
|
|
3486
|
+
**Components used:** Avatar, Input, Textarea, Button
|
|
3487
|
+
|
|
3488
|
+
**Example:**
|
|
3489
|
+
|
|
3490
|
+
```typescript
|
|
3491
|
+
import { Input, Textarea, Button, toaster } from '@discourser/design-system';
|
|
3492
|
+
import * as Avatar from '@discourser/design-system';
|
|
3493
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
3494
|
+
import { useState } from 'react';
|
|
3495
|
+
|
|
3496
|
+
function ProfileSettings() {
|
|
3497
|
+
const [profile, setProfile] = useState({
|
|
3498
|
+
name: 'Jane Doe',
|
|
3499
|
+
email: 'jane@example.com',
|
|
3500
|
+
bio: 'Product designer passionate about user experience',
|
|
3501
|
+
avatar: '/avatar.jpg'
|
|
3502
|
+
});
|
|
3503
|
+
const [loading, setLoading] = useState(false);
|
|
3504
|
+
|
|
3505
|
+
const handleSave = async () => {
|
|
3506
|
+
setLoading(true);
|
|
3507
|
+
|
|
3508
|
+
try {
|
|
3509
|
+
await updateProfile(profile);
|
|
3510
|
+
|
|
3511
|
+
toaster.create({
|
|
3512
|
+
title: 'Profile updated',
|
|
3513
|
+
description: 'Your changes have been saved.',
|
|
3514
|
+
type: 'success'
|
|
3515
|
+
});
|
|
3516
|
+
} catch (error) {
|
|
3517
|
+
toaster.create({
|
|
3518
|
+
title: 'Update failed',
|
|
3519
|
+
description: 'Please try again.',
|
|
3520
|
+
type: 'error'
|
|
3521
|
+
});
|
|
3522
|
+
} finally {
|
|
3523
|
+
setLoading(false);
|
|
3524
|
+
}
|
|
3525
|
+
};
|
|
3526
|
+
|
|
3527
|
+
return (
|
|
3528
|
+
<div className={css({ maxWidth: '600px', mx: 'auto', p: 'xl' })}>
|
|
3529
|
+
<h1 className={css({ textStyle: 'headlineMedium', mb: 'xl' })}>
|
|
3530
|
+
Profile Settings
|
|
3531
|
+
</h1>
|
|
3532
|
+
|
|
3533
|
+
<div className={css({
|
|
3534
|
+
display: 'flex',
|
|
3535
|
+
flexDirection: 'column',
|
|
3536
|
+
gap: 'xl'
|
|
3537
|
+
})}>
|
|
3538
|
+
{/* Avatar section */}
|
|
3539
|
+
<div className={css({
|
|
3540
|
+
display: 'flex',
|
|
3541
|
+
alignItems: 'center',
|
|
3542
|
+
gap: 'lg'
|
|
3543
|
+
})}>
|
|
3544
|
+
<Avatar.Root size="2xl">
|
|
3545
|
+
<Avatar.Image src={profile.avatar} alt={profile.name} />
|
|
3546
|
+
<Avatar.Fallback>
|
|
3547
|
+
{profile.name.split(' ').map(n => n[0]).join('')}
|
|
3548
|
+
</Avatar.Fallback>
|
|
3549
|
+
</Avatar.Root>
|
|
3550
|
+
|
|
3551
|
+
<div>
|
|
3552
|
+
<Button variant="outlined" size="sm">
|
|
3553
|
+
Change Photo
|
|
3554
|
+
</Button>
|
|
3555
|
+
<p className={css({
|
|
3556
|
+
textStyle: 'bodySmall',
|
|
3557
|
+
color: 'onSurfaceVariant',
|
|
3558
|
+
mt: 'xs'
|
|
3559
|
+
})}>
|
|
3560
|
+
JPG, PNG or GIF (max 2MB)
|
|
3561
|
+
</p>
|
|
3562
|
+
</div>
|
|
3563
|
+
</div>
|
|
3564
|
+
|
|
3565
|
+
{/* Form fields */}
|
|
3566
|
+
<div className={css({
|
|
3567
|
+
display: 'flex',
|
|
3568
|
+
flexDirection: 'column',
|
|
3569
|
+
gap: 'lg'
|
|
3570
|
+
})}>
|
|
3571
|
+
<Input
|
|
3572
|
+
label="Full Name"
|
|
3573
|
+
value={profile.name}
|
|
3574
|
+
onChange={(e) => setProfile({ ...profile, name: e.target.value })}
|
|
3575
|
+
required
|
|
3576
|
+
/>
|
|
3577
|
+
|
|
3578
|
+
<Input
|
|
3579
|
+
label="Email"
|
|
3580
|
+
type="email"
|
|
3581
|
+
value={profile.email}
|
|
3582
|
+
onChange={(e) => setProfile({ ...profile, email: e.target.value })}
|
|
3583
|
+
helperText="We'll send updates to this email"
|
|
3584
|
+
required
|
|
3585
|
+
/>
|
|
3586
|
+
|
|
3587
|
+
<Textarea
|
|
3588
|
+
label="Bio"
|
|
3589
|
+
value={profile.bio}
|
|
3590
|
+
onChange={(e) => setProfile({ ...profile, bio: e.target.value })}
|
|
3591
|
+
rows={4}
|
|
3592
|
+
helperText="Tell us about yourself in a few words"
|
|
3593
|
+
/>
|
|
3594
|
+
</div>
|
|
3595
|
+
|
|
3596
|
+
{/* Actions */}
|
|
3597
|
+
<div className={css({
|
|
3598
|
+
display: 'flex',
|
|
3599
|
+
gap: 'sm',
|
|
3600
|
+
justifyContent: 'flex-end',
|
|
3601
|
+
pt: 'md',
|
|
3602
|
+
borderTopWidth: '1px',
|
|
3603
|
+
borderTopColor: 'outlineVariant'
|
|
3604
|
+
})}>
|
|
3605
|
+
<Button variant="outlined" disabled={loading}>
|
|
3606
|
+
Cancel
|
|
3607
|
+
</Button>
|
|
3608
|
+
<Button variant="filled" onClick={handleSave} disabled={loading}>
|
|
3609
|
+
{loading ? 'Saving...' : 'Save Changes'}
|
|
3610
|
+
</Button>
|
|
3611
|
+
</div>
|
|
3612
|
+
</div>
|
|
3613
|
+
</div>
|
|
3614
|
+
);
|
|
3615
|
+
}
|
|
3616
|
+
```
|
|
3617
|
+
|
|
3618
|
+
**Best practices:**
|
|
3619
|
+
|
|
3620
|
+
- Show current profile info
|
|
3621
|
+
- Allow avatar upload
|
|
3622
|
+
- Validate email format
|
|
3623
|
+
- Provide cancel option
|
|
3624
|
+
- Show save confirmation
|
|
3625
|
+
|
|
3626
|
+
**Accessibility:**
|
|
3627
|
+
|
|
3628
|
+
- All inputs labeled
|
|
3629
|
+
- Avatar upload is accessible
|
|
3630
|
+
- Form submits properly
|
|
3631
|
+
- Changes are confirmed
|
|
3632
|
+
|
|
3633
|
+
---
|
|
3634
|
+
|
|
3635
|
+
## Empty States
|
|
3636
|
+
|
|
3637
|
+
### No Data
|
|
3638
|
+
|
|
3639
|
+
**When to use:** When a section has no content yet but users can add items.
|
|
3640
|
+
|
|
3641
|
+
**Components used:** Button
|
|
3642
|
+
|
|
3643
|
+
**Example:**
|
|
3644
|
+
|
|
3645
|
+
```typescript
|
|
3646
|
+
import { Button } from '@discourser/design-system';
|
|
3647
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
3648
|
+
|
|
3649
|
+
function NoData() {
|
|
3650
|
+
return (
|
|
3651
|
+
<div className={css({
|
|
3652
|
+
textAlign: 'center',
|
|
3653
|
+
py: 'xxxl',
|
|
3654
|
+
px: 'xl'
|
|
3655
|
+
})}>
|
|
3656
|
+
{/* Illustration or icon */}
|
|
3657
|
+
<div className={css({
|
|
3658
|
+
width: '120px',
|
|
3659
|
+
height: '120px',
|
|
3660
|
+
bg: 'surfaceContainerHighest',
|
|
3661
|
+
borderRadius: 'full',
|
|
3662
|
+
display: 'flex',
|
|
3663
|
+
alignItems: 'center',
|
|
3664
|
+
justifyContent: 'center',
|
|
3665
|
+
mx: 'auto',
|
|
3666
|
+
mb: 'lg',
|
|
3667
|
+
textStyle: 'displaySmall',
|
|
3668
|
+
color: 'onSurfaceVariant'
|
|
3669
|
+
})}>
|
|
3670
|
+
📂
|
|
3671
|
+
</div>
|
|
3672
|
+
|
|
3673
|
+
{/* Message */}
|
|
3674
|
+
<h2 className={css({
|
|
3675
|
+
textStyle: 'headlineSmall',
|
|
3676
|
+
color: 'onSurface',
|
|
3677
|
+
mb: 'md'
|
|
3678
|
+
})}>
|
|
3679
|
+
No projects yet
|
|
3680
|
+
</h2>
|
|
3681
|
+
|
|
3682
|
+
<p className={css({
|
|
3683
|
+
textStyle: 'bodyMedium',
|
|
3684
|
+
color: 'onSurfaceVariant',
|
|
3685
|
+
mb: 'lg',
|
|
3686
|
+
maxWidth: '400px',
|
|
3687
|
+
mx: 'auto'
|
|
3688
|
+
})}>
|
|
3689
|
+
Get started by creating your first project. Projects help you organize your work and collaborate with your team.
|
|
3690
|
+
</p>
|
|
3691
|
+
|
|
3692
|
+
{/* Action */}
|
|
3693
|
+
<Button variant="filled">
|
|
3694
|
+
Create Project
|
|
3695
|
+
</Button>
|
|
3696
|
+
</div>
|
|
3697
|
+
);
|
|
3698
|
+
}
|
|
3699
|
+
```
|
|
3700
|
+
|
|
3701
|
+
**Best practices:**
|
|
3702
|
+
|
|
3703
|
+
- Use friendly illustration or icon
|
|
3704
|
+
- Explain why it's empty
|
|
3705
|
+
- Provide clear call-to-action
|
|
3706
|
+
- Keep message concise
|
|
3707
|
+
- Center content vertically
|
|
3708
|
+
|
|
3709
|
+
**Accessibility:**
|
|
3710
|
+
|
|
3711
|
+
- Message is clear and helpful
|
|
3712
|
+
- Action button is prominent
|
|
3713
|
+
- Works with screen readers
|
|
3714
|
+
|
|
3715
|
+
---
|
|
3716
|
+
|
|
3717
|
+
### No Search Results
|
|
3718
|
+
|
|
3719
|
+
**When to use:** Search returned no matches.
|
|
3720
|
+
|
|
3721
|
+
**Components used:** Button
|
|
3722
|
+
|
|
3723
|
+
**Example:**
|
|
3724
|
+
|
|
3725
|
+
```typescript
|
|
3726
|
+
import { Button } from '@discourser/design-system';
|
|
3727
|
+
import { css } from '@discourser/design-system/styled-system/css';
|
|
3728
|
+
|
|
3729
|
+
function NoSearchResults({ query, onClear }: { query: string; onClear: () => void }) {
|
|
3730
|
+
return (
|
|
3731
|
+
<div className={css({
|
|
3732
|
+
textAlign: 'center',
|
|
3733
|
+
py: 'xxxl',
|
|
3734
|
+
px: 'xl'
|
|
3735
|
+
})}>
|
|
3736
|
+
{/* Icon */}
|
|
3737
|
+
<div className={css({
|
|
3738
|
+
width: '80px',
|
|
3739
|
+
height: '80px',
|
|
3740
|
+
bg: 'surfaceContainerHighest',
|
|
3741
|
+
borderRadius: 'full',
|
|
3742
|
+
display: 'flex',
|
|
3743
|
+
alignItems: 'center',
|
|
3744
|
+
justifyContent: 'center',
|
|
3745
|
+
mx: 'auto',
|
|
3746
|
+
mb: 'lg',
|
|
3747
|
+
textStyle: 'headlineLarge',
|
|
3748
|
+
color: 'onSurfaceVariant'
|
|
3749
|
+
})}>
|
|
3750
|
+
🔍
|
|
3751
|
+
</div>
|
|
3752
|
+
|
|
3753
|
+
{/* Message */}
|
|
3754
|
+
<h2 className={css({
|
|
3755
|
+
textStyle: 'headlineSmall',
|
|
3756
|
+
color: 'onSurface',
|
|
3757
|
+
mb: 'md'
|
|
3758
|
+
})}>
|
|
3759
|
+
No results for "{query}"
|
|
3760
|
+
</h2>
|
|
3761
|
+
|
|
3762
|
+
<p className={css({
|
|
3763
|
+
textStyle: 'bodyMedium',
|
|
3764
|
+
color: 'onSurfaceVariant',
|
|
3765
|
+
mb: 'lg'
|
|
3766
|
+
})}>
|
|
3767
|
+
Try different keywords or check your spelling
|
|
3768
|
+
</p>
|
|
3769
|
+
|
|
3770
|
+
{/* Suggestions */}
|
|
3771
|
+
<div className={css({ mb: 'lg' })}>
|
|
3772
|
+
<p className={css({
|
|
3773
|
+
textStyle: 'labelMedium',
|
|
3774
|
+
color: 'onSurfaceVariant',
|
|
3775
|
+
mb: 'sm'
|
|
3776
|
+
})}>
|
|
3777
|
+
Suggestions:
|
|
3778
|
+
</p>
|
|
3779
|
+
<ul className={css({
|
|
3780
|
+
textStyle: 'bodySmall',
|
|
3781
|
+
color: 'onSurfaceVariant',
|
|
3782
|
+
listStyle: 'none',
|
|
3783
|
+
p: 0
|
|
3784
|
+
})}>
|
|
3785
|
+
<li>Check spelling and try again</li>
|
|
3786
|
+
<li>Try more general keywords</li>
|
|
3787
|
+
<li>Try different keywords</li>
|
|
3788
|
+
</ul>
|
|
3789
|
+
</div>
|
|
3790
|
+
|
|
3791
|
+
{/* Action */}
|
|
3792
|
+
<Button variant="outlined" onClick={onClear}>
|
|
3793
|
+
Clear Search
|
|
3794
|
+
</Button>
|
|
3795
|
+
</div>
|
|
3796
|
+
);
|
|
3797
|
+
}
|
|
3798
|
+
```
|
|
3799
|
+
|
|
3800
|
+
**Best practices:**
|
|
3801
|
+
|
|
3802
|
+
- Show the search query
|
|
3803
|
+
- Provide helpful suggestions
|
|
3804
|
+
- Allow clearing search
|
|
3805
|
+
- Keep tone friendly
|
|
3806
|
+
- Consider showing related results
|
|
3807
|
+
|
|
3808
|
+
**Accessibility:**
|
|
3809
|
+
|
|
3810
|
+
- Clear messaging
|
|
3811
|
+
- Suggestions are readable
|
|
3812
|
+
- Action is accessible
|
|
3813
|
+
|
|
3814
|
+
---
|
|
3815
|
+
|
|
3816
|
+
## Summary
|
|
3817
|
+
|
|
3818
|
+
This guide covers 25+ common UI patterns using the Discourser Design System. Each pattern demonstrates:
|
|
3819
|
+
|
|
3820
|
+
- **When to use**: Clear use cases and scenarios
|
|
3821
|
+
- **Components used**: Which design system components to combine
|
|
3822
|
+
- **Complete code examples**: Production-ready TypeScript/JSX
|
|
3823
|
+
- **Best practices**: Implementation guidelines
|
|
3824
|
+
- **Accessibility**: Inclusive design considerations
|
|
3825
|
+
|
|
3826
|
+
### Pattern Categories
|
|
3827
|
+
|
|
3828
|
+
1. **Forms (5 patterns)**: Vertical, horizontal, multi-step, validation, dependencies
|
|
3829
|
+
2. **Navigation (4 patterns)**: Sidebar, top nav, tabs, breadcrumbs
|
|
3830
|
+
3. **Feedback (4 patterns)**: Success, errors, confirmations, notifications
|
|
3831
|
+
4. **Loading (4 patterns)**: Page load, partial load, button loading, infinite scroll
|
|
3832
|
+
5. **Data Display (4 patterns)**: Card grids, lists with actions, avatars, accordions
|
|
3833
|
+
6. **Search & Filter (3 patterns)**: Simple search, filtered search, search results
|
|
3834
|
+
7. **Authentication (3 patterns)**: Login, signup, password reset
|
|
3835
|
+
8. **Settings (2 patterns)**: Settings panel, profile settings
|
|
3836
|
+
9. **Empty States (2 patterns)**: No data, no results
|
|
3837
|
+
|
|
3838
|
+
### Using These Patterns
|
|
3839
|
+
|
|
3840
|
+
1. **Identify your use case**: Find the pattern that matches your needs
|
|
3841
|
+
2. **Review the example**: Understand the component structure
|
|
3842
|
+
3. **Customize as needed**: Adapt the pattern to your specific requirements
|
|
3843
|
+
4. **Follow best practices**: Apply the recommended guidelines
|
|
3844
|
+
5. **Test accessibility**: Ensure your implementation is inclusive
|
|
3845
|
+
|
|
3846
|
+
### Additional Resources
|
|
3847
|
+
|
|
3848
|
+
- **Component Guidelines**: See `guidelines/components/` for detailed component documentation
|
|
3849
|
+
- **Design Tokens**: See `guidelines/design-tokens/` for colors, spacing, typography
|
|
3850
|
+
- **Component Overview**: See `overview-components.md` for available components
|
|
3851
|
+
|
|
3852
|
+
For questions or contributions, visit the [Discourser Design System repository](https://github.com/Tasty-Maker-Studio/Discourser-Design-System).
|