@ponti-studios/ui 1.6.0 → 1.7.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 (77) hide show
  1. package/README.md +7 -11
  2. package/package.json +6 -31
  3. package/src/components/feedback/alert.tsx +8 -4
  4. package/src/components/feedback/index.ts +0 -4
  5. package/src/components/feedback/progress.stories.tsx +15 -0
  6. package/src/components/feedback/progress.tsx +14 -3
  7. package/src/components/feedback/update-guard.stories.tsx +4 -8
  8. package/src/components/feedback/update-guard.tsx +41 -272
  9. package/src/components/filters/filter-select.tsx +3 -5
  10. package/src/components/forms/date-picker.stories.tsx +158 -18
  11. package/src/components/forms/date-picker.tsx +286 -64
  12. package/src/components/forms/date-utils.ts +7 -2
  13. package/src/components/forms/drop-zone.tsx +11 -12
  14. package/src/components/forms/index.ts +2 -6
  15. package/src/components/forms/passkey-management.stories.tsx +8 -4
  16. package/src/components/forms/passkey-management.tsx +78 -42
  17. package/src/components/forms/radio-group.stories.tsx +3 -5
  18. package/src/components/forms/radio-group.tsx +2 -2
  19. package/src/components/forms/select.stories.tsx +16 -13
  20. package/src/components/forms/textarea.stories.tsx +1 -1
  21. package/src/components/layout/index.ts +2 -0
  22. package/src/components/layout/particle-background.stories.tsx +38 -0
  23. package/src/components/layout/particle-background.tsx +378 -0
  24. package/src/components/layout/scroll-area.stories.tsx +12 -4
  25. package/src/components/layout/scroll-area.tsx +41 -4
  26. package/src/components/navigation/tabs.stories.tsx +0 -21
  27. package/src/components/overlays/dialog.stories.tsx +38 -1
  28. package/src/components/overlays/dialog.tsx +53 -12
  29. package/src/components/overlays/index.ts +0 -11
  30. package/src/components/primitives/avatar-placeholder.svg +13 -0
  31. package/src/components/primitives/avatar.stories.tsx +2 -1
  32. package/src/components/primitives/badge.stories.tsx +13 -3
  33. package/src/components/primitives/badge.tsx +1 -1
  34. package/src/components/primitives/button.tsx +1 -1
  35. package/src/components/primitives/card.tsx +6 -2
  36. package/src/components/primitives/index.ts +0 -3
  37. package/src/index.ts +1 -3
  38. package/src/lib/index.ts +2 -3
  39. package/src/styles/animations.css +62 -0
  40. package/src/styles/index.css +27 -0
  41. package/src/styles/tokens/generated/dark.colors.generated.ts +15 -12
  42. package/src/styles/tokens/generated/dark.css +15 -12
  43. package/src/styles/tokens/generated/light.colors.generated.ts +14 -11
  44. package/src/styles/tokens/generated/light.css +14 -11
  45. package/src/styles/tokens/index.ts +0 -3
  46. package/src/styles/tokens/source/dark.tokens.json +24 -12
  47. package/src/styles/tokens/source/light.tokens.json +23 -11
  48. package/src/styles/tokens/typography.stories.tsx +157 -0
  49. package/src/components/feedback/file-upload-status.stories.tsx +0 -57
  50. package/src/components/feedback/file-upload-status.tsx +0 -160
  51. package/src/components/feedback/progress-bar.stories.tsx +0 -39
  52. package/src/components/feedback/progress-bar.tsx +0 -22
  53. package/src/components/forms/calendar.stories.tsx +0 -30
  54. package/src/components/forms/calendar.tsx +0 -75
  55. package/src/components/forms/date-field.stories.tsx +0 -202
  56. package/src/components/forms/date-field.tsx +0 -179
  57. package/src/components/overlays/alert-dialog.stories.tsx +0 -45
  58. package/src/components/overlays/alert-dialog.tsx +0 -116
  59. package/src/components/primitives/label.stories.tsx +0 -29
  60. package/src/components/primitives/label.tsx +0 -20
  61. package/src/components/primitives/status-badge.stories.tsx +0 -52
  62. package/src/components/primitives/status-badge.tsx +0 -33
  63. package/src/lib/create-memory-storage.ts +0 -24
  64. package/src/native/card.tsx +0 -36
  65. package/src/native/icon-button.tsx +0 -41
  66. package/src/native/index.ts +0 -38
  67. package/src/native/list-row.tsx +0 -67
  68. package/src/native/make-styles.ts +0 -32
  69. package/src/native/shadows.ts +0 -38
  70. package/src/native/text-field.tsx +0 -34
  71. package/src/native/uniwind.d.ts +0 -1
  72. package/src/native/use-theme-colors.ts +0 -19
  73. package/src/styles/tokens/foundations.stories.tsx +0 -109
  74. package/src/styles/tokens/generated/native.generated.ts +0 -266
  75. package/src/styles/tokens/normalize-native.ts +0 -88
  76. package/src/styles/tokens/source/native.tokens.json +0 -204
  77. package/src/styles/tokens/style-dictionary.native.mjs +0 -26
package/README.md CHANGED
@@ -16,13 +16,13 @@ The design system is documented in `docs/`:
16
16
  Common development workflows are available through the `Makefile`:
17
17
 
18
18
  ```bash
19
- make check # tokens, source contracts, and typecheck
20
- make tokens-build # regenerate token artifacts
21
- make storybook # build Storybook themes and start Storybook
22
- make test-a11y # run light and dark accessibility tests
19
+ make build # regenerate token artifacts and typecheck
20
+ make check # build, then verify tokens/source contracts/lint
21
+ make test # run light and dark Storybook/vitest tests (implies build)
22
+ make storybook # build tokens and start Storybook
23
23
  ```
24
24
 
25
- The existing `pnpm run` scripts delegate to these targets for compatibility.
25
+ The `pnpm run` scripts (`check`, `build`, `test`, `storybook`, `format`) delegate to these targets for compatibility.
26
26
 
27
27
  ## Release
28
28
 
@@ -37,11 +37,11 @@ Releases are owned by [release-please](https://github.com/googleapis/release-ple
37
37
  2. Once on `main`, find or wait for the `chore(main): release ui X.Y.Z` PR that Release Please opens or updates.
38
38
  3. Review and merge it. That merge creates the `ui-v<version>` tag; after validation succeeds, the release workflow publishes that exact tag automatically.
39
39
 
40
- `prepack` (`tokens:check && check:source && typecheck`) runs as part of `pnpm install`/publish tooling and is the last gate before anything reaches npm — see "Generated files" below for the failure mode that most commonly trips it.
40
+ `prepack` (`make check`) runs as part of `pnpm install`/publish tooling and is the last gate before anything reaches npm — see "Generated files" below for the failure mode that most commonly trips it.
41
41
 
42
42
  ### Generated files must never be hand-edited
43
43
 
44
- `src/styles/tokens/generated/**` and `.storybook/generated/**` are produced only by `pnpm run tokens:build` (style-dictionary) from the DTCG source in `src/styles/tokens/source/*.tokens.json`. `tokens:check` enforces this with `git diff --exit-code -- src/styles/tokens/generated` after a fresh rebuild — if the committed file doesn't byte-for-byte match what the tool just produced, the check (and therefore `prepack` and the publish) fails. This has happened in practice when a repo-wide formatter (oxfmt) reformatted a generated CSS file directly; `.oxfmtrc.json`'s `ignorePatterns` now excludes both generated directories specifically to prevent that recurring. If `tokens:check` fails on a file you didn't intend to touch, run `pnpm run tokens:build` and commit the regenerated output — don't hand-format it back into place.
44
+ `src/styles/tokens/generated/**` and `.storybook/generated/**` are produced only by `make build` (style-dictionary) from the DTCG source in `src/styles/tokens/source/*.tokens.json`. `make check` enforces this with `git diff --exit-code -- src/styles/tokens/generated .storybook/generated` after a fresh rebuild — if the committed file doesn't byte-for-byte match what the tool just produced, the check (and therefore `prepack` and the publish) fails. This has happened in practice when a repo-wide formatter (oxfmt) reformatted a generated CSS file directly; `.oxfmtrc.json`'s `ignorePatterns` now excludes both generated directories specifically to prevent that recurring. If `make check` fails on a file you didn't intend to touch, run `make build` and commit the regenerated output — don't hand-format it back into place.
45
45
 
46
46
  ## Consumers
47
47
 
@@ -70,7 +70,3 @@ This package has no build output — every `exports` entry resolves to a file un
70
70
  ```
71
71
 
72
72
  **Important:** `./styles.css` uses `@reference "tailwindcss"` (it does not import Tailwind itself). Your project owns the `@import "tailwindcss"` — place it before the UI stylesheet. Omitting it will cause missing CSS variables (e.g. `--tw-shadow-color`) and unresolved utilities.
73
-
74
- ### `./native` subpath
75
-
76
- `@ponti-studios/ui/native` is the only part of the package that imports `react-native`. Web consumers never pull it in; native consumers get `createMakeStyles`, `nativeShadows`, `useColorMode`, and `useThemeColors`. `react-native` is an optional peer dependency — only declare it in your own `package.json` if you actually import from this subpath.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ponti-studios/ui",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "private": false,
5
5
  "description": "Shared UI component library for Ponti Studios",
6
6
  "license": "MIT",
@@ -66,10 +66,6 @@
66
66
  "types": "./src/styles/tokens/css-vars.ts",
67
67
  "import": "./src/styles/tokens/css-vars.ts"
68
68
  },
69
- "./native": {
70
- "types": "./src/native/index.ts",
71
- "import": "./src/native/index.ts"
72
- },
73
69
  "./hooks": {
74
70
  "types": "./src/hooks/index.ts",
75
71
  "import": "./src/hooks/index.ts"
@@ -82,29 +78,19 @@
82
78
  "registry": "https://registry.npmjs.org"
83
79
  },
84
80
  "scripts": {
81
+ "check": "make check",
85
82
  "build": "make build",
86
- "dev": "vite",
87
- "storybook": "make storybook",
88
- "check:source": "make check-source",
89
83
  "test": "make test",
90
- "test:a11y": "make test-a11y",
91
- "test:a11y:light": "make test-a11y-light",
92
- "test:a11y:dark": "make test-a11y-dark",
93
- "tokens:build": "make tokens-build",
94
- "tokens:check": "make tokens-check",
95
- "typecheck": "make typecheck",
96
- "lint": "make lint",
84
+ "storybook": "make storybook",
97
85
  "format": "make format",
98
- "prepack": "make prepack"
86
+ "prepack": "make check"
99
87
  },
100
88
  "dependencies": {
101
89
  "@base-ui/react": "^1.6.0",
102
90
  "@radix-ui/colors": "^3.0.0",
103
91
  "class-variance-authority": "^0.7.1",
104
92
  "clsx": "^2.1.1",
105
- "date-fns": "^4.1.0",
106
93
  "lucide-react": "^1.24.0",
107
- "react-day-picker": "^10.0.1",
108
94
  "tailwind-merge": "^3.6.0"
109
95
  },
110
96
  "devDependencies": {
@@ -120,38 +106,27 @@
120
106
  "@types/node": "^26.1.1",
121
107
  "@types/react": "^19.2.17",
122
108
  "@types/react-dom": "^19.2.3",
123
- "@vitejs/plugin-react": "^6.0.3",
124
109
  "@vitest/browser": "4.1.10",
125
110
  "@vitest/browser-playwright": "4.1.10",
126
- "oxfmt": "0.61.0",
111
+ "oxfmt": "0.64.0",
127
112
  "oxlint": "^1.74.0",
128
113
  "playwright": "^1.61.1",
129
114
  "react": "^19.2.7",
130
115
  "react-dom": "^19.2.7",
131
- "react-native": "0.86.0",
132
116
  "storybook": "10.5.6",
133
117
  "style-dictionary": "5.5.0",
134
118
  "tailwindcss": "^4.3.3",
135
119
  "typescript": "6.0.3",
136
- "uniwind": "^1.10.1",
137
120
  "vite": "^8.1.5",
138
121
  "vitest": "^4.1.10"
139
122
  },
140
123
  "peerDependencies": {
141
124
  "react": "^18.2.0 || ^19.0.0",
142
- "react-dom": "^18.2.0 || ^19.0.0",
143
- "react-native": ">=0.74",
144
- "uniwind": ">=1.10.0"
125
+ "react-dom": "^18.2.0 || ^19.0.0"
145
126
  },
146
127
  "peerDependenciesMeta": {
147
128
  "react-dom": {
148
129
  "optional": true
149
- },
150
- "react-native": {
151
- "optional": true
152
- },
153
- "uniwind": {
154
- "optional": true
155
130
  }
156
131
  },
157
132
  "packageManager": "pnpm@11.10.0"
@@ -10,7 +10,7 @@ const alertVariants = cva(
10
10
  variant: {
11
11
  default: "bg-card text-card-foreground",
12
12
  destructive:
13
- "border-destructive/50 text-destructive-text bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive-text",
13
+ "border-destructive text-destructive-text bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive-text",
14
14
  },
15
15
  },
16
16
  defaultVariants: {
@@ -36,11 +36,13 @@ function Alert({
36
36
 
37
37
  function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
38
38
  return (
39
- <div
39
+ <h2
40
40
  data-slot="alert-title"
41
41
  className={cn("col-start-2 line-clamp-1 min-h-4 text-sm font-medium", className)}
42
42
  {...props}
43
- />
43
+ >
44
+ {props.children}
45
+ </h2>
44
46
  );
45
47
  }
46
48
 
@@ -53,7 +55,9 @@ function AlertDescription({ className, ...props }: React.ComponentProps<"div">)
53
55
  className,
54
56
  )}
55
57
  {...props}
56
- />
58
+ >
59
+ {props.children}
60
+ </div>
57
61
  );
58
62
  }
59
63
 
@@ -1,11 +1,7 @@
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
4
  export { Progress } from "./progress";
7
- export { ProgressBar } from "./progress-bar";
8
- export type { ProgressBarProps } from "./progress-bar";
9
5
  export { Skeleton } from "./skeleton";
10
6
  export { Spinner } from "./spinner";
11
7
  export type { SpinnerProps } from "./spinner";
@@ -16,14 +16,29 @@ export const Default: Story = {
16
16
  value: 60,
17
17
  "aria-label": "Upload progress",
18
18
  },
19
+ render: (args) => (
20
+ <div className="w-64">
21
+ <Progress {...args} />
22
+ </div>
23
+ ),
19
24
  };
20
25
 
21
26
  export const Empty: Story = {
22
27
  args: { value: 0, "aria-label": "Upload progress" },
28
+ render: (args) => (
29
+ <div className="w-64">
30
+ <Progress {...args} />
31
+ </div>
32
+ ),
23
33
  };
24
34
 
25
35
  export const Complete: Story = {
26
36
  args: { value: 100, "aria-label": "Upload progress" },
37
+ render: (args) => (
38
+ <div className="w-64">
39
+ <Progress {...args} />
40
+ </div>
41
+ ),
27
42
  };
28
43
 
29
44
  export const Partial: Story = {
@@ -11,18 +11,29 @@ function Progress({
11
11
  }: React.ComponentProps<typeof ProgressPrimitive.Root> & {
12
12
  indicatorClassName?: string;
13
13
  }) {
14
+ const isFilling = typeof value === "number" && value > 0 && value < 100;
14
15
  return (
15
16
  <ProgressPrimitive.Root
16
17
  data-slot="progress"
17
- className={cn("bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", className)}
18
+ className={cn("bg-muted relative h-2 w-full overflow-hidden rounded-full", className)}
18
19
  value={value}
19
20
  {...props}
20
21
  >
21
22
  <ProgressPrimitive.Indicator
22
23
  data-slot="progress-indicator"
23
- className={cn("bg-primary h-full w-full flex-1 transition-all", indicatorClassName)}
24
+ className={cn(
25
+ "bg-primary relative h-full w-full flex-1 overflow-hidden transition-transform duration-300 ease-out",
26
+ indicatorClassName,
27
+ )}
24
28
  style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
25
- />
29
+ >
30
+ {isFilling && (
31
+ <span
32
+ aria-hidden
33
+ className="animate-progress-shimmer absolute inset-0 bg-[linear-gradient(90deg,transparent_35%,rgba(255,255,255,0.75)_50%,transparent_65%)]"
34
+ />
35
+ )}
36
+ </ProgressPrimitive.Indicator>
26
37
  </ProgressPrimitive.Root>
27
38
  );
28
39
  }
@@ -10,7 +10,7 @@ const meta = {
10
10
  docs: {
11
11
  description: {
12
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`.",
13
+ "Surfaces an update-available notification. Service-worker registration and update activation are owned by the consuming application.",
14
14
  },
15
15
  },
16
16
  },
@@ -21,12 +21,8 @@ type Story = StoryObj<typeof meta>;
21
21
 
22
22
  export const Default: Story = {
23
23
  args: {
24
- hideInDev: false,
25
- children: (
26
- <div className="border-border bg-card 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
- ),
24
+ needRefresh: true,
25
+ onRefresh: () => undefined,
26
+ onDismiss: () => undefined,
31
27
  },
32
28
  };
@@ -1,288 +1,57 @@
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
- function getOnlineServerSnapshot() {
162
- return true;
163
- }
1
+ import { LucideRefreshCcw, LucideX } from "lucide-react";
164
2
 
165
3
  export interface UpdateGuardCopy {
166
- offlineReady?: string;
167
4
  newContentAvailable?: string;
168
5
  refreshButton?: string;
169
6
  closeButton?: string;
170
- offlineMessage?: string;
171
- offlineStaleMessage?: string;
172
7
  }
173
8
 
174
9
  export interface UpdateGuardProps {
175
- children: ReactNode;
176
- serviceWorkerPath?: string;
177
- hasStaleData?: boolean;
178
- hideInDev?: boolean;
179
- copy?: UpdateGuardCopy;
10
+ needRefresh: boolean;
11
+ onRefresh: () => void;
12
+ onDismiss?: () => void;
13
+ copyText?: string;
14
+ refreshText?: string;
180
15
  }
181
16
 
182
- function UpdateGuardClient({
183
- serviceWorkerPath,
184
- hasStaleData = false,
185
- hideInDev = true,
186
- copy,
187
- }: Omit<UpdateGuardProps, "children">) {
188
- const isDev =
189
- typeof import.meta !== "undefined" &&
190
- typeof import.meta.env !== "undefined" &&
191
- Boolean(import.meta.env.DEV);
192
-
193
- const {
194
- offlineReady: [offlineReady, setOfflineReady],
195
- needRefresh: [needRefresh, setNeedRefresh],
196
- updateServiceWorker,
197
- } = useRegisterSW({ serviceWorkerPath });
198
-
199
- const isOnline = useSyncExternalStore(
200
- subscribeOnline,
201
- getOnlineSnapshot,
202
- getOnlineServerSnapshot,
203
- );
204
-
205
- const closePrompt = () => {
206
- setOfflineReady(false);
207
- setNeedRefresh(false);
208
- };
209
-
210
- const offlineMessage = useMemo(() => {
211
- if (isOnline) {
212
- return null;
213
- }
214
-
215
- return hasStaleData
216
- ? (copy?.offlineStaleMessage ?? "Offline - showing cached data where available")
217
- : (copy?.offlineMessage ?? "Offline - data may be unavailable");
218
- }, [copy?.offlineMessage, copy?.offlineStaleMessage, hasStaleData, isOnline]);
219
-
220
- if (hideInDev && isDev) {
221
- return null;
222
- }
223
-
224
- return (
225
- <>
226
- {offlineMessage ? (
227
- <div className="fixed inset-x-0 bottom-16 z-50 flex justify-center px-4">
228
- <div className="border-border bg-card flex items-center gap-3 rounded-md border px-4 py-2">
229
- <span className="text-foreground text-sm">{offlineMessage}</span>
230
- </div>
231
- </div>
232
- ) : null}
233
-
234
- {offlineReady || needRefresh ? (
235
- <div className="fixed inset-x-0 bottom-4 z-50 flex justify-center px-4">
236
- <div className="border-border bg-card flex items-center gap-3 rounded-md border px-4 py-2">
237
- <span className="text-foreground text-sm">
238
- {offlineReady
239
- ? (copy?.offlineReady ?? "App ready to work offline")
240
- : (copy?.newContentAvailable ?? "New content available")}
241
- </span>
242
- {needRefresh ? (
243
- <button
244
- type="button"
245
- onClick={() => updateServiceWorker(true)}
246
- className="text-accent text-sm font-semibold"
247
- >
248
- {copy?.refreshButton ?? "Refresh"}
249
- </button>
250
- ) : null}
251
- <button type="button" onClick={closePrompt} className="text-muted-foreground text-sm">
252
- {copy?.closeButton ?? "Close"}
253
- </button>
254
- </div>
255
- </div>
256
- ) : null}
257
- </>
258
- );
259
- }
260
-
261
- /**
262
- * Registers a service worker and surfaces "offline ready" / "update
263
- * available" prompts around `children`. No-op (renders only `children`) when
264
- * rendered on the server.
265
- */
266
17
  export function UpdateGuard({
267
- children,
268
- serviceWorkerPath,
269
- hasStaleData = false,
270
- hideInDev = true,
271
- copy,
18
+ needRefresh,
19
+ onRefresh,
20
+ onDismiss,
21
+ copyText = "New content available",
22
+ refreshText = "Refresh",
272
23
  }: UpdateGuardProps) {
273
- const isClient = typeof window !== "undefined";
24
+ if (!needRefresh) {
25
+ return null;
26
+ }
274
27
 
275
28
  return (
276
- <>
277
- {children}
278
- {isClient ? (
279
- <UpdateGuardClient
280
- serviceWorkerPath={serviceWorkerPath}
281
- hasStaleData={hasStaleData}
282
- hideInDev={hideInDev}
283
- copy={copy}
284
- />
285
- ) : null}
286
- </>
29
+ <output
30
+ aria-live="polite"
31
+ aria-atomic="true"
32
+ className="fixed inset-x-0 bottom-0 z-50 flex justify-center px-4"
33
+ >
34
+ <div className="border-border bg-card flex items-center gap-3 rounded-md border px-4 py-2">
35
+ <span className="text-foreground text-sm">{copyText}</span>
36
+ <button
37
+ type="button"
38
+ aria-label={refreshText}
39
+ onClick={onRefresh}
40
+ className="text-accent-text font-semibold border-muted-foreground rounded-md border px-2 py-1 hover:bg-accent hover:text-accent-foreground"
41
+ >
42
+ <LucideRefreshCcw className="size-4" />
43
+ </button>
44
+ {onDismiss ? (
45
+ <button
46
+ aria-label="Close"
47
+ type="button"
48
+ onClick={onDismiss}
49
+ className="text-accent-text font-semibold border-muted-foreground rounded-md border px-2 py-1 hover:bg-accent hover:text-accent-foreground"
50
+ >
51
+ <LucideX className="size-4" />
52
+ </button>
53
+ ) : null}
54
+ </div>
55
+ </output>
287
56
  );
288
57
  }
@@ -1,8 +1,8 @@
1
1
  import { useId, useMemo } from "react";
2
2
 
3
3
  import { cn } from "../../lib/utils";
4
+ import { Spinner } from "../feedback/spinner";
4
5
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../forms/select";
5
- import { Label } from "../primitives/label";
6
6
 
7
7
  export interface FilterSelectOption {
8
8
  value: string;
@@ -14,7 +14,6 @@ export interface FilterSelectProps {
14
14
  onChange: (value: string | null) => void;
15
15
  options: FilterSelectOption[];
16
16
  label: string;
17
- loadingText?: string;
18
17
  placeholder?: string;
19
18
  disabled?: boolean;
20
19
  isLoading?: boolean;
@@ -28,7 +27,6 @@ export function FilterSelect({
28
27
  value,
29
28
  options,
30
29
  onChange,
31
- loadingText = "Loading...",
32
30
  placeholder = "All",
33
31
  disabled = false,
34
32
  isLoading = false,
@@ -45,7 +43,7 @@ export function FilterSelect({
45
43
 
46
44
  return (
47
45
  <div className={cn("space-y-2 min-w-36", className)}>
48
- <Label htmlFor={selectId}>{label}</Label>
46
+ <label htmlFor={selectId}>{label}</label>
49
47
  <Select value={value} onValueChange={onChange} items={items}>
50
48
  <SelectTrigger id={selectId} disabled={disabled} aria-label={label} className="w-full">
51
49
  <SelectValue placeholder={placeholder} />
@@ -53,7 +51,7 @@ export function FilterSelect({
53
51
  <SelectContent className="bg-background max-h-[250px] min-w-36 overflow-y-auto">
54
52
  <SelectItem>{placeholder}</SelectItem>
55
53
  {isLoading ? (
56
- <div className="text-muted-foreground px-2 py-1.5 text-sm">{loadingText}</div>
54
+ <Spinner presentation="centered" size="sm" />
57
55
  ) : options.length === 0 ? (
58
56
  <div className="text-muted-foreground px-2 py-1.5 text-sm">{emptyLabel}</div>
59
57
  ) : (