UNPKG

@zeix/ui-element

Version:

UIElement - a HTML-first library for reactive Web Components

170 lines (153 loc) 4.22 kB
import { type Component, type Computed, type Context, component, computed, dangerouslySetInnerHTML, on, provideContexts, type State, setText, show, state, toggleClass, UNSET, } 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 Array.from(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: AbortSignal): Promise<string> => { const currentPath = String(el[ROUTER_PATHNAME]) const url = String(new URL(currentPath, window.location.origin)) if (abort?.aborted) return '' 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 ?? '' }, ) 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', ({ event, target }) => { if (!isInternalLink(target)) return const url = new URL(target.href) if (url.origin === window.location.origin) { event.preventDefault() el[ROUTER_PATHNAME] = url.pathname } }), ]), // Render content first(outlet, [dangerouslySetInnerHTML(content)]), // Error display with aria-live first('card-callout', [show(() => !!error.get())]), first('.error', [setText(error)]), // Handle browser history navigation on('popstate', () => { el[ROUTER_PATHNAME] = window.location.pathname }), ] }, ) declare global { interface HTMLElementTagNameMap { 'context-router': Component<ContextRouterProps> } }