@open-mercato/ui 0.6.1-develop.3256.1.fe3dec2464 → 0.6.1-develop.3260.1.dc719cac84

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 (137) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/backend/CrudForm.js +46 -129
  3. package/dist/backend/CrudForm.js.map +3 -3
  4. package/dist/backend/DataTable.js +10 -3
  5. package/dist/backend/DataTable.js.map +2 -2
  6. package/dist/backend/EmptyState.js +1 -47
  7. package/dist/backend/EmptyState.js.map +2 -2
  8. package/dist/backend/FilterOverlay.js +31 -26
  9. package/dist/backend/FilterOverlay.js.map +2 -2
  10. package/dist/backend/FlashMessages.js +23 -25
  11. package/dist/backend/FlashMessages.js.map +2 -2
  12. package/dist/backend/JsonBuilder.js +4 -2
  13. package/dist/backend/JsonBuilder.js.map +2 -2
  14. package/dist/backend/LogList.js +49 -0
  15. package/dist/backend/LogList.js.map +7 -0
  16. package/dist/backend/crud/CollapsibleGroup.js +13 -3
  17. package/dist/backend/crud/CollapsibleGroup.js.map +2 -2
  18. package/dist/backend/detail/InlineEditors.js +80 -32
  19. package/dist/backend/detail/InlineEditors.js.map +2 -2
  20. package/dist/backend/inputs/ComboboxInput.js +60 -6
  21. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  22. package/dist/backend/inputs/DatePicker.js +6 -142
  23. package/dist/backend/inputs/DatePicker.js.map +2 -2
  24. package/dist/backend/inputs/DateTimePicker.js +6 -182
  25. package/dist/backend/inputs/DateTimePicker.js.map +2 -2
  26. package/dist/backend/inputs/TagsInput.js +16 -19
  27. package/dist/backend/inputs/TagsInput.js.map +2 -2
  28. package/dist/backend/inputs/TimeInput.js +1 -1
  29. package/dist/backend/inputs/TimeInput.js.map +2 -2
  30. package/dist/backend/inputs/TimePicker.js +83 -72
  31. package/dist/backend/inputs/TimePicker.js.map +2 -2
  32. package/dist/backend/utils/customFieldForms.js +2 -2
  33. package/dist/backend/utils/customFieldForms.js.map +1 -1
  34. package/dist/portal/components/PortalEmptyState.js +13 -9
  35. package/dist/portal/components/PortalEmptyState.js.map +2 -2
  36. package/dist/primitives/accordion.js +137 -0
  37. package/dist/primitives/accordion.js.map +7 -0
  38. package/dist/primitives/alert.js +180 -12
  39. package/dist/primitives/alert.js.map +2 -2
  40. package/dist/primitives/calendar.js +122 -32
  41. package/dist/primitives/calendar.js.map +2 -2
  42. package/dist/primitives/compact-select.js +47 -0
  43. package/dist/primitives/compact-select.js.map +7 -0
  44. package/dist/primitives/counter-input.js +174 -0
  45. package/dist/primitives/counter-input.js.map +7 -0
  46. package/dist/primitives/date-picker-helpers.js +79 -0
  47. package/dist/primitives/date-picker-helpers.js.map +7 -0
  48. package/dist/primitives/date-picker.js +216 -0
  49. package/dist/primitives/date-picker.js.map +7 -0
  50. package/dist/primitives/date-range-picker.js +239 -0
  51. package/dist/primitives/date-range-picker.js.map +7 -0
  52. package/dist/primitives/digit-input.js +157 -0
  53. package/dist/primitives/digit-input.js.map +7 -0
  54. package/dist/primitives/empty-state.js +93 -0
  55. package/dist/primitives/empty-state.js.map +7 -0
  56. package/dist/primitives/inline-input.js +29 -0
  57. package/dist/primitives/inline-input.js.map +7 -0
  58. package/dist/primitives/inline-select.js +41 -0
  59. package/dist/primitives/inline-select.js.map +7 -0
  60. package/dist/primitives/notification-stack.js +116 -0
  61. package/dist/primitives/notification-stack.js.map +7 -0
  62. package/dist/primitives/notification.js +62 -0
  63. package/dist/primitives/notification.js.map +7 -0
  64. package/dist/primitives/rich-editor.js +1230 -0
  65. package/dist/primitives/rich-editor.js.map +7 -0
  66. package/dist/primitives/select.js +1 -0
  67. package/dist/primitives/select.js.map +2 -2
  68. package/dist/primitives/skeleton.js +55 -0
  69. package/dist/primitives/skeleton.js.map +7 -0
  70. package/dist/primitives/tag-input.js +186 -0
  71. package/dist/primitives/tag-input.js.map +7 -0
  72. package/dist/primitives/tag.js +66 -13
  73. package/dist/primitives/tag.js.map +2 -2
  74. package/dist/primitives/time-picker.js +793 -0
  75. package/dist/primitives/time-picker.js.map +7 -0
  76. package/package.json +4 -3
  77. package/src/backend/CrudForm.tsx +61 -113
  78. package/src/backend/DataTable.tsx +17 -5
  79. package/src/backend/EmptyState.tsx +13 -63
  80. package/src/backend/FilterOverlay.tsx +38 -22
  81. package/src/backend/FlashMessages.tsx +23 -24
  82. package/src/backend/JsonBuilder.tsx +4 -2
  83. package/src/backend/LogList.tsx +109 -0
  84. package/src/backend/__tests__/CrudForm.datetime.test.tsx +12 -7
  85. package/src/backend/__tests__/FilterOverlay.dateRange.test.tsx +89 -0
  86. package/src/backend/__tests__/LogList.test.tsx +163 -0
  87. package/src/backend/crud/CollapsibleGroup.tsx +17 -3
  88. package/src/backend/detail/InlineEditors.tsx +78 -25
  89. package/src/backend/detail/__tests__/InlineEditors.test.tsx +81 -1
  90. package/src/backend/inputs/ComboboxInput.tsx +81 -9
  91. package/src/backend/inputs/DatePicker.tsx +28 -155
  92. package/src/backend/inputs/DateTimePicker.tsx +25 -193
  93. package/src/backend/inputs/TagsInput.tsx +10 -13
  94. package/src/backend/inputs/TimeInput.tsx +2 -1
  95. package/src/backend/inputs/TimePicker.tsx +99 -70
  96. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +133 -0
  97. package/src/backend/inputs/__tests__/TimePicker.test.tsx +2 -2
  98. package/src/backend/utils/__tests__/richTextSanitizer.test.ts +5 -2
  99. package/src/backend/utils/customFieldForms.ts +2 -2
  100. package/src/portal/components/PortalEmptyState.tsx +27 -22
  101. package/src/primitives/__tests__/accordion.test.tsx +197 -0
  102. package/src/primitives/__tests__/alert.test.tsx +204 -0
  103. package/src/primitives/__tests__/compact-select.test.tsx +98 -0
  104. package/src/primitives/__tests__/counter-input.test.tsx +165 -0
  105. package/src/primitives/__tests__/date-picker.test.tsx +221 -0
  106. package/src/primitives/__tests__/date-range-picker.test.tsx +173 -0
  107. package/src/primitives/__tests__/digit-input.test.tsx +168 -0
  108. package/src/primitives/__tests__/empty-state.test.tsx +154 -0
  109. package/src/primitives/__tests__/inline-input.test.tsx +100 -0
  110. package/src/primitives/__tests__/inline-select.test.tsx +103 -0
  111. package/src/primitives/__tests__/notification-stack.test.tsx +174 -0
  112. package/src/primitives/__tests__/notification.test.tsx +119 -0
  113. package/src/primitives/__tests__/rich-editor.test.tsx +411 -0
  114. package/src/primitives/__tests__/skeleton.test.tsx +76 -0
  115. package/src/primitives/__tests__/tag-input.test.tsx +198 -0
  116. package/src/primitives/__tests__/tag.test.tsx +123 -0
  117. package/src/primitives/__tests__/time-picker.test.tsx +392 -0
  118. package/src/primitives/accordion.tsx +225 -0
  119. package/src/primitives/alert.tsx +234 -24
  120. package/src/primitives/calendar.tsx +146 -38
  121. package/src/primitives/compact-select.tsx +59 -0
  122. package/src/primitives/counter-input.tsx +211 -0
  123. package/src/primitives/date-picker-helpers.ts +96 -0
  124. package/src/primitives/date-picker.tsx +290 -0
  125. package/src/primitives/date-range-picker.tsx +281 -0
  126. package/src/primitives/digit-input.tsx +208 -0
  127. package/src/primitives/empty-state.tsx +140 -0
  128. package/src/primitives/inline-input.tsx +55 -0
  129. package/src/primitives/inline-select.tsx +74 -0
  130. package/src/primitives/notification-stack.tsx +185 -0
  131. package/src/primitives/notification.tsx +136 -0
  132. package/src/primitives/rich-editor.tsx +1578 -0
  133. package/src/primitives/select.tsx +1 -0
  134. package/src/primitives/skeleton.tsx +70 -0
  135. package/src/primitives/tag-input.tsx +223 -0
  136. package/src/primitives/tag.tsx +69 -10
  137. package/src/primitives/time-picker.tsx +1059 -0
@@ -0,0 +1,154 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { fireEvent, render, screen } from '@testing-library/react'
7
+ import { EmptyState } from '../empty-state'
8
+
9
+ describe('EmptyState primitive', () => {
10
+ it('renders the title', () => {
11
+ render(<EmptyState title="No results" />)
12
+ expect(screen.getByText('No results')).toBeInTheDocument()
13
+ })
14
+
15
+ it('renders description when provided', () => {
16
+ render(<EmptyState title="Empty" description="Try adjusting filters" />)
17
+ expect(screen.getByText('Try adjusting filters')).toBeInTheDocument()
18
+ })
19
+
20
+ it('renders an icon when provided', () => {
21
+ const Icon = () => <svg data-testid="empty-icon" />
22
+ render(<EmptyState title="None" icon={<Icon />} />)
23
+ expect(screen.getByTestId('empty-icon')).toBeInTheDocument()
24
+ })
25
+
26
+ it('renders custom actions ReactNode when provided', () => {
27
+ render(
28
+ <EmptyState
29
+ title="Empty"
30
+ actions={<button type="button">Custom action</button>}
31
+ />,
32
+ )
33
+ expect(screen.getByRole('button', { name: 'Custom action' })).toBeInTheDocument()
34
+ })
35
+
36
+ it('renders the legacy action object as a default outline button', () => {
37
+ const onClick = jest.fn()
38
+ render(
39
+ <EmptyState
40
+ title="Empty"
41
+ action={{ label: 'Add item', onClick }}
42
+ />,
43
+ )
44
+ const button = screen.getByRole('button', { name: /Add item/i })
45
+ expect(button).toBeInTheDocument()
46
+ fireEvent.click(button)
47
+ expect(onClick).toHaveBeenCalledTimes(1)
48
+ })
49
+
50
+ it('renders legacy actionLabel + onAction as a button (BC for old backend EmptyState)', () => {
51
+ const onAction = jest.fn()
52
+ render(
53
+ <EmptyState title="Empty" actionLabel="Create" onAction={onAction} />,
54
+ )
55
+ const button = screen.getByRole('button', { name: /Create/i })
56
+ fireEvent.click(button)
57
+ expect(onAction).toHaveBeenCalledTimes(1)
58
+ })
59
+
60
+ it('prefers actions over legacy action when both are provided', () => {
61
+ render(
62
+ <EmptyState
63
+ title="Empty"
64
+ actions={<button type="button">Primary</button>}
65
+ action={{ label: 'Legacy', onClick: () => {} }}
66
+ />,
67
+ )
68
+ expect(screen.getByRole('button', { name: 'Primary' })).toBeInTheDocument()
69
+ expect(screen.queryByRole('button', { name: /Legacy/i })).not.toBeInTheDocument()
70
+ })
71
+
72
+ it('default variant renders dashed border + bg-muted/30', () => {
73
+ const { container } = render(<EmptyState title="Default" />)
74
+ const root = container.querySelector('[data-slot="empty-state"]')
75
+ expect(root!.className).toContain('border-dashed')
76
+ expect(root!.className).toContain('bg-muted/30')
77
+ })
78
+
79
+ it('subtle variant has no border and no muted background', () => {
80
+ const { container } = render(<EmptyState title="Subtle" variant="subtle" />)
81
+ const root = container.querySelector('[data-slot="empty-state"]')
82
+ expect(root!.className).not.toContain('border-dashed')
83
+ expect(root!.className).not.toContain('bg-muted/30')
84
+ })
85
+
86
+ it('subtle variant wraps icon in a round muted box (Figma illustration tile style)', () => {
87
+ const Icon = () => <svg data-testid="boxed-icon" />
88
+ const { container } = render(
89
+ <EmptyState title="Subtle" variant="subtle" icon={<Icon />} />,
90
+ )
91
+ const iconWrapper = container.querySelector('[data-slot="empty-state"] > div')
92
+ expect(iconWrapper!.className).toContain('bg-muted')
93
+ expect(iconWrapper!.className).toContain('rounded-full')
94
+ })
95
+
96
+ it('illustration prop renders without an icon-box wrapper (Figma illustrations bring own bg)', () => {
97
+ const Illustration = () => <svg data-testid="figma-illustration" width="120" height="120" />
98
+ const { container } = render(
99
+ <EmptyState
100
+ title="With illustration"
101
+ variant="subtle"
102
+ illustration={<Illustration />}
103
+ />,
104
+ )
105
+ expect(container.querySelector('[data-testid="figma-illustration"]')).toBeInTheDocument()
106
+ const wrapper = container.querySelector('[data-slot="empty-state"] > div')
107
+ expect(wrapper!.className).not.toContain('bg-muted')
108
+ expect(wrapper!.className).not.toContain('rounded-full')
109
+ })
110
+
111
+ it('illustration takes precedence over icon when both are provided', () => {
112
+ const Illustration = () => <svg data-testid="illustration" />
113
+ const Icon = () => <svg data-testid="icon" />
114
+ render(
115
+ <EmptyState
116
+ title="Both"
117
+ illustration={<Illustration />}
118
+ icon={<Icon />}
119
+ />,
120
+ )
121
+ expect(screen.getByTestId('illustration')).toBeInTheDocument()
122
+ expect(screen.queryByTestId('icon')).not.toBeInTheDocument()
123
+ })
124
+
125
+ it('size variants apply correct padding tokens', () => {
126
+ const { container: sm } = render(<EmptyState title="X" size="sm" />)
127
+ expect(sm.querySelector('[data-slot="empty-state"]')!.className).toContain('py-6')
128
+
129
+ const { container: dft } = render(<EmptyState title="X" />)
130
+ expect(dft.querySelector('[data-slot="empty-state"]')!.className).toContain('py-10')
131
+
132
+ const { container: lg } = render(<EmptyState title="X" size="lg" />)
133
+ expect(lg.querySelector('[data-slot="empty-state"]')!.className).toContain('py-16')
134
+ })
135
+
136
+ it('forwards className to the root element', () => {
137
+ const { container } = render(
138
+ <EmptyState title="Empty" className="custom-class" />,
139
+ )
140
+ expect(container.querySelector('[data-slot="empty-state"]')!.className).toContain('custom-class')
141
+ })
142
+
143
+ it('renders children between description and actions', () => {
144
+ render(
145
+ <EmptyState
146
+ title="Empty"
147
+ description="No items"
148
+ >
149
+ <p data-testid="custom-child">Extra content</p>
150
+ </EmptyState>,
151
+ )
152
+ expect(screen.getByTestId('custom-child')).toBeInTheDocument()
153
+ })
154
+ })
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { fireEvent, render, screen } from '@testing-library/react'
7
+ import { InlineInput } from '../inline-input'
8
+
9
+ describe('InlineInput primitive', () => {
10
+ it('renders an input element', () => {
11
+ render(<InlineInput placeholder="Edit value" />)
12
+ expect(screen.getByPlaceholderText('Edit value')).toBeInTheDocument()
13
+ })
14
+
15
+ it('renders without a visible border at rest (border-transparent + bg-transparent)', () => {
16
+ const { container } = render(<InlineInput value="hello" onChange={() => {}} />)
17
+ const wrapper = container.querySelector('[data-slot="input-wrapper"]')
18
+ expect(wrapper).toHaveClass('border-transparent')
19
+ expect(wrapper).toHaveClass('bg-transparent')
20
+ expect(wrapper).toHaveClass('shadow-none')
21
+ })
22
+
23
+ it('reveals a hover border by default', () => {
24
+ const { container } = render(<InlineInput value="x" onChange={() => {}} />)
25
+ const wrapper = container.querySelector('[data-slot="input-wrapper"]')
26
+ expect(wrapper).toHaveClass('hover:border-input')
27
+ })
28
+
29
+ it('omits the hover border when showBorderOnHover is false', () => {
30
+ const { container } = render(
31
+ <InlineInput value="x" onChange={() => {}} showBorderOnHover={false} />,
32
+ )
33
+ const wrapper = container.querySelector('[data-slot="input-wrapper"]')
34
+ expect(wrapper).not.toHaveClass('hover:border-input')
35
+ expect(wrapper).toHaveClass('hover:bg-transparent')
36
+ })
37
+
38
+ it('defaults to the sm size (h-8)', () => {
39
+ const { container } = render(<InlineInput value="x" onChange={() => {}} />)
40
+ const wrapper = container.querySelector('[data-slot="input-wrapper"]')
41
+ expect(wrapper).toHaveClass('h-8')
42
+ })
43
+
44
+ it('supports size="default" (h-9)', () => {
45
+ const { container } = render(
46
+ <InlineInput value="x" onChange={() => {}} size="default" />,
47
+ )
48
+ const wrapper = container.querySelector('[data-slot="input-wrapper"]')
49
+ expect(wrapper).toHaveClass('h-9')
50
+ })
51
+
52
+ it('forwards value / onChange like a regular Input', () => {
53
+ const onChange = jest.fn()
54
+ render(<InlineInput value="hello" onChange={onChange} />)
55
+ const input = screen.getByDisplayValue('hello') as HTMLInputElement
56
+ fireEvent.change(input, { target: { value: 'world' } })
57
+ expect(onChange).toHaveBeenCalled()
58
+ })
59
+
60
+ it('forwards onBlur for save-on-blur consumer flows', () => {
61
+ const onBlur = jest.fn()
62
+ render(<InlineInput value="hello" onChange={() => {}} onBlur={onBlur} />)
63
+ const input = screen.getByDisplayValue('hello')
64
+ fireEvent.blur(input)
65
+ expect(onBlur).toHaveBeenCalled()
66
+ })
67
+
68
+ it('forwards refs to the underlying input element', () => {
69
+ const ref = React.createRef<HTMLInputElement>()
70
+ render(<InlineInput ref={ref} value="hello" onChange={() => {}} />)
71
+ expect(ref.current).not.toBeNull()
72
+ expect(ref.current?.tagName).toBe('INPUT')
73
+ })
74
+
75
+ it('merges consumer className alongside the borderless baseline', () => {
76
+ const { container } = render(
77
+ <InlineInput value="x" onChange={() => {}} className="text-right font-mono" />,
78
+ )
79
+ const wrapper = container.querySelector('[data-slot="input-wrapper"]')
80
+ expect(wrapper).toHaveClass('text-right')
81
+ expect(wrapper).toHaveClass('font-mono')
82
+ expect(wrapper).toHaveClass('border-transparent')
83
+ })
84
+
85
+ it('renders aria-invalid via the underlying Input wrapper', () => {
86
+ const { container } = render(
87
+ <InlineInput value="x" onChange={() => {}} aria-invalid />,
88
+ )
89
+ const input = container.querySelector('input')
90
+ expect(input).toHaveAttribute('aria-invalid', 'true')
91
+ })
92
+
93
+ it('honors a custom input type', () => {
94
+ const { container } = render(
95
+ <InlineInput value="user@example.com" onChange={() => {}} type="email" />,
96
+ )
97
+ const input = container.querySelector('input')
98
+ expect(input).toHaveAttribute('type', 'email')
99
+ })
100
+ })
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { render, screen } from '@testing-library/react'
7
+ import {
8
+ InlineSelectTrigger,
9
+ Select,
10
+ SelectContent,
11
+ SelectItem,
12
+ SelectValue,
13
+ } from '../inline-select'
14
+
15
+ function renderInlineSelect(
16
+ props: Partial<React.ComponentProps<typeof InlineSelectTrigger>> = {},
17
+ ) {
18
+ return render(
19
+ <Select defaultValue="open">
20
+ <InlineSelectTrigger aria-label="Status" {...props}>
21
+ <SelectValue />
22
+ </InlineSelectTrigger>
23
+ <SelectContent>
24
+ <SelectItem value="open">Open</SelectItem>
25
+ <SelectItem value="closed">Closed</SelectItem>
26
+ </SelectContent>
27
+ </Select>,
28
+ )
29
+ }
30
+
31
+ describe('InlineSelect primitive', () => {
32
+ it('renders the trigger borderless at rest', () => {
33
+ const { container } = renderInlineSelect()
34
+ const trigger = container.querySelector('[data-slot="inline-select-trigger"]')
35
+ expect(trigger).not.toBeNull()
36
+ expect(trigger).toHaveClass('border-transparent')
37
+ expect(trigger).toHaveClass('bg-transparent')
38
+ expect(trigger).toHaveClass('shadow-none')
39
+ })
40
+
41
+ it('shows the hover border by default', () => {
42
+ const { container } = renderInlineSelect()
43
+ const trigger = container.querySelector('[data-slot="inline-select-trigger"]')
44
+ expect(trigger).toHaveClass('hover:border-input')
45
+ expect(trigger).toHaveClass('hover:bg-muted/40')
46
+ })
47
+
48
+ it('omits the hover border when showBorderOnHover is false', () => {
49
+ const { container } = renderInlineSelect({ showBorderOnHover: false })
50
+ const trigger = container.querySelector('[data-slot="inline-select-trigger"]')
51
+ expect(trigger).not.toHaveClass('hover:border-input')
52
+ expect(trigger).toHaveClass('hover:bg-transparent')
53
+ })
54
+
55
+ it('defaults to the sm size (h-8)', () => {
56
+ const { container } = renderInlineSelect()
57
+ const trigger = container.querySelector('[data-slot="inline-select-trigger"]')
58
+ expect(trigger).toHaveClass('h-8')
59
+ })
60
+
61
+ it('supports size="default" (h-9)', () => {
62
+ const { container } = renderInlineSelect({ size: 'default' })
63
+ const trigger = container.querySelector('[data-slot="inline-select-trigger"]')
64
+ expect(trigger).toHaveClass('h-9')
65
+ })
66
+
67
+ it('forwards refs to the underlying button trigger', () => {
68
+ const ref = React.createRef<HTMLButtonElement>()
69
+ render(
70
+ <Select defaultValue="a">
71
+ <InlineSelectTrigger ref={ref} aria-label="Pick">
72
+ <SelectValue />
73
+ </InlineSelectTrigger>
74
+ <SelectContent>
75
+ <SelectItem value="a">A</SelectItem>
76
+ </SelectContent>
77
+ </Select>,
78
+ )
79
+ expect(ref.current).not.toBeNull()
80
+ expect(ref.current?.tagName).toBe('BUTTON')
81
+ })
82
+
83
+ it('merges consumer className alongside the borderless baseline', () => {
84
+ const { container } = renderInlineSelect({
85
+ className: 'text-right font-mono',
86
+ })
87
+ const trigger = container.querySelector('[data-slot="inline-select-trigger"]')
88
+ expect(trigger).toHaveClass('text-right')
89
+ expect(trigger).toHaveClass('font-mono')
90
+ expect(trigger).toHaveClass('border-transparent')
91
+ })
92
+
93
+ it('forwards aria-label to the Radix trigger', () => {
94
+ renderInlineSelect({ 'aria-label': 'Pipeline stage' })
95
+ expect(screen.getByRole('combobox', { name: 'Pipeline stage' })).toBeInTheDocument()
96
+ })
97
+
98
+ it('renders aria-invalid styling when aria-invalid is set', () => {
99
+ const { container } = renderInlineSelect({ 'aria-invalid': true })
100
+ const trigger = container.querySelector('[aria-invalid="true"]')
101
+ expect(trigger).not.toBeNull()
102
+ })
103
+ })
@@ -0,0 +1,174 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { act, fireEvent, render, screen } from '@testing-library/react'
7
+ import {
8
+ NotificationProvider,
9
+ NotificationStack,
10
+ useNotification,
11
+ } from '../notification-stack'
12
+
13
+ function Harness({
14
+ placement,
15
+ maxVisible,
16
+ children,
17
+ }: {
18
+ placement?: React.ComponentProps<typeof NotificationStack>['placement']
19
+ maxVisible?: number
20
+ children?: React.ReactNode
21
+ }) {
22
+ return (
23
+ <NotificationProvider maxVisible={maxVisible}>
24
+ {children}
25
+ <NotificationStack placement={placement} />
26
+ </NotificationProvider>
27
+ )
28
+ }
29
+
30
+ function NotifyButton({ label = 'Notify', options }: { label?: string; options: any }) {
31
+ const { notify } = useNotification()
32
+ return (
33
+ <button type="button" onClick={() => notify(options)}>
34
+ {label}
35
+ </button>
36
+ )
37
+ }
38
+
39
+ describe('NotificationStack + useNotification', () => {
40
+ beforeEach(() => {
41
+ jest.useFakeTimers()
42
+ })
43
+ afterEach(() => {
44
+ jest.useRealTimers()
45
+ })
46
+
47
+ it('renders nothing when the queue is empty', () => {
48
+ const { container } = render(<Harness />)
49
+ expect(container.querySelector('[data-slot="notification-stack"]')).toBeNull()
50
+ })
51
+
52
+ it('renders the stack wrapper once a notification is queued', () => {
53
+ const { container } = render(
54
+ <Harness>
55
+ <NotifyButton options={{ title: 'Hello' }} />
56
+ </Harness>,
57
+ )
58
+ fireEvent.click(screen.getByText('Notify'))
59
+ expect(container.querySelector('[data-slot="notification-stack"]')).not.toBeNull()
60
+ expect(screen.getByText('Hello')).toBeInTheDocument()
61
+ })
62
+
63
+ it('respects the placement prop (data-placement attribute + Tailwind classes)', () => {
64
+ const { container } = render(
65
+ <Harness placement="bottom-left">
66
+ <NotifyButton options={{ title: 'A' }} />
67
+ </Harness>,
68
+ )
69
+ fireEvent.click(screen.getByText('Notify'))
70
+ const stack = container.querySelector('[data-slot="notification-stack"]')
71
+ expect(stack).toHaveAttribute('data-placement', 'bottom-left')
72
+ expect(stack).toHaveClass('bottom-4')
73
+ expect(stack).toHaveClass('left-4')
74
+ })
75
+
76
+ it('dismiss button on a notification removes it from the queue', () => {
77
+ render(
78
+ <Harness>
79
+ <NotifyButton options={{ title: 'Dismissable' }} />
80
+ </Harness>,
81
+ )
82
+ fireEvent.click(screen.getByText('Notify'))
83
+ expect(screen.getByText('Dismissable')).toBeInTheDocument()
84
+ fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
85
+ expect(screen.queryByText('Dismissable')).toBeNull()
86
+ })
87
+
88
+ it('auto-dismisses after autoDismissMs and clears its timer', () => {
89
+ render(
90
+ <Harness>
91
+ <NotifyButton options={{ title: 'Auto', autoDismissMs: 2000 }} />
92
+ </Harness>,
93
+ )
94
+ fireEvent.click(screen.getByText('Notify'))
95
+ expect(screen.getByText('Auto')).toBeInTheDocument()
96
+ act(() => {
97
+ jest.advanceTimersByTime(1999)
98
+ })
99
+ expect(screen.getByText('Auto')).toBeInTheDocument()
100
+ act(() => {
101
+ jest.advanceTimersByTime(1)
102
+ })
103
+ expect(screen.queryByText('Auto')).toBeNull()
104
+ })
105
+
106
+ it('manual dismiss before autoDismissMs cancels the timer (no late re-fire)', () => {
107
+ render(
108
+ <Harness>
109
+ <NotifyButton options={{ title: 'Cancel', autoDismissMs: 3000 }} />
110
+ </Harness>,
111
+ )
112
+ fireEvent.click(screen.getByText('Notify'))
113
+ fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
114
+ expect(screen.queryByText('Cancel')).toBeNull()
115
+ // Advance past the auto-dismiss window — no errors, no resurrected card.
116
+ act(() => {
117
+ jest.advanceTimersByTime(5000)
118
+ })
119
+ expect(screen.queryByText('Cancel')).toBeNull()
120
+ })
121
+
122
+ it('respects maxVisible cap (FIFO trim of oldest entries)', () => {
123
+ render(
124
+ <Harness maxVisible={2}>
125
+ <NotifyButton label="A" options={{ title: 'First' }} />
126
+ <NotifyButton label="B" options={{ title: 'Second' }} />
127
+ <NotifyButton label="C" options={{ title: 'Third' }} />
128
+ </Harness>,
129
+ )
130
+ fireEvent.click(screen.getByText('A'))
131
+ fireEvent.click(screen.getByText('B'))
132
+ fireEvent.click(screen.getByText('C'))
133
+ expect(screen.queryByText('First')).toBeNull()
134
+ expect(screen.getByText('Second')).toBeInTheDocument()
135
+ expect(screen.getByText('Third')).toBeInTheDocument()
136
+ })
137
+
138
+ it('useNotification throws outside the provider', () => {
139
+ function Bad() {
140
+ useNotification()
141
+ return null
142
+ }
143
+ // Suppress React error logging for the expected throw.
144
+ const spy = jest.spyOn(console, 'error').mockImplementation(() => {})
145
+ expect(() => render(<Bad />)).toThrow(
146
+ /useNotification must be used within a NotificationProvider/,
147
+ )
148
+ spy.mockRestore()
149
+ })
150
+
151
+ it('dismissAll clears the queue', () => {
152
+ function DismissAllButton() {
153
+ const { dismissAll } = useNotification()
154
+ return (
155
+ <button type="button" onClick={dismissAll}>
156
+ Clear
157
+ </button>
158
+ )
159
+ }
160
+ render(
161
+ <Harness>
162
+ <NotifyButton label="A" options={{ title: 'First' }} />
163
+ <NotifyButton label="B" options={{ title: 'Second' }} />
164
+ <DismissAllButton />
165
+ </Harness>,
166
+ )
167
+ fireEvent.click(screen.getByText('A'))
168
+ fireEvent.click(screen.getByText('B'))
169
+ expect(screen.getByText('First')).toBeInTheDocument()
170
+ fireEvent.click(screen.getByText('Clear'))
171
+ expect(screen.queryByText('First')).toBeNull()
172
+ expect(screen.queryByText('Second')).toBeNull()
173
+ })
174
+ })
@@ -0,0 +1,119 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { fireEvent, render, screen } from '@testing-library/react'
7
+ import { Notification } from '../notification'
8
+
9
+ describe('Notification primitive', () => {
10
+ it('renders the underlying Alert at size="default" (rounded-xl)', () => {
11
+ const { container } = render(<Notification title="Test" />)
12
+ const alert = container.querySelector('[data-slot="notification"]')
13
+ expect(alert).toHaveClass('rounded-xl')
14
+ })
15
+
16
+ it('marks the root with data-slot="notification" (overrides Alert data-slot via spread)', () => {
17
+ const { container } = render(<Notification title="Test" />)
18
+ expect(container.querySelector('[data-slot="notification"]')).not.toBeNull()
19
+ })
20
+
21
+ it('forwards id as data-notification-id', () => {
22
+ const { container } = render(<Notification id="notif-123" title="Test" />)
23
+ expect(container.querySelector('[data-notification-id="notif-123"]')).not.toBeNull()
24
+ })
25
+
26
+ it('renders title as AlertTitle and description below', () => {
27
+ render(<Notification title="John commented" description="On the latest deal" />)
28
+ expect(screen.getByText('John commented').tagName).toBe('H5')
29
+ expect(screen.getByText('On the latest deal').tagName).toBe('P')
30
+ })
31
+
32
+ it('description gets opacity-72 per Figma Notification reference', () => {
33
+ const { container } = render(
34
+ <Notification title="t" description="body" />,
35
+ )
36
+ const desc = container.querySelector('[data-slot="notification-description"]')
37
+ expect(desc).toHaveClass('opacity-72')
38
+ })
39
+
40
+ it('renders the timestamp slot when provided', () => {
41
+ render(<Notification title="t" timestamp="2 min ago" />)
42
+ expect(screen.getByText('2 min ago')).toBeInTheDocument()
43
+ })
44
+
45
+ it('omits the title row entirely when neither title nor timestamp is provided', () => {
46
+ const { container } = render(<Notification description="just a body" />)
47
+ expect(container.querySelector('h5')).toBeNull()
48
+ expect(container.querySelector('[data-slot="notification-timestamp"]')).toBeNull()
49
+ })
50
+
51
+ it('renders an actions row when actions are provided', () => {
52
+ render(
53
+ <Notification
54
+ title="t"
55
+ actions={
56
+ <>
57
+ <button data-testid="view">View</button>
58
+ <button data-testid="dismiss">Dismiss</button>
59
+ </>
60
+ }
61
+ />,
62
+ )
63
+ expect(screen.getByTestId('view')).toBeInTheDocument()
64
+ expect(screen.getByTestId('dismiss')).toBeInTheDocument()
65
+ })
66
+
67
+ it('actions row carries data-slot="notification-actions"', () => {
68
+ const { container } = render(
69
+ <Notification title="t" actions={<button>x</button>} />,
70
+ )
71
+ expect(container.querySelector('[data-slot="notification-actions"]')).not.toBeNull()
72
+ })
73
+
74
+ it('uses the avatar prop as the Alert icon override (no default status icon when avatar set)', () => {
75
+ const Avatar = () => <span data-testid="user-avatar" />
76
+ const { container } = render(<Notification title="t" avatar={<Avatar />} />)
77
+ expect(screen.getByTestId('user-avatar')).toBeInTheDocument()
78
+ // No badge wrap because Alert sees `icon` prop and treats this as the leading visual
79
+ expect(container.querySelector('[data-slot="alert-icon-badge"]')).not.toBeNull()
80
+ })
81
+
82
+ it('dismissible defaults to true and fires onDismiss when X is clicked', () => {
83
+ const onDismiss = jest.fn()
84
+ render(<Notification title="t" onDismiss={onDismiss} />)
85
+ fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
86
+ expect(onDismiss).toHaveBeenCalledTimes(1)
87
+ })
88
+
89
+ it('omits the dismiss button when dismissible is false', () => {
90
+ render(<Notification title="t" dismissible={false} />)
91
+ expect(screen.queryByRole('button', { name: 'Dismiss' })).toBeNull()
92
+ })
93
+
94
+ it('honors a custom dismissAriaLabel', () => {
95
+ render(<Notification title="t" dismissAriaLabel="Zamknij" />)
96
+ expect(screen.getByRole('button', { name: 'Zamknij' })).toBeInTheDocument()
97
+ })
98
+
99
+ it('forwards status to the underlying Alert', () => {
100
+ const { container } = render(<Notification status="success" title="Saved" />)
101
+ expect(container.querySelector('[data-slot="notification"]')).toHaveAttribute('data-status', 'success')
102
+ })
103
+
104
+ it('forwards style to the underlying Alert', () => {
105
+ const { container } = render(
106
+ <Notification status="warning" style="lighter" title="Heads up" />,
107
+ )
108
+ const alert = container.querySelector('[data-slot="notification"]')
109
+ expect(alert).toHaveAttribute('data-style', 'lighter')
110
+ expect(alert).toHaveClass('bg-status-warning-bg')
111
+ })
112
+
113
+ it('forwards refs to the underlying Alert wrapper div', () => {
114
+ const ref = React.createRef<HTMLDivElement>()
115
+ render(<Notification ref={ref} title="t" />)
116
+ expect(ref.current).not.toBeNull()
117
+ expect(ref.current?.getAttribute('data-slot')).toBe('notification')
118
+ })
119
+ })