@mpen/rerouter 0.1.9 → 0.3.0

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.
Files changed (59) hide show
  1. package/README.md +76 -18
  2. package/dist/bin.d.ts +29 -0
  3. package/dist/bin.js +228 -0
  4. package/dist/hooks-Dlwcb0sV.js +20 -0
  5. package/dist/hooks.d.ts +2 -0
  6. package/dist/hooks.js +2 -0
  7. package/dist/index-BYXpNitc.d.ts +5 -0
  8. package/dist/index.d.ts +265 -0
  9. package/dist/index.js +139 -0
  10. package/dist/routes-Hpf6cwcZ.js +135 -0
  11. package/examples/App.tsx +111 -0
  12. package/examples/index.html +67 -0
  13. package/examples/pages/BlogPost.tsx +17 -0
  14. package/examples/pages/FetchLoading.tsx +53 -0
  15. package/examples/pages/FetchLoadingItem.tsx +45 -0
  16. package/examples/pages/Home.tsx +3 -0
  17. package/examples/pages/KitchenSink.tsx +23 -0
  18. package/examples/pages/Login.tsx +3 -0
  19. package/examples/pages/Match.tsx +5 -0
  20. package/examples/pages/NotFound.tsx +3 -0
  21. package/examples/pages/SlowLoading.tsx +8 -0
  22. package/examples/routes.gen.ts +125 -0
  23. package/examples/routes.ts +40 -0
  24. package/package.json +37 -32
  25. package/src/bin.test.ts +199 -0
  26. package/src/bin.ts +333 -0
  27. package/src/components/Link.test.tsx +139 -0
  28. package/src/components/Link.tsx +87 -0
  29. package/src/components/NavLink.test.tsx +119 -0
  30. package/src/components/NavLink.tsx +71 -0
  31. package/src/components/Router.tsx +75 -0
  32. package/src/fixtures/bin/kitchen-sink.tsx +15 -0
  33. package/src/fixtures/bin/optional.tsx +3 -0
  34. package/src/fixtures/bin/pages/Home.tsx +3 -0
  35. package/src/fixtures/bin/pages/KitchenSink.tsx +3 -0
  36. package/src/fixtures/bin/pages/Login.tsx +3 -0
  37. package/src/fixtures/bin/pages/Match.tsx +3 -0
  38. package/src/fixtures/bin/pages/NotFound.tsx +3 -0
  39. package/src/fixtures/bin/pages/Optional.tsx +3 -0
  40. package/src/fixtures/bin/regexp-groups.tsx +11 -0
  41. package/src/fixtures/bin/simple.tsx +1 -0
  42. package/src/fixtures/bin/unnamed.tsx +4 -0
  43. package/src/hooks/index.ts +1 -0
  44. package/src/hooks/useUrl.ts +22 -0
  45. package/src/index.ts +6 -0
  46. package/src/lib/mergeSearch.test.ts +37 -0
  47. package/src/lib/mergeSearch.ts +21 -0
  48. package/src/lib/routes.test.ts +67 -0
  49. package/src/lib/routes.ts +245 -0
  50. package/src/lib/url.ts +9 -0
  51. package/tsconfig.json +9 -0
  52. package/tsdown.config.ts +22 -0
  53. package/LICENSE +0 -21
  54. package/dist/bundle.cjs +0 -422
  55. package/dist/bundle.d.ts +0 -2
  56. package/dist/bundle.mjs +0 -420
  57. package/dist/dev.d.ts +0 -1
  58. package/dist/log.d.ts +0 -1
  59. package/dist/uri-template.d.ts +0 -56
package/src/bin.ts ADDED
@@ -0,0 +1,333 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import process from 'node:process'
5
+ import { fileURLToPath, pathToFileURL } from 'node:url'
6
+ import { parseArgs, type ParseArgsConfig } from 'node:util'
7
+ import { parse } from 'path-to-regexp'
8
+ import { normalizeLegacyPathToRegexpSyntax, normalizeRoutes, type Route } from './lib/routes'
9
+
10
+ const PARSE_CONFIG = {
11
+ options: {
12
+ output: { type: 'string', short: 'o' },
13
+ write: { type: 'boolean', short: 'w' },
14
+ 'wildcard-delimiter': { type: 'string' },
15
+ 'encode-function': { type: 'string' },
16
+ },
17
+ allowPositionals: true,
18
+ strict: true,
19
+ } satisfies ParseArgsConfig
20
+
21
+ type CompileOptions = {
22
+ delimiter?: string
23
+ encode?: string
24
+ functionName?: string
25
+ }
26
+
27
+ type RunOptions = {
28
+ cwd?: string
29
+ commandName?: string
30
+ commandArgs?: readonly string[]
31
+ }
32
+
33
+ type RunResult = {
34
+ exitCode?: number
35
+ stdout: string
36
+ stderr: string
37
+ }
38
+
39
+ function escapeString(value: string): string {
40
+ return JSON.stringify(value)
41
+ }
42
+
43
+ function shellEscape(arg: string): string {
44
+ if (/^[a-z0-9/_.-]+$/i.test(arg)) return arg
45
+ return `'${arg.replace(/'/g, "'\\''")}'`
46
+ }
47
+
48
+ function compilePathGenerator(
49
+ pattern: string,
50
+ {
51
+ delimiter = '/',
52
+ encode = 'encodeURIComponent',
53
+ functionName = 'generate',
54
+ }: CompileOptions = {},
55
+ ): string {
56
+ const { tokens } = parse(normalizeLegacyPathToRegexpSyntax(pattern))
57
+
58
+ type Prop = { name: string; type: string }
59
+
60
+ const baseProps: Prop[] = []
61
+ const groupTypes: string[] = []
62
+
63
+ const typeOfParam = (t: any) => (t.type === 'wildcard' ? 'WildcardType' : 'ParamType')
64
+ const makeProp = (name: string, t: any): Prop => ({ name, type: typeOfParam(t) })
65
+
66
+ function collectGroupProps(ts2: any[]): Prop[] {
67
+ const props: Prop[] = []
68
+ for (const t of ts2) {
69
+ if (t.type === 'param' || t.type === 'wildcard') props.push(makeProp(t.name, t))
70
+ else if (t.type === 'group') props.push(...collectGroupProps(t.tokens))
71
+ }
72
+ return props
73
+ }
74
+
75
+ function collectTypes(ts2: any[], intoBase = true) {
76
+ for (const t of ts2) {
77
+ if ((t.type === 'param' || t.type === 'wildcard') && intoBase) {
78
+ baseProps.push(makeProp(t.name, t))
79
+ } else if (t.type === 'group') {
80
+ const groupProps = collectGroupProps(t.tokens)
81
+ if (groupProps.length) {
82
+ const some = [
83
+ '{',
84
+ ...groupProps.map((p) => ` ${escapeString(p.name)}: ${p.type}`),
85
+ '}',
86
+ ].join('\n')
87
+ groupTypes.push(`AllOrNone<${some}>`)
88
+ }
89
+ collectTypes(t.tokens, false)
90
+ }
91
+ }
92
+ }
93
+
94
+ collectTypes(tokens)
95
+
96
+ const baseParamsType = [
97
+ '{',
98
+ ...baseProps.map((p) => ` ${escapeString(p.name)}: ${p.type}`),
99
+ '}',
100
+ ].join('\n')
101
+ const paramsType = groupTypes.length
102
+ ? `${baseParamsType} & ${groupTypes.join(' & ')}`
103
+ : baseParamsType
104
+
105
+ const lines: string[] = []
106
+ const indentUnit = ' '
107
+ const line = (indentLevel: number, text = ''): void => {
108
+ if (text === '') lines.push('')
109
+ else lines.push(indentUnit.repeat(indentLevel) + text)
110
+ }
111
+ const hasAnyParams = baseProps.length > 0 || groupTypes.length > 0
112
+
113
+ if (hasAnyParams) {
114
+ lines.push(`export function ${functionName}(`)
115
+ lines.push(` params: ${paramsType}`)
116
+ lines.push(`): string {`)
117
+ } else {
118
+ lines.push(`export function ${functionName}(): string {`)
119
+ }
120
+ line(1, `let sb = ""`)
121
+ line(0)
122
+
123
+ const delim = escapeString(delimiter)
124
+
125
+ function collectNames(ts2: any[]): string[] {
126
+ const names: string[] = []
127
+ for (const t of ts2) {
128
+ if (t.type === 'param' || t.type === 'wildcard') names.push(t.name)
129
+ else if (t.type === 'group') names.push(...collectNames(t.tokens))
130
+ }
131
+ return names
132
+ }
133
+
134
+ function emitTokens(ts2: any[], indentLevel: number, optional = false): void {
135
+ if (!optional && hasAnyParams) {
136
+ for (const t of ts2) {
137
+ if (t.type === 'param' || t.type === 'wildcard') {
138
+ line(
139
+ indentLevel,
140
+ `if (params[${escapeString(t.name)}] == null) throw new Error(${escapeString(`Missing param: ${t.name}`)})`,
141
+ )
142
+ }
143
+ }
144
+ }
145
+
146
+ for (const t of ts2) {
147
+ if (t.type === 'text') {
148
+ line(indentLevel, `sb += ${escapeString(t.value)}`)
149
+ } else if (t.type === 'param') {
150
+ line(indentLevel, `sb += (${encode})(String(params[${escapeString(t.name)}]))`)
151
+ } else if (t.type === 'wildcard') {
152
+ line(
153
+ indentLevel,
154
+ `sb += Array.from(params[${escapeString(t.name)}], v => (${encode})(String(v))).join(${delim})`,
155
+ )
156
+ } else if (t.type === 'group') {
157
+ const names = collectNames(t.tokens).map((name) => escapeString(name))
158
+ if (!names.length) continue
159
+ const all = names.map((n) => `params[${n}] != null`).join(' && ')
160
+ const none = names.map((n) => `params[${n}] == null`).join(' && ')
161
+ const list = names.join(', ')
162
+
163
+ line(indentLevel, `if (${all}) {`)
164
+ emitTokens(t.tokens, indentLevel + 1, true)
165
+ line(indentLevel, `} else if (!(${none})) {`)
166
+ line(
167
+ indentLevel + 1,
168
+ `throw new Error(${escapeString(`Group requires all-or-none: ${list}`)})`,
169
+ )
170
+ line(indentLevel, `}`)
171
+ }
172
+ }
173
+ }
174
+
175
+ emitTokens(tokens, 1)
176
+ line(0)
177
+ line(1, `return sb`)
178
+ lines.push(`}`)
179
+ return lines.join('\n')
180
+ }
181
+
182
+ function toRouteFunctionName(routeName: string): string {
183
+ const ident = routeName
184
+ .trim()
185
+ .replace(/[^a-zA-Z0-9_]/g, '_')
186
+ .replace(/^[^a-zA-Z_]+/, '')
187
+ return ident || 'route'
188
+ }
189
+
190
+ type ExtractedRoute = { name: string; pattern: string }
191
+
192
+ async function importRoutes(routesPath: string): Promise<readonly Route[]> {
193
+ const mod = (await import(pathToFileURL(routesPath).href)) as { default?: unknown }
194
+ if (!Array.isArray(mod.default)) {
195
+ throw new Error('Routes file must default export an array of routes.')
196
+ }
197
+ return mod.default as readonly Route[]
198
+ }
199
+
200
+ function extractRoutes(routes: readonly Route[]): ExtractedRoute[] {
201
+ return normalizeRoutes(routes).flatMap((route) => {
202
+ if (!route.name || typeof route.pattern !== 'string') return []
203
+ return [{ name: route.name, pattern: route.pattern }]
204
+ })
205
+ }
206
+
207
+ async function main(
208
+ options: Options,
209
+ positionals: Positionals,
210
+ {
211
+ cwd = process.cwd(),
212
+ commandName = 'rerouter',
213
+ commandArgs = process.argv.slice(2),
214
+ }: RunOptions = {},
215
+ ): Promise<RunResult> {
216
+ const [routesPathArg] = positionals
217
+ if (!routesPathArg) {
218
+ return {
219
+ exitCode: 1,
220
+ stdout: '',
221
+ stderr: 'Usage: rerouter <routes-file> [-o <output-file>] [-w] [--wildcard-delimiter <string>] [--encode-function <identifier>]\n',
222
+ }
223
+ }
224
+
225
+ const routesPath = path.resolve(cwd, routesPathArg)
226
+ let outputPath: string | undefined
227
+ if (options.output) {
228
+ outputPath = path.resolve(cwd, options.output as string)
229
+ } else if (options.write) {
230
+ outputPath = path.join(
231
+ path.dirname(routesPath),
232
+ path.basename(routesPath, path.extname(routesPath)) + '.gen.ts',
233
+ )
234
+ }
235
+
236
+ const routes = extractRoutes(await importRoutes(routesPath))
237
+ .map((r) => ({ ...r, pattern: r.pattern.trim() }))
238
+ .filter((r) => r.pattern.startsWith('/') && r.pattern !== '*')
239
+
240
+ const wildcardDelimiter = (options['wildcard-delimiter'] as string | undefined) ?? '/'
241
+ const encodeFunction =
242
+ (options['encode-function'] as string | undefined) ?? 'encodeURIComponent'
243
+
244
+ const commandText = [commandName, ...commandArgs.map(shellEscape)].join(' ')
245
+
246
+ const out: string[] = []
247
+ out.push(`// Do not modify this file. It was auto-generated with the following command:`)
248
+ out.push(`// $ ${commandText}`)
249
+ out.push(``)
250
+ out.push(`type AllOrNone<T> =`)
251
+ out.push(` | Required<T>`)
252
+ out.push(` | { [K in keyof T]?: never }`)
253
+ out.push(``)
254
+ out.push(`type ParamType = string | number | boolean`)
255
+ out.push(`type WildcardType = Iterable<ParamType>`)
256
+ out.push(``)
257
+
258
+ if (!routes.length) {
259
+ out.push(`// No string route patterns found in the default export.`)
260
+ out.push(``)
261
+ } else {
262
+ const usedNames = new Set<string>()
263
+ for (const route of routes) {
264
+ const base = toRouteFunctionName(route.name)
265
+ let name = base
266
+ let i = 2
267
+ while (usedNames.has(name)) {
268
+ name = `${base}_${i++}`
269
+ }
270
+ usedNames.add(name)
271
+
272
+ out.push(
273
+ compilePathGenerator(route.pattern, {
274
+ functionName: name,
275
+ delimiter: wildcardDelimiter,
276
+ encode: encodeFunction,
277
+ }),
278
+ )
279
+ out.push(``)
280
+ }
281
+ }
282
+
283
+ const finalOutput = out.join('\n')
284
+ if (outputPath) {
285
+ await fs.writeFile(outputPath, finalOutput, 'utf8')
286
+ return { stdout: '', stderr: `Wrote ${outputPath}\n` }
287
+ } else {
288
+ return { stdout: finalOutput, stderr: '' }
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Runs the rerouter CLI implementation without spawning a separate process.
294
+ *
295
+ * @param args - Command line arguments, excluding the binary name.
296
+ * @param options - Runtime options used to resolve paths and render the command comment.
297
+ * @returns Captured stdout, stderr, and an optional process exit code.
298
+ *
299
+ * @example
300
+ * ```ts
301
+ * const result = await runRerouterBin(['./routes.ts'])
302
+ * process.stdout.write(result.stdout)
303
+ * ```
304
+ *
305
+ * @internal
306
+ */
307
+ export async function runRerouterBin(
308
+ args: readonly string[],
309
+ options: RunOptions = {},
310
+ ): Promise<RunResult> {
311
+ const { values, positionals } = parseArgs({ ...PARSE_CONFIG, args: [...args] })
312
+ return main(values, positionals, { ...options, commandArgs: args })
313
+ }
314
+
315
+ //#region Invoke main
316
+ type ParsedConfig = ReturnType<typeof parseArgs<typeof PARSE_CONFIG>>
317
+ type Options = ParsedConfig['values']
318
+ type Positionals = ParsedConfig['positionals']
319
+
320
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
321
+ runRerouterBin(process.argv.slice(2)).then(
322
+ (result) => {
323
+ if (result.stdout) process.stdout.write(result.stdout)
324
+ if (result.stderr) process.stderr.write(result.stderr)
325
+ if (typeof result.exitCode === 'number') process.exitCode = result.exitCode
326
+ },
327
+ (err) => {
328
+ console.error(err ?? 'An unknown error occurred')
329
+ process.exitCode = 1
330
+ },
331
+ )
332
+ }
333
+ //#endregion
@@ -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,87 @@
1
+ import { cc, type ClassValue } from '@mpen/classcat'
2
+ import type { OverrideProps } from '@mpen/ts-types/react'
3
+ import 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
+ if (replace) {
76
+ replaceUrl(href)
77
+ } else {
78
+ pushUrl(href)
79
+ }
80
+ }
81
+
82
+ return (
83
+ <a {...rest} className={linkClassName || undefined} href={href} onClick={onClick}>
84
+ {children}
85
+ </a>
86
+ )
87
+ }
@@ -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
+ })