@nocobase/portal-template-default 1.0.2 → 1.0.4

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 (62) hide show
  1. package/README.MD +10 -5
  2. package/package.json +1 -1
  3. package/registry/nocobase-ai/components/chat/chat-message.tsx +8 -6
  4. package/registry/nocobase-ai/components/chat/chat-messages.tsx +8 -3
  5. package/registry/nocobase-ai/components/chat/markdown-message.tsx +26 -0
  6. package/registry/nocobase-ai/components/chat/sub-agent-conversation.tsx +3 -3
  7. package/registry/nocobase-ai/demo/configuration-gate.tsx +83 -35
  8. package/registry/nocobase-i18n/README.md +23 -32
  9. package/registry/nocobase-i18n/components/language-switcher.tsx +1 -1
  10. package/registry/nocobase-i18n/demo/index.tsx +2 -2
  11. package/registry/nocobase-i18n/demo/prompt-generator.tsx +3 -2
  12. package/registry/nocobase-i18n/extension.tsx +3 -4
  13. package/registry/nocobase-i18n/index.ts +4 -3
  14. package/registry/nocobase-i18n/locales/en-US.ts +2 -52
  15. package/registry/nocobase-i18n/locales/index.ts +8 -0
  16. package/registry/nocobase-i18n/locales/zh-CN.ts +2 -52
  17. package/registry/nocobase-i18n/provider.tsx +18 -6
  18. package/registry/nocobase-i18n/server-resources.ts +13 -31
  19. package/registry/nocobase-i18n/tests/i18n-regression.mjs +14 -5
  20. package/registry/nocobase-route-surfaces/README.md +21 -0
  21. package/registry/nocobase-route-surfaces/demo/index.tsx +266 -0
  22. package/registry/nocobase-route-surfaces/demo/lazy-route.tsx +11 -0
  23. package/registry/nocobase-route-surfaces/demo/prompt-generator.tsx +90 -0
  24. package/registry/nocobase-route-surfaces/demo/scenarios.ts +95 -0
  25. package/registry/nocobase-route-surfaces/extension.tsx +119 -0
  26. package/registry/nocobase-route-surfaces/index.ts +7 -0
  27. package/registry/nocobase-route-surfaces/route-dialog.tsx +145 -0
  28. package/registry/nocobase-route-surfaces/route-drawer.tsx +186 -0
  29. package/registry/nocobase-route-surfaces/route-page.tsx +29 -0
  30. package/registry/nocobase-route-surfaces/route-surface-context.ts +13 -0
  31. package/registry/nocobase-route-surfaces/use-refine-unsaved-changes.tsx +106 -0
  32. package/registry/nocobase-route-surfaces/use-route-surface-close.ts +15 -0
  33. package/registry/nocobase-route-surfaces/use-route-surface-state.ts +65 -0
  34. package/registry.config.json +30 -3
  35. package/src/App.tsx +65 -35
  36. package/src/app/extension.ts +1 -2
  37. package/src/app/extensions.tsx +0 -14
  38. package/src/components/app-shell/breadcrumb.tsx +67 -2
  39. package/src/components/app-shell/sidebar.tsx +2 -2
  40. package/src/components/resources/views/list-view.tsx +3 -2
  41. package/src/components/ui/sheet.tsx +22 -21
  42. package/src/lib/nocobase/client.ts +6 -1
  43. package/src/locales/en-US.ts +81 -0
  44. package/src/locales/zh-CN.ts +77 -0
  45. package/src/pages/users/create.tsx +76 -53
  46. package/src/pages/users/edit.tsx +87 -56
  47. package/src/pages/users/index.ts +3 -0
  48. package/src/pages/users/layout.tsx +15 -0
  49. package/src/pages/users/list.tsx +19 -21
  50. package/src/pages/users/role-badges.tsx +43 -0
  51. package/src/pages/users/role-detail.tsx +272 -0
  52. package/src/pages/users/role-utils.ts +6 -0
  53. package/src/pages/users/routes.ts +24 -0
  54. package/src/pages/users/show.tsx +195 -104
  55. package/src/pages/users/types.ts +13 -0
  56. package/src/providers/i18n/index.ts +3 -0
  57. package/{registry/nocobase-i18n → src/providers/i18n}/runtime.ts +63 -16
  58. package/src/providers/system-settings/context.ts +26 -0
  59. package/src/providers/system-settings/index.ts +2 -0
  60. package/src/providers/system-settings/provider.tsx +94 -0
  61. /package/{registry/nocobase-i18n → src/providers/i18n}/i18n-provider.ts +0 -0
  62. /package/{registry/nocobase-i18n → src/providers/i18n}/locale-store.ts +0 -0
@@ -0,0 +1,119 @@
1
+ import type { AppExtension } from "@/app/extension";
2
+ import { Layers3 } from "lucide-react";
3
+ import { lazy } from "react";
4
+ import { Route } from "react-router";
5
+ import { LazyRouteSurfaceDemo } from "./demo/lazy-route";
6
+
7
+ const DemoHome = lazy(() =>
8
+ import("./demo").then((module) => ({
9
+ default: module.RouteSurfacesDemoHome,
10
+ }))
11
+ );
12
+ const DemoDrawer = lazy(() =>
13
+ import("./demo").then((module) => ({ default: module.DemoDrawerRoute }))
14
+ );
15
+ const DemoSecondDrawer = lazy(() =>
16
+ import("./demo").then((module) => ({
17
+ default: module.DemoSecondDrawerRoute,
18
+ }))
19
+ );
20
+ const DemoDialog = lazy(() =>
21
+ import("./demo").then((module) => ({ default: module.DemoDialogRoute }))
22
+ );
23
+ const DemoPage = lazy(() =>
24
+ import("./demo").then((module) => ({ default: module.DemoPageRoute }))
25
+ );
26
+ const DemoPageDrawer = lazy(() =>
27
+ import("./demo").then((module) => ({
28
+ default: module.DemoPageDrawerRoute,
29
+ }))
30
+ );
31
+ const DemoPageDrawerDialog = lazy(() =>
32
+ import("./demo").then((module) => ({
33
+ default: module.DemoPageDrawerDialogRoute,
34
+ }))
35
+ );
36
+
37
+ const routeSurfacesExtension: AppExtension = {
38
+ id: "nocobase-route-surfaces",
39
+ resources: [
40
+ {
41
+ name: "route-surfaces",
42
+ list: "/route-surfaces",
43
+ meta: {
44
+ label: "Route surfaces",
45
+ icon: <Layers3 />,
46
+ description: "URL-backed drawer, dialog, page, and nested route patterns.",
47
+ acl: { type: "authenticated" },
48
+ },
49
+ },
50
+ ],
51
+ routes: (
52
+ <>
53
+ <Route
54
+ key="route-surfaces-overlays"
55
+ path="/route-surfaces"
56
+ element={
57
+ <LazyRouteSurfaceDemo>
58
+ <DemoHome />
59
+ </LazyRouteSurfaceDemo>
60
+ }
61
+ >
62
+ <Route
63
+ path="drawer"
64
+ element={
65
+ <LazyRouteSurfaceDemo>
66
+ <DemoDrawer />
67
+ </LazyRouteSurfaceDemo>
68
+ }
69
+ >
70
+ <Route
71
+ path="second"
72
+ element={
73
+ <LazyRouteSurfaceDemo>
74
+ <DemoSecondDrawer />
75
+ </LazyRouteSurfaceDemo>
76
+ }
77
+ />
78
+ </Route>
79
+ <Route
80
+ path="dialog"
81
+ element={
82
+ <LazyRouteSurfaceDemo>
83
+ <DemoDialog />
84
+ </LazyRouteSurfaceDemo>
85
+ }
86
+ />
87
+ </Route>
88
+ <Route
89
+ key="route-surfaces-page"
90
+ path="/route-surfaces/page"
91
+ element={
92
+ <LazyRouteSurfaceDemo>
93
+ <DemoPage />
94
+ </LazyRouteSurfaceDemo>
95
+ }
96
+ >
97
+ <Route
98
+ path="drawer"
99
+ element={
100
+ <LazyRouteSurfaceDemo>
101
+ <DemoPageDrawer />
102
+ </LazyRouteSurfaceDemo>
103
+ }
104
+ >
105
+ <Route
106
+ path="dialog"
107
+ element={
108
+ <LazyRouteSurfaceDemo>
109
+ <DemoPageDrawerDialog />
110
+ </LazyRouteSurfaceDemo>
111
+ }
112
+ />
113
+ </Route>
114
+ </Route>
115
+ </>
116
+ ),
117
+ };
118
+
119
+ export default routeSurfacesExtension;
@@ -0,0 +1,7 @@
1
+ export * from "./route-dialog";
2
+ export * from "./route-drawer";
3
+ export * from "./route-page";
4
+ export * from "./route-surface-context";
5
+ export * from "./use-refine-unsaved-changes";
6
+ export * from "./use-route-surface-close";
7
+ export type { RouteSurfaceBeforeClose } from "./use-route-surface-state";
@@ -0,0 +1,145 @@
1
+ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
2
+ import { X } from "lucide-react";
3
+ import { type ReactNode, useCallback, useContext } from "react";
4
+
5
+ import { Button } from "@/components/ui/button";
6
+ import { cn } from "@/lib/utils";
7
+ import {
8
+ RouteOverlayDepthContext,
9
+ RouteSurfaceContext,
10
+ } from "./route-surface-context";
11
+ import {
12
+ type RouteSurfaceBeforeClose,
13
+ useRouteSurfaceState,
14
+ } from "./use-route-surface-state";
15
+
16
+ export function RouteDialog({
17
+ title,
18
+ description,
19
+ actions,
20
+ children,
21
+ closeLabel,
22
+ closeTo,
23
+ beforeClose,
24
+ className,
25
+ nested,
26
+ }: {
27
+ title: ReactNode;
28
+ description?: ReactNode;
29
+ actions?: ReactNode;
30
+ children: ReactNode;
31
+ closeLabel: string;
32
+ closeTo: string;
33
+ beforeClose?: RouteSurfaceBeforeClose;
34
+ className?: string;
35
+ nested?: ReactNode;
36
+ }) {
37
+ const parentOverlayDepth = useContext(RouteOverlayDepthContext);
38
+ const isNestedOverlay = parentOverlayDepth > 0;
39
+ const { open, setOpen, close, navigateAfterClose } = useRouteSurfaceState({
40
+ closeTo,
41
+ beforeClose,
42
+ animated: true,
43
+ });
44
+ const handleOpenChange = useCallback(
45
+ (nextOpen: boolean, eventDetails: DialogPrimitive.Root.ChangeEventDetails) => {
46
+ if (nextOpen) {
47
+ setOpen(true);
48
+ return;
49
+ }
50
+
51
+ eventDetails.cancel();
52
+ void close();
53
+ },
54
+ [close, setOpen]
55
+ );
56
+
57
+ return (
58
+ <RouteSurfaceContext.Provider value={close}>
59
+ <RouteOverlayDepthContext.Provider value={parentOverlayDepth + 1}>
60
+ <DialogPrimitive.Root
61
+ open={open}
62
+ onOpenChange={handleOpenChange}
63
+ onOpenChangeComplete={(nextOpen) => {
64
+ if (!nextOpen) navigateAfterClose();
65
+ }}
66
+ >
67
+ <DialogPrimitive.Portal>
68
+ {isNestedOverlay ? (
69
+ <RouteDialogBackdrop open={open} onClose={close} />
70
+ ) : (
71
+ <DialogPrimitive.Backdrop
72
+ className="fixed inset-0 isolate z-50 bg-black/10 duration-150 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0"
73
+ onClick={(event) => {
74
+ event.stopPropagation();
75
+ void close();
76
+ }}
77
+ />
78
+ )}
79
+ <DialogPrimitive.Popup
80
+ className={cn(
81
+ "fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl bg-popover text-sm text-popover-foreground ring-1 ring-foreground/10 duration-150 outline-none sm:max-w-2xl data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
82
+ className
83
+ )}
84
+ >
85
+ <header className="relative shrink-0 border-b px-5 py-4 pr-14 text-left">
86
+ <div className="flex items-start justify-between gap-4">
87
+ <div className="min-w-0 space-y-1">
88
+ <DialogPrimitive.Title className="font-heading truncate text-lg font-medium">
89
+ {title}
90
+ </DialogPrimitive.Title>
91
+ {description ? (
92
+ <DialogPrimitive.Description className="text-sm text-muted-foreground">
93
+ {description}
94
+ </DialogPrimitive.Description>
95
+ ) : null}
96
+ </div>
97
+ {actions ? (
98
+ <div className="flex shrink-0 items-center gap-2">
99
+ {actions}
100
+ </div>
101
+ ) : null}
102
+ </div>
103
+ <Button
104
+ type="button"
105
+ variant="ghost"
106
+ size="icon-sm"
107
+ className="absolute top-3 right-3"
108
+ onClick={() => void close()}
109
+ >
110
+ <X />
111
+ <span className="sr-only">{closeLabel}</span>
112
+ </Button>
113
+ </header>
114
+ {children}
115
+ </DialogPrimitive.Popup>
116
+ </DialogPrimitive.Portal>
117
+ {nested}
118
+ </DialogPrimitive.Root>
119
+ </RouteOverlayDepthContext.Provider>
120
+ </RouteSurfaceContext.Provider>
121
+ );
122
+ }
123
+
124
+ function RouteDialogBackdrop({
125
+ open,
126
+ onClose,
127
+ }: {
128
+ open: boolean;
129
+ onClose: () => Promise<boolean>;
130
+ }) {
131
+ return (
132
+ <div
133
+ role="presentation"
134
+ aria-hidden="true"
135
+ className={cn(
136
+ "fixed inset-0 isolate z-50 bg-black/10 transition-opacity duration-150 supports-backdrop-filter:backdrop-blur-xs",
137
+ open ? "opacity-100" : "pointer-events-none opacity-0"
138
+ )}
139
+ onClick={(event) => {
140
+ event.stopPropagation();
141
+ void onClose();
142
+ }}
143
+ />
144
+ );
145
+ }
@@ -0,0 +1,186 @@
1
+ import { Drawer as DrawerPrimitive } from "@base-ui/react/drawer";
2
+ import { X } from "lucide-react";
3
+ import {
4
+ type CSSProperties,
5
+ type ReactNode,
6
+ useCallback,
7
+ useContext,
8
+ } from "react";
9
+
10
+ import { Button } from "@/components/ui/button";
11
+ import { cn } from "@/lib/utils";
12
+ import {
13
+ RouteOverlayDepthContext,
14
+ RouteSurfaceContext,
15
+ } from "./route-surface-context";
16
+ import {
17
+ type RouteSurfaceBeforeClose,
18
+ useRouteSurfaceState,
19
+ } from "./use-route-surface-state";
20
+
21
+ export function RouteDrawer({
22
+ title,
23
+ description,
24
+ actions,
25
+ children,
26
+ closeLabel,
27
+ closeTo,
28
+ beforeClose,
29
+ className,
30
+ nested,
31
+ stackOffset = "4rem",
32
+ }: {
33
+ title: ReactNode;
34
+ description?: ReactNode;
35
+ actions?: ReactNode;
36
+ children: ReactNode;
37
+ closeLabel: string;
38
+ closeTo: string;
39
+ beforeClose?: RouteSurfaceBeforeClose;
40
+ className?: string;
41
+ nested?: ReactNode;
42
+ stackOffset?: string;
43
+ }) {
44
+ const parentOverlayDepth = useContext(RouteOverlayDepthContext);
45
+ const isNestedOverlay = parentOverlayDepth > 0;
46
+ const { open, setOpen, close, navigateAfterClose } = useRouteSurfaceState({
47
+ closeTo,
48
+ beforeClose,
49
+ animated: true,
50
+ });
51
+ const handleOpenChange = useCallback(
52
+ (nextOpen: boolean, eventDetails: DrawerPrimitive.Root.ChangeEventDetails) => {
53
+ if (nextOpen) {
54
+ setOpen(true);
55
+ return;
56
+ }
57
+
58
+ eventDetails.cancel();
59
+ void close();
60
+ },
61
+ [close, setOpen]
62
+ );
63
+ const stackStyle = {
64
+ "--peek": stackOffset,
65
+ "--stack-step": 0,
66
+ "--bleed": "0px",
67
+ } as CSSProperties;
68
+
69
+ return (
70
+ <RouteSurfaceContext.Provider value={close}>
71
+ <RouteOverlayDepthContext.Provider value={parentOverlayDepth + 1}>
72
+ <DrawerPrimitive.Root
73
+ open={open}
74
+ swipeDirection="right"
75
+ onOpenChange={handleOpenChange}
76
+ onOpenChangeComplete={(nextOpen) => {
77
+ if (!nextOpen) navigateAfterClose();
78
+ }}
79
+ >
80
+ <DrawerPrimitive.Portal>
81
+ {isNestedOverlay ? (
82
+ <RouteDrawerBackdrop open={open} onClose={close} />
83
+ ) : (
84
+ <DrawerPrimitive.Backdrop
85
+ className="fixed inset-0 z-50 min-h-dvh bg-black/10 opacity-[max(var(--drawer-overlay-min-opacity,0),calc(1-var(--drawer-swipe-progress)))] transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] select-none data-ending-style:pointer-events-none data-ending-style:opacity-0 data-starting-style:opacity-0 data-swiping:duration-0 supports-backdrop-filter:backdrop-blur-xs"
86
+ onClick={(event) => {
87
+ event.stopPropagation();
88
+ void close();
89
+ }}
90
+ />
91
+ )}
92
+ <DrawerPrimitive.Viewport className="pointer-events-none fixed inset-0 z-50 select-none">
93
+ <DrawerPrimitive.Popup
94
+ style={stackStyle}
95
+ className={cn(
96
+ "group/route-drawer pointer-events-auto fixed inset-y-0 right-0 z-50 flex w-full transform-[translate3d(var(--translate-x,0px),0,0)] flex-row bg-popover text-sm text-popover-foreground shadow-xl transition-transform duration-450 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform outline-none sm:w-full lg:w-[42vw] lg:min-w-[40rem]",
97
+ "[--stack-peek-offset:max(0px,calc((var(--nested-drawers)-var(--stack-progress))*var(--peek)))] [--stack-progress:clamp(0,var(--drawer-swipe-progress),1)]",
98
+ "[--closed-transform:translate3d(calc(100%+2px),0,0)] [--translate-x:calc(var(--drawer-swipe-movement-x)-var(--stack-peek-offset))]",
99
+ "data-ending-style:transform-(--closed-transform) data-ending-style:opacity-[0.9999] data-starting-style:transform-(--closed-transform) data-swiping:duration-0 data-nested-drawer-swiping:duration-0",
100
+ "data-nested-drawer-open:overflow-hidden",
101
+ className
102
+ )}
103
+ >
104
+ <div
105
+ aria-hidden="true"
106
+ className="pointer-events-none absolute inset-0 z-20 bg-black/10 opacity-0 transition-opacity duration-450 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-nested-drawer-open/route-drawer:opacity-100 supports-backdrop-filter:backdrop-blur-xs"
107
+ />
108
+ <DrawerPrimitive.Content className="flex min-h-0 flex-1 flex-col overflow-hidden overscroll-contain select-text group-data-swiping/route-drawer:select-none">
109
+ <header className="relative shrink-0 border-b px-5 py-4 pr-14 text-left">
110
+ <div className="flex items-start justify-between gap-4">
111
+ <div className="min-w-0 space-y-1">
112
+ <DrawerPrimitive.Title className="font-heading truncate text-lg font-medium text-foreground">
113
+ {title}
114
+ </DrawerPrimitive.Title>
115
+ {description ? (
116
+ <DrawerPrimitive.Description className="text-sm text-balance text-muted-foreground">
117
+ {description}
118
+ </DrawerPrimitive.Description>
119
+ ) : null}
120
+ </div>
121
+ {actions ? (
122
+ <div className="flex shrink-0 items-center gap-2">
123
+ {actions}
124
+ </div>
125
+ ) : null}
126
+ </div>
127
+ <Button
128
+ type="button"
129
+ variant="ghost"
130
+ size="icon-sm"
131
+ className="absolute top-3 right-3"
132
+ onClick={() => void close()}
133
+ >
134
+ <X />
135
+ <span className="sr-only">{closeLabel}</span>
136
+ </Button>
137
+ </header>
138
+ {children}
139
+ </DrawerPrimitive.Content>
140
+ </DrawerPrimitive.Popup>
141
+ </DrawerPrimitive.Viewport>
142
+ </DrawerPrimitive.Portal>
143
+ {nested}
144
+ </DrawerPrimitive.Root>
145
+ </RouteOverlayDepthContext.Provider>
146
+ </RouteSurfaceContext.Provider>
147
+ );
148
+ }
149
+
150
+ function RouteDrawerBackdrop({
151
+ open,
152
+ onClose,
153
+ }: {
154
+ open: boolean;
155
+ onClose: () => Promise<boolean>;
156
+ }) {
157
+ return (
158
+ <div
159
+ role="presentation"
160
+ aria-hidden="true"
161
+ className={cn(
162
+ "fixed inset-0 z-50 min-h-dvh bg-black/10 transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] select-none supports-backdrop-filter:backdrop-blur-xs",
163
+ open ? "opacity-100" : "pointer-events-none opacity-0"
164
+ )}
165
+ onClick={(event) => {
166
+ event.stopPropagation();
167
+ void onClose();
168
+ }}
169
+ />
170
+ );
171
+ }
172
+
173
+ export function RouteDrawerFooter({
174
+ className,
175
+ ...props
176
+ }: React.ComponentProps<"div">) {
177
+ return (
178
+ <div
179
+ className={cn(
180
+ "mt-auto flex shrink-0 flex-col gap-2 border-t px-5 py-4",
181
+ className
182
+ )}
183
+ {...props}
184
+ />
185
+ );
186
+ }
@@ -0,0 +1,29 @@
1
+ import { type ReactNode } from "react";
2
+
3
+ import { RouteSurfaceContext } from "./route-surface-context";
4
+ import {
5
+ type RouteSurfaceBeforeClose,
6
+ useRouteSurfaceState,
7
+ } from "./use-route-surface-state";
8
+
9
+ export function RoutePage({
10
+ children,
11
+ closeTo,
12
+ beforeClose,
13
+ }: {
14
+ children: ReactNode;
15
+ closeTo: string;
16
+ beforeClose?: RouteSurfaceBeforeClose;
17
+ }) {
18
+ const { close } = useRouteSurfaceState({
19
+ closeTo,
20
+ beforeClose,
21
+ animated: false,
22
+ });
23
+
24
+ return (
25
+ <RouteSurfaceContext.Provider value={close}>
26
+ {children}
27
+ </RouteSurfaceContext.Provider>
28
+ );
29
+ }
@@ -0,0 +1,13 @@
1
+ import { createContext } from "react";
2
+
3
+ export type RouteSurfaceCloseOptions = {
4
+ skipBeforeClose?: boolean;
5
+ };
6
+
7
+ export type RouteSurfaceClose = (
8
+ options?: RouteSurfaceCloseOptions
9
+ ) => Promise<boolean>;
10
+
11
+ export const RouteSurfaceContext = createContext<RouteSurfaceClose | null>(null);
12
+
13
+ export const RouteOverlayDepthContext = createContext(0);
@@ -0,0 +1,106 @@
1
+ import { useTranslate, useWarnAboutChange } from "@refinedev/core";
2
+ import { TriangleAlert } from "lucide-react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+
5
+ import {
6
+ AlertDialog,
7
+ AlertDialogAction,
8
+ AlertDialogCancel,
9
+ AlertDialogContent,
10
+ AlertDialogDescription,
11
+ AlertDialogFooter,
12
+ AlertDialogHeader,
13
+ AlertDialogMedia,
14
+ AlertDialogTitle,
15
+ } from "@/components/ui/alert-dialog";
16
+
17
+ type PendingConfirmation = {
18
+ promise: Promise<boolean>;
19
+ resolve: (allowed: boolean) => void;
20
+ };
21
+
22
+ export function useRefineUnsavedChangesGuard() {
23
+ const translate = useTranslate();
24
+ const { warnWhen, setWarnWhen } = useWarnAboutChange();
25
+ const [open, setOpen] = useState(false);
26
+ const pendingRef = useRef<PendingConfirmation | null>(null);
27
+
28
+ const settle = useCallback(
29
+ (allowed: boolean) => {
30
+ const pending = pendingRef.current;
31
+ if (!pending) return;
32
+
33
+ pendingRef.current = null;
34
+ if (allowed) setWarnWhen(false);
35
+ setOpen(false);
36
+ pending.resolve(allowed);
37
+ },
38
+ [setWarnWhen]
39
+ );
40
+
41
+ useEffect(
42
+ () => () => {
43
+ pendingRef.current?.resolve(false);
44
+ pendingRef.current = null;
45
+ },
46
+ []
47
+ );
48
+
49
+ const beforeClose = useCallback(() => {
50
+ if (!warnWhen) return true;
51
+ if (pendingRef.current) return pendingRef.current.promise;
52
+
53
+ let resolve!: (allowed: boolean) => void;
54
+ const promise = new Promise<boolean>((next) => {
55
+ resolve = next;
56
+ });
57
+ pendingRef.current = { promise, resolve };
58
+ setOpen(true);
59
+ return promise;
60
+ }, [warnWhen]);
61
+
62
+ const confirmation = useMemo(
63
+ () => (
64
+ <AlertDialog
65
+ open={open}
66
+ onOpenChange={(nextOpen) => {
67
+ if (!nextOpen) settle(false);
68
+ }}
69
+ >
70
+ <AlertDialogContent>
71
+ <AlertDialogHeader>
72
+ <AlertDialogMedia>
73
+ <TriangleAlert />
74
+ </AlertDialogMedia>
75
+ <AlertDialogTitle>
76
+ {translate(
77
+ "unsavedChanges.title",
78
+ "Discard unsaved changes?"
79
+ )}
80
+ </AlertDialogTitle>
81
+ <AlertDialogDescription>
82
+ {translate(
83
+ "unsavedChanges.description",
84
+ "Your changes have not been saved. Leaving now will discard them."
85
+ )}
86
+ </AlertDialogDescription>
87
+ </AlertDialogHeader>
88
+ <AlertDialogFooter>
89
+ <AlertDialogCancel onClick={() => settle(false)}>
90
+ {translate("unsavedChanges.stay", "Keep editing")}
91
+ </AlertDialogCancel>
92
+ <AlertDialogAction
93
+ variant="destructive"
94
+ onClick={() => settle(true)}
95
+ >
96
+ {translate("unsavedChanges.leave", "Discard changes")}
97
+ </AlertDialogAction>
98
+ </AlertDialogFooter>
99
+ </AlertDialogContent>
100
+ </AlertDialog>
101
+ ),
102
+ [open, settle, translate]
103
+ );
104
+
105
+ return { beforeClose, confirmation };
106
+ }
@@ -0,0 +1,15 @@
1
+ import { useContext } from "react";
2
+
3
+ import { RouteSurfaceContext } from "./route-surface-context";
4
+
5
+ export function useRouteSurfaceClose() {
6
+ const close = useContext(RouteSurfaceContext);
7
+
8
+ if (!close) {
9
+ throw new Error(
10
+ "useRouteSurfaceClose must be used inside a RouteDrawer, RouteDialog, or RoutePage."
11
+ );
12
+ }
13
+
14
+ return close;
15
+ }
@@ -0,0 +1,65 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { useNavigate } from "react-router";
3
+
4
+ import type {
5
+ RouteSurfaceClose,
6
+ RouteSurfaceCloseOptions,
7
+ } from "./route-surface-context";
8
+
9
+ export type RouteSurfaceBeforeClose = () => boolean | Promise<boolean>;
10
+
11
+ export function useRouteSurfaceState({
12
+ closeTo,
13
+ beforeClose,
14
+ animated,
15
+ }: {
16
+ closeTo: string;
17
+ beforeClose?: RouteSurfaceBeforeClose;
18
+ animated: boolean;
19
+ }) {
20
+ const navigate = useNavigate();
21
+ const [open, setOpen] = useState(!animated);
22
+ const closingRef = useRef(false);
23
+
24
+ useEffect(() => {
25
+ if (!animated) return;
26
+
27
+ const frame = requestAnimationFrame(() => setOpen(true));
28
+ return () => cancelAnimationFrame(frame);
29
+ }, [animated]);
30
+
31
+ const navigateAfterClose = useCallback(() => {
32
+ navigate(closeTo, { replace: true });
33
+ }, [closeTo, navigate]);
34
+
35
+ const close = useCallback<RouteSurfaceClose>(
36
+ async (options?: RouteSurfaceCloseOptions) => {
37
+ if (closingRef.current) return false;
38
+
39
+ closingRef.current = true;
40
+ try {
41
+ if (!options?.skipBeforeClose && beforeClose) {
42
+ const allowed = await beforeClose();
43
+ if (!allowed) return false;
44
+ }
45
+
46
+ if (animated) {
47
+ setOpen(false);
48
+ } else {
49
+ navigateAfterClose();
50
+ }
51
+ return true;
52
+ } finally {
53
+ closingRef.current = false;
54
+ }
55
+ },
56
+ [animated, beforeClose, navigateAfterClose]
57
+ );
58
+
59
+ return {
60
+ open,
61
+ setOpen,
62
+ close,
63
+ navigateAfterClose,
64
+ };
65
+ }