@reliability-design/web 1.4.6 → 1.4.8

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 (148) hide show
  1. package/CHANGELOG.md +246 -49
  2. package/dist/components/rel-accordion/README.md +133 -132
  3. package/dist/components/rel-alert/README.md +145 -141
  4. package/dist/components/rel-autocomplete/README.md +172 -61
  5. package/dist/components/rel-avatar/README.md +118 -130
  6. package/dist/components/rel-badge/README.md +97 -93
  7. package/dist/components/rel-breadcrumb/README.md +96 -102
  8. package/dist/components/rel-button/README.md +190 -130
  9. package/dist/components/rel-calendar/README.md +109 -109
  10. package/dist/components/rel-card/README.md +75 -94
  11. package/dist/components/rel-cardmedia/README.md +100 -127
  12. package/dist/components/rel-carousel/README.md +186 -166
  13. package/dist/components/rel-checkbox/README.md +96 -75
  14. package/dist/components/rel-chip/README.md +138 -121
  15. package/dist/components/rel-container/README.md +107 -99
  16. package/dist/components/rel-date-picker/README.md +200 -289
  17. package/dist/components/rel-divider/README.md +88 -100
  18. package/dist/components/rel-drawer/README.md +187 -200
  19. package/dist/components/rel-kbd/README.md +141 -151
  20. package/dist/components/rel-layout/README.md +34 -0
  21. package/dist/components/rel-menu/README.md +180 -151
  22. package/dist/components/rel-menubar/README.md +218 -329
  23. package/dist/components/rel-otp/README.md +171 -440
  24. package/dist/components/rel-pagination/README.md +100 -424
  25. package/dist/components/rel-popover/README.md +203 -568
  26. package/dist/components/rel-progress/README.md +88 -110
  27. package/dist/components/rel-radio/README.md +131 -138
  28. package/dist/components/rel-range-picker/README.md +165 -0
  29. package/dist/components/rel-resizable/README.md +199 -140
  30. package/dist/components/rel-segmented-button/README.md +156 -141
  31. package/dist/components/rel-select/README.md +156 -93
  32. package/dist/components/rel-sheet/README.md +168 -224
  33. package/dist/components/rel-skeleton/README.md +104 -0
  34. package/dist/components/rel-slider/README.md +131 -135
  35. package/dist/components/rel-snackbar/README.md +168 -134
  36. package/dist/components/rel-stepper/README.md +162 -160
  37. package/dist/components/rel-switch/README.md +117 -102
  38. package/dist/components/rel-tab/README.md +173 -163
  39. package/dist/components/rel-textfield/README.md +176 -69
  40. package/dist/components/rel-time-picker/README.md +122 -157
  41. package/dist/components/rel-toast/README.md +165 -148
  42. package/dist/components/rel-tooltip/README.md +119 -111
  43. package/dist/components/rel-upload/README.md +220 -0
  44. package/dist/index.d.mts +1726 -447
  45. package/dist/index.d.ts +1726 -447
  46. package/dist/index.js +13715 -10870
  47. package/dist/index.js.map +1 -1
  48. package/dist/index.mjs +14018 -11163
  49. package/dist/index.mjs.map +1 -1
  50. package/dist/styles/focus-ring.ts +84 -0
  51. package/dist/styles/vars.css +892 -866
  52. package/dist/{vars-XNMTSRVN.css → vars-DXX6QOAG.css} +892 -866
  53. package/package.json +3 -2
  54. package/dist/components/rel-accordion/rel-accordion.ts +0 -336
  55. package/dist/components/rel-alert/rel-alert.ts +0 -305
  56. package/dist/components/rel-autocomplete/rel-autocomplete.ts +0 -720
  57. package/dist/components/rel-avatar/rel-avatar.ts +0 -142
  58. package/dist/components/rel-badge/rel-badge.ts +0 -175
  59. package/dist/components/rel-breadcrumb/rel-breadcrumb.ts +0 -106
  60. package/dist/components/rel-button/rel-button.ts +0 -560
  61. package/dist/components/rel-calendar/index.ts +0 -1
  62. package/dist/components/rel-calendar/rel-calendar.ts +0 -565
  63. package/dist/components/rel-card/rel-card.ts +0 -88
  64. package/dist/components/rel-cardmedia/rel-cardmedia.ts +0 -88
  65. package/dist/components/rel-carousel/index.ts +0 -1
  66. package/dist/components/rel-carousel/rel-carousel.ts +0 -723
  67. package/dist/components/rel-chart/README.md +0 -286
  68. package/dist/components/rel-chart/index.ts +0 -1
  69. package/dist/components/rel-chart/rel-chart.ts +0 -754
  70. package/dist/components/rel-checkbox/rel-checkbox.ts +0 -382
  71. package/dist/components/rel-chip/rel-chip.ts +0 -483
  72. package/dist/components/rel-container/rel-container.ts +0 -95
  73. package/dist/components/rel-date-picker/date-format.test.ts +0 -43
  74. package/dist/components/rel-date-picker/date-format.ts +0 -47
  75. package/dist/components/rel-date-picker/date-parse.test.ts +0 -116
  76. package/dist/components/rel-date-picker/date-parse.ts +0 -178
  77. package/dist/components/rel-date-picker/index.ts +0 -1
  78. package/dist/components/rel-date-picker/rel-date-picker.ts +0 -1676
  79. package/dist/components/rel-divider/rel-divider.ts +0 -54
  80. package/dist/components/rel-drawer/index.ts +0 -1
  81. package/dist/components/rel-drawer/rel-drawer-footer.ts +0 -27
  82. package/dist/components/rel-drawer/rel-drawer.ts +0 -607
  83. package/dist/components/rel-kbd/rel-kbd.ts +0 -188
  84. package/dist/components/rel-layout/rel-layout.ts +0 -49
  85. package/dist/components/rel-menu/rel-menu.ts +0 -541
  86. package/dist/components/rel-menubar/index.ts +0 -2
  87. package/dist/components/rel-menubar/rel-menubar-menu.css +0 -3
  88. package/dist/components/rel-menubar/rel-menubar-menu.ts +0 -17
  89. package/dist/components/rel-menubar/rel-menubar.ts +0 -777
  90. package/dist/components/rel-nav-menu/README.md +0 -289
  91. package/dist/components/rel-nav-menu/index.ts +0 -3
  92. package/dist/components/rel-nav-menu/rel-nav-menu-item.ts +0 -243
  93. package/dist/components/rel-nav-menu/rel-nav-menu-panel.ts +0 -72
  94. package/dist/components/rel-nav-menu/rel-nav-menu.ts +0 -270
  95. package/dist/components/rel-navbar/README.md +0 -126
  96. package/dist/components/rel-navbar/rel-navbar.ts +0 -105
  97. package/dist/components/rel-otp/index.ts +0 -1
  98. package/dist/components/rel-otp/rel-otp.ts +0 -527
  99. package/dist/components/rel-pagination/index.ts +0 -1
  100. package/dist/components/rel-pagination/rel-pagination.ts +0 -522
  101. package/dist/components/rel-popover/index.ts +0 -2
  102. package/dist/components/rel-popover/rel-popover-content.ts +0 -81
  103. package/dist/components/rel-popover/rel-popover.ts +0 -475
  104. package/dist/components/rel-progress/rel-progress.ts +0 -234
  105. package/dist/components/rel-radio/rel-radio.ts +0 -481
  106. package/dist/components/rel-range-picker/rel-range-picker.ts +0 -1688
  107. package/dist/components/rel-resizable/index.ts +0 -3
  108. package/dist/components/rel-resizable/rel-resizable-group.ts +0 -405
  109. package/dist/components/rel-resizable/rel-resizable-handle.ts +0 -150
  110. package/dist/components/rel-resizable/rel-resizable-panel.ts +0 -58
  111. package/dist/components/rel-segmented-button/rel-segmented-button.ts +0 -217
  112. package/dist/components/rel-select/rel-select.ts +0 -1416
  113. package/dist/components/rel-sheet/rel-sheet.ts +0 -610
  114. package/dist/components/rel-sidebar/README.md +0 -209
  115. package/dist/components/rel-sidebar/rel-sidebar.ts +0 -1191
  116. package/dist/components/rel-skeleton/rel-skeleton.ts +0 -143
  117. package/dist/components/rel-slider/rel-slider.ts +0 -673
  118. package/dist/components/rel-snackbar/rel-snackbar.ts +0 -329
  119. package/dist/components/rel-stepper/rel-stepper.ts +0 -567
  120. package/dist/components/rel-switch/rel-switch.ts +0 -199
  121. package/dist/components/rel-tab/rel-tab.ts +0 -264
  122. package/dist/components/rel-table/README.md +0 -178
  123. package/dist/components/rel-table/rel-table-body.ts +0 -22
  124. package/dist/components/rel-table/rel-table-caption.ts +0 -30
  125. package/dist/components/rel-table/rel-table-cell.ts +0 -43
  126. package/dist/components/rel-table/rel-table-footer.ts +0 -24
  127. package/dist/components/rel-table/rel-table-head.ts +0 -47
  128. package/dist/components/rel-table/rel-table-header.ts +0 -22
  129. package/dist/components/rel-table/rel-table-row.ts +0 -37
  130. package/dist/components/rel-table/rel-table.ts +0 -62
  131. package/dist/components/rel-textfield/rel-textfield.ts +0 -1054
  132. package/dist/components/rel-time-picker/rel-time-picker.ts +0 -1482
  133. package/dist/components/rel-time-picker/time-parse.test.ts +0 -67
  134. package/dist/components/rel-time-picker/time-parse.ts +0 -85
  135. package/dist/components/rel-toast/rel-toast.ts +0 -206
  136. package/dist/components/rel-tooltip/rel-tooltip.ts +0 -193
  137. package/dist/components/rel-topbar/rel-topbar-actions.ts +0 -27
  138. package/dist/components/rel-topbar/rel-topbar-brand.ts +0 -54
  139. package/dist/components/rel-topbar/rel-topbar-dropdown-item.ts +0 -69
  140. package/dist/components/rel-topbar/rel-topbar-dropdown.ts +0 -31
  141. package/dist/components/rel-topbar/rel-topbar-item.ts +0 -324
  142. package/dist/components/rel-topbar/rel-topbar-mobile-panel.ts +0 -27
  143. package/dist/components/rel-topbar/rel-topbar-profile.ts +0 -243
  144. package/dist/components/rel-topbar/rel-topbar-utilities.ts +0 -27
  145. package/dist/components/rel-topbar/rel-topbar.ts +0 -446
  146. package/dist/components/rel-upload/UPLOAD-DESIGN-CHANGES.md +0 -134
  147. package/dist/components/rel-upload/rel-upload-actions.ts +0 -163
  148. package/dist/components/rel-upload/rel-upload.ts +0 -968
@@ -1,777 +0,0 @@
1
- import { LitElement, html, nothing } from "lit";
2
- import { customElement, property, state } from "lit/decorators.js";
3
- import { matchesShortcut, isEditableTarget } from "../../utils/shortcuts.js";
4
- import { RelMenubarMenu } from "./rel-menubar-menu";
5
- import "@reliability-design/icons/rel-icon";
6
- import "../rel-checkbox/rel-checkbox.js";
7
- import "../rel-radio/rel-radio.js";
8
-
9
- interface MenuState {
10
- menu: RelMenubarMenu;
11
- open: boolean;
12
- panelElement?: HTMLElement;
13
- }
14
-
15
- @customElement("rel-menubar")
16
- export class RelMenubar extends LitElement {
17
- /**
18
- * Opt-in: actually BIND the keys declared via data-shortcut (e.g.
19
- * data-shortcut="Ctrl+Z"), not just display them. "Ctrl" matches Cmd on
20
- * Apple platforms when written as "Mod". Keystrokes originating in
21
- * inputs/textareas/contenteditable are ignored.
22
- */
23
- @property({ type: Boolean, attribute: "bind-shortcuts" }) bindShortcuts = false;
24
-
25
- @state() private menus: MenuState[] = [];
26
- @state() private openMenuIndex: number = -1;
27
- @state() private focusedTriggerIndex: number = 0;
28
- @state() private focusedItemIndex: number = -1;
29
- /** Virtual-focus index inside the open submenu; -1 = focus is in the panel. */
30
- @state() private focusedSubItemIndex: number = -1;
31
- @state() private openSubmenuElement: HTMLElement | null = null;
32
-
33
- private hoverSwitchEnabled = false;
34
- private _onScroll?: () => void;
35
-
36
- /** Stable id prefix for aria-activedescendant targets (light DOM, so ids
37
- * must be unique per menubar instance on the page). */
38
- private menubarId = `rel-menubar-${Math.random().toString(36).slice(2)}`;
39
-
40
- public static readonly _css = `
41
- /* This component renders into LIGHT DOM (createRenderRoot returns this), so
42
- :host / <slot> don't work here — the raw <rel-menubar-menu> definitions
43
- are data holders that must be hidden with a plain selector. Relying on the
44
- global rel-menubar-menu.css is not enough: it can't cross a consumer's
45
- shadow boundary AND it's a no-op in the tsup-built package (css:"copy"). */
46
- rel-menubar {
47
- color: var(--rel-text-default);
48
- display: block;
49
- }
50
-
51
- rel-menubar-menu {
52
- display: none;
53
- }
54
-
55
- .menubar-container {
56
- display: flex;
57
- align-items: stretch;
58
- background: var(--rel-bg-surface-elevated);
59
- border: var(--rel-stroke-sm) solid var(--rel-border-default);
60
- border-radius: var(--rel-border-radius-sm);
61
- overflow: hidden;
62
- padding: 0;
63
- gap: var(--rel-spacing-3xs);
64
- height: 40px;
65
- width: fit-content;
66
- font-family: var(--rel-body-sm-regular-font-family);
67
- font-weight: var(--rel-body-sm-regular-font-weight);
68
- font-size: var(--rel-body-sm-regular-font-size);
69
- line-height: var(--rel-body-sm-regular-line-height);
70
- letter-spacing: var(--rel-body-sm-regular-letter-spacing);
71
- }
72
-
73
- .menubar-trigger {
74
- display: flex;
75
- align-items: center;
76
- align-self: stretch;
77
- height: 100%;
78
- padding: 0 var(--rel-spacing-sm);
79
- background: transparent;
80
- border: none;
81
- cursor: pointer;
82
- font-family: inherit;
83
- font-size: inherit;
84
- color: var(--rel-text-label);
85
- transition: background 0.15s ease;
86
- user-select: none;
87
- outline: none;
88
- }
89
-
90
- .menubar-trigger:hover,
91
- .menubar-trigger.active {
92
- background: var(--rel-bg-neutral-elevated-2);
93
- }
94
-
95
- .menubar-trigger:focus-visible {
96
- outline: 2px solid var(--rel-border-focus);
97
- outline-offset: 2px;
98
- }
99
-
100
- .menubar-panel {
101
- position: fixed;
102
- min-width: 220px;
103
- background: var(--rel-bg-surface);
104
- border: var(--rel-stroke-sm) solid var(--rel-border-default);
105
- border-radius: var(--rel-border-radius-sm);
106
- box-shadow: var(--rel-shadows-shadow-300);
107
- padding: 0;
108
- overflow: hidden;
109
- z-index: 1000;
110
- display: none;
111
- }
112
-
113
- .menubar-panel.open {
114
- display: block;
115
- }
116
-
117
- .menu-item {
118
- position: relative;
119
- display: flex;
120
- align-items: center;
121
- justify-content: space-between;
122
- gap: var(--rel-spacing-sm);
123
- padding: var(--rel-spacing-xs) var(--rel-spacing-md);
124
- cursor: pointer;
125
- transition: background 0.15s ease;
126
- user-select: none;
127
- color: var(--rel-text-label);
128
- line-height: var(--rel-body-sm-regular-line-height);
129
- }
130
-
131
- .menu-item:hover,
132
- .menu-item.focused {
133
- background: var(--rel-bg-neutral-elevated-2);
134
- }
135
-
136
- .menu-item[data-disabled="true"] {
137
- opacity: 0.5;
138
- cursor: not-allowed;
139
- }
140
-
141
- .menu-separator {
142
- height: var(--rel-stroke-sm);
143
- background: var(--rel-border-default);
144
- margin: 0;
145
- padding: 0;
146
- }
147
-
148
- rel-icon {
149
- display: inline-flex;
150
- pointer-events: none;
151
- }
152
-
153
- .menu-submenu-trigger {
154
- position: relative;
155
- }
156
-
157
- .menu-submenu-trigger rel-icon {
158
- opacity: 0.6;
159
- }
160
-
161
- .submenu-panel {
162
- position: fixed;
163
- min-width: 200px;
164
- background: var(--rel-bg-surface);
165
- border: var(--rel-stroke-sm) solid var(--rel-border-default);
166
- border-radius: var(--rel-border-radius-sm);
167
- box-shadow: var(--rel-shadows-shadow-300);
168
- padding: 0;
169
- overflow: hidden;
170
- z-index: 1001;
171
- display: none;
172
- }
173
-
174
- .submenu-panel.open {
175
- display: block;
176
- }
177
-
178
- .menu-shortcut {
179
- opacity: 0.6;
180
- margin-left: auto;
181
- display: flex;
182
- align-items: center;
183
- gap: var(--rel-spacing-3xs);
184
- font-family: var(--rel-body-xs-regular-font-family);
185
- font-weight: var(--rel-body-xs-regular-font-weight);
186
- font-size: var(--rel-body-xs-regular-font-size);
187
- line-height: var(--rel-body-xs-regular-line-height);
188
- letter-spacing: var(--rel-body-xs-regular-letter-spacing);
189
- }
190
-
191
- .menubar-panel rel-checkbox {
192
- display: block;
193
- width: 100%;
194
- padding: var(--rel-spacing-xs, 8px) var(--rel-spacing-md, 16px);
195
- box-sizing: border-box;
196
- cursor: pointer;
197
- }
198
- `;
199
-
200
- createRenderRoot() {
201
- return this;
202
- }
203
-
204
- connectedCallback() {
205
- super.connectedCallback();
206
- this.discoverMenus();
207
- document.addEventListener("click", this.handleOutsideClick);
208
- document.addEventListener("keydown", this.handleShortcutKeyDown);
209
- this._onScroll = () => {
210
- this._repositionOpenMenu();
211
- this._repositionOpenSubmenu();
212
- };
213
- window.addEventListener('scroll', this._onScroll, { passive: true, capture: true });
214
- }
215
-
216
- disconnectedCallback() {
217
- super.disconnectedCallback();
218
- document.removeEventListener("click", this.handleOutsideClick);
219
- document.removeEventListener("keydown", this.handleShortcutKeyDown);
220
- if (this._onScroll) {
221
- window.removeEventListener('scroll', this._onScroll, { capture: true });
222
- }
223
- }
224
-
225
- private _repositionOpenSubmenu() {
226
- if (!this.openSubmenuElement) return;
227
- const submenuPanel = this.querySelector('.submenu-panel.open') as HTMLElement;
228
- if (!submenuPanel) return;
229
- const trigger = this.querySelector('.menu-submenu-trigger.focused, .menu-submenu-trigger:hover') as HTMLElement
230
- ?? submenuPanel.previousElementSibling as HTMLElement;
231
- if (!trigger) return;
232
- const rect = trigger.getBoundingClientRect();
233
- submenuPanel.style.top = `${rect.top}px`;
234
- submenuPanel.style.left = `${rect.right + 4}px`;
235
- }
236
-
237
- private _repositionOpenMenu() {
238
- if (this.openMenuIndex === -1) return;
239
- const triggers = this.querySelectorAll('.menubar-trigger');
240
- const trigger = triggers[this.openMenuIndex] as HTMLElement;
241
- const panel = this.querySelector(`.menubar-panel[data-menu-index="${this.openMenuIndex}"]`) as HTMLElement;
242
- if (!trigger || !panel) return;
243
- const rect = trigger.getBoundingClientRect();
244
- panel.style.top = `${rect.bottom + 4}px`;
245
- panel.style.left = `${rect.left}px`;
246
- }
247
-
248
- private discoverMenus() {
249
- const menuElements = Array.from(
250
- this.querySelectorAll("rel-menubar-menu")
251
- ) as RelMenubarMenu[];
252
- this.menus = menuElements.map((menu) => ({ menu, open: false }));
253
- }
254
-
255
- private handleShortcutKeyDown = (e: KeyboardEvent) => {
256
- if (!this.bindShortcuts || e.defaultPrevented) return;
257
- if (isEditableTarget(e)) return;
258
-
259
- for (const { menu } of this.menus) {
260
- for (const item of this.getFocusableItems(menu)) {
261
- const shortcut = item.getAttribute("data-shortcut");
262
- if (!shortcut) continue;
263
- if ((item as HTMLButtonElement).disabled) continue;
264
- if (matchesShortcut(e, shortcut)) {
265
- e.preventDefault();
266
- this.handleItemClick(item as HTMLElement);
267
- return;
268
- }
269
- }
270
- }
271
- };
272
-
273
- private handleOutsideClick = (e: MouseEvent) => {
274
- // composedPath (NOT e.target): a document-level listener sees e.target
275
- // retargeted to the shadow host when the menubar lives inside a shadow root
276
- // (e.g. any consumer component), so this.contains(e.target) is false for a
277
- // click that is actually INSIDE the menubar — closing the menu on the very
278
- // click that opened it. composedPath pierces shadow boundaries.
279
- if (!e.composedPath().includes(this)) {
280
- this.closeAllMenus();
281
- }
282
- };
283
-
284
- private closeAllMenus() {
285
- this.openMenuIndex = -1;
286
- this.openSubmenuElement = null;
287
- this.focusedItemIndex = -1;
288
- this.focusedSubItemIndex = -1;
289
- this.hoverSwitchEnabled = false;
290
- this.requestUpdate();
291
- }
292
-
293
- /** Close everything and put DOM focus back on the trigger that owned the
294
- * open menu. (Capturing the index BEFORE closing — closeAllMenus resets
295
- * openMenuIndex to -1, which used to drop focus to <body>.) */
296
- private closeAllMenusAndRefocusTrigger() {
297
- const idx = this.openMenuIndex;
298
- this.closeAllMenus();
299
- if (idx !== -1) {
300
- this.focusedTriggerIndex = idx;
301
- this.focusTrigger(idx);
302
- }
303
- }
304
-
305
- private openMenu(index: number, triggerElement: HTMLElement) {
306
- this.openMenuIndex = index;
307
- this.focusedItemIndex = -1;
308
- this.focusedSubItemIndex = -1;
309
- this.openSubmenuElement = null;
310
- this.requestUpdate();
311
-
312
- // Position the panel below the trigger
313
- this.updateComplete.then(() => {
314
- const panel = this.querySelector(
315
- `.menubar-panel[data-menu-index="${index}"]`
316
- ) as HTMLElement;
317
- if (panel && triggerElement) {
318
- const rect = triggerElement.getBoundingClientRect();
319
- panel.style.top = `${rect.bottom + 4}px`;
320
- panel.style.left = `${rect.left}px`;
321
- }
322
- });
323
- }
324
-
325
- private handleTriggerClick(index: number, event: Event) {
326
- const target = event.currentTarget as HTMLElement;
327
- if (this.openMenuIndex === index) {
328
- this.closeAllMenus();
329
- } else {
330
- this.openMenu(index, target);
331
- this.hoverSwitchEnabled = true;
332
- }
333
- }
334
-
335
- private handleTriggerMouseEnter(index: number, event: Event) {
336
- if (this.openMenuIndex !== -1 && this.hoverSwitchEnabled) {
337
- const target = event.currentTarget as HTMLElement;
338
- this.openMenu(index, target);
339
- }
340
- }
341
-
342
- private handleTriggerKeyDown(event: KeyboardEvent) {
343
- const { key } = event;
344
-
345
- if (key === "ArrowRight") {
346
- event.preventDefault();
347
- this.focusedTriggerIndex = Math.min(
348
- this.menus.length - 1,
349
- this.focusedTriggerIndex + 1
350
- );
351
- this.focusTrigger(this.focusedTriggerIndex);
352
- } else if (key === "ArrowLeft") {
353
- event.preventDefault();
354
- this.focusedTriggerIndex = Math.max(0, this.focusedTriggerIndex - 1);
355
- this.focusTrigger(this.focusedTriggerIndex);
356
- } else if (key === "Enter" || key === " " || key === "ArrowDown") {
357
- event.preventDefault();
358
- const trigger = this.querySelector(
359
- `.menubar-trigger[data-index="${this.focusedTriggerIndex}"]`
360
- ) as HTMLElement;
361
- if (trigger) {
362
- if (this.openMenuIndex !== this.focusedTriggerIndex) {
363
- this.openMenu(this.focusedTriggerIndex, trigger);
364
- }
365
- this.updateComplete.then(() => this.focusMenuItem(0));
366
- }
367
- } else if (key === "Escape" && this.openMenuIndex !== -1) {
368
- event.preventDefault();
369
- this.closeAllMenusAndRefocusTrigger();
370
- }
371
- }
372
-
373
- private focusTrigger(index: number) {
374
- const trigger = this.querySelector(
375
- `.menubar-trigger[data-index="${index}"]`
376
- ) as HTMLElement;
377
- trigger?.focus();
378
- }
379
-
380
- private handlePanelKeyDown(event: KeyboardEvent) {
381
- const { key } = event;
382
- const menuContent = this.menus[this.openMenuIndex]?.menu;
383
- if (!menuContent) return;
384
-
385
- // ── Virtual focus is inside an open submenu ──
386
- if (this.openSubmenuElement) {
387
- const subItems = this.getSubmenuItems(this.openSubmenuElement);
388
- if (key === "ArrowDown") {
389
- event.preventDefault();
390
- this.focusedSubItemIndex = Math.min(
391
- subItems.length - 1,
392
- this.focusedSubItemIndex + 1
393
- );
394
- } else if (key === "ArrowUp") {
395
- event.preventDefault();
396
- this.focusedSubItemIndex = Math.max(0, this.focusedSubItemIndex - 1);
397
- } else if (key === "Home") {
398
- event.preventDefault();
399
- this.focusedSubItemIndex = 0;
400
- } else if (key === "End") {
401
- event.preventDefault();
402
- this.focusedSubItemIndex = subItems.length - 1;
403
- } else if (key === "ArrowLeft" || key === "Escape") {
404
- // Close only the submenu; keep the parent panel open and focused.
405
- event.preventDefault();
406
- event.stopPropagation();
407
- this.openSubmenuElement = null;
408
- this.focusedSubItemIndex = -1;
409
- } else if (key === "Enter" || key === " ") {
410
- event.preventDefault();
411
- const subItem = subItems[this.focusedSubItemIndex];
412
- if (subItem) this.handleSubmenuItemClick(subItem as HTMLElement);
413
- }
414
- this.requestUpdate();
415
- return;
416
- }
417
-
418
- // ── Virtual focus is in the panel's main list ──
419
- const items = this.getFocusableItems(menuContent);
420
-
421
- if (key === "ArrowDown") {
422
- event.preventDefault();
423
- this.focusedItemIndex = Math.min(items.length - 1, this.focusedItemIndex + 1);
424
- this.requestUpdate();
425
- } else if (key === "ArrowUp") {
426
- event.preventDefault();
427
- this.focusedItemIndex = Math.max(0, this.focusedItemIndex - 1);
428
- this.requestUpdate();
429
- } else if (key === "Home") {
430
- event.preventDefault();
431
- this.focusedItemIndex = 0;
432
- this.requestUpdate();
433
- } else if (key === "End") {
434
- event.preventDefault();
435
- this.focusedItemIndex = items.length - 1;
436
- this.requestUpdate();
437
- } else if (key === "ArrowRight") {
438
- event.preventDefault();
439
- const currentItem = items[this.focusedItemIndex];
440
- if (currentItem?.hasAttribute("data-submenu")) {
441
- // Position against the RENDERED row, not the display:none source
442
- // element inside <rel-menubar-menu> (whose rect is 0×0).
443
- const panel = this.querySelector<HTMLElement>(
444
- `.menubar-panel[data-menu-index="${this.openMenuIndex}"]`
445
- );
446
- const renderedTrigger = panel?.querySelector<HTMLElement>(
447
- `[data-focus-index="${this.focusedItemIndex}"]`
448
- );
449
- this.focusedSubItemIndex = 0;
450
- this.openSubmenu(currentItem as HTMLElement, renderedTrigger ?? undefined);
451
- }
452
- } else if (key === "ArrowLeft") {
453
- event.preventDefault();
454
- } else if (key === "Escape") {
455
- event.preventDefault();
456
- this.closeAllMenusAndRefocusTrigger();
457
- } else if (key === "Enter" || key === " ") {
458
- event.preventDefault();
459
- const currentItem = items[this.focusedItemIndex];
460
- if (currentItem) {
461
- if (currentItem.hasAttribute("data-submenu")) {
462
- const panel = this.querySelector<HTMLElement>(
463
- `.menubar-panel[data-menu-index="${this.openMenuIndex}"]`
464
- );
465
- const renderedTrigger = panel?.querySelector<HTMLElement>(
466
- `[data-focus-index="${this.focusedItemIndex}"]`
467
- );
468
- this.focusedSubItemIndex = 0;
469
- this.openSubmenu(currentItem as HTMLElement, renderedTrigger ?? undefined);
470
- } else {
471
- this.handleItemClick(currentItem as HTMLElement);
472
- }
473
- }
474
- }
475
- }
476
-
477
- private getSubmenuItems(submenuElement: HTMLElement): Element[] {
478
- return Array.from(submenuElement.children).filter((el) =>
479
- el.hasAttribute("data-item")
480
- );
481
- }
482
-
483
- private getFocusableItems(menuContent: RelMenubarMenu): Element[] {
484
- return Array.from(menuContent.children).filter(
485
- (el) =>
486
- el.hasAttribute("data-item") ||
487
- el.hasAttribute("data-checkbox") ||
488
- el.hasAttribute("data-radio") ||
489
- el.hasAttribute("data-submenu")
490
- );
491
- }
492
-
493
- private focusMenuItem(index: number) {
494
- this.focusedItemIndex = index;
495
- const panel = this.querySelector(
496
- `.menubar-panel[data-menu-index="${this.openMenuIndex}"]`
497
- ) as HTMLElement;
498
- panel?.focus();
499
- }
500
-
501
- private handleItemClick(item: HTMLElement) {
502
- if (item.hasAttribute("data-checkbox")) {
503
- const checkbox = item.querySelector('input[type="checkbox"]') as HTMLInputElement;
504
- const labelText = Array.from(item.childNodes)
505
- .filter(n => n.nodeType === Node.TEXT_NODE)
506
- .map(n => n.textContent?.trim())
507
- .filter(Boolean)
508
- .join(' ') || item.getAttribute('label') || '';
509
- if (checkbox) {
510
- checkbox.checked = !checkbox.checked;
511
- this.dispatchChangeEvent("checkbox", labelText, checkbox.checked);
512
- this.requestUpdate();
513
- }
514
- } else if (item.hasAttribute("data-radio")) {
515
- const radioGroup = item.closest("[data-radio-group]") as HTMLElement;
516
- const value = item.getAttribute("data-radio") || item.getAttribute("value") || "";
517
- const name = radioGroup?.getAttribute("name") || "";
518
-
519
- // Update visual state
520
- const allRadios = radioGroup?.querySelectorAll("[data-radio]");
521
- allRadios?.forEach((radio) => radio.removeAttribute("data-checked"));
522
- item.setAttribute("data-checked", "true");
523
-
524
- // Update internal value
525
- if (radioGroup) {
526
- radioGroup.setAttribute("value", value);
527
- }
528
-
529
- this.dispatchChangeEvent("radio", name, value);
530
- this.requestUpdate();
531
- } else if (item.hasAttribute("data-submenu")) {
532
- this.openSubmenu(item);
533
- } else {
534
- // Regular item: notify (clicks, Enter, and bound shortcuts all land
535
- // here — without this event none of them were observable) and close.
536
- this.dispatchSelectEvent(item);
537
- this.closeAllMenusAndRefocusTrigger();
538
- }
539
- }
540
-
541
- private dispatchSelectEvent(item: HTMLElement) {
542
- this.dispatchEvent(
543
- new CustomEvent("rel-menubar-select", {
544
- detail: {
545
- label: item.textContent?.trim() ?? "",
546
- value: item.getAttribute("value") ?? undefined,
547
- shortcut: item.getAttribute("data-shortcut") ?? undefined,
548
- },
549
- bubbles: true,
550
- composed: true,
551
- })
552
- );
553
- }
554
-
555
- private openSubmenu(submenuTrigger: HTMLElement, renderedTrigger?: HTMLElement) {
556
- this.openSubmenuElement = submenuTrigger;
557
- this.requestUpdate();
558
-
559
- this.updateComplete.then(() => {
560
- // Only one submenu is ever open; its rendered trigger row is its
561
- // previous sibling in the panel template.
562
- const submenuPanel = this.querySelector(".submenu-panel.open") as HTMLElement;
563
- if (submenuPanel) {
564
- const positionTarget =
565
- renderedTrigger ?? (submenuPanel.previousElementSibling as HTMLElement);
566
- const rect = positionTarget?.getBoundingClientRect();
567
- if (rect) {
568
- submenuPanel.style.top = `${rect.top}px`;
569
- submenuPanel.style.left = `${rect.right + 4}px`;
570
- }
571
- }
572
- });
573
- }
574
-
575
- private handleSubmenuItemClick(item: HTMLElement) {
576
- // Regular item - notify, close, and hand focus back to the trigger
577
- this.dispatchSelectEvent(item);
578
- this.closeAllMenusAndRefocusTrigger();
579
- }
580
-
581
- private dispatchChangeEvent(type: "checkbox" | "radio", labelOrName: string, value: string | boolean) {
582
- const detail =
583
- type === "checkbox"
584
- ? { type, label: labelOrName, name: "", checked: value as boolean }
585
- : { type, label: "", name: labelOrName, value: value as string };
586
- this.dispatchEvent(
587
- new CustomEvent("rel-menubar-change", {
588
- detail,
589
- bubbles: true,
590
- composed: true,
591
- })
592
- );
593
- }
594
-
595
- private renderMenuItem(item: Element, focusIndex: number, menuIndex: number): any {
596
- // focusIndex is the index within getFocusableItems() — the same list the
597
- // keyboard handler walks. Comparing raw child indexes against
598
- // focusedItemIndex highlighted the wrong row once separators existed.
599
- const isFocused = focusIndex >= 0 && focusIndex === this.focusedItemIndex;
600
- const itemId =
601
- focusIndex >= 0 ? `${this.menubarId}-m${menuIndex}-i${focusIndex}` : nothing;
602
-
603
- if (item.hasAttribute("data-item")) {
604
- const button = item as HTMLButtonElement;
605
- const disabled = button.disabled;
606
- const shortcut = button.getAttribute("data-shortcut") || "";
607
-
608
- return html`
609
- <div
610
- id="${itemId}"
611
- class="menu-item ${isFocused ? "focused" : ""}"
612
- role="menuitem"
613
- data-focus-index="${focusIndex}"
614
- data-disabled="${disabled}"
615
- aria-disabled="${disabled ? "true" : nothing}"
616
- @click="${() => !disabled && this.handleItemClick(item as HTMLElement)}"
617
- >
618
- <span>${button.textContent}</span>
619
- ${shortcut ? html`<span class="menu-shortcut">${shortcut}</span>` : ""}
620
- </div>
621
- `;
622
- }
623
-
624
- if (item.hasAttribute("data-separator")) {
625
- return html`<div class="menu-separator" role="separator"></div>`;
626
- }
627
-
628
- if (item.hasAttribute("data-checkbox")) {
629
- const label = item as HTMLLabelElement;
630
- const checkbox = label.querySelector('input[type="checkbox"]') as HTMLInputElement;
631
- const labelText = Array.from(label.childNodes)
632
- .filter(n => n.nodeType === Node.TEXT_NODE)
633
- .map(n => n.textContent?.trim())
634
- .filter(Boolean)
635
- .join(' ') || label.getAttribute('label') || '';
636
-
637
- return html`
638
- <rel-checkbox
639
- id="${itemId}"
640
- data-focus-index="${focusIndex}"
641
- label=${labelText}
642
- .status=${checkbox?.checked ? "checked" : "unchecked"}
643
- @rel-change=${(e: CustomEvent) => {
644
- if (checkbox) checkbox.checked = e.detail.checked;
645
- this.dispatchChangeEvent("checkbox", labelText, e.detail.checked);
646
- }}
647
- ></rel-checkbox>
648
- `;
649
- }
650
-
651
- if (item.hasAttribute("data-submenu")) {
652
- const label = item.getAttribute("label") || item.textContent?.trim() || "";
653
-
654
- return html`
655
- <div
656
- id="${itemId}"
657
- class="menu-item menu-submenu-trigger ${isFocused ? "focused" : ""}"
658
- role="menuitem"
659
- data-focus-index="${focusIndex}"
660
- aria-haspopup="menu"
661
- aria-expanded="${this.openSubmenuElement === item}"
662
- @click="${(e: Event) => this.openSubmenu(item as HTMLElement, e.currentTarget as HTMLElement)}"
663
- @mouseenter="${(e: Event) => this.openSubmenu(item as HTMLElement, e.currentTarget as HTMLElement)}"
664
- >
665
- <span>${label}</span>
666
- <rel-icon name="chevron-right"></rel-icon>
667
- </div>
668
- ${this.renderSubmenu(item as HTMLElement)}
669
- `;
670
- }
671
-
672
- if (item.hasAttribute("data-radio-group")) {
673
- const radioButtons = Array.from(item.querySelectorAll("[data-radio]"));
674
- const currentValue = item.getAttribute("value") || "";
675
- const groupName = item.getAttribute("name") || "";
676
-
677
- const options = radioButtons.map(radio => ({
678
- label: radio.textContent?.trim() || "",
679
- value: radio.getAttribute("data-radio") || radio.getAttribute("value") || "",
680
- }));
681
-
682
- return html`
683
- <rel-radio
684
- .options=${options}
685
- .value=${currentValue}
686
- @rel-change=${(e: CustomEvent) => {
687
- const newValue = e.detail.value;
688
- item.setAttribute("value", newValue);
689
- this.dispatchChangeEvent("radio", groupName, newValue);
690
- }}
691
- ></rel-radio>
692
- `;
693
- }
694
-
695
- return null;
696
- }
697
-
698
- private renderSubmenu(submenuElement: HTMLElement) {
699
- const isOpen = this.openSubmenuElement === submenuElement;
700
- const submenuItems = this.getSubmenuItems(submenuElement);
701
-
702
- return html`
703
- <div class="submenu-panel ${isOpen ? "open" : ""}" role="menu">
704
- ${submenuItems.map((subItem, subIdx) => {
705
- const button = subItem as HTMLButtonElement;
706
- return html`
707
- <div
708
- id="${isOpen ? `${this.menubarId}-sub-${subIdx}` : nothing}"
709
- class="menu-item ${isOpen && subIdx === this.focusedSubItemIndex ? "focused" : ""}"
710
- role="menuitem"
711
- @click="${() => this.handleSubmenuItemClick(subItem as HTMLElement)}"
712
- >
713
- <span>${button.textContent}</span>
714
- </div>
715
- `;
716
- })}
717
- </div>
718
- `;
719
- }
720
-
721
- private renderMenuPanel(menuState: MenuState, index: number) {
722
- const isOpen = index === this.openMenuIndex;
723
- const items = Array.from(menuState.menu.children);
724
- const focusables = this.getFocusableItems(menuState.menu);
725
-
726
- // DOM focus stays on the panel; this tells AT which row virtual focus is
727
- // on as the user arrows around (panel list or open submenu).
728
- const activeDescendant = !isOpen
729
- ? nothing
730
- : this.focusedSubItemIndex >= 0
731
- ? `${this.menubarId}-sub-${this.focusedSubItemIndex}`
732
- : this.focusedItemIndex >= 0
733
- ? `${this.menubarId}-m${index}-i${this.focusedItemIndex}`
734
- : nothing;
735
-
736
- return html`
737
- <div
738
- class="menubar-panel ${isOpen ? "open" : ""}"
739
- data-menu-index="${index}"
740
- tabindex="-1"
741
- role="menu"
742
- aria-activedescendant="${activeDescendant}"
743
- @keydown="${this.handlePanelKeyDown}"
744
- >
745
- ${items.map((item) =>
746
- this.renderMenuItem(item, focusables.indexOf(item), index)
747
- )}
748
- </div>
749
- `;
750
- }
751
-
752
- render() {
753
- return html`
754
- <style>${RelMenubar._css}</style>
755
- <div class="menubar-container" role="menubar">
756
- ${this.menus.map(
757
- (menuState, index) => html`
758
- <button
759
- class="menubar-trigger ${index === this.openMenuIndex ? "active" : ""}"
760
- data-index="${index}"
761
- role="menuitem"
762
- aria-haspopup="menu"
763
- aria-expanded="${index === this.openMenuIndex}"
764
- @click="${(e: Event) => this.handleTriggerClick(index, e)}"
765
- @mouseenter="${(e: Event) => this.handleTriggerMouseEnter(index, e)}"
766
- @focus="${() => { this.focusedTriggerIndex = index; }}"
767
- @keydown="${this.handleTriggerKeyDown}"
768
- >
769
- ${menuState.menu.label}
770
- </button>
771
- `
772
- )}
773
- </div>
774
- ${this.menus.map((menuState, index) => this.renderMenuPanel(menuState, index))}
775
- `;
776
- }
777
- }