✨ 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:
@@ -12,6 +12,11 @@ applyTo: "frontend/src/**"
|
||||
- SSR safety: guard browser-only code with `typeof window !== "undefined"`.
|
||||
- API behavior: PUT responses return only changed fields; filter by id uses `_id`; API requests reject non-2xx with `{ response, data }` and error payload in `error.data.error`.
|
||||
|
||||
## Tailwind CSS
|
||||
|
||||
- Always use canonical Tailwind utility classes instead of arbitrary values when a standard equivalent exists (e.g. `h-16.5` not `h-[66px]`, `min-h-3` not `min-h-[12px]`).
|
||||
- Only use arbitrary values (`[...]`) when no standard utility covers the needed value.
|
||||
|
||||
## i18n
|
||||
|
||||
- `svelte-i18n` is configured in `frontend/src/lib/i18n/index.ts` with lazy loading for locale files.
|
||||
|
||||
@@ -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>
|
||||
|
||||
195
frontend/src/lib/navigation.ts
Normal file
195
frontend/src/lib/navigation.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
59
frontend/src/widgets/Button.svelte
Normal file
59
frontend/src/widgets/Button.svelte
Normal 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>
|
||||
114
frontend/src/widgets/Carousel.svelte
Normal file
114
frontend/src/widgets/Carousel.svelte
Normal 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>
|
||||
47
frontend/src/widgets/DebugFooterInfo.svelte
Normal file
47
frontend/src/widgets/DebugFooterInfo.svelte
Normal 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>
|
||||
63
frontend/src/widgets/Form.svelte
Normal file
63
frontend/src/widgets/Form.svelte
Normal 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>
|
||||
394
frontend/src/widgets/Input.svelte
Normal file
394
frontend/src/widgets/Input.svelte
Normal 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>
|
||||
191
frontend/src/widgets/MedialibImage.svelte
Normal file
191
frontend/src/widgets/MedialibImage.svelte
Normal 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}
|
||||
126
frontend/src/widgets/Pagination.svelte
Normal file
126
frontend/src/widgets/Pagination.svelte
Normal 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}
|
||||
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>
|
||||
209
frontend/src/widgets/Select.svelte
Normal file
209
frontend/src/widgets/Select.svelte
Normal 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>
|
||||
83
frontend/src/widgets/Tooltip.svelte
Normal file
83
frontend/src/widgets/Tooltip.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user