@countermeasure-platform/web-components 1.3.3-dev.33.1 → 1.3.4
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/README.md +31 -0
- package/dist/component-D5sRm1fq.js +389 -0
- package/dist/component-D5sRm1fq.js.map +1 -0
- package/dist/components/index.js +27 -27
- package/dist/icons/index.d.ts +12 -0
- package/dist/icons/index.d.ts.map +1 -1
- package/dist/icons/index.js +12 -0
- package/dist/icons/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +127 -126
- package/dist/layout/app-shell.d.ts +50 -3
- package/dist/layout/app-shell.d.ts.map +1 -1
- package/dist/layout/app-shell.js +142 -13
- package/dist/layout/app-shell.js.map +1 -1
- package/dist/layout/core-app-chrome.d.ts +81 -0
- package/dist/layout/core-app-chrome.d.ts.map +1 -0
- package/dist/layout/core-app-chrome.js +349 -0
- package/dist/layout/core-app-chrome.js.map +1 -0
- package/dist/layout/index.d.ts +4 -2
- package/dist/layout/index.d.ts.map +1 -1
- package/dist/layout/index.js +88 -87
- package/dist/layout/index.js.map +1 -1
- package/dist/react/primitives/badge.d.ts +1 -1
- package/dist/react/primitives/button.d.ts +2 -2
- package/dist/react/primitives/copy-button.d.ts +1 -1
- package/dist/react/primitives/stat-card.d.ts +1 -1
- package/dist/react/primitives/toggle.d.ts +1 -1
- package/dist/react/sidebar.d.ts +4 -4
- package/dist/react/sidebar.d.ts.map +1 -1
- package/dist/react/sidebar.js +18 -16
- package/dist/react/sidebar.js.map +1 -1
- package/dist/sidebar/component.d.ts +24 -2
- package/dist/sidebar/component.d.ts.map +1 -1
- package/dist/sidebar/enhance.d.ts +8 -0
- package/dist/sidebar/enhance.d.ts.map +1 -1
- package/dist/sidebar/index.d.ts +3 -0
- package/dist/sidebar/index.d.ts.map +1 -1
- package/dist/sidebar/index.js +81 -28
- package/dist/sidebar/index.js.map +1 -1
- package/dist/sidebar/types.d.ts +126 -4
- package/dist/sidebar/types.d.ts.map +1 -1
- package/dist/styles/layout.css +252 -0
- package/dist/styles/sidebar.css +313 -5
- package/package.json +6 -1
- package/src/icons/icons.test.ts +9 -0
- package/src/icons/index.ts +12 -0
- package/src/index.ts +11 -0
- package/src/layout/app-shell.test.ts +204 -0
- package/src/layout/app-shell.ts +362 -3
- package/src/layout/core-app-chrome.test.ts +507 -0
- package/src/layout/core-app-chrome.ts +662 -0
- package/src/layout/index.ts +36 -2
- package/src/react/sidebar.test.tsx +104 -3
- package/src/react/sidebar.tsx +26 -4
- package/src/sidebar/component.test.ts +395 -1
- package/src/sidebar/component.ts +661 -86
- package/src/sidebar/enhance.ts +118 -0
- package/src/sidebar/index.ts +144 -0
- package/src/sidebar/types.ts +143 -4
- package/src/styles/layout.css +252 -0
- package/src/styles/sidebar.css +313 -5
- package/dist/component-Bxhxf21c.js +0 -167
- package/dist/component-Bxhxf21c.js.map +0 -1
|
@@ -1,7 +1,108 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { render } from '@testing-library/react'
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { fireEvent, render, waitFor } from '@testing-library/react'
|
|
3
3
|
|
|
4
|
-
import { SidebarCollapseButton } from './sidebar'
|
|
4
|
+
import { Sidebar, SidebarCollapseButton, type SidebarSection } from './sidebar'
|
|
5
|
+
|
|
6
|
+
const sidebarSections: SidebarSection[] = [
|
|
7
|
+
{
|
|
8
|
+
id: 'dashboards',
|
|
9
|
+
label: 'Dashboards',
|
|
10
|
+
items: [
|
|
11
|
+
{ id: 'library', label: 'Library', href: '/app', icon: 'library' },
|
|
12
|
+
{ id: 'jobs', label: 'Jobs', icon: 'jobs' },
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
const routeSections: SidebarSection[] = [
|
|
18
|
+
{
|
|
19
|
+
id: 'main',
|
|
20
|
+
label: 'Dashboards',
|
|
21
|
+
items: [
|
|
22
|
+
{ id: 'library', label: 'Library', href: '/app', end: true },
|
|
23
|
+
{ id: 'jobs', label: 'Jobs', href: '/app/jobs' },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
describe('Sidebar', () => {
|
|
29
|
+
it('mounts the vanilla SidebarComponent and syncs controlled state', async () => {
|
|
30
|
+
const onNavigate = vi.fn()
|
|
31
|
+
const { container, rerender } = render(
|
|
32
|
+
<Sidebar
|
|
33
|
+
className="react-host"
|
|
34
|
+
variant="cm"
|
|
35
|
+
sections={sidebarSections}
|
|
36
|
+
activeItemId="library"
|
|
37
|
+
open
|
|
38
|
+
onNavigate={onNavigate}
|
|
39
|
+
/>
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
await waitFor(() => {
|
|
43
|
+
expect(container.querySelector('.sidebar')).toBeTruthy()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const host = container.querySelector<HTMLElement>('[data-slot="sidebar"]')
|
|
47
|
+
const aside = container.querySelector<HTMLElement>('.sidebar')
|
|
48
|
+
const library = container.querySelector<HTMLElement>('[data-item-id="library"]')
|
|
49
|
+
|
|
50
|
+
expect(host?.classList.contains('react-host')).toBe(true)
|
|
51
|
+
expect(aside?.classList.contains('sidebar--product')).toBe(true)
|
|
52
|
+
expect(aside?.getAttribute('data-sidebar')).toBe('cm')
|
|
53
|
+
expect(aside?.getAttribute('data-open')).toBe('true')
|
|
54
|
+
expect(library?.getAttribute('aria-current')).toBe('page')
|
|
55
|
+
expect(container.querySelectorAll('.sidebar')).toHaveLength(1)
|
|
56
|
+
|
|
57
|
+
rerender(
|
|
58
|
+
<Sidebar
|
|
59
|
+
className="react-host"
|
|
60
|
+
variant="cm"
|
|
61
|
+
sections={sidebarSections}
|
|
62
|
+
activeItemId="jobs"
|
|
63
|
+
open={false}
|
|
64
|
+
onNavigate={onNavigate}
|
|
65
|
+
/>
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
await waitFor(() => {
|
|
69
|
+
expect(container.querySelector('[data-item-id="jobs"]')?.getAttribute('aria-current')).toBe(
|
|
70
|
+
'page'
|
|
71
|
+
)
|
|
72
|
+
expect(container.querySelector('.sidebar')?.getAttribute('data-open')).toBe('false')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
fireEvent.click(container.querySelector<HTMLElement>('[data-item-id="jobs"]')!)
|
|
76
|
+
|
|
77
|
+
expect(onNavigate).toHaveBeenCalledWith(expect.objectContaining({ id: 'jobs' }))
|
|
78
|
+
expect(container.querySelectorAll('.sidebar')).toHaveLength(1)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('keeps route-derived active state in the vanilla component when pathname changes', async () => {
|
|
82
|
+
const { container, rerender } = render(
|
|
83
|
+
<Sidebar variant="cm" sections={routeSections} pathname="/app" />
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
await waitFor(() => {
|
|
87
|
+
expect(
|
|
88
|
+
container.querySelector('[data-item-id="library"]')?.getAttribute('aria-current')
|
|
89
|
+
).toBe('page')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
rerender(<Sidebar variant="cm" sections={routeSections} pathname="/app/jobs" />)
|
|
93
|
+
|
|
94
|
+
await waitFor(() => {
|
|
95
|
+
expect(
|
|
96
|
+
container.querySelector('[data-item-id="library"]')?.getAttribute('aria-current')
|
|
97
|
+
).toBe(null)
|
|
98
|
+
expect(container.querySelector('[data-item-id="jobs"]')?.getAttribute('aria-current')).toBe(
|
|
99
|
+
'page'
|
|
100
|
+
)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
expect(container.querySelectorAll('.sidebar')).toHaveLength(1)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
5
106
|
|
|
6
107
|
describe('SidebarCollapseButton', () => {
|
|
7
108
|
it('renders the shared edge-docked collapse control', () => {
|
package/src/react/sidebar.tsx
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* import { Sidebar } from '@countermeasure-platform/web-components/react/sidebar'
|
|
6
6
|
*
|
|
7
7
|
* The wrapper renders a host div then mounts `SidebarComponent` into it on
|
|
8
|
-
* mount. Prop changes to `activeItemId` and controlled `open`
|
|
9
|
-
* the class's public API.
|
|
8
|
+
* mount. Prop changes to `activeItemId`, `pathname`, and controlled `open`
|
|
9
|
+
* are synced via the class's public API.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import * as React from 'react'
|
|
@@ -14,7 +14,11 @@ import { cn } from './primitives/utils'
|
|
|
14
14
|
import { SidebarComponent } from '../sidebar/component'
|
|
15
15
|
import type {
|
|
16
16
|
SidebarComponentConfig,
|
|
17
|
+
SidebarBadgeConfig,
|
|
18
|
+
SidebarBadgeTone,
|
|
17
19
|
SidebarNavItem,
|
|
20
|
+
SidebarScopeConfig,
|
|
21
|
+
SidebarScopeOption,
|
|
18
22
|
SidebarSection,
|
|
19
23
|
SidebarUser,
|
|
20
24
|
SidebarFooterAction,
|
|
@@ -33,7 +37,16 @@ export interface SidebarProps extends Omit<
|
|
|
33
37
|
className?: string
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
export type {
|
|
40
|
+
export type {
|
|
41
|
+
SidebarBadgeConfig,
|
|
42
|
+
SidebarBadgeTone,
|
|
43
|
+
SidebarFooterAction,
|
|
44
|
+
SidebarNavItem,
|
|
45
|
+
SidebarScopeConfig,
|
|
46
|
+
SidebarScopeOption,
|
|
47
|
+
SidebarSection,
|
|
48
|
+
SidebarUser,
|
|
49
|
+
}
|
|
37
50
|
|
|
38
51
|
export interface SidebarCollapseButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
39
52
|
collapsed?: boolean
|
|
@@ -43,7 +56,7 @@ export interface SidebarCollapseButtonProps extends React.ButtonHTMLAttributes<H
|
|
|
43
56
|
}
|
|
44
57
|
|
|
45
58
|
const Sidebar = (props: SidebarProps) => {
|
|
46
|
-
const { activeItemId, onNavigate, onCollapse, className, open, ...config } = props
|
|
59
|
+
const { activeItemId, pathname, onNavigate, onCollapse, className, open, ...config } = props
|
|
47
60
|
const containerRef = React.useRef<HTMLDivElement>(null)
|
|
48
61
|
const sidebarRef = React.useRef<SidebarComponent | null>(null)
|
|
49
62
|
const onNavigateRef = React.useRef(onNavigate)
|
|
@@ -67,6 +80,9 @@ const Sidebar = (props: SidebarProps) => {
|
|
|
67
80
|
if (open !== undefined) {
|
|
68
81
|
sidebarConfig.open = open
|
|
69
82
|
}
|
|
83
|
+
if (pathname !== undefined) {
|
|
84
|
+
sidebarConfig.pathname = pathname
|
|
85
|
+
}
|
|
70
86
|
sidebarRef.current = new SidebarComponent(sidebarConfig)
|
|
71
87
|
return () => {
|
|
72
88
|
sidebarRef.current?.destroy()
|
|
@@ -86,6 +102,12 @@ const Sidebar = (props: SidebarProps) => {
|
|
|
86
102
|
sidebarRef.current.setActive(activeItemId)
|
|
87
103
|
}, [activeItemId])
|
|
88
104
|
|
|
105
|
+
// Sync route-derived active state when no explicit active item controls it.
|
|
106
|
+
React.useEffect(() => {
|
|
107
|
+
if (!sidebarRef.current || activeItemId !== undefined) return
|
|
108
|
+
sidebarRef.current.setPathname(pathname)
|
|
109
|
+
}, [activeItemId, pathname])
|
|
110
|
+
|
|
89
111
|
return <div ref={containerRef} data-slot="sidebar" className={cn(className)} />
|
|
90
112
|
}
|
|
91
113
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest'
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
2
|
|
|
3
3
|
import { SidebarComponent } from './component'
|
|
4
4
|
|
|
@@ -10,6 +10,11 @@ function createContainer(): HTMLElement {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
describe('SidebarComponent', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
localStorage.clear()
|
|
15
|
+
window.history.replaceState({}, '', '/')
|
|
16
|
+
})
|
|
17
|
+
|
|
13
18
|
it('renders brand header, scroll body, and edge collapse control when configured', () => {
|
|
14
19
|
const container = createContainer()
|
|
15
20
|
const sidebar = new SidebarComponent({
|
|
@@ -149,4 +154,393 @@ describe('SidebarComponent', () => {
|
|
|
149
154
|
sidebar.destroy()
|
|
150
155
|
container.remove()
|
|
151
156
|
})
|
|
157
|
+
|
|
158
|
+
it('renders route links with derived active state and aria-current', () => {
|
|
159
|
+
window.history.replaceState({}, '', '/dashboards/library')
|
|
160
|
+
const container = createContainer()
|
|
161
|
+
const onNavigate = vi.fn()
|
|
162
|
+
const preventDefault = vi.fn((event: MouseEvent) => {
|
|
163
|
+
event.preventDefault()
|
|
164
|
+
})
|
|
165
|
+
const sidebar = new SidebarComponent({
|
|
166
|
+
container,
|
|
167
|
+
sections: [
|
|
168
|
+
{
|
|
169
|
+
id: 'dashboards',
|
|
170
|
+
label: 'Dashboards',
|
|
171
|
+
items: [
|
|
172
|
+
{ id: 'library', label: 'Library', href: '/dashboards/library', icon: 'library' },
|
|
173
|
+
{
|
|
174
|
+
id: 'capacity',
|
|
175
|
+
label: 'Capacity',
|
|
176
|
+
href: '/dashboards/capacity',
|
|
177
|
+
icon: 'capacity',
|
|
178
|
+
onClick: preventDefault,
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
onNavigate,
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const active = container.querySelector<HTMLAnchorElement>('[data-item-id="library"]')
|
|
187
|
+
const inactive = container.querySelector<HTMLAnchorElement>('[data-item-id="capacity"]')
|
|
188
|
+
|
|
189
|
+
expect(active?.tagName).toBe('A')
|
|
190
|
+
expect(active?.getAttribute('href')).toBe('/dashboards/library')
|
|
191
|
+
expect(active?.classList.contains('sidebar__item--active')).toBe(true)
|
|
192
|
+
expect(active?.getAttribute('aria-current')).toBe('page')
|
|
193
|
+
expect(inactive?.classList.contains('sidebar__item--active')).toBe(false)
|
|
194
|
+
|
|
195
|
+
inactive?.click()
|
|
196
|
+
expect(onNavigate).toHaveBeenCalledWith(expect.objectContaining({ id: 'capacity' }))
|
|
197
|
+
expect(preventDefault).toHaveBeenCalledWith(expect.any(MouseEvent))
|
|
198
|
+
|
|
199
|
+
sidebar.destroy()
|
|
200
|
+
container.remove()
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('derives active state from an explicit pathname when provided', () => {
|
|
204
|
+
window.history.replaceState({}, '', '/')
|
|
205
|
+
const container = createContainer()
|
|
206
|
+
const sidebar = new SidebarComponent({
|
|
207
|
+
container,
|
|
208
|
+
pathname: '/app/jobs?filter=active',
|
|
209
|
+
sections: [
|
|
210
|
+
{
|
|
211
|
+
id: 'main',
|
|
212
|
+
items: [
|
|
213
|
+
{ id: 'library', label: 'Library', href: '/app', end: true },
|
|
214
|
+
{ id: 'jobs', label: 'Jobs', href: '/app/jobs' },
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
const active = container.querySelector<HTMLElement>('[data-item-id="jobs"]')
|
|
221
|
+
const library = container.querySelector<HTMLElement>('[data-item-id="library"]')
|
|
222
|
+
|
|
223
|
+
expect(active?.classList.contains('sidebar__item--active')).toBe(true)
|
|
224
|
+
expect(active?.getAttribute('aria-current')).toBe('page')
|
|
225
|
+
expect(library?.classList.contains('sidebar__item--active')).toBe(false)
|
|
226
|
+
|
|
227
|
+
sidebar.destroy()
|
|
228
|
+
container.remove()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('recomputes route-derived active state when pathname changes', () => {
|
|
232
|
+
const container = createContainer()
|
|
233
|
+
const sidebar = new SidebarComponent({
|
|
234
|
+
container,
|
|
235
|
+
pathname: '/app',
|
|
236
|
+
sections: [
|
|
237
|
+
{
|
|
238
|
+
id: 'main',
|
|
239
|
+
items: [
|
|
240
|
+
{ id: 'library', label: 'Library', href: '/app', end: true },
|
|
241
|
+
{ id: 'jobs', label: 'Jobs', href: '/app/jobs' },
|
|
242
|
+
],
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
expect(container.querySelector('[data-item-id="library"]')?.getAttribute('aria-current')).toBe(
|
|
248
|
+
'page'
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
sidebar.setPathname('/app/jobs')
|
|
252
|
+
|
|
253
|
+
expect(container.querySelector('[data-item-id="library"]')?.getAttribute('aria-current')).toBe(
|
|
254
|
+
null
|
|
255
|
+
)
|
|
256
|
+
expect(container.querySelector('[data-item-id="jobs"]')?.getAttribute('aria-current')).toBe(
|
|
257
|
+
'page'
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
sidebar.destroy()
|
|
261
|
+
container.remove()
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('creates a badge when a count appears after initial render', () => {
|
|
265
|
+
const container = createContainer()
|
|
266
|
+
const sidebar = new SidebarComponent({
|
|
267
|
+
container,
|
|
268
|
+
sections: [
|
|
269
|
+
{
|
|
270
|
+
id: 'main',
|
|
271
|
+
items: [{ id: 'alerts', label: 'Alerts' }],
|
|
272
|
+
},
|
|
273
|
+
],
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
expect(container.querySelector('[data-badge="alerts"]')).toBeNull()
|
|
277
|
+
|
|
278
|
+
sidebar.updateBadge('alerts', 7, 'cyan')
|
|
279
|
+
|
|
280
|
+
const item = container.querySelector<HTMLElement>('[data-item-id="alerts"]')
|
|
281
|
+
expect(item?.getAttribute('data-badge-visible')).toBe('true')
|
|
282
|
+
expect(item?.getAttribute('data-badge-tone')).toBe('cyan')
|
|
283
|
+
expect(container.querySelector('[data-badge="alerts"]')?.textContent).toBe('7')
|
|
284
|
+
|
|
285
|
+
sidebar.destroy()
|
|
286
|
+
container.remove()
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('restores and persists collapsed state when a storage key is supplied', () => {
|
|
290
|
+
localStorage.setItem('cm-sidebar', 'true')
|
|
291
|
+
const container = createContainer()
|
|
292
|
+
const sidebar = new SidebarComponent({
|
|
293
|
+
container,
|
|
294
|
+
collapsible: true,
|
|
295
|
+
storageKey: 'cm-sidebar',
|
|
296
|
+
sections: [
|
|
297
|
+
{
|
|
298
|
+
id: 'main',
|
|
299
|
+
items: [{ id: 'dashboard', label: 'Dashboard' }],
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
const aside = container.querySelector<HTMLElement>('.sidebar')
|
|
305
|
+
const collapseButton = container.querySelector<HTMLButtonElement>(
|
|
306
|
+
'.sidebar__collapse-btn--edge'
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
expect(aside?.classList.contains('sidebar--collapsed')).toBe(true)
|
|
310
|
+
collapseButton?.click()
|
|
311
|
+
expect(aside?.classList.contains('sidebar--collapsed')).toBe(false)
|
|
312
|
+
expect(localStorage.getItem('cm-sidebar')).toBe('false')
|
|
313
|
+
|
|
314
|
+
sidebar.destroy()
|
|
315
|
+
container.remove()
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('controls mobile backdrop state and closes from the backdrop', () => {
|
|
319
|
+
const container = createContainer()
|
|
320
|
+
const onOpenChange = vi.fn()
|
|
321
|
+
const sidebar = new SidebarComponent({
|
|
322
|
+
container,
|
|
323
|
+
mobileBackdrop: true,
|
|
324
|
+
sections: [
|
|
325
|
+
{
|
|
326
|
+
id: 'main',
|
|
327
|
+
items: [{ id: 'dashboard', label: 'Dashboard' }],
|
|
328
|
+
},
|
|
329
|
+
],
|
|
330
|
+
onOpenChange,
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
const aside = container.querySelector<HTMLElement>('.sidebar')
|
|
334
|
+
const backdrop = container.querySelector<HTMLButtonElement>('.sidebar__backdrop')
|
|
335
|
+
|
|
336
|
+
expect(backdrop?.getAttribute('data-visible')).toBe('false')
|
|
337
|
+
sidebar.setOpen(true)
|
|
338
|
+
expect(aside?.getAttribute('data-open')).toBe('true')
|
|
339
|
+
expect(backdrop?.getAttribute('data-visible')).toBe('true')
|
|
340
|
+
expect(onOpenChange).toHaveBeenCalledWith(true)
|
|
341
|
+
|
|
342
|
+
backdrop?.click()
|
|
343
|
+
expect(aside?.getAttribute('data-open')).toBe('false')
|
|
344
|
+
expect(backdrop?.getAttribute('data-visible')).toBe('false')
|
|
345
|
+
expect(onOpenChange).toHaveBeenLastCalledWith(false)
|
|
346
|
+
|
|
347
|
+
sidebar.destroy()
|
|
348
|
+
container.remove()
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('renders and updates the scope selector', () => {
|
|
352
|
+
const container = createContainer()
|
|
353
|
+
const onChange = vi.fn()
|
|
354
|
+
const sidebar = new SidebarComponent({
|
|
355
|
+
container,
|
|
356
|
+
scope: {
|
|
357
|
+
label: 'Tenant',
|
|
358
|
+
icon: 'building',
|
|
359
|
+
value: 'all',
|
|
360
|
+
options: [
|
|
361
|
+
{ id: 'all', label: 'All Tenants', status: 'neutral' },
|
|
362
|
+
{ id: 'gold', label: 'Gold Tenant', status: 'active' },
|
|
363
|
+
],
|
|
364
|
+
onChange,
|
|
365
|
+
},
|
|
366
|
+
sections: [
|
|
367
|
+
{
|
|
368
|
+
id: 'main',
|
|
369
|
+
items: [{ id: 'dashboard', label: 'Dashboard' }],
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
const trigger = container.querySelector<HTMLButtonElement>('.sidebar__scope-trigger')
|
|
375
|
+
const menu = container.querySelector<HTMLElement>('.sidebar__scope-menu')
|
|
376
|
+
expect(trigger?.textContent).toContain('All Tenants')
|
|
377
|
+
expect(menu?.hidden).toBe(true)
|
|
378
|
+
|
|
379
|
+
trigger?.click()
|
|
380
|
+
expect(trigger?.getAttribute('aria-expanded')).toBe('true')
|
|
381
|
+
expect(menu?.hidden).toBe(false)
|
|
382
|
+
|
|
383
|
+
container.querySelector<HTMLButtonElement>('[data-option-id="gold"]')?.click()
|
|
384
|
+
expect(trigger?.textContent).toContain('Gold Tenant')
|
|
385
|
+
expect(menu?.hidden).toBe(true)
|
|
386
|
+
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ id: 'gold' }))
|
|
387
|
+
|
|
388
|
+
sidebar.destroy()
|
|
389
|
+
container.remove()
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
it('renders rich product navigation attributes, footer chrome, and active match variants', () => {
|
|
393
|
+
const container = createContainer()
|
|
394
|
+
const onUser = vi.fn()
|
|
395
|
+
const onDanger = vi.fn()
|
|
396
|
+
const sidebar = new SidebarComponent({
|
|
397
|
+
container,
|
|
398
|
+
pathname: '/app/custom/deep',
|
|
399
|
+
brand: { label: 'Core', onClick: vi.fn(), showWordmark: false, decorative: true },
|
|
400
|
+
user: {
|
|
401
|
+
name: 'Solo',
|
|
402
|
+
detail: 'Analyst',
|
|
403
|
+
avatarUrl: '/avatar.png',
|
|
404
|
+
presence: 'online',
|
|
405
|
+
label: 'Open user menu',
|
|
406
|
+
onClick: onUser,
|
|
407
|
+
},
|
|
408
|
+
footerActions: [
|
|
409
|
+
{ id: 'settings', icon: 'settings', label: 'Settings' },
|
|
410
|
+
{ id: 'delete', icon: 'trash', label: 'Delete', danger: true, onClick: onDanger },
|
|
411
|
+
],
|
|
412
|
+
sections: [
|
|
413
|
+
{
|
|
414
|
+
id: 'group',
|
|
415
|
+
label: 'Group',
|
|
416
|
+
collapsible: true,
|
|
417
|
+
defaultExpanded: false,
|
|
418
|
+
icon: 'folder',
|
|
419
|
+
items: [
|
|
420
|
+
{
|
|
421
|
+
id: 'custom',
|
|
422
|
+
label: 'Custom',
|
|
423
|
+
href: '/app/custom',
|
|
424
|
+
activeMatch: '/app/custom',
|
|
425
|
+
target: '_blank',
|
|
426
|
+
tag: 'New',
|
|
427
|
+
badge: { label: '', tone: 'warning', dot: true, title: 'Warning' },
|
|
428
|
+
ariaLabel: 'Custom route',
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
id: 'regex',
|
|
432
|
+
label: 'Regex',
|
|
433
|
+
href: '/regex',
|
|
434
|
+
activeMatch: /regex/,
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
id: 'function',
|
|
438
|
+
label: 'Function',
|
|
439
|
+
activeMatch: pathname => pathname.includes('function'),
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
id: 'disabled',
|
|
443
|
+
label: 'Disabled',
|
|
444
|
+
href: '/disabled',
|
|
445
|
+
disabled: true,
|
|
446
|
+
},
|
|
447
|
+
],
|
|
448
|
+
},
|
|
449
|
+
],
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
const groupHeader = container.querySelector<HTMLButtonElement>('.sidebar__group-header')
|
|
453
|
+
const custom = container.querySelector<HTMLAnchorElement>('[data-item-id="custom"]')
|
|
454
|
+
const disabled = container.querySelector<HTMLElement>('[data-item-id="disabled"]')
|
|
455
|
+
const user = container.querySelector<HTMLButtonElement>('.sidebar__user-trigger')
|
|
456
|
+
const danger = container.querySelector<HTMLButtonElement>(
|
|
457
|
+
'.sidebar__footer-icon[aria-label="Delete"]'
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
expect(groupHeader?.getAttribute('aria-expanded')).toBe('false')
|
|
461
|
+
groupHeader?.click()
|
|
462
|
+
expect(groupHeader?.getAttribute('aria-expanded')).toBe('true')
|
|
463
|
+
expect(custom?.getAttribute('aria-current')).toBe('page')
|
|
464
|
+
expect(custom?.target).toBe('_blank')
|
|
465
|
+
expect(custom?.rel).toBe('noreferrer noopener')
|
|
466
|
+
expect(custom?.getAttribute('aria-label')).toBe('Custom route')
|
|
467
|
+
expect(custom?.querySelector('.sidebar__tag')?.textContent).toBe('New')
|
|
468
|
+
expect(custom?.getAttribute('data-badge-visible')).toBe('true')
|
|
469
|
+
expect(custom?.getAttribute('data-badge-tone')).toBe('warning')
|
|
470
|
+
expect(disabled?.tagName).toBe('SPAN')
|
|
471
|
+
expect(disabled?.getAttribute('aria-disabled')).toBe('true')
|
|
472
|
+
expect(disabled?.getAttribute('tabindex')).toBe('-1')
|
|
473
|
+
|
|
474
|
+
sidebar.setPathname('/regex')
|
|
475
|
+
expect(container.querySelector('[data-item-id="regex"]')?.getAttribute('aria-current')).toBe(
|
|
476
|
+
'page'
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
sidebar.setPathname('/function')
|
|
480
|
+
expect(container.querySelector('[data-item-id="function"]')?.getAttribute('aria-current')).toBe(
|
|
481
|
+
'page'
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
expect(user?.getAttribute('aria-label')).toBe('Open user menu')
|
|
485
|
+
expect(container.querySelector('.sidebar__avatar img')?.getAttribute('src')).toBe('/avatar.png')
|
|
486
|
+
expect(container.querySelector('.sidebar__presence--online')).toBeTruthy()
|
|
487
|
+
user?.click()
|
|
488
|
+
expect(onUser).toHaveBeenCalled()
|
|
489
|
+
danger?.click()
|
|
490
|
+
expect(onDanger).toHaveBeenCalled()
|
|
491
|
+
expect(danger?.classList.contains('sidebar__footer-icon--danger')).toBe(true)
|
|
492
|
+
|
|
493
|
+
sidebar.destroy()
|
|
494
|
+
container.remove()
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
it('keeps scope menus and mobile drawers in sync with outside events', () => {
|
|
498
|
+
const container = createContainer()
|
|
499
|
+
const sidebar = new SidebarComponent({
|
|
500
|
+
container,
|
|
501
|
+
open: true,
|
|
502
|
+
mobileBackdrop: true,
|
|
503
|
+
mobileBreakpoint: 10_000,
|
|
504
|
+
closeOnNavigate: true,
|
|
505
|
+
scope: {
|
|
506
|
+
placeholder: 'Pick a tenant',
|
|
507
|
+
options: [
|
|
508
|
+
{ id: 'all', label: 'All Tenants' },
|
|
509
|
+
{ id: 'disabled', label: 'Disabled Tenant', disabled: true },
|
|
510
|
+
],
|
|
511
|
+
},
|
|
512
|
+
sections: [
|
|
513
|
+
{
|
|
514
|
+
id: 'main',
|
|
515
|
+
items: [{ id: 'dashboard', label: 'Dashboard', href: '/dashboard' }],
|
|
516
|
+
},
|
|
517
|
+
],
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
const aside = container.querySelector<HTMLElement>('.sidebar')
|
|
521
|
+
const trigger = container.querySelector<HTMLButtonElement>('.sidebar__scope-trigger')
|
|
522
|
+
const menu = container.querySelector<HTMLElement>('.sidebar__scope-menu')
|
|
523
|
+
|
|
524
|
+
expect(trigger?.textContent).toContain('All Tenants')
|
|
525
|
+
expect(
|
|
526
|
+
container.querySelector<HTMLButtonElement>('[data-option-id="disabled"]')?.disabled
|
|
527
|
+
).toBe(true)
|
|
528
|
+
|
|
529
|
+
trigger?.click()
|
|
530
|
+
expect(menu?.hidden).toBe(false)
|
|
531
|
+
document.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
|
|
532
|
+
expect(menu?.hidden).toBe(true)
|
|
533
|
+
|
|
534
|
+
aside
|
|
535
|
+
?.querySelector<HTMLAnchorElement>('[data-item-id="dashboard"]')
|
|
536
|
+
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
537
|
+
expect(aside?.getAttribute('data-open')).toBe('false')
|
|
538
|
+
|
|
539
|
+
sidebar.setOpen(true)
|
|
540
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
|
541
|
+
expect(aside?.getAttribute('data-open')).toBe('false')
|
|
542
|
+
|
|
543
|
+
sidebar.destroy()
|
|
544
|
+
container.remove()
|
|
545
|
+
})
|
|
152
546
|
})
|