@titan-design/react-ui 0.1.0 → 0.2.5

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 (236) hide show
  1. package/README.md +47 -1
  2. package/dist/{chunk-MGW37MPO.mjs → chunk-UXVRPOME.mjs} +173 -3
  3. package/dist/chunk-UXVRPOME.mjs.map +1 -0
  4. package/dist/index.d.mts +101 -12
  5. package/dist/index.d.ts +101 -12
  6. package/dist/index.js +1267 -468
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +734 -112
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/theme/index.d.mts +86 -1
  11. package/dist/theme/index.d.ts +86 -1
  12. package/dist/theme/index.js +166 -0
  13. package/dist/theme/index.js.map +1 -1
  14. package/dist/theme/index.mjs +1 -1
  15. package/package.json +22 -6
  16. package/src/components/custom/DateTime/DateTime.stories.tsx +180 -0
  17. package/src/components/custom/DateTime/DateTime.test.tsx +152 -0
  18. package/src/components/custom/DateTime/DateTime.tsx +235 -0
  19. package/src/components/custom/DateTime/index.ts +2 -0
  20. package/src/components/custom/EmptyState/EmptyState.stories.tsx +99 -0
  21. package/src/components/custom/EmptyState/EmptyState.test.tsx +106 -0
  22. package/src/components/custom/EmptyState/EmptyState.tsx +68 -0
  23. package/src/components/custom/EmptyState/index.ts +2 -0
  24. package/src/components/custom/Metric/Metric.stories.tsx +133 -0
  25. package/src/components/custom/Metric/Metric.test.tsx +120 -0
  26. package/src/components/custom/Metric/Metric.tsx +92 -0
  27. package/src/components/custom/Metric/index.ts +2 -0
  28. package/src/components/custom/Sidebar/Sidebar.stories.tsx +227 -0
  29. package/src/components/custom/Sidebar/Sidebar.test.tsx +185 -0
  30. package/src/components/custom/Sidebar/Sidebar.tsx +257 -0
  31. package/src/components/custom/Sidebar/index.ts +18 -0
  32. package/src/components/custom/Table/Table.stories.tsx +481 -0
  33. package/src/components/custom/Table/Table.test.tsx +531 -0
  34. package/src/components/custom/Table/Table.tsx +696 -0
  35. package/src/components/custom/Table/index.ts +22 -0
  36. package/src/components/custom/Typography/Typography.stories.tsx +153 -0
  37. package/src/components/custom/Typography/Typography.test.tsx +107 -0
  38. package/src/components/custom/Typography/Typography.tsx +218 -0
  39. package/src/components/custom/Typography/index.ts +19 -0
  40. package/src/components/custom/Workout/RestTimer.stories.tsx +71 -0
  41. package/src/components/custom/Workout/RestTimer.test.tsx +145 -0
  42. package/src/components/custom/Workout/RestTimer.tsx +172 -0
  43. package/src/components/custom/Workout/SupersetWrapper.stories.tsx +144 -0
  44. package/src/components/custom/Workout/SupersetWrapper.test.tsx +130 -0
  45. package/src/components/custom/Workout/SupersetWrapper.tsx +63 -0
  46. package/src/components/custom/Workout/index.ts +2 -0
  47. package/src/components/custom/index.ts +9 -0
  48. package/src/components/custom/stepper/Stepper.stories.tsx +245 -0
  49. package/src/components/custom/stepper/Stepper.test.tsx +230 -0
  50. package/src/components/custom/stepper/Stepper.tsx +250 -0
  51. package/src/components/custom/stepper/index.ts +10 -0
  52. package/src/components/index.ts +5 -0
  53. package/src/components/ui/alert/Alert.stories.tsx +154 -0
  54. package/src/components/ui/alert/Alert.test.tsx +202 -0
  55. package/src/components/ui/alert/Alert.tsx +170 -0
  56. package/src/components/ui/alert/index.ts +8 -0
  57. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +299 -0
  58. package/src/components/ui/autocomplete/Autocomplete.test.tsx +247 -0
  59. package/src/components/ui/autocomplete/Autocomplete.tsx +332 -0
  60. package/src/components/ui/autocomplete/index.ts +2 -0
  61. package/src/components/ui/avatar/Avatar.stories.tsx +105 -0
  62. package/src/components/ui/avatar/Avatar.test.tsx +152 -0
  63. package/src/components/ui/avatar/Avatar.tsx +176 -0
  64. package/src/components/ui/avatar/index.ts +2 -0
  65. package/src/components/ui/badge/Badge.stories.tsx +100 -0
  66. package/src/components/ui/badge/Badge.test.tsx +103 -0
  67. package/src/components/ui/badge/Badge.tsx +119 -0
  68. package/src/components/ui/badge/index.ts +3 -0
  69. package/src/components/ui/breadcrumbs/Breadcrumbs.stories.tsx +70 -0
  70. package/src/components/ui/breadcrumbs/Breadcrumbs.test.tsx +156 -0
  71. package/src/components/ui/breadcrumbs/Breadcrumbs.tsx +127 -0
  72. package/src/components/ui/breadcrumbs/index.ts +2 -0
  73. package/src/components/ui/button/Button.stories.tsx +216 -0
  74. package/src/components/ui/button/Button.test.tsx +132 -0
  75. package/src/components/ui/button/Button.tsx +297 -0
  76. package/src/components/ui/button/index.ts +10 -0
  77. package/src/components/ui/card/Card.stories.tsx +443 -0
  78. package/src/components/ui/card/Card.test.tsx +294 -0
  79. package/src/components/ui/card/Card.tsx +401 -0
  80. package/src/components/ui/card/index.ts +20 -0
  81. package/src/components/ui/checkbox/Checkbox.stories.tsx +126 -0
  82. package/src/components/ui/checkbox/Checkbox.test.tsx +152 -0
  83. package/src/components/ui/checkbox/Checkbox.tsx +163 -0
  84. package/src/components/ui/checkbox/index.ts +2 -0
  85. package/src/components/ui/chip/Chip.stories.tsx +112 -0
  86. package/src/components/ui/chip/Chip.test.tsx +119 -0
  87. package/src/components/ui/chip/Chip.tsx +151 -0
  88. package/src/components/ui/chip/index.ts +2 -0
  89. package/src/components/ui/collapse/Collapse.stories.tsx +313 -0
  90. package/src/components/ui/collapse/Collapse.test.tsx +298 -0
  91. package/src/components/ui/collapse/Collapse.tsx +275 -0
  92. package/src/components/ui/collapse/index.ts +18 -0
  93. package/src/components/ui/data-row/DataRow.stories.tsx +78 -0
  94. package/src/components/ui/data-row/DataRow.test.tsx +78 -0
  95. package/src/components/ui/data-row/DataRow.tsx +29 -0
  96. package/src/components/ui/data-row/index.ts +2 -0
  97. package/src/components/ui/divider/Divider.stories.tsx +71 -0
  98. package/src/components/ui/divider/Divider.test.tsx +55 -0
  99. package/src/components/ui/divider/Divider.tsx +37 -0
  100. package/src/components/ui/divider/index.ts +2 -0
  101. package/src/components/ui/drawer/Drawer.stories.tsx +228 -0
  102. package/src/components/ui/drawer/Drawer.test.tsx +142 -0
  103. package/src/components/ui/drawer/Drawer.tsx +228 -0
  104. package/src/components/ui/drawer/index.ts +9 -0
  105. package/src/components/ui/form-field/FormField.stories.tsx +260 -0
  106. package/src/components/ui/form-field/FormField.test.tsx +280 -0
  107. package/src/components/ui/form-field/FormField.tsx +308 -0
  108. package/src/components/ui/form-field/index.ts +2 -0
  109. package/src/components/ui/help-tip/HelpTip.stories.tsx +250 -0
  110. package/src/components/ui/help-tip/HelpTip.test.tsx +198 -0
  111. package/src/components/ui/help-tip/HelpTip.tsx +229 -0
  112. package/src/components/ui/help-tip/index.ts +2 -0
  113. package/src/components/ui/icon-box/IconBox.stories.tsx +139 -0
  114. package/src/components/ui/icon-box/IconBox.test.tsx +120 -0
  115. package/src/components/ui/icon-box/IconBox.tsx +57 -0
  116. package/src/components/ui/icon-box/index.ts +1 -0
  117. package/src/components/ui/index.ts +38 -0
  118. package/src/components/ui/indicator/Indicator.stories.tsx +74 -0
  119. package/src/components/ui/indicator/Indicator.test.tsx +55 -0
  120. package/src/components/ui/indicator/Indicator.tsx +73 -0
  121. package/src/components/ui/indicator/index.ts +2 -0
  122. package/src/components/ui/input/Input.stories.tsx +192 -0
  123. package/src/components/ui/input/Input.test.tsx +86 -0
  124. package/src/components/ui/input/Input.tsx +273 -0
  125. package/src/components/ui/input/index.ts +2 -0
  126. package/src/components/ui/link/Link.stories.tsx +78 -0
  127. package/src/components/ui/link/Link.test.tsx +94 -0
  128. package/src/components/ui/link/Link.tsx +86 -0
  129. package/src/components/ui/link/index.ts +2 -0
  130. package/src/components/ui/list-item/ListItem.stories.tsx +146 -0
  131. package/src/components/ui/list-item/ListItem.test.tsx +128 -0
  132. package/src/components/ui/list-item/ListItem.tsx +89 -0
  133. package/src/components/ui/list-item/index.ts +14 -0
  134. package/src/components/ui/menu/Menu.stories.tsx +180 -0
  135. package/src/components/ui/menu/Menu.test.tsx +353 -0
  136. package/src/components/ui/menu/Menu.tsx +222 -0
  137. package/src/components/ui/menu/index.ts +2 -0
  138. package/src/components/ui/modal/Modal.stories.tsx +350 -0
  139. package/src/components/ui/modal/Modal.test.tsx +159 -0
  140. package/src/components/ui/modal/Modal.tsx +254 -0
  141. package/src/components/ui/modal/index.ts +19 -0
  142. package/src/components/ui/pill/Pill.stories.tsx +71 -0
  143. package/src/components/ui/pill/Pill.test.tsx +69 -0
  144. package/src/components/ui/pill/Pill.tsx +104 -0
  145. package/src/components/ui/pill/index.ts +2 -0
  146. package/src/components/ui/popover/Popover.stories.tsx +190 -0
  147. package/src/components/ui/popover/Popover.test.tsx +419 -0
  148. package/src/components/ui/popover/Popover.tsx +237 -0
  149. package/src/components/ui/popover/index.ts +2 -0
  150. package/src/components/ui/progress/Progress.stories.tsx +190 -0
  151. package/src/components/ui/progress/Progress.test.tsx +210 -0
  152. package/src/components/ui/progress/Progress.tsx +339 -0
  153. package/src/components/ui/progress/index.ts +9 -0
  154. package/src/components/ui/radio/Radio.stories.tsx +199 -0
  155. package/src/components/ui/radio/Radio.test.tsx +166 -0
  156. package/src/components/ui/radio/Radio.tsx +200 -0
  157. package/src/components/ui/radio/index.ts +2 -0
  158. package/src/components/ui/section/Section.stories.tsx +111 -0
  159. package/src/components/ui/section/Section.test.tsx +121 -0
  160. package/src/components/ui/section/Section.tsx +52 -0
  161. package/src/components/ui/section/index.ts +6 -0
  162. package/src/components/ui/select/Select.stories.tsx +286 -0
  163. package/src/components/ui/select/Select.test.tsx +200 -0
  164. package/src/components/ui/select/Select.tsx +243 -0
  165. package/src/components/ui/select/index.ts +2 -0
  166. package/src/components/ui/skeleton/Skeleton.stories.tsx +153 -0
  167. package/src/components/ui/skeleton/Skeleton.test.tsx +162 -0
  168. package/src/components/ui/skeleton/Skeleton.tsx +229 -0
  169. package/src/components/ui/skeleton/index.ts +16 -0
  170. package/src/components/ui/spinner/Spinner.stories.tsx +67 -0
  171. package/src/components/ui/spinner/Spinner.test.tsx +67 -0
  172. package/src/components/ui/spinner/Spinner.tsx +72 -0
  173. package/src/components/ui/spinner/index.ts +2 -0
  174. package/src/components/ui/stack/Stack.stories.tsx +127 -0
  175. package/src/components/ui/stack/Stack.test.tsx +184 -0
  176. package/src/components/ui/stack/Stack.tsx +76 -0
  177. package/src/components/ui/stack/index.ts +2 -0
  178. package/src/components/ui/surface/Surface.stories.tsx +91 -0
  179. package/src/components/ui/surface/Surface.test.tsx +63 -0
  180. package/src/components/ui/surface/Surface.tsx +51 -0
  181. package/src/components/ui/surface/index.ts +1 -0
  182. package/src/components/ui/switch/Switch.stories.tsx +111 -0
  183. package/src/components/ui/switch/Switch.test.tsx +112 -0
  184. package/src/components/ui/switch/Switch.tsx +103 -0
  185. package/src/components/ui/switch/index.ts +2 -0
  186. package/src/components/ui/tabs/Tabs.stories.tsx +213 -0
  187. package/src/components/ui/tabs/Tabs.test.tsx +192 -0
  188. package/src/components/ui/tabs/Tabs.tsx +253 -0
  189. package/src/components/ui/tabs/index.ts +16 -0
  190. package/src/components/ui/toast/Toast.stories.tsx +255 -0
  191. package/src/components/ui/toast/Toast.test.tsx +207 -0
  192. package/src/components/ui/toast/Toast.tsx +327 -0
  193. package/src/components/ui/toast/index.ts +9 -0
  194. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +415 -0
  195. package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +224 -0
  196. package/src/components/ui/toolbar-button/ToolbarButton.tsx +307 -0
  197. package/src/components/ui/toolbar-button/index.ts +7 -0
  198. package/src/components/ui/tooltip/Tooltip.stories.tsx +126 -0
  199. package/src/components/ui/tooltip/Tooltip.test.tsx +262 -0
  200. package/src/components/ui/tooltip/Tooltip.tsx +234 -0
  201. package/src/components/ui/tooltip/index.ts +2 -0
  202. package/src/index.ts +10 -0
  203. package/src/stories/PresetShowcase.stories.tsx +462 -0
  204. package/src/stories/ThemePresets.stories.tsx +85 -0
  205. package/src/test/setup.ts +19 -0
  206. package/src/theme/Colors.stories.tsx +258 -0
  207. package/src/theme/config.ts +193 -0
  208. package/src/theme/elevation.stories.tsx +346 -0
  209. package/src/theme/elevation.test.ts +27 -0
  210. package/src/theme/elevation.ts +567 -0
  211. package/src/theme/global.css +129 -1
  212. package/src/theme/index.ts +40 -0
  213. package/src/theme/presets/apply.ts +97 -0
  214. package/src/theme/presets/audiobook.ts +93 -0
  215. package/src/theme/presets/default.ts +6 -0
  216. package/src/theme/presets/index.ts +4 -0
  217. package/src/theme/presets/presets.test.ts +51 -0
  218. package/src/theme/presets/types.ts +76 -0
  219. package/src/theme/shadows.stories.tsx +523 -0
  220. package/src/theme/shadows.ts +439 -0
  221. package/src/theme/tokens/index.ts +2 -0
  222. package/src/theme/tokens/primitives.ts +354 -0
  223. package/src/theme/tokens/semantic.ts +381 -0
  224. package/src/types/jest-axe.d.ts +16 -0
  225. package/src/types/nativewind.d.ts +54 -0
  226. package/src/utils/avatar-color.test.ts +30 -0
  227. package/src/utils/avatar-color.ts +20 -0
  228. package/src/utils/cn.ts +14 -0
  229. package/src/utils/colors.ts +140 -0
  230. package/src/utils/form.ts +188 -0
  231. package/src/utils/index.ts +29 -0
  232. package/src/utils/useTheme.ts +72 -0
  233. package/src/web-jsx/jsx-dev-runtime.ts +28 -0
  234. package/src/web-jsx/jsx-runtime.ts +41 -0
  235. package/tailwind.config.js +53 -3
  236. package/dist/chunk-MGW37MPO.mjs.map +0 -1
@@ -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
+ })
@@ -0,0 +1,254 @@
1
+ import React, { createContext, useContext } from 'react'
2
+ import { Modal as RNModal, View, Text, Pressable, ScrollView, type ModalProps as RNModalProps } from 'react-native'
3
+ import { cn } from '../../../utils/cn'
4
+
5
+ export type ModalSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full'
6
+ export type ModalScrollBehavior = 'inside' | 'outside'
7
+
8
+ interface ModalContextType {
9
+ onClose?: () => void
10
+ size: ModalSize
11
+ scrollBehavior: ModalScrollBehavior
12
+ }
13
+
14
+ const ModalContext = createContext<ModalContextType>({ size: 'md', scrollBehavior: 'outside' })
15
+
16
+ export interface ModalProps extends Omit<RNModalProps, 'visible'> {
17
+ /** Whether the modal is open */
18
+ isOpen: boolean
19
+ /** Callback when modal should close */
20
+ onClose?: () => void
21
+ /** Modal size */
22
+ size?: ModalSize
23
+ /** Where scrolling happens: inside modal body or whole modal */
24
+ scrollBehavior?: ModalScrollBehavior
25
+ /** Whether clicking backdrop closes modal */
26
+ closeOnOverlayClick?: boolean
27
+ /** Whether to blur the backdrop */
28
+ backdropBlur?: boolean
29
+ /** Additional className for backdrop */
30
+ backdropClassName?: string
31
+ /** Animation type */
32
+ animationType?: 'none' | 'fade' | 'slide'
33
+ children?: React.ReactNode
34
+ }
35
+
36
+ const sizeStyles: Record<ModalSize, string> = {
37
+ xs: 'max-w-xs',
38
+ sm: 'max-w-sm',
39
+ md: 'max-w-md',
40
+ lg: 'max-w-lg',
41
+ xl: 'max-w-xl',
42
+ full: 'max-w-full m-4',
43
+ }
44
+
45
+ /**
46
+ * Modal component for dialogs and overlays.
47
+ *
48
+ * @example
49
+ * <Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
50
+ * <ModalContent>
51
+ * <ModalHeader>
52
+ * <ModalTitle>Modal Title</ModalTitle>
53
+ * <ModalCloseButton />
54
+ * </ModalHeader>
55
+ * <ModalBody>
56
+ * <Text>Modal content</Text>
57
+ * </ModalBody>
58
+ * <ModalFooter>
59
+ * <Button onPress={() => setIsOpen(false)}>Close</Button>
60
+ * </ModalFooter>
61
+ * </ModalContent>
62
+ * </Modal>
63
+ */
64
+ export function Modal({
65
+ isOpen,
66
+ onClose,
67
+ size = 'md',
68
+ scrollBehavior = 'outside',
69
+ closeOnOverlayClick = true,
70
+ backdropBlur = false,
71
+ backdropClassName,
72
+ animationType = 'fade',
73
+ children,
74
+ ...props
75
+ }: ModalProps) {
76
+ const handleBackdropPress = () => {
77
+ if (closeOnOverlayClick && onClose) {
78
+ onClose()
79
+ }
80
+ }
81
+
82
+ const backdropContent = (
83
+ <Pressable
84
+ onPress={handleBackdropPress}
85
+ className={cn(
86
+ 'flex-1 items-center justify-center',
87
+ backdropBlur ? 'bg-black/30 web:backdrop-blur-sm' : 'bg-black/50',
88
+ backdropClassName
89
+ )}
90
+ >
91
+ {children}
92
+ </Pressable>
93
+ )
94
+
95
+ return (
96
+ <ModalContext.Provider value={{ onClose, size, scrollBehavior }}>
97
+ <RNModal
98
+ visible={isOpen}
99
+ transparent
100
+ animationType={animationType}
101
+ onRequestClose={onClose}
102
+ {...props}
103
+ >
104
+ {scrollBehavior === 'outside' ? (
105
+ <ScrollView
106
+ contentContainerStyle={{ flexGrow: 1 }}
107
+ keyboardShouldPersistTaps="handled"
108
+ >
109
+ {backdropContent}
110
+ </ScrollView>
111
+ ) : (
112
+ backdropContent
113
+ )}
114
+ </RNModal>
115
+ </ModalContext.Provider>
116
+ )
117
+ }
118
+
119
+ export interface ModalContentProps {
120
+ children?: React.ReactNode
121
+ className?: string
122
+ }
123
+
124
+ /**
125
+ * Container for modal content.
126
+ */
127
+ export function ModalContent({ children, className }: ModalContentProps) {
128
+ const { size } = useContext(ModalContext)
129
+
130
+ return (
131
+ <Pressable
132
+ // Prevent backdrop click from propagating
133
+ onPress={(e) => e.stopPropagation()}
134
+ >
135
+ <View
136
+ className={cn(
137
+ 'bg-surface-elevated rounded-lg shadow-xl w-full',
138
+ sizeStyles[size],
139
+ className
140
+ )}
141
+ >
142
+ {children}
143
+ </View>
144
+ </Pressable>
145
+ )
146
+ }
147
+
148
+ export interface ModalHeaderProps {
149
+ children?: React.ReactNode
150
+ className?: string
151
+ }
152
+
153
+ /**
154
+ * Header section of modal.
155
+ */
156
+ export function ModalHeader({ children, className }: ModalHeaderProps) {
157
+ return (
158
+ <View className={cn('flex-row items-center justify-between px-6 py-4 border-b border-divider', className)}>
159
+ {children}
160
+ </View>
161
+ )
162
+ }
163
+
164
+ export interface ModalTitleProps {
165
+ children?: React.ReactNode
166
+ className?: string
167
+ }
168
+
169
+ /**
170
+ * Title for modal header.
171
+ */
172
+ export function ModalTitle({ children, className }: ModalTitleProps) {
173
+ return (
174
+ <Text
175
+ accessibilityRole="header"
176
+ className={cn('text-lg font-semibold text-text-primary font-heading', className)}
177
+ >
178
+ {children}
179
+ </Text>
180
+ )
181
+ }
182
+
183
+ export interface ModalCloseButtonProps {
184
+ className?: string
185
+ }
186
+
187
+ /**
188
+ * Close button for modal header.
189
+ */
190
+ export function ModalCloseButton({ className }: ModalCloseButtonProps) {
191
+ const { onClose } = useContext(ModalContext)
192
+
193
+ return (
194
+ <Pressable
195
+ onPress={onClose}
196
+ accessibilityRole="button"
197
+ accessibilityLabel="Close modal"
198
+ className={cn(
199
+ 'p-1 rounded-md web:hover:bg-interactive-hover active:bg-interactive-active',
200
+ className
201
+ )}
202
+ >
203
+ <Text className="text-text-secondary text-xl leading-none">×</Text>
204
+ </Pressable>
205
+ )
206
+ }
207
+
208
+ export interface ModalBodyProps {
209
+ children?: React.ReactNode
210
+ /** Maximum height for scrollable content (when scrollBehavior="inside") */
211
+ maxHeight?: number
212
+ className?: string
213
+ }
214
+
215
+ /**
216
+ * Body section of modal.
217
+ */
218
+ export function ModalBody({ children, maxHeight, className }: ModalBodyProps) {
219
+ const { scrollBehavior } = useContext(ModalContext)
220
+
221
+ if (scrollBehavior === 'inside') {
222
+ return (
223
+ <ScrollView
224
+ className={cn('px-6 py-4', className)}
225
+ style={maxHeight ? { maxHeight } : undefined}
226
+ showsVerticalScrollIndicator
227
+ >
228
+ {children}
229
+ </ScrollView>
230
+ )
231
+ }
232
+
233
+ return (
234
+ <View className={cn('px-6 py-4', className)}>
235
+ {children}
236
+ </View>
237
+ )
238
+ }
239
+
240
+ export interface ModalFooterProps {
241
+ children?: React.ReactNode
242
+ className?: string
243
+ }
244
+
245
+ /**
246
+ * Footer section of modal.
247
+ */
248
+ export function ModalFooter({ children, className }: ModalFooterProps) {
249
+ return (
250
+ <View className={cn('flex-row items-center justify-end gap-2 px-6 py-4 border-t border-divider', className)}>
251
+ {children}
252
+ </View>
253
+ )
254
+ }
@@ -0,0 +1,19 @@
1
+ export {
2
+ Modal,
3
+ ModalContent,
4
+ ModalHeader,
5
+ ModalTitle,
6
+ ModalCloseButton,
7
+ ModalBody,
8
+ ModalFooter,
9
+ } from './Modal'
10
+ export type {
11
+ ModalProps,
12
+ ModalContentProps,
13
+ ModalHeaderProps,
14
+ ModalTitleProps,
15
+ ModalCloseButtonProps,
16
+ ModalBodyProps,
17
+ ModalFooterProps,
18
+ ModalSize,
19
+ } from './Modal'
@@ -0,0 +1,71 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View } from 'react-native'
3
+ import { Pill } from './Pill'
4
+ import { Indicator } from '../indicator'
5
+
6
+ const meta: Meta<typeof Pill> = {
7
+ title: 'Components/Pill',
8
+ component: Pill,
9
+ tags: ['autodocs'],
10
+ argTypes: {
11
+ variant: { control: 'select', options: ['subtle', 'outline'] },
12
+ color: { control: 'select', options: ['default', 'primary', 'secondary', 'success', 'error', 'warning', 'info'] },
13
+ size: { control: 'select', options: ['xs', 'sm', 'md'] },
14
+ rounded: { control: 'boolean' },
15
+ },
16
+ }
17
+ export default meta
18
+ type Story = StoryObj<typeof Pill>
19
+
20
+ export const Default: Story = { args: { children: 'Label', color: 'primary' } }
21
+
22
+ export const AllVariants: Story = {
23
+ render: () => (
24
+ <View className="flex-row gap-2">
25
+ <Pill variant="subtle" color="primary">Subtle</Pill>
26
+ <Pill variant="outline" color="primary">Outline</Pill>
27
+ </View>
28
+ ),
29
+ }
30
+
31
+ export const AllColors: Story = {
32
+ render: () => (
33
+ <View className="flex-row gap-2 flex-wrap">
34
+ <Pill color="default">Default</Pill>
35
+ <Pill color="primary">Primary</Pill>
36
+ <Pill color="success">Success</Pill>
37
+ <Pill color="error">Error</Pill>
38
+ <Pill color="warning">Warning</Pill>
39
+ <Pill color="info">Info</Pill>
40
+ </View>
41
+ ),
42
+ }
43
+
44
+ export const AllSizes: Story = {
45
+ render: () => (
46
+ <View className="flex-row gap-2 items-center">
47
+ <Pill size="xs" color="primary">XS</Pill>
48
+ <Pill size="sm" color="primary">SM</Pill>
49
+ <Pill size="md" color="primary">MD</Pill>
50
+ </View>
51
+ ),
52
+ }
53
+
54
+ export const WithDot: Story = {
55
+ render: () => (
56
+ <View className="flex-row gap-2">
57
+ <Pill color="success" leftElement={<Indicator size="xs" color="success" />}>Active</Pill>
58
+ <Pill color="error" leftElement={<Indicator size="xs" color="error" />}>Failed</Pill>
59
+ <Pill color="warning" leftElement={<Indicator size="xs" color="warning" />}>Pending</Pill>
60
+ </View>
61
+ ),
62
+ }
63
+
64
+ export const SquareCorners: Story = {
65
+ render: () => (
66
+ <View className="flex-row gap-2">
67
+ <Pill rounded={false} color="primary">Tag</Pill>
68
+ <Pill rounded={false} color="success">Done</Pill>
69
+ </View>
70
+ ),
71
+ }
@@ -0,0 +1,69 @@
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 { Pill } from './Pill'
5
+
6
+ describe('Pill', () => {
7
+ it('renders string children', () => {
8
+ render(<Pill>Active</Pill>)
9
+ expect(screen.getByText('Active')).toBeInTheDocument()
10
+ })
11
+
12
+ it('renders with all variants', () => {
13
+ const variants = ['subtle', 'outline'] as const
14
+ variants.forEach((variant) => {
15
+ const { unmount } = render(<Pill variant={variant}>Test</Pill>)
16
+ expect(screen.getByText('Test')).toBeInTheDocument()
17
+ unmount()
18
+ })
19
+ })
20
+
21
+ it('renders with all colors', () => {
22
+ const colors = ['default', 'primary', 'secondary', 'success', 'error', 'warning', 'info'] as const
23
+ colors.forEach((color) => {
24
+ const { unmount } = render(<Pill color={color}>Test</Pill>)
25
+ expect(screen.getByText('Test')).toBeInTheDocument()
26
+ unmount()
27
+ })
28
+ })
29
+
30
+ it('renders with all sizes', () => {
31
+ const sizes = ['xs', 'sm', 'md'] as const
32
+ sizes.forEach((size) => {
33
+ const { unmount } = render(<Pill size={size}>Test</Pill>)
34
+ expect(screen.getByText('Test')).toBeInTheDocument()
35
+ unmount()
36
+ })
37
+ })
38
+
39
+ it('applies rounded-full by default', () => {
40
+ const { container } = render(<Pill>Test</Pill>)
41
+ expect(container.firstChild).toBeInTheDocument()
42
+ })
43
+
44
+ it('supports leftElement', () => {
45
+ render(<Pill leftElement={<span data-testid="dot" />}>Label</Pill>)
46
+ expect(screen.getByTestId('dot')).toBeInTheDocument()
47
+ expect(screen.getByText('Label')).toBeInTheDocument()
48
+ })
49
+
50
+ it('handles onPress', () => {
51
+ const handler = vi.fn()
52
+ render(<Pill onPress={handler}>Click</Pill>)
53
+ fireEvent.click(screen.getByText('Click'))
54
+ expect(handler).toHaveBeenCalled()
55
+ })
56
+
57
+ it('applies custom className', () => {
58
+ const { container } = render(<Pill className="custom">Test</Pill>)
59
+ expect(container.firstChild).toBeInTheDocument()
60
+ })
61
+
62
+ describe('accessibility', () => {
63
+ it('has no accessibility violations', async () => {
64
+ const { container } = render(<Pill>Active</Pill>)
65
+ const results = await axe(container)
66
+ expect(results).toHaveNoViolations()
67
+ })
68
+ })
69
+ })
@@ -0,0 +1,104 @@
1
+ import React from 'react'
2
+ import { View, Text, Pressable, type ViewProps } from 'react-native'
3
+ import { cn } from '../../../utils/cn'
4
+
5
+ export type PillVariant = 'subtle' | 'outline'
6
+ export type PillColor = 'default' | 'primary' | 'secondary' | 'success' | 'error' | 'warning' | 'info'
7
+ export type PillSize = 'xs' | 'sm' | 'md'
8
+
9
+ export interface PillProps extends ViewProps {
10
+ /** Pill content */
11
+ children: React.ReactNode
12
+ /** Visual variant */
13
+ variant?: PillVariant
14
+ /** Color scheme */
15
+ color?: PillColor
16
+ /** Size */
17
+ size?: PillSize
18
+ /** Fully rounded (default true) or slight radius */
19
+ rounded?: boolean
20
+ /** Element before the label (e.g., Indicator dot) */
21
+ leftElement?: React.ReactNode
22
+ /** Press handler (makes pill interactive) */
23
+ onPress?: () => void
24
+ /** Additional className */
25
+ className?: string
26
+ }
27
+
28
+ const variantColorStyles: Record<PillVariant, Record<PillColor, string>> = {
29
+ subtle: {
30
+ default: 'bg-surface-raised border-border-subtle text-text-secondary',
31
+ primary: 'bg-brand-primary/10 border-brand-primary/25 text-brand-primary',
32
+ secondary: 'bg-brand-secondary/10 border-brand-secondary/25 text-brand-secondary',
33
+ success: 'bg-status-success/10 border-status-success/25 text-status-success',
34
+ error: 'bg-status-error/10 border-status-error/25 text-status-error',
35
+ warning: 'bg-status-warning/10 border-status-warning/25 text-status-warning',
36
+ info: 'bg-status-info/10 border-status-info/25 text-status-info',
37
+ },
38
+ outline: {
39
+ default: 'border-border-default text-text-secondary',
40
+ primary: 'border-brand-primary text-brand-primary',
41
+ secondary: 'border-brand-secondary text-brand-secondary',
42
+ success: 'border-status-success text-status-success',
43
+ error: 'border-status-error text-status-error',
44
+ warning: 'border-status-warning text-status-warning',
45
+ info: 'border-status-info text-status-info',
46
+ },
47
+ }
48
+
49
+ const sizeStyles: Record<PillSize, { container: string; text: string }> = {
50
+ xs: { container: 'px-1 py-px', text: 'text-[9px]' },
51
+ sm: { container: 'px-2 py-0.5', text: 'text-[10px]' },
52
+ md: { container: 'px-2 py-1', text: 'text-[10px]' },
53
+ }
54
+
55
+ export function Pill({
56
+ children,
57
+ variant = 'subtle',
58
+ color = 'default',
59
+ size = 'sm',
60
+ rounded = true,
61
+ leftElement,
62
+ onPress,
63
+ className,
64
+ ...props
65
+ }: PillProps) {
66
+ const containerClasses = cn(
67
+ 'flex-row items-center gap-1 border self-start shrink-0',
68
+ rounded ? 'rounded-full' : 'rounded',
69
+ variantColorStyles[variant][color],
70
+ sizeStyles[size].container,
71
+ className
72
+ )
73
+
74
+ const textClasses = cn(
75
+ 'font-heading font-semibold',
76
+ sizeStyles[size].text,
77
+ 'text-inherit'
78
+ )
79
+
80
+ const content = (
81
+ <>
82
+ {leftElement}
83
+ {typeof children === 'string' ? (
84
+ <Text className={textClasses}>{children}</Text>
85
+ ) : (
86
+ children
87
+ )}
88
+ </>
89
+ )
90
+
91
+ if (onPress) {
92
+ return (
93
+ <Pressable onPress={onPress} className={containerClasses} {...props}>
94
+ {content}
95
+ </Pressable>
96
+ )
97
+ }
98
+
99
+ return (
100
+ <View className={containerClasses} {...props}>
101
+ {content}
102
+ </View>
103
+ )
104
+ }
@@ -0,0 +1,2 @@
1
+ export { Pill } from './Pill'
2
+ export type { PillProps, PillVariant, PillColor, PillSize } from './Pill'