react-navplus
Version:
A flexible, performance-optimized navigation link component for React with multi-router support, prefetching, and advanced active state detection
170 lines • 6.8 kB
JavaScript
import { jsx as _jsx } from "react/jsx-runtime";
/**
* @file NavPlus.tsx
* @description A clean, flexible navigation link component
* @version 2.1.0
*/
import React, { useMemo, useCallback, useRef } from 'react';
import { Link } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
// Utility functions
const matchers = {
exact: (pathname, url) => pathname === url,
startsWith: (pathname, url) => pathname.startsWith(url),
includes: (pathname, url) => pathname.includes(url),
pattern: (pathname, _url, pattern) => pattern ? pattern.test(pathname) : false
};
/**
* Determine if a link is active based on the current pathname, URL, and match mode.
* @param {string} pathname - Current pathname
* @param {string} url - URL to match against
* @param {MatchMode} [matchMode='includes'] - How to match the URL
* @param {RegExp} [matchPattern] - Optional regex pattern for matching
* @returns {boolean} - Whether the link is active
*/
const isActive = (pathname, url, matchMode = 'includes', matchPattern) => {
const matchFn = matchers[matchMode] || matchers.includes;
return matchFn(pathname, url, matchPattern);
};
const buildClassName = (baseClassName, isActive, activeClassName, inActiveClassName) => {
const classes = [
baseClassName,
isActive ? activeClassName : inActiveClassName,
'navplus-link'
].filter(Boolean);
return classes.join(' ').trim();
};
// Simple prefetch implementation
const prefetchCache = new Set();
const simplePrefetch = (url) => {
if (prefetchCache.has(url) || typeof window === 'undefined')
return;
try {
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = url;
document.head.appendChild(link);
prefetchCache.add(url);
}
catch (error) {
console.warn('Prefetch failed:', error);
}
};
/**
* NavPlus Component - A flexible navigation link component
*/
export const NavPlus = React.memo(({ to, children, className = '', activeClassName = 'active', inActiveClassName = '', disabled = false, isExternal = false, matchMode = 'includes', matchPattern, customActiveUrl, activeStyle, inactiveStyle, prefetch = false, replace = false, triggerEvent = 'click', navigationDelay, onClick, onMouseEnter, onMouseLeave, as: Component, testId, linkProps = {}, ...restProps }) => {
const location = useLocation();
const navigate = useNavigate();
const timeoutRef = useRef();
// Early validation
if (!to) {
if (process.env.NODE_ENV !== 'production') {
console.warn('NavPlus: "to" prop is required');
}
return null;
}
// Determine if link is active
const linkIsActive = useMemo(() => {
if (!(location === null || location === void 0 ? void 0 : location.pathname))
return false;
const urlToMatch = customActiveUrl || to;
return isActive(location.pathname, urlToMatch, matchMode, matchPattern);
}, [location === null || location === void 0 ? void 0 : location.pathname, customActiveUrl, to, matchMode, matchPattern]);
// Handle navigation with optional delay
const handleNavigation = useCallback((e) => {
if (disabled || isExternal)
return;
e.preventDefault();
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
const navigateToUrl = () => navigate(to, { replace });
if (navigationDelay && navigationDelay > 0) {
timeoutRef.current = setTimeout(navigateToUrl, navigationDelay);
}
else {
navigateToUrl();
}
}, [disabled, isExternal, navigate, to, replace, navigationDelay]);
// Event handlers
const handleClick = useCallback((e) => {
if (disabled) {
e.preventDefault();
return;
}
onClick === null || onClick === void 0 ? void 0 : onClick(e);
if (triggerEvent === 'click' && !e.defaultPrevented) {
handleNavigation(e);
}
}, [disabled, onClick, triggerEvent, handleNavigation]);
const handleMouseEnterEvent = useCallback((e) => {
// Handle prefetch
if (prefetch && !isExternal && !disabled) {
simplePrefetch(to);
}
// Handle hover navigation
if (triggerEvent === 'hover') {
handleNavigation(e);
}
onMouseEnter === null || onMouseEnter === void 0 ? void 0 : onMouseEnter(e);
}, [prefetch, isExternal, disabled, to, triggerEvent, handleNavigation, onMouseEnter]);
const handleMouseLeaveEvent = useCallback((e) => {
onMouseLeave === null || onMouseLeave === void 0 ? void 0 : onMouseLeave(e);
}, [onMouseLeave]);
// Computed props
const computedClassName = useMemo(() => buildClassName(className, linkIsActive, activeClassName, inActiveClassName), [className, linkIsActive, activeClassName, inActiveClassName]);
const computedStyle = useMemo(() => linkIsActive ? activeStyle : inactiveStyle, [linkIsActive, activeStyle, inactiveStyle]);
const commonProps = useMemo(() => ({
className: computedClassName,
style: computedStyle,
onClick: handleClick,
onMouseEnter: handleMouseEnterEvent,
onMouseLeave: handleMouseLeaveEvent,
'data-testid': testId,
'data-active': linkIsActive,
'aria-current': linkIsActive ? 'page' : undefined,
'aria-disabled': disabled,
...restProps
}), [
computedClassName,
computedStyle,
handleClick,
handleMouseEnterEvent,
handleMouseLeaveEvent,
testId,
linkIsActive,
disabled,
restProps
]);
// Render children
const renderChildren = useMemo(() => {
if (typeof children === 'function') {
return children(linkIsActive);
}
return children;
}, [children, linkIsActive]);
// Cleanup timeout on unmount
React.useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
// Render based on conditions
if (Component) {
return (_jsx(Component, { ...commonProps, href: to, children: renderChildren }));
}
if (disabled) {
return _jsx("span", { ...commonProps, children: renderChildren });
}
if (isExternal) {
return (_jsx("a", { ...commonProps, href: to, target: "_blank", rel: "noopener noreferrer", children: renderChildren }));
}
return (_jsx(Link, { to: to, replace: replace, ...commonProps, ...linkProps, children: renderChildren }));
});
NavPlus.displayName = 'NavPlus';
// Export everything
export default NavPlus;
//# sourceMappingURL=NavPlus.js.map