@ponti-studios/ui 0.1.2 → 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/LICENSE +21 -0
- package/README.md +7 -7
- package/THIRD_PARTY_NOTICES.md +17 -0
- package/dist/index.js +1028 -638
- package/dist/index.js.map +1 -1
- package/package.json +30 -32
- package/src/animations.css +16 -434
- package/src/components/data-display/accordion.tsx +10 -7
- package/src/components/data-display/filter-chip.tsx +2 -2
- 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 -5
- package/src/components/feedback/alert.tsx +4 -4
- package/src/components/feedback/empty-state.tsx +6 -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 +1 -1
- 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 +11 -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 +45 -53
- package/src/components/forms/slider.stories.tsx +5 -2
- package/src/components/forms/slider.tsx +27 -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 +7 -7
- package/src/components/overlays/alert-dialog.tsx +21 -46
- package/src/components/overlays/command.tsx +51 -82
- 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 +44 -142
- package/src/components/overlays/popover.stories.tsx +2 -2
- package/src/components/overlays/popover.tsx +34 -16
- package/src/components/overlays/sheet.stories.tsx +2 -2
- package/src/components/overlays/sheet.tsx +35 -17
- package/src/components/primitives/avatar.tsx +3 -3
- package/src/components/primitives/badge.tsx +7 -11
- package/src/components/primitives/button.tsx +41 -13
- package/src/components/primitives/card.stories.tsx +2 -2
- package/src/components/primitives/card.tsx +2 -2
- 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 +5 -5
- 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 +76 -1040
- package/src/tokens/color-systems.stories.tsx +9 -119
- package/src/tokens/foundations.stories.tsx +21 -113
- package/src/tokens/generated/dark.colors.generated.ts +56 -0
- package/src/tokens/generated/dark.css +54 -0
- package/src/tokens/generated/foundations.css +148 -0
- package/src/tokens/generated/foundations.generated.ts +443 -0
- package/src/tokens/generated/light.colors.generated.ts +56 -0
- package/src/tokens/generated/light.css +54 -0
- package/src/tokens/index.ts +74 -16
- package/src/tokens/layout.stories.tsx +3 -108
- 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,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
|
};
|
|
@@ -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
|
+
}
|
|
@@ -12,31 +12,31 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
|
|
|
12
12
|
showOutsideDays={showOutsideDays}
|
|
13
13
|
className={cn("relative w-fit rounded-md p-4", className)}
|
|
14
14
|
classNames={{
|
|
15
|
-
root: "text-
|
|
15
|
+
root: "text-foreground",
|
|
16
16
|
months: "flex flex-col gap-4 sm:flex-row",
|
|
17
17
|
month: "flex flex-col gap-4",
|
|
18
18
|
month_caption: "relative flex h-8 items-center justify-center",
|
|
19
19
|
caption_label: "text-sm font-medium",
|
|
20
20
|
nav: "absolute inset-x-4 top-4 flex items-center justify-between",
|
|
21
21
|
button_previous:
|
|
22
|
-
"inline-flex size-8 p-2 items-center justify-center rounded-full border
|
|
22
|
+
"inline-flex size-8 p-2 items-center justify-center rounded-full border bg-background text-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50",
|
|
23
23
|
button_next:
|
|
24
|
-
"inline-flex size-8 p-2 items-center justify-center rounded-full border
|
|
24
|
+
"inline-flex size-8 p-2 items-center justify-center rounded-full border bg-background text-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50",
|
|
25
25
|
month_grid: "w-full border-collapse",
|
|
26
26
|
weekdays: "flex",
|
|
27
|
-
weekday: "flex size-8 items-center justify-center text-[0.8rem] font-normal text-
|
|
27
|
+
weekday: "flex size-8 items-center justify-center text-[0.8rem] font-normal text-muted-foreground",
|
|
28
28
|
week: "mt-1 flex w-full",
|
|
29
29
|
day: "relative size-8 p-0 text-center text-lg focus-within:z-20",
|
|
30
30
|
day_button:
|
|
31
31
|
"inline-flex size-6 items-center justify-center rounded-md text-sm font-normal disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50",
|
|
32
32
|
selected:
|
|
33
|
-
"[&>button]:bg-
|
|
34
|
-
today: "[&>button]:bg-
|
|
35
|
-
outside: "text-
|
|
36
|
-
disabled: "text-
|
|
37
|
-
range_start: "rounded-l-md bg-
|
|
38
|
-
range_middle: "bg-
|
|
39
|
-
range_end: "rounded-r-md bg-
|
|
33
|
+
"[&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary/90",
|
|
34
|
+
today: "[&>button]:bg-primary [&>button]:text-primary-foreground",
|
|
35
|
+
outside: "text-muted-foreground [&>button]:text-muted-foreground",
|
|
36
|
+
disabled: "text-muted-foreground opacity-50",
|
|
37
|
+
range_start: "rounded-l-md bg-primary [&>button]:bg-primary [&>button]:text-primary-foreground",
|
|
38
|
+
range_middle: "bg-primary [&>button]:rounded-none [&>button]:text-primary-foreground",
|
|
39
|
+
range_end: "rounded-r-md bg-primary [&>button]:bg-primary [&>button]:text-primary-foreground",
|
|
40
40
|
hidden: "invisible",
|
|
41
41
|
...classNames,
|
|
42
42
|
}}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
|
|
4
|
+
import { DateMonthSelect, getCurrentMonthYear } from "./date-month-select";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Forms/DateMonthSelect",
|
|
8
|
+
component: DateMonthSelect,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
} satisfies Meta<typeof DateMonthSelect>;
|
|
11
|
+
|
|
12
|
+
export default meta;
|
|
13
|
+
type Story = StoryObj<typeof meta>;
|
|
14
|
+
|
|
15
|
+
function DateMonthSelectPreview() {
|
|
16
|
+
const [value, setValue] = useState(getCurrentMonthYear());
|
|
17
|
+
return <DateMonthSelect selectedMonthYear={value} onMonthChange={setValue} />;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const Default: Story = {
|
|
21
|
+
args: {
|
|
22
|
+
selectedMonthYear: getCurrentMonthYear(),
|
|
23
|
+
onMonthChange: () => {},
|
|
24
|
+
},
|
|
25
|
+
render: () => <DateMonthSelectPreview />,
|
|
26
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Calendar } from "lucide-react";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
|
|
4
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
|
|
5
|
+
|
|
6
|
+
interface MonthOption {
|
|
7
|
+
value: string;
|
|
8
|
+
label: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DateMonthSelectProps {
|
|
12
|
+
selectedMonthYear: string;
|
|
13
|
+
onMonthChange: (value: string) => void;
|
|
14
|
+
monthsBack?: number;
|
|
15
|
+
className?: string;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Select a `YYYY-MM` month from the last `monthsBack` months. */
|
|
20
|
+
export function DateMonthSelect({
|
|
21
|
+
selectedMonthYear,
|
|
22
|
+
onMonthChange,
|
|
23
|
+
monthsBack = 12,
|
|
24
|
+
className = "w-[200px]",
|
|
25
|
+
placeholder = "Select month",
|
|
26
|
+
}: DateMonthSelectProps) {
|
|
27
|
+
const monthOptions = useMemo<MonthOption[]>(() => {
|
|
28
|
+
const options: MonthOption[] = [];
|
|
29
|
+
const today = new Date();
|
|
30
|
+
for (let i = 0; i < monthsBack; i++) {
|
|
31
|
+
const date = new Date(today.getFullYear(), today.getMonth() - i, 1);
|
|
32
|
+
const year = date.getFullYear();
|
|
33
|
+
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
|
34
|
+
options.push({
|
|
35
|
+
value: `${year}-${month}`,
|
|
36
|
+
label: date.toLocaleString("default", { month: "long", year: "numeric" }),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return options;
|
|
40
|
+
}, [monthsBack]);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="flex items-center gap-2">
|
|
44
|
+
<Calendar className="size-4" />
|
|
45
|
+
<Select
|
|
46
|
+
value={selectedMonthYear}
|
|
47
|
+
onValueChange={(v: string | null) => v != null && onMonthChange(v)}
|
|
48
|
+
>
|
|
49
|
+
<SelectTrigger className={className} aria-label={placeholder}>
|
|
50
|
+
<SelectValue placeholder={placeholder} />
|
|
51
|
+
</SelectTrigger>
|
|
52
|
+
<SelectContent>
|
|
53
|
+
{monthOptions.map((option) => (
|
|
54
|
+
<SelectItem key={option.value} value={option.value}>
|
|
55
|
+
{option.label}
|
|
56
|
+
</SelectItem>
|
|
57
|
+
))}
|
|
58
|
+
</SelectContent>
|
|
59
|
+
</Select>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Current month in `YYYY-MM` format. */
|
|
65
|
+
export const getCurrentMonthYear = () => {
|
|
66
|
+
const now = new Date();
|
|
67
|
+
const year = now.getFullYear();
|
|
68
|
+
const month = (now.getMonth() + 1).toString().padStart(2, "0");
|
|
69
|
+
return `${year}-${month}`;
|
|
70
|
+
};
|
|
@@ -35,7 +35,7 @@ export function DatePicker({
|
|
|
35
35
|
return (
|
|
36
36
|
<div className={containerClassName}>
|
|
37
37
|
{showLabel && label ? (
|
|
38
|
-
<label htmlFor={id} className="text-
|
|
38
|
+
<label htmlFor={id} className="text-foreground mb-2 block text-sm font-medium">
|
|
39
39
|
{label}
|
|
40
40
|
</label>
|
|
41
41
|
) : null}
|
|
@@ -45,13 +45,13 @@ export function DatePicker({
|
|
|
45
45
|
id={id}
|
|
46
46
|
variant={variant}
|
|
47
47
|
disabled={disabled}
|
|
48
|
-
className="bg-
|
|
48
|
+
className="bg-muted placeholder:text-muted-foreground w-full justify-start text-left font-normal"
|
|
49
49
|
>
|
|
50
50
|
<CalendarIcon className="mr-2 size-4" />
|
|
51
51
|
{value ? format(value, dateFormat) : <span>{placeholder}</span>}
|
|
52
52
|
</Button>
|
|
53
53
|
</PopoverTrigger>
|
|
54
|
-
<PopoverContent className="bg-
|
|
54
|
+
<PopoverContent className="bg-popover w-auto p-0" align={popoverAlign}>
|
|
55
55
|
<Calendar mode="single" selected={value} onSelect={onSelect} />
|
|
56
56
|
</PopoverContent>
|
|
57
57
|
</Popover>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
|
|
4
|
+
import { DropZone } from "./drop-zone";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Forms/DropZone",
|
|
8
|
+
component: DropZone,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
} satisfies Meta<typeof DropZone>;
|
|
11
|
+
|
|
12
|
+
export default meta;
|
|
13
|
+
type Story = StoryObj<typeof meta>;
|
|
14
|
+
|
|
15
|
+
function DropZonePreview({ isImporting = false }: { isImporting?: boolean }) {
|
|
16
|
+
const [dragActive, setDragActive] = useState(false);
|
|
17
|
+
const [files, setFiles] = useState<File[]>([]);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div className="w-96 space-y-3">
|
|
21
|
+
<DropZone
|
|
22
|
+
isImporting={isImporting}
|
|
23
|
+
dragActive={dragActive}
|
|
24
|
+
onDragOver={() => setDragActive(true)}
|
|
25
|
+
onDragLeave={() => setDragActive(false)}
|
|
26
|
+
onDrop={setFiles}
|
|
27
|
+
onChange={setFiles}
|
|
28
|
+
accept=".csv"
|
|
29
|
+
helpText="Supported formats: CSV"
|
|
30
|
+
/>
|
|
31
|
+
{files.length > 0 ? (
|
|
32
|
+
<p className="text-muted-foreground text-sm">
|
|
33
|
+
{files.length} file{files.length > 1 ? "s" : ""} selected
|
|
34
|
+
</p>
|
|
35
|
+
) : null}
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const baseArgs = {
|
|
41
|
+
isImporting: false,
|
|
42
|
+
dragActive: false,
|
|
43
|
+
onDrop: () => {},
|
|
44
|
+
onDragOver: () => {},
|
|
45
|
+
onDragLeave: () => {},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const Default: Story = {
|
|
49
|
+
args: baseArgs,
|
|
50
|
+
render: () => <DropZonePreview />,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const Importing: Story = {
|
|
54
|
+
args: { ...baseArgs, isImporting: true },
|
|
55
|
+
render: () => <DropZonePreview isImporting />,
|
|
56
|
+
};
|