@esmx/router-vue
Version:
Vue integration for @esmx/router - A universal router that works seamlessly with both Vue 2.7+ and Vue 3
215 lines (204 loc) • 7.57 kB
text/typescript
import type {
RouteLayerOptions,
RouteLocationInput,
RouteMatchType,
RouterLinkType
} from '@esmx/router';
import { type PropType, defineComponent, h } from 'vue';
import { useLink } from './use';
import { isVue3 } from './util';
/**
* RouterLink component for navigation.
* Renders an anchor tag with proper navigation behavior and active state management.
*
* @param props - Component properties
* @param props.to - Target route location to navigate to
* @param props.type - Navigation type ('push' | 'replace' | 'pushWindow' | 'replaceWindow' | 'pushLayer')
* @param props.replace - Use type='replace' instead
* @param props.exact - How to match the active state ('include' | 'exact' | 'route')
* @param props.activeClass - CSS class to apply when link is active
* @param props.event - Event(s) that trigger navigation
* @param props.tag - Custom tag to render instead of 'a'
* @param props.layerOptions - Layer options for layer-based navigation
* @param slots - Component slots
* @param slots.default - Default slot content
* @returns Vue component instance
*
* @example
* ```vue
* <template>
* <nav>
* <!-- Basic navigation -->
* <RouterLink to="/home">Home</RouterLink>
* <RouterLink to="/about">About</RouterLink>
*
* <!-- With custom styling -->
* <RouterLink
* to="/dashboard"
* active-class="nav-active"
* >
* Dashboard
* </RouterLink>
*
* <!-- Replace navigation -->
* <RouterLink to="/login" type="replace">Login</RouterLink>
*
* <!-- Custom tag and exact matching -->
* <RouterLink
* to="/contact"
* exact="exact"
* tag="button"
* class="btn"
* >
* Contact
* </RouterLink>
* </nav>
* </template>
* ```
*/
export const RouterLink = defineComponent({
name: 'RouterLink',
props: {
/**
* Target route location to navigate to.
* Can be a string path or route location object.
* @example '/home' | { path: '/user', query: { id: '123' } }
*/
to: {
type: [String, Object] as PropType<RouteLocationInput>,
required: true
},
/**
* Navigation type for the link.
* @default 'push'
* @example 'push' | 'replace' | 'pushWindow' | 'replaceWindow' | 'pushLayer'
*/
type: { type: String as PropType<RouterLinkType>, default: 'push' },
/**
* @deprecated Use 'type="replace"' instead
* @example :replace={true} → type="replace"
*/
replace: { type: Boolean, default: false },
/**
* How to match the active state.
* - 'include': Match if current route includes this path
* - 'exact': Match only if routes are exactly the same
* - 'route': Match based on route configuration
* @default 'include'
*/
exact: { type: String as PropType<RouteMatchType>, default: 'include' },
/**
* CSS class to apply when link is active (route matches).
* @example 'nav-active' | 'selected'
*/
activeClass: { type: String },
/**
* Event(s) that trigger navigation. Can be string or array of strings.
* @default 'click'
* @example 'click' | ['click', 'mouseenter']
*/
event: {
type: [String, Array] as PropType<string | string[]>,
default: 'click'
},
/**
* Custom tag to render instead of 'a'.
* @default 'a'
* @example 'button' | 'div' | 'span'
*/
tag: { type: String, default: 'a' },
/**
* Layer options for layer-based navigation.
* Only used when type='pushLayer'.
* @example { zIndex: 1000, autoPush: false, routerOptions: { mode: 'memory' } }
*/
layerOptions: { type: Object as PropType<RouteLayerOptions> },
/**
* Custom event handler to control navigation behavior.
* Should return `true` to allow router to navigate, otherwise to prevent it.
*
* @Note you need to call `e.preventDefault()` to prevent default browser navigation.
* @default
*
* (event: Event & Partial<MouseEvent>): boolean => {
* // don't redirect with control keys
* if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return false;
* // don't redirect when preventDefault called
* if (e.defaultPrevented) return false;
* // don't redirect on right click
* if (e.button !== undefined && e.button !== 0) return false;
* // don't redirect if `target="_blank"`
* const target = e.currentTarget?.getAttribute?.('target') ?? '';
* if (/\b_blank\b/i.test(target)) return false;
* // Prevent default browser navigation to enable SPA routing
* // Note: this may be a Weex event which doesn't have this method
* if (e.preventDefault) e.preventDefault();
*
* return true;
* }
*/
eventHandler: {
type: Function as PropType<
(event: Event) => boolean | undefined | void
>
}
},
setup(props, context) {
const { slots, attrs } = context;
const link = useLink(props);
const wrapHandler = (
externalHandler: Function,
internalHandler: Function | undefined
) =>
!internalHandler
? (externalHandler as (e: Event) => Promise<void>)
: async (e: Event) => {
try {
await externalHandler(e);
} finally {
await internalHandler(e);
}
};
const vue3renderer = () => {
const data = link.value;
const genEventName = (name: string): string =>
`on${name.charAt(0).toUpperCase()}${name.slice(1)}`;
const eventHandlers = data.getEventHandlers(genEventName);
Object.entries(attrs).forEach(([key, listener]) => {
// In Vue 3, external event handlers are in attrs with 'on' prefix
if (!key.startsWith('on') || typeof listener !== 'function')
return;
eventHandlers[key] = wrapHandler(listener, eventHandlers[key]);
});
return h(
data.tag,
{
...data.attributes,
...eventHandlers
},
slots.default?.()
);
};
const vue2renderer = () => {
const data = link.value;
const eventHandlers = data.getEventHandlers();
// Vue 2: get external listeners from context
const $listeners = (context as any).listeners || {};
Object.entries($listeners).forEach(([key, listener]) => {
if (typeof listener !== 'function') return;
eventHandlers[key] = wrapHandler(listener, eventHandlers[key]);
});
const { class: className, ...attrs } = data.attributes;
return h(
data.tag,
{
attrs,
class: className,
on: eventHandlers
},
slots.default?.()
);
};
return isVue3 ? vue3renderer : vue2renderer;
}
});