@countermeasure-platform/web-components 1.3.3-dev.33.1 → 1.3.4-dev.35.1
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
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
|
+
})
|
package/src/layout/app-shell.ts
CHANGED
|
@@ -1,18 +1,354 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* AppShell -
|
|
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 = [
|
|
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.
|
|
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
|
+
}
|