|
5 | 5 | @naturalWidth="(newNaturalWidth: number) => $emit('naturalWidth', newNaturalWidth)"
|
6 | 6 | :type="'Dropdown'"
|
7 | 7 | :windowEdgeMargin="0"
|
| 8 | + :escapeCloses="false" |
8 | 9 | v-bind="{ direction, scrollableY, minWidth }"
|
9 | 10 | ref="floatingMenu"
|
10 | 11 | data-hover-menu-keep-open
|
|
15 | 16 | v-for="(entry, entryIndex) in section"
|
16 | 17 | :key="entryIndex"
|
17 | 18 | class="row"
|
18 |
| - :class="{ open: isEntryOpen(entry), active: entry.label === activeEntry?.label }" |
| 19 | + :class="{ open: isEntryOpen(entry), active: entry.label === highlighted?.label }" |
19 | 20 | @click="() => onEntryClick(entry)"
|
20 | 21 | @pointerenter="() => onEntryPointerEnter(entry)"
|
21 | 22 | @pointerleave="() => onEntryPointerLeave(entry)"
|
22 | 23 | >
|
23 |
| - <CheckboxInput v-if="entry.checkbox" v-model:checked="entry.checked" :outlineStyle="true" class="entry-checkbox" /> |
| 24 | + <CheckboxInput v-if="entry.checkbox" v-model:checked="entry.checked" :outlineStyle="true" :disableTabIndex="true" class="entry-checkbox" /> |
24 | 25 | <IconLabel v-else-if="entry.icon && drawIcon" :icon="entry.icon" class="entry-icon" />
|
25 | 26 | <div v-else-if="drawIcon" class="no-icon"></div>
|
26 | 27 |
|
@@ -179,19 +180,22 @@ const MenuList = defineComponent({
|
179 | 180 | direction: { type: String as PropType<MenuDirection>, default: "Bottom" },
|
180 | 181 | minWidth: { type: Number as PropType<number>, default: 0 },
|
181 | 182 | drawIcon: { type: Boolean as PropType<boolean>, default: false },
|
| 183 | + interactive: { type: Boolean as PropType<boolean>, default: false }, |
182 | 184 | scrollableY: { type: Boolean as PropType<boolean>, default: false },
|
183 | 185 | defaultAction: { type: Function as PropType<() => void>, required: false },
|
184 | 186 | },
|
185 | 187 | data() {
|
186 | 188 | return {
|
187 | 189 | isOpen: this.open,
|
188 | 190 | keyboardLockInfoMessage: this.fullscreen.keyboardLockApiSupported ? KEYBOARD_LOCK_USE_FULLSCREEN : KEYBOARD_LOCK_SWITCH_BROWSER,
|
| 191 | + highlighted: this.activeEntry as MenuListEntry | undefined, |
189 | 192 | };
|
190 | 193 | },
|
191 | 194 | watch: {
|
192 | 195 | // Called only when `open` is changed from outside this component (with v-model)
|
193 | 196 | open(newOpen: boolean) {
|
194 | 197 | this.isOpen = newOpen;
|
| 198 | + this.highlighted = this.activeEntry; |
195 | 199 | },
|
196 | 200 | isOpen(newIsOpen: boolean) {
|
197 | 201 | this.$emit("update:open", newIsOpen);
|
@@ -240,6 +244,88 @@ const MenuList = defineComponent({
|
240 | 244 |
|
241 | 245 | return this.open;
|
242 | 246 | },
|
| 247 | +
|
| 248 | + /// Handles keyboard navigation for the menu. Returns if the entire menu stack should be dismissed |
| 249 | + keydown(e: KeyboardEvent, submenu: boolean): boolean { |
| 250 | + // Interactive menus should keep the active entry the same as the highlighted one |
| 251 | + if (this.interactive) this.highlighted = this.activeEntry; |
| 252 | +
|
| 253 | + const menuOpen = this.isOpen; |
| 254 | + const flatEntries = this.entries.flat(); |
| 255 | + const openChild = flatEntries.findIndex((entry) => entry.children?.length && entry.ref?.isOpen); |
| 256 | +
|
| 257 | + const openSubmenu = (highlighted: MenuListEntry<string>): void => { |
| 258 | + if (highlighted.ref && highlighted.children?.length) { |
| 259 | + highlighted.ref.isOpen = true; |
| 260 | +
|
| 261 | + // Highlight first item |
| 262 | + highlighted.ref.setHighlighted(highlighted.children[0][0]); |
| 263 | + } |
| 264 | + }; |
| 265 | +
|
| 266 | + if (!menuOpen && (e.key === " " || e.key === "Enter")) { |
| 267 | + // Allow opening menu with space or enter |
| 268 | + this.isOpen = true; |
| 269 | + this.highlighted = this.activeEntry; |
| 270 | + } else if (menuOpen && openChild >= 0) { |
| 271 | + // Redirect the keyboard navigation to a submenu if one is open |
| 272 | + const shouldCloseStack = flatEntries[openChild].ref?.keydown(e, true); |
| 273 | +
|
| 274 | + // Highlight the menu item in the parent list that corresponds with the open submenu |
| 275 | + if (e.key !== "Escape" && this.highlighted) this.setHighlighted(flatEntries[openChild]); |
| 276 | +
|
| 277 | + // Handle the child closing the entire menu stack |
| 278 | + if (shouldCloseStack) { |
| 279 | + this.isOpen = false; |
| 280 | + return true; |
| 281 | + } |
| 282 | + } else if ((menuOpen || this.interactive) && (e.key === "ArrowUp" || e.key === "ArrowDown")) { |
| 283 | + // Navigate to the next and previous entries with arrow keys |
| 284 | +
|
| 285 | + let newIndex = e.key === "ArrowUp" ? flatEntries.length - 1 : 0; |
| 286 | + if (this.highlighted) { |
| 287 | + const index = this.highlighted ? flatEntries.map((entry) => entry.label).indexOf(this.highlighted.label) : 0; |
| 288 | + newIndex = index + (e.key === "ArrowUp" ? -1 : 1); |
| 289 | +
|
| 290 | + // Interactive dropdowns should lock at the end whereas other dropdowns should loop |
| 291 | + if (this.interactive) newIndex = Math.min(flatEntries.length - 1, Math.max(0, newIndex)); |
| 292 | + else newIndex = (newIndex + flatEntries.length) % flatEntries.length; |
| 293 | + } |
| 294 | +
|
| 295 | + const newEntry = flatEntries[newIndex]; |
| 296 | + this.setHighlighted(newEntry); |
| 297 | + } else if (menuOpen && e.key === "Escape") { |
| 298 | + // Close menu with escape key |
| 299 | + this.isOpen = false; |
| 300 | +
|
| 301 | + // Reset active to before open |
| 302 | + this.setHighlighted(this.activeEntry); |
| 303 | + } else if (menuOpen && this.highlighted && e.key === "Enter") { |
| 304 | + // Handle clicking on an option if enter is pressed |
| 305 | + if (!this.highlighted.children?.length) this.onEntryClick(this.highlighted); |
| 306 | + else openSubmenu(this.highlighted); |
| 307 | +
|
| 308 | + // Stop the event from triggering a press on a new dialog |
| 309 | + e.preventDefault(); |
| 310 | +
|
| 311 | + // Enter should close the entire menu stack |
| 312 | + return true; |
| 313 | + } else if (menuOpen && this.highlighted && e.key === "ArrowRight") { |
| 314 | + // Right arrow opens a submenu |
| 315 | + openSubmenu(this.highlighted); |
| 316 | + } else if (menuOpen && e.key === "ArrowLeft") { |
| 317 | + // Left arrow closes a submenu |
| 318 | + if (submenu) this.isOpen = false; |
| 319 | + } |
| 320 | +
|
| 321 | + // By default, keep the menu stack open |
| 322 | + return false; |
| 323 | + }, |
| 324 | + setHighlighted(newHighlight: MenuListEntry<string> | undefined) { |
| 325 | + this.highlighted = newHighlight; |
| 326 | + // Interactive menus should keep the active entry the same as the highlighted one |
| 327 | + if (this.interactive && newHighlight?.value !== this.activeEntry?.value) this.$emit("update:activeEntry", newHighlight); |
| 328 | + }, |
243 | 329 | },
|
244 | 330 | computed: {
|
245 | 331 | entriesWithoutRefs(): MenuListEntryData[][] {
|
|
0 commit comments