summaryrefslogtreecommitdiffstats
path: root/apps/web-shared/src/components/dropdown.svelte
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web-shared/src/components/dropdown.svelte')
-rw-r--r--apps/web-shared/src/components/dropdown.svelte374
1 files changed, 374 insertions, 0 deletions
diff --git a/apps/web-shared/src/components/dropdown.svelte b/apps/web-shared/src/components/dropdown.svelte
new file mode 100644
index 0000000..b5068a7
--- /dev/null
+++ b/apps/web-shared/src/components/dropdown.svelte
@@ -0,0 +1,374 @@
+<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 entriesUlNode;
+ 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;
+ }
+ 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) {
+ 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 = entriesUlNode?.querySelector("li: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) {
+ if (enterPressed && showCreationHint) {
+ methods.create_entry(searchValue.trim());
+ return;
+ }
+
+ if (arrowDownPressed) {
+ const firstEntry = entriesUlNode.querySelector("li:first-of-type");
+ if (firstEntry) {
+ firstEntry.focus();
+ }
+ return;
+ }
+ }
+
+ if (focusedEntry && (arrowUpPressed || arrowDownPressed)) {
+ if (arrowDownPressed && focusedEntry.nextElementSibling) {
+ focusedEntry.nextElementSibling.focus();
+ } else if (arrowUpPressed && focusedEntry.previousElementSibling) {
+ focusedEntry.previousElementSibling.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"
+ 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">
+ <ul bind:this={entriesUlNode}
+ 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>