mirror of
https://github.com/dsec-hub/dsec-notebook.git
synced 2026-09-22 07:24:27 +00:00
added search bar for searching units
This commit is contained in:
parent
ccadada9ce
commit
b17e5590ab
3 changed files with 228 additions and 28 deletions
170
src/lib/components/SearchSelect.svelte
Normal file
170
src/lib/components/SearchSelect.svelte
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
|
||||
type SearchSelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
searchText?: string;
|
||||
};
|
||||
|
||||
let {
|
||||
id,
|
||||
value = $bindable(""),
|
||||
options,
|
||||
placeholder = "Select...",
|
||||
searchPlaceholder = "Search...",
|
||||
emptyMessage = "No matches",
|
||||
}: {
|
||||
id: string;
|
||||
value?: string;
|
||||
options: SearchSelectOption[];
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyMessage?: string;
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let query = $state("");
|
||||
let highlight = $state(0);
|
||||
let searchInput: HTMLInputElement | undefined = $state();
|
||||
let listEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
const selected = $derived(options.find((option) => option.value === value));
|
||||
const filtered = $derived.by(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return options;
|
||||
return options.filter((option) => {
|
||||
const haystack = (option.searchText ?? option.label).toLowerCase();
|
||||
return haystack.includes(q);
|
||||
});
|
||||
});
|
||||
|
||||
async function openMenu() {
|
||||
open = true;
|
||||
query = "";
|
||||
await tick();
|
||||
const selectedIndex = filtered.findIndex((option) => option.value === value);
|
||||
highlight = selectedIndex >= 0 ? selectedIndex : 0;
|
||||
searchInput?.focus();
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
open = false;
|
||||
query = "";
|
||||
}
|
||||
|
||||
function choose(optionValue: string) {
|
||||
value = optionValue;
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
function moveHighlight(delta: number) {
|
||||
if (filtered.length === 0) return;
|
||||
if (highlight < 0 || highlight >= filtered.length) {
|
||||
highlight = delta > 0 ? 0 : filtered.length - 1;
|
||||
} else {
|
||||
highlight = (highlight + delta + filtered.length) % filtered.length;
|
||||
}
|
||||
const item = listEl?.children[highlight + 1] as HTMLElement | undefined;
|
||||
item?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
function onSearchKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
moveHighlight(1);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
moveHighlight(-1);
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const option = filtered[highlight];
|
||||
if (option) choose(option.value);
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeMenu();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
{id}
|
||||
type="button"
|
||||
class="border-rule text-ink focus:border-primary bg-surface flex w-full items-center justify-between gap-2 rounded-sm border px-3 py-2.5 text-left text-sm transition-colors outline-none"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
onclick={() => (open ? closeMenu() : openMenu())}
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate {selected ? '' : 'text-faint'}"
|
||||
>{selected?.label ?? placeholder}</span
|
||||
>
|
||||
<svg
|
||||
class="text-muted h-3.5 w-3.5 shrink-0 transition-transform {open ? 'rotate-180' : ''}"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="square" d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<button
|
||||
type="button"
|
||||
class="fixed inset-0 z-10 cursor-default"
|
||||
aria-label="Close menu"
|
||||
onclick={closeMenu}
|
||||
></button>
|
||||
<div
|
||||
class="border-rule bg-surface absolute z-20 mt-1 w-full overflow-hidden rounded-sm border shadow-lg"
|
||||
>
|
||||
<div class="border-rule border-b p-1.5">
|
||||
<input
|
||||
bind:this={searchInput}
|
||||
type="search"
|
||||
bind:value={query}
|
||||
placeholder={searchPlaceholder}
|
||||
aria-label={searchPlaceholder}
|
||||
class="field py-1.5 text-sm"
|
||||
onkeydown={onSearchKeydown}
|
||||
oninput={() => (highlight = 0)}
|
||||
/>
|
||||
</div>
|
||||
<div bind:this={listEl} role="listbox" class="max-h-56 overflow-y-auto py-1">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={value === ""}
|
||||
class="block w-full px-3 py-2 text-left text-sm {value === ''
|
||||
? 'text-primary'
|
||||
: 'text-muted hover:text-ink'}"
|
||||
onmouseenter={() => (highlight = -1)}
|
||||
onclick={() => choose("")}
|
||||
>
|
||||
{placeholder}
|
||||
</button>
|
||||
{#each filtered as option, index (option.value)}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={option.value === value}
|
||||
class="block w-full px-3 py-2 text-left text-sm {index === highlight ||
|
||||
option.value === value
|
||||
? 'text-primary'
|
||||
: 'text-ink hover:text-primary'}"
|
||||
onmouseenter={() => (highlight = index)}
|
||||
onclick={() => choose(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
{/each}
|
||||
{#if filtered.length === 0}
|
||||
<p class="text-muted px-3 py-2 text-sm">{emptyMessage}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
import { onMount } from "svelte";
|
||||
import { get } from "svelte/store";
|
||||
import MarkdownEditor from "$lib/components/MarkdownEditor.svelte";
|
||||
import SearchSelect from "$lib/components/SearchSelect.svelte";
|
||||
import { postPath } from "$lib/paths";
|
||||
import type { TopicDoc, UnitDoc } from "$lib/types";
|
||||
|
||||
|
|
@ -19,6 +20,20 @@
|
|||
let error = $state("");
|
||||
let loading = $state(false);
|
||||
|
||||
const topicOptions = $derived(
|
||||
topics.map((topic) => ({
|
||||
value: topic._id,
|
||||
label: topic.name,
|
||||
})),
|
||||
);
|
||||
const unitOptions = $derived(
|
||||
units.map((unit) => ({
|
||||
value: unit._id,
|
||||
label: `${unit.code}${unit.code2 ? ` / ${unit.code2}` : ""} — ${unit.name}`,
|
||||
searchText: `${unit.code} ${unit.code2 ?? ""} ${unit.name}`,
|
||||
})),
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
await initAuth();
|
||||
if (!get(isAuthenticated)) {
|
||||
|
|
@ -122,25 +137,25 @@
|
|||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="topic" class="kicker mb-2 block">Topic (optional)</label>
|
||||
<select id="topic" bind:value={selectedTopicId} class="field">
|
||||
<option value="">Select a topic...</option>
|
||||
{#each topics as topic}
|
||||
<option value={topic._id}>{topic.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<SearchSelect
|
||||
id="topic"
|
||||
bind:value={selectedTopicId}
|
||||
options={topicOptions}
|
||||
placeholder="Select a topic..."
|
||||
searchPlaceholder="Search topics..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="unit" class="kicker mb-2 block">Unit</label>
|
||||
{#if !useCustomUnit}
|
||||
<select id="unit" bind:value={selectedUnitId} class="field">
|
||||
<option value="">Select a unit...</option>
|
||||
{#each units as unit}
|
||||
<option value={unit._id}
|
||||
>{unit.code}{unit.code2 ? ` / ${unit.code2}` : ""} — {unit.name}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
<SearchSelect
|
||||
id="unit"
|
||||
bind:value={selectedUnitId}
|
||||
options={unitOptions}
|
||||
placeholder="Select a unit..."
|
||||
searchPlaceholder="Search units..."
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import { onMount } from "svelte";
|
||||
import { get } from "svelte/store";
|
||||
import MarkdownEditor from "$lib/components/MarkdownEditor.svelte";
|
||||
import SearchSelect from "$lib/components/SearchSelect.svelte";
|
||||
import { postPath } from "$lib/paths";
|
||||
import type { TopicDoc, UnitDoc } from "$lib/types";
|
||||
|
||||
|
|
@ -19,6 +20,20 @@
|
|||
let error = $state("");
|
||||
let loading = $state(false);
|
||||
|
||||
const topicOptions = $derived(
|
||||
topics.map((topic) => ({
|
||||
value: topic._id,
|
||||
label: topic.name,
|
||||
})),
|
||||
);
|
||||
const unitOptions = $derived(
|
||||
units.map((unit) => ({
|
||||
value: unit._id,
|
||||
label: `${unit.code}${unit.code2 ? ` / ${unit.code2}` : ""} — ${unit.name}`,
|
||||
searchText: `${unit.code} ${unit.code2 ?? ""} ${unit.name}`,
|
||||
})),
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
await initAuth();
|
||||
if (!get(isAuthenticated)) {
|
||||
|
|
@ -124,25 +139,25 @@
|
|||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="topic" class="kicker mb-2 block">Topic (optional)</label>
|
||||
<select id="topic" bind:value={selectedTopicId} class="field">
|
||||
<option value="">Select a topic...</option>
|
||||
{#each topics as topic}
|
||||
<option value={topic._id}>{topic.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<SearchSelect
|
||||
id="topic"
|
||||
bind:value={selectedTopicId}
|
||||
options={topicOptions}
|
||||
placeholder="Select a topic..."
|
||||
searchPlaceholder="Search topics..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="unit" class="kicker mb-2 block">Unit</label>
|
||||
{#if !useCustomUnit}
|
||||
<select id="unit" bind:value={selectedUnitId} class="field">
|
||||
<option value="">Select a unit...</option>
|
||||
{#each units as unit}
|
||||
<option value={unit._id}
|
||||
>{unit.code}{unit.code2 ? ` / ${unit.code2}` : ""} — {unit.name}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
<SearchSelect
|
||||
id="unit"
|
||||
bind:value={selectedUnitId}
|
||||
options={unitOptions}
|
||||
placeholder="Select a unit..."
|
||||
searchPlaceholder="Search units..."
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
|
|
|
|||
Loading…
Reference in a new issue