Create infinite virtual subpaths for your React webapp
npm i @hazae41/chemin
- 100% TypeScript and ESM
- No external dependencies
- Compatible with your framework
- Uses only web standards
- Infinite virtual subpaths
- Element coordinates to URL
- Search params to React state
This library allows you to create infinite virtual hash-based and search-based subpaths
e.g. https://example.com/chat/#/settings/user?left=/tree&right=/page
All the paths in this URL are easily routed and modified with React components
https://example.com/chat ┐
└ # = /settings/user ┐
├ left = /tree
└ right = /page
This allows creating dialogs, subdialogs, things on left and right, and many more
You can use HashPathProvider
to provide a hash-based path for your app
import { HashPathProvider } from "@hazae41/chemin"
export function App() {
const [client, setClient] = useState(false)
useEffect(() => {
setClient(true)
}, [])
if (!client)
return null
return <HashPathProvider>
<Router />
</HashPathProvider>
}
e.g. https://example.com/app/#/this/is/the/pathname
console.log(usePathContext().unwrap().url.pathname)
This will display /this/is/the/pathname
You can also provide root-based path for your app
import { useRouter } from "next/router"
export function NextPathProvider(props: { children?: ReactNode }) {
const router = useRouter()
const { children } = props
const [raw, setRaw] = useState<string>()
useEffect(() => {
const onRouteChangeComplete = () => setRaw(location.href)
router.events.on("routeChangeComplete", onRouteChangeComplete)
return () => router.events.off("routeChangeComplete", onRouteChangeComplete)
}, [])
useEffect(() => {
const onHashChangeComplete = () => setRaw(location.href)
router.events.on("hashChangeComplete", onHashChangeComplete)
return () => router.events.off("hashChangeComplete", onHashChangeComplete)
}, [])
const get = useCallback(() => {
return new URL(location.href)
}, [raw])
const url = useMemo(() => {
return get()
}, [get])
const go = useCallback((hrefOrUrl: string | URL) => {
return new URL(hrefOrUrl, location.href)
}, [])
const handle = useMemo(() => {
return { url, get, go } satisfies PathHandle
}, [url, get, go])
return <PathContext.Provider value={handle}>
{children}
</PathContext.Provider>
}
export function App() {
const [client, setClient] = useState(false)
useEffect(() => {
setClient(true)
}, [])
if (!client)
return null
return <NextPathProvider>
<Router />
</NextPathProvider>
}
This uses the modern Navigation API that only works on some browsers for now
import { RootPathProvider } from "@hazae41/chemin"
export function App() {
const [client, setClient] = useState(false)
useEffect(() => {
setClient(true)
}, [])
if (!client)
return null
return <RootPathProvider>
<Router />
</RootPathProvider>
}
e.g. https://example.com/this/is/the/pathname
console.log(usePathContext().unwrap().url.pathname)
This will display /this/is/the/pathname
You may need to disable client-side navigation from your framework
declare const navigation: Nullable<any>
export default function App({ Component, pageProps, router }: AppProps) {
useEffect(() => {
/**
* Disable Next.js client-side navigation
*/
removeEventListener("popstate", router.onPopState)
}, [router])
useEffect(() => {
/**
* Enable modern client-side navigation
*/
navigation?.addEventListener("navigate", (event: any) => event.intercept())
}, [])
return <Component {...pageProps} />
}
And rewrite all URLs to a common one
rewrites() {
return [
{
source: "/:path*",
destination: "/",
},
]
}
You can route things with usePathContext()
import { usePathContext } from "@hazae41/chemin"
function Router() {
const path = usePathContext().unwrap()
if (path.url.pathname === "/home")
return <HomePage />
return <NotFoundPage />
}
You can route things with pattern-matching via regexes
import { usePathContext } from "@hazae41/chemin"
function Router() {
const path = usePathContext().unwrap()
let matches: RegExpMatchArray | null
if ((matches = path.url.pathname.match(/^\/$/)))
return <LandingPage />
if (matches = path.url.pathname.match(/^\/home(\/)?$/))
return <HomePage />
if (matches = path.url.pathname.match(/^\/home\/settings(\/)?$/))
return <HomeSettingsPage />
if (matches = path.url.pathname.match(/^\/user\/([^\/]+)(\/)?$/))
return <UserPage uuid={matches[1]} />
return <NotFoundPage />
}
You can also route things inside a component
import { usePathContext } from "@hazae41/chemin"
function FunPage() {
const path = usePathContext().unwrap()
return <>
{path.url.pathname === "/help" &&
<HelpDialog />}
{path.url.pathname === "/send" &&
<SendDialog />}
<div>
Have fun!
</div>
</>
}
You can use anchors and buttons to declaratively and imperatively navigate
import { usePathContext } from "@hazae41/chemin"
function LandingPage() {
const path = usePathContext().unwrap()
const onHelpClick = useCallback(() => {
location.replace(path.go("/help"))
}, [path])
return <>
<div>
Welcome!
</div>
<a href={path.go("/home").href}>
Home
</a>
<button onClick={onHelpClick}>
Help
</button>
</>
}
You can create hash-based subroutes
e.g. https://example.com/home/#/secret
import { usePathContext, useHashSubpath, HashSubpathProvider } from "@hazae41/chemin"
function HomePageSubrouter() {
const path = usePathContext().unwrap()
if (path.url.pathname === "/secret")
return <SecretDialog />
return null
}
function HomePage() {
const path = usePathContext().unwrap()
const hash = useHashSubpath(path)
const onSecretButtonClick = useCallback(() => {
location.replace(hash.go("/secret"))
}, [hash])
return <>
<HashSubpathProvider>
<HomePageSubrouter />
</HashSubpathProvider>
<div>
Hello world!
</div>
<a href={hash.go("/secret").href}>
Secret anchor
</a>
<button onClick={onSecretButtonClick}>
Secret button
</button>
</>
}
You can create search-based subroutes
e.g. https://example.com/home?left=/football&right=/baseball
import { usePathContext, useSearchSubpath, SearchSubpathProvider } from "@hazae41/chemin"
function PanelRouter() {
const path = usePathContext().unwrap()
if (path.url.pathname === "/football")
return <FootballPanel />
if (path.url.pathname === "/baseball")
return <BaseballPanel />
return <EmptyPanel />
}
function HomePage() {
const path = usePathContext().unwrap()
const left = useSearchSubpath(path, "left")
const right = useSearchSubpath(path, "right")
return <>
<div>
Hello world!
</div>
<a href={left.go("/football").href}>
Show football on left
</a>
<a href={right.go("/baseball").href}>
Show baseball on right
</a>
<div className="flex">
<SearchSubpathProvider key="left">
<PanelRouter />
</SearchSubpathProvider>
<SearchSubpathProvider key="right">
<PanelRouter />
</SearchSubpathProvider>
</div>
</>
}
You can also create search-based non-path values
import { usePathContext, useSearchState } from "@hazae41/chemin"
function Page() {
const path = usePathContext().unwrap()
const user = useSearchValue(path, "user")
if (user.value === "root")
return <>Hello root!</>
return <a href={user.set("root").href}>
Login as root
</a>
}
You can even create search-based non-path React state
import { usePathContext, useSearchState } from "@hazae41/chemin"
function Page() {
const path = usePathContext().unwrap()
const [counter, setCounter] = useSearchState(path, "counter")
const onClick = useCallback(() => {
setCounter(previous => String(Number(previous) + 1))
}, [])
return <button onClick={onClick}>
Add
</button>
}
You can use useCoords(path, url)
with an HTML element to pass the element's X-Y coordinates to the URL
function Page() {
const path = usePathContext().unwrap()
const hash = useHashSubpath(path)
const settings = useCoords(hash, "/settings")
return <>
<HashSubpathProvider>
{hash.url.pathname === "/settings" &&
<Dialog>
Settings
</Dialog>}
</HashSubpathProvider>
<a className="anchor"
href={settings.href}
onClick={settings.onClick}
onKeyDown={settings.onKeyDown}>
Open settings
</a>
</>
}
Then you can consume those coordinates to add fancy animations and positioning
function Dialog(props: ChildrenProps) {
const { children } = props
const path = usePathContext().unwrap()
const x = path.url.searchParams.get("x") || 0
const y = path.url.searchParams.get("x") || 0
return <div style={{ "--x": `${x}px`, "--y": `${y}px` }}>
<div className="dialog">
{children}
</div>
</div>
}
Hint: x
is element.clientX
and y
is element.clientY
You can also navigate on right-click using onContextMenu
function Page() {
const path = usePathContext().unwrap()
const hash = useHashSubpath(path)
const menu = useCoords(hash, "/menu")
return <>
<HashSubpathProvider>
{hash.url.pathname === "/menu" &&
<Menu>
<button>Share</button>
</Menu>}
</HashSubpathProvider>
<div className="card"
onContextMenu={menu.onContextMenu}>
Right-click me
</div>
</>
}
All providers of PathContext
are also providers of CloseContext
You can use the provided CloseContext
to go back to the root of the current path
e.g. https://example.com/home/#/aaa/bbb/ccc
-> https://example.com/home
e.g. https://example.com/home/#/aaa/#/bbb/#/ccc
-> https://example.com/home/#/aaa/#/bbb
You can consume CloseContext
in any component with a close()
feature
import { useCloseContext } from "@hazae41/chemin"
function Dialog(props: ChildrenProps) {
const { children } = props
const close = useCloseContext().unwrap()
const onClose = useCallback(() => {
close()
}, [close])
return <div>
<button onClick={onClose}>
Close
</button>
<div>
{children}
</div>
</div>
}