@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.
- package/README.MD +10 -5
- package/package.json +1 -1
- package/registry/nocobase-ai/components/chat/chat-message.tsx +8 -6
- package/registry/nocobase-ai/components/chat/chat-messages.tsx +8 -3
- package/registry/nocobase-ai/components/chat/markdown-message.tsx +26 -0
- package/registry/nocobase-ai/components/chat/sub-agent-conversation.tsx +3 -3
- package/registry/nocobase-ai/demo/configuration-gate.tsx +83 -35
- package/registry/nocobase-i18n/README.md +23 -32
- package/registry/nocobase-i18n/components/language-switcher.tsx +1 -1
- package/registry/nocobase-i18n/demo/index.tsx +2 -2
- package/registry/nocobase-i18n/demo/prompt-generator.tsx +3 -2
- package/registry/nocobase-i18n/extension.tsx +3 -4
- package/registry/nocobase-i18n/index.ts +4 -3
- package/registry/nocobase-i18n/locales/en-US.ts +2 -52
- package/registry/nocobase-i18n/locales/index.ts +8 -0
- package/registry/nocobase-i18n/locales/zh-CN.ts +2 -52
- package/registry/nocobase-i18n/provider.tsx +18 -6
- package/registry/nocobase-i18n/server-resources.ts +13 -31
- package/registry/nocobase-i18n/tests/i18n-regression.mjs +14 -5
- package/registry/nocobase-route-surfaces/README.md +21 -0
- package/registry/nocobase-route-surfaces/demo/index.tsx +266 -0
- package/registry/nocobase-route-surfaces/demo/lazy-route.tsx +11 -0
- package/registry/nocobase-route-surfaces/demo/prompt-generator.tsx +90 -0
- package/registry/nocobase-route-surfaces/demo/scenarios.ts +95 -0
- package/registry/nocobase-route-surfaces/extension.tsx +119 -0
- package/registry/nocobase-route-surfaces/index.ts +7 -0
- package/registry/nocobase-route-surfaces/route-dialog.tsx +145 -0
- package/registry/nocobase-route-surfaces/route-drawer.tsx +186 -0
- package/registry/nocobase-route-surfaces/route-page.tsx +29 -0
- package/registry/nocobase-route-surfaces/route-surface-context.ts +13 -0
- package/registry/nocobase-route-surfaces/use-refine-unsaved-changes.tsx +106 -0
- package/registry/nocobase-route-surfaces/use-route-surface-close.ts +15 -0
- package/registry/nocobase-route-surfaces/use-route-surface-state.ts +65 -0
- package/registry.config.json +30 -3
- package/src/App.tsx +65 -35
- package/src/app/extension.ts +1 -2
- package/src/app/extensions.tsx +0 -14
- package/src/components/app-shell/breadcrumb.tsx +67 -2
- package/src/components/app-shell/sidebar.tsx +2 -2
- package/src/components/resources/views/list-view.tsx +3 -2
- package/src/components/ui/sheet.tsx +22 -21
- package/src/lib/nocobase/client.ts +6 -1
- package/src/locales/en-US.ts +81 -0
- package/src/locales/zh-CN.ts +77 -0
- package/src/pages/users/create.tsx +76 -53
- package/src/pages/users/edit.tsx +87 -56
- package/src/pages/users/index.ts +3 -0
- package/src/pages/users/layout.tsx +15 -0
- package/src/pages/users/list.tsx +19 -21
- package/src/pages/users/role-badges.tsx +43 -0
- package/src/pages/users/role-detail.tsx +272 -0
- package/src/pages/users/role-utils.ts +6 -0
- package/src/pages/users/routes.ts +24 -0
- package/src/pages/users/show.tsx +195 -104
- package/src/pages/users/types.ts +13 -0
- package/src/providers/i18n/index.ts +3 -0
- package/{registry/nocobase-i18n → src/providers/i18n}/runtime.ts +63 -16
- package/src/providers/system-settings/context.ts +26 -0
- package/src/providers/system-settings/index.ts +2 -0
- package/src/providers/system-settings/provider.tsx +94 -0
- /package/{registry/nocobase-i18n → src/providers/i18n}/i18n-provider.ts +0 -0
- /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
|
+
}
|