@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
@@ -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
  /* =======================
@@ -50,8 +50,12 @@ export class RelRangePicker extends LitElement {
50
50
  @state() private viewMonth: number = new Date().getMonth();
51
51
  @state() private isSelectingRange: boolean = false;
52
52
  @state() private hoverDate: string | null = null;
53
+ /** ISO date of the cell that owns the grid's single Tab stop (roving tabindex). */
54
+ @state() private focusedDate: string | null = null;
53
55
 
54
56
  @query(".trigger-button") private triggerButton?: HTMLButtonElement;
57
+ @query(".popover") private popoverEl?: HTMLDivElement;
58
+ @query(".backdrop") private backdropEl?: HTMLDivElement;
55
59
 
56
60
  static styles = css`
57
61
  :host {
@@ -113,6 +117,17 @@ export class RelRangePicker extends LitElement {
113
117
 
114
118
  /* ── Popover shell ── */
115
119
  .popover {
120
+ /* Reset native [popover] UA styles first (the shell is promoted to
121
+ the top layer so transformed / overflow:hidden ancestors — e.g. a
122
+ centered rel-modal panel — can't hijack or clip it). While in the
123
+ top layer, JS overrides the anchored position below with inline
124
+ fixed coordinates; the CSS anchoring is the no-Popover-API
125
+ fallback. */
126
+ margin: 0;
127
+ padding: 0;
128
+ width: auto;
129
+ height: auto;
130
+ color: inherit;
116
131
  position: absolute;
117
132
  top: calc(100% + 4px);
118
133
  left: 0;
@@ -137,6 +152,13 @@ export class RelRangePicker extends LitElement {
137
152
  .backdrop {
138
153
  position: fixed;
139
154
  inset: 0;
155
+ /* Reset native [popover] UA styles — the backdrop is promoted to the
156
+ top layer right before the panel so the panel stacks above it. */
157
+ margin: 0;
158
+ padding: 0;
159
+ width: auto;
160
+ height: auto;
161
+ border: none;
140
162
  z-index: 2147483646;
141
163
  background: rgba(0,0,0,0.4);
142
164
  }
@@ -332,6 +354,12 @@ export class RelRangePicker extends LitElement {
332
354
  cursor: pointer;
333
355
  }
334
356
 
357
+ .day-cell:focus-visible {
358
+ outline: 2px solid var(--rel-border-focus);
359
+ outline-offset: -2px;
360
+ z-index: 2;
361
+ }
362
+
335
363
  .day-cell.other-month {
336
364
  opacity: 0.35;
337
365
  pointer-events: none;
@@ -448,17 +476,111 @@ export class RelRangePicker extends LitElement {
448
476
  super.disconnectedCallback();
449
477
  document.removeEventListener("click", this.handleClickOutside);
450
478
  document.removeEventListener("keydown", this.handleGlobalKeydown);
479
+ window.removeEventListener("resize", this.onReposition);
480
+ window.removeEventListener("scroll", this.onReposition, true);
481
+ this.hideTopLayer();
451
482
  }
452
483
 
453
- updated() {
484
+ updated(changed: Map<string, unknown>) {
454
485
  this.style.width = this.width;
455
486
  this.style.minWidth = this.minWidth;
456
487
  this.style.maxWidth = this.maxWidth;
457
488
  this.style.height = this.height;
458
489
  this.style.minHeight = this.minHeight;
459
490
  this.style.maxHeight = this.maxHeight;
491
+
492
+ // `open` is a public reflected property (consumers may set it directly,
493
+ // not only via the trigger), so top-layer promotion syncs here rather
494
+ // than inside openPopover/cancelPopover.
495
+ if (changed.has("open")) {
496
+ if (this.open) {
497
+ this.showTopLayer();
498
+ window.addEventListener("resize", this.onReposition);
499
+ window.addEventListener("scroll", this.onReposition, true);
500
+ } else {
501
+ window.removeEventListener("resize", this.onReposition);
502
+ window.removeEventListener("scroll", this.onReposition, true);
503
+ this.hideTopLayer();
504
+ }
505
+ }
460
506
  }
461
507
 
508
+ /* A transformed ancestor (e.g. rel-modal's centered panel with
509
+ * translate(-50%,-50%)) becomes the containing block for position:fixed
510
+ * descendants, and its overflow:hidden clips position:absolute ones —
511
+ * either way the picker shell breaks inside a modal. Promoting shell
512
+ * (and popup-mode backdrop) to the browser top layer sidesteps both —
513
+ * same fix as rel-popover / rel-autocomplete / rel-select. `manual`
514
+ * keeps outside-click and Escape handling under this component's own
515
+ * control. While in the top layer the CSS anchoring (absolute, below
516
+ * the trigger) is meaningless, so popover mode gets inline fixed
517
+ * coordinates from updatePopoverPosition(). */
518
+
519
+ private showTopLayer() {
520
+ // Backdrop first — later-shown popovers stack above earlier ones.
521
+ for (const el of [this.mode === "popup" ? this.backdropEl : undefined, this.popoverEl]) {
522
+ if (!el || typeof el.showPopover !== "function") continue;
523
+ try {
524
+ if (!el.matches(":popover-open")) el.showPopover();
525
+ } catch {
526
+ /* older engines / detached element — CSS fallback applies */
527
+ }
528
+ }
529
+ if (this.mode === "popover") {
530
+ requestAnimationFrame(() => this.updatePopoverPosition());
531
+ }
532
+ }
533
+
534
+ private hideTopLayer() {
535
+ for (const el of [this.popoverEl, this.backdropEl]) {
536
+ if (!el || typeof el.hidePopover !== "function") continue;
537
+ try {
538
+ if (el.matches(":popover-open")) el.hidePopover();
539
+ } catch {
540
+ /* already hidden or unsupported — nothing to do */
541
+ }
542
+ }
543
+ if (this.popoverEl) {
544
+ this.popoverEl.style.position = "";
545
+ this.popoverEl.style.top = "";
546
+ this.popoverEl.style.left = "";
547
+ }
548
+ }
549
+
550
+ private onReposition = () => {
551
+ if (this.open && this.mode === "popover") this.updatePopoverPosition();
552
+ };
553
+
554
+ private updatePopoverPosition = () => {
555
+ const panel = this.popoverEl;
556
+ if (!panel || !this.open || this.mode !== "popover") return;
557
+
558
+ // Host rect anchors both trigger variants (default button and icon).
559
+ const t = this.getBoundingClientRect();
560
+ const c = panel.getBoundingClientRect();
561
+
562
+ let top = t.bottom + 4;
563
+ if (top + c.height > window.innerHeight - 8) {
564
+ top = Math.max(8, t.top - c.height - 4);
565
+ }
566
+ let left = t.left;
567
+ if (left + c.width > window.innerWidth - 8) {
568
+ left = Math.max(8, window.innerWidth - c.width - 8);
569
+ }
570
+
571
+ // Without top-layer support a transformed ancestor may be the containing
572
+ // block for position:fixed, shifting the shell away from the viewport
573
+ // coordinates computed above. Apply, re-measure, and cancel the offset.
574
+ panel.style.position = "fixed";
575
+ panel.style.top = `${top}px`;
576
+ panel.style.left = `${left}px`;
577
+ const applied = panel.getBoundingClientRect();
578
+ top -= applied.top - top;
579
+ left -= applied.left - left;
580
+ panel.style.top = `${top}px`;
581
+ panel.style.left = `${left}px`;
582
+ };
583
+
462
584
  // ── Open / close ──
463
585
 
464
586
  private handleClickOutside = (e: MouseEvent) => {
@@ -513,6 +635,7 @@ export class RelRangePicker extends LitElement {
513
635
  this.draftActivePreset = null;
514
636
  this.isSelectingRange = false;
515
637
  this.hoverDate = null;
638
+ this.focusedDate = null;
516
639
 
517
640
  if (this.draftFrom) {
518
641
  const date = this.parseISODate(this.draftFrom);
@@ -868,11 +991,130 @@ export class RelRangePicker extends LitElement {
868
991
  this.hoverDate = this.formatISODate(day.date);
869
992
  }
870
993
 
994
+ // ── Grid keyboard navigation (APG grid pattern) ──
995
+
996
+ /**
997
+ * The cell that carries tabindex="0". Prefers the last roved date, then the
998
+ * draft start, then today, then the first selectable day — always exactly
999
+ * one Tab stop, even after arrowing away with nothing selected.
1000
+ */
1001
+ private getRovingIso(days: RangeDay[]): string {
1002
+ const inView = days.filter((d) => d.isCurrentMonth);
1003
+ if (inView.length === 0) return "";
1004
+
1005
+ const candidates = [
1006
+ this.focusedDate,
1007
+ this.draftFrom,
1008
+ this.formatISODate(new Date()),
1009
+ ];
1010
+ for (const iso of candidates) {
1011
+ if (iso && inView.some((d) => this.formatISODate(d.date) === iso)) {
1012
+ return iso;
1013
+ }
1014
+ }
1015
+ const firstEnabled = inView.find((d) => !d.isDisabled) ?? inView[0];
1016
+ return this.formatISODate(firstEnabled.date);
1017
+ }
1018
+
1019
+ private handleGridKeydown = (e: KeyboardEvent) => {
1020
+ const cell = (e.target as HTMLElement).closest?.(".day-cell") as
1021
+ | HTMLElement
1022
+ | null;
1023
+ const iso = cell?.dataset.iso;
1024
+ if (!iso) return;
1025
+ const date = this.parseISODate(iso);
1026
+ if (!date) return;
1027
+
1028
+ if (e.key === "Enter" || e.key === " ") {
1029
+ e.preventDefault();
1030
+ const isDisabled = this.isDateDisabled(date);
1031
+ if (!isDisabled) {
1032
+ this.handleDayClick({
1033
+ date,
1034
+ day: date.getDate(),
1035
+ isCurrentMonth: true,
1036
+ isToday: false,
1037
+ isDraftFrom: false,
1038
+ isDraftTo: false,
1039
+ isInRange: false,
1040
+ isDisabled,
1041
+ isWeekend: false,
1042
+ });
1043
+ }
1044
+ return;
1045
+ }
1046
+
1047
+ const next = new Date(date);
1048
+ const dow = next.getDay();
1049
+ switch (e.key) {
1050
+ case "ArrowLeft":
1051
+ next.setDate(next.getDate() - 1);
1052
+ break;
1053
+ case "ArrowRight":
1054
+ next.setDate(next.getDate() + 1);
1055
+ break;
1056
+ case "ArrowUp":
1057
+ next.setDate(next.getDate() - 7);
1058
+ break;
1059
+ case "ArrowDown":
1060
+ next.setDate(next.getDate() + 7);
1061
+ break;
1062
+ case "Home":
1063
+ next.setDate(
1064
+ next.getDate() -
1065
+ (this.weekStart === "mon" ? (dow === 0 ? 6 : dow - 1) : dow)
1066
+ );
1067
+ break;
1068
+ case "End":
1069
+ next.setDate(
1070
+ next.getDate() +
1071
+ (this.weekStart === "mon" ? (dow === 0 ? 0 : 7 - dow) : 6 - dow)
1072
+ );
1073
+ break;
1074
+ case "PageUp":
1075
+ if (e.shiftKey) next.setFullYear(next.getFullYear() - 1);
1076
+ else next.setMonth(next.getMonth() - 1);
1077
+ break;
1078
+ case "PageDown":
1079
+ if (e.shiftKey) next.setFullYear(next.getFullYear() + 1);
1080
+ else next.setMonth(next.getMonth() + 1);
1081
+ break;
1082
+ default:
1083
+ return;
1084
+ }
1085
+
1086
+ e.preventDefault();
1087
+ this.moveGridFocusTo(next);
1088
+ };
1089
+
1090
+ private async moveGridFocusTo(date: Date) {
1091
+ this.focusedDate = this.formatISODate(date);
1092
+
1093
+ if (
1094
+ date.getMonth() !== this.viewMonth ||
1095
+ date.getFullYear() !== this.viewYear
1096
+ ) {
1097
+ this.viewMonth = date.getMonth();
1098
+ this.viewYear = date.getFullYear();
1099
+ }
1100
+
1101
+ // Keyboard twin of handleDayHover: keep the range preview following focus.
1102
+ if (this.isSelectingRange && !this.isDateDisabled(date)) {
1103
+ this.hoverDate = this.focusedDate;
1104
+ }
1105
+
1106
+ await this.updateComplete;
1107
+ this.renderRoot
1108
+ .querySelector<HTMLElement>(`.day-cell[data-iso="${this.focusedDate}"]`)
1109
+ ?.focus();
1110
+ }
1111
+
871
1112
  // ── Render ──
872
1113
 
873
1114
  render() {
874
1115
  const quickSelects = this.getQuickSelects();
875
1116
  const days = this.getCalendarDays();
1117
+ const rovingIso = this.getRovingIso(days);
876
1118
  const weekdayLabels = this.getWeekdayLabels();
877
1119
 
878
1120
  const draftFromDisplay = this.draftFrom ? this.formatDisplayDate(this.draftFrom) : "";
@@ -916,11 +1158,12 @@ export class RelRangePicker extends LitElement {
916
1158
  `}
917
1159
 
918
1160
  ${isPopup && this.open
919
- ? html`<div part="overlay" class="backdrop" @click=${this.cancelPopover}></div>`
1161
+ ? html`<div part="overlay" class="backdrop" popover="manual" @click=${this.cancelPopover}></div>`
920
1162
  : nothing}
921
1163
 
922
1164
  <div
923
1165
  part="panel" class="popover ${this.open ? "open" : ""} ${isPopup ? "popup" : ""}"
1166
+ popover="manual"
924
1167
  role="dialog"
925
1168
  aria-label="Select date range"
926
1169
  >
@@ -1006,8 +1249,10 @@ export class RelRangePicker extends LitElement {
1006
1249
  role="grid"
1007
1250
  aria-label="Select date range"
1008
1251
  @mouseleave=${() => { this.hoverDate = null; }}
1252
+ @keydown=${this.handleGridKeydown}
1009
1253
  >
1010
1254
  ${days.map((day) => {
1255
+ const iso = this.formatISODate(day.date);
1011
1256
  const cellClasses = [
1012
1257
  "day-cell",
1013
1258
  day.isInRange ? "in-range" : "",
@@ -1025,8 +1270,11 @@ export class RelRangePicker extends LitElement {
1025
1270
  <div
1026
1271
  class="${cellClasses}"
1027
1272
  role="gridcell"
1028
- aria-label="${this.formatISODate(day.date)}"
1273
+ tabindex="${day.isCurrentMonth && iso === rovingIso ? 0 : -1}"
1274
+ data-iso="${iso}"
1275
+ aria-label="${iso}"
1029
1276
  aria-selected="${day.isDraftFrom || day.isDraftTo || day.isInRange}"
1277
+ aria-disabled="${day.isDisabled ? "true" : nothing}"
1030
1278
  @click=${() => this.handleDayClick(day)}
1031
1279
  @mouseenter=${() => this.handleDayHover(day)}
1032
1280
  >
@@ -94,6 +94,16 @@ export class RelResizableGroup extends LitElement {
94
94
  // Calculate initial sizes
95
95
  const sizes = this.calculateInitialSizes();
96
96
  this.applySizes(sizes);
97
+
98
+ // Initialize each handle's aria-valuenow — before this, AT had no value
99
+ // to announce until the first drag/keypress.
100
+ this.handles.forEach((handle, idx) => {
101
+ const left = sizes[idx];
102
+ const right = sizes[idx + 1];
103
+ if (left !== undefined && right !== undefined && left + right > 0) {
104
+ handle.updateAriaValue((left / (left + right)) * 100);
105
+ }
106
+ });
97
107
  }
98
108
 
99
109
  private calculateInitialSizes(): number[] {
@@ -275,6 +285,16 @@ export class RelResizableGroup extends LitElement {
275
285
  const delta = e.key === "ArrowUp" ? -step : step;
276
286
  this.applyKeyboardResize(handleIndex, delta);
277
287
  applied = true;
288
+ } else if (e.key === "Home" || e.key === "End") {
289
+ // APG window-splitter: Home collapses the leading panel to its min,
290
+ // End grows it to its max (both still clamped by the neighbor).
291
+ const leftPanel = this.panels[handleIndex];
292
+ if (leftPanel && handleIndex + 1 < this.panels.length) {
293
+ e.preventDefault();
294
+ const target = e.key === "Home" ? leftPanel.minSize : leftPanel.maxSize;
295
+ this.applyKeyboardResize(handleIndex, target - leftPanel.currentSize);
296
+ applied = true;
297
+ }
278
298
  }
279
299
 
280
300
  if (applied) {
@@ -108,21 +108,21 @@ export class RelSegmentedButton extends LitElement {
108
108
 
109
109
  /* Sizes */
110
110
  .segmented-buttons.small .segment {
111
- padding: var(--rel-spacing-3xs) var(--rel-spacing-xs);
112
- font-family: var(--rel-body-xs-regular-font-family);
111
+ padding: var(--rel-spacing-3xs) var(--rel-spacing-xs);
112
+ font-family: var(--rel-body-xs-regular-font-family);
113
113
  font-weight: var(--rel-body-xs-semibold-font-weight);
114
114
  font-size: var(--rel-body-xs-semibold-font-size);
115
115
  line-height: var(--rel-body-xs-semibold-line-height);
116
- letter-spacing: var(--rel-body-xs-semibold-letter-spacing);
116
+ letter-spacing: var(--rel-body-xs-semibold-letter-spacing);
117
117
  }
118
118
 
119
119
  .segmented-buttons.medium .segment {
120
- padding: var(--rel-spacing-2xs) var(--rel-spacing-md);
121
- font-family: var(--rel-body-sm-regular-font-family);
122
- font-weight: var(--rel-body-sm-regular-font-weight);
123
- font-size: var(--rel-body-sm-regular-font-size);
124
- line-height: var(--rel-body-sm-regular-line-height);
125
- letter-spacing: var(--rel-body-sm-regular-letter-spacing);
120
+ padding: var(--rel-spacing-2xs) var(--rel-spacing-md);
121
+ font-family: var(--rel-body-sm-regular-font-family);
122
+ font-weight: var(--rel-body-sm-regular-font-weight);
123
+ font-size: var(--rel-body-sm-regular-font-size);
124
+ line-height: var(--rel-body-sm-regular-line-height);
125
+ letter-spacing: var(--rel-body-sm-regular-letter-spacing);
126
126
  }
127
127
  `;
128
128
 
@@ -139,7 +139,33 @@ export class RelSegmentedButton extends LitElement {
139
139
  }
140
140
  }
141
141
 
142
- private renderSegment(option: SegmentOption) {
142
+ /** APG radio-group navigation: arrows move focus AND select, the group is
143
+ * one Tab stop (roving tabindex on the selected segment). */
144
+ private onKeyDown = (e: KeyboardEvent) => {
145
+ const prevKey = e.key === "ArrowLeft" || e.key === "ArrowUp";
146
+ const nextKey = e.key === "ArrowRight" || e.key === "ArrowDown";
147
+ if (!prevKey && !nextKey && e.key !== "Home" && e.key !== "End") return;
148
+
149
+ const enabled = this.options.filter((o) => !o.disabled);
150
+ if (enabled.length === 0) return;
151
+ e.preventDefault();
152
+
153
+ const currentIdx = enabled.findIndex((o) => o.value === this.active);
154
+ let target: number;
155
+ if (e.key === "Home") target = 0;
156
+ else if (e.key === "End") target = enabled.length - 1;
157
+ else if (prevKey) target = currentIdx <= 0 ? enabled.length - 1 : currentIdx - 1;
158
+ else target = (currentIdx + 1) % enabled.length;
159
+
160
+ this.setActive(enabled[target]);
161
+ this.updateComplete.then(() => {
162
+ this.shadowRoot
163
+ ?.querySelector<HTMLElement>('button[tabindex="0"]')
164
+ ?.focus();
165
+ });
166
+ };
167
+
168
+ private renderSegment(option: SegmentOption, rovingValue: string | null) {
143
169
  const isActive = this.active === option.value;
144
170
  const classes = [
145
171
  "segment",
@@ -152,6 +178,9 @@ export class RelSegmentedButton extends LitElement {
152
178
  return html`
153
179
  <button
154
180
  part="option" class="${classes}"
181
+ role="radio"
182
+ aria-checked="${isActive}"
183
+ tabindex="${!option.disabled && option.value === rovingValue ? 0 : -1}"
155
184
  ?disabled="${option.disabled}"
156
185
  @click="${() => this.setActive(option)}">
157
186
  ${option.label}
@@ -166,9 +195,18 @@ export class RelSegmentedButton extends LitElement {
166
195
  `align-${this.align}`,
167
196
  ].join(" ");
168
197
 
198
+ const enabled = this.options.filter((o) => !o.disabled);
199
+ const rovingValue = enabled.some((o) => o.value === this.active)
200
+ ? this.active
201
+ : enabled[0]?.value ?? null;
202
+
169
203
  return html`
170
- <div part="base" class="${containerClasses}">
171
- ${this.options.map((option) => this.renderSegment(option))}
204
+ <div
205
+ part="base" class="${containerClasses}"
206
+ role="radiogroup"
207
+ @keydown="${this.onKeyDown}"
208
+ >
209
+ ${this.options.map((option) => this.renderSegment(option, rovingValue))}
172
210
  </div>
173
211
  `;
174
212
  }
@@ -119,3 +119,4 @@ None exposed.
119
119
  - Typed text that doesn't exactly match (case-insensitive) any option reverts to the last committed `selected` value on blur, Escape, or Tab
120
120
  - The ghost-text suggestion shows the remainder of the first option whose name starts with the typed text (case-insensitive); it has no effect on filtering, which matches substrings anywhere in an option
121
121
  - Dropdown panel is positioned via `position: fixed` and flips above the trigger if there isn't enough viewport space below
122
+ - The open listbox is promoted to the browser **top layer** (native Popover API, `popover="manual"`), so it renders correctly inside transformed / `overflow: hidden` containers — a centered `rel-modal`, a bottom `rel-drawer`. Engines without the Popover API fall back to `position: fixed` with a measure-and-cancel correction for transformed ancestors