@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
@@ -50,6 +50,8 @@ 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;
55
57
  @query(".popover") private popoverEl?: HTMLDivElement;
@@ -352,6 +354,12 @@ export class RelRangePicker extends LitElement {
352
354
  cursor: pointer;
353
355
  }
354
356
 
357
+ .day-cell:focus-visible {
358
+ outline: 2px solid var(--rel-border-focus);
359
+ outline-offset: -2px;
360
+ z-index: 2;
361
+ }
362
+
355
363
  .day-cell.other-month {
356
364
  opacity: 0.35;
357
365
  pointer-events: none;
@@ -627,6 +635,7 @@ export class RelRangePicker extends LitElement {
627
635
  this.draftActivePreset = null;
628
636
  this.isSelectingRange = false;
629
637
  this.hoverDate = null;
638
+ this.focusedDate = null;
630
639
 
631
640
  if (this.draftFrom) {
632
641
  const date = this.parseISODate(this.draftFrom);
@@ -982,11 +991,130 @@ export class RelRangePicker extends LitElement {
982
991
  this.hoverDate = this.formatISODate(day.date);
983
992
  }
984
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
+
985
1112
  // ── Render ──
986
1113
 
987
1114
  render() {
988
1115
  const quickSelects = this.getQuickSelects();
989
1116
  const days = this.getCalendarDays();
1117
+ const rovingIso = this.getRovingIso(days);
990
1118
  const weekdayLabels = this.getWeekdayLabels();
991
1119
 
992
1120
  const draftFromDisplay = this.draftFrom ? this.formatDisplayDate(this.draftFrom) : "";
@@ -1121,8 +1249,10 @@ export class RelRangePicker extends LitElement {
1121
1249
  role="grid"
1122
1250
  aria-label="Select date range"
1123
1251
  @mouseleave=${() => { this.hoverDate = null; }}
1252
+ @keydown=${this.handleGridKeydown}
1124
1253
  >
1125
1254
  ${days.map((day) => {
1255
+ const iso = this.formatISODate(day.date);
1126
1256
  const cellClasses = [
1127
1257
  "day-cell",
1128
1258
  day.isInRange ? "in-range" : "",
@@ -1140,8 +1270,11 @@ export class RelRangePicker extends LitElement {
1140
1270
  <div
1141
1271
  class="${cellClasses}"
1142
1272
  role="gridcell"
1143
- aria-label="${this.formatISODate(day.date)}"
1273
+ tabindex="${day.isCurrentMonth && iso === rovingIso ? 0 : -1}"
1274
+ data-iso="${iso}"
1275
+ aria-label="${iso}"
1144
1276
  aria-selected="${day.isDraftFrom || day.isDraftTo || day.isInRange}"
1277
+ aria-disabled="${day.isDisabled ? "true" : nothing}"
1145
1278
  @click=${() => this.handleDayClick(day)}
1146
1279
  @mouseenter=${() => this.handleDayHover(day)}
1147
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
  }
@@ -103,6 +103,11 @@ export class RelSelect extends LitElement {
103
103
 
104
104
  @state() private open = false;
105
105
  @state() private highlightedIndex = -1;
106
+ /** Virtual highlight on the Select-All header row (multi mode). */
107
+ @state() private selectAllHighlighted = false;
108
+
109
+ private typeaheadBuffer = "";
110
+ private typeaheadTimer: number | undefined;
106
111
  @state() private panelStyle = "";
107
112
  @state() private searchValue = "";
108
113
  @state() private internalState: "default" | "focused" = "default";
@@ -571,6 +576,10 @@ export class RelSelect extends LitElement {
571
576
  padding: 12px;
572
577
  }
573
578
 
579
+ .select-all-row.highlighted {
580
+ background: var(--rel-select-option-hover, var(--rel-bg-neutral-elevated-1));
581
+ }
582
+
574
583
  .select-empty {
575
584
  padding: var(--rel-spacing-xs) 12px;
576
585
  color: var(--rel-select-placeholder-color, var(--rel-text-body));
@@ -769,6 +778,7 @@ export class RelSelect extends LitElement {
769
778
  // before the user has touched anything reads as an unintended default.
770
779
  // Highlight only kicks in once they actually hover or press an arrow key.
771
780
  this.highlightedIndex = -1;
781
+ this.selectAllHighlighted = false;
772
782
  this.addGlobalListeners();
773
783
  requestAnimationFrame(() => {
774
784
  this.showNativePopover();
@@ -903,6 +913,20 @@ export class RelSelect extends LitElement {
903
913
 
904
914
  private onTriggerKeyDown(e: KeyboardEvent) {
905
915
  if (this.state === "disabled") return;
916
+
917
+ // Chips render inside the trigger <button>, so their remove buttons can
918
+ // never be tab stops — Backspace/Delete is the keyboard path for
919
+ // removing the most recent selection (standard multi-combobox behavior).
920
+ if (
921
+ this.multiple &&
922
+ (e.key === "Backspace" || e.key === "Delete") &&
923
+ this.selectedValues.length > 0
924
+ ) {
925
+ e.preventDefault();
926
+ this.removeChip(this.selectedValues[this.selectedValues.length - 1]);
927
+ return;
928
+ }
929
+
906
930
  if (!this.open) {
907
931
  if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
908
932
  e.preventDefault();
@@ -918,31 +942,79 @@ export class RelSelect extends LitElement {
918
942
  private onSearchInput = (e: InputEvent) => {
919
943
  this.searchValue = (e.target as HTMLInputElement).value;
920
944
  this.highlightedIndex = this.filteredOptions.length > 0 ? 0 : -1;
945
+ this.selectAllHighlighted = false;
921
946
  requestAnimationFrame(() => this.updatePosition());
922
947
  };
923
948
 
924
949
  private onSearchKeyDown(e: KeyboardEvent) {
950
+ // Backspace in an empty search field removes the last chip (see
951
+ // onTriggerKeyDown for the rationale).
952
+ if (
953
+ this.multiple &&
954
+ e.key === "Backspace" &&
955
+ this.searchValue === "" &&
956
+ this.selectedValues.length > 0
957
+ ) {
958
+ this.removeChip(this.selectedValues[this.selectedValues.length - 1]);
959
+ return;
960
+ }
925
961
  this.handleListNavigation(e);
926
962
  }
927
963
 
928
964
  private handleListNavigation(e: KeyboardEvent) {
929
965
  const filtered = this.filteredOptions;
966
+ // The Select-All row participates in arrow navigation as a virtual row
967
+ // above the first option — it lives in the panel header, outside the
968
+ // option list, and would otherwise be keyboard-unreachable.
969
+ const hasSelectAll = this.multiple && filtered.length > 0;
970
+
930
971
  switch (e.key) {
931
972
  case "ArrowDown":
932
973
  e.preventDefault();
933
- this.highlightedIndex = Math.min(filtered.length - 1, this.highlightedIndex + 1);
974
+ if (this.selectAllHighlighted) {
975
+ this.selectAllHighlighted = false;
976
+ this.highlightedIndex = filtered.length > 0 ? 0 : -1;
977
+ } else if (this.highlightedIndex === -1 && hasSelectAll) {
978
+ this.selectAllHighlighted = true;
979
+ } else {
980
+ this.highlightedIndex = Math.min(filtered.length - 1, this.highlightedIndex + 1);
981
+ }
982
+ this.scrollHighlightedIntoView();
934
983
  break;
935
984
  case "ArrowUp":
936
985
  e.preventDefault();
937
- this.highlightedIndex = Math.max(0, this.highlightedIndex - 1);
986
+ if (!this.selectAllHighlighted && this.highlightedIndex <= 0 && hasSelectAll) {
987
+ this.highlightedIndex = -1;
988
+ this.selectAllHighlighted = true;
989
+ } else {
990
+ this.highlightedIndex = Math.max(0, this.highlightedIndex - 1);
991
+ }
992
+ this.scrollHighlightedIntoView();
993
+ break;
994
+ case "Home":
995
+ if (filtered.length === 0) break;
996
+ e.preventDefault();
997
+ this.selectAllHighlighted = false;
998
+ this.highlightedIndex = 0;
999
+ this.scrollHighlightedIntoView();
1000
+ break;
1001
+ case "End":
1002
+ if (filtered.length === 0) break;
1003
+ e.preventDefault();
1004
+ this.selectAllHighlighted = false;
1005
+ this.highlightedIndex = filtered.length - 1;
1006
+ this.scrollHighlightedIntoView();
1007
+ break;
1008
+ case " ":
1009
+ // With a search field, Space types into it; only the no-search mode
1010
+ // (trigger holds focus) treats Space as toggle/select.
1011
+ if (!this.noSearch) return;
1012
+ e.preventDefault();
1013
+ this.activateHighlighted();
938
1014
  break;
939
1015
  case "Enter":
940
1016
  e.preventDefault();
941
- if (this.highlightedIndex >= 0 && filtered[this.highlightedIndex]) {
942
- const opt = filtered[this.highlightedIndex];
943
- if (this.multiple) this.toggleMultiOption(opt);
944
- else this.selectSingleOption(opt);
945
- }
1017
+ this.activateHighlighted();
946
1018
  break;
947
1019
  case "Escape":
948
1020
  e.preventDefault();
@@ -951,9 +1023,67 @@ export class RelSelect extends LitElement {
951
1023
  case "Tab":
952
1024
  this.closeListbox();
953
1025
  break;
1026
+ default:
1027
+ this.handleTypeahead(e);
1028
+ break;
1029
+ }
1030
+ }
1031
+
1032
+ /** APG typeahead for the no-search mode (with a search field, typing
1033
+ * filters instead). Accumulates printable keys for 500 ms and jumps the
1034
+ * highlight to the next option whose label starts with the buffer. */
1035
+ private handleTypeahead(e: KeyboardEvent) {
1036
+ if (!this.noSearch) return;
1037
+ if (e.key.length !== 1 || e.ctrlKey || e.metaKey || e.altKey) return;
1038
+
1039
+ e.preventDefault();
1040
+ const isNewQuery = this.typeaheadBuffer === "";
1041
+ this.typeaheadBuffer += e.key.toLowerCase();
1042
+ clearTimeout(this.typeaheadTimer);
1043
+ this.typeaheadTimer = window.setTimeout(() => {
1044
+ this.typeaheadBuffer = "";
1045
+ }, 500);
1046
+
1047
+ const filtered = this.filteredOptions;
1048
+ if (filtered.length === 0) return;
1049
+
1050
+ // A fresh single-letter query starts searching AFTER the highlight (so
1051
+ // repeated presses cycle through matches); a growing buffer re-tests the
1052
+ // current one first.
1053
+ const start = Math.max(0, this.highlightedIndex) + (isNewQuery ? 1 : 0);
1054
+ for (let offset = 0; offset < filtered.length; offset++) {
1055
+ const idx = (start + offset) % filtered.length;
1056
+ if (filtered[idx].label.toLowerCase().startsWith(this.typeaheadBuffer)) {
1057
+ this.selectAllHighlighted = false;
1058
+ this.highlightedIndex = idx;
1059
+ this.scrollHighlightedIntoView();
1060
+ return;
1061
+ }
1062
+ }
1063
+ }
1064
+
1065
+ private activateHighlighted() {
1066
+ if (this.selectAllHighlighted) {
1067
+ this.toggleSelectAll();
1068
+ return;
1069
+ }
1070
+ const filtered = this.filteredOptions;
1071
+ if (this.highlightedIndex >= 0 && filtered[this.highlightedIndex]) {
1072
+ const opt = filtered[this.highlightedIndex];
1073
+ if (this.multiple) this.toggleMultiOption(opt);
1074
+ else this.selectSingleOption(opt);
954
1075
  }
955
1076
  }
956
1077
 
1078
+ private scrollHighlightedIntoView() {
1079
+ if (this.highlightedIndex < 0) return;
1080
+ requestAnimationFrame(() => {
1081
+ this.renderRoot
1082
+ .querySelector<HTMLElement>(`#${this.uniqueId}-option-${this.highlightedIndex}`)
1083
+ ?.scrollIntoView({ block: "nearest" });
1084
+ });
1085
+ }
1086
+
957
1087
  private renderChips() {
958
1088
  const values = this.selectedValues;
959
1089
  const visible = values.slice(0, Math.min(this.visibleChipCount, this.maxVisibleChips));
@@ -1046,8 +1176,13 @@ export class RelSelect extends LitElement {
1046
1176
  .join(" ");
1047
1177
 
1048
1178
  const listboxId = `${this.uniqueId}-listbox`;
1049
- const activeOptionId =
1050
- this.open && this.highlightedIndex >= 0 ? `${this.uniqueId}-option-${this.highlightedIndex}` : "";
1179
+ const activeOptionId = !this.open
1180
+ ? ""
1181
+ : this.selectAllHighlighted
1182
+ ? `${this.uniqueId}-select-all`
1183
+ : this.highlightedIndex >= 0
1184
+ ? `${this.uniqueId}-option-${this.highlightedIndex}`
1185
+ : "";
1051
1186
 
1052
1187
  const selectedSingle = opts.find((o) => o.value === this.selected);
1053
1188
 
@@ -1145,7 +1280,17 @@ export class RelSelect extends LitElement {
1145
1280
  </div>`
1146
1281
  : ""}
1147
1282
  ${this.multiple && filtered.length > 0
1148
- ? html`<div class="select-all-row">
1283
+ ? html`<div
1284
+ id="${this.uniqueId}-select-all"
1285
+ class="select-all-row ${this.selectAllHighlighted ? "highlighted" : ""}"
1286
+ role="option"
1287
+ aria-selected="${this.selectAllStatus === "checked"}"
1288
+ @pointerenter="${() => {
1289
+ this.selectAllHighlighted = true;
1290
+ this.highlightedIndex = -1;
1291
+ }}"
1292
+ @pointerleave="${() => (this.selectAllHighlighted = false)}"
1293
+ >
1149
1294
  <rel-checkbox
1150
1295
  label="${this.selectAllLabel}"
1151
1296
  status="${this.selectAllStatus}"
@@ -1175,7 +1320,10 @@ export class RelSelect extends LitElement {
1175
1320
  class="checkbox-option ${index === this.highlightedIndex ? "highlighted" : ""}"
1176
1321
  role="option"
1177
1322
  aria-selected="${this.selectedValues.includes(option.value)}"
1178
- @pointerenter="${() => (this.highlightedIndex = index)}"
1323
+ @pointerenter="${() => {
1324
+ this.highlightedIndex = index;
1325
+ this.selectAllHighlighted = false;
1326
+ }}"
1179
1327
  >
1180
1328
  <rel-checkbox
1181
1329
  label="${option.label}"
@@ -1,6 +1,7 @@
1
1
  import { LitElement, html, css } from "lit";
2
2
  import { customElement, property, query } from "lit/decorators.js";
3
3
  import "@reliability-design/icons/rel-icon";
4
+ import { getDeepActiveElement, getFocusableElements, trapTabKey } from "../../utils/focus.js";
4
5
 
5
6
  export type ModalSide = "top" | "bottom" | "center";
6
7
 
@@ -21,6 +22,7 @@ export class RelModal extends LitElement {
21
22
  breakpoint: "responsive" | "web" = "responsive";
22
23
 
23
24
  @query(".overlay") private overlayElement!: HTMLElement;
25
+ @query(".panel") private panelElement!: HTMLElement;
24
26
 
25
27
  private previousFocus: HTMLElement | null = null;
26
28
  private originalBodyOverflow = "";
@@ -322,9 +324,23 @@ export class RelModal extends LitElement {
322
324
  }
323
325
 
324
326
  private handleOpen() {
325
- this.previousFocus = document.activeElement as HTMLElement;
327
+ // Deep-resolved — document.activeElement retargets to shadow hosts whose
328
+ // .focus() may be a no-op, silently dropping the restore.
329
+ this.previousFocus = getDeepActiveElement();
326
330
  this.lockScroll();
327
331
  document.addEventListener("keydown", this.handleKeydown);
332
+
333
+ // Move focus into the dialog — before this, a keyboard user had to Tab
334
+ // through the entire page behind the overlay to reach the sheet.
335
+ if (this.modal) {
336
+ this.updateComplete.then(() => {
337
+ const panel = this.panelElement;
338
+ if (!panel) return;
339
+ const focusTarget = getFocusableElements(panel)[0];
340
+ if (focusTarget) focusTarget.focus();
341
+ else panel.focus();
342
+ });
343
+ }
328
344
  this.dispatchEvent(
329
345
  new CustomEvent("rel-open-change", {
330
346
  detail: true,
@@ -374,6 +390,11 @@ export class RelModal extends LitElement {
374
390
  if (e.key === "Escape" && this.dismissible) {
375
391
  e.preventDefault();
376
392
  this.open = false;
393
+ return;
394
+ }
395
+ // aria-modal promised containment; actually deliver it.
396
+ if (e.key === "Tab" && this.modal && this.panelElement) {
397
+ trapTabKey(e, this.panelElement);
377
398
  }
378
399
  };
379
400