Files
open-webui/src/lib/components/common/MultiSelect.svelte
T
2026-08-12 00:51:12 -06:00

154 lines
3.9 KiB
Svelte

<script lang="ts">
import { tick, createEventDispatcher } from 'svelte';
import { flyAndScale } from '$lib/utils/transitions';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
const dispatch = createEventDispatcher();
export let value: string[] = [];
export let options: ({ label?: string; value: string } | string)[] = [];
export let placeholder = '';
export let className = '';
let open = false;
let triggerEl;
let contentEl;
$: items = options.map((option) =>
typeof option === 'object' && option !== null
? { value: option.value, label: option.label ?? option.value }
: { value: option, label: option }
);
$: selectedValues = Array.isArray(value) ? value : [];
$: selectedLabels = items
.filter((item) => selectedValues.includes(item.value))
.map((item) => item.label);
const toggleItem = (itemValue) => {
const current = Array.isArray(value) ? value : [];
value = current.includes(itemValue)
? current.filter((v) => v !== itemValue)
: [...current, itemValue];
dispatch('change');
};
/** Svelte action: moves the node to document.body (portal) */
function portal(node) {
document.body.appendChild(node);
return {
destroy() {
if (node.parentNode) {
node.parentNode.removeChild(node);
}
}
};
}
function positionContent() {
if (!triggerEl || !contentEl) return;
const rect = triggerEl.getBoundingClientRect();
contentEl.style.position = 'fixed';
contentEl.style.zIndex = '9999';
contentEl.style.top = `${rect.bottom + 4}px`;
contentEl.style.left = `${rect.left}px`;
contentEl.style.minWidth = `${rect.width}px`;
}
async function toggleOpen() {
open = !open;
if (open) {
await tick();
positionContent();
}
}
function handleWindowClick(event) {
if (!open) return;
if (triggerEl?.contains(event.target)) return;
if (contentEl?.contains(event.target)) return;
open = false;
}
function handleKeydown(event) {
if (event.key === 'Escape' && open) {
open = false;
}
}
</script>
<svelte:window
on:click={handleWindowClick}
on:keydown={handleKeydown}
on:scroll|capture={positionContent}
on:resize={positionContent}
/>
<button
bind:this={triggerEl}
class="focus-ring {className}"
aria-label={placeholder}
type="button"
on:click={toggleOpen}
>
<div class="flex w-full items-center justify-between gap-2">
<span class="truncate text-left {selectedLabels.length ? '' : 'text-gray-500'}">
{selectedLabels.length ? selectedLabels.join(', ') : placeholder}
</span>
<ChevronDown className="size-3 shrink-0" strokeWidth="2.5" />
</div>
</button>
{#if open}
<div use:portal bind:this={contentEl} transition:flyAndScale>
<div
class="rounded-2xl min-w-[10.625rem] max-h-72 overflow-y-auto p-1 border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
>
{#each items as item}
<button
class="focus-ring flex w-full gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
type="button"
on:click={() => toggleItem(item.value)}
>
<div
class="size-3.5 shrink-0 rounded-sm flex items-center justify-center outline -outline-offset-1 outline-[1.5px] {selectedValues.includes(
item.value
)
? 'bg-black outline-black text-white'
: 'outline-gray-200 dark:outline-gray-600'}"
>
{#if selectedValues.includes(item.value)}
<svg
class="size-3"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="m5 12 4.7 4.5 9.3-9"
/>
</svg>
{/if}
</div>
<span
class="truncate {selectedValues.includes(item.value)
? ''
: 'text-gray-500 dark:text-gray-400'}"
>
{item.label}
</span>
</button>
{/each}
</div>
</div>
{/if}