@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.
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
@@ -24,6 +24,12 @@ export interface EnhanceOptions {
24
24
  skeletonTimeoutMs?: number
25
25
  /** CSS class for collapsed state (default: 'sidebar--collapsed') */
26
26
  collapsedClass?: string
27
+ /** Variants that should opt into product sidebar visuals. */
28
+ productVariants?: string[]
29
+ /** Derive the active nav item from window.location on enhancement. */
30
+ activeFromLocation?: boolean
31
+ /** Close an open mobile sidebar after clicking a route link. */
32
+ closeOnNavigate?: boolean
27
33
  }
28
34
 
29
35
  const DEFAULT_OPTIONS: Required<EnhanceOptions> = {
@@ -31,10 +37,36 @@ const DEFAULT_OPTIONS: Required<EnhanceOptions> = {
31
37
  mobileBreakpoint: 900,
32
38
  skeletonTimeoutMs: 3000,
33
39
  collapsedClass: 'sidebar--collapsed',
40
+ productVariants: ['app', 'cm', 'threat-library'],
41
+ activeFromLocation: true,
42
+ closeOnNavigate: true,
34
43
  }
35
44
 
36
45
  const ENHANCED_ATTR = 'data-sidebar-enhanced'
37
46
 
47
+ function normalizePathname(pathname: string): string {
48
+ if (pathname === '/') {
49
+ return pathname
50
+ }
51
+
52
+ return pathname.replace(/\/+$/, '')
53
+ }
54
+
55
+ function routeMatches(pathname: string, href: string): boolean {
56
+ try {
57
+ const hrefPathname = normalizePathname(new URL(href, window.location.href).pathname)
58
+ const currentPathname = normalizePathname(pathname)
59
+
60
+ if (hrefPathname === '/') {
61
+ return currentPathname === '/'
62
+ }
63
+
64
+ return currentPathname === hrefPathname || currentPathname.startsWith(`${hrefPathname}/`)
65
+ } catch {
66
+ return false
67
+ }
68
+ }
69
+
38
70
  export class Sidebar {
39
71
  readonly element: HTMLElement
40
72
  private readonly options: Required<EnhanceOptions>
@@ -50,11 +82,75 @@ export class Sidebar {
50
82
  }
51
83
 
52
84
  private init(): void {
85
+ this.setupProductChrome()
86
+ this.setupRouteState()
53
87
  this.setupCollapse()
54
88
  this.setupKeyboardNav()
55
89
  this.setupSkeletonTimeout()
56
90
  }
57
91
 
92
+ private setupProductChrome(): void {
93
+ const shouldUseProductStyle =
94
+ this.element.hasAttribute('data-sidebar-product') ||
95
+ this.element.classList.contains('sidebar--cm') ||
96
+ this.options.productVariants.includes(this.variant)
97
+
98
+ if (shouldUseProductStyle) {
99
+ this.element.classList.add('sidebar--product')
100
+ }
101
+
102
+ this.element
103
+ .querySelectorAll<HTMLElement>('.sidebar__item, .sidebar__nav-link')
104
+ .forEach(item => {
105
+ if (!item.hasAttribute('data-nav-item')) {
106
+ item.setAttribute('data-nav-item', '')
107
+ }
108
+ })
109
+ }
110
+
111
+ private setupRouteState(): void {
112
+ if (this.options.activeFromLocation !== true || typeof window === 'undefined') {
113
+ return
114
+ }
115
+
116
+ const items = Array.from(
117
+ this.element.querySelectorAll<HTMLElement>(
118
+ 'a.sidebar__item[href], a.sidebar__nav-link[href], a[data-nav-item][href]'
119
+ )
120
+ )
121
+
122
+ let active: HTMLElement | null = null
123
+ let activeLength = -1
124
+
125
+ for (const item of items) {
126
+ const href = item.getAttribute('href')
127
+ if (href === null || !routeMatches(window.location.pathname, href)) {
128
+ continue
129
+ }
130
+
131
+ try {
132
+ const pathname = normalizePathname(new URL(href, window.location.href).pathname)
133
+ if (pathname.length > activeLength) {
134
+ active = item
135
+ activeLength = pathname.length
136
+ }
137
+ } catch {
138
+ // Ignore malformed href values; they cannot represent the current route.
139
+ }
140
+ }
141
+
142
+ if (active === null) {
143
+ return
144
+ }
145
+
146
+ this.element.querySelectorAll<HTMLElement>('.sidebar__item--active').forEach(item => {
147
+ item.classList.remove('sidebar__item--active')
148
+ item.removeAttribute('aria-current')
149
+ })
150
+ active.classList.add('sidebar__item--active')
151
+ active.setAttribute('aria-current', 'page')
152
+ }
153
+
58
154
  /**
59
155
  * Collapse/expand toggle with localStorage persistence.
60
156
  */
@@ -99,6 +195,28 @@ export class Sidebar {
99
195
  })
100
196
  })
101
197
 
198
+ if (this.options.closeOnNavigate === true) {
199
+ const navigateHandler = (event: MouseEvent): void => {
200
+ const target = event.target
201
+ const navItem =
202
+ target instanceof Element
203
+ ? target.closest<HTMLElement>(
204
+ 'a.sidebar__item[href], a.sidebar__nav-link[href], a[data-nav-item][href]'
205
+ )
206
+ : null
207
+ if (navItem !== null && this.element.getAttribute('data-open') === 'true') {
208
+ this.element.setAttribute('data-open', 'false')
209
+ if (backdrop) {
210
+ backdrop.setAttribute('data-visible', 'false')
211
+ }
212
+ }
213
+ }
214
+ this.element.addEventListener('click', navigateHandler)
215
+ this.cleanupFns.push(() => {
216
+ this.element.removeEventListener('click', navigateHandler)
217
+ })
218
+ }
219
+
102
220
  // Backdrop click closes sidebar
103
221
  if (backdrop) {
104
222
  const handler = (): void => {
@@ -26,6 +26,9 @@ export interface SidebarConfig {
26
26
  storageKey?: string
27
27
  mobileBreakpoint?: number
28
28
  collapsedClass?: string
29
+ productVariants?: string[]
30
+ activeFromLocation?: boolean
31
+ closeOnNavigate?: boolean
29
32
  }
30
33
 
31
34
  export interface LegacySidebarConfig {
@@ -40,9 +43,13 @@ const DEFAULT_CONFIG: Required<SidebarConfig> = {
40
43
  storageKey: 'sidebar-collapsed',
41
44
  mobileBreakpoint: 900,
42
45
  collapsedClass: 'sidebar--collapsed',
46
+ productVariants: ['app', 'cm', 'threat-library'],
47
+ activeFromLocation: true,
48
+ closeOnNavigate: true,
43
49
  }
44
50
 
45
51
  let currentConfig: Required<SidebarConfig> = { ...DEFAULT_CONFIG }
52
+ const INITIALIZED_ATTR = 'data-sidebar-initialized'
46
53
 
47
54
  /**
48
55
  * Configure sidebar settings.
@@ -103,6 +110,119 @@ interface SidebarHandlerParams {
103
110
  key: string
104
111
  }
105
112
 
113
+ function normalizePathname(pathname: string): string {
114
+ if (pathname === '/') {
115
+ return pathname
116
+ }
117
+
118
+ return pathname.replace(/\/+$/, '')
119
+ }
120
+
121
+ function routeMatches(pathname: string, href: string): boolean {
122
+ try {
123
+ const hrefPathname = normalizePathname(new URL(href, window.location.href).pathname)
124
+ const currentPathname = normalizePathname(pathname)
125
+
126
+ if (hrefPathname === '/') {
127
+ return currentPathname === '/'
128
+ }
129
+
130
+ return currentPathname === hrefPathname || currentPathname.startsWith(`${hrefPathname}/`)
131
+ } catch {
132
+ return false
133
+ }
134
+ }
135
+
136
+ function resolveActiveRouteItem(sidebar: HTMLElement): HTMLElement | null {
137
+ const items = Array.from(
138
+ sidebar.querySelectorAll<HTMLElement>(
139
+ 'a.sidebar__item[href], a.sidebar__nav-link[href], a[data-nav-item][href]'
140
+ )
141
+ )
142
+
143
+ let active: HTMLElement | null = null
144
+ let activeLength = -1
145
+
146
+ for (const item of items) {
147
+ const href = item.getAttribute('href')
148
+ if (href === null || !routeMatches(window.location.pathname, href)) {
149
+ continue
150
+ }
151
+
152
+ try {
153
+ const pathname = normalizePathname(new URL(href, window.location.href).pathname)
154
+ if (pathname.length > activeLength) {
155
+ active = item
156
+ activeLength = pathname.length
157
+ }
158
+ } catch {
159
+ // Ignore malformed href values; they cannot represent the current route.
160
+ }
161
+ }
162
+
163
+ return active
164
+ }
165
+
166
+ function syncRouteActiveState(sidebar: HTMLElement): void {
167
+ if (currentConfig.activeFromLocation !== true || typeof window === 'undefined') {
168
+ return
169
+ }
170
+
171
+ const active = resolveActiveRouteItem(sidebar)
172
+ if (active === null) {
173
+ return
174
+ }
175
+
176
+ sidebar.querySelectorAll<HTMLElement>('.sidebar__item--active').forEach(item => {
177
+ item.classList.remove('sidebar__item--active')
178
+ item.removeAttribute('aria-current')
179
+ })
180
+ active.classList.add('sidebar__item--active')
181
+ active.setAttribute('aria-current', 'page')
182
+ }
183
+
184
+ function prepareUnifiedSidebar(sidebar: HTMLElement, variant: string): void {
185
+ const shouldUseProductStyle =
186
+ sidebar.hasAttribute('data-sidebar-product') ||
187
+ sidebar.classList.contains('sidebar--cm') ||
188
+ currentConfig.productVariants.includes(variant)
189
+
190
+ if (shouldUseProductStyle) {
191
+ sidebar.classList.add('sidebar--product')
192
+ }
193
+
194
+ sidebar.querySelectorAll<HTMLElement>('.sidebar__item, .sidebar__nav-link').forEach(item => {
195
+ if (!item.hasAttribute('data-nav-item')) {
196
+ item.setAttribute('data-nav-item', '')
197
+ }
198
+ })
199
+
200
+ syncRouteActiveState(sidebar)
201
+ }
202
+
203
+ function setupKeyboardNavigation(sidebar: HTMLElement): void {
204
+ sidebar.addEventListener('keydown', event => {
205
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
206
+
207
+ const items = Array.from(
208
+ sidebar.querySelectorAll<HTMLElement>('[data-nav-item]:not([aria-disabled="true"])')
209
+ ).filter(item => !item.hidden && item.offsetParent !== null)
210
+
211
+ if (items.length === 0) return
212
+
213
+ const currentIndex = items.indexOf(document.activeElement as HTMLElement)
214
+ if (currentIndex === -1) return
215
+
216
+ event.preventDefault()
217
+ const nextIndex =
218
+ event.key === 'ArrowDown'
219
+ ? (currentIndex + 1) % items.length
220
+ : (currentIndex - 1 + items.length) % items.length
221
+
222
+ items[nextIndex]?.focus()
223
+ })
224
+ }
225
+
106
226
  /**
107
227
  * Attach all sidebar event handlers (shared between unified and legacy init).
108
228
  */
@@ -141,6 +261,21 @@ function attachSidebarHandlers({
141
261
  })
142
262
  }
143
263
 
264
+ if (currentConfig.closeOnNavigate === true) {
265
+ sidebar.addEventListener('click', event => {
266
+ const target = event.target
267
+ const navItem =
268
+ target instanceof Element
269
+ ? target.closest<HTMLElement>(
270
+ 'a.sidebar__item[href], a.sidebar__nav-link[href], a[data-nav-item][href]'
271
+ )
272
+ : null
273
+ if (navItem !== null && sidebar.getAttribute('data-open') === 'true') {
274
+ closeSidebar(sidebar, backdrop)
275
+ }
276
+ })
277
+ }
278
+
144
279
  // Handle window resize
145
280
  window.addEventListener('resize', () => {
146
281
  if (isMobile()) {
@@ -154,6 +289,9 @@ function attachSidebarHandlers({
154
289
  closeSidebar(sidebar, backdrop)
155
290
  }
156
291
  })
292
+
293
+ setupKeyboardNavigation(sidebar)
294
+ sidebar.setAttribute(INITIALIZED_ATTR, 'true')
157
295
  }
158
296
 
159
297
  /**
@@ -165,6 +303,12 @@ function initUnifiedSidebar(sidebar: HTMLElement): void {
165
303
  return
166
304
  }
167
305
 
306
+ prepareUnifiedSidebar(sidebar, variant)
307
+
308
+ if (sidebar.getAttribute(INITIALIZED_ATTR) === 'true') {
309
+ return
310
+ }
311
+
168
312
  attachSidebarHandlers({
169
313
  sidebar,
170
314
  toggle: sidebar.querySelector<HTMLButtonElement>('[data-sidebar-toggle]'),
@@ -6,22 +6,84 @@
6
6
  import { type BrandSize } from '../components/brand'
7
7
  import { type IconSet } from '../icons/index'
8
8
 
9
+ export type SidebarBadgeTone =
10
+ | 'default'
11
+ | 'primary'
12
+ | 'amber'
13
+ | 'cyan'
14
+ | 'rose'
15
+ | 'warning'
16
+ | 'error'
17
+
18
+ export type SidebarActiveMatcher =
19
+ | 'exact'
20
+ | 'prefix'
21
+ | string
22
+ | RegExp
23
+ | ((pathname: string, item: SidebarNavItem) => boolean)
24
+
25
+ export interface SidebarBadgeConfig {
26
+ /** Visible badge text. Use short labels such as "9", "99+", or "Soon". */
27
+ label: string
28
+ /** Optional accessible title used in collapsed tooltips and native titles. */
29
+ title?: string
30
+ /**
31
+ * Visual tone for the badge.
32
+ * @default 'primary'
33
+ */
34
+ tone?: SidebarBadgeTone
35
+ /**
36
+ * Keep the collapsed rail dot visible even when label is empty.
37
+ * @default false
38
+ */
39
+ dot?: boolean
40
+ }
41
+
9
42
  export interface SidebarNavItem {
10
43
  /** Stable identifier used for selection and badge updates. */
11
44
  id: string
12
45
  /** Visible label rendered alongside the icon. */
13
46
  label: string
47
+ /** Optional navigation target. When omitted, the item renders as a button. */
48
+ href?: string
49
+ /** Optional target for link navigation. */
50
+ target?: string
51
+ /** Optional rel for external links. */
52
+ rel?: string
53
+ /** Accessible label override. */
54
+ ariaLabel?: string
14
55
  /** Icon name resolved through the active icon set. */
15
56
  icon?: string
16
- /** Numeric badge displayed at the trailing edge of the row. */
17
- badge?: number
57
+ /** Numeric or rich badge displayed at the trailing edge of the row. */
58
+ badge?: number | SidebarBadgeConfig
18
59
  /**
19
60
  * Visual tone for the badge.
20
61
  * @default 'primary'
21
62
  */
22
- badgeTone?: 'default' | 'primary' | 'amber' | 'cyan' | 'rose' | 'warning' | 'error'
63
+ badgeTone?: SidebarBadgeTone
64
+ /** Static pill label such as "Admin" or "Soon". */
65
+ tag?: string
66
+ /** Tooltip/title shown when the sidebar is collapsed. */
67
+ tooltip?: string
68
+ /**
69
+ * Whether this item is the current page. If omitted, active state can be
70
+ * derived from `activeItemId`, `href`, or `activeMatch`.
71
+ */
72
+ active?: boolean
73
+ /** Route matching rule used against the current configured/browser pathname. */
74
+ activeMatch?: SidebarActiveMatcher
75
+ /**
76
+ * Treat href matching as exact instead of prefix matching.
77
+ * @default false
78
+ */
79
+ end?: boolean
80
+ /**
81
+ * Render the item as unavailable while preserving its position.
82
+ * @default false
83
+ */
84
+ disabled?: boolean
23
85
  /** Called when the item is activated. */
24
- onClick?: () => void
86
+ onClick?: (event: MouseEvent) => void
25
87
  }
26
88
 
27
89
  export interface SidebarSection {
@@ -50,6 +112,18 @@ export interface SidebarSection {
50
112
  export interface SidebarUser {
51
113
  /** Display name shown in the footer. */
52
114
  name: string
115
+ /** Secondary user metadata such as email or role. */
116
+ detail?: string
117
+ /** Avatar image URL. */
118
+ avatarUrl?: string
119
+ /** Initials fallback when no avatar URL is provided. */
120
+ initials?: string
121
+ /** Presence dot tone shown on the avatar. */
122
+ presence?: 'online' | 'away' | 'busy' | 'dnd' | 'offline'
123
+ /** Accessible label for the user trigger. */
124
+ label?: string
125
+ /** Called when the user surface is activated. */
126
+ onClick?: () => void
53
127
  }
54
128
 
55
129
  export interface SidebarFooterAction {
@@ -89,6 +163,28 @@ export interface SidebarBrandConfig {
89
163
  decorative?: boolean
90
164
  }
91
165
 
166
+ export interface SidebarScopeOption {
167
+ id: string
168
+ label: string
169
+ status?: 'active' | 'suspended' | 'archived' | 'warning' | 'error' | 'neutral'
170
+ disabled?: boolean
171
+ }
172
+
173
+ export interface SidebarScopeConfig {
174
+ /** Label shown above/inside the selector. */
175
+ label?: string
176
+ /** Icon name resolved through the active icon set. */
177
+ icon?: string
178
+ /** Current selected option id. */
179
+ value?: string
180
+ /** Fallback label when `value` does not match an option. */
181
+ placeholder?: string
182
+ /** Options rendered in the selector menu. */
183
+ options: SidebarScopeOption[]
184
+ /** Called when the selected option changes. */
185
+ onChange?: (option: SidebarScopeOption) => void
186
+ }
187
+
92
188
  export interface SidebarComponentConfig {
93
189
  /** Container element (or selector) the sidebar is mounted into. */
94
190
  container: HTMLElement | string
@@ -98,6 +194,12 @@ export interface SidebarComponentConfig {
98
194
  sections: SidebarSection[]
99
195
  /** Optional brand header rendered above the navigation. */
100
196
  brand?: SidebarBrandConfig
197
+ /** Optional scope/tenant selector rendered below the brand. */
198
+ scope?: SidebarScopeConfig
199
+ /** Optional element inserted between the header/scope area and navigation. */
200
+ beforeNav?: HTMLElement | HTMLElement[]
201
+ /** Optional element inserted after navigation and before footer. */
202
+ afterNav?: HTMLElement | HTMLElement[]
101
203
  /**
102
204
  * Render the shared edge-docked collapse control.
103
205
  * @default false
@@ -124,6 +226,41 @@ export interface SidebarComponentConfig {
124
226
  * @default false
125
227
  */
126
228
  collapsed?: boolean
229
+ /**
230
+ * Current active nav item ID. Applied during initial render and when React
231
+ * wrappers call `setActive`.
232
+ */
233
+ activeItemId?: string
234
+ /**
235
+ * Current route path used to derive active link state. When omitted, browser
236
+ * consumers fall back to `window.location.pathname`.
237
+ */
238
+ pathname?: string
239
+ /**
240
+ * Persist collapsed state under this localStorage key.
241
+ * When omitted, collapsed state remains in memory only.
242
+ */
243
+ storageKey?: string
244
+ /**
245
+ * Persist collapsed state when `storageKey` is supplied.
246
+ * @default true
247
+ */
248
+ persistCollapsed?: boolean
249
+ /**
250
+ * Viewport width below which the sidebar behaves as a mobile drawer.
251
+ * @default 900
252
+ */
253
+ mobileBreakpoint?: number
254
+ /**
255
+ * Render and control a mobile backdrop alongside the sidebar.
256
+ * @default false
257
+ */
258
+ mobileBackdrop?: boolean
259
+ /**
260
+ * Close the mobile drawer after a nav item activates.
261
+ * @default true
262
+ */
263
+ closeOnNavigate?: boolean
127
264
  /**
128
265
  * Controlled mobile open state. Reflected to `data-open` for the mobile
129
266
  * sidebar visibility contract.
@@ -134,4 +271,6 @@ export interface SidebarComponentConfig {
134
271
  onNavigate?: (item: SidebarNavItem) => void
135
272
  /** Called when the sidebar's collapsed state changes. */
136
273
  onCollapse?: (collapsed: boolean) => void
274
+ /** Called when the mobile open state changes. */
275
+ onOpenChange?: (open: boolean) => void
137
276
  }