@nocobase/portal-template-default 1.0.3 → 1.0.5

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 (170) hide show
  1. package/README.MD +10 -5
  2. package/package.json +17 -17
  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/{src/extensions/nocobase-acl → registry/nocobase-route-surfaces}/demo/lazy-route.tsx +2 -1
  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/scripts/registry.mjs +0 -2
  36. package/src/App.tsx +65 -35
  37. package/src/app/extension.ts +1 -2
  38. package/src/app/extensions.tsx +0 -14
  39. package/src/components/app-shell/breadcrumb.tsx +67 -2
  40. package/src/components/app-shell/sidebar.tsx +2 -2
  41. package/src/components/resources/views/list-view.tsx +3 -2
  42. package/src/components/ui/sheet.tsx +22 -21
  43. package/src/lib/nocobase/client.ts +6 -1
  44. package/src/locales/en-US.ts +81 -0
  45. package/src/locales/zh-CN.ts +77 -0
  46. package/src/pages/users/create.tsx +76 -53
  47. package/src/pages/users/edit.tsx +87 -56
  48. package/src/pages/users/index.ts +3 -0
  49. package/src/pages/users/layout.tsx +15 -0
  50. package/src/pages/users/list.tsx +19 -21
  51. package/src/pages/users/role-badges.tsx +43 -0
  52. package/src/pages/users/role-detail.tsx +272 -0
  53. package/src/pages/users/role-utils.ts +6 -0
  54. package/src/pages/users/routes.ts +24 -0
  55. package/src/pages/users/show.tsx +195 -104
  56. package/src/pages/users/types.ts +13 -0
  57. package/src/providers/i18n/index.ts +3 -0
  58. package/{registry/nocobase-i18n → src/providers/i18n}/runtime.ts +63 -16
  59. package/src/providers/system-settings/context.ts +26 -0
  60. package/src/providers/system-settings/index.ts +2 -0
  61. package/src/providers/system-settings/provider.tsx +94 -0
  62. package/src/extensions/nocobase-acl/README.md +0 -19
  63. package/src/extensions/nocobase-acl/components/acl-boundary.tsx +0 -106
  64. package/src/extensions/nocobase-acl/components/index.ts +0 -3
  65. package/src/extensions/nocobase-acl/components/role-options.ts +0 -36
  66. package/src/extensions/nocobase-acl/components/role-switcher.tsx +0 -135
  67. package/src/extensions/nocobase-acl/demo/boundary-api.tsx +0 -186
  68. package/src/extensions/nocobase-acl/demo/components.tsx +0 -231
  69. package/src/extensions/nocobase-acl/demo/index.tsx +0 -505
  70. package/src/extensions/nocobase-acl/demo/prompt-generator.tsx +0 -134
  71. package/src/extensions/nocobase-acl/demo/role-switcher-prompt-generator.tsx +0 -198
  72. package/src/extensions/nocobase-acl/demo/scenario-section.tsx +0 -37
  73. package/src/extensions/nocobase-acl/extension.tsx +0 -71
  74. package/src/extensions/nocobase-acl/index.ts +0 -1
  75. package/src/extensions/nocobase-ai/README.md +0 -70
  76. package/src/extensions/nocobase-ai/adapters/react-hook-form.ts +0 -20
  77. package/src/extensions/nocobase-ai/assets/nocobase-ai-chat.svg +0 -50
  78. package/src/extensions/nocobase-ai/components/ai-root-provider.tsx +0 -32
  79. package/src/extensions/nocobase-ai/components/chat/ai-employee-avatar.tsx +0 -31
  80. package/src/extensions/nocobase-ai/components/chat/chat-attachment.tsx +0 -92
  81. package/src/extensions/nocobase-ai/components/chat/chat-compact.tsx +0 -90
  82. package/src/extensions/nocobase-ai/components/chat/chat-composer.tsx +0 -429
  83. package/src/extensions/nocobase-ai/components/chat/chat-empty-state.tsx +0 -51
  84. package/src/extensions/nocobase-ai/components/chat/chat-header.tsx +0 -88
  85. package/src/extensions/nocobase-ai/components/chat/chat-history-dialog.tsx +0 -77
  86. package/src/extensions/nocobase-ai/components/chat/chat-message.tsx +0 -252
  87. package/src/extensions/nocobase-ai/components/chat/chat-messages.tsx +0 -173
  88. package/src/extensions/nocobase-ai/components/chat/chat-window.tsx +0 -118
  89. package/src/extensions/nocobase-ai/components/chat/conversation-list.tsx +0 -343
  90. package/src/extensions/nocobase-ai/components/chat/markdown-message.tsx +0 -165
  91. package/src/extensions/nocobase-ai/components/chat/model-select-options.tsx +0 -30
  92. package/src/extensions/nocobase-ai/components/chat/reasoning-panel.tsx +0 -29
  93. package/src/extensions/nocobase-ai/components/chat/sub-agent-conversation.tsx +0 -142
  94. package/src/extensions/nocobase-ai/components/chat/tool-call-card.tsx +0 -316
  95. package/src/extensions/nocobase-ai/components/chat/user-prompt-editor.tsx +0 -99
  96. package/src/extensions/nocobase-ai/components/chat/work-context-chip.tsx +0 -114
  97. package/src/extensions/nocobase-ai/components/index.ts +0 -59
  98. package/src/extensions/nocobase-ai/components/page-elements/ai-form.tsx +0 -44
  99. package/src/extensions/nocobase-ai/components/page-elements/page-element-provider.tsx +0 -469
  100. package/src/extensions/nocobase-ai/components/surfaces/chat-dialog.tsx +0 -31
  101. package/src/extensions/nocobase-ai/components/surfaces/chat-inline.tsx +0 -18
  102. package/src/extensions/nocobase-ai/components/surfaces/chat-page.tsx +0 -18
  103. package/src/extensions/nocobase-ai/components/surfaces/chat-side-panel-layout.tsx +0 -54
  104. package/src/extensions/nocobase-ai/components/surfaces/chat-side-panel.tsx +0 -74
  105. package/src/extensions/nocobase-ai/components/surfaces/chat-surface-actions.tsx +0 -54
  106. package/src/extensions/nocobase-ai/components/tools/builtin-tool-renderers.tsx +0 -31
  107. package/src/extensions/nocobase-ai/components/tools/business-report-dialog.tsx +0 -364
  108. package/src/extensions/nocobase-ai/components/tools/business-report-renderer.tsx +0 -122
  109. package/src/extensions/nocobase-ai/components/tools/business-report-utils.tsx +0 -282
  110. package/src/extensions/nocobase-ai/components/tools/chart-renderer.tsx +0 -99
  111. package/src/extensions/nocobase-ai/components/tools/echarts-preview.tsx +0 -59
  112. package/src/extensions/nocobase-ai/components/tools/echarts-runtime-advanced.ts +0 -18
  113. package/src/extensions/nocobase-ai/components/tools/echarts-runtime-common.ts +0 -20
  114. package/src/extensions/nocobase-ai/components/tools/echarts-runtime-components.ts +0 -34
  115. package/src/extensions/nocobase-ai/components/tools/echarts-runtime-hierarchy.ts +0 -24
  116. package/src/extensions/nocobase-ai/components/tools/echarts-runtime.ts +0 -138
  117. package/src/extensions/nocobase-ai/components/tools/sub-agent-renderer.tsx +0 -67
  118. package/src/extensions/nocobase-ai/components/tools/suggestions-renderer.tsx +0 -59
  119. package/src/extensions/nocobase-ai/components/tools/tool-renderer-provider.tsx +0 -55
  120. package/src/extensions/nocobase-ai/components/tools/tool-renderer-utils.ts +0 -18
  121. package/src/extensions/nocobase-ai/components/tools/workflow-renderer.tsx +0 -108
  122. package/src/extensions/nocobase-ai/components/triggers/ai-chat-floating-trigger.tsx +0 -73
  123. package/src/extensions/nocobase-ai/components/triggers/ai-employee-shortcut.tsx +0 -149
  124. package/src/extensions/nocobase-ai/demo/configuration-gate.tsx +0 -49
  125. package/src/extensions/nocobase-ai/demo/container-showcase.tsx +0 -210
  126. package/src/extensions/nocobase-ai/demo/floating.tsx +0 -134
  127. package/src/extensions/nocobase-ai/demo/index.tsx +0 -346
  128. package/src/extensions/nocobase-ai/demo/interaction-showcase.tsx +0 -151
  129. package/src/extensions/nocobase-ai/demo/page-context-prompt-generator.tsx +0 -355
  130. package/src/extensions/nocobase-ai/demo/page-context.tsx +0 -884
  131. package/src/extensions/nocobase-ai/demo/page-element-showcase.tsx +0 -197
  132. package/src/extensions/nocobase-ai/demo/prompt-card.tsx +0 -41
  133. package/src/extensions/nocobase-ai/demo/prompt-generator.tsx +0 -452
  134. package/src/extensions/nocobase-ai/demo/shortcut.tsx +0 -1207
  135. package/src/extensions/nocobase-ai/demo/tool-cards.tsx +0 -530
  136. package/src/extensions/nocobase-ai/extension.tsx +0 -158
  137. package/src/extensions/nocobase-ai/global-ai-chat.tsx +0 -213
  138. package/src/extensions/nocobase-ai/index.ts +0 -3
  139. package/src/extensions/nocobase-ai/providers/ai-provider.tsx +0 -323
  140. package/src/extensions/nocobase-ai/providers/avatars.ts +0 -817
  141. package/src/extensions/nocobase-ai/providers/chat-context.tsx +0 -109
  142. package/src/extensions/nocobase-ai/providers/chat-controller.ts +0 -84
  143. package/src/extensions/nocobase-ai/providers/chat-message-utils.ts +0 -198
  144. package/src/extensions/nocobase-ai/providers/chat-provider.tsx +0 -871
  145. package/src/extensions/nocobase-ai/providers/chat-reducer.ts +0 -147
  146. package/src/extensions/nocobase-ai/providers/chat-task-utils.ts +0 -105
  147. package/src/extensions/nocobase-ai/providers/chat-transport.ts +0 -184
  148. package/src/extensions/nocobase-ai/providers/form-registry.tsx +0 -271
  149. package/src/extensions/nocobase-ai/providers/frontend-tool-registry.tsx +0 -231
  150. package/src/extensions/nocobase-ai/providers/index.ts +0 -61
  151. package/src/extensions/nocobase-ai/providers/model.ts +0 -32
  152. package/src/extensions/nocobase-ai/providers/page-context.tsx +0 -136
  153. package/src/extensions/nocobase-ai/providers/stream-coalescer.ts +0 -60
  154. package/src/extensions/nocobase-ai/providers/stream-event-utils.ts +0 -82
  155. package/src/extensions/nocobase-ai/providers/stream-parser.ts +0 -61
  156. package/src/extensions/nocobase-ai/providers/sub-agent-stream.ts +0 -316
  157. package/src/extensions/nocobase-ai/providers/types.ts +0 -183
  158. package/src/extensions/nocobase-ai/providers/ui-message-stream.ts +0 -463
  159. package/src/extensions/nocobase-ai/providers/use-automatic-tool-approval.ts +0 -94
  160. package/src/extensions/nocobase-ai/providers/use-chat-attachments.ts +0 -206
  161. package/src/extensions/nocobase-ai/providers/use-chat-message-actions.ts +0 -554
  162. package/src/extensions/nocobase-ai/providers/use-chat-runtime.ts +0 -210
  163. package/src/extensions/nocobase-ai/providers/use-chat-work-context.ts +0 -93
  164. package/src/extensions/nocobase-ai/providers/use-conversation-catalog.ts +0 -108
  165. package/src/extensions/nocobase-ai/providers/use-conversation-history.ts +0 -178
  166. package/src/extensions/nocobase-ai/services/index.ts +0 -7
  167. package/src/extensions/nocobase-ai/services/nocobase-ai-service.ts +0 -454
  168. package/src/extensions/nocobase-ai/services/types.ts +0 -82
  169. /package/{registry/nocobase-i18n → src/providers/i18n}/i18n-provider.ts +0 -0
  170. /package/{registry/nocobase-i18n → src/providers/i18n}/locale-store.ts +0 -0
@@ -0,0 +1,95 @@
1
+ export type RouteSurfaceScenarioId =
2
+ | "drawer"
3
+ | "dialog"
4
+ | "page"
5
+ | "nested-drawers"
6
+ | "page-drawer-dialog";
7
+
8
+ export type RouteSurfaceScenario = {
9
+ id: RouteSurfaceScenarioId;
10
+ number: string;
11
+ title: string;
12
+ description: string;
13
+ path: string;
14
+ routeShape: string;
15
+ };
16
+
17
+ export const routeSurfaceScenarios: RouteSurfaceScenario[] = [
18
+ {
19
+ id: "drawer",
20
+ number: "1",
21
+ title: "Drawer route",
22
+ description: "Keep the current page mounted and open one URL-backed drawer.",
23
+ path: "/route-surfaces/drawer",
24
+ routeShape: "Page → Drawer",
25
+ },
26
+ {
27
+ id: "dialog",
28
+ number: "2",
29
+ title: "Dialog route",
30
+ description: "Open focused content in a modal while preserving the page URL history.",
31
+ path: "/route-surfaces/dialog",
32
+ routeShape: "Page → Dialog",
33
+ },
34
+ {
35
+ id: "page",
36
+ number: "3",
37
+ title: "Child page",
38
+ description: "Replace the current page instead of rendering the child inside an overlay.",
39
+ path: "/route-surfaces/page",
40
+ routeShape: "Page → Child page",
41
+ },
42
+ {
43
+ id: "nested-drawers",
44
+ number: "4",
45
+ title: "Second-level drawer",
46
+ description: "Push the lower drawer outward and keep the top drawer at a stable size.",
47
+ path: "/route-surfaces/drawer/second",
48
+ routeShape: "Page → Drawer → Drawer",
49
+ },
50
+ {
51
+ id: "page-drawer-dialog",
52
+ number: "5",
53
+ title: "Page, drawer, and dialog",
54
+ description: "Compose different presentation types without coupling business content to them.",
55
+ path: "/route-surfaces/page/drawer/dialog",
56
+ routeShape: "Child page → Drawer → Dialog",
57
+ },
58
+ ];
59
+
60
+ export function getRouteSurfacePrompt(
61
+ scenario: RouteSurfaceScenario,
62
+ target: string
63
+ ) {
64
+ const presentation = {
65
+ drawer:
66
+ "Keep the current page mounted and open the target content with RouteDrawer. Closing the drawer returns to the page route.",
67
+ dialog:
68
+ "Keep the current page mounted and open the target content with RouteDialog. The dialog route must be directly addressable and dismissible through the shared close lifecycle.",
69
+ page:
70
+ "Render the target content as a real child page with RoutePage. Register it as a page route branch rather than placing it inside a drawer outlet.",
71
+ "nested-drawers":
72
+ "Create a parent RouteDrawer with an Outlet and pass the rendered child outlet through its nested prop. The second RouteDrawer must push the lower drawer outward, keep equal widths, and close back to the parent route.",
73
+ "page-drawer-dialog":
74
+ "Render a RoutePage first, place a RouteDrawer route inside that page, then render a RouteDialog as the drawer's nested route. Closing each layer must return to the immediately lower URL.",
75
+ }[scenario.id];
76
+
77
+ return `Build ${target || "the requested workflow"} with the installed NocoBase route surfaces.
78
+
79
+ Scenario:
80
+ - ${scenario.routeShape}
81
+ - ${presentation}
82
+
83
+ Implementation requirements:
84
+ - Import RouteDrawer, RouteDialog, RoutePage, and useRouteSurfaceClose from @/extensions/nocobase-route-surfaces as needed.
85
+ - Keep data loading, forms, ACL guards, route paths, and business copy in application-owned files.
86
+ - Keep business content independent from its presentation container so the same content can move between a drawer, dialog, or page.
87
+ - Every surface must have an explicit closeTo URL.
88
+ - Use nested React Router routes and Outlet only when the visual layer is genuinely nested.
89
+ - A full child page must be a page route branch, not an Outlet rendered inside a drawer.
90
+ - For editable forms, use useRefineUnsavedChangesGuard, pass its beforeClose callback to the surface, render its confirmation node, and use useRouteSurfaceClose for Cancel and successful submission.
91
+ - Direct URL entry, refresh, browser back/forward, Escape, the close button, and backdrop dismissal must all restore the correct layer stack.
92
+ - Apply ResourceAccessGuard to each business resource/action independently; do not rely on a parent list permission for unrelated child pages.
93
+
94
+ Deliver the complete route definitions and React components using the application's existing design system.`;
95
+ }
@@ -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);