Skip to content

Commit

Permalink
Refactor search params support
Browse files Browse the repository at this point in the history
  • Loading branch information
ai committed Feb 10, 2024
1 parent d3dd143 commit 4e0aa93
Show file tree
Hide file tree
Showing 9 changed files with 192 additions and 505 deletions.
62 changes: 27 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
A tiny URL router for [Nano Stores](https://github.com/nanostores/nanostores)
state manager.

* **Small.** 673 bytes (minified and brotlied). Zero dependencies.
* **Small.** 685 bytes (minified and brotlied). Zero dependencies.
* Good **TypeScript** support.
* Framework agnostic. Can be used with **React**, **Preact**, **Vue**,
**Svelte**, **Angular**, **Solid.js**, and vanilla JS.
Expand All @@ -20,8 +20,8 @@ import { createRouter } from '@nanostores/router'

export const $router = createRouter({
home: '/',
category: '/posts/:categoryId',
post: '/posts/:categoryId/:postId'
list: '/posts/:category',
post: '/posts/:category/:post'
})
```

Expand All @@ -41,10 +41,10 @@ export const Layout = () => {
return <Error404 />
} else if (page.route === 'home') {
return <HomePage />
} else if (page.route === 'category') {
return <CategoryPage categoryId={page.params.categoryId} />
} else if (page.route === 'list') {
return <ListPage category={page.params.category} filters={page.search} />
} else if (page.route === 'post') {
return <PostPage postId={page.params.postId} />
return <PostPage post={page.params.post} />
}
}
```
Expand Down Expand Up @@ -112,7 +112,19 @@ createRouter({
```


### Search query routing
### Search Query Routing

Router value contains parsed `?a=1&b=2` search values:

```js
location.href = '/posts/general?sort=name'
router.get() //=> {
// path: '/posts/category',
// route: 'list',
// params: { category: 'general' },
// search: { sort: 'name' }
// }
```

To use search query like `?a=1&b=2` in routes you need to set `search` option:

Expand All @@ -127,33 +139,6 @@ createRouter({
Router will works with `?search` part as a string. Parameters order will
be critical.

There is another store to watch for `?search` parameters separately.
It can be useful where `?search` is used only as sub-routes for specific page.
For instance, for filters settings on search page.

```js
// stores/searchParams.ts
import { createSearchParams } from '@nanostores/router'

export const $searchParams = createSearchParams()
```

```js
// stores/searchResult.ts
import { $searchParams } from '../searchParams'

export const $searchResult = atom([])

onMount($searchResult, () => {
return $searchParams.subscribe(params => {
$searchResult.set(await search(params))
})
})

function changeSearchParam(key: 'sort' | 'filter', value: string) {
$searchParams.open({ ...$searchParams.get(), [key]: value })
}
```

### Clicks Tracking

Expand Down Expand Up @@ -194,7 +179,7 @@ to use the router as a single place of truth.
import { getPagePath } from '@nanostores/router'

<a href={getPagePath($router, 'post', { categoryId: 'guides', id: '10' })}>
<a href={getPagePath($router, 'post', { category: 'guides', post: '10' })}>
```

If you need to change URL programmatically you can use `openPage`
Expand All @@ -213,6 +198,13 @@ function onLoginSuccess() {
}
```

All functions accept search params as last argument:

```tsx
getPagePath($router, 'list', { category: 'guides' }, { sort: 'name' })
//=> '/posts/guides?sort=name'
```


### Server-Side Rendering

Expand Down
69 changes: 23 additions & 46 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,20 @@ type MappedC<A, B> = {
}
type OptionalKeys<T> = MappedC<T, Required<T>>[keyof T]

type EmptyObject = Record<string, never>

type SearchParams = Record<string, string | number>

export type ParamsArg<
Config extends RouterConfig,
PageName extends keyof Config
> = keyof ParamsFromConfig<Config>[PageName] extends never
? []
? [EmptyObject?, SearchParams?]
: keyof ParamsFromConfig<Config>[PageName] extends OptionalKeys<
ParamsFromConfig<Config>[PageName]
>
? [Input<ParamsFromConfig<Config>[PageName]>?]
: [Input<ParamsFromConfig<Config>[PageName]>]
? [Input<ParamsFromConfig<Config>[PageName]>?, SearchParams?]
: [Input<ParamsFromConfig<Config>[PageName]>, SearchParams?]

type Pattern<RouteParams> = Readonly<
[RegExp, (...parts: string[]) => RouteParams]
Expand All @@ -84,6 +88,7 @@ export type Page<
params: ParamsFromConfig<Config>[PageName]
path: string
route: PageName
search: Record<string, string>
}
: never

Expand Down Expand Up @@ -172,7 +177,11 @@ export function openPage<
export function openPage<
Config extends RouterConfig,
PageName extends keyof Config
>(router: Router<Config>, route: InputPage<Config, PageName>): void
>(
router: Router<Config>,
route: InputPage<Config, PageName>,
search?: SearchParams
): void

/**
* Open page by name and parameters. Replaces recent state in history.
Expand All @@ -199,7 +208,11 @@ export function redirectPage<
export function redirectPage<
Config extends RouterConfig,
PageName extends keyof Config
>(router: Router<Config>, route: InputPage<Config, PageName>): void
>(
router: Router<Config>,
route: InputPage<Config, PageName>,
search?: SearchParams
): void

/**
* Generates pathname by name and parameters. Useful to render links.
Expand All @@ -225,44 +238,8 @@ export function getPagePath<
export function getPagePath<
Config extends RouterConfig,
PageName extends keyof Config
>(router: Router<Config>, route: InputPage<Config, PageName>): string

export interface SearchParamsOptions {
links?: boolean
}

/**
* Store to watch for `?search` URL part changes.
*
* It will track history API and clicks on page’s links.
*/
export interface SearchParamsStore
extends ReadableAtom<Record<string, string>> {
/**
* Change `?search` URL part and update store value.
*
* ```js
* searchParams.open({ sort: 'name', type: 'small' })
* ```
*
* @param path Absolute URL (`https://example.com/a`)
* or domain-less URL (`/a`).
* @param redirect Don’t add entry to the navigation history.
*/
open(params: Record<string, string | number>, redirect?: boolean): void
}

/**
* Create {@link SearchParamsStore} store to watch for `?search` URL part.
*
* ```js
* import { createSearchParams } from 'nanostores'
*
* export const searchParams = createSearchParams()
* ```
*
* @param opts Options.
*/
export function createSearchParams(
opts?: SearchParamsOptions
): SearchParamsStore
>(
router: Router<Config>,
route: InputPage<Config, PageName>,
search?: SearchParams
): string
127 changes: 31 additions & 96 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,5 @@
import { atom, onMount } from 'nanostores'

function isRouterClick(event, link) {
return (
link &&
event.button === 0 && // Left mouse button
link.target !== '_blank' && // Not for new tab
link.origin === location.origin && // Not external link
link.rel !== 'external' && // Not external link
link.target !== '_self' && // Now manually disabled
!link.download && // Not download link
!event.altKey && // Not download link by user
!event.metaKey && // Not open in new tab by user
!event.ctrlKey && // Not open in new tab by user
!event.shiftKey && // Not open in new window by user
!event.defaultPrevented // Click was not cancelled
)
}

export function createRouter(routes, opts = {}) {
let router = atom()
router.routes = Object.keys(routes).map(name => {
Expand Down Expand Up @@ -45,22 +28,39 @@ export function createRouter(routes, opts = {}) {

let prev
let parse = path => {
if (!opts.search) path = path.split('?')[0]
path = path.replace(/\/($|\?)/, '$1') || '/'
if (prev === path) return false
prev = path

let url = new URL(path, 'http://a')
if (!opts.search) path = url.pathname

let search = Object.fromEntries(url.searchParams)

for (let [route, pattern, cb] of router.routes) {
let match = path.match(pattern)
if (match) {
return { params: cb(...match.slice(1)), path, route }
return { params: cb(...match.slice(1)), path, route, search }
}
}
}

let click = event => {
let link = event.target.closest('a')
if (isRouterClick(event, link)) {
if (
link &&
event.button === 0 && // Left mouse button
link.target !== '_blank' && // Not for new tab
link.origin === location.origin && // Not external link
link.rel !== 'external' && // Not external link
link.target !== '_self' && // Now manually disabled
!link.download && // Not download link
!event.altKey && // Not download link by user
!event.metaKey && // Not open in new tab by user
!event.ctrlKey && // Not open in new tab by user
!event.shiftKey && // Not open in new window by user
!event.defaultPrevented // Click was not cancelled
) {
event.preventDefault()
let changed = location.hash !== link.hash
router.open(link.pathname + link.search)
Expand Down Expand Up @@ -116,8 +116,9 @@ export function createRouter(routes, opts = {}) {
return router
}

export function getPagePath(router, name, params) {
export function getPagePath(router, name, params, search) {
if (typeof name === 'object') {
search = params
params = name.params
name = name.route
}
Expand All @@ -135,83 +136,17 @@ export function getPagePath(router, name, params) {
}
})
.replace(/\/:\w+/g, i => '/' + encodeURIComponent(params[i.slice(2)]))
return path || '/'
}

export function openPage(router, name, params) {
router.open(getPagePath(router, name, params))
let postfix = ''
if (search) {
postfix = '?' + new URLSearchParams(search)
}
return (path || '/') + postfix
}

export function redirectPage(router, name, params) {
router.open(getPagePath(router, name, params), true)
export function openPage(router, name, params, search) {
router.open(getPagePath(router, name, params, search))
}

export function createSearchParams(opts = {}) {
let store = atom({})

let set = store.set
if (process.env.NODE_ENV !== 'production') {
delete store.set
}

let prev
let update = href => {
let url = new URL(href)
if (prev === url.search) return false
prev = url.search
set(Object.fromEntries(url.searchParams))
}

store.open = (params, redirect) => {
let urlParams = new URLSearchParams(params)
let search = urlParams.toString()
if (search) search = '?' + search

if (prev === search) return
prev = search

if (typeof history !== 'undefined') {
let href = location.pathname + search + location.hash
if (typeof history !== 'undefined') {
if (redirect) {
history.replaceState(null, null, href)
} else {
history.pushState(null, null, href)
}
}
}
set(Object.fromEntries(urlParams.entries()))
}

let click = event => {
let link = event.target.closest('a')
if (isRouterClick(event, link)) {
if (link.search !== prev) {
prev = link.search
set(Object.fromEntries(new URL(link.href).searchParams))
}
if (link.pathname === location.pathname && link.hash === location.hash) {
event.preventDefault()
history.pushState(null, null, link.href)
}
}
}

let popstate = () => {
update(location.href)
}

if (typeof window !== 'undefined' && typeof location !== 'undefined') {
onMount(store, () => {
popstate()
if (opts.links !== false) document.body.addEventListener('click', click)
window.addEventListener('popstate', popstate)
return () => {
document.body.removeEventListener('click', click)
window.removeEventListener('popstate', popstate)
}
})
}

return store
export function redirectPage(router, name, params, search) {
router.open(getPagePath(router, name, params, search), true)
}
Loading

0 comments on commit 4e0aa93

Please sign in to comment.