- 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.
314 lines
9.6 KiB
Svelte
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>
|