@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.
- package/README.md +76 -18
- package/dist/bin.d.ts +29 -0
- package/dist/bin.js +228 -0
- package/dist/hooks-Dlwcb0sV.js +20 -0
- package/dist/hooks.d.ts +2 -0
- package/dist/hooks.js +2 -0
- package/dist/index-BYXpNitc.d.ts +5 -0
- package/dist/index.d.ts +265 -0
- package/dist/index.js +139 -0
- package/dist/routes-Hpf6cwcZ.js +135 -0
- package/examples/App.tsx +111 -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 +125 -0
- package/examples/routes.ts +40 -0
- package/package.json +37 -32
- package/src/bin.test.ts +199 -0
- package/src/bin.ts +333 -0
- package/src/components/Link.test.tsx +139 -0
- package/src/components/Link.tsx +87 -0
- package/src/components/NavLink.test.tsx +119 -0
- package/src/components/NavLink.tsx +71 -0
- package/src/components/Router.tsx +75 -0
- package/src/fixtures/bin/kitchen-sink.tsx +15 -0
- package/src/fixtures/bin/optional.tsx +3 -0
- package/src/fixtures/bin/pages/Home.tsx +3 -0
- package/src/fixtures/bin/pages/KitchenSink.tsx +3 -0
- package/src/fixtures/bin/pages/Login.tsx +3 -0
- package/src/fixtures/bin/pages/Match.tsx +3 -0
- package/src/fixtures/bin/pages/NotFound.tsx +3 -0
- package/src/fixtures/bin/pages/Optional.tsx +3 -0
- package/src/fixtures/bin/regexp-groups.tsx +11 -0
- package/src/fixtures/bin/simple.tsx +1 -0
- package/src/fixtures/bin/unnamed.tsx +4 -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 +245 -0
- package/src/lib/url.ts +9 -0
- package/tsconfig.json +9 -0
- package/tsdown.config.ts +22 -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
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
|
+
})
|