@fpkit/acss 3.7.0 → 3.8.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,607 @@
1
+ import type { StoryObj, Meta } from "@storybook/react-vite";
2
+ import { within, userEvent, expect, fn } from "storybook/test";
3
+ import React from "react";
4
+
5
+ import { Checkbox } from "./checkbox";
6
+ import "./checkbox.scss";
7
+
8
+ const checkboxChanged = fn();
9
+
10
+ const meta = {
11
+ title: "FP.React Forms/Inputs/Checkbox",
12
+ component: Checkbox,
13
+ tags: ["beta"],
14
+ args: {
15
+ onChange: checkboxChanged,
16
+ },
17
+ parameters: {
18
+ actions: { argTypesRegex: "^on.*" },
19
+ },
20
+ } as Meta<typeof Checkbox>;
21
+
22
+ export default meta;
23
+ type Story = StoryObj<typeof Checkbox>;
24
+
25
+ /**
26
+ * Default checkbox with interactive play function testing.
27
+ *
28
+ * Tests keyboard navigation (Tab, Space) and mouse interaction.
29
+ */
30
+ export const CheckboxComponent: Story = {
31
+ args: {
32
+ id: "default-checkbox",
33
+ label: "Accept terms and conditions",
34
+ onChange: checkboxChanged,
35
+ },
36
+ play: async ({ canvasElement, step }) => {
37
+ const canvas = within(canvasElement);
38
+ const checkbox = canvas.getByRole("checkbox");
39
+
40
+ await step("Checkbox is rendered", async () => {
41
+ expect(checkbox).toBeInTheDocument();
42
+ expect(checkbox).not.toBeChecked();
43
+ });
44
+
45
+ await step("Checkbox gets focus on tab", async () => {
46
+ await userEvent.tab();
47
+ expect(checkbox).toHaveFocus();
48
+ });
49
+
50
+ await step("Checkbox toggles on space key", async () => {
51
+ await userEvent.keyboard("{space}");
52
+ expect(checkbox).toBeChecked();
53
+ });
54
+
55
+ await step("Checkbox toggles on click", async () => {
56
+ await userEvent.click(checkbox);
57
+ expect(checkbox).not.toBeChecked();
58
+ });
59
+
60
+ await step("onChange handler is called", async () => {
61
+ await userEvent.click(checkbox);
62
+ expect(checkboxChanged).toHaveBeenCalled();
63
+ });
64
+ },
65
+ };
66
+
67
+ /**
68
+ * Size variants: small (sm), medium (md), large (lg)
69
+ *
70
+ * All sizes maintain proper touch target accessibility.
71
+ */
72
+ export const Sizes: Story = {
73
+ render: () => (
74
+ <div style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
75
+ <Checkbox id="size-sm" label="Small checkbox" size="sm" />
76
+ <Checkbox id="size-md" label="Medium checkbox (default)" size="md" />
77
+ <Checkbox id="size-lg" label="Large checkbox" size="lg" />
78
+ </div>
79
+ ),
80
+ };
81
+
82
+ /**
83
+ * Color variants: primary, secondary, error, success
84
+ *
85
+ * All colors meet WCAG 2.1 AA contrast requirements (4.5:1 minimum).
86
+ */
87
+ export const Colors: Story = {
88
+ render: () => (
89
+ <div style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
90
+ <Checkbox
91
+ id="color-primary"
92
+ label="Primary (Blue - 4.68:1 contrast)"
93
+ color="primary"
94
+ defaultChecked
95
+ />
96
+ <Checkbox
97
+ id="color-secondary"
98
+ label="Secondary (Gray - 7.56:1 contrast)"
99
+ color="secondary"
100
+ defaultChecked
101
+ />
102
+ <Checkbox
103
+ id="color-error"
104
+ label="Error (Red - 5.14:1 contrast)"
105
+ color="error"
106
+ defaultChecked
107
+ />
108
+ <Checkbox
109
+ id="color-success"
110
+ label="Success (Green - 4.54:1 contrast)"
111
+ color="success"
112
+ defaultChecked
113
+ />
114
+ </div>
115
+ ),
116
+ };
117
+
118
+ /**
119
+ * Checkbox states: unchecked, checked, indeterminate, disabled
120
+ *
121
+ * Indeterminate state is useful for "select all" patterns.
122
+ */
123
+ export const States: Story = {
124
+ render: () => (
125
+ <div style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
126
+ <Checkbox id="state-unchecked" label="Unchecked" />
127
+ <Checkbox id="state-checked" label="Checked" defaultChecked />
128
+ <Checkbox id="state-indeterminate" label="Indeterminate" indeterminate />
129
+ <Checkbox
130
+ id="state-disabled-unchecked"
131
+ label="Disabled (unchecked)"
132
+ disabled
133
+ />
134
+ <Checkbox
135
+ id="state-disabled-checked"
136
+ label="Disabled (checked)"
137
+ disabled
138
+ defaultChecked
139
+ />
140
+ <Checkbox
141
+ id="state-disabled-indeterminate"
142
+ label="Disabled (indeterminate)"
143
+ disabled
144
+ indeterminate
145
+ />
146
+ </div>
147
+ ),
148
+ parameters: {
149
+ docs: {
150
+ description: {
151
+ story: `
152
+ Checkbox supports multiple states:
153
+ - **Unchecked**: Default state
154
+ - **Checked**: Selected state
155
+ - **Indeterminate**: Partial selection (e.g., some but not all items selected)
156
+ - **Disabled**: Non-interactive state (remains focusable for accessibility)
157
+
158
+ Disabled checkboxes use aria-disabled to maintain keyboard focusability per WCAG 2.1 AA.
159
+ `,
160
+ },
161
+ },
162
+ },
163
+ };
164
+
165
+ /**
166
+ * Checkbox with description helper text
167
+ *
168
+ * Description is linked via aria-describedby for screen readers.
169
+ */
170
+ export const WithDescription: Story = {
171
+ args: {
172
+ id: "description-checkbox",
173
+ label: "Enable email notifications",
174
+ description:
175
+ "Receive email updates about important changes to your account",
176
+ },
177
+ };
178
+
179
+ /**
180
+ * Checkbox with validation error
181
+ *
182
+ * Error message is linked via aria-errormessage when validationState="invalid".
183
+ */
184
+ export const WithError: Story = {
185
+ args: {
186
+ id: "error-checkbox",
187
+ label: "I accept the terms and conditions",
188
+ required: true,
189
+ validationState: "invalid",
190
+ errorMessage: "You must accept the terms to continue",
191
+ },
192
+ };
193
+
194
+ /**
195
+ * Label positioning: left vs right
196
+ *
197
+ * Label can appear before or after the checkbox.
198
+ */
199
+ export const LabelPositions: Story = {
200
+ render: () => (
201
+ <div style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
202
+ <Checkbox
203
+ id="label-right"
204
+ label="Label on right (default)"
205
+ labelPosition="right"
206
+ />
207
+ <Checkbox id="label-left" label="Label on left" labelPosition="left" />
208
+ </div>
209
+ ),
210
+ };
211
+
212
+ /**
213
+ * Controlled mode with React state
214
+ *
215
+ * Checkbox state is managed by parent component.
216
+ */
217
+ export const Controlled: Story = {
218
+ render: function ControlledCheckbox() {
219
+ const [checked, setChecked] = React.useState(false);
220
+
221
+ return (
222
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
223
+ <Checkbox
224
+ id="controlled-checkbox"
225
+ label="Subscribe to newsletter"
226
+ checked={checked}
227
+ onChange={(e) => setChecked(e.target.checked)}
228
+ />
229
+ <div style={{ fontSize: "0.875rem", color: "#6b7280" }}>
230
+ State: <strong>{checked ? "Checked" : "Unchecked"}</strong>
231
+ </div>
232
+ <button
233
+ type="button"
234
+ onClick={() => setChecked(!checked)}
235
+ style={{
236
+ padding: "0.5rem 1rem",
237
+ border: "1px solid #d1d5db",
238
+ borderRadius: "0.375rem",
239
+ background: "white",
240
+ cursor: "pointer",
241
+ }}
242
+ >
243
+ Toggle via button
244
+ </button>
245
+ </div>
246
+ );
247
+ },
248
+ parameters: {
249
+ docs: {
250
+ description: {
251
+ story: `
252
+ In controlled mode, the parent component manages the checkbox state via the \`checked\` prop and \`onChange\` handler.
253
+
254
+ Use controlled mode when you need to:
255
+ - Sync checkbox state with other UI elements
256
+ - Validate or transform input before updating state
257
+ - Integrate with form libraries
258
+ `,
259
+ },
260
+ },
261
+ },
262
+ };
263
+
264
+ /**
265
+ * Uncontrolled mode with defaultChecked
266
+ *
267
+ * Browser manages checkbox state internally.
268
+ */
269
+ export const Uncontrolled: Story = {
270
+ render: () => (
271
+ <div style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
272
+ <Checkbox
273
+ id="uncontrolled-unchecked"
274
+ label="Starts unchecked"
275
+ defaultChecked={false}
276
+ />
277
+ <Checkbox
278
+ id="uncontrolled-checked"
279
+ label="Starts checked"
280
+ defaultChecked={true}
281
+ />
282
+ </div>
283
+ ),
284
+ parameters: {
285
+ docs: {
286
+ description: {
287
+ story: `
288
+ In uncontrolled mode, use \`defaultChecked\` to set the initial state. The browser manages the state internally.
289
+
290
+ Use uncontrolled mode when:
291
+ - Building simple forms without complex validation
292
+ - You don't need to read the value until form submission
293
+ - State management overhead isn't needed
294
+ `,
295
+ },
296
+ },
297
+ },
298
+ };
299
+
300
+ /**
301
+ * Required field indicator
302
+ *
303
+ * Shows asterisk and sets aria-required for screen readers.
304
+ */
305
+ export const Required: Story = {
306
+ args: {
307
+ id: "required-checkbox",
308
+ label: "I agree to the privacy policy",
309
+ required: true,
310
+ },
311
+ };
312
+
313
+ /**
314
+ * Complex form with validation
315
+ *
316
+ * Demonstrates multiple checkboxes with description, validation, and states.
317
+ */
318
+ export const ComplexForm: Story = {
319
+ render: function ComplexFormExample() {
320
+ const [accepted, setAccepted] = React.useState(false);
321
+ const [newsletter, setNewsletter] = React.useState(false);
322
+ const [submitted, setSubmitted] = React.useState(false);
323
+
324
+ const handleSubmit = (e: React.FormEvent) => {
325
+ e.preventDefault();
326
+ setSubmitted(true);
327
+ };
328
+
329
+ return (
330
+ <form
331
+ onSubmit={handleSubmit}
332
+ style={{
333
+ display: "flex",
334
+ flexDirection: "column",
335
+ gap: "1.5rem",
336
+ maxWidth: "32rem",
337
+ }}
338
+ >
339
+ <h3 style={{ margin: 0, fontSize: "1.25rem", fontWeight: 600 }}>
340
+ Account Preferences
341
+ </h3>
342
+
343
+ <Checkbox
344
+ id="terms-checkbox"
345
+ label="I accept the terms and conditions"
346
+ required
347
+ checked={accepted}
348
+ onChange={(e) => setAccepted(e.target.checked)}
349
+ validationState={submitted && !accepted ? "invalid" : "none"}
350
+ errorMessage="You must accept the terms to continue"
351
+ description="Read our terms and conditions before accepting"
352
+ />
353
+
354
+ <Checkbox
355
+ id="newsletter-checkbox"
356
+ label="Subscribe to newsletter"
357
+ checked={newsletter}
358
+ onChange={(e) => setNewsletter(e.target.checked)}
359
+ description="Receive monthly updates about new features"
360
+ />
361
+
362
+ <button
363
+ type="submit"
364
+ style={{
365
+ padding: "0.75rem 1.5rem",
366
+ border: "none",
367
+ borderRadius: "0.375rem",
368
+ background: "#2563eb",
369
+ color: "white",
370
+ fontSize: "1rem",
371
+ fontWeight: 500,
372
+ cursor: "pointer",
373
+ }}
374
+ >
375
+ Submit
376
+ </button>
377
+
378
+ {submitted && accepted && (
379
+ <div
380
+ style={{
381
+ padding: "1rem",
382
+ background: "#d4edda",
383
+ border: "1px solid #c3e6cb",
384
+ borderRadius: "0.375rem",
385
+ color: "#155724",
386
+ }}
387
+ >
388
+ ✓ Form submitted successfully!
389
+ {newsletter && " You're subscribed to the newsletter."}
390
+ </div>
391
+ )}
392
+ </form>
393
+ );
394
+ },
395
+ parameters: {
396
+ docs: {
397
+ description: {
398
+ story: `
399
+ Complete form example demonstrating:
400
+ - Required checkboxes with validation
401
+ - Optional checkboxes
402
+ - Description text
403
+ - Error messages
404
+ - Form submission handling
405
+ `,
406
+ },
407
+ },
408
+ },
409
+ };
410
+
411
+ /**
412
+ * Select All pattern with indeterminate state
413
+ *
414
+ * Common pattern for batch selection with "select all" checkbox.
415
+ */
416
+ export const SelectAll: Story = {
417
+ render: function SelectAllExample() {
418
+ const items = [
419
+ { id: "item-1", label: "Item 1" },
420
+ { id: "item-2", label: "Item 2" },
421
+ { id: "item-3", label: "Item 3" },
422
+ { id: "item-4", label: "Item 4" },
423
+ ];
424
+
425
+ const [selectedItems, setSelectedItems] = React.useState<string[]>([]);
426
+
427
+ const allSelected = selectedItems.length === items.length;
428
+ const someSelected = selectedItems.length > 0 && !allSelected;
429
+
430
+ const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
431
+ if (e.target.checked) {
432
+ setSelectedItems(items.map((item) => item.id));
433
+ } else {
434
+ setSelectedItems([]);
435
+ }
436
+ };
437
+
438
+ const handleItemToggle = (itemId: string) => {
439
+ setSelectedItems((prev) =>
440
+ prev.includes(itemId)
441
+ ? prev.filter((id) => id !== itemId)
442
+ : [...prev, itemId]
443
+ );
444
+ };
445
+
446
+ return (
447
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
448
+ <Checkbox
449
+ id="select-all"
450
+ label="Select all"
451
+ checked={allSelected}
452
+ indeterminate={someSelected}
453
+ onChange={handleSelectAll}
454
+ styles={{
455
+ fontWeight: 600,
456
+ borderBottom: "1px solid #e5e7eb",
457
+ paddingBottom: "0.75rem",
458
+ }}
459
+ />
460
+
461
+ <div
462
+ style={{
463
+ display: "flex",
464
+ flexDirection: "column",
465
+ gap: "0.75rem",
466
+ paddingLeft: "1.5rem",
467
+ }}
468
+ >
469
+ {items.map((item) => (
470
+ <Checkbox
471
+ key={item.id}
472
+ id={item.id}
473
+ label={item.label}
474
+ checked={selectedItems.includes(item.id)}
475
+ onChange={() => handleItemToggle(item.id)}
476
+ />
477
+ ))}
478
+ </div>
479
+
480
+ <div
481
+ style={{
482
+ marginTop: "1rem",
483
+ padding: "0.75rem",
484
+ background: "#f9fafb",
485
+ border: "1px solid #e5e7eb",
486
+ borderRadius: "0.375rem",
487
+ fontSize: "0.875rem",
488
+ color: "#4b5563",
489
+ }}
490
+ >
491
+ Selected: <strong>{selectedItems.length}</strong> of{" "}
492
+ <strong>{items.length}</strong> items
493
+ </div>
494
+ </div>
495
+ );
496
+ },
497
+ parameters: {
498
+ docs: {
499
+ description: {
500
+ story: `
501
+ The "select all" pattern uses the indeterminate state to show partial selection:
502
+
503
+ - **Unchecked**: No items selected
504
+ - **Indeterminate**: Some items selected (shows dash)
505
+ - **Checked**: All items selected
506
+
507
+ This provides clear visual feedback about selection state at a glance.
508
+ `,
509
+ },
510
+ },
511
+ },
512
+ };
513
+
514
+ /**
515
+ * Custom styling with CSS variables
516
+ *
517
+ * Demonstrates customization using CSS custom properties.
518
+ */
519
+ export const CustomStyling: Story = {
520
+ render: () => (
521
+ <div style={{ display: "flex", flexDirection: "column", gap: "2rem" }}>
522
+ <div>
523
+ <h4 style={{ marginTop: 0, marginBottom: "1rem" }}>Custom Size</h4>
524
+ <Checkbox
525
+ id="custom-size"
526
+ label="Extra large checkbox"
527
+ defaultChecked
528
+ styles={
529
+ {
530
+ "--checkbox-size": "2rem",
531
+ "--checkbox-label-fs": "1.25rem",
532
+ } as React.CSSProperties
533
+ }
534
+ />
535
+ </div>
536
+
537
+ <div>
538
+ <h4 style={{ marginTop: 0, marginBottom: "1rem" }}>
539
+ Custom Colors (Brand Purple)
540
+ </h4>
541
+ <Checkbox
542
+ id="custom-color"
543
+ label="Custom brand color"
544
+ defaultChecked
545
+ styles={
546
+ {
547
+ "--checkbox-checked-bg": "#7c3aed",
548
+ "--checkbox-checked-border": "#7c3aed",
549
+ "--checkbox-focus-outline-color": "#c4b5fd",
550
+ } as React.CSSProperties
551
+ }
552
+ />
553
+ </div>
554
+
555
+ <div>
556
+ <h4 style={{ marginTop: 0, marginBottom: "1rem" }}>Rounded Style</h4>
557
+ <Checkbox
558
+ id="custom-rounded"
559
+ label="Fully rounded checkbox"
560
+ defaultChecked
561
+ styles={
562
+ {
563
+ "--checkbox-radius": "100rem",
564
+ } as React.CSSProperties
565
+ }
566
+ />
567
+ </div>
568
+
569
+ <div>
570
+ <h4 style={{ marginTop: 0, marginBottom: "1rem" }}>
571
+ No Border, Shadow Style
572
+ </h4>
573
+ <Checkbox
574
+ id="custom-shadow"
575
+ label="Shadow instead of border"
576
+ defaultChecked
577
+ styles={
578
+ {
579
+ "--checkbox-border": "none",
580
+ "--checkbox-bg": "#f3f4f6",
581
+ "--checkbox-checked-bg": "#10b981",
582
+ boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)",
583
+ } as React.CSSProperties
584
+ }
585
+ />
586
+ </div>
587
+ </div>
588
+ ),
589
+ parameters: {
590
+ docs: {
591
+ description: {
592
+ story: `
593
+ Customize checkbox appearance using CSS custom properties:
594
+
595
+ **Available Variables:**
596
+ - \`--checkbox-size\`: Checkbox dimensions
597
+ - \`--checkbox-checked-bg\`: Background when checked
598
+ - \`--checkbox-checked-border\`: Border when checked
599
+ - \`--checkbox-radius\`: Border radius
600
+ - \`--checkbox-focus-outline-color\`: Focus indicator color
601
+ - \`--checkbox-label-fs\`: Label font size
602
+ - And many more! See STYLES.mdx for complete reference.
603
+ `,
604
+ },
605
+ },
606
+ },
607
+ };