@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
package/src/index.ts CHANGED
@@ -44,7 +44,18 @@ export {
44
44
  setupNavKeyboard,
45
45
  initLayout,
46
46
  AppShell,
47
+ TopMenuBar,
47
48
  createAppShell,
49
+ createCoreAppChromePreset,
50
+ createCoreAppSidebarConfig,
51
+ createCoreAppSidebarSections,
52
+ createCoreAppTopbarActions,
53
+ createCoreAppTopbarConfig,
54
+ createProductShell,
55
+ createTopMenuBar,
56
+ getCoreAppRouteLabel,
57
+ mountCoreAppChrome,
58
+ mountCoreAppChromeFromDom,
48
59
  PageHeader,
49
60
  createPageHeader,
50
61
  } from './layout/index'
@@ -0,0 +1,204 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ import { AppShell, TopMenuBar, createProductShell, createTopMenuBar } from './app-shell'
4
+
5
+ describe('TopMenuBar', () => {
6
+ it('renders product breadcrumb, icon actions, and user menu actions', () => {
7
+ const onSettings = vi.fn()
8
+ const topbar = new TopMenuBar({
9
+ product: 'Threat Library',
10
+ current: 'Library Dashboard',
11
+ actions: [{ id: 'notifications', label: 'Notifications', icon: 'bell', badge: true }],
12
+ user: {
13
+ name: 'Wyatt Roersma',
14
+ detail: 'Threat Library',
15
+ initials: 'WR',
16
+ actions: [{ id: 'settings', label: 'Settings', icon: 'settings', onClick: onSettings }],
17
+ },
18
+ })
19
+
20
+ expect(topbar.el.classList.contains('cmm-topbar')).toBe(true)
21
+ expect(topbar.el.querySelector('.cmm-topbar__product')?.textContent).toBe('Threat Library')
22
+ expect(topbar.el.querySelector('.cmm-topbar__current')?.textContent).toBe('Library Dashboard')
23
+ expect(topbar.el.querySelector('[data-action-id="notifications"]')).toBeTruthy()
24
+ expect(topbar.el.querySelector('.cmm-topbar__avatar')?.textContent).toBe('WR')
25
+
26
+ const trigger = topbar.el.querySelector<HTMLButtonElement>('.cmm-topbar__user-trigger')
27
+ const menu = topbar.el.querySelector<HTMLElement>('.cmm-topbar__user-menu')
28
+ expect(menu?.hidden).toBe(true)
29
+
30
+ trigger?.click()
31
+ expect(trigger?.getAttribute('aria-expanded')).toBe('true')
32
+ expect(menu?.hidden).toBe(false)
33
+
34
+ topbar.el.querySelector<HTMLButtonElement>('[data-action-id="settings"]')?.click()
35
+ expect(onSettings).toHaveBeenCalled()
36
+
37
+ topbar.destroy()
38
+ expect(document.body.contains(topbar.el)).toBe(false)
39
+ })
40
+
41
+ it('supports native custom action nodes and data-hook attributes', () => {
42
+ const notificationWrapper = document.createElement('div')
43
+ notificationWrapper.setAttribute('data-notification-wrapper', '')
44
+ notificationWrapper.textContent = 'Notifications'
45
+
46
+ const topbar = new TopMenuBar({
47
+ product: 'CounterMeasure',
48
+ current: 'Dashboard',
49
+ actions: [
50
+ {
51
+ id: 'feedback',
52
+ label: 'Feedback',
53
+ showLabel: true,
54
+ attributes: {
55
+ 'data-feedback-toggle': true,
56
+ 'aria-controls': 'feedback-panel',
57
+ },
58
+ },
59
+ notificationWrapper,
60
+ {
61
+ id: 'help',
62
+ label: 'Help',
63
+ icon: 'circle-help',
64
+ attributes: { 'data-help-toggle': true },
65
+ },
66
+ ],
67
+ })
68
+
69
+ const feedback = topbar.el.querySelector<HTMLElement>('[data-action-id="feedback"]')
70
+ expect(feedback?.getAttribute('data-feedback-toggle')).toBe('')
71
+ expect(feedback?.getAttribute('aria-controls')).toBe('feedback-panel')
72
+ expect(feedback?.querySelector('.cmm-topbar__action-label')?.textContent).toBe('Feedback')
73
+ expect(topbar.el.querySelector('[data-notification-wrapper]')).toBe(notificationWrapper)
74
+ expect(topbar.el.querySelector('[data-help-toggle]')).toBeTruthy()
75
+ })
76
+
77
+ it('renders breadcrumbs, link actions, numeric badges, and outside-menu dismissal', () => {
78
+ const container = document.createElement('div')
79
+ const beforeAction = document.createElement('span')
80
+ const afterAction = document.createElement('span')
81
+ beforeAction.setAttribute('data-before-action', '')
82
+ afterAction.setAttribute('data-after-action', '')
83
+ document.body.appendChild(container)
84
+
85
+ const topbar = new TopMenuBar({
86
+ container,
87
+ breadcrumbs: [{ label: 'Threat Library', href: '/threat-library' }, { label: 'Dashboards' }],
88
+ current: 'Library',
89
+ beforeActions: beforeAction,
90
+ afterActions: [afterAction],
91
+ actions: [
92
+ {
93
+ id: 'docs',
94
+ label: 'Docs',
95
+ href: '/docs',
96
+ target: '_blank',
97
+ icon: 'book-open',
98
+ badge: 2,
99
+ className: 'extra-action',
100
+ attributes: {
101
+ 'data-docs-link': true,
102
+ 'data-nullish': null,
103
+ 'data-false': false,
104
+ },
105
+ },
106
+ ],
107
+ user: {
108
+ name: 'Solo',
109
+ avatarUrl: '/avatar.png',
110
+ menuLabel: 'Account menu',
111
+ },
112
+ })
113
+
114
+ const product = topbar.el.querySelector<HTMLElement>('.cmm-topbar__product')
115
+ const docs = topbar.el.querySelector<HTMLAnchorElement>('[data-action-id="docs"]')
116
+ const trigger = topbar.el.querySelector<HTMLButtonElement>('.cmm-topbar__user-trigger')
117
+ const menu = topbar.el.querySelector<HTMLElement>('.cmm-topbar__user-menu')
118
+
119
+ expect(container.querySelector('.cmm-topbar')).toBe(topbar.el)
120
+ expect(product?.textContent).toBe('Threat Library')
121
+ expect(topbar.el.querySelector('.cmm-topbar__breadcrumb')?.textContent).toBe('Dashboards')
122
+ expect(docs?.tagName).toBe('A')
123
+ expect(docs?.target).toBe('_blank')
124
+ expect(docs?.rel).toBe('noreferrer noopener')
125
+ expect(docs?.classList.contains('extra-action')).toBe(true)
126
+ expect(docs?.getAttribute('data-docs-link')).toBe('')
127
+ expect(docs?.hasAttribute('data-nullish')).toBe(false)
128
+ expect(docs?.hasAttribute('data-false')).toBe(false)
129
+ expect(docs?.querySelector('.cmm-topbar__action-badge')?.textContent).toBe('2')
130
+ expect(topbar.el.querySelector('[data-before-action]')).toBe(beforeAction)
131
+ expect(topbar.el.querySelector('[data-after-action]')).toBe(afterAction)
132
+ expect(topbar.el.querySelector('.cmm-topbar__avatar img')?.getAttribute('src')).toBe(
133
+ '/avatar.png'
134
+ )
135
+ expect(trigger?.getAttribute('aria-label')).toBe('Account menu')
136
+
137
+ trigger?.click()
138
+ expect(menu?.hidden).toBe(false)
139
+
140
+ document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
141
+ expect(menu?.hidden).toBe(true)
142
+
143
+ trigger?.click()
144
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
145
+ expect(menu?.hidden).toBe(true)
146
+
147
+ topbar.destroy()
148
+ container.remove()
149
+ })
150
+
151
+ it('composes a product app shell with topbar and content', () => {
152
+ const sidebar = document.createElement('aside')
153
+ const content = document.createElement('section')
154
+ content.textContent = 'Dashboard content'
155
+
156
+ const shell = new AppShell({
157
+ sidebar,
158
+ topbar: {
159
+ product: 'Threat Library',
160
+ current: 'Library Dashboard',
161
+ },
162
+ content,
163
+ variant: 'product',
164
+ sidebarCollapsed: false,
165
+ })
166
+
167
+ expect(shell.el.classList.contains('cmm-app-shell--product')).toBe(true)
168
+ expect(shell.el.getAttribute('data-sidebar-collapsed')).toBe('false')
169
+ expect(shell.el.querySelector('.cmm-app-shell__sidebar')?.contains(sidebar)).toBe(true)
170
+ expect(shell.el.querySelector('.cmm-topbar__current')?.textContent).toBe('Library Dashboard')
171
+ expect(shell.contentEl.contains(content)).toBe(true)
172
+ })
173
+
174
+ it('provides factory helpers for app shell chrome', () => {
175
+ expect(createTopMenuBar({ current: 'Capacity' }).classList.contains('cmm-topbar')).toBe(true)
176
+ expect(createProductShell().classList.contains('cmm-app-shell--product')).toBe(true)
177
+ })
178
+
179
+ it('composes default shell slots, metrics, and prebuilt topbar elements', () => {
180
+ const container = document.createElement('div')
181
+ const topbar = document.createElement('nav')
182
+ const metrics = document.createElement('section')
183
+ document.body.appendChild(container)
184
+
185
+ const shell = new AppShell({
186
+ container,
187
+ topbar,
188
+ metrics,
189
+ className: 'custom-shell',
190
+ })
191
+
192
+ expect(container.querySelector('.cmm-app-shell')).toBe(shell.el)
193
+ expect(shell.el.classList.contains('custom-shell')).toBe(true)
194
+ expect(shell.el.classList.contains('cmm-app-shell--product')).toBe(false)
195
+ expect(shell.mainEl.contains(topbar)).toBe(true)
196
+ expect(shell.el.querySelector('.cmm-app-shell__metric-strip')?.contains(metrics)).toBe(true)
197
+
198
+ const headerOnly = document.createElement('header')
199
+ const shellWithHeader = new AppShell({ header: headerOnly })
200
+ expect(shellWithHeader.mainEl.contains(headerOnly)).toBe(true)
201
+
202
+ container.remove()
203
+ })
204
+ })
@@ -1,18 +1,354 @@
1
1
  /**
2
- * AppShell - Lightweight layout composition for app chrome.
2
+ * AppShell - Layout composition for app chrome.
3
3
  */
4
4
 
5
+ import { getIconSvgInner, type IconSet } from '../icons/index'
5
6
  import { resolveContainer } from '../primitives/utils'
7
+ import { setSafeIconHtml } from '../utils/sanitize'
8
+
9
+ export interface TopMenuBarBreadcrumb {
10
+ label: string
11
+ href?: string
12
+ }
13
+
14
+ export interface TopMenuBarAction {
15
+ id: string
16
+ label: string
17
+ icon?: string
18
+ href?: string
19
+ target?: string
20
+ badge?: boolean | string | number
21
+ className?: string
22
+ showLabel?: boolean
23
+ attributes?: Record<string, string | number | boolean | null | undefined>
24
+ onClick?: () => void
25
+ }
26
+
27
+ export type TopMenuBarActionInput = TopMenuBarAction | HTMLElement
28
+
29
+ export interface TopMenuBarUser {
30
+ name: string
31
+ detail?: string
32
+ initials?: string
33
+ avatarUrl?: string
34
+ menuLabel?: string
35
+ actions?: TopMenuBarAction[]
36
+ }
37
+
38
+ export interface TopMenuBarConfig {
39
+ container?: HTMLElement | string
40
+ product?: string
41
+ current?: string
42
+ breadcrumbs?: TopMenuBarBreadcrumb[]
43
+ beforeActions?: HTMLElement | HTMLElement[]
44
+ actions?: TopMenuBarActionInput[]
45
+ afterActions?: HTMLElement | HTMLElement[]
46
+ user?: TopMenuBarUser
47
+ iconSet?: IconSet
48
+ className?: string
49
+ }
6
50
 
7
51
  export interface AppShellConfig {
8
52
  container?: HTMLElement | string
9
53
  sidebar?: HTMLElement
10
54
  header?: HTMLElement
55
+ topbar?: HTMLElement | TopMenuBarConfig
11
56
  content?: HTMLElement
12
57
  metrics?: HTMLElement
58
+ variant?: 'default' | 'product'
59
+ sidebarCollapsed?: boolean
13
60
  className?: string
14
61
  }
15
62
 
63
+ function createIconSvg(iconSet: IconSet, name: string): SVGElement {
64
+ const svgNs = 'http://www.w3.org/2000/svg'
65
+ const svg = document.createElementNS(svgNs, 'svg')
66
+ svg.setAttribute('xmlns', svgNs)
67
+ svg.setAttribute('width', '16')
68
+ svg.setAttribute('height', '16')
69
+ svg.setAttribute('viewBox', '0 0 24 24')
70
+ svg.setAttribute('fill', 'none')
71
+ svg.setAttribute('stroke', 'currentColor')
72
+ svg.setAttribute('stroke-width', '2')
73
+ svg.setAttribute('stroke-linecap', 'round')
74
+ svg.setAttribute('stroke-linejoin', 'round')
75
+ svg.setAttribute('aria-hidden', 'true')
76
+
77
+ const inner = getIconSvgInner(iconSet, name)
78
+ if (inner) {
79
+ setSafeIconHtml(svg, inner)
80
+ }
81
+
82
+ return svg
83
+ }
84
+
85
+ function toInitials(name: string): string {
86
+ const parts = name.trim().split(/\s+/).filter(Boolean)
87
+
88
+ if (parts.length >= 2) {
89
+ return `${parts[0]?.charAt(0) ?? ''}${parts[parts.length - 1]?.charAt(0) ?? ''}`.toUpperCase()
90
+ }
91
+
92
+ return name.slice(0, 2).toUpperCase()
93
+ }
94
+
95
+ function appendElements(
96
+ parent: HTMLElement,
97
+ elementOrElements: HTMLElement | HTMLElement[] | undefined
98
+ ): void {
99
+ if (elementOrElements === undefined) {
100
+ return
101
+ }
102
+
103
+ const elements = Array.isArray(elementOrElements) ? elementOrElements : [elementOrElements]
104
+ for (const element of elements) {
105
+ parent.appendChild(element)
106
+ }
107
+ }
108
+
109
+ function applyAttributes(
110
+ element: HTMLElement,
111
+ attributes: TopMenuBarAction['attributes'] | undefined
112
+ ): void {
113
+ if (attributes === undefined) {
114
+ return
115
+ }
116
+
117
+ for (const [name, value] of Object.entries(attributes)) {
118
+ if (value === undefined || value === null || value === false) {
119
+ continue
120
+ }
121
+
122
+ element.setAttribute(name, value === true ? '' : String(value))
123
+ }
124
+ }
125
+
126
+ function createActionElement(action: TopMenuBarActionInput, iconSet: IconSet): HTMLElement {
127
+ if (action instanceof HTMLElement) {
128
+ return action
129
+ }
130
+
131
+ const element =
132
+ action.href !== undefined ? document.createElement('a') : document.createElement('button')
133
+
134
+ element.classList.add('cmm-topbar__action')
135
+ if (action.className !== undefined) {
136
+ element.classList.add(...action.className.split(/\s+/).filter(Boolean))
137
+ }
138
+ element.setAttribute('aria-label', action.label)
139
+ element.setAttribute('title', action.label)
140
+ element.setAttribute('data-action-id', action.id)
141
+ applyAttributes(element, action.attributes)
142
+
143
+ if (element instanceof HTMLAnchorElement) {
144
+ element.href = action.href ?? '#'
145
+ if (action.target !== undefined) {
146
+ element.target = action.target
147
+ }
148
+ if (action.target === '_blank') {
149
+ element.rel = 'noreferrer noopener'
150
+ }
151
+ } else if (element instanceof HTMLButtonElement) {
152
+ element.type = 'button'
153
+ }
154
+
155
+ if (action.onClick !== undefined) {
156
+ element.addEventListener('click', action.onClick)
157
+ }
158
+
159
+ if (action.icon !== undefined) {
160
+ element.appendChild(createIconSvg(iconSet, action.icon))
161
+ }
162
+
163
+ if (action.showLabel === true || action.icon === undefined) {
164
+ const label = document.createElement('span')
165
+ label.classList.add('cmm-topbar__action-label')
166
+ label.textContent = action.label
167
+ element.appendChild(label)
168
+ }
169
+
170
+ if (action.badge !== undefined && action.badge !== false) {
171
+ const badge = document.createElement('span')
172
+ badge.classList.add('cmm-topbar__action-badge')
173
+ badge.textContent = action.badge === true ? '' : String(action.badge)
174
+ element.appendChild(badge)
175
+ }
176
+
177
+ return element
178
+ }
179
+
180
+ export class TopMenuBar {
181
+ public readonly el: HTMLElement
182
+ private readonly cleanupFns: (() => void)[] = []
183
+
184
+ constructor(config: TopMenuBarConfig = {}) {
185
+ const iconSet = config.iconSet ?? 'lucide'
186
+ this.el = document.createElement('header')
187
+ this.el.className = ['cmm-topbar', config.className].filter(Boolean).join(' ')
188
+
189
+ const crumbWrap = document.createElement('div')
190
+ crumbWrap.classList.add('cmm-topbar__breadcrumbs')
191
+
192
+ const product = config.product ?? config.breadcrumbs?.[0]?.label
193
+ if (product !== undefined) {
194
+ const productEl = document.createElement('span')
195
+ productEl.classList.add('cmm-topbar__product')
196
+ productEl.textContent = product
197
+ crumbWrap.appendChild(productEl)
198
+ }
199
+
200
+ const crumbs = config.breadcrumbs ?? []
201
+ const crumbStart = config.product === undefined && product !== undefined ? 1 : 0
202
+ for (const crumb of crumbs.slice(crumbStart)) {
203
+ const separator = document.createElement('span')
204
+ separator.classList.add('cmm-topbar__separator')
205
+ separator.setAttribute('aria-hidden', 'true')
206
+ separator.textContent = '•'
207
+ crumbWrap.appendChild(separator)
208
+
209
+ const crumbEl =
210
+ crumb.href !== undefined ? document.createElement('a') : document.createElement('span')
211
+ crumbEl.classList.add('cmm-topbar__breadcrumb')
212
+ crumbEl.textContent = crumb.label
213
+ if (crumbEl instanceof HTMLAnchorElement) {
214
+ crumbEl.href = crumb.href ?? '#'
215
+ }
216
+ crumbWrap.appendChild(crumbEl)
217
+ }
218
+
219
+ if (config.current !== undefined) {
220
+ const separator = document.createElement('span')
221
+ separator.classList.add('cmm-topbar__separator')
222
+ separator.setAttribute('aria-hidden', 'true')
223
+ separator.textContent = '•'
224
+ crumbWrap.appendChild(separator)
225
+
226
+ const current = document.createElement('span')
227
+ current.classList.add('cmm-topbar__current')
228
+ current.textContent = config.current
229
+ crumbWrap.appendChild(current)
230
+ }
231
+
232
+ const actions = document.createElement('div')
233
+ actions.classList.add('cmm-topbar__actions')
234
+
235
+ appendElements(actions, config.beforeActions)
236
+
237
+ for (const action of config.actions ?? []) {
238
+ actions.appendChild(createActionElement(action, iconSet))
239
+ }
240
+
241
+ if (config.user !== undefined) {
242
+ actions.appendChild(this.createUserMenu(config.user, iconSet))
243
+ }
244
+
245
+ appendElements(actions, config.afterActions)
246
+
247
+ this.el.appendChild(crumbWrap)
248
+ this.el.appendChild(actions)
249
+
250
+ if (config.container !== undefined) {
251
+ resolveContainer(config.container).appendChild(this.el)
252
+ }
253
+ }
254
+
255
+ destroy(): void {
256
+ for (const cleanup of this.cleanupFns.splice(0)) {
257
+ cleanup()
258
+ }
259
+ this.el.remove()
260
+ }
261
+
262
+ private createUserMenu(user: TopMenuBarUser, iconSet: IconSet): HTMLElement {
263
+ const root = document.createElement('div')
264
+ root.classList.add('cmm-topbar__user')
265
+
266
+ const trigger = document.createElement('button')
267
+ trigger.type = 'button'
268
+ trigger.classList.add('cmm-topbar__user-trigger')
269
+ trigger.setAttribute('aria-haspopup', 'menu')
270
+ trigger.setAttribute('aria-expanded', 'false')
271
+ trigger.setAttribute('aria-label', user.menuLabel ?? `User menu for ${user.name}`)
272
+
273
+ const avatar = document.createElement('span')
274
+ avatar.classList.add('cmm-topbar__avatar')
275
+ if (user.avatarUrl !== undefined) {
276
+ const image = document.createElement('img')
277
+ image.src = user.avatarUrl
278
+ image.alt = ''
279
+ avatar.appendChild(image)
280
+ } else {
281
+ avatar.textContent = user.initials ?? toInitials(user.name)
282
+ }
283
+ trigger.appendChild(avatar)
284
+ trigger.appendChild(createIconSvg(iconSet, 'chevron-down'))
285
+
286
+ const menu = document.createElement('div')
287
+ menu.classList.add('cmm-topbar__user-menu')
288
+ menu.setAttribute('role', 'menu')
289
+ menu.hidden = true
290
+
291
+ const profile = document.createElement('div')
292
+ profile.classList.add('cmm-topbar__user-profile')
293
+ const name = document.createElement('span')
294
+ name.classList.add('cmm-topbar__user-name')
295
+ name.textContent = user.name
296
+ profile.appendChild(name)
297
+ if (user.detail !== undefined) {
298
+ const detail = document.createElement('span')
299
+ detail.classList.add('cmm-topbar__user-detail')
300
+ detail.textContent = user.detail
301
+ profile.appendChild(detail)
302
+ }
303
+ menu.appendChild(profile)
304
+
305
+ for (const action of user.actions ?? []) {
306
+ const item = createActionElement(action, iconSet)
307
+ item.classList.remove('cmm-topbar__action')
308
+ item.classList.add('cmm-topbar__menu-item')
309
+ item.setAttribute('role', 'menuitem')
310
+ if (item.querySelector('.cmm-topbar__action-label') === null) {
311
+ const label = document.createElement('span')
312
+ label.classList.add('cmm-topbar__action-label')
313
+ label.textContent = action.label
314
+ item.appendChild(label)
315
+ }
316
+ menu.appendChild(item)
317
+ }
318
+
319
+ const setOpen = (open: boolean): void => {
320
+ menu.hidden = !open
321
+ trigger.setAttribute('aria-expanded', String(open))
322
+ }
323
+
324
+ trigger.addEventListener('click', () => {
325
+ setOpen(menu.hidden === true)
326
+ })
327
+
328
+ const closeOnOutsidePointer = (event: PointerEvent): void => {
329
+ if (!root.contains(event.target as Node)) {
330
+ setOpen(false)
331
+ }
332
+ }
333
+ const closeOnEscape = (event: KeyboardEvent): void => {
334
+ if (event.key === 'Escape') {
335
+ setOpen(false)
336
+ }
337
+ }
338
+
339
+ document.addEventListener('pointerdown', closeOnOutsidePointer)
340
+ document.addEventListener('keydown', closeOnEscape)
341
+ this.cleanupFns.push(() => {
342
+ document.removeEventListener('pointerdown', closeOnOutsidePointer)
343
+ document.removeEventListener('keydown', closeOnEscape)
344
+ })
345
+
346
+ root.appendChild(trigger)
347
+ root.appendChild(menu)
348
+ return root
349
+ }
350
+ }
351
+
16
352
  export class AppShell {
17
353
  public readonly el: HTMLElement
18
354
  public readonly mainEl: HTMLElement
@@ -20,11 +356,22 @@ export class AppShell {
20
356
 
21
357
  constructor(config: AppShellConfig = {}) {
22
358
  this.el = document.createElement('div')
23
- this.el.className = ['cmm-app-shell', config.className].filter(Boolean).join(' ')
359
+ this.el.className = [
360
+ 'cmm-app-shell',
361
+ config.variant === 'product' && 'cmm-app-shell--product',
362
+ config.className,
363
+ ]
364
+ .filter(Boolean)
365
+ .join(' ')
366
+ this.el.setAttribute('data-shell-layout', '')
367
+ if (config.sidebarCollapsed !== undefined) {
368
+ this.el.setAttribute('data-sidebar-collapsed', String(config.sidebarCollapsed))
369
+ }
24
370
 
25
371
  if (config.sidebar !== undefined) {
26
372
  const sidebarSlot = document.createElement('aside')
27
373
  sidebarSlot.className = 'cmm-app-shell__sidebar'
374
+ sidebarSlot.setAttribute('data-shell-sidebar', '')
28
375
  sidebarSlot.appendChild(config.sidebar)
29
376
  this.el.appendChild(sidebarSlot)
30
377
  }
@@ -32,7 +379,11 @@ export class AppShell {
32
379
  this.mainEl = document.createElement('div')
33
380
  this.mainEl.className = 'cmm-app-shell__main'
34
381
 
35
- if (config.header !== undefined) {
382
+ if (config.topbar !== undefined) {
383
+ const topbar =
384
+ config.topbar instanceof HTMLElement ? config.topbar : new TopMenuBar(config.topbar).el
385
+ this.mainEl.appendChild(topbar)
386
+ } else if (config.header !== undefined) {
36
387
  this.mainEl.appendChild(config.header)
37
388
  }
38
389
 
@@ -62,3 +413,11 @@ export class AppShell {
62
413
  export function createAppShell(config: AppShellConfig = {}): HTMLElement {
63
414
  return new AppShell(config).el
64
415
  }
416
+
417
+ export function createTopMenuBar(config: TopMenuBarConfig = {}): HTMLElement {
418
+ return new TopMenuBar(config).el
419
+ }
420
+
421
+ export function createProductShell(config: AppShellConfig = {}): HTMLElement {
422
+ return new AppShell({ ...config, variant: 'product' }).el
423
+ }