@cratis/scene.react 4.13.0 → 4.14.0

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 (89) hide show
  1. package/dist/cjs/core/coreComponents.js +1 -1
  2. package/dist/cjs/index.js +12 -2
  3. package/dist/cjs/index.js.map +1 -1
  4. package/dist/cjs/navigation/SceneNavigationContext.js.map +1 -1
  5. package/dist/cjs/navigation/SceneNavigationHost.js +19 -44
  6. package/dist/cjs/navigation/SceneNavigationHost.js.map +1 -1
  7. package/dist/cjs/navigation/createBrowserSceneHistory.js +39 -0
  8. package/dist/cjs/navigation/createBrowserSceneHistory.js.map +1 -0
  9. package/dist/cjs/navigation/createMemorySceneHistory.js +53 -0
  10. package/dist/cjs/navigation/createMemorySceneHistory.js.map +1 -0
  11. package/dist/cjs/navigation/matchSceneRoute.js +43 -0
  12. package/dist/cjs/navigation/matchSceneRoute.js.map +1 -0
  13. package/dist/cjs/navigation/resolveDestination.js +1 -0
  14. package/dist/cjs/navigation/resolveDestination.js.map +1 -1
  15. package/dist/cjs/navigation/sceneRoutesFrom.js +37 -0
  16. package/dist/cjs/navigation/sceneRoutesFrom.js.map +1 -0
  17. package/dist/cjs/navigation/useSceneHistoryNavigation.js +112 -0
  18. package/dist/cjs/navigation/useSceneHistoryNavigation.js.map +1 -0
  19. package/dist/esm/core/coreComponents.js +1 -1
  20. package/dist/esm/index.js +6 -1
  21. package/dist/esm/index.js.map +1 -1
  22. package/dist/esm/navigation/DestinationResolution.d.ts +1 -0
  23. package/dist/esm/navigation/DestinationResolution.d.ts.map +1 -1
  24. package/dist/esm/navigation/DialogResult.d.ts +5 -0
  25. package/dist/esm/navigation/DialogResult.d.ts.map +1 -0
  26. package/dist/esm/navigation/DialogResult.js +2 -0
  27. package/dist/esm/navigation/DialogResult.js.map +1 -0
  28. package/dist/esm/navigation/SceneHistory.d.ts +10 -0
  29. package/dist/esm/navigation/SceneHistory.d.ts.map +1 -0
  30. package/dist/esm/navigation/SceneHistory.js +2 -0
  31. package/dist/esm/navigation/SceneHistory.js.map +1 -0
  32. package/dist/esm/navigation/SceneNavigationContext.d.ts +6 -1
  33. package/dist/esm/navigation/SceneNavigationContext.d.ts.map +1 -1
  34. package/dist/esm/navigation/SceneNavigationContext.js.map +1 -1
  35. package/dist/esm/navigation/SceneNavigationHost.d.ts +7 -2
  36. package/dist/esm/navigation/SceneNavigationHost.d.ts.map +1 -1
  37. package/dist/esm/navigation/SceneNavigationHost.js +20 -45
  38. package/dist/esm/navigation/SceneNavigationHost.js.map +1 -1
  39. package/dist/esm/navigation/SceneNavigationState.d.ts +8 -0
  40. package/dist/esm/navigation/SceneNavigationState.d.ts.map +1 -0
  41. package/dist/esm/navigation/SceneNavigationState.js +2 -0
  42. package/dist/esm/navigation/SceneNavigationState.js.map +1 -0
  43. package/dist/esm/navigation/SceneRoute.d.ts +6 -0
  44. package/dist/esm/navigation/SceneRoute.d.ts.map +1 -0
  45. package/dist/esm/navigation/SceneRoute.js +2 -0
  46. package/dist/esm/navigation/SceneRoute.js.map +1 -0
  47. package/dist/esm/navigation/createBrowserSceneHistory.d.ts +4 -0
  48. package/dist/esm/navigation/createBrowserSceneHistory.d.ts.map +1 -0
  49. package/dist/esm/navigation/createBrowserSceneHistory.js +37 -0
  50. package/dist/esm/navigation/createBrowserSceneHistory.js.map +1 -0
  51. package/dist/esm/navigation/createMemorySceneHistory.d.ts +7 -0
  52. package/dist/esm/navigation/createMemorySceneHistory.d.ts.map +1 -0
  53. package/dist/esm/navigation/createMemorySceneHistory.js +51 -0
  54. package/dist/esm/navigation/createMemorySceneHistory.js.map +1 -0
  55. package/dist/esm/navigation/index.d.ts +10 -1
  56. package/dist/esm/navigation/index.d.ts.map +1 -1
  57. package/dist/esm/navigation/index.js +10 -1
  58. package/dist/esm/navigation/index.js.map +1 -1
  59. package/dist/esm/navigation/matchSceneRoute.d.ts +7 -0
  60. package/dist/esm/navigation/matchSceneRoute.d.ts.map +1 -0
  61. package/dist/esm/navigation/matchSceneRoute.js +41 -0
  62. package/dist/esm/navigation/matchSceneRoute.js.map +1 -0
  63. package/dist/esm/navigation/resolveDestination.d.ts.map +1 -1
  64. package/dist/esm/navigation/resolveDestination.js +1 -0
  65. package/dist/esm/navigation/resolveDestination.js.map +1 -1
  66. package/dist/esm/navigation/sceneRoutesFrom.d.ts +4 -0
  67. package/dist/esm/navigation/sceneRoutesFrom.d.ts.map +1 -0
  68. package/dist/esm/navigation/sceneRoutesFrom.js +35 -0
  69. package/dist/esm/navigation/sceneRoutesFrom.js.map +1 -0
  70. package/dist/esm/navigation/useSceneHistoryNavigation.d.ts +6 -0
  71. package/dist/esm/navigation/useSceneHistoryNavigation.d.ts.map +1 -0
  72. package/dist/esm/navigation/useSceneHistoryNavigation.js +110 -0
  73. package/dist/esm/navigation/useSceneHistoryNavigation.js.map +1 -0
  74. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  75. package/navigation/DestinationResolution.ts +3 -0
  76. package/navigation/DialogResult.ts +8 -0
  77. package/navigation/SceneHistory.ts +24 -0
  78. package/navigation/SceneNavigationContext.tsx +16 -1
  79. package/navigation/SceneNavigationHost.tsx +30 -40
  80. package/navigation/SceneNavigationState.ts +19 -0
  81. package/navigation/SceneRoute.ts +12 -0
  82. package/navigation/createBrowserSceneHistory.ts +40 -0
  83. package/navigation/createMemorySceneHistory.ts +44 -0
  84. package/navigation/index.ts +10 -1
  85. package/navigation/matchSceneRoute.ts +45 -0
  86. package/navigation/resolveDestination.ts +1 -0
  87. package/navigation/sceneRoutesFrom.ts +24 -0
  88. package/navigation/useSceneHistoryNavigation.ts +80 -0
  89. package/package.json +5 -5
@@ -8,6 +8,9 @@ export interface DestinationResolution {
8
8
  url?: string;
9
9
  outlet?: string;
10
10
  dialog?: string;
11
+
12
+ /** The resolved route parameters, by name, before they are written into the URL. */
13
+ parameters: Record<string, string>;
11
14
  action: 'navigate' | 'openDialog' | 'openExternal';
12
15
  diagnostics: string[];
13
16
  }
@@ -0,0 +1,8 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ /** What a dialog returned when it closed, for the screen that opened it. */
5
+ export interface DialogResult {
6
+ dialog: string;
7
+ result?: unknown;
8
+ }
@@ -0,0 +1,24 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { SceneNavigationState } from './SceneNavigationState';
5
+
6
+ /**
7
+ * The history a navigation host records its entries in. The browser implementation drives `window.history`,
8
+ * so deep links, refresh and the browser's back and forward buttons work; the memory implementation keeps
9
+ * the same stack for embedded and design-time hosts that do not own the address bar.
10
+ */
11
+ export interface SceneHistory {
12
+ /** The current URL relative to the base path, or undefined at the base path itself. */
13
+ location(): string | undefined;
14
+
15
+ /** The state stored with the current entry, if the host wrote it. */
16
+ state(): SceneNavigationState | undefined;
17
+
18
+ push(state: SceneNavigationState): void;
19
+ replace(state: SceneNavigationState): void;
20
+ back(): void;
21
+
22
+ /** Called with the entry's URL and stored state after back or forward. Returns the unsubscribe function. */
23
+ listen(listener: (url: string | undefined, state: SceneNavigationState | undefined) => void): () => void;
24
+ }
@@ -5,15 +5,30 @@ import { ReactNode, createContext, useContext } from 'react';
5
5
  import { BindingScope } from '@cratis/scene.engine';
6
6
  import { DestinationReference } from '@cratis/scene.model';
7
7
  import { DestinationResolution } from './DestinationResolution';
8
+ import { DialogResult } from './DialogResult';
8
9
 
9
10
  export interface SceneNavigationContextValue {
10
11
  currentScreen: string;
11
12
  currentUrl?: string;
12
13
  currentOutlet?: string;
13
14
  currentDialog?: string;
15
+
16
+ /** The route parameters of the current entry - from the destination that opened it, or the deep link. */
17
+ currentParameters: Record<string, string>;
18
+
19
+ /** What the last dialog returned when it closed. */
20
+ dialogResult?: DialogResult;
21
+
22
+ /** A deep link that matched no route; the host fell back to its initial screen. */
23
+ unresolvedUrl?: string;
14
24
  bindingScope: BindingScope;
15
25
  navigate(destination: DestinationReference): DestinationResolution;
16
- closeDialog(): void;
26
+
27
+ /** Closes the open dialog, returning to the entry that opened it, and records its result. */
28
+ closeDialog(result?: unknown): void;
29
+
30
+ /** Goes back one history entry. */
31
+ back(): void;
17
32
  }
18
33
 
19
34
  const Context = createContext<SceneNavigationContextValue | undefined>(undefined);
@@ -1,13 +1,17 @@
1
1
  // Copyright (c) Cratis. All rights reserved.
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
- import { useCallback, useMemo, useState } from 'react';
4
+ import { useMemo, useState } from 'react';
5
5
  import { BindingScope } from '@cratis/scene.engine';
6
- import { DestinationKind, DestinationReference, SceneElement } from '@cratis/scene.model';
6
+ import { DestinationReference, SceneElement } from '@cratis/scene.model';
7
7
  import { SceneElementView } from '../SceneElementView';
8
8
  import { ComponentRegistry } from '../renderer';
9
- import { SceneNavigationContextValue, SceneNavigationProvider } from './SceneNavigationContext';
10
- import { resolveDestination } from './resolveDestination';
9
+ import { createMemorySceneHistory } from './createMemorySceneHistory';
10
+ import { SceneHistory } from './SceneHistory';
11
+ import { SceneNavigationProvider } from './SceneNavigationContext';
12
+ import { SceneRoute } from './SceneRoute';
13
+ import { sceneRoutesFrom } from './sceneRoutesFrom';
14
+ import { useSceneHistoryNavigation } from './useSceneHistoryNavigation';
11
15
 
12
16
  export interface SceneNavigationHostProps {
13
17
  screens: Record<string, SceneElement>;
@@ -15,52 +19,38 @@ export interface SceneNavigationHostProps {
15
19
  initialScreen: string;
16
20
  registry: ComponentRegistry;
17
21
  bindingScope?: BindingScope;
22
+
23
+ /**
24
+ * Where entries are recorded. `createBrowserSceneHistory()` drives the address bar, so deep links,
25
+ * refresh and back/forward work; the default is an in-memory history for embedded hosts.
26
+ */
27
+ history?: SceneHistory;
28
+
29
+ /** The destinations the application navigates to, so their route overrides can be deep linked. */
30
+ destinations?: DestinationReference[];
31
+
32
+ /** An explicit route table, used instead of the one derived from `screens` and `destinations`. */
33
+ routes?: SceneRoute[];
18
34
  }
19
35
 
20
36
  /**
21
37
  * Executes Scene destinations against an in-memory rendered hierarchy: screen routes replace outlets,
22
- * dialog destinations render dialog content, and external destinations only report their resolved action.
38
+ * dialog destinations render dialog content over the screen that opened them, and external destinations
39
+ * only report their resolved action. Every navigation is a history entry.
23
40
  */
24
- export function SceneNavigationHost({ screens, dialogs = {}, initialScreen, registry, bindingScope = {} }: SceneNavigationHostProps) {
25
- const [currentScreen, setCurrentScreen] = useState(initialScreen);
26
- const [currentUrl, setCurrentUrl] = useState<string | undefined>(initialScreen);
27
- const [currentOutlet, setCurrentOutlet] = useState<string | undefined>();
28
- const [currentDialog, setCurrentDialog] = useState<string | undefined>();
29
-
30
- const navigate = useCallback((destination: DestinationReference) => {
31
- const resolution = resolveDestination(destination, bindingScope);
32
- if (resolution.kind === DestinationKind.Dialog) {
33
- setCurrentDialog(resolution.dialog);
34
- return resolution;
35
- }
36
-
37
- if (resolution.kind !== DestinationKind.External) {
38
- setCurrentScreen(destination.screen ?? destination.slice ?? currentScreen);
39
- setCurrentOutlet(resolution.outlet);
40
- }
41
-
42
- setCurrentUrl(resolution.url);
43
- return resolution;
44
- }, [bindingScope, currentScreen]);
45
-
46
- const value = useMemo<SceneNavigationContextValue>(() => ({
47
- currentScreen,
48
- currentUrl,
49
- currentOutlet,
50
- currentDialog,
51
- bindingScope,
52
- navigate,
53
- closeDialog: () => setCurrentDialog(undefined),
54
- }), [bindingScope, currentDialog, currentOutlet, currentScreen, currentUrl, navigate]);
41
+ export function SceneNavigationHost({ screens, dialogs = {}, initialScreen, registry, bindingScope = {}, history, destinations, routes }: SceneNavigationHostProps) {
42
+ const [memoryHistory] = useState(() => createMemorySceneHistory());
43
+ const routeTable = useMemo(() => routes ?? sceneRoutesFrom(Object.keys(screens), destinations), [destinations, routes, screens]);
44
+ const value = useSceneHistoryNavigation(history ?? memoryHistory, routeTable, initialScreen, bindingScope);
55
45
 
56
- const screen = screens[currentScreen];
57
- const dialog = currentDialog ? dialogs[currentDialog] : undefined;
46
+ const screen = screens[value.currentScreen];
47
+ const dialog = value.currentDialog ? dialogs[value.currentDialog] : undefined;
58
48
 
59
49
  return <SceneNavigationProvider value={value}>
60
- <div data-scene-outlet={currentOutlet ?? 'primary'} data-scene-url={currentUrl}>
50
+ <div data-scene-outlet={value.currentOutlet ?? 'primary'} data-scene-url={value.currentUrl}>
61
51
  {screen && <SceneElementView element={screen} registry={registry} dataContext={bindingScope.dataContext} queryResults={bindingScope.queryResults} componentOutputs={bindingScope.componentOutputs} />}
62
52
  </div>
63
- {dialog && <div role='dialog' data-scene-dialog={currentDialog}>
53
+ {dialog && <div role='dialog' aria-modal='true' aria-label={value.currentDialog} data-scene-dialog={value.currentDialog}>
64
54
  <SceneElementView element={dialog} registry={registry} dataContext={bindingScope.dataContext} queryResults={bindingScope.queryResults} componentOutputs={bindingScope.componentOutputs} />
65
55
  </div>}
66
56
  </SceneNavigationProvider>;
@@ -0,0 +1,19 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ /**
5
+ * Where a navigation host is: the screen in its outlet, the URL that reaches it, the route parameters
6
+ * that URL carries and the dialog open on top of it. It is what a history entry stores, so back, forward
7
+ * and refresh restore exactly this.
8
+ */
9
+ export interface SceneNavigationState {
10
+ screen: string;
11
+
12
+ /** The URL relative to the host's base path, as `resolveDestination` builds it. */
13
+ url?: string;
14
+ outlet?: string;
15
+ dialog?: string;
16
+
17
+ /** Route parameters, from the path template and the query string. */
18
+ parameters: Record<string, string>;
19
+ }
@@ -0,0 +1,12 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ /**
5
+ * One route a navigation host can be deep linked to: a template such as `sales/orders/{orderId}` and the
6
+ * screen, and optionally the outlet, it opens.
7
+ */
8
+ export interface SceneRoute {
9
+ route: string;
10
+ screen: string;
11
+ outlet?: string;
12
+ }
@@ -0,0 +1,40 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { SceneHistory } from './SceneHistory';
5
+ import { SceneNavigationState } from './SceneNavigationState';
6
+
7
+ /** The part of `window` the browser history needs, so a host can pass its own frame's window. */
8
+ export type SceneBrowserWindow = Pick<Window, 'history' | 'location' | 'addEventListener' | 'removeEventListener'>;
9
+
10
+ const stateKey = 'sceneNavigation';
11
+
12
+ /**
13
+ * A history backed by the browser's `history` API under a base path such as `/app/`. Entries carry their
14
+ * navigation state, so back and forward restore the screen, outlet, parameters and dialog without
15
+ * re-resolving; an entry without state - a typed or bookmarked URL - is reported by URL alone.
16
+ */
17
+ export function createBrowserSceneHistory(browser: SceneBrowserWindow = window, basePath = '/'): SceneHistory {
18
+ const base = basePath.endsWith('/') ? basePath : `${basePath}/`;
19
+ const href = (url: string | undefined) => `${base}${url ?? ''}`;
20
+ const location = () => {
21
+ const current = `${browser.location.pathname}${browser.location.search}`;
22
+ if (!current.startsWith(base)) return undefined;
23
+ const relative = current.substring(base.length);
24
+ return relative.length ? relative : undefined;
25
+ };
26
+ const stored = (value: unknown) => (value as Record<string, SceneNavigationState> | null)?.[stateKey];
27
+
28
+ return {
29
+ location,
30
+ state: () => stored(browser.history.state),
31
+ push: state => browser.history.pushState({ [stateKey]: state }, '', href(state.url)),
32
+ replace: state => browser.history.replaceState({ [stateKey]: state }, '', href(state.url)),
33
+ back: () => browser.history.back(),
34
+ listen: listener => {
35
+ const onPopState = (event: PopStateEvent) => listener(location(), stored(event.state));
36
+ browser.addEventListener('popstate', onPopState);
37
+ return () => browser.removeEventListener('popstate', onPopState);
38
+ },
39
+ };
40
+ }
@@ -0,0 +1,44 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { SceneHistory } from './SceneHistory';
5
+ import { SceneNavigationState } from './SceneNavigationState';
6
+
7
+ /**
8
+ * A history kept in memory with the same push, replace and back semantics as the browser's: pushing
9
+ * discards any forward entries, and going back notifies listeners. `forward` is exposed for hosts and specs
10
+ * that drive the stack themselves.
11
+ */
12
+ export function createMemorySceneHistory(initialUrl?: string): SceneHistory & { forward(): void; entries(): SceneNavigationState[] } {
13
+ const entries: { url?: string; state?: SceneNavigationState }[] = [{ url: initialUrl }];
14
+ let index = 0;
15
+ const listeners = new Set<(url: string | undefined, state: SceneNavigationState | undefined) => void>();
16
+ const notify = () => listeners.forEach(listener => listener(entries[index].url, entries[index].state));
17
+
18
+ return {
19
+ location: () => entries[index].url,
20
+ state: () => entries[index].state,
21
+ push: state => {
22
+ entries.splice(index + 1, entries.length, { url: state.url, state });
23
+ index = entries.length - 1;
24
+ },
25
+ replace: state => {
26
+ entries[index] = { url: state.url, state };
27
+ },
28
+ back: () => {
29
+ if (index === 0) return;
30
+ index--;
31
+ notify();
32
+ },
33
+ forward: () => {
34
+ if (index === entries.length - 1) return;
35
+ index++;
36
+ notify();
37
+ },
38
+ entries: () => entries.map(entry => entry.state).filter((state): state is SceneNavigationState => state !== undefined),
39
+ listen: listener => {
40
+ listeners.add(listener);
41
+ return () => listeners.delete(listener);
42
+ },
43
+ };
44
+ }
@@ -2,9 +2,18 @@
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
4
  export * from './DestinationResolution';
5
+ export * from './DialogResult';
6
+ export * from './SceneHistory';
5
7
  export * from './SceneNavigationContext';
6
8
  export * from './SceneNavigationHost';
7
- export * from './resolveDestination';
9
+ export * from './SceneNavigationState';
10
+ export * from './SceneRoute';
11
+ export * from './createBrowserSceneHistory';
12
+ export * from './createMemorySceneHistory';
8
13
  export * from './diagnoseRenderedNavigation';
9
14
  export * from './identityRoute';
15
+ export * from './matchSceneRoute';
10
16
  export * from './navigationRouteKey';
17
+ export * from './resolveDestination';
18
+ export * from './sceneRoutesFrom';
19
+ export * from './useSceneHistoryNavigation';
@@ -0,0 +1,45 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { SceneRoute } from './SceneRoute';
5
+
6
+ /** A URL matched to the route that opens it. */
7
+ export interface SceneRouteMatch {
8
+ route: SceneRoute;
9
+ parameters: Record<string, string>;
10
+ }
11
+
12
+ /**
13
+ * Matches a URL - as a deep link, a refresh or a typed address delivers it - against route templates.
14
+ * `{name}` segments capture path parameters; the query string adds the rest. Literal segments win over
15
+ * parameters, so `orders/new` beats `orders/{orderId}`, and routes are otherwise tried in the given order.
16
+ */
17
+ export function matchSceneRoute(url: string, routes: SceneRoute[]): SceneRouteMatch | undefined {
18
+ const [path, query = ''] = url.split('?', 2);
19
+ const segments = path.split('/').filter(segment => segment.length > 0).map(decodeURIComponent);
20
+ const queryParameters = Object.fromEntries(new URLSearchParams(query));
21
+
22
+ const candidates = routes
23
+ .map((route, order) => ({ route, order, template: route.route.split('/').filter(segment => segment.length > 0) }))
24
+ .filter(candidate => candidate.template.length === segments.length)
25
+ .sort((left, right) => literalCount(right.template) - literalCount(left.template) || left.order - right.order);
26
+
27
+ for (const { route, template } of candidates) {
28
+ const parameters: Record<string, string> = {};
29
+ const matches = template.every((part, index) => {
30
+ const name = /^\{([^}]+)\}$/.exec(part)?.[1];
31
+ if (name) {
32
+ parameters[name] = segments[index];
33
+ return true;
34
+ }
35
+ return part === segments[index];
36
+ });
37
+ if (matches) return { route, parameters: { ...queryParameters, ...parameters } };
38
+ }
39
+
40
+ return undefined;
41
+ }
42
+
43
+ function literalCount(template: string[]): number {
44
+ return template.filter(part => !/^\{[^}]+\}$/.test(part)).length;
45
+ }
@@ -23,6 +23,7 @@ export function resolveDestination(destination: DestinationReference, scope: Bin
23
23
  url,
24
24
  outlet: destination.outlet,
25
25
  dialog: destination.dialog,
26
+ parameters: Object.fromEntries(parameters),
26
27
  action: kind === DestinationKind.Dialog ? 'openDialog' : kind === DestinationKind.External ? 'openExternal' : 'navigate',
27
28
  diagnostics,
28
29
  };
@@ -0,0 +1,24 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { DestinationKind, DestinationReference } from '@cratis/scene.model';
5
+ import { identityRoute } from './identityRoute';
6
+ import { SceneRoute } from './SceneRoute';
7
+
8
+ /**
9
+ * The routes a host can deep link to: every screen at its own name, and every outlet destination at its
10
+ * authored route override or identity route. A destination listed earlier wins a route it shares with a
11
+ * later one, and authored overrides come before the screen-name defaults.
12
+ */
13
+ export function sceneRoutesFrom(screens: string[], destinations: DestinationReference[] = []): SceneRoute[] {
14
+ const routes: SceneRoute[] = [];
15
+ for (const destination of destinations) {
16
+ const kind = destination.kind ?? (destination.dialog ? DestinationKind.Dialog : DestinationKind.Outlet);
17
+ const screen = destination.screen ?? destination.slice;
18
+ const route = destination.route ?? identityRoute(destination);
19
+ if (kind !== DestinationKind.Outlet || !screen || !route) continue;
20
+ routes.push({ route, screen, ...(destination.outlet ? { outlet: destination.outlet } : {}) });
21
+ }
22
+
23
+ return [...routes, ...screens.map(screen => ({ route: screen, screen }))];
24
+ }
@@ -0,0 +1,80 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
5
+ import { BindingScope } from '@cratis/scene.engine';
6
+ import { DestinationKind, DestinationReference } from '@cratis/scene.model';
7
+ import { DialogResult } from './DialogResult';
8
+ import { matchSceneRoute } from './matchSceneRoute';
9
+ import { resolveDestination } from './resolveDestination';
10
+ import { SceneHistory } from './SceneHistory';
11
+ import { SceneNavigationContextValue } from './SceneNavigationContext';
12
+ import { SceneNavigationState } from './SceneNavigationState';
13
+ import { SceneRoute } from './SceneRoute';
14
+
15
+ /**
16
+ * Keeps a navigation host's state in a {@link SceneHistory}: every navigation and every dialog is an entry,
17
+ * so back and forward step through them, a refresh restores the stored entry, and a deep link is matched
18
+ * against the route table. Closing a dialog goes back to the entry that opened it.
19
+ */
20
+ export function useSceneHistoryNavigation(history: SceneHistory, routes: SceneRoute[], initialScreen: string, bindingScope: BindingScope): SceneNavigationContextValue {
21
+ const fromLocation = useCallback((url: string | undefined): SceneNavigationState | undefined => {
22
+ const match = url ? matchSceneRoute(url, routes) : undefined;
23
+ return match ? { screen: match.route.screen, url, outlet: match.route.outlet, parameters: match.parameters } : undefined;
24
+ }, [routes]);
25
+ const fallback = useMemo<SceneNavigationState>(() => ({ screen: initialScreen, url: initialScreen, parameters: {} }), [initialScreen]);
26
+
27
+ const [unresolvedUrl] = useState(() => {
28
+ const url = history.location();
29
+ return !history.state() && url && !fromLocation(url) ? url : undefined;
30
+ });
31
+ const [state, setState] = useState<SceneNavigationState>(() => history.state() ?? fromLocation(history.location()) ?? fallback);
32
+ const [dialogResult, setDialogResult] = useState<DialogResult>();
33
+
34
+ // Back and forward restore the stored entry, or match a URL the host never wrote against the current routes.
35
+ const restore = useRef((url: string | undefined, stored: SceneNavigationState | undefined) => stored ?? fromLocation(url) ?? fallback);
36
+ restore.current = (url, stored) => stored ?? fromLocation(url) ?? fallback;
37
+ const initialState = useRef(state);
38
+
39
+ // The current entry is written once, when a history is attached; later entries are written by navigate.
40
+ useEffect(() => {
41
+ history.replace(initialState.current);
42
+ return history.listen((url, stored) => setState(restore.current(url, stored)));
43
+ }, [history]);
44
+
45
+ const go = useCallback((next: SceneNavigationState) => {
46
+ history.push(next);
47
+ setState(next);
48
+ }, [history]);
49
+
50
+ const navigate = useCallback((destination: DestinationReference) => {
51
+ const resolution = resolveDestination(destination, bindingScope);
52
+ if (resolution.kind === DestinationKind.Dialog) {
53
+ if (resolution.dialog) go({ ...state, dialog: resolution.dialog });
54
+ } else if (resolution.kind !== DestinationKind.External) {
55
+ go({ screen: destination.screen ?? destination.slice ?? state.screen, url: resolution.url, outlet: resolution.outlet, parameters: resolution.parameters });
56
+ }
57
+
58
+ return resolution;
59
+ }, [bindingScope, go, state]);
60
+
61
+ const closeDialog = useCallback((result?: unknown) => {
62
+ if (!state.dialog) return;
63
+ setDialogResult({ dialog: state.dialog, result });
64
+ history.back();
65
+ }, [history, state.dialog]);
66
+
67
+ return useMemo(() => ({
68
+ currentScreen: state.screen,
69
+ currentUrl: state.url,
70
+ currentOutlet: state.outlet,
71
+ currentDialog: state.dialog,
72
+ currentParameters: state.parameters,
73
+ dialogResult,
74
+ unresolvedUrl,
75
+ bindingScope,
76
+ navigate,
77
+ closeDialog,
78
+ back: () => history.back(),
79
+ }), [bindingScope, closeDialog, dialogResult, history, navigate, state, unresolvedUrl]);
80
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/scene.react",
3
- "version": "4.13.0",
3
+ "version": "4.14.0",
4
4
  "description": "The React renderer implementing Scene.Engine's Renderer contract against real React/DOM.",
5
5
  "keywords": [
6
6
  "cratis",
@@ -51,12 +51,12 @@
51
51
  "build-storybook": "storybook build"
52
52
  },
53
53
  "dependencies": {
54
- "@cratis/scene.engine": "4.13.0",
55
- "@cratis/scene.model": "4.13.0"
54
+ "@cratis/scene.engine": "4.14.0",
55
+ "@cratis/scene.model": "4.14.0"
56
56
  },
57
57
  "devDependencies": {
58
- "@cratis/scene.engine": "4.13.0",
59
- "@cratis/scene.model": "4.13.0",
58
+ "@cratis/scene.engine": "4.14.0",
59
+ "@cratis/scene.model": "4.14.0",
60
60
  "@storybook/addon-links": "^10.6.1",
61
61
  "@storybook/react": "^10.6.1",
62
62
  "@storybook/react-vite": "^10.6.1",