Files
tibi-svelte-starter/frontend/src/widgets/SearchableSelect.svelte
Sebastian Frank 602fd6101f feat: Add new input, select, and tooltip components with validation and accessibility features
- Introduced Input component with support for various input types, validation, and error handling.
- Added MedialibImage component for displaying images with lazy loading and caption support.
- Implemented Pagination component for navigating through pages with ellipsis for large page sets.
- Created SearchableSelect component allowing users to search and select options from a dropdown.
- Developed Select component with integrated styling and validation.
- Added Tooltip component for displaying additional information on hover/focus.
2026-02-25 20:15:23 +00:00

314 lines
9.6 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy, getContext } from "svelte"
import type { HTMLInputAttributes } from "svelte/elements"
import { FORM_CONTEXT, type FormContext, type ValidatableField } from "../lib/formContext"
let {
value = $bindable(""),
options = [] as { value: string; label: string; searchText?: string }[],
name = "",
required = false,
id = "",
label = "",
hideLabel = true,
error = $bindable(""),
disabled = false,
placeholder = "",
messages = {},
validator,
...rest
}: {
value?: string
options?: { value: string; label: string; searchText?: string }[]
name?: string
required?: boolean
id?: string
label?: string
hideLabel?: boolean
error?: string
disabled?: boolean
placeholder?: string
messages?: Partial<Record<"valueMissing", string>>
validator?: (value: string) => Promise<string | null | undefined> | string | null | undefined
} & HTMLInputAttributes = $props()
const fallbackId = `searchable-select-${Math.random().toString(36).substr(2, 9)}`
const inputId = $derived(id || fallbackId)
const listboxId = $derived(`${inputId}-listbox`)
const errorId = $derived(`${inputId}-error`)
let inputElement = $state<HTMLInputElement>()
let inputValue = $state("")
let isOpen = $state(false)
let highlightedIndex = $state(-1)
let hasTypedSinceOpen = $state(false)
const formContext = getContext<FormContext>(FORM_CONTEXT)
let selectedOption = $derived.by(() => options.find((option) => option.value === value) || null)
let filteredOptions = $derived.by(() => {
const query = isOpen && !hasTypedSinceOpen ? "" : inputValue.trim().toLowerCase()
if (!query) {
return options
}
return options.filter((option) => {
const searchable = `${option.label} ${option.searchText || ""}`.toLowerCase()
return searchable.includes(query)
})
})
$effect(() => {
if (!isOpen) {
inputValue = selectedOption?.label || ""
}
})
export const validate = async () => {
if (required && !value) {
error = messages.valueMissing || "Please select an option"
return false
}
if (value && !options.some((option) => option.value === value)) {
error = messages.valueMissing || "Please select a valid option"
return false
}
if (validator) {
const customError = await validator(value)
if (customError) {
error = customError
return false
}
}
error = ""
return true
}
const reset = () => {
error = ""
}
const focus = () => {
inputElement?.focus()
}
const field: ValidatableField = { validate, reset, focus }
onMount(() => {
if (formContext) {
formContext.register(field)
}
})
onDestroy(() => {
if (formContext) {
formContext.unregister(field)
}
})
function openDropdown() {
if (disabled) return
if (!isOpen) {
hasTypedSinceOpen = false
}
isOpen = true
inputElement?.focus()
const cursorPosition = inputValue.length
setTimeout(() => {
inputElement?.setSelectionRange(cursorPosition, cursorPosition)
}, 0)
highlightedIndex = filteredOptions.length > 0 ? 0 : -1
}
function closeDropdown() {
isOpen = false
highlightedIndex = -1
hasTypedSinceOpen = false
}
function selectOption(option: { value: string; label: string; searchText?: string }) {
value = option.value
inputValue = option.label
closeDropdown()
validate()
}
function handleInput(event: Event) {
const target = event.target as HTMLInputElement
inputValue = target.value
openDropdown()
hasTypedSinceOpen = true
const normalizedInput = inputValue.trim().toLowerCase()
const exactMatch = options.find((option) => {
const labelMatch = option.label.toLowerCase() === normalizedInput
const searchTextMatch = option.searchText
?.toLowerCase()
.split("|")
.map((entry) => entry.trim())
.includes(normalizedInput)
return labelMatch || !!searchTextMatch
})
value = exactMatch?.value || ""
validate()
}
function handleBlur() {
setTimeout(() => {
const normalizedInput = inputValue.trim().toLowerCase()
const exactMatch = options.find((option) => {
const labelMatch = option.label.toLowerCase() === normalizedInput
const searchTextMatch = option.searchText
?.toLowerCase()
.split("|")
.map((entry) => entry.trim())
.includes(normalizedInput)
return labelMatch || !!searchTextMatch
})
if (exactMatch) {
value = exactMatch.value
inputValue = exactMatch.label
} else if (selectedOption) {
inputValue = selectedOption.label
}
closeDropdown()
validate()
}, 120)
}
function handleKeyDown(event: KeyboardEvent) {
if (disabled) return
if (event.key === "ArrowDown") {
event.preventDefault()
if (!isOpen) {
openDropdown()
return
}
if (filteredOptions.length > 0) {
highlightedIndex = Math.min(highlightedIndex + 1, filteredOptions.length - 1)
}
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
if (!isOpen) {
openDropdown()
return
}
if (filteredOptions.length > 0) {
highlightedIndex = Math.max(highlightedIndex - 1, 0)
}
return
}
if (event.key === "Enter" && isOpen && highlightedIndex >= 0 && filteredOptions[highlightedIndex]) {
event.preventDefault()
selectOption(filteredOptions[highlightedIndex])
return
}
if (event.key === "Escape") {
closeDropdown()
}
}
</script>
<div class="w-full">
{#if label}
<label
for={inputId}
class="block text-base mb-1 {hideLabel ? 'sr-only' : ''} {error ? 'text-red-600' : 'text-gray-900'}"
>
{label}
{#if required}<span class="text-red-600">*</span>{/if}
</label>
{/if}
<div class="relative w-full">
<input
bind:this={inputElement}
id={inputId}
type="text"
{name}
value={inputValue}
{disabled}
{placeholder}
autocomplete="off"
role="combobox"
aria-expanded={isOpen}
aria-controls={isOpen ? listboxId : undefined}
aria-autocomplete="list"
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
class="w-full border rounded-lg px-4 py-3 bg-white text-base transition-colors focus:outline-none focus:ring-1 focus:ring-blue-600 {error
? 'border-red-500 focus:border-red-500'
: 'border-gray-300 hover:border-blue-600 focus:border-blue-600'} {disabled ? 'bg-gray-50' : 'bg-white'}"
onfocus={openDropdown}
oninput={handleInput}
onblur={handleBlur}
onkeydown={handleKeyDown}
{...rest}
/>
<button
type="button"
class="absolute right-4 top-1/2 -translate-y-1/2 cursor-pointer"
aria-label="Open dropdown"
tabindex="-1"
onclick={() => {
openDropdown()
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 {error ? 'text-red-500' : 'text-gray-400'}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
{#if isOpen && filteredOptions.length > 0}
<ul
id={listboxId}
role="listbox"
class="absolute z-30 mt-1 w-full max-h-64 overflow-auto rounded-lg border border-gray-300 bg-white shadow-lg"
>
{#each filteredOptions as option, index}
<li
role="option"
aria-selected={option.value === value}
class="px-4 py-2 cursor-pointer text-sm {index === highlightedIndex
? 'bg-blue-600/10 text-blue-600'
: 'text-gray-900 hover:bg-gray-50'}"
onmousedown={(event) => {
event.preventDefault()
selectOption(option)
}}
>
{option.label}
</li>
{/each}
</ul>
{/if}
</div>
<div class="min-h-5.5 mt-1">
{#if error}
<p id={errorId} class="text-sm text-red-600">{error}</p>
{/if}
</div>
</div>