@discourser/design-system 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/guidelines/Guidelines.md +195 -0
- package/guidelines/components/accordion.md +639 -0
- package/guidelines/components/avatar.md +945 -0
- package/guidelines/components/badge.md +667 -0
- package/guidelines/components/button.md +314 -0
- package/guidelines/components/card.md +353 -0
- package/guidelines/components/checkbox.md +583 -0
- package/guidelines/components/dialog.md +465 -0
- package/guidelines/components/drawer.md +961 -0
- package/guidelines/components/heading.md +505 -0
- package/guidelines/components/icon-button.md +417 -0
- package/guidelines/components/input.md +499 -0
- package/guidelines/components/popover.md +1200 -0
- package/guidelines/components/progress.md +773 -0
- package/guidelines/components/radio-group.md +757 -0
- package/guidelines/components/select.md +1155 -0
- package/guidelines/components/skeleton.md +726 -0
- package/guidelines/components/switch.md +457 -0
- package/guidelines/components/tabs.md +834 -0
- package/guidelines/components/textarea.md +425 -0
- package/guidelines/components/toast.md +707 -0
- package/guidelines/components/tooltip.md +832 -0
- package/guidelines/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 +204 -0
- package/package.json +3 -2
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
# Input
|
|
2
|
+
|
|
3
|
+
**Purpose:** Text input field with built-in label, validation, and helper text following Material Design 3 patterns.
|
|
4
|
+
|
|
5
|
+
## Import
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { Input } from '@discourser/design-system';
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Variants
|
|
12
|
+
|
|
13
|
+
The Input component supports 2 Material Design 3 variants:
|
|
14
|
+
|
|
15
|
+
| Variant | Visual Style | Usage | When to Use |
|
|
16
|
+
|---------|-------------|-------|-------------|
|
|
17
|
+
| `outlined` | Outlined border around input | Default text inputs | Most common, clear boundaries |
|
|
18
|
+
| `filled` | Filled background with bottom border | Alternative style | When you want less visual weight |
|
|
19
|
+
|
|
20
|
+
### Visual Characteristics
|
|
21
|
+
|
|
22
|
+
- **outlined**: Transparent background, 1px border, 2px border on focus
|
|
23
|
+
- **filled**: `surfaceContainerHighest` background, bottom border only, rounded top corners
|
|
24
|
+
|
|
25
|
+
## Sizes
|
|
26
|
+
|
|
27
|
+
| Size | Height | Font Size | Usage |
|
|
28
|
+
|------|--------|-----------|-------|
|
|
29
|
+
| `sm` | 40px | bodySmall | Compact forms, dense layouts |
|
|
30
|
+
| `md` | 56px | bodyLarge | Default, most use cases |
|
|
31
|
+
|
|
32
|
+
## Props
|
|
33
|
+
|
|
34
|
+
| Prop | Type | Default | Description |
|
|
35
|
+
|------|------|---------|-------------|
|
|
36
|
+
| `label` | `string` | - | Label text (highly recommended for accessibility) |
|
|
37
|
+
| `helperText` | `string` | - | Helper text displayed below input |
|
|
38
|
+
| `errorText` | `string` | - | Error message (also sets error state) |
|
|
39
|
+
| `variant` | `'outlined' \| 'filled'` | `'outlined'` | Visual style variant |
|
|
40
|
+
| `size` | `'sm' \| 'md'` | `'md'` | Input size |
|
|
41
|
+
| `state` | `'error'` | - | Visual state (auto-set if errorText provided) |
|
|
42
|
+
| `disabled` | `boolean` | `false` | Disable input |
|
|
43
|
+
| `value` | `string` | - | Controlled value |
|
|
44
|
+
| `defaultValue` | `string` | - | Uncontrolled default value |
|
|
45
|
+
| `onChange` | `(event: ChangeEvent) => void` | - | Change handler |
|
|
46
|
+
| `placeholder` | `string` | - | Placeholder text |
|
|
47
|
+
| `type` | `string` | `'text'` | HTML input type (text, email, password, etc.) |
|
|
48
|
+
| `required` | `boolean` | `false` | Mark as required field |
|
|
49
|
+
|
|
50
|
+
**Note:** Input extends `InputHTMLAttributes<HTMLInputElement>` (excluding 'size'), so all standard HTML input attributes are supported.
|
|
51
|
+
|
|
52
|
+
## Examples
|
|
53
|
+
|
|
54
|
+
### Basic Usage
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
// Outlined input (default)
|
|
58
|
+
<Input label="Email" placeholder="you@example.com" />
|
|
59
|
+
|
|
60
|
+
// Filled input
|
|
61
|
+
<Input variant="filled" label="Username" />
|
|
62
|
+
|
|
63
|
+
// Small size
|
|
64
|
+
<Input size="sm" label="Search" />
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### With Helper Text
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
<Input
|
|
71
|
+
label="Password"
|
|
72
|
+
type="password"
|
|
73
|
+
helperText="Must be at least 8 characters"
|
|
74
|
+
/>
|
|
75
|
+
|
|
76
|
+
<Input
|
|
77
|
+
label="Email"
|
|
78
|
+
type="email"
|
|
79
|
+
helperText="We'll never share your email"
|
|
80
|
+
/>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Error State
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
<Input
|
|
87
|
+
label="Email"
|
|
88
|
+
type="email"
|
|
89
|
+
errorText="Please enter a valid email address"
|
|
90
|
+
/>
|
|
91
|
+
|
|
92
|
+
<Input
|
|
93
|
+
label="Username"
|
|
94
|
+
errorText="Username is already taken"
|
|
95
|
+
/>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Controlled Input
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
const [email, setEmail] = useState('');
|
|
102
|
+
|
|
103
|
+
<Input
|
|
104
|
+
label="Email"
|
|
105
|
+
value={email}
|
|
106
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
107
|
+
/>
|
|
108
|
+
|
|
109
|
+
// With validation
|
|
110
|
+
const [email, setEmail] = useState('');
|
|
111
|
+
const emailError = email && !isValidEmail(email)
|
|
112
|
+
? 'Invalid email address'
|
|
113
|
+
: undefined;
|
|
114
|
+
|
|
115
|
+
<Input
|
|
116
|
+
label="Email"
|
|
117
|
+
type="email"
|
|
118
|
+
value={email}
|
|
119
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
120
|
+
errorText={emailError}
|
|
121
|
+
/>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Uncontrolled Input
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
<Input
|
|
128
|
+
label="Name"
|
|
129
|
+
defaultValue="John Doe"
|
|
130
|
+
/>
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Different Input Types
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
// Email
|
|
137
|
+
<Input label="Email" type="email" />
|
|
138
|
+
|
|
139
|
+
// Password
|
|
140
|
+
<Input label="Password" type="password" />
|
|
141
|
+
|
|
142
|
+
// Number
|
|
143
|
+
<Input label="Age" type="number" min="0" max="120" />
|
|
144
|
+
|
|
145
|
+
// Tel
|
|
146
|
+
<Input label="Phone" type="tel" />
|
|
147
|
+
|
|
148
|
+
// URL
|
|
149
|
+
<Input label="Website" type="url" />
|
|
150
|
+
|
|
151
|
+
// Date
|
|
152
|
+
<Input label="Date of Birth" type="date" />
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Required Field
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
<Input
|
|
159
|
+
label="Email"
|
|
160
|
+
type="email"
|
|
161
|
+
required
|
|
162
|
+
helperText="This field is required"
|
|
163
|
+
/>
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Disabled State
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
<Input
|
|
170
|
+
label="Username"
|
|
171
|
+
value="johndoe"
|
|
172
|
+
disabled
|
|
173
|
+
/>
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Common Patterns
|
|
177
|
+
|
|
178
|
+
### Login Form
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
const [email, setEmail] = useState('');
|
|
182
|
+
const [password, setPassword] = useState('');
|
|
183
|
+
|
|
184
|
+
<form onSubmit={handleLogin}>
|
|
185
|
+
<div className={css({ display: 'flex', flexDirection: 'column', gap: 'md' })}>
|
|
186
|
+
<Input
|
|
187
|
+
label="Email"
|
|
188
|
+
type="email"
|
|
189
|
+
value={email}
|
|
190
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
191
|
+
required
|
|
192
|
+
/>
|
|
193
|
+
<Input
|
|
194
|
+
label="Password"
|
|
195
|
+
type="password"
|
|
196
|
+
value={password}
|
|
197
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
198
|
+
required
|
|
199
|
+
/>
|
|
200
|
+
<Button type="submit">Log In</Button>
|
|
201
|
+
</div>
|
|
202
|
+
</form>
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Form with Validation
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
const [formData, setFormData] = useState({ email: '', password: '' });
|
|
209
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
210
|
+
|
|
211
|
+
const handleSubmit = (e: FormEvent) => {
|
|
212
|
+
e.preventDefault();
|
|
213
|
+
const newErrors: Record<string, string> = {};
|
|
214
|
+
|
|
215
|
+
if (!formData.email) {
|
|
216
|
+
newErrors.email = 'Email is required';
|
|
217
|
+
} else if (!isValidEmail(formData.email)) {
|
|
218
|
+
newErrors.email = 'Invalid email format';
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!formData.password) {
|
|
222
|
+
newErrors.password = 'Password is required';
|
|
223
|
+
} else if (formData.password.length < 8) {
|
|
224
|
+
newErrors.password = 'Password must be at least 8 characters';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
setErrors(newErrors);
|
|
228
|
+
|
|
229
|
+
if (Object.keys(newErrors).length === 0) {
|
|
230
|
+
// Submit form
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
<form onSubmit={handleSubmit}>
|
|
235
|
+
<div className={css({ display: 'flex', flexDirection: 'column', gap: 'md' })}>
|
|
236
|
+
<Input
|
|
237
|
+
label="Email"
|
|
238
|
+
type="email"
|
|
239
|
+
value={formData.email}
|
|
240
|
+
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
|
241
|
+
errorText={errors.email}
|
|
242
|
+
required
|
|
243
|
+
/>
|
|
244
|
+
<Input
|
|
245
|
+
label="Password"
|
|
246
|
+
type="password"
|
|
247
|
+
value={formData.password}
|
|
248
|
+
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
|
249
|
+
errorText={errors.password}
|
|
250
|
+
helperText="Must be at least 8 characters"
|
|
251
|
+
required
|
|
252
|
+
/>
|
|
253
|
+
<Button type="submit">Sign Up</Button>
|
|
254
|
+
</div>
|
|
255
|
+
</form>
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Search Input
|
|
259
|
+
|
|
260
|
+
```typescript
|
|
261
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
262
|
+
|
|
263
|
+
<Input
|
|
264
|
+
label="Search"
|
|
265
|
+
size="sm"
|
|
266
|
+
value={searchQuery}
|
|
267
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
268
|
+
placeholder="Search products..."
|
|
269
|
+
/>
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### Password with Toggle Visibility
|
|
273
|
+
|
|
274
|
+
```typescript
|
|
275
|
+
const [password, setPassword] = useState('');
|
|
276
|
+
const [showPassword, setShowPassword] = useState(false);
|
|
277
|
+
|
|
278
|
+
<div className={css({ position: 'relative' })}>
|
|
279
|
+
<Input
|
|
280
|
+
label="Password"
|
|
281
|
+
type={showPassword ? 'text' : 'password'}
|
|
282
|
+
value={password}
|
|
283
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
284
|
+
/>
|
|
285
|
+
<IconButton
|
|
286
|
+
variant="standard"
|
|
287
|
+
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
|
288
|
+
onClick={() => setShowPassword(!showPassword)}
|
|
289
|
+
className={css({ position: 'absolute', right: 'xs', top: 'md' })}
|
|
290
|
+
>
|
|
291
|
+
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
|
|
292
|
+
</IconButton>
|
|
293
|
+
</div>
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
## DO NOT
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
// ❌ Don't use native input without component
|
|
300
|
+
<input type="text" placeholder="Email" /> // Use <Input> instead
|
|
301
|
+
|
|
302
|
+
// ❌ Don't omit label (accessibility issue)
|
|
303
|
+
<Input placeholder="Enter your email" /> // Missing label
|
|
304
|
+
|
|
305
|
+
// ✅ Always provide label
|
|
306
|
+
<Input label="Email" placeholder="you@example.com" />
|
|
307
|
+
|
|
308
|
+
// ❌ Don't show both helperText and errorText (errorText takes precedence)
|
|
309
|
+
<Input
|
|
310
|
+
label="Email"
|
|
311
|
+
helperText="Helper text"
|
|
312
|
+
errorText="Error text" // Only error will show
|
|
313
|
+
/>
|
|
314
|
+
|
|
315
|
+
// ❌ Don't use inline styles for errors
|
|
316
|
+
<Input
|
|
317
|
+
label="Email"
|
|
318
|
+
style={{ borderColor: 'red' }}
|
|
319
|
+
/>
|
|
320
|
+
|
|
321
|
+
// ✅ Use errorText prop
|
|
322
|
+
<Input
|
|
323
|
+
label="Email"
|
|
324
|
+
errorText="Invalid email"
|
|
325
|
+
/>
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
## Accessibility
|
|
329
|
+
|
|
330
|
+
The Input component follows WCAG 2.1 Level AA standards:
|
|
331
|
+
|
|
332
|
+
- **Labels**: Always provide labels for screen readers
|
|
333
|
+
- **Error Messages**: Errors are announced to screen readers
|
|
334
|
+
- **Helper Text**: Helper text is associated with input
|
|
335
|
+
- **Focus Indicator**: Visible focus outline on focus
|
|
336
|
+
- **Required Fields**: Use `required` prop for required fields
|
|
337
|
+
- **Field Validation**: Use Ark UI's Field component internally for proper ARIA attributes
|
|
338
|
+
|
|
339
|
+
### Accessibility Best Practices
|
|
340
|
+
|
|
341
|
+
```typescript
|
|
342
|
+
// ✅ Always provide labels
|
|
343
|
+
<Input label="Email" type="email" />
|
|
344
|
+
|
|
345
|
+
// ✅ Mark required fields
|
|
346
|
+
<Input label="Email" required />
|
|
347
|
+
|
|
348
|
+
// ✅ Provide helpful error messages
|
|
349
|
+
<Input
|
|
350
|
+
label="Email"
|
|
351
|
+
errorText="Please enter a valid email address"
|
|
352
|
+
/>
|
|
353
|
+
|
|
354
|
+
// ✅ Use appropriate input types
|
|
355
|
+
<Input label="Email" type="email" /> // Enables email keyboard on mobile
|
|
356
|
+
<Input label="Phone" type="tel" /> // Enables numeric keyboard on mobile
|
|
357
|
+
<Input label="Website" type="url" /> // Enables URL keyboard on mobile
|
|
358
|
+
|
|
359
|
+
// ✅ Provide helper text for complex requirements
|
|
360
|
+
<Input
|
|
361
|
+
label="Password"
|
|
362
|
+
type="password"
|
|
363
|
+
helperText="Must include uppercase, lowercase, number, and special character"
|
|
364
|
+
/>
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
## Form Integration
|
|
368
|
+
|
|
369
|
+
```typescript
|
|
370
|
+
// React Hook Form integration
|
|
371
|
+
import { useForm } from 'react-hook-form';
|
|
372
|
+
|
|
373
|
+
const { register, handleSubmit, formState: { errors } } = useForm();
|
|
374
|
+
|
|
375
|
+
<form onSubmit={handleSubmit(onSubmit)}>
|
|
376
|
+
<Input
|
|
377
|
+
label="Email"
|
|
378
|
+
{...register('email', {
|
|
379
|
+
required: 'Email is required',
|
|
380
|
+
pattern: {
|
|
381
|
+
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
|
382
|
+
message: 'Invalid email address'
|
|
383
|
+
}
|
|
384
|
+
})}
|
|
385
|
+
errorText={errors.email?.message}
|
|
386
|
+
/>
|
|
387
|
+
</form>
|
|
388
|
+
|
|
389
|
+
// Formik integration
|
|
390
|
+
import { useFormik } from 'formik';
|
|
391
|
+
|
|
392
|
+
const formik = useFormik({
|
|
393
|
+
initialValues: { email: '' },
|
|
394
|
+
onSubmit: values => { /* ... */ },
|
|
395
|
+
validate: values => {
|
|
396
|
+
const errors: any = {};
|
|
397
|
+
if (!values.email) {
|
|
398
|
+
errors.email = 'Required';
|
|
399
|
+
}
|
|
400
|
+
return errors;
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
<form onSubmit={formik.handleSubmit}>
|
|
405
|
+
<Input
|
|
406
|
+
label="Email"
|
|
407
|
+
name="email"
|
|
408
|
+
value={formik.values.email}
|
|
409
|
+
onChange={formik.handleChange}
|
|
410
|
+
errorText={formik.errors.email}
|
|
411
|
+
/>
|
|
412
|
+
</form>
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
## Variant Selection Guide
|
|
416
|
+
|
|
417
|
+
| Scenario | Recommended Variant | Reasoning |
|
|
418
|
+
|----------|-------------------|-----------|
|
|
419
|
+
| Standard forms | `outlined` | Clear boundaries, default choice |
|
|
420
|
+
| Dense forms | `outlined` + `size="sm"` | Compact while maintaining clarity |
|
|
421
|
+
| Minimal UI | `filled` | Lighter visual weight |
|
|
422
|
+
| Search bars | `outlined` or `filled` | Either works, depends on design |
|
|
423
|
+
| Settings forms | `outlined` | Clear separation of fields |
|
|
424
|
+
|
|
425
|
+
## State Behaviors
|
|
426
|
+
|
|
427
|
+
| State | Visual Change | Behavior |
|
|
428
|
+
|-------|---------------|----------|
|
|
429
|
+
| **Default** | Normal border/background | Ready for input |
|
|
430
|
+
| **Hover** | Border darkens or background changes | Visual feedback |
|
|
431
|
+
| **Focus** | 2px border, primary color | Active input state |
|
|
432
|
+
| **Error** | Error color border, error message shown | Validation failed |
|
|
433
|
+
| **Disabled** | 38% opacity, no interaction | Cannot be edited |
|
|
434
|
+
|
|
435
|
+
## Responsive Considerations
|
|
436
|
+
|
|
437
|
+
```typescript
|
|
438
|
+
// Mobile-first: Larger inputs for better touch
|
|
439
|
+
<Input size="md" label="Email" />
|
|
440
|
+
|
|
441
|
+
// Responsive sizing
|
|
442
|
+
<Input
|
|
443
|
+
label="Email"
|
|
444
|
+
size={{ base: 'md', lg: 'sm' }}
|
|
445
|
+
/>
|
|
446
|
+
|
|
447
|
+
// Full-width inputs (default)
|
|
448
|
+
<Input label="Email" /> // Already full-width
|
|
449
|
+
|
|
450
|
+
// Custom width
|
|
451
|
+
<Input
|
|
452
|
+
label="Email"
|
|
453
|
+
className={css({ maxWidth: '400px' })}
|
|
454
|
+
/>
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
## Testing
|
|
458
|
+
|
|
459
|
+
```typescript
|
|
460
|
+
import { render, screen } from '@testing-library/react';
|
|
461
|
+
import userEvent from '@testing-library/user-event';
|
|
462
|
+
|
|
463
|
+
test('input accepts text input', async () => {
|
|
464
|
+
render(<Input label="Email" />);
|
|
465
|
+
|
|
466
|
+
const input = screen.getByLabelText('Email');
|
|
467
|
+
await userEvent.type(input, 'test@example.com');
|
|
468
|
+
|
|
469
|
+
expect(input).toHaveValue('test@example.com');
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test('input shows error message', () => {
|
|
473
|
+
render(
|
|
474
|
+
<Input label="Email" errorText="Invalid email" />
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
expect(screen.getByText('Invalid email')).toBeInTheDocument();
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test('input shows helper text when no error', () => {
|
|
481
|
+
render(
|
|
482
|
+
<Input label="Email" helperText="We'll never share your email" />
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
expect(screen.getByText("We'll never share your email")).toBeInTheDocument();
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test('controlled input updates value', async () => {
|
|
489
|
+
const handleChange = vi.fn();
|
|
490
|
+
render(
|
|
491
|
+
<Input label="Email" value="" onChange={handleChange} />
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
const input = screen.getByLabelText('Email');
|
|
495
|
+
await userEvent.type(input, 'a');
|
|
496
|
+
|
|
497
|
+
expect(handleChange).toHaveBeenCalled();
|
|
498
|
+
});
|
|
499
|
+
```
|