UNPKG

@zeix/ui-element

Version:

UIElement - a HTML-first library for reactive Web Components

190 lines (172 loc) 4.59 kB
import { type Component, type Computed, type Context, type State, UNSET, component, computed, dangerouslySetInnerHTML, on, provideContexts, setText, show, state, toggleClass, } from '../../..' import { fetchWithCache } from '../../functions/shared/fetch-with-cache' import { isInternalLink } from '../../functions/shared/is-internal-link' export type ContextRouterProps = { 'router-pathname': string 'router-query': Record<string, string> } /* === Exported Contexts === */ export const ROUTER_PATHNAME = 'router-pathname' as Context< 'router-pathname', State<string> > export const ROUTER_QUERY = 'router-query' as Context< 'router-query', Computed<Record<string, string>> > /* === Component === */ export default component( 'context-router', { [ROUTER_PATHNAME]: window.location.pathname, [ROUTER_QUERY]: () => { const queryMap = new Map() for (const [key, value] of new URLSearchParams( window.location.search, )) { queryMap.set(key, state(value)) } const getSetParam = (key: string, value?: string): string => { if (!queryMap.has(key)) queryMap.set(key, state(value ?? UNSET)) else if (value != null) queryMap.get(key).set(value) return queryMap.get(key).get() } const syncToURL = () => { const params = new URLSearchParams() for (const [key, signal] of queryMap) { const value = signal.get() if (value && value !== UNSET) params.set(key, value) } window.history.replaceState( null, '', `${window.location.pathname}?${params.toString()}${window.location.hash}`, ) } return () => new Proxy( {}, { has(_, prop: string) { return queryMap.has(prop) }, get(_, prop: string) { return getSetParam(prop) }, set(_, prop: string, value: string) { getSetParam(prop, value) syncToURL() return true }, ownKeys() { return [...queryMap.keys()] }, }, ) }, }, (el, { all, first }) => { const outlet = el.getAttribute('outlet') ?? 'main' const error = state('') // Convert all relative links to absolute URLs during setup for (const link of el.querySelectorAll('a[href]')) { const href = link.getAttribute('href') if ( href && !href.startsWith('#') && !href.includes('://') && !href.startsWith('/') ) { try { const absoluteUrl = new URL(href, window.location.href) link.setAttribute('href', absoluteUrl.pathname) } catch { // Skip invalid URLs } } } const content = computed(async abort => { const currentPath = String(el[ROUTER_PATHNAME]) const url = String(new URL(currentPath, window.location.origin)) if (abort?.aborted) return content.get() try { error.set('') const { content: html } = await fetchWithCache(url, abort) const doc = new DOMParser().parseFromString(html, 'text/html') // Update title and URL const newTitle = doc.querySelector('title')?.textContent if (newTitle) document.title = newTitle if (currentPath !== window.location.pathname) window.history.pushState({}, '', url) return doc.querySelector(outlet)?.innerHTML ?? '' } catch (err) { const errorMessage = `Navigation failed: ${err instanceof Error ? err.message : String(err)}` error.set(errorMessage) return content.get() // Keep current content on error } }) return [ // Provide contexts provideContexts([ROUTER_PATHNAME, ROUTER_QUERY]), // Navigate and update 'active' class all( 'a[href]:not([href^="#"])', toggleClass( 'active', target => isInternalLink(target) && el[ROUTER_PATHNAME] === target.pathname, ), on('click', e => { if (!isInternalLink(e.target)) return const url = new URL(e.target.href) if (url.origin === window.location.origin) { e.preventDefault() el[ROUTER_PATHNAME] = url.pathname } }), ), // Render content first( outlet, dangerouslySetInnerHTML(content, { allowScripts: true }), ), // Error display with aria-live first( 'card-callout', show(() => !!error.get()), ), first('.error', setText(error)), // Handle browser history navigation () => { const handlePopState = () => { el[ROUTER_PATHNAME] = window.location.pathname } window.addEventListener('popstate', handlePopState) return () => { window.removeEventListener('popstate', handlePopState) } }, ] }, ) declare global { interface HTMLElementTagNameMap { 'context-router': Component<{}> } }