@reliability-design/web 1.4.5 → 1.4.7

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 (142) hide show
  1. package/CHANGELOG.md +211 -49
  2. package/dist/components/rel-accordion/README.md +132 -132
  3. package/dist/components/rel-alert/README.md +145 -141
  4. package/dist/components/rel-autocomplete/README.md +156 -61
  5. package/dist/components/rel-avatar/README.md +116 -130
  6. package/dist/components/rel-badge/README.md +98 -93
  7. package/dist/components/rel-breadcrumb/README.md +96 -102
  8. package/dist/components/rel-button/README.md +160 -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 +184 -166
  13. package/dist/components/rel-checkbox/README.md +93 -75
  14. package/dist/components/rel-chip/README.md +132 -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 +78 -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-menu/README.md +178 -151
  21. package/dist/components/rel-menubar/README.md +218 -329
  22. package/dist/components/rel-otp/README.md +170 -440
  23. package/dist/components/rel-pagination/README.md +100 -424
  24. package/dist/components/rel-popover/README.md +203 -568
  25. package/dist/components/rel-progress/README.md +88 -110
  26. package/dist/components/rel-radio/README.md +131 -135
  27. package/dist/components/rel-range-picker/README.md +162 -0
  28. package/dist/components/rel-resizable/README.md +199 -140
  29. package/dist/components/rel-segmented-button/README.md +155 -141
  30. package/dist/components/rel-select/README.md +154 -93
  31. package/dist/components/rel-sheet/README.md +168 -224
  32. package/dist/components/rel-skeleton/README.md +103 -0
  33. package/dist/components/rel-slider/README.md +130 -135
  34. package/dist/components/rel-snackbar/README.md +162 -134
  35. package/dist/components/rel-stepper/README.md +158 -160
  36. package/dist/components/rel-switch/README.md +108 -102
  37. package/dist/components/rel-tab/README.md +171 -163
  38. package/dist/components/rel-textfield/README.md +159 -69
  39. package/dist/components/rel-time-picker/README.md +122 -157
  40. package/dist/components/rel-toast/README.md +157 -148
  41. package/dist/components/rel-tooltip/README.md +108 -103
  42. package/dist/components/rel-upload/README.md +220 -0
  43. package/dist/index.d.mts +1387 -350
  44. package/dist/index.d.ts +1387 -350
  45. package/dist/index.js +5863 -3600
  46. package/dist/index.js.map +1 -1
  47. package/dist/index.mjs +5864 -3592
  48. package/dist/index.mjs.map +1 -1
  49. package/dist/styles/vars.css +877 -866
  50. package/dist/{vars-XNMTSRVN.css → vars-JHTUGNRG.css} +877 -866
  51. package/package.json +4 -3
  52. package/dist/components/rel-accordion/rel-accordion.ts +0 -336
  53. package/dist/components/rel-alert/rel-alert.ts +0 -305
  54. package/dist/components/rel-autocomplete/rel-autocomplete.ts +0 -689
  55. package/dist/components/rel-avatar/rel-avatar.ts +0 -142
  56. package/dist/components/rel-badge/rel-badge.ts +0 -170
  57. package/dist/components/rel-breadcrumb/rel-breadcrumb.ts +0 -106
  58. package/dist/components/rel-button/rel-button.ts +0 -560
  59. package/dist/components/rel-calendar/index.ts +0 -1
  60. package/dist/components/rel-calendar/rel-calendar.ts +0 -519
  61. package/dist/components/rel-card/rel-card.ts +0 -88
  62. package/dist/components/rel-cardmedia/rel-cardmedia.ts +0 -88
  63. package/dist/components/rel-carousel/index.ts +0 -1
  64. package/dist/components/rel-carousel/rel-carousel.ts +0 -700
  65. package/dist/components/rel-chart/README.md +0 -286
  66. package/dist/components/rel-chart/index.ts +0 -1
  67. package/dist/components/rel-chart/rel-chart.ts +0 -754
  68. package/dist/components/rel-checkbox/rel-checkbox.ts +0 -382
  69. package/dist/components/rel-chip/rel-chip.ts +0 -472
  70. package/dist/components/rel-container/rel-container.ts +0 -95
  71. package/dist/components/rel-date-picker/date-format.test.ts +0 -43
  72. package/dist/components/rel-date-picker/date-format.ts +0 -47
  73. package/dist/components/rel-date-picker/index.ts +0 -1
  74. package/dist/components/rel-date-picker/rel-date-picker.ts +0 -1237
  75. package/dist/components/rel-divider/rel-divider.ts +0 -54
  76. package/dist/components/rel-drawer/index.ts +0 -1
  77. package/dist/components/rel-drawer/rel-drawer-footer.ts +0 -27
  78. package/dist/components/rel-drawer/rel-drawer.ts +0 -604
  79. package/dist/components/rel-kbd/rel-kbd.ts +0 -188
  80. package/dist/components/rel-layout/rel-layout.ts +0 -49
  81. package/dist/components/rel-menu/rel-menu.ts +0 -535
  82. package/dist/components/rel-menubar/index.ts +0 -2
  83. package/dist/components/rel-menubar/rel-menubar-menu.css +0 -3
  84. package/dist/components/rel-menubar/rel-menubar-menu.ts +0 -17
  85. package/dist/components/rel-menubar/rel-menubar.ts +0 -777
  86. package/dist/components/rel-nav-menu/README.md +0 -289
  87. package/dist/components/rel-nav-menu/index.ts +0 -3
  88. package/dist/components/rel-nav-menu/rel-nav-menu-item.ts +0 -243
  89. package/dist/components/rel-nav-menu/rel-nav-menu-panel.ts +0 -72
  90. package/dist/components/rel-nav-menu/rel-nav-menu.ts +0 -270
  91. package/dist/components/rel-navbar/README.md +0 -126
  92. package/dist/components/rel-navbar/rel-navbar.ts +0 -105
  93. package/dist/components/rel-otp/index.ts +0 -1
  94. package/dist/components/rel-otp/rel-otp.ts +0 -517
  95. package/dist/components/rel-pagination/index.ts +0 -1
  96. package/dist/components/rel-pagination/rel-pagination.ts +0 -509
  97. package/dist/components/rel-popover/index.ts +0 -2
  98. package/dist/components/rel-popover/rel-popover-content.ts +0 -81
  99. package/dist/components/rel-popover/rel-popover.ts +0 -444
  100. package/dist/components/rel-progress/rel-progress.ts +0 -225
  101. package/dist/components/rel-radio/rel-radio.ts +0 -305
  102. package/dist/components/rel-range-picker/rel-range-picker.ts +0 -1331
  103. package/dist/components/rel-resizable/index.ts +0 -3
  104. package/dist/components/rel-resizable/rel-resizable-group.ts +0 -405
  105. package/dist/components/rel-resizable/rel-resizable-handle.ts +0 -150
  106. package/dist/components/rel-resizable/rel-resizable-panel.ts +0 -58
  107. package/dist/components/rel-segmented-button/rel-segmented-button.ts +0 -213
  108. package/dist/components/rel-select/rel-select.ts +0 -1385
  109. package/dist/components/rel-sheet/rel-sheet.ts +0 -465
  110. package/dist/components/rel-sidebar/README.md +0 -209
  111. package/dist/components/rel-sidebar/rel-sidebar.ts +0 -1185
  112. package/dist/components/rel-skeleton/rel-skeleton.ts +0 -134
  113. package/dist/components/rel-slider/rel-slider.ts +0 -670
  114. package/dist/components/rel-snackbar/rel-snackbar.ts +0 -325
  115. package/dist/components/rel-stepper/rel-stepper.ts +0 -565
  116. package/dist/components/rel-switch/rel-switch.ts +0 -197
  117. package/dist/components/rel-tab/rel-tab.ts +0 -264
  118. package/dist/components/rel-table/README.md +0 -178
  119. package/dist/components/rel-table/rel-table-body.ts +0 -22
  120. package/dist/components/rel-table/rel-table-caption.ts +0 -30
  121. package/dist/components/rel-table/rel-table-cell.ts +0 -43
  122. package/dist/components/rel-table/rel-table-footer.ts +0 -24
  123. package/dist/components/rel-table/rel-table-head.ts +0 -47
  124. package/dist/components/rel-table/rel-table-header.ts +0 -22
  125. package/dist/components/rel-table/rel-table-row.ts +0 -37
  126. package/dist/components/rel-table/rel-table.ts +0 -62
  127. package/dist/components/rel-textfield/rel-textfield.ts +0 -866
  128. package/dist/components/rel-time-picker/rel-time-picker.ts +0 -1126
  129. package/dist/components/rel-toast/rel-toast.ts +0 -204
  130. package/dist/components/rel-tooltip/rel-tooltip.ts +0 -190
  131. package/dist/components/rel-topbar/rel-topbar-actions.ts +0 -27
  132. package/dist/components/rel-topbar/rel-topbar-brand.ts +0 -54
  133. package/dist/components/rel-topbar/rel-topbar-dropdown-item.ts +0 -69
  134. package/dist/components/rel-topbar/rel-topbar-dropdown.ts +0 -31
  135. package/dist/components/rel-topbar/rel-topbar-item.ts +0 -324
  136. package/dist/components/rel-topbar/rel-topbar-mobile-panel.ts +0 -27
  137. package/dist/components/rel-topbar/rel-topbar-profile.ts +0 -243
  138. package/dist/components/rel-topbar/rel-topbar-utilities.ts +0 -27
  139. package/dist/components/rel-topbar/rel-topbar.ts +0 -446
  140. package/dist/components/rel-upload/UPLOAD-DESIGN-CHANGES.md +0 -134
  141. package/dist/components/rel-upload/rel-upload-actions.ts +0 -163
  142. package/dist/components/rel-upload/rel-upload.ts +0 -943
@@ -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
- }