@reliability-design/web 1.4.4 → 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 (39) hide show
  1. package/dist/components/rel-accordion/rel-accordion.ts +13 -1
  2. package/dist/components/rel-button/rel-button.ts +18 -2
  3. package/dist/components/rel-calendar/rel-calendar.ts +23 -1
  4. package/dist/components/rel-carousel/rel-carousel.ts +20 -0
  5. package/dist/components/rel-checkbox/rel-checkbox.ts +4 -1
  6. package/dist/components/rel-chip/rel-chip.ts +29 -0
  7. package/dist/components/rel-date-picker/rel-date-picker.ts +156 -13
  8. package/dist/components/rel-drawer/rel-drawer.ts +22 -58
  9. package/dist/components/rel-menu/rel-menu.ts +124 -15
  10. package/dist/components/rel-menubar/rel-menubar.ts +221 -63
  11. package/dist/components/rel-nav-menu/rel-nav-menu.ts +39 -3
  12. package/dist/components/rel-otp/rel-otp.ts +3 -3
  13. package/dist/components/rel-popover/rel-popover.ts +31 -3
  14. package/dist/components/rel-range-picker/rel-range-picker.ts +134 -1
  15. package/dist/components/rel-resizable/rel-resizable-group.ts +20 -0
  16. package/dist/components/rel-segmented-button/rel-segmented-button.ts +50 -12
  17. package/dist/components/rel-select/rel-select.ts +159 -11
  18. package/dist/components/rel-sheet/rel-sheet.ts +22 -1
  19. package/dist/components/rel-sidebar/rel-sidebar.ts +43 -39
  20. package/dist/components/rel-stepper/rel-stepper.ts +44 -1
  21. package/dist/components/rel-tab/rel-tab.ts +32 -7
  22. package/dist/components/rel-time-picker/rel-time-picker.ts +338 -206
  23. package/dist/components/rel-tooltip/rel-tooltip.ts +30 -0
  24. package/dist/components/rel-topbar/rel-topbar-actions.ts +1 -1
  25. package/dist/components/rel-topbar/rel-topbar-brand.ts +1 -1
  26. package/dist/components/rel-topbar/rel-topbar-dropdown-item.ts +1 -1
  27. package/dist/components/rel-topbar/rel-topbar-dropdown.ts +1 -1
  28. package/dist/components/rel-topbar/rel-topbar-item.ts +318 -319
  29. package/dist/components/rel-topbar/rel-topbar-mobile-panel.ts +1 -1
  30. package/dist/components/rel-topbar/rel-topbar-profile.ts +224 -224
  31. package/dist/components/rel-topbar/rel-topbar-utilities.ts +1 -1
  32. package/dist/components/rel-topbar/rel-topbar.ts +440 -440
  33. package/dist/index.d.mts +225 -17
  34. package/dist/index.d.ts +225 -17
  35. package/dist/index.js +1468 -344
  36. package/dist/index.js.map +1 -1
  37. package/dist/index.mjs +1721 -607
  38. package/dist/index.mjs.map +1 -1
  39. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
- import { LitElement, html } from "lit";
2
- import { customElement, state } from "lit/decorators.js";
1
+ import { LitElement, html, nothing } from "lit";
2
+ import { customElement, property, state } from "lit/decorators.js";
3
+ import { matchesShortcut, isEditableTarget } from "../../utils/shortcuts.js";
3
4
  import { RelMenubarMenu } from "./rel-menubar-menu";
4
5
  import "@reliability-design/icons/rel-icon";
5
6
  import "../rel-checkbox/rel-checkbox.js";
@@ -13,15 +14,29 @@ interface MenuState {
13
14
 
14
15
  @customElement("rel-menubar")
15
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
+
16
25
  @state() private menus: MenuState[] = [];
17
26
  @state() private openMenuIndex: number = -1;
18
27
  @state() private focusedTriggerIndex: number = 0;
19
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;
20
31
  @state() private openSubmenuElement: HTMLElement | null = null;
21
32
 
22
33
  private hoverSwitchEnabled = false;
23
34
  private _onScroll?: () => void;
24
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
+
25
40
  public static readonly _css = `
26
41
  /* This component renders into LIGHT DOM (createRenderRoot returns this), so
27
42
  :host / <slot> don't work here — the raw <rel-menubar-menu> definitions
@@ -190,6 +205,7 @@ export class RelMenubar extends LitElement {
190
205
  super.connectedCallback();
191
206
  this.discoverMenus();
192
207
  document.addEventListener("click", this.handleOutsideClick);
208
+ document.addEventListener("keydown", this.handleShortcutKeyDown);
193
209
  this._onScroll = () => {
194
210
  this._repositionOpenMenu();
195
211
  this._repositionOpenSubmenu();
@@ -200,6 +216,7 @@ export class RelMenubar extends LitElement {
200
216
  disconnectedCallback() {
201
217
  super.disconnectedCallback();
202
218
  document.removeEventListener("click", this.handleOutsideClick);
219
+ document.removeEventListener("keydown", this.handleShortcutKeyDown);
203
220
  if (this._onScroll) {
204
221
  window.removeEventListener('scroll', this._onScroll, { capture: true });
205
222
  }
@@ -235,6 +252,24 @@ export class RelMenubar extends LitElement {
235
252
  this.menus = menuElements.map((menu) => ({ menu, open: false }));
236
253
  }
237
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
+
238
273
  private handleOutsideClick = (e: MouseEvent) => {
239
274
  // composedPath (NOT e.target): a document-level listener sees e.target
240
275
  // retargeted to the shadow host when the menubar lives inside a shadow root
@@ -249,13 +284,28 @@ export class RelMenubar extends LitElement {
249
284
  private closeAllMenus() {
250
285
  this.openMenuIndex = -1;
251
286
  this.openSubmenuElement = null;
287
+ this.focusedItemIndex = -1;
288
+ this.focusedSubItemIndex = -1;
252
289
  this.hoverSwitchEnabled = false;
253
290
  this.requestUpdate();
254
291
  }
255
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
+
256
305
  private openMenu(index: number, triggerElement: HTMLElement) {
257
306
  this.openMenuIndex = index;
258
307
  this.focusedItemIndex = -1;
308
+ this.focusedSubItemIndex = -1;
259
309
  this.openSubmenuElement = null;
260
310
  this.requestUpdate();
261
311
 
@@ -303,27 +353,20 @@ export class RelMenubar extends LitElement {
303
353
  event.preventDefault();
304
354
  this.focusedTriggerIndex = Math.max(0, this.focusedTriggerIndex - 1);
305
355
  this.focusTrigger(this.focusedTriggerIndex);
306
- } else if (key === "Enter" || key === " ") {
356
+ } else if (key === "Enter" || key === " " || key === "ArrowDown") {
307
357
  event.preventDefault();
308
358
  const trigger = this.querySelector(
309
359
  `.menubar-trigger[data-index="${this.focusedTriggerIndex}"]`
310
360
  ) as HTMLElement;
311
361
  if (trigger) {
312
- this.openMenu(this.focusedTriggerIndex, trigger);
313
- this.updateComplete.then(() => {
314
- const focusTarget = this.querySelector<HTMLElement>(
315
- "[autofocus], [role='menuitem']"
316
- );
317
- if (focusTarget) {
318
- focusTarget.focus();
319
- } else {
320
- this.focusMenuItem(0);
321
- }
322
- });
362
+ if (this.openMenuIndex !== this.focusedTriggerIndex) {
363
+ this.openMenu(this.focusedTriggerIndex, trigger);
364
+ }
365
+ this.updateComplete.then(() => this.focusMenuItem(0));
323
366
  }
324
- } else if (key === "ArrowDown" && this.openMenuIndex !== -1) {
367
+ } else if (key === "Escape" && this.openMenuIndex !== -1) {
325
368
  event.preventDefault();
326
- this.focusMenuItem(0);
369
+ this.closeAllMenusAndRefocusTrigger();
327
370
  }
328
371
  }
329
372
 
@@ -339,6 +382,40 @@ export class RelMenubar extends LitElement {
339
382
  const menuContent = this.menus[this.openMenuIndex]?.menu;
340
383
  if (!menuContent) return;
341
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 ──
342
419
  const items = this.getFocusableItems(menuContent);
343
420
 
344
421
  if (key === "ArrowDown") {
@@ -349,31 +426,60 @@ export class RelMenubar extends LitElement {
349
426
  event.preventDefault();
350
427
  this.focusedItemIndex = Math.max(0, this.focusedItemIndex - 1);
351
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();
352
437
  } else if (key === "ArrowRight") {
353
438
  event.preventDefault();
354
439
  const currentItem = items[this.focusedItemIndex];
355
440
  if (currentItem?.hasAttribute("data-submenu")) {
356
- this.openSubmenu(currentItem as HTMLElement);
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);
357
451
  }
358
452
  } else if (key === "ArrowLeft") {
359
453
  event.preventDefault();
360
- if (this.openSubmenuElement) {
361
- this.openSubmenuElement = null;
362
- this.requestUpdate();
363
- }
364
454
  } else if (key === "Escape") {
365
455
  event.preventDefault();
366
- this.closeAllMenus();
367
- this.focusTrigger(this.openMenuIndex);
456
+ this.closeAllMenusAndRefocusTrigger();
368
457
  } else if (key === "Enter" || key === " ") {
369
458
  event.preventDefault();
370
459
  const currentItem = items[this.focusedItemIndex];
371
460
  if (currentItem) {
372
- this.handleItemClick(currentItem as HTMLElement);
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
+ }
373
473
  }
374
474
  }
375
475
  }
376
476
 
477
+ private getSubmenuItems(submenuElement: HTMLElement): Element[] {
478
+ return Array.from(submenuElement.children).filter((el) =>
479
+ el.hasAttribute("data-item")
480
+ );
481
+ }
482
+
377
483
  private getFocusableItems(menuContent: RelMenubarMenu): Element[] {
378
484
  return Array.from(menuContent.children).filter(
379
485
  (el) =>
@@ -425,32 +531,51 @@ export class RelMenubar extends LitElement {
425
531
  } else if (item.hasAttribute("data-submenu")) {
426
532
  this.openSubmenu(item);
427
533
  } else {
428
- // Regular item click
429
- this.closeAllMenus();
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();
430
538
  }
431
539
  }
432
540
 
433
- private openSubmenu(submenuTrigger: HTMLElement, renderedTrigger?: HTMLElement, submenuIndex?: number) {
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) {
434
556
  this.openSubmenuElement = submenuTrigger;
435
557
  this.requestUpdate();
436
558
 
437
559
  this.updateComplete.then(() => {
438
- const submenuId = `submenu-${submenuIndex ?? 0}`;
439
- const submenuPanel = this.querySelector(
440
- `.submenu-panel[data-submenu-id="${submenuId}"]`
441
- ) as HTMLElement;
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;
442
563
  if (submenuPanel) {
443
- const positionTarget = renderedTrigger ?? submenuTrigger;
444
- const rect = positionTarget.getBoundingClientRect();
445
- submenuPanel.style.top = `${rect.top}px`;
446
- submenuPanel.style.left = `${rect.right + 4}px`;
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
+ }
447
571
  }
448
572
  });
449
573
  }
450
574
 
451
- private handleSubmenuItemClick(_item: HTMLElement) {
452
- // Regular item - just close
453
- this.closeAllMenus();
575
+ private handleSubmenuItemClick(item: HTMLElement) {
576
+ // Regular item - notify, close, and hand focus back to the trigger
577
+ this.dispatchSelectEvent(item);
578
+ this.closeAllMenusAndRefocusTrigger();
454
579
  }
455
580
 
456
581
  private dispatchChangeEvent(type: "checkbox" | "radio", labelOrName: string, value: string | boolean) {
@@ -467,8 +592,13 @@ export class RelMenubar extends LitElement {
467
592
  );
468
593
  }
469
594
 
470
- private renderMenuItem(item: Element, index: number): any {
471
- const isFocused = index === this.focusedItemIndex;
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;
472
602
 
473
603
  if (item.hasAttribute("data-item")) {
474
604
  const button = item as HTMLButtonElement;
@@ -477,8 +607,12 @@ export class RelMenubar extends LitElement {
477
607
 
478
608
  return html`
479
609
  <div
610
+ id="${itemId}"
480
611
  class="menu-item ${isFocused ? "focused" : ""}"
612
+ role="menuitem"
613
+ data-focus-index="${focusIndex}"
481
614
  data-disabled="${disabled}"
615
+ aria-disabled="${disabled ? "true" : nothing}"
482
616
  @click="${() => !disabled && this.handleItemClick(item as HTMLElement)}"
483
617
  >
484
618
  <span>${button.textContent}</span>
@@ -488,7 +622,7 @@ export class RelMenubar extends LitElement {
488
622
  }
489
623
 
490
624
  if (item.hasAttribute("data-separator")) {
491
- return html`<div class="menu-separator"></div>`;
625
+ return html`<div class="menu-separator" role="separator"></div>`;
492
626
  }
493
627
 
494
628
  if (item.hasAttribute("data-checkbox")) {
@@ -502,6 +636,8 @@ export class RelMenubar extends LitElement {
502
636
 
503
637
  return html`
504
638
  <rel-checkbox
639
+ id="${itemId}"
640
+ data-focus-index="${focusIndex}"
505
641
  label=${labelText}
506
642
  .status=${checkbox?.checked ? "checked" : "unchecked"}
507
643
  @rel-change=${(e: CustomEvent) => {
@@ -514,18 +650,22 @@ export class RelMenubar extends LitElement {
514
650
 
515
651
  if (item.hasAttribute("data-submenu")) {
516
652
  const label = item.getAttribute("label") || item.textContent?.trim() || "";
517
- const submenuId = `submenu-${index}`;
518
653
 
519
654
  return html`
520
655
  <div
656
+ id="${itemId}"
521
657
  class="menu-item menu-submenu-trigger ${isFocused ? "focused" : ""}"
522
- @click="${(e: Event) => this.openSubmenu(item as HTMLElement, e.currentTarget as HTMLElement, index)}"
523
- @mouseenter="${(e: Event) => this.openSubmenu(item as HTMLElement, e.currentTarget as HTMLElement, index)}"
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)}"
524
664
  >
525
665
  <span>${label}</span>
526
666
  <rel-icon name="chevron-right"></rel-icon>
527
667
  </div>
528
- ${this.renderSubmenu(item as HTMLElement, submenuId)}
668
+ ${this.renderSubmenu(item as HTMLElement)}
529
669
  `;
530
670
  }
531
671
 
@@ -555,25 +695,24 @@ export class RelMenubar extends LitElement {
555
695
  return null;
556
696
  }
557
697
 
558
- private renderSubmenu(submenuElement: HTMLElement, submenuId: string) {
698
+ private renderSubmenu(submenuElement: HTMLElement) {
559
699
  const isOpen = this.openSubmenuElement === submenuElement;
560
- const submenuItems = Array.from(submenuElement.children);
700
+ const submenuItems = this.getSubmenuItems(submenuElement);
561
701
 
562
702
  return html`
563
- <div class="submenu-panel ${isOpen ? "open" : ""}" data-submenu-id="${submenuId}">
564
- ${submenuItems.map((subItem) => {
565
- if (subItem.hasAttribute("data-item")) {
566
- const button = subItem as HTMLButtonElement;
567
- return html`
568
- <div
569
- class="menu-item"
570
- @click="${() => this.handleSubmenuItemClick(subItem as HTMLElement)}"
571
- >
572
- <span>${button.textContent}</span>
573
- </div>
574
- `;
575
- }
576
- return null;
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
+ `;
577
716
  })}
578
717
  </div>
579
718
  `;
@@ -582,15 +721,30 @@ export class RelMenubar extends LitElement {
582
721
  private renderMenuPanel(menuState: MenuState, index: number) {
583
722
  const isOpen = index === this.openMenuIndex;
584
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;
585
735
 
586
736
  return html`
587
737
  <div
588
738
  class="menubar-panel ${isOpen ? "open" : ""}"
589
739
  data-menu-index="${index}"
590
740
  tabindex="-1"
741
+ role="menu"
742
+ aria-activedescendant="${activeDescendant}"
591
743
  @keydown="${this.handlePanelKeyDown}"
592
744
  >
593
- ${items.map((item, idx) => this.renderMenuItem(item, idx))}
745
+ ${items.map((item) =>
746
+ this.renderMenuItem(item, focusables.indexOf(item), index)
747
+ )}
594
748
  </div>
595
749
  `;
596
750
  }
@@ -598,14 +752,18 @@ export class RelMenubar extends LitElement {
598
752
  render() {
599
753
  return html`
600
754
  <style>${RelMenubar._css}</style>
601
- <div class="menubar-container">
755
+ <div class="menubar-container" role="menubar">
602
756
  ${this.menus.map(
603
757
  (menuState, index) => html`
604
758
  <button
605
759
  class="menubar-trigger ${index === this.openMenuIndex ? "active" : ""}"
606
760
  data-index="${index}"
761
+ role="menuitem"
762
+ aria-haspopup="menu"
763
+ aria-expanded="${index === this.openMenuIndex}"
607
764
  @click="${(e: Event) => this.handleTriggerClick(index, e)}"
608
765
  @mouseenter="${(e: Event) => this.handleTriggerMouseEnter(index, e)}"
766
+ @focus="${() => { this.focusedTriggerIndex = index; }}"
609
767
  @keydown="${this.handleTriggerKeyDown}"
610
768
  >
611
769
  ${menuState.menu.label}
@@ -7,7 +7,7 @@ type DensityType = "comfortable" | "compact";
7
7
  /**
8
8
  * @deprecated rel-nav-menu has no Figma design in RelDS 2.0 and is excluded
9
9
  * from the parity contract. Use rel-menu (with the select-option/fixed/fit
10
- * types) or the App Topbar recipe instead. It remains exported for
10
+ * types) or the Navbar recipe instead. It remains exported for
11
11
  * compatibility but will be removed in a future major version.
12
12
  */
13
13
  @customElement("rel-nav-menu")
@@ -168,6 +168,32 @@ export class RelNavMenu extends LitElement {
168
168
 
169
169
  const { key } = event;
170
170
  const focusableItems = this.items.filter((item) => !item.disabled);
171
+ const path = event.composedPath();
172
+ const currentItem = focusableItems.find((item) => path.includes(item));
173
+ const inPanel = path.some(
174
+ (el) => el instanceof HTMLElement && el.classList.contains("panel")
175
+ );
176
+
177
+ // Sync the index to where focus ACTUALLY is — Tab moves focus without
178
+ // going through the arrow handlers, so focusedIndex went stale and
179
+ // Enter/arrows acted on the wrong item.
180
+ if (currentItem && !inPanel) {
181
+ this.focusedIndex = focusableItems.indexOf(currentItem);
182
+ }
183
+
184
+ // Keys originating from inside an open panel: let its links/buttons
185
+ // behave natively; only Escape is ours (close + refocus the trigger).
186
+ if (inPanel) {
187
+ if (key === "Escape" && this.open) {
188
+ event.preventDefault();
189
+ const openItemId = this.open;
190
+ this.closePanel();
191
+ const openItem = this.items.find((i) => i.itemId === openItemId);
192
+ const trigger = openItem?.shadowRoot?.querySelector(".trigger") as HTMLElement;
193
+ trigger?.focus();
194
+ }
195
+ return;
196
+ }
171
197
 
172
198
  if (key === "ArrowRight") {
173
199
  event.preventDefault();
@@ -177,10 +203,20 @@ export class RelNavMenu extends LitElement {
177
203
  event.preventDefault();
178
204
  this.focusedIndex = Math.max(0, this.focusedIndex - 1);
179
205
  this.focusItem(this.focusedIndex);
180
- } else if (key === "Enter" || key === " ") {
206
+ } else if (key === "Home") {
181
207
  event.preventDefault();
182
- const item = focusableItems[this.focusedIndex];
208
+ this.focusedIndex = 0;
209
+ this.focusItem(this.focusedIndex);
210
+ } else if (key === "End") {
211
+ event.preventDefault();
212
+ this.focusedIndex = focusableItems.length - 1;
213
+ this.focusItem(this.focusedIndex);
214
+ } else if (key === "Enter" || key === " ") {
215
+ const item = currentItem ?? focusableItems[this.focusedIndex];
183
216
  if (item && item.hasContent) {
217
+ // Only intercept panel triggers — plain link items keep their
218
+ // native Enter activation (preventDefault used to suppress it).
219
+ event.preventDefault();
184
220
  this.openPanel(item.itemId);
185
221
  }
186
222
  } else if (key === "Escape") {
@@ -58,7 +58,7 @@ export class RelOtp extends LitElement {
58
58
  display: flex;
59
59
  align-items: center;
60
60
  justify-content: center;
61
- color: var(--rel-border-default);
61
+ color: var(--rel-border-default);
62
62
  user-select: none;
63
63
  padding: 0 var(--rel-spacing-3xs);
64
64
  font-family: var(--rel-action-xl-font-family);
@@ -77,7 +77,7 @@ export class RelOtp extends LitElement {
77
77
  height: 48px;
78
78
  border: var(--rel-stroke-sm) solid var(--rel-border-default);
79
79
  border-radius: var(--rel-border-radius-sm);
80
- background: var(--rel-bg-neutral);
80
+ background: var(--rel-bg-neutral);
81
81
  text-align: center;
82
82
  color: var(--rel-text-default);
83
83
  outline: none;
@@ -471,7 +471,7 @@ export class RelOtp extends LitElement {
471
471
  data-index="${index}"
472
472
  type="${this.mask ? "password" : "text"}"
473
473
  inputmode="${this.getInputMode()}"
474
- autocomplete="new-password"
474
+ autocomplete="one-time-code"
475
475
  pattern="${this.pattern || ""}"
476
476
  placeholder="${this.placeholder}"
477
477
  maxlength="1"
@@ -1,5 +1,6 @@
1
1
  import { LitElement, html, css } from "lit";
2
2
  import { customElement, property, state, query } from "lit/decorators.js";
3
+ import { getDeepActiveElement, getFocusableElements } from "../../utils/focus.js";
3
4
 
4
5
  type SideType = "top" | "right" | "bottom" | "left";
5
6
  type AlignType = "start" | "center" | "end";
@@ -86,13 +87,15 @@ export class RelPopover extends LitElement {
86
87
  super.connectedCallback();
87
88
  this.internalOpen = this.isControlled() ? !!this.open : this.defaultOpen;
88
89
 
89
- // ONE interaction entry point — capture phase
90
+ // ONE interaction entry point per input method — capture phase
90
91
  this.addEventListener("pointerdown", this.onHostPointerDown, true);
92
+ this.addEventListener("keydown", this.onHostKeyDown, true);
91
93
  }
92
94
 
93
95
  disconnectedCallback() {
94
96
  super.disconnectedCallback();
95
97
  this.removeEventListener("pointerdown", this.onHostPointerDown, true);
98
+ this.removeEventListener("keydown", this.onHostKeyDown, true);
96
99
  this.removeGlobalListeners();
97
100
  this.hideNativePopover();
98
101
  }
@@ -159,13 +162,25 @@ export class RelPopover extends LitElement {
159
162
  this.setOpen(!this.activeOpen);
160
163
  };
161
164
 
165
+ private onHostKeyDown = (e: KeyboardEvent) => {
166
+ if (this.disabled) return;
167
+ if (e.key !== "Enter" && e.key !== " ") return;
168
+ if (!this.triggerEl || !e.composedPath().includes(this.triggerEl)) return;
169
+
170
+ // Keyboard twin of onHostPointerDown. preventDefault also suppresses the
171
+ // native synthetic click of a <button> trigger, which would toggle twice.
172
+ e.preventDefault();
173
+ e.stopPropagation();
174
+
175
+ this.setOpen(!this.activeOpen);
176
+ };
177
+
162
178
  /* =======================
163
179
  * OPEN / CLOSE
164
180
  * ======================= */
165
181
 
166
182
  private onOpen() {
167
- this.returnFocusEl =
168
- (document.activeElement as HTMLElement) ?? this.triggerEl;
183
+ this.returnFocusEl = getDeepActiveElement() ?? this.triggerEl;
169
184
 
170
185
  this.addGlobalListeners();
171
186
  this.showNativePopover();
@@ -386,6 +401,19 @@ export class RelPopover extends LitElement {
386
401
  this.triggerEl.setAttribute("aria-haspopup", "dialog");
387
402
  this.triggerEl.setAttribute("aria-expanded", String(this.activeOpen));
388
403
  this.triggerEl.setAttribute("aria-controls", this.uniqueId);
404
+
405
+ // A bare <div>/<span> trigger must be Tab-reachable. Skip anything that
406
+ // is (or contains) natively focusable so buttons don't get a second stop.
407
+ if (
408
+ this.triggerEl.tabIndex < 0 &&
409
+ !this.triggerEl.hasAttribute("tabindex") &&
410
+ getFocusableElements(this.triggerEl).length === 0
411
+ ) {
412
+ this.triggerEl.setAttribute("tabindex", "0");
413
+ if (!this.triggerEl.hasAttribute("role")) {
414
+ this.triggerEl.setAttribute("role", "button");
415
+ }
416
+ }
389
417
  }
390
418
 
391
419
  /* =======================