✨ 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.
This commit is contained in:
313
frontend/src/widgets/SearchableSelect.svelte
Normal file
313
frontend/src/widgets/SearchableSelect.svelte
Normal file
@@ -0,0 +1,313 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user