@titan-design/react-ui 0.1.0 → 0.1.1

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.
Files changed (192) hide show
  1. package/package.json +10 -5
  2. package/src/components/custom/DateTime/DateTime.stories.tsx +180 -0
  3. package/src/components/custom/DateTime/DateTime.test.tsx +152 -0
  4. package/src/components/custom/DateTime/DateTime.tsx +235 -0
  5. package/src/components/custom/DateTime/index.ts +2 -0
  6. package/src/components/custom/EmptyState/EmptyState.stories.tsx +99 -0
  7. package/src/components/custom/EmptyState/EmptyState.test.tsx +106 -0
  8. package/src/components/custom/EmptyState/EmptyState.tsx +68 -0
  9. package/src/components/custom/EmptyState/index.ts +2 -0
  10. package/src/components/custom/Metric/Metric.stories.tsx +133 -0
  11. package/src/components/custom/Metric/Metric.test.tsx +120 -0
  12. package/src/components/custom/Metric/Metric.tsx +92 -0
  13. package/src/components/custom/Metric/index.ts +2 -0
  14. package/src/components/custom/Sidebar/Sidebar.stories.tsx +227 -0
  15. package/src/components/custom/Sidebar/Sidebar.test.tsx +185 -0
  16. package/src/components/custom/Sidebar/Sidebar.tsx +257 -0
  17. package/src/components/custom/Sidebar/index.ts +18 -0
  18. package/src/components/custom/Table/Table.stories.tsx +481 -0
  19. package/src/components/custom/Table/Table.test.tsx +531 -0
  20. package/src/components/custom/Table/Table.tsx +696 -0
  21. package/src/components/custom/Table/index.ts +22 -0
  22. package/src/components/custom/Typography/Typography.stories.tsx +153 -0
  23. package/src/components/custom/Typography/Typography.test.tsx +107 -0
  24. package/src/components/custom/Typography/Typography.tsx +218 -0
  25. package/src/components/custom/Typography/index.ts +19 -0
  26. package/src/components/custom/index.ts +8 -0
  27. package/src/components/custom/stepper/Stepper.stories.tsx +245 -0
  28. package/src/components/custom/stepper/Stepper.test.tsx +230 -0
  29. package/src/components/custom/stepper/Stepper.tsx +250 -0
  30. package/src/components/custom/stepper/index.ts +10 -0
  31. package/src/components/index.ts +5 -0
  32. package/src/components/ui/alert/Alert.stories.tsx +154 -0
  33. package/src/components/ui/alert/Alert.test.tsx +202 -0
  34. package/src/components/ui/alert/Alert.tsx +170 -0
  35. package/src/components/ui/alert/index.ts +8 -0
  36. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +299 -0
  37. package/src/components/ui/autocomplete/Autocomplete.test.tsx +247 -0
  38. package/src/components/ui/autocomplete/Autocomplete.tsx +332 -0
  39. package/src/components/ui/autocomplete/index.ts +2 -0
  40. package/src/components/ui/avatar/Avatar.stories.tsx +105 -0
  41. package/src/components/ui/avatar/Avatar.test.tsx +135 -0
  42. package/src/components/ui/avatar/Avatar.tsx +168 -0
  43. package/src/components/ui/avatar/index.ts +2 -0
  44. package/src/components/ui/badge/Badge.stories.tsx +100 -0
  45. package/src/components/ui/badge/Badge.test.tsx +86 -0
  46. package/src/components/ui/badge/Badge.tsx +105 -0
  47. package/src/components/ui/badge/index.ts +2 -0
  48. package/src/components/ui/breadcrumbs/Breadcrumbs.stories.tsx +70 -0
  49. package/src/components/ui/breadcrumbs/Breadcrumbs.test.tsx +156 -0
  50. package/src/components/ui/breadcrumbs/Breadcrumbs.tsx +127 -0
  51. package/src/components/ui/breadcrumbs/index.ts +2 -0
  52. package/src/components/ui/button/Button.stories.tsx +216 -0
  53. package/src/components/ui/button/Button.test.tsx +132 -0
  54. package/src/components/ui/button/Button.tsx +242 -0
  55. package/src/components/ui/button/index.ts +10 -0
  56. package/src/components/ui/card/Card.stories.tsx +443 -0
  57. package/src/components/ui/card/Card.test.tsx +265 -0
  58. package/src/components/ui/card/Card.tsx +380 -0
  59. package/src/components/ui/card/index.ts +20 -0
  60. package/src/components/ui/checkbox/Checkbox.stories.tsx +126 -0
  61. package/src/components/ui/checkbox/Checkbox.test.tsx +152 -0
  62. package/src/components/ui/checkbox/Checkbox.tsx +163 -0
  63. package/src/components/ui/checkbox/index.ts +2 -0
  64. package/src/components/ui/chip/Chip.stories.tsx +112 -0
  65. package/src/components/ui/chip/Chip.test.tsx +119 -0
  66. package/src/components/ui/chip/Chip.tsx +151 -0
  67. package/src/components/ui/chip/index.ts +2 -0
  68. package/src/components/ui/collapse/Collapse.stories.tsx +313 -0
  69. package/src/components/ui/collapse/Collapse.test.tsx +298 -0
  70. package/src/components/ui/collapse/Collapse.tsx +275 -0
  71. package/src/components/ui/collapse/index.ts +18 -0
  72. package/src/components/ui/data-row/DataRow.stories.tsx +78 -0
  73. package/src/components/ui/data-row/DataRow.test.tsx +78 -0
  74. package/src/components/ui/data-row/DataRow.tsx +29 -0
  75. package/src/components/ui/data-row/index.ts +2 -0
  76. package/src/components/ui/divider/Divider.stories.tsx +71 -0
  77. package/src/components/ui/divider/Divider.test.tsx +55 -0
  78. package/src/components/ui/divider/Divider.tsx +37 -0
  79. package/src/components/ui/divider/index.ts +2 -0
  80. package/src/components/ui/drawer/Drawer.stories.tsx +228 -0
  81. package/src/components/ui/drawer/Drawer.test.tsx +142 -0
  82. package/src/components/ui/drawer/Drawer.tsx +228 -0
  83. package/src/components/ui/drawer/index.ts +9 -0
  84. package/src/components/ui/form-field/FormField.stories.tsx +260 -0
  85. package/src/components/ui/form-field/FormField.test.tsx +280 -0
  86. package/src/components/ui/form-field/FormField.tsx +308 -0
  87. package/src/components/ui/form-field/index.ts +2 -0
  88. package/src/components/ui/help-tip/HelpTip.stories.tsx +250 -0
  89. package/src/components/ui/help-tip/HelpTip.test.tsx +198 -0
  90. package/src/components/ui/help-tip/HelpTip.tsx +229 -0
  91. package/src/components/ui/help-tip/index.ts +2 -0
  92. package/src/components/ui/icon-box/IconBox.stories.tsx +139 -0
  93. package/src/components/ui/icon-box/IconBox.test.tsx +120 -0
  94. package/src/components/ui/icon-box/IconBox.tsx +57 -0
  95. package/src/components/ui/icon-box/index.ts +1 -0
  96. package/src/components/ui/index.ts +36 -0
  97. package/src/components/ui/input/Input.stories.tsx +192 -0
  98. package/src/components/ui/input/Input.test.tsx +86 -0
  99. package/src/components/ui/input/Input.tsx +273 -0
  100. package/src/components/ui/input/index.ts +2 -0
  101. package/src/components/ui/link/Link.stories.tsx +78 -0
  102. package/src/components/ui/link/Link.test.tsx +94 -0
  103. package/src/components/ui/link/Link.tsx +86 -0
  104. package/src/components/ui/link/index.ts +2 -0
  105. package/src/components/ui/list-item/ListItem.stories.tsx +146 -0
  106. package/src/components/ui/list-item/ListItem.test.tsx +128 -0
  107. package/src/components/ui/list-item/ListItem.tsx +89 -0
  108. package/src/components/ui/list-item/index.ts +14 -0
  109. package/src/components/ui/menu/Menu.stories.tsx +180 -0
  110. package/src/components/ui/menu/Menu.test.tsx +353 -0
  111. package/src/components/ui/menu/Menu.tsx +222 -0
  112. package/src/components/ui/menu/index.ts +2 -0
  113. package/src/components/ui/modal/Modal.stories.tsx +350 -0
  114. package/src/components/ui/modal/Modal.test.tsx +159 -0
  115. package/src/components/ui/modal/Modal.tsx +254 -0
  116. package/src/components/ui/modal/index.ts +19 -0
  117. package/src/components/ui/popover/Popover.stories.tsx +190 -0
  118. package/src/components/ui/popover/Popover.test.tsx +277 -0
  119. package/src/components/ui/popover/Popover.tsx +162 -0
  120. package/src/components/ui/popover/index.ts +2 -0
  121. package/src/components/ui/progress/Progress.stories.tsx +190 -0
  122. package/src/components/ui/progress/Progress.test.tsx +181 -0
  123. package/src/components/ui/progress/Progress.tsx +320 -0
  124. package/src/components/ui/progress/index.ts +9 -0
  125. package/src/components/ui/radio/Radio.stories.tsx +199 -0
  126. package/src/components/ui/radio/Radio.test.tsx +166 -0
  127. package/src/components/ui/radio/Radio.tsx +200 -0
  128. package/src/components/ui/radio/index.ts +2 -0
  129. package/src/components/ui/section/Section.stories.tsx +111 -0
  130. package/src/components/ui/section/Section.test.tsx +121 -0
  131. package/src/components/ui/section/Section.tsx +52 -0
  132. package/src/components/ui/section/index.ts +6 -0
  133. package/src/components/ui/select/Select.stories.tsx +286 -0
  134. package/src/components/ui/select/Select.test.tsx +180 -0
  135. package/src/components/ui/select/Select.tsx +236 -0
  136. package/src/components/ui/select/index.ts +2 -0
  137. package/src/components/ui/skeleton/Skeleton.stories.tsx +153 -0
  138. package/src/components/ui/skeleton/Skeleton.test.tsx +162 -0
  139. package/src/components/ui/skeleton/Skeleton.tsx +229 -0
  140. package/src/components/ui/skeleton/index.ts +16 -0
  141. package/src/components/ui/spinner/Spinner.stories.tsx +67 -0
  142. package/src/components/ui/spinner/Spinner.test.tsx +67 -0
  143. package/src/components/ui/spinner/Spinner.tsx +72 -0
  144. package/src/components/ui/spinner/index.ts +2 -0
  145. package/src/components/ui/stack/Stack.stories.tsx +127 -0
  146. package/src/components/ui/stack/Stack.test.tsx +184 -0
  147. package/src/components/ui/stack/Stack.tsx +76 -0
  148. package/src/components/ui/stack/index.ts +2 -0
  149. package/src/components/ui/surface/Surface.stories.tsx +91 -0
  150. package/src/components/ui/surface/Surface.test.tsx +63 -0
  151. package/src/components/ui/surface/Surface.tsx +51 -0
  152. package/src/components/ui/surface/index.ts +1 -0
  153. package/src/components/ui/switch/Switch.stories.tsx +111 -0
  154. package/src/components/ui/switch/Switch.test.tsx +112 -0
  155. package/src/components/ui/switch/Switch.tsx +103 -0
  156. package/src/components/ui/switch/index.ts +2 -0
  157. package/src/components/ui/tabs/Tabs.stories.tsx +213 -0
  158. package/src/components/ui/tabs/Tabs.test.tsx +192 -0
  159. package/src/components/ui/tabs/Tabs.tsx +253 -0
  160. package/src/components/ui/tabs/index.ts +16 -0
  161. package/src/components/ui/toast/Toast.stories.tsx +255 -0
  162. package/src/components/ui/toast/Toast.test.tsx +207 -0
  163. package/src/components/ui/toast/Toast.tsx +327 -0
  164. package/src/components/ui/toast/index.ts +8 -0
  165. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +415 -0
  166. package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +224 -0
  167. package/src/components/ui/toolbar-button/ToolbarButton.tsx +307 -0
  168. package/src/components/ui/toolbar-button/index.ts +7 -0
  169. package/src/components/ui/tooltip/Tooltip.stories.tsx +126 -0
  170. package/src/components/ui/tooltip/Tooltip.test.tsx +179 -0
  171. package/src/components/ui/tooltip/Tooltip.tsx +143 -0
  172. package/src/components/ui/tooltip/index.ts +2 -0
  173. package/src/index.ts +10 -0
  174. package/src/test/setup.ts +19 -0
  175. package/src/theme/Colors.stories.tsx +258 -0
  176. package/src/theme/config.ts +193 -0
  177. package/src/theme/elevation.stories.tsx +346 -0
  178. package/src/theme/elevation.test.ts +27 -0
  179. package/src/theme/elevation.ts +567 -0
  180. package/src/theme/index.ts +39 -0
  181. package/src/theme/shadows.stories.tsx +523 -0
  182. package/src/theme/shadows.ts +439 -0
  183. package/src/theme/tokens/index.ts +2 -0
  184. package/src/theme/tokens/primitives.ts +354 -0
  185. package/src/theme/tokens/semantic.ts +381 -0
  186. package/src/types/jest-axe.d.ts +16 -0
  187. package/src/types/nativewind.d.ts +54 -0
  188. package/src/utils/cn.ts +14 -0
  189. package/src/utils/colors.ts +140 -0
  190. package/src/utils/form.ts +188 -0
  191. package/src/utils/index.ts +29 -0
  192. package/src/utils/useTheme.ts +72 -0
@@ -0,0 +1,350 @@
1
+ import React, { useState } from 'react'
2
+ import type { Meta, StoryObj } from '@storybook/react-vite'
3
+ import { View, Text, TextInput } from 'react-native'
4
+ import {
5
+ Modal,
6
+ ModalContent,
7
+ ModalHeader,
8
+ ModalTitle,
9
+ ModalCloseButton,
10
+ ModalBody,
11
+ ModalFooter,
12
+ } from './Modal'
13
+ import { Button, ButtonText } from '../button/Button'
14
+
15
+ const meta: Meta<typeof Modal> = {
16
+ title: 'Components/Modal',
17
+ component: Modal,
18
+ tags: ['autodocs'],
19
+ argTypes: {
20
+ isOpen: {
21
+ control: 'boolean',
22
+ description: 'Whether the modal is open',
23
+ },
24
+ size: {
25
+ control: 'select',
26
+ options: ['xs', 'sm', 'md', 'lg', 'xl', 'full'],
27
+ description: 'Modal size',
28
+ },
29
+ scrollBehavior: {
30
+ control: 'select',
31
+ options: ['inside', 'outside'],
32
+ description: 'Where scrolling happens',
33
+ },
34
+ closeOnOverlayClick: {
35
+ control: 'boolean',
36
+ description: 'Whether clicking backdrop closes modal',
37
+ },
38
+ backdropBlur: {
39
+ control: 'boolean',
40
+ description: 'Whether to blur the backdrop',
41
+ },
42
+ animationType: {
43
+ control: 'select',
44
+ options: ['none', 'fade', 'slide'],
45
+ description: 'Animation type',
46
+ },
47
+ },
48
+ }
49
+
50
+ export default meta
51
+ type Story = StoryObj<typeof Modal>
52
+
53
+ /**
54
+ * Interactive wrapper that manages open state for modal stories.
55
+ */
56
+ function ModalDemo({
57
+ children,
58
+ buttonLabel = 'Open Modal',
59
+ ...modalProps
60
+ }: Omit<React.ComponentProps<typeof Modal>, 'isOpen'> & { buttonLabel?: string }) {
61
+ const [isOpen, setIsOpen] = useState(false)
62
+ return (
63
+ <View>
64
+ <Button variant="solid" color="primary" onPress={() => setIsOpen(true)}>
65
+ <ButtonText>{buttonLabel}</ButtonText>
66
+ </Button>
67
+ <Modal isOpen={isOpen} onClose={() => setIsOpen(false)} {...modalProps}>
68
+ {typeof children === 'function'
69
+ ? (children as (close: () => void) => React.ReactNode)(() => setIsOpen(false))
70
+ : children}
71
+ </Modal>
72
+ </View>
73
+ )
74
+ }
75
+
76
+ export const Default: Story = {
77
+ render: () => (
78
+ <ModalDemo>
79
+ <ModalContent>
80
+ <ModalHeader>
81
+ <ModalTitle>Modal Title</ModalTitle>
82
+ <ModalCloseButton />
83
+ </ModalHeader>
84
+ <ModalBody>
85
+ <Text className="text-text-primary">
86
+ This is the default modal with medium size. It includes a header with title and close
87
+ button, a body with content, and a footer with actions.
88
+ </Text>
89
+ </ModalBody>
90
+ <ModalFooter>
91
+ <Button variant="ghost" color="secondary">
92
+ <ButtonText>Cancel</ButtonText>
93
+ </Button>
94
+ <Button variant="solid" color="primary">
95
+ <ButtonText>Confirm</ButtonText>
96
+ </Button>
97
+ </ModalFooter>
98
+ </ModalContent>
99
+ </ModalDemo>
100
+ ),
101
+ }
102
+
103
+ export const Sizes: Story = {
104
+ render: () => (
105
+ <View style={{ flexDirection: 'row', gap: 8, flexWrap: 'wrap' }}>
106
+ {(['xs', 'sm', 'md', 'lg', 'xl', 'full'] as const).map((size) => (
107
+ <ModalDemo key={size} size={size} buttonLabel={`Size: ${size}`}>
108
+ <ModalContent>
109
+ <ModalHeader>
110
+ <ModalTitle>{size.toUpperCase()} Modal</ModalTitle>
111
+ <ModalCloseButton />
112
+ </ModalHeader>
113
+ <ModalBody>
114
+ <Text className="text-text-primary">
115
+ This modal uses size=&quot;{size}&quot;. The content width is constrained by the
116
+ max-width of the selected size variant.
117
+ </Text>
118
+ </ModalBody>
119
+ <ModalFooter>
120
+ <Button variant="solid" color="primary">
121
+ <ButtonText>Close</ButtonText>
122
+ </Button>
123
+ </ModalFooter>
124
+ </ModalContent>
125
+ </ModalDemo>
126
+ ))}
127
+ </View>
128
+ ),
129
+ }
130
+
131
+ export const WithFormContent: Story = {
132
+ render: () => (
133
+ <ModalDemo size="md" buttonLabel="Open Form Modal">
134
+ {(close: () => void) => (
135
+ <ModalContent>
136
+ <ModalHeader>
137
+ <ModalTitle>Create New Item</ModalTitle>
138
+ <ModalCloseButton />
139
+ </ModalHeader>
140
+ <ModalBody>
141
+ <View style={{ gap: 16 }}>
142
+ <View style={{ gap: 4 }}>
143
+ <Text className="text-sm font-medium text-text-secondary">Name</Text>
144
+ <TextInput
145
+ placeholder="Enter item name"
146
+ className="px-3 py-2 rounded-md border border-border-input bg-surface-base text-text-primary"
147
+ />
148
+ </View>
149
+ <View style={{ gap: 4 }}>
150
+ <Text className="text-sm font-medium text-text-secondary">Description</Text>
151
+ <TextInput
152
+ placeholder="Enter description"
153
+ multiline
154
+ numberOfLines={3}
155
+ className="px-3 py-2 rounded-md border border-border-input bg-surface-base text-text-primary"
156
+ style={{ minHeight: 80, textAlignVertical: 'top' }}
157
+ />
158
+ </View>
159
+ <View style={{ gap: 4 }}>
160
+ <Text className="text-sm font-medium text-text-secondary">Category</Text>
161
+ <TextInput
162
+ placeholder="Enter category"
163
+ className="px-3 py-2 rounded-md border border-border-input bg-surface-base text-text-primary"
164
+ />
165
+ </View>
166
+ </View>
167
+ </ModalBody>
168
+ <ModalFooter>
169
+ <Button variant="ghost" color="secondary" onPress={close}>
170
+ <ButtonText>Cancel</ButtonText>
171
+ </Button>
172
+ <Button variant="solid" color="primary" onPress={close}>
173
+ <ButtonText>Create</ButtonText>
174
+ </Button>
175
+ </ModalFooter>
176
+ </ModalContent>
177
+ )}
178
+ </ModalDemo>
179
+ ),
180
+ }
181
+
182
+ export const ScrollableContent: Story = {
183
+ render: () => (
184
+ <ModalDemo size="md" scrollBehavior="inside" buttonLabel="Scrollable Modal">
185
+ <ModalContent>
186
+ <ModalHeader>
187
+ <ModalTitle>Terms of Service</ModalTitle>
188
+ <ModalCloseButton />
189
+ </ModalHeader>
190
+ <ModalBody maxHeight={300}>
191
+ <View style={{ gap: 12 }}>
192
+ {Array.from({ length: 15 }).map((_, i) => (
193
+ <View key={i}>
194
+ <Text className="text-text-primary font-semibold mb-1">Section {i + 1}</Text>
195
+ <Text className="text-text-secondary text-sm">
196
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
197
+ incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis
198
+ nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
199
+ </Text>
200
+ </View>
201
+ ))}
202
+ </View>
203
+ </ModalBody>
204
+ <ModalFooter>
205
+ <Button variant="ghost" color="secondary">
206
+ <ButtonText>Decline</ButtonText>
207
+ </Button>
208
+ <Button variant="solid" color="primary">
209
+ <ButtonText>Accept</ButtonText>
210
+ </Button>
211
+ </ModalFooter>
212
+ </ModalContent>
213
+ </ModalDemo>
214
+ ),
215
+ }
216
+
217
+ export const BackdropBlur: Story = {
218
+ render: () => (
219
+ <View style={{ gap: 16 }}>
220
+ <Text className="text-text-secondary text-sm">
221
+ The backdrop blur variant uses a lighter overlay with a blur effect behind the modal.
222
+ </Text>
223
+ <ModalDemo backdropBlur buttonLabel="Blurred Backdrop">
224
+ <ModalContent>
225
+ <ModalHeader>
226
+ <ModalTitle>Blurred Backdrop</ModalTitle>
227
+ <ModalCloseButton />
228
+ </ModalHeader>
229
+ <ModalBody>
230
+ <Text className="text-text-primary">
231
+ This modal uses backdropBlur which applies a CSS backdrop-filter blur on web and a
232
+ lighter overlay opacity.
233
+ </Text>
234
+ </ModalBody>
235
+ <ModalFooter>
236
+ <Button variant="solid" color="primary">
237
+ <ButtonText>Got it</ButtonText>
238
+ </Button>
239
+ </ModalFooter>
240
+ </ModalContent>
241
+ </ModalDemo>
242
+ </View>
243
+ ),
244
+ }
245
+
246
+ export const NoOverlayClose: Story = {
247
+ render: () => (
248
+ <ModalDemo closeOnOverlayClick={false} buttonLabel="Persistent Modal">
249
+ <ModalContent>
250
+ <ModalHeader>
251
+ <ModalTitle>Persistent Modal</ModalTitle>
252
+ <ModalCloseButton />
253
+ </ModalHeader>
254
+ <ModalBody>
255
+ <Text className="text-text-primary">
256
+ This modal does not close when clicking the overlay. You must use the close button or
257
+ footer actions to dismiss it. This is useful for important confirmations.
258
+ </Text>
259
+ </ModalBody>
260
+ <ModalFooter>
261
+ <Button variant="solid" color="primary">
262
+ <ButtonText>I Understand</ButtonText>
263
+ </Button>
264
+ </ModalFooter>
265
+ </ModalContent>
266
+ </ModalDemo>
267
+ ),
268
+ }
269
+
270
+ export const DeleteConfirmation: Story = {
271
+ render: () => (
272
+ <ModalDemo size="sm" buttonLabel="Delete Item">
273
+ {(close: () => void) => (
274
+ <ModalContent>
275
+ <ModalHeader>
276
+ <ModalTitle>Delete Item</ModalTitle>
277
+ <ModalCloseButton />
278
+ </ModalHeader>
279
+ <ModalBody>
280
+ <Text className="text-text-primary">
281
+ Are you sure you want to delete this item? This action cannot be undone.
282
+ </Text>
283
+ </ModalBody>
284
+ <ModalFooter>
285
+ <Button variant="ghost" color="secondary" onPress={close}>
286
+ <ButtonText>Cancel</ButtonText>
287
+ </Button>
288
+ <Button variant="solid" color="error" onPress={close}>
289
+ <ButtonText>Delete</ButtonText>
290
+ </Button>
291
+ </ModalFooter>
292
+ </ModalContent>
293
+ )}
294
+ </ModalDemo>
295
+ ),
296
+ }
297
+
298
+ export const SlideAnimation: Story = {
299
+ render: () => (
300
+ <ModalDemo animationType="slide" buttonLabel="Slide Modal">
301
+ <ModalContent>
302
+ <ModalHeader>
303
+ <ModalTitle>Slide Animation</ModalTitle>
304
+ <ModalCloseButton />
305
+ </ModalHeader>
306
+ <ModalBody>
307
+ <Text className="text-text-primary">
308
+ This modal uses animationType=&quot;slide&quot; instead of the default fade animation.
309
+ </Text>
310
+ </ModalBody>
311
+ <ModalFooter>
312
+ <Button variant="solid" color="primary">
313
+ <ButtonText>Close</ButtonText>
314
+ </Button>
315
+ </ModalFooter>
316
+ </ModalContent>
317
+ </ModalDemo>
318
+ ),
319
+ }
320
+
321
+ export const LargeWithScrollOutside: Story = {
322
+ render: () => (
323
+ <ModalDemo size="lg" scrollBehavior="outside" buttonLabel="Large Scrollable (Outside)">
324
+ <ModalContent>
325
+ <ModalHeader>
326
+ <ModalTitle>Large Modal - Outside Scroll</ModalTitle>
327
+ <ModalCloseButton />
328
+ </ModalHeader>
329
+ <ModalBody>
330
+ <View style={{ gap: 12 }}>
331
+ {Array.from({ length: 20 }).map((_, i) => (
332
+ <Text key={i} className="text-text-primary text-sm">
333
+ Paragraph {i + 1}: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
334
+ eiusmod tempor incididunt ut labore et dolore magna aliqua.
335
+ </Text>
336
+ ))}
337
+ </View>
338
+ </ModalBody>
339
+ <ModalFooter>
340
+ <Button variant="ghost" color="secondary">
341
+ <ButtonText>Cancel</ButtonText>
342
+ </Button>
343
+ <Button variant="solid" color="primary">
344
+ <ButtonText>Save</ButtonText>
345
+ </Button>
346
+ </ModalFooter>
347
+ </ModalContent>
348
+ </ModalDemo>
349
+ ),
350
+ }
@@ -0,0 +1,159 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import {
5
+ Modal,
6
+ ModalContent,
7
+ ModalHeader,
8
+ ModalTitle,
9
+ ModalCloseButton,
10
+ ModalBody,
11
+ ModalFooter,
12
+ } from './Modal'
13
+
14
+ function renderModal(props: Partial<React.ComponentProps<typeof Modal>> = {}) {
15
+ return render(
16
+ <Modal isOpen onClose={vi.fn()} {...props}>
17
+ <ModalContent>
18
+ <ModalHeader>
19
+ <ModalTitle>Test Modal</ModalTitle>
20
+ <ModalCloseButton />
21
+ </ModalHeader>
22
+ <ModalBody>Modal body content</ModalBody>
23
+ <ModalFooter>
24
+ <button>Cancel</button>
25
+ </ModalFooter>
26
+ </ModalContent>
27
+ </Modal>
28
+ )
29
+ }
30
+
31
+ describe('Modal', () => {
32
+ it('renders when isOpen is true', () => {
33
+ renderModal({ isOpen: true })
34
+ expect(screen.getByText('Test Modal')).toBeInTheDocument()
35
+ expect(screen.getByText('Modal body content')).toBeInTheDocument()
36
+ })
37
+
38
+ it('does not render content when isOpen is false', () => {
39
+ renderModal({ isOpen: false })
40
+ expect(screen.queryByText('Test Modal')).not.toBeInTheDocument()
41
+ })
42
+
43
+ it('calls onClose when backdrop is pressed', () => {
44
+ const onClose = vi.fn()
45
+ renderModal({ isOpen: true, onClose })
46
+ // The backdrop is a Pressable wrapping children
47
+ // ModalContent stops propagation, so clicking outside content triggers onClose
48
+ })
49
+
50
+ it('does not call onClose on backdrop press when closeOnOverlayClick is false', () => {
51
+ const onClose = vi.fn()
52
+ renderModal({ isOpen: true, onClose, closeOnOverlayClick: false })
53
+ // Backdrop press should not trigger onClose
54
+ })
55
+
56
+ describe('ModalCloseButton', () => {
57
+ it('calls onClose when close button is pressed', () => {
58
+ const onClose = vi.fn()
59
+ renderModal({ isOpen: true, onClose })
60
+
61
+ const closeButton = screen.getByLabelText('Close modal')
62
+ fireEvent.click(closeButton)
63
+ expect(onClose).toHaveBeenCalledTimes(1)
64
+ })
65
+
66
+ it('has accessible label', () => {
67
+ renderModal({ isOpen: true })
68
+ expect(screen.getByLabelText('Close modal')).toBeInTheDocument()
69
+ })
70
+ })
71
+
72
+ describe('ModalTitle', () => {
73
+ it('renders title text', () => {
74
+ renderModal({ isOpen: true })
75
+ expect(screen.getByText('Test Modal')).toBeInTheDocument()
76
+ })
77
+
78
+ it('has header accessibility role', () => {
79
+ renderModal({ isOpen: true })
80
+ expect(screen.getByRole('heading')).toBeInTheDocument()
81
+ })
82
+ })
83
+
84
+ describe('ModalBody', () => {
85
+ it('renders body content', () => {
86
+ renderModal({ isOpen: true })
87
+ expect(screen.getByText('Modal body content')).toBeInTheDocument()
88
+ })
89
+
90
+ it('renders as ScrollView when scrollBehavior is inside', () => {
91
+ render(
92
+ <Modal isOpen onClose={vi.fn()} scrollBehavior="inside">
93
+ <ModalContent>
94
+ <ModalBody>Scrollable content</ModalBody>
95
+ </ModalContent>
96
+ </Modal>
97
+ )
98
+ expect(screen.getByText('Scrollable content')).toBeInTheDocument()
99
+ })
100
+ })
101
+
102
+ describe('ModalFooter', () => {
103
+ it('renders footer content', () => {
104
+ renderModal({ isOpen: true })
105
+ expect(screen.getByText('Cancel')).toBeInTheDocument()
106
+ })
107
+ })
108
+
109
+ describe('sizes', () => {
110
+ const sizes = ['xs', 'sm', 'md', 'lg', 'xl', 'full'] as const
111
+ sizes.forEach((size) => {
112
+ it(`renders with size ${size}`, () => {
113
+ renderModal({ isOpen: true, size })
114
+ expect(screen.getByText('Test Modal')).toBeInTheDocument()
115
+ })
116
+ })
117
+ })
118
+
119
+ describe('scroll behavior', () => {
120
+ it('renders with outside scroll behavior by default', () => {
121
+ renderModal({ isOpen: true })
122
+ expect(screen.getByText('Modal body content')).toBeInTheDocument()
123
+ })
124
+
125
+ it('renders with inside scroll behavior', () => {
126
+ render(
127
+ <Modal isOpen onClose={vi.fn()} scrollBehavior="inside">
128
+ <ModalContent>
129
+ <ModalBody>Inside scroll content</ModalBody>
130
+ </ModalContent>
131
+ </Modal>
132
+ )
133
+ expect(screen.getByText('Inside scroll content')).toBeInTheDocument()
134
+ })
135
+ })
136
+
137
+ describe('animation types', () => {
138
+ const animationTypes = ['none', 'fade', 'slide'] as const
139
+ animationTypes.forEach((animationType) => {
140
+ it(`renders with animationType ${animationType}`, () => {
141
+ renderModal({ isOpen: true, animationType })
142
+ expect(screen.getByText('Test Modal')).toBeInTheDocument()
143
+ })
144
+ })
145
+ })
146
+
147
+ describe('accessibility', () => {
148
+ it('has no accessibility violations', async () => {
149
+ const { container } = renderModal({ isOpen: true })
150
+ const results = await axe(container)
151
+ expect(results).toHaveNoViolations()
152
+ })
153
+
154
+ it('close button has correct accessibility role', () => {
155
+ renderModal({ isOpen: true })
156
+ expect(screen.getByRole('button', { name: 'Close modal' })).toBeInTheDocument()
157
+ })
158
+ })
159
+ })