@open-mercato/ui 0.6.4-develop.4178.1.aad9ddaa95 → 0.6.4-develop.4210.1.d412061cfe

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 (92) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +14 -0
  3. package/dist/backend/DataTable.js +28 -62
  4. package/dist/backend/DataTable.js.map +2 -2
  5. package/dist/backend/detail/AttachmentMetadataDialog.js +1 -1
  6. package/dist/backend/detail/AttachmentMetadataDialog.js.map +2 -2
  7. package/dist/primitives/activity-feed.js +196 -0
  8. package/dist/primitives/activity-feed.js.map +7 -0
  9. package/dist/primitives/avatar.js +140 -14
  10. package/dist/primitives/avatar.js.map +3 -3
  11. package/dist/primitives/badge.js +94 -5
  12. package/dist/primitives/badge.js.map +2 -2
  13. package/dist/primitives/button-group.js +52 -0
  14. package/dist/primitives/button-group.js.map +7 -0
  15. package/dist/primitives/color-picker.js +520 -0
  16. package/dist/primitives/color-picker.js.map +7 -0
  17. package/dist/primitives/command-menu.js +355 -0
  18. package/dist/primitives/command-menu.js.map +7 -0
  19. package/dist/primitives/dialog.js +210 -35
  20. package/dist/primitives/dialog.js.map +2 -2
  21. package/dist/primitives/drawer.js +219 -0
  22. package/dist/primitives/drawer.js.map +7 -0
  23. package/dist/primitives/notification-feed.js +213 -0
  24. package/dist/primitives/notification-feed.js.map +7 -0
  25. package/dist/primitives/notification.js +23 -2
  26. package/dist/primitives/notification.js.map +2 -2
  27. package/dist/primitives/pagination.js +245 -0
  28. package/dist/primitives/pagination.js.map +7 -0
  29. package/dist/primitives/progress.js +178 -5
  30. package/dist/primitives/progress.js.map +2 -2
  31. package/dist/primitives/rating.js +174 -0
  32. package/dist/primitives/rating.js.map +7 -0
  33. package/dist/primitives/scroll-area.js +82 -0
  34. package/dist/primitives/scroll-area.js.map +7 -0
  35. package/dist/primitives/segmented-control.js +101 -0
  36. package/dist/primitives/segmented-control.js.map +7 -0
  37. package/dist/primitives/separator.js +103 -4
  38. package/dist/primitives/separator.js.map +2 -2
  39. package/dist/primitives/slider.js +115 -0
  40. package/dist/primitives/slider.js.map +7 -0
  41. package/dist/primitives/step-indicator.js +187 -0
  42. package/dist/primitives/step-indicator.js.map +7 -0
  43. package/dist/primitives/table.js +102 -10
  44. package/dist/primitives/table.js.map +2 -2
  45. package/dist/primitives/tabs.js +122 -14
  46. package/dist/primitives/tabs.js.map +2 -2
  47. package/dist/primitives/time-picker.js +9 -11
  48. package/dist/primitives/time-picker.js.map +2 -2
  49. package/package.json +8 -4
  50. package/src/backend/DataTable.tsx +43 -60
  51. package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
  52. package/src/primitives/__tests__/activity-feed.test.tsx +216 -0
  53. package/src/primitives/__tests__/avatar.test.tsx +131 -0
  54. package/src/primitives/__tests__/badge.test.tsx +189 -0
  55. package/src/primitives/__tests__/button-group.test.tsx +117 -0
  56. package/src/primitives/__tests__/color-picker.test.tsx +589 -0
  57. package/src/primitives/__tests__/command-menu.test.tsx +231 -0
  58. package/src/primitives/__tests__/dialog.test.tsx +290 -0
  59. package/src/primitives/__tests__/drawer.test.tsx +264 -0
  60. package/src/primitives/__tests__/notification-feed.test.tsx +303 -0
  61. package/src/primitives/__tests__/notification.test.tsx +102 -2
  62. package/src/primitives/__tests__/pagination.test.tsx +268 -0
  63. package/src/primitives/__tests__/progress.test.tsx +222 -0
  64. package/src/primitives/__tests__/rating.test.tsx +164 -0
  65. package/src/primitives/__tests__/scroll-area.test.tsx +109 -0
  66. package/src/primitives/__tests__/segmented-control.test.tsx +129 -0
  67. package/src/primitives/__tests__/separator.test.tsx +143 -0
  68. package/src/primitives/__tests__/slider.test.tsx +86 -0
  69. package/src/primitives/__tests__/step-indicator.test.tsx +205 -0
  70. package/src/primitives/__tests__/table.test.tsx +199 -0
  71. package/src/primitives/__tests__/tabs.test.tsx +139 -0
  72. package/src/primitives/activity-feed.tsx +326 -0
  73. package/src/primitives/avatar.tsx +225 -22
  74. package/src/primitives/badge.tsx +141 -6
  75. package/src/primitives/button-group.tsx +100 -0
  76. package/src/primitives/color-picker.tsx +693 -0
  77. package/src/primitives/command-menu.tsx +503 -0
  78. package/src/primitives/dialog.tsx +315 -51
  79. package/src/primitives/drawer.tsx +338 -0
  80. package/src/primitives/notification-feed.tsx +358 -0
  81. package/src/primitives/notification.tsx +42 -2
  82. package/src/primitives/pagination.tsx +376 -0
  83. package/src/primitives/progress.tsx +267 -6
  84. package/src/primitives/rating.tsx +245 -0
  85. package/src/primitives/scroll-area.tsx +169 -0
  86. package/src/primitives/segmented-control.tsx +162 -0
  87. package/src/primitives/separator.tsx +163 -3
  88. package/src/primitives/slider.tsx +160 -0
  89. package/src/primitives/step-indicator.tsx +287 -0
  90. package/src/primitives/table.tsx +142 -11
  91. package/src/primitives/tabs.tsx +174 -12
  92. package/src/primitives/time-picker.tsx +9 -11
@@ -0,0 +1,264 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ import * as React from 'react'
4
+ import { render, fireEvent, screen } from '@testing-library/react'
5
+ import {
6
+ Drawer,
7
+ DrawerTrigger,
8
+ DrawerContent,
9
+ DrawerHeader,
10
+ DrawerTitle,
11
+ DrawerDescription,
12
+ DrawerBody,
13
+ DrawerFooter,
14
+ DrawerClose,
15
+ } from '../drawer'
16
+
17
+ function ExampleDrawer({
18
+ side,
19
+ hideCloseButton,
20
+ defaultOpen = true,
21
+ }: {
22
+ side?: 'right' | 'left' | 'top' | 'bottom'
23
+ hideCloseButton?: boolean
24
+ defaultOpen?: boolean
25
+ }) {
26
+ return (
27
+ <Drawer defaultOpen={defaultOpen}>
28
+ <DrawerTrigger>Open</DrawerTrigger>
29
+ <DrawerContent side={side} hideCloseButton={hideCloseButton}>
30
+ <DrawerHeader>
31
+ <DrawerTitle>Edit person</DrawerTitle>
32
+ <DrawerDescription>Update the person's contact info.</DrawerDescription>
33
+ </DrawerHeader>
34
+ <DrawerBody>
35
+ <p>Body content</p>
36
+ </DrawerBody>
37
+ <DrawerFooter>
38
+ <DrawerClose>Cancel</DrawerClose>
39
+ <button>Save</button>
40
+ </DrawerFooter>
41
+ </DrawerContent>
42
+ </Drawer>
43
+ )
44
+ }
45
+
46
+ describe('Drawer', () => {
47
+ it('renders trigger + content slots inside a Radix Dialog', () => {
48
+ const { container } = render(<ExampleDrawer />)
49
+ expect(document.querySelector('[data-slot="drawer-content"]')).not.toBeNull()
50
+ expect(document.querySelector('[data-slot="drawer-overlay"]')).not.toBeNull()
51
+ expect(document.querySelector('[data-slot="drawer-header"]')).not.toBeNull()
52
+ expect(document.querySelector('[data-slot="drawer-body"]')).not.toBeNull()
53
+ expect(document.querySelector('[data-slot="drawer-footer"]')).not.toBeNull()
54
+ expect(document.querySelector('[data-slot="drawer-title"]')).not.toBeNull()
55
+ expect(document.querySelector('[data-slot="drawer-description"]')).not.toBeNull()
56
+ })
57
+
58
+ it('opens on trigger click when controlled-default is closed', () => {
59
+ function ControlledDrawer() {
60
+ const [open, setOpen] = React.useState(false)
61
+ return (
62
+ <Drawer open={open} onOpenChange={setOpen}>
63
+ <DrawerTrigger>Open</DrawerTrigger>
64
+ <DrawerContent>
65
+ <DrawerHeader>
66
+ <DrawerTitle>Test</DrawerTitle>
67
+ </DrawerHeader>
68
+ </DrawerContent>
69
+ </Drawer>
70
+ )
71
+ }
72
+ const { container, getByText } = render(<ControlledDrawer />)
73
+ expect(document.querySelector('[data-slot="drawer-content"]')).toBeNull()
74
+ fireEvent.click(getByText('Open'))
75
+ expect(document.querySelector('[data-slot="drawer-content"]')).not.toBeNull()
76
+ })
77
+
78
+ it('marks the content with data-side="right" by default', () => {
79
+ const { container } = render(<ExampleDrawer />)
80
+ const content = document.querySelector('[data-slot="drawer-content"]') as HTMLElement
81
+ expect(content.getAttribute('data-side')).toBe('right')
82
+ })
83
+
84
+ it('applies right-side positioning classes + inner rounded corners by default', () => {
85
+ const { container } = render(<ExampleDrawer />)
86
+ const content = document.querySelector('[data-slot="drawer-content"]') as HTMLElement
87
+ expect(content.className).toContain('right-0')
88
+ expect(content.className).toContain('inset-y-0')
89
+ expect(content.className).toContain('max-w-md')
90
+ // Per Figma: inner edge rounded, no panel border on the seam.
91
+ expect(content.className).toContain('rounded-l-xl')
92
+ expect(content.className).not.toMatch(/\bborder-l\b/)
93
+ })
94
+
95
+ it('applies left-side classes (rounded right edge) when side="left"', () => {
96
+ const { container } = render(<ExampleDrawer side="left" />)
97
+ const content = document.querySelector('[data-slot="drawer-content"]') as HTMLElement
98
+ expect(content.className).toContain('left-0')
99
+ expect(content.className).toContain('inset-y-0')
100
+ expect(content.className).toContain('rounded-r-xl')
101
+ expect(content.className).not.toMatch(/\bborder-r\b/)
102
+ expect(content.getAttribute('data-side')).toBe('left')
103
+ })
104
+
105
+ it('applies top-side classes (rounded bottom edge) when side="top"', () => {
106
+ const { container } = render(<ExampleDrawer side="top" />)
107
+ const content = document.querySelector('[data-slot="drawer-content"]') as HTMLElement
108
+ expect(content.className).toContain('top-0')
109
+ expect(content.className).toContain('inset-x-0')
110
+ expect(content.className).toContain('rounded-b-xl')
111
+ expect(content.className).not.toMatch(/\bborder-b\b/)
112
+ expect(content.className).toContain('max-h-[80vh]')
113
+ })
114
+
115
+ it('applies bottom-side classes (rounded top edge) when side="bottom"', () => {
116
+ const { container } = render(<ExampleDrawer side="bottom" />)
117
+ const content = document.querySelector('[data-slot="drawer-content"]') as HTMLElement
118
+ expect(content.className).toContain('bottom-0')
119
+ expect(content.className).toContain('inset-x-0')
120
+ expect(content.className).toContain('rounded-t-xl')
121
+ expect(content.className).not.toMatch(/\bborder-t\b/)
122
+ expect(content.className).toContain('max-h-[80vh]')
123
+ })
124
+
125
+ it('renders the auto close button by default', () => {
126
+ const { container } = render(<ExampleDrawer />)
127
+ expect(document.querySelector('[data-slot="drawer-close-button"]')).not.toBeNull()
128
+ })
129
+
130
+ it('hides the auto close button when hideCloseButton=true', () => {
131
+ const { container } = render(<ExampleDrawer hideCloseButton />)
132
+ expect(document.querySelector('[data-slot="drawer-close-button"]')).toBeNull()
133
+ })
134
+
135
+ it('renders role="dialog" with aria-labelledby/describedby wiring from Title + Description', () => {
136
+ render(<ExampleDrawer />)
137
+ const dialog = screen.getByRole('dialog')
138
+ expect(dialog).toBeInTheDocument()
139
+ const labelledBy = dialog.getAttribute('aria-labelledby')
140
+ const describedBy = dialog.getAttribute('aria-describedby')
141
+ expect(labelledBy).toBeTruthy()
142
+ expect(describedBy).toBeTruthy()
143
+ expect(document.getElementById(labelledBy as string)?.textContent).toBe('Edit person')
144
+ })
145
+
146
+ it('closes when DrawerClose child is clicked', () => {
147
+ const { container, getByText } = render(<ExampleDrawer />)
148
+ expect(document.querySelector('[data-slot="drawer-content"]')).not.toBeNull()
149
+ fireEvent.click(getByText('Cancel'))
150
+ expect(document.querySelector('[data-slot="drawer-content"]')).toBeNull()
151
+ })
152
+
153
+ it('renders the header leading badge slot when `leading` is provided', () => {
154
+ render(
155
+ <Drawer defaultOpen>
156
+ <DrawerTrigger>Open</DrawerTrigger>
157
+ <DrawerContent>
158
+ <DrawerHeader leading={<span data-testid="leading-icon">⏱</span>}>
159
+ <DrawerTitle>With leading</DrawerTitle>
160
+ <DrawerDescription>Optional description.</DrawerDescription>
161
+ </DrawerHeader>
162
+ </DrawerContent>
163
+ </Drawer>,
164
+ )
165
+ const badge = document.querySelector('[data-slot="drawer-header-leading"]') as HTMLElement
166
+ expect(badge).not.toBeNull()
167
+ expect(badge.querySelector('[data-testid="leading-icon"]')).not.toBeNull()
168
+ expect(badge.className).toContain('rounded-full')
169
+ expect(badge.className).toContain('size-10')
170
+ // Title + description still render inside the text block alongside the badge.
171
+ const text = document.querySelector('[data-slot="drawer-header-text"]') as HTMLElement
172
+ expect(text).not.toBeNull()
173
+ expect(text.textContent).toContain('With leading')
174
+ expect(text.textContent).toContain('Optional description.')
175
+ })
176
+
177
+ it('renders the header without a leading badge by default', () => {
178
+ render(<ExampleDrawer />)
179
+ expect(document.querySelector('[data-slot="drawer-header-leading"]')).toBeNull()
180
+ // Per Figma: no chrome border below the header.
181
+ const header = document.querySelector('[data-slot="drawer-header"]') as HTMLElement
182
+ expect(header.className).not.toMatch(/\bborder-b\b/)
183
+ })
184
+
185
+ it('renders the footer default layout with right-aligned trailing children and no top border', () => {
186
+ render(<ExampleDrawer />)
187
+ const footer = document.querySelector('[data-slot="drawer-footer"]') as HTMLElement
188
+ expect(footer.getAttribute('data-layout')).toBe('default')
189
+ expect(footer.className).not.toMatch(/\bborder-t\b/)
190
+ expect(document.querySelector('[data-slot="drawer-footer-leading"]')).toBeNull()
191
+ const trailing = document.querySelector('[data-slot="drawer-footer-trailing"]') as HTMLElement
192
+ expect(trailing).not.toBeNull()
193
+ // Without a leading slot the trailing group anchors itself to the right.
194
+ expect(trailing.className).toContain('ml-auto')
195
+ })
196
+
197
+ it('stretches children evenly when footer layout="equal"', () => {
198
+ render(
199
+ <Drawer defaultOpen>
200
+ <DrawerTrigger>Open</DrawerTrigger>
201
+ <DrawerContent>
202
+ <DrawerHeader>
203
+ <DrawerTitle>T</DrawerTitle>
204
+ </DrawerHeader>
205
+ <DrawerFooter layout="equal">
206
+ <DrawerClose>Cancel</DrawerClose>
207
+ <button>Continue</button>
208
+ </DrawerFooter>
209
+ </DrawerContent>
210
+ </Drawer>,
211
+ )
212
+ const footer = document.querySelector('[data-slot="drawer-footer"]') as HTMLElement
213
+ expect(footer.getAttribute('data-layout')).toBe('equal')
214
+ // The `[&>*]:flex-1` selector lives in the class list so children
215
+ // stretch equally. There is no trailing wrapper in equal layout.
216
+ expect(footer.className).toContain('[&>*]:flex-1')
217
+ expect(document.querySelector('[data-slot="drawer-footer-trailing"]')).toBeNull()
218
+ })
219
+
220
+ it('renders a left-side leading slot in the footer when `leading` is provided', () => {
221
+ render(
222
+ <Drawer defaultOpen>
223
+ <DrawerTrigger>Open</DrawerTrigger>
224
+ <DrawerContent>
225
+ <DrawerHeader>
226
+ <DrawerTitle>T</DrawerTitle>
227
+ </DrawerHeader>
228
+ <DrawerFooter
229
+ leading={<label data-testid="dont-show">Don&apos;t show again</label>}
230
+ >
231
+ <DrawerClose>Cancel</DrawerClose>
232
+ <button>Continue</button>
233
+ </DrawerFooter>
234
+ </DrawerContent>
235
+ </Drawer>,
236
+ )
237
+ const leading = document.querySelector('[data-slot="drawer-footer-leading"]') as HTMLElement
238
+ expect(leading).not.toBeNull()
239
+ expect(leading.className).toContain('mr-auto')
240
+ expect(leading.querySelector('[data-testid="dont-show"]')).not.toBeNull()
241
+ // When a leading slot is present the trailing wrapper drops `ml-auto`
242
+ // (the leading mr-auto already pushes them apart).
243
+ const trailing = document.querySelector('[data-slot="drawer-footer-trailing"]') as HTMLElement
244
+ expect(trailing).not.toBeNull()
245
+ expect(trailing.className).not.toMatch(/\bml-auto\b/)
246
+ })
247
+
248
+ it('forwards className to drawer-content without dropping side classes', () => {
249
+ const { container } = render(
250
+ <Drawer defaultOpen>
251
+ <DrawerTrigger>Open</DrawerTrigger>
252
+ <DrawerContent className="custom-class">
253
+ <DrawerHeader>
254
+ <DrawerTitle>Title</DrawerTitle>
255
+ </DrawerHeader>
256
+ </DrawerContent>
257
+ </Drawer>,
258
+ )
259
+ const content = document.querySelector('[data-slot="drawer-content"]') as HTMLElement
260
+ expect(content.className).toContain('custom-class')
261
+ expect(content.className).toContain('right-0')
262
+ expect(content.className).toContain('rounded-l-xl')
263
+ })
264
+ })
@@ -0,0 +1,303 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ import * as React from 'react'
4
+ import { render as rtlRender, fireEvent } from '@testing-library/react'
5
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
6
+
7
+ // NotificationFeedItem uses useT() for the interactive aria-label fallback.
8
+ // Use the `wrapper` option (instead of manually wrapping `ui`) so that
9
+ // testing-library's `rerender` re-uses the same I18nProvider — otherwise
10
+ // rerender bypasses the wrap and throws "useT must be used within I18nProvider".
11
+ const Wrapper = ({ children }: { children: React.ReactNode }) => (
12
+ <I18nProvider locale="en" dict={{}}>{children}</I18nProvider>
13
+ )
14
+ const render: typeof rtlRender = (ui: React.ReactElement, options?: Parameters<typeof rtlRender>[1]) =>
15
+ rtlRender(ui, { wrapper: Wrapper, ...options })
16
+
17
+ import {
18
+ NotificationFeed,
19
+ NotificationFeedHeader,
20
+ NotificationFeedList,
21
+ NotificationFeedItem,
22
+ NotificationFeedFooter,
23
+ NotificationFeedIconBadge,
24
+ } from '../notification-feed'
25
+
26
+ describe('NotificationFeed', () => {
27
+ it('renders the root card with data-slot markers and rounded shell', () => {
28
+ const { container } = render(
29
+ <NotificationFeed>
30
+ <NotificationFeedHeader title="Notifications" />
31
+ <NotificationFeedList>
32
+ <NotificationFeedItem title="New lead" />
33
+ </NotificationFeedList>
34
+ </NotificationFeed>,
35
+ )
36
+ const root = container.querySelector('[data-slot="notification-feed"]') as HTMLElement
37
+ expect(root).not.toBeNull()
38
+ expect(root.className).toContain('rounded-xl')
39
+ expect(root.className).toContain('border')
40
+ expect(container.querySelector('[data-slot="notification-feed-header"]')).not.toBeNull()
41
+ expect(container.querySelector('[data-slot="notification-feed-list"]')).not.toBeNull()
42
+ expect(container.querySelector('[data-slot="notification-feed-item"]')).not.toBeNull()
43
+ })
44
+
45
+ it('renders the header title + actions slot when both are provided', () => {
46
+ const { container } = render(
47
+ <NotificationFeed>
48
+ <NotificationFeedHeader title="Notifications">
49
+ <button data-testid="settings">⚙</button>
50
+ </NotificationFeedHeader>
51
+ </NotificationFeed>,
52
+ )
53
+ const title = container.querySelector('[data-slot="notification-feed-header-title"]') as HTMLElement
54
+ expect(title).not.toBeNull()
55
+ expect(title.textContent).toBe('Notifications')
56
+ const actions = container.querySelector(
57
+ '[data-slot="notification-feed-header-actions"]',
58
+ ) as HTMLElement
59
+ expect(actions).not.toBeNull()
60
+ expect(actions.querySelector('[data-testid="settings"]')).not.toBeNull()
61
+ })
62
+
63
+ it('renders item title + body + timestamp slots without any separator glyph', () => {
64
+ const { container } = render(
65
+ <NotificationFeed>
66
+ <NotificationFeedList>
67
+ <NotificationFeedItem
68
+ title="New Lead Generated"
69
+ body="John Smith submitted web form"
70
+ timestamp="10 minutes ago"
71
+ />
72
+ </NotificationFeedList>
73
+ </NotificationFeed>,
74
+ )
75
+ const item = container.querySelector('[data-slot="notification-feed-item"]') as HTMLElement
76
+ expect(item).not.toBeNull()
77
+ expect(item.textContent).toContain('New Lead Generated')
78
+ expect(item.textContent).toContain('John Smith submitted web form')
79
+ expect(item.textContent).toContain('10 minutes ago')
80
+ // No middle-dot or em-dash separator anywhere in the row.
81
+ expect(item.textContent).not.toContain('·')
82
+ expect(item.textContent).not.toContain('—')
83
+ })
84
+
85
+ it('renders the unread dot only when `unread` is true', () => {
86
+ const { container, rerender } = render(
87
+ <NotificationFeed>
88
+ <NotificationFeedList>
89
+ <NotificationFeedItem title="Read" />
90
+ </NotificationFeedList>
91
+ </NotificationFeed>,
92
+ )
93
+ expect(container.querySelector('[data-slot="notification-feed-item-unread-dot"]')).toBeNull()
94
+ expect(
95
+ container.querySelector('[data-slot="notification-feed-item"]')!.getAttribute('data-unread'),
96
+ ).toBeNull()
97
+
98
+ rerender(
99
+ <NotificationFeed>
100
+ <NotificationFeedList>
101
+ <NotificationFeedItem title="Unread" unread />
102
+ </NotificationFeedList>
103
+ </NotificationFeed>,
104
+ )
105
+ expect(
106
+ container.querySelector('[data-slot="notification-feed-item-unread-dot"]'),
107
+ ).not.toBeNull()
108
+ expect(
109
+ container.querySelector('[data-slot="notification-feed-item"]')!.getAttribute('data-unread'),
110
+ ).toBe('true')
111
+ })
112
+
113
+ it('renders the item icon + indented children slots when provided', () => {
114
+ const { container } = render(
115
+ <NotificationFeed>
116
+ <NotificationFeedList>
117
+ <NotificationFeedItem
118
+ title="With icon and children"
119
+ icon={<span data-testid="badge">B</span>}
120
+ >
121
+ <button data-testid="approve">Approve</button>
122
+ </NotificationFeedItem>
123
+ </NotificationFeedList>
124
+ </NotificationFeed>,
125
+ )
126
+ expect(container.querySelector('[data-slot="notification-feed-item-icon"]')).not.toBeNull()
127
+ expect(container.querySelector('[data-testid="badge"]')).not.toBeNull()
128
+ const childrenSlot = container.querySelector(
129
+ '[data-slot="notification-feed-item-children"]',
130
+ ) as HTMLElement
131
+ expect(childrenSlot).not.toBeNull()
132
+ expect(childrenSlot.querySelector('[data-testid="approve"]')).not.toBeNull()
133
+ })
134
+
135
+ it('renders item as a button when onClick is provided and fires on click + Enter + Space', () => {
136
+ const onClick = jest.fn()
137
+ const { container } = render(
138
+ <NotificationFeed>
139
+ <NotificationFeedList>
140
+ <NotificationFeedItem
141
+ title="Click me"
142
+ onClick={onClick}
143
+ />
144
+ </NotificationFeedList>
145
+ </NotificationFeed>,
146
+ )
147
+ const item = container.querySelector('[data-slot="notification-feed-item"]') as HTMLElement
148
+ expect(item.getAttribute('role')).toBe('button')
149
+ expect(item.getAttribute('tabIndex')).toBe('0')
150
+ expect(item.getAttribute('aria-label')).toBe('Click me')
151
+
152
+ fireEvent.click(item)
153
+ expect(onClick).toHaveBeenCalledTimes(1)
154
+
155
+ fireEvent.keyDown(item, { key: 'Enter' })
156
+ expect(onClick).toHaveBeenCalledTimes(2)
157
+
158
+ fireEvent.keyDown(item, { key: ' ' })
159
+ expect(onClick).toHaveBeenCalledTimes(3)
160
+
161
+ fireEvent.keyDown(item, { key: 'a' })
162
+ expect(onClick).toHaveBeenCalledTimes(3)
163
+ })
164
+
165
+ it('does NOT wrap the item as a button when onClick is omitted', () => {
166
+ const { container } = render(
167
+ <NotificationFeed>
168
+ <NotificationFeedList>
169
+ <NotificationFeedItem title="Non-clickable" />
170
+ </NotificationFeedList>
171
+ </NotificationFeed>,
172
+ )
173
+ const item = container.querySelector('[data-slot="notification-feed-item"]') as HTMLElement
174
+ expect(item.getAttribute('role')).toBeNull()
175
+ expect(item.getAttribute('tabIndex')).toBeNull()
176
+ expect(item.className).not.toMatch(/cursor-pointer/)
177
+ })
178
+
179
+ it('renders the actions slot when provided and prevents bubble click to row onClick', () => {
180
+ const onItemClick = jest.fn()
181
+ const onActionClick = jest.fn()
182
+ const { container } = render(
183
+ <NotificationFeed>
184
+ <NotificationFeedList>
185
+ <NotificationFeedItem
186
+ title="With actions"
187
+ onClick={onItemClick}
188
+ actions={
189
+ <button data-testid="kebab" onClick={onActionClick}>
190
+ ⋯
191
+ </button>
192
+ }
193
+ />
194
+ </NotificationFeedList>
195
+ </NotificationFeed>,
196
+ )
197
+ const actionsWrap = container.querySelector(
198
+ '[data-slot="notification-feed-item-actions"]',
199
+ ) as HTMLElement
200
+ expect(actionsWrap).not.toBeNull()
201
+ const kebab = container.querySelector('[data-testid="kebab"]') as HTMLButtonElement
202
+ fireEvent.click(kebab)
203
+ expect(onActionClick).toHaveBeenCalledTimes(1)
204
+ // Action click does NOT bubble up to the row's onClick — the slot
205
+ // wrapper explicitly stops propagation.
206
+ expect(onItemClick).not.toHaveBeenCalled()
207
+ })
208
+
209
+ it('renders the footer slot', () => {
210
+ const { container } = render(
211
+ <NotificationFeed>
212
+ <NotificationFeedList>
213
+ <NotificationFeedItem title="x" />
214
+ </NotificationFeedList>
215
+ <NotificationFeedFooter>
216
+ <button data-testid="archive-all">Archive all</button>
217
+ </NotificationFeedFooter>
218
+ </NotificationFeed>,
219
+ )
220
+ const footer = container.querySelector('[data-slot="notification-feed-footer"]') as HTMLElement
221
+ expect(footer).not.toBeNull()
222
+ expect(footer.querySelector('[data-testid="archive-all"]')).not.toBeNull()
223
+ })
224
+
225
+ it('IconBadge renders with the correct tone class per tone', () => {
226
+ const cases: Array<{
227
+ tone: 'indigo' | 'success' | 'warning' | 'error' | 'info' | 'brand' | 'neutral'
228
+ expectedClasses: string[]
229
+ }> = [
230
+ { tone: 'indigo', expectedClasses: ['bg-accent-indigo/10', 'text-accent-indigo'] },
231
+ { tone: 'success', expectedClasses: ['bg-status-success-icon/10', 'text-status-success-icon'] },
232
+ { tone: 'warning', expectedClasses: ['bg-status-warning-icon/10', 'text-status-warning-icon'] },
233
+ { tone: 'error', expectedClasses: ['bg-status-error-icon/10', 'text-status-error-icon'] },
234
+ { tone: 'info', expectedClasses: ['bg-status-info-icon/10', 'text-status-info-icon'] },
235
+ { tone: 'brand', expectedClasses: ['bg-brand-violet/10', 'text-brand-violet'] },
236
+ { tone: 'neutral', expectedClasses: ['bg-muted', 'text-muted-foreground'] },
237
+ ]
238
+ for (const { tone, expectedClasses } of cases) {
239
+ const { container, unmount } = render(
240
+ <NotificationFeedIconBadge tone={tone}>x</NotificationFeedIconBadge>,
241
+ )
242
+ const badge = container.querySelector(
243
+ '[data-slot="notification-feed-icon-badge"]',
244
+ ) as HTMLElement
245
+ expect(badge).not.toBeNull()
246
+ expect(badge.getAttribute('data-tone')).toBe(tone)
247
+ for (const cls of expectedClasses) {
248
+ expect(badge.className).toContain(cls)
249
+ }
250
+ unmount()
251
+ }
252
+ })
253
+
254
+ it('IconBadge defaults to tone="indigo" and size="default" (size-10)', () => {
255
+ const { container } = render(
256
+ <NotificationFeedIconBadge>x</NotificationFeedIconBadge>,
257
+ )
258
+ const badge = container.querySelector(
259
+ '[data-slot="notification-feed-icon-badge"]',
260
+ ) as HTMLElement
261
+ expect(badge.getAttribute('data-tone')).toBe('indigo')
262
+ expect(badge.className).toContain('bg-accent-indigo/10')
263
+ expect(badge.className).toContain('size-10')
264
+ })
265
+
266
+ it('IconBadge supports sm size (size-8)', () => {
267
+ const { container } = render(
268
+ <NotificationFeedIconBadge size="sm">x</NotificationFeedIconBadge>,
269
+ )
270
+ const badge = container.querySelector(
271
+ '[data-slot="notification-feed-icon-badge"]',
272
+ ) as HTMLElement
273
+ expect(badge.className).toContain('size-8')
274
+ expect(badge.className).not.toContain('size-10')
275
+ })
276
+
277
+ it('forwards className on each compound slot', () => {
278
+ const { container } = render(
279
+ <NotificationFeed className="root-custom">
280
+ <NotificationFeedHeader className="header-custom" title="t" />
281
+ <NotificationFeedList className="list-custom">
282
+ <NotificationFeedItem className="item-custom" title="x" />
283
+ </NotificationFeedList>
284
+ <NotificationFeedFooter className="footer-custom">x</NotificationFeedFooter>
285
+ </NotificationFeed>,
286
+ )
287
+ expect(container.querySelector('[data-slot="notification-feed"]')!.className).toContain(
288
+ 'root-custom',
289
+ )
290
+ expect(container.querySelector('[data-slot="notification-feed-header"]')!.className).toContain(
291
+ 'header-custom',
292
+ )
293
+ expect(container.querySelector('[data-slot="notification-feed-list"]')!.className).toContain(
294
+ 'list-custom',
295
+ )
296
+ expect(container.querySelector('[data-slot="notification-feed-item"]')!.className).toContain(
297
+ 'item-custom',
298
+ )
299
+ expect(container.querySelector('[data-slot="notification-feed-footer"]')!.className).toContain(
300
+ 'footer-custom',
301
+ )
302
+ })
303
+ })
@@ -29,12 +29,12 @@ describe('Notification primitive', () => {
29
29
  expect(screen.getByText('On the latest deal').tagName).toBe('P')
30
30
  })
31
31
 
32
- it('description gets opacity-72 per Figma Notification reference', () => {
32
+ it('description gets opacity-70 (DS opacity scale; was opacity-72 in early v5, snapped to scale per .ai/ds-rules.md)', () => {
33
33
  const { container } = render(
34
34
  <Notification title="t" description="body" />,
35
35
  )
36
36
  const desc = container.querySelector('[data-slot="notification-description"]')
37
- expect(desc).toHaveClass('opacity-72')
37
+ expect(desc).toHaveClass('opacity-70')
38
38
  })
39
39
 
40
40
  it('renders the timestamp slot when provided', () => {
@@ -116,4 +116,104 @@ describe('Notification primitive', () => {
116
116
  expect(ref.current).not.toBeNull()
117
117
  expect(ref.current?.getAttribute('data-slot')).toBe('notification')
118
118
  })
119
+
120
+ describe('autoDismissMs', () => {
121
+ beforeEach(() => {
122
+ jest.useFakeTimers()
123
+ })
124
+ afterEach(() => {
125
+ jest.runOnlyPendingTimers()
126
+ jest.useRealTimers()
127
+ })
128
+
129
+ it('fires onDismiss after the configured delay', () => {
130
+ const onDismiss = jest.fn()
131
+ render(
132
+ <Notification
133
+ title="Saved"
134
+ autoDismissMs={3000}
135
+ onDismiss={onDismiss}
136
+ />,
137
+ )
138
+ expect(onDismiss).not.toHaveBeenCalled()
139
+ jest.advanceTimersByTime(2999)
140
+ expect(onDismiss).not.toHaveBeenCalled()
141
+ jest.advanceTimersByTime(1)
142
+ expect(onDismiss).toHaveBeenCalledTimes(1)
143
+ })
144
+
145
+ it('does NOT fire onDismiss when autoDismissMs is undefined', () => {
146
+ const onDismiss = jest.fn()
147
+ render(<Notification title="Persistent" onDismiss={onDismiss} />)
148
+ jest.advanceTimersByTime(60000)
149
+ expect(onDismiss).not.toHaveBeenCalled()
150
+ })
151
+
152
+ it('does NOT fire onDismiss when autoDismissMs is 0', () => {
153
+ const onDismiss = jest.fn()
154
+ render(
155
+ <Notification title="t" autoDismissMs={0} onDismiss={onDismiss} />,
156
+ )
157
+ jest.advanceTimersByTime(10000)
158
+ expect(onDismiss).not.toHaveBeenCalled()
159
+ })
160
+
161
+ it('pauses the timer on hover and resumes (restarts) on mouse leave when pauseOnHover defaults to true', () => {
162
+ const onDismiss = jest.fn()
163
+ const { container } = render(
164
+ <Notification
165
+ title="Hover me"
166
+ autoDismissMs={3000}
167
+ onDismiss={onDismiss}
168
+ />,
169
+ )
170
+ const root = container.querySelector('[data-slot="notification"]') as HTMLElement
171
+ // Advance ~half the delay
172
+ jest.advanceTimersByTime(1500)
173
+ // Hover pauses the timer
174
+ fireEvent.mouseEnter(root)
175
+ expect(root.getAttribute('data-auto-dismiss-paused')).toBe('true')
176
+ jest.advanceTimersByTime(10000)
177
+ expect(onDismiss).not.toHaveBeenCalled()
178
+ // Leave restarts the timer from zero — full delay needed
179
+ fireEvent.mouseLeave(root)
180
+ expect(root.getAttribute('data-auto-dismiss-paused')).toBeNull()
181
+ jest.advanceTimersByTime(2999)
182
+ expect(onDismiss).not.toHaveBeenCalled()
183
+ jest.advanceTimersByTime(1)
184
+ expect(onDismiss).toHaveBeenCalledTimes(1)
185
+ })
186
+
187
+ it('honors pauseOnHover=false — timer keeps running on hover', () => {
188
+ const onDismiss = jest.fn()
189
+ const { container } = render(
190
+ <Notification
191
+ title="No pause"
192
+ autoDismissMs={2000}
193
+ pauseOnHover={false}
194
+ onDismiss={onDismiss}
195
+ />,
196
+ )
197
+ const root = container.querySelector('[data-slot="notification"]') as HTMLElement
198
+ fireEvent.mouseEnter(root)
199
+ expect(root.getAttribute('data-auto-dismiss-paused')).toBeNull()
200
+ jest.advanceTimersByTime(2000)
201
+ expect(onDismiss).toHaveBeenCalledTimes(1)
202
+ })
203
+
204
+ it('cancels the timer when autoDismissMs prop is removed (set to undefined)', () => {
205
+ const onDismiss = jest.fn()
206
+ const { rerender } = render(
207
+ <Notification
208
+ title="Cancel me"
209
+ autoDismissMs={3000}
210
+ onDismiss={onDismiss}
211
+ />,
212
+ )
213
+ jest.advanceTimersByTime(1000)
214
+ rerender(<Notification title="Cancel me" onDismiss={onDismiss} />)
215
+ jest.advanceTimersByTime(10000)
216
+ expect(onDismiss).not.toHaveBeenCalled()
217
+ })
218
+ })
119
219
  })