136 lines
4.4 KiB
TypeScript
136 lines
4.4 KiB
TypeScript
// Copyright (c) 2024 The Brave Authors. All rights reserved.
|
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
|
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
|
import * as React from "react"
|
|
import { useEffect, useMemo, useState } from "react"
|
|
|
|
// This file provides a helper for extract route params from a path and
|
|
// notifying consumers when those path params change.
|
|
|
|
// Extracts routeParams from a string:
|
|
// /foo/{bar}/baz ==> { bar: string }
|
|
// /foo/{bar}/{baz} ==> { bar: string }
|
|
// /foo/{bar}/{baz} ==> { bar: string, baz: string }
|
|
type RouteParamsAssumeTrailing<T extends string> = T extends `${infer Segment}/${infer Rest}`
|
|
// Check if the current segment is a param
|
|
? (Segment extends `{${infer ParamName}}`
|
|
? {
|
|
[P in ParamName]: string
|
|
}
|
|
: {}) & RouteParamsAssumeTrailing<Rest>
|
|
// Basecase is no params
|
|
: {}
|
|
|
|
// Ensures there's a trailing slash in the tokens to be consumed
|
|
type RouteParams<T extends string> = T extends `${string}/`
|
|
? RouteParamsAssumeTrailing<T>
|
|
: RouteParamsAssumeTrailing<`${T}/`>
|
|
|
|
// Parses a param name from a path segment (or null, if the segment is not a
|
|
// param)
|
|
// {foo} ==> foo
|
|
// bar ==> null
|
|
const getParamName = (part: string) => {
|
|
if (part.startsWith("{") && part.endsWith("}")) {
|
|
return part.substring(1, part.length - 1)
|
|
}
|
|
return null
|
|
}
|
|
|
|
// Finds a matching route for a given URL - if found returns a tuple of
|
|
// 1. The route (for looking up in the routes map)
|
|
// 2. The list of callbacks for the given route
|
|
// 3. The parsed parameters for the route.
|
|
const extractParams = (currentUrl: string, route: string) => {
|
|
const path = new URL(currentUrl).pathname
|
|
const pathParts = path.split('/')
|
|
const routeParts = route.split('/')
|
|
|
|
if (routeParts.length !== pathParts.length) return null
|
|
|
|
const params: { [key: string]: string } = {}
|
|
for (let i = 0; i < pathParts.length; ++i) {
|
|
const routePart = routeParts[i]
|
|
const pathPart = pathParts[i]
|
|
|
|
const paramName = getParamName(routePart)
|
|
// If its a param, store the value
|
|
if (paramName) {
|
|
params[paramName] = pathPart
|
|
} else if (routePart !== pathPart) {
|
|
// The path doesn't match, so bail
|
|
return null
|
|
}
|
|
}
|
|
|
|
return params
|
|
}
|
|
|
|
// Note: Unfortunately we can't use the new Navigation API because it isn't
|
|
// supported in Safari yet (and the AI Chat WebUI runs on iOS).
|
|
(['pushState', 'replaceState'] as const).forEach(method => {
|
|
const old = window.history[method]
|
|
window.history[method] = (state, title, url) => {
|
|
old.call(window.history, state, title, url)
|
|
window.dispatchEvent(new CustomEvent('brave-history-changed', {
|
|
detail: {
|
|
state,
|
|
title,
|
|
url
|
|
}
|
|
}))
|
|
}
|
|
})
|
|
|
|
// Helper hook for getting the current location and changing when history changes.
|
|
export const useLocation = () => {
|
|
const [location, setLocation] = useState(window.location.href)
|
|
|
|
useEffect(() => {
|
|
const handler = (e: CustomEvent) => {
|
|
setLocation(window.location.href)
|
|
}
|
|
window.addEventListener('brave-history-changed', handler)
|
|
window.addEventListener('popstate', handler)
|
|
return () => {
|
|
window.removeEventListener('brave-history-changed', handler)
|
|
window.removeEventListener('popstate', handler)
|
|
}
|
|
}, [])
|
|
|
|
return location
|
|
}
|
|
|
|
// Extracts and returns matching route params from a URL, or null if the pattern
|
|
// doesn't match.
|
|
// Additionally, when the route matches navigations will be intercepted,
|
|
// preventing a page reload.
|
|
export const useRoute = <Route extends string>(route: Route): RouteParams<Route> | null => {
|
|
const location = useLocation()
|
|
const params = useMemo(() => extractParams(location, route), [location, route])
|
|
return params as RouteParams<Route> | null
|
|
}
|
|
|
|
// This component is useful if you want a Link which won't cause a page reload.
|
|
export const Link = (props: React.DetailedHTMLProps<React.AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>) => {
|
|
const { href, onClick, ...rest } = props
|
|
return <a {...rest} onClick={(e) => {
|
|
onClick?.(e);
|
|
|
|
// Ensure the event hasn't been handled by the user.
|
|
if (e.defaultPrevented || e.isPropagationStopped()) {
|
|
return
|
|
}
|
|
|
|
// Don't intercept clicks which would open in a different tab/window.
|
|
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) {
|
|
return
|
|
}
|
|
|
|
e.preventDefault()
|
|
window.history.pushState(null, '', href)
|
|
}} />
|
|
}
|