diff options
Diffstat (limited to 'apps/web-shared/src/components/dropdown.svelte')
| -rw-r--r-- | apps/web-shared/src/components/dropdown.svelte | 389 |
1 files changed, 0 insertions, 389 deletions
diff --git a/apps/web-shared/src/components/dropdown.svelte b/apps/web-shared/src/components/dropdown.svelte deleted file mode 100644 index a28bcd3..0000000 --- a/apps/web-shared/src/components/dropdown.svelte +++ /dev/null @@ -1,389 +0,0 @@ -<script lang="ts"> - // @ts-ignore - import { go, highlight } from "fuzzysort"; - import { element_has_focus, random_string } from "$shared/lib/helpers"; - import Button from "$shared/components/button.svelte"; - import Chip from "$shared/components/chip.svelte"; - - export let name; - export let id; - export let maxlength; - export let placeholder = "Search"; - export let entries = []; - export let createable = false; - export let loading = false; - export let multiple = false; - export let noResultsText; - export let errorText; - export let label; - export let on_create_async = ({name: string}) => { - }; - - export const reset = () => methods.reset(); - export const select = (id: string) => methods.select_entry(id); - export const deselect = (id: string) => methods.deselect_entry(id); - - const INTERNAL_ID = "__dropdown-" + random_string(5); - - let searchInputNode; - let searchResults = []; - let searchValue = ""; - let showCreationHint = false; - let showDropdown = false; - let lastKeydownCode = ""; - let mouseIsOverDropdown = false; - let mouseIsOverComponent = false; - - $: hasSelection = entries.some((c) => c.selected === true); - $: if (searchValue.trim()) { - showCreationHint = createable && entries.every((c) => search.normalise_value(c.name) !== search.normalise_value(searchValue)); - } else { - showCreationHint = false; - entries = methods.get_sorted_array(entries); - } - - const search = { - normalise_value(value: string): string { - if (!value) { - return ""; - } - return value.toString().trim().toLowerCase(); - }, - do() { - const query = search.normalise_value(searchValue); - if (!query.trim()) { - searchResults = []; - return; - } - - const options = { - limit: 10, - allowTypo: true, - threshold: -10000, - key: "name", - }; - searchResults = go(query, entries, options); - showDropdown = true; - }, - on_input_focusout() { - if (lastKeydownCode !== "Tab" && (mouseIsOverDropdown || lastKeydownCode === "ArrowDown")) { - return; - } - const selected = entries.find((c) => c.selected === true); - if (selected && !multiple) { - searchValue = selected.name; - } - document.querySelector("#" + INTERNAL_ID + " ul li.focus")?.classList.remove("focus"); - showDropdown = false; - } - }; - - const methods = { - reset(focus_input = false) { - searchValue = ""; - const copy = entries; - for (const entry of copy) { - entry.selected = false; - } - entries = methods.get_sorted_array(copy); - if (focus_input) { - searchInputNode?.focus(); - showDropdown = true; - } else { - showDropdown = false; - } - }, - async create_entry(name) { - if (!name || !createable || loading) { - console.log("Not sending creation event due to failed preconditions", {name, createable, loading}); - return; - } - try { - await on_create_async({name}); - searchValue = ""; - loading = false; - } catch (e) { - console.error(e); - } - }, - select_entry(entry_id) { - if (!entry_id || loading) { - console.log("Not selecting entry due to failed preconditions", { - entry_id, - loading, - }); - return; - } - - const copy = entries; - let selected; - for (const entry of entries) { - if (entry.id === entry_id) { - entry.selected = true; - selected = entry; - if (multiple) { - searchValue = ""; - } else { - searchValue = entry.name; - } - } else if (!multiple) { - entry.selected = false; - } - } - entries = methods.get_sorted_array(copy); - searchInputNode?.focus(); - searchResults = []; - }, - deselect_entry(entry_id) { - if (!entry_id || loading) { - console.log("Not deselecting entry due to failed preconditions", { - entry_id, - loading, - }); - return; - } - console.log("Deselecting entry", entry_id); - - const copy = entries; - let deselected; - - for (const entry of copy) { - if (entry.id === entry_id) { - entry.selected = false; - deselected = entry; - } - } - - entries = methods.get_sorted_array(copy); - searchInputNode?.focus(); - }, - get_sorted_array(entries: Array<DropdownEntry>): Array<DropdownEntry> { - if (!entries) { - return; - } - if (entries.length < 1) { - return []; - } - if (searchValue) { - return entries; - } - return (entries as any).sort((a, b) => { - search.normalise_value(a.name).localeCompare(search.normalise_value(b.name)); - }); - }, - }; - - const windowEvents = { - on_mousemove(event) { - if (!event.target) return; - mouseIsOverDropdown = (event.target?.closest("#" + INTERNAL_ID + " .autocomplete__results") != null ?? false); - mouseIsOverComponent = (event.target?.closest("#" + INTERNAL_ID) != null ?? false); - }, - on_click(event) { - if (showDropdown && !mouseIsOverDropdown && !mouseIsOverComponent && event.target.id !== id && event.target?.htmlFor !== id) { - showDropdown = false; - } - }, - on_keydown(event) { - lastKeydownCode = event.code; - const enterPressed = event.code === "Enter"; - const backspacePressed = event.code === "Backspace"; - const arrowUpPressed = event.code === "ArrowUp"; - const spacePressed = event.code === "Space"; - const arrowDownPressed = event.code === "ArrowDown"; - const searchInputHasFocus = element_has_focus(searchInputNode); - const focusedEntry = document.querySelector("#" + INTERNAL_ID + " ul .focus"); - - if (showDropdown && (enterPressed || arrowDownPressed)) { - event.preventDefault(); - event.stopPropagation(); - } - - if (searchInputHasFocus && backspacePressed && !searchValue && entries.length > 0) { - if (entries.filter(c => c.selected === true).at(-1)?.id ?? false) { - methods.deselect_entry(entries.filter(c => c.selected === true).at(-1)?.id ?? ""); - } - return; - } - if (searchInputHasFocus && enterPressed && showCreationHint) { - methods.create_entry(searchValue.trim()); - return; - } - - if (searchInputHasFocus && !focusedEntry && arrowDownPressed) { - const firstEntry = document.querySelector("#" + INTERNAL_ID + " ul li:first-of-type"); - if (firstEntry) { - firstEntry.classList.add("focus"); - return; - } - } - - if (focusedEntry && (arrowUpPressed || arrowDownPressed)) { - if (arrowDownPressed) { - if (focusedEntry.nextElementSibling) { - focusedEntry.nextElementSibling.classList.add("focus"); - focusedEntry.nextElementSibling.scrollIntoView(); - } else { - document.querySelector("#" + INTERNAL_ID + " ul li:first-of-type").classList.add("focus"); - document.querySelector("#" + INTERNAL_ID + " ul li:first-of-type").scrollIntoView(); - } - } else if (arrowUpPressed) { - if (focusedEntry.previousElementSibling) { - focusedEntry.previousElementSibling.classList.add("focus"); - focusedEntry.previousElementSibling.scrollIntoView(); - } else { - document.querySelector("#" + INTERNAL_ID + " ul li:last-of-type").classList.add("focus"); - document.querySelector("#" + INTERNAL_ID + " ul li:last-of-type").scrollIntoView(); - } - } - focusedEntry.classList.remove("focus"); - return; - } - - if (focusedEntry && (spacePressed || enterPressed)) { - methods.select_entry(focusedEntry.dataset.id); - return; - } - - if (lastKeydownCode === "Tab" && !searchInputHasFocus) { - showDropdown = false; - } - }, - on_touchend(event) { - windowEvents.on_mousemove(event); - } - }; - - interface DropdownEntry { - name: string, - id: string, - } -</script> - -<svelte:window - on:keydown={windowEvents.on_keydown} - on:mousemove={windowEvents.on_mousemove} - on:touchend={windowEvents.on_touchend} - on:click={windowEvents.on_click} -/> - -{#if label} - <label for="{id}" - class="form-label margin-bottom-xxs">{label}</label> -{/if} - -<div class="autocomplete position-relative select-auto" - class:cursor-wait={loading} - class:autocomplete--results-visible={showDropdown} - class:select-auto--selection-done={searchValue} - id={INTERNAL_ID} -> - <!-- input --> - <div class="select-auto__input-wrapper form-control" - style="cursor: text" - on:click={() => { - if (!element_has_focus(searchInputNode)) searchInputNode.focus(); - showDropdown = true; - }} - class:multiple={multiple === true} - class:has-selection={hasSelection}> - {#if multiple === true && hasSelection} - {#each entries.filter((c) => c.selected === true) as entry} - <Chip id={entry.id} - removable={true} - tabindex="-1" - on:remove={() => methods.deselect_entry(entry.id)} - text={entry.name}/> - {/each} - {/if} - <input - class="reset width-100%" - style="outline:none;" - type="text" - {name} - {id} - {maxlength} - {placeholder} - bind:value={searchValue} - bind:this={searchInputNode} - on:input={() => search.do()} - on:click={() => (showDropdown = true)} - on:focus={() => (showDropdown = true)} - on:blur={search.on_input_focusout} - autocomplete="off" - /> - <div class="select-auto__input-icon-wrapper"> - <!-- arrow icon --> - <svg class="icon" - viewBox="0 0 16 16"> - <title>Open selection</title> - <polyline points="1 5 8 12 15 5" - fill="none" - stroke="currentColor" - stroke-linecap="round" - stroke-linejoin="round" - stroke-width="2"/> - </svg> - - <!-- close X icon --> - <button class="reset select-auto__input-btn" - type="button" - on:click={() => reset(true)}> - <svg class="icon" - viewBox="0 0 16 16"> - <title>Reset selection</title> - <path - d="M8,0a8,8,0,1,0,8,8A8,8,0,0,0,8,0Zm3.707,10.293a1,1,0,1,1-1.414,1.414L8,9.414,5.707,11.707a1,1,0,0,1-1.414-1.414L6.586,8,4.293,5.707A1,1,0,0,1,5.707,4.293L8,6.586l2.293-2.293a1,1,0,1,1,1.414,1.414L9.414,8Z" - /> - </svg> - </button> - </div> - </div> - - {#if errorText} - <small class="color-error">{errorText}</small> - {/if} - - <!-- dropdown --> - <div class="autocomplete__results select-auto__results shadow-xs inner-glow"> - <ul on:keydown={(event) => event.code.startsWith("Arrow") && event.preventDefault()} - tabindex="-1" - class="autocomplete__list"> - {#if searchResults.length > 0} - {#each searchResults.filter((c) => !c.selected) as result} - <li class="select-auto__option padding-y-xs padding-x-sm" - data-id={result.obj.id} - on:click={(e) => methods.select_entry(e.target.dataset.id)} - tabindex="-1"> - {@html highlight(result, (open = '<span class="font-semibold">'), (close = "</span>"))} - </li> - {/each} - {:else if entries.length > 0} - {#each entries.filter((c) => !c.selected) as entry} - <li class="select-auto__option padding-y-xs padding-x-sm" - data-id={entry.id} - on:click={(e) => methods.select_entry(e.target.dataset.id)} - tabindex="-1"> - {entry.name} - </li> - {/each} - {:else} - <li class="select-auto__option text-center padding-y-xs padding-x-sm pointer-events-none" - tabindex="-1"> - {noResultsText} - </li> - {/if} - </ul> - {#if showCreationHint} - <div class="width-100% border-top border-bg-lighter padding-xxxs"> - <Button variant="reset" - type="button" - class="width-100%" - text="Press enter or click to create {searchValue.trim()}" - title="Press enter or click here to create {searchValue.trim()}" - loading={loading} - on:click={() => methods.create_entry(searchValue.trim())}/> - </div> - {/if} - </div> -</div> |
