|
33 | 33 |
|
34 | 34 | // Keep the child references outside of the entries array so as to avoid infinite recursion.
|
35 | 35 | let childReferences: (typeof self)[][] = [];
|
36 |
| - let search: string | undefined; |
| 36 | + let search = ""; |
37 | 37 |
|
38 | 38 | let highlighted = activeEntry as MenuListEntry | undefined;
|
39 | 39 | let virtualScrollingEntriesStart = 0;
|
|
46 | 46 | });
|
47 | 47 | $: watchHighlightedWithSearch(filteredEntries);
|
48 | 48 |
|
49 |
| - $: filteredEntries = entries.map((section) => section.filter(inSearch(search))); |
| 49 | + $: filteredEntries = entries.map((section) => section.filter((entry) => inSearch(search, entry))); |
50 | 50 | $: virtualScrollingTotalHeight = filteredEntries.length === 0 ? 0 : filteredEntries[0].length * virtualScrollingEntryHeight;
|
51 | 51 | $: virtualScrollingStartIndex = Math.floor(virtualScrollingEntriesStart / virtualScrollingEntryHeight) || 0;
|
52 | 52 | $: virtualScrollingEndIndex = filteredEntries.length === 0 ? 0 : Math.min(filteredEntries[0].length, virtualScrollingStartIndex + 1 + 400 / virtualScrollingEntryHeight);
|
|
64 | 64 | }
|
65 | 65 |
|
66 | 66 | // Detect when the user types, which creates a search box
|
67 |
| - async function startSearch(event: KeyboardEvent) { |
68 |
| - if (search !== undefined || event.key.length !== 1) return; |
| 67 | + async function startSearch(e: KeyboardEvent) { |
| 68 | + // Only accept single-character symbol inputs other than space |
| 69 | + if (e.key.length !== 1 || e.key === " ") return; |
69 | 70 |
|
70 | 71 | // Stop shortcuts being activated
|
71 |
| - event.stopPropagation(); |
72 |
| - event.preventDefault(); |
| 72 | + e.stopPropagation(); |
| 73 | + e.preventDefault(); |
73 | 74 |
|
74 |
| - // Open the search bar |
75 |
| - search = ""; |
| 75 | + // Forward the input's first character to the search box, which after that point the user will continue typing into directly |
| 76 | + search = e.key; |
76 | 77 |
|
77 |
| - // Must wait until the DOM elements have been created before focusing the search box |
| 78 | + // Must wait until the DOM elements have been created (after the if condition becomes true) before the search box exists |
78 | 79 | await tick();
|
79 |
| - searchTextInput?.focus(); |
80 |
| -
|
81 |
| - // Forward the input's first character to the search box, which after that point the user will continue typing into directly |
82 |
| - search = event.key; |
83 | 80 |
|
84 | 81 | // Get the search box element
|
85 |
| - let searchElement = searchTextInput?.element(); |
86 |
| - if (!searchElement) return; |
| 82 | + const searchElement = searchTextInput?.element(); |
| 83 | + if (!searchTextInput || !searchElement) return; |
87 | 84 |
|
88 |
| - // Allow arrow key navigation whilst in the search box |
89 |
| - searchElement.onkeydown = (event) => { |
90 |
| - if (["Enter", "Escape", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key)) { |
91 |
| - keydown(event, false); |
| 85 | + // Focus the search box and move the cursor to the end |
| 86 | + searchTextInput.focus(); |
| 87 | + searchElement.setSelectionRange(search.length, search.length); |
| 88 | +
|
| 89 | + // Continue listening for keyboard navigation even when the search box is focused |
| 90 | + searchElement.onkeydown = (e) => { |
| 91 | + if (["Enter", "Escape", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) { |
| 92 | + keydown(e, false); |
92 | 93 | }
|
93 | 94 | };
|
94 | 95 | }
|
95 | 96 |
|
96 |
| - function inSearch(search: string | undefined): (entry: MenuListEntry) => boolean { |
97 |
| - return (entry: MenuListEntry) => !search || entry.label.toLowerCase().includes(search.toLowerCase()); |
| 97 | + function inSearch(search: string, entry: MenuListEntry): boolean { |
| 98 | + return !search || entry.label.toLowerCase().includes(search.toLowerCase()); |
98 | 99 | }
|
99 | 100 |
|
100 | 101 | function watchOpen(open: boolean) {
|
101 | 102 | highlighted = activeEntry;
|
102 | 103 | dispatch("open", open);
|
103 | 104 |
|
104 |
| - search = undefined; |
| 105 | + search = ""; |
105 | 106 | }
|
106 | 107 |
|
107 | 108 | function watchRemeasureWidth(_: MenuListEntry[][], __: boolean) {
|
|
160 | 161 | return getChildReference(menuListEntry)?.open || false;
|
161 | 162 | }
|
162 | 163 |
|
| 164 | + function includeSeparator(entries: MenuListEntry[][], section: MenuListEntry[], sectionIndex: number, search: string): boolean { |
| 165 | + const elementsBeforeCurrentSection = entries |
| 166 | + .slice(0, sectionIndex) |
| 167 | + .flat() |
| 168 | + .filter((entry) => inSearch(search, entry)); |
| 169 | + const entriesInCurrentSection = section.filter((entry) => inSearch(search, entry)); |
| 170 | +
|
| 171 | + return elementsBeforeCurrentSection.length > 0 && entriesInCurrentSection.length > 0; |
| 172 | + } |
| 173 | +
|
| 174 | + function currentEntries(section: MenuListEntry[], virtualScrollingEntryHeight: number, virtualScrollingStartIndex: number, virtualScrollingEndIndex: number, search: string) { |
| 175 | + if (!virtualScrollingEntryHeight) { |
| 176 | + return section.filter((entry) => inSearch(search, entry)); |
| 177 | + } |
| 178 | + return section.filter((entry) => inSearch(search, entry)).slice(virtualScrollingStartIndex, virtualScrollingEndIndex); |
| 179 | + } |
| 180 | +
|
163 | 181 | /// Handles keyboard navigation for the menu. Returns if the entire menu stack should be dismissed
|
164 | 182 | export function keydown(e: KeyboardEvent, submenu: boolean): boolean {
|
165 | 183 | // Interactive menus should keep the active entry the same as the highlighted one
|
|
248 | 266 |
|
249 | 267 | e.preventDefault();
|
250 | 268 | }
|
251 |
| - } else if (menuOpen && e.key !== " ") { |
| 269 | + } else if (menuOpen && search === "") { |
252 | 270 | startSearch(e);
|
253 | 271 | }
|
254 | 272 |
|
|
305 | 323 | scrollableY={scrollableY && virtualScrollingEntryHeight === 0}
|
306 | 324 | bind:this={self}
|
307 | 325 | >
|
308 |
| - {#if search !== undefined} |
309 |
| - <TextInput value={search} on:value={(value) => (search = value.detail)} bind:this={searchTextInput}></TextInput> |
| 326 | + {#if search.length > 0} |
| 327 | + <TextInput |
| 328 | + class="search" |
| 329 | + value={search} |
| 330 | + on:value={({ detail }) => { |
| 331 | + search = detail; |
| 332 | + console.log(detail); |
| 333 | + }} |
| 334 | + bind:this={searchTextInput} |
| 335 | + ></TextInput> |
310 | 336 | {/if}
|
311 | 337 | <!-- If we put the scrollableY on the layoutcol for non-font dropdowns then for some reason it always creates a tiny scrollbar.
|
312 | 338 | 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`. -->
|
|
320 | 346 | <LayoutRow class="scroll-spacer" styles={{ height: `${virtualScrollingStartIndex * virtualScrollingEntryHeight}px` }} />
|
321 | 347 | {/if}
|
322 | 348 | {#each entries as section, sectionIndex (sectionIndex)}
|
323 |
| - {#if entries.slice(undefined, sectionIndex).flat().filter(inSearch(search)).length > 0 && section.filter(inSearch(search)).length > 0} |
| 349 | + {#if includeSeparator(entries, section, sectionIndex, search)} |
324 | 350 | <Separator type="Section" direction="Vertical" />
|
325 | 351 | {/if}
|
326 |
| - {#each virtualScrollingEntryHeight ? section |
327 |
| - .filter(inSearch(search)) |
328 |
| - .slice(virtualScrollingStartIndex, virtualScrollingEndIndex) : section.filter(inSearch(search)) as entry, entryIndex (entryIndex + startIndex)} |
| 352 | + {#each currentEntries(section, virtualScrollingEntryHeight, virtualScrollingStartIndex, virtualScrollingEndIndex, search) as entry, entryIndex (entryIndex + startIndex)} |
329 | 353 | <LayoutRow
|
330 | 354 | class="row"
|
331 | 355 | classes={{ open: isEntryOpen(entry), active: entry.label === highlighted?.label, disabled: Boolean(entry.disabled) }}
|
|
358 | 382 | {/if}
|
359 | 383 |
|
360 | 384 | {#if entry.children}
|
361 |
| - <!-- TODO: Solve the red underline error on the bind:this below --> |
362 | 385 | <svelte:self
|
363 | 386 | on:naturalWidth
|
364 | 387 | open={getChildReference(entry)?.open || false}
|
|
369 | 392 | {scrollableY}
|
370 | 393 | bind:this={childReferences[sectionIndex][entryIndex + startIndex]}
|
371 | 394 | />
|
| 395 | + <!-- TODO: Solve the red underline error on the bind:this above --> |
372 | 396 | {/if}
|
373 | 397 | </LayoutRow>
|
374 | 398 | {/each}
|
|
381 | 405 |
|
382 | 406 | <style lang="scss" global>
|
383 | 407 | .menu-list {
|
| 408 | + .search { |
| 409 | + margin: 4px; |
| 410 | + margin-top: 0; |
| 411 | + } |
| 412 | +
|
384 | 413 | .floating-menu-container .floating-menu-content.floating-menu-content {
|
385 | 414 | padding: 4px 0;
|
386 | 415 |
|
|
0 commit comments