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
+191
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}