@telegraph/modal 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
@@ -1,14 +1,16 @@
1
- ![Telegraph by Knock](https://github.com/knocklabs/telegraph/assets/29106675/9b5022e3-b02c-4582-ba57-3d6171e45e44)
1
+ # 🪟 Modal
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/@telegraph/modal.svg)](https://www.npmjs.com/package/@telegraph/modal)
3
+ > Accessible modal dialog component with stacking support, animations, and focus management built on Radix UI.
4
4
 
5
- # @telegraph/modal
5
+ ![Telegraph by Knock](https://github.com/knocklabs/telegraph/assets/29106675/9b5022e3-b02c-4582-ba57-3d6171e45e44)
6
6
 
7
- > A modal component
7
+ [![npm version](https://img.shields.io/npm/v/@telegraph/modal.svg)](https://www.npmjs.com/package/@telegraph/modal)
8
+ [![minzipped size](https://img.shields.io/bundlephobia/minzip/@telegraph/modal)](https://bundlephobia.com/result?p=@telegraph/modal)
9
+ [![license](https://img.shields.io/npm/l/@telegraph/modal)](https://github.com/knocklabs/telegraph/blob/main/LICENSE)
8
10
 
9
- ## Installation Instructions
11
+ ## Installation
10
12
 
11
- ```
13
+ ```bash
12
14
  npm install @telegraph/modal
13
15
  ```
14
16
 
@@ -18,15 +20,1047 @@ Pick one:
18
20
 
19
21
  Via CSS (preferred):
20
22
 
21
- ```
22
- @import "@telegraph/modal"
23
+ ```css
24
+ @import "@telegraph/modal";
23
25
  ```
24
26
 
25
27
  Via Javascript:
26
28
 
27
- ```
28
- import "@telegraph/modal/default.css"
29
+ ```tsx
30
+ import "@telegraph/modal/default.css";
29
31
  ```
30
32
 
31
33
  > Then, include `className="tgph"` on the farthest parent element wrapping the telegraph components
32
34
 
35
+ ## Quick Start
36
+
37
+ ```tsx
38
+ import { Button } from "@telegraph/button";
39
+ import { Modal } from "@telegraph/modal";
40
+ import { useState } from "react";
41
+
42
+ export const BasicModal = () => {
43
+ const [open, setOpen] = useState(false);
44
+
45
+ return (
46
+ <>
47
+ <Button onClick={() => setOpen(true)}>Open Modal</Button>
48
+
49
+ <Modal.Root open={open} onOpenChange={setOpen} a11yTitle="Settings">
50
+ <Modal.Content>
51
+ <Modal.Header>
52
+ <h2>Settings</h2>
53
+ <Modal.Close />
54
+ </Modal.Header>
55
+
56
+ <Modal.Body>
57
+ <p>Configure your application settings here.</p>
58
+ </Modal.Body>
59
+
60
+ <Modal.Footer>
61
+ <Button variant="outline" onClick={() => setOpen(false)}>
62
+ Cancel
63
+ </Button>
64
+ <Button onClick={() => setOpen(false)}>Save Changes</Button>
65
+ </Modal.Footer>
66
+ </Modal.Content>
67
+ </Modal.Root>
68
+ </>
69
+ );
70
+ };
71
+ ```
72
+
73
+ ## API Reference
74
+
75
+ ### `<Modal.Root>`
76
+
77
+ The root modal container that manages state and provides context.
78
+
79
+ | Prop | Type | Default | Description |
80
+ | -------------------- | ------------------------- | ----------- | ------------------------------------------ |
81
+ | `open` | `boolean` | `undefined` | Controlled open state |
82
+ | `defaultOpen` | `boolean` | `false` | Default open state |
83
+ | `onOpenChange` | `(open: boolean) => void` | `undefined` | Callback when open state changes |
84
+ | `a11yTitle` | `string` | required | Accessible title for screen readers |
85
+ | `a11yDescription` | `string` | `undefined` | Accessible description for screen readers |
86
+ | `trapped` | `boolean` | `true` | Whether focus should be trapped |
87
+ | `onMountAutoFocus` | `(event: Event) => void` | `undefined` | Called when modal opens and focuses |
88
+ | `onUnmountAutoFocus` | `(event: Event) => void` | `undefined` | Called when modal closes and focus returns |
89
+ | `layer` | `number` | `undefined` | Layer index for stacked modals |
90
+
91
+ Inherits all Stack props for additional styling.
92
+
93
+ ### `<Modal.Content>`
94
+
95
+ The content wrapper that handles the modal's main container.
96
+
97
+ | Prop | Type | Default | Description |
98
+ | ---------- | ----------------- | -------- | ------------- |
99
+ | `children` | `React.ReactNode` | required | Modal content |
100
+
101
+ Inherits all Stack props for layout and styling.
102
+
103
+ ### `<Modal.Header>`
104
+
105
+ Header section typically containing title and close button.
106
+
107
+ | Prop | Type | Default | Description |
108
+ | ---------- | ----------------- | -------- | -------------- |
109
+ | `children` | `React.ReactNode` | required | Header content |
110
+
111
+ Inherits all Stack props for layout and styling.
112
+
113
+ ### `<Modal.Body>`
114
+
115
+ Main content area with automatic scrolling when content overflows.
116
+
117
+ | Prop | Type | Default | Description |
118
+ | ---------- | ----------------- | -------- | ------------ |
119
+ | `children` | `React.ReactNode` | required | Body content |
120
+
121
+ Inherits all Stack props for layout and styling.
122
+
123
+ ### `<Modal.Footer>`
124
+
125
+ Footer section typically containing action buttons.
126
+
127
+ | Prop | Type | Default | Description |
128
+ | ---------- | ----------------- | -------- | -------------- |
129
+ | `children` | `React.ReactNode` | required | Footer content |
130
+
131
+ Inherits all Stack props for layout and styling.
132
+
133
+ ### `<Modal.Close>`
134
+
135
+ Close button that dismisses the modal.
136
+
137
+ | Prop | Type | Default | Description |
138
+ | --------- | --------------- | --------- | -------------- |
139
+ | `size` | `ButtonSize` | `"1"` | Button size |
140
+ | `variant` | `ButtonVariant` | `"ghost"` | Button variant |
141
+
142
+ Inherits all Button props for additional styling.
143
+
144
+ ### `<ModalStackingProvider>`
145
+
146
+ Provider for managing multiple stacked modals.
147
+
148
+ | Prop | Type | Default | Description |
149
+ | ---------- | ----------------- | -------- | ----------- |
150
+ | `children` | `React.ReactNode` | required | App content |
151
+
152
+ ## Usage Patterns
153
+
154
+ ### Basic Modal
155
+
156
+ ```tsx
157
+ import { Button } from "@telegraph/button";
158
+ import { Modal } from "@telegraph/modal";
159
+ import { useState } from "react";
160
+
161
+ const BasicModal = () => {
162
+ const [open, setOpen] = useState(false);
163
+
164
+ return (
165
+ <>
166
+ <Button onClick={() => setOpen(true)}>Open Modal</Button>
167
+
168
+ <Modal.Root open={open} onOpenChange={setOpen} a11yTitle="Basic Modal">
169
+ <Modal.Content>
170
+ <Modal.Header>
171
+ <h2>Modal Title</h2>
172
+ <Modal.Close />
173
+ </Modal.Header>
174
+
175
+ <Modal.Body>
176
+ <p>This is the modal content.</p>
177
+ </Modal.Body>
178
+ </Modal.Content>
179
+ </Modal.Root>
180
+ </>
181
+ );
182
+ };
183
+ ```
184
+
185
+ ### Confirmation Modal
186
+
187
+ ```tsx
188
+ import { Button } from "@telegraph/button";
189
+ import { Modal } from "@telegraph/modal";
190
+ import { AlertTriangle } from "lucide-react";
191
+
192
+ const ConfirmationModal = ({ open, onClose, onConfirm, title, message }) => (
193
+ <Modal.Root open={open} onOpenChange={onClose} a11yTitle={title}>
194
+ <Modal.Content maxW="96">
195
+ <Modal.Header>
196
+ <Stack direction="row" align="center" gap="2">
197
+ <Icon icon={AlertTriangle} color="red" />
198
+ <h2>{title}</h2>
199
+ </Stack>
200
+ <Modal.Close />
201
+ </Modal.Header>
202
+
203
+ <Modal.Body>
204
+ <p>{message}</p>
205
+ </Modal.Body>
206
+
207
+ <Modal.Footer>
208
+ <Button variant="outline" onClick={onClose}>
209
+ Cancel
210
+ </Button>
211
+ <Button color="red" onClick={onConfirm}>
212
+ Confirm
213
+ </Button>
214
+ </Modal.Footer>
215
+ </Modal.Content>
216
+ </Modal.Root>
217
+ );
218
+
219
+ // Usage
220
+ <ConfirmationModal
221
+ open={deleteModalOpen}
222
+ onClose={() => setDeleteModalOpen(false)}
223
+ onConfirm={handleDelete}
224
+ title="Delete Item"
225
+ message="Are you sure you want to delete this item? This action cannot be undone."
226
+ />;
227
+ ```
228
+
229
+ ### Form Modal
230
+
231
+ ```tsx
232
+ import { Button } from "@telegraph/button";
233
+ import { Input } from "@telegraph/input";
234
+ import { Stack } from "@telegraph/layout";
235
+ import { Modal } from "@telegraph/modal";
236
+ import { useState } from "react";
237
+
238
+ const FormModal = ({ open, onClose, onSubmit }) => {
239
+ const [formData, setFormData] = useState({ name: "", email: "" });
240
+
241
+ const handleSubmit = (e) => {
242
+ e.preventDefault();
243
+ onSubmit(formData);
244
+ onClose();
245
+ };
246
+
247
+ return (
248
+ <Modal.Root open={open} onOpenChange={onClose} a11yTitle="Add User">
249
+ <Modal.Content>
250
+ <Modal.Header>
251
+ <h2>Add New User</h2>
252
+ <Modal.Close />
253
+ </Modal.Header>
254
+
255
+ <Modal.Body>
256
+ <form onSubmit={handleSubmit}>
257
+ <Stack direction="column" gap="4">
258
+ <Stack direction="column" gap="1">
259
+ <label htmlFor="name">Name</label>
260
+ <Input
261
+ id="name"
262
+ value={formData.name}
263
+ onChange={(e) =>
264
+ setFormData((prev) => ({ ...prev, name: e.target.value }))
265
+ }
266
+ placeholder="Enter name"
267
+ />
268
+ </Stack>
269
+
270
+ <Stack direction="column" gap="1">
271
+ <label htmlFor="email">Email</label>
272
+ <Input
273
+ id="email"
274
+ type="email"
275
+ value={formData.email}
276
+ onChange={(e) =>
277
+ setFormData((prev) => ({ ...prev, email: e.target.value }))
278
+ }
279
+ placeholder="Enter email"
280
+ />
281
+ </Stack>
282
+ </Stack>
283
+ </form>
284
+ </Modal.Body>
285
+
286
+ <Modal.Footer>
287
+ <Button variant="outline" onClick={onClose}>
288
+ Cancel
289
+ </Button>
290
+ <Button onClick={handleSubmit}>Add User</Button>
291
+ </Modal.Footer>
292
+ </Modal.Content>
293
+ </Modal.Root>
294
+ );
295
+ };
296
+ ```
297
+
298
+ ### Full-Screen Modal
299
+
300
+ ```tsx
301
+ import { Modal } from "@telegraph/modal";
302
+
303
+ const FullScreenModal = ({ open, onClose, children }) => (
304
+ <Modal.Root open={open} onOpenChange={onClose} a11yTitle="Full Screen View">
305
+ <Modal.Content w="screen" maxW="screen" h="screen" rounded="0">
306
+ <Modal.Header>
307
+ <h2>Full Screen Modal</h2>
308
+ <Modal.Close />
309
+ </Modal.Header>
310
+
311
+ <Modal.Body flex="1">{children}</Modal.Body>
312
+ </Modal.Content>
313
+ </Modal.Root>
314
+ );
315
+ ```
316
+
317
+ ## Advanced Usage
318
+
319
+ ### Stacked Modals
320
+
321
+ For applications that need multiple modals open simultaneously:
322
+
323
+ ```tsx
324
+ import { Button } from "@telegraph/button";
325
+ import { Modal, ModalStackingProvider } from "@telegraph/modal";
326
+ import { useState } from "react";
327
+
328
+ const StackedModalsExample = () => {
329
+ const [modal1Open, setModal1Open] = useState(false);
330
+ const [modal2Open, setModal2Open] = useState(false);
331
+ const [modal3Open, setModal3Open] = useState(false);
332
+
333
+ return (
334
+ <ModalStackingProvider>
335
+ <Button onClick={() => setModal1Open(true)}>Open First Modal</Button>
336
+
337
+ {/* First Modal */}
338
+ <Modal.Root
339
+ open={modal1Open}
340
+ onOpenChange={setModal1Open}
341
+ a11yTitle="First Modal"
342
+ >
343
+ <Modal.Content>
344
+ <Modal.Header>
345
+ <h2>First Modal</h2>
346
+ <Modal.Close />
347
+ </Modal.Header>
348
+
349
+ <Modal.Body>
350
+ <p>This is the first modal.</p>
351
+ <Button onClick={() => setModal2Open(true)}>
352
+ Open Second Modal
353
+ </Button>
354
+ </Modal.Body>
355
+ </Modal.Content>
356
+ </Modal.Root>
357
+
358
+ {/* Second Modal */}
359
+ <Modal.Root
360
+ open={modal2Open}
361
+ onOpenChange={setModal2Open}
362
+ a11yTitle="Second Modal"
363
+ >
364
+ <Modal.Content>
365
+ <Modal.Header>
366
+ <h2>Second Modal</h2>
367
+ <Modal.Close />
368
+ </Modal.Header>
369
+
370
+ <Modal.Body>
371
+ <p>This modal is stacked on top.</p>
372
+ <Button onClick={() => setModal3Open(true)}>
373
+ Open Third Modal
374
+ </Button>
375
+ </Modal.Body>
376
+ </Modal.Content>
377
+ </Modal.Root>
378
+
379
+ {/* Third Modal */}
380
+ <Modal.Root
381
+ open={modal3Open}
382
+ onOpenChange={setModal3Open}
383
+ a11yTitle="Third Modal"
384
+ >
385
+ <Modal.Content>
386
+ <Modal.Header>
387
+ <h2>Third Modal</h2>
388
+ <Modal.Close />
389
+ </Modal.Header>
390
+
391
+ <Modal.Body>
392
+ <p>This is the top-most modal.</p>
393
+ </Modal.Body>
394
+ </Modal.Content>
395
+ </Modal.Root>
396
+ </ModalStackingProvider>
397
+ );
398
+ };
399
+ ```
400
+
401
+ ### Custom Focus Management
402
+
403
+ ```tsx
404
+ import { Modal } from "@telegraph/modal";
405
+ import { useRef } from "react";
406
+
407
+ const CustomFocusModal = ({ open, onClose }) => {
408
+ const firstInputRef = useRef(null);
409
+
410
+ return (
411
+ <Modal.Root
412
+ open={open}
413
+ onOpenChange={onClose}
414
+ a11yTitle="Custom Focus"
415
+ onMountAutoFocus={(event) => {
416
+ event.preventDefault();
417
+ firstInputRef.current?.focus();
418
+ }}
419
+ onUnmountAutoFocus={(event) => {
420
+ event.preventDefault();
421
+ // Custom focus return logic
422
+ }}
423
+ >
424
+ <Modal.Content>
425
+ <Modal.Header>
426
+ <h2>Custom Focus Management</h2>
427
+ <Modal.Close />
428
+ </Modal.Header>
429
+
430
+ <Modal.Body>
431
+ <Input ref={firstInputRef} placeholder="This will be focused" />
432
+ <Input placeholder="Second input" />
433
+ </Modal.Body>
434
+ </Modal.Content>
435
+ </Modal.Root>
436
+ );
437
+ };
438
+ ```
439
+
440
+ ### Modal with Loading State
441
+
442
+ ```tsx
443
+ import { Button } from "@telegraph/button";
444
+ import { Modal } from "@telegraph/modal";
445
+ import { Spinner } from "@telegraph/spinner";
446
+ import { useState } from "react";
447
+
448
+ const LoadingModal = ({ open, onClose }) => {
449
+ const [loading, setLoading] = useState(false);
450
+
451
+ const handleSave = async () => {
452
+ setLoading(true);
453
+ try {
454
+ await saveData();
455
+ onClose();
456
+ } catch (error) {
457
+ console.error("Save failed:", error);
458
+ } finally {
459
+ setLoading(false);
460
+ }
461
+ };
462
+
463
+ return (
464
+ <Modal.Root open={open} onOpenChange={onClose} a11yTitle="Save Changes">
465
+ <Modal.Content>
466
+ <Modal.Header>
467
+ <h2>Save Changes</h2>
468
+ <Modal.Close disabled={loading} />
469
+ </Modal.Header>
470
+
471
+ <Modal.Body>
472
+ {loading ? (
473
+ <Stack align="center" gap="2">
474
+ <Spinner />
475
+ <p>Saving changes...</p>
476
+ </Stack>
477
+ ) : (
478
+ <p>Are you sure you want to save these changes?</p>
479
+ )}
480
+ </Modal.Body>
481
+
482
+ <Modal.Footer>
483
+ <Button variant="outline" onClick={onClose} disabled={loading}>
484
+ Cancel
485
+ </Button>
486
+ <Button onClick={handleSave} disabled={loading}>
487
+ {loading ? "Saving..." : "Save"}
488
+ </Button>
489
+ </Modal.Footer>
490
+ </Modal.Content>
491
+ </Modal.Root>
492
+ );
493
+ };
494
+ ```
495
+
496
+ ### Modal with Custom Animation
497
+
498
+ ```tsx
499
+ import { Modal } from "@telegraph/modal";
500
+ import { motion } from "motion/react";
501
+
502
+ const AnimatedModal = ({ open, onClose }) => (
503
+ <Modal.Root open={open} onOpenChange={onClose} a11yTitle="Animated Modal">
504
+ <Modal.Content
505
+ as={motion.div}
506
+ initial={{ scale: 0.8, opacity: 0 }}
507
+ animate={{ scale: 1, opacity: 1 }}
508
+ exit={{ scale: 0.8, opacity: 0 }}
509
+ transition={{ duration: 0.2 }}
510
+ >
511
+ <Modal.Header>
512
+ <h2>Animated Modal</h2>
513
+ <Modal.Close />
514
+ </Modal.Header>
515
+
516
+ <Modal.Body>
517
+ <p>This modal has custom animations.</p>
518
+ </Modal.Body>
519
+ </Modal.Content>
520
+ </Modal.Root>
521
+ );
522
+ ```
523
+
524
+ ### Controlled Modal
525
+
526
+ ```tsx
527
+ import { Button } from "@telegraph/button";
528
+ import { Modal } from "@telegraph/modal";
529
+
530
+ // For when you need more control over modal behavior
531
+ const ControlledModal = () => {
532
+ const [open, setOpen] = useState(false);
533
+ const [canClose, setCanClose] = useState(true);
534
+
535
+ const handleOpenChange = (newOpen) => {
536
+ if (!newOpen && !canClose) {
537
+ // Prevent closing if conditions aren't met
538
+ return;
539
+ }
540
+ setOpen(newOpen);
541
+ };
542
+
543
+ return (
544
+ <>
545
+ <Button onClick={() => setOpen(true)}>Open Modal</Button>
546
+
547
+ <Modal.Root
548
+ open={open}
549
+ onOpenChange={handleOpenChange}
550
+ a11yTitle="Controlled Modal"
551
+ >
552
+ <Modal.Content>
553
+ <Modal.Header>
554
+ <h2>Controlled Modal</h2>
555
+ {canClose && <Modal.Close />}
556
+ </Modal.Header>
557
+
558
+ <Modal.Body>
559
+ <p>This modal's closing behavior is controlled.</p>
560
+ <Button onClick={() => setCanClose(!canClose)}>
561
+ {canClose ? "Disable" : "Enable"} Closing
562
+ </Button>
563
+ </Modal.Body>
564
+
565
+ <Modal.Footer>
566
+ <Button onClick={() => setOpen(false)} disabled={!canClose}>
567
+ Done
568
+ </Button>
569
+ </Modal.Footer>
570
+ </Modal.Content>
571
+ </Modal.Root>
572
+ </>
573
+ );
574
+ };
575
+ ```
576
+
577
+ ### Modal with Steps/Wizard
578
+
579
+ ```tsx
580
+ import { Button } from "@telegraph/button";
581
+ import { Stack } from "@telegraph/layout";
582
+ import { Modal } from "@telegraph/modal";
583
+ import { useState } from "react";
584
+
585
+ const WizardModal = ({ open, onClose }) => {
586
+ const [currentStep, setCurrentStep] = useState(0);
587
+
588
+ const steps = [
589
+ { title: "Step 1", content: "First step content" },
590
+ { title: "Step 2", content: "Second step content" },
591
+ { title: "Step 3", content: "Final step content" },
592
+ ];
593
+
594
+ const isFirstStep = currentStep === 0;
595
+ const isLastStep = currentStep === steps.length - 1;
596
+
597
+ return (
598
+ <Modal.Root open={open} onOpenChange={onClose} a11yTitle="Setup Wizard">
599
+ <Modal.Content>
600
+ <Modal.Header>
601
+ <h2>{steps[currentStep].title}</h2>
602
+ <Modal.Close />
603
+ </Modal.Header>
604
+
605
+ <Modal.Body>
606
+ <Stack direction="column" gap="4">
607
+ {/* Progress indicator */}
608
+ <Stack direction="row" gap="1">
609
+ {steps.map((_, index) => (
610
+ <Box
611
+ key={index}
612
+ w="8"
613
+ h="1"
614
+ bg={index <= currentStep ? "accent-9" : "gray-6"}
615
+ rounded="full"
616
+ />
617
+ ))}
618
+ </Stack>
619
+
620
+ <p>{steps[currentStep].content}</p>
621
+ </Stack>
622
+ </Modal.Body>
623
+
624
+ <Modal.Footer>
625
+ <Button
626
+ variant="outline"
627
+ onClick={() => setCurrentStep((prev) => prev - 1)}
628
+ disabled={isFirstStep}
629
+ >
630
+ Previous
631
+ </Button>
632
+
633
+ {isLastStep ? (
634
+ <Button onClick={onClose}>Finish</Button>
635
+ ) : (
636
+ <Button onClick={() => setCurrentStep((prev) => prev + 1)}>
637
+ Next
638
+ </Button>
639
+ )}
640
+ </Modal.Footer>
641
+ </Modal.Content>
642
+ </Modal.Root>
643
+ );
644
+ };
645
+ ```
646
+
647
+ ## Design Tokens & Styling
648
+
649
+ The modal component uses Telegraph design tokens for consistent styling:
650
+
651
+ ### Layout Tokens
652
+
653
+ - **Max width**: `var(--tgph-spacing-160)` (default)
654
+ - **Margin**: `var(--tgph-spacing-16)` from viewport edges
655
+ - **Border radius**: `var(--tgph-rounded-4)`
656
+ - **Shadow**: `var(--tgph-shadow-3)`
657
+
658
+ ### Color Tokens
659
+
660
+ - **Background**: `var(--tgph-surface-1)`
661
+ - **Overlay**: `var(--tgph-alpha-black-6)`
662
+ - **Border**: `var(--tgph-gray-8)`
663
+
664
+ ### Z-Index Tokens
665
+
666
+ - **Overlay**: `var(--tgph-zIndex-overlay)`
667
+ - **Modal**: `var(--tgph-zIndex-modal)`
668
+ - **Stacked modals**: `calc(var(--tgph-zIndex-modal) + layer)`
669
+
670
+ ### Animation Tokens
671
+
672
+ - **Duration**: `0.3s` spring transition
673
+ - **Stacking offset**: `var(--tgph-spacing-4)` per layer
674
+ - **Scale reduction**: `0.02` per background layer
675
+
676
+ ## Accessibility
677
+
678
+ - ✅ **Focus Management**: Automatic focus trapping and restoration
679
+ - ✅ **Keyboard Navigation**: Escape key closes modal
680
+ - ✅ **Screen Reader Support**: Proper ARIA roles and descriptions
681
+ - ✅ **Content Structure**: Semantic heading hierarchy
682
+ - ✅ **Backdrop Interaction**: Click outside to close
683
+ - ✅ **Focus Restoration**: Returns focus to trigger element
684
+
685
+ ### Keyboard Shortcuts
686
+
687
+ | Key | Action |
688
+ | ----------------- | -------------------------------------- |
689
+ | `Escape` | Close modal |
690
+ | `Tab` | Navigate to next focusable element |
691
+ | `Shift + Tab` | Navigate to previous focusable element |
692
+ | `Enter` / `Space` | Activate focused button |
693
+
694
+ ### ARIA Attributes
695
+
696
+ - `role="dialog"` on modal content
697
+ - `aria-labelledby` references the title
698
+ - `aria-describedby` references the description
699
+ - `aria-modal="true"` indicates modal state
700
+ - `aria-hidden` on background content when modal is open
701
+
702
+ ### Accessibility Guidelines
703
+
704
+ 1. **Provide Clear Titles**: Always include `a11yTitle` for screen readers
705
+ 2. **Use Semantic Headings**: Structure content with proper heading hierarchy
706
+ 3. **Descriptive Labels**: Add `a11yDescription` for complex modals
707
+ 4. **Focus Management**: Let the component handle focus automatically
708
+ 5. **Escape Routes**: Always provide clear ways to close the modal
709
+
710
+ ```tsx
711
+ // ✅ Good accessibility practices
712
+ <Modal.Root
713
+ open={open}
714
+ onOpenChange={setOpen}
715
+ a11yTitle="Delete Confirmation"
716
+ a11yDescription="Confirm that you want to permanently delete this item"
717
+ >
718
+ <Modal.Content>
719
+ <Modal.Header>
720
+ <h2>Delete Item</h2>
721
+ <Modal.Close />
722
+ </Modal.Header>
723
+
724
+ <Modal.Body>
725
+ <p>Are you sure you want to delete "Document.pdf"? This action cannot be undone.</p>
726
+ </Modal.Body>
727
+
728
+ <Modal.Footer>
729
+ <Button variant="outline" onClick={() => setOpen(false)}>
730
+ Cancel
731
+ </Button>
732
+ <Button color="red" onClick={handleDelete}>
733
+ Delete
734
+ </Button>
735
+ </Modal.Footer>
736
+ </Modal.Content>
737
+ </Modal.Root>
738
+
739
+ // ❌ Poor accessibility
740
+ <Modal.Root open={open} onOpenChange={setOpen}> {/* Missing a11yTitle */}
741
+ <Modal.Content>
742
+ <div>Delete Item</div> {/* Not a proper heading */}
743
+ <div>Are you sure?</div> {/* No clear context */}
744
+ <button onClick={handleDelete}>OK</button> {/* Unclear action */}
745
+ </Modal.Content>
746
+ </Modal.Root>
747
+ ```
748
+
749
+ ### Best Practices
750
+
751
+ 1. **Meaningful Titles**: Use descriptive titles that explain the modal's purpose
752
+ 2. **Logical Structure**: Use Header, Body, Footer to create clear content areas
753
+ 3. **Action Clarity**: Make button labels clear about their actions
754
+ 4. **Error Handling**: Provide clear feedback for errors
755
+ 5. **Consistent Patterns**: Use consistent modal patterns across your application
756
+
757
+ ## Examples
758
+
759
+ ### Settings Modal
760
+
761
+ ```tsx
762
+ import { Button } from "@telegraph/button";
763
+ import { Input } from "@telegraph/input";
764
+ import { Stack } from "@telegraph/layout";
765
+ import { Modal } from "@telegraph/modal";
766
+ import { Switch } from "@telegraph/switch";
767
+
768
+ export const SettingsModal = ({ open, onClose, settings, onSave }) => {
769
+ const [localSettings, setLocalSettings] = useState(settings);
770
+
771
+ const handleSave = () => {
772
+ onSave(localSettings);
773
+ onClose();
774
+ };
775
+
776
+ return (
777
+ <Modal.Root
778
+ open={open}
779
+ onOpenChange={onClose}
780
+ a11yTitle="Application Settings"
781
+ >
782
+ <Modal.Content maxW="120">
783
+ <Modal.Header>
784
+ <h2>Settings</h2>
785
+ <Modal.Close />
786
+ </Modal.Header>
787
+
788
+ <Modal.Body>
789
+ <Stack direction="column" gap="4">
790
+ <Stack direction="column" gap="2">
791
+ <h3>Profile</h3>
792
+ <Stack direction="column" gap="1">
793
+ <label htmlFor="display-name">Display Name</label>
794
+ <Input
795
+ id="display-name"
796
+ value={localSettings.displayName}
797
+ onChange={(e) =>
798
+ setLocalSettings((prev) => ({
799
+ ...prev,
800
+ displayName: e.target.value,
801
+ }))
802
+ }
803
+ />
804
+ </Stack>
805
+ </Stack>
806
+
807
+ <Stack direction="column" gap="2">
808
+ <h3>Preferences</h3>
809
+ <Stack direction="row" align="center" justify="between">
810
+ <label htmlFor="notifications">Email Notifications</label>
811
+ <Switch
812
+ id="notifications"
813
+ checked={localSettings.emailNotifications}
814
+ onCheckedChange={(checked) =>
815
+ setLocalSettings((prev) => ({
816
+ ...prev,
817
+ emailNotifications: checked,
818
+ }))
819
+ }
820
+ />
821
+ </Stack>
822
+
823
+ <Stack direction="row" align="center" justify="between">
824
+ <label htmlFor="dark-mode">Dark Mode</label>
825
+ <Switch
826
+ id="dark-mode"
827
+ checked={localSettings.darkMode}
828
+ onCheckedChange={(checked) =>
829
+ setLocalSettings((prev) => ({
830
+ ...prev,
831
+ darkMode: checked,
832
+ }))
833
+ }
834
+ />
835
+ </Stack>
836
+ </Stack>
837
+ </Stack>
838
+ </Modal.Body>
839
+
840
+ <Modal.Footer>
841
+ <Button variant="outline" onClick={onClose}>
842
+ Cancel
843
+ </Button>
844
+ <Button onClick={handleSave}>Save Settings</Button>
845
+ </Modal.Footer>
846
+ </Modal.Content>
847
+ </Modal.Root>
848
+ );
849
+ };
850
+ ```
851
+
852
+ ### Image Gallery Modal
853
+
854
+ ```tsx
855
+ import { Button } from "@telegraph/button";
856
+ import { Modal } from "@telegraph/modal";
857
+ import { ChevronLeft, ChevronRight, Download, Share } from "lucide-react";
858
+ import { useState } from "react";
859
+
860
+ export const ImageGalleryModal = ({
861
+ open,
862
+ onClose,
863
+ images,
864
+ initialIndex = 0,
865
+ }) => {
866
+ const [currentIndex, setCurrentIndex] = useState(initialIndex);
867
+
868
+ const currentImage = images[currentIndex];
869
+ const canGoPrev = currentIndex > 0;
870
+ const canGoNext = currentIndex < images.length - 1;
871
+
872
+ return (
873
+ <Modal.Root open={open} onOpenChange={onClose} a11yTitle="Image Gallery">
874
+ <Modal.Content w="screen" maxW="screen" h="screen" rounded="0" p="0">
875
+ <Modal.Header px="6" py="4">
876
+ <Stack direction="row" align="center" justify="between" w="full">
877
+ <h2>{currentImage?.title || `Image ${currentIndex + 1}`}</h2>
878
+ <Stack direction="row" gap="2">
879
+ <Button
880
+ variant="ghost"
881
+ icon={{ icon: Download, alt: "Download" }}
882
+ />
883
+ <Button variant="ghost" icon={{ icon: Share, alt: "Share" }} />
884
+ <Modal.Close />
885
+ </Stack>
886
+ </Stack>
887
+ </Modal.Header>
888
+
889
+ <Modal.Body p="0" position="relative">
890
+ <img
891
+ src={currentImage?.src}
892
+ alt={currentImage?.alt}
893
+ style={{
894
+ width: "100%",
895
+ height: "100%",
896
+ objectFit: "contain",
897
+ backgroundColor: "var(--tgph-black)",
898
+ }}
899
+ />
900
+
901
+ {/* Navigation buttons */}
902
+ {canGoPrev && (
903
+ <Button
904
+ variant="ghost"
905
+ icon={{ icon: ChevronLeft, alt: "Previous image" }}
906
+ onClick={() => setCurrentIndex((prev) => prev - 1)}
907
+ style={{
908
+ position: "absolute",
909
+ left: "var(--tgph-spacing-4)",
910
+ top: "50%",
911
+ transform: "translateY(-50%)",
912
+ }}
913
+ />
914
+ )}
915
+
916
+ {canGoNext && (
917
+ <Button
918
+ variant="ghost"
919
+ icon={{ icon: ChevronRight, alt: "Next image" }}
920
+ onClick={() => setCurrentIndex((prev) => prev + 1)}
921
+ style={{
922
+ position: "absolute",
923
+ right: "var(--tgph-spacing-4)",
924
+ top: "50%",
925
+ transform: "translateY(-50%)",
926
+ }}
927
+ />
928
+ )}
929
+ </Modal.Body>
930
+
931
+ <Modal.Footer px="6" py="4">
932
+ <Stack direction="row" align="center" justify="center" gap="2">
933
+ {images.map((_, index) => (
934
+ <Box
935
+ key={index}
936
+ w="2"
937
+ h="2"
938
+ bg={index === currentIndex ? "white" : "alpha-white-6"}
939
+ rounded="full"
940
+ onClick={() => setCurrentIndex(index)}
941
+ style={{ cursor: "pointer" }}
942
+ />
943
+ ))}
944
+ </Stack>
945
+ </Modal.Footer>
946
+ </Modal.Content>
947
+ </Modal.Root>
948
+ );
949
+ };
950
+ ```
951
+
952
+ ### Data Export Modal
953
+
954
+ ```tsx
955
+ import { Button } from "@telegraph/button";
956
+ import { Checkbox } from "@telegraph/checkbox";
957
+ import { Stack } from "@telegraph/layout";
958
+ import { Modal } from "@telegraph/modal";
959
+ import { RadioCards } from "@telegraph/radio";
960
+ import { Select } from "@telegraph/select";
961
+ import { useState } from "react";
962
+
963
+ export const ExportModal = ({ open, onClose, onExport }) => {
964
+ const [format, setFormat] = useState("csv");
965
+ const [dateRange, setDateRange] = useState("last-30-days");
966
+ const [includeHeaders, setIncludeHeaders] = useState(true);
967
+ const [includeMetadata, setIncludeMetadata] = useState(false);
968
+
969
+ const handleExport = () => {
970
+ onExport({
971
+ format,
972
+ dateRange,
973
+ includeHeaders,
974
+ includeMetadata,
975
+ });
976
+ onClose();
977
+ };
978
+
979
+ return (
980
+ <Modal.Root open={open} onOpenChange={onClose} a11yTitle="Export Data">
981
+ <Modal.Content>
982
+ <Modal.Header>
983
+ <h2>Export Data</h2>
984
+ <Modal.Close />
985
+ </Modal.Header>
986
+
987
+ <Modal.Body>
988
+ <Stack direction="column" gap="4">
989
+ <Stack direction="column" gap="2">
990
+ <h3>Export Format</h3>
991
+ <RadioCards value={format} onValueChange={setFormat}>
992
+ <RadioCards.Item value="csv">
993
+ <Stack direction="column" gap="1">
994
+ <strong>CSV</strong>
995
+ <span>Comma-separated values</span>
996
+ </Stack>
997
+ </RadioCards.Item>
998
+ <RadioCards.Item value="json">
999
+ <Stack direction="column" gap="1">
1000
+ <strong>JSON</strong>
1001
+ <span>JavaScript Object Notation</span>
1002
+ </Stack>
1003
+ </RadioCards.Item>
1004
+ <RadioCards.Item value="xlsx">
1005
+ <Stack direction="column" gap="1">
1006
+ <strong>Excel</strong>
1007
+ <span>Microsoft Excel format</span>
1008
+ </Stack>
1009
+ </RadioCards.Item>
1010
+ </RadioCards>
1011
+ </Stack>
1012
+
1013
+ <Stack direction="column" gap="2">
1014
+ <label htmlFor="date-range">Date Range</label>
1015
+ <Select value={dateRange} onValueChange={setDateRange}>
1016
+ <Select.Option value="last-7-days">Last 7 days</Select.Option>
1017
+ <Select.Option value="last-30-days">Last 30 days</Select.Option>
1018
+ <Select.Option value="last-90-days">Last 90 days</Select.Option>
1019
+ <Select.Option value="all-time">All time</Select.Option>
1020
+ </Select>
1021
+ </Stack>
1022
+
1023
+ <Stack direction="column" gap="2">
1024
+ <h3>Options</h3>
1025
+ <Stack direction="column" gap="2">
1026
+ <Checkbox
1027
+ checked={includeHeaders}
1028
+ onCheckedChange={setIncludeHeaders}
1029
+ >
1030
+ Include column headers
1031
+ </Checkbox>
1032
+ <Checkbox
1033
+ checked={includeMetadata}
1034
+ onCheckedChange={setIncludeMetadata}
1035
+ >
1036
+ Include metadata
1037
+ </Checkbox>
1038
+ </Stack>
1039
+ </Stack>
1040
+ </Stack>
1041
+ </Modal.Body>
1042
+
1043
+ <Modal.Footer>
1044
+ <Button variant="outline" onClick={onClose}>
1045
+ Cancel
1046
+ </Button>
1047
+ <Button onClick={handleExport}>Export Data</Button>
1048
+ </Modal.Footer>
1049
+ </Modal.Content>
1050
+ </Modal.Root>
1051
+ );
1052
+ };
1053
+ ```
1054
+
1055
+ ## References
1056
+
1057
+ - [Radix UI Dialog](https://www.radix-ui.com/docs/primitives/components/dialog)
1058
+ - [Storybook Demo](https://storybook.telegraph.dev/?path=/docs/modal)
1059
+
1060
+ ## Contributing
1061
+
1062
+ See our [Contributing Guide](../../CONTRIBUTING.md) for more details.
1063
+
1064
+ ## License
1065
+
1066
+ MIT License - see [LICENSE](../../LICENSE) for details.