@reliability-design/web 1.4.3 → 1.4.5

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 (48) hide show
  1. package/dist/components/rel-accordion/rel-accordion.ts +13 -1
  2. package/dist/components/rel-autocomplete/README.md +1 -0
  3. package/dist/components/rel-autocomplete/rel-autocomplete.ts +56 -1
  4. package/dist/components/rel-button/rel-button.ts +18 -2
  5. package/dist/components/rel-calendar/rel-calendar.ts +23 -1
  6. package/dist/components/rel-carousel/rel-carousel.ts +20 -0
  7. package/dist/components/rel-checkbox/README.md +69 -22
  8. package/dist/components/rel-checkbox/rel-checkbox.ts +119 -9
  9. package/dist/components/rel-chip/rel-chip.ts +29 -0
  10. package/dist/components/rel-date-picker/rel-date-picker.ts +156 -13
  11. package/dist/components/rel-drawer/rel-drawer.ts +22 -58
  12. package/dist/components/rel-menu/rel-menu.ts +124 -15
  13. package/dist/components/rel-menubar/rel-menubar.ts +221 -63
  14. package/dist/components/rel-nav-menu/rel-nav-menu.ts +39 -3
  15. package/dist/components/rel-otp/rel-otp.ts +3 -3
  16. package/dist/components/rel-pagination/README.md +14 -0
  17. package/dist/components/rel-pagination/rel-pagination.ts +30 -14
  18. package/dist/components/rel-popover/rel-popover.ts +31 -3
  19. package/dist/components/rel-range-picker/rel-range-picker.ts +251 -3
  20. package/dist/components/rel-resizable/rel-resizable-group.ts +20 -0
  21. package/dist/components/rel-segmented-button/rel-segmented-button.ts +50 -12
  22. package/dist/components/rel-select/README.md +1 -0
  23. package/dist/components/rel-select/rel-select.ts +1385 -1170
  24. package/dist/components/rel-sheet/rel-sheet.ts +22 -1
  25. package/dist/components/rel-sidebar/rel-sidebar.ts +43 -39
  26. package/dist/components/rel-stepper/rel-stepper.ts +44 -1
  27. package/dist/components/rel-tab/rel-tab.ts +32 -7
  28. package/dist/components/rel-time-picker/rel-time-picker.ts +338 -206
  29. package/dist/components/rel-tooltip/rel-tooltip.ts +30 -0
  30. package/dist/components/rel-topbar/rel-topbar-actions.ts +1 -1
  31. package/dist/components/rel-topbar/rel-topbar-brand.ts +1 -1
  32. package/dist/components/rel-topbar/rel-topbar-dropdown-item.ts +1 -1
  33. package/dist/components/rel-topbar/rel-topbar-dropdown.ts +1 -1
  34. package/dist/components/rel-topbar/rel-topbar-item.ts +318 -319
  35. package/dist/components/rel-topbar/rel-topbar-mobile-panel.ts +1 -1
  36. package/dist/components/rel-topbar/rel-topbar-profile.ts +224 -224
  37. package/dist/components/rel-topbar/rel-topbar-utilities.ts +1 -1
  38. package/dist/components/rel-topbar/rel-topbar.ts +440 -440
  39. package/dist/components/rel-upload/UPLOAD-DESIGN-CHANGES.md +134 -0
  40. package/dist/components/rel-upload/rel-upload-actions.ts +163 -0
  41. package/dist/components/rel-upload/rel-upload.ts +943 -0
  42. package/dist/index.d.mts +491 -18
  43. package/dist/index.d.ts +491 -18
  44. package/dist/index.js +2748 -410
  45. package/dist/index.js.map +1 -1
  46. package/dist/index.mjs +2953 -627
  47. package/dist/index.mjs.map +1 -1
  48. package/package.json +1 -1
@@ -365,6 +365,34 @@ export class RelChip extends LitElement {
365
365
  );
366
366
  }
367
367
 
368
+ /** The chip body is announced as a button, so it must also act on keys:
369
+ * Enter/Space re-dispatch as a host click (consumers' @click handlers
370
+ * fire), Delete/Backspace remove when removable, and arrow keys ask the
371
+ * container to reorder a draggable chip (`chip-move`) — the keyboard
372
+ * alternative to pointer drag-and-drop. */
373
+ private handleKeydown = (e: KeyboardEvent) => {
374
+ if (e.key === "Enter" || e.key === " ") {
375
+ e.preventDefault();
376
+ this.click();
377
+ return;
378
+ }
379
+ if ((e.key === "Delete" || e.key === "Backspace") && this.removable) {
380
+ e.preventDefault();
381
+ this.handleRemove(e);
382
+ return;
383
+ }
384
+ if (this.draggableChip && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
385
+ e.preventDefault();
386
+ this.dispatchEvent(
387
+ new CustomEvent("chip-move", {
388
+ detail: { direction: e.key === "ArrowLeft" ? -1 : 1 },
389
+ bubbles: true,
390
+ composed: true,
391
+ })
392
+ );
393
+ }
394
+ };
395
+
368
396
  private onDragStart = (e: DragEvent) => {
369
397
  if (!this.draggableChip) return;
370
398
  this.dragging = true;
@@ -409,6 +437,7 @@ export class RelChip extends LitElement {
409
437
  role="button"
410
438
  tabindex="0"
411
439
  aria-label="${this.label}"
440
+ @keydown=${this.handleKeydown}
412
441
  >
413
442
  ${showAvatar
414
443
  ? html`<rel-avatar type="text" text=${this.avatar} size="xs"></rel-avatar>`
@@ -1,6 +1,7 @@
1
1
  import { LitElement, html, css, nothing } from "lit";
2
2
  import { customElement, property, state, query } from "lit/decorators.js";
3
3
  import { formatDatePattern } from "./date-format";
4
+ import { getDeepActiveElement, trapTabKey } from "../../utils/focus.js";
4
5
 
5
6
  interface CalendarDay {
6
7
  date: Date;
@@ -357,6 +358,11 @@ export class RelDatePicker extends LitElement {
357
358
  }
358
359
 
359
360
  .dropdown-item {
361
+ display: block;
362
+ width: 100%;
363
+ border: none;
364
+ background: transparent;
365
+ text-align: left;
360
366
  padding: var(--rel-spacing-xs) var(--rel-spacing-sm);
361
367
  cursor: pointer;
362
368
  color: var(--rel-text-default);
@@ -368,6 +374,11 @@ export class RelDatePicker extends LitElement {
368
374
  letter-spacing: var(--rel-body-sm-regular-letter-spacing);
369
375
  }
370
376
 
377
+ .dropdown-item:focus-visible {
378
+ outline: 2px solid var(--rel-border-focus);
379
+ outline-offset: -2px;
380
+ }
381
+
371
382
  .dropdown-item:hover {
372
383
  background: var(--rel-bg-surface-elevated);
373
384
  }
@@ -497,8 +508,15 @@ export class RelDatePicker extends LitElement {
497
508
  };
498
509
 
499
510
  private handleEscapeKey = (e: KeyboardEvent) => {
500
- if (e.key === "Escape" && this.open) {
511
+ if (!this.open) return;
512
+ if (e.key === "Escape") {
501
513
  this.closeCalendarPopover();
514
+ return;
515
+ }
516
+ // The popover announces itself as a dialog — keep Tab inside it.
517
+ if (e.key === "Tab") {
518
+ const popover = this.renderRoot.querySelector<HTMLElement>(".popover");
519
+ if (popover) trapTabKey(e, popover);
502
520
  }
503
521
  };
504
522
 
@@ -512,12 +530,17 @@ export class RelDatePicker extends LitElement {
512
530
  }
513
531
 
514
532
  if (changedProperties.has("open") && this.open) {
515
- // Focus first day when opening
533
+ // Focus the roving cell (selected day, else today, else first
534
+ // selectable) when opening — not blindly the first of the month.
516
535
  setTimeout(() => {
517
- const firstDay = this.calendarGrid?.querySelector<HTMLButtonElement>(
518
- ".day-button:not(:disabled)"
519
- );
520
- firstDay?.focus();
536
+ const target =
537
+ this.calendarGrid?.querySelector<HTMLButtonElement>(
538
+ '.day-button[tabindex="0"]:not(:disabled)'
539
+ ) ??
540
+ this.calendarGrid?.querySelector<HTMLButtonElement>(
541
+ ".day-button:not(:disabled)"
542
+ );
543
+ target?.focus();
521
544
  }, 100);
522
545
  }
523
546
  }
@@ -865,24 +888,125 @@ export class RelDatePicker extends LitElement {
865
888
  e.stopPropagation();
866
889
  this.monthDropdownOpen = !this.monthDropdownOpen;
867
890
  this.yearDropdownOpen = false;
891
+ if (this.monthDropdownOpen) this.focusOpenDropdownSelection();
868
892
  }
869
893
 
870
894
  private toggleYearDropdown(e: Event) {
871
895
  e.stopPropagation();
872
896
  this.yearDropdownOpen = !this.yearDropdownOpen;
873
897
  this.monthDropdownOpen = false;
898
+ if (this.yearDropdownOpen) this.focusOpenDropdownSelection();
874
899
  }
875
900
 
901
+ private handleDropdownTriggerKeydown(e: KeyboardEvent, kind: "month" | "year") {
902
+ if (e.key !== "ArrowDown") return;
903
+ e.preventDefault();
904
+ e.stopPropagation();
905
+ this.monthDropdownOpen = kind === "month";
906
+ this.yearDropdownOpen = kind === "year";
907
+ this.focusOpenDropdownSelection();
908
+ }
909
+
910
+ private async focusOpenDropdownSelection() {
911
+ await this.updateComplete;
912
+ const menu = this.renderRoot.querySelector<HTMLElement>(".dropdown-menu.open");
913
+ if (!menu) return;
914
+ const target =
915
+ menu.querySelector<HTMLElement>(".dropdown-item.selected") ??
916
+ menu.querySelector<HTMLElement>(".dropdown-item");
917
+ target?.focus();
918
+ target?.scrollIntoView({ block: "nearest" });
919
+ }
920
+
921
+ private handleDropdownKeydown = (e: KeyboardEvent) => {
922
+ const menu = e.currentTarget as HTMLElement;
923
+ const items = Array.from(
924
+ menu.querySelectorAll<HTMLElement>(".dropdown-item")
925
+ );
926
+ if (items.length === 0) return;
927
+
928
+ if (e.key === "Escape") {
929
+ // Close only the dropdown, not the whole calendar popover behind it.
930
+ e.preventDefault();
931
+ e.stopPropagation();
932
+ const trigger = menu
933
+ .closest(".dropdown-container")
934
+ ?.querySelector<HTMLElement>(".dropdown-trigger");
935
+ this.monthDropdownOpen = false;
936
+ this.yearDropdownOpen = false;
937
+ trigger?.focus();
938
+ return;
939
+ }
940
+
941
+ if (e.key === "Tab") {
942
+ this.monthDropdownOpen = false;
943
+ this.yearDropdownOpen = false;
944
+ return;
945
+ }
946
+
947
+ const idx = items.indexOf(getDeepActiveElement() as HTMLElement);
948
+ let target: number;
949
+ switch (e.key) {
950
+ case "ArrowDown":
951
+ target = Math.min(items.length - 1, idx + 1);
952
+ break;
953
+ case "ArrowUp":
954
+ target = Math.max(0, idx - 1);
955
+ break;
956
+ case "Home":
957
+ target = 0;
958
+ break;
959
+ case "End":
960
+ target = items.length - 1;
961
+ break;
962
+ default:
963
+ return;
964
+ }
965
+ e.preventDefault();
966
+ e.stopPropagation();
967
+ items[target]?.focus();
968
+ items[target]?.scrollIntoView({ block: "nearest" });
969
+ };
970
+
876
971
  private selectMonth(month: number, e: Event) {
877
972
  e.stopPropagation();
973
+ const trigger = (e.target as HTMLElement)
974
+ .closest(".dropdown-container")
975
+ ?.querySelector<HTMLElement>(".dropdown-trigger");
878
976
  this.viewMonth = month;
879
977
  this.monthDropdownOpen = false;
978
+ // The focused item just went display:none — keep keyboard users anchored.
979
+ this.updateComplete.then(() => trigger?.focus());
880
980
  }
881
981
 
882
982
  private selectYear(year: number, e: Event) {
883
983
  e.stopPropagation();
984
+ const trigger = (e.target as HTMLElement)
985
+ .closest(".dropdown-container")
986
+ ?.querySelector<HTMLElement>(".dropdown-trigger");
884
987
  this.viewYear = year;
885
988
  this.yearDropdownOpen = false;
989
+ this.updateComplete.then(() => trigger?.focus());
990
+ }
991
+
992
+ /**
993
+ * Index of the single grid cell carrying tabindex="0". The old expression
994
+ * (`isSelected || (index === 0 && !value)`) left the grid with ZERO tab
995
+ * stops once the user arrowed around without a selection.
996
+ */
997
+ private getRovingIndex(days: CalendarDay[]): number {
998
+ const candidates = [
999
+ this.focusedDate
1000
+ ? days.findIndex(
1001
+ (d) => d.isCurrentMonth && this.isSameDay(d.date, this.focusedDate!)
1002
+ )
1003
+ : -1,
1004
+ days.findIndex((d) => d.isSelected),
1005
+ days.findIndex((d) => d.isCurrentMonth && d.isToday),
1006
+ days.findIndex((d) => d.isCurrentMonth && !d.isDisabled),
1007
+ ];
1008
+ const found = candidates.find((i) => i >= 0);
1009
+ return found ?? 0;
886
1010
  }
887
1011
 
888
1012
  render() {
@@ -890,6 +1014,7 @@ export class RelDatePicker extends LitElement {
890
1014
  const weekdayLabels = this.getWeekdayLabels();
891
1015
  const months = this.getMonthsList();
892
1016
  const years = this.getYearsList();
1017
+ const rovingIndex = this.getRovingIndex(days);
893
1018
 
894
1019
  const isError = this.error;
895
1020
  const belowText = isError && this.errorMessage ? this.errorMessage : this.helperText;
@@ -988,22 +1113,31 @@ export class RelDatePicker extends LitElement {
988
1113
  class="dropdown-trigger"
989
1114
  type="button"
990
1115
  @click=${this.toggleMonthDropdown}
1116
+ @keydown=${(e: KeyboardEvent) => this.handleDropdownTriggerKeydown(e, "month")}
991
1117
  aria-label="Select month"
1118
+ aria-haspopup="listbox"
992
1119
  aria-expanded=${this.monthDropdownOpen}
993
1120
  >
994
1121
  ${this.getMonthName()}
995
1122
  </button>
996
- <div class="dropdown-menu ${this.monthDropdownOpen ? "open" : ""}">
1123
+ <div
1124
+ class="dropdown-menu ${this.monthDropdownOpen ? "open" : ""}"
1125
+ role="listbox"
1126
+ aria-label="Month"
1127
+ @keydown=${this.handleDropdownKeydown}
1128
+ >
997
1129
  ${months.map(
998
1130
  (m) => html`
999
- <div
1131
+ <button
1000
1132
  class="dropdown-item ${m.value === this.viewMonth ? "selected" : ""}"
1133
+ type="button"
1134
+ tabindex="-1"
1001
1135
  @click=${(e: Event) => this.selectMonth(m.value, e)}
1002
1136
  role="option"
1003
1137
  aria-selected=${m.value === this.viewMonth}
1004
1138
  >
1005
1139
  ${m.label}
1006
- </div>
1140
+ </button>
1007
1141
  `
1008
1142
  )}
1009
1143
  </div>
@@ -1014,22 +1148,31 @@ export class RelDatePicker extends LitElement {
1014
1148
  class="dropdown-trigger"
1015
1149
  type="button"
1016
1150
  @click=${this.toggleYearDropdown}
1151
+ @keydown=${(e: KeyboardEvent) => this.handleDropdownTriggerKeydown(e, "year")}
1017
1152
  aria-label="Select year"
1153
+ aria-haspopup="listbox"
1018
1154
  aria-expanded=${this.yearDropdownOpen}
1019
1155
  >
1020
1156
  ${this.viewYear}
1021
1157
  </button>
1022
- <div class="dropdown-menu ${this.yearDropdownOpen ? "open" : ""}">
1158
+ <div
1159
+ class="dropdown-menu ${this.yearDropdownOpen ? "open" : ""}"
1160
+ role="listbox"
1161
+ aria-label="Year"
1162
+ @keydown=${this.handleDropdownKeydown}
1163
+ >
1023
1164
  ${years.map(
1024
1165
  (y) => html`
1025
- <div
1166
+ <button
1026
1167
  class="dropdown-item ${y === this.viewYear ? "selected" : ""}"
1168
+ type="button"
1169
+ tabindex="-1"
1027
1170
  @click=${(e: Event) => this.selectYear(y, e)}
1028
1171
  role="option"
1029
1172
  aria-selected=${y === this.viewYear}
1030
1173
  >
1031
1174
  ${y}
1032
- </div>
1175
+ </button>
1033
1176
  `
1034
1177
  )}
1035
1178
  </div>
@@ -1074,7 +1217,7 @@ export class RelDatePicker extends LitElement {
1074
1217
  ?disabled=${day.isDisabled}
1075
1218
  @click=${() => this.selectDay(day)}
1076
1219
  @keydown=${(e: KeyboardEvent) => this.handleDayKeydown(e, day, index)}
1077
- tabindex="${day.isSelected || (index === 0 && !this.value) ? 0 : -1}"
1220
+ tabindex="${index === rovingIndex ? 0 : -1}"
1078
1221
  >
1079
1222
  ${day.day}
1080
1223
  </button>
@@ -2,6 +2,7 @@ import { LitElement, html, css } from "lit";
2
2
  import { customElement, property, state, query } from "lit/decorators.js";
3
3
  import "@reliability-design/icons/rel-icon";
4
4
  import "./rel-drawer-footer";
5
+ import { getDeepActiveElement, getFocusableElements, trapTabKey } from "../../utils/focus.js";
5
6
 
6
7
  export type DrawerSide = "bottom" | "left" | "right";
7
8
  export type DrawerCloseReason = "backdrop" | "escape" | "drag" | "programmatic";
@@ -327,16 +328,26 @@ export class RelDrawer extends LitElement {
327
328
  }
328
329
 
329
330
  private handleOpen() {
330
- this.previousFocus = document.activeElement as HTMLElement;
331
+ // Deep-resolved: document.activeElement retargets to a component's host
332
+ // (e.g. <rel-button>), whose .focus() is a no-op — restore would drop
333
+ // focus to <body>.
334
+ this.previousFocus = getDeepActiveElement();
331
335
  this.lockScroll();
332
336
 
333
337
  if (this.modal) {
338
+ // The panel lives in the shadow root, so plain this.querySelector never
339
+ // found it (focus was never moved in). The shared util walks the
340
+ // composed tree, seeing both shadow and slotted content.
334
341
  this.updateComplete.then(() => {
335
- const focusTarget =
336
- this.querySelector<HTMLElement>("[autofocus]") ??
337
- this.querySelector<HTMLElement>(".close-button") ??
338
- this.querySelector<HTMLElement>(".panel");
339
- focusTarget?.focus();
342
+ const panel = this.panelElement;
343
+ if (!panel) return;
344
+ const focusTarget = getFocusableElements(panel)[0];
345
+ if (focusTarget) {
346
+ focusTarget.focus();
347
+ } else {
348
+ panel.tabIndex = -1;
349
+ panel.focus();
350
+ }
340
351
  });
341
352
  }
342
353
 
@@ -414,35 +425,6 @@ export class RelDrawer extends LitElement {
414
425
  document.body.style.paddingRight = this.originalBodyPaddingRight;
415
426
  }
416
427
 
417
- private getFocusableElements(): HTMLElement[] {
418
- if (!this.panelElement) return [];
419
-
420
- const selectors = [
421
- 'a[href]',
422
- 'button:not([disabled])',
423
- 'textarea:not([disabled])',
424
- 'input:not([disabled])',
425
- 'select:not([disabled])',
426
- '[tabindex]:not([tabindex="-1"])',
427
- ].join(",");
428
-
429
- const elements = Array.from(
430
- this.panelElement.querySelectorAll(selectors)
431
- ) as HTMLElement[];
432
-
433
- // Also check slotted content
434
- const slots = this.panelElement.querySelectorAll("slot");
435
- slots.forEach((slot) => {
436
- const assigned = slot.assignedElements({ flatten: true });
437
- assigned.forEach((el) => {
438
- const focusableInSlot = el.querySelectorAll(selectors);
439
- elements.push(...(Array.from(focusableInSlot) as HTMLElement[]));
440
- });
441
- });
442
-
443
- return elements;
444
- }
445
-
446
428
  private handleKeydown = (e: KeyboardEvent) => {
447
429
  if (!this.open) return;
448
430
 
@@ -452,29 +434,11 @@ export class RelDrawer extends LitElement {
452
434
  return;
453
435
  }
454
436
 
455
- // Focus trap
456
- if (e.key === "Tab" && this.modal) {
457
- const focusable = this.getFocusableElements();
458
- if (focusable.length === 0) {
459
- e.preventDefault();
460
- return;
461
- }
462
-
463
- const firstFocusable = focusable[0];
464
- const lastFocusable = focusable[focusable.length - 1];
465
- const activeElement = document.activeElement;
466
-
467
- if (e.shiftKey) {
468
- if (activeElement === firstFocusable) {
469
- e.preventDefault();
470
- lastFocusable.focus();
471
- }
472
- } else {
473
- if (activeElement === lastFocusable) {
474
- e.preventDefault();
475
- firstFocusable.focus();
476
- }
477
- }
437
+ // Focus trap — the shared helper resolves the deep active element, which
438
+ // the old document.activeElement comparison never could (it retargets to
439
+ // the <rel-drawer> host, so Tab was never wrapped).
440
+ if (e.key === "Tab" && this.modal && this.panelElement) {
441
+ trapTabKey(e, this.panelElement);
478
442
  }
479
443
  };
480
444
 
@@ -1,4 +1,4 @@
1
- import { LitElement, html } from "lit";
1
+ import { LitElement, html, nothing } from "lit";
2
2
  import { customElement, property, state } from "lit/decorators.js";
3
3
  import "@reliability-design/icons/rel-icon";
4
4
  import "../rel-checkbox/rel-checkbox.js";
@@ -38,8 +38,13 @@ export class RelMenu extends LitElement {
38
38
  @property({ type: Array }) items: MenuItem[] = [];
39
39
 
40
40
  @state() private focusedIndex: number = 0;
41
+ /** Index inside the open submenu that virtual focus sits on; -1 = main list. */
42
+ @state() private subFocusedIndex: number = -1;
41
43
  @state() private _items: MenuItem[] = [];
42
44
 
45
+ /** Stable id prefix for aria-activedescendant targets. */
46
+ private menuId = `rel-menu-${Math.random().toString(36).slice(2)}`;
47
+
43
48
  public static readonly _css = `
44
49
  :host {
45
50
  color: var(--rel-text-default);
@@ -122,7 +127,10 @@ export class RelMenu extends LitElement {
122
127
  cursor: not-allowed;
123
128
  }
124
129
 
125
- .rel-menu-item[data-focused="true"] {
130
+ /* [data-focused] (presence, not ="true"): the ?data-focused boolean
131
+ binding renders the attribute with an EMPTY value, so a ="true"
132
+ selector never matches. */
133
+ .rel-menu-item[data-focused] {
126
134
  outline: 2px solid var(--rel-border-focus);
127
135
  outline-offset: -2px;
128
136
  }
@@ -235,6 +243,7 @@ export class RelMenu extends LitElement {
235
243
  // click that opened it. composedPath pierces shadow boundaries.
236
244
  if (!e.composedPath().includes(this)) {
237
245
  this.lockedIndex = -1;
246
+ this.subFocusedIndex = -1;
238
247
  this._items = this._items.map(i => ({ ...i, open: false }));
239
248
  }
240
249
  };
@@ -319,38 +328,119 @@ export class RelMenu extends LitElement {
319
328
  );
320
329
  }
321
330
 
331
+ /** Keyboard-open locks the submenu (no hover-out to close it) and moves
332
+ * virtual focus onto its first entry. */
333
+ private openSubmenuAt(index: number) {
334
+ this.lockedIndex = index;
335
+ this.subFocusedIndex = 0;
336
+ this._items = this._items.map((i, idx) => ({ ...i, open: idx === index }));
337
+ }
338
+
339
+ private closeSubmenus() {
340
+ this.lockedIndex = -1;
341
+ this.subFocusedIndex = -1;
342
+ this._items = this._items.map(i => ({ ...i, open: false }));
343
+ }
344
+
322
345
  private onKeydown(ev: KeyboardEvent) {
323
346
  const last = this._items.length - 1;
347
+ const focusedItem = this._items[this.focusedIndex];
348
+
349
+ // ── Virtual focus is inside an open submenu ──
350
+ if (
351
+ this.subFocusedIndex >= 0 &&
352
+ this.type === "arrow" &&
353
+ focusedItem?.open &&
354
+ focusedItem.submenu?.length
355
+ ) {
356
+ const subLast = focusedItem.submenu.length - 1;
357
+ switch (ev.key) {
358
+ case "ArrowDown":
359
+ this.subFocusedIndex = Math.min(subLast, this.subFocusedIndex + 1);
360
+ ev.preventDefault();
361
+ return;
362
+ case "ArrowUp":
363
+ this.subFocusedIndex = Math.max(0, this.subFocusedIndex - 1);
364
+ ev.preventDefault();
365
+ return;
366
+ case "Home":
367
+ this.subFocusedIndex = 0;
368
+ ev.preventDefault();
369
+ return;
370
+ case "End":
371
+ this.subFocusedIndex = subLast;
372
+ ev.preventDefault();
373
+ return;
374
+ case "ArrowLeft":
375
+ case "Escape":
376
+ // Close only the submenu; don't let Escape also dismiss an
377
+ // enclosing popover in the same keystroke.
378
+ this.closeSubmenus();
379
+ ev.preventDefault();
380
+ ev.stopPropagation();
381
+ return;
382
+ case "Enter":
383
+ case " ":
384
+ this.onSubClick(this.focusedIndex, this.subFocusedIndex);
385
+ ev.preventDefault();
386
+ return;
387
+ default:
388
+ return;
389
+ }
390
+ }
324
391
 
392
+ // ── Virtual focus is in the main list ──
325
393
  if (ev.key === "ArrowDown") {
326
394
  this.focusedIndex = Math.min(last, this.focusedIndex + 1);
327
395
  ev.preventDefault();
328
396
  } else if (ev.key === "ArrowUp") {
329
397
  this.focusedIndex = Math.max(0, this.focusedIndex - 1);
330
398
  ev.preventDefault();
399
+ } else if (ev.key === "Home") {
400
+ this.focusedIndex = 0;
401
+ ev.preventDefault();
402
+ } else if (ev.key === "End") {
403
+ this.focusedIndex = last;
404
+ ev.preventDefault();
331
405
  } else if (ev.key === "ArrowRight") {
332
406
  const item = this._items[this.focusedIndex];
333
- if (this.type === "arrow" && item?.submenu?.length) {
334
- // keyboard-open locks (no hover-out to close it)
335
- this.lockedIndex = this.focusedIndex;
336
- this._items = this._items.map((i, idx) => ({
337
- ...i,
338
- open: idx === this.focusedIndex ? true : i.open,
339
- }));
407
+ if (this.type === "arrow" && item?.submenu?.length && !item.disabled) {
408
+ this.openSubmenuAt(this.focusedIndex);
409
+ ev.preventDefault();
340
410
  }
341
411
  } else if (ev.key === "ArrowLeft" || ev.key === "Escape") {
342
- this.lockedIndex = -1;
343
- this._items = this._items.map(i => ({ ...i, open: false }));
412
+ const anyOpen = this._items.some(i => i.open);
413
+ if (anyOpen) {
414
+ this.closeSubmenus();
415
+ ev.preventDefault();
416
+ if (ev.key === "Escape") ev.stopPropagation();
417
+ }
344
418
  } else if (ev.key === "Enter" || ev.key === " ") {
345
419
  const item = this._items[this.focusedIndex];
346
- this.onItemClick(item, this.focusedIndex);
420
+ if (this.type === "arrow" && item?.submenu?.length && !item.disabled) {
421
+ this.openSubmenuAt(this.focusedIndex);
422
+ } else {
423
+ this.onItemClick(item, this.focusedIndex);
424
+ }
347
425
  ev.preventDefault();
348
426
  }
349
427
  }
350
428
 
351
429
  private renderMenuItem(item: MenuItem, index: number) {
352
- const isFocused = this.focusedIndex === index;
430
+ const isFocused = this.focusedIndex === index && this.subFocusedIndex < 0;
353
431
  const hasSubmenu = this.type === "arrow" && !!item.submenu?.length;
432
+ const role =
433
+ this.type === "checkbox"
434
+ ? "menuitemcheckbox"
435
+ : this.type === "select-option"
436
+ ? "menuitemradio"
437
+ : "menuitem";
438
+ const ariaChecked =
439
+ this.type === "checkbox"
440
+ ? String(!!item.checked)
441
+ : this.type === "select-option"
442
+ ? String(!!item.active)
443
+ : nothing;
354
444
 
355
445
  // Checkbox / select-option rows delegate their control to the real
356
446
  // rel-checkbox / rel-radio components (pointer-events:none so the whole ROW
@@ -381,8 +471,13 @@ export class RelMenu extends LitElement {
381
471
 
382
472
  return html`
383
473
  <div
474
+ id="${this.menuId}-item-${index}"
384
475
  class="rel-menu-item ${item.active ? "active" : ""} ${item.disabled ? "disabled" : ""}"
385
- role="menuitem"
476
+ role="${role}"
477
+ aria-checked="${ariaChecked}"
478
+ aria-disabled="${item.disabled ? "true" : nothing}"
479
+ aria-haspopup="${hasSubmenu ? "menu" : nothing}"
480
+ aria-expanded="${hasSubmenu ? String(!!item.open) : nothing}"
386
481
  ?data-focused="${isFocused}"
387
482
  @mouseenter="${() => this.onItemEnter(item, index)}"
388
483
  @mouseleave="${() => this.onItemLeave(item, index)}"
@@ -392,12 +487,16 @@ export class RelMenu extends LitElement {
392
487
  ? html`
393
488
  <div
394
489
  part="panel" class="rel-submenu ${item.open ? "visible" : ""}"
490
+ role="menu"
395
491
  @click="${(e: Event) => e.stopPropagation()}">
396
492
  ${item.submenu!.map(
397
493
  (sub, subIndex) => html`
398
494
  <div
399
- class="rel-menu-item ${sub.active ? "active" : ""}"
495
+ id="${this.menuId}-item-${index}-sub-${subIndex}"
496
+ class="rel-menu-item ${sub.active ? "active" : ""} ${sub.disabled ? "disabled" : ""}"
400
497
  role="menuitem"
498
+ aria-disabled="${sub.disabled ? "true" : nothing}"
499
+ ?data-focused="${this.focusedIndex === index && this.subFocusedIndex === subIndex}"
401
500
  @click="${(e: Event) => this.onSubClick(index, subIndex, e)}">
402
501
  <span class="menu-label">${sub.label}</span>
403
502
  </div>
@@ -411,12 +510,22 @@ export class RelMenu extends LitElement {
411
510
  }
412
511
 
413
512
  render() {
513
+ // DOM focus stays on the container; this tells AT which item virtual
514
+ // focus is on as the user arrows around (main list or open submenu).
515
+ const activeDescendant =
516
+ this._items.length === 0
517
+ ? nothing
518
+ : this.subFocusedIndex >= 0
519
+ ? `${this.menuId}-item-${this.focusedIndex}-sub-${this.subFocusedIndex}`
520
+ : `${this.menuId}-item-${this.focusedIndex}`;
521
+
414
522
  return html`
415
523
  <style>${RelMenu._css}</style>
416
524
  <div
417
525
  part="list" class="rel-menu ${this.type === "fixed" || this.type === "fit" ? this.type : ""}"
418
526
  role="menu"
419
527
  tabindex="0"
528
+ aria-activedescendant="${activeDescendant}"
420
529
  @keydown="${this.onKeydown}"
421
530
  @click="${(e: Event) => e.stopPropagation()}">
422
531
  ${this._items.map((item, index) => this.renderMenuItem(item, index))}