diff --git a/packages/react/src/runtime/link.tsx b/packages/react/src/runtime/link.tsx index 2da361f53..0e583d2c1 100644 --- a/packages/react/src/runtime/link.tsx +++ b/packages/react/src/runtime/link.tsx @@ -8,31 +8,19 @@ import type { CSSProperties, RefObject, } from 'react'; -import { isModifiedEvent, mergeDataIntoHref } from 'void/pages-client'; -import type { Href } from 'void/routes'; +import { + handleLinkClick, + linkPrefetchStrategies, + observeLinkVisible, + prefetchLink, + resolveLink, + type BaseLinkProps, +} from 'void/pages-client'; import { RouterContext } from './context.ts'; -type PrefetchStrategy = 'hover' | 'click' | 'mount' | 'visible'; -type LinkMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; -type PrefetchOptions = { cacheFor?: LinkProps['cacheFor'] }; - -type CommonProps = { - href: Href; - method?: LinkMethod | Lowercase; - data?: Record; - preserveScroll?: boolean; - preserveState?: boolean; - replace?: boolean; - reloadDocument?: boolean; - viewTransition?: boolean; - prefetch?: boolean | PrefetchStrategy | ReadonlyArray; - cacheFor?: number | string | [string, string]; - onNavigate?: (event: { preventDefault(): void }) => void; -}; - -export type LinkProps = CommonProps & - Omit, keyof CommonProps> & - Omit, keyof CommonProps>; +export type LinkProps = BaseLinkProps & + Omit, keyof BaseLinkProps> & + Omit, keyof BaseLinkProps>; export function Link({ href, @@ -43,7 +31,7 @@ export function Link({ replace = false, reloadDocument = false, viewTransition, - prefetch: prefetchProp = false, + prefetch = false, cacheFor, onNavigate, children, @@ -59,110 +47,45 @@ export function Link({ const router = useContext(RouterContext); const elementRef = useRef(null); const hoverTimerRef = useRef | null>(null); - const normalizedMethod = useMemo(() => method.toUpperCase() as LinkMethod, [method]); - const isGet = normalizedMethod === 'GET'; - const hrefWithData = useMemo( - () => (isGet ? mergeDataIntoHref(href, data) : href), - [data, href, isGet], + const link = useMemo( + () => resolveLink({ href, method, data, prefetch, reloadDocument }), + [data, href, method, prefetch, reloadDocument], + ); + const strategies = useMemo( + () => linkPrefetchStrategies({ prefetch, reloadDocument }), + [prefetch, reloadDocument], ); - const strategies = useMemo>(() => { - if (prefetchProp === false || reloadDocument) { - return []; - } - if (prefetchProp === true) { - return ['hover']; - } - if (typeof prefetchProp === 'string') { - return [prefetchProp]; - } - return prefetchProp; - }, [prefetchProp, reloadDocument]); const hoverDelay = router?._hoverDelay ?? 75; - if (prefetchProp !== false && !isGet) { - throw new Error( - 'Link: prefetch only supports GET links. Remove `prefetch` or use method="GET".', - ); - } - if (reloadDocument && !isGet) { - throw new Error('Link: reloadDocument only supports GET links.'); - } - - const doPrefetch = useCallback(() => { - if (!router?.prefetch) { - return; - } - const current = window.location.pathname + window.location.search; - if (hrefWithData === current) { - return; - } - const options: PrefetchOptions = {}; - if (cacheFor !== undefined) { - options.cacheFor = cacheFor; - } - router.prefetch(hrefWithData, options); - }, [cacheFor, hrefWithData, router]); - - const shouldHandleClick = useCallback( - (event: MouseEvent): boolean => { - if (reloadDocument || !router || event.defaultPrevented || isModifiedEvent(event)) { - return false; - } - if ((event.currentTarget as HTMLElement).hasAttribute('download')) { - return false; - } - - const url = new URL(hrefWithData, window.location.href); - if (url.origin !== window.location.origin) { - return false; - } - - return true; - }, - [hrefWithData, reloadDocument, router], + const doPrefetch = useCallback( + () => prefetchLink(router, link.href, cacheFor), + [cacheFor, link.href, router], ); const onClick = useCallback( (event: MouseEvent) => { // oxlint-disable-next-line no-explicit-any -- React merges anchor/button handler types awkwardly here onClickProp?.(event as any); - if (!shouldHandleClick(event)) { - return; - } - - let navigatePrevented = false; - onNavigate?.({ - preventDefault() { - navigatePrevented = true; - }, - }); - if (navigatePrevented) { - event.preventDefault(); - return; - } - - event.preventDefault(); - router?.visit(hrefWithData, { - method: normalizedMethod, - data: isGet ? undefined : data, + handleLinkClick(event, router, link, { + data, preserveScroll, preserveState, replace, + reloadDocument, viewTransition, + onNavigate, }); }, [ data, - hrefWithData, - isGet, - normalizedMethod, + link, onClickProp, onNavigate, preserveScroll, preserveState, + reloadDocument, replace, router, - shouldHandleClick, viewTransition, ], ); @@ -236,29 +159,17 @@ export function Link({ doPrefetch(); } - let observer: IntersectionObserver | null = null; - if (strategies.includes('visible') && elementRef.current) { - observer = new IntersectionObserver( - (entries) => { - if (entries.some((e) => e.isIntersecting)) { - doPrefetch(); - observer?.disconnect(); - observer = null; - } - }, - { rootMargin: '0px' }, - ); - observer.observe(elementRef.current); - } + const stopObserving = + strategies.includes('visible') && elementRef.current + ? observeLinkVisible(elementRef.current, doPrefetch) + : null; return () => { if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } - if (observer) { - observer.disconnect(); - } + stopObserving?.(); }; }, [doPrefetch, strategies]); @@ -310,11 +221,11 @@ export function Link({ [style], ); - if (isGet) { + if (link.isGet) { return ( } - href={hrefWithData} + href={link.href} style={resolvedStyle} {...rest} {...eventHandlers} diff --git a/packages/solid/src/runtime/link.tsx b/packages/solid/src/runtime/link.tsx index 6789e0fda..3cc53f507 100644 --- a/packages/solid/src/runtime/link.tsx +++ b/packages/solid/src/runtime/link.tsx @@ -1,26 +1,16 @@ import { createMemo, onCleanup, onMount, splitProps, useContext } from 'solid-js'; import type { JSX } from 'solid-js'; -import { isModifiedEvent, mergeDataIntoHref } from 'void/pages-client'; -import type { Href } from 'void/routes'; +import { + handleLinkClick, + linkPrefetchStrategies, + observeLinkVisible, + prefetchLink, + resolveLink, + type BaseLinkProps, +} from 'void/pages-client'; import { RouterContext } from './context.ts'; -type PrefetchStrategy = 'hover' | 'click' | 'mount' | 'visible'; -type LinkMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; -type NavigateEvent = { preventDefault(): void }; -type PrefetchOptions = { cacheFor?: LinkProps['cacheFor'] }; - -type CommonProps = { - href: Href; - method?: LinkMethod | Lowercase; - data?: Record; - preserveScroll?: boolean; - preserveState?: boolean; - replace?: boolean; - reloadDocument?: boolean; - viewTransition?: boolean; - prefetch?: boolean | PrefetchStrategy | ReadonlyArray; - cacheFor?: number | string | [string, string]; - onNavigate?: (event: NavigateEvent) => void; +type CommonProps = BaseLinkProps & { children?: JSX.Element; }; @@ -73,103 +63,21 @@ export function Link(props: LinkProps): JSX.Element { ]); const router = useContext(RouterContext); - const linkState = createMemo(() => { - const method = (local.method || 'GET').toUpperCase() as LinkMethod; - const isGet = method === 'GET'; - - if (local.prefetch !== false && local.prefetch !== undefined && !isGet) { - throw new Error( - 'Link: prefetch only supports GET links. Remove `prefetch` or use method="GET".', - ); - } - if (local.reloadDocument && !isGet) { - throw new Error('Link: reloadDocument only supports GET links.'); - } - - return { - href: isGet ? mergeDataIntoHref(local.href, local.data) : local.href, - isGet, - method, - }; - }); - const normalizedMethod = () => linkState().method; - const isGet = () => linkState().isGet; - const hrefWithData = () => linkState().href; - const strategies = createMemo>(() => { - if (local.prefetch === false || local.prefetch === undefined || local.reloadDocument) { - return []; - } - if (local.prefetch === true) { - return ['hover']; - } - if (typeof local.prefetch === 'string') { - return [local.prefetch]; - } - return local.prefetch; - }); + const link = createMemo(() => resolveLink(local)); + const strategies = createMemo(() => linkPrefetchStrategies(local)); const resolvedStyle = createMemo(() => mergeStyle(local.style)); function doPrefetch() { - if (!router?.prefetch) { - return; - } - const current = window.location.pathname + window.location.search; - if (hrefWithData() === current) { - return; - } - const options: PrefetchOptions = {}; - if (local.cacheFor !== undefined) { - options.cacheFor = local.cacheFor; - } - router.prefetch(hrefWithData(), options); - } - - function shouldHandleClick(event: MouseEvent): boolean { - if (local.reloadDocument || !router || event.defaultPrevented || isModifiedEvent(event)) { - return false; - } - if ((event.currentTarget as HTMLElement).hasAttribute('download')) { - return false; - } - - const url = new URL(hrefWithData(), window.location.href); - if (url.origin !== window.location.origin) { - return false; - } - - return true; + prefetchLink(router, link().href, local.cacheFor); } function onClick(event: MouseEvent) { callHandler(local.onClick, event); - if (!shouldHandleClick(event)) { - return; - } - - let navigatePrevented = false; - local.onNavigate?.({ - preventDefault() { - navigatePrevented = true; - }, - }); - if (navigatePrevented) { - event.preventDefault(); - return; - } - - event.preventDefault(); - router?.visit(hrefWithData(), { - method: normalizedMethod(), - data: isGet() ? undefined : local.data, - preserveScroll: local.preserveScroll, - preserveState: local.preserveState, - replace: local.replace, - viewTransition: local.viewTransition, - }); + handleLinkClick(event, router, link(), local); } let hoverTimer: ReturnType | null = null; - let observer: IntersectionObserver | null = null; + let stopObserving: (() => void) | null = null; let el: HTMLElement | undefined; function onMouseEnter(event: MouseEvent) { @@ -217,17 +125,7 @@ export function Link(props: LinkProps): JSX.Element { doPrefetch(); } if (strategies().includes('visible') && el) { - observer = new IntersectionObserver( - (entries) => { - if (entries.some((e) => e.isIntersecting)) { - doPrefetch(); - observer?.disconnect(); - observer = null; - } - }, - { rootMargin: '0px' }, - ); - observer.observe(el); + stopObserving = observeLinkVisible(el, doPrefetch); } }); @@ -235,10 +133,7 @@ export function Link(props: LinkProps): JSX.Element { if (hoverTimer) { clearTimeout(hoverTimer); } - if (observer) { - observer.disconnect(); - observer = null; - } + stopObserving?.(); }); const eventHandlers = createMemo(() => { @@ -268,12 +163,12 @@ export function Link(props: LinkProps): JSX.Element { return ( <> - {isGet() ? ( + {link().isGet ? ( { el = r; }} - href={hrefWithData()} + href={link().href} {...rest} style={resolvedStyle()} {...eventHandlers()} diff --git a/packages/svelte/src/runtime/Link.svelte b/packages/svelte/src/runtime/Link.svelte index 7f16da3f1..3752361ed 100644 --- a/packages/svelte/src/runtime/Link.svelte +++ b/packages/svelte/src/runtime/Link.svelte @@ -1,12 +1,14 @@ -{#if isGet} +{#if link.isGet} = (event: T) => void; -export type LinkProps = { - href: Href; - method?: LinkMethod | Lowercase; - data?: Record; - preserveScroll?: boolean; - preserveState?: boolean; - replace?: boolean; - reloadDocument?: boolean; - viewTransition?: boolean; - prefetch?: boolean | PrefetchStrategy | Array; - cacheFor?: number | string | [string, string]; - onNavigate?: (event: NavigateEvent) => void; +export type LinkProps = BaseLinkProps & { children?: Snippet; onclick?: EventHandler; onmouseenter?: EventHandler; diff --git a/packages/void/src/pages/client.ts b/packages/void/src/pages/client.ts index 86d748ebc..77311f53e 100644 --- a/packages/void/src/pages/client.ts +++ b/packages/void/src/pages/client.ts @@ -22,6 +22,17 @@ import { ISLAND_FRONTMATTER_ID } from './serialize.ts'; import { isSameOrigin } from './url.ts'; export { getValidationErrors, isModifiedEvent, mergeDataIntoHref } from './adapter-helpers.ts'; +export { + handleLinkClick, + linkPrefetchStrategies, + observeLinkVisible, + prefetchLink, + resolveLink, + type BaseLinkProps, + type LinkNavigateEvent, + type LinkPrefetchStrategy, + type ResolvedLink, +} from './link.ts'; export { VoidActionError, categorizeActionError, diff --git a/packages/void/src/pages/link.ts b/packages/void/src/pages/link.ts new file mode 100644 index 000000000..6f11dfe1d --- /dev/null +++ b/packages/void/src/pages/link.ts @@ -0,0 +1,180 @@ +/// +import type { Href } from 'void/routes'; +import { isModifiedEvent, mergeDataIntoHref } from './adapter-helpers.ts'; +import type { NavigationMethod, VoidRouter } from './client.ts'; +import type { PrefetchOptions } from './navigation-engine.ts'; +import type { CacheForInput } from './prefetch.ts'; + +export type LinkPrefetchStrategy = 'hover' | 'click' | 'mount' | 'visible'; +export type LinkNavigateEvent = { preventDefault(): void }; + +/** Props every adapter's Link accepts. Adapters add element attributes and children. */ +export type BaseLinkProps = { + href: Href; + method?: NavigationMethod | Lowercase; + data?: Record; + preserveScroll?: boolean; + preserveState?: boolean; + replace?: boolean; + reloadDocument?: boolean; + viewTransition?: boolean; + prefetch?: boolean | LinkPrefetchStrategy | ReadonlyArray; + cacheFor?: CacheForInput; + onNavigate?: (event: LinkNavigateEvent) => void; +}; + +export type ResolvedLink = { + method: NavigationMethod; + isGet: boolean; + /** The destination, with GET data merged into its query. */ + href: Href; +}; + +type LinkClickEvent = Pick< + MouseEvent, + | 'currentTarget' + | 'ctrlKey' + | 'metaKey' + | 'shiftKey' + | 'altKey' + | 'button' + | 'defaultPrevented' + | 'preventDefault' +>; + +type LinkClickProps = Pick< + BaseLinkProps, + | 'data' + | 'preserveScroll' + | 'preserveState' + | 'replace' + | 'reloadDocument' + | 'viewTransition' + | 'onNavigate' +>; + +/** Normalizes the method and destination, rejecting options that only GET links support. */ +export function resolveLink( + props: Pick, +): ResolvedLink { + const method = (props.method ?? 'GET').toUpperCase() as NavigationMethod; + const isGet = method === 'GET'; + + if (props.prefetch !== undefined && props.prefetch !== false && !isGet) { + throw new Error( + 'Link: prefetch only supports GET links. Remove `prefetch` or use method="GET".', + ); + } + if (props.reloadDocument && !isGet) { + throw new Error('Link: reloadDocument only supports GET links.'); + } + + return { + method, + isGet, + href: isGet ? mergeDataIntoHref(props.href, props.data) : props.href, + }; +} + +export function linkPrefetchStrategies( + props: Pick, +): ReadonlyArray { + const { prefetch } = props; + if (prefetch === undefined || prefetch === false || props.reloadDocument) { + return []; + } + + if (prefetch === true) { + return ['hover']; + } + + if (typeof prefetch === 'string') { + return [prefetch]; + } + + return prefetch; +} + +export function prefetchLink( + router: VoidRouter | null | undefined, + href: Href, + cacheFor: CacheForInput | undefined, +): void { + if (!router?.prefetch) { + return; + } + + const current = window.location.pathname + window.location.search; + if (href === current) { + return; + } + + const options: PrefetchOptions = {}; + if (cacheFor !== undefined) { + options.cacheFor = cacheFor; + } + + router.prefetch(href, options); +} + +/** + * Runs a client-side visit for a plain same-origin click. Modified clicks, downloads, + * cross-origin destinations and `reloadDocument` links are left to the browser. + * Call it after the user's own click handler so its `preventDefault()` is respected. + */ +export function handleLinkClick( + event: LinkClickEvent, + router: VoidRouter | null | undefined, + link: ResolvedLink, + props: LinkClickProps, +): void { + if (props.reloadDocument || !router || event.defaultPrevented || isModifiedEvent(event)) { + return; + } + + if ((event.currentTarget as HTMLElement).hasAttribute('download')) { + return; + } + + const url = new URL(link.href, window.location.href); + if (url.origin !== window.location.origin) { + return; + } + + let navigatePrevented = false; + props.onNavigate?.({ + preventDefault() { + navigatePrevented = true; + }, + }); + + event.preventDefault(); + if (navigatePrevented) { + return; + } + + router.visit(link.href, { + method: link.method, + data: link.isGet ? undefined : props.data, + preserveScroll: props.preserveScroll, + preserveState: props.preserveState, + replace: props.replace, + viewTransition: props.viewTransition, + }); +} + +/** Calls `onVisible` once, the first time `element` enters the viewport. Returns a cleanup. */ +export function observeLinkVisible(element: Element, onVisible: () => void): () => void { + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + onVisible(); + observer.disconnect(); + } + }, + { rootMargin: '0px' }, + ); + observer.observe(element); + + return () => observer.disconnect(); +} diff --git a/packages/void/test/unit/pages-link.test.ts b/packages/void/test/unit/pages-link.test.ts new file mode 100644 index 000000000..c7fa046cf --- /dev/null +++ b/packages/void/test/unit/pages-link.test.ts @@ -0,0 +1,212 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import type { VoidRouter } from '../../src/pages/client.ts'; +import { + handleLinkClick, + linkPrefetchStrategies, + observeLinkVisible, + prefetchLink, + resolveLink, +} from '../../src/pages/link.ts'; + +function createRouter() { + return { + visit: vi.fn(() => Promise.resolve()), + prefetch: vi.fn(() => Promise.resolve()), + } as unknown as VoidRouter & { + visit: ReturnType; + prefetch: ReturnType; + }; +} + +function click( + overrides: Partial> = {}, + attributes: Record = {}, +) { + const preventDefault = vi.fn(); + const event = { + currentTarget: { + getAttribute: (name: string) => attributes[name] ?? null, + hasAttribute: (name: string) => name in attributes, + } as unknown as EventTarget, + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + button: 0, + defaultPrevented: false, + preventDefault, + ...overrides, + } as Parameters[0]; + return { event, preventDefault }; +} + +beforeEach(() => { + vi.stubGlobal('window', { + location: new URL('https://app.test/users?page=1'), + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('resolveLink', () => { + it('merges GET data into the destination', () => { + expect(resolveLink({ href: '/users', method: 'get', data: { page: 2 } })).toEqual({ + method: 'GET', + isGet: true, + href: '/users?page=2', + }); + }); + + it('keeps data out of the destination for other methods', () => { + expect(resolveLink({ href: '/users', method: 'post', data: { name: 'Ada' } })).toEqual({ + method: 'POST', + isGet: false, + href: '/users', + }); + }); + + it('rejects prefetch on non-GET links', () => { + expect(() => resolveLink({ href: '/users', method: 'POST', prefetch: 'mount' })).toThrow( + 'Link: prefetch only supports GET links', + ); + expect(() => resolveLink({ href: '/users', method: 'POST', prefetch: false })).not.toThrow(); + }); + + it('rejects reloadDocument on non-GET links', () => { + expect(() => resolveLink({ href: '/users', method: 'DELETE', reloadDocument: true })).toThrow( + 'Link: reloadDocument only supports GET links.', + ); + }); +}); + +describe('linkPrefetchStrategies', () => { + it.each([ + [undefined, []], + [false, []], + [true, ['hover']], + ['visible', ['visible']], + [['mount', 'click'] as const, ['mount', 'click']], + ] as const)('maps prefetch=%j to %j', (prefetch, expected) => { + expect(linkPrefetchStrategies({ prefetch })).toEqual(expected); + }); + + it('disables prefetch for document reloads', () => { + expect(linkPrefetchStrategies({ prefetch: 'mount', reloadDocument: true })).toEqual([]); + }); +}); + +describe('prefetchLink', () => { + it('prefetches other destinations with cacheFor only when given', () => { + const router = createRouter(); + prefetchLink(router, '/users/42', undefined); + prefetchLink(router, '/users/43', '30s'); + + expect(router.prefetch.mock.calls).toEqual([ + ['/users/42', {}], + ['/users/43', { cacheFor: '30s' }], + ]); + }); + + it('skips the current page and missing routers', () => { + const router = createRouter(); + prefetchLink(router, '/users?page=1', undefined); + prefetchLink(null, '/users/42', undefined); + + expect(router.prefetch).not.toHaveBeenCalled(); + }); +}); + +describe('handleLinkClick', () => { + it('visits same-origin destinations with the navigation options', () => { + const router = createRouter(); + const { event, preventDefault } = click(); + handleLinkClick(event, router, resolveLink({ href: '/users/42' }), { + data: { ignored: true }, + preserveScroll: true, + replace: true, + }); + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(router.visit).toHaveBeenCalledWith('/users/42', { + method: 'GET', + data: undefined, + preserveScroll: true, + preserveState: undefined, + replace: true, + viewTransition: undefined, + }); + }); + + it('sends data as the body for other methods', () => { + const router = createRouter(); + const { event } = click(); + const data = { name: 'Ada' }; + handleLinkClick(event, router, resolveLink({ href: '/users', method: 'post', data }), { + data, + }); + + expect(router.visit).toHaveBeenCalledWith( + '/users', + expect.objectContaining({ method: 'POST', data }), + ); + }); + + it.each([ + ['a modified click', click({ metaKey: true }).event, '/users/42', {}], + ['a new-tab target', click({}, { target: '_blank' }).event, '/users/42', {}], + ['a download', click({}, { download: '' }).event, '/users/42', {}], + ['a prevented click', click({ defaultPrevented: true }).event, '/users/42', {}], + ['a cross-origin destination', click().event, 'https://example.com/', {}], + ['a document reload', click().event, '/users/42', { reloadDocument: true }], + ] as const)('leaves %s to the browser', (_name, event, href, props) => { + const router = createRouter(); + handleLinkClick(event, router, resolveLink({ href }), props); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(router.visit).not.toHaveBeenCalled(); + }); + + it('cancels the visit when onNavigate prevents it', () => { + const router = createRouter(); + const { event, preventDefault } = click(); + handleLinkClick(event, router, resolveLink({ href: '/users/42' }), { + onNavigate: (navigation) => navigation.preventDefault(), + }); + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(router.visit).not.toHaveBeenCalled(); + }); +}); + +describe('observeLinkVisible', () => { + it('calls back once when the element becomes visible', () => { + let notify: (entries: Array<{ isIntersecting: boolean }>) => void = () => {}; + const disconnect = vi.fn(); + const observe = vi.fn(); + vi.stubGlobal( + 'IntersectionObserver', + class { + constructor(callback: typeof notify) { + notify = callback; + } + observe = observe; + disconnect = disconnect; + }, + ); + const element = {} as Element; + const onVisible = vi.fn(); + const stop = observeLinkVisible(element, onVisible); + + notify([{ isIntersecting: false }]); + expect(onVisible).not.toHaveBeenCalled(); + notify([{ isIntersecting: true }]); + + expect(observe).toHaveBeenCalledWith(element); + expect(onVisible).toHaveBeenCalledOnce(); + expect(disconnect).toHaveBeenCalledOnce(); + stop(); + expect(disconnect).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/vue/src/runtime/link.ts b/packages/vue/src/runtime/link.ts index f88d9fb76..0a04ce520 100644 --- a/packages/vue/src/runtime/link.ts +++ b/packages/vue/src/runtime/link.ts @@ -1,7 +1,12 @@ import { - isModifiedEvent, - mergeDataIntoHref, - type PrefetchOptions, + handleLinkClick, + linkPrefetchStrategies, + observeLinkVisible, + prefetchLink, + resolveLink, + type BaseLinkProps, + type LinkNavigateEvent, + type LinkPrefetchStrategy, type VoidRouter, } from 'void/pages-client'; import type { Href } from 'void/routes'; @@ -20,42 +25,29 @@ import { type PropType, } from 'vue'; -type PrefetchStrategy = 'hover' | 'click' | 'mount' | 'visible'; -type LinkMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; -type NavigateEvent = { preventDefault(): void }; - -type VoidLinkProps = { - href: Href; - method?: LinkMethod | Lowercase; - data?: Record; - preserveScroll?: boolean; - preserveState?: boolean; - replace?: boolean; - reloadDocument?: boolean; - viewTransition?: boolean; - onNavigate?: (event: NavigateEvent) => void; - prefetch?: boolean | PrefetchStrategy | ReadonlyArray; - cacheFor?: number | string | [string, string]; -}; +type LinkMethod = NonNullable; // Attributes and listeners fall through to the rendered or