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:
2026-02-25 20:15:23 +00:00
parent 74bb860d4f
commit 602fd6101f
13 changed files with 1807 additions and 0 deletions

View File

@@ -4,6 +4,8 @@
import { _, locale } from "./lib/i18n/index"
import LoadingBar from "./widgets/LoadingBar.svelte"
import ToastContainer from "./widgets/ToastContainer.svelte"
import DebugFooterInfo from "./widgets/DebugFooterInfo.svelte"
import { initScrollRestoration } from "./lib/navigation"
import {
SUPPORTED_LANGUAGES,
LANGUAGE_LABELS,
@@ -17,6 +19,8 @@
} from "./lib/i18n"
export let url = ""
initScrollRestoration()
if (url) {
// ssr
let l = url.split("?")
@@ -110,3 +114,7 @@
<p>{$_("page.home.text")}</p>
{/if}
</main>
<footer class="text-center p-2">
<DebugFooterInfo />
</footer>

View File

@@ -0,0 +1,195 @@
/**
* SPA Navigation utilities
* Provides navigation functions that work with the history API
* and automatically update the location store.
*/
import { previousPath } from "./store"
export type SpaNavigateOptions = {
/** Use replaceState instead of pushState (default: false) */
replace?: boolean
/** State object to pass to pushState/replaceState */
state?: Record<string, unknown> | null
/** Skip scrolling to top (default: false) */
noScroll?: boolean
}
/**
* Initialize scroll restoration for SPA navigation.
* Call this once at app startup to enable automatic scroll position restoration
* when using browser back/forward buttons.
*/
export const initScrollRestoration = (): void => {
if (typeof window === "undefined") {
return // SSR guard
}
// Disable browser's automatic scroll restoration - we handle it manually
if ("scrollRestoration" in history) {
history.scrollRestoration = "manual"
}
// Restore scroll position on popstate (back/forward navigation)
window.addEventListener("popstate", (event) => {
const state = event.state
if (state?.scrollY !== undefined) {
// Use requestAnimationFrame to ensure DOM has updated
requestAnimationFrame(() => {
window.scrollTo(0, state.scrollY)
})
}
})
}
/**
* Navigate to a new URL within the SPA.
* Uses pushState by default (creates history entry).
* Set replace: true to use replaceState (no history entry).
* Automatically scrolls to top and saves scroll position for back navigation.
*
* @param url - The URL to navigate to (can be relative or absolute path)
* @param options - Navigation options
*
* @example
* // Navigate to /about (creates history entry)
* spaNavigate('/about')
*
* @example
* // Navigate with hash, replacing current entry
* spaNavigate('/search#q=test', { replace: true })
*
* @example
* // Navigate with state data
* spaNavigate('/product/123', { state: { from: 'search' } })
*/
export const spaNavigate = (url: string, options: SpaNavigateOptions = {}): void => {
if (typeof window === "undefined") {
return // SSR guard
}
const { replace = false, state = null, noScroll = false } = options
// Save current path to previousPath store before navigating
previousPath.set(window.location.pathname)
// Save current scroll position in current history entry before navigating
const currentScrollY = window.scrollY
const currentState = history.state || {}
history.replaceState({ ...currentState, scrollY: currentScrollY }, "")
// Merge user state with scroll position (for new page, start at top)
const newState = { ...state, scrollY: 0 }
// Normalize relative URLs to absolute paths
let finalUrl = url
if (
!url.startsWith("/") &&
!url.startsWith("http") &&
!url.startsWith("mailto:") &&
!url.startsWith("tel:") &&
!url.startsWith("#")
) {
finalUrl = "/" + url
}
if (replace) {
window.history.replaceState(newState, "", finalUrl)
} else {
window.history.pushState(newState, "", finalUrl)
}
// Scroll to top for new navigation (unless noScroll is set)
if (!noScroll) {
window.scrollTo(0, 0)
}
}
/**
* Parse hash parameters from a URL hash string.
* @param hash - The hash string (with or without leading #)
* @returns URLSearchParams-like object for easy access
*/
export const parseHashParams = (hash: string): URLSearchParams => {
const cleanHash = hash.startsWith("#") ? hash.slice(1) : hash
return new URLSearchParams(cleanHash)
}
/**
* Build a hash string from key-value pairs.
* @param params - Object with parameter key-value pairs
* @returns Hash string with leading #, or empty string if no params
*/
export const buildHashString = (params: Record<string, string | string[] | null | undefined>): string => {
const parts: string[] = []
for (const [key, value] of Object.entries(params)) {
if (value === null || value === undefined || value === "") {
continue
}
if (Array.isArray(value)) {
if (value.length > 0) {
parts.push(`${encodeURIComponent(key)}=${value.map(encodeURIComponent).join(",")}`)
}
} else {
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
}
}
return parts.length > 0 ? `#${parts.join("&")}` : ""
}
/**
* Svelte action for SPA link navigation.
* Intercepts clicks on anchor elements and uses spaNavigate instead of full page reload.
*
* @param node - The anchor element to enhance
* @param options - Navigation options passed to spaNavigate
*
* @example
* <a href="/products" use:spaLink>Products</a>
*
* @example
* // With options
* <a href="/search" use:spaLink={{ replace: true }}>Search</a>
*/
export const spaLink = (node: HTMLAnchorElement, options: SpaNavigateOptions = {}) => {
const handleClick = (event: MouseEvent) => {
// Allow normal behavior for modifier keys (open in new tab, etc.)
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return
}
// Only handle left clicks
if (event.button !== 0) {
return
}
const href = node.getAttribute("href")
// Skip external links or special protocols
if (!href || href.startsWith("http") || href.startsWith("mailto:") || href.startsWith("tel:")) {
return
}
// Skip if target is set (e.g., _blank)
if (node.target && node.target !== "_self") {
return
}
event.preventDefault()
spaNavigate(href, options)
}
node.addEventListener("click", handleClick)
return {
update(newOptions: SpaNavigateOptions) {
options = newOptions
},
destroy() {
node.removeEventListener("click", handleClick)
},
}
}

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import type { MouseEventHandler } from "svelte/elements"
import type { Snippet } from "svelte"
type Props = {
text?: string
variant?: "primary" | "secondary" | "outline" | "text" | "danger" | "ghost"
size?: "sm" | "md" | "lg"
type?: "button" | "submit"
disabled?: boolean
onclick?: MouseEventHandler<HTMLButtonElement>
class?: string
children?: Snippet
}
let {
text = "Button",
variant = "primary",
size = "md",
type = "button",
disabled = false,
onclick = undefined,
class: className = "",
children,
}: Props = $props()
// Base classes
const baseClasses =
"font-sans font-bold rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 flex items-center justify-center"
// Variant classes
const variantClasses = {
primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-600 disabled:bg-gray-300",
secondary:
"bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500 disabled:bg-gray-100 disabled:text-gray-400",
outline:
"bg-transparent border-2 border-blue-600 text-blue-600 hover:bg-blue-50 focus:ring-blue-600 disabled:border-gray-300 disabled:text-gray-400",
text: "bg-transparent text-blue-600 hover:bg-blue-50 hover:text-blue-700 focus:ring-blue-600 disabled:text-gray-400",
danger: "bg-transparent border-2 border-red-200 text-red-600 hover:bg-red-50 hover:border-red-300 focus:ring-red-500 disabled:border-gray-300 disabled:text-gray-400",
ghost: "bg-transparent text-gray-900 hover:text-blue-600 focus:ring-blue-600 disabled:text-gray-400 p-0",
}
// Size classes
const sizeClasses = {
sm: "px-4 py-2 text-base",
md: "px-6 py-2.5 text-lg leading-relaxed",
lg: "px-8 py-4 text-xl",
}
let classes = $derived(`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`)
</script>
<button {type} class={classes} {onclick} {disabled}>
{#if children}
{@render children()}
{:else}
{text}
{/if}
</button>

View File

@@ -0,0 +1,114 @@
<script lang="ts">
import { onMount, type Snippet } from "svelte"
interface CarouselProps {
gap?: string
scrollAmount?: number
containerClass?: string
wrapperClass?: string
fullWidth?: boolean
/** Max width for the navigation arrows container (default: none) */
maxWidth?: string
children: Snippet
}
let {
gap = "gap-3 md:gap-4 lg:gap-8",
scrollAmount = 450,
containerClass = "",
wrapperClass = "",
fullWidth = false,
maxWidth = "",
children,
}: CarouselProps = $props()
let scrollContainer: HTMLElement
let canScrollLeft = $state(false)
let canScrollRight = $state(false)
function checkScroll() {
if (scrollContainer) {
canScrollLeft = scrollContainer.scrollLeft > 50
canScrollRight = scrollContainer.scrollLeft < scrollContainer.scrollWidth - scrollContainer.clientWidth - 1
}
}
function scrollLeft() {
if (scrollContainer) {
scrollContainer.scrollBy({
left: -scrollAmount,
behavior: "smooth",
})
}
}
function scrollRight() {
if (scrollContainer) {
scrollContainer.scrollBy({
left: scrollAmount,
behavior: "smooth",
})
}
}
onMount(() => {
checkScroll()
})
</script>
<div class="relative {wrapperClass}">
<!-- Scroll Container -->
<div
class="flex {gap} overflow-x-auto snap-x snap-mandatory scrollbar-hide pb-4 scroll-smooth {fullWidth
? 'px-4 md:px-8 lg:px-12'
: ''} {containerClass}"
bind:this={scrollContainer}
onscroll={checkScroll}
onload={checkScroll}
>
{@render children()}
</div>
</div>
<!-- Navigation Arrows -->
{#if canScrollLeft || canScrollRight}
<div class="{maxWidth ? `max-w-[${maxWidth}]` : ''} mx-auto px-4 md:px-8 lg:px-12">
<div class="flex gap-0 mt-6">
<button
onclick={scrollLeft}
disabled={!canScrollLeft}
class="w-12 h-10 flex items-center justify-center transition-opacity {canScrollLeft
? 'cursor-pointer hover:opacity-70'
: 'cursor-default opacity-30'}"
aria-label="Scroll left"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
</button>
<button
onclick={scrollRight}
disabled={!canScrollRight}
class="w-12 h-10 flex items-center justify-center transition-opacity {canScrollRight
? 'cursor-pointer hover:opacity-70'
: 'cursor-default opacity-30'}"
aria-label="Scroll right"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</button>
</div>
</div>
{/if}
<style>
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
</style>

View File

@@ -0,0 +1,47 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte"
import { gitHash, buildTime } from "../lib/buildInfo"
import { serverBuildTime } from "../lib/serverBuildInfo"
let debugText = ""
let interval: ReturnType<typeof setInterval> | undefined
function formatBuildTime(value: string | null | undefined): string {
if (!value) return "-"
try {
return new Date(value).toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})
} catch {
return "?"
}
}
function update() {
try {
const frontendBuilt = formatBuildTime(buildTime)
const serverBuilt = formatBuildTime($serverBuildTime)
debugText = `fe:${frontendBuilt} | srv:${serverBuilt} | v:${gitHash}`
} catch (e) {
debugText = `debug-error: ${e}`
}
}
onMount(() => {
update()
interval = setInterval(update, 5000)
})
onDestroy(() => {
if (interval) clearInterval(interval)
})
</script>
<span class="text-[10px] leading-none select-all text-gray-400" aria-hidden="true" title="Debug Info">
{debugText}
</span>

View File

@@ -0,0 +1,63 @@
<script lang="ts">
import { setContext, type Snippet } from "svelte"
import { FORM_CONTEXT, type FormContext, type ValidatableField } from "../lib/formContext"
import type { HTMLFormAttributes } from "svelte/elements"
let {
children,
onsubmit,
...rest
}: {
children: Snippet
onsubmit?: (e: SubmitEvent) => void
} & HTMLFormAttributes = $props()
const fields: ValidatableField[] = []
const register = (field: ValidatableField) => {
fields.push(field)
}
const unregister = (field: ValidatableField) => {
const index = fields.indexOf(field)
if (index > -1) {
fields.splice(index, 1)
}
}
setContext<FormContext>(FORM_CONTEXT, {
register,
unregister,
})
export const validate = async () => {
let isValid = true
let firstInvalidField: ValidatableField | null = null
const results = await Promise.all(
fields.map(async (field) => {
const valid = await field.validate()
return { field, valid }
})
)
for (const { field, valid } of results) {
if (!valid) {
isValid = false
if (!firstInvalidField) {
firstInvalidField = field
}
}
}
if (firstInvalidField && firstInvalidField.focus) {
firstInvalidField.focus()
}
return isValid
}
</script>
<form novalidate {onsubmit} {...rest}>
{@render children()}
</form>

View File

@@ -0,0 +1,394 @@
<script lang="ts">
import { onMount, onDestroy, getContext } from "svelte"
import type { FormEventHandler, HTMLInputAttributes } from "svelte/elements"
import type { Snippet } from "svelte"
import { FORM_CONTEXT, type FormContext, type ValidatableField } from "../lib/formContext"
type InputValue = string | number | null | undefined
let {
placeholder = "",
value = $bindable<InputValue>(),
type = "text",
name = "",
required = false,
id = "",
label = "",
hideLabel = true,
variant = "default",
error = $bindable(""),
hint = "",
readonly = false,
disabled = false,
rightIcon,
messages = {},
validator,
oninput = undefined as FormEventHandler<HTMLInputElement> | undefined,
onchange = undefined as FormEventHandler<HTMLInputElement> | undefined,
onblur = undefined as FormEventHandler<HTMLInputElement> | undefined,
...rest
}: {
placeholder?: string
value?: InputValue
type?: string
name?: string
required?: boolean
id?: string
label?: string
hideLabel?: boolean
variant?: "default" | "integrated"
error?: string
hint?: string
readonly?: boolean
disabled?: boolean
rightIcon?: Snippet
messages?: Partial<Record<keyof ValidityState, string>>
oninput?: FormEventHandler<HTMLInputElement>
onchange?: FormEventHandler<HTMLInputElement>
onblur?: FormEventHandler<HTMLInputElement>
validator?: (value: string) => Promise<string | null | undefined> | string | null | undefined
} & HTMLInputAttributes = $props()
// Generate unique ID if not provided
const fallbackId = `input-${Math.random().toString(36).substr(2, 9)}`
const inputId = $derived(id || fallbackId)
const errorId = $derived(`${inputId}-error`)
let inputElement = $state<HTMLInputElement>()
const formContext = getContext<FormContext>(FORM_CONTEXT)
let isValidating = $state(false)
let validationRunId = 0
let showPassword = $state(false)
const isPasswordType = $derived(type === "password")
const effectiveType = $derived(isPasswordType && showPassword ? "text" : type)
const togglePassword = () => {
showPassword = !showPassword
inputElement?.focus()
}
const getDefaultValue = (inputType: string) => {
const normalizedType = inputType?.toLowerCase?.() || "text"
if (normalizedType === "number" || normalizedType === "range") {
return 0
}
return ""
}
$effect(() => {
if (value === null || value === undefined) {
value = getDefaultValue(type)
}
})
export const validate = async () => {
if (!inputElement) return true
// Reset custom validity to check native validity correctly
inputElement.setCustomValidity("")
const runId = ++validationRunId
if (inputElement.checkValidity()) {
if (validator) {
const valueForValidation = value === null || value === undefined ? "" : String(value)
const result = validator(valueForValidation)
const isAsync = !!result && typeof (result as Promise<string | null | undefined>).then === "function"
if (isAsync) {
isValidating = true
let customError: string | null | undefined
try {
customError = await result
} finally {
if (runId === validationRunId) {
isValidating = false
}
}
if (runId !== validationRunId) return false
if (customError) {
error = customError
return false
}
} else {
const customError = result as string | null | undefined
if (customError) {
error = customError
return false
}
}
}
error = ""
return true
}
const validity = inputElement.validity
let errorMessage = inputElement.validationMessage
if (validity.valueMissing && messages.valueMissing) errorMessage = messages.valueMissing
else if (validity.typeMismatch && messages.typeMismatch) errorMessage = messages.typeMismatch
else if (validity.patternMismatch && messages.patternMismatch) errorMessage = messages.patternMismatch
else if (validity.tooLong && messages.tooLong) errorMessage = messages.tooLong
else if (validity.tooShort && messages.tooShort) errorMessage = messages.tooShort
else if (validity.rangeUnderflow && messages.rangeUnderflow) errorMessage = messages.rangeUnderflow
else if (validity.rangeOverflow && messages.rangeOverflow) errorMessage = messages.rangeOverflow
else if (validity.stepMismatch && messages.stepMismatch) errorMessage = messages.stepMismatch
else if (validity.badInput && messages.badInput) errorMessage = messages.badInput
error = errorMessage
return false
}
const reset = () => {
error = ""
}
const focus = () => {
inputElement?.focus()
}
const handleBlur: FormEventHandler<HTMLInputElement> = (e) => {
validate()
onblur?.(e)
}
let debounceTimer: ReturnType<typeof setTimeout> | undefined
const handleInput: FormEventHandler<HTMLInputElement> = (e) => {
oninput?.(e)
// Only validate on input if we already have an error to clear
if (error) {
// Immediate check for native validity
inputElement!.setCustomValidity("")
const isNativelyValid = inputElement!.checkValidity()
if (!isNativelyValid) {
// If natively invalid (e.g. required missing), validate immediately to show correct error
validate()
} else if (validator) {
// If natively valid but we have a custom async validator, debounce it
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
validate()
}, 300)
} else {
// If natively valid and no custom validator, validate immediately (clears error)
validate()
}
}
}
const field: ValidatableField = { validate, reset, focus }
onMount(() => {
if (formContext) {
formContext.register(field)
}
})
onDestroy(() => {
clearTimeout(debounceTimer)
if (formContext) {
formContext.unregister(field)
}
})
</script>
<div class="w-full">
{#if variant === "integrated"}
<div
class="relative w-full border rounded-lg px-3 py-2 h-16.5 transition-colors group focus-within:ring-1 focus-within:ring-blue-600 {readonly ||
disabled
? 'bg-gray-50'
: 'bg-white'} {error
? 'border-red-500'
: 'border-gray-300 hover:border-blue-600 focus-within:border-blue-600'}"
>
{#if label}
<label
for={inputId}
class="block text-xs font-bold uppercase mb-1 {error ? 'text-red-500' : 'text-gray-500'}"
>
{label}
{#if required}<span class="text-red-600">*</span>{/if}
</label>
{/if}
<input
bind:this={inputElement}
id={inputId}
type={effectiveType}
{name}
{placeholder}
{required}
{readonly}
{disabled}
bind:value
aria-label={label || placeholder}
aria-required={required}
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
class="w-full bg-transparent text-base text-gray-900 focus:outline-none placeholder:text-gray-400 {isPasswordType
? 'pr-12'
: rightIcon || isValidating
? 'pr-24'
: ''}"
oninput={handleInput}
onblur={handleBlur}
{onchange}
{...rest}
/>
{#if isPasswordType}
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none transition-colors"
onclick={togglePassword}
aria-label={showPassword ? "Hide password" : "Show password"}
tabindex={-1}
>
{#if showPassword}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
><path
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"
></path><line x1="1" y1="1" x2="23" y2="23"></line></svg
>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"
></circle></svg
>
{/if}
</button>
{:else if isValidating || rightIcon}
<div class="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none">
{#if isValidating}
<span class="inline-flex items-center gap-2 text-xs text-gray-500">
<span
class="inline-block h-3 w-3 rounded-full border border-gray-400 border-t-transparent animate-spin"
></span>
Checking…
</span>
{:else if rightIcon}
{@render rightIcon()}
{/if}
</div>
{/if}
</div>
{:else}
{#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={effectiveType}
{name}
{placeholder}
{required}
{readonly}
{disabled}
bind:value
aria-label={label || placeholder}
aria-required={required}
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 placeholder:text-gray-400 {error
? 'border-red-500 focus:border-red-500'
: 'border-gray-300 hover:border-blue-600 focus:border-blue-600'} {readonly || disabled
? 'bg-gray-50'
: 'bg-white'} {isPasswordType ? 'pr-12' : rightIcon || isValidating ? 'pr-24' : ''}"
oninput={handleInput}
onblur={handleBlur}
{onchange}
{...rest}
/>
{#if isPasswordType}
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none transition-colors"
onclick={togglePassword}
aria-label={showPassword ? "Hide password" : "Show password"}
tabindex={-1}
>
{#if showPassword}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
><path
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"
></path><line x1="1" y1="1" x2="23" y2="23"></line></svg
>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"
></circle></svg
>
{/if}
</button>
{:else if isValidating || rightIcon}
<div class="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none">
{#if isValidating}
<span class="inline-flex items-center gap-2 text-xs text-gray-500">
<span
class="inline-block h-3 w-3 rounded-full border border-gray-400 border-t-transparent animate-spin"
></span>
Checking…
</span>
{:else if rightIcon}
{@render rightIcon()}
{/if}
</div>
{/if}
</div>
{/if}
{#if hint}
<p class="mt-0.5 text-xs text-gray-500">{hint}</p>
{/if}
<div class="min-h-3 mt-0">
{#if error}
<p id={errorId} class="text-sm text-red-600">{error}</p>
{/if}
</div>
</div>

View File

@@ -0,0 +1,191 @@
<script lang="ts">
import { getDBEntries, getDBEntry } from "../lib/api"
import { apiBaseURL } from "../config"
import { apiBaseOverride } from "../lib/store"
import { get } from "svelte/store"
// Medialib cache (module-level)
const medialibCache: { [id: string]: MedialibEntry } = {}
let loadQueue: string[] = []
let debounceTimer: ReturnType<typeof setTimeout> | null = null
async function processQueue() {
if (loadQueue.length) {
const _ids = [...loadQueue]
loadQueue = []
const entries = await getDBEntries(
"medialib",
{ _id: { $in: _ids } },
"_id",
undefined,
undefined,
"public"
)
entries.forEach((entry: MedialibEntry) => {
if (entry.id) medialibCache[entry.id] = entry
})
}
}
async function loadMedialibEntry(id: string): Promise<MedialibEntry> {
if (medialibCache[id]) return medialibCache[id]
loadQueue.push(id)
await new Promise<void>((resolve) => {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(async () => {
await processQueue()
resolve()
}, 50)
})
return medialibCache[id]
}
interface Props {
id: string
filter?: string | null
noPlaceholder?: boolean
caption?: string
showCaption?: boolean
minWidth?: number
widthMultiplier?: number
lazy?: boolean
style?: string
}
let {
id,
filter = null,
noPlaceholder = false,
caption = "",
showCaption = false,
minWidth = 0,
widthMultiplier = 1,
lazy = false,
style = "",
}: Props = $props()
let loading = $state(true)
let entry = $state<MedialibEntry | null>(null)
let fileSrc = $state<string | null>(null)
let imgEl = $state<HTMLImageElement | null>(null)
let currentFilter = $state<string>("l-webp")
// Sync explicit filter prop reactively
$effect(() => {
if (filter) currentFilter = filter
})
function getAutoFilter(imgWidth: number): string {
const width = minWidth ? Math.max(imgWidth, minWidth) : imgWidth
const effectiveWidth = width * (widthMultiplier || 1)
if (effectiveWidth <= 90) return "xs-webp"
if (effectiveWidth <= 300) return "s-webp"
if (effectiveWidth <= 600) return "m-webp"
if (effectiveWidth <= 1200) return "l-webp"
if (effectiveWidth <= 2000) return "xl-webp"
return "xxl-webp"
}
function isRasterImage(entry: MedialibEntry | null): boolean {
if (entry?.file?.type?.match(/^image\/(png|jpe?g|webp)/)) return true
// Fallback: check file extension when MIME type is missing (e.g. public projection)
if (entry?.file?.src?.match(/\.(jpe?g|png|webp)$/i)) return true
return false
}
async function loadFile() {
if (!id) return
loading = true
entry = null
fileSrc = null
try {
const _apiBase = get(apiBaseOverride) || apiBaseURL
entry =
typeof window !== "undefined"
? await loadMedialibEntry(id)
: await getDBEntry("medialib", { _id: id }, "public")
if (entry?.file?.src) {
fileSrc = _apiBase + "medialib/" + id + "/" + entry.file.src
}
} catch (e) {
console.error(e)
}
loading = false
}
// SSR: fire-and-forget — $effect does NOT run during SSR.
// loadFile() internally checks if id is set.
if (typeof window === "undefined") loadFile()
$effect(() => {
if (id) loadFile()
})
// ResizeObserver: only when no explicit filter and raster image
$effect(() => {
const el = imgEl
if (!el || filter || !entry || !isRasterImage(entry)) return
if (typeof ResizeObserver === "undefined") return
let maxObservedWidth = 0
const observer = new ResizeObserver(() => {
const newWidth = el.clientWidth
if (newWidth <= maxObservedWidth) return // only scale up
maxObservedWidth = newWidth
currentFilter = getAutoFilter(newWidth)
})
observer.observe(el)
return () => observer.disconnect()
})
function getSrc(src: string | null, entry: MedialibEntry | null): string {
if (!src) return "/assets/img/placeholder-image.svg"
if (!isRasterImage(entry)) return src
if (filter) return src + `?filter=${filter}`
return src + `?filter=${currentFilter}`
}
</script>
{#if id}
{#if loading}
{#if !noPlaceholder}
<img src="/assets/img/placeholder-image.svg" alt="loading" />
{/if}
{:else if entry && fileSrc}
{#if showCaption && caption}
<figure>
<picture>
<img
bind:this={imgEl}
src={getSrc(fileSrc, entry)}
alt={entry.alt || ""}
data-entry-id={id}
loading={lazy ? "lazy" : undefined}
{style}
/>
</picture>
<figcaption class="mt-2 text-sm text-gray-500 text-center italic">
{@html caption}
</figcaption>
</figure>
{:else}
<picture>
<img
bind:this={imgEl}
src={getSrc(fileSrc, entry)}
alt={entry.alt || ""}
data-entry-id={id}
loading={lazy ? "lazy" : undefined}
{style}
/>
</picture>
{/if}
{:else if !noPlaceholder}
<picture>
<img src="/assets/img/placeholder-image.svg" alt="not found" data-entry-id={id} />
</picture>
{/if}
{/if}

View File

@@ -0,0 +1,126 @@
<script lang="ts">
interface Props {
currentPage: number
totalPages: number
onPageChange: (page: number) => void
/** Maximum number of visible page buttons (excluding prev/next) */
maxVisiblePages?: number
}
let { currentPage, totalPages, onPageChange, maxVisiblePages = 7 }: Props = $props()
/**
* Calculate visible page numbers with ellipsis.
* Always shows first and last page, with a range around the current page.
*/
const getVisiblePages = (): (number | "ellipsis")[] => {
if (totalPages <= maxVisiblePages) {
return Array.from({ length: totalPages }, (_, i) => i + 1)
}
const pages: (number | "ellipsis")[] = []
const halfVisible = Math.floor((maxVisiblePages - 3) / 2)
// Always first page
pages.push(1)
let rangeStart = Math.max(2, currentPage - halfVisible)
let rangeEnd = Math.min(totalPages - 1, currentPage + halfVisible)
if (currentPage <= halfVisible + 2) {
rangeEnd = Math.min(totalPages - 1, maxVisiblePages - 2)
} else if (currentPage >= totalPages - halfVisible - 1) {
rangeStart = Math.max(2, totalPages - maxVisiblePages + 3)
}
if (rangeStart > 2) {
pages.push("ellipsis")
}
for (let i = rangeStart; i <= rangeEnd; i++) {
pages.push(i)
}
if (rangeEnd < totalPages - 1) {
pages.push("ellipsis")
}
if (totalPages > 1) {
pages.push(totalPages)
}
return pages
}
let visiblePages = $derived(getVisiblePages())
</script>
{#if totalPages > 1}
<nav class="flex items-center justify-center gap-1 sm:gap-2 mt-8" aria-label="Pagination">
<!-- Previous button -->
<button
type="button"
class="
flex items-center justify-center
w-10 h-10 sm:w-12 sm:h-12
rounded-md
text-gray-900
transition-colors
{currentPage === 1 ? 'opacity-40 cursor-not-allowed' : 'hover:bg-blue-600/10 cursor-pointer'}
"
disabled={currentPage === 1}
onclick={() => onPageChange(currentPage - 1)}
aria-label="Previous page"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
</button>
<!-- Page numbers -->
<div class="flex items-center gap-1">
{#each visiblePages as page, index (index)}
{#if page === "ellipsis"}
<span class="w-10 h-10 sm:w-12 sm:h-12 flex items-center justify-center text-gray-900/50"></span>
{:else}
<button
type="button"
class="
flex items-center justify-center
w-10 h-10 sm:w-12 sm:h-12
rounded-md
font-semibold text-sm sm:text-base
transition-colors
{page === currentPage ? 'bg-blue-600 text-white' : 'text-gray-900 hover:bg-blue-600/10'}
"
aria-current={page === currentPage ? "page" : undefined}
aria-label={page === currentPage ? `Page ${page}, current page` : `Go to page ${page}`}
onclick={() => onPageChange(page)}
>
{page}
</button>
{/if}
{/each}
</div>
<!-- Next button -->
<button
type="button"
class="
flex items-center justify-center
w-10 h-10 sm:w-12 sm:h-12
rounded-md
text-gray-900
transition-colors
{currentPage === totalPages ? 'opacity-40 cursor-not-allowed' : 'hover:bg-blue-600/10 cursor-pointer'}
"
disabled={currentPage === totalPages}
onclick={() => onPageChange(currentPage + 1)}
aria-label="Next page"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</button>
</nav>
{/if}

View 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>

View File

@@ -0,0 +1,209 @@
<script lang="ts">
import { onMount, onDestroy, getContext } from "svelte"
import type { FormEventHandler, HTMLSelectAttributes } from "svelte/elements"
import type { Snippet } from "svelte"
import { FORM_CONTEXT, type FormContext, type ValidatableField } from "../lib/formContext"
let {
value = $bindable(""),
options = [] as { value: string; label: string }[],
name = "",
required = false,
id = "",
label = "",
hideLabel = true,
variant = "default",
error = $bindable(""),
disabled = false,
messages = {},
validator,
onchange = undefined as FormEventHandler<HTMLSelectElement> | undefined,
children,
...rest
}: {
value?: string
options?: { value: string; label: string }[]
name?: string
required?: boolean
id?: string
label?: string
hideLabel?: boolean
variant?: "default" | "integrated"
error?: string
disabled?: boolean
messages?: Partial<Record<keyof ValidityState, string>>
onchange?: FormEventHandler<HTMLSelectElement>
children?: Snippet
validator?: (value: string) => Promise<string | null | undefined> | string | null | undefined
} & HTMLSelectAttributes = $props()
const fallbackId = `select-${Math.random().toString(36).substr(2, 9)}`
const selectId = $derived(id || fallbackId)
const errorId = $derived(`${selectId}-error`)
let selectElement = $state<HTMLSelectElement>()
const formContext = getContext<FormContext>(FORM_CONTEXT)
export const validate = async () => {
if (!selectElement) return true
selectElement.setCustomValidity("")
if (selectElement.checkValidity()) {
if (validator) {
const customError = await validator(value)
if (customError) {
error = customError
return false
}
}
error = ""
return true
}
const validity = selectElement.validity
let errorMessage = selectElement.validationMessage
if (validity.valueMissing && messages.valueMissing) errorMessage = messages.valueMissing
else if (validity.customError && messages.customError) errorMessage = messages.customError
error = errorMessage
return false
}
const reset = () => {
error = ""
}
const focus = () => {
selectElement?.focus()
}
const handleChange: FormEventHandler<HTMLSelectElement> = (e) => {
onchange?.(e)
validate()
}
const field: ValidatableField = { validate, reset, focus }
onMount(() => {
if (formContext) {
formContext.register(field)
}
})
onDestroy(() => {
if (formContext) {
formContext.unregister(field)
}
})
</script>
<div class="w-full">
{#if variant === "integrated"}
<div
class="relative w-full border rounded-lg px-3 py-2 h-16.5 transition-colors group focus-within:ring-1 focus-within:ring-blue-600 {disabled
? 'bg-gray-50'
: 'bg-white'} {error
? 'border-red-500'
: 'border-gray-300 hover:border-blue-600 focus-within:border-blue-600'}"
>
{#if label}
<label
for={selectId}
class="block text-xs font-bold uppercase mb-1 {error ? 'text-red-500' : 'text-gray-500'}"
>
{label}
{#if required}<span class="text-red-600">*</span>{/if}
</label>
{/if}
<select
bind:this={selectElement}
id={selectId}
{name}
bind:value
{required}
{disabled}
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
class="w-full bg-transparent text-base text-gray-900 focus:outline-none appearance-none cursor-pointer"
onchange={handleChange}
{...rest}
>
{#if options.length > 0}
{#each options as option}
<option value={option.value}>{option.label}</option>
{/each}
{:else if children}
{@render children()}
{/if}
</select>
<div class="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-gray-400 group-hover:text-blue-600"
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>
</div>
</div>
{:else}
{#if label}
<label
for={selectId}
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">
<select
bind:this={selectElement}
id={selectId}
{name}
bind:value
{required}
{disabled}
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
class="w-full border rounded-lg px-4 py-3 bg-white text-base appearance-none cursor-pointer 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'}"
onchange={handleChange}
{...rest}
>
{#if options.length > 0}
{#each options as option}
<option value={option.value}>{option.label}</option>
{/each}
{:else if children}
{@render children()}
{/if}
</select>
<div class="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 {error ? 'text-red-500' : 'text-gray-400'} group-hover:text-blue-600"
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>
</div>
</div>
{/if}
<div class="min-h-5.5 mt-1">
{#if error}
<p id={errorId} class="text-sm text-red-600">{error}</p>
{/if}
</div>
</div>

View File

@@ -0,0 +1,83 @@
<script lang="ts">
/**
* Tooltip component for displaying additional information on hover/focus.
* Uses position:fixed to escape overflow:hidden containers.
*/
import type { Snippet } from "svelte"
type Props = {
/** Tooltip text to display */
text: string
/** Optional CSS class for the trigger wrapper */
class?: string
/** Child content (trigger element) */
children: Snippet
}
let { text, class: className = "", children }: Props = $props()
let visible = $state(false)
let triggerEl: HTMLSpanElement | undefined = $state()
let tooltipStyle = $state("")
function updatePosition() {
if (!triggerEl) return
const rect = triggerEl.getBoundingClientRect()
const top = rect.top - 8 // 8px gap above trigger
const left = rect.left
tooltipStyle = `top:${top}px;left:${left}px;transform:translateY(-100%)`
}
function show() {
updatePosition()
visible = true
}
function hide() {
visible = false
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
bind:this={triggerEl}
class="inline-block {className}"
onmouseenter={show}
onmouseleave={hide}
onfocusin={show}
onfocusout={hide}
>
{@render children()}
{#if visible && text}
<span
role="tooltip"
class="fixed px-3 py-2 text-sm leading-snug
bg-gray-800 text-white rounded-lg shadow-lg
whitespace-normal text-left z-50
pointer-events-none animate-fade-in"
style="max-width:320px;{tooltipStyle}"
>
{text}
<!-- Arrow -->
<span class="absolute top-full left-3 border-4 border-transparent border-t-gray-800"></span>
</span>
{/if}
</span>
<style>
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(calc(-100% + 4px));
}
to {
opacity: 1;
transform: translateY(-100%);
}
}
.animate-fade-in {
animation: fade-in 0.15s ease-out forwards;
}
</style>