flotilla/src/app/components/TopicMultiSelect.svelte

123 lines
3.1 KiB
Svelte
Raw Normal View History

<script lang="ts">
import {writable} from "svelte/store"
import type {Writable} from "svelte/store"
import type {Instance} from "tippy.js"
import {remove, reject, spec, uniq} from "@welshman/lib"
2026-08-29 17:20:39 +00:00
import type {Maybe} from "@welshman/lib"
2026-07-28 16:16:26 +00:00
import {Topics, createSearch} from "@welshman/app"
import {normalizeTopic} from "@lib/util"
import Suggestions from "@lib/components/Suggestions.svelte"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte"
2026-08-29 17:20:39 +00:00
import type {TippyController} from "@lib/components/Tippy.svelte"
import Button from "@lib/components/Button.svelte"
import TopicSuggestion from "@app/components/TopicSuggestion.svelte"
2026-07-28 16:16:26 +00:00
import {app} from "@app/core"
2026-07-28 16:16:26 +00:00
type Props = {
value: string[]
term?: Writable<string>
}
let {value = $bindable(), term = writable("")}: Props = $props()
2026-07-28 16:16:26 +00:00
const topics = $app.use(Topics).all.$
const topicSearch = $derived.by(() =>
createSearch(reject(spec({name: value}), $topics), {
getValue: topic => topic.name,
fuseOptions: {
keys: ["name"],
threshold: 0.4,
},
}),
)
const addTopic = (text: string) => {
const topic = normalizeTopic(text)
if (topic) {
value = uniq([...value, topic])
}
term.set("")
2026-08-29 17:20:39 +00:00
tippy?.hide()
}
const removeTopic = (topic: string) => {
value = remove(topic, value)
}
const onKeyDown = (e: KeyboardEvent) => {
2026-08-29 17:20:39 +00:00
if (tippy?.content?.onKeyDown(e)) {
e.preventDefault()
return
}
if (e.key === "Enter" && $term) {
e.preventDefault()
addTopic($term)
}
}
const onBlur = () => {
term.set("")
2026-08-29 17:20:39 +00:00
tippy?.hide()
}
let label: Element | undefined = $state()
2026-08-29 17:20:39 +00:00
let tippy: Maybe<TippyController> = $state()
$effect(() => {
if ($term.trim()) {
2026-08-29 17:20:39 +00:00
tippy?.show()
} else {
2026-08-29 17:20:39 +00:00
tippy?.hide()
}
})
</script>
<div class="flex flex-col gap-2">
<div>
{#each value as topic (topic)}
<div class="flex-inline badge badge-neutral mr-1 gap-1">
<Button class="flex items-center" onclick={() => removeTopic(topic)}>
<Icon icon={CloseCircle} size={4} class="mt-px" />
</Button>
<span>#{topic}</span>
</div>
{/each}
</div>
<label class="input flex w-full items-center gap-2" bind:this={label}>
<Icon icon={Magnifier} />
<input
bind:value={$term}
class="grow"
type="text"
placeholder="Add topics..."
onkeydown={onKeyDown}
onblur={onBlur} />
</label>
<Tippy
2026-08-29 17:20:39 +00:00
bind:controller={tippy}
component={Suggestions}
props={{
term,
search: topicSearch.searchValues,
select: addTopic,
component: TopicSuggestion,
allowCreate: true,
}}
params={{
trigger: "manual",
interactive: true,
placement: "bottom",
getReferenceClientRect: () => label!.getBoundingClientRect(),
onShow: (instance: Instance) => {
instance.popper.style.width = `${label!.getBoundingClientRect().width + 8}px`
},
}} />
</div>