mirror of
https://github.com/dsec-hub/dsec-notebook.git
synced 2026-09-22 15:43:58 +00:00
Merge pull request #14 from dsec-hub/feature/search-bar
added search bar for searching units
This commit is contained in:
commit
dac6fcd92b
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 { onMount } from "svelte";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import MarkdownEditor from "$lib/components/MarkdownEditor.svelte";
|
import MarkdownEditor from "$lib/components/MarkdownEditor.svelte";
|
||||||
|
import SearchSelect from "$lib/components/SearchSelect.svelte";
|
||||||
import { postPath } from "$lib/paths";
|
import { postPath } from "$lib/paths";
|
||||||
import type { TopicDoc, UnitDoc } from "$lib/types";
|
import type { TopicDoc, UnitDoc } from "$lib/types";
|
||||||
|
|
||||||
|
|
@ -19,6 +20,20 @@
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
let loading = $state(false);
|
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 () => {
|
onMount(async () => {
|
||||||
await initAuth();
|
await initAuth();
|
||||||
if (!get(isAuthenticated)) {
|
if (!get(isAuthenticated)) {
|
||||||
|
|
@ -122,25 +137,25 @@
|
||||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label for="topic" class="kicker mb-2 block">Topic (optional)</label>
|
<label for="topic" class="kicker mb-2 block">Topic (optional)</label>
|
||||||
<select id="topic" bind:value={selectedTopicId} class="field">
|
<SearchSelect
|
||||||
<option value="">Select a topic...</option>
|
id="topic"
|
||||||
{#each topics as topic}
|
bind:value={selectedTopicId}
|
||||||
<option value={topic._id}>{topic.name}</option>
|
options={topicOptions}
|
||||||
{/each}
|
placeholder="Select a topic..."
|
||||||
</select>
|
searchPlaceholder="Search topics..."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="unit" class="kicker mb-2 block">Unit</label>
|
<label for="unit" class="kicker mb-2 block">Unit</label>
|
||||||
{#if !useCustomUnit}
|
{#if !useCustomUnit}
|
||||||
<select id="unit" bind:value={selectedUnitId} class="field">
|
<SearchSelect
|
||||||
<option value="">Select a unit...</option>
|
id="unit"
|
||||||
{#each units as unit}
|
bind:value={selectedUnitId}
|
||||||
<option value={unit._id}
|
options={unitOptions}
|
||||||
>{unit.code}{unit.code2 ? ` / ${unit.code2}` : ""} — {unit.name}</option
|
placeholder="Select a unit..."
|
||||||
>
|
searchPlaceholder="Search units..."
|
||||||
{/each}
|
/>
|
||||||
</select>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import MarkdownEditor from "$lib/components/MarkdownEditor.svelte";
|
import MarkdownEditor from "$lib/components/MarkdownEditor.svelte";
|
||||||
|
import SearchSelect from "$lib/components/SearchSelect.svelte";
|
||||||
import { postPath } from "$lib/paths";
|
import { postPath } from "$lib/paths";
|
||||||
import type { TopicDoc, UnitDoc } from "$lib/types";
|
import type { TopicDoc, UnitDoc } from "$lib/types";
|
||||||
|
|
||||||
|
|
@ -19,6 +20,20 @@
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
let loading = $state(false);
|
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 () => {
|
onMount(async () => {
|
||||||
await initAuth();
|
await initAuth();
|
||||||
if (!get(isAuthenticated)) {
|
if (!get(isAuthenticated)) {
|
||||||
|
|
@ -124,25 +139,25 @@
|
||||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label for="topic" class="kicker mb-2 block">Topic (optional)</label>
|
<label for="topic" class="kicker mb-2 block">Topic (optional)</label>
|
||||||
<select id="topic" bind:value={selectedTopicId} class="field">
|
<SearchSelect
|
||||||
<option value="">Select a topic...</option>
|
id="topic"
|
||||||
{#each topics as topic}
|
bind:value={selectedTopicId}
|
||||||
<option value={topic._id}>{topic.name}</option>
|
options={topicOptions}
|
||||||
{/each}
|
placeholder="Select a topic..."
|
||||||
</select>
|
searchPlaceholder="Search topics..."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="unit" class="kicker mb-2 block">Unit</label>
|
<label for="unit" class="kicker mb-2 block">Unit</label>
|
||||||
{#if !useCustomUnit}
|
{#if !useCustomUnit}
|
||||||
<select id="unit" bind:value={selectedUnitId} class="field">
|
<SearchSelect
|
||||||
<option value="">Select a unit...</option>
|
id="unit"
|
||||||
{#each units as unit}
|
bind:value={selectedUnitId}
|
||||||
<option value={unit._id}
|
options={unitOptions}
|
||||||
>{unit.code}{unit.code2 ? ` / ${unit.code2}` : ""} — {unit.name}</option
|
placeholder="Select a unit..."
|
||||||
>
|
searchPlaceholder="Search units..."
|
||||||
{/each}
|
/>
|
||||||
</select>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue