@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.
- package/dist/components/rel-accordion/rel-accordion.ts +13 -1
- package/dist/components/rel-autocomplete/README.md +1 -0
- package/dist/components/rel-autocomplete/rel-autocomplete.ts +56 -1
- package/dist/components/rel-button/rel-button.ts +18 -2
- package/dist/components/rel-calendar/rel-calendar.ts +23 -1
- package/dist/components/rel-carousel/rel-carousel.ts +20 -0
- package/dist/components/rel-checkbox/README.md +69 -22
- package/dist/components/rel-checkbox/rel-checkbox.ts +119 -9
- package/dist/components/rel-chip/rel-chip.ts +29 -0
- package/dist/components/rel-date-picker/rel-date-picker.ts +156 -13
- package/dist/components/rel-drawer/rel-drawer.ts +22 -58
- package/dist/components/rel-menu/rel-menu.ts +124 -15
- package/dist/components/rel-menubar/rel-menubar.ts +221 -63
- package/dist/components/rel-nav-menu/rel-nav-menu.ts +39 -3
- package/dist/components/rel-otp/rel-otp.ts +3 -3
- package/dist/components/rel-pagination/README.md +14 -0
- package/dist/components/rel-pagination/rel-pagination.ts +30 -14
- package/dist/components/rel-popover/rel-popover.ts +31 -3
- package/dist/components/rel-range-picker/rel-range-picker.ts +251 -3
- package/dist/components/rel-resizable/rel-resizable-group.ts +20 -0
- package/dist/components/rel-segmented-button/rel-segmented-button.ts +50 -12
- package/dist/components/rel-select/README.md +1 -0
- package/dist/components/rel-select/rel-select.ts +1385 -1170
- package/dist/components/rel-sheet/rel-sheet.ts +22 -1
- package/dist/components/rel-sidebar/rel-sidebar.ts +43 -39
- package/dist/components/rel-stepper/rel-stepper.ts +44 -1
- package/dist/components/rel-tab/rel-tab.ts +32 -7
- package/dist/components/rel-time-picker/rel-time-picker.ts +338 -206
- package/dist/components/rel-tooltip/rel-tooltip.ts +30 -0
- package/dist/components/rel-topbar/rel-topbar-actions.ts +1 -1
- package/dist/components/rel-topbar/rel-topbar-brand.ts +1 -1
- package/dist/components/rel-topbar/rel-topbar-dropdown-item.ts +1 -1
- package/dist/components/rel-topbar/rel-topbar-dropdown.ts +1 -1
- package/dist/components/rel-topbar/rel-topbar-item.ts +318 -319
- package/dist/components/rel-topbar/rel-topbar-mobile-panel.ts +1 -1
- package/dist/components/rel-topbar/rel-topbar-profile.ts +224 -224
- package/dist/components/rel-topbar/rel-topbar-utilities.ts +1 -1
- package/dist/components/rel-topbar/rel-topbar.ts +440 -440
- package/dist/components/rel-upload/UPLOAD-DESIGN-CHANGES.md +134 -0
- package/dist/components/rel-upload/rel-upload-actions.ts +163 -0
- package/dist/components/rel-upload/rel-upload.ts +943 -0
- package/dist/index.d.mts +491 -18
- package/dist/index.d.ts +491 -18
- package/dist/index.js +2748 -410
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2953 -627
- package/dist/index.mjs.map +1 -1
- 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 (
|
|
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
|
|
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
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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="${
|
|
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
|
-
|
|
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
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
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.
|
|
343
|
-
|
|
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.
|
|
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="
|
|
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
|
-
|
|
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))}
|