@ds-mo/ui 1.0.2 → 1.2.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/README.md +0 -1
  2. package/dist/components/ds-bar-nav-action.js +1 -1
  3. package/dist/components/ds-bar-nav.js +1 -1
  4. package/dist/components/ds-bar-nav.js.map +1 -1
  5. package/dist/components/ds-breadcrumb.js +1 -1
  6. package/dist/components/ds-button-group.js +1 -1
  7. package/dist/components/ds-button.js +1 -1
  8. package/dist/components/ds-card.js +1 -1
  9. package/dist/components/ds-card.js.map +1 -1
  10. package/dist/components/ds-checkbox.js +1 -1
  11. package/dist/components/ds-divider.js +1 -1
  12. package/dist/components/ds-divider.js.map +1 -1
  13. package/dist/components/ds-empty-state.js +1 -1
  14. package/dist/components/ds-empty-state.js.map +1 -1
  15. package/dist/components/ds-fade.js +1 -1
  16. package/dist/components/ds-field.js +1 -1
  17. package/dist/components/ds-header.js +1 -1
  18. package/dist/components/ds-header.js.map +1 -1
  19. package/dist/components/ds-icon.js +1 -1
  20. package/dist/components/ds-input.js +1 -1
  21. package/dist/components/ds-menu.js +1 -1
  22. package/dist/components/ds-panel-nav.js +1 -1
  23. package/dist/components/ds-select.js +1 -1
  24. package/dist/components/ds-slider.js +1 -1
  25. package/dist/components/{ds-label-wrap.d.ts → ds-tab-group-nav.d.ts} +4 -4
  26. package/dist/components/ds-tab-group-nav.js +2 -0
  27. package/dist/components/ds-tab-group-nav.js.map +1 -0
  28. package/dist/components/ds-tab-group.js +1 -1
  29. package/dist/components/ds-tab-group.js.map +1 -1
  30. package/dist/components/ds-table.js +1 -1
  31. package/dist/components/ds-table.js.map +1 -1
  32. package/dist/components/ds-tag.js +1 -1
  33. package/dist/components/ds-text.js +1 -1
  34. package/dist/components/ds-toast-provider.js +1 -1
  35. package/dist/components/ds-toggle-button-group.js +1 -1
  36. package/dist/components/ds-toggle-button.js +1 -1
  37. package/dist/components/ds-toggle.js +1 -1
  38. package/dist/components/ds-tooltip.js +1 -1
  39. package/dist/components/p-BQq26pt9.js +2 -0
  40. package/dist/components/p-BQq26pt9.js.map +1 -0
  41. package/dist/components/{p-DouEVPmi.js → p-CE5zeV1w.js} +2 -2
  42. package/dist/components/{p-D3CwGoIu.js → p-COsKOwZR.js} +2 -2
  43. package/dist/components/p-CQQe6VNo.js +2 -0
  44. package/dist/components/p-CQQe6VNo.js.map +1 -0
  45. package/dist/components/p-Cj00JkJb.js +2 -0
  46. package/dist/components/p-Cj00JkJb.js.map +1 -0
  47. package/dist/components/p-CpbnmEu1.js +2 -0
  48. package/dist/components/p-CpbnmEu1.js.map +1 -0
  49. package/dist/components/{p-BvxKLqNx.js → p-H89PzqGU.js} +2 -2
  50. package/dist/components/{p-BYe8Rfdg.js → p-Vwlmhl4E.js} +2 -2
  51. package/dist/types/.stencil/BarNav/BarNav.d.ts +7 -2
  52. package/dist/types/.stencil/BarNav/bar-nav-dom-utils.d.ts +1 -2
  53. package/dist/types/.stencil/Divider/Divider.d.ts +14 -0
  54. package/dist/types/.stencil/TabGroupNav/TabGroupNav.d.ts +27 -0
  55. package/dist/types/.stencil/Text/Text.d.ts +2 -0
  56. package/dist/types/components.d.ts +125 -63
  57. package/package.json +37 -7
  58. package/src/angular/index.ts +1 -1
  59. package/src/angular/proxies.ts +32 -27
  60. package/src/react/components.ts +45 -0
  61. package/src/react/ds-accordion.ts +27 -0
  62. package/src/react/ds-badge.ts +26 -0
  63. package/src/react/ds-banner.ts +27 -0
  64. package/src/react/ds-bar-nav-action.ts +27 -0
  65. package/src/react/ds-bar-nav.ts +33 -0
  66. package/src/react/ds-breadcrumb.ts +27 -0
  67. package/src/react/ds-button-group.ts +27 -0
  68. package/src/react/ds-button.ts +35 -0
  69. package/src/react/ds-card.ts +26 -0
  70. package/src/react/ds-checkbox.ts +27 -0
  71. package/src/react/ds-divider.ts +26 -0
  72. package/src/react/ds-empty-state.ts +26 -0
  73. package/src/react/ds-fade.ts +26 -0
  74. package/src/react/ds-field.ts +26 -0
  75. package/src/react/ds-header.ts +26 -0
  76. package/src/react/ds-icon.ts +26 -0
  77. package/src/react/ds-input.ts +33 -0
  78. package/src/react/ds-loader.ts +26 -0
  79. package/src/react/ds-menu.ts +33 -0
  80. package/src/react/ds-modal.ts +27 -0
  81. package/src/react/ds-pagination.ts +27 -0
  82. package/src/react/ds-panel-nav.ts +37 -0
  83. package/src/react/ds-radio-group.ts +27 -0
  84. package/src/react/ds-scrollbar.ts +26 -0
  85. package/src/react/ds-select.ts +27 -0
  86. package/src/react/ds-sidebar.ts +33 -0
  87. package/src/react/ds-skeleton.ts +26 -0
  88. package/src/react/ds-slider.ts +27 -0
  89. package/src/react/ds-surface.ts +26 -0
  90. package/src/react/ds-tab-group-nav.ts +27 -0
  91. package/src/react/ds-tab-group.ts +27 -0
  92. package/src/react/ds-table.ts +35 -0
  93. package/src/react/ds-tag.ts +35 -0
  94. package/src/react/ds-text.ts +26 -0
  95. package/src/react/ds-toast-provider.ts +26 -0
  96. package/src/react/ds-toggle-button-group.ts +27 -0
  97. package/src/react/ds-toggle-button.ts +27 -0
  98. package/src/react/ds-toggle.ts +27 -0
  99. package/src/react/ds-tooltip.ts +26 -0
  100. package/src/wc/components/Accordion/Accordion.tsx +144 -0
  101. package/src/wc/components/Badge/Badge.tsx +34 -0
  102. package/src/wc/components/Badge/index.ts +1 -0
  103. package/src/wc/components/Banner/Banner.tsx +148 -0
  104. package/src/wc/components/BarNav/BarNav.tsx +605 -0
  105. package/src/wc/components/BarNav/bar-nav-dom-utils.ts +23 -0
  106. package/src/wc/components/BarNav/bar-nav-tabs-menu-utils.ts +59 -0
  107. package/src/wc/components/BarNav/bar-nav-utils.ts +76 -0
  108. package/src/wc/components/BarNav/index.ts +1 -0
  109. package/src/wc/components/BarNavAction/BarNavAction.tsx +67 -0
  110. package/src/wc/components/BarNavAction/index.ts +1 -0
  111. package/src/wc/components/Breadcrumb/Breadcrumb.tsx +61 -0
  112. package/src/wc/components/Button/Button.tsx +158 -0
  113. package/src/wc/components/Button/index.ts +1 -0
  114. package/src/wc/components/ButtonGroup/ButtonGroup.tsx +110 -0
  115. package/src/wc/components/Card/Card.tsx +26 -0
  116. package/src/wc/components/Checkbox/Checkbox.tsx +60 -0
  117. package/src/wc/components/Checkbox/index.ts +1 -0
  118. package/src/wc/components/Divider/Divider.tsx +107 -0
  119. package/src/wc/components/Divider/index.ts +2 -0
  120. package/src/wc/components/EmptyState/EmptyState.tsx +32 -0
  121. package/src/wc/components/Fade/Fade.tsx +32 -0
  122. package/src/wc/components/Fade/index.ts +1 -0
  123. package/src/wc/components/Field/Field.tsx +28 -0
  124. package/src/wc/components/Header/Header.tsx +33 -0
  125. package/src/wc/components/Icon/Icon.tsx +112 -0
  126. package/src/wc/components/Icon/flag-icon-catalog.ts +68 -0
  127. package/src/wc/components/Icon/index.ts +1 -0
  128. package/src/wc/components/Icon/system-icon-catalog.ts +798 -0
  129. package/src/wc/components/Input/Input.tsx +100 -0
  130. package/src/wc/components/Input/index.ts +1 -0
  131. package/src/wc/components/Loader/Loader.tsx +47 -0
  132. package/src/wc/components/Loader/index.ts +1 -0
  133. package/src/wc/components/Menu/Menu.tsx +340 -0
  134. package/src/wc/components/Modal/Modal.tsx +197 -0
  135. package/src/wc/components/Pagination/Pagination.tsx +97 -0
  136. package/src/wc/components/PanelNav/PanelNav.tsx +666 -0
  137. package/src/wc/components/PanelNav/index.ts +7 -0
  138. package/src/wc/components/PanelNav/panel-nav-utils.ts +124 -0
  139. package/src/wc/components/RadioGroup/RadioGroup.tsx +128 -0
  140. package/src/wc/components/RadioGroup/index.ts +1 -0
  141. package/src/wc/components/Scrollbar/Scrollbar.tsx +189 -0
  142. package/src/wc/components/Select/Select.tsx +195 -0
  143. package/src/wc/components/Sidebar/Sidebar.tsx +245 -0
  144. package/src/wc/components/Skeleton/Skeleton.tsx +64 -0
  145. package/src/wc/components/Skeleton/index.ts +1 -0
  146. package/src/wc/components/Slider/Slider.tsx +68 -0
  147. package/src/wc/components/Slider/index.ts +1 -0
  148. package/src/wc/components/Surface/Surface.tsx +81 -0
  149. package/src/wc/components/TabGroup/TabGroup.tsx +188 -0
  150. package/src/wc/components/TabGroup/tab-item-utils.ts +24 -0
  151. package/src/wc/components/TabGroupNav/TabGroupNav.tsx +188 -0
  152. package/src/wc/components/Table/Table.tsx +268 -0
  153. package/src/wc/components/Tag/Tag.tsx +135 -0
  154. package/src/wc/components/Tag/index.ts +1 -0
  155. package/src/wc/components/Text/Text.tsx +103 -0
  156. package/src/wc/components/Text/index.ts +1 -0
  157. package/src/wc/components/Toast/Toast.tsx +174 -0
  158. package/src/wc/components/Toast/toast-service.ts +60 -0
  159. package/src/wc/components/Toggle/Toggle.tsx +43 -0
  160. package/src/wc/components/Toggle/index.ts +1 -0
  161. package/src/wc/components/ToggleButton/ToggleButton.tsx +120 -0
  162. package/src/wc/components/ToggleButtonGroup/ToggleButtonGroup.tsx +118 -0
  163. package/src/wc/components/Tooltip/Tooltip.tsx +196 -0
  164. package/src/wc/components.d.ts +3022 -0
  165. package/dist/components/ds-label-wrap.js +0 -2
  166. package/dist/components/ds-label-wrap.js.map +0 -1
  167. package/dist/components/p-C4p1fG67.js +0 -2
  168. package/dist/components/p-C4p1fG67.js.map +0 -1
  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/types/.stencil/LabelWrap/LabelWrap.d.ts +0 -15
  174. /package/dist/components/{p-DouEVPmi.js.map → p-CE5zeV1w.js.map} +0 -0
  175. /package/dist/components/{p-D3CwGoIu.js.map → p-COsKOwZR.js.map} +0 -0
  176. /package/dist/components/{p-BvxKLqNx.js.map → p-H89PzqGU.js.map} +0 -0
  177. /package/dist/components/{p-BYe8Rfdg.js.map → p-Vwlmhl4E.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,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
+ }
@@ -0,0 +1,197 @@
1
+ import { Component, Prop, State, Event, EventEmitter, Element, Watch, h, Host } from '@stencil/core';
2
+
3
+ export type ModalWidth = 'sm' | 'md' | 'lg';
4
+
5
+ const WIDTH_MAP: Record<ModalWidth, string> = {
6
+ sm: 'var(--dimension-modal-width-sm, 400px)',
7
+ md: 'var(--dimension-modal-width-md, 560px)',
8
+ lg: 'var(--dimension-modal-width-lg, 720px)',
9
+ };
10
+
11
+ const CLOSE_ANIMATION_MS = 220;
12
+
13
+ const FOCUSABLE_SEL = [
14
+ 'a[href]',
15
+ 'area[href]',
16
+ 'button:not([disabled])',
17
+ 'input:not([disabled]):not([type="hidden"])',
18
+ 'select:not([disabled])',
19
+ 'textarea:not([disabled])',
20
+ 'iframe',
21
+ '[contenteditable="true"]',
22
+ '[tabindex]:not([tabindex="-1"])',
23
+ ].join(',');
24
+
25
+ let modalIdCounter = 0;
26
+
27
+ @Component({
28
+ tag: 'ds-modal',
29
+ styleUrl: 'Modal.css',
30
+ scoped: true,
31
+ })
32
+ export class Modal {
33
+ @Element() el!: HTMLElement;
34
+
35
+ @Prop({ mutable: true }) open: boolean = false;
36
+ @Prop() heading!: string;
37
+ @Prop() subtitle: string | undefined;
38
+ @Prop() modalWidth: ModalWidth | string = 'md';
39
+
40
+ @State() private shouldRender: boolean = false;
41
+ @State() private closing: boolean = false;
42
+
43
+ @Event() dsClose!: EventEmitter<void>;
44
+
45
+ private instanceId = ++modalIdCounter;
46
+ private titleId = `ds-modal-title-${modalIdCounter}`;
47
+ private subtitleId = `ds-modal-subtitle-${modalIdCounter}`;
48
+ private previousFocus: HTMLElement | null = null;
49
+ private escapeHandler: ((e: KeyboardEvent) => void) | null = null;
50
+ private tabHandler: ((e: KeyboardEvent) => void) | null = null;
51
+ private closeTimer: ReturnType<typeof setTimeout> | null = null;
52
+
53
+ componentDidLoad() {
54
+ if (this.open) this.onOpenChange(true);
55
+ }
56
+
57
+ disconnectedCallback() {
58
+ this.teardownListeners();
59
+ }
60
+
61
+ @Watch('open')
62
+ onOpenChange(isOpen: boolean) {
63
+ if (isOpen) {
64
+ this.shouldRender = true;
65
+ this.closing = false;
66
+ this.previousFocus = document.activeElement as HTMLElement | null;
67
+ requestAnimationFrame(() => {
68
+ this.focusFirst();
69
+ this.setupListeners();
70
+ });
71
+ } else if (this.shouldRender) {
72
+ this.closing = true;
73
+ this.teardownListeners();
74
+ this.closeTimer = setTimeout(() => {
75
+ this.shouldRender = false;
76
+ this.closing = false;
77
+ this.closeTimer = null;
78
+ this.previousFocus?.focus?.();
79
+ this.previousFocus = null;
80
+ }, CLOSE_ANIMATION_MS);
81
+ }
82
+ }
83
+
84
+ private focusFirst() {
85
+ const dialog = this.el.querySelector('.modal-dialog') as HTMLElement | null;
86
+ if (!dialog) return;
87
+ const focusables = this.getFocusables(dialog);
88
+ (focusables[0] ?? dialog).focus();
89
+ }
90
+
91
+ private getFocusables(root: HTMLElement): HTMLElement[] {
92
+ return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SEL)).filter(
93
+ el => !el.hasAttribute('disabled') && el.getAttribute('aria-hidden') !== 'true'
94
+ );
95
+ }
96
+
97
+ private setupListeners() {
98
+ this.escapeHandler = (e: KeyboardEvent) => {
99
+ if (e.key === 'Escape') { e.preventDefault(); this.close(); }
100
+ };
101
+
102
+ this.tabHandler = (e: KeyboardEvent) => {
103
+ if (e.key !== 'Tab') return;
104
+ const dialog = this.el.querySelector('.modal-dialog') as HTMLElement | null;
105
+ if (!dialog) return;
106
+ const focusables = this.getFocusables(dialog);
107
+ if (!focusables.length) { e.preventDefault(); dialog.focus(); return; }
108
+ const first = focusables[0];
109
+ const last = focusables[focusables.length - 1];
110
+ const active = document.activeElement;
111
+ if (e.shiftKey) {
112
+ if (active === first || active === dialog || !dialog.contains(active)) {
113
+ e.preventDefault(); last.focus();
114
+ }
115
+ } else {
116
+ if (active === last || !dialog.contains(active)) {
117
+ e.preventDefault(); first.focus();
118
+ }
119
+ }
120
+ };
121
+
122
+ document.addEventListener('keydown', this.escapeHandler);
123
+ document.addEventListener('keydown', this.tabHandler);
124
+ }
125
+
126
+ private teardownListeners() {
127
+ if (this.escapeHandler) {
128
+ document.removeEventListener('keydown', this.escapeHandler);
129
+ this.escapeHandler = null;
130
+ }
131
+ if (this.tabHandler) {
132
+ document.removeEventListener('keydown', this.tabHandler);
133
+ this.tabHandler = null;
134
+ }
135
+ if (this.closeTimer) {
136
+ clearTimeout(this.closeTimer);
137
+ this.closeTimer = null;
138
+ }
139
+ }
140
+
141
+ private close() {
142
+ this.dsClose.emit();
143
+ this.open = false;
144
+ }
145
+
146
+ private handleBackdropMouseDown(e: MouseEvent) {
147
+ if (e.target === e.currentTarget) this.close();
148
+ }
149
+
150
+ private get resolvedWidth(): string {
151
+ if (this.modalWidth === 'sm' || this.modalWidth === 'md' || this.modalWidth === 'lg') {
152
+ return WIDTH_MAP[this.modalWidth];
153
+ }
154
+ return this.modalWidth as string;
155
+ }
156
+
157
+ render() {
158
+ // Always render slots so scoped-mode slot distribution doesn't leak content.
159
+ // The backdrop is hidden via CSS when not active.
160
+ const hidden = !this.shouldRender && !this.closing;
161
+
162
+ return (
163
+ <Host style={{ display: 'contents' }}>
164
+ <div
165
+ class={{ 'modal-backdrop': true, 'modal-backdrop--closing': this.closing, 'modal-backdrop--hidden': hidden }}
166
+ onMouseDown={(e: MouseEvent) => this.handleBackdropMouseDown(e)}
167
+ style={{ position: 'fixed', inset: '0', zIndex: '9999', display: hidden ? 'none' : 'flex', alignItems: 'center', justifyContent: 'center', padding: 'var(--dimension-space-200)' }}
168
+ >
169
+ <div
170
+ class={{ 'modal-dialog': true, 'modal-dialog--closing': this.closing }}
171
+ role="dialog"
172
+ aria-modal="true"
173
+ aria-labelledby={this.titleId}
174
+ aria-describedby={this.subtitle ? this.subtitleId : undefined}
175
+ tabIndex={-1}
176
+ style={{ width: `min(${this.resolvedWidth}, calc(100vw - 32px))` }}
177
+ >
178
+ <div class="modal-header">
179
+ <h2 id={this.titleId} class="text-title-small modal-title">{this.heading}</h2>
180
+ {this.subtitle && (
181
+ <div id={this.subtitleId} class="modal-subtitle text-body-medium">
182
+ {this.subtitle}
183
+ </div>
184
+ )}
185
+ </div>
186
+ <div class="modal-body">
187
+ <slot />
188
+ </div>
189
+ <div class="modal-footer">
190
+ <slot name="footer" />
191
+ </div>
192
+ </div>
193
+ </div>
194
+ </Host>
195
+ );
196
+ }
197
+ }