@nocobase/app-template-default 1.0.0-beta.88 → 1.0.0-beta.89

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 (70) hide show
  1. package/.agents/rules/components.md +3 -1
  2. package/.agents/rules/shell.md +8 -2
  3. package/AGENTS.md +1 -1
  4. package/client/components/back-button.tsx +37 -12
  5. package/client/components/brand-loading-mark.tsx +5 -2
  6. package/client/components/brand-mark-geometry.ts +3 -3
  7. package/client/components/breadcrumbs.tsx +47 -3
  8. package/client/components/data-table/data-table.tsx +90 -19
  9. package/client/components/data-table/pagination.tsx +17 -2
  10. package/client/components/list-feedback.tsx +31 -22
  11. package/client/components/loading.tsx +127 -16
  12. package/client/components/pending-button.tsx +111 -0
  13. package/client/components/role-list.tsx +7 -0
  14. package/client/components/route-child-page.tsx +72 -1
  15. package/client/components/route-overlay.tsx +149 -15
  16. package/client/components/task-progress.tsx +155 -0
  17. package/client/components/use-route-overlay.ts +8 -11
  18. package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +62 -32
  19. package/client/extensions/nocobase-file-component-ui/locales/de-DE.ts +2 -0
  20. package/client/extensions/nocobase-file-component-ui/locales/en-US.ts +1 -0
  21. package/client/extensions/nocobase-file-component-ui/locales/es-ES.ts +1 -0
  22. package/client/extensions/nocobase-file-component-ui/locales/fr-FR.ts +1 -0
  23. package/client/extensions/nocobase-file-component-ui/locales/id-ID.ts +1 -0
  24. package/client/extensions/nocobase-file-component-ui/locales/ja-JP.ts +2 -0
  25. package/client/extensions/nocobase-file-component-ui/locales/pt-BR.ts +1 -0
  26. package/client/extensions/nocobase-file-component-ui/locales/vi-VN.ts +1 -0
  27. package/client/extensions/nocobase-file-component-ui/locales/zh-CN.ts +1 -0
  28. package/client/extensions/nocobase-file-component-ui/locales/zh-TW.ts +1 -0
  29. package/client/extensions/nocobase-workflow-management/README.md +5 -5
  30. package/client/extensions/nocobase-workflow-management/definition-list.tsx +2 -9
  31. package/client/extensions/nocobase-workflow-management/execution-list.tsx +2 -4
  32. package/client/layouts/app-layout.tsx +0 -4
  33. package/client/locales/system/en-US.ts +19 -0
  34. package/client/locales/system/zh-CN.ts +19 -0
  35. package/client/pages/settings/default-access.tsx +1 -1
  36. package/client/pages/settings/roles.tsx +27 -27
  37. package/client/pages/settings/routes.ts +15 -7
  38. package/client/pages/settings/user-detail.tsx +3 -17
  39. package/client/pages/settings/user-form.tsx +9 -15
  40. package/client/pages/settings/users.tsx +11 -27
  41. package/client/routing/client-route.tsx +199 -37
  42. package/client/routing/navigation-destination.ts +42 -0
  43. package/client/routing/route-leave-boundary.tsx +13 -1
  44. package/client/styles.css +18 -1
  45. package/package.json +8 -8
  46. package/tests/client/auth-i18n.test.tsx +1 -1
  47. package/tests/client/authenticated-client-fixture.test.tsx +37 -0
  48. package/tests/client/back-button.test.tsx +34 -0
  49. package/tests/client/client-route-recovery.test.tsx +93 -1
  50. package/tests/client/list-feedback.test.tsx +5 -5
  51. package/tests/client/list-loading.test.tsx +397 -0
  52. package/tests/client/loading.test.tsx +20 -11
  53. package/tests/client/manual-list-retry.test.tsx +2 -2
  54. package/tests/client/navigation-parent.test.tsx +172 -0
  55. package/tests/client/pending-button.test.tsx +106 -0
  56. package/tests/client/permission-management-tabs.test.tsx +48 -12
  57. package/tests/client/primitive-labels.test.tsx +2 -2
  58. package/tests/client/role-return.test.tsx +21 -3
  59. package/tests/client/roles.test.tsx +84 -3
  60. package/tests/client/route-leave-recheck.test.tsx +74 -0
  61. package/tests/client/route-presentation.test.tsx +557 -0
  62. package/tests/client/session-preparation.test.tsx +20 -17
  63. package/tests/client/settings-continuity.test.tsx +35 -11
  64. package/tests/client/settings-home.test.tsx +4 -2
  65. package/tests/client/settings-idle-list.test.tsx +124 -0
  66. package/tests/client/user-drawer.test.tsx +3 -4
  67. package/tests/fixtures/dialog-footer.html +12 -0
  68. package/tests/fixtures/dialog-footer.tsx +215 -0
  69. package/tests/helpers/authenticated-client.tsx +70 -22
  70. package/tests/playwright/dialog-footer.test.ts +227 -0
@@ -1,36 +1,147 @@
1
+ import { usePendingFeedback } from '@nocobase/app-client';
1
2
  import { useTranslation } from '@nocobase/i18n/client';
2
3
  import type { ComponentPropsWithoutRef, ReactElement } from 'react';
3
-
4
- import { BrandLoadingMark } from '#components/brand-loading-mark';
5
4
  import { cn } from 'cn';
5
+ import { BrandLoadingMark } from './brand-loading-mark.js';
6
6
 
7
7
  export interface LoadingProps extends ComponentPropsWithoutRef<'div'> {
8
8
  readonly fullscreen?: boolean;
9
+ readonly pending?: boolean;
10
+ readonly identity?: unknown;
9
11
  readonly label?: string;
12
+ readonly longWaitLabel?: string;
10
13
  }
11
14
 
12
- export function Loading(inputProps: LoadingProps): ReactElement {
15
+ /** Mount inside the region that owns the work; fullscreen is for application startup. */
16
+ export function Loading({
17
+ className,
18
+ fullscreen = false,
19
+ pending = true,
20
+ identity,
21
+ label,
22
+ longWaitLabel,
23
+ ...props
24
+ }: LoadingProps): ReactElement {
13
25
  const { t } = useTranslation();
14
- const {
15
- className,
16
- fullscreen = false,
17
- label = t('status.loading', { defaultValue: 'Loading' }),
18
- ...props
19
- } = inputProps;
26
+ const feedback = usePendingFeedback(pending, identity);
27
+ const loadingLabel =
28
+ label ?? t('status.loading', { defaultValue: 'Loading' });
29
+ const waitingLabel =
30
+ longWaitLabel ??
31
+ t('pendingFeedback.longWait', {
32
+ defaultValue: 'This is taking longer than usual. You can keep waiting.',
33
+ });
34
+ const message = feedback.longWaiting ? waitingLabel : loadingLabel;
35
+ return (
36
+ <>
37
+ <div
38
+ {...props}
39
+ data-slot='loading'
40
+ aria-busy={pending}
41
+ className={cn(
42
+ 'flex flex-col items-center justify-center',
43
+ fullscreen && 'min-h-svh w-full bg-background',
44
+ className,
45
+ )}
46
+ >
47
+ <div
48
+ aria-hidden='true'
49
+ className={cn(
50
+ 'flex flex-col items-center gap-3 text-center',
51
+ !feedback.visible && 'invisible',
52
+ )}
53
+ >
54
+ <BrandLoadingMark />
55
+ <span className='grid text-sm text-muted-foreground'>
56
+ <span
57
+ className={cn(
58
+ 'col-start-1 row-start-1',
59
+ feedback.longWaiting && 'invisible',
60
+ )}
61
+ >
62
+ {loadingLabel}
63
+ </span>
64
+ <span
65
+ className={cn(
66
+ 'col-start-1 row-start-1',
67
+ !feedback.longWaiting && 'invisible',
68
+ )}
69
+ >
70
+ {waitingLabel}
71
+ </span>
72
+ </span>
73
+ </div>
74
+ </div>
75
+ {feedback.visible && (
76
+ <span
77
+ role='status'
78
+ aria-live='polite'
79
+ aria-label={message}
80
+ className='sr-only'
81
+ >
82
+ {message}
83
+ </span>
84
+ )}
85
+ </>
86
+ );
87
+ }
20
88
 
89
+ export interface RefreshIndicatorProps {
90
+ readonly pending: boolean;
91
+ readonly identity?: unknown;
92
+ readonly label?: string;
93
+ readonly longWaitLabel?: string;
94
+ readonly className?: string;
95
+ }
96
+
97
+ /**
98
+ * Background refresh of content that still belongs to the current query and permission scope. Render it inside the
99
+ * positioned element that owns that content; it takes no layout space and never replaces the content.
100
+ */
101
+ export function RefreshIndicator({
102
+ pending,
103
+ identity,
104
+ label,
105
+ longWaitLabel,
106
+ className,
107
+ }: RefreshIndicatorProps): ReactElement | null {
108
+ const { t } = useTranslation();
109
+ const feedback = usePendingFeedback(pending, identity);
110
+ if (!feedback.visible) return null;
111
+ const message = feedback.longWaiting
112
+ ? (longWaitLabel ??
113
+ t('pendingFeedback.longWait', {
114
+ defaultValue: 'This is taking longer than usual. You can keep waiting.',
115
+ }))
116
+ : (label ?? t('status.refreshing', { defaultValue: 'Refreshing' }));
21
117
  return (
22
118
  <div
23
- aria-label={label}
119
+ data-slot='refresh-indicator'
24
120
  className={cn(
25
- 'flex flex-col items-center justify-center gap-3',
26
- fullscreen && 'min-h-svh w-full bg-background',
121
+ 'pointer-events-none absolute inset-x-0 top-0 z-10 flex flex-col items-end',
27
122
  className,
28
123
  )}
29
- role='status'
30
- {...props}
31
124
  >
32
- <BrandLoadingMark />
33
- <span className='text-sm text-muted-foreground'>{label}</span>
125
+ <div
126
+ aria-hidden='true'
127
+ className='h-0.5 w-full bg-primary/70 motion-safe:animate-pulse'
128
+ />
129
+ {feedback.longWaiting && (
130
+ <span
131
+ aria-hidden='true'
132
+ className='m-2 max-w-[calc(100%-1rem)] rounded-md border border-border bg-popover px-2 py-1 text-xs text-muted-foreground shadow-sm'
133
+ >
134
+ {message}
135
+ </span>
136
+ )}
137
+ <span
138
+ role='status'
139
+ aria-live='polite'
140
+ aria-label={message}
141
+ className='sr-only'
142
+ >
143
+ {message}
144
+ </span>
34
145
  </div>
35
146
  );
36
147
  }
@@ -0,0 +1,111 @@
1
+ import { usePendingFeedback } from '@nocobase/app-client';
2
+ import { useTranslation } from '@nocobase/i18n/client';
3
+ import {
4
+ useImperativeHandle,
5
+ useRef,
6
+ type ComponentProps,
7
+ type ReactElement,
8
+ type ReactNode,
9
+ } from 'react';
10
+ import { Tooltip } from '@base-ui/react/tooltip';
11
+ import { cn } from 'cn';
12
+ import { Button } from '#components/ui/button';
13
+ import { BrandLoadingMark } from './brand-loading-mark.js';
14
+
15
+ export interface PendingButtonProps extends Omit<
16
+ ComponentProps<typeof Button>,
17
+ 'children' | 'className' | 'render' | 'nativeButton'
18
+ > {
19
+ readonly busy: boolean;
20
+ readonly identity?: unknown;
21
+ readonly pendingLabel?: string;
22
+ readonly longWaitLabel?: string;
23
+ readonly children: ReactNode;
24
+ readonly className?: string;
25
+ }
26
+
27
+ /** Consume useSubmission().submitting; the submission controller still owns the lock. */
28
+ export function PendingButton({
29
+ busy,
30
+ identity,
31
+ pendingLabel,
32
+ longWaitLabel,
33
+ children,
34
+ ref,
35
+ disabled,
36
+ className,
37
+ 'aria-label': ariaLabel,
38
+ ...props
39
+ }: PendingButtonProps): ReactElement {
40
+ const { t } = useTranslation();
41
+ const anchorRef = useRef<HTMLButtonElement>(null);
42
+ useImperativeHandle(ref, () => anchorRef.current!, []);
43
+ const feedback = usePendingFeedback(busy, identity);
44
+ const label =
45
+ pendingLabel ?? t('pendingFeedback.working', { defaultValue: 'Working…' });
46
+ const message = feedback.longWaiting
47
+ ? (longWaitLabel ??
48
+ t('pendingFeedback.longWait', {
49
+ defaultValue: 'This is taking longer than usual. You can keep waiting.',
50
+ }))
51
+ : label;
52
+ return (
53
+ <>
54
+ <Button
55
+ {...props}
56
+ ref={anchorRef}
57
+ disabled={disabled || busy}
58
+ aria-busy={busy}
59
+ aria-label={feedback.visible ? label : ariaLabel}
60
+ className={cn('motion-reduce:transition-none', className)}
61
+ >
62
+ <span className='grid'>
63
+ <span
64
+ className={cn(
65
+ 'col-start-1 row-start-1 inline-flex items-center justify-center gap-1.5',
66
+ feedback.visible && 'invisible',
67
+ )}
68
+ aria-hidden={feedback.visible || undefined}
69
+ >
70
+ {children}
71
+ </span>
72
+ <span
73
+ className={cn(
74
+ 'col-start-1 row-start-1 inline-flex items-center justify-center gap-1.5',
75
+ !feedback.visible && 'invisible',
76
+ )}
77
+ aria-hidden='true'
78
+ >
79
+ <BrandLoadingMark className='size-4 text-current' />
80
+ {label}
81
+ </span>
82
+ </span>
83
+ </Button>
84
+ <Tooltip.Root open={feedback.longWaiting}>
85
+ <Tooltip.Portal>
86
+ <Tooltip.Positioner
87
+ anchor={anchorRef}
88
+ side='top'
89
+ sideOffset={8}
90
+ collisionPadding={12}
91
+ // The hint only explains the wait; it must not block the controls it covers.
92
+ className='pointer-events-none z-50'
93
+ >
94
+ <Tooltip.Popup
95
+ data-slot='pending-hint'
96
+ aria-hidden='true'
97
+ className='max-w-64 rounded-md bg-foreground px-3 py-2 text-xs text-background shadow-md'
98
+ >
99
+ {message}
100
+ </Tooltip.Popup>
101
+ </Tooltip.Positioner>
102
+ </Tooltip.Portal>
103
+ </Tooltip.Root>
104
+ {feedback.visible && (
105
+ <span className='sr-only' role='status' aria-live='polite'>
106
+ {message}
107
+ </span>
108
+ )}
109
+ </>
110
+ );
111
+ }
@@ -26,6 +26,11 @@ export interface RoleListProps {
26
26
  readonly custom: string;
27
27
  };
28
28
  readonly ariaLabel?: string;
29
+ /**
30
+ * The host is refreshing these current roles. Pass false while another
31
+ * control, such as a retry after a refresh failure, already announces the wait.
32
+ */
33
+ readonly loading?: boolean;
29
34
  readonly tableState?: Pick<
30
35
  DataTableProps<RoleListRole>,
31
36
  | 'paginationState'
@@ -41,6 +46,7 @@ export function RoleList({
41
46
  roles,
42
47
  labels,
43
48
  ariaLabel,
49
+ loading = false,
44
50
  tableState,
45
51
  }: RoleListProps): ReactElement {
46
52
  const columns = useMemo<ColumnDef<RoleListRole>[]>(
@@ -95,6 +101,7 @@ export function RoleList({
95
101
  data={roles}
96
102
  getRowId={(role) => role.key}
97
103
  showSelectedCount={false}
104
+ loading={loading}
98
105
  />
99
106
  </div>
100
107
  );
@@ -1,8 +1,15 @@
1
+ import {
2
+ RouteChildPageHostContext,
3
+ type RouteChildPageHostValue,
4
+ } from '@nocobase/app-client/route-containers';
1
5
  import {
2
6
  createContext,
3
7
  useContext,
4
8
  useEffect,
9
+ useLayoutEffect,
10
+ useMemo,
5
11
  useRef,
12
+ useState,
6
13
  type ReactElement,
7
14
  type ReactNode,
8
15
  type RefObject,
@@ -36,7 +43,58 @@ const EnclosingLayerContext =
36
43
  * the tab's outlet — anchors to the outer element: it covers this layer whole instead of scrolling out of sight with
37
44
  * the content once the user has scrolled this page.
38
45
  */
39
- export function RouteChildPage({
46
+ export function RouteChildPage(props: RouteChildPageProps): ReactElement {
47
+ const host = useContext(RouteChildPageHostContext);
48
+ if (host) return <HostedRouteChildPage host={host} {...props} />;
49
+ return <RouteChildPageLayer {...props} />;
50
+ }
51
+
52
+ // Inside the layer the application opened, the page's `RouteChildPage` adds no second layer: its content renders in
53
+ // that layer and its `className` moves onto it, so a migrated page keeps its layout.
54
+ function HostedRouteChildPage({
55
+ host,
56
+ children,
57
+ className,
58
+ }: RouteChildPageProps & {
59
+ readonly host: RouteChildPageHostValue;
60
+ }): ReactElement {
61
+ useLayoutEffect(() => {
62
+ host.configure({ className });
63
+ return () => host.configure({});
64
+ }, [host, className]);
65
+ return (
66
+ <RouteChildPageHostContext.Provider value={null}>
67
+ {children}
68
+ </RouteChildPageHostContext.Provider>
69
+ );
70
+ }
71
+
72
+ /**
73
+ * The layer the application opens for a route that declares a `child-page` presentation, before the page module, its
74
+ * authorization check or its data are ready. Waiting, denial and failure render inside it; once the page renders, its
75
+ * own `RouteChildPage` contributes content to this layer. Everything the hosted page renders, including an outlet it
76
+ * places beside its `RouteChildPage`, is inside the layer, so a deeper child page covers this one whole.
77
+ */
78
+ export function RouteChildPageHost({
79
+ children,
80
+ }: {
81
+ readonly children?: ReactNode;
82
+ }): ReactElement {
83
+ const [className, setClassName] = useState<string>();
84
+ const host = useMemo<RouteChildPageHostValue>(
85
+ () => ({ configure: (config) => setClassName(config.className) }),
86
+ [],
87
+ );
88
+ return (
89
+ <RouteChildPageLayer className={className}>
90
+ <RouteChildPageHostContext.Provider value={host}>
91
+ {children}
92
+ </RouteChildPageHostContext.Provider>
93
+ </RouteChildPageLayer>
94
+ );
95
+ }
96
+
97
+ function RouteChildPageLayer({
40
98
  children,
41
99
  className,
42
100
  }: RouteChildPageProps): ReactElement {
@@ -119,3 +177,16 @@ export function RouteChildPage({
119
177
  </div>
120
178
  );
121
179
  }
180
+
181
+ /** Ends a `RouteChildPageHost`'s reach, so a route rendered inside one that is not hosted builds its own layer. */
182
+ export function RouteChildPageHostBoundary({
183
+ children,
184
+ }: {
185
+ readonly children?: ReactNode;
186
+ }): ReactElement {
187
+ return (
188
+ <RouteChildPageHostContext.Provider value={null}>
189
+ {children}
190
+ </RouteChildPageHostContext.Provider>
191
+ );
192
+ }
@@ -26,10 +26,14 @@ import {
26
26
  DialogPortal,
27
27
  DialogTitle,
28
28
  } from '#components/ui/dialog';
29
+ import {
30
+ RouteOverlayContext,
31
+ RouteOverlayHostContext,
32
+ type RouteOverlayHostConfig,
33
+ type RouteOverlayHostValue,
34
+ } from '@nocobase/app-client/route-containers';
29
35
  import { cn } from 'cn';
30
36
 
31
- import { RouteOverlayContext } from './use-route-overlay.js';
32
-
33
37
  export interface RouteOverlayProps {
34
38
  readonly title: ReactNode;
35
39
  readonly description?: ReactNode;
@@ -52,8 +56,103 @@ const ParentPopupContext =
52
56
  /**
53
57
  * The shared implementation of `RouteDialog` and `RouteDrawer`: a modal that is open for as long as its route matches,
54
58
  * and closes by navigating away from it. Route registration stays with the application.
59
+ *
60
+ * On a route that declares a `dialog` or `drawer` presentation the application has already opened the panel, and this
61
+ * component configures that one instead of opening a second: its title, description, footer, `closeTo` and
62
+ * `beforeClose` move to the host and its children render in the host's body. On any other route it opens its own.
63
+ * The host context comes from `@nocobase/app-client`, so a plugin's own copy of this component joins the same panel.
64
+ */
65
+ export function RouteOverlay(
66
+ props: RouteOverlayProps & { readonly drawer?: boolean },
67
+ ): ReactElement {
68
+ const host = useContext(RouteOverlayHostContext);
69
+ return host ? (
70
+ <HostedRouteOverlay host={host} {...props} />
71
+ ) : (
72
+ <RouteOverlayFrame {...props} />
73
+ );
74
+ }
75
+
76
+ function HostedRouteOverlay({
77
+ host,
78
+ children,
79
+ drawer: _drawer,
80
+ ...config
81
+ }: RouteOverlayProps & {
82
+ readonly host: RouteOverlayHostValue;
83
+ readonly drawer?: boolean;
84
+ }): ReactElement {
85
+ // Runs after every render so the footer and guard the host shows are always this render's. There is no cleanup:
86
+ // the host stops using the configuration once it is told the page is gone, and an `<Activity>` that only hides
87
+ // the page during a recheck must not take its close guard away.
88
+ useLayoutEffect(() => {
89
+ host.configure(config);
90
+ });
91
+ return (
92
+ <RouteOverlayHostContext.Provider value={null}>
93
+ {children}
94
+ </RouteOverlayHostContext.Provider>
95
+ );
96
+ }
97
+
98
+ export interface RouteOverlayHostProps {
99
+ readonly drawer?: boolean;
100
+ /** Shown until the hosted page configures its own title, and whenever its content is concealed. */
101
+ readonly title: ReactNode;
102
+ /**
103
+ * True while the page component is mounted. While it is false — the module is loading, failed or was denied — the
104
+ * host ignores what an earlier mount configured and closes without a guard.
105
+ */
106
+ readonly ready?: boolean;
107
+ /**
108
+ * True while the mounted page is hidden, for example during an authorization recheck: the host shows only its own
109
+ * title and no footer, so nothing the page derived from protected data stays on screen. The page's close guard
110
+ * is temporarily blocked until the page's effects reconnect, because hiding disconnects form dirty registrations.
111
+ */
112
+ readonly concealed?: boolean;
113
+ readonly children?: ReactNode;
114
+ }
115
+
116
+ /**
117
+ * The panel the application opens for a route that declares a `dialog` or `drawer` presentation, before the page
118
+ * module, its authorization check or its data are ready. Waiting, denial and failure render as its children; once the
119
+ * page renders its own `RouteDialog` or `RouteDrawer` inside, that configures this panel. There is still one panel
120
+ * and one focus trap, and the same close path and focus return as a standalone overlay. A hosted page renders its
121
+ * `RouteDialog` or `RouteDrawer` unconditionally, as its root.
55
122
  */
56
- export function RouteOverlay({
123
+ export function RouteOverlayHost({
124
+ drawer = false,
125
+ title,
126
+ ready = false,
127
+ concealed = false,
128
+ children,
129
+ }: RouteOverlayHostProps): ReactElement {
130
+ const [config, setConfig] = useState<RouteOverlayHostConfig | null>(null);
131
+ const host = useMemo<RouteOverlayHostValue>(
132
+ () => ({ configure: setConfig }),
133
+ [],
134
+ );
135
+ const page = ready ? config : null;
136
+ const shown = concealed ? null : page;
137
+ return (
138
+ <RouteOverlayFrame
139
+ drawer={drawer}
140
+ title={shown?.title ?? title}
141
+ description={shown?.description}
142
+ footer={shown?.footer}
143
+ closeTo={page?.closeTo}
144
+ beforeClose={page?.beforeClose}
145
+ closeBlocked={ready && concealed}
146
+ className={page?.className}
147
+ >
148
+ <RouteOverlayHostContext.Provider value={host}>
149
+ {children}
150
+ </RouteOverlayHostContext.Provider>
151
+ </RouteOverlayFrame>
152
+ );
153
+ }
154
+
155
+ function RouteOverlayFrame({
57
156
  title,
58
157
  description,
59
158
  children,
@@ -62,10 +161,24 @@ export function RouteOverlay({
62
161
  beforeClose,
63
162
  className,
64
163
  drawer = false,
65
- }: RouteOverlayProps & { readonly drawer?: boolean }): ReactElement {
164
+ closeBlocked = false,
165
+ }: RouteOverlayProps & {
166
+ readonly drawer?: boolean;
167
+ readonly closeBlocked?: boolean;
168
+ }): ReactElement {
66
169
  const { t } = useTranslation();
67
170
  const parentPopup = useContext(ParentPopupContext);
68
171
  const popupRef = useRef<HTMLDivElement>(null);
172
+ const [portal, setPortal] = useState<HTMLDivElement | null>(null);
173
+ // A parent route's Activity can stop at its first DOM element, while this portal lives outside that element.
174
+ // Base UI creates the portal after mount, so track the actual node before connecting its visibility lifecycle.
175
+ useLayoutEffect(() => {
176
+ if (!portal) return;
177
+ portal.style.removeProperty('display');
178
+ return () => {
179
+ portal.style.display = 'none';
180
+ };
181
+ }, [portal]);
69
182
  const previousFocusRef = useRef<HTMLElement | null>(null);
70
183
  useLayoutEffect(() => {
71
184
  previousFocusRef.current =
@@ -75,29 +188,37 @@ export function RouteOverlay({
75
188
  }, []);
76
189
  const navigate = useNavigate();
77
190
  const location = useLocation();
78
- const [closingLocation, setClosingLocation] = useState<
79
- typeof location | null
80
- >(null);
81
- const isClosing = closingLocation === location;
191
+ const closeScope = useMemo(
192
+ () => ({ location, closeBlocked }),
193
+ [location, closeBlocked],
194
+ );
195
+ const [closingScope, setClosingScope] = useState<typeof closeScope | null>(
196
+ null,
197
+ );
198
+ const isClosing = closingScope === closeScope;
82
199
  const pendingRef = useRef<Promise<void> | null>(null);
83
200
  const generationRef = useRef(0);
84
201
 
85
- // A still-mounted parent can change location while its confirmation is pending.
86
- // Invalidate that request as well as requests from an unmounted route.
202
+ // A location change or concealment invalidates pending closes. Activity cleanup can resolve a form's old
203
+ // confirmation, but that result must never navigate, including after permission is restored.
87
204
  useLayoutEffect(() => {
88
205
  generationRef.current += 1;
89
206
  pendingRef.current = null;
90
207
  return () => {
91
208
  generationRef.current += 1;
209
+ setClosingScope(null);
92
210
  };
93
- }, [location]);
211
+ }, [location, closeBlocked]);
94
212
 
95
213
  const close = useCallback((): Promise<void> => {
214
+ if (closeBlocked) return Promise.resolve();
96
215
  if (pendingRef.current) return pendingRef.current;
97
216
  const requestGeneration = generationRef.current;
98
- setClosingLocation(location);
217
+ setClosingScope(closeScope);
99
218
  const request = Promise.resolve()
100
219
  .then(async () => {
220
+ // A recheck can start while an earlier confirmation is open, or before this microtask runs.
221
+ if (generationRef.current !== requestGeneration) return;
101
222
  const allowed = beforeClose ? await beforeClose() : true;
102
223
  if (allowed && generationRef.current === requestGeneration) {
103
224
  await navigate(
@@ -109,12 +230,12 @@ export function RouteOverlay({
109
230
  .finally(() => {
110
231
  if (generationRef.current === requestGeneration) {
111
232
  pendingRef.current = null;
112
- setClosingLocation(null);
233
+ setClosingScope(null);
113
234
  }
114
235
  });
115
236
  pendingRef.current = request;
116
237
  return request;
117
- }, [beforeClose, closeTo, location, navigate]);
238
+ }, [beforeClose, closeBlocked, closeScope, closeTo, location, navigate]);
118
239
  const value = useMemo(() => ({ close, isClosing }), [close, isClosing]);
119
240
 
120
241
  return (
@@ -131,7 +252,7 @@ export function RouteOverlay({
131
252
  });
132
253
  }}
133
254
  >
134
- <DialogPortal>
255
+ <DialogPortal ref={setPortal}>
135
256
  {/* Each nested panel needs its own backdrop above its parent panel. */}
136
257
  <DialogOverlay forceRender />
137
258
  <DialogPrimitive.Popup
@@ -212,3 +333,16 @@ export function RouteOverlay({
212
333
  </RouteOverlayContext.Provider>
213
334
  );
214
335
  }
336
+
337
+ /** Ends a `RouteOverlayHost`'s reach, so a route rendered inside one that is not hosted builds its own overlay. */
338
+ export function RouteOverlayHostBoundary({
339
+ children,
340
+ }: {
341
+ readonly children?: ReactNode;
342
+ }): ReactElement {
343
+ return (
344
+ <RouteOverlayHostContext.Provider value={null}>
345
+ {children}
346
+ </RouteOverlayHostContext.Provider>
347
+ );
348
+ }