@ds-mo/ui 1.10.0 → 1.11.0

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 (33) hide show
  1. package/dist/.build-stamp +1 -1
  2. package/dist/components/ds-app-shell.js +1 -1
  3. package/dist/components/ds-app-shell.js.map +1 -1
  4. package/dist/components/ds-bar-nav.js +1 -1
  5. package/dist/components/ds-bar-nav.js.map +1 -1
  6. package/dist/components/ds-panel-nav.js +1 -1
  7. package/dist/components/ds-panel-nav.js.map +1 -1
  8. package/dist/components/ds-panel-tools.js +1 -1
  9. package/dist/components/ds-panel-tools.js.map +1 -1
  10. package/dist/components/p-DN65lYYc.js +2 -0
  11. package/dist/components/p-DN65lYYc.js.map +1 -0
  12. package/dist/types/components/AppShell/AppShell.d.ts +16 -2
  13. package/dist/types/components/BarNav/BarNav.d.ts +10 -0
  14. package/dist/types/components/PanelNav/PanelNav.d.ts +4 -0
  15. package/dist/types/components/PanelTools/PanelTools.d.ts +4 -0
  16. package/dist/types/components.d.ts +29 -2
  17. package/dist/types/nav/chrome-transition.d.ts +8 -0
  18. package/dist/types/nav/index.d.ts +5 -1
  19. package/dist/types/nav/shell-chrome-metrics.d.ts +12 -0
  20. package/dist/types/nav/shell-gradient.d.ts +2 -0
  21. package/package.json +2 -2
  22. package/src/angular/proxies.ts +22 -4
  23. package/src/react/ds-panel-nav.ts +5 -1
  24. package/src/react/ds-panel-tools.ts +11 -3
  25. package/src/wc/components/AppShell/AppShell.tsx +133 -21
  26. package/src/wc/components/BarNav/BarNav.tsx +91 -2
  27. package/src/wc/components/PanelNav/PanelNav.tsx +10 -0
  28. package/src/wc/components/PanelTools/PanelTools.tsx +10 -0
  29. package/src/wc/components.d.ts +29 -2
  30. package/src/wc/nav/chrome-transition.ts +10 -0
  31. package/src/wc/nav/index.ts +14 -0
  32. package/src/wc/nav/shell-chrome-metrics.ts +49 -0
  33. package/src/wc/nav/shell-gradient.ts +5 -0
@@ -1,5 +1,16 @@
1
1
  import { Component, Prop, Element, Watch, h, Host } from '@stencil/core';
2
2
  import type { NavChromeStyle } from '../../nav/nav-chrome';
3
+ import {
4
+ CHROME_TRANSITION_END,
5
+ CHROME_TRANSITION_START,
6
+ type ChromeTransitionDetail,
7
+ } from '../../nav/chrome-transition';
8
+ import {
9
+ isPanelNavCollapsed,
10
+ panelWidthPxFromTokens,
11
+ readPanelNavWidthTokens,
12
+ type PanelNavWidthTokens,
13
+ } from '../../nav/shell-chrome-metrics';
3
14
  import {
4
15
  SHELL_GRADIENT_IMAGE_VAR,
5
16
  SHELL_GRADIENT_OPACITY,
@@ -22,9 +33,12 @@ export class AppShell {
22
33
  /** Chrome style propagated to slotted `ds-panel-nav` and `ds-bar-nav`. */
23
34
  @Prop({ attribute: 'nav-style', reflect: true }) navStyle: NavChromeStyle = 'dashboard';
24
35
 
25
- /** When `true`, paints the shared chrome surface (bg + wash + grid) behind panel, bar, and tools. */
36
+ /** When `true`, paints the radial wash behind panel, bar, and tools (synced to shell layout). */
26
37
  @Prop({ reflect: true }) gradient: boolean = false;
27
38
 
39
+ /** When `true`, paints the diagonal grid overlay on the shared chrome layer. Independent of `gradient`. */
40
+ @Prop({ reflect: true }) grid: boolean = false;
41
+
28
42
  /**
29
43
  * Optional custom gradient for `background-image` (e.g. SVG URL).
30
44
  * When set, overrides the built-in radial wash.
@@ -34,31 +48,72 @@ export class AppShell {
34
48
  @Element() el!: HTMLElement;
35
49
 
36
50
  private resizeObserver: ResizeObserver | null = null;
51
+ private chromeSyncRafId = 0;
52
+ private chromeTransitionDepth = 0;
53
+ private panelWidthTokens: PanelNavWidthTokens = { expandedPx: 0, collapsedPx: 0 };
54
+ private cachedShellWidth = 0;
55
+ private cachedShellHeight = 0;
37
56
 
38
57
  componentDidLoad() {
39
58
  this.syncSlottedNavStyle();
40
59
  this.connectMetricsObserver();
41
- this.scheduleChromeSync();
42
- }
43
-
44
- private scheduleChromeSync() {
45
- this.syncChrome();
46
- requestAnimationFrame(() => this.syncChrome());
60
+ this.el.addEventListener(CHROME_TRANSITION_START, this.onChromeTransitionStart);
61
+ this.el.addEventListener(CHROME_TRANSITION_END, this.onChromeTransitionEnd);
62
+ requestAnimationFrame(() => {
63
+ this.cachePanelWidthTokens();
64
+ this.scheduleChromeSync();
65
+ });
47
66
  }
48
67
 
49
68
  disconnectedCallback() {
50
69
  this.resizeObserver?.disconnect();
51
70
  this.resizeObserver = null;
71
+ if (this.chromeSyncRafId) {
72
+ cancelAnimationFrame(this.chromeSyncRafId);
73
+ this.chromeSyncRafId = 0;
74
+ }
75
+ this.el.removeEventListener(CHROME_TRANSITION_START, this.onChromeTransitionStart);
76
+ this.el.removeEventListener(CHROME_TRANSITION_END, this.onChromeTransitionEnd);
52
77
  }
53
78
 
54
79
  @Watch('navStyle')
55
80
  @Watch('gradient')
81
+ @Watch('grid')
56
82
  @Watch('gradientSrc')
57
83
  onShellPropsChange() {
58
84
  this.syncSlottedNavStyle();
59
85
  this.scheduleChromeSync();
60
86
  }
61
87
 
88
+ private onChromeTransitionStart = (event: Event) => {
89
+ const source = (event as CustomEvent<ChromeTransitionDetail>).detail?.source;
90
+ if (source === 'panel-nav') {
91
+ this.chromeTransitionDepth += 1;
92
+ this.cachedShellWidth = this.el.clientWidth;
93
+ this.cachedShellHeight = this.el.clientHeight;
94
+ this.syncChrome();
95
+ }
96
+ };
97
+
98
+ private onChromeTransitionEnd = (event: Event) => {
99
+ const source = (event as CustomEvent<ChromeTransitionDetail>).detail?.source;
100
+ if (source !== 'panel-nav') return;
101
+
102
+ this.chromeTransitionDepth = Math.max(0, this.chromeTransitionDepth - 1);
103
+ if (this.chromeTransitionDepth === 0) {
104
+ this.scheduleChromeSync();
105
+ }
106
+ };
107
+
108
+ /** Coalesce ResizeObserver bursts to one layout read per frame. */
109
+ private scheduleChromeSync() {
110
+ if (this.chromeSyncRafId) return;
111
+ this.chromeSyncRafId = requestAnimationFrame(() => {
112
+ this.chromeSyncRafId = 0;
113
+ this.syncChrome();
114
+ });
115
+ }
116
+
62
117
  private syncSlottedNavStyle() {
63
118
  const panel = this.el.querySelector('ds-panel-nav') as (HTMLElement & { navStyle: NavChromeStyle }) | null;
64
119
  const bar = this.el.querySelector('ds-bar-nav') as (HTMLElement & { navStyle: NavChromeStyle }) | null;
@@ -73,11 +128,19 @@ export class AppShell {
73
128
  }
74
129
 
75
130
  private connectMetricsObserver() {
76
- this.resizeObserver = new ResizeObserver(() => this.scheduleChromeSync());
131
+ this.resizeObserver = new ResizeObserver(() => {
132
+ if (this.chromeTransitionDepth > 0) return;
133
+ this.scheduleChromeSync();
134
+ });
77
135
  this.resizeObserver.observe(this.el);
78
136
  const panelWrap = this.el.querySelector('.app-shell__panel');
79
137
  if (panelWrap) this.resizeObserver.observe(panelWrap);
80
- this.scheduleChromeSync();
138
+ }
139
+
140
+ private cachePanelWidthTokens() {
141
+ const navRoot = this.el.querySelector('ds-panel-nav .panel-nav') as HTMLElement | null;
142
+ if (!navRoot) return;
143
+ this.panelWidthTokens = readPanelNavWidthTokens(navRoot);
81
144
  }
82
145
 
83
146
  private applyGradientVars(target: HTMLElement, vars: Record<string, string | null>) {
@@ -114,32 +177,79 @@ export class AppShell {
114
177
  }
115
178
  }
116
179
 
117
- private syncChrome() {
118
- const panel = this.el.querySelector('ds-panel-nav') as HTMLElement | null;
119
- const bar = this.el.querySelector('ds-bar-nav') as HTMLElement | null;
120
- const targets = [this.el, panel, bar].filter((el): el is HTMLElement => el !== null);
180
+ private resolvePanelWidthPx(panelNav: HTMLElement | null): number {
181
+ const navRoot = panelNav?.querySelector('.panel-nav') as HTMLElement | null;
182
+
183
+ if (this.chromeTransitionDepth > 0 && this.panelWidthTokens.expandedPx > 0) {
184
+ const collapsed = isPanelNavCollapsed(panelNav, navRoot);
185
+ return panelWidthPxFromTokens(this.panelWidthTokens, collapsed);
186
+ }
121
187
 
122
- const shellRect = this.el.getBoundingClientRect();
123
188
  const panelWrap = this.el.querySelector('.app-shell__panel') as HTMLElement | null;
124
- const panelWidth = panelWrap?.getBoundingClientRect().width ?? 0;
189
+ const measured = panelWrap?.getBoundingClientRect().width ?? 0;
190
+ if (measured > 0) return measured;
125
191
 
126
- const panelPosition = shellGradientPositionPanel();
127
- const barPosition = shellGradientPositionBar(panelWidth);
192
+ if (this.panelWidthTokens.expandedPx > 0) {
193
+ const collapsed = isPanelNavCollapsed(panelNav, navRoot);
194
+ return panelWidthPxFromTokens(this.panelWidthTokens, collapsed);
195
+ }
128
196
 
129
- this.syncChromeLayoutVars(panel, bar, panelPosition, barPosition);
197
+ return 0;
198
+ }
199
+
200
+ private resolveShellDimensions(): { width: number; height: number } {
201
+ if (this.chromeTransitionDepth > 0 && this.cachedShellWidth > 0) {
202
+ return {
203
+ width: this.cachedShellWidth,
204
+ height: this.cachedShellHeight || this.el.clientHeight,
205
+ };
206
+ }
207
+
208
+ const rect = this.el.getBoundingClientRect();
209
+ return { width: rect.width, height: rect.height };
210
+ }
211
+
212
+ private chromeLayerActive(): boolean {
213
+ return this.gradient || this.grid;
214
+ }
215
+
216
+ private syncChrome() {
217
+ const panelNav = this.el.querySelector('ds-panel-nav') as HTMLElement | null;
218
+ const bar = this.el.querySelector('ds-bar-nav') as HTMLElement | null;
219
+ const targets = [this.el, panelNav, bar].filter((el): el is HTMLElement => el !== null);
220
+
221
+ const clearLayoutVars = () => {
222
+ if (panelNav) panelNav.style.removeProperty(SHELL_GRADIENT_POSITION_PANEL_VAR);
223
+ if (bar) bar.style.removeProperty(SHELL_GRADIENT_POSITION_BAR_VAR);
224
+ };
225
+
226
+ if (!this.chromeLayerActive()) {
227
+ this.clearGradientPaintVars(targets);
228
+ clearLayoutVars();
229
+ return;
230
+ }
130
231
 
131
232
  if (!this.gradient) {
132
233
  this.clearGradientPaintVars(targets);
234
+ clearLayoutVars();
133
235
  return;
134
236
  }
135
237
 
238
+ const { width: shellWidth, height: shellHeight } = this.resolveShellDimensions();
239
+ const panelWidth = this.resolvePanelWidthPx(panelNav);
240
+
241
+ const panelPosition = shellGradientPositionPanel();
242
+ const barPosition = shellGradientPositionBar(panelWidth);
243
+
244
+ this.syncChromeLayoutVars(panelNav, bar, panelPosition, barPosition);
245
+
136
246
  const image = this.gradientSrc.trim()
137
247
  ? `url(${this.gradientSrc.trim()})`
138
248
  : buildShellRadialGradient();
139
249
 
140
250
  const size = shellGradientSize({
141
- width: shellRect.width,
142
- height: shellRect.height,
251
+ width: shellWidth,
252
+ height: shellHeight,
143
253
  panelWidth,
144
254
  });
145
255
  const opacity = SHELL_GRADIENT_OPACITY;
@@ -168,16 +278,18 @@ export class AppShell {
168
278
  }
169
279
 
170
280
  render() {
281
+ const chromeActive = this.chromeLayerActive();
171
282
  const shellCls: Record<string, boolean> = {
172
283
  'app-shell': true,
173
284
  'app-shell--gradient': this.gradient,
285
+ 'app-shell--grid': this.grid,
174
286
  [`app-shell--${this.navStyle}`]: true,
175
287
  };
176
288
 
177
289
  return (
178
290
  <Host class={shellCls}>
179
291
  <div class="app-shell__row">
180
- {this.gradient ? <div class="app-shell__chrome" aria-hidden="true" /> : null}
292
+ {chromeActive ? <div class="app-shell__chrome" aria-hidden="true" /> : null}
181
293
  <div class="app-shell__panel">
182
294
  <slot name="panel" />
183
295
  </div>
@@ -28,6 +28,12 @@ import {
28
28
  tabsOverflowContainer,
29
29
  tabsToMenuSections,
30
30
  } from './bar-nav-tabs-menu-utils';
31
+ import {
32
+ CHROME_TRANSITION_END,
33
+ CHROME_TRANSITION_START,
34
+ type ChromeTransitionDetail,
35
+ } from '../../nav/chrome-transition';
36
+ import { readCssVarWidthPx } from '../../nav/shell-chrome-metrics';
31
37
 
32
38
  @Component({
33
39
  tag: 'ds-bar-nav',
@@ -91,6 +97,7 @@ export class BarNav {
91
97
 
92
98
  private static readonly HOST_PROP_SYNC_BUDGET = 8;
93
99
  private static readonly INTRINSIC_WIDTH_RETRY_MAX = 3;
100
+ private static readonly OVERFLOW_HYSTERESIS_PX = 8;
94
101
 
95
102
  private headerEl: HTMLElement | null = null;
96
103
  private triggerEl: HTMLButtonElement | null = null;
@@ -102,6 +109,9 @@ export class BarNav {
102
109
  private resizeObserver: ResizeObserver | null = null;
103
110
  private overflowCheckScheduled = false;
104
111
  private intrinsicWidthRetryCount = 0;
112
+ private chromeTransitionDepth = 0;
113
+ private chromeTransitionShell: HTMLElement | null = null;
114
+ private toolsDrawerOpening = false;
105
115
  /** Matches `basePath` once tabs + URL are reconciled for the active section. */
106
116
  @State() private committedSection = '';
107
117
  /** Section waiting for URL + tabs to catch up after a cross-area navigation. */
@@ -174,10 +184,15 @@ export class BarNav {
174
184
  this.syncHostPropsIfNeeded();
175
185
  this.scheduleDeferredHostPropSync();
176
186
  this.setupOverflowObserver();
187
+ this.bindChromeTransitionListeners();
177
188
  this.scheduleOverflowCheck();
178
189
  }
179
190
 
180
191
  componentDidRender() {
192
+ if (this.chromeTransitionDepth > 0) {
193
+ this.syncMenuAnchor();
194
+ return;
195
+ }
181
196
  this.scheduleOverflowCheck();
182
197
  this.syncMenuAnchor();
183
198
  if (this.tabsCollapsed) {
@@ -189,8 +204,63 @@ export class BarNav {
189
204
  this.resizeObserver?.disconnect();
190
205
  this.resizeObserver = null;
191
206
  this.syncTriggerLabelObserver(true);
207
+ this.unbindChromeTransitionListeners();
192
208
  }
193
209
 
210
+ private bindChromeTransitionListeners() {
211
+ const shell = this.el.closest('ds-app-shell');
212
+ if (!shell) return;
213
+ this.chromeTransitionShell = shell;
214
+ shell.addEventListener(CHROME_TRANSITION_START, this.onChromeTransitionStart);
215
+ shell.addEventListener(CHROME_TRANSITION_END, this.onChromeTransitionEnd);
216
+ }
217
+
218
+ private unbindChromeTransitionListeners() {
219
+ if (!this.chromeTransitionShell) return;
220
+ this.chromeTransitionShell.removeEventListener(
221
+ CHROME_TRANSITION_START,
222
+ this.onChromeTransitionStart,
223
+ );
224
+ this.chromeTransitionShell.removeEventListener(
225
+ CHROME_TRANSITION_END,
226
+ this.onChromeTransitionEnd,
227
+ );
228
+ this.chromeTransitionShell = null;
229
+ }
230
+
231
+ private onChromeTransitionStart = (event: Event) => {
232
+ const source = (event as CustomEvent<ChromeTransitionDetail>).detail?.source;
233
+ if (source === 'panel-nav') {
234
+ this.chromeTransitionDepth += 1;
235
+ return;
236
+ }
237
+ if (source === 'panel-tools') {
238
+ this.toolsDrawerOpening = true;
239
+ this.el.classList.add('bar-nav--tools-drawer-opening');
240
+ if (this.resolvedTabs.length > 0 && !this.hideTabsForDetailRoute) {
241
+ this.tabsCollapsed = true;
242
+ this.menuOpen = false;
243
+ }
244
+ this.scheduleOverflowCheck();
245
+ }
246
+ };
247
+
248
+ private onChromeTransitionEnd = (event: Event) => {
249
+ const source = (event as CustomEvent<ChromeTransitionDetail>).detail?.source;
250
+ if (source === 'panel-nav') {
251
+ this.chromeTransitionDepth = Math.max(0, this.chromeTransitionDepth - 1);
252
+ if (this.chromeTransitionDepth === 0) {
253
+ this.scheduleOverflowCheck();
254
+ }
255
+ return;
256
+ }
257
+ if (source === 'panel-tools') {
258
+ this.toolsDrawerOpening = false;
259
+ this.el.classList.remove('bar-nav--tools-drawer-opening');
260
+ this.scheduleOverflowCheck();
261
+ }
262
+ };
263
+
194
264
  /** Re-resolve props assigned by the host after componentWillLoad (Angular ngAfterViewInit). */
195
265
  private syncHostPropsIfNeeded() {
196
266
  if (
@@ -224,15 +294,20 @@ export class BarNav {
224
294
  private setupOverflowObserver() {
225
295
  if (typeof ResizeObserver === 'undefined' || !this.headerEl) return;
226
296
  this.resizeObserver?.disconnect();
227
- this.resizeObserver = new ResizeObserver(() => this.scheduleOverflowCheck());
297
+ this.resizeObserver = new ResizeObserver(() => {
298
+ if (this.chromeTransitionDepth > 0) return;
299
+ this.scheduleOverflowCheck();
300
+ });
228
301
  this.resizeObserver.observe(this.headerEl);
229
302
  }
230
303
 
231
304
  private scheduleOverflowCheck() {
305
+ if (this.chromeTransitionDepth > 0) return;
232
306
  if (this.overflowCheckScheduled) return;
233
307
  this.overflowCheckScheduled = true;
234
308
  requestAnimationFrame(() => {
235
309
  this.overflowCheckScheduled = false;
310
+ if (this.chromeTransitionDepth > 0) return;
236
311
  this.updateTabsCollapsed();
237
312
  this.updateTriggerLabelTruncation();
238
313
  });
@@ -292,7 +367,20 @@ export class BarNav {
292
367
  const available =
293
368
  this.headerEl.clientWidth - horizontalPadding - actionsWidth - spacing;
294
369
 
295
- return Math.max(0, available);
370
+ return Math.max(0, available - this.toolsDrawerOpeningReservePx());
371
+ }
372
+
373
+ /** While the tools drawer is opening, reserve width not yet claimed by flex shrink. */
374
+ private toolsDrawerOpeningReservePx(): number {
375
+ if (!this.toolsDrawerOpening) return 0;
376
+
377
+ const tools = this.el.closest('ds-app-shell')?.querySelector('ds-panel-tools') as HTMLElement | null;
378
+ if (!tools) return 0;
379
+
380
+ const drawerTarget = readCssVarWidthPx(tools, '--_panel-tools-drawer-width');
381
+ const drawerNow =
382
+ tools.querySelector('.panel-tools__drawer')?.getBoundingClientRect().width ?? 0;
383
+ return Math.max(0, drawerTarget - drawerNow);
296
384
  }
297
385
 
298
386
  private getTabsIntrinsicWidth(): number {
@@ -327,6 +415,7 @@ export class BarNav {
327
415
  intrinsicWidth,
328
416
  this.getLeftZoneAvailableWidth(),
329
417
  this.tabsCollapsed,
418
+ BarNav.OVERFLOW_HYSTERESIS_PX,
330
419
  );
331
420
 
332
421
  if (shouldCollapse !== this.tabsCollapsed) {
@@ -1,4 +1,5 @@
1
1
  import { Component, Prop, Event, EventEmitter, Watch, State, Element, h, Host } from '@stencil/core';
2
+ import type { ChromeTransitionDetail } from '../../nav/chrome-transition';
2
3
  import type { NavChromeStyle } from '../../nav/nav-chrome';
3
4
  import {
4
5
  deriveActiveIdFromUrl,
@@ -69,6 +70,13 @@ export class PanelNav {
69
70
  /** Emitted when the collapse toggle is clicked. Detail = new collapsed state. */
70
71
  @Event() dsNavToggle!: EventEmitter<boolean>;
71
72
 
73
+ /** Bubbling lifecycle — `ds-app-shell` pauses chrome metrics during width motion. */
74
+ @Event({ bubbles: true, composed: true })
75
+ dsChromeTransitionStart!: EventEmitter<ChromeTransitionDetail>;
76
+
77
+ @Event({ bubbles: true, composed: true })
78
+ dsChromeTransitionEnd!: EventEmitter<ChromeTransitionDetail>;
79
+
72
80
  /** Emitted when the footer left button (gear / dashboard) is clicked. */
73
81
  @Event() dsNavFooterAction!: EventEmitter<void>;
74
82
 
@@ -181,6 +189,7 @@ export class PanelNav {
181
189
 
182
190
  private startCollapseAnimation() {
183
191
  this.isAnimating = true;
192
+ this.dsChromeTransitionStart.emit({ source: 'panel-nav' });
184
193
  const panel = this.el.querySelector('.panel-nav') as HTMLElement | null;
185
194
  if (this.transitionEndHandler) {
186
195
  panel?.removeEventListener('transitionend', this.transitionEndHandler);
@@ -188,6 +197,7 @@ export class PanelNav {
188
197
  this.transitionEndHandler = (e: TransitionEvent) => {
189
198
  if (e.propertyName === 'width') {
190
199
  this.isAnimating = false;
200
+ this.dsChromeTransitionEnd.emit({ source: 'panel-nav' });
191
201
  panel?.removeEventListener('transitionend', this.transitionEndHandler!);
192
202
  }
193
203
  };
@@ -1,4 +1,5 @@
1
1
  import { Component, Prop, Event, EventEmitter, Element, State, Watch, h, Host } from '@stencil/core';
2
+ import type { ChromeTransitionDetail } from '../../nav/chrome-transition';
2
3
  import {
3
4
  PANEL_TOOLS_LABELS,
4
5
  PANEL_TOOLS_PRIMARY_TOOL_ID,
@@ -37,6 +38,13 @@ export class PanelTools {
37
38
  selected: boolean;
38
39
  }>;
39
40
 
41
+ /** Bubbling lifecycle — `ds-bar-nav` defers overflow checks during drawer motion. */
42
+ @Event({ bubbles: true, composed: true })
43
+ dsChromeTransitionStart!: EventEmitter<ChromeTransitionDetail>;
44
+
45
+ @Event({ bubbles: true, composed: true })
46
+ dsChromeTransitionEnd!: EventEmitter<ChromeTransitionDetail>;
47
+
40
48
  /** Arms open vs close easing for the in-flight width transition. */
41
49
  @State() private motion: 'opening' | 'closing' | 'idle' = 'idle';
42
50
 
@@ -74,6 +82,7 @@ export class PanelTools {
74
82
  openChanged(isOpen: boolean, wasOpen?: boolean) {
75
83
  if (this.readyForMotion && wasOpen !== undefined && wasOpen !== isOpen) {
76
84
  this.motion = isOpen ? 'opening' : 'closing';
85
+ this.dsChromeTransitionStart.emit({ source: 'panel-tools' });
77
86
  }
78
87
  this.deferMotionEnable();
79
88
  }
@@ -97,6 +106,7 @@ export class PanelTools {
97
106
  if (event.target !== this.el.querySelector('.panel-tools__drawer')) return;
98
107
  if (event.propertyName !== 'max-width') return;
99
108
  this.motion = 'idle';
109
+ this.dsChromeTransitionEnd.emit({ source: 'panel-tools' });
100
110
  };
101
111
 
102
112
  /** Rail selection follows `open` immediately — independent of the slide animation. */
@@ -26,6 +26,7 @@ import { MenuItemData, MenuSection } from "./components/Menu/menu-types";
26
26
  import { MenuAlign, MenuSide } from "./components/Menu/menu-position";
27
27
  import { ModalWidth } from "./components/Modal/Modal";
28
28
  import { PanelNavGroup, PanelNavRouterMode, PanelNavUserActionDetail } from "./components/PanelNav/panel-nav-types";
29
+ import { ChromeTransitionDetail } from "./nav/chrome-transition";
29
30
  import { PanelToolsItem, PanelToolsToolId } from "./components/PanelTools/panel-tools-types";
30
31
  import { RadioOption } from "./components/RadioGroup/RadioGroup";
31
32
  import { ScrollbarVariant } from "./components/Scrollbar/Scrollbar";
@@ -63,6 +64,7 @@ export { MenuItemData, MenuSection } from "./components/Menu/menu-types";
63
64
  export { MenuAlign, MenuSide } from "./components/Menu/menu-position";
64
65
  export { ModalWidth } from "./components/Modal/Modal";
65
66
  export { PanelNavGroup, PanelNavRouterMode, PanelNavUserActionDetail } from "./components/PanelNav/panel-nav-types";
67
+ export { ChromeTransitionDetail } from "./nav/chrome-transition";
66
68
  export { PanelToolsItem, PanelToolsToolId } from "./components/PanelTools/panel-tools-types";
67
69
  export { RadioOption } from "./components/RadioGroup/RadioGroup";
68
70
  export { ScrollbarVariant } from "./components/Scrollbar/Scrollbar";
@@ -97,7 +99,7 @@ export namespace Components {
97
99
  }
98
100
  interface DsAppShell {
99
101
  /**
100
- * When `true`, paints the shared chrome surface (bg + wash + grid) behind panel, bar, and tools.
102
+ * When `true`, paints the radial wash behind panel, bar, and tools (synced to shell layout).
101
103
  * @default false
102
104
  */
103
105
  "gradient": boolean;
@@ -106,6 +108,11 @@ export namespace Components {
106
108
  * @default ''
107
109
  */
108
110
  "gradientSrc": string;
111
+ /**
112
+ * When `true`, paints the diagonal grid overlay on the shared chrome layer. Independent of `gradient`.
113
+ * @default false
114
+ */
115
+ "grid": boolean;
109
116
  /**
110
117
  * Chrome style propagated to slotted `ds-panel-nav` and `ds-bar-nav`.
111
118
  * @default 'dashboard'
@@ -1476,6 +1483,8 @@ declare global {
1476
1483
  interface HTMLDsPanelNavElementEventMap {
1477
1484
  "dsNavSelect": string;
1478
1485
  "dsNavToggle": boolean;
1486
+ "dsChromeTransitionStart": ChromeTransitionDetail;
1487
+ "dsChromeTransitionEnd": ChromeTransitionDetail;
1479
1488
  "dsNavFooterAction": void;
1480
1489
  "dsNavUserAction": PanelNavUserActionDetail;
1481
1490
  }
@@ -1498,6 +1507,8 @@ declare global {
1498
1507
  id: PanelToolsToolId;
1499
1508
  selected: boolean;
1500
1509
  };
1510
+ "dsChromeTransitionStart": ChromeTransitionDetail;
1511
+ "dsChromeTransitionEnd": ChromeTransitionDetail;
1501
1512
  }
1502
1513
  interface HTMLDsPanelToolsElement extends Components.DsPanelTools, HTMLStencilElement {
1503
1514
  addEventListener<K extends keyof HTMLDsPanelToolsElementEventMap>(type: K, listener: (this: HTMLDsPanelToolsElement, ev: DsPanelToolsCustomEvent<HTMLDsPanelToolsElementEventMap[K]>) => any, options?: boolean | AddEventListenerOptions): void;
@@ -1775,7 +1786,7 @@ declare namespace LocalJSX {
1775
1786
  }
1776
1787
  interface DsAppShell {
1777
1788
  /**
1778
- * When `true`, paints the shared chrome surface (bg + wash + grid) behind panel, bar, and tools.
1789
+ * When `true`, paints the radial wash behind panel, bar, and tools (synced to shell layout).
1779
1790
  * @default false
1780
1791
  */
1781
1792
  "gradient"?: boolean;
@@ -1784,6 +1795,11 @@ declare namespace LocalJSX {
1784
1795
  * @default ''
1785
1796
  */
1786
1797
  "gradientSrc"?: string;
1798
+ /**
1799
+ * When `true`, paints the diagonal grid overlay on the shared chrome layer. Independent of `gradient`.
1800
+ * @default false
1801
+ */
1802
+ "grid"?: boolean;
1787
1803
  /**
1788
1804
  * Chrome style propagated to slotted `ds-panel-nav` and `ds-bar-nav`.
1789
1805
  * @default 'dashboard'
@@ -2378,6 +2394,11 @@ declare namespace LocalJSX {
2378
2394
  * @default 'dashboard'
2379
2395
  */
2380
2396
  "navStyle"?: NavChromeStyle;
2397
+ "onDsChromeTransitionEnd"?: (event: DsPanelNavCustomEvent<ChromeTransitionDetail>) => void;
2398
+ /**
2399
+ * Bubbling lifecycle — `ds-app-shell` pauses chrome metrics during width motion.
2400
+ */
2401
+ "onDsChromeTransitionStart"?: (event: DsPanelNavCustomEvent<ChromeTransitionDetail>) => void;
2381
2402
  /**
2382
2403
  * Emitted when the footer left button (gear / dashboard) is clicked.
2383
2404
  */
@@ -2431,6 +2452,11 @@ declare namespace LocalJSX {
2431
2452
  * @default ''
2432
2453
  */
2433
2454
  "itemsJson"?: string;
2455
+ "onDsChromeTransitionEnd"?: (event: DsPanelToolsCustomEvent<ChromeTransitionDetail>) => void;
2456
+ /**
2457
+ * Bubbling lifecycle — `ds-bar-nav` defers overflow checks during drawer motion.
2458
+ */
2459
+ "onDsChromeTransitionStart"?: (event: DsPanelToolsCustomEvent<ChromeTransitionDetail>) => void;
2434
2460
  /**
2435
2461
  * Emitted when a rail button is toggled. Detail = { id, selected }.
2436
2462
  */
@@ -2855,6 +2881,7 @@ declare namespace LocalJSX {
2855
2881
  interface DsAppShellAttributes {
2856
2882
  "navStyle": NavChromeStyle;
2857
2883
  "gradient": boolean;
2884
+ "grid": boolean;
2858
2885
  "gradientSrc": string;
2859
2886
  }
2860
2887
  interface DsBadgeAttributes {
@@ -0,0 +1,10 @@
1
+ /** Bubbling/composed events — `ds-app-shell` and `ds-bar-nav` coordinate during width motion. */
2
+
3
+ export const CHROME_TRANSITION_START = 'dsChromeTransitionStart';
4
+ export const CHROME_TRANSITION_END = 'dsChromeTransitionEnd';
5
+
6
+ export type ChromeTransitionSource = 'panel-nav' | 'panel-tools';
7
+
8
+ export interface ChromeTransitionDetail {
9
+ source: ChromeTransitionSource;
10
+ }
@@ -33,8 +33,22 @@ export {
33
33
  shellGradientPositionPanel,
34
34
  shellGradientPositionBar,
35
35
  shellChromeSurfacePosition,
36
+ shellChromeLayerActive,
36
37
  } from './shell-gradient';
37
38
  export type { ShellGradientLayout } from './shell-gradient';
39
+ export type { ChromeTransitionDetail, ChromeTransitionSource } from './chrome-transition';
40
+ export {
41
+ CHROME_TRANSITION_END,
42
+ CHROME_TRANSITION_START,
43
+ } from './chrome-transition';
44
+ export {
45
+ barGradientPositionFromPanelWidth,
46
+ isPanelNavCollapsed,
47
+ panelWidthPxFromTokens,
48
+ readCssVarWidthPx,
49
+ readPanelNavWidthTokens,
50
+ } from './shell-chrome-metrics';
51
+ export type { PanelNavWidthTokens } from './shell-chrome-metrics';
38
52
  export {
39
53
  BADGE_GRADIENT_POSITION_VAR,
40
54
  badgeGradientPosition,
@@ -0,0 +1,49 @@
1
+ import { shellGradientPositionBar } from './shell-gradient';
2
+
3
+ /** Read a `var(--token)` width in px using a hidden probe (works with calc() tokens). */
4
+ export function readCssVarWidthPx(context: HTMLElement, cssVarName: string): number {
5
+ if (typeof document === 'undefined') return 0;
6
+
7
+ const probe = document.createElement('div');
8
+ probe.style.cssText =
9
+ 'position:absolute;visibility:hidden;pointer-events:none;height:0;width:var(' +
10
+ cssVarName +
11
+ ');';
12
+ context.appendChild(probe);
13
+ const width = probe.getBoundingClientRect().width;
14
+ context.removeChild(probe);
15
+ return width;
16
+ }
17
+
18
+ export interface PanelNavWidthTokens {
19
+ expandedPx: number;
20
+ collapsedPx: number;
21
+ }
22
+
23
+ /** Resolve panel-nav expanded/collapsed widths from scoped CSS vars on `.panel-nav`. */
24
+ export function readPanelNavWidthTokens(navRoot: HTMLElement): PanelNavWidthTokens {
25
+ return {
26
+ expandedPx: readCssVarWidthPx(navRoot, '--_nav-width'),
27
+ collapsedPx: readCssVarWidthPx(navRoot, '--_nav-width-collapsed'),
28
+ };
29
+ }
30
+
31
+ export function isPanelNavCollapsed(
32
+ panelNavHost: HTMLElement | null,
33
+ navRoot: HTMLElement | null,
34
+ ): boolean {
35
+ if (navRoot?.classList.contains('panel-nav--collapsed')) return true;
36
+ const collapsedProp = (panelNavHost as (HTMLElement & { collapsed?: boolean }) | null)?.collapsed;
37
+ return collapsedProp === true;
38
+ }
39
+
40
+ export function panelWidthPxFromTokens(
41
+ tokens: PanelNavWidthTokens,
42
+ collapsed: boolean,
43
+ ): number {
44
+ return collapsed ? tokens.collapsedPx : tokens.expandedPx;
45
+ }
46
+
47
+ export function barGradientPositionFromPanelWidth(panelWidthPx: number): string {
48
+ return shellGradientPositionBar(panelWidthPx);
49
+ }
@@ -10,6 +10,11 @@ export const SHELL_CHROME_SURFACE_POSITION_VAR = '--ds-shell-chrome-surface-posi
10
10
  /** Layer opacity for the nav gradient wash. */
11
11
  export const SHELL_GRADIENT_OPACITY = '0.1';
12
12
 
13
+ /** Whether the shared chrome layer (bg + optional wash/grid) should mount. */
14
+ export function shellChromeLayerActive(gradient: boolean, grid: boolean): boolean {
15
+ return gradient || grid;
16
+ }
17
+
13
18
  const GRADIENT_GEOMETRY = '100% 100% at 0% 0%';
14
19
 
15
20
  /**