@mpen/rerouter 0.1.9 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -18
- package/cli/bin.test.ts +221 -0
- package/cli/bin.ts +342 -0
- package/cli/fixtures/bin/kitchen-sink.tsx +15 -0
- package/cli/fixtures/bin/optional.tsx +3 -0
- package/cli/fixtures/bin/pages/Home.tsx +3 -0
- package/cli/fixtures/bin/pages/KitchenSink.tsx +3 -0
- package/cli/fixtures/bin/pages/Login.tsx +3 -0
- package/cli/fixtures/bin/pages/Match.tsx +3 -0
- package/cli/fixtures/bin/pages/NotFound.tsx +3 -0
- package/cli/fixtures/bin/pages/Optional.tsx +3 -0
- package/cli/fixtures/bin/regexp-groups.tsx +11 -0
- package/cli/fixtures/bin/simple.tsx +1 -0
- package/cli/fixtures/bin/unnamed.tsx +4 -0
- package/cli/tsconfig.json +9 -0
- package/dist/acorn-k7ED_tOl.js +4968 -0
- package/dist/angular--Iqdw9UJ.js +4057 -0
- package/dist/babel-hfWAujRY.js +9878 -0
- package/dist/bin.d.ts +29 -0
- package/dist/bin.js +233 -0
- package/dist/estree-C1Zjnvlw.js +7266 -0
- package/dist/flow-BaD9LyIP.js +52912 -0
- package/dist/glimmer-CvCjW_1V.js +7541 -0
- package/dist/graphql-BdtzBuWh.js +1945 -0
- package/dist/html-DkZtUVbo.js +7137 -0
- package/dist/index.d.ts +278 -0
- package/dist/index.js +247 -0
- package/dist/markdown-Z8Vrc69e.js +6876 -0
- package/dist/meriyah-DeO4stuH.js +7590 -0
- package/dist/postcss-BmgGJ0E5.js +6777 -0
- package/dist/prettier-BT_F8kIx.js +15629 -0
- package/dist/routes-PW-bNm8e.js +135 -0
- package/dist/typescript-DtIxStjy.js +22936 -0
- package/dist/yaml-CWOPBY0q.js +5281 -0
- package/examples/App.tsx +80 -0
- package/examples/dist/BlogPost-c10d9w2p.js +1 -0
- package/examples/dist/FetchLoading-534mdrgz.js +1 -0
- package/examples/dist/FetchLoading-sbxbdkre.js +1 -0
- package/examples/dist/Home-a1258p25.js +1 -0
- package/examples/dist/KitchenSink-821mjg0h.js +1 -0
- package/examples/dist/Login-wywx6bp7.js +1 -0
- package/examples/dist/Match-1e72jm5w.js +1 -0
- package/examples/dist/NotFound-smxj24jw.js +1 -0
- package/examples/dist/SlowLoading-59xxmbfk.js +1 -0
- package/examples/dist/index-0d4kj0rv.js +2 -0
- package/examples/dist/index-3x197sbt.js +9 -0
- package/examples/dist/index-a2hkfx1n.js +9 -0
- package/examples/dist/index-d21me1mc.js +9 -0
- package/examples/dist/index-ktqdknsn.js +2 -0
- package/examples/dist/index-p53qxxzd.js +2 -0
- package/examples/dist/index.html +67 -0
- package/examples/index.html +67 -0
- package/examples/pages/BlogPost.tsx +17 -0
- package/examples/pages/FetchLoading.tsx +53 -0
- package/examples/pages/FetchLoadingItem.tsx +45 -0
- package/examples/pages/Home.tsx +3 -0
- package/examples/pages/KitchenSink.tsx +23 -0
- package/examples/pages/Login.tsx +3 -0
- package/examples/pages/Match.tsx +5 -0
- package/examples/pages/NotFound.tsx +3 -0
- package/examples/pages/SlowLoading.tsx +8 -0
- package/examples/routes.gen.ts +105 -0
- package/examples/routes.ts +40 -0
- package/examples/server/serve-dist.ts +33 -0
- package/examples/server/tsconfig.json +9 -0
- package/package.json +41 -31
- package/src/components/Link.test.tsx +139 -0
- package/src/components/Link.tsx +89 -0
- package/src/components/NavLink.test.tsx +119 -0
- package/src/components/NavLink.tsx +71 -0
- package/src/components/Router.test.tsx +183 -0
- package/src/components/Router.tsx +207 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useUrl.ts +22 -0
- package/src/index.ts +6 -0
- package/src/lib/mergeSearch.test.ts +37 -0
- package/src/lib/mergeSearch.ts +21 -0
- package/src/lib/routes.test.ts +67 -0
- package/src/lib/routes.ts +247 -0
- package/src/lib/url.ts +9 -0
- package/tsconfig.json +10 -0
- package/tsdown.config.ts +21 -0
- package/LICENSE +0 -21
- package/dist/bundle.cjs +0 -422
- package/dist/bundle.d.ts +0 -2
- package/dist/bundle.mjs +0 -420
- package/dist/dev.d.ts +0 -1
- package/dist/log.d.ts +0 -1
- package/dist/uri-template.d.ts +0 -56
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { Window } from 'happy-dom'
|
|
3
|
+
|
|
4
|
+
const testWindow = new Window({ url: 'http://localhost/start' })
|
|
5
|
+
|
|
6
|
+
testWindow.SyntaxError = SyntaxError
|
|
7
|
+
|
|
8
|
+
Object.assign(globalThis, {
|
|
9
|
+
window: testWindow,
|
|
10
|
+
document: testWindow.document,
|
|
11
|
+
navigator: testWindow.navigator,
|
|
12
|
+
location: testWindow.location,
|
|
13
|
+
history: testWindow.history,
|
|
14
|
+
Event: testWindow.Event,
|
|
15
|
+
HTMLElement: testWindow.HTMLElement,
|
|
16
|
+
MouseEvent: testWindow.MouseEvent,
|
|
17
|
+
Node: testWindow.Node,
|
|
18
|
+
PopStateEvent: testWindow.PopStateEvent,
|
|
19
|
+
SyntaxError,
|
|
20
|
+
getComputedStyle: testWindow.getComputedStyle.bind(testWindow),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const { cleanup, fireEvent, render, screen } = await import('@testing-library/react')
|
|
24
|
+
const { Link } = await import('./Link')
|
|
25
|
+
|
|
26
|
+
describe(Link.name, () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
window.history.replaceState(null, '', '/start')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
cleanup()
|
|
33
|
+
window.history.replaceState(null, '', '/start')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('renders a link with merged search params', () => {
|
|
37
|
+
render(
|
|
38
|
+
<Link
|
|
39
|
+
aria-label="Match details"
|
|
40
|
+
className={['match-link', { selected: true, pending: false }]}
|
|
41
|
+
to="/matches?sort=asc"
|
|
42
|
+
search={{ page: 2, sort: 'desc' }}
|
|
43
|
+
>
|
|
44
|
+
View match
|
|
45
|
+
</Link>,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const link = screen.getByRole('link', { name: 'Match details' })
|
|
49
|
+
|
|
50
|
+
expect(link.textContent).toBe('View match')
|
|
51
|
+
expect(link.getAttribute('class')).toBe('match-link selected')
|
|
52
|
+
expect(link.getAttribute('href')).toBe('/matches?sort=desc&page=2')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('pushes the target URL and emits popstate on ordinary clicks', () => {
|
|
56
|
+
let popstateCount = 0
|
|
57
|
+
window.addEventListener(
|
|
58
|
+
'popstate',
|
|
59
|
+
() => {
|
|
60
|
+
popstateCount += 1
|
|
61
|
+
},
|
|
62
|
+
{ once: true },
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
render(<Link to="/matches/42?tab=details">View match</Link>)
|
|
66
|
+
|
|
67
|
+
fireEvent.click(screen.getByRole('link', { name: 'View match' }))
|
|
68
|
+
|
|
69
|
+
expect(window.location.pathname).toBe('/matches/42')
|
|
70
|
+
expect(window.location.search).toBe('?tab=details')
|
|
71
|
+
expect(popstateCount).toBe(1)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('replaces the current URL when replace is set', () => {
|
|
75
|
+
const replaceState = window.history.replaceState.bind(window.history)
|
|
76
|
+
let replacedUrl = ''
|
|
77
|
+
window.history.replaceState = ((data, title, url) => {
|
|
78
|
+
replacedUrl = String(url)
|
|
79
|
+
return replaceState(data, title, url)
|
|
80
|
+
}) as History['replaceState']
|
|
81
|
+
|
|
82
|
+
render(
|
|
83
|
+
<Link replace to="/matches/42">
|
|
84
|
+
Replace match
|
|
85
|
+
</Link>,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
fireEvent.click(screen.getByRole('link', { name: 'Replace match' }))
|
|
90
|
+
|
|
91
|
+
expect(replacedUrl).toBe('/matches/42')
|
|
92
|
+
expect(window.location.pathname).toBe('/matches/42')
|
|
93
|
+
} finally {
|
|
94
|
+
window.history.replaceState = replaceState
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('leaves modified clicks to the browser', () => {
|
|
99
|
+
let popstateCount = 0
|
|
100
|
+
window.addEventListener(
|
|
101
|
+
'popstate',
|
|
102
|
+
() => {
|
|
103
|
+
popstateCount += 1
|
|
104
|
+
},
|
|
105
|
+
{ once: true },
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
render(<Link to="/matches/42">Open elsewhere</Link>)
|
|
109
|
+
|
|
110
|
+
const defaultWasNotPrevented = fireEvent.click(
|
|
111
|
+
screen.getByRole('link', { name: 'Open elsewhere' }),
|
|
112
|
+
{ ctrlKey: true },
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
expect(defaultWasNotPrevented).toBe(true)
|
|
116
|
+
expect(popstateCount).toBe(0)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('leaves non-primary button clicks to the browser', () => {
|
|
120
|
+
let popstateCount = 0
|
|
121
|
+
window.addEventListener(
|
|
122
|
+
'popstate',
|
|
123
|
+
() => {
|
|
124
|
+
popstateCount += 1
|
|
125
|
+
},
|
|
126
|
+
{ once: true },
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
render(<Link to="/matches/42">Open with auxiliary button</Link>)
|
|
130
|
+
|
|
131
|
+
const defaultWasNotPrevented = fireEvent.click(
|
|
132
|
+
screen.getByRole('link', { name: 'Open with auxiliary button' }),
|
|
133
|
+
{ button: 1 },
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
expect(defaultWasNotPrevented).toBe(true)
|
|
137
|
+
expect(popstateCount).toBe(0)
|
|
138
|
+
})
|
|
139
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { cc, type ClassValue } from '@mpen/classcat'
|
|
2
|
+
import type { OverrideProps } from '@mpen/ts-types/react'
|
|
3
|
+
import { startTransition, type MouseEvent } from 'react'
|
|
4
|
+
import { pushUrl, replaceUrl } from '../lib/url'
|
|
5
|
+
import { mergeSearch } from '../lib/mergeSearch'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Values accepted by [`Link`]{@link Link} for building query strings.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```tsx
|
|
12
|
+
* <Link to="/matches" search={{ page: 2, sort: 'desc' }}>
|
|
13
|
+
* Matches
|
|
14
|
+
* </Link>
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export type SearchParamsInit =
|
|
18
|
+
| string
|
|
19
|
+
| string[][]
|
|
20
|
+
| Record<string, string | number | boolean | undefined | null>
|
|
21
|
+
| URLSearchParams
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Props for [`Link`]{@link Link}.
|
|
25
|
+
*/
|
|
26
|
+
export type LinkProps = OverrideProps<
|
|
27
|
+
'a',
|
|
28
|
+
{
|
|
29
|
+
/**
|
|
30
|
+
* Classes to apply to the rendered anchor.
|
|
31
|
+
*/
|
|
32
|
+
className?: ClassValue
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Destination URL passed to the rendered anchor's `href` attribute.
|
|
36
|
+
*/
|
|
37
|
+
to: string
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Query parameters to merge into [`LinkProps.to`]{@link LinkProps#to}.
|
|
41
|
+
*/
|
|
42
|
+
search?: SearchParamsInit
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Whether navigation should replace the current history entry instead of pushing a new one.
|
|
46
|
+
*/
|
|
47
|
+
replace?: boolean
|
|
48
|
+
|
|
49
|
+
href: never
|
|
50
|
+
onClick: never
|
|
51
|
+
}
|
|
52
|
+
>
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Renders an anchor that navigates with rerouter history updates on ordinary clicks.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```tsx
|
|
59
|
+
* <Link to="/matches/42" search={{ tab: 'details' }}>
|
|
60
|
+
* View match
|
|
61
|
+
* </Link>
|
|
62
|
+
* ```
|
|
63
|
+
*
|
|
64
|
+
* @param props - Anchor props plus rerouter navigation options.
|
|
65
|
+
* @returns An anchor element that pushes or replaces the browser URL.
|
|
66
|
+
*/
|
|
67
|
+
export function Link({ to, search, children, className, replace, ...rest }: LinkProps) {
|
|
68
|
+
const href = search ? mergeSearch(to, search) : to
|
|
69
|
+
const linkClassName = cc(className)
|
|
70
|
+
|
|
71
|
+
const onClick = (ev: MouseEvent<HTMLAnchorElement>) => {
|
|
72
|
+
if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return
|
|
73
|
+
if (ev.button !== 0) return
|
|
74
|
+
ev.preventDefault()
|
|
75
|
+
startTransition(() => {
|
|
76
|
+
if (replace) {
|
|
77
|
+
replaceUrl(href)
|
|
78
|
+
} else {
|
|
79
|
+
pushUrl(href)
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<a {...rest} className={linkClassName || undefined} href={href} onClick={onClick}>
|
|
86
|
+
{children}
|
|
87
|
+
</a>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { Window } from 'happy-dom'
|
|
3
|
+
|
|
4
|
+
const testWindow = new Window({ url: 'http://localhost/start' })
|
|
5
|
+
|
|
6
|
+
testWindow.SyntaxError = SyntaxError
|
|
7
|
+
|
|
8
|
+
Object.assign(globalThis, {
|
|
9
|
+
window: testWindow,
|
|
10
|
+
document: testWindow.document,
|
|
11
|
+
navigator: testWindow.navigator,
|
|
12
|
+
location: testWindow.location,
|
|
13
|
+
history: testWindow.history,
|
|
14
|
+
Event: testWindow.Event,
|
|
15
|
+
HTMLElement: testWindow.HTMLElement,
|
|
16
|
+
MouseEvent: testWindow.MouseEvent,
|
|
17
|
+
Node: testWindow.Node,
|
|
18
|
+
PopStateEvent: testWindow.PopStateEvent,
|
|
19
|
+
SyntaxError,
|
|
20
|
+
getComputedStyle: testWindow.getComputedStyle.bind(testWindow),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const { cleanup, render } = await import('@testing-library/react')
|
|
24
|
+
const { NavLink } = await import('./NavLink')
|
|
25
|
+
|
|
26
|
+
describe(NavLink.name, () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
window.history.replaceState(null, '', '/start')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
cleanup()
|
|
33
|
+
window.history.replaceState(null, '', '/start')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('renders active classes when the target path matches the current path', () => {
|
|
37
|
+
const { getByRole } = render(
|
|
38
|
+
<NavLink
|
|
39
|
+
activeClass={{ active: true, pending: false }}
|
|
40
|
+
className="pill"
|
|
41
|
+
inactiveClass="muted"
|
|
42
|
+
to="/start?tab=details"
|
|
43
|
+
>
|
|
44
|
+
Start
|
|
45
|
+
</NavLink>,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
expect(getByRole('link', { name: 'Start' }).getAttribute('class')).toBe('pill active')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('renders inactive classes when the target path does not match the current path', () => {
|
|
52
|
+
const { getByRole } = render(
|
|
53
|
+
<NavLink
|
|
54
|
+
activeClass="active"
|
|
55
|
+
className="pill"
|
|
56
|
+
inactiveClass={{ muted: true }}
|
|
57
|
+
to="/matches"
|
|
58
|
+
>
|
|
59
|
+
Matches
|
|
60
|
+
</NavLink>,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
expect(getByRole('link', { name: 'Matches' }).getAttribute('class')).toBe('pill muted')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('normalizes relative targets using the current URL', () => {
|
|
67
|
+
window.history.replaceState(null, '', '/matches/42')
|
|
68
|
+
|
|
69
|
+
const { getByRole } = render(
|
|
70
|
+
<NavLink activeClass="active" className="pill" inactiveClass="muted" to="?tab=details">
|
|
71
|
+
Current match
|
|
72
|
+
</NavLink>,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
expect(getByRole('link', { name: 'Current match' }).getAttribute('class')).toBe(
|
|
76
|
+
'pill active',
|
|
77
|
+
)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('renders active classes for prefix matches', () => {
|
|
81
|
+
window.history.replaceState(null, '', '/fetch-loading/abc-123')
|
|
82
|
+
|
|
83
|
+
const { getByRole } = render(
|
|
84
|
+
<NavLink
|
|
85
|
+
activeClass="active"
|
|
86
|
+
className="pill"
|
|
87
|
+
inactiveClass="muted"
|
|
88
|
+
match="prefix"
|
|
89
|
+
to="/fetch-loading"
|
|
90
|
+
>
|
|
91
|
+
Fetch Loading
|
|
92
|
+
</NavLink>,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
expect(getByRole('link', { name: 'Fetch Loading' }).getAttribute('class')).toBe(
|
|
96
|
+
'pill active',
|
|
97
|
+
)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('does not render active classes for partial segment prefix matches', () => {
|
|
101
|
+
window.history.replaceState(null, '', '/fetch-loading-old')
|
|
102
|
+
|
|
103
|
+
const { getByRole } = render(
|
|
104
|
+
<NavLink
|
|
105
|
+
activeClass="active"
|
|
106
|
+
className="pill"
|
|
107
|
+
inactiveClass="muted"
|
|
108
|
+
match="prefix"
|
|
109
|
+
to="/fetch-loading"
|
|
110
|
+
>
|
|
111
|
+
Fetch Loading
|
|
112
|
+
</NavLink>,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
expect(getByRole('link', { name: 'Fetch Loading' }).getAttribute('class')).toBe(
|
|
116
|
+
'pill muted',
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { cc, type ClassValue } from '@mpen/classcat'
|
|
2
|
+
import type { Override } from '@mpen/ts-types'
|
|
3
|
+
import { useUrlPath } from '../hooks/useUrl'
|
|
4
|
+
import { Link, type LinkProps } from './Link'
|
|
5
|
+
|
|
6
|
+
export type NavLinkMatch = 'exact' | 'prefix'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Props for [`NavLink`]{@link NavLink}.
|
|
10
|
+
*/
|
|
11
|
+
export type NavLinkProps = Override<
|
|
12
|
+
LinkProps,
|
|
13
|
+
{
|
|
14
|
+
/**
|
|
15
|
+
* Classes to apply when the link target matches the current path.
|
|
16
|
+
*/
|
|
17
|
+
activeClass?: ClassValue
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Classes to apply when the link target does not match the current path.
|
|
21
|
+
*/
|
|
22
|
+
inactiveClass?: ClassValue
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How to compare the link target to the current path.
|
|
26
|
+
*
|
|
27
|
+
* @defaultValue `'exact'`
|
|
28
|
+
*/
|
|
29
|
+
match?: NavLinkMatch
|
|
30
|
+
}
|
|
31
|
+
>
|
|
32
|
+
|
|
33
|
+
function isActivePath(pathname: string, targetPathname: string, match: NavLinkMatch): boolean {
|
|
34
|
+
if (pathname === targetPathname) return true
|
|
35
|
+
if (match === 'exact') return false
|
|
36
|
+
if (targetPathname === '/') return false
|
|
37
|
+
return pathname.startsWith(`${targetPathname}/`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Renders a [`Link`]{@link Link} with classes selected from the current route.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```tsx
|
|
45
|
+
* <NavLink
|
|
46
|
+
* activeClass={['pill', 'active']}
|
|
47
|
+
* inactiveClass={['pill', { muted: true }]}
|
|
48
|
+
* to="/settings"
|
|
49
|
+
* >
|
|
50
|
+
* Settings
|
|
51
|
+
* </NavLink>
|
|
52
|
+
* ```
|
|
53
|
+
*
|
|
54
|
+
* @param props - Link props plus active and inactive class values.
|
|
55
|
+
* @returns An anchor element that navigates through rerouter.
|
|
56
|
+
*/
|
|
57
|
+
export function NavLink({
|
|
58
|
+
activeClass,
|
|
59
|
+
className,
|
|
60
|
+
inactiveClass,
|
|
61
|
+
match = 'exact',
|
|
62
|
+
to,
|
|
63
|
+
...props
|
|
64
|
+
}: NavLinkProps) {
|
|
65
|
+
const pathname = useUrlPath()
|
|
66
|
+
const target = new URL(to, window.location.href)
|
|
67
|
+
const active = isActivePath(pathname, target.pathname, match)
|
|
68
|
+
const linkClassName = cc(className, active ? activeClass : inactiveClass)
|
|
69
|
+
|
|
70
|
+
return <Link {...props} className={linkClassName || undefined} to={to} />
|
|
71
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { Window } from 'happy-dom'
|
|
3
|
+
import type { RouteObject } from '../lib/routes'
|
|
4
|
+
|
|
5
|
+
const testWindow = new Window({ url: 'http://localhost/start' })
|
|
6
|
+
|
|
7
|
+
testWindow.SyntaxError = SyntaxError
|
|
8
|
+
|
|
9
|
+
Object.assign(globalThis, {
|
|
10
|
+
window: testWindow,
|
|
11
|
+
document: testWindow.document,
|
|
12
|
+
navigator: testWindow.navigator,
|
|
13
|
+
location: testWindow.location,
|
|
14
|
+
history: testWindow.history,
|
|
15
|
+
Event: testWindow.Event,
|
|
16
|
+
HTMLElement: testWindow.HTMLElement,
|
|
17
|
+
MouseEvent: testWindow.MouseEvent,
|
|
18
|
+
Node: testWindow.Node,
|
|
19
|
+
PopStateEvent: testWindow.PopStateEvent,
|
|
20
|
+
SyntaxError,
|
|
21
|
+
getComputedStyle: testWindow.getComputedStyle.bind(testWindow),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const { act, cleanup, render, waitFor } = await import('@testing-library/react')
|
|
25
|
+
const { Router } = await import('./Router')
|
|
26
|
+
const { pushUrl } = await import('../lib/url')
|
|
27
|
+
|
|
28
|
+
function wait(ms: number): Promise<void> {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
setTimeout(resolve, ms)
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe(Router.name, () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
window.history.replaceState(null, '', '/start')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
cleanup()
|
|
41
|
+
window.history.replaceState(null, '', '/start')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('delays the loading fallback while a route component is loading', async () => {
|
|
45
|
+
window.history.replaceState(null, '', '/slow')
|
|
46
|
+
|
|
47
|
+
const routes: readonly RouteObject[] = [
|
|
48
|
+
{
|
|
49
|
+
pattern: '/slow',
|
|
50
|
+
component: () =>
|
|
51
|
+
new Promise(() => {
|
|
52
|
+
// Keep the route pending so the fallback delay is observable.
|
|
53
|
+
}),
|
|
54
|
+
},
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
const view = render(
|
|
58
|
+
<Router loading={<div>Loading route...</div>} loadingDelayMs={25} routes={routes} />,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
expect(view.queryByText('Loading route...')).toBeNull()
|
|
62
|
+
|
|
63
|
+
await act(async () => {
|
|
64
|
+
await wait(30)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
expect(view.getByText('Loading route...')).toBeTruthy()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('does not show the loading fallback when the route loads before the delay', async () => {
|
|
71
|
+
window.history.replaceState(null, '', '/quick')
|
|
72
|
+
|
|
73
|
+
const routes: readonly RouteObject[] = [
|
|
74
|
+
{
|
|
75
|
+
pattern: '/quick',
|
|
76
|
+
component: () =>
|
|
77
|
+
wait(5).then(() => ({
|
|
78
|
+
default: function QuickRoute() {
|
|
79
|
+
return <div>Quick route</div>
|
|
80
|
+
},
|
|
81
|
+
})),
|
|
82
|
+
},
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
const view = render(
|
|
86
|
+
<Router loading={<div>Loading route...</div>} loadingDelayMs={50} routes={routes} />,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
expect(view.queryByText('Loading route...')).toBeNull()
|
|
90
|
+
|
|
91
|
+
await waitFor(() => {
|
|
92
|
+
expect(view.getByText('Quick route')).toBeTruthy()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
expect(view.queryByText('Loading route...')).toBeNull()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('keeps the current route visible until a slow next route reaches the delay', async () => {
|
|
99
|
+
const routes: readonly RouteObject[] = [
|
|
100
|
+
{
|
|
101
|
+
pattern: '/start',
|
|
102
|
+
component: async () => ({
|
|
103
|
+
default: function StartRoute() {
|
|
104
|
+
return <div>Start route</div>
|
|
105
|
+
},
|
|
106
|
+
}),
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
pattern: '/slow',
|
|
110
|
+
component: () =>
|
|
111
|
+
new Promise(() => {
|
|
112
|
+
// Keep the next route pending so the delayed loading state is observable.
|
|
113
|
+
}),
|
|
114
|
+
},
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
const view = render(
|
|
118
|
+
<Router loading={<div>Loading route...</div>} loadingDelayMs={25} routes={routes} />,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
await waitFor(() => {
|
|
122
|
+
expect(view.getByText('Start route')).toBeTruthy()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
act(() => {
|
|
126
|
+
pushUrl('/slow')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
expect(view.getByText('Start route')).toBeTruthy()
|
|
130
|
+
expect(view.queryByText('Loading route...')).toBeNull()
|
|
131
|
+
|
|
132
|
+
await act(async () => {
|
|
133
|
+
await wait(30)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
expect(view.queryByText('Start route')).toBeNull()
|
|
137
|
+
expect(view.getByText('Loading route...')).toBeTruthy()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('keeps the current route visible until a quick next route is ready', async () => {
|
|
141
|
+
const routes: readonly RouteObject[] = [
|
|
142
|
+
{
|
|
143
|
+
pattern: '/start',
|
|
144
|
+
component: async () => ({
|
|
145
|
+
default: function StartRoute() {
|
|
146
|
+
return <div>Start route</div>
|
|
147
|
+
},
|
|
148
|
+
}),
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
pattern: '/quick',
|
|
152
|
+
component: () =>
|
|
153
|
+
wait(5).then(() => ({
|
|
154
|
+
default: function QuickRoute() {
|
|
155
|
+
return <div>Quick route</div>
|
|
156
|
+
},
|
|
157
|
+
})),
|
|
158
|
+
},
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
const view = render(
|
|
162
|
+
<Router loading={<div>Loading route...</div>} loadingDelayMs={50} routes={routes} />,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
await waitFor(() => {
|
|
166
|
+
expect(view.getByText('Start route')).toBeTruthy()
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
act(() => {
|
|
170
|
+
pushUrl('/quick')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
expect(view.getByText('Start route')).toBeTruthy()
|
|
174
|
+
expect(view.queryByText('Loading route...')).toBeNull()
|
|
175
|
+
|
|
176
|
+
await waitFor(() => {
|
|
177
|
+
expect(view.getByText('Quick route')).toBeTruthy()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
expect(view.queryByText('Start route')).toBeNull()
|
|
181
|
+
expect(view.queryByText('Loading route...')).toBeNull()
|
|
182
|
+
})
|
|
183
|
+
})
|