@open-mercato/ui 0.6.2-develop.3372.1.de9a169973 → 0.6.2-develop.3380.1.bb14509041
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 +1 -0
- package/dist/ai/AiIcon.js +1 -1
- package/dist/ai/AiIcon.js.map +2 -2
- package/dist/backend/AppShell.js +223 -153
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/ProfileDropdown.js +101 -79
- package/dist/backend/ProfileDropdown.js.map +2 -2
- package/dist/backend/notifications/NotificationCountBadge.js +1 -1
- package/dist/backend/notifications/NotificationCountBadge.js.map +2 -2
- package/dist/backend/notifications/NotificationItem.js +67 -50
- package/dist/backend/notifications/NotificationItem.js.map +2 -2
- package/dist/backend/notifications/NotificationPanel.js +108 -85
- package/dist/backend/notifications/NotificationPanel.js.map +2 -2
- package/dist/primitives/breadcrumb.js +161 -0
- package/dist/primitives/breadcrumb.js.map +7 -0
- package/dist/primitives/sheet.js +114 -0
- package/dist/primitives/sheet.js.map +7 -0
- package/package.json +3 -3
- package/src/ai/AiIcon.tsx +1 -1
- package/src/backend/AppShell.tsx +151 -49
- package/src/backend/ProfileDropdown.tsx +115 -88
- package/src/backend/__tests__/AppShell.test.tsx +8 -0
- package/src/backend/notifications/NotificationCountBadge.tsx +1 -1
- package/src/backend/notifications/NotificationItem.tsx +97 -104
- package/src/backend/notifications/NotificationPanel.tsx +126 -102
- package/src/primitives/__tests__/breadcrumb.test.tsx +298 -0
- package/src/primitives/__tests__/sheet.test.tsx +152 -0
- package/src/primitives/breadcrumb.tsx +185 -0
- package/src/primitives/sheet.tsx +165 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
import * as React from 'react'
|
|
3
|
-
import
|
|
3
|
+
import Link from 'next/link'
|
|
4
|
+
import { ArrowDown, ArrowUp, Bell, Loader2, RotateCcw, Settings2, X } from 'lucide-react'
|
|
4
5
|
import { Button } from '../../primitives/button'
|
|
5
6
|
import { IconButton } from '../../primitives/icon-button'
|
|
6
|
-
import {
|
|
7
|
+
import { Sheet, SheetContent, SheetTitle } from '../../primitives/sheet'
|
|
7
8
|
import { NotificationItem } from './NotificationItem'
|
|
8
9
|
import type { NotificationDto, NotificationRendererProps } from '@open-mercato/shared/modules/notifications/types'
|
|
9
10
|
import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
|
|
@@ -96,117 +97,119 @@ export function NotificationPanel({
|
|
|
96
97
|
}
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
// Preserve the body scroll lock contract that consumers (and integration
|
|
101
|
+
// tests) rely on. Radix Dialog locks scroll via react-remove-scroll which
|
|
102
|
+
// does not set `document.body.style.overflow` — we set it ourselves so the
|
|
103
|
+
// contract is observable.
|
|
99
104
|
React.useEffect(() => {
|
|
100
|
-
if (!open) return
|
|
101
|
-
const
|
|
105
|
+
if (!open || typeof document === 'undefined') return
|
|
106
|
+
const previous = document.body.style.overflow
|
|
102
107
|
document.body.style.overflow = 'hidden'
|
|
103
108
|
return () => {
|
|
104
|
-
document.body.style.overflow =
|
|
109
|
+
document.body.style.overflow = previous
|
|
105
110
|
}
|
|
106
111
|
}, [open])
|
|
107
112
|
|
|
108
|
-
React.useEffect(() => {
|
|
109
|
-
const handleKeyDown = (event: KeyboardEvent) => {
|
|
110
|
-
if (event.key === 'Escape' && open) {
|
|
111
|
-
onOpenChange(false)
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
document.addEventListener('keydown', handleKeyDown)
|
|
115
|
-
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
116
|
-
}, [open, onOpenChange])
|
|
117
|
-
|
|
118
|
-
if (!open) return null
|
|
119
|
-
|
|
120
113
|
return (
|
|
121
|
-
|
|
122
|
-
<
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
/>
|
|
127
|
-
|
|
128
|
-
<div
|
|
129
|
-
className="fixed right-0 top-0 z-modal h-full w-full max-w-md border-l bg-background shadow-lg"
|
|
130
|
-
role="dialog"
|
|
131
|
-
aria-modal="true"
|
|
114
|
+
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
115
|
+
<SheetContent
|
|
116
|
+
side="right"
|
|
117
|
+
className="gap-0 p-0"
|
|
118
|
+
hideClose
|
|
132
119
|
aria-label={t('notifications.title', 'Notifications')}
|
|
133
120
|
>
|
|
134
|
-
<div className="flex
|
|
135
|
-
<
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
</Button>
|
|
160
|
-
)}
|
|
161
|
-
<IconButton variant="ghost" size="lg" onClick={() => onOpenChange(false)}>
|
|
162
|
-
<X className="h-5 w-5" />
|
|
163
|
-
</IconButton>
|
|
164
|
-
</div>
|
|
121
|
+
<div className="flex items-center justify-between gap-3 border-b px-5 py-4">
|
|
122
|
+
<SheetTitle className="text-base font-medium leading-6 tracking-tight text-foreground">
|
|
123
|
+
{t('notifications.title', 'Notifications')}
|
|
124
|
+
</SheetTitle>
|
|
125
|
+
<div className="flex items-center gap-1">
|
|
126
|
+
{unreadCount > 0 ? (
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
onClick={handleMarkAllRead}
|
|
130
|
+
disabled={markingAllRead}
|
|
131
|
+
className="inline-flex items-center gap-1 rounded-md px-1 text-sm font-medium leading-5 text-accent-indigo transition-colors hover:text-accent-indigo/80 disabled:opacity-50"
|
|
132
|
+
>
|
|
133
|
+
{markingAllRead ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
|
134
|
+
{t('notifications.markAllRead', 'Mark all read')}
|
|
135
|
+
</button>
|
|
136
|
+
) : null}
|
|
137
|
+
<IconButton
|
|
138
|
+
type="button"
|
|
139
|
+
variant="ghost"
|
|
140
|
+
size="sm"
|
|
141
|
+
onClick={() => onOpenChange(false)}
|
|
142
|
+
aria-label={t('notifications.close', 'Close notifications')}
|
|
143
|
+
>
|
|
144
|
+
<X className="size-4" />
|
|
145
|
+
</IconButton>
|
|
165
146
|
</div>
|
|
147
|
+
</div>
|
|
166
148
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
149
|
+
<div role="tablist" className="flex items-center gap-5 border-b px-5 py-3.5">
|
|
150
|
+
{(['all', 'unread', 'action'] as const).map((value) => {
|
|
151
|
+
const isActive = filter === value
|
|
152
|
+
const label =
|
|
153
|
+
value === 'all'
|
|
154
|
+
? t('notifications.filters.all', 'All')
|
|
155
|
+
: value === 'unread'
|
|
156
|
+
? t('notifications.filters.unread', 'Unread')
|
|
157
|
+
: t('notifications.filters.actionRequired', 'Action Required')
|
|
158
|
+
return (
|
|
159
|
+
<button
|
|
160
|
+
key={value}
|
|
161
|
+
type="button"
|
|
162
|
+
role="tab"
|
|
163
|
+
aria-selected={isActive}
|
|
164
|
+
onClick={() => setFilter(value)}
|
|
165
|
+
className="relative inline-flex items-center gap-1.5 pb-2 text-sm font-medium leading-5 tracking-tight transition-colors focus:outline-none focus-visible:text-foreground data-[active=true]:text-foreground data-[active=false]:text-muted-foreground hover:text-foreground"
|
|
166
|
+
data-active={isActive}
|
|
167
|
+
>
|
|
168
|
+
<span>{label}</span>
|
|
169
|
+
{value === 'unread' && unreadCount > 0 ? (
|
|
170
|
+
<span className="inline-flex min-w-4 items-center justify-center rounded-full bg-accent-indigo px-1 text-overline font-medium text-accent-indigo-foreground">
|
|
171
|
+
{unreadCount > 99 ? '99+' : unreadCount}
|
|
172
|
+
</span>
|
|
173
|
+
) : null}
|
|
174
|
+
{isActive ? (
|
|
175
|
+
<span
|
|
176
|
+
className="absolute bottom-[-14px] left-0 right-0 h-0.5 bg-foreground"
|
|
177
|
+
aria-hidden="true"
|
|
178
|
+
/>
|
|
179
|
+
) : null}
|
|
180
|
+
</button>
|
|
181
|
+
)
|
|
182
|
+
})}
|
|
183
|
+
</div>
|
|
184
184
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
</div>
|
|
185
|
+
{dismissUndo && onUndoDismiss && (
|
|
186
|
+
<div className="border-b bg-muted/50 px-4 py-2 text-sm">
|
|
187
|
+
<div className="flex items-center justify-between gap-3">
|
|
188
|
+
<span>
|
|
189
|
+
{t('notifications.toast.dismissed', 'Notification dismissed')}
|
|
190
|
+
</span>
|
|
191
|
+
<Button variant="ghost" size="sm" onClick={() => onUndoDismiss()}>
|
|
192
|
+
<RotateCcw className="mr-1 h-3 w-3" />
|
|
193
|
+
{t('notifications.actions.undo', 'Undo')}
|
|
194
|
+
</Button>
|
|
196
195
|
</div>
|
|
197
|
-
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
198
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
<
|
|
199
|
+
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain">
|
|
200
|
+
{filteredNotifications.length === 0 ? (
|
|
201
|
+
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
|
|
202
|
+
<div className="flex size-12 items-center justify-center rounded-full bg-muted/50">
|
|
203
|
+
<Bell className="size-5" aria-hidden="true" />
|
|
204
204
|
</div>
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
205
|
+
<p className="text-sm">{t('notifications.empty', 'No notifications')}</p>
|
|
206
|
+
</div>
|
|
207
|
+
) : (
|
|
208
|
+
<div className="flex flex-col gap-1 p-2">
|
|
209
|
+
{filteredNotifications.map((notification, idx) => (
|
|
210
|
+
<React.Fragment key={notification.id}>
|
|
211
|
+
{idx > 0 ? <div className="my-px h-px bg-border" aria-hidden="true" /> : null}
|
|
208
212
|
<NotificationItem
|
|
209
|
-
key={notification.id}
|
|
210
213
|
notification={notification}
|
|
211
214
|
onMarkAsRead={() => onMarkAsRead(notification.id)}
|
|
212
215
|
onExecuteAction={(actionId) => onExecuteAction(notification.id, actionId)}
|
|
@@ -214,12 +217,33 @@ export function NotificationPanel({
|
|
|
214
217
|
t={t}
|
|
215
218
|
customRenderer={customRenderers?.[notification.type]}
|
|
216
219
|
/>
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
+
</React.Fragment>
|
|
221
|
+
))}
|
|
222
|
+
</div>
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
<div className="flex items-center justify-between gap-3 border-t px-5 py-3.5 text-xs leading-4 text-muted-foreground">
|
|
227
|
+
<div className="flex items-center gap-2">
|
|
228
|
+
<span>{t('notifications.footer.useHint', 'Use')}</span>
|
|
229
|
+
<span className="inline-flex items-center rounded border bg-muted/50 px-1 py-0.5">
|
|
230
|
+
<ArrowUp className="size-3" aria-hidden="true" />
|
|
231
|
+
</span>
|
|
232
|
+
<span className="inline-flex items-center rounded border bg-muted/50 px-1 py-0.5">
|
|
233
|
+
<ArrowDown className="size-3" aria-hidden="true" />
|
|
234
|
+
</span>
|
|
235
|
+
<span>{t('notifications.footer.toNavigate', 'to navigate')}</span>
|
|
220
236
|
</div>
|
|
237
|
+
<Link
|
|
238
|
+
href="/backend/config/notifications"
|
|
239
|
+
onClick={() => onOpenChange(false)}
|
|
240
|
+
className="inline-flex items-center gap-1.5 font-medium text-muted-foreground transition-colors hover:text-foreground"
|
|
241
|
+
>
|
|
242
|
+
<Settings2 className="size-3.5" aria-hidden="true" />
|
|
243
|
+
{t('notifications.footer.manage', 'Manage notifications')}
|
|
244
|
+
</Link>
|
|
221
245
|
</div>
|
|
222
|
-
</
|
|
223
|
-
|
|
246
|
+
</SheetContent>
|
|
247
|
+
</Sheet>
|
|
224
248
|
)
|
|
225
249
|
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import { render, screen, within } from '@testing-library/react'
|
|
7
|
+
import {
|
|
8
|
+
Breadcrumb,
|
|
9
|
+
BreadcrumbEllipsis,
|
|
10
|
+
BreadcrumbItem,
|
|
11
|
+
BreadcrumbLink,
|
|
12
|
+
BreadcrumbList,
|
|
13
|
+
BreadcrumbPage,
|
|
14
|
+
BreadcrumbSeparator,
|
|
15
|
+
BreadcrumbStatic,
|
|
16
|
+
} from '../breadcrumb'
|
|
17
|
+
|
|
18
|
+
describe('Breadcrumb primitive', () => {
|
|
19
|
+
it('renders a <nav> with aria-label="Breadcrumb"', () => {
|
|
20
|
+
render(
|
|
21
|
+
<Breadcrumb>
|
|
22
|
+
<BreadcrumbList>
|
|
23
|
+
<BreadcrumbItem>
|
|
24
|
+
<BreadcrumbPage>Home</BreadcrumbPage>
|
|
25
|
+
</BreadcrumbItem>
|
|
26
|
+
</BreadcrumbList>
|
|
27
|
+
</Breadcrumb>,
|
|
28
|
+
)
|
|
29
|
+
const nav = screen.getByRole('navigation', { name: 'Breadcrumb' })
|
|
30
|
+
expect(nav).toBeInTheDocument()
|
|
31
|
+
expect(nav).toHaveAttribute('data-slot', 'breadcrumb')
|
|
32
|
+
expect(nav).toHaveAttribute('data-divider', 'slash')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('marks the last item with aria-current="page"', () => {
|
|
36
|
+
render(
|
|
37
|
+
<Breadcrumb>
|
|
38
|
+
<BreadcrumbList>
|
|
39
|
+
<BreadcrumbItem>
|
|
40
|
+
<BreadcrumbLink href="/">Home</BreadcrumbLink>
|
|
41
|
+
</BreadcrumbItem>
|
|
42
|
+
<BreadcrumbSeparator />
|
|
43
|
+
<BreadcrumbItem>
|
|
44
|
+
<BreadcrumbPage>Current</BreadcrumbPage>
|
|
45
|
+
</BreadcrumbItem>
|
|
46
|
+
</BreadcrumbList>
|
|
47
|
+
</Breadcrumb>,
|
|
48
|
+
)
|
|
49
|
+
const current = screen.getByText('Current')
|
|
50
|
+
expect(current).toHaveAttribute('aria-current', 'page')
|
|
51
|
+
expect(current).toHaveAttribute('data-slot', 'breadcrumb-page')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('renders <ol> for the list and <li> for items + separators', () => {
|
|
55
|
+
render(
|
|
56
|
+
<Breadcrumb>
|
|
57
|
+
<BreadcrumbList>
|
|
58
|
+
<BreadcrumbItem>
|
|
59
|
+
<BreadcrumbLink href="/">Home</BreadcrumbLink>
|
|
60
|
+
</BreadcrumbItem>
|
|
61
|
+
<BreadcrumbSeparator />
|
|
62
|
+
<BreadcrumbItem>
|
|
63
|
+
<BreadcrumbPage>Current</BreadcrumbPage>
|
|
64
|
+
</BreadcrumbItem>
|
|
65
|
+
</BreadcrumbList>
|
|
66
|
+
</Breadcrumb>,
|
|
67
|
+
)
|
|
68
|
+
const nav = screen.getByRole('navigation', { name: 'Breadcrumb' })
|
|
69
|
+
const list = within(nav).getByRole('list')
|
|
70
|
+
expect(list.tagName).toBe('OL')
|
|
71
|
+
const listItems = within(nav).getAllByRole('listitem')
|
|
72
|
+
expect(listItems).toHaveLength(2)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('renders slash separator by default', () => {
|
|
76
|
+
render(
|
|
77
|
+
<Breadcrumb>
|
|
78
|
+
<BreadcrumbList>
|
|
79
|
+
<BreadcrumbItem>
|
|
80
|
+
<BreadcrumbLink href="/">Home</BreadcrumbLink>
|
|
81
|
+
</BreadcrumbItem>
|
|
82
|
+
<BreadcrumbSeparator />
|
|
83
|
+
<BreadcrumbItem>
|
|
84
|
+
<BreadcrumbPage>Current</BreadcrumbPage>
|
|
85
|
+
</BreadcrumbItem>
|
|
86
|
+
</BreadcrumbList>
|
|
87
|
+
</Breadcrumb>,
|
|
88
|
+
)
|
|
89
|
+
const separator = document.querySelector('[data-slot="breadcrumb-separator"]')!
|
|
90
|
+
expect(separator).toHaveAttribute('aria-hidden', 'true')
|
|
91
|
+
expect(separator).toHaveAttribute('data-divider', 'slash')
|
|
92
|
+
expect(separator.textContent).toContain('/')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('renders arrow separator (ChevronRight svg) when divider="arrow"', () => {
|
|
96
|
+
render(
|
|
97
|
+
<Breadcrumb divider="arrow">
|
|
98
|
+
<BreadcrumbList>
|
|
99
|
+
<BreadcrumbItem>
|
|
100
|
+
<BreadcrumbLink href="/">Home</BreadcrumbLink>
|
|
101
|
+
</BreadcrumbItem>
|
|
102
|
+
<BreadcrumbSeparator />
|
|
103
|
+
<BreadcrumbItem>
|
|
104
|
+
<BreadcrumbPage>Current</BreadcrumbPage>
|
|
105
|
+
</BreadcrumbItem>
|
|
106
|
+
</BreadcrumbList>
|
|
107
|
+
</Breadcrumb>,
|
|
108
|
+
)
|
|
109
|
+
const separator = document.querySelector('[data-slot="breadcrumb-separator"]')!
|
|
110
|
+
expect(separator).toHaveAttribute('data-divider', 'arrow')
|
|
111
|
+
expect(separator.querySelector('svg')).not.toBeNull()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('renders dot separator when divider="dot"', () => {
|
|
115
|
+
render(
|
|
116
|
+
<Breadcrumb divider="dot">
|
|
117
|
+
<BreadcrumbList>
|
|
118
|
+
<BreadcrumbItem>
|
|
119
|
+
<BreadcrumbLink href="/">Home</BreadcrumbLink>
|
|
120
|
+
</BreadcrumbItem>
|
|
121
|
+
<BreadcrumbSeparator />
|
|
122
|
+
<BreadcrumbItem>
|
|
123
|
+
<BreadcrumbPage>Current</BreadcrumbPage>
|
|
124
|
+
</BreadcrumbItem>
|
|
125
|
+
</BreadcrumbList>
|
|
126
|
+
</Breadcrumb>,
|
|
127
|
+
)
|
|
128
|
+
const separator = document.querySelector('[data-slot="breadcrumb-separator"]')!
|
|
129
|
+
expect(separator).toHaveAttribute('data-divider', 'dot')
|
|
130
|
+
expect(separator.textContent).toContain('·')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('allows a per-separator divider override', () => {
|
|
134
|
+
render(
|
|
135
|
+
<Breadcrumb divider="slash">
|
|
136
|
+
<BreadcrumbList>
|
|
137
|
+
<BreadcrumbItem>
|
|
138
|
+
<BreadcrumbLink href="/a">A</BreadcrumbLink>
|
|
139
|
+
</BreadcrumbItem>
|
|
140
|
+
<BreadcrumbSeparator divider="arrow" data-testid="custom-sep" />
|
|
141
|
+
<BreadcrumbItem>
|
|
142
|
+
<BreadcrumbPage>B</BreadcrumbPage>
|
|
143
|
+
</BreadcrumbItem>
|
|
144
|
+
</BreadcrumbList>
|
|
145
|
+
</Breadcrumb>,
|
|
146
|
+
)
|
|
147
|
+
const sep = screen.getByTestId('custom-sep')
|
|
148
|
+
expect(sep).toHaveAttribute('data-divider', 'arrow')
|
|
149
|
+
expect(sep.querySelector('svg')).not.toBeNull()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('BreadcrumbLink renders an <a> with hover underline classes', () => {
|
|
153
|
+
render(
|
|
154
|
+
<Breadcrumb>
|
|
155
|
+
<BreadcrumbList>
|
|
156
|
+
<BreadcrumbItem>
|
|
157
|
+
<BreadcrumbLink href="/customers">Customers</BreadcrumbLink>
|
|
158
|
+
</BreadcrumbItem>
|
|
159
|
+
</BreadcrumbList>
|
|
160
|
+
</Breadcrumb>,
|
|
161
|
+
)
|
|
162
|
+
const link = screen.getByRole('link', { name: 'Customers' })
|
|
163
|
+
expect(link.tagName).toBe('A')
|
|
164
|
+
expect(link).toHaveAttribute('href', '/customers')
|
|
165
|
+
expect(link.className).toContain('hover:text-foreground')
|
|
166
|
+
expect(link.className).toContain('text-muted-foreground')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('BreadcrumbLink with asChild slots the child element', () => {
|
|
170
|
+
render(
|
|
171
|
+
<Breadcrumb>
|
|
172
|
+
<BreadcrumbList>
|
|
173
|
+
<BreadcrumbItem>
|
|
174
|
+
<BreadcrumbLink asChild>
|
|
175
|
+
<a href="/people" data-testid="slotted">People</a>
|
|
176
|
+
</BreadcrumbLink>
|
|
177
|
+
</BreadcrumbItem>
|
|
178
|
+
</BreadcrumbList>
|
|
179
|
+
</Breadcrumb>,
|
|
180
|
+
)
|
|
181
|
+
const slotted = screen.getByTestId('slotted')
|
|
182
|
+
expect(slotted.tagName).toBe('A')
|
|
183
|
+
expect(slotted).toHaveAttribute('href', '/people')
|
|
184
|
+
expect(slotted).toHaveAttribute('data-slot', 'breadcrumb-link')
|
|
185
|
+
expect(slotted.className).toContain('hover:text-foreground')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('BreadcrumbLink renders icon children alongside the label', () => {
|
|
189
|
+
render(
|
|
190
|
+
<Breadcrumb>
|
|
191
|
+
<BreadcrumbList>
|
|
192
|
+
<BreadcrumbItem>
|
|
193
|
+
<BreadcrumbLink href="/">
|
|
194
|
+
<span data-testid="home-icon">H</span>
|
|
195
|
+
Home
|
|
196
|
+
</BreadcrumbLink>
|
|
197
|
+
</BreadcrumbItem>
|
|
198
|
+
</BreadcrumbList>
|
|
199
|
+
</Breadcrumb>,
|
|
200
|
+
)
|
|
201
|
+
const link = screen.getByRole('link', { name: 'H Home' })
|
|
202
|
+
expect(within(link).getByTestId('home-icon')).toBeInTheDocument()
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('BreadcrumbLink asChild preserves aria-label on the slotted icon-only anchor', () => {
|
|
206
|
+
render(
|
|
207
|
+
<Breadcrumb>
|
|
208
|
+
<BreadcrumbList>
|
|
209
|
+
<BreadcrumbItem>
|
|
210
|
+
<BreadcrumbLink asChild aria-label="Dashboard">
|
|
211
|
+
<a href="/backend" data-testid="home-link">
|
|
212
|
+
<svg data-testid="home-svg" />
|
|
213
|
+
</a>
|
|
214
|
+
</BreadcrumbLink>
|
|
215
|
+
</BreadcrumbItem>
|
|
216
|
+
</BreadcrumbList>
|
|
217
|
+
</Breadcrumb>,
|
|
218
|
+
)
|
|
219
|
+
const link = screen.getByRole('link', { name: 'Dashboard' })
|
|
220
|
+
expect(link).toHaveAttribute('href', '/backend')
|
|
221
|
+
expect(link).toHaveAttribute('data-slot', 'breadcrumb-link')
|
|
222
|
+
expect(within(link).getByTestId('home-svg')).toBeInTheDocument()
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('BreadcrumbList renders nothing when it has no children', () => {
|
|
226
|
+
const { container } = render(
|
|
227
|
+
<Breadcrumb>
|
|
228
|
+
<BreadcrumbList>{null}</BreadcrumbList>
|
|
229
|
+
</Breadcrumb>,
|
|
230
|
+
)
|
|
231
|
+
expect(container.querySelector('ol')).toBeNull()
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('BreadcrumbLink default classes include truncate and a viewport-bounded max-width', () => {
|
|
235
|
+
render(
|
|
236
|
+
<Breadcrumb>
|
|
237
|
+
<BreadcrumbList>
|
|
238
|
+
<BreadcrumbItem>
|
|
239
|
+
<BreadcrumbLink href="/products">Products</BreadcrumbLink>
|
|
240
|
+
</BreadcrumbItem>
|
|
241
|
+
</BreadcrumbList>
|
|
242
|
+
</Breadcrumb>,
|
|
243
|
+
)
|
|
244
|
+
const link = screen.getByRole('link', { name: 'Products' })
|
|
245
|
+
expect(link.className).toContain('truncate')
|
|
246
|
+
expect(link.className).toContain('max-w-[40vw]')
|
|
247
|
+
expect(link.className).toContain('md:max-w-[28vw]')
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('BreadcrumbLink forwards the title attribute for native tooltip on truncated labels', () => {
|
|
251
|
+
render(
|
|
252
|
+
<Breadcrumb>
|
|
253
|
+
<BreadcrumbList>
|
|
254
|
+
<BreadcrumbItem>
|
|
255
|
+
<BreadcrumbLink href="/x" title="A very long category name that overflows">
|
|
256
|
+
A very long category name that overflows
|
|
257
|
+
</BreadcrumbLink>
|
|
258
|
+
</BreadcrumbItem>
|
|
259
|
+
</BreadcrumbList>
|
|
260
|
+
</Breadcrumb>,
|
|
261
|
+
)
|
|
262
|
+
const link = screen.getByRole('link')
|
|
263
|
+
expect(link).toHaveAttribute('title', 'A very long category name that overflows')
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('BreadcrumbStatic renders muted text without aria-current or cursor affordance', () => {
|
|
267
|
+
render(
|
|
268
|
+
<Breadcrumb>
|
|
269
|
+
<BreadcrumbList>
|
|
270
|
+
<BreadcrumbItem>
|
|
271
|
+
<BreadcrumbStatic>Settings</BreadcrumbStatic>
|
|
272
|
+
</BreadcrumbItem>
|
|
273
|
+
</BreadcrumbList>
|
|
274
|
+
</Breadcrumb>,
|
|
275
|
+
)
|
|
276
|
+
const node = screen.getByText('Settings')
|
|
277
|
+
expect(node).toHaveAttribute('data-slot', 'breadcrumb-static')
|
|
278
|
+
expect(node).not.toHaveAttribute('aria-current')
|
|
279
|
+
expect(node.className).toContain('text-muted-foreground')
|
|
280
|
+
expect(node.className).toContain('select-none')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('BreadcrumbEllipsis renders MoreHorizontal icon and an sr-only label', () => {
|
|
284
|
+
render(
|
|
285
|
+
<Breadcrumb>
|
|
286
|
+
<BreadcrumbList>
|
|
287
|
+
<BreadcrumbItem>
|
|
288
|
+
<BreadcrumbEllipsis />
|
|
289
|
+
</BreadcrumbItem>
|
|
290
|
+
</BreadcrumbList>
|
|
291
|
+
</Breadcrumb>,
|
|
292
|
+
)
|
|
293
|
+
const ellipsis = document.querySelector('[data-slot="breadcrumb-ellipsis"]')!
|
|
294
|
+
expect(ellipsis).toBeInTheDocument()
|
|
295
|
+
expect(ellipsis.querySelector('svg')).not.toBeNull()
|
|
296
|
+
expect(ellipsis.textContent).toContain('More')
|
|
297
|
+
})
|
|
298
|
+
})
|