/** * Mandu Link Component ๐Ÿ”— * Client-side ๋„ค๋น„๊ฒŒ์ด์…˜์„ ์œ„ํ•œ Link ์ปดํฌ๋„ŒํŠธ */ import React, { type AnchorHTMLAttributes, type MouseEvent, type ReactNode, useCallback, useEffect, useRef, } from "react"; import { navigate, prefetch } from "./router"; import { autoStableManduId } from "../runtime/stable-selector"; export interface LinkProps extends Omit, "href"> { /** ์ด๋™ํ•  URL */ href: string; /** Stable selector id (optional). If omitted, core injects best-effort id. */ manduId?: string; /** history.replaceState ์‚ฌ์šฉ ์—ฌ๋ถ€ */ replace?: boolean; /** ๋งˆ์šฐ์Šค hover ์‹œ prefetch ์—ฌ๋ถ€ */ prefetch?: boolean; /** ์Šคํฌ๋กค ์œ„์น˜ ๋ณต์› ์—ฌ๋ถ€ (๊ธฐ๋ณธ: true) */ scroll?: boolean; /** ์ž์‹ ์š”์†Œ */ children?: ReactNode; } /** * Client-side ๋„ค๋น„๊ฒŒ์ด์…˜ Link ์ปดํฌ๋„ŒํŠธ * * @example * ```tsx * import { Link } from "@mandujs/core/client"; * * // ๊ธฐ๋ณธ ์‚ฌ์šฉ * About * * // Prefetch ํ™œ์„ฑํ™” * Users * * // Replace ๋ชจ๋“œ (๋’ค๋กœ๊ฐ€๊ธฐ ํžˆ์Šคํ† ๋ฆฌ ์—†์Œ) * Login * ``` */ export function Link({ href, manduId, replace = false, prefetch: shouldPrefetch = false, scroll = true, children, onClick, onMouseEnter, onFocus, ...rest }: LinkProps): React.ReactElement { const prefetchedRef = useRef(false); // ํด๋ฆญ ํ•ธ๋“ค๋Ÿฌ const handleClick = useCallback( (event: MouseEvent) => { // ์‚ฌ์šฉ์ž ์ •์˜ onClick ๋จผ์ € ์‹คํ–‰ onClick?.(event); // ๊ธฐ๋ณธ ๋™์ž‘ ๋ฐฉ์ง€ ์กฐ๊ฑด if ( event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey ) { return; } // ์™ธ๋ถ€ ๋งํฌ ์ฒดํฌ try { const url = new URL(href, window.location.origin); if (url.origin !== window.location.origin) { return; // ์™ธ๋ถ€ ๋งํฌ๋Š” ๊ธฐ๋ณธ ๋™์ž‘ } } catch { return; } // Client-side ๋„ค๋น„๊ฒŒ์ด์…˜ event.preventDefault(); void navigate(href, { replace, scroll }); }, [href, replace, scroll, onClick] ); // Prefetch ์‹คํ–‰ const doPrefetch = useCallback(() => { if (!shouldPrefetch || prefetchedRef.current) return; try { const url = new URL(href, window.location.origin); if (url.origin === window.location.origin) { void prefetch(href); prefetchedRef.current = true; } } catch { // ๋ฌด์‹œ } }, [href, shouldPrefetch]); // ๋งˆ์šฐ์Šค hover ํ•ธ๋“ค๋Ÿฌ const handleMouseEnter = useCallback( (event: MouseEvent) => { onMouseEnter?.(event); doPrefetch(); }, [onMouseEnter, doPrefetch] ); // ํฌ์ปค์Šค ํ•ธ๋“ค๋Ÿฌ (ํ‚ค๋ณด๋“œ ๋„ค๋น„๊ฒŒ์ด์…˜) const handleFocus = useCallback( (event: React.FocusEvent) => { onFocus?.(event); doPrefetch(); }, [onFocus, doPrefetch] ); // Viewport ์ง„์ž… ์‹œ prefetch (IntersectionObserver) useEffect(() => { if (!shouldPrefetch || typeof IntersectionObserver === "undefined") { return; } // ref๊ฐ€ ์—†์œผ๋ฉด ๋ฌด์‹œ (SSR) return; }, [shouldPrefetch]); const stableId = manduId ?? autoStableManduId("Link"); return ( {children} ); } /** * NavLink - ํ˜„์žฌ ๊ฒฝ๋กœ์™€ ์ผ์น˜ํ•  ๋•Œ ํ™œ์„ฑ ์Šคํƒ€์ผ ์ ์šฉ * * @example * ```tsx * import { NavLink } from "@mandujs/core/client"; * * isActive ? "active" : ""} * > * About * * ``` */ export interface NavLinkProps extends Omit { /** ํ™œ์„ฑ ์ƒํƒœ์— ๋”ฐ๋ฅธ className */ className?: string | ((props: { isActive: boolean }) => string); /** ํ™œ์„ฑ ์ƒํƒœ์— ๋”ฐ๋ฅธ style */ style?: | React.CSSProperties | ((props: { isActive: boolean }) => React.CSSProperties); /** ํ™œ์„ฑ ์ƒํƒœ์ผ ๋•Œ ์ ์šฉํ•  style (style๊ณผ ๋ณ‘ํ•ฉ๋จ) */ activeStyle?: React.CSSProperties; /** ํ™œ์„ฑ ์ƒํƒœ์ผ ๋•Œ ์ถ”๊ฐ€ํ•  className */ activeClassName?: string; /** ์ •ํ™•ํžˆ ์ผ์น˜ํ•ด์•ผ ํ™œ์„ฑํ™” (๊ธฐ๋ณธ: false) */ exact?: boolean; } export function NavLink({ href, className, style, activeStyle, activeClassName, exact = false, ...rest }: NavLinkProps): React.ReactElement { // ํ˜„์žฌ ๊ฒฝ๋กœ์™€ ๋น„๊ต const isActive = typeof window !== "undefined" ? exact ? window.location.pathname === href : window.location.pathname.startsWith(href) : false; // className ์ฒ˜๋ฆฌ let resolvedClassName = typeof className === "function" ? className({ isActive }) : className; if (isActive && activeClassName) { resolvedClassName = resolvedClassName ? `${resolvedClassName} ${activeClassName}` : activeClassName; } // style ์ฒ˜๋ฆฌ let resolvedStyle = typeof style === "function" ? style({ isActive }) : style; if (isActive && activeStyle) { resolvedStyle = { ...resolvedStyle, ...activeStyle }; } return ( ); } export default Link;