@discourser/design-system 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/guidelines/Guidelines.md +88 -0
- package/guidelines/components/button.md +314 -0
- package/guidelines/components/card.md +353 -0
- package/guidelines/components/dialog.md +465 -0
- package/guidelines/components/icon-button.md +417 -0
- package/guidelines/components/input.md +499 -0
- package/guidelines/components/switch.md +457 -0
- package/guidelines/design-tokens/colors.md +187 -0
- package/guidelines/design-tokens/elevation.md +274 -0
- package/guidelines/design-tokens/spacing.md +289 -0
- package/guidelines/design-tokens/typography.md +226 -0
- package/guidelines/overview-components.md +156 -0
- package/package.json +3 -2
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
# IconButton
|
|
2
|
+
|
|
3
|
+
**Purpose:** Icon-only interactive button for compact actions following Material Design 3 patterns.
|
|
4
|
+
|
|
5
|
+
## Import
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { IconButton } from '@discourser/design-system';
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Variants
|
|
12
|
+
|
|
13
|
+
The IconButton component supports 4 Material Design 3 variants:
|
|
14
|
+
|
|
15
|
+
| Variant | Visual Style | Usage | When to Use |
|
|
16
|
+
|---------|-------------|-------|-------------|
|
|
17
|
+
| `standard` | Transparent background | Default icon actions | Most common, minimal emphasis |
|
|
18
|
+
| `filled` | Primary color background | High emphasis icon actions | Important actions that need prominence |
|
|
19
|
+
| `tonal` | Secondary container background | Medium emphasis actions | Supportive actions with some emphasis |
|
|
20
|
+
| `outlined` | Outlined border | Secondary icon actions | Alternative to standard with more definition |
|
|
21
|
+
|
|
22
|
+
### Visual Characteristics
|
|
23
|
+
|
|
24
|
+
- **standard**: Transparent, `onSurfaceVariant` color, subtle hover background
|
|
25
|
+
- **filled**: `primary` background, `onPrimary` icon color
|
|
26
|
+
- **tonal**: `secondaryContainer` background, `onSecondaryContainer` icon color
|
|
27
|
+
- **outlined**: Transparent with 1px `outline` border
|
|
28
|
+
|
|
29
|
+
## Sizes
|
|
30
|
+
|
|
31
|
+
| Size | Dimensions | Icon Size | Usage |
|
|
32
|
+
|------|-----------|-----------|-------|
|
|
33
|
+
| `sm` | 32×32px | 18×18px | Compact UI, dense layouts, inline actions |
|
|
34
|
+
| `md` | 40×40px | 24×24px | Default, most use cases |
|
|
35
|
+
| `lg` | 48×48px | 24×24px | Touch targets, mobile emphasis, FAB |
|
|
36
|
+
|
|
37
|
+
**Important:** Icon sizes are automatically set by the component via CSS. Ensure your SVG icons inherit currentColor and size.
|
|
38
|
+
|
|
39
|
+
## Props
|
|
40
|
+
|
|
41
|
+
| Prop | Type | Default | Description |
|
|
42
|
+
|------|------|---------|-------------|
|
|
43
|
+
| `children` | `ReactNode` | Required | Icon element (SVG, icon component) |
|
|
44
|
+
| `aria-label` | `string` | **Required** | Accessible label for screen readers |
|
|
45
|
+
| `variant` | `'standard' \| 'filled' \| 'tonal' \| 'outlined'` | `'standard'` | Visual style variant |
|
|
46
|
+
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Button size |
|
|
47
|
+
| `disabled` | `boolean` | `false` | Disable button interaction |
|
|
48
|
+
| `onClick` | `(event: MouseEvent) => void` | - | Click handler |
|
|
49
|
+
| `type` | `'button' \| 'submit' \| 'reset'` | `'button'` | HTML button type |
|
|
50
|
+
| `className` | `string` | - | Additional CSS classes (use sparingly) |
|
|
51
|
+
|
|
52
|
+
**Critical:** `aria-label` is **required** for accessibility. Icon buttons have no visible text, so screen readers need this label.
|
|
53
|
+
|
|
54
|
+
## Examples
|
|
55
|
+
|
|
56
|
+
### Basic Usage
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
import { XIcon, PencilIcon, TrashIcon, HeartIcon } from 'your-icon-library';
|
|
60
|
+
|
|
61
|
+
// Standard icon button (default)
|
|
62
|
+
<IconButton aria-label="Close">
|
|
63
|
+
<XIcon />
|
|
64
|
+
</IconButton>
|
|
65
|
+
|
|
66
|
+
// Filled (high emphasis)
|
|
67
|
+
<IconButton variant="filled" aria-label="Edit">
|
|
68
|
+
<PencilIcon />
|
|
69
|
+
</IconButton>
|
|
70
|
+
|
|
71
|
+
// Tonal (medium emphasis)
|
|
72
|
+
<IconButton variant="tonal" aria-label="Delete">
|
|
73
|
+
<TrashIcon />
|
|
74
|
+
</IconButton>
|
|
75
|
+
|
|
76
|
+
// Outlined
|
|
77
|
+
<IconButton variant="outlined" aria-label="Like">
|
|
78
|
+
<HeartIcon />
|
|
79
|
+
</IconButton>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Different Sizes
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
// Small (compact)
|
|
86
|
+
<IconButton size="sm" aria-label="Close">
|
|
87
|
+
<XIcon />
|
|
88
|
+
</IconButton>
|
|
89
|
+
|
|
90
|
+
// Medium (default)
|
|
91
|
+
<IconButton size="md" aria-label="Close">
|
|
92
|
+
<XIcon />
|
|
93
|
+
</IconButton>
|
|
94
|
+
|
|
95
|
+
// Large (mobile-friendly)
|
|
96
|
+
<IconButton size="lg" aria-label="Close">
|
|
97
|
+
<XIcon />
|
|
98
|
+
</IconButton>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Common Actions
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
// Close button (dialogs, modals)
|
|
105
|
+
<IconButton variant="standard" aria-label="Close" onClick={onClose}>
|
|
106
|
+
<XIcon />
|
|
107
|
+
</IconButton>
|
|
108
|
+
|
|
109
|
+
// Edit button
|
|
110
|
+
<IconButton variant="tonal" aria-label="Edit item" onClick={handleEdit}>
|
|
111
|
+
<PencilIcon />
|
|
112
|
+
</IconButton>
|
|
113
|
+
|
|
114
|
+
// Delete button
|
|
115
|
+
<IconButton variant="outlined" aria-label="Delete item" onClick={handleDelete}>
|
|
116
|
+
<TrashIcon />
|
|
117
|
+
</IconButton>
|
|
118
|
+
|
|
119
|
+
// Menu toggle
|
|
120
|
+
<IconButton variant="standard" aria-label="Open menu" onClick={toggleMenu}>
|
|
121
|
+
<MenuIcon />
|
|
122
|
+
</IconButton>
|
|
123
|
+
|
|
124
|
+
// Like/Favorite button (toggleable)
|
|
125
|
+
<IconButton
|
|
126
|
+
variant={isLiked ? 'filled' : 'outlined'}
|
|
127
|
+
aria-label={isLiked ? 'Unlike' : 'Like'}
|
|
128
|
+
onClick={toggleLike}
|
|
129
|
+
>
|
|
130
|
+
<HeartIcon />
|
|
131
|
+
</IconButton>
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Common Patterns
|
|
135
|
+
|
|
136
|
+
### Dialog/Modal Close Button
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
<Dialog.Content>
|
|
140
|
+
<IconButton
|
|
141
|
+
variant="standard"
|
|
142
|
+
aria-label="Close dialog"
|
|
143
|
+
onClick={onClose}
|
|
144
|
+
className={css({ position: 'absolute', top: 'md', right: 'md' })}
|
|
145
|
+
>
|
|
146
|
+
<XIcon />
|
|
147
|
+
</IconButton>
|
|
148
|
+
|
|
149
|
+
<Dialog.Title>Dialog Title</Dialog.Title>
|
|
150
|
+
<Dialog.Description>Dialog content...</Dialog.Description>
|
|
151
|
+
</Dialog.Content>
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Action Bar
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
<div className={css({ display: 'flex', gap: 'xs', alignItems: 'center' })}>
|
|
158
|
+
<IconButton variant="standard" aria-label="Edit">
|
|
159
|
+
<PencilIcon />
|
|
160
|
+
</IconButton>
|
|
161
|
+
<IconButton variant="standard" aria-label="Share">
|
|
162
|
+
<ShareIcon />
|
|
163
|
+
</IconButton>
|
|
164
|
+
<IconButton variant="standard" aria-label="Delete">
|
|
165
|
+
<TrashIcon />
|
|
166
|
+
</IconButton>
|
|
167
|
+
</div>
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Floating Action Button (FAB)
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
<IconButton
|
|
174
|
+
variant="filled"
|
|
175
|
+
size="lg"
|
|
176
|
+
aria-label="Create new item"
|
|
177
|
+
className={css({
|
|
178
|
+
position: 'fixed',
|
|
179
|
+
bottom: 'lg',
|
|
180
|
+
right: 'lg',
|
|
181
|
+
shadow: 'level3'
|
|
182
|
+
})}
|
|
183
|
+
onClick={handleCreate}
|
|
184
|
+
>
|
|
185
|
+
<PlusIcon />
|
|
186
|
+
</IconButton>
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Toggle Icon Button
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
const [isStarred, setIsStarred] = useState(false);
|
|
193
|
+
|
|
194
|
+
<IconButton
|
|
195
|
+
variant={isStarred ? 'filled' : 'standard'}
|
|
196
|
+
aria-label={isStarred ? 'Unstar' : 'Star'}
|
|
197
|
+
aria-pressed={isStarred}
|
|
198
|
+
onClick={() => setIsStarred(!isStarred)}
|
|
199
|
+
>
|
|
200
|
+
{isStarred ? <StarFilledIcon /> : <StarOutlineIcon />}
|
|
201
|
+
</IconButton>
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## DO NOT
|
|
205
|
+
|
|
206
|
+
```typescript
|
|
207
|
+
// ❌ Don't use IconButton with text (use Button instead)
|
|
208
|
+
<IconButton aria-label="Save">
|
|
209
|
+
<SaveIcon /> Save
|
|
210
|
+
</IconButton> // Use <Button leftIcon={<SaveIcon />}>Save</Button>
|
|
211
|
+
|
|
212
|
+
// ❌ Don't forget aria-label (accessibility violation)
|
|
213
|
+
<IconButton>
|
|
214
|
+
<XIcon />
|
|
215
|
+
</IconButton> // TypeScript will error - aria-label is required
|
|
216
|
+
|
|
217
|
+
// ❌ Don't use vague labels
|
|
218
|
+
<IconButton aria-label="Icon"> // Not descriptive
|
|
219
|
+
<TrashIcon />
|
|
220
|
+
</IconButton>
|
|
221
|
+
|
|
222
|
+
// ✅ Use descriptive labels
|
|
223
|
+
<IconButton aria-label="Delete item">
|
|
224
|
+
<TrashIcon />
|
|
225
|
+
</IconButton>
|
|
226
|
+
|
|
227
|
+
// ❌ Don't use icons that don't inherit size/color
|
|
228
|
+
<IconButton aria-label="Close">
|
|
229
|
+
<img src="/icon.png" /> // Won't scale properly
|
|
230
|
+
</IconButton>
|
|
231
|
+
|
|
232
|
+
// ✅ Use SVG icons that inherit currentColor
|
|
233
|
+
<IconButton aria-label="Close">
|
|
234
|
+
<XIcon /> // SVG with currentColor
|
|
235
|
+
</IconButton>
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Accessibility
|
|
239
|
+
|
|
240
|
+
IconButton has strict accessibility requirements:
|
|
241
|
+
|
|
242
|
+
- **aria-label Required**: Must describe the action (e.g., "Close dialog", "Edit item")
|
|
243
|
+
- **Keyboard Navigation**: Focusable via Tab, activates with Enter/Space
|
|
244
|
+
- **Focus Indicator**: 2px outline on focus-visible
|
|
245
|
+
- **Touch Target**: Minimum 40×40px (use `md` or `lg` size)
|
|
246
|
+
- **State Indication**: Use `aria-pressed` for toggle buttons
|
|
247
|
+
|
|
248
|
+
### Accessibility Best Practices
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
// ✅ Descriptive aria-label
|
|
252
|
+
<IconButton aria-label="Delete comment by John">
|
|
253
|
+
<TrashIcon />
|
|
254
|
+
</IconButton>
|
|
255
|
+
|
|
256
|
+
// ✅ Toggle state indication
|
|
257
|
+
<IconButton
|
|
258
|
+
aria-label={isMuted ? 'Unmute' : 'Mute'}
|
|
259
|
+
aria-pressed={isMuted}
|
|
260
|
+
onClick={toggleMute}
|
|
261
|
+
>
|
|
262
|
+
{isMuted ? <MuteIcon /> : <VolumeIcon />}
|
|
263
|
+
</IconButton>
|
|
264
|
+
|
|
265
|
+
// ✅ Disabled state
|
|
266
|
+
<IconButton aria-label="Save" disabled={!hasChanges}>
|
|
267
|
+
<SaveIcon />
|
|
268
|
+
</IconButton>
|
|
269
|
+
|
|
270
|
+
// ✅ Loading state
|
|
271
|
+
<IconButton aria-label="Save" disabled={isSaving} aria-busy={isSaving}>
|
|
272
|
+
{isSaving ? <SpinnerIcon /> : <SaveIcon />}
|
|
273
|
+
</IconButton>
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## Icon Requirements
|
|
277
|
+
|
|
278
|
+
Icons must meet these requirements:
|
|
279
|
+
|
|
280
|
+
```typescript
|
|
281
|
+
// ✅ Correct: SVG with currentColor
|
|
282
|
+
const CheckIcon = () => (
|
|
283
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
284
|
+
<path d="M5 12l5 5L20 7" strokeWidth="2" />
|
|
285
|
+
</svg>
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
<IconButton aria-label="Confirm">
|
|
289
|
+
<CheckIcon />
|
|
290
|
+
</IconButton>
|
|
291
|
+
|
|
292
|
+
// ❌ Wrong: Fixed colors
|
|
293
|
+
const WrongIcon = () => (
|
|
294
|
+
<svg viewBox="0 0 24 24">
|
|
295
|
+
<path fill="#000" d="..." /> // Fixed color won't adapt
|
|
296
|
+
</svg>
|
|
297
|
+
);
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
## Variant Selection Guide
|
|
301
|
+
|
|
302
|
+
| Scenario | Recommended Variant | Reasoning |
|
|
303
|
+
|----------|-------------------|-----------|
|
|
304
|
+
| Close button (dialogs) | `standard` | Minimal emphasis, common action |
|
|
305
|
+
| Primary action (FAB) | `filled` | High emphasis, main action |
|
|
306
|
+
| Edit/Modify action | `tonal` | Medium emphasis, supportive |
|
|
307
|
+
| Delete action | `outlined` | Secondary action, needs definition |
|
|
308
|
+
| Menu/Navigation | `standard` | Minimal emphasis, frequently used |
|
|
309
|
+
| Toggle (active state) | `filled` | Shows active state clearly |
|
|
310
|
+
| Toggle (inactive state) | `standard` or `outlined` | Shows inactive state |
|
|
311
|
+
| Action bar items | `standard` | Consistent, minimal emphasis |
|
|
312
|
+
|
|
313
|
+
## State Behaviors
|
|
314
|
+
|
|
315
|
+
| State | Visual Change | Behavior |
|
|
316
|
+
|-------|---------------|----------|
|
|
317
|
+
| **Hover** | Background color change or opacity | `standard`: 8% background<br />`filled`/`tonal`: 92% opacity |
|
|
318
|
+
| **Active** | Slight opacity change | Further visual feedback on click |
|
|
319
|
+
| **Focus** | 2px outline | Primary color outline, 2px offset |
|
|
320
|
+
| **Disabled** | 38% opacity, no interaction | Cannot be clicked, greyed out |
|
|
321
|
+
|
|
322
|
+
## Responsive Considerations
|
|
323
|
+
|
|
324
|
+
```typescript
|
|
325
|
+
// Mobile-first: Larger touch targets
|
|
326
|
+
<IconButton size="lg" aria-label="Menu">
|
|
327
|
+
<MenuIcon />
|
|
328
|
+
</IconButton>
|
|
329
|
+
|
|
330
|
+
// Responsive sizing
|
|
331
|
+
<IconButton
|
|
332
|
+
size={{ base: 'lg', md: 'md' }}
|
|
333
|
+
aria-label="Close"
|
|
334
|
+
>
|
|
335
|
+
<XIcon />
|
|
336
|
+
</IconButton>
|
|
337
|
+
|
|
338
|
+
// Dense desktop layouts
|
|
339
|
+
<div className={css({
|
|
340
|
+
display: 'flex',
|
|
341
|
+
gap: { base: 'sm', md: 'xs' }
|
|
342
|
+
})}>
|
|
343
|
+
<IconButton size={{ base: 'md', md: 'sm' }} aria-label="Edit">
|
|
344
|
+
<PencilIcon />
|
|
345
|
+
</IconButton>
|
|
346
|
+
<IconButton size={{ base: 'md', md: 'sm' }} aria-label="Delete">
|
|
347
|
+
<TrashIcon />
|
|
348
|
+
</IconButton>
|
|
349
|
+
</div>
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
## Testing
|
|
353
|
+
|
|
354
|
+
```typescript
|
|
355
|
+
import { render, screen } from '@testing-library/react';
|
|
356
|
+
import userEvent from '@testing-library/user-event';
|
|
357
|
+
|
|
358
|
+
test('icon button has accessible label', () => {
|
|
359
|
+
render(
|
|
360
|
+
<IconButton aria-label="Close dialog">
|
|
361
|
+
<XIcon />
|
|
362
|
+
</IconButton>
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
const button = screen.getByRole('button', { name: 'Close dialog' });
|
|
366
|
+
expect(button).toBeInTheDocument();
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('icon button handles clicks', async () => {
|
|
370
|
+
const handleClick = vi.fn();
|
|
371
|
+
render(
|
|
372
|
+
<IconButton aria-label="Delete" onClick={handleClick}>
|
|
373
|
+
<TrashIcon />
|
|
374
|
+
</IconButton>
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
const button = screen.getByRole('button', { name: 'Delete' });
|
|
378
|
+
await userEvent.click(button);
|
|
379
|
+
|
|
380
|
+
expect(handleClick).toHaveBeenCalledOnce();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test('disabled icon button cannot be clicked', async () => {
|
|
384
|
+
const handleClick = vi.fn();
|
|
385
|
+
render(
|
|
386
|
+
<IconButton aria-label="Delete" disabled onClick={handleClick}>
|
|
387
|
+
<TrashIcon />
|
|
388
|
+
</IconButton>
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
const button = screen.getByRole('button', { name: 'Delete' });
|
|
392
|
+
await userEvent.click(button);
|
|
393
|
+
|
|
394
|
+
expect(handleClick).not.toHaveBeenCalled();
|
|
395
|
+
expect(button).toBeDisabled();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test('toggle button has correct aria-pressed state', () => {
|
|
399
|
+
const { rerender } = render(
|
|
400
|
+
<IconButton aria-label="Star" aria-pressed={false}>
|
|
401
|
+
<StarIcon />
|
|
402
|
+
</IconButton>
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
let button = screen.getByRole('button', { name: 'Star', pressed: false });
|
|
406
|
+
expect(button).toBeInTheDocument();
|
|
407
|
+
|
|
408
|
+
rerender(
|
|
409
|
+
<IconButton aria-label="Star" aria-pressed={true}>
|
|
410
|
+
<StarIcon />
|
|
411
|
+
</IconButton>
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
button = screen.getByRole('button', { name: 'Star', pressed: true });
|
|
415
|
+
expect(button).toBeInTheDocument();
|
|
416
|
+
});
|
|
417
|
+
```
|