@cratis/scene.react 3.7.0 → 4.1.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 (125) hide show
  1. package/core/CoreButton.tsx +2 -2
  2. package/dist/cjs/SceneElementView.js +2 -0
  3. package/dist/cjs/SceneElementView.js.map +1 -1
  4. package/dist/cjs/core/CoreButton.js +2 -1
  5. package/dist/cjs/core/CoreButton.js.map +1 -1
  6. package/dist/cjs/index.js +18 -0
  7. package/dist/cjs/index.js.map +1 -1
  8. package/dist/cjs/interactions/ConfirmHost.js +71 -0
  9. package/dist/cjs/interactions/ConfirmHost.js.map +1 -0
  10. package/dist/cjs/interactions/InteractionScope.js +74 -0
  11. package/dist/cjs/interactions/InteractionScope.js.map +1 -0
  12. package/dist/cjs/interactions/NotificationHost.js +73 -0
  13. package/dist/cjs/interactions/NotificationHost.js.map +1 -0
  14. package/dist/cjs/interactions/UnresolvedActionHost.js +53 -0
  15. package/dist/cjs/interactions/UnresolvedActionHost.js.map +1 -0
  16. package/dist/cjs/interactions/createBrowserDispatcher.js +102 -0
  17. package/dist/cjs/interactions/createBrowserDispatcher.js.map +1 -0
  18. package/dist/cjs/interactions/useInteractionHosts.js +109 -0
  19. package/dist/cjs/interactions/useInteractionHosts.js.map +1 -0
  20. package/dist/cjs/interactions/useInteractions.js +122 -0
  21. package/dist/cjs/interactions/useInteractions.js.map +1 -0
  22. package/dist/cjs/renderer/InteractiveComponent.js +26 -0
  23. package/dist/cjs/renderer/InteractiveComponent.js.map +1 -0
  24. package/dist/cjs/renderer/createReactRenderer.js +3 -1
  25. package/dist/cjs/renderer/createReactRenderer.js.map +1 -1
  26. package/dist/esm/SceneElementView.js +2 -0
  27. package/dist/esm/SceneElementView.js.map +1 -1
  28. package/dist/esm/core/CoreButton.d.ts +1 -1
  29. package/dist/esm/core/CoreButton.d.ts.map +1 -1
  30. package/dist/esm/core/CoreButton.js +2 -1
  31. package/dist/esm/core/CoreButton.js.map +1 -1
  32. package/dist/esm/for_useInteractionHosts/when_a_document_asks_the_user_something.d.ts +2 -0
  33. package/dist/esm/for_useInteractionHosts/when_a_document_asks_the_user_something.d.ts.map +1 -0
  34. package/dist/esm/for_useInteractionHosts/when_a_document_asks_the_user_something.js +73 -0
  35. package/dist/esm/for_useInteractionHosts/when_a_document_asks_the_user_something.js.map +1 -0
  36. package/dist/esm/for_useInteractionHosts/when_the_host_cannot_do_what_was_asked.d.ts +2 -0
  37. package/dist/esm/for_useInteractionHosts/when_the_host_cannot_do_what_was_asked.d.ts.map +1 -0
  38. package/dist/esm/for_useInteractionHosts/when_the_host_cannot_do_what_was_asked.js +43 -0
  39. package/dist/esm/for_useInteractionHosts/when_the_host_cannot_do_what_was_asked.js.map +1 -0
  40. package/dist/esm/for_useInteractions/when_clicking_an_element_that_executes_a_command.d.ts +2 -0
  41. package/dist/esm/for_useInteractions/when_clicking_an_element_that_executes_a_command.d.ts.map +1 -0
  42. package/dist/esm/for_useInteractions/when_clicking_an_element_that_executes_a_command.js +94 -0
  43. package/dist/esm/for_useInteractions/when_clicking_an_element_that_executes_a_command.js.map +1 -0
  44. package/dist/esm/index.d.ts +1 -0
  45. package/dist/esm/index.d.ts.map +1 -1
  46. package/dist/esm/index.js +8 -0
  47. package/dist/esm/index.js.map +1 -1
  48. package/dist/esm/interactions/ConfirmHost.d.ts +8 -0
  49. package/dist/esm/interactions/ConfirmHost.d.ts.map +1 -0
  50. package/dist/esm/interactions/ConfirmHost.js +69 -0
  51. package/dist/esm/interactions/ConfirmHost.js.map +1 -0
  52. package/dist/esm/interactions/ConfirmRequest.d.ts +5 -0
  53. package/dist/esm/interactions/ConfirmRequest.d.ts.map +1 -0
  54. package/dist/esm/interactions/ConfirmRequest.js +2 -0
  55. package/dist/esm/interactions/ConfirmRequest.js.map +1 -0
  56. package/dist/esm/interactions/InteractionScope.d.ts +17 -0
  57. package/dist/esm/interactions/InteractionScope.d.ts.map +1 -0
  58. package/dist/esm/interactions/InteractionScope.js +70 -0
  59. package/dist/esm/interactions/InteractionScope.js.map +1 -0
  60. package/dist/esm/interactions/NotificationHost.d.ts +7 -0
  61. package/dist/esm/interactions/NotificationHost.d.ts.map +1 -0
  62. package/dist/esm/interactions/NotificationHost.js +71 -0
  63. package/dist/esm/interactions/NotificationHost.js.map +1 -0
  64. package/dist/esm/interactions/SceneNotification.d.ts +7 -0
  65. package/dist/esm/interactions/SceneNotification.d.ts.map +1 -0
  66. package/dist/esm/interactions/SceneNotification.js +2 -0
  67. package/dist/esm/interactions/SceneNotification.js.map +1 -0
  68. package/dist/esm/interactions/UnresolvedAction.d.ts +5 -0
  69. package/dist/esm/interactions/UnresolvedAction.d.ts.map +1 -0
  70. package/dist/esm/interactions/UnresolvedAction.js +2 -0
  71. package/dist/esm/interactions/UnresolvedAction.js.map +1 -0
  72. package/dist/esm/interactions/UnresolvedActionHost.d.ts +6 -0
  73. package/dist/esm/interactions/UnresolvedActionHost.d.ts.map +1 -0
  74. package/dist/esm/interactions/UnresolvedActionHost.js +51 -0
  75. package/dist/esm/interactions/UnresolvedActionHost.js.map +1 -0
  76. package/dist/esm/interactions/createBrowserDispatcher.d.ts +17 -0
  77. package/dist/esm/interactions/createBrowserDispatcher.d.ts.map +1 -0
  78. package/dist/esm/interactions/createBrowserDispatcher.js +100 -0
  79. package/dist/esm/interactions/createBrowserDispatcher.js.map +1 -0
  80. package/dist/esm/interactions/index.d.ts +11 -0
  81. package/dist/esm/interactions/index.d.ts.map +1 -0
  82. package/dist/esm/interactions/index.js +11 -0
  83. package/dist/esm/interactions/index.js.map +1 -0
  84. package/dist/esm/interactions/useInteractionHosts.d.ts +11 -0
  85. package/dist/esm/interactions/useInteractionHosts.d.ts.map +1 -0
  86. package/dist/esm/interactions/useInteractionHosts.js +107 -0
  87. package/dist/esm/interactions/useInteractionHosts.js.map +1 -0
  88. package/dist/esm/interactions/useInteractions.d.ts +16 -0
  89. package/dist/esm/interactions/useInteractions.d.ts.map +1 -0
  90. package/dist/esm/interactions/useInteractions.js +120 -0
  91. package/dist/esm/interactions/useInteractions.js.map +1 -0
  92. package/dist/esm/renderer/ComponentRegistry.d.ts +2 -0
  93. package/dist/esm/renderer/ComponentRegistry.d.ts.map +1 -1
  94. package/dist/esm/renderer/InteractiveComponent.d.ts +10 -0
  95. package/dist/esm/renderer/InteractiveComponent.d.ts.map +1 -0
  96. package/dist/esm/renderer/InteractiveComponent.js +24 -0
  97. package/dist/esm/renderer/InteractiveComponent.js.map +1 -0
  98. package/dist/esm/renderer/createReactRenderer.d.ts.map +1 -1
  99. package/dist/esm/renderer/createReactRenderer.js +3 -1
  100. package/dist/esm/renderer/createReactRenderer.js.map +1 -1
  101. package/dist/esm/renderer/index.d.ts +1 -0
  102. package/dist/esm/renderer/index.d.ts.map +1 -1
  103. package/dist/esm/renderer/index.js +1 -0
  104. package/dist/esm/renderer/index.js.map +1 -1
  105. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  106. package/for_useInteractionHosts/when_a_document_asks_the_user_something.tsx +106 -0
  107. package/for_useInteractionHosts/when_the_host_cannot_do_what_was_asked.tsx +66 -0
  108. package/for_useInteractions/when_clicking_an_element_that_executes_a_command.tsx +141 -0
  109. package/index.ts +1 -0
  110. package/interactions/ConfirmHost.tsx +42 -0
  111. package/interactions/ConfirmRequest.ts +19 -0
  112. package/interactions/InteractionScope.tsx +94 -0
  113. package/interactions/NotificationHost.tsx +48 -0
  114. package/interactions/SceneNotification.ts +18 -0
  115. package/interactions/UnresolvedAction.ts +17 -0
  116. package/interactions/UnresolvedActionHost.tsx +33 -0
  117. package/interactions/createBrowserDispatcher.ts +160 -0
  118. package/interactions/index.ts +13 -0
  119. package/interactions/useInteractionHosts.tsx +101 -0
  120. package/interactions/useInteractions.ts +116 -0
  121. package/package.json +5 -5
  122. package/renderer/ComponentRegistry.ts +11 -0
  123. package/renderer/InteractiveComponent.tsx +25 -0
  124. package/renderer/createReactRenderer.tsx +2 -1
  125. package/renderer/index.ts +1 -0
@@ -0,0 +1,160 @@
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 { ActionDispatcher, CommandOutcome, DialogOutcome } from '@cratis/scene.engine';
5
+ import { NotificationLevel } from '@cratis/scene.model';
6
+
7
+ /**
8
+ * What a host has to supply for an interaction to reach anything real.
9
+ *
10
+ * Every member is optional and every one that is missing is *reported* rather than quietly ignored. A host
11
+ * that cannot execute commands is a legitimate state - a preview surface, a design tool - but a button that
12
+ * appears to work and does nothing is not.
13
+ */
14
+ export interface BrowserDispatcherHost {
15
+ /** Submits a command. The transport belongs to the host: Scene does not know what a backend is. */
16
+ executeCommand?: (command: string, args: Record<string, unknown>) => Promise<CommandOutcome>;
17
+
18
+ /** Navigates to a screen. The URL belongs to the host, which is why no route is constructed here. */
19
+ navigate?: (screen: string, args: Record<string, unknown>) => void;
20
+
21
+ /** Goes back. */
22
+ navigateBack?: () => void;
23
+
24
+ /** Opens a dialog and answers with what the user did. */
25
+ openDialog?: (dialogTemplate: string, args: Record<string, unknown>) => Promise<DialogOutcome>;
26
+
27
+ /** Closes the open dialog. */
28
+ closeDialog?: () => void;
29
+
30
+ /** Re-runs a query. */
31
+ refreshQuery?: (query: string) => Promise<void>;
32
+
33
+ /** Writes screen state. */
34
+ setState?: (target: string, value: unknown) => void;
35
+
36
+ /** Surfaces a message. Falls back to the console rather than to nothing. */
37
+ notify?: (level: NotificationLevel, message: string) => void;
38
+
39
+ /** Asks the user to confirm. Falls back to the browser's own prompt. */
40
+ confirm?: (message: string) => Promise<boolean>;
41
+
42
+ /** Raises an application trigger. */
43
+ raise?: (trigger: string, args: Record<string, unknown>) => Promise<void>;
44
+
45
+ /** Where an unsupported action is reported. */
46
+ onUnsupported?: (action: string) => void;
47
+ }
48
+
49
+ /**
50
+ * Creates the dispatcher a browser host uses.
51
+ *
52
+ * This is the only place in Scene.React that knows an action has to *happen*. Everything about which actions
53
+ * run and in what order is the engine's, which is why this file is a list of one-line delegations and holds no
54
+ * rules of its own.
55
+ *
56
+ * @param host What the surrounding application can actually do.
57
+ * @returns The {@link ActionDispatcher}.
58
+ */
59
+ export function createBrowserDispatcher(host: BrowserDispatcherHost = {}): ActionDispatcher {
60
+ const unsupported = (action: string) => {
61
+ host.onUnsupported?.(action);
62
+ console.warn(`[scene] The host cannot '${action}', so the interaction stopped there.`);
63
+ };
64
+
65
+ return {
66
+ async executeCommand(command, args) {
67
+ if (!host.executeCommand) {
68
+ unsupported(`execute ${command}`);
69
+
70
+ // Reported as a failure, so the document's own `on failure` runs. Answering success would
71
+ // make a command that never reached a backend look like one that worked.
72
+ return { isSuccess: false, validationErrors: [] };
73
+ }
74
+
75
+ return host.executeCommand(command, args);
76
+ },
77
+
78
+ navigate(screen, args) {
79
+ if (host.navigate) {
80
+ host.navigate(screen, args);
81
+ return;
82
+ }
83
+
84
+ unsupported(`navigate to ${screen}`);
85
+ },
86
+
87
+ navigateBack() {
88
+ if (host.navigateBack) {
89
+ host.navigateBack();
90
+ return;
91
+ }
92
+
93
+ unsupported('navigate back');
94
+ },
95
+
96
+ async openDialog(dialogTemplate, args) {
97
+ if (!host.openDialog) {
98
+ unsupported(`open dialog ${dialogTemplate}`);
99
+ return { isConfirmed: false };
100
+ }
101
+
102
+ return host.openDialog(dialogTemplate, args);
103
+ },
104
+
105
+ closeDialog() {
106
+ if (host.closeDialog) {
107
+ host.closeDialog();
108
+ return;
109
+ }
110
+
111
+ unsupported('close dialog');
112
+ },
113
+
114
+ async refreshQuery(query) {
115
+ if (host.refreshQuery) {
116
+ await host.refreshQuery(query);
117
+ return;
118
+ }
119
+
120
+ unsupported(`refresh ${query}`);
121
+ },
122
+
123
+ setState(target, value) {
124
+ if (host.setState) {
125
+ host.setState(target, value);
126
+ return;
127
+ }
128
+
129
+ unsupported(`set ${target}`);
130
+ },
131
+
132
+ notify(level, message) {
133
+ if (host.notify) {
134
+ host.notify(level, message);
135
+ return;
136
+ }
137
+
138
+ // The console is a poor notification, but it is a real one - and a host that has not wired
139
+ // notifications yet should still see what the document wanted to say.
140
+ console.log(`[scene:${level}] ${message}`);
141
+ },
142
+
143
+ async confirm(message) {
144
+ if (host.confirm) return host.confirm(message);
145
+
146
+ // The browser's own prompt is the honest default: a confirm exists to gate what follows, so
147
+ // answering `true` without asking anyone would remove the gate rather than approximate it.
148
+ return typeof globalThis.confirm === 'function' ? globalThis.confirm(message) : false;
149
+ },
150
+
151
+ async raise(trigger, args) {
152
+ if (host.raise) {
153
+ await host.raise(trigger, args);
154
+ return;
155
+ }
156
+
157
+ unsupported(`raise ${trigger}`);
158
+ },
159
+ };
160
+ }
@@ -0,0 +1,13 @@
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
+ export * from './InteractionScope';
5
+ export * from './useInteractions';
6
+ export * from './createBrowserDispatcher';
7
+ export * from './ConfirmHost';
8
+ export * from './ConfirmRequest';
9
+ export * from './NotificationHost';
10
+ export * from './SceneNotification';
11
+ export * from './UnresolvedAction';
12
+ export * from './UnresolvedActionHost';
13
+ export * from './useInteractionHosts';
@@ -0,0 +1,101 @@
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 { ReactNode, useCallback, useMemo, useRef, useState } from 'react';
5
+ import { NotificationLevel } from '@cratis/scene.model';
6
+ import { BrowserDispatcherHost } from './createBrowserDispatcher';
7
+ import { ConfirmHost } from './ConfirmHost';
8
+ import { ConfirmRequest } from './ConfirmRequest';
9
+ import { NotificationHost } from './NotificationHost';
10
+ import { SceneNotification } from './SceneNotification';
11
+ import { UnresolvedAction } from './UnresolvedAction';
12
+ import { UnresolvedActionHost } from './UnresolvedActionHost';
13
+
14
+ /**
15
+ * What {@link useInteractionHosts} gives an application.
16
+ */
17
+ export interface InteractionHosts {
18
+ /** The parts of a {@link BrowserDispatcherHost} these surfaces provide. Spread it into the host you build. */
19
+ readonly host: Pick<BrowserDispatcherHost, 'notify' | 'confirm' | 'onUnsupported'>;
20
+
21
+ /** The surfaces themselves. Render them once, anywhere inside the application. */
22
+ readonly surfaces: ReactNode;
23
+ }
24
+
25
+ /**
26
+ * How long a notification stays before it removes itself, in milliseconds.
27
+ */
28
+ export interface InteractionHostOptions {
29
+ /** How long a notification stays up. `0` keeps it until it is dismissed. */
30
+ readonly dismissAfter?: number;
31
+ }
32
+
33
+ /**
34
+ * Provides the default surfaces for the actions that need somewhere to appear.
35
+ *
36
+ * `notify` and `confirm` are the two actions a document can take that have no backend and no route: they are
37
+ * purely things the user sees. Without a surface, a host either invents one or the action goes to the console,
38
+ * which is why these ship with Scene rather than being every application's first chore.
39
+ *
40
+ * The three are returned as one unit because they are wired the same way and forgetting to render one of them
41
+ * is exactly the failure they exist to prevent:
42
+ *
43
+ * ```tsx
44
+ * const { host, surfaces } = useInteractionHosts();
45
+ * const dispatcher = useMemo(() => createBrowserDispatcher({ ...host, executeCommand, navigate }), [host]);
46
+ *
47
+ * return <InteractionScope dispatcher={dispatcher}>{children}{surfaces}</InteractionScope>;
48
+ * ```
49
+ *
50
+ * An application with a design system of its own passes its own `notify` after the spread and keeps the rest.
51
+ * @param options The {@link InteractionHostOptions}.
52
+ * @returns The {@link InteractionHosts}.
53
+ */
54
+ export function useInteractionHosts(options: InteractionHostOptions = {}): InteractionHosts {
55
+ const { dismissAfter = 6000 } = options;
56
+ const [notifications, setNotifications] = useState<readonly SceneNotification[]>([]);
57
+ const [unresolved, setUnresolved] = useState<readonly UnresolvedAction[]>([]);
58
+ const [request, setRequest] = useState<ConfirmRequest | undefined>(undefined);
59
+ const nextId = useRef(0);
60
+
61
+ const dismiss = useCallback((id: number) => setNotifications(current => current.filter(_ => _.id !== id)), []);
62
+
63
+ const notify = useCallback((level: NotificationLevel, message: string) => {
64
+ const id = nextId.current++;
65
+ setNotifications(current => [...current, { id, level, message }]);
66
+
67
+ if (dismissAfter > 0) {
68
+ setTimeout(() => setNotifications(current => current.filter(_ => _.id !== id)), dismissAfter);
69
+ }
70
+ }, [dismissAfter]);
71
+
72
+ const confirm = useCallback((message: string) => new Promise<boolean>(resolve => {
73
+ setRequest({
74
+ message,
75
+ answer(confirmed) {
76
+ // Cleared before resolving, so the sequence that continues cannot find a question on screen
77
+ // that has already been answered.
78
+ setRequest(undefined);
79
+ resolve(confirmed);
80
+ },
81
+ });
82
+ }), []);
83
+
84
+ const onUnsupported = useCallback((action: string) => {
85
+ // Reported once per distinct action. An unsupported action on a hover or an interval would otherwise
86
+ // grow the list until it covered the application it is trying to tell you about.
87
+ setUnresolved(current => current.some(_ => _.action === action) ? current : [...current, { id: nextId.current++, action }]);
88
+ }, []);
89
+
90
+ const host = useMemo(() => ({ notify, confirm, onUnsupported }), [notify, confirm, onUnsupported]);
91
+
92
+ const surfaces = useMemo(() => (
93
+ <>
94
+ <NotificationHost notifications={notifications} onDismiss={dismiss} />
95
+ <ConfirmHost request={request} />
96
+ <UnresolvedActionHost unresolved={unresolved} />
97
+ </>
98
+ ), [notifications, dismiss, request, unresolved]);
99
+
100
+ return { host, surfaces };
101
+ }
@@ -0,0 +1,116 @@
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 } from 'react';
5
+ import { BehaviorAttachment, resolveBehaviors, resolveIntervalBehaviors, runBindings } from '@cratis/scene.engine';
6
+ import { Behavior, InteractionTriggerKind } from '@cratis/scene.model';
7
+ import { attachmentFor, useInteractionScope } from './InteractionScope';
8
+
9
+ /**
10
+ * The DOM handlers an element should spread onto whatever it renders.
11
+ *
12
+ * Only the handlers a document actually asks for are present. An element with no `on click` gets no `onClick`,
13
+ * so it does not become clickable - and, just as importantly, does not swallow the click from something
14
+ * enclosing it that does.
15
+ */
16
+ export interface InteractionHandlers {
17
+ onClick?: (event: { stopPropagation(): void }) => void;
18
+ onDoubleClick?: (event: { stopPropagation(): void }) => void;
19
+ onChange?: () => void;
20
+ onSubmit?: (event: { preventDefault(): void }) => void;
21
+ onSelect?: () => void;
22
+ }
23
+
24
+ const domTriggers: [keyof InteractionHandlers, InteractionTriggerKind][] = [
25
+ ['onClick', InteractionTriggerKind.Click],
26
+ ['onDoubleClick', InteractionTriggerKind.DoubleClick],
27
+ ['onChange', InteractionTriggerKind.Change],
28
+ ['onSubmit', InteractionTriggerKind.Submit],
29
+ ['onSelect', InteractionTriggerKind.Select],
30
+ ];
31
+
32
+ /**
33
+ * Wires the behaviors attached to an element to the events that start them.
34
+ *
35
+ * The hook resolves and runs; it decides nothing. Which bindings a trigger runs, in what order, and where a
36
+ * sequence stops are all the engine's, so a different renderer inherits the same semantics instead of
37
+ * reimplementing them - and so all of it stays testable without a DOM.
38
+ *
39
+ * Lifecycle triggers are handled here rather than by the caller because React is what knows when an element
40
+ * mounts: `load` and `unload` follow the element, and `enter` and `leave` follow the screen. An `interval`
41
+ * becomes a timer that is cleared on unmount, because a document declaring one is asking for it to run while
42
+ * the thing it is attached to is on screen and not afterwards.
43
+ *
44
+ * @param level What is being attached to, used when reporting a finding.
45
+ * @param behaviors The behaviors attached to this element.
46
+ * @returns The handlers to spread onto the rendered element.
47
+ */
48
+ export function useInteractions(level: string, behaviors?: Behavior[]): InteractionHandlers {
49
+ const scope = useInteractionScope();
50
+
51
+ const attachments = useMemo<BehaviorAttachment[]>(
52
+ () => [...(scope?.attachments ?? []), ...attachmentFor(level, behaviors)],
53
+ [scope, level, behaviors]);
54
+
55
+ const run = useCallback(async (kind: InteractionTriggerKind) => {
56
+ if (!scope) return;
57
+
58
+ const result = await runBindings(resolveBehaviors(attachments, kind), scope.dispatcher, scope.context);
59
+ if (result.findings.length > 0) scope.onFindings?.(result.findings);
60
+ }, [scope, attachments]);
61
+
62
+ // Mount and unmount, for the element and for the screen it is on. Running the unload and leave bindings
63
+ // from the cleanup is what makes them reliable: React guarantees it, a component remembering to call
64
+ // something on the way out does not.
65
+ useEffect(() => {
66
+ void run(InteractionTriggerKind.Load);
67
+ void run(InteractionTriggerKind.Enter);
68
+
69
+ return () => {
70
+ void run(InteractionTriggerKind.Unload);
71
+ void run(InteractionTriggerKind.Leave);
72
+ };
73
+ }, [run]);
74
+
75
+ useEffect(() => {
76
+ const intervals = resolveIntervalBehaviors(attachments);
77
+ if (intervals.length === 0 || !scope) return;
78
+
79
+ const timers = intervals.map(candidate => {
80
+ const seconds = (candidate.binding.trigger as { seconds?: number }).seconds ?? 0;
81
+ return setInterval(
82
+ () => void runBindings([candidate], scope.dispatcher, scope.context),
83
+ Math.max(seconds, 1) * 1000);
84
+ });
85
+
86
+ return () => timers.forEach(clearInterval);
87
+ }, [attachments, scope]);
88
+
89
+ return useMemo(() => {
90
+ const handlers: InteractionHandlers = {};
91
+ if (!scope) return handlers;
92
+
93
+ for (const [handler, kind] of domTriggers) {
94
+ // Nothing attached for this trigger means no handler at all. Attaching an empty one would make
95
+ // every element look interactive and would stop clicks reaching whatever encloses it.
96
+ if (resolveBehaviors(attachments, kind).length === 0) continue;
97
+
98
+ if (handler === 'onSubmit') {
99
+ handlers.onSubmit = event => {
100
+ event.preventDefault();
101
+ void run(kind);
102
+ };
103
+ continue;
104
+ }
105
+
106
+ handlers[handler] = ((event?: { stopPropagation(): void }) => {
107
+ // An interaction that ran here is handled here. Without this, a click on a row inside a table
108
+ // that both declare `on click` would run the table's twice: once for the row, once on the way up.
109
+ event?.stopPropagation();
110
+ void run(kind);
111
+ }) as never;
112
+ }
113
+
114
+ return handlers;
115
+ }, [scope, attachments, run]);
116
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/scene.react",
3
- "version": "3.7.0",
3
+ "version": "4.1.0",
4
4
  "description": "The React renderer implementing Scene.Engine's Renderer contract against real React/DOM.",
5
5
  "keywords": [
6
6
  "cratis",
@@ -50,12 +50,12 @@
50
50
  "build-storybook": "storybook build"
51
51
  },
52
52
  "dependencies": {
53
- "@cratis/scene.engine": "3.7.0",
54
- "@cratis/scene.model": "3.7.0"
53
+ "@cratis/scene.engine": "4.1.0",
54
+ "@cratis/scene.model": "4.1.0"
55
55
  },
56
56
  "devDependencies": {
57
- "@cratis/scene.engine": "3.7.0",
58
- "@cratis/scene.model": "3.7.0",
57
+ "@cratis/scene.engine": "4.1.0",
58
+ "@cratis/scene.model": "4.1.0",
59
59
  "@storybook/addon-links": "^10.6.0",
60
60
  "@storybook/react": "^10.6.0",
61
61
  "@storybook/react-vite": "^10.6.0",
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { ComponentType, ReactNode } from 'react';
5
5
  import { ExternalComponent } from '@cratis/scene.model';
6
+ import { InteractionHandlers } from '../interactions';
6
7
 
7
8
  /**
8
9
  * The props every registered component receives.
@@ -10,6 +11,16 @@ import { ExternalComponent } from '@cratis/scene.model';
10
11
  export interface RegisteredComponentProps {
11
12
  element: ExternalComponent;
12
13
  slots: Record<string, ReactNode[]>;
14
+
15
+ /**
16
+ * The handlers for whatever the document attached to this element, to spread onto the node they belong on.
17
+ *
18
+ * Passed to the component rather than applied by the renderer because only the component knows which node
19
+ * that is - on a button it is the button, on a table it is the row. It is empty when the document attached
20
+ * nothing, and absent when the component is rendered directly rather than through the renderer - a
21
+ * specification, or a gallery. Spreading it is safe either way.
22
+ */
23
+ interactions?: InteractionHandlers;
13
24
  }
14
25
 
15
26
  /**
@@ -0,0 +1,25 @@
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 { ComponentType, ReactNode, createElement } from 'react';
5
+ import { ExternalComponent } from '@cratis/scene.model';
6
+ import { useInteractions } from '../interactions';
7
+ import { RegisteredComponentProps } from './ComponentRegistry';
8
+
9
+ export interface InteractiveComponentProps {
10
+ component: ComponentType<RegisteredComponentProps>;
11
+ element: ExternalComponent;
12
+ slots: Record<string, ReactNode[]>;
13
+ }
14
+
15
+ /**
16
+ * Resolves an element's interactions and hands them to the component that renders it.
17
+ *
18
+ * This exists as a component rather than as a call inside the renderer because resolving them is a hook, and a
19
+ * hook cannot run inside the plain function a {@link Renderer} is. Keeping the renderer plain is what lets the
20
+ * same element tree be rendered by something that is not React at all.
21
+ */
22
+ export function InteractiveComponent({ component, element, slots }: InteractiveComponentProps) {
23
+ const interactions = useInteractions(element.id, element.behaviors);
24
+ return createElement(component, { element, slots, interactions });
25
+ }
@@ -5,6 +5,7 @@ import { ReactNode, createElement } from 'react';
5
5
  import { Renderer } from '@cratis/scene.engine';
6
6
  import { ContentControl, ExternalComponent, ItemsControl, Panel } from '@cratis/scene.model';
7
7
  import { ComponentRegistry } from './ComponentRegistry';
8
+ import { InteractiveComponent } from './InteractiveComponent';
8
9
  import { childStyle, panelStyle } from './panelLayout';
9
10
  import { UnresolvedComponent } from './UnresolvedComponent';
10
11
 
@@ -28,7 +29,7 @@ export function createReactRenderer(registry: ComponentRegistry): Renderer<React
28
29
 
29
30
  renderExternalComponent(element: ExternalComponent, slots: Record<string, ReactNode[]>): ReactNode {
30
31
  const Component = registry[element.componentName] ?? UnresolvedComponent;
31
- return createElement(Component, { key: element.id, element, slots });
32
+ return createElement(InteractiveComponent, { key: element.id, component: Component, element, slots });
32
33
  },
33
34
 
34
35
  renderPanel(element: Panel, children: ReactNode[]): ReactNode {
package/renderer/index.ts CHANGED
@@ -4,3 +4,4 @@
4
4
  export * from './ComponentRegistry';
5
5
  export * from './UnresolvedComponent';
6
6
  export * from './createReactRenderer';
7
+ export * from './InteractiveComponent';