@fpkit/acss 5.0.0 → 6.0.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,665 @@
1
+ import { Meta, Canvas, Story, Controls } from "@storybook/addon-docs/blocks";
2
+ import * as CheckboxStories from "./input.stories";
3
+
4
+ <Meta title="FP.REACT Forms/Checkbox/Guide" />
5
+
6
+ # Checkbox Component
7
+
8
+ An accessible checkbox input component with automatic label association,
9
+ simplified boolean API, and semantic size variants.
10
+
11
+ ## Overview
12
+
13
+ The `Checkbox` component provides a clean, accessible API for checkbox inputs
14
+ with automatic label association via `htmlFor`, boolean `onChange` handler, and
15
+ predefined size variants. Built on top of the base `Input` component, it
16
+ maintains full WCAG 2.1 AA compliance while offering an enhanced developer
17
+ experience.
18
+
19
+ ## Features
20
+
21
+ ✅ **Semantic Size Prop** - Predefined `xs`, `sm`, `md`, `lg` sizes via
22
+ intuitive prop API ✅ **Boolean onChange** - Simplified
23
+ `onChange={(checked) => ...}` instead of event objects ✅ **Auto Label
24
+ Association** - Uses `htmlFor` for proper accessibility ✅ **CSS Variable
25
+ Overrides** - Custom sizes beyond presets via `styles` prop ✅ **WCAG 2.1 AA
26
+ Compliant** - aria-disabled pattern for screen reader compatibility ✅
27
+ **Controlled & Uncontrolled** - Both `checked` and `defaultChecked` modes
28
+ supported ✅ **Validation States** - Built-in error, valid, and neutral states
29
+ ✅ **Keyboard Accessible** - Space key toggles, full keyboard navigation ✅
30
+ **Focus Indicators** - High contrast focus rings for keyboard users
31
+
32
+ ---
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ npm install @fpkit/acss
38
+ ```
39
+
40
+ ## Import
41
+
42
+ ```tsx
43
+ import { Checkbox } from "@fpkit/acss";
44
+ // Import styles
45
+ import "@fpkit/acss/styles";
46
+ ```
47
+
48
+ ---
49
+
50
+ ## Basic Usage
51
+
52
+ ### Uncontrolled Mode
53
+
54
+ The simplest way to use a checkbox - React doesn't manage the state:
55
+
56
+ ```tsx
57
+ <Checkbox
58
+ id="terms"
59
+ label="I accept the terms and conditions"
60
+ defaultChecked={false}
61
+ />
62
+ ```
63
+
64
+ ### Controlled Mode
65
+
66
+ For forms where you need to track the checkbox state:
67
+
68
+ ```tsx
69
+ import { useState } from "react";
70
+
71
+ function MyForm() {
72
+ const [agreed, setAgreed] = useState(false);
73
+
74
+ return (
75
+ <Checkbox
76
+ id="terms"
77
+ label="I accept the terms"
78
+ checked={agreed}
79
+ onChange={setAgreed} // Boolean API - no event object!
80
+ />
81
+ );
82
+ }
83
+ ```
84
+
85
+ ---
86
+
87
+ ## Size Variants
88
+
89
+ ### Using the `size` Prop (Recommended)
90
+
91
+ The `size` prop provides predefined size variants optimized for common use
92
+ cases:
93
+
94
+ <Canvas of={CheckboxStories.CheckboxCustomSize} />
95
+
96
+ ```tsx
97
+ // Extra Small - Compact layouts
98
+ <Checkbox id="xs" label="Extra Small" size="xs" />
99
+
100
+ // Small - Dense forms
101
+ <Checkbox id="sm" label="Small" size="sm" />
102
+
103
+ // Medium - Default, optimal for most cases
104
+ <Checkbox id="md" label="Medium (Default)" size="md" />
105
+
106
+ // Large - Touch-friendly interfaces
107
+ <Checkbox id="lg" label="Large" size="lg" />
108
+ ```
109
+
110
+ | Size | Dimensions | Gap | Use Case |
111
+ | ---- | --------------- | --------------- | ---------------------------------------- |
112
+ | `xs` | 0.875rem (14px) | 0.375rem (6px) | Compact forms, space-constrained UIs |
113
+ | `sm` | 1rem (16px) | 0.5rem (8px) | Dense layouts, secondary forms |
114
+ | `md` | 1.25rem (20px) | 0.5rem (8px) | **Default** - Optimal for most use cases |
115
+ | `lg` | 1.5rem (24px) | 0.625rem (10px) | Touch-friendly, mobile-first, prominent |
116
+
117
+ ### Custom Sizes via CSS Variables
118
+
119
+ For sizes beyond the standard presets, use the `styles` prop to override CSS
120
+ variables:
121
+
122
+ <Canvas of={CheckboxStories.CheckboxCustomSizeCSSOverride} />
123
+
124
+ ```tsx
125
+ <Checkbox
126
+ id="custom"
127
+ label="Custom sized checkbox"
128
+ styles={{
129
+ "--checkbox-size": "2rem", // 32px
130
+ "--checkbox-gap": "1rem", // 16px
131
+ }}
132
+ />
133
+ ```
134
+
135
+ **When to use each approach:**
136
+
137
+ - **Size prop**: For 90% of use cases - provides semantic, consistent sizes
138
+ - **CSS variables**: For custom designs, branding, or sizes outside standard
139
+ range
140
+
141
+ ---
142
+
143
+ ## Validation & States
144
+
145
+ ### Required Field
146
+
147
+ Display a red asterisk indicator:
148
+
149
+ ```tsx
150
+ <Checkbox id="required" label="This field is required" required />
151
+ ```
152
+
153
+ Sets `aria-required="true"` for screen readers.
154
+
155
+ ### Error State
156
+
157
+ Show validation errors with associated error message:
158
+
159
+ ```tsx
160
+ <Checkbox
161
+ id="error"
162
+ label="I accept the terms"
163
+ validationState="invalid"
164
+ errorMessage="You must accept the terms to continue"
165
+ />
166
+ ```
167
+
168
+ The error message is automatically linked via `aria-describedby`.
169
+
170
+ ### Valid State
171
+
172
+ Indicate successful validation:
173
+
174
+ ```tsx
175
+ <Checkbox
176
+ id="valid"
177
+ label="I accept the terms"
178
+ checked={true}
179
+ validationState="valid"
180
+ />
181
+ ```
182
+
183
+ ### Example: Form with Validation
184
+
185
+ ```tsx
186
+ function TermsAcceptance() {
187
+ const [agreed, setAgreed] = useState(false);
188
+ const [attempted, setAttempted] = useState(false);
189
+
190
+ const handleSubmit = () => {
191
+ setAttempted(true);
192
+ if (!agreed) return; // Block submission
193
+ // Proceed...
194
+ };
195
+
196
+ return (
197
+ <>
198
+ <Checkbox
199
+ id="terms"
200
+ label="I accept the terms and conditions"
201
+ checked={agreed}
202
+ onChange={setAgreed}
203
+ required
204
+ validationState={attempted && !agreed ? "invalid" : "none"}
205
+ errorMessage={attempted && !agreed ? "Required field" : undefined}
206
+ />
207
+ <button onClick={handleSubmit}>Submit</button>
208
+ </>
209
+ );
210
+ }
211
+ ```
212
+
213
+ ---
214
+
215
+ ## Disabled State
216
+
217
+ ### WCAG-Compliant Disabled Pattern
218
+
219
+ Uses `aria-disabled` instead of native `disabled` for better accessibility:
220
+
221
+ <Canvas of={CheckboxStories.CheckboxDisabled} />
222
+
223
+ ```tsx
224
+ <Checkbox id="disabled" label="Disabled option" disabled defaultChecked />
225
+ ```
226
+
227
+ **Why aria-disabled?**
228
+
229
+ | Feature | `aria-disabled` | Native `disabled` |
230
+ | -------------------------- | --------------- | ----------------- |
231
+ | Keyboard focusable | ✅ Yes | ❌ No |
232
+ | Screen reader discoverable | ✅ Yes | ❌ No |
233
+ | Can show tooltips | ✅ Yes | ❌ No |
234
+ | WCAG 2.1.1 compliant | ✅ Yes | ⚠️ Questionable |
235
+
236
+ **Behavior:**
237
+
238
+ - Remains in keyboard tab order (discoverable)
239
+ - Prevents all interactions (click, change events)
240
+ - Screen readers announce "disabled" state
241
+ - Visual opacity applied via `--checkbox-disabled-opacity`
242
+
243
+ ---
244
+
245
+ ## Hint Text & Help
246
+
247
+ Provide contextual help without cluttering the label:
248
+
249
+ <Canvas of={CheckboxStories.CheckboxWithHint} />
250
+
251
+ ```tsx
252
+ <Checkbox
253
+ id="2fa"
254
+ label="Enable two-factor authentication"
255
+ hintText="Adds an extra layer of security to your account"
256
+ />
257
+ ```
258
+
259
+ Hint text is associated via `aria-describedby` for screen readers.
260
+
261
+ ---
262
+
263
+ ## Checkbox Groups
264
+
265
+ Group related checkboxes using semantic HTML:
266
+
267
+ <Canvas of={CheckboxStories.CheckboxGroup} />
268
+
269
+ ```tsx
270
+ <fieldset>
271
+ <legend>Notification Preferences</legend>
272
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
273
+ <Checkbox
274
+ id="email"
275
+ name="notifications"
276
+ value="email"
277
+ label="Email notifications"
278
+ defaultChecked
279
+ size="lg"
280
+ />
281
+ <Checkbox
282
+ id="sms"
283
+ name="notifications"
284
+ value="sms"
285
+ label="SMS notifications"
286
+ size="lg"
287
+ />
288
+ <Checkbox
289
+ id="push"
290
+ name="notifications"
291
+ value="push"
292
+ label="Push notifications"
293
+ size="lg"
294
+ />
295
+ </div>
296
+ </fieldset>
297
+ ```
298
+
299
+ **Best Practices:**
300
+
301
+ - Use `<fieldset>` to group related checkboxes
302
+ - Provide a `<legend>` describing the group
303
+ - Use the same `name` attribute for backend processing
304
+ - Unique `id` for each checkbox
305
+ - Different `value` for each option
306
+
307
+ ---
308
+
309
+ ## Accessibility
310
+
311
+ ### WCAG 2.1 AA Compliance
312
+
313
+ | Success Criterion | Status | Implementation |
314
+ | ------------------------------ | ------- | ---------------------------------------------- |
315
+ | **2.1.1 Keyboard** | ✅ Pass | Space key toggles, full keyboard navigation |
316
+ | **2.4.7 Focus Visible** | ✅ Pass | High contrast focus rings via `:focus-visible` |
317
+ | **3.3.1 Error Identification** | ✅ Pass | Error messages via `aria-describedby` |
318
+ | **3.3.2 Labels** | ✅ Pass | Required `label` prop with `htmlFor` |
319
+ | **4.1.2 Name, Role, Value** | ✅ Pass | Proper ARIA attributes |
320
+
321
+ ### Screen Reader Support
322
+
323
+ - **Label announcement**: Automatically associated via `htmlFor={id}`
324
+ - **Required state**: Announced via `aria-required="true"` + visual asterisk
325
+ - **Error messages**: Linked via `aria-describedby` attribute
326
+ - **Disabled state**: Uses `aria-disabled="true"` (remains focusable)
327
+ - **Validation state**: Communicated via `aria-invalid`
328
+
329
+ Tested with:
330
+
331
+ - NVDA (Windows)
332
+ - JAWS (Windows)
333
+ - VoiceOver (macOS, iOS)
334
+ - TalkBack (Android)
335
+
336
+ ### Keyboard Navigation
337
+
338
+ | Key | Action |
339
+ | ------------- | ---------------------- |
340
+ | `Tab` | Focus checkbox |
341
+ | `Space` | Toggle checked state |
342
+ | `Shift + Tab` | Focus previous element |
343
+
344
+ ---
345
+
346
+ ## Props API
347
+
348
+ ### CheckboxProps
349
+
350
+ | Prop | Type | Default | Required | Description |
351
+ | ----------------- | -------------------------------- | ------------------ | -------- | ------------------------------------------------ |
352
+ | `id` | `string` | - | ✅ Yes | Unique identifier for label association |
353
+ | `label` | `ReactNode` | - | ✅ Yes | Label text or element displayed next to checkbox |
354
+ | `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'md'` | No | Predefined size variant |
355
+ | `checked` | `boolean` | - | No | Controlled mode: current checked state |
356
+ | `defaultChecked` | `boolean` | `false` | No | Uncontrolled mode: initial checked state |
357
+ | `onChange` | `(checked: boolean) => void` | - | No | Boolean change handler (not event object!) |
358
+ | `disabled` | `boolean` | `false` | No | Disable checkbox (aria-disabled pattern) |
359
+ | `required` | `boolean` | `false` | No | Mark as required (shows asterisk) |
360
+ | `validationState` | `'valid' \| 'invalid' \| 'none'` | `'none'` | No | Validation state |
361
+ | `errorMessage` | `string` | - | No | Error message text (linked via aria-describedby) |
362
+ | `hintText` | `string` | - | No | Helper text below checkbox |
363
+ | `name` | `string` | - | No | Form input name attribute |
364
+ | `value` | `string` | `'on'` | No | Form submission value when checked |
365
+ | `classes` | `string` | - | No | Custom CSS classes for wrapper div |
366
+ | `inputClasses` | `string` | `'checkbox-input'` | No | Custom CSS classes for input element |
367
+ | `styles` | `CSSProperties` | - | No | Inline styles with CSS variables |
368
+
369
+ ---
370
+
371
+ ## CSS Customization
372
+
373
+ See [CHECKBOX-STYLES.mdx](./CHECKBOX-STYLES.mdx) for comprehensive CSS
374
+ documentation.
375
+
376
+ ### Quick Reference
377
+
378
+ Override these CSS variables for custom styling:
379
+
380
+ ```tsx
381
+ <Checkbox
382
+ id="custom"
383
+ label="Custom styled"
384
+ styles={{
385
+ "--checkbox-size": "2rem",
386
+ "--checkbox-gap": "1rem",
387
+ "--checkbox-radius": "0.5rem",
388
+ "--checkbox-checked-bg": "#0066cc",
389
+ "--checkbox-focus-ring-color": "#ff0000",
390
+ }}
391
+ />
392
+ ```
393
+
394
+ ---
395
+
396
+ ## Examples
397
+
398
+ ### Multi-Step Form
399
+
400
+ ```tsx
401
+ function MultiStepForm() {
402
+ const [step1Complete, setStep1Complete] = useState(false);
403
+ const [step2Complete, setStep2Complete] = useState(false);
404
+
405
+ return (
406
+ <div>
407
+ <Checkbox
408
+ id="step1"
409
+ label="Complete registration details"
410
+ checked={step1Complete}
411
+ onChange={setStep1Complete}
412
+ size="lg"
413
+ />
414
+ <Checkbox
415
+ id="step2"
416
+ label="Verify email address"
417
+ checked={step2Complete}
418
+ onChange={setStep2Complete}
419
+ disabled={!step1Complete}
420
+ size="lg"
421
+ />
422
+ </div>
423
+ );
424
+ }
425
+ ```
426
+
427
+ ### Select All Pattern
428
+
429
+ ```tsx
430
+ function SelectAllList() {
431
+ const [items, setItems] = useState([
432
+ { id: 1, label: "Item 1", checked: false },
433
+ { id: 2, label: "Item 2", checked: false },
434
+ { id: 3, label: "Item 3", checked: false },
435
+ ]);
436
+
437
+ const allChecked = items.every((item) => item.checked);
438
+ const someChecked = items.some((item) => item.checked) && !allChecked;
439
+
440
+ const toggleAll = (checked: boolean) => {
441
+ setItems(items.map((item) => ({ ...item, checked })));
442
+ };
443
+
444
+ const toggleItem = (id: number, checked: boolean) => {
445
+ setItems(
446
+ items.map((item) => (item.id === id ? { ...item, checked } : item))
447
+ );
448
+ };
449
+
450
+ return (
451
+ <div>
452
+ <Checkbox
453
+ id="select-all"
454
+ label="Select All"
455
+ checked={allChecked}
456
+ onChange={toggleAll}
457
+ styles={{
458
+ "--checkbox-fw": someChecked ? "600" : "400",
459
+ }}
460
+ />
461
+ <hr />
462
+ {items.map((item) => (
463
+ <Checkbox
464
+ key={item.id}
465
+ id={`item-${item.id}`}
466
+ label={item.label}
467
+ checked={item.checked}
468
+ onChange={(checked) => toggleItem(item.id, checked)}
469
+ />
470
+ ))}
471
+ </div>
472
+ );
473
+ }
474
+ ```
475
+
476
+ ---
477
+
478
+ ## Migration Guide
479
+
480
+ ### From v4.x to v5.0
481
+
482
+ **New Feature: Size Prop**
483
+
484
+ No breaking changes! The new `size` prop is optional and fully backward
485
+ compatible.
486
+
487
+ **Before (still works):**
488
+
489
+ ```tsx
490
+ <Checkbox
491
+ id="large"
492
+ label="Large checkbox"
493
+ styles={{ "--checkbox-size": "1.5rem" }}
494
+ />
495
+ ```
496
+
497
+ **After (recommended):**
498
+
499
+ ```tsx
500
+ <Checkbox id="large" label="Large checkbox" size="lg" />
501
+ ```
502
+
503
+ **Benefits of migration:**
504
+
505
+ - ✅ Cleaner API - prop instead of CSS variable string
506
+ - ✅ Type safety - TypeScript autocomplete for sizes
507
+ - ✅ Consistency - matches Button component pattern
508
+ - ✅ Flexibility - `styles` prop still works for custom sizes
509
+
510
+ ---
511
+
512
+ ## Browser Support
513
+
514
+ - ✅ Chrome 105+ (`:has()` selector support)
515
+ - ✅ Firefox 121+
516
+ - ✅ Safari 15.4+
517
+ - ✅ Edge 105+
518
+ - ✅ Mobile Safari (iOS 15.4+)
519
+ - ✅ Chrome Android
520
+
521
+ **Graceful degradation:** Older browsers receive standard checkbox styling
522
+ without advanced `:has()` selectors.
523
+
524
+ ---
525
+
526
+ ## TypeScript
527
+
528
+ Full TypeScript support with exported types:
529
+
530
+ ```tsx
531
+ import { Checkbox, type CheckboxProps } from "@fpkit/acss";
532
+
533
+ // Custom wrapper with pre-configured props
534
+ const TermsCheckbox: React.FC<Omit<CheckboxProps, "label">> = (props) => {
535
+ return (
536
+ <Checkbox label="I accept the terms and conditions" size="lg" {...props} />
537
+ );
538
+ };
539
+
540
+ // Type-safe onChange handler
541
+ const handleChange: CheckboxProps["onChange"] = (checked) => {
542
+ console.log("Checked:", checked); // boolean, not event!
543
+ };
544
+ ```
545
+
546
+ ---
547
+
548
+ ## Testing
549
+
550
+ ### Unit Testing with React Testing Library
551
+
552
+ ```tsx
553
+ import { render, screen } from "@testing-library/react";
554
+ import userEvent from "@testing-library/user-event";
555
+ import { Checkbox } from "@fpkit/acss";
556
+
557
+ test("toggles on click", async () => {
558
+ const handleChange = jest.fn();
559
+
560
+ render(<Checkbox id="test" label="Test checkbox" onChange={handleChange} />);
561
+
562
+ const checkbox = screen.getByRole("checkbox");
563
+ await userEvent.click(checkbox);
564
+
565
+ expect(handleChange).toHaveBeenCalledWith(true);
566
+ expect(checkbox).toBeChecked();
567
+ });
568
+
569
+ test("label click toggles checkbox", async () => {
570
+ render(<Checkbox id="test" label="Click me" />);
571
+
572
+ const label = screen.getByText("Click me");
573
+ await userEvent.click(label);
574
+
575
+ expect(screen.getByRole("checkbox")).toBeChecked();
576
+ });
577
+
578
+ test("size prop applies data attribute", () => {
579
+ const { container } = render(<Checkbox id="test" label="Test" size="lg" />);
580
+
581
+ const wrapper = container.querySelector('[data-checkbox-size="lg"]');
582
+ expect(wrapper).toBeInTheDocument();
583
+ });
584
+ ```
585
+
586
+ ### Accessibility Testing
587
+
588
+ ```tsx
589
+ test("has proper ARIA attributes", () => {
590
+ render(
591
+ <Checkbox
592
+ id="test"
593
+ label="Test"
594
+ required
595
+ validationState="invalid"
596
+ errorMessage="Required"
597
+ />
598
+ );
599
+
600
+ const checkbox = screen.getByRole("checkbox");
601
+ expect(checkbox).toHaveAttribute("aria-required", "true");
602
+ expect(checkbox).toHaveAttribute("aria-invalid", "true");
603
+ expect(checkbox).toHaveAttribute("aria-describedby");
604
+ });
605
+
606
+ test("disabled checkbox is focusable", async () => {
607
+ render(<Checkbox id="test" label="Disabled" disabled />);
608
+
609
+ const checkbox = screen.getByRole("checkbox");
610
+ expect(checkbox).toHaveAttribute("aria-disabled", "true");
611
+
612
+ // Should be focusable
613
+ await userEvent.tab();
614
+ expect(checkbox).toHaveFocus();
615
+
616
+ // But interactions should be prevented
617
+ await userEvent.click(checkbox);
618
+ expect(checkbox).not.toBeChecked();
619
+ });
620
+ ```
621
+
622
+ ---
623
+
624
+ ## Related Components
625
+
626
+ - [Input](../input) - Base input component
627
+ - [Field](../field) - Form field wrapper
628
+ - [Button](../../buttons/button) - Button component (also uses size prop)
629
+
630
+ ---
631
+
632
+ ## Changelog
633
+
634
+ ### v5.0.0 (2025-01-11)
635
+
636
+ **✨ Added:**
637
+
638
+ - New `size` prop with `xs`, `sm`, `md`, `lg` variants
639
+ - Size tokens in CSS: `--checkbox-size-xs/sm/md/lg`
640
+ - Gap tokens: `--checkbox-gap-xs/sm/md/lg`
641
+ - Data attribute pattern: `data-checkbox-size`
642
+ - Comprehensive documentation (this guide + STYLES guide)
643
+
644
+ **🔧 Changed:**
645
+
646
+ - None - fully backward compatible
647
+
648
+ **🗑️ Deprecated:**
649
+
650
+ - None
651
+
652
+ ---
653
+
654
+ ## Support & Resources
655
+
656
+ - **NPM**: [@fpkit/acss](https://www.npmjs.com/package/@fpkit/acss)
657
+ - **GitHub**: [Report issues](https://github.com/your-org/fpkit/issues)
658
+ - **Storybook**: Interactive examples
659
+ - **API Docs**: See Props API section above
660
+
661
+ ---
662
+
663
+ ## License
664
+
665
+ MIT © fpkit
@@ -85,6 +85,34 @@ div:has(> input[type="checkbox"]) {
85
85
  color: var(--checkbox-valid-label-color, currentColor);
86
86
  }
87
87
  }
88
+
89
+ /* ==========================================================================
90
+ Size Variants - Applied via data-checkbox-size attribute
91
+ ========================================================================== */
92
+
93
+ // Extra Small variant
94
+ &[data-checkbox-size~="xs"] {
95
+ --checkbox-size: var(--checkbox-size-xs);
96
+ --checkbox-gap: var(--checkbox-gap-xs, 0.375rem);
97
+ }
98
+
99
+ // Small variant
100
+ &[data-checkbox-size~="sm"] {
101
+ --checkbox-size: var(--checkbox-size-sm);
102
+ --checkbox-gap: var(--checkbox-gap-sm, 0.5rem);
103
+ }
104
+
105
+ // Medium variant (default - no attribute needed, but included for explicitness)
106
+ &[data-checkbox-size~="md"] {
107
+ --checkbox-size: var(--checkbox-size-md);
108
+ --checkbox-gap: var(--checkbox-gap-md, 0.5rem);
109
+ }
110
+
111
+ // Large variant
112
+ &[data-checkbox-size~="lg"] {
113
+ --checkbox-size: var(--checkbox-size-lg);
114
+ --checkbox-gap: var(--checkbox-gap-lg, 0.625rem);
115
+ }
88
116
  }
89
117
 
90
118
  // Checkbox label styling