@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.
Files changed (71) hide show
  1. package/.storybook/main.ts +20 -0
  2. package/.storybook/preview.ts +18 -0
  3. package/.storybook/vitest.setup.ts +7 -0
  4. package/README.md +69 -0
  5. package/components.json +21 -0
  6. package/eslint.config.js +26 -0
  7. package/index.html +13 -0
  8. package/package.json +57 -0
  9. package/public/vite.svg +1 -0
  10. package/src/App.css +42 -0
  11. package/src/App.tsx +11 -0
  12. package/src/assets/react.svg +1 -0
  13. package/src/components/atoms/button/button.stories.tsx +222 -0
  14. package/src/components/atoms/button/button.test.tsx +78 -0
  15. package/src/components/atoms/button/index.tsx +80 -0
  16. package/src/components/atoms/checkbox/checkbox.stories.tsx +314 -0
  17. package/src/components/atoms/checkbox/checkbox.test.tsx +278 -0
  18. package/src/components/atoms/checkbox/index.tsx +103 -0
  19. package/src/components/atoms/chip/chip.stories.tsx +317 -0
  20. package/src/components/atoms/chip/chip.test.tsx +300 -0
  21. package/src/components/atoms/chip/index.tsx +114 -0
  22. package/src/components/atoms/input/index.tsx +27 -0
  23. package/src/components/atoms/link/index.tsx +79 -0
  24. package/src/components/atoms/link/link.stories.tsx +159 -0
  25. package/src/components/atoms/link/link.test.tsx +176 -0
  26. package/src/components/atoms/radiobutton/index.tsx +103 -0
  27. package/src/components/atoms/radiobutton/radiobutton.stories.tsx +314 -0
  28. package/src/components/atoms/radiobutton/radiobutton.test.tsx +245 -0
  29. package/src/components/atoms/tag/index.tsx +196 -0
  30. package/src/components/atoms/tag/tag.stories.tsx +281 -0
  31. package/src/components/atoms/tag/tag.test.tsx +282 -0
  32. package/src/components/atoms/typography/index.tsx +62 -0
  33. package/src/components/atoms/typography/typography.stories.tsx +214 -0
  34. package/src/components/atoms/typography/typography.test.tsx +187 -0
  35. package/src/components/index.tsx +17 -0
  36. package/src/components/molecules/announcement/announcement.stories.tsx +277 -0
  37. package/src/components/molecules/announcement/announcement.test.tsx +354 -0
  38. package/src/components/molecules/announcement/index.tsx +200 -0
  39. package/src/components/molecules/notification-alert/index.tsx +293 -0
  40. package/src/components/molecules/notification-alert/notification-alert.stories.tsx +418 -0
  41. package/src/components/molecules/notification-alert/notification-alert.test.tsx +454 -0
  42. package/src/components/molecules/popover/index.tsx +175 -0
  43. package/src/components/molecules/popover/popover.stories.tsx +241 -0
  44. package/src/components/molecules/popover/popover.test.tsx +191 -0
  45. package/src/components/molecules/textfield/index.tsx +154 -0
  46. package/src/components/molecules/textfield/textfield.stories.tsx +168 -0
  47. package/src/components/molecules/textfield/textfield.test.tsx +157 -0
  48. package/src/components/molecules/tooltip/index.tsx +263 -0
  49. package/src/components/molecules/tooltip/tooltip.stories.tsx +363 -0
  50. package/src/components/molecules/tooltip/tooltip.test.tsx +468 -0
  51. package/src/components/organisms/dialog/dialog.stories.tsx +522 -0
  52. package/src/components/organisms/dialog/dialog.test.tsx +525 -0
  53. package/src/components/organisms/dialog/index.tsx +233 -0
  54. package/src/components/organisms/dropdown/dropdown.stories.tsx +529 -0
  55. package/src/components/organisms/dropdown/dropdown.test.tsx +390 -0
  56. package/src/components/organisms/dropdown/index.tsx +624 -0
  57. package/src/index.css +184 -0
  58. package/src/lib/color-utils.ts +94 -0
  59. package/src/lib/utils.ts +6 -0
  60. package/src/main.tsx +10 -0
  61. package/src/stories/Colors.stories.tsx +107 -0
  62. package/src/stories/Shadows.stories.tsx +110 -0
  63. package/src/stories/Spacing.stories.tsx +121 -0
  64. package/src/stories/Typography.stories.tsx +197 -0
  65. package/src/vite-env.d.ts +1 -0
  66. package/tsconfig.app.json +33 -0
  67. package/tsconfig.json +13 -0
  68. package/tsconfig.node.json +25 -0
  69. package/vite.config.ts +43 -0
  70. package/vitest.config.ts +15 -0
  71. 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 }