@compa11y/react 0.1.0 → 0.1.2

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 CHANGED
@@ -75,11 +75,7 @@ function SettingsTabs() {
75
75
  ### Toast
76
76
 
77
77
  ```tsx
78
- import {
79
- ToastProvider,
80
- ToastViewport,
81
- useToastHelpers,
82
- } from '@compa11y/react';
78
+ import { ToastProvider, ToastViewport, useToastHelpers } from '@compa11y/react';
83
79
 
84
80
  function App() {
85
81
  return (
@@ -125,6 +121,430 @@ function CountrySelect() {
125
121
  }
126
122
  ```
127
123
 
124
+ ### Select
125
+
126
+ ```tsx
127
+ import { Select } from '@compa11y/react';
128
+
129
+ const fruits = [
130
+ { value: 'apple', label: 'Apple' },
131
+ { value: 'banana', label: 'Banana' },
132
+ { value: 'cherry', label: 'Cherry' },
133
+ { value: 'dragonfruit', label: 'Dragon Fruit', disabled: true },
134
+ { value: 'elderberry', label: 'Elderberry' },
135
+ ];
136
+
137
+ function FruitPicker() {
138
+ const [value, setValue] = useState(null);
139
+
140
+ return (
141
+ <Select
142
+ options={fruits}
143
+ value={value}
144
+ onValueChange={setValue}
145
+ aria-label="Choose a fruit"
146
+ >
147
+ <Select.Trigger placeholder="Pick a fruit..." />
148
+ <Select.Listbox />
149
+ </Select>
150
+ );
151
+ }
152
+ ```
153
+
154
+ **Keyboard Navigation:**
155
+
156
+ | Key | Action |
157
+ | ----------------- | ----------------------------------------- |
158
+ | `Enter` / `Space` | Open listbox or select highlighted option |
159
+ | `ArrowDown` | Open listbox / move highlight down |
160
+ | `ArrowUp` | Open listbox / move highlight up |
161
+ | `Home` / `End` | Jump to first / last option |
162
+ | `Escape` | Close listbox |
163
+ | `Tab` | Close listbox and move focus |
164
+ | Type characters | Jump to matching option (type-ahead) |
165
+
166
+ **Props:**
167
+
168
+ | Prop | Type | Default | Description |
169
+ | ----------------- | --------------------------------- | ----------------------- | ---------------------------- |
170
+ | `options` | `SelectOption[]` | — | List of options |
171
+ | `value` | `string \| null` | — | Controlled selected value |
172
+ | `defaultValue` | `string` | — | Default value (uncontrolled) |
173
+ | `onValueChange` | `(value: string \| null) => void` | — | Change handler |
174
+ | `placeholder` | `string` | `'Select an option...'` | Trigger placeholder text |
175
+ | `disabled` | `boolean` | `false` | Disable the select |
176
+ | `aria-label` | `string` | — | Accessible label |
177
+ | `aria-labelledby` | `string` | — | ID of labelling element |
178
+
179
+ ### Switch
180
+
181
+ ```tsx
182
+ import { Switch } from '@compa11y/react';
183
+
184
+ function NotificationSettings() {
185
+ const [enabled, setEnabled] = useState(false);
186
+
187
+ return (
188
+ <Switch checked={enabled} onCheckedChange={setEnabled}>
189
+ Enable notifications
190
+ </Switch>
191
+ );
192
+ }
193
+ ```
194
+
195
+ **Customization:**
196
+
197
+ ```css
198
+ .my-switch {
199
+ --compa11y-switch-bg: #d1d5db;
200
+ --compa11y-switch-checked-bg: #10b981;
201
+ --compa11y-switch-thumb-bg: white;
202
+ --compa11y-switch-width: 3rem;
203
+ --compa11y-switch-height: 1.75rem;
204
+ --compa11y-focus-color: #10b981;
205
+ }
206
+ ```
207
+
208
+ ### Listbox
209
+
210
+ ```tsx
211
+ import { Listbox } from '@compa11y/react';
212
+
213
+ // Single select (selection follows focus)
214
+ function FruitPicker() {
215
+ const [fruit, setFruit] = useState('apple');
216
+
217
+ return (
218
+ <Listbox value={fruit} onValueChange={setFruit} aria-label="Favorite fruit">
219
+ <Listbox.Group label="Citrus">
220
+ <Listbox.Option value="orange">Orange</Listbox.Option>
221
+ <Listbox.Option value="lemon">Lemon</Listbox.Option>
222
+ <Listbox.Option value="grapefruit">Grapefruit</Listbox.Option>
223
+ </Listbox.Group>
224
+ <Listbox.Option value="apple">Apple</Listbox.Option>
225
+ <Listbox.Option value="banana" disabled>
226
+ Banana (sold out)
227
+ </Listbox.Option>
228
+ </Listbox>
229
+ );
230
+ }
231
+
232
+ // Multi select (focus independent of selection)
233
+ function ToppingsPicker() {
234
+ const [toppings, setToppings] = useState(['cheese']);
235
+
236
+ return (
237
+ <Listbox
238
+ multiple
239
+ value={toppings}
240
+ onValueChange={setToppings}
241
+ aria-label="Pizza toppings"
242
+ >
243
+ <Listbox.Option value="cheese">Cheese</Listbox.Option>
244
+ <Listbox.Option value="pepperoni">Pepperoni</Listbox.Option>
245
+ <Listbox.Option value="mushrooms">Mushrooms</Listbox.Option>
246
+ <Listbox.Option value="olives">Olives</Listbox.Option>
247
+ </Listbox>
248
+ );
249
+ }
250
+ ```
251
+
252
+ **Props (Listbox):**
253
+
254
+ | Prop | Type | Default | Description |
255
+ | ----------------- | ------------------------------------- | ------------ | ----------------------------------------------------- |
256
+ | `value` | `string \| string[]` | — | Controlled value (string for single, array for multi) |
257
+ | `defaultValue` | `string \| string[]` | — | Default value (uncontrolled) |
258
+ | `onValueChange` | `(value: string \| string[]) => void` | — | Change handler |
259
+ | `multiple` | `boolean` | `false` | Enable multi-select mode |
260
+ | `disabled` | `boolean` | `false` | Disable all options |
261
+ | `discoverable` | `boolean` | `true` | Keep disabled listbox in tab order |
262
+ | `orientation` | `'horizontal' \| 'vertical'` | `'vertical'` | Layout orientation |
263
+ | `unstyled` | `boolean` | `false` | Remove default styles |
264
+ | `aria-label` | `string` | — | Accessible label |
265
+ | `aria-labelledby` | `string` | — | ID of labelling element |
266
+
267
+ **Props (Listbox.Option):**
268
+
269
+ | Prop | Type | Default | Description |
270
+ | -------------- | --------- | ------- | ------------------------------------------ |
271
+ | `value` | `string` | — | Value for this option (required) |
272
+ | `disabled` | `boolean` | `false` | Disable this option |
273
+ | `discoverable` | `boolean` | `true` | Keep disabled option discoverable |
274
+ | `unstyled` | `boolean` | — | Remove default styles (inherits from root) |
275
+ | `aria-label` | `string` | — | Accessible label override |
276
+
277
+ **Props (Listbox.Group):**
278
+
279
+ | Prop | Type | Default | Description |
280
+ | ---------- | --------- | ------- | ------------------------------------------ |
281
+ | `label` | `string` | — | Group label (required, visible) |
282
+ | `disabled` | `boolean` | `false` | Disable all options in group |
283
+ | `unstyled` | `boolean` | — | Remove default styles (inherits from root) |
284
+
285
+ **Keyboard Navigation:**
286
+
287
+ | Key | Single Select | Multi Select |
288
+ | ----------------------- | ---------------------------- | -------------------------- |
289
+ | `ArrowDown` / `ArrowUp` | Move focus and select | Move focus only |
290
+ | `Home` / `End` | First/last option and select | Move focus only |
291
+ | `Space` | — | Toggle focused option |
292
+ | `Shift+Arrow` | — | Move focus and toggle |
293
+ | `Ctrl+Shift+Home/End` | — | Select range to first/last |
294
+ | `Ctrl+A` | — | Toggle select all |
295
+ | Type characters | Jump to match and select | Jump to match |
296
+
297
+ **Customization:**
298
+
299
+ ```css
300
+ [data-compa11y-listbox] {
301
+ --compa11y-listbox-bg: white;
302
+ --compa11y-listbox-border: 1px solid #ccc;
303
+ --compa11y-listbox-radius: 6px;
304
+ --compa11y-listbox-max-height: 300px;
305
+ }
306
+
307
+ [data-compa11y-listbox-option] {
308
+ --compa11y-option-hover-bg: #f5f5f5;
309
+ --compa11y-option-focused-bg: #e6f0ff;
310
+ --compa11y-option-selected-bg: #e6f0ff;
311
+ --compa11y-option-selected-color: #10b981;
312
+ --compa11y-option-check-color: #10b981;
313
+ --compa11y-option-disabled-color: #999;
314
+ --compa11y-focus-color: #10b981;
315
+ }
316
+ ```
317
+
318
+ ### Input
319
+
320
+ ```tsx
321
+ import { Input } from '@compa11y/react';
322
+
323
+ function ContactForm() {
324
+ const [name, setName] = useState('');
325
+ const [nameError, setNameError] = useState('');
326
+
327
+ const validate = () => {
328
+ if (!name.trim()) setNameError('Name is required');
329
+ else setNameError('');
330
+ };
331
+
332
+ return (
333
+ <Input
334
+ label="Full Name"
335
+ hint="Enter your first and last name"
336
+ error={nameError || undefined}
337
+ required
338
+ placeholder="John Doe"
339
+ value={name}
340
+ onValueChange={setName}
341
+ onBlur={validate}
342
+ />
343
+ );
344
+ }
345
+
346
+ // Compound mode for custom layouts
347
+ function CustomInput() {
348
+ const [value, setValue] = useState('');
349
+
350
+ return (
351
+ <Input value={value} onValueChange={setValue}>
352
+ <Input.Label>Email</Input.Label>
353
+ <Input.Field type="email" placeholder="you@example.com" />
354
+ <Input.Hint>We'll never share your email</Input.Hint>
355
+ <Input.Error>{/* error message here */}</Input.Error>
356
+ </Input>
357
+ );
358
+ }
359
+ ```
360
+
361
+ **Props:**
362
+
363
+ | Prop | Type | Default | Description |
364
+ | ----------------- | ------------------------- | -------- | ------------------------------------------------------------ |
365
+ | `label` | `ReactNode` | — | Visible label text |
366
+ | `hint` | `ReactNode` | — | Hint/description text |
367
+ | `error` | `ReactNode` | — | Error message (enables `aria-invalid`) |
368
+ | `value` | `string` | — | Controlled value |
369
+ | `defaultValue` | `string` | `''` | Default value (uncontrolled) |
370
+ | `onValueChange` | `(value: string) => void` | — | Change handler |
371
+ | `type` | `string` | `'text'` | Input type (text, email, password, number, tel, url, search) |
372
+ | `placeholder` | `string` | — | Placeholder text |
373
+ | `required` | `boolean` | `false` | Required field |
374
+ | `disabled` | `boolean` | `false` | Disable the input |
375
+ | `readOnly` | `boolean` | `false` | Read-only input |
376
+ | `unstyled` | `boolean` | `false` | Remove default styles |
377
+ | `aria-label` | `string` | — | Accessible label (when no visible label) |
378
+ | `aria-labelledby` | `string` | — | ID of labelling element |
379
+
380
+ **Customization:**
381
+
382
+ ```css
383
+ .my-input {
384
+ --compa11y-input-border: 1px solid #ccc;
385
+ --compa11y-input-border-focus: #10b981;
386
+ --compa11y-input-border-error: #ef4444;
387
+ --compa11y-input-radius: 8px;
388
+ --compa11y-input-label-weight: 600;
389
+ --compa11y-input-error-color: #ef4444;
390
+ --compa11y-input-hint-color: #666;
391
+ --compa11y-focus-color: #10b981;
392
+ }
393
+ ```
394
+
395
+ ### Button
396
+
397
+ ```tsx
398
+ import { Button } from '@compa11y/react';
399
+
400
+ function Actions() {
401
+ return (
402
+ <div style={{ display: 'flex', gap: '0.5rem' }}>
403
+ <Button variant="primary" onClick={handleSave}>
404
+ Save
405
+ </Button>
406
+ <Button variant="outline" onClick={handleCancel}>
407
+ Cancel
408
+ </Button>
409
+ <Button variant="danger" onClick={handleDelete}>
410
+ Delete
411
+ </Button>
412
+ </div>
413
+ );
414
+ }
415
+
416
+ // Loading state
417
+ function SaveButton() {
418
+ const [loading, setLoading] = useState(false);
419
+
420
+ const handleSave = async () => {
421
+ setLoading(true);
422
+ await saveData();
423
+ setLoading(false);
424
+ };
425
+
426
+ return (
427
+ <Button variant="primary" loading={loading} onClick={handleSave}>
428
+ {loading ? 'Saving...' : 'Save Changes'}
429
+ </Button>
430
+ );
431
+ }
432
+
433
+ // Disabled but discoverable (stays in tab order)
434
+ <Button variant="primary" disabled discoverable>
435
+ Unavailable
436
+ </Button>;
437
+ ```
438
+
439
+ **Props:**
440
+
441
+ | Prop | Type | Default | Description |
442
+ | -------------- | -------------------------------------------------------------- | ------------- | ------------------------------------------------------ |
443
+ | `variant` | `'primary' \| 'secondary' \| 'danger' \| 'outline' \| 'ghost'` | `'secondary'` | Visual variant |
444
+ | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Button size |
445
+ | `type` | `'button' \| 'submit' \| 'reset'` | `'button'` | HTML button type |
446
+ | `disabled` | `boolean` | `false` | Disable the button |
447
+ | `discoverable` | `boolean` | `false` | Keep disabled button in tab order with `aria-disabled` |
448
+ | `loading` | `boolean` | `false` | Loading state (shows spinner, sets `aria-busy`) |
449
+ | `unstyled` | `boolean` | `false` | Remove default styles |
450
+ | `aria-label` | `string` | — | Accessible label |
451
+
452
+ **Customization:**
453
+
454
+ ```css
455
+ [data-compa11y-button] {
456
+ --compa11y-button-radius: 8px;
457
+ --compa11y-button-font-weight: 600;
458
+ --compa11y-button-primary-bg: #10b981;
459
+ --compa11y-button-primary-color: white;
460
+ --compa11y-button-danger-bg: #ef4444;
461
+ --compa11y-button-danger-color: white;
462
+ --compa11y-button-disabled-opacity: 0.5;
463
+ --compa11y-focus-color: #10b981;
464
+ }
465
+ ```
466
+
467
+ ### Textarea
468
+
469
+ ```tsx
470
+ import { Textarea } from '@compa11y/react';
471
+
472
+ function FeedbackForm() {
473
+ const [desc, setDesc] = useState('');
474
+ const [descError, setDescError] = useState('');
475
+
476
+ const validate = () => {
477
+ if (!desc.trim()) setDescError('Description is required');
478
+ else if (desc.trim().length < 10)
479
+ setDescError('Must be at least 10 characters');
480
+ else setDescError('');
481
+ };
482
+
483
+ return (
484
+ <Textarea
485
+ label="Description"
486
+ hint="Provide at least 10 characters"
487
+ error={descError || undefined}
488
+ required
489
+ rows={4}
490
+ placeholder="Enter a description..."
491
+ value={desc}
492
+ onValueChange={setDesc}
493
+ onBlur={validate}
494
+ />
495
+ );
496
+ }
497
+
498
+ // Compound mode for custom layouts
499
+ function CustomTextarea() {
500
+ const [value, setValue] = useState('');
501
+
502
+ return (
503
+ <Textarea value={value} onValueChange={setValue}>
504
+ <Textarea.Label>Bio</Textarea.Label>
505
+ <Textarea.Field rows={5} placeholder="Tell us about yourself..." />
506
+ <Textarea.Hint>Markdown is supported</Textarea.Hint>
507
+ <Textarea.Error>{/* error message here */}</Textarea.Error>
508
+ </Textarea>
509
+ );
510
+ }
511
+ ```
512
+
513
+ **Props:**
514
+
515
+ | Prop | Type | Default | Description |
516
+ | ----------------- | ------------------------- | ------------ | -------------------------------------------------- |
517
+ | `label` | `ReactNode` | — | Visible label text |
518
+ | `hint` | `ReactNode` | — | Hint/description text |
519
+ | `error` | `ReactNode` | — | Error message (enables `aria-invalid`) |
520
+ | `value` | `string` | — | Controlled value |
521
+ | `defaultValue` | `string` | `''` | Default value (uncontrolled) |
522
+ | `onValueChange` | `(value: string) => void` | — | Change handler |
523
+ | `rows` | `number` | `3` | Number of visible text rows |
524
+ | `resize` | `string` | `'vertical'` | Resize behavior (none, both, horizontal, vertical) |
525
+ | `placeholder` | `string` | — | Placeholder text |
526
+ | `required` | `boolean` | `false` | Required field |
527
+ | `disabled` | `boolean` | `false` | Disable the textarea |
528
+ | `readOnly` | `boolean` | `false` | Read-only textarea |
529
+ | `unstyled` | `boolean` | `false` | Remove default styles |
530
+ | `aria-label` | `string` | — | Accessible label (when no visible label) |
531
+ | `aria-labelledby` | `string` | — | ID of labelling element |
532
+
533
+ **Customization:**
534
+
535
+ ```css
536
+ .my-textarea {
537
+ --compa11y-textarea-border: 1px solid #ccc;
538
+ --compa11y-textarea-border-focus: #10b981;
539
+ --compa11y-textarea-border-error: #ef4444;
540
+ --compa11y-textarea-radius: 8px;
541
+ --compa11y-textarea-label-weight: 600;
542
+ --compa11y-textarea-error-color: #ef4444;
543
+ --compa11y-textarea-hint-color: #666;
544
+ --compa11y-focus-color: #10b981;
545
+ }
546
+ ```
547
+
128
548
  ## Hooks
129
549
 
130
550
  ### useFocusTrap
@@ -245,6 +665,68 @@ All components are unstyled. Use `data-*` attributes for state-based styling:
245
665
  [data-compa11y-combobox-option][data-highlighted='true'] {
246
666
  background: #f0f0f0;
247
667
  }
668
+
669
+ /* Listbox */
670
+ [data-compa11y-listbox] {
671
+ border: 1px solid #e0e0e0;
672
+ border-radius: 6px;
673
+ max-height: 300px;
674
+ overflow-y: auto;
675
+ }
676
+
677
+ [data-compa11y-listbox-option][data-focused='true'] {
678
+ background: #e6f0ff;
679
+ }
680
+
681
+ [data-compa11y-listbox-option][data-selected='true'] {
682
+ background: #e6f0ff;
683
+ font-weight: 600;
684
+ }
685
+
686
+ /* Select */
687
+ [data-compa11y-select] {
688
+ position: relative;
689
+ width: 300px;
690
+ }
691
+
692
+ [data-compa11y-select-trigger] {
693
+ width: 100%;
694
+ display: flex;
695
+ align-items: center;
696
+ justify-content: space-between;
697
+ padding: 0.5rem 2rem 0.5rem 0.75rem;
698
+ border: 1px solid #ccc;
699
+ border-radius: 4px;
700
+ background: white;
701
+ cursor: pointer;
702
+ text-align: left;
703
+ }
704
+
705
+ [data-compa11y-select-listbox] {
706
+ position: absolute;
707
+ top: 100%;
708
+ left: 0;
709
+ right: 0;
710
+ margin-top: 4px;
711
+ background: white;
712
+ border: 1px solid #e0e0e0;
713
+ border-radius: 4px;
714
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
715
+ max-height: 200px;
716
+ overflow-y: auto;
717
+ z-index: 1000;
718
+ list-style: none;
719
+ padding: 0;
720
+ }
721
+
722
+ [data-compa11y-select-option][data-highlighted='true'] {
723
+ background: #f0f0f0;
724
+ }
725
+
726
+ [data-compa11y-select-option][data-selected='true'] {
727
+ background: #e6f0ff;
728
+ font-weight: 600;
729
+ }
248
730
  ```
249
731
 
250
732
  ## License