@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.
Files changed (64) hide show
  1. package/README.md +31 -0
  2. package/dist/component-D5sRm1fq.js +389 -0
  3. package/dist/component-D5sRm1fq.js.map +1 -0
  4. package/dist/components/index.js +27 -27
  5. package/dist/icons/index.d.ts +12 -0
  6. package/dist/icons/index.d.ts.map +1 -1
  7. package/dist/icons/index.js +12 -0
  8. package/dist/icons/index.js.map +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +127 -126
  12. package/dist/layout/app-shell.d.ts +50 -3
  13. package/dist/layout/app-shell.d.ts.map +1 -1
  14. package/dist/layout/app-shell.js +142 -13
  15. package/dist/layout/app-shell.js.map +1 -1
  16. package/dist/layout/core-app-chrome.d.ts +81 -0
  17. package/dist/layout/core-app-chrome.d.ts.map +1 -0
  18. package/dist/layout/core-app-chrome.js +349 -0
  19. package/dist/layout/core-app-chrome.js.map +1 -0
  20. package/dist/layout/index.d.ts +4 -2
  21. package/dist/layout/index.d.ts.map +1 -1
  22. package/dist/layout/index.js +88 -87
  23. package/dist/layout/index.js.map +1 -1
  24. package/dist/react/primitives/badge.d.ts +1 -1
  25. package/dist/react/primitives/button.d.ts +2 -2
  26. package/dist/react/primitives/copy-button.d.ts +1 -1
  27. package/dist/react/primitives/stat-card.d.ts +1 -1
  28. package/dist/react/primitives/toggle.d.ts +1 -1
  29. package/dist/react/sidebar.d.ts +4 -4
  30. package/dist/react/sidebar.d.ts.map +1 -1
  31. package/dist/react/sidebar.js +18 -16
  32. package/dist/react/sidebar.js.map +1 -1
  33. package/dist/sidebar/component.d.ts +24 -2
  34. package/dist/sidebar/component.d.ts.map +1 -1
  35. package/dist/sidebar/enhance.d.ts +8 -0
  36. package/dist/sidebar/enhance.d.ts.map +1 -1
  37. package/dist/sidebar/index.d.ts +3 -0
  38. package/dist/sidebar/index.d.ts.map +1 -1
  39. package/dist/sidebar/index.js +81 -28
  40. package/dist/sidebar/index.js.map +1 -1
  41. package/dist/sidebar/types.d.ts +126 -4
  42. package/dist/sidebar/types.d.ts.map +1 -1
  43. package/dist/styles/layout.css +252 -0
  44. package/dist/styles/sidebar.css +313 -5
  45. package/package.json +6 -1
  46. package/src/icons/icons.test.ts +9 -0
  47. package/src/icons/index.ts +12 -0
  48. package/src/index.ts +11 -0
  49. package/src/layout/app-shell.test.ts +204 -0
  50. package/src/layout/app-shell.ts +362 -3
  51. package/src/layout/core-app-chrome.test.ts +507 -0
  52. package/src/layout/core-app-chrome.ts +662 -0
  53. package/src/layout/index.ts +36 -2
  54. package/src/react/sidebar.test.tsx +104 -3
  55. package/src/react/sidebar.tsx +26 -4
  56. package/src/sidebar/component.test.ts +395 -1
  57. package/src/sidebar/component.ts +661 -86
  58. package/src/sidebar/enhance.ts +118 -0
  59. package/src/sidebar/index.ts +144 -0
  60. package/src/sidebar/types.ts +143 -4
  61. package/src/styles/layout.css +252 -0
  62. package/src/styles/sidebar.css +313 -5
  63. package/dist/component-Bxhxf21c.js +0 -167
  64. 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', () => {
@@ -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` are synced via
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 { SidebarNavItem, SidebarSection, SidebarUser, SidebarFooterAction }
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
  })