@ds-mo/ui 1.6.1 → 1.6.3

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-bar-nav.js +1 -1
  3. package/dist/components/ds-bar-nav.js.map +1 -1
  4. package/dist/components/ds-menu.js +1 -1
  5. package/dist/components/ds-panel-nav.js +1 -1
  6. package/dist/components/ds-panel-nav.js.map +1 -1
  7. package/dist/components/ds-select.js +1 -1
  8. package/dist/components/ds-select.js.map +1 -1
  9. package/dist/components/p-C4VSrrAB.js +2 -0
  10. package/dist/components/p-C4VSrrAB.js.map +1 -0
  11. package/dist/types/components/BarNav/bar-nav-tabs-menu-utils.d.ts +1 -1
  12. package/dist/types/components/Menu/Menu.d.ts +9 -16
  13. package/dist/types/components/Menu/menu-position.d.ts +20 -0
  14. package/dist/types/components/Menu/menu-types.d.ts +16 -0
  15. package/dist/types/components/PanelNav/PanelNav.d.ts +3 -3
  16. package/dist/types/components/PanelNav/index.d.ts +2 -1
  17. package/dist/types/components/PanelNav/panel-nav-types.d.ts +6 -0
  18. package/dist/types/components.d.ts +9 -7
  19. package/package.json +3 -3
  20. package/src/angular/proxies.ts +5 -3
  21. package/src/react/ds-panel-nav.ts +2 -2
  22. package/src/wc/components/BarNav/BarNav.tsx +1 -1
  23. package/src/wc/components/BarNav/bar-nav-tabs-menu-utils.ts +1 -1
  24. package/src/wc/components/Menu/Menu.tsx +78 -67
  25. package/src/wc/components/Menu/menu-position.ts +78 -0
  26. package/src/wc/components/Menu/menu-types.ts +17 -0
  27. package/src/wc/components/PanelNav/PanelNav.tsx +14 -6
  28. package/src/wc/components/PanelNav/index.ts +7 -1
  29. package/src/wc/components/PanelNav/panel-nav-types.ts +8 -0
  30. package/src/wc/components/Select/Select.tsx +1 -1
  31. package/src/wc/components.d.ts +9 -7
  32. package/dist/components/p-C4WbCXBj.js +0 -2
  33. package/dist/components/p-C4WbCXBj.js.map +0 -1
@@ -0,0 +1,20 @@
1
+ export type MenuSide = 'top' | 'right' | 'bottom' | 'left';
2
+ export type MenuAlign = 'start' | 'center' | 'end';
3
+ export interface MenuPositionInput {
4
+ anchorRect: Pick<DOMRectReadOnly, 'top' | 'left' | 'right' | 'bottom' | 'width' | 'height'>;
5
+ popupWidth: number;
6
+ popupHeight: number;
7
+ side: MenuSide;
8
+ align: MenuAlign;
9
+ sideOffsetPx: number;
10
+ alignOffsetPx: number;
11
+ viewportPadPx: number;
12
+ viewportWidth: number;
13
+ viewportHeight: number;
14
+ }
15
+ /** Pure layout math for ds-menu — anchor rect + placement props → viewport-fixed x/y. */
16
+ export declare function computeMenuPosition(input: MenuPositionInput): {
17
+ x: number;
18
+ y: number;
19
+ };
20
+ //# sourceMappingURL=menu-position.d.ts.map
@@ -0,0 +1,16 @@
1
+ export type { MenuAlign, MenuSide } from './menu-position';
2
+ export interface MenuItemData {
3
+ label: string;
4
+ value?: string;
5
+ subtext?: string;
6
+ isSelected?: boolean;
7
+ isInactive?: boolean;
8
+ isDestructive?: boolean;
9
+ showToggle?: boolean;
10
+ toggleValue?: boolean;
11
+ }
12
+ export interface MenuSection {
13
+ header?: string;
14
+ items: MenuItemData[];
15
+ }
16
+ //# sourceMappingURL=menu-types.d.ts.map
@@ -1,6 +1,6 @@
1
1
  import { EventEmitter } from '../../stencil-public-runtime';
2
2
  import type { NavChromeStyle } from '../../nav/nav-chrome';
3
- import type { PanelNavGroup, PanelNavRouterMode } from './panel-nav-types';
3
+ import { type PanelNavGroup, type PanelNavRouterMode, type PanelNavUserActionDetail } from './panel-nav-types';
4
4
  export declare class PanelNav {
5
5
  /** Chrome style: `navigation` = navigation tokens, `default` = standard app tokens.
6
6
  * Property: `navStyle`. HTML attribute: `nav-style`. */
@@ -40,8 +40,8 @@ export declare class PanelNav {
40
40
  dsNavToggle: EventEmitter<boolean>;
41
41
  /** Emitted when the footer left button (gear / dashboard) is clicked. */
42
42
  dsNavFooterAction: EventEmitter<void>;
43
- /** Emitted when the footer user button is clicked. */
44
- dsNavUserAction: EventEmitter<void>;
43
+ /** Emitted when the footer user button is clicked. Detail includes the anchor for `ds-menu`. */
44
+ dsNavUserAction: EventEmitter<PanelNavUserActionDetail>;
45
45
  el: HTMLElement;
46
46
  /** Mirrors `style` but is only updated inside the VT callback so the
47
47
  * View Transition captures a clean before/after snapshot. */
@@ -1,4 +1,5 @@
1
- export type { PanelNavRouterMode, PanelNavItem, PanelNavGroup } from './panel-nav-types';
1
+ export type { PanelNavRouterMode, PanelNavItem, PanelNavGroup, PanelNavUserActionDetail, } from './panel-nav-types';
2
+ export { PANEL_NAV_USER_MENU_ANCHOR_ID } from './panel-nav-types';
2
3
  export type { NavChromeStyle } from '../../nav/nav-chrome';
3
4
  export { NAV_STYLE_HINT_ATTR, setNavStyleHint, clearNavStyleHint, resolvePanelNavStyle, shouldResyncPanelNavStyle, ensurePanelNavVtStyle, deriveActiveIdFromUrl, parsePanelNavGroups, } from './panel-nav-utils';
4
5
  //# sourceMappingURL=index.d.ts.map
@@ -17,4 +17,10 @@ export interface PanelNavGroup {
17
17
  label?: string;
18
18
  items: PanelNavItem[];
19
19
  }
20
+ /** Stable `id` on the footer user button — use with `ds-menu` `anchor-id`. */
21
+ export declare const PANEL_NAV_USER_MENU_ANCHOR_ID = "ds-panel-nav-user-menu-anchor";
22
+ /** Detail for `dsNavUserAction` — anchor element for an external `ds-menu`. */
23
+ export interface PanelNavUserActionDetail {
24
+ anchor: HTMLElement;
25
+ }
20
26
  //# sourceMappingURL=panel-nav-types.d.ts.map
@@ -22,9 +22,10 @@ import { FadeSide, FadeSize, FadeSurface } from "./components/Fade/Fade";
22
22
  import { HeaderBackground } from "./components/Header/Header";
23
23
  import { IconColor, IconSize } from "./components/Icon/Icon";
24
24
  import { InputType } from "./components/Input/Input";
25
- import { MenuAlign, MenuItemData, MenuSection, MenuSide } from "./components/Menu/Menu";
25
+ import { MenuItemData, MenuSection } from "./components/Menu/menu-types";
26
+ import { MenuAlign, MenuSide } from "./components/Menu/menu-position";
26
27
  import { ModalWidth } from "./components/Modal/Modal";
27
- import { PanelNavGroup, PanelNavRouterMode } from "./components/PanelNav/panel-nav-types";
28
+ import { PanelNavGroup, PanelNavRouterMode, PanelNavUserActionDetail } from "./components/PanelNav/panel-nav-types";
28
29
  import { RadioOption } from "./components/RadioGroup/RadioGroup";
29
30
  import { ScrollbarVariant } from "./components/Scrollbar/Scrollbar";
30
31
  import { SelectOption } from "./components/Select/Select";
@@ -57,9 +58,10 @@ export { FadeSide, FadeSize, FadeSurface } from "./components/Fade/Fade";
57
58
  export { HeaderBackground } from "./components/Header/Header";
58
59
  export { IconColor, IconSize } from "./components/Icon/Icon";
59
60
  export { InputType } from "./components/Input/Input";
60
- export { MenuAlign, MenuItemData, MenuSection, MenuSide } from "./components/Menu/Menu";
61
+ export { MenuItemData, MenuSection } from "./components/Menu/menu-types";
62
+ export { MenuAlign, MenuSide } from "./components/Menu/menu-position";
61
63
  export { ModalWidth } from "./components/Modal/Modal";
62
- export { PanelNavGroup, PanelNavRouterMode } from "./components/PanelNav/panel-nav-types";
64
+ export { PanelNavGroup, PanelNavRouterMode, PanelNavUserActionDetail } from "./components/PanelNav/panel-nav-types";
63
65
  export { RadioOption } from "./components/RadioGroup/RadioGroup";
64
66
  export { ScrollbarVariant } from "./components/Scrollbar/Scrollbar";
65
67
  export { SelectOption } from "./components/Select/Select";
@@ -1442,7 +1444,7 @@ declare global {
1442
1444
  "dsNavSelect": string;
1443
1445
  "dsNavToggle": boolean;
1444
1446
  "dsNavFooterAction": void;
1445
- "dsNavUserAction": void;
1447
+ "dsNavUserAction": PanelNavUserActionDetail;
1446
1448
  }
1447
1449
  interface HTMLDsPanelNavElement extends Components.DsPanelNav, HTMLStencilElement {
1448
1450
  addEventListener<K extends keyof HTMLDsPanelNavElementEventMap>(type: K, listener: (this: HTMLDsPanelNavElement, ev: DsPanelNavCustomEvent<HTMLDsPanelNavElementEventMap[K]>) => any, options?: boolean | AddEventListenerOptions): void;
@@ -2330,9 +2332,9 @@ declare namespace LocalJSX {
2330
2332
  */
2331
2333
  "onDsNavToggle"?: (event: DsPanelNavCustomEvent<boolean>) => void;
2332
2334
  /**
2333
- * Emitted when the footer user button is clicked.
2335
+ * Emitted when the footer user button is clicked. Detail includes the anchor for `ds-menu`.
2334
2336
  */
2335
- "onDsNavUserAction"?: (event: DsPanelNavCustomEvent<void>) => void;
2337
+ "onDsNavUserAction"?: (event: DsPanelNavCustomEvent<PanelNavUserActionDetail>) => void;
2336
2338
  /**
2337
2339
  * How items with `href` render: - `anchor` (default): native `<a href>` — works with routers that intercept anchors. - `event`: always `<button>`; host handles navigation via `dsNavSelect`.
2338
2340
  * @default 'anchor'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ds-mo/ui",
3
- "version": "1.6.1",
3
+ "version": "1.6.3",
4
4
  "description": "CompoMo — composable web components styled with TokoMo design tokens",
5
5
  "keywords": [
6
6
  "web-components",
@@ -69,7 +69,7 @@
69
69
  "pretest": "node scripts/generate-icon-catalog.mjs",
70
70
  "build": "stencil build && node scripts/patch-index-types.mjs && node scripts/verify-icons-externalized.mjs && node scripts/write-build-stamp.mjs",
71
71
  "verify:pack": "node scripts/verify-npm-pack.mjs && node scripts/verify-nav-import.mjs",
72
- "test": "node --import tsx/esm --test tests/panel-nav-utils.test.ts tests/shell-view-transition.test.ts tests/shell-gradient.test.ts tests/resolve-css-length-px.test.ts tests/resolve-css-time-ms.test.ts tests/overlay-positioning.test.ts tests/bar-nav-utils.test.ts tests/bar-nav-tabs-menu-utils.test.ts tests/bar-nav-dom-utils.test.ts tests/icon-catalog.test.ts",
72
+ "test": "node --import tsx/esm --test tests/panel-nav-utils.test.ts tests/shell-view-transition.test.ts tests/shell-gradient.test.ts tests/resolve-css-length-px.test.ts tests/resolve-css-time-ms.test.ts tests/overlay-positioning.test.ts tests/menu-position.test.ts tests/bar-nav-utils.test.ts tests/bar-nav-tabs-menu-utils.test.ts tests/bar-nav-dom-utils.test.ts tests/icon-catalog.test.ts",
73
73
  "test:e2e": "npm run build && playwright test",
74
74
  "test:e2e:install": "playwright install chromium",
75
75
  "build:docs": "stencil build --docs",
@@ -104,7 +104,7 @@
104
104
  },
105
105
  "devDependencies": {
106
106
  "@ds-mo/icons": "^6.0.0",
107
- "@ds-mo/tokens": "^2.10.0",
107
+ "@ds-mo/tokens": "^2.11.0",
108
108
  "@eslint/js": "^10.0.1",
109
109
  "@figma/code-connect": "^1.4.8",
110
110
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -658,7 +658,7 @@ export class DsPanelNav {
658
658
  @Output() dsNavSelect = new EventEmitter<CustomEvent<string>>();
659
659
  @Output() dsNavToggle = new EventEmitter<CustomEvent<boolean>>();
660
660
  @Output() dsNavFooterAction = new EventEmitter<CustomEvent<void>>();
661
- @Output() dsNavUserAction = new EventEmitter<CustomEvent<void>>();
661
+ @Output() dsNavUserAction = new EventEmitter<CustomEvent<IDsPanelNavPanelNavUserActionDetail>>();
662
662
  constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) {
663
663
  c.detach();
664
664
  this.el = r.nativeElement;
@@ -666,6 +666,8 @@ export class DsPanelNav {
666
666
  }
667
667
 
668
668
 
669
+ import type { PanelNavUserActionDetail as IDsPanelNavPanelNavUserActionDetail } from '@ds-mo/ui';
670
+
669
671
  export declare interface DsPanelNav extends Components.DsPanelNav {
670
672
  /**
671
673
  * Emitted when a nav item is clicked. Detail = the item's `id`.
@@ -680,9 +682,9 @@ export declare interface DsPanelNav extends Components.DsPanelNav {
680
682
  */
681
683
  dsNavFooterAction: EventEmitter<CustomEvent<void>>;
682
684
  /**
683
- * Emitted when the footer user button is clicked.
685
+ * Emitted when the footer user button is clicked. Detail includes the anchor for `ds-menu`.
684
686
  */
685
- dsNavUserAction: EventEmitter<CustomEvent<void>>;
687
+ dsNavUserAction: EventEmitter<CustomEvent<IDsPanelNavPanelNavUserActionDetail>>;
686
688
  }
687
689
 
688
690
 
@@ -11,7 +11,7 @@ import type { EventName, StencilReactComponent } from '@stencil/react-output-tar
11
11
  import { createComponent } from '@stencil/react-output-target/runtime';
12
12
  import React from 'react';
13
13
 
14
- import { type DsPanelNavCustomEvent } from "@ds-mo/ui";
14
+ import { type DsPanelNavCustomEvent, type PanelNavUserActionDetail } from "@ds-mo/ui";
15
15
  import type { Components } from "@ds-mo/ui/dist/components";
16
16
  import { DsPanelNav as DsPanelNavElement, defineCustomElement as defineDsPanelNav } from "@ds-mo/ui/dist/components/ds-panel-nav.js";
17
17
 
@@ -19,7 +19,7 @@ export type DsPanelNavEvents = {
19
19
  onDsNavSelect: EventName<DsPanelNavCustomEvent<string>>,
20
20
  onDsNavToggle: EventName<DsPanelNavCustomEvent<boolean>>,
21
21
  onDsNavFooterAction: EventName<DsPanelNavCustomEvent<void>>,
22
- onDsNavUserAction: EventName<DsPanelNavCustomEvent<void>>
22
+ onDsNavUserAction: EventName<DsPanelNavCustomEvent<PanelNavUserActionDetail>>
23
23
  };
24
24
 
25
25
  export const DsPanelNav: StencilReactComponent<DsPanelNavElement, DsPanelNavEvents, Components.DsPanelNav> = /*@__PURE__*/ createComponent<DsPanelNavElement, DsPanelNavEvents, Components.DsPanelNav>({
@@ -8,7 +8,7 @@ import {
8
8
  h,
9
9
  Host,
10
10
  } from '@stencil/core';
11
- import type { MenuItemData } from '../Menu/Menu';
11
+ import type { MenuItemData } from '../Menu/menu-types';
12
12
  import type { BarNavActionBackground } from '../BarNavAction/BarNavAction';
13
13
  import type { NavChromeStyle } from '../../nav/nav-chrome';
14
14
  import { SHELL_BAR_NAV_VT_NAME } from '../../nav/shell-view-transition';
@@ -1,4 +1,4 @@
1
- import type { MenuSection } from '../Menu/Menu';
1
+ import type { MenuSection } from '../Menu/menu-types';
2
2
  import {
3
3
  getSelectableTabs,
4
4
  isTabDivider,
@@ -1,24 +1,10 @@
1
1
  import { Component, Prop, State, Event, EventEmitter, Element, Watch, Listen, h, Host } from '@stencil/core';
2
2
  import { resolveCssLengthPx, resolveCssTimeMs, TOKEN_DEFAULTS } from '../../utils';
3
+ import { computeMenuPosition, type MenuAlign, type MenuSide } from './menu-position';
4
+ import type { MenuItemData, MenuSection } from './menu-types';
3
5
 
4
- export type MenuSide = 'top' | 'right' | 'bottom' | 'left';
5
- export type MenuAlign = 'start' | 'center' | 'end';
6
-
7
- export interface MenuItemData {
8
- label: string;
9
- value?: string;
10
- subtext?: string;
11
- isSelected?: boolean;
12
- isInactive?: boolean;
13
- isDestructive?: boolean;
14
- showToggle?: boolean;
15
- toggleValue?: boolean;
16
- }
17
-
18
- export interface MenuSection {
19
- header?: string;
20
- items: MenuItemData[];
21
- }
6
+ /** rAF retries while the popup mounts or the anchor resolves. */
7
+ const POSITION_RETRY_BUDGET = 8;
22
8
 
23
9
  @Component({
24
10
  tag: 'ds-menu',
@@ -57,12 +43,15 @@ export class Menu {
57
43
  private scrollResizeHandler: (() => void) | null = null;
58
44
  private closeTimer: ReturnType<typeof setTimeout> | null = null;
59
45
  private itemEls: HTMLElement[] = [];
46
+ private positionRetryRaf: number | null = null;
47
+ private listenersReady = false;
60
48
 
61
49
  componentDidLoad() {
62
50
  if (this.open) this.onOpenChange(true);
63
51
  }
64
52
 
65
53
  disconnectedCallback() {
54
+ this.cancelPositionRetry();
66
55
  this.teardownListeners();
67
56
  }
68
57
 
@@ -72,15 +61,19 @@ export class Menu {
72
61
  this.shouldRender = true;
73
62
  this.closing = false;
74
63
  this.positionReady = false;
75
- requestAnimationFrame(() => {
76
- this.calculatePosition();
77
- this.positionReady = true;
78
- this.focusInitialItem();
79
- this.setupListeners();
64
+ this.listenersReady = false;
65
+ this.schedulePositionUpdate(() => {
66
+ if (!this.listenersReady) {
67
+ this.listenersReady = true;
68
+ this.focusInitialItem();
69
+ this.setupListeners();
70
+ }
80
71
  });
81
72
  } else if (this.shouldRender) {
73
+ this.cancelPositionRetry();
82
74
  this.closing = true;
83
75
  this.teardownListeners();
76
+ this.listenersReady = false;
84
77
  this.closeTimer = setTimeout(() => {
85
78
  this.shouldRender = false;
86
79
  this.closing = false;
@@ -89,12 +82,18 @@ export class Menu {
89
82
  }
90
83
  }
91
84
 
85
+ @Watch('anchor')
86
+ @Watch('anchorId')
87
+ onAnchorChange() {
88
+ if (this.open) this.schedulePositionUpdate();
89
+ }
90
+
92
91
  @Watch('side')
93
92
  @Watch('align')
94
93
  @Watch('sideOffset')
95
94
  @Watch('alignOffset')
96
95
  onPositionPropsChange() {
97
- if (this.open) this.calculatePosition();
96
+ if (this.open) this.schedulePositionUpdate();
98
97
  }
99
98
 
100
99
  private get viewportPadPx(): number {
@@ -137,51 +136,61 @@ export class Menu {
137
136
  return this.activeSections.flatMap(s => s.items);
138
137
  }
139
138
 
140
- private calculatePosition() {
141
- const anchorEl = this.resolvedAnchor;
142
- if (!anchorEl) return;
143
- const popup = this.el.querySelector('.menu-popup') as HTMLElement | null;
144
- if (!popup) return;
145
-
146
- const a = anchorEl.getBoundingClientRect();
147
- const pw = popup.offsetWidth || this.popupFallbackWidthPx;
148
- const ph = popup.offsetHeight || this.popupFallbackHeightPx;
149
- const sideOffset = this.sideOffsetPx;
150
- const alignOffset = this.alignOffsetPx;
151
- const vpPad = this.viewportPadPx;
152
- let x = 0, y = 0;
153
-
154
- switch (this.side) {
155
- case 'top':
156
- y = a.top - ph - sideOffset;
157
- x = this.align === 'start' ? a.left + alignOffset
158
- : this.align === 'end' ? a.right - pw + alignOffset
159
- : a.left + a.width / 2 - pw / 2 + alignOffset;
160
- break;
161
- case 'bottom':
162
- y = a.bottom + sideOffset;
163
- x = this.align === 'start' ? a.left + alignOffset
164
- : this.align === 'end' ? a.right - pw + alignOffset
165
- : a.left + a.width / 2 - pw / 2 + alignOffset;
166
- break;
167
- case 'left':
168
- x = a.left - pw - sideOffset;
169
- y = this.align === 'start' ? a.top + alignOffset
170
- : this.align === 'end' ? a.bottom - ph + alignOffset
171
- : a.top + a.height / 2 - ph / 2 + alignOffset;
172
- break;
173
- case 'right':
174
- x = a.right + sideOffset;
175
- y = this.align === 'start' ? a.top + alignOffset
176
- : this.align === 'end' ? a.bottom - ph + alignOffset
177
- : a.top + a.height / 2 - ph / 2 + alignOffset;
178
- break;
139
+ private cancelPositionRetry() {
140
+ if (this.positionRetryRaf !== null) {
141
+ cancelAnimationFrame(this.positionRetryRaf);
142
+ this.positionRetryRaf = null;
179
143
  }
144
+ }
180
145
 
181
- this.pos = {
182
- x: Math.min(Math.max(x, vpPad), window.innerWidth - pw - vpPad),
183
- y: Math.min(Math.max(y, vpPad), window.innerHeight - ph - vpPad),
146
+ /** Retry until anchor + popup exist — do not reveal at 0,0 on a failed first pass. */
147
+ private schedulePositionUpdate(onReady?: () => void) {
148
+ if (!this.open) return;
149
+
150
+ this.cancelPositionRetry();
151
+ this.positionReady = false;
152
+
153
+ let remaining = POSITION_RETRY_BUDGET;
154
+
155
+ const attempt = () => {
156
+ this.positionRetryRaf = null;
157
+ if (!this.open) return;
158
+
159
+ if (this.calculatePosition()) {
160
+ this.positionReady = true;
161
+ onReady?.();
162
+ return;
163
+ }
164
+
165
+ if (remaining > 0) {
166
+ remaining -= 1;
167
+ this.positionRetryRaf = requestAnimationFrame(attempt);
168
+ }
184
169
  };
170
+
171
+ this.positionRetryRaf = requestAnimationFrame(attempt);
172
+ }
173
+
174
+ /** @returns `true` when anchor and popup were found and `pos` was updated. */
175
+ private calculatePosition(): boolean {
176
+ const anchorEl = this.resolvedAnchor;
177
+ if (!anchorEl) return false;
178
+ const popup = this.el.querySelector('.menu-popup') as HTMLElement | null;
179
+ if (!popup) return false;
180
+
181
+ this.pos = computeMenuPosition({
182
+ anchorRect: anchorEl.getBoundingClientRect(),
183
+ popupWidth: popup.offsetWidth || this.popupFallbackWidthPx,
184
+ popupHeight: popup.offsetHeight || this.popupFallbackHeightPx,
185
+ side: this.side,
186
+ align: this.align,
187
+ sideOffsetPx: this.sideOffsetPx,
188
+ alignOffsetPx: this.alignOffsetPx,
189
+ viewportPadPx: this.viewportPadPx,
190
+ viewportWidth: window.innerWidth,
191
+ viewportHeight: window.innerHeight,
192
+ });
193
+ return true;
185
194
  }
186
195
 
187
196
  /** Focus the selected item when present, otherwise the first enabled item. */
@@ -208,7 +217,9 @@ export class Menu {
208
217
  this.close();
209
218
  };
210
219
 
211
- this.scrollResizeHandler = () => this.calculatePosition();
220
+ this.scrollResizeHandler = () => {
221
+ if (this.open) this.calculatePosition();
222
+ };
212
223
 
213
224
  document.addEventListener('mousedown', this.clickOutsideHandler, true);
214
225
  window.addEventListener('scroll', this.scrollResizeHandler, true);
@@ -0,0 +1,78 @@
1
+ export type MenuSide = 'top' | 'right' | 'bottom' | 'left';
2
+ export type MenuAlign = 'start' | 'center' | 'end';
3
+
4
+ export interface MenuPositionInput {
5
+ anchorRect: Pick<DOMRectReadOnly, 'top' | 'left' | 'right' | 'bottom' | 'width' | 'height'>;
6
+ popupWidth: number;
7
+ popupHeight: number;
8
+ side: MenuSide;
9
+ align: MenuAlign;
10
+ sideOffsetPx: number;
11
+ alignOffsetPx: number;
12
+ viewportPadPx: number;
13
+ viewportWidth: number;
14
+ viewportHeight: number;
15
+ }
16
+
17
+ /** Pure layout math for ds-menu — anchor rect + placement props → viewport-fixed x/y. */
18
+ export function computeMenuPosition(input: MenuPositionInput): { x: number; y: number } {
19
+ const {
20
+ anchorRect: a,
21
+ popupWidth: pw,
22
+ popupHeight: ph,
23
+ side,
24
+ align,
25
+ sideOffsetPx,
26
+ alignOffsetPx,
27
+ viewportPadPx: vpPad,
28
+ viewportWidth,
29
+ viewportHeight,
30
+ } = input;
31
+
32
+ let x = 0;
33
+ let y = 0;
34
+
35
+ switch (side) {
36
+ case 'top':
37
+ y = a.top - ph - sideOffsetPx;
38
+ x =
39
+ align === 'start'
40
+ ? a.left + alignOffsetPx
41
+ : align === 'end'
42
+ ? a.right - pw + alignOffsetPx
43
+ : a.left + a.width / 2 - pw / 2 + alignOffsetPx;
44
+ break;
45
+ case 'bottom':
46
+ y = a.bottom + sideOffsetPx;
47
+ x =
48
+ align === 'start'
49
+ ? a.left + alignOffsetPx
50
+ : align === 'end'
51
+ ? a.right - pw + alignOffsetPx
52
+ : a.left + a.width / 2 - pw / 2 + alignOffsetPx;
53
+ break;
54
+ case 'left':
55
+ x = a.left - pw - sideOffsetPx;
56
+ y =
57
+ align === 'start'
58
+ ? a.top + alignOffsetPx
59
+ : align === 'end'
60
+ ? a.bottom - ph + alignOffsetPx
61
+ : a.top + a.height / 2 - ph / 2 + alignOffsetPx;
62
+ break;
63
+ case 'right':
64
+ x = a.right + sideOffsetPx;
65
+ y =
66
+ align === 'start'
67
+ ? a.top + alignOffsetPx
68
+ : align === 'end'
69
+ ? a.bottom - ph + alignOffsetPx
70
+ : a.top + a.height / 2 - ph / 2 + alignOffsetPx;
71
+ break;
72
+ }
73
+
74
+ return {
75
+ x: Math.min(Math.max(x, vpPad), viewportWidth - pw - vpPad),
76
+ y: Math.min(Math.max(y, vpPad), viewportHeight - ph - vpPad),
77
+ };
78
+ }
@@ -0,0 +1,17 @@
1
+ export type { MenuAlign, MenuSide } from './menu-position';
2
+
3
+ export interface MenuItemData {
4
+ label: string;
5
+ value?: string;
6
+ subtext?: string;
7
+ isSelected?: boolean;
8
+ isInactive?: boolean;
9
+ isDestructive?: boolean;
10
+ showToggle?: boolean;
11
+ toggleValue?: boolean;
12
+ }
13
+
14
+ export interface MenuSection {
15
+ header?: string;
16
+ items: MenuItemData[];
17
+ }
@@ -14,7 +14,13 @@ import {
14
14
  shouldResyncPanelNavGroups,
15
15
  shouldResyncPanelNavStyle,
16
16
  } from './panel-nav-utils';
17
- import type { PanelNavGroup, PanelNavItem, PanelNavRouterMode } from './panel-nav-types';
17
+ import {
18
+ PANEL_NAV_USER_MENU_ANCHOR_ID,
19
+ type PanelNavGroup,
20
+ type PanelNavItem,
21
+ type PanelNavRouterMode,
22
+ type PanelNavUserActionDetail,
23
+ } from './panel-nav-types';
18
24
 
19
25
  // Module-level WeakMaps
20
26
  interface ViewTransition {
@@ -86,8 +92,8 @@ export class PanelNav {
86
92
  /** Emitted when the footer left button (gear / dashboard) is clicked. */
87
93
  @Event() dsNavFooterAction!: EventEmitter<void>;
88
94
 
89
- /** Emitted when the footer user button is clicked. */
90
- @Event() dsNavUserAction!: EventEmitter<void>;
95
+ /** Emitted when the footer user button is clicked. Detail includes the anchor for `ds-menu`. */
96
+ @Event() dsNavUserAction!: EventEmitter<PanelNavUserActionDetail>;
91
97
 
92
98
  @Element() el!: HTMLElement;
93
99
 
@@ -379,8 +385,9 @@ export class PanelNav {
379
385
  this.dsNavFooterAction.emit();
380
386
  }
381
387
 
382
- private handleUserAction() {
383
- this.dsNavUserAction.emit();
388
+ private handleUserAction(e: MouseEvent) {
389
+ const anchor = e.currentTarget as HTMLElement;
390
+ this.dsNavUserAction.emit({ anchor });
384
391
  }
385
392
 
386
393
  private clearEdgeOverlayTimer() {
@@ -587,9 +594,10 @@ export class PanelNav {
587
594
  {/* Right user — label fades like nav items; right icon cross-fades chevron ↔ circle+initial */}
588
595
  <button
589
596
  type="button"
597
+ id={PANEL_NAV_USER_MENU_ANCHOR_ID}
590
598
  class="panel-nav__item panel-nav__footer-user"
591
599
  aria-label={collapsed ? `User: ${userName}` : `User menu for ${userName}`}
592
- onClick={() => this.handleUserAction()}
600
+ onClick={(e) => this.handleUserAction(e)}
593
601
  >
594
602
  <span class="panel-nav__item-label panel-nav__footer-user-label">
595
603
  <span class="panel-nav__item-label-text">
@@ -1,4 +1,10 @@
1
- export type { PanelNavRouterMode, PanelNavItem, PanelNavGroup } from './panel-nav-types';
1
+ export type {
2
+ PanelNavRouterMode,
3
+ PanelNavItem,
4
+ PanelNavGroup,
5
+ PanelNavUserActionDetail,
6
+ } from './panel-nav-types';
7
+ export { PANEL_NAV_USER_MENU_ANCHOR_ID } from './panel-nav-types';
2
8
  export type { NavChromeStyle } from '../../nav/nav-chrome';
3
9
  export {
4
10
  NAV_STYLE_HINT_ATTR,
@@ -20,3 +20,11 @@ export interface PanelNavGroup {
20
20
  label?: string;
21
21
  items: PanelNavItem[];
22
22
  }
23
+
24
+ /** Stable `id` on the footer user button — use with `ds-menu` `anchor-id`. */
25
+ export const PANEL_NAV_USER_MENU_ANCHOR_ID = 'ds-panel-nav-user-menu-anchor';
26
+
27
+ /** Detail for `dsNavUserAction` — anchor element for an external `ds-menu`. */
28
+ export interface PanelNavUserActionDetail {
29
+ anchor: HTMLElement;
30
+ }
@@ -1,5 +1,5 @@
1
1
  import { Component, Prop, State, Event, EventEmitter, Element, Watch, Listen, h, Host } from '@stencil/core';
2
- import type { MenuItemData } from '../Menu/Menu';
2
+ import type { MenuItemData } from '../Menu/menu-types';
3
3
 
4
4
  export interface SelectOption {
5
5
  label: string;
@@ -22,9 +22,10 @@ import { FadeSide, FadeSize, FadeSurface } from "./components/Fade/Fade";
22
22
  import { HeaderBackground } from "./components/Header/Header";
23
23
  import { IconColor, IconSize } from "./components/Icon/Icon";
24
24
  import { InputType } from "./components/Input/Input";
25
- import { MenuAlign, MenuItemData, MenuSection, MenuSide } from "./components/Menu/Menu";
25
+ import { MenuItemData, MenuSection } from "./components/Menu/menu-types";
26
+ import { MenuAlign, MenuSide } from "./components/Menu/menu-position";
26
27
  import { ModalWidth } from "./components/Modal/Modal";
27
- import { PanelNavGroup, PanelNavRouterMode } from "./components/PanelNav/panel-nav-types";
28
+ import { PanelNavGroup, PanelNavRouterMode, PanelNavUserActionDetail } from "./components/PanelNav/panel-nav-types";
28
29
  import { RadioOption } from "./components/RadioGroup/RadioGroup";
29
30
  import { ScrollbarVariant } from "./components/Scrollbar/Scrollbar";
30
31
  import { SelectOption } from "./components/Select/Select";
@@ -57,9 +58,10 @@ export { FadeSide, FadeSize, FadeSurface } from "./components/Fade/Fade";
57
58
  export { HeaderBackground } from "./components/Header/Header";
58
59
  export { IconColor, IconSize } from "./components/Icon/Icon";
59
60
  export { InputType } from "./components/Input/Input";
60
- export { MenuAlign, MenuItemData, MenuSection, MenuSide } from "./components/Menu/Menu";
61
+ export { MenuItemData, MenuSection } from "./components/Menu/menu-types";
62
+ export { MenuAlign, MenuSide } from "./components/Menu/menu-position";
61
63
  export { ModalWidth } from "./components/Modal/Modal";
62
- export { PanelNavGroup, PanelNavRouterMode } from "./components/PanelNav/panel-nav-types";
64
+ export { PanelNavGroup, PanelNavRouterMode, PanelNavUserActionDetail } from "./components/PanelNav/panel-nav-types";
63
65
  export { RadioOption } from "./components/RadioGroup/RadioGroup";
64
66
  export { ScrollbarVariant } from "./components/Scrollbar/Scrollbar";
65
67
  export { SelectOption } from "./components/Select/Select";
@@ -1442,7 +1444,7 @@ declare global {
1442
1444
  "dsNavSelect": string;
1443
1445
  "dsNavToggle": boolean;
1444
1446
  "dsNavFooterAction": void;
1445
- "dsNavUserAction": void;
1447
+ "dsNavUserAction": PanelNavUserActionDetail;
1446
1448
  }
1447
1449
  interface HTMLDsPanelNavElement extends Components.DsPanelNav, HTMLStencilElement {
1448
1450
  addEventListener<K extends keyof HTMLDsPanelNavElementEventMap>(type: K, listener: (this: HTMLDsPanelNavElement, ev: DsPanelNavCustomEvent<HTMLDsPanelNavElementEventMap[K]>) => any, options?: boolean | AddEventListenerOptions): void;
@@ -2330,9 +2332,9 @@ declare namespace LocalJSX {
2330
2332
  */
2331
2333
  "onDsNavToggle"?: (event: DsPanelNavCustomEvent<boolean>) => void;
2332
2334
  /**
2333
- * Emitted when the footer user button is clicked.
2335
+ * Emitted when the footer user button is clicked. Detail includes the anchor for `ds-menu`.
2334
2336
  */
2335
- "onDsNavUserAction"?: (event: DsPanelNavCustomEvent<void>) => void;
2337
+ "onDsNavUserAction"?: (event: DsPanelNavCustomEvent<PanelNavUserActionDetail>) => void;
2336
2338
  /**
2337
2339
  * How items with `href` render: - `anchor` (default): native `<a href>` — works with routers that intercept anchors. - `event`: always `<button>`; host handles navigation via `dsNavSelect`.
2338
2340
  * @default 'anchor'