@ds-mo/ui 1.0.2 → 1.1.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 (177) hide show
  1. package/dist/components/ds-bar-nav-action.js +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-breadcrumb.js +1 -1
  5. package/dist/components/ds-button-group.js +1 -1
  6. package/dist/components/ds-button.js +1 -1
  7. package/dist/components/ds-card.js +1 -1
  8. package/dist/components/ds-card.js.map +1 -1
  9. package/dist/components/ds-checkbox.js +1 -1
  10. package/dist/components/ds-divider.js +1 -1
  11. package/dist/components/ds-empty-state.js +1 -1
  12. package/dist/components/ds-empty-state.js.map +1 -1
  13. package/dist/components/ds-fade.js +1 -1
  14. package/dist/components/ds-field.js +1 -1
  15. package/dist/components/ds-header.js +1 -1
  16. package/dist/components/ds-header.js.map +1 -1
  17. package/dist/components/ds-icon.js +1 -1
  18. package/dist/components/ds-input.js +1 -1
  19. package/dist/components/ds-label-wrap.js +1 -1
  20. package/dist/components/ds-menu.js +1 -1
  21. package/dist/components/ds-modal.js +1 -1
  22. package/dist/components/ds-panel-nav.js +1 -1
  23. package/dist/components/ds-radio-group.js +1 -1
  24. package/dist/components/ds-radio-group.js.map +1 -1
  25. package/dist/components/ds-scrollbar.js +1 -1
  26. package/dist/components/ds-select.js +1 -1
  27. package/dist/components/ds-slider.js +1 -1
  28. package/dist/components/ds-slider.js.map +1 -1
  29. package/dist/components/ds-surface.js +1 -1
  30. package/dist/components/ds-tab-group-nav.d.ts +11 -0
  31. package/dist/components/ds-tab-group-nav.js +2 -0
  32. package/dist/components/ds-tab-group-nav.js.map +1 -0
  33. package/dist/components/ds-tab-group.js +1 -1
  34. package/dist/components/ds-tab-group.js.map +1 -1
  35. package/dist/components/ds-table.js +1 -1
  36. package/dist/components/ds-table.js.map +1 -1
  37. package/dist/components/ds-tag.js +1 -1
  38. package/dist/components/ds-text.js +1 -1
  39. package/dist/components/ds-toast-provider.js +1 -1
  40. package/dist/components/ds-toggle-button-group.js +1 -1
  41. package/dist/components/ds-toggle-button.js +1 -1
  42. package/dist/components/ds-toggle.js +1 -1
  43. package/dist/components/ds-tooltip.js +1 -1
  44. package/dist/components/p-BQq26pt9.js +2 -0
  45. package/dist/components/p-BQq26pt9.js.map +1 -0
  46. package/dist/components/p-CMyQsk0K.js +2 -0
  47. package/dist/components/p-CMyQsk0K.js.map +1 -0
  48. package/dist/components/{p-D3CwGoIu.js → p-COsKOwZR.js} +2 -2
  49. package/dist/components/p-CpbnmEu1.js +2 -0
  50. package/dist/components/p-CpbnmEu1.js.map +1 -0
  51. package/dist/components/{p-DouEVPmi.js → p-Dd0GrKaF.js} +2 -2
  52. package/dist/components/{p-BvxKLqNx.js → p-H89PzqGU.js} +2 -2
  53. package/dist/components/{p-BYe8Rfdg.js → p-Vwlmhl4E.js} +2 -2
  54. package/dist/components/{p-C4p1fG67.js → p-woooYulR.js} +2 -2
  55. package/dist/types/.stencil/BarNav/BarNav.d.ts +7 -2
  56. package/dist/types/.stencil/BarNav/bar-nav-dom-utils.d.ts +1 -2
  57. package/dist/types/.stencil/TabGroupNav/TabGroupNav.d.ts +27 -0
  58. package/dist/types/components.d.ts +68 -0
  59. package/package.json +37 -7
  60. package/src/angular/index.ts +1 -0
  61. package/src/angular/proxies.ts +28 -0
  62. package/src/react/components.ts +46 -0
  63. package/src/react/ds-accordion.ts +27 -0
  64. package/src/react/ds-badge.ts +26 -0
  65. package/src/react/ds-banner.ts +27 -0
  66. package/src/react/ds-bar-nav-action.ts +27 -0
  67. package/src/react/ds-bar-nav.ts +33 -0
  68. package/src/react/ds-breadcrumb.ts +27 -0
  69. package/src/react/ds-button-group.ts +27 -0
  70. package/src/react/ds-button.ts +35 -0
  71. package/src/react/ds-card.ts +26 -0
  72. package/src/react/ds-checkbox.ts +27 -0
  73. package/src/react/ds-divider.ts +26 -0
  74. package/src/react/ds-empty-state.ts +26 -0
  75. package/src/react/ds-fade.ts +26 -0
  76. package/src/react/ds-field.ts +26 -0
  77. package/src/react/ds-header.ts +26 -0
  78. package/src/react/ds-icon.ts +26 -0
  79. package/src/react/ds-input.ts +33 -0
  80. package/src/react/ds-label-wrap.ts +26 -0
  81. package/src/react/ds-loader.ts +26 -0
  82. package/src/react/ds-menu.ts +33 -0
  83. package/src/react/ds-modal.ts +27 -0
  84. package/src/react/ds-pagination.ts +27 -0
  85. package/src/react/ds-panel-nav.ts +37 -0
  86. package/src/react/ds-radio-group.ts +27 -0
  87. package/src/react/ds-scrollbar.ts +26 -0
  88. package/src/react/ds-select.ts +27 -0
  89. package/src/react/ds-sidebar.ts +33 -0
  90. package/src/react/ds-skeleton.ts +26 -0
  91. package/src/react/ds-slider.ts +27 -0
  92. package/src/react/ds-surface.ts +26 -0
  93. package/src/react/ds-tab-group-nav.ts +27 -0
  94. package/src/react/ds-tab-group.ts +27 -0
  95. package/src/react/ds-table.ts +35 -0
  96. package/src/react/ds-tag.ts +35 -0
  97. package/src/react/ds-text.ts +26 -0
  98. package/src/react/ds-toast-provider.ts +26 -0
  99. package/src/react/ds-toggle-button-group.ts +27 -0
  100. package/src/react/ds-toggle-button.ts +27 -0
  101. package/src/react/ds-toggle.ts +27 -0
  102. package/src/react/ds-tooltip.ts +26 -0
  103. package/src/wc/components/Accordion/Accordion.tsx +144 -0
  104. package/src/wc/components/Badge/Badge.tsx +34 -0
  105. package/src/wc/components/Badge/index.ts +1 -0
  106. package/src/wc/components/Banner/Banner.tsx +148 -0
  107. package/src/wc/components/BarNav/BarNav.tsx +605 -0
  108. package/src/wc/components/BarNav/bar-nav-dom-utils.ts +23 -0
  109. package/src/wc/components/BarNav/bar-nav-tabs-menu-utils.ts +59 -0
  110. package/src/wc/components/BarNav/bar-nav-utils.ts +76 -0
  111. package/src/wc/components/BarNav/index.ts +1 -0
  112. package/src/wc/components/BarNavAction/BarNavAction.tsx +67 -0
  113. package/src/wc/components/BarNavAction/index.ts +1 -0
  114. package/src/wc/components/Breadcrumb/Breadcrumb.tsx +61 -0
  115. package/src/wc/components/Button/Button.tsx +158 -0
  116. package/src/wc/components/Button/index.ts +1 -0
  117. package/src/wc/components/ButtonGroup/ButtonGroup.tsx +110 -0
  118. package/src/wc/components/Card/Card.tsx +26 -0
  119. package/src/wc/components/Checkbox/Checkbox.tsx +60 -0
  120. package/src/wc/components/Checkbox/index.ts +1 -0
  121. package/src/wc/components/Divider/Divider.tsx +23 -0
  122. package/src/wc/components/Divider/index.ts +2 -0
  123. package/src/wc/components/EmptyState/EmptyState.tsx +32 -0
  124. package/src/wc/components/Fade/Fade.tsx +32 -0
  125. package/src/wc/components/Fade/index.ts +1 -0
  126. package/src/wc/components/Field/Field.tsx +28 -0
  127. package/src/wc/components/Header/Header.tsx +33 -0
  128. package/src/wc/components/Icon/Icon.tsx +112 -0
  129. package/src/wc/components/Icon/flag-icon-catalog.ts +68 -0
  130. package/src/wc/components/Icon/index.ts +1 -0
  131. package/src/wc/components/Icon/system-icon-catalog.ts +798 -0
  132. package/src/wc/components/Input/Input.tsx +100 -0
  133. package/src/wc/components/Input/index.ts +1 -0
  134. package/src/wc/components/LabelWrap/LabelWrap.tsx +38 -0
  135. package/src/wc/components/Loader/Loader.tsx +47 -0
  136. package/src/wc/components/Loader/index.ts +1 -0
  137. package/src/wc/components/Menu/Menu.tsx +340 -0
  138. package/src/wc/components/Modal/Modal.tsx +197 -0
  139. package/src/wc/components/Pagination/Pagination.tsx +97 -0
  140. package/src/wc/components/PanelNav/PanelNav.tsx +666 -0
  141. package/src/wc/components/PanelNav/index.ts +7 -0
  142. package/src/wc/components/PanelNav/panel-nav-utils.ts +124 -0
  143. package/src/wc/components/RadioGroup/RadioGroup.tsx +128 -0
  144. package/src/wc/components/RadioGroup/index.ts +1 -0
  145. package/src/wc/components/Scrollbar/Scrollbar.tsx +189 -0
  146. package/src/wc/components/Select/Select.tsx +195 -0
  147. package/src/wc/components/Sidebar/Sidebar.tsx +245 -0
  148. package/src/wc/components/Skeleton/Skeleton.tsx +64 -0
  149. package/src/wc/components/Skeleton/index.ts +1 -0
  150. package/src/wc/components/Slider/Slider.tsx +68 -0
  151. package/src/wc/components/Slider/index.ts +1 -0
  152. package/src/wc/components/Surface/Surface.tsx +81 -0
  153. package/src/wc/components/TabGroup/TabGroup.tsx +188 -0
  154. package/src/wc/components/TabGroup/tab-item-utils.ts +24 -0
  155. package/src/wc/components/TabGroupNav/TabGroupNav.tsx +188 -0
  156. package/src/wc/components/Table/Table.tsx +268 -0
  157. package/src/wc/components/Tag/Tag.tsx +135 -0
  158. package/src/wc/components/Tag/index.ts +1 -0
  159. package/src/wc/components/Text/Text.tsx +100 -0
  160. package/src/wc/components/Text/index.ts +1 -0
  161. package/src/wc/components/Toast/Toast.tsx +174 -0
  162. package/src/wc/components/Toast/toast-service.ts +60 -0
  163. package/src/wc/components/Toggle/Toggle.tsx +43 -0
  164. package/src/wc/components/Toggle/index.ts +1 -0
  165. package/src/wc/components/ToggleButton/ToggleButton.tsx +120 -0
  166. package/src/wc/components/ToggleButtonGroup/ToggleButtonGroup.tsx +118 -0
  167. package/src/wc/components/Tooltip/Tooltip.tsx +196 -0
  168. package/src/wc/components.d.ts +3028 -0
  169. package/dist/components/p-DV8pmt1m.js +0 -2
  170. package/dist/components/p-DV8pmt1m.js.map +0 -1
  171. package/dist/components/p-STnE_bmQ.js +0 -2
  172. package/dist/components/p-STnE_bmQ.js.map +0 -1
  173. /package/dist/components/{p-D3CwGoIu.js.map → p-COsKOwZR.js.map} +0 -0
  174. /package/dist/components/{p-DouEVPmi.js.map → p-Dd0GrKaF.js.map} +0 -0
  175. /package/dist/components/{p-BvxKLqNx.js.map → p-H89PzqGU.js.map} +0 -0
  176. /package/dist/components/{p-BYe8Rfdg.js.map → p-Vwlmhl4E.js.map} +0 -0
  177. /package/dist/components/{p-C4p1fG67.js.map → p-woooYulR.js.map} +0 -0
@@ -0,0 +1,100 @@
1
+ import { Component, Prop, Event, EventEmitter, Element, Method, h, Host } from '@stencil/core';
2
+
3
+ export type InputType = 'text' | 'email' | 'tel' | 'url' | 'search' | 'password';
4
+
5
+ let idCounter = 0;
6
+
7
+ @Component({
8
+ tag: 'ds-input',
9
+ styleUrl: 'Input.css',
10
+ scoped: true,
11
+ })
12
+ export class Input {
13
+ @Element() el!: HTMLElement;
14
+
15
+ private generatedId = `ds-input-${++idCounter}`;
16
+ private errorId = `${this.generatedId}-error`;
17
+
18
+ @Prop() value: string = '';
19
+ @Prop() placeholder: string | undefined;
20
+ @Prop() type: InputType = 'text';
21
+ @Prop() inactive: boolean = false;
22
+ @Prop() autoFocus: boolean = false;
23
+ @Prop() error: boolean = false;
24
+ @Prop() errorMessage: string | undefined;
25
+ /** Associates the internal input with an external <label>. */
26
+ @Prop() inputId: string | undefined;
27
+ @Prop({ attribute: 'aria-label' }) ariaLabel: string | undefined;
28
+ @Prop({ attribute: 'aria-labelledby' }) ariaLabelledby: string | undefined;
29
+ @Prop({ attribute: 'aria-describedby' }) ariaDescribedby: string | undefined;
30
+
31
+ @Event() dsChange!: EventEmitter<string>;
32
+ @Event() dsClear!: EventEmitter<void>;
33
+
34
+ @Method()
35
+ async setFocus() {
36
+ this.el.querySelector('input')?.focus();
37
+ }
38
+
39
+ private handleInput = (e: Event) => {
40
+ this.dsChange.emit((e.target as HTMLInputElement).value);
41
+ };
42
+
43
+ private handleClear = () => {
44
+ this.dsChange.emit('');
45
+ this.dsClear.emit();
46
+ this.el.querySelector('input')?.focus();
47
+ };
48
+
49
+ render() {
50
+ const inputId = this.inputId ?? this.generatedId;
51
+ const showClear = this.type === 'search' && this.value.length > 0 && !this.inactive;
52
+ const showError = this.error && Boolean(this.errorMessage);
53
+
54
+ const describedBy = [
55
+ this.ariaDescribedby,
56
+ showError ? this.errorId : undefined,
57
+ ].filter(Boolean).join(' ') || undefined;
58
+
59
+ return (
60
+ <Host class="input-container">
61
+ <div class={{ wrapper: true, 'wrapper--error': this.error }}>
62
+ <div class="row">
63
+ <input
64
+ type={this.type}
65
+ id={inputId}
66
+ value={this.value}
67
+ placeholder={this.placeholder}
68
+ disabled={this.inactive}
69
+ autoFocus={this.autoFocus}
70
+ class="native-input"
71
+ aria-label={this.ariaLabel}
72
+ aria-labelledby={this.ariaLabelledby}
73
+ aria-describedby={describedBy}
74
+ aria-invalid={this.error || undefined}
75
+ onInput={this.handleInput}
76
+ />
77
+ {showClear && (
78
+ <button
79
+ type="button"
80
+ class="clear-btn"
81
+ onClick={this.handleClear}
82
+ aria-label="Clear"
83
+ >
84
+ <span aria-hidden="true">&times;</span>
85
+ </button>
86
+ )}
87
+ <span class="suffix">
88
+ <slot name="suffix" />
89
+ </span>
90
+ </div>
91
+ </div>
92
+ {showError && (
93
+ <div id={this.errorId} role="alert" class="error-text">
94
+ {this.errorMessage}
95
+ </div>
96
+ )}
97
+ </Host>
98
+ );
99
+ }
100
+ }
@@ -0,0 +1 @@
1
+ export type { InputType } from './Input';
@@ -0,0 +1,38 @@
1
+ import { Component, Prop, h, Host } from '@stencil/core';
2
+
3
+ export type LabelWrapSize = 'xs' | 'sm' | 'md' | 'lg';
4
+
5
+ /**
6
+ * Internal utility wrapper that kills line-height struts and adds optical
7
+ * horizontal padding. Used inside interactive components (Button, Tag, etc.)
8
+ *
9
+ * Not intended as a standalone consumer-facing component — internal use only.
10
+ */
11
+ @Component({
12
+ tag: 'ds-label-wrap',
13
+ styleUrl: 'LabelWrap.css',
14
+ scoped: true,
15
+ })
16
+ export class LabelWrap {
17
+ /** Typography size — controls optical horizontal padding. */
18
+ @Prop() size: LabelWrapSize = 'md';
19
+
20
+ /** Enable truncation with ellipsis — use inside full-width containers. */
21
+ @Prop() truncate: boolean = false;
22
+
23
+ render() {
24
+ return (
25
+ <Host
26
+ class={{
27
+ 'label-wrap': true,
28
+ 'size-xs': this.size === 'xs',
29
+ 'size-sm': this.size === 'sm',
30
+ 'size-lg': this.size === 'lg',
31
+ truncate: this.truncate,
32
+ }}
33
+ >
34
+ <slot />
35
+ </Host>
36
+ );
37
+ }
38
+ }
@@ -0,0 +1,47 @@
1
+ import { Component, Prop, h } from '@stencil/core';
2
+
3
+ @Component({
4
+ tag: 'ds-loader',
5
+ styleUrl: 'Loader.css',
6
+ shadow: true,
7
+ })
8
+ export class Loader {
9
+ /** Render size in px. Designed on a 16×16 viewBox; stroke scales proportionally. */
10
+ @Prop() size: number = 20;
11
+ /**
12
+ * Accessible label for standalone usage. Wraps the spinner in a live region
13
+ * and renders the label visually-hidden. Omit when the host element already
14
+ * conveys busy state via aria-busy.
15
+ */
16
+ @Prop() label: string | undefined;
17
+
18
+ render() {
19
+ const px = `${this.size}px`;
20
+ const svg = (
21
+ <svg
22
+ style={{ width: px, height: px }}
23
+ viewBox="0 0 16 16"
24
+ fill="none"
25
+ class="spinner"
26
+ aria-hidden="true"
27
+ >
28
+ <circle
29
+ cx="8" cy="8" r="5"
30
+ stroke="currentColor"
31
+ stroke-width="1"
32
+ stroke-linecap="round"
33
+ stroke-dasharray="23.6 7.9"
34
+ />
35
+ </svg>
36
+ );
37
+
38
+ if (!this.label) return svg;
39
+
40
+ return (
41
+ <span role="status" aria-live="polite" class="status">
42
+ {svg}
43
+ <span class="visually-hidden">{this.label}</span>
44
+ </span>
45
+ );
46
+ }
47
+ }
@@ -0,0 +1 @@
1
+ export type {} from './Loader';
@@ -0,0 +1,340 @@
1
+ import { Component, Prop, State, Event, EventEmitter, Element, Watch, Listen, h, Host } from '@stencil/core';
2
+
3
+ export type MenuSide = 'top' | 'right' | 'bottom' | 'left';
4
+ export type MenuAlign = 'start' | 'center' | 'end';
5
+
6
+ export interface MenuItemData {
7
+ label: string;
8
+ value?: string;
9
+ subtext?: string;
10
+ isSelected?: boolean;
11
+ isInactive?: boolean;
12
+ isDestructive?: boolean;
13
+ showToggle?: boolean;
14
+ toggleValue?: boolean;
15
+ }
16
+
17
+ export interface MenuSection {
18
+ header?: string;
19
+ items: MenuItemData[];
20
+ }
21
+
22
+ const CLOSE_ANIMATION_MS = 220;
23
+ const VP_PAD = 4;
24
+
25
+ @Component({
26
+ tag: 'ds-menu',
27
+ styleUrl: 'Menu.css',
28
+ scoped: true,
29
+ })
30
+ export class Menu {
31
+ @Element() el!: HTMLElement;
32
+
33
+ @Prop({ mutable: true }) open: boolean = false;
34
+ @Prop() items: MenuItemData[] = [];
35
+ @Prop() sections: MenuSection[] = [];
36
+ @Prop() side: MenuSide = 'bottom';
37
+ @Prop() align: MenuAlign = 'start';
38
+ @Prop() sideOffset: number = 4;
39
+ @Prop() alignOffset: number = 0;
40
+ @Prop() menuWidth: string | undefined;
41
+ @Prop() minWidth: string | undefined;
42
+ /** External trigger element to position against. Set via JS: menuEl.anchor = buttonEl */
43
+ @Prop() anchor: HTMLElement | undefined;
44
+ /** ID of the external trigger element for positioning */
45
+ @Prop() anchorId: string | undefined;
46
+
47
+ @State() private shouldRender: boolean = false;
48
+ @State() private closing: boolean = false;
49
+ @State() private pos: { x: number; y: number } = { x: 0, y: 0 };
50
+ @State() private focusedIndex: number = 0;
51
+ @State() private positionReady: boolean = false;
52
+
53
+ @Event() dsClose!: EventEmitter<void>;
54
+ @Event() dsSelect!: EventEmitter<MenuItemData>;
55
+
56
+ private clickOutsideHandler: ((e: MouseEvent) => void) | null = null;
57
+ private scrollResizeHandler: (() => void) | null = null;
58
+ private closeTimer: ReturnType<typeof setTimeout> | null = null;
59
+ private itemEls: HTMLElement[] = [];
60
+
61
+ componentDidLoad() {
62
+ if (this.open) this.onOpenChange(true);
63
+ }
64
+
65
+ disconnectedCallback() {
66
+ this.teardownListeners();
67
+ }
68
+
69
+ @Watch('open')
70
+ onOpenChange(isOpen: boolean) {
71
+ if (isOpen) {
72
+ this.shouldRender = true;
73
+ this.closing = false;
74
+ this.positionReady = false;
75
+ requestAnimationFrame(() => {
76
+ this.calculatePosition();
77
+ this.positionReady = true;
78
+ this.focusInitialItem();
79
+ this.setupListeners();
80
+ });
81
+ } else if (this.shouldRender) {
82
+ this.closing = true;
83
+ this.teardownListeners();
84
+ this.closeTimer = setTimeout(() => {
85
+ this.shouldRender = false;
86
+ this.closing = false;
87
+ this.closeTimer = null;
88
+ }, CLOSE_ANIMATION_MS);
89
+ }
90
+ }
91
+
92
+ private get resolvedAnchor(): HTMLElement | null {
93
+ if (this.anchor) return this.anchor;
94
+ if (this.anchorId) return document.getElementById(this.anchorId);
95
+ return null;
96
+ }
97
+
98
+ private get activeSections(): MenuSection[] {
99
+ if (this.sections.length > 0) return this.sections;
100
+ if (this.items.length > 0) return [{ items: this.items }];
101
+ return [];
102
+ }
103
+
104
+ private get flatItems(): MenuItemData[] {
105
+ return this.activeSections.flatMap(s => s.items);
106
+ }
107
+
108
+ private calculatePosition() {
109
+ const anchorEl = this.resolvedAnchor;
110
+ if (!anchorEl) return;
111
+ const popup = this.el.querySelector('.menu-popup') as HTMLElement | null;
112
+ if (!popup) return;
113
+
114
+ const a = anchorEl.getBoundingClientRect();
115
+ const pw = popup.offsetWidth || 200;
116
+ const ph = popup.offsetHeight || 160;
117
+ let x = 0, y = 0;
118
+
119
+ switch (this.side) {
120
+ case 'top':
121
+ y = a.top - ph - this.sideOffset;
122
+ x = this.align === 'start' ? a.left + this.alignOffset
123
+ : this.align === 'end' ? a.right - pw + this.alignOffset
124
+ : a.left + a.width / 2 - pw / 2 + this.alignOffset;
125
+ break;
126
+ case 'bottom':
127
+ y = a.bottom + this.sideOffset;
128
+ x = this.align === 'start' ? a.left + this.alignOffset
129
+ : this.align === 'end' ? a.right - pw + this.alignOffset
130
+ : a.left + a.width / 2 - pw / 2 + this.alignOffset;
131
+ break;
132
+ case 'left':
133
+ x = a.left - pw - this.sideOffset;
134
+ y = this.align === 'start' ? a.top + this.alignOffset
135
+ : this.align === 'end' ? a.bottom - ph + this.alignOffset
136
+ : a.top + a.height / 2 - ph / 2 + this.alignOffset;
137
+ break;
138
+ case 'right':
139
+ x = a.right + this.sideOffset;
140
+ y = this.align === 'start' ? a.top + this.alignOffset
141
+ : this.align === 'end' ? a.bottom - ph + this.alignOffset
142
+ : a.top + a.height / 2 - ph / 2 + this.alignOffset;
143
+ break;
144
+ }
145
+
146
+ this.pos = {
147
+ x: Math.min(Math.max(x, VP_PAD), window.innerWidth - pw - VP_PAD),
148
+ y: Math.min(Math.max(y, VP_PAD), window.innerHeight - ph - VP_PAD),
149
+ };
150
+ }
151
+
152
+ /** Focus the selected item when present, otherwise the first enabled item. */
153
+ private focusInitialItem() {
154
+ const flat = this.flatItems;
155
+ const selectedIdx = flat.findIndex(it => it.isSelected && !it.isInactive);
156
+ const firstEnabledIdx = flat.findIndex(it => !it.isInactive);
157
+ this.focusedIndex = selectedIdx >= 0
158
+ ? selectedIdx
159
+ : (firstEnabledIdx >= 0 ? firstEnabledIdx : 0);
160
+
161
+ requestAnimationFrame(() => {
162
+ const btns = this.el.querySelectorAll<HTMLElement>('.menu-item');
163
+ btns[this.focusedIndex]?.focus();
164
+ });
165
+ }
166
+
167
+ private setupListeners() {
168
+ this.clickOutsideHandler = (e: MouseEvent) => {
169
+ const t = e.target as Node;
170
+ const popup = this.el.querySelector('.menu-popup');
171
+ const anchorEl = this.resolvedAnchor;
172
+ if (popup?.contains(t) || anchorEl?.contains(t)) return;
173
+ this.close();
174
+ };
175
+
176
+ this.scrollResizeHandler = () => this.calculatePosition();
177
+
178
+ document.addEventListener('mousedown', this.clickOutsideHandler, true);
179
+ window.addEventListener('scroll', this.scrollResizeHandler, true);
180
+ window.addEventListener('resize', this.scrollResizeHandler);
181
+ }
182
+
183
+ private teardownListeners() {
184
+ if (this.clickOutsideHandler) {
185
+ document.removeEventListener('mousedown', this.clickOutsideHandler, true);
186
+ this.clickOutsideHandler = null;
187
+ }
188
+ if (this.scrollResizeHandler) {
189
+ window.removeEventListener('scroll', this.scrollResizeHandler, true);
190
+ window.removeEventListener('resize', this.scrollResizeHandler);
191
+ this.scrollResizeHandler = null;
192
+ }
193
+ if (this.closeTimer) {
194
+ clearTimeout(this.closeTimer);
195
+ this.closeTimer = null;
196
+ }
197
+ }
198
+
199
+ private close() {
200
+ this.resolvedAnchor?.focus();
201
+ this.dsClose.emit();
202
+ this.open = false;
203
+ }
204
+
205
+ @Listen('keydown')
206
+ handleKeyDown(e: KeyboardEvent) {
207
+ if (!this.open) return;
208
+ const flat = this.flatItems;
209
+ const enabled = flat.map((it, i) => ({ it, i })).filter(({ it }) => !it.isInactive).map(({ i }) => i);
210
+ if (!enabled.length) return;
211
+
212
+ const cur = enabled.indexOf(this.focusedIndex);
213
+ const safe = cur < 0 ? 0 : cur;
214
+
215
+ switch (e.key) {
216
+ case 'ArrowDown':
217
+ e.preventDefault(); e.stopPropagation();
218
+ this.focusedIndex = enabled[(safe + 1) % enabled.length];
219
+ this.focusItem(this.focusedIndex);
220
+ break;
221
+ case 'ArrowUp':
222
+ e.preventDefault(); e.stopPropagation();
223
+ this.focusedIndex = enabled[(safe - 1 + enabled.length) % enabled.length];
224
+ this.focusItem(this.focusedIndex);
225
+ break;
226
+ case 'Home':
227
+ e.preventDefault(); e.stopPropagation();
228
+ this.focusedIndex = enabled[0];
229
+ this.focusItem(this.focusedIndex);
230
+ break;
231
+ case 'End':
232
+ e.preventDefault(); e.stopPropagation();
233
+ this.focusedIndex = enabled[enabled.length - 1];
234
+ this.focusItem(this.focusedIndex);
235
+ break;
236
+ case 'Escape':
237
+ e.preventDefault();
238
+ this.close();
239
+ break;
240
+ case 'Tab':
241
+ e.preventDefault();
242
+ this.close();
243
+ break;
244
+ }
245
+ }
246
+
247
+ private focusItem(idx: number) {
248
+ const btns = this.el.querySelectorAll<HTMLElement>('.menu-item');
249
+ btns[idx]?.focus();
250
+ }
251
+
252
+ private handleItemClick(item: MenuItemData) {
253
+ if (item.isInactive) return;
254
+ this.dsSelect.emit(item);
255
+ this.close();
256
+ }
257
+
258
+ render() {
259
+ if (!this.shouldRender) return <Host style={{ display: 'contents' }} />;
260
+
261
+ const sections = this.activeSections;
262
+ let flatIdx = 0;
263
+
264
+ const popupStyle: Record<string, string> = {
265
+ position: 'fixed',
266
+ left: '0',
267
+ top: '0',
268
+ transform: `translate(${Math.round(this.pos.x)}px, ${Math.round(this.pos.y)}px)`,
269
+ zIndex: '9998',
270
+ visibility: this.positionReady ? 'visible' : 'hidden',
271
+ };
272
+
273
+ if (this.menuWidth) popupStyle['width'] = this.menuWidth;
274
+ if (this.minWidth) popupStyle['min-width'] = this.minWidth;
275
+
276
+ return (
277
+ <Host style={{ display: 'contents' }}>
278
+ <div
279
+ class={{ 'menu-popup': true, 'menu-popup--closing': this.closing }}
280
+ style={popupStyle}
281
+ role="menu"
282
+ aria-label="Menu"
283
+ aria-orientation="vertical"
284
+ >
285
+ {sections.map((section, si) => (
286
+ <div
287
+ key={si}
288
+ class={{ 'menu-section': true, 'menu-section--divided': si < sections.length - 1 }}
289
+ role={section.header ? 'group' : undefined}
290
+ aria-label={section.header}
291
+ >
292
+ {section.header && (
293
+ <div class="section-header" aria-hidden="true">
294
+ <span class="text-body-small-emphasis section-label">{section.header}</span>
295
+ </div>
296
+ )}
297
+ {section.items.map(item => {
298
+ const idx = flatIdx++;
299
+ const isFocused = this.focusedIndex === idx;
300
+ return (
301
+ <button
302
+ key={idx}
303
+ type="button"
304
+ class={{
305
+ 'menu-item': true,
306
+ 'menu-item--selected': !!item.isSelected,
307
+ 'menu-item--inactive': !!item.isInactive,
308
+ 'menu-item--destructive': !!item.isDestructive,
309
+ 'menu-item--focused': isFocused,
310
+ }}
311
+ role="menuitem"
312
+ aria-current={item.isSelected ? 'true' : undefined}
313
+ disabled={item.isInactive}
314
+ tabIndex={isFocused ? 0 : -1}
315
+ onClick={() => this.handleItemClick(item)}
316
+ onFocus={() => { this.focusedIndex = idx; }}
317
+ >
318
+ <div class="menu-item__content">
319
+ <span class={item.isSelected ? 'text-body-medium-emphasis menu-item__label' : 'text-body-medium menu-item__label'}>
320
+ {item.label}
321
+ </span>
322
+ {item.subtext && (
323
+ <span class="text-body-small menu-item__subtext">{item.subtext}</span>
324
+ )}
325
+ </div>
326
+ {item.showToggle && (
327
+ <div class={{ 'menu-item__toggle': true, 'menu-item__toggle--on': !!item.toggleValue }} aria-hidden="true">
328
+ <div class="menu-item__toggle-thumb" />
329
+ </div>
330
+ )}
331
+ </button>
332
+ );
333
+ })}
334
+ </div>
335
+ ))}
336
+ </div>
337
+ </Host>
338
+ );
339
+ }
340
+ }