create-react-adam 0.2.2 → 0.4.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.
@@ -1,19 +1,30 @@
1
1
  @import "tailwindcss";
2
2
 
3
+ @font-face {
4
+ font-family: "Inter";
5
+ font-style: normal;
6
+ font-weight: 100 900;
7
+ font-display: swap;
8
+ src: url("/fonts/InterVariable.woff2") format("woff2");
9
+ }
10
+
3
11
  @theme {
4
- --font-sans: "Inter", sans-serif;
12
+ --font-sans:
13
+ "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
5
14
 
6
- --color-brand-primary: #3b82f6;
7
- --color-brand-primaryHover: #2563eb;
8
- --color-brand-primaryActive: #1d4ed8;
15
+ /* Brand colors are chosen to meet WCAG AA contrast (>= 4.5:1) with white
16
+ text; keep that in mind when changing them. */
17
+ --color-brand-primary: #2563eb;
18
+ --color-brand-primaryHover: #1d4ed8;
19
+ --color-brand-primaryActive: #1e40af;
9
20
 
10
- --color-brand-secondary: #8b5cf6;
11
- --color-brand-secondaryHover: #7c3aed;
12
- --color-brand-secondaryActive: #6d28d9;
21
+ --color-brand-secondary: #7c3aed;
22
+ --color-brand-secondaryHover: #6d28d9;
23
+ --color-brand-secondaryActive: #5b21b6;
13
24
 
14
- --color-brand-success: #22c55e;
25
+ --color-brand-success: #15803d;
15
26
  --color-brand-warning: #f59e0b;
16
- --color-brand-danger: #ef4444;
27
+ --color-brand-danger: #dc2626;
17
28
 
18
29
  --color-brand-black: #0f172a;
19
30
  --color-brand-white: #ffffff;
@@ -27,3 +38,10 @@
27
38
 
28
39
  --color-brand-disabled: #94a3b8;
29
40
  }
41
+
42
+ @layer base {
43
+ :focus-visible {
44
+ outline: 2px solid var(--color-brand-primary);
45
+ outline-offset: 2px;
46
+ }
47
+ }
@@ -0,0 +1,27 @@
1
+ import { ComponentPropsWithoutRef } from "react";
2
+ import { ButtonVariant } from "../types/ui";
3
+
4
+ interface ButtonProps extends ComponentPropsWithoutRef<"button"> {
5
+ variant?: ButtonVariant;
6
+ }
7
+
8
+ const variantClasses: Record<ButtonVariant, string> = {
9
+ primary: "bg-brand-primary hover:bg-brand-primaryHover",
10
+ success: "bg-brand-success hover:bg-brand-success/90",
11
+ danger: "bg-brand-danger hover:bg-brand-danger/90",
12
+ };
13
+
14
+ const Button = ({ variant = "primary", className, ...props }: ButtonProps) => (
15
+ <button
16
+ className={[
17
+ "text-brand-white rounded-lg px-4 py-2 transition-colors",
18
+ variantClasses[variant],
19
+ className,
20
+ ]
21
+ .filter(Boolean)
22
+ .join(" ")}
23
+ {...props}
24
+ />
25
+ );
26
+
27
+ export default Button;
@@ -0,0 +1,49 @@
1
+ import type { ComponentProps, FocusEvent, MouseEvent, TouchEvent } from "react";
2
+ import { Link } from "wouter";
3
+ import { preloadRoute } from "../routes";
4
+
5
+ // Drop-in replacement for wouter's <Link> that preloads the target page's
6
+ // chunk as soon as the user shows intent to navigate (hover, keyboard focus,
7
+ // or touch). By the time the click lands, the chunk is usually in flight or
8
+ // already cached, so navigation feels instant. See src/routes.ts for the
9
+ // full preloading picture.
10
+ //
11
+ // HOW TO REMOVE: swap <PreloadLink> back to wouter's <Link> wherever it is
12
+ // used (the props are identical), delete this file, and follow the removal
13
+ // steps in src/routes.ts.
14
+
15
+ // Mirrors wouter's <Link> props in its plain-anchor form. wouter's asChild
16
+ // variant and `to` alias are not supported here — use wouter's <Link>
17
+ // directly if you need those.
18
+ type PreloadLinkProps = Omit<ComponentProps<"a">, "className" | "href"> & {
19
+ href: string;
20
+ className?: string | ((isActive: boolean) => string | undefined);
21
+ replace?: boolean;
22
+ state?: unknown;
23
+ };
24
+
25
+ const PreloadLink = (props: PreloadLinkProps) => {
26
+ const handleIntent = () => {
27
+ preloadRoute(props.href);
28
+ };
29
+
30
+ return (
31
+ <Link
32
+ {...props}
33
+ onMouseEnter={(event: MouseEvent<HTMLAnchorElement>) => {
34
+ handleIntent();
35
+ props.onMouseEnter?.(event);
36
+ }}
37
+ onFocus={(event: FocusEvent<HTMLAnchorElement>) => {
38
+ handleIntent();
39
+ props.onFocus?.(event);
40
+ }}
41
+ onTouchStart={(event: TouchEvent<HTMLAnchorElement>) => {
42
+ handleIntent();
43
+ props.onTouchStart?.(event);
44
+ }}
45
+ />
46
+ );
47
+ };
48
+
49
+ export default PreloadLink;
@@ -1,6 +1,11 @@
1
- import { Link } from "wouter";
1
+ import { useEffect } from "react";
2
+ import PreloadLink from "../../components/PreloadLink";
2
3
 
3
4
  const About = () => {
5
+ useEffect(() => {
6
+ document.title = "About | __PROJECT_NAME__";
7
+ }, []);
8
+
4
9
  return (
5
10
  <div className="bg-brand-background flex min-h-screen flex-col items-center justify-center">
6
11
  <div className="mx-auto max-w-2xl px-4 text-center">
@@ -25,12 +30,12 @@ const About = () => {
25
30
  </ul>
26
31
  </div>
27
32
 
28
- <Link
33
+ <PreloadLink
29
34
  href="/"
30
35
  className="bg-brand-primary text-brand-white hover:bg-brand-primaryHover rounded-lg px-6 py-3 transition-colors"
31
36
  >
32
37
  Back to Home
33
- </Link>
38
+ </PreloadLink>
34
39
  </div>
35
40
  </div>
36
41
  );
@@ -1,4 +1,6 @@
1
- import { Link } from "wouter";
1
+ import { useEffect } from "react";
2
+ import Button from "../../components/Button";
3
+ import PreloadLink from "../../components/PreloadLink";
2
4
  import { useReactPersist } from "../../utils/Storage";
3
5
  import { useUrlState } from "../../utils/useUrlState";
4
6
 
@@ -6,6 +8,10 @@ const About = () => {
6
8
  const [sharedCount, setSharedCount] = useReactPersist("sharedCounter", 0);
7
9
  const [urlCount, setUrlCount] = useUrlState("counter", 0);
8
10
 
11
+ useEffect(() => {
12
+ document.title = "About | __PROJECT_NAME__";
13
+ }, []);
14
+
9
15
  return (
10
16
  <div className="bg-brand-background flex min-h-screen flex-col items-center justify-center">
11
17
  <div className="mx-auto max-w-2xl px-4 text-center">
@@ -26,21 +32,23 @@ const About = () => {
26
32
  This counter is shared between Home and About pages
27
33
  </p>
28
34
  <div className="flex items-center justify-center gap-4">
29
- <button
35
+ <Button
36
+ variant="danger"
37
+ aria-label="Decrease shared counter"
30
38
  onClick={() => setSharedCount(sharedCount - 1)}
31
- className="bg-brand-danger text-brand-white hover:bg-brand-danger/90 rounded-lg px-4 py-2 transition-colors"
32
39
  >
33
40
  -
34
- </button>
41
+ </Button>
35
42
  <span className="text-brand-black text-3xl font-bold">
36
43
  {sharedCount}
37
44
  </span>
38
- <button
45
+ <Button
46
+ variant="success"
47
+ aria-label="Increase shared counter"
39
48
  onClick={() => setSharedCount(sharedCount + 1)}
40
- className="bg-brand-success text-brand-white hover:bg-brand-success/90 rounded-lg px-4 py-2 transition-colors"
41
49
  >
42
50
  +
43
- </button>
51
+ </Button>
44
52
  </div>
45
53
  </div>
46
54
 
@@ -52,21 +60,23 @@ const About = () => {
52
60
  This counter syncs with URL parameters
53
61
  </p>
54
62
  <div className="flex items-center justify-center gap-4">
55
- <button
63
+ <Button
64
+ variant="danger"
65
+ aria-label="Decrease URL counter"
56
66
  onClick={() => setUrlCount(urlCount - 1)}
57
- className="bg-brand-danger text-brand-white hover:bg-brand-danger/90 rounded-lg px-4 py-2 transition-colors"
58
67
  >
59
68
  -
60
- </button>
69
+ </Button>
61
70
  <span className="text-brand-black text-3xl font-bold">
62
71
  {urlCount}
63
72
  </span>
64
- <button
73
+ <Button
74
+ variant="success"
75
+ aria-label="Increase URL counter"
65
76
  onClick={() => setUrlCount(urlCount + 1)}
66
- className="bg-brand-success text-brand-white hover:bg-brand-success/90 rounded-lg px-4 py-2 transition-colors"
67
77
  >
68
78
  +
69
- </button>
79
+ </Button>
70
80
  </div>
71
81
  </div>
72
82
  </div>
@@ -83,12 +93,12 @@ const About = () => {
83
93
  <li>✨ ESLint & Prettier configured</li>
84
94
  </ul>
85
95
  </div>
86
- <Link
96
+ <PreloadLink
87
97
  href="/"
88
98
  className="bg-brand-primary text-brand-white hover:bg-brand-primaryHover rounded-lg px-6 py-3 transition-colors"
89
99
  >
90
100
  Back to Home
91
- </Link>
101
+ </PreloadLink>
92
102
  </div>
93
103
  </div>
94
104
  );
@@ -1,6 +1,14 @@
1
- import { Link } from "wouter";
1
+ import { useEffect, useState } from "react";
2
+ import Button from "../../components/Button";
3
+ import PreloadLink from "../../components/PreloadLink";
2
4
 
3
5
  const Home = () => {
6
+ const [count, setCount] = useState(0);
7
+
8
+ useEffect(() => {
9
+ document.title = "__PROJECT_NAME__";
10
+ }, []);
11
+
4
12
  return (
5
13
  <div className="bg-brand-background flex min-h-screen flex-col items-center justify-center">
6
14
  <div className="mx-auto max-w-2xl px-4 text-center">
@@ -11,7 +19,7 @@ const Home = () => {
11
19
  Built with React, TypeScript, Vite, Wouter, and Tailwind CSS
12
20
  </p>
13
21
 
14
- <div className="mb-8">
22
+ <div className="mb-8 space-y-6">
15
23
  <div className="bg-brand-white rounded-lg p-6 shadow-md">
16
24
  <h2 className="text-brand-black mb-4 text-2xl font-semibold">
17
25
  Get Started
@@ -27,15 +35,43 @@ const Home = () => {
27
35
  This is a clean starting point for your React application.
28
36
  </p>
29
37
  </div>
38
+
39
+ <div className="bg-brand-white rounded-lg p-6 shadow-md">
40
+ <h2 className="text-brand-black mb-4 text-2xl font-semibold">
41
+ Counter
42
+ </h2>
43
+ <p className="text-brand-gray mb-4">
44
+ A simple counter using the shared Button component
45
+ </p>
46
+ <div className="flex items-center justify-center gap-4">
47
+ <Button
48
+ variant="danger"
49
+ aria-label="Decrease counter"
50
+ onClick={() => setCount(count - 1)}
51
+ >
52
+ -
53
+ </Button>
54
+ <span className="text-brand-black text-3xl font-bold">
55
+ {count}
56
+ </span>
57
+ <Button
58
+ variant="success"
59
+ aria-label="Increase counter"
60
+ onClick={() => setCount(count + 1)}
61
+ >
62
+ +
63
+ </Button>
64
+ </div>
65
+ </div>
30
66
  </div>
31
67
 
32
68
  <div className="flex justify-center gap-4">
33
- <Link
69
+ <PreloadLink
34
70
  href="/about"
35
71
  className="bg-brand-primary text-brand-white hover:bg-brand-primaryHover rounded-lg px-6 py-3 transition-colors"
36
72
  >
37
73
  About Page
38
- </Link>
74
+ </PreloadLink>
39
75
  <a
40
76
  href="https://react.dev"
41
77
  target="_blank"
@@ -1,4 +1,6 @@
1
- import { Link } from "wouter";
1
+ import { useEffect } from "react";
2
+ import Button from "../../components/Button";
3
+ import PreloadLink from "../../components/PreloadLink";
2
4
  import { useReactPersist } from "../../utils/Storage";
3
5
  import { useUrlState } from "../../utils/useUrlState";
4
6
 
@@ -6,6 +8,10 @@ const Home = () => {
6
8
  const [sharedCount, setSharedCount] = useReactPersist("sharedCounter", 0);
7
9
  const [urlCount, setUrlCount] = useUrlState("counter", 0);
8
10
 
11
+ useEffect(() => {
12
+ document.title = "__PROJECT_NAME__";
13
+ }, []);
14
+
9
15
  return (
10
16
  <div className="bg-brand-background flex min-h-screen flex-col items-center justify-center">
11
17
  <div className="mx-auto max-w-2xl px-4 text-center">
@@ -25,21 +31,23 @@ const Home = () => {
25
31
  This counter is shared between Home and About pages
26
32
  </p>
27
33
  <div className="flex items-center justify-center gap-4">
28
- <button
34
+ <Button
35
+ variant="danger"
36
+ aria-label="Decrease shared counter"
29
37
  onClick={() => setSharedCount(sharedCount - 1)}
30
- className="bg-brand-danger text-brand-white hover:bg-brand-danger/90 rounded-lg px-4 py-2 transition-colors"
31
38
  >
32
39
  -
33
- </button>
40
+ </Button>
34
41
  <span className="text-brand-black text-3xl font-bold">
35
42
  {sharedCount}
36
43
  </span>
37
- <button
44
+ <Button
45
+ variant="success"
46
+ aria-label="Increase shared counter"
38
47
  onClick={() => setSharedCount(sharedCount + 1)}
39
- className="bg-brand-success text-brand-white hover:bg-brand-success/90 rounded-lg px-4 py-2 transition-colors"
40
48
  >
41
49
  +
42
- </button>
50
+ </Button>
43
51
  </div>
44
52
  </div>
45
53
 
@@ -51,32 +59,34 @@ const Home = () => {
51
59
  This counter syncs with URL parameters
52
60
  </p>
53
61
  <div className="flex items-center justify-center gap-4">
54
- <button
62
+ <Button
63
+ variant="danger"
64
+ aria-label="Decrease URL counter"
55
65
  onClick={() => setUrlCount(urlCount - 1)}
56
- className="bg-brand-danger text-brand-white hover:bg-brand-danger/90 rounded-lg px-4 py-2 transition-colors"
57
66
  >
58
67
  -
59
- </button>
68
+ </Button>
60
69
  <span className="text-brand-black text-3xl font-bold">
61
70
  {urlCount}
62
71
  </span>
63
- <button
72
+ <Button
73
+ variant="success"
74
+ aria-label="Increase URL counter"
64
75
  onClick={() => setUrlCount(urlCount + 1)}
65
- className="bg-brand-success text-brand-white hover:bg-brand-success/90 rounded-lg px-4 py-2 transition-colors"
66
76
  >
67
77
  +
68
- </button>
78
+ </Button>
69
79
  </div>
70
80
  </div>
71
81
  </div>
72
82
 
73
83
  <div className="flex justify-center gap-4">
74
- <Link
84
+ <PreloadLink
75
85
  href="/about"
76
86
  className="bg-brand-primary text-brand-white hover:bg-brand-primaryHover rounded-lg px-6 py-3 transition-colors"
77
87
  >
78
88
  About Page
79
- </Link>
89
+ </PreloadLink>
80
90
  <a
81
91
  href="https://react.dev"
82
92
  target="_blank"
@@ -1,23 +1,30 @@
1
- import { Link } from "wouter";
1
+ import { useEffect } from "react";
2
+ import PreloadLink from "../../components/PreloadLink";
2
3
 
3
- const NotFound = () => (
4
- <div className="bg-brand-background flex min-h-screen flex-col items-center justify-center">
5
- <div className="text-center">
6
- <h1 className="text-brand-black text-9xl font-bold">404</h1>
7
- <p className="text-brand-black mt-4 text-2xl font-semibold">
8
- Page Not Found
9
- </p>
10
- <p className="text-brand-gray mt-2">
11
- The page you're looking for doesn't exist.
12
- </p>
13
- <Link
14
- href="/"
15
- className="bg-brand-primary text-brand-white hover:bg-brand-primaryHover mt-6 inline-block rounded-lg px-6 py-3 transition-colors"
16
- >
17
- Go Home
18
- </Link>
4
+ const NotFound = () => {
5
+ useEffect(() => {
6
+ document.title = "Page Not Found | __PROJECT_NAME__";
7
+ }, []);
8
+
9
+ return (
10
+ <div className="bg-brand-background flex min-h-screen flex-col items-center justify-center">
11
+ <div className="text-center">
12
+ <h1 className="text-brand-black text-9xl font-bold">404</h1>
13
+ <p className="text-brand-black mt-4 text-2xl font-semibold">
14
+ Page Not Found
15
+ </p>
16
+ <p className="text-brand-gray mt-2">
17
+ The page you're looking for doesn't exist.
18
+ </p>
19
+ <PreloadLink
20
+ href="/"
21
+ className="bg-brand-primary text-brand-white hover:bg-brand-primaryHover mt-6 inline-block rounded-lg px-6 py-3 transition-colors"
22
+ >
23
+ Go Home
24
+ </PreloadLink>
25
+ </div>
19
26
  </div>
20
- </div>
21
- );
27
+ );
28
+ };
22
29
 
23
30
  export default NotFound;
@@ -0,0 +1,94 @@
1
+ import type { ComponentType } from "react";
2
+
3
+ // Route-level code splitting + preloading.
4
+ //
5
+ // This file is the single source of truth for every page's dynamic import.
6
+ // App.tsx feeds these thunks to React.lazy() for routing, and the preload
7
+ // helpers below fire the same thunks ahead of navigation. Because dynamic
8
+ // imports are deduped by module, a preloaded chunk is never downloaded
9
+ // twice — navigating just picks up the in-flight or already-cached module.
10
+ //
11
+ // Preloading happens in two layers:
12
+ // 1. Intent: <PreloadLink> (src/components/PreloadLink.tsx) calls
13
+ // preloadRoute() on hover/focus/touch. Hover-to-click is typically
14
+ // 200-400ms — enough for a small page chunk to arrive before the click.
15
+ // 2. Idle backstop: preloadAllRoutesWhenIdle() (called once from App.tsx)
16
+ // fetches every remaining chunk after the window `load` event, one at a
17
+ // time during idle periods, so it never competes with first-paint
18
+ // resources (or your Lighthouse score).
19
+ //
20
+ // HOW TO REMOVE PRELOADING:
21
+ // 1. In App.tsx: delete the preloadAllRoutesWhenIdle() effect and the
22
+ // useDeferredValue lines, and inline the imports back into lazy(),
23
+ // e.g. const Home = lazy(() => import("./pages/Home"));
24
+ // 2. In src/pages/: replace <PreloadLink> with wouter's <Link>.
25
+ // 3. Delete this file and src/components/PreloadLink.tsx.
26
+
27
+ type PageLoader = () => Promise<{ default: ComponentType }>;
28
+
29
+ export const routeImports: Record<string, PageLoader> = {
30
+ "/": () => import("./pages/Home"),
31
+ "/about": () => import("./pages/About"),
32
+ // Not a real link target — keyed by a pseudo-path so the idle prefetcher
33
+ // warms the 404 page too.
34
+ "/404": () => import("./pages/NotFound"),
35
+ };
36
+
37
+ // Fires a route's import ahead of navigation. Safe to call repeatedly and
38
+ // with paths that are not in the map (external links, dynamic segments) —
39
+ // those are a no-op.
40
+ export function preloadRoute(path: string): void {
41
+ const load = routeImports[path];
42
+ if (!load) return;
43
+ load().catch(() => {
44
+ // Preloading is opportunistic: if it fails (offline, a deploy replaced
45
+ // the chunk), the real import on navigation will surface the error.
46
+ });
47
+ }
48
+
49
+ let hasStartedIdlePreload = false;
50
+
51
+ // Fetches every route chunk after the page has fully loaded, one chunk at a
52
+ // time during browser idle periods. Skips entirely for users on data-saver
53
+ // or 2G connections.
54
+ export function preloadAllRoutesWhenIdle(): void {
55
+ // React StrictMode runs effects twice in development — only start once.
56
+ if (hasStartedIdlePreload) return;
57
+ hasStartedIdlePreload = true;
58
+
59
+ const { connection } = navigator as Navigator & {
60
+ connection?: { saveData?: boolean; effectiveType?: string };
61
+ };
62
+ if (connection?.saveData === true) return;
63
+ if (connection?.effectiveType?.endsWith("2g") === true) return;
64
+
65
+ // Safari has no requestIdleCallback; a timeout is a close-enough stand-in.
66
+ const scheduleIdle = (callback: () => void): void => {
67
+ if (typeof window.requestIdleCallback === "function") {
68
+ window.requestIdleCallback(callback);
69
+ } else {
70
+ window.setTimeout(callback, 1500);
71
+ }
72
+ };
73
+
74
+ const queue = Object.values(routeImports);
75
+ const loadNext = async (): Promise<void> => {
76
+ const load = queue.shift();
77
+ if (!load) return;
78
+ try {
79
+ await load();
80
+ } catch {
81
+ // Ignore failures here for the same reason as preloadRoute.
82
+ }
83
+ scheduleIdle(() => void loadNext());
84
+ };
85
+
86
+ // Waiting for `load` keeps prefetching out of the critical path: every
87
+ // first-paint resource is done before the first idle callback runs.
88
+ const start = (): void => scheduleIdle(() => void loadNext());
89
+ if (document.readyState === "complete") {
90
+ start();
91
+ } else {
92
+ window.addEventListener("load", start, { once: true });
93
+ }
94
+ }
@@ -0,0 +1 @@
1
+ export type ButtonVariant = "primary" | "success" | "danger";
@@ -1,3 +1,24 @@
1
- export default function classNames(...classes: string[]) {
2
- return classes.filter(Boolean).join(" ");
1
+ type ClassValue = string | false | null | undefined | Record<string, boolean>;
2
+
3
+ export default function classNames(...classes: ClassValue[]): string {
4
+ const result: string[] = [];
5
+
6
+ for (const entry of classes) {
7
+ if (!entry) {
8
+ continue;
9
+ }
10
+
11
+ if (typeof entry === "string") {
12
+ result.push(entry);
13
+ continue;
14
+ }
15
+
16
+ for (const [name, isEnabled] of Object.entries(entry)) {
17
+ if (isEnabled) {
18
+ result.push(name);
19
+ }
20
+ }
21
+ }
22
+
23
+ return result.join(" ");
3
24
  }
@@ -0,0 +1,22 @@
1
+ // Browsers store setTimeout delays as a 32-bit signed integer, so any delay
2
+ // above 2^31 - 1 ms (~24.8 days) overflows and fires immediately.
3
+ const MAX_TIMEOUT_MS = 2_147_483_647;
4
+
5
+ /**
6
+ * Drop-in replacement for setTimeout that clamps delays larger than the
7
+ * maximum 32-bit signed integer (2,147,483,647 ms / ~24.8 days) instead of
8
+ * letting them overflow and fire immediately.
9
+ */
10
+ export function safeTimeout<TArgs extends unknown[]>(
11
+ callback: (...callbackArgs: TArgs) => void,
12
+ delay: number,
13
+ ...args: TArgs
14
+ ): ReturnType<typeof globalThis.setTimeout> {
15
+ if (delay > MAX_TIMEOUT_MS) {
16
+ console.error(
17
+ `safeTimeout: delay ${delay} exceeds the maximum of ${MAX_TIMEOUT_MS} ms (~24.8 days). Using the maximum value.`,
18
+ );
19
+ delay = MAX_TIMEOUT_MS;
20
+ }
21
+ return globalThis.setTimeout(callback, delay, ...args);
22
+ }
@@ -4,16 +4,28 @@ import { useLocation, useSearch } from "wouter";
4
4
  export function useUrlState(
5
5
  key: string,
6
6
  defaultValue: number,
7
- ): [number, (value: number) => void] {
7
+ ): [number, (value: number) => void];
8
+ export function useUrlState(
9
+ key: string,
10
+ defaultValue: string,
11
+ ): [string, (value: string) => void];
12
+ export function useUrlState(
13
+ key: string,
14
+ defaultValue: string | number,
15
+ ): [string, (value: string) => void] | [number, (value: number) => void] {
8
16
  const [, setLocation] = useLocation();
9
17
  const searchParams = useSearch();
10
18
 
11
19
  const params = new URLSearchParams(searchParams);
12
20
  const urlValue = params.get(key);
13
- const value = urlValue ? Number.parseInt(urlValue, 10) : defaultValue;
21
+ const value = urlValue
22
+ ? typeof defaultValue === "number"
23
+ ? Number.parseInt(urlValue, 10)
24
+ : urlValue
25
+ : defaultValue;
14
26
 
15
27
  const setValue = useCallback(
16
- (newValue: number) => {
28
+ (newValue: string | number) => {
17
29
  const currentParams = new URLSearchParams(globalThis.location.search);
18
30
  currentParams.set(key, newValue.toString());
19
31
  setLocation(
@@ -23,5 +35,6 @@ export function useUrlState(
23
35
  [key, setLocation],
24
36
  );
25
37
 
26
- return [value, setValue];
38
+ // The overloads guarantee value/setValue agree with the default's type.
39
+ return [value, setValue] as [string, (value: string) => void];
27
40
  }