@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.
- package/dist/components/rel-accordion/rel-accordion.ts +13 -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/rel-checkbox.ts +4 -1
- 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-popover/rel-popover.ts +31 -3
- package/dist/components/rel-range-picker/rel-range-picker.ts +134 -1
- 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/rel-select.ts +159 -11
- 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/index.d.mts +225 -17
- package/dist/index.d.ts +225 -17
- package/dist/index.js +1468 -344
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1721 -607
- package/dist/index.mjs.map +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
|
171
|
-
${
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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="${() =>
|
|
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
|
-
|
|
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
|
|