diff --git a/README.md b/README.md index 2e26c238..ed645d00 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A lightweight History API based router for [Solid](https://github.com/solidjs/so - Simple — single top level router, no nesting, no context, handles all `` clicks - Light — [few dependencies](https://npm.anvaka.com/#/view/2d/%2540maxmilton%252Fsolid-router); under the hood it's mostly an abstraction on top of Solid's built-in Switch and Match components + a little handling logic - Flexible path matching — static paths, parameters, optional parameters, wildcards, and no match fallback -- URL search query params parsing +- Optional URL search query params parsing > Note: This package is not designed to work with SSR or DOM-less pre-rendering. If you need a universal solution use [solid-app-router](https://github.com/solidjs/solid-app-router) instead. @@ -32,7 +32,7 @@ yarn add @maxmilton/solid-router ## Usage -Simple + JavaScript: +### Simple + JavaScript ```jsx import { NavLink, Router, routeTo } from '@maxmilton/solid-router'; @@ -72,11 +72,17 @@ const App = () => ( render(App, document.body); ``` -All features + TypeScript: +### All features + TypeScript ```tsx -import { NavLink, Route, Router, routeTo } from '@maxmilton/solid-router'; -import { Component, JSX, lazy } from 'solid-js'; +import { + NavLink, + Router, + useURLParams, + routeTo, + type Route, +} from '@maxmilton/solid-router'; +import { lazy, type Component, type JSX } from 'solid-js'; import { ErrorBoundary, render, Suspense } from 'solid-js/web'; interface ErrorPageProps { @@ -108,8 +114,18 @@ const routes: Route[] = [ { path: '/xx/:x1/:x2?', component: (props) => { - console.log('PARAMS', props.params); - console.log('QUERY', props.query); + console.log(props.params); // -> { x1: "...", x2: ... } + + const [urlParams, setUrlParams] = useURLParams(); + console.log(urlParams()); // -> { ... } + + // Add new URL params + setUrlParams({ ...urlParams(), name: 'example', x: [1, 2] }); // -> location.search == "?name=example&x=1&x=2" + + // Delete URL params (set to `undefined`) + setUrlParams({ ...urlParams(), x: undefined }); // -> location.search == "?name=example" + + // Regular links are still handled by the router return I'm still handled correctly!; }, }, @@ -161,9 +177,21 @@ TODO: Write me +## Browser support + +No particularly modern JavaScript APIs are used so browser support should be excellent. However, keep in mind [Solid's official browser support](https://github.com/solidjs/solid#browser-support) only targets modern evergreen browsers. + +## Bugs + +Report any bugs you encounter on the [GitHub issue tracker](https://github.com/maxmilton/new-tab/issues). + +## Changelog + +See [releases on GitHub](https://github.com/maxmilton/solid-router/releases). + ## License -`@maxmilton/solid-router` is an MIT licensed open source project. See [LICENSE](https://github.com/maxmilton/solid-router/blob/master/LICENSE). +MIT license. See [LICENSE](https://github.com/maxmilton/solid-router/blob/master/LICENSE). --- diff --git a/prettier.config.js b/prettier.config.js index af589f16..8d155df7 100644 --- a/prettier.config.js +++ b/prettier.config.js @@ -1,3 +1,4 @@ +/** @type {import('prettier').Config} */ module.exports = { arrowParens: 'always', endOfLine: 'lf', @@ -7,4 +8,12 @@ module.exports = { './node_modules/prettier-plugin-pkg', './node_modules/prettier-plugin-sh', ], + overrides: [ + { + files: ['*.test.tsx', '*.test.ts'], + options: { + printWidth: 100, + }, + }, + ], }; diff --git a/src/index.tsx b/src/index.tsx index 0eb72bad..43fcd9d9 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,19 +1,20 @@ -import { decode } from 'qss'; +import { decode, encode } from 'qss'; import { parse } from 'regexparam'; import { - Component, createSignal, - JSX, onCleanup, splitProps, startTransition, + type Accessor, + type Component, + type JSX, } from 'solid-js'; import { Match, Switch } from 'solid-js/web'; const [urlPath, setUrlPath] = createSignal(window.location.pathname); export function routeTo(url: string, replace?: boolean): Promise { - window.history[`${replace ? 'replace' : 'push'}State` as const]({}, '', url); + window.history[`${replace ? 'replace' : 'push'}State`](null, '', url); return startTransition(() => setUrlPath(/[^#?]*/.exec(url)![0])); } @@ -21,7 +22,6 @@ export type RouteComponent> = ( props: P & { children?: JSX.Element; readonly params: Record; - readonly query: Partial>; }, ) => JSX.Element; @@ -94,7 +94,6 @@ export const Router: Component = (props) => { return ( {(matches) => { - const search = window.location.search.slice(1); const params: Record = {}; let index = 0; @@ -103,12 +102,7 @@ export const Router: Component = (props) => { } // FIXME: Lazy loaded components do not trigger - return ( - - ); + return ; }} ); @@ -155,3 +149,35 @@ export const NavLink: Component = (props) => { /> ); }; + +export type URLParams = Record< +string, +string | number | boolean | (string | number | boolean)[] | undefined +>; + +/** + * The current URL search query params parsed into a reactive object. + * + * When updating the object via the setter function, the URL will be updated + * as well. + * + * Note that the object is _not live_, meaning that if the URL query params + * change externally (e.g., with `history.replaceState`), the object will not + * update automatically. + */ +export const useURLParams = (): [ + Accessor, + /** + * @param params - The new URL search query params to set. Properties set as + * `undefined` will not be included in the URL. + */ + (params: URLParams) => void, +] => { + const [params, set] = createSignal(decode(window.location.search.slice(1))); + + const setParams = (urlParams: URLParams) => { + window.history.replaceState(null, '', encode(set(urlParams), '?')); + }; + + return [params, setParams]; +}; diff --git a/test/NavLink.test.tsx b/test/NavLink.test.tsx index 6b93e41d..96c3fff2 100644 --- a/test/NavLink.test.tsx +++ b/test/NavLink.test.tsx @@ -15,22 +15,14 @@ test('renders without props', () => { test('renders correctly with required props', () => { expect.assertions(1); const rendered = render(() => x); - expect(rendered.container.innerHTML).toMatchInlineSnapshot( - '"x"', - ); + expect(rendered.container.innerHTML).toMatchInlineSnapshot('"x"'); }); test.todo('renders "aria-current" attribute when location matches'); -test.todo( - 'does not render "aria-current" attribute when location does not match', -); +test.todo('does not render "aria-current" attribute when location does not match'); test.todo('renders "aria-current" attribute only on matching links'); test.todo('renders "aria-current" attribute when location deep matches'); -test.todo( - 'does not render "aria-current" attribute when location does not deep match', -); -test.todo( - 'renders "aria-current" attribute after changing to a matching location', -); +test.todo('does not render "aria-current" attribute when location does not deep match'); +test.todo('renders "aria-current" attribute after changing to a matching location'); test.todo('adds props as attributes on element'); test.todo('does not add deepMatch prop as attribute on element'); diff --git a/test/Router.test.tsx b/test/Router.test.tsx index 9bce376d..33e0c67b 100644 --- a/test/Router.test.tsx +++ b/test/Router.test.tsx @@ -82,9 +82,7 @@ test.todo('does not handle click with ctrl key'); test.todo('does not handle click with meta key'); test.todo('does not handle click with alt key'); test.todo('does not handle click with shift key'); -test.todo( - 'does not handle click when mouse button pressed is not the main button', -); +test.todo('does not handle click when mouse button pressed is not the main button'); test.todo('does not handle click when default already prevented'); test.todo('does not handle click when not on or inside a '); test.todo('does not handle click when has target attribute'); diff --git a/test/index.test.ts b/test/index.test.ts index 9245d90a..e7485de1 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -4,31 +4,26 @@ import * as allExports from '../src/index'; const publicExports = [ - ['routeTo', 'function'], - ['Router', 'function'], - ['NavLink', 'function'], + ['routeTo', 'Function'], + ['Router', 'Function'], + ['NavLink', 'Function'], + ['useURLParams', 'Function'], ] as const; for (const [name, type] of publicExports) { test(`exports public "${name}" ${type}`, () => { expect.assertions(2); - expect(name in allExports).toBe(true); - expect(typeof allExports[name]).toBe(type); + expect(allExports).toHaveProperty(name); + expect(Object.prototype.toString.call(allExports[name])).toBe(`[object ${type}]`); }); } test('does not export any private internals', () => { - expect.assertions(2); - const allPublicExportNames = [ - ...publicExports.map((x) => x[0]), - 'default', // synthetic default created by TS at test runtime - ]; - const remainingExports = Object.keys(allExports); - expect(remainingExports.length >= publicExports.length).toBe(true); - for (const name of allPublicExportNames) { - remainingExports.splice(remainingExports.indexOf(name), 1); - } - expect(remainingExports).toHaveLength(0); + expect.assertions(5); + const allPublicExportNames = publicExports.map((x) => x[0]); + expect(allPublicExportNames).toHaveLength(Object.keys(allExports).length); + // eslint-disable-next-line guard-for-in + for (const name in allExports) expect(allPublicExportNames).toContain(name); }); test('has no default export', () => { diff --git a/test/useURLParams.test.tsx b/test/useURLParams.test.tsx new file mode 100644 index 00000000..e97461e7 --- /dev/null +++ b/test/useURLParams.test.tsx @@ -0,0 +1,64 @@ +/** @jest-environment jsdom */ + +import { useURLParams } from '../src'; + +function setURL(url: string) { + const oldLocation = window.location; + const location = new URL(url); + // @ts-expect-error - replace with mock + delete window.location; + // @ts-expect-error - simple mock + window.location = location; + + return () => { + window.location = oldLocation; + }; +} + +// TODO: Break up tests + more and better tests + +test('read works as expected', () => { + expect.assertions(3); + const reset1 = setURL('http://localhost/'); + const [read1] = useURLParams(); + expect(read1()).toEqual({}); + reset1(); + const reset2 = setURL( + 'http://localhost/?a=1&b=2&c=3&c=4&d&c=null&c=undefined&c=0&c=false&c=true', + ); + const [read2] = useURLParams(); + expect(read2()).toEqual({ + a: 1, + b: 2, + c: [3, 4, 'null', 'undefined', 0, false, true], + d: '', + }); + reset2(); + const reset3 = setURL('http://localhost/?ab_c=123.456&_def=1.00&-x-=-0.1&&&&'); + const [read3] = useURLParams(); + expect(read3()).toEqual({ + ab_c: 123.456, + _def: 1, + '-x-': -0.1, + }); + reset3(); +}); + +test('set works as expected', () => { + expect.assertions(10); + const [read, set] = useURLParams(); + expect(window.location.search).toBe(''); + expect(read()).toEqual({}); + set({ a: 1 }); + expect(window.location.search).toBe('?a=1'); + expect(read()).toEqual({ a: 1 }); + set({ ...read(), b: 2 }); + expect(window.location.search).toBe('?a=1&b=2'); + expect(read()).toEqual({ a: 1, b: 2 }); + set({ ...read(), a: undefined }); + expect(window.location.search).toBe('?b=2'); + expect(read()).toEqual({ a: undefined, b: 2 }); + set({}); + expect(window.location.search).toBe(''); + expect(read()).toEqual({}); +});
> = ( props: P & { children?: JSX.Element; readonly params: Record; - readonly query: Partial>; }, ) => JSX.Element; @@ -94,7 +94,6 @@ export const Router: Component = (props) => { return ( {(matches) => { - const search = window.location.search.slice(1); const params: Record = {}; let index = 0; @@ -103,12 +102,7 @@ export const Router: Component = (props) => { } // FIXME: Lazy loaded components do not trigger - return ( - - ); + return ; }} ); @@ -155,3 +149,35 @@ export const NavLink: Component = (props) => { /> ); }; + +export type URLParams = Record< +string, +string | number | boolean | (string | number | boolean)[] | undefined +>; + +/** + * The current URL search query params parsed into a reactive object. + * + * When updating the object via the setter function, the URL will be updated + * as well. + * + * Note that the object is _not live_, meaning that if the URL query params + * change externally (e.g., with `history.replaceState`), the object will not + * update automatically. + */ +export const useURLParams = (): [ + Accessor, + /** + * @param params - The new URL search query params to set. Properties set as + * `undefined` will not be included in the URL. + */ + (params: URLParams) => void, +] => { + const [params, set] = createSignal(decode(window.location.search.slice(1))); + + const setParams = (urlParams: URLParams) => { + window.history.replaceState(null, '', encode(set(urlParams), '?')); + }; + + return [params, setParams]; +}; diff --git a/test/NavLink.test.tsx b/test/NavLink.test.tsx index 6b93e41d..96c3fff2 100644 --- a/test/NavLink.test.tsx +++ b/test/NavLink.test.tsx @@ -15,22 +15,14 @@ test('renders without props', () => { test('renders correctly with required props', () => { expect.assertions(1); const rendered = render(() => x); - expect(rendered.container.innerHTML).toMatchInlineSnapshot( - '"x"', - ); + expect(rendered.container.innerHTML).toMatchInlineSnapshot('"x"'); }); test.todo('renders "aria-current" attribute when location matches'); -test.todo( - 'does not render "aria-current" attribute when location does not match', -); +test.todo('does not render "aria-current" attribute when location does not match'); test.todo('renders "aria-current" attribute only on matching links'); test.todo('renders "aria-current" attribute when location deep matches'); -test.todo( - 'does not render "aria-current" attribute when location does not deep match', -); -test.todo( - 'renders "aria-current" attribute after changing to a matching location', -); +test.todo('does not render "aria-current" attribute when location does not deep match'); +test.todo('renders "aria-current" attribute after changing to a matching location'); test.todo('adds props as attributes on element'); test.todo('does not add deepMatch prop as attribute on element'); diff --git a/test/Router.test.tsx b/test/Router.test.tsx index 9bce376d..33e0c67b 100644 --- a/test/Router.test.tsx +++ b/test/Router.test.tsx @@ -82,9 +82,7 @@ test.todo('does not handle click with ctrl key'); test.todo('does not handle click with meta key'); test.todo('does not handle click with alt key'); test.todo('does not handle click with shift key'); -test.todo( - 'does not handle click when mouse button pressed is not the main button', -); +test.todo('does not handle click when mouse button pressed is not the main button'); test.todo('does not handle click when default already prevented'); test.todo('does not handle click when not on or inside a '); test.todo('does not handle click when has target attribute'); diff --git a/test/index.test.ts b/test/index.test.ts index 9245d90a..e7485de1 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -4,31 +4,26 @@ import * as allExports from '../src/index'; const publicExports = [ - ['routeTo', 'function'], - ['Router', 'function'], - ['NavLink', 'function'], + ['routeTo', 'Function'], + ['Router', 'Function'], + ['NavLink', 'Function'], + ['useURLParams', 'Function'], ] as const; for (const [name, type] of publicExports) { test(`exports public "${name}" ${type}`, () => { expect.assertions(2); - expect(name in allExports).toBe(true); - expect(typeof allExports[name]).toBe(type); + expect(allExports).toHaveProperty(name); + expect(Object.prototype.toString.call(allExports[name])).toBe(`[object ${type}]`); }); } test('does not export any private internals', () => { - expect.assertions(2); - const allPublicExportNames = [ - ...publicExports.map((x) => x[0]), - 'default', // synthetic default created by TS at test runtime - ]; - const remainingExports = Object.keys(allExports); - expect(remainingExports.length >= publicExports.length).toBe(true); - for (const name of allPublicExportNames) { - remainingExports.splice(remainingExports.indexOf(name), 1); - } - expect(remainingExports).toHaveLength(0); + expect.assertions(5); + const allPublicExportNames = publicExports.map((x) => x[0]); + expect(allPublicExportNames).toHaveLength(Object.keys(allExports).length); + // eslint-disable-next-line guard-for-in + for (const name in allExports) expect(allPublicExportNames).toContain(name); }); test('has no default export', () => { diff --git a/test/useURLParams.test.tsx b/test/useURLParams.test.tsx new file mode 100644 index 00000000..e97461e7 --- /dev/null +++ b/test/useURLParams.test.tsx @@ -0,0 +1,64 @@ +/** @jest-environment jsdom */ + +import { useURLParams } from '../src'; + +function setURL(url: string) { + const oldLocation = window.location; + const location = new URL(url); + // @ts-expect-error - replace with mock + delete window.location; + // @ts-expect-error - simple mock + window.location = location; + + return () => { + window.location = oldLocation; + }; +} + +// TODO: Break up tests + more and better tests + +test('read works as expected', () => { + expect.assertions(3); + const reset1 = setURL('http://localhost/'); + const [read1] = useURLParams(); + expect(read1()).toEqual({}); + reset1(); + const reset2 = setURL( + 'http://localhost/?a=1&b=2&c=3&c=4&d&c=null&c=undefined&c=0&c=false&c=true', + ); + const [read2] = useURLParams(); + expect(read2()).toEqual({ + a: 1, + b: 2, + c: [3, 4, 'null', 'undefined', 0, false, true], + d: '', + }); + reset2(); + const reset3 = setURL('http://localhost/?ab_c=123.456&_def=1.00&-x-=-0.1&&&&'); + const [read3] = useURLParams(); + expect(read3()).toEqual({ + ab_c: 123.456, + _def: 1, + '-x-': -0.1, + }); + reset3(); +}); + +test('set works as expected', () => { + expect.assertions(10); + const [read, set] = useURLParams(); + expect(window.location.search).toBe(''); + expect(read()).toEqual({}); + set({ a: 1 }); + expect(window.location.search).toBe('?a=1'); + expect(read()).toEqual({ a: 1 }); + set({ ...read(), b: 2 }); + expect(window.location.search).toBe('?a=1&b=2'); + expect(read()).toEqual({ a: 1, b: 2 }); + set({ ...read(), a: undefined }); + expect(window.location.search).toBe('?b=2'); + expect(read()).toEqual({ a: undefined, b: 2 }); + set({}); + expect(window.location.search).toBe(''); + expect(read()).toEqual({}); +});