@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +14 -0
- package/dist/backend/DataTable.js +28 -62
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/detail/AttachmentMetadataDialog.js +1 -1
- package/dist/backend/detail/AttachmentMetadataDialog.js.map +2 -2
- package/dist/primitives/activity-feed.js +196 -0
- package/dist/primitives/activity-feed.js.map +7 -0
- package/dist/primitives/avatar.js +140 -14
- package/dist/primitives/avatar.js.map +3 -3
- package/dist/primitives/badge.js +94 -5
- package/dist/primitives/badge.js.map +2 -2
- package/dist/primitives/button-group.js +52 -0
- package/dist/primitives/button-group.js.map +7 -0
- package/dist/primitives/color-picker.js +520 -0
- package/dist/primitives/color-picker.js.map +7 -0
- package/dist/primitives/command-menu.js +355 -0
- package/dist/primitives/command-menu.js.map +7 -0
- package/dist/primitives/dialog.js +210 -35
- package/dist/primitives/dialog.js.map +2 -2
- package/dist/primitives/drawer.js +219 -0
- package/dist/primitives/drawer.js.map +7 -0
- package/dist/primitives/notification-feed.js +213 -0
- package/dist/primitives/notification-feed.js.map +7 -0
- package/dist/primitives/notification.js +23 -2
- package/dist/primitives/notification.js.map +2 -2
- package/dist/primitives/pagination.js +245 -0
- package/dist/primitives/pagination.js.map +7 -0
- package/dist/primitives/progress.js +178 -5
- package/dist/primitives/progress.js.map +2 -2
- package/dist/primitives/rating.js +174 -0
- package/dist/primitives/rating.js.map +7 -0
- package/dist/primitives/scroll-area.js +82 -0
- package/dist/primitives/scroll-area.js.map +7 -0
- package/dist/primitives/segmented-control.js +101 -0
- package/dist/primitives/segmented-control.js.map +7 -0
- package/dist/primitives/separator.js +103 -4
- package/dist/primitives/separator.js.map +2 -2
- package/dist/primitives/slider.js +115 -0
- package/dist/primitives/slider.js.map +7 -0
- package/dist/primitives/step-indicator.js +187 -0
- package/dist/primitives/step-indicator.js.map +7 -0
- package/dist/primitives/table.js +102 -10
- package/dist/primitives/table.js.map +2 -2
- package/dist/primitives/tabs.js +122 -14
- package/dist/primitives/tabs.js.map +2 -2
- package/dist/primitives/time-picker.js +9 -11
- package/dist/primitives/time-picker.js.map +2 -2
- package/package.json +8 -4
- package/src/backend/DataTable.tsx +43 -60
- package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
- package/src/primitives/__tests__/activity-feed.test.tsx +216 -0
- package/src/primitives/__tests__/avatar.test.tsx +131 -0
- package/src/primitives/__tests__/badge.test.tsx +189 -0
- package/src/primitives/__tests__/button-group.test.tsx +117 -0
- package/src/primitives/__tests__/color-picker.test.tsx +589 -0
- package/src/primitives/__tests__/command-menu.test.tsx +231 -0
- package/src/primitives/__tests__/dialog.test.tsx +290 -0
- package/src/primitives/__tests__/drawer.test.tsx +264 -0
- package/src/primitives/__tests__/notification-feed.test.tsx +303 -0
- package/src/primitives/__tests__/notification.test.tsx +102 -2
- package/src/primitives/__tests__/pagination.test.tsx +268 -0
- package/src/primitives/__tests__/progress.test.tsx +222 -0
- package/src/primitives/__tests__/rating.test.tsx +164 -0
- package/src/primitives/__tests__/scroll-area.test.tsx +109 -0
- package/src/primitives/__tests__/segmented-control.test.tsx +129 -0
- package/src/primitives/__tests__/separator.test.tsx +143 -0
- package/src/primitives/__tests__/slider.test.tsx +86 -0
- package/src/primitives/__tests__/step-indicator.test.tsx +205 -0
- package/src/primitives/__tests__/table.test.tsx +199 -0
- package/src/primitives/__tests__/tabs.test.tsx +139 -0
- package/src/primitives/activity-feed.tsx +326 -0
- package/src/primitives/avatar.tsx +225 -22
- package/src/primitives/badge.tsx +141 -6
- package/src/primitives/button-group.tsx +100 -0
- package/src/primitives/color-picker.tsx +693 -0
- package/src/primitives/command-menu.tsx +503 -0
- package/src/primitives/dialog.tsx +315 -51
- package/src/primitives/drawer.tsx +338 -0
- package/src/primitives/notification-feed.tsx +358 -0
- package/src/primitives/notification.tsx +42 -2
- package/src/primitives/pagination.tsx +376 -0
- package/src/primitives/progress.tsx +267 -6
- package/src/primitives/rating.tsx +245 -0
- package/src/primitives/scroll-area.tsx +169 -0
- package/src/primitives/segmented-control.tsx +162 -0
- package/src/primitives/separator.tsx +163 -3
- package/src/primitives/slider.tsx +160 -0
- package/src/primitives/step-indicator.tsx +287 -0
- package/src/primitives/table.tsx +142 -11
- package/src/primitives/tabs.tsx +174 -12
- 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'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
|
|
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-
|
|
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
|
})
|