/** @jsxImportSource react */ // MemoryRouter/Routes/Route/Navigate/Outlet are ported from react-router's // declarative components (lib/components.tsx, MIT). Link is Sigil-native. import { Children, Fragment, isValidElement, startTransition, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactElement, type ReactNode, } from "react"; import { Text, type Props as TextProps } from "#/components/Text.tsx"; import { useFocus } from "#/hooks/use-focus.ts"; import { useInput } from "#/hooks/use-input.ts"; import { LocationContext, NavigationContext, type Navigator } from "#/router/context.ts"; import { createMemoryHistory, type InitialEntry, type Location, type To, } from "#/router/history.ts"; import { useLocation, useNavigate, useOutlet, useResolvedPath, useRoutes, } from "#/router/hooks.tsx"; import { normalizePathname, type RouteObject } from "#/router/matcher.ts"; export type MemoryRouterProps = { /** The navigation stack to start with. Defaults to `["/"]`. */ initialEntries?: InitialEntry[]; /** The index of the initial entry to render. Defaults to the last entry. */ initialIndex?: number; children?: ReactNode; }; /** The routing container for a Sigil app. Stores the navigation stack in memory — routes aren't URLs, they're screen states. ```tsx } /> } /> ``` */ export function MemoryRouter({ initialEntries, initialIndex, children }: MemoryRouterProps) { const historyRef = useRef | null>(null); historyRef.current ??= createMemoryHistory({ initialEntries, initialIndex }); const history = historyRef.current; const [state, setState] = useState({ action: history.action, location: history.location, }); // Navigation is a transition: if the destination screen suspends, the // current screen stays visible until it's ready. useLayoutEffect( () => history.listen(({ action, location }) => { startTransition(() => { setState({ action, location }); }); }), [history], ); const navigator = useMemo( () => ({ push: (to, historyState) => history.push(to, historyState), replace: (to, historyState) => history.replace(to, historyState), go: (delta) => history.go(delta), canGoBack: () => history.canGoBack, canGoForward: () => history.canGoForward, }), [history], ); const navigationContext = useMemo(() => ({ navigator }), [navigator]); const locationContext = useMemo( () => ({ location: state.location, navigationType: state.action }), [state], ); return ( {children} ); } export type RouteProps = { /** The path pattern to match, relative to the parent route. Supports `:param` dynamic segments, optional segments (`:param?`, `edit?`), and a trailing `*` splat. */ path?: string; /** Render this route in the parent's `` at the parent's exact path. Index routes cannot have children. */ index?: boolean; /** The element to render when this route matches. */ element?: ReactNode; /** Nested `` elements, rendered into this route's ``. */ children?: ReactNode; }; /** Declares a route. Only valid as a child of `` or another ``. */ export function Route(_props: RouteProps): ReactElement | null { throw new Error( "A is only ever to be used as the child of a element, " + "never rendered directly. Please wrap your in a .", ); } function createRoutesFromChildren(children: ReactNode): RouteObject[] { const routes: RouteObject[] = []; Children.forEach(children, (element) => { if (!isValidElement(element)) { // Ignore non-elements. This allows people to more easily inline // conditionals in their route config. return; } if (element.type === Fragment) { // Transparently support React.Fragment and its children. routes.push( ...createRoutesFromChildren((element.props as { children?: ReactNode }).children), ); return; } if (element.type !== Route) { throw new Error( `[${typeof element.type === "string" ? element.type : ((element.type as { name?: string }).name ?? "unknown")}] ` + `is not a component. All component children of must be a or .`, ); } const props = element.props as RouteProps; const route: RouteObject = { path: props.path, index: props.index, element: props.element, }; if (props.children) { route.children = createRoutesFromChildren(props.children); } routes.push(route); }); return routes; } export type RoutesProps = { children?: ReactNode; /** Match against this location instead of the current one. Useful for rendering a screen other than the one navigated to (e.g. transitions). */ location?: Partial | string; }; /** Renders the branch of child `` elements that best matches the current location. */ export function Routes({ children, location }: RoutesProps): ReactElement | null { return useRoutes(createRoutesFromChildren(children), location); } export type OutletProps = { /** A value to make available to descendant routes via `useOutletContext()`. */ context?: unknown; }; /** Renders the matching child route of a parent route, or nothing if no child matches. */ export function Outlet(props: OutletProps): ReactElement | null { return useOutlet(props.context); } export type NavigateProps = { to: To; replace?: boolean; state?: unknown; }; /** Navigates as soon as it renders. The component form of `useNavigate`, for declarative redirects: ```tsx } /> ``` */ export function Navigate({ to, replace, state }: NavigateProps): null { const navigate = useNavigate(); const { pathname, search } = useResolvedPath(to); useEffect(() => { navigate({ pathname, search }, { replace, state }); }, [navigate, pathname, search, replace, state]); return null; } type LinkRenderState = { /** Whether this link currently has focus. */ isFocused: boolean; /** Whether the current location is the link's destination or a descendant of it. */ isActive: boolean; }; export type LinkProps = Omit & { /** The destination to navigate to when the link is activated. */ to: To; /** Replace the current entry in the navigation stack instead of pushing. */ replace?: boolean; /** State to attach to the destination location. */ state?: unknown; /** Focus this link if nothing else is focused yet. */ autoFocus?: boolean; /** An ID for programmatic focus via `useFocusManager().focus(id)`. */ id?: string; /** Link content. Pass a function to take full control of rendering based on focus and active state. */ children?: ReactNode | ((state: LinkRenderState) => ReactNode); }; /** A focusable navigation element — the terminal's `` tag. Focus it with Tab and activate it with Enter. By default the focused link renders inverse; pass a function as `children` (or any `Text` props) to customize. ```tsx Settings ``` */ export function Link({ to, replace = false, state, autoFocus = false, id, children, ...textProps }: LinkProps) { const navigate = useNavigate(); const path = useResolvedPath(to); const { pathname: locationPathname } = useLocation(); const { isFocused } = useFocus({ autoFocus, id }); const toPathname = normalizePathname(path.pathname); const isActive = locationPathname === toPathname || (locationPathname.startsWith(toPathname) && locationPathname.charAt(toPathname.length) === "/"); const activate = useCallback(() => { navigate({ pathname: path.pathname, search: path.search }, { replace, state }); }, [navigate, path.pathname, path.search, replace, state]); useInput( (_input, key) => { if (key.return) { activate(); } }, { isActive: isFocused }, ); if (typeof children === "function") { return <>{children({ isFocused, isActive })}; } return ( {children} ); }