|
1 |
| -<script lang="ts"> |
| 1 | +<script lang="ts" context="module"> |
| 2 | + export type SelectItemType<S extends string> = Record<S, unknown>; |
| 3 | +</script> |
| 4 | + |
| 5 | +<script lang="ts" generics="SelectItemType extends Record<string, unknown>"> |
2 | 6 | import ScrollableContainer from './ScrollableContainer.svelte';
|
3 | 7 | import TextBox from './TextBox.svelte';
|
4 | 8 | import { clickOutside } from '$lib/clickOutside';
|
| 9 | + import { filterStringByKey } from '$lib/utils/filters'; |
| 10 | + import { KeyName } from '$lib/utils/hotkeys'; |
| 11 | + import { throttle } from '$lib/utils/misc'; |
5 | 12 | import { pxToRem } from '$lib/utils/pxToRem';
|
| 13 | + import { isChar } from '$lib/utils/string'; |
6 | 14 | import { createEventDispatcher } from 'svelte';
|
7 | 15 |
|
| 16 | + const INPUT_THROTTLE_TIME = 100; |
| 17 | +
|
| 18 | + type SelectItemKeyType = keyof SelectItemType; |
| 19 | +
|
8 | 20 | export let id: undefined | string = undefined;
|
9 | 21 | export let label = '';
|
10 | 22 | export let disabled = false;
|
11 | 23 | export let loading = false;
|
12 | 24 | export let wide = false;
|
13 |
| - export let items: any[]; |
14 |
| - export let labelId = 'label'; |
15 |
| - export let itemId = 'value'; |
| 25 | + export let items: SelectItemType[]; |
| 26 | + export let labelId: SelectItemKeyType = 'label'; |
| 27 | + export let itemId: SelectItemKeyType = 'value'; |
16 | 28 | export let value: any = undefined;
|
17 | 29 | export let selectedItemId: any = undefined;
|
18 | 30 | export let placeholder = '';
|
|
27 | 39 | let listOpen = false;
|
28 | 40 | let element: HTMLElement;
|
29 | 41 | let options: HTMLDivElement;
|
| 42 | + let highlightIndex: number | undefined = undefined; |
| 43 | + let highlightedItem: SelectItemType | undefined = undefined; |
| 44 | + let filterText: string | undefined = undefined; |
| 45 | + let filteredItems: SelectItemType[] = items; |
30 | 46 |
|
31 |
| - function handleItemClick(item: any) { |
| 47 | + $: filterText === undefined |
| 48 | + ? (filteredItems = items) |
| 49 | + : (filteredItems = filterStringByKey(items, labelId, filterText)); |
| 50 | +
|
| 51 | + // Set highlighted item based on index |
| 52 | + $: highlightIndex !== undefined |
| 53 | + ? (highlightedItem = filteredItems[highlightIndex]) |
| 54 | + : (highlightedItem = undefined); |
| 55 | +
|
| 56 | + function handleItemClick(item: SelectItemType) { |
32 | 57 | if (item?.selectable === false) return;
|
33 | 58 | if (value && value[itemId] === item[itemId]) return closeList();
|
34 | 59 | selectedItemId = item[itemId];
|
|
52 | 77 |
|
53 | 78 | function closeList() {
|
54 | 79 | listOpen = false;
|
| 80 | + highlightIndex = undefined; |
| 81 | + filterText = undefined; |
| 82 | + } |
| 83 | +
|
| 84 | + function handleEnter() { |
| 85 | + if (highlightIndex !== undefined) { |
| 86 | + handleItemClick(filteredItems[highlightIndex]); |
| 87 | + } |
| 88 | + closeList(); |
| 89 | + } |
| 90 | +
|
| 91 | + function handleArrowUp() { |
| 92 | + if (highlightIndex === undefined) { |
| 93 | + highlightIndex = filteredItems.length - 1; |
| 94 | + } else { |
| 95 | + highlightIndex = highlightIndex === 0 ? filteredItems.length - 1 : highlightIndex - 1; |
| 96 | + } |
| 97 | + } |
| 98 | +
|
| 99 | + function handleArrowDown() { |
| 100 | + if (highlightIndex === undefined) { |
| 101 | + highlightIndex = 0; |
| 102 | + } else { |
| 103 | + highlightIndex = highlightIndex === filteredItems.length - 1 ? 0 : highlightIndex + 1; |
| 104 | + } |
| 105 | + } |
| 106 | +
|
| 107 | + const handleChar = throttle((char: string) => { |
| 108 | + highlightIndex = undefined; |
| 109 | + filterText ??= ''; |
| 110 | + filterText += char; |
| 111 | + }, INPUT_THROTTLE_TIME); |
| 112 | +
|
| 113 | + const handleDelete = throttle(() => { |
| 114 | + if (filterText === undefined) return; |
| 115 | +
|
| 116 | + if (filterText.length === 1) { |
| 117 | + filterText = undefined; |
| 118 | + return; |
| 119 | + } |
| 120 | +
|
| 121 | + filterText = filterText.slice(0, -1); |
| 122 | + }, INPUT_THROTTLE_TIME); |
| 123 | +
|
| 124 | + function handleKeyDown(event: CustomEvent<KeyboardEvent>) { |
| 125 | + if (!listOpen) { |
| 126 | + return; |
| 127 | + } |
| 128 | + event.detail.stopPropagation(); |
| 129 | + event.detail.preventDefault(); |
| 130 | +
|
| 131 | + const { key } = event.detail; |
| 132 | + switch (key) { |
| 133 | + case KeyName.Escape: |
| 134 | + closeList(); |
| 135 | + break; |
| 136 | + case KeyName.Up: |
| 137 | + handleArrowUp(); |
| 138 | + break; |
| 139 | + case KeyName.Down: |
| 140 | + handleArrowDown(); |
| 141 | + break; |
| 142 | + case KeyName.Enter: |
| 143 | + handleEnter(); |
| 144 | + break; |
| 145 | + case KeyName.Delete: |
| 146 | + handleDelete(); |
| 147 | + break; |
| 148 | + default: |
| 149 | + if (isChar(key)) handleChar(key); |
| 150 | + break; |
| 151 | + } |
55 | 152 | }
|
56 | 153 | </script>
|
57 | 154 |
|
|
67 | 164 | type="select"
|
68 | 165 | reversedDirection
|
69 | 166 | icon="select-chevron"
|
70 |
| - value={value?.[labelId]} |
| 167 | + value={filterText ?? value?.[labelId]} |
71 | 168 | disabled={disabled || loading}
|
72 | 169 | on:mousedown={() => toggleList()}
|
| 170 | + on:keydown={(ev) => handleKeyDown(ev)} |
73 | 171 | />
|
74 | 172 | <div
|
75 | 173 | class="options card"
|
|
78 | 176 | style:max-height={maxHeight && pxToRem(maxHeight)}
|
79 | 177 | use:clickOutside={{
|
80 | 178 | trigger: element,
|
81 |
| - handler: () => (listOpen = !listOpen), |
| 179 | + handler: closeList, |
82 | 180 | enabled: listOpen
|
83 | 181 | }}
|
84 | 182 | >
|
85 | 183 | <ScrollableContainer initiallyVisible>
|
86 |
| - {#if items} |
| 184 | + {#if filteredItems} |
87 | 185 | <div class="options__group">
|
88 |
| - {#each items as item} |
| 186 | + {#each filteredItems as item} |
89 | 187 | <div
|
90 | 188 | class="option"
|
91 | 189 | class:selected={item == value}
|
|
94 | 192 | on:mousedown={() => handleItemClick(item)}
|
95 | 193 | on:keydown|preventDefault|stopPropagation
|
96 | 194 | >
|
97 |
| - <slot name="template" {item} selected={item == value} /> |
| 195 | + <slot |
| 196 | + name="template" |
| 197 | + {item} |
| 198 | + selected={item == value} |
| 199 | + highlighted={item === highlightedItem} |
| 200 | + /> |
98 | 201 | </div>
|
99 | 202 | {/each}
|
100 | 203 | </div>
|
|
0 commit comments