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
+195
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)
},
}
}