@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.
- package/LICENSE +21 -0
- package/README.md +7 -7
- package/THIRD_PARTY_NOTICES.md +17 -0
- package/dist/index.js +988 -640
- package/dist/index.js.map +1 -1
- package/package.json +41 -41
- package/src/animations.css +42 -383
- package/src/components/data-display/accordion.tsx +22 -10
- package/src/components/data-display/filter-chip.stories.tsx +38 -0
- package/src/components/data-display/filter-chip.tsx +10 -4
- package/src/components/data-display/index.ts +5 -0
- package/src/components/data-display/metric-card.stories.tsx +48 -0
- package/src/components/data-display/metric-card.tsx +20 -0
- package/src/components/data-display/sort-controls.stories.tsx +45 -0
- package/src/components/data-display/sort-controls.tsx +99 -0
- package/src/components/data-display/sort-row.tsx +74 -0
- package/src/components/data-display/table.tsx +5 -9
- package/src/components/feedback/alert.tsx +4 -4
- package/src/components/feedback/empty-state.tsx +16 -6
- package/src/components/feedback/file-upload-status-badge.stories.tsx +28 -0
- package/src/components/feedback/file-upload-status-badge.tsx +33 -0
- package/src/components/feedback/file-upload-status.stories.tsx +57 -0
- package/src/components/feedback/file-upload-status.tsx +152 -0
- package/src/components/feedback/index.ts +9 -0
- package/src/components/feedback/progress-bar.stories.tsx +39 -0
- package/src/components/feedback/progress-bar.tsx +21 -0
- package/src/components/feedback/progress.stories.tsx +8 -6
- package/src/components/feedback/progress.tsx +3 -3
- package/src/components/feedback/skeleton.stories.tsx +36 -0
- package/src/components/feedback/skeleton.tsx +7 -0
- package/src/components/feedback/spinner.tsx +2 -2
- package/src/components/feedback/update-guard.stories.tsx +32 -0
- package/src/components/feedback/update-guard.tsx +280 -0
- package/src/components/forms/calendar.tsx +14 -11
- package/src/components/forms/date-month-select.stories.tsx +26 -0
- package/src/components/forms/date-month-select.tsx +70 -0
- package/src/components/forms/date-picker.tsx +3 -3
- package/src/components/forms/drop-zone.stories.tsx +56 -0
- package/src/components/forms/drop-zone.tsx +146 -0
- package/src/components/forms/entity-select.stories.tsx +65 -0
- package/src/components/forms/entity-select.tsx +79 -0
- package/src/components/forms/field.tsx +3 -3
- package/src/components/forms/group-by-select.stories.tsx +26 -0
- package/src/components/forms/group-by-select.tsx +47 -0
- package/src/components/forms/index.ts +10 -0
- package/src/components/forms/input.stories.tsx +4 -4
- package/src/components/forms/input.tsx +1 -1
- package/src/components/forms/passkey-management.stories.tsx +81 -0
- package/src/components/forms/passkey-management.tsx +138 -0
- package/src/components/forms/radio-group.tsx +14 -13
- package/src/components/forms/select.stories.tsx +4 -4
- package/src/components/forms/select.tsx +47 -48
- package/src/components/forms/slider.stories.tsx +5 -2
- package/src/components/forms/slider.tsx +39 -17
- package/src/components/forms/stepper.tsx +4 -4
- package/src/components/forms/switch.stories.tsx +1 -1
- package/src/components/forms/switch.tsx +2 -2
- package/src/components/forms/textarea.stories.tsx +2 -2
- package/src/components/forms/textarea.tsx +1 -1
- package/src/components/layout/index.ts +3 -0
- package/src/components/layout/scroll-area.stories.tsx +4 -4
- package/src/components/layout/scroll-area.tsx +23 -21
- package/src/components/layout/section-intro.stories.tsx +29 -0
- package/src/components/layout/section-intro.tsx +31 -0
- package/src/components/layout/surface-panel.stories.tsx +32 -0
- package/src/components/layout/surface-panel.tsx +15 -0
- package/src/components/navigation/app-navigation.stories.tsx +2 -2
- package/src/components/navigation/app-navigation.tsx +13 -13
- package/src/components/navigation/index.ts +4 -0
- package/src/components/navigation/pagination-controls.stories.tsx +41 -0
- package/src/components/navigation/pagination-controls.tsx +50 -0
- package/src/components/navigation/route-link.stories.tsx +30 -0
- package/src/components/navigation/route-link.tsx +42 -0
- package/src/components/navigation/tabs.stories.tsx +5 -5
- package/src/components/navigation/tabs.tsx +8 -8
- package/src/components/overlays/alert-dialog.tsx +22 -25
- package/src/components/overlays/command.tsx +98 -26
- package/src/components/overlays/dialog.tsx +5 -5
- package/src/components/overlays/dropdown-menu.stories.tsx +0 -5
- package/src/components/overlays/dropdown-menu.tsx +49 -31
- package/src/components/overlays/popover.stories.tsx +2 -2
- package/src/components/overlays/popover.tsx +43 -14
- package/src/components/overlays/sheet.stories.tsx +2 -2
- package/src/components/overlays/sheet.tsx +51 -17
- package/src/components/primitives/avatar.tsx +9 -5
- package/src/components/primitives/badge.tsx +11 -12
- package/src/components/primitives/button.tsx +49 -23
- package/src/components/primitives/card.stories.tsx +2 -2
- package/src/components/primitives/card.tsx +2 -5
- package/src/components/primitives/index.ts +2 -0
- package/src/components/primitives/label.stories.tsx +1 -1
- package/src/components/primitives/label.tsx +5 -6
- package/src/components/primitives/status-badge.stories.tsx +52 -0
- package/src/components/primitives/status-badge.tsx +33 -0
- package/src/constants/chart-colors.ts +7 -7
- package/src/hooks/index.ts +1 -0
- package/src/hooks/use-sort.ts +20 -0
- package/src/index.ts +30 -11
- package/src/styles.css +100 -974
- package/src/tokens/chart-colors.stories.tsx +192 -0
- package/src/tokens/color-systems.stories.tsx +20 -112
- package/src/tokens/foundations.stories.tsx +58 -100
- package/src/tokens/generated/dark.colors.generated.ts +29 -0
- package/src/tokens/generated/dark.css +29 -0
- package/src/tokens/generated/foundations.css +148 -0
- package/src/tokens/generated/foundations.generated.ts +451 -0
- package/src/tokens/generated/light.colors.generated.ts +29 -0
- package/src/tokens/generated/light.css +29 -0
- package/src/tokens/index.ts +79 -16
- package/src/tokens/layout.stories.tsx +23 -91
- package/src/tokens/colors.ts +0 -156
- package/src/tokens/motion.ts +0 -51
- package/src/tokens/radii.ts +0 -10
- package/src/tokens/shadows.ts +0 -34
- package/src/tokens/spacing.ts +0 -30
- 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
|
|
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-
|
|
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-
|
|
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
|
+
};
|
|
@@ -37,12 +37,12 @@ export function Spinner({
|
|
|
37
37
|
>
|
|
38
38
|
<span
|
|
39
39
|
className={cn(
|
|
40
|
-
"border-
|
|
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-
|
|
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
|
+
}
|