@cratis/scene.react 3.6.0 → 4.0.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.
- package/core/CoreButton.tsx +2 -2
- package/dist/cjs/SceneElementView.js +1 -0
- package/dist/cjs/SceneElementView.js.map +1 -1
- package/dist/cjs/core/CoreButton.js +2 -1
- package/dist/cjs/core/CoreButton.js.map +1 -1
- package/dist/cjs/index.js +10 -0
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/interactions/InteractionScope.js +65 -0
- package/dist/cjs/interactions/InteractionScope.js.map +1 -0
- package/dist/cjs/interactions/createBrowserDispatcher.js +102 -0
- package/dist/cjs/interactions/createBrowserDispatcher.js.map +1 -0
- package/dist/cjs/interactions/useInteractions.js +122 -0
- package/dist/cjs/interactions/useInteractions.js.map +1 -0
- package/dist/cjs/renderer/InteractiveComponent.js +25 -0
- package/dist/cjs/renderer/InteractiveComponent.js.map +1 -0
- package/dist/cjs/renderer/createReactRenderer.js +3 -1
- package/dist/cjs/renderer/createReactRenderer.js.map +1 -1
- package/dist/esm/SceneElementView.js +1 -0
- package/dist/esm/SceneElementView.js.map +1 -1
- package/dist/esm/core/CoreButton.d.ts +1 -1
- package/dist/esm/core/CoreButton.d.ts.map +1 -1
- package/dist/esm/core/CoreButton.js +2 -1
- package/dist/esm/core/CoreButton.js.map +1 -1
- package/dist/esm/for_useInteractions/when_clicking_an_element_that_executes_a_command.d.ts +2 -0
- package/dist/esm/for_useInteractions/when_clicking_an_element_that_executes_a_command.d.ts.map +1 -0
- package/dist/esm/for_useInteractions/when_clicking_an_element_that_executes_a_command.js +94 -0
- package/dist/esm/for_useInteractions/when_clicking_an_element_that_executes_a_command.js.map +1 -0
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +4 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/interactions/InteractionScope.d.ts +16 -0
- package/dist/esm/interactions/InteractionScope.d.ts.map +1 -0
- package/dist/esm/interactions/InteractionScope.js +61 -0
- package/dist/esm/interactions/InteractionScope.js.map +1 -0
- package/dist/esm/interactions/createBrowserDispatcher.d.ts +17 -0
- package/dist/esm/interactions/createBrowserDispatcher.d.ts.map +1 -0
- package/dist/esm/interactions/createBrowserDispatcher.js +100 -0
- package/dist/esm/interactions/createBrowserDispatcher.js.map +1 -0
- package/dist/esm/interactions/index.d.ts +4 -0
- package/dist/esm/interactions/index.d.ts.map +1 -0
- package/dist/esm/interactions/index.js +4 -0
- package/dist/esm/interactions/index.js.map +1 -0
- package/dist/esm/interactions/useInteractions.d.ts +16 -0
- package/dist/esm/interactions/useInteractions.d.ts.map +1 -0
- package/dist/esm/interactions/useInteractions.js +120 -0
- package/dist/esm/interactions/useInteractions.js.map +1 -0
- package/dist/esm/renderer/ComponentRegistry.d.ts +2 -0
- package/dist/esm/renderer/ComponentRegistry.d.ts.map +1 -1
- package/dist/esm/renderer/InteractiveComponent.d.ts +10 -0
- package/dist/esm/renderer/InteractiveComponent.d.ts.map +1 -0
- package/dist/esm/renderer/InteractiveComponent.js +23 -0
- package/dist/esm/renderer/InteractiveComponent.js.map +1 -0
- package/dist/esm/renderer/createReactRenderer.d.ts.map +1 -1
- package/dist/esm/renderer/createReactRenderer.js +3 -1
- package/dist/esm/renderer/createReactRenderer.js.map +1 -1
- package/dist/esm/renderer/index.d.ts +1 -0
- package/dist/esm/renderer/index.d.ts.map +1 -1
- package/dist/esm/renderer/index.js +1 -0
- package/dist/esm/renderer/index.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/for_useInteractions/when_clicking_an_element_that_executes_a_command.tsx +141 -0
- package/index.ts +1 -0
- package/interactions/InteractionScope.tsx +74 -0
- package/interactions/createBrowserDispatcher.ts +160 -0
- package/interactions/index.ts +6 -0
- package/interactions/useInteractions.ts +116 -0
- package/package.json +5 -5
- package/renderer/ComponentRegistry.ts +11 -0
- package/renderer/InteractiveComponent.tsx +25 -0
- package/renderer/createReactRenderer.tsx +2 -1
- package/renderer/index.ts +1 -0
|
@@ -0,0 +1,141 @@
|
|
|
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 { render, screen } from '@testing-library/react';
|
|
5
|
+
import userEvent from '@testing-library/user-event';
|
|
6
|
+
import { ActionDispatcher, CommandOutcome, InteractionContext } from '@cratis/scene.engine';
|
|
7
|
+
import { Behavior, InteractionActionKind, InteractionTriggerKind, NotificationLevel } from '@cratis/scene.model';
|
|
8
|
+
import { InteractionScope, useInteractions } from '../interactions';
|
|
9
|
+
|
|
10
|
+
const calls: string[] = [];
|
|
11
|
+
|
|
12
|
+
const dispatcher = {
|
|
13
|
+
async executeCommand(command: string, args: Record<string, unknown>): Promise<CommandOutcome> {
|
|
14
|
+
calls.push(`executeCommand:${command}:${JSON.stringify(args)}`);
|
|
15
|
+
return { isSuccess: true };
|
|
16
|
+
},
|
|
17
|
+
navigate: (screenName: string) => { calls.push(`navigate:${screenName}`); },
|
|
18
|
+
navigateBack: () => { calls.push('navigateBack'); },
|
|
19
|
+
openDialog: async () => { calls.push('openDialog'); return { isConfirmed: true }; },
|
|
20
|
+
closeDialog: () => { calls.push('closeDialog'); },
|
|
21
|
+
refreshQuery: async (query: string) => { calls.push(`refreshQuery:${query}`); },
|
|
22
|
+
setState: (target: string) => { calls.push(`setState:${target}`); },
|
|
23
|
+
notify: (level: NotificationLevel, message: string) => { calls.push(`notify:${level}:${message}`); },
|
|
24
|
+
confirm: async () => { calls.push('confirm'); return true; },
|
|
25
|
+
raise: async (trigger: string) => { calls.push(`raise:${trigger}`); },
|
|
26
|
+
} satisfies ActionDispatcher;
|
|
27
|
+
|
|
28
|
+
const context: InteractionContext = { resolve: binding => `<${binding.path}>` };
|
|
29
|
+
|
|
30
|
+
const cancelOnClick: Behavior = {
|
|
31
|
+
name: 'CancelInvoice',
|
|
32
|
+
bindings: [
|
|
33
|
+
{
|
|
34
|
+
trigger: { kind: InteractionTriggerKind.Click } as never,
|
|
35
|
+
actions: [
|
|
36
|
+
{
|
|
37
|
+
kind: InteractionActionKind.ExecuteCommand,
|
|
38
|
+
command: 'CancelInvoice',
|
|
39
|
+
arguments: [{ name: 'invoiceId', value: { path: 'item.invoiceId' } }],
|
|
40
|
+
onSuccess: [{ kind: InteractionActionKind.RefreshQuery, query: 'Invoices' }],
|
|
41
|
+
} as never,
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function Button({ behaviors }: { behaviors?: Behavior[] }) {
|
|
48
|
+
const handlers = useInteractions('element', behaviors);
|
|
49
|
+
return <button type="button" {...handlers}>Cancel</button>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function renderInScope(behaviors?: Behavior[]) {
|
|
53
|
+
return render(
|
|
54
|
+
<InteractionScope dispatcher={dispatcher} context={context} attachments={[]}>
|
|
55
|
+
<Button behaviors={behaviors} />
|
|
56
|
+
</InteractionScope>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('when clicking an element that executes a command', () => {
|
|
61
|
+
beforeEach(() => { calls.length = 0; });
|
|
62
|
+
|
|
63
|
+
// The whole point of everything beneath this: a document said what a click does, and a click does it.
|
|
64
|
+
it('should execute the command the document declared', async () => {
|
|
65
|
+
renderInScope([cancelOnClick]);
|
|
66
|
+
|
|
67
|
+
await userEvent.click(screen.getByRole('button'));
|
|
68
|
+
|
|
69
|
+
calls.should.eql(['executeCommand:CancelInvoice:{"invoiceId":"<item.invoiceId>"}', 'refreshQuery:Invoices']);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should not make an element interactive when nothing is attached', async () => {
|
|
73
|
+
renderInScope();
|
|
74
|
+
|
|
75
|
+
await userEvent.click(screen.getByRole('button'));
|
|
76
|
+
calls.should.have.lengthOf(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// A handler attached for a trigger the document never mentioned would swallow the event from whatever
|
|
80
|
+
// encloses the element, which is a bug you find by accident and spend an afternoon on.
|
|
81
|
+
it('should attach no handler for a trigger the document does not use', async () => {
|
|
82
|
+
renderInScope([cancelOnClick]);
|
|
83
|
+
|
|
84
|
+
await userEvent.dblClick(screen.getByRole('button'));
|
|
85
|
+
|
|
86
|
+
// The two clicks a double click is made of still run the click binding - that is what a double click
|
|
87
|
+
// is. What must not happen is a double-click handler existing when no binding asked for one.
|
|
88
|
+
calls.filter(call => call.startsWith('executeCommand')).should.have.lengthOf(2);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should run a behavior attached further out as well as its own', async () => {
|
|
92
|
+
render(
|
|
93
|
+
<InteractionScope
|
|
94
|
+
dispatcher={dispatcher}
|
|
95
|
+
context={context}
|
|
96
|
+
attachments={[{
|
|
97
|
+
level: 'module',
|
|
98
|
+
behaviors: [{
|
|
99
|
+
name: 'ModuleWide',
|
|
100
|
+
bindings: [{
|
|
101
|
+
trigger: { kind: InteractionTriggerKind.Click } as never,
|
|
102
|
+
actions: [{ kind: InteractionActionKind.Notify, level: NotificationLevel.Info, message: { text: 'from the module' } } as never],
|
|
103
|
+
}],
|
|
104
|
+
}],
|
|
105
|
+
}]}>
|
|
106
|
+
<Button behaviors={[cancelOnClick]} />
|
|
107
|
+
</InteractionScope>
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
await userEvent.click(screen.getByRole('button'));
|
|
111
|
+
|
|
112
|
+
calls.should.eql([
|
|
113
|
+
'notify:Info:from the module',
|
|
114
|
+
'executeCommand:CancelInvoice:{"invoiceId":"<item.invoiceId>"}',
|
|
115
|
+
'refreshQuery:Invoices',
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should not run a binding whose guard is false', async () => {
|
|
120
|
+
render(
|
|
121
|
+
<InteractionScope
|
|
122
|
+
dispatcher={dispatcher}
|
|
123
|
+
// The guard has to actually resolve to false; a context echoing the path would make every
|
|
124
|
+
// guard a non-empty string, and so always true.
|
|
125
|
+
context={{ resolve: () => false }}
|
|
126
|
+
attachments={[]}>
|
|
127
|
+
<Button behaviors={[{
|
|
128
|
+
name: 'Guarded',
|
|
129
|
+
bindings: [{
|
|
130
|
+
trigger: { kind: InteractionTriggerKind.Click } as never,
|
|
131
|
+
condition: { path: 'item.canCancel' },
|
|
132
|
+
actions: [{ kind: InteractionActionKind.ExecuteCommand, command: 'CancelInvoice' } as never],
|
|
133
|
+
}],
|
|
134
|
+
}]} />
|
|
135
|
+
</InteractionScope>
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
await userEvent.click(screen.getByRole('button'));
|
|
139
|
+
calls.should.have.lengthOf(0);
|
|
140
|
+
});
|
|
141
|
+
});
|
package/index.ts
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
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, createContext, useContext, useMemo } from 'react';
|
|
5
|
+
import { ActionDispatcher, BehaviorAttachment, InteractionContext, InteractionFinding } from '@cratis/scene.engine';
|
|
6
|
+
import { Behavior } from '@cratis/scene.model';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What the elements beneath a scope need in order to run an interaction.
|
|
10
|
+
*/
|
|
11
|
+
export interface InteractionScopeValue {
|
|
12
|
+
/** The seam that performs the effects. */
|
|
13
|
+
dispatcher: ActionDispatcher;
|
|
14
|
+
|
|
15
|
+
/** How bindings and localization resolve. */
|
|
16
|
+
context: InteractionContext;
|
|
17
|
+
|
|
18
|
+
/** The attachments enclosing this point, outermost first. */
|
|
19
|
+
attachments: BehaviorAttachment[];
|
|
20
|
+
|
|
21
|
+
/** Where a finding goes. Absent means nothing is listening, which is itself worth knowing. */
|
|
22
|
+
onFindings?: (findings: InteractionFinding[]) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const Scope = createContext<InteractionScopeValue | undefined>(undefined);
|
|
26
|
+
|
|
27
|
+
export interface InteractionScopeProps extends InteractionScopeValue {
|
|
28
|
+
children: ReactNode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Provides the dispatcher, the resolution context and the enclosing attachments to everything beneath it.
|
|
33
|
+
*
|
|
34
|
+
* Attachments nest: a scope inside another scope appends to it rather than replacing it, so an element's
|
|
35
|
+
* enclosing chain is assembled by the tree rather than reconstructed at each leaf. That is what keeps the
|
|
36
|
+
* additive rule true in a renderer - a module's behavior is still in scope at a button six levels down,
|
|
37
|
+
* without the button knowing the module exists.
|
|
38
|
+
*/
|
|
39
|
+
export function InteractionScope({ children, dispatcher, context, attachments, onFindings }: InteractionScopeProps) {
|
|
40
|
+
const enclosing = useContext(Scope);
|
|
41
|
+
|
|
42
|
+
const value = useMemo<InteractionScopeValue>(() => ({
|
|
43
|
+
dispatcher,
|
|
44
|
+
context,
|
|
45
|
+
attachments: [...(enclosing?.attachments ?? []), ...attachments],
|
|
46
|
+
onFindings: onFindings ?? enclosing?.onFindings,
|
|
47
|
+
}), [dispatcher, context, attachments, onFindings, enclosing]);
|
|
48
|
+
|
|
49
|
+
return <Scope.Provider value={value}>{children}</Scope.Provider>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Reads the enclosing interaction scope.
|
|
54
|
+
*
|
|
55
|
+
* @returns The scope, or `undefined` when the tree is rendered without one.
|
|
56
|
+
* @remarks
|
|
57
|
+
* Rendering without a scope is legitimate - a preview surface showing what a screen looks like has nothing to
|
|
58
|
+
* dispatch to - so this answers `undefined` rather than throwing. What must not happen is an element silently
|
|
59
|
+
* appearing interactive when nothing can run; {@link useInteractions} is where that is decided.
|
|
60
|
+
*/
|
|
61
|
+
export function useInteractionScope(): InteractionScopeValue | undefined {
|
|
62
|
+
return useContext(Scope);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Builds the attachment list for one element, so a caller does not have to know the shape.
|
|
67
|
+
*
|
|
68
|
+
* @param level What is being attached to, used when reporting.
|
|
69
|
+
* @param behaviors The behaviors attached there.
|
|
70
|
+
* @returns The attachment, or an empty list when there are none.
|
|
71
|
+
*/
|
|
72
|
+
export function attachmentFor(level: string, behaviors?: Behavior[]): BehaviorAttachment[] {
|
|
73
|
+
return behaviors && behaviors.length > 0 ? [{ level, behaviors }] : [];
|
|
74
|
+
}
|
|
@@ -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,6 @@
|
|
|
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';
|
|
@@ -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
|
+
"version": "4.0.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": "
|
|
54
|
-
"@cratis/scene.model": "
|
|
53
|
+
"@cratis/scene.engine": "4.0.0",
|
|
54
|
+
"@cratis/scene.model": "4.0.0"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
|
-
"@cratis/scene.engine": "
|
|
58
|
-
"@cratis/scene.model": "
|
|
57
|
+
"@cratis/scene.engine": "4.0.0",
|
|
58
|
+
"@cratis/scene.model": "4.0.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(
|
|
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