@yuno-payments/dashboard-design-system 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.storybook/main.ts +20 -0
- package/.storybook/preview.ts +18 -0
- package/.storybook/vitest.setup.ts +7 -0
- package/README.md +69 -0
- package/components.json +21 -0
- package/eslint.config.js +26 -0
- package/index.html +13 -0
- package/package.json +57 -0
- package/public/vite.svg +1 -0
- package/src/App.css +42 -0
- package/src/App.tsx +11 -0
- package/src/assets/react.svg +1 -0
- package/src/components/atoms/button/button.stories.tsx +222 -0
- package/src/components/atoms/button/button.test.tsx +78 -0
- package/src/components/atoms/button/index.tsx +80 -0
- package/src/components/atoms/checkbox/checkbox.stories.tsx +314 -0
- package/src/components/atoms/checkbox/checkbox.test.tsx +278 -0
- package/src/components/atoms/checkbox/index.tsx +103 -0
- package/src/components/atoms/chip/chip.stories.tsx +317 -0
- package/src/components/atoms/chip/chip.test.tsx +300 -0
- package/src/components/atoms/chip/index.tsx +114 -0
- package/src/components/atoms/input/index.tsx +27 -0
- package/src/components/atoms/link/index.tsx +79 -0
- package/src/components/atoms/link/link.stories.tsx +159 -0
- package/src/components/atoms/link/link.test.tsx +176 -0
- package/src/components/atoms/radiobutton/index.tsx +103 -0
- package/src/components/atoms/radiobutton/radiobutton.stories.tsx +314 -0
- package/src/components/atoms/radiobutton/radiobutton.test.tsx +245 -0
- package/src/components/atoms/tag/index.tsx +196 -0
- package/src/components/atoms/tag/tag.stories.tsx +281 -0
- package/src/components/atoms/tag/tag.test.tsx +282 -0
- package/src/components/atoms/typography/index.tsx +62 -0
- package/src/components/atoms/typography/typography.stories.tsx +214 -0
- package/src/components/atoms/typography/typography.test.tsx +187 -0
- package/src/components/index.tsx +17 -0
- package/src/components/molecules/announcement/announcement.stories.tsx +277 -0
- package/src/components/molecules/announcement/announcement.test.tsx +354 -0
- package/src/components/molecules/announcement/index.tsx +200 -0
- package/src/components/molecules/notification-alert/index.tsx +293 -0
- package/src/components/molecules/notification-alert/notification-alert.stories.tsx +418 -0
- package/src/components/molecules/notification-alert/notification-alert.test.tsx +454 -0
- package/src/components/molecules/popover/index.tsx +175 -0
- package/src/components/molecules/popover/popover.stories.tsx +241 -0
- package/src/components/molecules/popover/popover.test.tsx +191 -0
- package/src/components/molecules/textfield/index.tsx +154 -0
- package/src/components/molecules/textfield/textfield.stories.tsx +168 -0
- package/src/components/molecules/textfield/textfield.test.tsx +157 -0
- package/src/components/molecules/tooltip/index.tsx +263 -0
- package/src/components/molecules/tooltip/tooltip.stories.tsx +363 -0
- package/src/components/molecules/tooltip/tooltip.test.tsx +468 -0
- package/src/components/organisms/dialog/dialog.stories.tsx +522 -0
- package/src/components/organisms/dialog/dialog.test.tsx +525 -0
- package/src/components/organisms/dialog/index.tsx +233 -0
- package/src/components/organisms/dropdown/dropdown.stories.tsx +529 -0
- package/src/components/organisms/dropdown/dropdown.test.tsx +390 -0
- package/src/components/organisms/dropdown/index.tsx +624 -0
- package/src/index.css +184 -0
- package/src/lib/color-utils.ts +94 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.tsx +10 -0
- package/src/stories/Colors.stories.tsx +107 -0
- package/src/stories/Shadows.stories.tsx +110 -0
- package/src/stories/Spacing.stories.tsx +121 -0
- package/src/stories/Typography.stories.tsx +197 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.app.json +33 -0
- package/tsconfig.json +13 -0
- package/tsconfig.node.json +25 -0
- package/vite.config.ts +43 -0
- package/vitest.config.ts +15 -0
- package/vitest.shims.d.ts +1 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { render, fireEvent } from '@testing-library/react'
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
4
|
+
import '@testing-library/jest-dom'
|
|
5
|
+
import { NotificationAlert } from './index'
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
vi.useFakeTimers()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
vi.runOnlyPendingTimers()
|
|
13
|
+
vi.useRealTimers()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
function renderComponent(props = {}) {
|
|
17
|
+
const defaultProps = {
|
|
18
|
+
message: 'Test notification',
|
|
19
|
+
open: true,
|
|
20
|
+
...props
|
|
21
|
+
}
|
|
22
|
+
const result = render(<NotificationAlert {...defaultProps} />)
|
|
23
|
+
return { ...result }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getByTestId(container: HTMLElement, testId: string) {
|
|
27
|
+
const el = container.querySelector(`[data-testid="${testId}"]`)
|
|
28
|
+
if (!el) throw new Error(`Element with data-testid="${testId}" not found`)
|
|
29
|
+
return el as HTMLElement
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('NotificationAlert', () => {
|
|
33
|
+
it('renders correctly with message', () => {
|
|
34
|
+
const { container } = renderComponent()
|
|
35
|
+
const alert = getByTestId(container, 'notification-alert')
|
|
36
|
+
const message = getByTestId(container, 'notification-message')
|
|
37
|
+
|
|
38
|
+
expect(alert).toBeInTheDocument()
|
|
39
|
+
expect(message).toHaveTextContent('Test notification')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('renders with title and message', () => {
|
|
43
|
+
const { container } = renderComponent({
|
|
44
|
+
title: 'Test Title',
|
|
45
|
+
message: 'Test Message'
|
|
46
|
+
})
|
|
47
|
+
const title = getByTestId(container, 'notification-title')
|
|
48
|
+
const message = getByTestId(container, 'notification-message')
|
|
49
|
+
|
|
50
|
+
expect(title).toHaveTextContent('Test Title')
|
|
51
|
+
expect(message).toHaveTextContent('Test Message')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('renders with only title', () => {
|
|
55
|
+
const { container } = renderComponent({
|
|
56
|
+
title: 'Test Title',
|
|
57
|
+
message: undefined
|
|
58
|
+
})
|
|
59
|
+
const title = getByTestId(container, 'notification-title')
|
|
60
|
+
const message = container.querySelector('[data-testid="notification-message"]')
|
|
61
|
+
|
|
62
|
+
expect(title).toHaveTextContent('Test Title')
|
|
63
|
+
expect(message).not.toBeInTheDocument()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('does not render when open is false', () => {
|
|
67
|
+
const { container } = renderComponent({ open: false })
|
|
68
|
+
const alert = container.querySelector('[data-testid="notification-alert"]')
|
|
69
|
+
|
|
70
|
+
expect(alert).not.toBeInTheDocument()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('renders with default mode (no icon)', () => {
|
|
74
|
+
const { container } = renderComponent({ mode: 'default' })
|
|
75
|
+
const icon = container.querySelector('[data-testid="notification-icon"]')
|
|
76
|
+
|
|
77
|
+
expect(icon).not.toBeInTheDocument()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('renders with info mode and icon', () => {
|
|
81
|
+
const { container } = renderComponent({ mode: 'info' })
|
|
82
|
+
const icon = getByTestId(container, 'notification-icon')
|
|
83
|
+
|
|
84
|
+
expect(icon).toBeInTheDocument()
|
|
85
|
+
expect(icon.querySelector('svg')).toBeInTheDocument()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('renders with success mode and icon', () => {
|
|
89
|
+
const { container } = renderComponent({ mode: 'success' })
|
|
90
|
+
const icon = getByTestId(container, 'notification-icon')
|
|
91
|
+
|
|
92
|
+
expect(icon).toBeInTheDocument()
|
|
93
|
+
expect(icon.querySelector('svg')).toBeInTheDocument()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('renders with warning mode and icon', () => {
|
|
97
|
+
const { container } = renderComponent({ mode: 'warning' })
|
|
98
|
+
const icon = getByTestId(container, 'notification-icon')
|
|
99
|
+
|
|
100
|
+
expect(icon).toBeInTheDocument()
|
|
101
|
+
expect(icon.querySelector('svg')).toBeInTheDocument()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('renders with error mode and icon', () => {
|
|
105
|
+
const { container } = renderComponent({ mode: 'error' })
|
|
106
|
+
const icon = getByTestId(container, 'notification-icon')
|
|
107
|
+
|
|
108
|
+
expect(icon).toBeInTheDocument()
|
|
109
|
+
expect(icon.querySelector('svg')).toBeInTheDocument()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('renders close button when withCloseButton is true and onClose is provided', () => {
|
|
113
|
+
const handleClose = vi.fn()
|
|
114
|
+
const { container } = renderComponent({
|
|
115
|
+
withCloseButton: true,
|
|
116
|
+
onClose: handleClose
|
|
117
|
+
})
|
|
118
|
+
const closeButton = getByTestId(container, 'notification-close')
|
|
119
|
+
|
|
120
|
+
expect(closeButton).toBeInTheDocument()
|
|
121
|
+
expect(closeButton).toHaveAttribute('aria-label', 'Close notification')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('does not render close button when withCloseButton is false', () => {
|
|
125
|
+
const handleClose = vi.fn()
|
|
126
|
+
const { container } = renderComponent({
|
|
127
|
+
withCloseButton: false,
|
|
128
|
+
onClose: handleClose
|
|
129
|
+
})
|
|
130
|
+
const closeButton = container.querySelector('[data-testid="notification-close"]')
|
|
131
|
+
|
|
132
|
+
expect(closeButton).not.toBeInTheDocument()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('does not render close button when onClose is not provided', () => {
|
|
136
|
+
const { container } = renderComponent({
|
|
137
|
+
withCloseButton: true,
|
|
138
|
+
onClose: undefined
|
|
139
|
+
})
|
|
140
|
+
const closeButton = container.querySelector('[data-testid="notification-close"]')
|
|
141
|
+
|
|
142
|
+
expect(closeButton).not.toBeInTheDocument()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('calls onClose when close button is clicked', () => {
|
|
146
|
+
const handleClose = vi.fn()
|
|
147
|
+
const { container } = renderComponent({
|
|
148
|
+
withCloseButton: true,
|
|
149
|
+
onClose: handleClose
|
|
150
|
+
})
|
|
151
|
+
const closeButton = getByTestId(container, 'notification-close')
|
|
152
|
+
|
|
153
|
+
fireEvent.click(closeButton)
|
|
154
|
+
expect(handleClose).toHaveBeenCalledTimes(1)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('renders progress bar when withProgress is true', () => {
|
|
158
|
+
const { container } = renderComponent({
|
|
159
|
+
withProgress: true,
|
|
160
|
+
timeToLive: 10
|
|
161
|
+
})
|
|
162
|
+
const progress = getByTestId(container, 'notification-progress')
|
|
163
|
+
|
|
164
|
+
expect(progress).toBeInTheDocument()
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('does not render progress bar when withProgress is false', () => {
|
|
168
|
+
const { container } = renderComponent({
|
|
169
|
+
withProgress: false
|
|
170
|
+
})
|
|
171
|
+
const progress = container.querySelector('[data-testid="notification-progress"]')
|
|
172
|
+
|
|
173
|
+
expect(progress).not.toBeInTheDocument()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('renders action button when primaryActionLabel and onClick are provided', () => {
|
|
177
|
+
const handleClick = vi.fn()
|
|
178
|
+
const { container } = renderComponent({
|
|
179
|
+
primaryActionLabel: 'Got it!',
|
|
180
|
+
onClick: handleClick
|
|
181
|
+
})
|
|
182
|
+
const actionButton = getByTestId(container, 'notification-action')
|
|
183
|
+
|
|
184
|
+
expect(actionButton).toBeInTheDocument()
|
|
185
|
+
expect(actionButton).toHaveTextContent('Got it!')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('does not render action button when primaryActionLabel is not provided', () => {
|
|
189
|
+
const handleClick = vi.fn()
|
|
190
|
+
const { container } = renderComponent({
|
|
191
|
+
onClick: handleClick
|
|
192
|
+
})
|
|
193
|
+
const actionButton = container.querySelector('[data-testid="notification-action"]')
|
|
194
|
+
|
|
195
|
+
expect(actionButton).not.toBeInTheDocument()
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('does not render action button when onClick is not provided', () => {
|
|
199
|
+
const { container } = renderComponent({
|
|
200
|
+
primaryActionLabel: 'Got it!'
|
|
201
|
+
})
|
|
202
|
+
const actionButton = container.querySelector('[data-testid="notification-action"]')
|
|
203
|
+
|
|
204
|
+
expect(actionButton).not.toBeInTheDocument()
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('calls onClick and onClose when action button is clicked', () => {
|
|
208
|
+
const handleClick = vi.fn()
|
|
209
|
+
const handleClose = vi.fn()
|
|
210
|
+
const { container } = renderComponent({
|
|
211
|
+
primaryActionLabel: 'Got it!',
|
|
212
|
+
onClick: handleClick,
|
|
213
|
+
onClose: handleClose
|
|
214
|
+
})
|
|
215
|
+
const actionButton = getByTestId(container, 'notification-action')
|
|
216
|
+
|
|
217
|
+
fireEvent.click(actionButton)
|
|
218
|
+
expect(handleClick).toHaveBeenCalledTimes(1)
|
|
219
|
+
expect(handleClose).toHaveBeenCalledTimes(1)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('applies correct positioning classes for floating notifications', () => {
|
|
223
|
+
const { container } = renderComponent({
|
|
224
|
+
position: 'top-right',
|
|
225
|
+
isFloating: true
|
|
226
|
+
})
|
|
227
|
+
const alert = getByTestId(container, 'notification-alert')
|
|
228
|
+
|
|
229
|
+
expect(alert).toHaveClass('fixed', 'top-4', 'right-4', 'z-50')
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('applies static positioning for non-floating notifications', () => {
|
|
233
|
+
const { container } = renderComponent({
|
|
234
|
+
position: 'top-right',
|
|
235
|
+
isFloating: false
|
|
236
|
+
})
|
|
237
|
+
const alert = getByTestId(container, 'notification-alert')
|
|
238
|
+
|
|
239
|
+
expect(alert).toHaveClass('relative')
|
|
240
|
+
expect(alert).not.toHaveClass('fixed', 'top-4', 'right-4')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('applies correct mode styling classes', () => {
|
|
244
|
+
const { container: infoContainer } = renderComponent({ mode: 'info' })
|
|
245
|
+
const infoAlert = getByTestId(infoContainer, 'notification-alert')
|
|
246
|
+
expect(infoAlert).toHaveClass('border-l-blue-500')
|
|
247
|
+
|
|
248
|
+
const { container: successContainer } = renderComponent({ mode: 'success' })
|
|
249
|
+
const successAlert = getByTestId(successContainer, 'notification-alert')
|
|
250
|
+
expect(successAlert).toHaveClass('border-l-green-500')
|
|
251
|
+
|
|
252
|
+
const { container: warningContainer } = renderComponent({ mode: 'warning' })
|
|
253
|
+
const warningAlert = getByTestId(warningContainer, 'notification-alert')
|
|
254
|
+
expect(warningAlert).toHaveClass('border-l-yellow-500')
|
|
255
|
+
|
|
256
|
+
const { container: errorContainer } = renderComponent({ mode: 'error' })
|
|
257
|
+
const errorAlert = getByTestId(errorContainer, 'notification-alert')
|
|
258
|
+
expect(errorAlert).toHaveClass('border-l-red-500')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('handles timeToLive countdown', async () => {
|
|
262
|
+
const handleClose = vi.fn()
|
|
263
|
+
renderComponent({
|
|
264
|
+
timeToLive: 2,
|
|
265
|
+
onClose: handleClose
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
await act(async () => {
|
|
269
|
+
vi.advanceTimersByTime(2000)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
expect(handleClose).toHaveBeenCalledTimes(1)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('stops timer when component unmounts', () => {
|
|
276
|
+
const { unmount } = renderComponent({
|
|
277
|
+
timeToLive: 10
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
const clearIntervalSpy = vi.spyOn(global, 'clearInterval')
|
|
281
|
+
unmount()
|
|
282
|
+
|
|
283
|
+
expect(clearIntervalSpy).toHaveBeenCalled()
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('stops timer when open becomes false', () => {
|
|
287
|
+
const { rerender } = renderComponent({
|
|
288
|
+
timeToLive: 10,
|
|
289
|
+
open: true
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
const clearIntervalSpy = vi.spyOn(global, 'clearInterval')
|
|
293
|
+
|
|
294
|
+
rerender(<NotificationAlert message="Test" timeToLive={10} open={false} />)
|
|
295
|
+
|
|
296
|
+
expect(clearIntervalSpy).toHaveBeenCalled()
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('forwards ref correctly', () => {
|
|
300
|
+
const ref = vi.fn()
|
|
301
|
+
renderComponent({ ref })
|
|
302
|
+
|
|
303
|
+
expect(ref).toHaveBeenCalled()
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('applies custom className', () => {
|
|
307
|
+
const { container } = renderComponent({ className: 'custom-class' })
|
|
308
|
+
const alert = getByTestId(container, 'notification-alert')
|
|
309
|
+
|
|
310
|
+
expect(alert).toHaveClass('custom-class')
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('passes through HTML div attributes', () => {
|
|
314
|
+
const { container } = renderComponent({
|
|
315
|
+
'data-custom': 'test-value',
|
|
316
|
+
id: 'test-notification'
|
|
317
|
+
})
|
|
318
|
+
const alert = getByTestId(container, 'notification-alert')
|
|
319
|
+
|
|
320
|
+
expect(alert).toHaveAttribute('data-custom', 'test-value')
|
|
321
|
+
expect(alert).toHaveAttribute('id', 'test-notification')
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('renders with different icon sizes', () => {
|
|
325
|
+
const { container: defaultContainer } = renderComponent({
|
|
326
|
+
mode: 'info',
|
|
327
|
+
iconSize: 'default'
|
|
328
|
+
})
|
|
329
|
+
const defaultIcon = getByTestId(defaultContainer, 'notification-icon')
|
|
330
|
+
expect(defaultIcon.querySelector('svg')).toHaveClass('size-4')
|
|
331
|
+
|
|
332
|
+
const { container: largeContainer } = renderComponent({
|
|
333
|
+
mode: 'info',
|
|
334
|
+
iconSize: 'large'
|
|
335
|
+
})
|
|
336
|
+
const largeIcon = getByTestId(largeContainer, 'notification-icon')
|
|
337
|
+
expect(largeIcon.querySelector('svg')).toHaveClass('size-5')
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('renders with different sizes', () => {
|
|
341
|
+
const { container: defaultContainer } = renderComponent({ size: 'default' })
|
|
342
|
+
const defaultAlert = getByTestId(defaultContainer, 'notification-alert')
|
|
343
|
+
expect(defaultAlert).toHaveClass('min-w-[300px]', 'max-w-[420px]')
|
|
344
|
+
|
|
345
|
+
const { container: fullContainer } = renderComponent({ size: 'full' })
|
|
346
|
+
const fullAlert = getByTestId(fullContainer, 'notification-alert')
|
|
347
|
+
expect(fullAlert).toHaveClass('w-full')
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('renders with all position variants', () => {
|
|
351
|
+
const positions = ['top-right', 'top-left', 'bottom-right', 'bottom-left', 'center'] as const
|
|
352
|
+
const expectedClasses = [
|
|
353
|
+
['top-4', 'right-4'],
|
|
354
|
+
['top-4', 'left-4'],
|
|
355
|
+
['bottom-4', 'right-4'],
|
|
356
|
+
['bottom-4', 'left-4'],
|
|
357
|
+
['top-1/2', 'left-1/2', '-translate-x-1/2', '-translate-y-1/2']
|
|
358
|
+
]
|
|
359
|
+
|
|
360
|
+
positions.forEach((position, index) => {
|
|
361
|
+
const { container } = renderComponent({ position, isFloating: true })
|
|
362
|
+
const alert = getByTestId(container, 'notification-alert')
|
|
363
|
+
expectedClasses[index].forEach(className => {
|
|
364
|
+
expect(alert).toHaveClass(className)
|
|
365
|
+
})
|
|
366
|
+
})
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('combines multiple features correctly', () => {
|
|
370
|
+
const handleClick = vi.fn()
|
|
371
|
+
const handleClose = vi.fn()
|
|
372
|
+
const { container } = renderComponent({
|
|
373
|
+
mode: 'warning',
|
|
374
|
+
title: 'Warning Title',
|
|
375
|
+
message: 'Warning message',
|
|
376
|
+
withProgress: true,
|
|
377
|
+
timeToLive: 10,
|
|
378
|
+
primaryActionLabel: 'Dismiss',
|
|
379
|
+
onClick: handleClick,
|
|
380
|
+
onClose: handleClose,
|
|
381
|
+
withCloseButton: true,
|
|
382
|
+
position: 'top-left',
|
|
383
|
+
isFloating: true,
|
|
384
|
+
className: 'custom-class'
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
const alert = getByTestId(container, 'notification-alert')
|
|
388
|
+
const icon = getByTestId(container, 'notification-icon')
|
|
389
|
+
const title = getByTestId(container, 'notification-title')
|
|
390
|
+
const message = getByTestId(container, 'notification-message')
|
|
391
|
+
const progress = getByTestId(container, 'notification-progress')
|
|
392
|
+
const actionButton = getByTestId(container, 'notification-action')
|
|
393
|
+
const closeButton = getByTestId(container, 'notification-close')
|
|
394
|
+
|
|
395
|
+
expect(alert).toHaveClass('border-l-yellow-500', 'custom-class', 'top-4', 'left-4')
|
|
396
|
+
expect(icon).toBeInTheDocument()
|
|
397
|
+
expect(title).toHaveTextContent('Warning Title')
|
|
398
|
+
expect(message).toHaveTextContent('Warning message')
|
|
399
|
+
expect(progress).toBeInTheDocument()
|
|
400
|
+
expect(actionButton).toHaveTextContent('Dismiss')
|
|
401
|
+
expect(closeButton).toBeInTheDocument()
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('handles React node content for title and message', () => {
|
|
405
|
+
const complexTitle = <span>Complex <strong>Title</strong></span>
|
|
406
|
+
const complexMessage = <div>Complex <em>Message</em></div>
|
|
407
|
+
|
|
408
|
+
const { container } = renderComponent({
|
|
409
|
+
title: complexTitle,
|
|
410
|
+
message: complexMessage
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
const title = getByTestId(container, 'notification-title')
|
|
414
|
+
const message = getByTestId(container, 'notification-message')
|
|
415
|
+
|
|
416
|
+
expect(title).toContainHTML('<span>Complex <strong>Title</strong></span>')
|
|
417
|
+
expect(message).toContainHTML('<div>Complex <em>Message</em></div>')
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
it('updates progress bar correctly during countdown', async () => {
|
|
421
|
+
const { container } = renderComponent({
|
|
422
|
+
withProgress: true,
|
|
423
|
+
timeToLive: 4
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
const progress = getByTestId(container, 'notification-progress')
|
|
427
|
+
const progressBar = progress.querySelector('[role="progressbar"]') as HTMLElement
|
|
428
|
+
|
|
429
|
+
await act(async () => {
|
|
430
|
+
vi.advanceTimersByTime(1000)
|
|
431
|
+
})
|
|
432
|
+
expect(progressBar).toHaveAttribute('aria-valuenow', '25')
|
|
433
|
+
|
|
434
|
+
await act(async () => {
|
|
435
|
+
vi.advanceTimersByTime(1000)
|
|
436
|
+
})
|
|
437
|
+
expect(progressBar).toHaveAttribute('aria-valuenow', '50')
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('maintains accessibility attributes', () => {
|
|
441
|
+
const handleClose = vi.fn()
|
|
442
|
+
const { container } = renderComponent({
|
|
443
|
+
withCloseButton: true,
|
|
444
|
+
onClose: handleClose
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
const alert = getByTestId(container, 'notification-alert')
|
|
448
|
+
const closeButton = getByTestId(container, 'notification-close')
|
|
449
|
+
|
|
450
|
+
expect(alert).toHaveAttribute('data-testid', 'notification-alert')
|
|
451
|
+
expect(closeButton).toHaveAttribute('type', 'button')
|
|
452
|
+
expect(closeButton).toHaveAttribute('aria-label', 'Close notification')
|
|
453
|
+
})
|
|
454
|
+
})
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
3
|
+
import { cn } from "@/lib/utils"
|
|
4
|
+
|
|
5
|
+
const popoverVariants = cva(
|
|
6
|
+
"absolute z-50 min-w-[8rem] max-w-[300px] rounded-md border bg-popover px-3 py-1.5 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
7
|
+
{
|
|
8
|
+
variants: {
|
|
9
|
+
side: {
|
|
10
|
+
top: "-translate-y-2 bottom-full left-1/2 -translate-x-1/2",
|
|
11
|
+
bottom: "translate-y-2 top-full left-1/2 -translate-x-1/2",
|
|
12
|
+
left: "-translate-x-2 right-full top-1/2 -translate-y-1/2",
|
|
13
|
+
right: "translate-x-2 left-full top-1/2 -translate-y-1/2",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
defaultVariants: {
|
|
17
|
+
side: "bottom",
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
const popoverContentVariants = cva(
|
|
23
|
+
"text-[var(--font-size-tiny)] leading-[var(--line-height-tiny)] text-popover-foreground",
|
|
24
|
+
{
|
|
25
|
+
variants: {
|
|
26
|
+
hasShortcut: {
|
|
27
|
+
true: "flex items-center gap-2",
|
|
28
|
+
false: "",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
defaultVariants: {
|
|
32
|
+
hasShortcut: false,
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
export interface PopoverProps
|
|
38
|
+
extends React.HTMLAttributes<HTMLDivElement>,
|
|
39
|
+
VariantProps<typeof popoverVariants> {
|
|
40
|
+
label: React.ReactNode
|
|
41
|
+
shortcut?: string
|
|
42
|
+
children?: React.ReactNode
|
|
43
|
+
showOn?: "hover" | "click"
|
|
44
|
+
isAvailable?: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const Popover = React.forwardRef<HTMLDivElement, PopoverProps>(
|
|
48
|
+
(
|
|
49
|
+
{
|
|
50
|
+
className,
|
|
51
|
+
side,
|
|
52
|
+
label,
|
|
53
|
+
shortcut,
|
|
54
|
+
children = "Hover this to see the popover",
|
|
55
|
+
showOn = "hover",
|
|
56
|
+
isAvailable = true,
|
|
57
|
+
...props
|
|
58
|
+
},
|
|
59
|
+
ref
|
|
60
|
+
) => {
|
|
61
|
+
const [isOpen, setIsOpen] = React.useState(false)
|
|
62
|
+
const triggerRef = React.useRef<HTMLDivElement>(null)
|
|
63
|
+
const popoverRef = React.useRef<HTMLDivElement>(null)
|
|
64
|
+
|
|
65
|
+
const handleOpen = React.useCallback(() => {
|
|
66
|
+
setIsOpen(true)
|
|
67
|
+
}, [])
|
|
68
|
+
|
|
69
|
+
const handleClose = React.useCallback(() => {
|
|
70
|
+
setIsOpen(false)
|
|
71
|
+
}, [])
|
|
72
|
+
|
|
73
|
+
const handleMouseEnter = React.useCallback(() => {
|
|
74
|
+
if (showOn === "hover") {
|
|
75
|
+
handleOpen()
|
|
76
|
+
}
|
|
77
|
+
}, [showOn, handleOpen])
|
|
78
|
+
|
|
79
|
+
const handleMouseLeave = React.useCallback(() => {
|
|
80
|
+
if (showOn === "hover") {
|
|
81
|
+
handleClose()
|
|
82
|
+
}
|
|
83
|
+
}, [showOn, handleClose])
|
|
84
|
+
|
|
85
|
+
const handleClick = React.useCallback(() => {
|
|
86
|
+
if (showOn === "click") {
|
|
87
|
+
if (isOpen) {
|
|
88
|
+
handleClose()
|
|
89
|
+
} else {
|
|
90
|
+
handleOpen()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}, [showOn, isOpen, handleOpen, handleClose])
|
|
94
|
+
|
|
95
|
+
React.useEffect(() => {
|
|
96
|
+
const handleEscape = (event: KeyboardEvent) => {
|
|
97
|
+
if (event.key === "Escape" && isOpen) {
|
|
98
|
+
handleClose()
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (isOpen) {
|
|
103
|
+
document.addEventListener("keydown", handleEscape)
|
|
104
|
+
return () => document.removeEventListener("keydown", handleEscape)
|
|
105
|
+
}
|
|
106
|
+
}, [isOpen, handleClose])
|
|
107
|
+
|
|
108
|
+
React.useEffect(() => {
|
|
109
|
+
const handleOutsideClick = (event: MouseEvent) => {
|
|
110
|
+
if (
|
|
111
|
+
showOn === "click" &&
|
|
112
|
+
isOpen &&
|
|
113
|
+
triggerRef.current &&
|
|
114
|
+
popoverRef.current &&
|
|
115
|
+
!triggerRef.current.contains(event.target as Node) &&
|
|
116
|
+
!popoverRef.current.contains(event.target as Node)
|
|
117
|
+
) {
|
|
118
|
+
handleClose()
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (isOpen && showOn === "click") {
|
|
123
|
+
document.addEventListener("mousedown", handleOutsideClick)
|
|
124
|
+
return () => document.removeEventListener("mousedown", handleOutsideClick)
|
|
125
|
+
}
|
|
126
|
+
}, [isOpen, showOn, handleClose])
|
|
127
|
+
|
|
128
|
+
if (!isAvailable) {
|
|
129
|
+
return <>{children}</>
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div ref={ref} className="relative inline-block">
|
|
134
|
+
<div
|
|
135
|
+
ref={triggerRef}
|
|
136
|
+
onMouseEnter={handleMouseEnter}
|
|
137
|
+
onMouseLeave={handleMouseLeave}
|
|
138
|
+
onClick={handleClick}
|
|
139
|
+
aria-describedby={isOpen ? "popover-content" : undefined}
|
|
140
|
+
className="cursor-pointer"
|
|
141
|
+
>
|
|
142
|
+
{children}
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
{isOpen && (
|
|
146
|
+
<div
|
|
147
|
+
ref={popoverRef}
|
|
148
|
+
id="popover-content"
|
|
149
|
+
role="tooltip"
|
|
150
|
+
className={cn(
|
|
151
|
+
popoverVariants({ side, className }),
|
|
152
|
+
showOn === "hover" && "pointer-events-none"
|
|
153
|
+
)}
|
|
154
|
+
data-state="open"
|
|
155
|
+
data-side={side}
|
|
156
|
+
{...props}
|
|
157
|
+
>
|
|
158
|
+
<div className={cn(popoverContentVariants({ hasShortcut: Boolean(shortcut) }))}>
|
|
159
|
+
<span className="text-popover-foreground">{label}</span>
|
|
160
|
+
{shortcut && (
|
|
161
|
+
<span className="text-muted-foreground text-[var(--font-size-tiny)]">
|
|
162
|
+
{shortcut}
|
|
163
|
+
</span>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
Popover.displayName = "Popover"
|
|
174
|
+
|
|
175
|
+
export { Popover }
|