@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,207 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent, act } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import { Toast, ToastProvider, useToast } from './Toast'
|
|
5
|
+
|
|
6
|
+
describe('Toast (standalone)', () => {
|
|
7
|
+
it('renders with title', () => {
|
|
8
|
+
render(<Toast title="Success!" />)
|
|
9
|
+
expect(screen.getByText('Success!')).toBeInTheDocument()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('renders with description', () => {
|
|
13
|
+
render(<Toast title="Saved" description="Your changes have been saved." />)
|
|
14
|
+
expect(screen.getByText('Your changes have been saved.')).toBeInTheDocument()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('renders without description', () => {
|
|
18
|
+
render(<Toast title="Done" />)
|
|
19
|
+
expect(screen.getByText('Done')).toBeInTheDocument()
|
|
20
|
+
expect(screen.queryByText('Your changes')).not.toBeInTheDocument()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('statuses', () => {
|
|
24
|
+
const statuses = ['success', 'error', 'warning', 'info'] as const
|
|
25
|
+
statuses.forEach((status) => {
|
|
26
|
+
it(`renders with status ${status}`, () => {
|
|
27
|
+
render(<Toast title={`${status} toast`} status={status} />)
|
|
28
|
+
expect(screen.getByText(`${status} toast`)).toBeInTheDocument()
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('renders with default info status', () => {
|
|
34
|
+
render(<Toast title="Info toast" />)
|
|
35
|
+
expect(screen.getByRole('alert')).toBeInTheDocument()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('icon visibility', () => {
|
|
39
|
+
it('shows icon by default', () => {
|
|
40
|
+
render(<Toast title="Test" status="success" />)
|
|
41
|
+
// The checkmark icon is rendered
|
|
42
|
+
expect(screen.getByRole('alert')).toBeInTheDocument()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('hides icon when showIcon is false', () => {
|
|
46
|
+
render(<Toast title="Test" showIcon={false} />)
|
|
47
|
+
expect(screen.getByText('Test')).toBeInTheDocument()
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('close button', () => {
|
|
52
|
+
it('does not show close button by default', () => {
|
|
53
|
+
render(<Toast title="Test" />)
|
|
54
|
+
expect(screen.queryByLabelText('Close toast')).not.toBeInTheDocument()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('shows close button when isClosable and onClose are provided', () => {
|
|
58
|
+
const onClose = vi.fn()
|
|
59
|
+
render(<Toast title="Test" isClosable onClose={onClose} />)
|
|
60
|
+
expect(screen.getByLabelText('Close toast')).toBeInTheDocument()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('calls onClose when close button is pressed', () => {
|
|
64
|
+
const onClose = vi.fn()
|
|
65
|
+
render(<Toast title="Test" isClosable onClose={onClose} />)
|
|
66
|
+
|
|
67
|
+
fireEvent.click(screen.getByLabelText('Close toast'))
|
|
68
|
+
expect(onClose).toHaveBeenCalledTimes(1)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('accessibility', () => {
|
|
73
|
+
it('has no accessibility violations', async () => {
|
|
74
|
+
const { container } = render(
|
|
75
|
+
<Toast title="Accessible toast" description="Description text" status="success" />
|
|
76
|
+
)
|
|
77
|
+
const results = await axe(container)
|
|
78
|
+
expect(results).toHaveNoViolations()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('has alert role', () => {
|
|
82
|
+
render(<Toast title="Alert" />)
|
|
83
|
+
expect(screen.getByRole('alert')).toBeInTheDocument()
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('ToastProvider', () => {
|
|
89
|
+
function TestConsumer() {
|
|
90
|
+
const { addToast, removeAllToasts } = useToast()
|
|
91
|
+
return (
|
|
92
|
+
<div>
|
|
93
|
+
<button
|
|
94
|
+
onClick={() =>
|
|
95
|
+
addToast({ title: 'New Toast', status: 'success' })
|
|
96
|
+
}
|
|
97
|
+
>
|
|
98
|
+
Add Toast
|
|
99
|
+
</button>
|
|
100
|
+
<button onClick={removeAllToasts}>Clear All</button>
|
|
101
|
+
</div>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
it('renders children', () => {
|
|
106
|
+
render(
|
|
107
|
+
<ToastProvider>
|
|
108
|
+
<div>App Content</div>
|
|
109
|
+
</ToastProvider>
|
|
110
|
+
)
|
|
111
|
+
expect(screen.getByText('App Content')).toBeInTheDocument()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('adds a toast via useToast hook', () => {
|
|
115
|
+
render(
|
|
116
|
+
<ToastProvider>
|
|
117
|
+
<TestConsumer />
|
|
118
|
+
</ToastProvider>
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
fireEvent.click(screen.getByText('Add Toast'))
|
|
122
|
+
expect(screen.getByText('New Toast')).toBeInTheDocument()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('removes all toasts', () => {
|
|
126
|
+
render(
|
|
127
|
+
<ToastProvider>
|
|
128
|
+
<TestConsumer />
|
|
129
|
+
</ToastProvider>
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
fireEvent.click(screen.getByText('Add Toast'))
|
|
133
|
+
expect(screen.getByText('New Toast')).toBeInTheDocument()
|
|
134
|
+
|
|
135
|
+
fireEvent.click(screen.getByText('Clear All'))
|
|
136
|
+
expect(screen.queryByText('New Toast')).not.toBeInTheDocument()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('respects maxToasts limit', () => {
|
|
140
|
+
render(
|
|
141
|
+
<ToastProvider maxToasts={2}>
|
|
142
|
+
<TestConsumer />
|
|
143
|
+
</ToastProvider>
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
fireEvent.click(screen.getByText('Add Toast'))
|
|
147
|
+
fireEvent.click(screen.getByText('Add Toast'))
|
|
148
|
+
fireEvent.click(screen.getByText('Add Toast'))
|
|
149
|
+
|
|
150
|
+
// Only 2 should be visible
|
|
151
|
+
const toasts = screen.getAllByText('New Toast')
|
|
152
|
+
expect(toasts.length).toBeLessThanOrEqual(2)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('auto-dismisses toasts after duration', () => {
|
|
156
|
+
vi.useFakeTimers()
|
|
157
|
+
|
|
158
|
+
render(
|
|
159
|
+
<ToastProvider defaultDuration={3000}>
|
|
160
|
+
<TestConsumer />
|
|
161
|
+
</ToastProvider>
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
fireEvent.click(screen.getByText('Add Toast'))
|
|
165
|
+
expect(screen.getByText('New Toast')).toBeInTheDocument()
|
|
166
|
+
|
|
167
|
+
act(() => {
|
|
168
|
+
vi.advanceTimersByTime(3000)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
expect(screen.queryByText('New Toast')).not.toBeInTheDocument()
|
|
172
|
+
|
|
173
|
+
vi.useRealTimers()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('throws when useToast is used outside ToastProvider', () => {
|
|
177
|
+
function BadConsumer() {
|
|
178
|
+
useToast()
|
|
179
|
+
return null
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
expect(() => render(<BadConsumer />)).toThrow(
|
|
183
|
+
'useToast must be used within a ToastProvider'
|
|
184
|
+
)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
describe('positions', () => {
|
|
188
|
+
const positions = [
|
|
189
|
+
'top',
|
|
190
|
+
'top-right',
|
|
191
|
+
'top-left',
|
|
192
|
+
'bottom',
|
|
193
|
+
'bottom-right',
|
|
194
|
+
'bottom-left',
|
|
195
|
+
] as const
|
|
196
|
+
positions.forEach((position) => {
|
|
197
|
+
it(`renders with position ${position}`, () => {
|
|
198
|
+
render(
|
|
199
|
+
<ToastProvider position={position}>
|
|
200
|
+
<div>Content</div>
|
|
201
|
+
</ToastProvider>
|
|
202
|
+
)
|
|
203
|
+
expect(screen.getByText('Content')).toBeInTheDocument()
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
})
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'
|
|
2
|
+
import { View, Text, Pressable, type ViewProps } from 'react-native'
|
|
3
|
+
import { cn } from '../../../utils/cn'
|
|
4
|
+
|
|
5
|
+
export type ToastStatus = 'success' | 'error' | 'warning' | 'info'
|
|
6
|
+
export type ToastPosition = 'top' | 'top-right' | 'top-left' | 'bottom' | 'bottom-right' | 'bottom-left'
|
|
7
|
+
|
|
8
|
+
export interface ToastConfig {
|
|
9
|
+
/** Unique ID for the toast */
|
|
10
|
+
id: string
|
|
11
|
+
/** Title text */
|
|
12
|
+
title: string
|
|
13
|
+
/** Description text */
|
|
14
|
+
description?: string
|
|
15
|
+
/** Status/type of the toast */
|
|
16
|
+
status?: ToastStatus
|
|
17
|
+
/** Duration in milliseconds (0 = no auto-dismiss) */
|
|
18
|
+
duration?: number
|
|
19
|
+
/** Whether the toast can be dismissed */
|
|
20
|
+
isClosable?: boolean
|
|
21
|
+
/** Callback when toast is closed */
|
|
22
|
+
onClose?: () => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ToastContextType {
|
|
26
|
+
toasts: ToastConfig[]
|
|
27
|
+
addToast: (config: Omit<ToastConfig, 'id'>) => string
|
|
28
|
+
removeToast: (id: string) => void
|
|
29
|
+
removeAllToasts: () => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ToastContext = createContext<ToastContextType | null>(null)
|
|
33
|
+
|
|
34
|
+
let toastIdCounter = 0
|
|
35
|
+
|
|
36
|
+
export interface ToastProviderProps {
|
|
37
|
+
/** Position of toasts on screen */
|
|
38
|
+
position?: ToastPosition
|
|
39
|
+
/** Default duration for toasts */
|
|
40
|
+
defaultDuration?: number
|
|
41
|
+
/** Maximum number of visible toasts */
|
|
42
|
+
maxToasts?: number
|
|
43
|
+
children?: React.ReactNode
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const positionStyles: Record<ToastPosition, string> = {
|
|
47
|
+
'top': 'top-4 left-1/2 -translate-x-1/2',
|
|
48
|
+
'top-right': 'top-4 right-4',
|
|
49
|
+
'top-left': 'top-4 left-4',
|
|
50
|
+
'bottom': 'bottom-4 left-1/2 -translate-x-1/2',
|
|
51
|
+
'bottom-right': 'bottom-4 right-4',
|
|
52
|
+
'bottom-left': 'bottom-4 left-4',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Provider component for the toast notification system.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* // Wrap your app with ToastProvider
|
|
60
|
+
* <ToastProvider position="top-right">
|
|
61
|
+
* <App />
|
|
62
|
+
* </ToastProvider>
|
|
63
|
+
*
|
|
64
|
+
* // Then use the useToast hook in any component
|
|
65
|
+
* const { addToast } = useToast()
|
|
66
|
+
* addToast({ title: 'Success!', status: 'success' })
|
|
67
|
+
*/
|
|
68
|
+
export function ToastProvider({
|
|
69
|
+
position = 'top-right',
|
|
70
|
+
defaultDuration = 5000,
|
|
71
|
+
maxToasts = 5,
|
|
72
|
+
children,
|
|
73
|
+
}: ToastProviderProps) {
|
|
74
|
+
const [toasts, setToasts] = useState<ToastConfig[]>([])
|
|
75
|
+
|
|
76
|
+
const addToast = useCallback((config: Omit<ToastConfig, 'id'>) => {
|
|
77
|
+
const id = `toast-${++toastIdCounter}`
|
|
78
|
+
const newToast: ToastConfig = {
|
|
79
|
+
id,
|
|
80
|
+
duration: defaultDuration,
|
|
81
|
+
isClosable: true,
|
|
82
|
+
status: 'info',
|
|
83
|
+
...config,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
setToasts((prev) => {
|
|
87
|
+
const updated = [newToast, ...prev]
|
|
88
|
+
return updated.slice(0, maxToasts)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
return id
|
|
92
|
+
}, [defaultDuration, maxToasts])
|
|
93
|
+
|
|
94
|
+
const removeToast = useCallback((id: string) => {
|
|
95
|
+
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
96
|
+
}, [])
|
|
97
|
+
|
|
98
|
+
const removeAllToasts = useCallback(() => {
|
|
99
|
+
setToasts([])
|
|
100
|
+
}, [])
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<ToastContext.Provider value={{ toasts, addToast, removeToast, removeAllToasts }}>
|
|
104
|
+
{children}
|
|
105
|
+
{/* Toast Container */}
|
|
106
|
+
<View
|
|
107
|
+
className={cn(
|
|
108
|
+
'fixed z-[9999] flex flex-col gap-2',
|
|
109
|
+
positionStyles[position]
|
|
110
|
+
)}
|
|
111
|
+
pointerEvents="box-none"
|
|
112
|
+
>
|
|
113
|
+
{toasts.map((toast) => (
|
|
114
|
+
<ToastItem
|
|
115
|
+
key={toast.id}
|
|
116
|
+
{...toast}
|
|
117
|
+
onClose={() => {
|
|
118
|
+
removeToast(toast.id)
|
|
119
|
+
toast.onClose?.()
|
|
120
|
+
}}
|
|
121
|
+
/>
|
|
122
|
+
))}
|
|
123
|
+
</View>
|
|
124
|
+
</ToastContext.Provider>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Hook to access toast functions.
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* const { addToast, removeAllToasts } = useToast()
|
|
133
|
+
*
|
|
134
|
+
* // Show a success toast
|
|
135
|
+
* addToast({
|
|
136
|
+
* title: 'Saved!',
|
|
137
|
+
* description: 'Your changes have been saved.',
|
|
138
|
+
* status: 'success'
|
|
139
|
+
* })
|
|
140
|
+
*/
|
|
141
|
+
export function useToast() {
|
|
142
|
+
const context = useContext(ToastContext)
|
|
143
|
+
if (!context) {
|
|
144
|
+
throw new Error('useToast must be used within a ToastProvider')
|
|
145
|
+
}
|
|
146
|
+
return context
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
interface ToastItemProps extends ToastConfig {
|
|
150
|
+
onClose: () => void
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const statusStyles: Record<ToastStatus, { bg: string; border: string; icon: string }> = {
|
|
154
|
+
success: {
|
|
155
|
+
bg: 'bg-status-success/10',
|
|
156
|
+
border: 'border-status-success',
|
|
157
|
+
icon: '✓',
|
|
158
|
+
},
|
|
159
|
+
error: {
|
|
160
|
+
bg: 'bg-status-error/10',
|
|
161
|
+
border: 'border-status-error',
|
|
162
|
+
icon: '✕',
|
|
163
|
+
},
|
|
164
|
+
warning: {
|
|
165
|
+
bg: 'bg-status-warning/10',
|
|
166
|
+
border: 'border-status-warning',
|
|
167
|
+
icon: '⚠',
|
|
168
|
+
},
|
|
169
|
+
info: {
|
|
170
|
+
bg: 'bg-status-info/10',
|
|
171
|
+
border: 'border-status-info',
|
|
172
|
+
icon: 'ℹ',
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const iconColors: Record<ToastStatus, string> = {
|
|
177
|
+
success: 'text-status-success',
|
|
178
|
+
error: 'text-status-error',
|
|
179
|
+
warning: 'text-status-warning',
|
|
180
|
+
info: 'text-status-info',
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function ToastItem({
|
|
184
|
+
id,
|
|
185
|
+
title,
|
|
186
|
+
description,
|
|
187
|
+
status = 'info',
|
|
188
|
+
duration = 5000,
|
|
189
|
+
isClosable = true,
|
|
190
|
+
onClose,
|
|
191
|
+
}: ToastItemProps) {
|
|
192
|
+
const { bg, border, icon } = statusStyles[status]
|
|
193
|
+
|
|
194
|
+
// Auto-dismiss
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
if (duration > 0) {
|
|
197
|
+
const timer = setTimeout(onClose, duration)
|
|
198
|
+
return () => clearTimeout(timer)
|
|
199
|
+
}
|
|
200
|
+
}, [duration, onClose])
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<View
|
|
204
|
+
className={cn(
|
|
205
|
+
'min-w-[280px] max-w-[400px] rounded-lg border-l-4 shadow-lg',
|
|
206
|
+
'bg-surface-elevated',
|
|
207
|
+
border
|
|
208
|
+
)}
|
|
209
|
+
accessibilityRole="alert"
|
|
210
|
+
>
|
|
211
|
+
<View className={cn('flex-row items-start p-3', bg)}>
|
|
212
|
+
{/* Icon */}
|
|
213
|
+
<View className={cn('w-6 h-6 items-center justify-center mr-3')}>
|
|
214
|
+
<Text className={cn('text-lg', iconColors[status])}>{icon}</Text>
|
|
215
|
+
</View>
|
|
216
|
+
|
|
217
|
+
{/* Content */}
|
|
218
|
+
<View className="flex-1">
|
|
219
|
+
<Text className="text-sm font-semibold text-text-primary">
|
|
220
|
+
{title}
|
|
221
|
+
</Text>
|
|
222
|
+
{description && (
|
|
223
|
+
<Text className="text-sm text-text-secondary mt-0.5">
|
|
224
|
+
{description}
|
|
225
|
+
</Text>
|
|
226
|
+
)}
|
|
227
|
+
</View>
|
|
228
|
+
|
|
229
|
+
{/* Close button */}
|
|
230
|
+
{isClosable && (
|
|
231
|
+
<Pressable
|
|
232
|
+
onPress={onClose}
|
|
233
|
+
className="p-1 -m-1 rounded web:hover:bg-interactive-hover"
|
|
234
|
+
accessibilityLabel="Close toast"
|
|
235
|
+
>
|
|
236
|
+
<Text className="text-text-tertiary text-lg">×</Text>
|
|
237
|
+
</Pressable>
|
|
238
|
+
)}
|
|
239
|
+
</View>
|
|
240
|
+
</View>
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export interface ToastProps extends ViewProps {
|
|
245
|
+
/** Title text */
|
|
246
|
+
title: string
|
|
247
|
+
/** Description text */
|
|
248
|
+
description?: string
|
|
249
|
+
/** Status/type of the toast */
|
|
250
|
+
status?: ToastStatus
|
|
251
|
+
/** Whether to show the icon */
|
|
252
|
+
showIcon?: boolean
|
|
253
|
+
/** Whether the toast can be dismissed */
|
|
254
|
+
isClosable?: boolean
|
|
255
|
+
/** Callback when close button is clicked */
|
|
256
|
+
onClose?: () => void
|
|
257
|
+
/** Additional className */
|
|
258
|
+
className?: string
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Standalone Toast component (for static rendering without provider).
|
|
263
|
+
*
|
|
264
|
+
* @example
|
|
265
|
+
* <Toast
|
|
266
|
+
* title="Success!"
|
|
267
|
+
* description="Your changes have been saved."
|
|
268
|
+
* status="success"
|
|
269
|
+
* />
|
|
270
|
+
*/
|
|
271
|
+
export function Toast({
|
|
272
|
+
title,
|
|
273
|
+
description,
|
|
274
|
+
status = 'info',
|
|
275
|
+
showIcon = true,
|
|
276
|
+
isClosable = false,
|
|
277
|
+
onClose,
|
|
278
|
+
className,
|
|
279
|
+
...props
|
|
280
|
+
}: ToastProps) {
|
|
281
|
+
const { bg, border, icon } = statusStyles[status]
|
|
282
|
+
|
|
283
|
+
return (
|
|
284
|
+
<View
|
|
285
|
+
className={cn(
|
|
286
|
+
'min-w-[280px] max-w-[400px] rounded-lg border-l-4 shadow-lg',
|
|
287
|
+
'bg-surface-elevated',
|
|
288
|
+
border,
|
|
289
|
+
className
|
|
290
|
+
)}
|
|
291
|
+
accessibilityRole="alert"
|
|
292
|
+
{...props}
|
|
293
|
+
>
|
|
294
|
+
<View className={cn('flex-row items-start p-3', bg)}>
|
|
295
|
+
{/* Icon */}
|
|
296
|
+
{showIcon && (
|
|
297
|
+
<View className="w-6 h-6 items-center justify-center mr-3">
|
|
298
|
+
<Text className={cn('text-lg', iconColors[status])}>{icon}</Text>
|
|
299
|
+
</View>
|
|
300
|
+
)}
|
|
301
|
+
|
|
302
|
+
{/* Content */}
|
|
303
|
+
<View className="flex-1">
|
|
304
|
+
<Text className="text-sm font-semibold text-text-primary">
|
|
305
|
+
{title}
|
|
306
|
+
</Text>
|
|
307
|
+
{description && (
|
|
308
|
+
<Text className="text-sm text-text-secondary mt-0.5">
|
|
309
|
+
{description}
|
|
310
|
+
</Text>
|
|
311
|
+
)}
|
|
312
|
+
</View>
|
|
313
|
+
|
|
314
|
+
{/* Close button */}
|
|
315
|
+
{isClosable && onClose && (
|
|
316
|
+
<Pressable
|
|
317
|
+
onPress={onClose}
|
|
318
|
+
className="p-1 -m-1 rounded web:hover:bg-interactive-hover"
|
|
319
|
+
accessibilityLabel="Close toast"
|
|
320
|
+
>
|
|
321
|
+
<Text className="text-text-tertiary text-lg">×</Text>
|
|
322
|
+
</Pressable>
|
|
323
|
+
)}
|
|
324
|
+
</View>
|
|
325
|
+
</View>
|
|
326
|
+
)
|
|
327
|
+
}
|