|
1 | 1 | <svelte:options accessors={true} />
|
2 | 2 |
|
3 | 3 | <script lang="ts">
|
4 |
| - import { createEventDispatcher } from "svelte"; |
| 4 | + import { createEventDispatcher, tick } from "svelte"; |
5 | 5 |
|
6 | 6 | import type { MenuListEntry } from "@graphite/wasm-communication/messages";
|
7 | 7 |
|
8 | 8 | import FloatingMenu, { type MenuDirection } from "@graphite/components/layout/FloatingMenu.svelte";
|
9 | 9 | import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
|
10 | 10 | import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
|
| 11 | + import TextInput from "@graphite/components/widgets/inputs/TextInput.svelte"; |
11 | 12 | import IconLabel from "@graphite/components/widgets/labels/IconLabel.svelte";
|
12 | 13 | import Separator from "@graphite/components/widgets/labels/Separator.svelte";
|
13 | 14 | import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
|
|
29 | 30 | export let virtualScrollingEntryHeight = 0;
|
30 | 31 | export let tooltip: string | undefined = undefined;
|
31 | 32 |
|
| 33 | + // Keep the child references outside of the entries array so as to avoid infinite recursion. |
| 34 | + let childReferences: (typeof self)[][] = []; |
| 35 | + let search: string | undefined; |
| 36 | + let focus: () => void | undefined; |
| 37 | + let searchElement: () => HTMLInputElement | HTMLTextAreaElement | undefined; |
| 38 | +
|
32 | 39 | let highlighted = activeEntry as MenuListEntry | undefined;
|
33 | 40 | let virtualScrollingEntriesStart = 0;
|
34 | 41 |
|
35 | 42 | // Called only when `open` is changed from outside this component
|
36 | 43 | $: watchOpen(open);
|
37 |
| - $: watchRemeasureWidth(entries, drawIcon); |
| 44 | + $: filteredEntries = entries.map((section) => section.filter(inSearch(search))); |
| 45 | + $: watchRemeasureWidth(filteredEntries, drawIcon); |
38 | 46 |
|
39 |
| - $: virtualScrollingTotalHeight = entries.length === 0 ? 0 : entries[0].length * virtualScrollingEntryHeight; |
| 47 | + $: virtualScrollingTotalHeight = filteredEntries.length === 0 ? 0 : filteredEntries[0].length * virtualScrollingEntryHeight; |
40 | 48 | $: virtualScrollingStartIndex = Math.floor(virtualScrollingEntriesStart / virtualScrollingEntryHeight) || 0;
|
41 |
| - $: virtualScrollingEndIndex = entries.length === 0 ? 0 : Math.min(entries[0].length, virtualScrollingStartIndex + 1 + 400 / virtualScrollingEntryHeight); |
| 49 | + $: virtualScrollingEndIndex = filteredEntries.length === 0 ? 0 : Math.min(filteredEntries[0].length, virtualScrollingStartIndex + 1 + 400 / virtualScrollingEntryHeight); |
42 | 50 | $: startIndex = virtualScrollingEntryHeight ? virtualScrollingStartIndex : 0;
|
43 | 51 |
|
| 52 | + function expandChildReferences(entries: MenuListEntry[][]) { |
| 53 | + entries.forEach((_, index) => { |
| 54 | + if (!childReferences[index]) childReferences[index] = []; |
| 55 | + }); |
| 56 | + } |
| 57 | + $: expandChildReferences(entries); |
| 58 | +
|
| 59 | + async function startSearch(event: KeyboardEvent) { |
| 60 | + if (search !== undefined && event.key.length !== 1) return; |
| 61 | + // Stop shortcuts being activated |
| 62 | + event.stopPropagation(); |
| 63 | + event.preventDefault(); |
| 64 | + // Open the sarch bar |
| 65 | + search = ""; |
| 66 | + // Must wait until the dom elements have been created before focus |
| 67 | + await tick(); |
| 68 | + focus(); |
| 69 | + // Forward the input |
| 70 | + search = event.key; |
| 71 | +
|
| 72 | + // Allow arrow key navigation whilst in the search box |
| 73 | + let element = searchElement(); |
| 74 | + if (element) { |
| 75 | + element.onkeydown = (event) => { |
| 76 | + if (["Enter", "ArrowUp", "ArrowDown"].includes(event.key)) keydown(event, false); |
| 77 | + }; |
| 78 | + } |
| 79 | + } |
| 80 | +
|
| 81 | + function inSearch(search: string | undefined): (entry: MenuListEntry) => boolean { |
| 82 | + return (entry: MenuListEntry) => !search || entry.label.toLowerCase().includes(search.toLowerCase()); |
| 83 | + } |
| 84 | +
|
44 | 85 | function watchOpen(open: boolean) {
|
45 | 86 | highlighted = activeEntry;
|
46 | 87 | dispatch("open", open);
|
| 88 | +
|
| 89 | + search = undefined; |
| 90 | + } |
| 91 | +
|
| 92 | + $: updateHighlightedWithSearch(filteredEntries); |
| 93 | + // Required to keep the highlighted item centred and to find a new highlighted item if necessary |
| 94 | + async function updateHighlightedWithSearch(filteredEntries: MenuListEntry[][]) { |
| 95 | + if (highlighted) { |
| 96 | + // Allows the scrollable area to expand if necessary |
| 97 | + await tick(); |
| 98 | + setHighlighted(filteredEntries.flat().includes(highlighted) ? highlighted : filteredEntries.flat()[0]); |
| 99 | + } |
47 | 100 | }
|
48 | 101 |
|
49 | 102 | function watchRemeasureWidth(_: MenuListEntry[][], __: boolean) {
|
|
55 | 108 | virtualScrollingEntriesStart = (e.target as HTMLElement)?.scrollTop || 0;
|
56 | 109 | }
|
57 | 110 |
|
| 111 | + function getChildReference(menuListEntry: MenuListEntry): typeof self | undefined { |
| 112 | + return childReferences.flat()[filteredEntries.flat().indexOf(menuListEntry)]; |
| 113 | + } |
| 114 | +
|
58 | 115 | function onEntryClick(menuListEntry: MenuListEntry) {
|
59 | 116 | // Call the action if available
|
60 | 117 | if (menuListEntry.action) menuListEntry.action();
|
|
63 | 120 | dispatch("activeEntry", menuListEntry);
|
64 | 121 |
|
65 | 122 | // Close the containing menu
|
66 |
| - if (menuListEntry.ref) { |
67 |
| - menuListEntry.ref.open = false; |
| 123 | + let childReference = getChildReference(menuListEntry); |
| 124 | + if (childReference) { |
| 125 | + childReference.open = false; |
68 | 126 | entries = entries;
|
69 | 127 | }
|
70 | 128 | dispatch("open", false);
|
|
74 | 132 | function onEntryPointerEnter(menuListEntry: MenuListEntry) {
|
75 | 133 | if (!menuListEntry.children?.length) return;
|
76 | 134 |
|
77 |
| - if (menuListEntry.ref) { |
78 |
| - menuListEntry.ref.open = true; |
| 135 | + let childReference = getChildReference(menuListEntry); |
| 136 | + if (childReference) { |
| 137 | + childReference.open = true; |
79 | 138 | entries = entries;
|
80 | 139 | } else dispatch("open", true);
|
81 | 140 | }
|
82 | 141 |
|
83 | 142 | function onEntryPointerLeave(menuListEntry: MenuListEntry) {
|
84 | 143 | if (!menuListEntry.children?.length) return;
|
85 | 144 |
|
86 |
| - if (menuListEntry.ref) { |
87 |
| - menuListEntry.ref.open = false; |
| 145 | + let childReference = getChildReference(menuListEntry); |
| 146 | + if (childReference) { |
| 147 | + childReference.open = false; |
88 | 148 | entries = entries;
|
89 | 149 | } else dispatch("open", false);
|
90 | 150 | }
|
91 | 151 |
|
92 | 152 | function isEntryOpen(menuListEntry: MenuListEntry): boolean {
|
93 | 153 | if (!menuListEntry.children?.length) return false;
|
94 | 154 |
|
95 |
| - return menuListEntry.ref?.open || false; |
| 155 | + return getChildReference(menuListEntry)?.open || false; |
96 | 156 | }
|
97 | 157 |
|
98 | 158 | /// Handles keyboard navigation for the menu. Returns if the entire menu stack should be dismissed
|
|
101 | 161 | if (interactive) highlighted = activeEntry;
|
102 | 162 |
|
103 | 163 | const menuOpen = open;
|
104 |
| - const flatEntries = entries.flat().filter((entry) => !entry.disabled); |
105 |
| - const openChild = flatEntries.findIndex((entry) => (entry.children?.length ?? 0) > 0 && entry.ref?.open); |
| 164 | + const flatEntries = filteredEntries.flat().filter((entry) => !entry.disabled); |
| 165 | + const openChild = flatEntries.findIndex((entry) => (entry.children?.length ?? 0) > 0 && getChildReference(entry)?.open); |
106 | 166 |
|
107 | 167 | const openSubmenu = (highlightedEntry: MenuListEntry) => {
|
108 |
| - if (highlightedEntry.ref && highlightedEntry.children?.length) { |
109 |
| - highlightedEntry.ref.open = true; |
| 168 | + let childReference = getChildReference(highlightedEntry); |
| 169 | + if (childReference && highlightedEntry.children?.length) { |
| 170 | + childReference.open = true; |
110 | 171 | // The reason we bother taking `highlightdEntry` as an argument is because, when this function is called, it can ensure `highlightedEntry` is not undefined.
|
111 | 172 | // But here we still have to set `highlighted` to itself so Svelte knows to reactively update it after we set its `.ref.open` property.
|
112 | 173 | highlighted = highlighted;
|
113 | 174 |
|
114 | 175 | // Highlight first item
|
115 |
| - highlightedEntry.ref.setHighlighted(highlightedEntry.children[0][0]); |
| 176 | + childReference.setHighlighted(highlightedEntry.children[0][0]); |
116 | 177 | }
|
117 | 178 | };
|
118 | 179 |
|
|
122 | 183 | highlighted = activeEntry;
|
123 | 184 | } else if (menuOpen && openChild >= 0) {
|
124 | 185 | // Redirect the keyboard navigation to a submenu if one is open
|
125 |
| - const shouldCloseStack = flatEntries[openChild].ref?.keydown(e, true); |
| 186 | + const shouldCloseStack = getChildReference(flatEntries[openChild])?.keydown(e, true); |
126 | 187 |
|
127 | 188 | // Highlight the menu item in the parent list that corresponds with the open submenu
|
128 | 189 | if (e.key !== "Escape" && highlighted) setHighlighted(flatEntries[openChild]);
|
|
171 | 232 | if (submenu) open = false;
|
172 | 233 | }
|
173 | 234 |
|
| 235 | + startSearch(e); |
| 236 | + e.stopPropagation(); |
| 237 | + e.preventDefault(); |
| 238 | +
|
174 | 239 | // By default, keep the menu stack open
|
175 | 240 | return false;
|
176 | 241 | }
|
|
179 | 244 | highlighted = newHighlight;
|
180 | 245 | // Interactive menus should keep the active entry the same as the highlighted one
|
181 | 246 | if (interactive && newHighlight?.value !== activeEntry?.value && newHighlight) dispatch("activeEntry", newHighlight);
|
| 247 | +
|
| 248 | + // Scroll into view |
| 249 | + let container = scroller?.div(); |
| 250 | + if (!container || !highlighted) return; |
| 251 | + let containerBoundingRect = container.getBoundingClientRect(); |
| 252 | + let highlightedIndex = filteredEntries.flat().findIndex((entry) => entry === highlighted); |
| 253 | +
|
| 254 | + let selectedBoundingRect = new DOMRect(); |
| 255 | + if (virtualScrollingEntryHeight) { |
| 256 | + // Special case for virtual scrolling |
| 257 | + selectedBoundingRect.y = highlightedIndex * virtualScrollingEntryHeight - container.scrollTop + containerBoundingRect.y; |
| 258 | + selectedBoundingRect.height = virtualScrollingEntryHeight; |
| 259 | + } else { |
| 260 | + let entries = Array.from(container.children).filter((element) => element.classList.contains("row")); |
| 261 | + let element = entries[highlightedIndex - startIndex]; |
| 262 | + if (!element) return; |
| 263 | + containerBoundingRect = element.getBoundingClientRect(); |
| 264 | + } |
| 265 | +
|
| 266 | + if (containerBoundingRect.y > selectedBoundingRect.y) { |
| 267 | + container.scrollBy(0, selectedBoundingRect.y - containerBoundingRect.y); |
| 268 | + } |
| 269 | + if (containerBoundingRect.y + containerBoundingRect.height < selectedBoundingRect.y + selectedBoundingRect.height) { |
| 270 | + container.scrollBy(0, selectedBoundingRect.y - (containerBoundingRect.y + containerBoundingRect.height) + selectedBoundingRect.height); |
| 271 | + } |
182 | 272 | }
|
183 | 273 |
|
184 | 274 | export function scrollViewTo(distanceDown: number) {
|
|
199 | 289 | scrollableY={scrollableY && virtualScrollingEntryHeight === 0}
|
200 | 290 | bind:this={self}
|
201 | 291 | >
|
| 292 | + {#if search !== undefined} |
| 293 | + <TextInput value={search} on:value={(value) => (search = value.detail)} bind:focus bind:element={searchElement}></TextInput> |
| 294 | + {/if} |
202 | 295 | <!-- If we put the scrollableY on the layoutcol for non-font dropdowns then for some reason it always creates a tiny scrollbar.
|
203 | 296 | However when we are using the virtual scrolling then we need the layoutcol to be scrolling so we can bind the events without using `self`. -->
|
204 | 297 | <LayoutCol
|
|
211 | 304 | <LayoutRow class="scroll-spacer" styles={{ height: `${virtualScrollingStartIndex * virtualScrollingEntryHeight}px` }} />
|
212 | 305 | {/if}
|
213 | 306 | {#each entries as section, sectionIndex (sectionIndex)}
|
214 |
| - {#if sectionIndex > 0} |
| 307 | + {#if entries.slice(undefined, sectionIndex).flat().filter(inSearch(search)).length > 0 && section.filter(inSearch(search)).length > 0} |
215 | 308 | <Separator type="List" direction="Vertical" />
|
216 | 309 | {/if}
|
217 |
| - {#each virtualScrollingEntryHeight ? section.slice(virtualScrollingStartIndex, virtualScrollingEndIndex) : section as entry, entryIndex (entryIndex + startIndex)} |
| 310 | + {#each virtualScrollingEntryHeight ? section |
| 311 | + .filter(inSearch(search)) |
| 312 | + .slice(virtualScrollingStartIndex, virtualScrollingEndIndex) : section.filter(inSearch(search)) as entry, entryIndex (entryIndex + startIndex)} |
218 | 313 | <LayoutRow
|
219 | 314 | class="row"
|
220 | 315 | classes={{ open: isEntryOpen(entry), active: entry.label === highlighted?.label, disabled: Boolean(entry.disabled) }}
|
|
248 | 343 |
|
249 | 344 | {#if entry.children}
|
250 | 345 | <!-- TODO: Solve the red underline error on the bind:this below -->
|
251 |
| - <svelte:self on:naturalWidth open={entry.ref?.open || false} direction="TopRight" entries={entry.children} {minWidth} {drawIcon} {scrollableY} bind:this={entry.ref} /> |
| 346 | + <svelte:self |
| 347 | + on:naturalWidth |
| 348 | + open={getChildReference(entry)?.open || false} |
| 349 | + direction="TopRight" |
| 350 | + entries={entry.children} |
| 351 | + {minWidth} |
| 352 | + {drawIcon} |
| 353 | + {scrollableY} |
| 354 | + bind:this={childReferences[sectionIndex][entryIndex + startIndex]} |
| 355 | + /> |
252 | 356 | {/if}
|
253 | 357 | </LayoutRow>
|
254 | 358 | {/each}
|
|
0 commit comments