@nocobase/app-template-default 1.0.0-beta.87 → 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 (107) hide show
  1. package/.agents/rules/components.md +3 -1
  2. package/.agents/rules/notification-templates.md +5 -0
  3. package/.agents/rules/shell.md +9 -3
  4. package/AGENTS.md +4 -2
  5. package/README.MD +11 -5
  6. package/client/components/back-button.tsx +37 -12
  7. package/client/components/body-portal.tsx +25 -0
  8. package/client/components/brand-loading-mark.tsx +5 -2
  9. package/client/components/brand-mark-geometry.ts +3 -3
  10. package/client/components/breadcrumbs.tsx +60 -3
  11. package/client/components/data-table/data-table.tsx +90 -19
  12. package/client/components/data-table/pagination.tsx +17 -2
  13. package/client/components/flow-diagram.tsx +2 -2
  14. package/client/components/list-feedback.tsx +31 -22
  15. package/client/components/loading.tsx +127 -16
  16. package/client/components/pending-button.tsx +111 -0
  17. package/client/components/role-list.tsx +7 -0
  18. package/client/components/route-child-page.tsx +72 -1
  19. package/client/components/route-overlay.tsx +149 -15
  20. package/client/components/task-progress.tsx +155 -0
  21. package/client/components/use-route-overlay.ts +8 -11
  22. package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +62 -32
  23. package/client/extensions/nocobase-file-component-ui/locales/de-DE.ts +2 -0
  24. package/client/extensions/nocobase-file-component-ui/locales/en-US.ts +1 -0
  25. package/client/extensions/nocobase-file-component-ui/locales/es-ES.ts +1 -0
  26. package/client/extensions/nocobase-file-component-ui/locales/fr-FR.ts +1 -0
  27. package/client/extensions/nocobase-file-component-ui/locales/id-ID.ts +1 -0
  28. package/client/extensions/nocobase-file-component-ui/locales/ja-JP.ts +2 -0
  29. package/client/extensions/nocobase-file-component-ui/locales/pt-BR.ts +1 -0
  30. package/client/extensions/nocobase-file-component-ui/locales/vi-VN.ts +1 -0
  31. package/client/extensions/nocobase-file-component-ui/locales/zh-CN.ts +1 -0
  32. package/client/extensions/nocobase-file-component-ui/locales/zh-TW.ts +1 -0
  33. package/client/extensions/nocobase-workflow-management/README.md +5 -5
  34. package/client/extensions/nocobase-workflow-management/definition-list.tsx +2 -9
  35. package/client/extensions/nocobase-workflow-management/execution-list.tsx +2 -4
  36. package/client/layouts/app-layout.tsx +8 -8
  37. package/client/layouts/components/sidebar-footer.tsx +18 -25
  38. package/client/lib/navigation-transform.ts +156 -22
  39. package/client/locales/system/en-US.ts +26 -5
  40. package/client/locales/system/settings-en-US.ts +17 -0
  41. package/client/locales/system/settings-zh-CN.ts +15 -0
  42. package/client/locales/system/zh-CN.ts +26 -5
  43. package/client/pages/auth/register.tsx +39 -22
  44. package/client/pages/auth/shared.tsx +1 -3
  45. package/client/pages/settings/default-access.tsx +1 -1
  46. package/client/pages/settings/hooks.tsx +3 -68
  47. package/client/pages/settings/notification-routes.ts +109 -0
  48. package/client/pages/settings/notification-templates.tsx +26 -0
  49. package/client/pages/settings/permission-management-page.tsx +15 -90
  50. package/client/pages/settings/roles.tsx +27 -27
  51. package/client/pages/settings/routes.ts +107 -101
  52. package/client/pages/settings/settings-home.tsx +109 -0
  53. package/client/pages/settings/settings-module-page.tsx +158 -0
  54. package/client/pages/settings/shell-routes.ts +53 -0
  55. package/client/pages/settings/user-detail.tsx +3 -17
  56. package/client/pages/settings/user-form.tsx +9 -15
  57. package/client/pages/settings/users.tsx +11 -27
  58. package/client/routes.ts +17 -1
  59. package/client/routing/app-router.tsx +1 -14
  60. package/client/routing/client-route.tsx +199 -30
  61. package/client/routing/navigation-destination.ts +42 -0
  62. package/client/routing/route-context.tsx +30 -3
  63. package/client/routing/route-leave-boundary.tsx +261 -0
  64. package/client/routing/route-navigation.ts +72 -8
  65. package/client/routing/route-tree.tsx +7 -12
  66. package/client/runtime.ts +2 -2
  67. package/client/styles.css +18 -1
  68. package/config.example.yml +1 -1
  69. package/package.json +13 -13
  70. package/server/config/auth.ts +1 -1
  71. package/server/providers/index.ts +2 -0
  72. package/server/providers/notification-management.ts +27 -0
  73. package/tests/client/account-permissions.test.tsx +1 -1
  74. package/tests/client/auth-i18n.test.tsx +1 -1
  75. package/tests/client/authenticated-client-fixture.test.tsx +37 -0
  76. package/tests/client/back-button.test.tsx +34 -0
  77. package/tests/client/client-auth.test.tsx +10 -1
  78. package/tests/client/client-route-recovery.test.tsx +93 -1
  79. package/tests/client/client-routes.test.ts +137 -4
  80. package/tests/client/client-shell.test.tsx +23 -8
  81. package/tests/client/foundation-integration.test.ts +38 -17
  82. package/tests/client/layout-i18n.test.tsx +9 -4
  83. package/tests/client/list-feedback.test.tsx +5 -5
  84. package/tests/client/list-loading.test.tsx +397 -0
  85. package/tests/client/loading.test.tsx +20 -11
  86. package/tests/client/manual-list-retry.test.tsx +2 -2
  87. package/tests/client/navigation-parent.test.tsx +172 -0
  88. package/tests/client/pending-button.test.tsx +106 -0
  89. package/tests/client/permission-management-tabs.test.tsx +458 -17
  90. package/tests/client/primitive-labels.test.tsx +2 -2
  91. package/tests/client/registration-feedback.test.tsx +143 -0
  92. package/tests/client/role-return.test.tsx +21 -3
  93. package/tests/client/roles.test.tsx +84 -3
  94. package/tests/client/route-leave-recheck.test.tsx +74 -0
  95. package/tests/client/route-presentation.test.tsx +557 -0
  96. package/tests/client/session-preparation.test.tsx +20 -17
  97. package/tests/client/settings-continuity.test.tsx +35 -11
  98. package/tests/client/settings-home.test.tsx +112 -0
  99. package/tests/client/settings-idle-list.test.tsx +124 -0
  100. package/tests/client/settings-navigation.test.ts +230 -0
  101. package/tests/client/user-drawer.test.tsx +3 -4
  102. package/tests/fixtures/dialog-footer.html +12 -0
  103. package/tests/fixtures/dialog-footer.tsx +215 -0
  104. package/tests/helpers/authenticated-client.tsx +70 -22
  105. package/tests/playwright/dialog-footer.test.ts +227 -0
  106. package/tests/server/notification-templates.test.ts +144 -0
  107. package/tests/client/permission-management-routes.test.ts +0 -64
@@ -8,7 +8,7 @@ import {
8
8
  type ReactElement,
9
9
  type ReactNode,
10
10
  } from 'react';
11
- import { createPortal } from 'react-dom';
11
+ import { BodyPortal } from './body-portal.js';
12
12
  import {
13
13
  BaseEdge,
14
14
  Controls,
@@ -934,7 +934,7 @@ export function FlowDiagram({
934
934
  )}
935
935
  </div>
936
936
  );
937
- return expanded ? createPortal(canvas, document.body) : canvas;
937
+ return expanded ? <BodyPortal>{canvas}</BodyPortal> : canvas;
938
938
  }
939
939
 
940
940
  /** View controls belong inside React Flow so they always use this canvas's viewport. */
@@ -3,7 +3,6 @@ import { useTranslation } from '@nocobase/i18n/client';
3
3
  import {
4
4
  CircleAlert,
5
5
  Inbox,
6
- LoaderCircle,
7
6
  LockKeyhole,
8
7
  LogIn,
9
8
  RefreshCw,
@@ -14,6 +13,8 @@ import { Fragment, type ReactElement, type ReactNode } from 'react';
14
13
  import { Link } from 'react-router';
15
14
  import { Button } from '#components/ui/button';
16
15
  import { Alert } from '#components/ui/alert';
16
+ import { Loading } from '#components/loading';
17
+ import { PendingButton } from '#components/pending-button';
17
18
  import {
18
19
  Empty,
19
20
  EmptyContent,
@@ -23,7 +24,6 @@ import {
23
24
  } from '#components/ui/empty';
24
25
 
25
26
  const feedback = {
26
- loading: ['listFeedback.loading', 'Loading', LoaderCircle],
27
27
  empty: ['listFeedback.empty', 'No data yet', Inbox],
28
28
  'no-results': ['listFeedback.noResults', 'No results', SearchX],
29
29
  forbidden: ['listFeedback.forbidden', 'Access denied', LockKeyhole],
@@ -38,6 +38,11 @@ export interface ListFeedbackProps {
38
38
  readonly empty?: ReactNode;
39
39
  /** Optional business-specific action; the standard create action belongs in the page header. */
40
40
  readonly emptyAction?: ReactNode;
41
+ /**
42
+ * The current query's stable identity, such as its request path. A new
43
+ * query restarts the display delay without remounting the list.
44
+ */
45
+ readonly identity?: unknown;
41
46
  }
42
47
 
43
48
  export interface ListFeedbackActionsProps {
@@ -67,19 +72,17 @@ export function ListFeedbackActions({
67
72
 
68
73
  if ((state === 'failed' || state === 'refresh-failed') && onRetry) {
69
74
  return (
70
- <Button variant='outline' disabled={busy} onClick={onRetry}>
71
- {busy ? (
72
- <LoaderCircle
73
- aria-hidden='true'
74
- className='motion-safe:animate-spin'
75
- />
76
- ) : (
77
- <RefreshCw aria-hidden='true' />
78
- )}
79
- {busy
80
- ? t('listFeedback.retrying', { defaultValue: 'Retrying…' })
81
- : t('listFeedback.retry', { defaultValue: 'Retry' })}
82
- </Button>
75
+ <PendingButton
76
+ variant='outline'
77
+ busy={busy}
78
+ pendingLabel={t('listFeedback.retrying', {
79
+ defaultValue: 'Retrying…',
80
+ })}
81
+ onClick={onRetry}
82
+ >
83
+ <RefreshCw aria-hidden='true' />
84
+ {t('listFeedback.retry', { defaultValue: 'Retry' })}
85
+ </PendingButton>
83
86
  );
84
87
  }
85
88
 
@@ -105,6 +108,7 @@ export function ListFeedback({
105
108
  children,
106
109
  empty,
107
110
  emptyAction,
111
+ identity,
108
112
  }: ListFeedbackProps): ReactElement {
109
113
  const { t } = useTranslation();
110
114
 
@@ -134,6 +138,17 @@ export function ListFeedback({
134
138
  );
135
139
  }
136
140
 
141
+ if (state === 'loading') {
142
+ // The body keeps its footprint at once; the indicator and one status follow the shared display delay.
143
+ return (
144
+ <Loading
145
+ identity={identity}
146
+ label={t('listFeedback.loading', { defaultValue: 'Loading' })}
147
+ className='min-h-24 px-4 py-4'
148
+ />
149
+ );
150
+ }
151
+
137
152
  const [key, fallback, Icon] = feedback[state];
138
153
  const isError =
139
154
  state === 'failed' || state === 'forbidden' || state === 'unauthenticated';
@@ -145,15 +160,9 @@ export function ListFeedback({
145
160
  <Empty
146
161
  role={isError ? 'alert' : 'status'}
147
162
  className='min-h-24 flex-row flex-none gap-2 rounded-none border-0 px-4 py-6 text-pretty text-card-foreground'
148
- aria-busy={state === 'loading'}
149
163
  >
150
164
  <EmptyMedia className='mb-0 size-4 text-muted-foreground'>
151
- <Icon
152
- aria-hidden='true'
153
- className={
154
- state === 'loading' ? 'size-4 motion-safe:animate-spin' : 'size-4'
155
- }
156
- />
165
+ <Icon aria-hidden='true' className='size-4' />
157
166
  </EmptyMedia>
158
167
  <EmptyTitle className='text-sm font-medium text-muted-foreground'>
159
168
  {t(key, { defaultValue: fallback })}
@@ -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
+ }