@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.
Files changed (30) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +1 -0
  3. package/dist/ai/AiIcon.js +1 -1
  4. package/dist/ai/AiIcon.js.map +2 -2
  5. package/dist/backend/AppShell.js +223 -153
  6. package/dist/backend/AppShell.js.map +2 -2
  7. package/dist/backend/ProfileDropdown.js +101 -79
  8. package/dist/backend/ProfileDropdown.js.map +2 -2
  9. package/dist/backend/notifications/NotificationCountBadge.js +1 -1
  10. package/dist/backend/notifications/NotificationCountBadge.js.map +2 -2
  11. package/dist/backend/notifications/NotificationItem.js +67 -50
  12. package/dist/backend/notifications/NotificationItem.js.map +2 -2
  13. package/dist/backend/notifications/NotificationPanel.js +108 -85
  14. package/dist/backend/notifications/NotificationPanel.js.map +2 -2
  15. package/dist/primitives/breadcrumb.js +161 -0
  16. package/dist/primitives/breadcrumb.js.map +7 -0
  17. package/dist/primitives/sheet.js +114 -0
  18. package/dist/primitives/sheet.js.map +7 -0
  19. package/package.json +3 -3
  20. package/src/ai/AiIcon.tsx +1 -1
  21. package/src/backend/AppShell.tsx +151 -49
  22. package/src/backend/ProfileDropdown.tsx +115 -88
  23. package/src/backend/__tests__/AppShell.test.tsx +8 -0
  24. package/src/backend/notifications/NotificationCountBadge.tsx +1 -1
  25. package/src/backend/notifications/NotificationItem.tsx +97 -104
  26. package/src/backend/notifications/NotificationPanel.tsx +126 -102
  27. package/src/primitives/__tests__/breadcrumb.test.tsx +298 -0
  28. package/src/primitives/__tests__/sheet.test.tsx +152 -0
  29. package/src/primitives/breadcrumb.tsx +185 -0
  30. package/src/primitives/sheet.tsx +165 -0
@@ -1,9 +1,10 @@
1
1
  "use client"
2
2
  import * as React from 'react'
3
- import { X, Bell, CheckCheck, Loader2, RotateCcw } from 'lucide-react'
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 { Tabs, TabsList, TabsTrigger } from '../../primitives/tabs'
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 prevOverflow = document.body.style.overflow
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 = prevOverflow
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
- <div
123
- className="fixed inset-0 z-overlay bg-black/20"
124
- onClick={() => onOpenChange(false)}
125
- aria-hidden="true"
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 h-full flex-col">
135
- <div className="flex items-center justify-between border-b px-4 py-3">
136
- <div className="flex items-center gap-2">
137
- <Bell className="h-5 w-5" />
138
- <h2 className="font-semibold">{t('notifications.title', 'Notifications')}</h2>
139
- {unreadCount > 0 && (
140
- <span className="rounded-full bg-destructive px-2 py-0.5 text-xs text-destructive-foreground">
141
- {unreadCount}
142
- </span>
143
- )}
144
- </div>
145
- <div className="flex items-center gap-2">
146
- {unreadCount > 0 && (
147
- <Button
148
- variant="ghost"
149
- size="sm"
150
- onClick={handleMarkAllRead}
151
- disabled={markingAllRead}
152
- >
153
- {markingAllRead ? (
154
- <Loader2 className="mr-1 h-4 w-4 animate-spin" />
155
- ) : (
156
- <CheckCheck className="mr-1 h-4 w-4" />
157
- )}
158
- {t('notifications.markAllRead', 'Mark all read')}
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
- <Tabs
168
- value={filter}
169
- onValueChange={(v) => setFilter(v as typeof filter)}
170
- className="border-b"
171
- >
172
- <TabsList className="w-full justify-start rounded-none border-0 bg-transparent px-4">
173
- <TabsTrigger value="all">
174
- {t('notifications.filters.all', 'All')}
175
- </TabsTrigger>
176
- <TabsTrigger value="unread">
177
- {t('notifications.filters.unread', 'Unread')}
178
- </TabsTrigger>
179
- <TabsTrigger value="action">
180
- {t('notifications.filters.actionRequired', 'Action Required')}
181
- </TabsTrigger>
182
- </TabsList>
183
- </Tabs>
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
- {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>
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
- <div className="flex-1 overflow-y-auto overscroll-contain">
200
- {filteredNotifications.length === 0 ? (
201
- <div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
202
- <Bell className="mb-2 h-8 w-8 opacity-50" />
203
- <p>{t('notifications.empty', 'No notifications')}</p>
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
- <div className="divide-y">
207
- {filteredNotifications.map((notification) => (
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
- </div>
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
- </div>
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
+ })