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