| <!-- |
| |
| Licensed to the Apache Software Foundation (ASF) under one |
| or more contributor license agreements. See the NOTICE file |
| distributed with this work for additional information |
| regarding copyright ownership. The ASF licenses this file |
| to you under the Apache License, Version 2.0 (the |
| "License"); you may not use this file except in compliance |
| with the License. You may obtain a copy of the License at |
| |
| http://www.apache.org/licenses/LICENSE-2.0 |
| |
| Unless required by applicable law or agreed to in writing, |
| software distributed under the License is distributed on an |
| "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| KIND, either express or implied. See the License for the |
| specific language governing permissions and limitations |
| under the License. |
| |
| --> |
| <script lang="ts"> |
| import { run } from 'svelte/legacy'; |
| |
| import { noTypeCheck } from '$lib/utils/noTypeCheck'; |
| import Icon from './Icon.svelte'; |
| import { createEventDispatcher, onMount, tick } from 'svelte'; |
| import { createCombobox } from 'svelte-headlessui'; |
| import Transition from 'svelte-transition'; |
| import Button from './Button.svelte'; |
| import { twMerge } from 'tailwind-merge'; |
| import Loader from './Loader.svelte'; |
| |
| type T = $$Generic<{ id: number; name: string }>; |
| |
| interface Props { |
| label?: string | undefined; |
| items: T[]; |
| isLoading?: boolean; |
| selectedValue?: T | undefined; |
| formatter?: ((_item: T) => string) | undefined; |
| } |
| |
| let { |
| label = undefined, |
| items, |
| isLoading = false, |
| selectedValue = $bindable(undefined), |
| formatter = undefined |
| }: Props = $props(); |
| |
| const combobox = createCombobox({ label: label || 'Actions' }); |
| const dispatch = createEventDispatcher<{ select: T }>(); |
| |
| let mounted = $state(false); |
| onMount(() => (mounted = true)); |
| |
| run(() => { |
| if (items.length > 0 && selectedValue === undefined) { |
| selectedValue = items[0]; |
| } |
| }); |
| |
| run(() => { |
| (async () => { |
| if (selectedValue !== undefined) { |
| combobox.set({ |
| selected: formatter ? { ...selectedValue, name: formatter(selectedValue) } : selectedValue |
| }); |
| |
| if (!mounted) await tick(); |
| dispatch('select', selectedValue); |
| } |
| })(); |
| }); |
| |
| let filtered = $derived( |
| (() => { |
| const value = $combobox.filter.toLowerCase().replace(/\s+/g, ''); |
| return items.filter((item) => { |
| if (formatter) { |
| return formatter(item).toLowerCase().replace(/\s+/g, '').includes(value); |
| } |
| const rawName = item.name.toLowerCase().replace(/\s+/g, ''); |
| return rawName.includes(value); |
| }); |
| })() |
| ); |
| </script> |
| |
| <div class="flex flex-col gap-2"> |
| {#if label} |
| <label for="combobox-input" class="text-sm ml-1 text-color"> |
| {label} |
| </label> |
| {/if} |
| |
| <div class="relative"> |
| <div |
| class="rounded-md dark:bg-shade-d400 ring-1 text-color ring-gray-300 dark:ring-gray-500 flex items-center h-[40px] text-color relative focus-within:ring-2 focus-within:ring-gray-400 transition group" |
| > |
| <input |
| id="combobox-input" |
| use:combobox.input |
| onselect={(e) => { |
| selectedValue = noTypeCheck(e).detail.selected; |
| }} |
| disabled={isLoading} |
| class="w-full bg-transparent px-4 outline-hidden" |
| /> |
| |
| {#if isLoading} |
| <div class="mr-4"> |
| <Loader /> |
| </div> |
| {:else} |
| <div use:combobox.button> |
| <Button |
| tabindex={-1} |
| variant="rounded" |
| disabled={isLoading} |
| type="button" |
| class={$combobox.expanded ? 'rotate-180' : 'rotate-0'} |
| > |
| <Icon name="chevronDown" class="w-[18px]" /> |
| </Button> |
| </div> |
| {/if} |
| </div> |
| |
| <Transition |
| show={$combobox.expanded} |
| leave="transition ease-in duration-100" |
| leaveFrom="opacity-100" |
| leaveTo="opacity-0" |
| on:after-leave={() => combobox.reset()} |
| > |
| <ul |
| use:combobox.items |
| class="absolute mt-1 z-10 max-h-[200px] w-full overflow-auto rounded-md dark:bg-shade-d400 bg-shade-l200 py-1 text-sm shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-hidden" |
| > |
| {#each filtered as value (value.id)} |
| {@const active = $combobox.active?.id === value.id} |
| {@const selected = $combobox.selected?.id === value.id} |
| <li |
| class={twMerge( |
| 'relative text-color select-none py-2 px-4 hoverable', |
| selected && 'dark:!bg-shade-d170 !bg-shade-l600', |
| active && 'dark:bg-shade-d200 bg-shade-l300' |
| )} |
| use:combobox.item={{ value }} |
| > |
| <span class="block truncate">{formatter ? formatter(value) : value.name}</span> |
| </li> |
| {:else} |
| <li class="relative cursor-default select-none px-4 py-2 text-color"> |
| <span class="block truncate font-normal">Nothing found</span> |
| </li> |
| {/each} |
| </ul> |
| </Transition> |
| </div> |
| </div> |