✨ 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:
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user