@ponti-studios/ui 0.1.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.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -7
  3. package/THIRD_PARTY_NOTICES.md +17 -0
  4. package/dist/index.js +988 -640
  5. package/dist/index.js.map +1 -1
  6. package/package.json +41 -41
  7. package/src/animations.css +42 -383
  8. package/src/components/data-display/accordion.tsx +22 -10
  9. package/src/components/data-display/filter-chip.stories.tsx +38 -0
  10. package/src/components/data-display/filter-chip.tsx +10 -4
  11. package/src/components/data-display/index.ts +5 -0
  12. package/src/components/data-display/metric-card.stories.tsx +48 -0
  13. package/src/components/data-display/metric-card.tsx +20 -0
  14. package/src/components/data-display/sort-controls.stories.tsx +45 -0
  15. package/src/components/data-display/sort-controls.tsx +99 -0
  16. package/src/components/data-display/sort-row.tsx +74 -0
  17. package/src/components/data-display/table.tsx +5 -9
  18. package/src/components/feedback/alert.tsx +4 -4
  19. package/src/components/feedback/empty-state.tsx +16 -6
  20. package/src/components/feedback/file-upload-status-badge.stories.tsx +28 -0
  21. package/src/components/feedback/file-upload-status-badge.tsx +33 -0
  22. package/src/components/feedback/file-upload-status.stories.tsx +57 -0
  23. package/src/components/feedback/file-upload-status.tsx +152 -0
  24. package/src/components/feedback/index.ts +9 -0
  25. package/src/components/feedback/progress-bar.stories.tsx +39 -0
  26. package/src/components/feedback/progress-bar.tsx +21 -0
  27. package/src/components/feedback/progress.stories.tsx +8 -6
  28. package/src/components/feedback/progress.tsx +3 -3
  29. package/src/components/feedback/skeleton.stories.tsx +36 -0
  30. package/src/components/feedback/skeleton.tsx +7 -0
  31. package/src/components/feedback/spinner.tsx +2 -2
  32. package/src/components/feedback/update-guard.stories.tsx +32 -0
  33. package/src/components/feedback/update-guard.tsx +280 -0
  34. package/src/components/forms/calendar.tsx +14 -11
  35. package/src/components/forms/date-month-select.stories.tsx +26 -0
  36. package/src/components/forms/date-month-select.tsx +70 -0
  37. package/src/components/forms/date-picker.tsx +3 -3
  38. package/src/components/forms/drop-zone.stories.tsx +56 -0
  39. package/src/components/forms/drop-zone.tsx +146 -0
  40. package/src/components/forms/entity-select.stories.tsx +65 -0
  41. package/src/components/forms/entity-select.tsx +79 -0
  42. package/src/components/forms/field.tsx +3 -3
  43. package/src/components/forms/group-by-select.stories.tsx +26 -0
  44. package/src/components/forms/group-by-select.tsx +47 -0
  45. package/src/components/forms/index.ts +10 -0
  46. package/src/components/forms/input.stories.tsx +4 -4
  47. package/src/components/forms/input.tsx +1 -1
  48. package/src/components/forms/passkey-management.stories.tsx +81 -0
  49. package/src/components/forms/passkey-management.tsx +138 -0
  50. package/src/components/forms/radio-group.tsx +14 -13
  51. package/src/components/forms/select.stories.tsx +4 -4
  52. package/src/components/forms/select.tsx +47 -48
  53. package/src/components/forms/slider.stories.tsx +5 -2
  54. package/src/components/forms/slider.tsx +39 -17
  55. package/src/components/forms/stepper.tsx +4 -4
  56. package/src/components/forms/switch.stories.tsx +1 -1
  57. package/src/components/forms/switch.tsx +2 -2
  58. package/src/components/forms/textarea.stories.tsx +2 -2
  59. package/src/components/forms/textarea.tsx +1 -1
  60. package/src/components/layout/index.ts +3 -0
  61. package/src/components/layout/scroll-area.stories.tsx +4 -4
  62. package/src/components/layout/scroll-area.tsx +23 -21
  63. package/src/components/layout/section-intro.stories.tsx +29 -0
  64. package/src/components/layout/section-intro.tsx +31 -0
  65. package/src/components/layout/surface-panel.stories.tsx +32 -0
  66. package/src/components/layout/surface-panel.tsx +15 -0
  67. package/src/components/navigation/app-navigation.stories.tsx +2 -2
  68. package/src/components/navigation/app-navigation.tsx +13 -13
  69. package/src/components/navigation/index.ts +4 -0
  70. package/src/components/navigation/pagination-controls.stories.tsx +41 -0
  71. package/src/components/navigation/pagination-controls.tsx +50 -0
  72. package/src/components/navigation/route-link.stories.tsx +30 -0
  73. package/src/components/navigation/route-link.tsx +42 -0
  74. package/src/components/navigation/tabs.stories.tsx +5 -5
  75. package/src/components/navigation/tabs.tsx +8 -8
  76. package/src/components/overlays/alert-dialog.tsx +22 -25
  77. package/src/components/overlays/command.tsx +98 -26
  78. package/src/components/overlays/dialog.tsx +5 -5
  79. package/src/components/overlays/dropdown-menu.stories.tsx +0 -5
  80. package/src/components/overlays/dropdown-menu.tsx +49 -31
  81. package/src/components/overlays/popover.stories.tsx +2 -2
  82. package/src/components/overlays/popover.tsx +43 -14
  83. package/src/components/overlays/sheet.stories.tsx +2 -2
  84. package/src/components/overlays/sheet.tsx +51 -17
  85. package/src/components/primitives/avatar.tsx +9 -5
  86. package/src/components/primitives/badge.tsx +11 -12
  87. package/src/components/primitives/button.tsx +49 -23
  88. package/src/components/primitives/card.stories.tsx +2 -2
  89. package/src/components/primitives/card.tsx +2 -5
  90. package/src/components/primitives/index.ts +2 -0
  91. package/src/components/primitives/label.stories.tsx +1 -1
  92. package/src/components/primitives/label.tsx +5 -6
  93. package/src/components/primitives/status-badge.stories.tsx +52 -0
  94. package/src/components/primitives/status-badge.tsx +33 -0
  95. package/src/constants/chart-colors.ts +7 -7
  96. package/src/hooks/index.ts +1 -0
  97. package/src/hooks/use-sort.ts +20 -0
  98. package/src/index.ts +30 -11
  99. package/src/styles.css +100 -974
  100. package/src/tokens/chart-colors.stories.tsx +192 -0
  101. package/src/tokens/color-systems.stories.tsx +20 -112
  102. package/src/tokens/foundations.stories.tsx +58 -100
  103. package/src/tokens/generated/dark.colors.generated.ts +29 -0
  104. package/src/tokens/generated/dark.css +29 -0
  105. package/src/tokens/generated/foundations.css +148 -0
  106. package/src/tokens/generated/foundations.generated.ts +451 -0
  107. package/src/tokens/generated/light.colors.generated.ts +29 -0
  108. package/src/tokens/generated/light.css +29 -0
  109. package/src/tokens/index.ts +79 -16
  110. package/src/tokens/layout.stories.tsx +23 -91
  111. package/src/tokens/colors.ts +0 -156
  112. package/src/tokens/motion.ts +0 -51
  113. package/src/tokens/radii.ts +0 -10
  114. package/src/tokens/shadows.ts +0 -34
  115. package/src/tokens/spacing.ts +0 -30
  116. package/src/tokens/typography.ts +0 -42
@@ -0,0 +1,152 @@
1
+ import { XIcon } from "lucide-react";
2
+ import { memo } from "react";
3
+
4
+ import { cn } from "../../lib/utils";
5
+ import { ProgressBar } from "./progress-bar";
6
+
7
+ export interface FileUploadStats {
8
+ progress?: number;
9
+ processingTime?: number;
10
+ total?: number;
11
+ created?: number;
12
+ updated?: number;
13
+ skipped?: number;
14
+ merged?: number;
15
+ invalid?: number;
16
+ errors?: unknown[];
17
+ }
18
+
19
+ export interface FileUploadState {
20
+ status: "uploading" | "processing" | "queued" | "done" | "error" | (string & {});
21
+ error?: string;
22
+ stats?: FileUploadStats;
23
+ }
24
+
25
+ export const FileUploadStatus = memo(function FileUploadStatus({
26
+ uploadStatus,
27
+ }: {
28
+ uploadStatus?: FileUploadState;
29
+ }) {
30
+ if (!uploadStatus) return null;
31
+ const { status, error, stats } = uploadStatus;
32
+
33
+ if (status === "uploading" || status === "processing") {
34
+ const progress = stats?.progress;
35
+
36
+ return (
37
+ <output className="w-full">
38
+ <div className="mb-1.5 flex items-center justify-between">
39
+ <span className="text-muted-foreground text-sm font-medium">
40
+ {status === "uploading" ? "Uploading" : "Processing"}
41
+ </span>
42
+ <span className="text-foreground text-sm font-medium">
43
+ {progress !== undefined ? `${Math.round(progress)}%` : ""}
44
+ </span>
45
+ </div>
46
+ <div className="w-full">
47
+ {typeof progress === "number" ? (
48
+ <ProgressBar
49
+ progress={progress}
50
+ className={cn("bg-muted h-2", "before:bg-emphasis-high")}
51
+ />
52
+ ) : (
53
+ <div className="bg-muted h-2 overflow-hidden">
54
+ <div className="bg-emphasis-high void-anim-breezy-progress h-full" />
55
+ </div>
56
+ )}
57
+ </div>
58
+ </output>
59
+ );
60
+ }
61
+
62
+ if (status === "queued") {
63
+ return (
64
+ <output className="w-full">
65
+ <div className="mb-1.5 flex items-center justify-between">
66
+ <span className="text-muted-foreground text-sm font-medium">Queue position</span>
67
+ </div>
68
+ <div className="w-full">
69
+ <ProgressBar progress={0} className="bg-warning-subtle before:bg-warning h-2" />
70
+ </div>
71
+ </output>
72
+ );
73
+ }
74
+
75
+ if (status === "done") {
76
+ return (
77
+ <output className="space-y-3">
78
+ <div className="w-full">
79
+ <ProgressBar progress={100} className="bg-emphasis-minimal before:bg-emphasis-high h-2" />
80
+ </div>
81
+ {stats && <ProcessingStats stats={stats} />}
82
+ </output>
83
+ );
84
+ }
85
+
86
+ if (status === "error") {
87
+ return (
88
+ <output className="mt-2" role="alert">
89
+ <div className="bg-destructive/10 text-destructive flex items-start gap-2 p-3">
90
+ <XIcon className="mt-0.5 h-5 w-5 flex-shrink-0" aria-hidden="true" />
91
+ <span className="text-sm">{error}</span>
92
+ </div>
93
+ </output>
94
+ );
95
+ }
96
+
97
+ return <output className="text-muted-foreground text-sm">{status}</output>;
98
+ });
99
+
100
+ const ProcessingStats = memo(function ProcessingStats({ stats }: { stats: FileUploadStats }) {
101
+ if (!stats || typeof stats !== "object") return null;
102
+
103
+ return (
104
+ <dl className="divide-border mt-3 divide-y">
105
+ {stats.processingTime !== undefined && (
106
+ <div className="py-2 first:pt-0 last:pb-0">
107
+ <dt className="sr-only">Processing time</dt>
108
+ <dd className="text-foreground text-sm font-medium">
109
+ Completed in {(stats.processingTime / 1000).toFixed(1)}s
110
+ </dd>
111
+ </div>
112
+ )}
113
+ {stats.total !== undefined && Object.values(stats).length ? (
114
+ <>
115
+ <ProcessingStat label="Total" value={stats.total} />
116
+ <ProcessingStat label="Created" value={stats.created} />
117
+ <ProcessingStat label="Updated" value={stats.updated} />
118
+ <ProcessingStat label="Skipped" value={stats.skipped} />
119
+ <ProcessingStat label="Merged" value={stats.merged} />
120
+ <ProcessingStat label="Invalid" value={stats.invalid} />
121
+ </>
122
+ ) : null}
123
+
124
+ {stats.errors?.length && stats.errors.length > 0 ? (
125
+ <div className="py-2">
126
+ <dt className="text-foreground text-sm font-medium">Errors</dt>
127
+ <dd className="mt-1">
128
+ <span className="bg-destructive/10 text-destructive inline-flex items-center px-2 py-1 text-xs font-medium">
129
+ {stats.errors.length} error{stats.errors.length > 1 ? "s" : ""}
130
+ </span>
131
+ </dd>
132
+ </div>
133
+ ) : null}
134
+ </dl>
135
+ );
136
+ });
137
+
138
+ const ProcessingStat = memo(function ProcessingStat({
139
+ label,
140
+ value,
141
+ }: {
142
+ label: string;
143
+ value?: number | undefined;
144
+ }) {
145
+ if (value === undefined) return null;
146
+ return (
147
+ <div className="py-2">
148
+ <dt className="text-muted-foreground text-sm font-medium">{label}</dt>
149
+ <dd className="text-foreground mt-1 text-sm">{value.toLocaleString()}</dd>
150
+ </div>
151
+ );
152
+ });
@@ -1,6 +1,15 @@
1
1
  export { Alert, AlertDescription, AlertTitle } from "./alert";
2
2
  export { EmptyState } from "./empty-state";
3
3
  export type { EmptyStateProps } from "./empty-state";
4
+ export { FileUploadStatus } from "./file-upload-status";
5
+ export type { FileUploadState, FileUploadStats } from "./file-upload-status";
6
+ export { FileUploadStatusBadge } from "./file-upload-status-badge";
7
+ export type { FileUploadStatusValue } from "./file-upload-status-badge";
4
8
  export { Progress } from "./progress";
9
+ export { ProgressBar } from "./progress-bar";
10
+ export type { ProgressBarProps } from "./progress-bar";
11
+ export { Skeleton } from "./skeleton";
5
12
  export { Spinner } from "./spinner";
6
13
  export type { SpinnerProps } from "./spinner";
14
+ export { UpdateGuard } from "./update-guard";
15
+ export type { UpdateGuardCopy, UpdateGuardProps } from "./update-guard";
@@ -0,0 +1,39 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+
3
+ import { ProgressBar } from "./progress-bar";
4
+
5
+ const meta = {
6
+ title: "Feedback/ProgressBar",
7
+ component: ProgressBar,
8
+ tags: ["autodocs"],
9
+ } satisfies Meta<typeof ProgressBar>;
10
+
11
+ export default meta;
12
+ type Story = StoryObj<typeof meta>;
13
+
14
+ export const Default: Story = {
15
+ args: { progress: 60 },
16
+ render: (args) => (
17
+ <div className="w-64">
18
+ <ProgressBar {...args} />
19
+ </div>
20
+ ),
21
+ };
22
+
23
+ export const Empty: Story = {
24
+ args: { progress: 0 },
25
+ render: (args) => (
26
+ <div className="w-64">
27
+ <ProgressBar {...args} />
28
+ </div>
29
+ ),
30
+ };
31
+
32
+ export const Complete: Story = {
33
+ args: { progress: 100 },
34
+ render: (args) => (
35
+ <div className="w-64">
36
+ <ProgressBar {...args} />
37
+ </div>
38
+ ),
39
+ };
@@ -0,0 +1,21 @@
1
+ import { cn } from "../../lib/utils";
2
+
3
+ export interface ProgressBarProps {
4
+ className?: string;
5
+ progress?: number;
6
+ }
7
+
8
+ /** Thin linear progress indicator, distinct from the Radix-backed `Progress`. */
9
+ export function ProgressBar({ className, progress = 0 }: ProgressBarProps) {
10
+ return (
11
+ <div className={cn("border-border h-[2px] w-full overflow-hidden border-t", className)}>
12
+ <div
13
+ className={cn("border-warning h-full border-t")}
14
+ style={{
15
+ width: `${Math.min(100, Math.max(0, progress))}%`,
16
+ opacity: progress === 100 ? 0 : 0.8,
17
+ }}
18
+ />
19
+ </div>
20
+ );
21
+ }
@@ -14,24 +14,26 @@ type Story = StoryObj<typeof meta>;
14
14
  export const Default: Story = {
15
15
  args: {
16
16
  value: 60,
17
+ "aria-label": "Upload progress",
17
18
  },
18
19
  };
19
20
 
20
21
  export const Empty: Story = {
21
- args: { value: 0 },
22
+ args: { value: 0, "aria-label": "Upload progress" },
22
23
  };
23
24
 
24
25
  export const Complete: Story = {
25
- args: { value: 100 },
26
+ args: { value: 100, "aria-label": "Upload progress" },
26
27
  };
27
28
 
28
29
  export const Partial: Story = {
30
+ args: { value: 60 },
29
31
  render: () => (
30
32
  <div className="flex w-64 flex-col gap-4">
31
- <Progress value={10} />
32
- <Progress value={33} />
33
- <Progress value={66} />
34
- <Progress value={90} />
33
+ <Progress value={10} aria-label="Upload progress" />
34
+ <Progress value={33} aria-label="Upload progress" />
35
+ <Progress value={66} aria-label="Upload progress" />
36
+ <Progress value={90} aria-label="Upload progress" />
35
37
  </div>
36
38
  ),
37
39
  };
@@ -1,4 +1,4 @@
1
- import * as ProgressPrimitive from "@radix-ui/react-progress";
1
+ import { Progress as ProgressPrimitive } from "@base-ui/react/progress";
2
2
  import * as React from "react";
3
3
 
4
4
  import { cn } from "../../lib/utils";
@@ -14,13 +14,13 @@ function Progress({
14
14
  return (
15
15
  <ProgressPrimitive.Root
16
16
  data-slot="progress"
17
- className={cn("bg-accent/20 relative h-2 w-full overflow-hidden rounded-full", className)}
17
+ className={cn("bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", className)}
18
18
  value={value}
19
19
  {...props}
20
20
  >
21
21
  <ProgressPrimitive.Indicator
22
22
  data-slot="progress-indicator"
23
- className={cn("bg-accent h-full w-full flex-1 transition-all", indicatorClassName)}
23
+ className={cn("bg-primary h-full w-full flex-1 transition-all", indicatorClassName)}
24
24
  style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
25
25
  />
26
26
  </ProgressPrimitive.Root>
@@ -0,0 +1,36 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+
3
+ import { Skeleton } from "./skeleton";
4
+
5
+ const meta = {
6
+ title: "Feedback/Skeleton",
7
+ component: Skeleton,
8
+ tags: ["autodocs"],
9
+ } satisfies Meta<typeof Skeleton>;
10
+
11
+ export default meta;
12
+ type Story = StoryObj<typeof meta>;
13
+
14
+ export const Default: Story = {
15
+ render: () => <Skeleton className="h-4 w-48" />,
16
+ };
17
+
18
+ export const TextLines: Story = {
19
+ render: () => (
20
+ <div className="w-64 space-y-2">
21
+ <Skeleton className="h-4 w-full" />
22
+ <Skeleton className="h-4 w-5/6" />
23
+ <Skeleton className="h-4 w-2/3" />
24
+ </div>
25
+ ),
26
+ };
27
+
28
+ export const CardShape: Story = {
29
+ render: () => (
30
+ <div className="w-64 space-y-3">
31
+ <Skeleton className="h-32 w-full" />
32
+ <Skeleton className="h-4 w-3/4" />
33
+ <Skeleton className="h-4 w-1/2" />
34
+ </div>
35
+ ),
36
+ };
@@ -0,0 +1,7 @@
1
+ import type { ComponentProps } from "react";
2
+
3
+ import { cn } from "../../lib/utils";
4
+
5
+ export function Skeleton({ className, ...props }: ComponentProps<"div">) {
6
+ return <div className={cn("bg-muted animate-pulse rounded-md", className)} {...props} />;
7
+ }
@@ -37,12 +37,12 @@ export function Spinner({
37
37
  >
38
38
  <span
39
39
  className={cn(
40
- "border-default border-t-accent rounded-full border-2 motion-safe:animate-spin",
40
+ "border-border border-t-primary rounded-full border-2 motion-safe:animate-spin",
41
41
  sizeClasses[size],
42
42
  )}
43
43
  aria-hidden="true"
44
44
  />
45
- {label && <span className="text-secondary text-xs">{label}</span>}
45
+ {label && <span className="text-muted-foreground text-xs">{label}</span>}
46
46
  </div>
47
47
  );
48
48
  }
@@ -0,0 +1,32 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+
3
+ import { UpdateGuard } from "./update-guard";
4
+
5
+ const meta = {
6
+ title: "Feedback/UpdateGuard",
7
+ component: UpdateGuard,
8
+ tags: ["autodocs"],
9
+ parameters: {
10
+ docs: {
11
+ description: {
12
+ component:
13
+ "Registers a service worker at `serviceWorkerPath` and surfaces offline/update-available prompts. In Storybook there is no service worker to register against, so the prompts stay hidden — this story documents the wrapper's pass-through behavior for `children`.",
14
+ },
15
+ },
16
+ },
17
+ } satisfies Meta<typeof UpdateGuard>;
18
+
19
+ export default meta;
20
+ type Story = StoryObj<typeof meta>;
21
+
22
+ export const Default: Story = {
23
+ args: {
24
+ hideInDev: false,
25
+ children: (
26
+ <div className="border-border bg-surface text-muted-foreground rounded-md border p-6 text-sm">
27
+ App content renders here. Offline/update prompts appear fixed to the bottom of the viewport
28
+ when the service worker reports a change.
29
+ </div>
30
+ ),
31
+ },
32
+ };
@@ -0,0 +1,280 @@
1
+ import type { ReactNode } from "react";
2
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
3
+
4
+ interface RegisterSWOptions {
5
+ onRegistered?: (registration: ServiceWorkerRegistration | undefined) => void;
6
+ onRegisterError?: (error: Error) => void;
7
+ serviceWorkerPath?: string;
8
+ }
9
+
10
+ interface RegisterSWResult {
11
+ offlineReady: [boolean, (value: boolean) => void];
12
+ needRefresh: [boolean, (value: boolean) => void];
13
+ updateServiceWorker: (reload?: boolean) => void;
14
+ }
15
+
16
+ function useRegisterSW(options?: RegisterSWOptions): RegisterSWResult {
17
+ const [offlineReady, setOfflineReady] = useState(false);
18
+ const [needRefresh, setNeedRefresh] = useState(false);
19
+ const registrationRef = useRef<ServiceWorkerRegistration | undefined>(undefined);
20
+ const shouldReloadRef = useRef(false);
21
+ const hasReloadedRef = useRef(false);
22
+ const onRegisteredRef = useRef(options?.onRegistered);
23
+ const onRegisterErrorRef = useRef(options?.onRegisterError);
24
+ const serviceWorkerPathRef = useRef(options?.serviceWorkerPath ?? "/sw.js");
25
+
26
+ useEffect(() => {
27
+ onRegisteredRef.current = options?.onRegistered;
28
+ onRegisterErrorRef.current = options?.onRegisterError;
29
+ serviceWorkerPathRef.current = options?.serviceWorkerPath ?? "/sw.js";
30
+ }, [options?.onRegistered, options?.onRegisterError, options?.serviceWorkerPath]);
31
+
32
+ useEffect(() => {
33
+ if (
34
+ typeof window === "undefined" ||
35
+ !("serviceWorker" in navigator) ||
36
+ !window.isSecureContext
37
+ ) {
38
+ return;
39
+ }
40
+
41
+ let isMounted = true;
42
+
43
+ const handleControllerChange = () => {
44
+ if (!shouldReloadRef.current || hasReloadedRef.current) {
45
+ return;
46
+ }
47
+
48
+ hasReloadedRef.current = true;
49
+ window.location.reload();
50
+ };
51
+
52
+ navigator.serviceWorker.addEventListener("controllerchange", handleControllerChange);
53
+
54
+ const handleWaitingServiceWorker = (registration: ServiceWorkerRegistration) => {
55
+ registrationRef.current = registration;
56
+ if (!isMounted) {
57
+ return;
58
+ }
59
+
60
+ setNeedRefresh(true);
61
+ setOfflineReady(false);
62
+ };
63
+
64
+ const handleInstalledServiceWorker = (registration: ServiceWorkerRegistration) => {
65
+ registrationRef.current = registration;
66
+ if (!isMounted) {
67
+ return;
68
+ }
69
+
70
+ if (navigator.serviceWorker.controller) {
71
+ setNeedRefresh(true);
72
+ setOfflineReady(false);
73
+ return;
74
+ }
75
+
76
+ setOfflineReady(true);
77
+ };
78
+
79
+ const trackRegistration = (registration: ServiceWorkerRegistration) => {
80
+ registrationRef.current = registration;
81
+ onRegisteredRef.current?.(registration);
82
+
83
+ if (registration.waiting) {
84
+ handleWaitingServiceWorker(registration);
85
+ }
86
+
87
+ registration.addEventListener("updatefound", () => {
88
+ const installingWorker = registration.installing;
89
+ if (!installingWorker) {
90
+ return;
91
+ }
92
+
93
+ installingWorker.addEventListener("statechange", () => {
94
+ if (installingWorker.state === "installed") {
95
+ handleInstalledServiceWorker(registration);
96
+ }
97
+ });
98
+ });
99
+ };
100
+
101
+ void navigator.serviceWorker
102
+ .register(serviceWorkerPathRef.current)
103
+ .then((registration) => {
104
+ if (!isMounted) {
105
+ return;
106
+ }
107
+
108
+ trackRegistration(registration);
109
+ return navigator.serviceWorker.ready.then(() => {
110
+ if (!isMounted || navigator.serviceWorker.controller) {
111
+ return;
112
+ }
113
+
114
+ setOfflineReady(true);
115
+ });
116
+ })
117
+ .catch((error: Error) => {
118
+ if (isMounted) {
119
+ onRegisterErrorRef.current?.(error);
120
+ }
121
+ });
122
+
123
+ return () => {
124
+ isMounted = false;
125
+ navigator.serviceWorker.removeEventListener("controllerchange", handleControllerChange);
126
+ };
127
+ }, []);
128
+
129
+ return {
130
+ offlineReady: [offlineReady, setOfflineReady],
131
+ needRefresh: [needRefresh, setNeedRefresh],
132
+ updateServiceWorker: (reload = true) => {
133
+ shouldReloadRef.current = reload;
134
+ registrationRef.current?.waiting?.postMessage({ type: "SKIP_WAITING" });
135
+ },
136
+ };
137
+ }
138
+
139
+ function subscribeOnline(onStoreChange: () => void) {
140
+ if (typeof window === "undefined") {
141
+ return () => {};
142
+ }
143
+
144
+ window.addEventListener("online", onStoreChange);
145
+ window.addEventListener("offline", onStoreChange);
146
+
147
+ return () => {
148
+ window.removeEventListener("online", onStoreChange);
149
+ window.removeEventListener("offline", onStoreChange);
150
+ };
151
+ }
152
+
153
+ function getOnlineSnapshot() {
154
+ if (typeof navigator === "undefined") {
155
+ return true;
156
+ }
157
+
158
+ return navigator.onLine;
159
+ }
160
+
161
+ export interface UpdateGuardCopy {
162
+ offlineReady?: string;
163
+ newContentAvailable?: string;
164
+ refreshButton?: string;
165
+ closeButton?: string;
166
+ offlineMessage?: string;
167
+ offlineStaleMessage?: string;
168
+ }
169
+
170
+ export interface UpdateGuardProps {
171
+ children: ReactNode;
172
+ serviceWorkerPath?: string;
173
+ hasStaleData?: boolean;
174
+ hideInDev?: boolean;
175
+ copy?: UpdateGuardCopy;
176
+ }
177
+
178
+ function UpdateGuardClient({
179
+ serviceWorkerPath,
180
+ hasStaleData = false,
181
+ hideInDev = true,
182
+ copy,
183
+ }: Omit<UpdateGuardProps, "children">) {
184
+ const isDev =
185
+ typeof import.meta !== "undefined" &&
186
+ typeof import.meta.env !== "undefined" &&
187
+ Boolean(import.meta.env.DEV);
188
+
189
+ const {
190
+ offlineReady: [offlineReady, setOfflineReady],
191
+ needRefresh: [needRefresh, setNeedRefresh],
192
+ updateServiceWorker,
193
+ } = useRegisterSW({ serviceWorkerPath });
194
+
195
+ const isOnline = useSyncExternalStore(subscribeOnline, getOnlineSnapshot, () => true);
196
+
197
+ const closePrompt = () => {
198
+ setOfflineReady(false);
199
+ setNeedRefresh(false);
200
+ };
201
+
202
+ const offlineMessage = useMemo(() => {
203
+ if (isOnline) {
204
+ return null;
205
+ }
206
+
207
+ return hasStaleData
208
+ ? (copy?.offlineStaleMessage ?? "Offline - showing cached data where available")
209
+ : (copy?.offlineMessage ?? "Offline - data may be unavailable");
210
+ }, [copy?.offlineMessage, copy?.offlineStaleMessage, hasStaleData, isOnline]);
211
+
212
+ if (hideInDev && isDev) {
213
+ return null;
214
+ }
215
+
216
+ return (
217
+ <>
218
+ {offlineMessage ? (
219
+ <div className="fixed inset-x-0 bottom-16 z-50 flex justify-center px-4">
220
+ <div className="border-border bg-surface flex items-center gap-3 rounded-md border px-4 py-2">
221
+ <span className="text-foreground text-sm">{offlineMessage}</span>
222
+ </div>
223
+ </div>
224
+ ) : null}
225
+
226
+ {offlineReady || needRefresh ? (
227
+ <div className="fixed inset-x-0 bottom-4 z-50 flex justify-center px-4">
228
+ <div className="border-border bg-surface flex items-center gap-3 rounded-md border px-4 py-2">
229
+ <span className="text-foreground text-sm">
230
+ {offlineReady
231
+ ? (copy?.offlineReady ?? "App ready to work offline")
232
+ : (copy?.newContentAvailable ?? "New content available")}
233
+ </span>
234
+ {needRefresh ? (
235
+ <button
236
+ type="button"
237
+ onClick={() => updateServiceWorker(true)}
238
+ className="text-accent text-sm font-semibold"
239
+ >
240
+ {copy?.refreshButton ?? "Refresh"}
241
+ </button>
242
+ ) : null}
243
+ <button type="button" onClick={closePrompt} className="text-muted-foreground text-sm">
244
+ {copy?.closeButton ?? "Close"}
245
+ </button>
246
+ </div>
247
+ </div>
248
+ ) : null}
249
+ </>
250
+ );
251
+ }
252
+
253
+ /**
254
+ * Registers a service worker and surfaces "offline ready" / "update
255
+ * available" prompts around `children`. No-op (renders only `children`) when
256
+ * rendered on the server.
257
+ */
258
+ export function UpdateGuard({
259
+ children,
260
+ serviceWorkerPath,
261
+ hasStaleData = false,
262
+ hideInDev = true,
263
+ copy,
264
+ }: UpdateGuardProps) {
265
+ const isClient = typeof window !== "undefined";
266
+
267
+ return (
268
+ <>
269
+ {children}
270
+ {isClient ? (
271
+ <UpdateGuardClient
272
+ serviceWorkerPath={serviceWorkerPath}
273
+ hasStaleData={hasStaleData}
274
+ hideInDev={hideInDev}
275
+ copy={copy}
276
+ />
277
+ ) : null}
278
+ </>
279
+ );
280
+ }