@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.
@@ -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
+ ```