@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,830 @@
|
|
|
1
|
+
# InputGroup
|
|
2
|
+
|
|
3
|
+
**Purpose:** Layout wrapper component for composing inputs with decorative elements (icons, text, buttons) positioned at start or end following Material Design 3 patterns.
|
|
4
|
+
|
|
5
|
+
## When to Use This Component
|
|
6
|
+
|
|
7
|
+
Use InputGroup when you need to **position elements inside or around an input field** (search icons, clear buttons, prefix/suffix text).
|
|
8
|
+
|
|
9
|
+
**Decision Tree:**
|
|
10
|
+
|
|
11
|
+
| Scenario | Use This | Why |
|
|
12
|
+
| -------------------------------------------------------- | --------------------- | ------------------------------------ |
|
|
13
|
+
| Input with prefix icon or text (search, user, currency) | InputGroup ✅ | Positions elements relative to input |
|
|
14
|
+
| Input with suffix icon or text (units, domains, actions) | InputGroup ✅ | Manages internal layout and spacing |
|
|
15
|
+
| Input with both prefix and suffix elements | InputGroup ✅ | Coordinates multiple elements |
|
|
16
|
+
| Standalone input without decoration | Input | No layout management needed |
|
|
17
|
+
| Input with external label above/below | Input with label prop | Built-in label positioning |
|
|
18
|
+
| Multiple inputs side by side | Flex/Grid layout | Different layout pattern |
|
|
19
|
+
|
|
20
|
+
**Component Comparison:**
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
// ✅ Use InputGroup for input with icon
|
|
24
|
+
<InputGroup.Root size="md">
|
|
25
|
+
<InputGroup.Element>
|
|
26
|
+
<SearchIcon />
|
|
27
|
+
</InputGroup.Element>
|
|
28
|
+
<Input placeholder="Search..." />
|
|
29
|
+
</InputGroup.Root>
|
|
30
|
+
|
|
31
|
+
// ✅ Use InputGroup for input with addon text
|
|
32
|
+
<InputGroup.Root size="md">
|
|
33
|
+
<InputAddon variant="outline">$</InputAddon>
|
|
34
|
+
<Input placeholder="0.00" type="number" />
|
|
35
|
+
</InputGroup.Root>
|
|
36
|
+
|
|
37
|
+
// ✅ Use InputGroup for input with action button
|
|
38
|
+
<InputGroup.Root size="md">
|
|
39
|
+
<Input placeholder="Password" type="password" />
|
|
40
|
+
<InputGroup.Element>
|
|
41
|
+
<IconButton variant="ghost" aria-label="Toggle visibility">
|
|
42
|
+
<EyeIcon />
|
|
43
|
+
</IconButton>
|
|
44
|
+
</InputGroup.Element>
|
|
45
|
+
</InputGroup.Root>
|
|
46
|
+
|
|
47
|
+
// ❌ Don't use InputGroup for standalone input
|
|
48
|
+
<InputGroup.Root size="md">
|
|
49
|
+
<Input placeholder="Email" /> // Wrong - no decorative elements
|
|
50
|
+
</InputGroup.Root>
|
|
51
|
+
|
|
52
|
+
<Input placeholder="Email" /> // Correct - no group needed
|
|
53
|
+
|
|
54
|
+
// ❌ Don't use InputGroup for external labels
|
|
55
|
+
<InputGroup.Root>
|
|
56
|
+
<label>Email Address</label> // Wrong - labels go outside
|
|
57
|
+
<Input />
|
|
58
|
+
</InputGroup.Root>
|
|
59
|
+
|
|
60
|
+
<div>
|
|
61
|
+
<label>Email Address</label> // Correct
|
|
62
|
+
<Input />
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
// ❌ Don't use InputGroup for multiple separate inputs
|
|
66
|
+
<InputGroup.Root>
|
|
67
|
+
<Input placeholder="First name" />
|
|
68
|
+
<Input placeholder="Last name" /> // Wrong - separate inputs
|
|
69
|
+
</InputGroup.Root>
|
|
70
|
+
|
|
71
|
+
<div className={css({ display: 'flex', gap: 'md' })}>
|
|
72
|
+
<Input placeholder="First name" />
|
|
73
|
+
<Input placeholder="Last name" /> // Correct
|
|
74
|
+
</div>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Import
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
import { InputGroup, Input, InputAddon } from '@discourser/design-system';
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Component Structure
|
|
84
|
+
|
|
85
|
+
InputGroup uses a compound component pattern with these parts:
|
|
86
|
+
|
|
87
|
+
- `InputGroup.Root` - Container wrapper that manages layout and sizing
|
|
88
|
+
- `InputGroup.Element` - Positioned element container for icons or buttons (no border/background)
|
|
89
|
+
- `InputAddon` - Styled addon element for text or bordered content (separate component)
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
// Basic structure with Element (icon, button)
|
|
93
|
+
<InputGroup.Root size="md">
|
|
94
|
+
<InputGroup.Element><!-- icon or button --></InputGroup.Element>
|
|
95
|
+
<Input />
|
|
96
|
+
<InputGroup.Element><!-- icon or button --></InputGroup.Element>
|
|
97
|
+
</InputGroup.Root>
|
|
98
|
+
|
|
99
|
+
// Structure with InputAddon (text, bordered elements)
|
|
100
|
+
<InputGroup.Root size="md">
|
|
101
|
+
<InputAddon><!-- prefix text --></InputAddon>
|
|
102
|
+
<Input />
|
|
103
|
+
<InputAddon><!-- suffix text --></InputAddon>
|
|
104
|
+
</InputGroup.Root>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Sizes
|
|
108
|
+
|
|
109
|
+
| Size | Input Height | Element Min Width | Icon Size | Usage |
|
|
110
|
+
| ---- | ------------ | ----------------- | --------- | ------------------------------- |
|
|
111
|
+
| `xs` | 32px | 32px | 16px | Extra compact forms, mobile |
|
|
112
|
+
| `sm` | 36px | 36px | 18px | Compact forms, dense layouts |
|
|
113
|
+
| `md` | 40px | 40px | 20px | Default, most use cases |
|
|
114
|
+
| `lg` | 44px | 44px | 20px | Touch targets, prominent inputs |
|
|
115
|
+
| `xl` | 48px | 44px | 22px | Large forms, hero sections |
|
|
116
|
+
|
|
117
|
+
**Recommendation:** Use `md` for most cases. Size automatically applies to child Input and addons.
|
|
118
|
+
|
|
119
|
+
## Props
|
|
120
|
+
|
|
121
|
+
### Root Props
|
|
122
|
+
|
|
123
|
+
| Prop | Type | Default | Description |
|
|
124
|
+
| ----------- | -------------------------------------- | -------- | ---------------------------- |
|
|
125
|
+
| `size` | `'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl'` | `'md'` | Size applied to all children |
|
|
126
|
+
| `children` | `ReactNode` | Required | Input and addon elements |
|
|
127
|
+
| `className` | `string` | - | Additional CSS classes |
|
|
128
|
+
|
|
129
|
+
### Element Props
|
|
130
|
+
|
|
131
|
+
| Prop | Type | Default | Description |
|
|
132
|
+
| ----------- | ----------- | -------- | ------------------------ |
|
|
133
|
+
| `children` | `ReactNode` | Required | Icon, button, or content |
|
|
134
|
+
| `className` | `string` | - | Additional CSS classes |
|
|
135
|
+
|
|
136
|
+
**Note:** InputGroup.Root and InputGroup.Element extend `HTMLAttributes<HTMLDivElement>`.
|
|
137
|
+
|
|
138
|
+
## Examples
|
|
139
|
+
|
|
140
|
+
### Basic Icon Elements
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
import { InputGroup, Input } from '@discourser/design-system';
|
|
144
|
+
import { SearchIcon, UserIcon, MailIcon, LockIcon } from 'your-icon-library';
|
|
145
|
+
|
|
146
|
+
// Search with icon prefix
|
|
147
|
+
<InputGroup.Root size="md">
|
|
148
|
+
<InputGroup.Element>
|
|
149
|
+
<SearchIcon />
|
|
150
|
+
</InputGroup.Element>
|
|
151
|
+
<Input placeholder="Search..." />
|
|
152
|
+
</InputGroup.Root>
|
|
153
|
+
|
|
154
|
+
// User icon prefix
|
|
155
|
+
<InputGroup.Root size="md">
|
|
156
|
+
<InputGroup.Element>
|
|
157
|
+
<UserIcon />
|
|
158
|
+
</InputGroup.Element>
|
|
159
|
+
<Input placeholder="Username" />
|
|
160
|
+
</InputGroup.Root>
|
|
161
|
+
|
|
162
|
+
// Email icon prefix
|
|
163
|
+
<InputGroup.Root size="md">
|
|
164
|
+
<InputGroup.Element>
|
|
165
|
+
<MailIcon />
|
|
166
|
+
</InputGroup.Element>
|
|
167
|
+
<Input type="email" placeholder="Email address" />
|
|
168
|
+
</InputGroup.Root>
|
|
169
|
+
|
|
170
|
+
// Lock icon for password
|
|
171
|
+
<InputGroup.Root size="md">
|
|
172
|
+
<InputGroup.Element>
|
|
173
|
+
<LockIcon />
|
|
174
|
+
</InputGroup.Element>
|
|
175
|
+
<Input type="password" placeholder="Password" />
|
|
176
|
+
</InputGroup.Root>
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Icon on Right
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
import { CalendarIcon, ChevronDownIcon } from 'your-icon-library';
|
|
183
|
+
|
|
184
|
+
// Calendar icon suffix
|
|
185
|
+
<InputGroup.Root size="md">
|
|
186
|
+
<Input type="date" />
|
|
187
|
+
<InputGroup.Element>
|
|
188
|
+
<CalendarIcon />
|
|
189
|
+
</InputGroup.Element>
|
|
190
|
+
</InputGroup.Root>
|
|
191
|
+
|
|
192
|
+
// Dropdown indicator
|
|
193
|
+
<InputGroup.Root size="md">
|
|
194
|
+
<Input placeholder="Select option..." readOnly />
|
|
195
|
+
<InputGroup.Element>
|
|
196
|
+
<ChevronDownIcon />
|
|
197
|
+
</InputGroup.Element>
|
|
198
|
+
</InputGroup.Root>
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Interactive Button Elements
|
|
202
|
+
|
|
203
|
+
```typescript
|
|
204
|
+
import { IconButton } from '@discourser/design-system';
|
|
205
|
+
import { XIcon, EyeIcon, EyeOffIcon } from 'your-icon-library';
|
|
206
|
+
|
|
207
|
+
// Clear button
|
|
208
|
+
const [value, setValue] = useState('');
|
|
209
|
+
|
|
210
|
+
<InputGroup.Root size="md">
|
|
211
|
+
<Input
|
|
212
|
+
placeholder="Search..."
|
|
213
|
+
value={value}
|
|
214
|
+
onChange={(e) => setValue(e.target.value)}
|
|
215
|
+
/>
|
|
216
|
+
{value && (
|
|
217
|
+
<InputGroup.Element>
|
|
218
|
+
<IconButton
|
|
219
|
+
variant="ghost"
|
|
220
|
+
size="sm"
|
|
221
|
+
aria-label="Clear"
|
|
222
|
+
onClick={() => setValue('')}
|
|
223
|
+
>
|
|
224
|
+
<XIcon />
|
|
225
|
+
</IconButton>
|
|
226
|
+
</InputGroup.Element>
|
|
227
|
+
)}
|
|
228
|
+
</InputGroup.Root>
|
|
229
|
+
|
|
230
|
+
// Password visibility toggle
|
|
231
|
+
const [showPassword, setShowPassword] = useState(false);
|
|
232
|
+
|
|
233
|
+
<InputGroup.Root size="md">
|
|
234
|
+
<Input
|
|
235
|
+
type={showPassword ? 'text' : 'password'}
|
|
236
|
+
placeholder="Password"
|
|
237
|
+
/>
|
|
238
|
+
<InputGroup.Element>
|
|
239
|
+
<IconButton
|
|
240
|
+
variant="ghost"
|
|
241
|
+
size="sm"
|
|
242
|
+
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
|
243
|
+
onClick={() => setShowPassword(!showPassword)}
|
|
244
|
+
>
|
|
245
|
+
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
|
|
246
|
+
</IconButton>
|
|
247
|
+
</InputGroup.Element>
|
|
248
|
+
</InputGroup.Root>
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### With InputAddon (Text/Bordered Elements)
|
|
252
|
+
|
|
253
|
+
```typescript
|
|
254
|
+
import { InputAddon } from '@discourser/design-system';
|
|
255
|
+
|
|
256
|
+
// Currency prefix
|
|
257
|
+
<InputGroup.Root size="md">
|
|
258
|
+
<InputAddon variant="outline">$</InputAddon>
|
|
259
|
+
<Input placeholder="0.00" type="number" />
|
|
260
|
+
</InputGroup.Root>
|
|
261
|
+
|
|
262
|
+
// Unit suffix
|
|
263
|
+
<InputGroup.Root size="md">
|
|
264
|
+
<Input placeholder="Enter weight" type="number" />
|
|
265
|
+
<InputAddon variant="outline">kg</InputAddon>
|
|
266
|
+
</InputGroup.Root>
|
|
267
|
+
|
|
268
|
+
// Domain suffix
|
|
269
|
+
<InputGroup.Root size="md">
|
|
270
|
+
<Input placeholder="username" />
|
|
271
|
+
<InputAddon variant="outline">@example.com</InputAddon>
|
|
272
|
+
</InputGroup.Root>
|
|
273
|
+
|
|
274
|
+
// URL prefix
|
|
275
|
+
<InputGroup.Root size="md">
|
|
276
|
+
<InputAddon variant="outline">https://</InputAddon>
|
|
277
|
+
<Input placeholder="example.com" />
|
|
278
|
+
</InputGroup.Root>
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### Combining Element and InputAddon
|
|
282
|
+
|
|
283
|
+
```typescript
|
|
284
|
+
// Icon with currency
|
|
285
|
+
<InputGroup.Root size="md">
|
|
286
|
+
<InputGroup.Element>
|
|
287
|
+
<DollarIcon />
|
|
288
|
+
</InputGroup.Element>
|
|
289
|
+
<Input placeholder="0.00" type="number" />
|
|
290
|
+
<InputAddon variant="outline">USD</InputAddon>
|
|
291
|
+
</InputGroup.Root>
|
|
292
|
+
|
|
293
|
+
// Search icon with clear button
|
|
294
|
+
<InputGroup.Root size="md">
|
|
295
|
+
<InputGroup.Element>
|
|
296
|
+
<SearchIcon />
|
|
297
|
+
</InputGroup.Element>
|
|
298
|
+
<Input
|
|
299
|
+
placeholder="Search..."
|
|
300
|
+
value={searchQuery}
|
|
301
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
302
|
+
/>
|
|
303
|
+
{searchQuery && (
|
|
304
|
+
<InputGroup.Element>
|
|
305
|
+
<IconButton
|
|
306
|
+
variant="ghost"
|
|
307
|
+
size="sm"
|
|
308
|
+
aria-label="Clear"
|
|
309
|
+
onClick={() => setSearchQuery('')}
|
|
310
|
+
>
|
|
311
|
+
<XIcon />
|
|
312
|
+
</IconButton>
|
|
313
|
+
</InputGroup.Element>
|
|
314
|
+
)}
|
|
315
|
+
</InputGroup.Root>
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
### Different Sizes
|
|
319
|
+
|
|
320
|
+
```typescript
|
|
321
|
+
// Extra small
|
|
322
|
+
<InputGroup.Root size="xs">
|
|
323
|
+
<InputGroup.Element>
|
|
324
|
+
<SearchIcon />
|
|
325
|
+
</InputGroup.Element>
|
|
326
|
+
<Input placeholder="Search..." />
|
|
327
|
+
</InputGroup.Root>
|
|
328
|
+
|
|
329
|
+
// Small
|
|
330
|
+
<InputGroup.Root size="sm">
|
|
331
|
+
<InputGroup.Element>
|
|
332
|
+
<SearchIcon />
|
|
333
|
+
</InputGroup.Element>
|
|
334
|
+
<Input placeholder="Search..." />
|
|
335
|
+
</InputGroup.Root>
|
|
336
|
+
|
|
337
|
+
// Medium (default)
|
|
338
|
+
<InputGroup.Root size="md">
|
|
339
|
+
<InputGroup.Element>
|
|
340
|
+
<SearchIcon />
|
|
341
|
+
</InputGroup.Element>
|
|
342
|
+
<Input placeholder="Search..." />
|
|
343
|
+
</InputGroup.Root>
|
|
344
|
+
|
|
345
|
+
// Large
|
|
346
|
+
<InputGroup.Root size="lg">
|
|
347
|
+
<InputGroup.Element>
|
|
348
|
+
<SearchIcon />
|
|
349
|
+
</InputGroup.Element>
|
|
350
|
+
<Input placeholder="Search..." />
|
|
351
|
+
</InputGroup.Root>
|
|
352
|
+
|
|
353
|
+
// Extra large
|
|
354
|
+
<InputGroup.Root size="xl">
|
|
355
|
+
<InputGroup.Element>
|
|
356
|
+
<SearchIcon />
|
|
357
|
+
</InputGroup.Element>
|
|
358
|
+
<Input placeholder="Search..." />
|
|
359
|
+
</InputGroup.Root>
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### Multiple Elements
|
|
363
|
+
|
|
364
|
+
```typescript
|
|
365
|
+
// Both prefix and suffix elements
|
|
366
|
+
<InputGroup.Root size="md">
|
|
367
|
+
<InputGroup.Element>
|
|
368
|
+
<UserIcon />
|
|
369
|
+
</InputGroup.Element>
|
|
370
|
+
<Input placeholder="Username" />
|
|
371
|
+
<InputGroup.Element>
|
|
372
|
+
<CheckIcon />
|
|
373
|
+
</InputGroup.Element>
|
|
374
|
+
</InputGroup.Root>
|
|
375
|
+
|
|
376
|
+
// Complex composition
|
|
377
|
+
<InputGroup.Root size="md">
|
|
378
|
+
<InputAddon variant="outline">https://</InputAddon>
|
|
379
|
+
<Input placeholder="mysite" />
|
|
380
|
+
<InputAddon variant="outline">.com</InputAddon>
|
|
381
|
+
<InputGroup.Element>
|
|
382
|
+
<IconButton variant="ghost" size="sm" aria-label="Copy">
|
|
383
|
+
<CopyIcon />
|
|
384
|
+
</IconButton>
|
|
385
|
+
</InputGroup.Element>
|
|
386
|
+
</InputGroup.Root>
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
### Loading State
|
|
390
|
+
|
|
391
|
+
```typescript
|
|
392
|
+
import { Spinner } from '@discourser/design-system';
|
|
393
|
+
|
|
394
|
+
const [isSearching, setIsSearching] = useState(false);
|
|
395
|
+
|
|
396
|
+
<InputGroup.Root size="md">
|
|
397
|
+
<InputGroup.Element>
|
|
398
|
+
{isSearching ? (
|
|
399
|
+
<Spinner size="sm" />
|
|
400
|
+
) : (
|
|
401
|
+
<SearchIcon />
|
|
402
|
+
)}
|
|
403
|
+
</InputGroup.Element>
|
|
404
|
+
<Input placeholder="Search..." />
|
|
405
|
+
</InputGroup.Root>
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
### With Form Label
|
|
409
|
+
|
|
410
|
+
```typescript
|
|
411
|
+
<div className={css({ display: 'flex', flexDirection: 'column', gap: 'xs' })}>
|
|
412
|
+
<label htmlFor="search" className={css({ fontWeight: 'medium', textStyle: 'sm' })}>
|
|
413
|
+
Search
|
|
414
|
+
</label>
|
|
415
|
+
<InputGroup.Root size="md">
|
|
416
|
+
<InputGroup.Element>
|
|
417
|
+
<SearchIcon />
|
|
418
|
+
</InputGroup.Element>
|
|
419
|
+
<Input id="search" placeholder="Search products..." />
|
|
420
|
+
</InputGroup.Root>
|
|
421
|
+
</div>
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
## Common Patterns
|
|
425
|
+
|
|
426
|
+
### Search Input
|
|
427
|
+
|
|
428
|
+
```typescript
|
|
429
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
430
|
+
const [isSearching, setIsSearching] = useState(false);
|
|
431
|
+
|
|
432
|
+
<InputGroup.Root size="md">
|
|
433
|
+
<InputGroup.Element>
|
|
434
|
+
{isSearching ? (
|
|
435
|
+
<Spinner size="sm" />
|
|
436
|
+
) : (
|
|
437
|
+
<SearchIcon />
|
|
438
|
+
)}
|
|
439
|
+
</InputGroup.Element>
|
|
440
|
+
<Input
|
|
441
|
+
placeholder="Search products..."
|
|
442
|
+
value={searchQuery}
|
|
443
|
+
onChange={(e) => {
|
|
444
|
+
setSearchQuery(e.target.value);
|
|
445
|
+
setIsSearching(true);
|
|
446
|
+
// Debounce search logic
|
|
447
|
+
}}
|
|
448
|
+
/>
|
|
449
|
+
{searchQuery && (
|
|
450
|
+
<InputGroup.Element>
|
|
451
|
+
<IconButton
|
|
452
|
+
variant="ghost"
|
|
453
|
+
size="sm"
|
|
454
|
+
aria-label="Clear search"
|
|
455
|
+
onClick={() => {
|
|
456
|
+
setSearchQuery('');
|
|
457
|
+
setIsSearching(false);
|
|
458
|
+
}}
|
|
459
|
+
>
|
|
460
|
+
<XIcon />
|
|
461
|
+
</IconButton>
|
|
462
|
+
</InputGroup.Element>
|
|
463
|
+
)}
|
|
464
|
+
</InputGroup.Root>
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
### Password Input with Toggle
|
|
468
|
+
|
|
469
|
+
```typescript
|
|
470
|
+
const [password, setPassword] = useState('');
|
|
471
|
+
const [showPassword, setShowPassword] = useState(false);
|
|
472
|
+
|
|
473
|
+
<div className={css({ display: 'flex', flexDirection: 'column', gap: 'xs' })}>
|
|
474
|
+
<label htmlFor="password" className={css({ fontWeight: 'medium' })}>
|
|
475
|
+
Password
|
|
476
|
+
</label>
|
|
477
|
+
<InputGroup.Root size="md">
|
|
478
|
+
<InputGroup.Element>
|
|
479
|
+
<LockIcon />
|
|
480
|
+
</InputGroup.Element>
|
|
481
|
+
<Input
|
|
482
|
+
id="password"
|
|
483
|
+
type={showPassword ? 'text' : 'password'}
|
|
484
|
+
placeholder="Enter password"
|
|
485
|
+
value={password}
|
|
486
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
487
|
+
/>
|
|
488
|
+
<InputGroup.Element>
|
|
489
|
+
<IconButton
|
|
490
|
+
variant="ghost"
|
|
491
|
+
size="sm"
|
|
492
|
+
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
|
493
|
+
onClick={() => setShowPassword(!showPassword)}
|
|
494
|
+
>
|
|
495
|
+
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
|
|
496
|
+
</IconButton>
|
|
497
|
+
</InputGroup.Element>
|
|
498
|
+
</InputGroup.Root>
|
|
499
|
+
</div>
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
### Currency Input
|
|
503
|
+
|
|
504
|
+
```typescript
|
|
505
|
+
const [amount, setAmount] = useState('');
|
|
506
|
+
|
|
507
|
+
<div className={css({ display: 'flex', flexDirection: 'column', gap: 'xs' })}>
|
|
508
|
+
<label htmlFor="amount" className={css({ fontWeight: 'medium' })}>
|
|
509
|
+
Amount
|
|
510
|
+
</label>
|
|
511
|
+
<InputGroup.Root size="md">
|
|
512
|
+
<InputAddon variant="outline">$</InputAddon>
|
|
513
|
+
<Input
|
|
514
|
+
id="amount"
|
|
515
|
+
type="number"
|
|
516
|
+
placeholder="0.00"
|
|
517
|
+
value={amount}
|
|
518
|
+
onChange={(e) => setAmount(e.target.value)}
|
|
519
|
+
min="0"
|
|
520
|
+
step="0.01"
|
|
521
|
+
/>
|
|
522
|
+
<InputAddon variant="outline">USD</InputAddon>
|
|
523
|
+
</InputGroup.Root>
|
|
524
|
+
</div>
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
### URL Input
|
|
528
|
+
|
|
529
|
+
```typescript
|
|
530
|
+
const [url, setUrl] = useState('');
|
|
531
|
+
|
|
532
|
+
<InputGroup.Root size="md">
|
|
533
|
+
<InputAddon variant="outline">https://</InputAddon>
|
|
534
|
+
<Input
|
|
535
|
+
placeholder="example.com"
|
|
536
|
+
value={url}
|
|
537
|
+
onChange={(e) => setUrl(e.target.value)}
|
|
538
|
+
/>
|
|
539
|
+
{url && (
|
|
540
|
+
<InputGroup.Element>
|
|
541
|
+
<IconButton
|
|
542
|
+
variant="ghost"
|
|
543
|
+
size="sm"
|
|
544
|
+
aria-label="Copy URL"
|
|
545
|
+
onClick={() => navigator.clipboard.writeText(`https://${url}`)}
|
|
546
|
+
>
|
|
547
|
+
<CopyIcon />
|
|
548
|
+
</IconButton>
|
|
549
|
+
</InputGroup.Element>
|
|
550
|
+
)}
|
|
551
|
+
</InputGroup.Root>
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
### Phone Number Input
|
|
555
|
+
|
|
556
|
+
```typescript
|
|
557
|
+
const [phone, setPhone] = useState('');
|
|
558
|
+
|
|
559
|
+
<InputGroup.Root size="md">
|
|
560
|
+
<InputGroup.Element>
|
|
561
|
+
<PhoneIcon />
|
|
562
|
+
</InputGroup.Element>
|
|
563
|
+
<InputAddon variant="outline">+1</InputAddon>
|
|
564
|
+
<Input
|
|
565
|
+
placeholder="(555) 000-0000"
|
|
566
|
+
type="tel"
|
|
567
|
+
value={phone}
|
|
568
|
+
onChange={(e) => setPhone(e.target.value)}
|
|
569
|
+
/>
|
|
570
|
+
</InputGroup.Root>
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
### Email Input with Validation
|
|
574
|
+
|
|
575
|
+
```typescript
|
|
576
|
+
const [email, setEmail] = useState('');
|
|
577
|
+
const [isValid, setIsValid] = useState<boolean | null>(null);
|
|
578
|
+
|
|
579
|
+
const validateEmail = (value: string) => {
|
|
580
|
+
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
581
|
+
return regex.test(value);
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
<InputGroup.Root size="md">
|
|
585
|
+
<InputGroup.Element>
|
|
586
|
+
<MailIcon />
|
|
587
|
+
</InputGroup.Element>
|
|
588
|
+
<Input
|
|
589
|
+
type="email"
|
|
590
|
+
placeholder="you@example.com"
|
|
591
|
+
value={email}
|
|
592
|
+
onChange={(e) => {
|
|
593
|
+
setEmail(e.target.value);
|
|
594
|
+
setIsValid(e.target.value ? validateEmail(e.target.value) : null);
|
|
595
|
+
}}
|
|
596
|
+
/>
|
|
597
|
+
<InputGroup.Element>
|
|
598
|
+
{isValid === true && <CheckIcon className={css({ color: 'success' })} />}
|
|
599
|
+
{isValid === false && <XIcon className={css({ color: 'error' })} />}
|
|
600
|
+
</InputGroup.Element>
|
|
601
|
+
</InputGroup.Root>
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
## DO NOT
|
|
605
|
+
|
|
606
|
+
```typescript
|
|
607
|
+
// ❌ Don't use InputGroup without decorative elements
|
|
608
|
+
<InputGroup.Root>
|
|
609
|
+
<Input placeholder="Email" /> // Wrong - no point without addons/elements
|
|
610
|
+
</InputGroup.Root>
|
|
611
|
+
|
|
612
|
+
// ✅ Use Input directly if no decorations
|
|
613
|
+
<Input placeholder="Email" />
|
|
614
|
+
|
|
615
|
+
// ❌ Don't put labels inside InputGroup
|
|
616
|
+
<InputGroup.Root>
|
|
617
|
+
<label>Search</label> // Wrong - labels go outside
|
|
618
|
+
<Input />
|
|
619
|
+
</InputGroup.Root>
|
|
620
|
+
|
|
621
|
+
// ✅ Put labels outside
|
|
622
|
+
<div>
|
|
623
|
+
<label>Search</label>
|
|
624
|
+
<InputGroup.Root>
|
|
625
|
+
<InputGroup.Element><SearchIcon /></InputGroup.Element>
|
|
626
|
+
<Input />
|
|
627
|
+
</InputGroup.Root>
|
|
628
|
+
</div>
|
|
629
|
+
|
|
630
|
+
// ❌ Don't use multiple inputs in one group
|
|
631
|
+
<InputGroup.Root>
|
|
632
|
+
<Input placeholder="First name" />
|
|
633
|
+
<Input placeholder="Last name" /> // Wrong - separate inputs
|
|
634
|
+
</InputGroup.Root>
|
|
635
|
+
|
|
636
|
+
// ✅ Use separate groups or flex layout
|
|
637
|
+
<div className={css({ display: 'flex', gap: 'md' })}>
|
|
638
|
+
<Input placeholder="First name" />
|
|
639
|
+
<Input placeholder="Last name" />
|
|
640
|
+
</div>
|
|
641
|
+
|
|
642
|
+
// ❌ Don't nest InputGroups
|
|
643
|
+
<InputGroup.Root>
|
|
644
|
+
<InputGroup.Root> // Wrong - no nesting
|
|
645
|
+
<Input />
|
|
646
|
+
</InputGroup.Root>
|
|
647
|
+
</InputGroup.Root>
|
|
648
|
+
|
|
649
|
+
// ❌ Don't override positioning with inline styles
|
|
650
|
+
<InputGroup.Root>
|
|
651
|
+
<InputGroup.Element style={{ position: 'relative' }}>
|
|
652
|
+
<SearchIcon />
|
|
653
|
+
</InputGroup.Element>
|
|
654
|
+
<Input />
|
|
655
|
+
</InputGroup.Root> // Wrong - breaks internal positioning
|
|
656
|
+
|
|
657
|
+
// ✅ Use component as designed
|
|
658
|
+
<InputGroup.Root>
|
|
659
|
+
<InputGroup.Element>
|
|
660
|
+
<SearchIcon />
|
|
661
|
+
</InputGroup.Element>
|
|
662
|
+
<Input />
|
|
663
|
+
</InputGroup.Root>
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
## Accessibility
|
|
667
|
+
|
|
668
|
+
The InputGroup component follows WCAG 2.1 Level AA standards:
|
|
669
|
+
|
|
670
|
+
- **Visual Association**: Elements are visually grouped with inputs
|
|
671
|
+
- **Focus Management**: Focus stays on input, not decorative elements
|
|
672
|
+
- **Interactive Elements**: Buttons have proper labels and keyboard access
|
|
673
|
+
- **Icon Semantics**: Decorative icons are hidden from screen readers
|
|
674
|
+
- **Touch Targets**: Minimum 44x44px for interactive elements (size md or larger)
|
|
675
|
+
|
|
676
|
+
### Accessibility Best Practices
|
|
677
|
+
|
|
678
|
+
```typescript
|
|
679
|
+
// ✅ Provide aria-label for icon-only buttons
|
|
680
|
+
<InputGroup.Root>
|
|
681
|
+
<Input />
|
|
682
|
+
<InputGroup.Element>
|
|
683
|
+
<IconButton
|
|
684
|
+
variant="ghost"
|
|
685
|
+
aria-label="Clear input"
|
|
686
|
+
onClick={handleClear}
|
|
687
|
+
>
|
|
688
|
+
<XIcon />
|
|
689
|
+
</IconButton>
|
|
690
|
+
</InputGroup.Element>
|
|
691
|
+
</InputGroup.Root>
|
|
692
|
+
|
|
693
|
+
// ✅ Use proper labels for inputs
|
|
694
|
+
<div>
|
|
695
|
+
<label htmlFor="search">Search products</label>
|
|
696
|
+
<InputGroup.Root>
|
|
697
|
+
<InputGroup.Element>
|
|
698
|
+
<SearchIcon aria-hidden="true" />
|
|
699
|
+
</InputGroup.Element>
|
|
700
|
+
<Input id="search" placeholder="Search..." />
|
|
701
|
+
</InputGroup.Root>
|
|
702
|
+
</div>
|
|
703
|
+
|
|
704
|
+
// ✅ Hide decorative icons from screen readers
|
|
705
|
+
<InputGroup.Root>
|
|
706
|
+
<InputGroup.Element>
|
|
707
|
+
<SearchIcon aria-hidden="true" />
|
|
708
|
+
</InputGroup.Element>
|
|
709
|
+
<Input aria-label="Search" placeholder="Search..." />
|
|
710
|
+
</InputGroup.Root>
|
|
711
|
+
|
|
712
|
+
// ✅ Provide context in input labels
|
|
713
|
+
<InputGroup.Root>
|
|
714
|
+
<InputAddon variant="outline">$</InputAddon>
|
|
715
|
+
<Input
|
|
716
|
+
aria-label="Price in US dollars"
|
|
717
|
+
placeholder="0.00"
|
|
718
|
+
type="number"
|
|
719
|
+
/>
|
|
720
|
+
</InputGroup.Root>
|
|
721
|
+
|
|
722
|
+
// ✅ Announce loading states
|
|
723
|
+
<InputGroup.Root>
|
|
724
|
+
<InputGroup.Element>
|
|
725
|
+
<Spinner size="sm" role="status" aria-label="Searching" />
|
|
726
|
+
</InputGroup.Element>
|
|
727
|
+
<Input placeholder="Search..." />
|
|
728
|
+
</InputGroup.Root>
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
## State Behaviors
|
|
732
|
+
|
|
733
|
+
| State | Visual Change | Behavior |
|
|
734
|
+
| ------------------ | -------------------------------------------- | --------------------------- |
|
|
735
|
+
| **Default** | Elements positioned, input ready | Static layout |
|
|
736
|
+
| **Input Focus** | Input receives focus ring | Elements remain in position |
|
|
737
|
+
| **Input Disabled** | All elements show disabled state | No interaction |
|
|
738
|
+
| **With Content** | Dynamic elements appear (e.g., clear button) | Conditional rendering |
|
|
739
|
+
|
|
740
|
+
## Responsive Considerations
|
|
741
|
+
|
|
742
|
+
```typescript
|
|
743
|
+
// Mobile-first: Use larger sizes for touch
|
|
744
|
+
<InputGroup.Root size={{ base: 'lg', md: 'md' }}>
|
|
745
|
+
<InputGroup.Element>
|
|
746
|
+
<SearchIcon />
|
|
747
|
+
</InputGroup.Element>
|
|
748
|
+
<Input placeholder="Search..." />
|
|
749
|
+
</InputGroup.Root>
|
|
750
|
+
|
|
751
|
+
// Adaptive sizing
|
|
752
|
+
<InputGroup.Root size={{ base: 'md', lg: 'sm' }}>
|
|
753
|
+
<InputAddon variant="outline">$</InputAddon>
|
|
754
|
+
<Input placeholder="0.00" />
|
|
755
|
+
</InputGroup.Root>
|
|
756
|
+
|
|
757
|
+
// Full width on mobile, constrained on desktop
|
|
758
|
+
<div className={css({ maxWidth: { base: 'full', md: '400px' } })}>
|
|
759
|
+
<InputGroup.Root size="md">
|
|
760
|
+
<InputGroup.Element>
|
|
761
|
+
<SearchIcon />
|
|
762
|
+
</InputGroup.Element>
|
|
763
|
+
<Input placeholder="Search..." />
|
|
764
|
+
</InputGroup.Root>
|
|
765
|
+
</div>
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
## Testing
|
|
769
|
+
|
|
770
|
+
```typescript
|
|
771
|
+
import { render, screen } from '@testing-library/react';
|
|
772
|
+
import userEvent from '@testing-library/user-event';
|
|
773
|
+
|
|
774
|
+
test('input group renders icon element', () => {
|
|
775
|
+
render(
|
|
776
|
+
<InputGroup.Root>
|
|
777
|
+
<InputGroup.Element>
|
|
778
|
+
<SearchIcon data-testid="search-icon" />
|
|
779
|
+
</InputGroup.Element>
|
|
780
|
+
<Input placeholder="Search" />
|
|
781
|
+
</InputGroup.Root>
|
|
782
|
+
);
|
|
783
|
+
|
|
784
|
+
expect(screen.getByTestId('search-icon')).toBeInTheDocument();
|
|
785
|
+
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
test('input group button triggers action', async () => {
|
|
789
|
+
const handleClear = vi.fn();
|
|
790
|
+
|
|
791
|
+
render(
|
|
792
|
+
<InputGroup.Root>
|
|
793
|
+
<Input value="test" />
|
|
794
|
+
<InputGroup.Element>
|
|
795
|
+
<IconButton aria-label="Clear" onClick={handleClear}>
|
|
796
|
+
<XIcon />
|
|
797
|
+
</IconButton>
|
|
798
|
+
</InputGroup.Element>
|
|
799
|
+
</InputGroup.Root>
|
|
800
|
+
);
|
|
801
|
+
|
|
802
|
+
const clearButton = screen.getByLabelText('Clear');
|
|
803
|
+
await userEvent.click(clearButton);
|
|
804
|
+
|
|
805
|
+
expect(handleClear).toHaveBeenCalledOnce();
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
test('input remains focusable with decorative elements', () => {
|
|
809
|
+
render(
|
|
810
|
+
<InputGroup.Root>
|
|
811
|
+
<InputGroup.Element>
|
|
812
|
+
<SearchIcon />
|
|
813
|
+
</InputGroup.Element>
|
|
814
|
+
<Input placeholder="Search" />
|
|
815
|
+
</InputGroup.Root>
|
|
816
|
+
);
|
|
817
|
+
|
|
818
|
+
const input = screen.getByPlaceholderText('Search');
|
|
819
|
+
input.focus();
|
|
820
|
+
|
|
821
|
+
expect(input).toHaveFocus();
|
|
822
|
+
});
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
## Related Components
|
|
826
|
+
|
|
827
|
+
- **Input** - Text input field that InputGroup wraps
|
|
828
|
+
- **InputAddon** - Styled addon elements for text/bordered content
|
|
829
|
+
- **IconButton** - For interactive buttons within elements
|
|
830
|
+
- **Spinner** - For loading states in elements
|