@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.
- package/README.md +47 -1
- package/dist/{chunk-MGW37MPO.mjs → chunk-UXVRPOME.mjs} +173 -3
- package/dist/chunk-UXVRPOME.mjs.map +1 -0
- package/dist/index.d.mts +101 -12
- package/dist/index.d.ts +101 -12
- package/dist/index.js +1267 -468
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +734 -112
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +86 -1
- package/dist/theme/index.d.ts +86 -1
- package/dist/theme/index.js +166 -0
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +1 -1
- package/package.json +22 -6
- package/src/components/custom/DateTime/DateTime.stories.tsx +180 -0
- package/src/components/custom/DateTime/DateTime.test.tsx +152 -0
- package/src/components/custom/DateTime/DateTime.tsx +235 -0
- package/src/components/custom/DateTime/index.ts +2 -0
- package/src/components/custom/EmptyState/EmptyState.stories.tsx +99 -0
- package/src/components/custom/EmptyState/EmptyState.test.tsx +106 -0
- package/src/components/custom/EmptyState/EmptyState.tsx +68 -0
- package/src/components/custom/EmptyState/index.ts +2 -0
- package/src/components/custom/Metric/Metric.stories.tsx +133 -0
- package/src/components/custom/Metric/Metric.test.tsx +120 -0
- package/src/components/custom/Metric/Metric.tsx +92 -0
- package/src/components/custom/Metric/index.ts +2 -0
- package/src/components/custom/Sidebar/Sidebar.stories.tsx +227 -0
- package/src/components/custom/Sidebar/Sidebar.test.tsx +185 -0
- package/src/components/custom/Sidebar/Sidebar.tsx +257 -0
- package/src/components/custom/Sidebar/index.ts +18 -0
- package/src/components/custom/Table/Table.stories.tsx +481 -0
- package/src/components/custom/Table/Table.test.tsx +531 -0
- package/src/components/custom/Table/Table.tsx +696 -0
- package/src/components/custom/Table/index.ts +22 -0
- package/src/components/custom/Typography/Typography.stories.tsx +153 -0
- package/src/components/custom/Typography/Typography.test.tsx +107 -0
- package/src/components/custom/Typography/Typography.tsx +218 -0
- package/src/components/custom/Typography/index.ts +19 -0
- package/src/components/custom/Workout/RestTimer.stories.tsx +71 -0
- package/src/components/custom/Workout/RestTimer.test.tsx +145 -0
- package/src/components/custom/Workout/RestTimer.tsx +172 -0
- package/src/components/custom/Workout/SupersetWrapper.stories.tsx +144 -0
- package/src/components/custom/Workout/SupersetWrapper.test.tsx +130 -0
- package/src/components/custom/Workout/SupersetWrapper.tsx +63 -0
- package/src/components/custom/Workout/index.ts +2 -0
- package/src/components/custom/index.ts +9 -0
- package/src/components/custom/stepper/Stepper.stories.tsx +245 -0
- package/src/components/custom/stepper/Stepper.test.tsx +230 -0
- package/src/components/custom/stepper/Stepper.tsx +250 -0
- package/src/components/custom/stepper/index.ts +10 -0
- package/src/components/index.ts +5 -0
- package/src/components/ui/alert/Alert.stories.tsx +154 -0
- package/src/components/ui/alert/Alert.test.tsx +202 -0
- package/src/components/ui/alert/Alert.tsx +170 -0
- package/src/components/ui/alert/index.ts +8 -0
- package/src/components/ui/autocomplete/Autocomplete.stories.tsx +299 -0
- package/src/components/ui/autocomplete/Autocomplete.test.tsx +247 -0
- package/src/components/ui/autocomplete/Autocomplete.tsx +332 -0
- package/src/components/ui/autocomplete/index.ts +2 -0
- package/src/components/ui/avatar/Avatar.stories.tsx +105 -0
- package/src/components/ui/avatar/Avatar.test.tsx +152 -0
- package/src/components/ui/avatar/Avatar.tsx +176 -0
- package/src/components/ui/avatar/index.ts +2 -0
- package/src/components/ui/badge/Badge.stories.tsx +100 -0
- package/src/components/ui/badge/Badge.test.tsx +103 -0
- package/src/components/ui/badge/Badge.tsx +119 -0
- package/src/components/ui/badge/index.ts +3 -0
- package/src/components/ui/breadcrumbs/Breadcrumbs.stories.tsx +70 -0
- package/src/components/ui/breadcrumbs/Breadcrumbs.test.tsx +156 -0
- package/src/components/ui/breadcrumbs/Breadcrumbs.tsx +127 -0
- package/src/components/ui/breadcrumbs/index.ts +2 -0
- package/src/components/ui/button/Button.stories.tsx +216 -0
- package/src/components/ui/button/Button.test.tsx +132 -0
- package/src/components/ui/button/Button.tsx +297 -0
- package/src/components/ui/button/index.ts +10 -0
- package/src/components/ui/card/Card.stories.tsx +443 -0
- package/src/components/ui/card/Card.test.tsx +294 -0
- package/src/components/ui/card/Card.tsx +401 -0
- package/src/components/ui/card/index.ts +20 -0
- package/src/components/ui/checkbox/Checkbox.stories.tsx +126 -0
- package/src/components/ui/checkbox/Checkbox.test.tsx +152 -0
- package/src/components/ui/checkbox/Checkbox.tsx +163 -0
- package/src/components/ui/checkbox/index.ts +2 -0
- package/src/components/ui/chip/Chip.stories.tsx +112 -0
- package/src/components/ui/chip/Chip.test.tsx +119 -0
- package/src/components/ui/chip/Chip.tsx +151 -0
- package/src/components/ui/chip/index.ts +2 -0
- package/src/components/ui/collapse/Collapse.stories.tsx +313 -0
- package/src/components/ui/collapse/Collapse.test.tsx +298 -0
- package/src/components/ui/collapse/Collapse.tsx +275 -0
- package/src/components/ui/collapse/index.ts +18 -0
- package/src/components/ui/data-row/DataRow.stories.tsx +78 -0
- package/src/components/ui/data-row/DataRow.test.tsx +78 -0
- package/src/components/ui/data-row/DataRow.tsx +29 -0
- package/src/components/ui/data-row/index.ts +2 -0
- package/src/components/ui/divider/Divider.stories.tsx +71 -0
- package/src/components/ui/divider/Divider.test.tsx +55 -0
- package/src/components/ui/divider/Divider.tsx +37 -0
- package/src/components/ui/divider/index.ts +2 -0
- package/src/components/ui/drawer/Drawer.stories.tsx +228 -0
- package/src/components/ui/drawer/Drawer.test.tsx +142 -0
- package/src/components/ui/drawer/Drawer.tsx +228 -0
- package/src/components/ui/drawer/index.ts +9 -0
- package/src/components/ui/form-field/FormField.stories.tsx +260 -0
- package/src/components/ui/form-field/FormField.test.tsx +280 -0
- package/src/components/ui/form-field/FormField.tsx +308 -0
- package/src/components/ui/form-field/index.ts +2 -0
- package/src/components/ui/help-tip/HelpTip.stories.tsx +250 -0
- package/src/components/ui/help-tip/HelpTip.test.tsx +198 -0
- package/src/components/ui/help-tip/HelpTip.tsx +229 -0
- package/src/components/ui/help-tip/index.ts +2 -0
- package/src/components/ui/icon-box/IconBox.stories.tsx +139 -0
- package/src/components/ui/icon-box/IconBox.test.tsx +120 -0
- package/src/components/ui/icon-box/IconBox.tsx +57 -0
- package/src/components/ui/icon-box/index.ts +1 -0
- package/src/components/ui/index.ts +38 -0
- package/src/components/ui/indicator/Indicator.stories.tsx +74 -0
- package/src/components/ui/indicator/Indicator.test.tsx +55 -0
- package/src/components/ui/indicator/Indicator.tsx +73 -0
- package/src/components/ui/indicator/index.ts +2 -0
- package/src/components/ui/input/Input.stories.tsx +192 -0
- package/src/components/ui/input/Input.test.tsx +86 -0
- package/src/components/ui/input/Input.tsx +273 -0
- package/src/components/ui/input/index.ts +2 -0
- package/src/components/ui/link/Link.stories.tsx +78 -0
- package/src/components/ui/link/Link.test.tsx +94 -0
- package/src/components/ui/link/Link.tsx +86 -0
- package/src/components/ui/link/index.ts +2 -0
- package/src/components/ui/list-item/ListItem.stories.tsx +146 -0
- package/src/components/ui/list-item/ListItem.test.tsx +128 -0
- package/src/components/ui/list-item/ListItem.tsx +89 -0
- package/src/components/ui/list-item/index.ts +14 -0
- package/src/components/ui/menu/Menu.stories.tsx +180 -0
- package/src/components/ui/menu/Menu.test.tsx +353 -0
- package/src/components/ui/menu/Menu.tsx +222 -0
- package/src/components/ui/menu/index.ts +2 -0
- package/src/components/ui/modal/Modal.stories.tsx +350 -0
- package/src/components/ui/modal/Modal.test.tsx +159 -0
- package/src/components/ui/modal/Modal.tsx +254 -0
- package/src/components/ui/modal/index.ts +19 -0
- package/src/components/ui/pill/Pill.stories.tsx +71 -0
- package/src/components/ui/pill/Pill.test.tsx +69 -0
- package/src/components/ui/pill/Pill.tsx +104 -0
- package/src/components/ui/pill/index.ts +2 -0
- package/src/components/ui/popover/Popover.stories.tsx +190 -0
- package/src/components/ui/popover/Popover.test.tsx +419 -0
- package/src/components/ui/popover/Popover.tsx +237 -0
- package/src/components/ui/popover/index.ts +2 -0
- package/src/components/ui/progress/Progress.stories.tsx +190 -0
- package/src/components/ui/progress/Progress.test.tsx +210 -0
- package/src/components/ui/progress/Progress.tsx +339 -0
- package/src/components/ui/progress/index.ts +9 -0
- package/src/components/ui/radio/Radio.stories.tsx +199 -0
- package/src/components/ui/radio/Radio.test.tsx +166 -0
- package/src/components/ui/radio/Radio.tsx +200 -0
- package/src/components/ui/radio/index.ts +2 -0
- package/src/components/ui/section/Section.stories.tsx +111 -0
- package/src/components/ui/section/Section.test.tsx +121 -0
- package/src/components/ui/section/Section.tsx +52 -0
- package/src/components/ui/section/index.ts +6 -0
- package/src/components/ui/select/Select.stories.tsx +286 -0
- package/src/components/ui/select/Select.test.tsx +200 -0
- package/src/components/ui/select/Select.tsx +243 -0
- package/src/components/ui/select/index.ts +2 -0
- package/src/components/ui/skeleton/Skeleton.stories.tsx +153 -0
- package/src/components/ui/skeleton/Skeleton.test.tsx +162 -0
- package/src/components/ui/skeleton/Skeleton.tsx +229 -0
- package/src/components/ui/skeleton/index.ts +16 -0
- package/src/components/ui/spinner/Spinner.stories.tsx +67 -0
- package/src/components/ui/spinner/Spinner.test.tsx +67 -0
- package/src/components/ui/spinner/Spinner.tsx +72 -0
- package/src/components/ui/spinner/index.ts +2 -0
- package/src/components/ui/stack/Stack.stories.tsx +127 -0
- package/src/components/ui/stack/Stack.test.tsx +184 -0
- package/src/components/ui/stack/Stack.tsx +76 -0
- package/src/components/ui/stack/index.ts +2 -0
- package/src/components/ui/surface/Surface.stories.tsx +91 -0
- package/src/components/ui/surface/Surface.test.tsx +63 -0
- package/src/components/ui/surface/Surface.tsx +51 -0
- package/src/components/ui/surface/index.ts +1 -0
- package/src/components/ui/switch/Switch.stories.tsx +111 -0
- package/src/components/ui/switch/Switch.test.tsx +112 -0
- package/src/components/ui/switch/Switch.tsx +103 -0
- package/src/components/ui/switch/index.ts +2 -0
- package/src/components/ui/tabs/Tabs.stories.tsx +213 -0
- package/src/components/ui/tabs/Tabs.test.tsx +192 -0
- package/src/components/ui/tabs/Tabs.tsx +253 -0
- package/src/components/ui/tabs/index.ts +16 -0
- package/src/components/ui/toast/Toast.stories.tsx +255 -0
- package/src/components/ui/toast/Toast.test.tsx +207 -0
- package/src/components/ui/toast/Toast.tsx +327 -0
- package/src/components/ui/toast/index.ts +9 -0
- package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +415 -0
- package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +224 -0
- package/src/components/ui/toolbar-button/ToolbarButton.tsx +307 -0
- package/src/components/ui/toolbar-button/index.ts +7 -0
- package/src/components/ui/tooltip/Tooltip.stories.tsx +126 -0
- package/src/components/ui/tooltip/Tooltip.test.tsx +262 -0
- package/src/components/ui/tooltip/Tooltip.tsx +234 -0
- package/src/components/ui/tooltip/index.ts +2 -0
- package/src/index.ts +10 -0
- package/src/stories/PresetShowcase.stories.tsx +462 -0
- package/src/stories/ThemePresets.stories.tsx +85 -0
- package/src/test/setup.ts +19 -0
- package/src/theme/Colors.stories.tsx +258 -0
- package/src/theme/config.ts +193 -0
- package/src/theme/elevation.stories.tsx +346 -0
- package/src/theme/elevation.test.ts +27 -0
- package/src/theme/elevation.ts +567 -0
- package/src/theme/global.css +129 -1
- package/src/theme/index.ts +40 -0
- package/src/theme/presets/apply.ts +97 -0
- package/src/theme/presets/audiobook.ts +93 -0
- package/src/theme/presets/default.ts +6 -0
- package/src/theme/presets/index.ts +4 -0
- package/src/theme/presets/presets.test.ts +51 -0
- package/src/theme/presets/types.ts +76 -0
- package/src/theme/shadows.stories.tsx +523 -0
- package/src/theme/shadows.ts +439 -0
- package/src/theme/tokens/index.ts +2 -0
- package/src/theme/tokens/primitives.ts +354 -0
- package/src/theme/tokens/semantic.ts +381 -0
- package/src/types/jest-axe.d.ts +16 -0
- package/src/types/nativewind.d.ts +54 -0
- package/src/utils/avatar-color.test.ts +30 -0
- package/src/utils/avatar-color.ts +20 -0
- package/src/utils/cn.ts +14 -0
- package/src/utils/colors.ts +140 -0
- package/src/utils/form.ts +188 -0
- package/src/utils/index.ts +29 -0
- package/src/utils/useTheme.ts +72 -0
- package/src/web-jsx/jsx-dev-runtime.ts +28 -0
- package/src/web-jsx/jsx-runtime.ts +41 -0
- package/tailwind.config.js +53 -3
- 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
|
+
}
|