@cratis/scene.react 4.0.0 → 4.1.1

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 (79) hide show
  1. package/dist/cjs/SceneElementView.js +1 -0
  2. package/dist/cjs/SceneElementView.js.map +1 -1
  3. package/dist/cjs/index.js +8 -0
  4. package/dist/cjs/index.js.map +1 -1
  5. package/dist/cjs/interactions/ConfirmHost.js +71 -0
  6. package/dist/cjs/interactions/ConfirmHost.js.map +1 -0
  7. package/dist/cjs/interactions/InteractionScope.js +10 -1
  8. package/dist/cjs/interactions/InteractionScope.js.map +1 -1
  9. package/dist/cjs/interactions/NotificationHost.js +73 -0
  10. package/dist/cjs/interactions/NotificationHost.js.map +1 -0
  11. package/dist/cjs/interactions/UnresolvedActionHost.js +53 -0
  12. package/dist/cjs/interactions/UnresolvedActionHost.js.map +1 -0
  13. package/dist/cjs/interactions/useInteractionHosts.js +109 -0
  14. package/dist/cjs/interactions/useInteractionHosts.js.map +1 -0
  15. package/dist/cjs/renderer/InteractiveComponent.js +1 -0
  16. package/dist/cjs/renderer/InteractiveComponent.js.map +1 -1
  17. package/dist/esm/SceneElementView.js +1 -0
  18. package/dist/esm/SceneElementView.js.map +1 -1
  19. package/dist/esm/for_useInteractionHosts/when_a_document_asks_the_user_something.d.ts +2 -0
  20. package/dist/esm/for_useInteractionHosts/when_a_document_asks_the_user_something.d.ts.map +1 -0
  21. package/dist/esm/for_useInteractionHosts/when_a_document_asks_the_user_something.js +73 -0
  22. package/dist/esm/for_useInteractionHosts/when_a_document_asks_the_user_something.js.map +1 -0
  23. package/dist/esm/for_useInteractionHosts/when_the_host_cannot_do_what_was_asked.d.ts +2 -0
  24. package/dist/esm/for_useInteractionHosts/when_the_host_cannot_do_what_was_asked.d.ts.map +1 -0
  25. package/dist/esm/for_useInteractionHosts/when_the_host_cannot_do_what_was_asked.js +43 -0
  26. package/dist/esm/for_useInteractionHosts/when_the_host_cannot_do_what_was_asked.js.map +1 -0
  27. package/dist/esm/index.js +4 -0
  28. package/dist/esm/index.js.map +1 -1
  29. package/dist/esm/interactions/ConfirmHost.d.ts +8 -0
  30. package/dist/esm/interactions/ConfirmHost.d.ts.map +1 -0
  31. package/dist/esm/interactions/ConfirmHost.js +69 -0
  32. package/dist/esm/interactions/ConfirmHost.js.map +1 -0
  33. package/dist/esm/interactions/ConfirmRequest.d.ts +5 -0
  34. package/dist/esm/interactions/ConfirmRequest.d.ts.map +1 -0
  35. package/dist/esm/interactions/ConfirmRequest.js +2 -0
  36. package/dist/esm/interactions/ConfirmRequest.js.map +1 -0
  37. package/dist/esm/interactions/InteractionScope.d.ts +2 -1
  38. package/dist/esm/interactions/InteractionScope.d.ts.map +1 -1
  39. package/dist/esm/interactions/InteractionScope.js +10 -1
  40. package/dist/esm/interactions/InteractionScope.js.map +1 -1
  41. package/dist/esm/interactions/NotificationHost.d.ts +7 -0
  42. package/dist/esm/interactions/NotificationHost.d.ts.map +1 -0
  43. package/dist/esm/interactions/NotificationHost.js +71 -0
  44. package/dist/esm/interactions/NotificationHost.js.map +1 -0
  45. package/dist/esm/interactions/SceneNotification.d.ts +7 -0
  46. package/dist/esm/interactions/SceneNotification.d.ts.map +1 -0
  47. package/dist/esm/interactions/SceneNotification.js +2 -0
  48. package/dist/esm/interactions/SceneNotification.js.map +1 -0
  49. package/dist/esm/interactions/UnresolvedAction.d.ts +5 -0
  50. package/dist/esm/interactions/UnresolvedAction.d.ts.map +1 -0
  51. package/dist/esm/interactions/UnresolvedAction.js +2 -0
  52. package/dist/esm/interactions/UnresolvedAction.js.map +1 -0
  53. package/dist/esm/interactions/UnresolvedActionHost.d.ts +6 -0
  54. package/dist/esm/interactions/UnresolvedActionHost.d.ts.map +1 -0
  55. package/dist/esm/interactions/UnresolvedActionHost.js +51 -0
  56. package/dist/esm/interactions/UnresolvedActionHost.js.map +1 -0
  57. package/dist/esm/interactions/index.d.ts +7 -0
  58. package/dist/esm/interactions/index.d.ts.map +1 -1
  59. package/dist/esm/interactions/index.js +7 -0
  60. package/dist/esm/interactions/index.js.map +1 -1
  61. package/dist/esm/interactions/useInteractionHosts.d.ts +11 -0
  62. package/dist/esm/interactions/useInteractionHosts.d.ts.map +1 -0
  63. package/dist/esm/interactions/useInteractionHosts.js +107 -0
  64. package/dist/esm/interactions/useInteractionHosts.js.map +1 -0
  65. package/dist/esm/renderer/InteractiveComponent.js +1 -0
  66. package/dist/esm/renderer/InteractiveComponent.js.map +1 -1
  67. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  68. package/for_useInteractionHosts/when_a_document_asks_the_user_something.tsx +106 -0
  69. package/for_useInteractionHosts/when_the_host_cannot_do_what_was_asked.tsx +66 -0
  70. package/interactions/ConfirmHost.tsx +42 -0
  71. package/interactions/ConfirmRequest.ts +19 -0
  72. package/interactions/InteractionScope.tsx +22 -2
  73. package/interactions/NotificationHost.tsx +48 -0
  74. package/interactions/SceneNotification.ts +18 -0
  75. package/interactions/UnresolvedAction.ts +17 -0
  76. package/interactions/UnresolvedActionHost.tsx +33 -0
  77. package/interactions/index.ts +7 -0
  78. package/interactions/useInteractionHosts.tsx +101 -0
  79. package/package.json +5 -5
@@ -0,0 +1,106 @@
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, within } from '@testing-library/react';
5
+ import userEvent from '@testing-library/user-event';
6
+ import { useMemo } from 'react';
7
+ import { InteractionActionKind, InteractionTriggerKind, Behavior, NotificationLevel } from '@cratis/scene.model';
8
+ import { InteractionScope, createBrowserDispatcher, useInteractionHosts, useInteractions } from '../interactions';
9
+
10
+ const executed: string[] = [];
11
+
12
+ const confirmThenExecute: Behavior = {
13
+ name: 'CancelInvoice',
14
+ bindings: [
15
+ {
16
+ trigger: { kind: InteractionTriggerKind.Click } as never,
17
+ actions: [
18
+ { kind: InteractionActionKind.Confirm, message: { text: 'Cancel this invoice?' } } as never,
19
+ { kind: InteractionActionKind.ExecuteCommand, command: 'CancelInvoice' } as never,
20
+ { kind: InteractionActionKind.Notify, level: NotificationLevel.Info, message: { text: 'Invoice cancelled' } } as never,
21
+ ],
22
+ },
23
+ ],
24
+ };
25
+
26
+ function Button({ behaviors }: { behaviors: Behavior[] }) {
27
+ const handlers = useInteractions('element', behaviors);
28
+ return <button type="button" {...handlers}>Cancel</button>;
29
+ }
30
+
31
+ function Application({ behaviors }: { behaviors: Behavior[] }) {
32
+ const { host, surfaces } = useInteractionHosts();
33
+ const dispatcher = useMemo(() => createBrowserDispatcher({
34
+ ...host,
35
+ executeCommand: async command => {
36
+ executed.push(command);
37
+ return { isSuccess: true };
38
+ },
39
+ }), [host]);
40
+
41
+ return (
42
+ <InteractionScope dispatcher={dispatcher} attachments={[]}>
43
+ <Button behaviors={behaviors} />
44
+ {surfaces}
45
+ </InteractionScope>
46
+ );
47
+ }
48
+
49
+ describe('when a document asks the user something', () => {
50
+ beforeEach(() => { executed.length = 0; });
51
+
52
+ it('should ask in the page rather than in a browser prompt', async () => {
53
+ render(<Application behaviors={[confirmThenExecute]} />);
54
+
55
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
56
+
57
+ screen.getByRole('alertdialog').getAttribute('aria-label')!.should.equal('Cancel this invoice?');
58
+ });
59
+
60
+ // The reason a confirm exists at all: what follows it runs only if the answer was yes.
61
+ it('should not run what follows until the question is answered', async () => {
62
+ render(<Application behaviors={[confirmThenExecute]} />);
63
+
64
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
65
+
66
+ executed.should.have.lengthOf(0);
67
+ });
68
+
69
+ it('should run what follows when the answer is yes', async () => {
70
+ render(<Application behaviors={[confirmThenExecute]} />);
71
+
72
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
73
+ await userEvent.click(screen.getByRole('button', { name: 'OK' }));
74
+
75
+ executed.should.eql(['CancelInvoice']);
76
+ });
77
+
78
+ it('should abandon the sequence when the answer is no', async () => {
79
+ render(<Application behaviors={[confirmThenExecute]} />);
80
+
81
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
82
+ // Two buttons say 'Cancel' now - the modelled one and the declining choice - so the question has to
83
+ // be answered through the dialog it belongs to.
84
+ await userEvent.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Cancel' }));
85
+
86
+ executed.should.have.lengthOf(0);
87
+ });
88
+
89
+ it('should take the question down once it is answered', async () => {
90
+ render(<Application behaviors={[confirmThenExecute]} />);
91
+
92
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
93
+ await userEvent.click(screen.getByRole('button', { name: 'OK' }));
94
+
95
+ (screen.queryByRole('alertdialog') === null).should.equal(true);
96
+ });
97
+
98
+ it('should show what the document said after the command ran', async () => {
99
+ render(<Application behaviors={[confirmThenExecute]} />);
100
+
101
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
102
+ await userEvent.click(screen.getByRole('button', { name: 'OK' }));
103
+
104
+ screen.getByText('Invoice cancelled').textContent!.should.equal('Invoice cancelled');
105
+ });
106
+ });
@@ -0,0 +1,66 @@
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 { useMemo } from 'react';
7
+ import { InteractionActionKind, InteractionTriggerKind, Behavior } from '@cratis/scene.model';
8
+ import { InteractionScope, createBrowserDispatcher, useInteractionHosts, useInteractions } from '../interactions';
9
+
10
+ const navigateSomewhere: Behavior = {
11
+ name: 'GoToInvoice',
12
+ bindings: [
13
+ {
14
+ trigger: { kind: InteractionTriggerKind.Click } as never,
15
+ actions: [{ kind: InteractionActionKind.Navigate, screen: 'InvoiceDetails' } as never],
16
+ },
17
+ ],
18
+ };
19
+
20
+ function Button() {
21
+ const handlers = useInteractions('element', [navigateSomewhere]);
22
+ return <button type="button" {...handlers}>Open</button>;
23
+ }
24
+
25
+ /**
26
+ * A host that can do nothing at all - a preview surface, or an application half-way through being wired.
27
+ */
28
+ function Application() {
29
+ const { host, surfaces } = useInteractionHosts();
30
+ const dispatcher = useMemo(() => createBrowserDispatcher(host), [host]);
31
+
32
+ return (
33
+ <InteractionScope dispatcher={dispatcher} attachments={[]}>
34
+ <Button />
35
+ {surfaces}
36
+ </InteractionScope>
37
+ );
38
+ }
39
+
40
+ describe('when the host cannot do what was asked', () => {
41
+ it('should say so on the screen rather than do nothing', async () => {
42
+ render(<Application />);
43
+
44
+ await userEvent.click(screen.getByRole('button', { name: 'Open' }));
45
+
46
+ screen.getByText('navigate to InvoiceDetails').textContent!.should.equal('navigate to InvoiceDetails');
47
+ });
48
+
49
+ // A trigger that repeats would otherwise grow the report until it covered the application it is
50
+ // describing, which turns a useful warning into something to be closed without reading.
51
+ it('should report the same action once however often it is asked for', async () => {
52
+ render(<Application />);
53
+
54
+ await userEvent.click(screen.getByRole('button', { name: 'Open' }));
55
+ await userEvent.click(screen.getByRole('button', { name: 'Open' }));
56
+ await userEvent.click(screen.getByRole('button', { name: 'Open' }));
57
+
58
+ screen.getAllByText('navigate to InvoiceDetails').should.have.lengthOf(1);
59
+ });
60
+
61
+ it('should say nothing when nothing has failed', () => {
62
+ render(<Application />);
63
+
64
+ (screen.queryByText(/cannot do what the document asked/) === null).should.equal(true);
65
+ });
66
+ });
@@ -0,0 +1,42 @@
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 { ConfirmRequest } from './ConfirmRequest';
5
+
6
+ /**
7
+ * The properties of {@link ConfirmHost}.
8
+ */
9
+ export interface ConfirmHostProps {
10
+ /** The question waiting on an answer, or `undefined` when nothing is being asked. */
11
+ readonly request?: ConfirmRequest;
12
+
13
+ /** The label on the agreeing choice. */
14
+ readonly confirmLabel?: string;
15
+
16
+ /** The label on the declining choice. */
17
+ readonly cancelLabel?: string;
18
+ }
19
+
20
+ /**
21
+ * Asks what a document's `confirm` asked, in the page rather than in a browser prompt.
22
+ *
23
+ * The browser's own prompt blocks the event loop and cannot be styled or tested, which is why it is the
24
+ * fallback and not this. Declining is the default action, so dismissing the question stops the sequence - a
25
+ * confirm exists to gate what follows it, and a gate that opens when ignored is not one.
26
+ * @param props The {@link ConfirmHostProps}.
27
+ */
28
+ export function ConfirmHost({ request, confirmLabel = 'OK', cancelLabel = 'Cancel' }: ConfirmHostProps) {
29
+ if (!request) return null;
30
+
31
+ return (
32
+ <div data-scene-confirm style={{ position: 'fixed', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.35)', zIndex: 1100 }}>
33
+ <div role="alertdialog" aria-modal="true" aria-label={request.message} style={{ minWidth: '18rem', maxWidth: '28rem', padding: '1rem', borderRadius: 4, background: '#fff', color: '#1a1a1a' }}>
34
+ <p style={{ margin: '0 0 1rem' }}>{request.message}</p>
35
+ <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
36
+ <button type="button" onClick={() => request.answer(false)}>{cancelLabel}</button>
37
+ <button type="button" onClick={() => request.answer(true)}>{confirmLabel}</button>
38
+ </div>
39
+ </div>
40
+ </div>
41
+ );
42
+ }
@@ -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
+ * A question an interaction is waiting on the answer to.
6
+ *
7
+ * The sequence that asked is suspended until {@link answer} is called, which is the whole point of a confirm:
8
+ * everything after it in the document runs only if the answer was yes.
9
+ */
10
+ export interface ConfirmRequest {
11
+ /** What the document asked, already resolved against the interaction's context. */
12
+ readonly message: string;
13
+
14
+ /**
15
+ * Answers the question and lets the suspended sequence continue.
16
+ * @param confirmed Whether the user agreed.
17
+ */
18
+ answer(confirmed: boolean): void;
19
+ }
@@ -22,9 +22,29 @@ export interface InteractionScopeValue {
22
22
  onFindings?: (findings: InteractionFinding[]) => void;
23
23
  }
24
24
 
25
+ /**
26
+ * The context a scope uses when it was given none.
27
+ *
28
+ * A document whose messages are literal text and whose actions take no arguments resolves nothing, and that is
29
+ * an ordinary document rather than a degenerate one. Requiring a context anyway would make every host invent
30
+ * an empty resolver, and an invented one is a place for a mistake to hide.
31
+ */
32
+ const resolvesNothing: InteractionContext = { resolve: () => undefined };
33
+
25
34
  const Scope = createContext<InteractionScopeValue | undefined>(undefined);
26
35
 
27
- export interface InteractionScopeProps extends InteractionScopeValue {
36
+ /**
37
+ * The properties of {@link InteractionScope}.
38
+ *
39
+ * The context is the one thing an author may leave out, because a document whose messages are literal text and
40
+ * whose actions take no arguments resolves nothing. Everything beneath a scope still reads a context that is
41
+ * definitely there, so only the author is spared, not the code doing the work.
42
+ */
43
+ export interface InteractionScopeProps extends Omit<InteractionScopeValue, 'context'> {
44
+ /** How bindings and localization resolve. Omitted means nothing resolves. */
45
+ context?: InteractionContext;
46
+
47
+ /** What the scope encloses. */
28
48
  children: ReactNode;
29
49
  }
30
50
 
@@ -36,7 +56,7 @@ export interface InteractionScopeProps extends InteractionScopeValue {
36
56
  * additive rule true in a renderer - a module's behavior is still in scope at a button six levels down,
37
57
  * without the button knowing the module exists.
38
58
  */
39
- export function InteractionScope({ children, dispatcher, context, attachments, onFindings }: InteractionScopeProps) {
59
+ export function InteractionScope({ children, dispatcher, context = resolvesNothing, attachments, onFindings }: InteractionScopeProps) {
40
60
  const enclosing = useContext(Scope);
41
61
 
42
62
  const value = useMemo<InteractionScopeValue>(() => ({
@@ -0,0 +1,48 @@
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 { NotificationLevel } from '@cratis/scene.model';
5
+ import { SceneNotification } from './SceneNotification';
6
+
7
+ /**
8
+ * The properties of {@link NotificationHost}.
9
+ */
10
+ export interface NotificationHostProps {
11
+ /** What is currently being said, oldest first. */
12
+ readonly notifications: readonly SceneNotification[];
13
+
14
+ /** Called when one is dismissed. */
15
+ readonly onDismiss: (id: number) => void;
16
+ }
17
+
18
+ const colours: Record<NotificationLevel, string> = {
19
+ [NotificationLevel.Info]: '#1e6fbf',
20
+ [NotificationLevel.Warning]: '#b8860b',
21
+ [NotificationLevel.Error]: '#c0392b',
22
+ };
23
+
24
+ /**
25
+ * Shows what a document's `notify` asked to say.
26
+ *
27
+ * Deliberately plain. An application with a design system of its own passes its own `notify` to the dispatcher
28
+ * and never renders this; what this exists for is that the default is *something* rather than a console line
29
+ * nobody is looking at.
30
+ * @param props The {@link NotificationHostProps}.
31
+ */
32
+ export function NotificationHost({ notifications, onDismiss }: NotificationHostProps) {
33
+ if (notifications.length === 0) return null;
34
+
35
+ return (
36
+ <div role="log" aria-live="polite" data-scene-notifications style={{ position: 'fixed', top: '1rem', right: '1rem', display: 'flex', flexDirection: 'column', gap: '0.5rem', zIndex: 1000 }}>
37
+ {notifications.map(notification => (
38
+ <div
39
+ key={notification.id}
40
+ data-scene-notification={notification.level}
41
+ style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', maxWidth: '24rem', padding: '0.625rem 0.75rem', borderRadius: 4, border: `1px solid ${colours[notification.level]}`, borderLeftWidth: 4, background: '#fff', color: '#1a1a1a' }}>
42
+ <span style={{ flex: 1 }}>{notification.message}</span>
43
+ <button type="button" aria-label="Dismiss" onClick={() => onDismiss(notification.id)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: '1rem', lineHeight: 1 }}>x</button>
44
+ </div>
45
+ ))}
46
+ </div>
47
+ );
48
+ }
@@ -0,0 +1,18 @@
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 { NotificationLevel } from '@cratis/scene.model';
5
+
6
+ /**
7
+ * Something a document asked to be said, waiting to be seen.
8
+ */
9
+ export interface SceneNotification {
10
+ /** Identifies the notification for the lifetime of the surface showing it. */
11
+ readonly id: number;
12
+
13
+ /** How the document classified it. */
14
+ readonly level: NotificationLevel;
15
+
16
+ /** What it says, already resolved against the interaction's context. */
17
+ readonly message: string;
18
+ }
@@ -0,0 +1,17 @@
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
+ * An action a document asked for that this host cannot carry out.
6
+ *
7
+ * Kept as data rather than written straight to the console, so a surface can show it. A modelled button that
8
+ * silently does nothing is the hardest kind of defect to trace back to its cause: nothing failed, so nothing
9
+ * was reported, and the document looks correct because it is.
10
+ */
11
+ export interface UnresolvedAction {
12
+ /** Identifies the report for the lifetime of the surface showing it. */
13
+ readonly id: number;
14
+
15
+ /** What the document asked for, phrased as the document phrased it. */
16
+ readonly action: string;
17
+ }
@@ -0,0 +1,33 @@
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 { UnresolvedAction } from './UnresolvedAction';
5
+
6
+ /**
7
+ * The properties of {@link UnresolvedActionHost}.
8
+ */
9
+ export interface UnresolvedActionHostProps {
10
+ /** What has been asked for and could not be done. */
11
+ readonly unresolved: readonly UnresolvedAction[];
12
+ }
13
+
14
+ /**
15
+ * Shows what a document asked for that this host cannot do.
16
+ *
17
+ * Only useful while an application is being built, which is why an application that has wired everything will
18
+ * never see it. It is rendered rather than logged because the person who would fix it is usually looking at
19
+ * the screen and not at the console.
20
+ * @param props The {@link UnresolvedActionHostProps}.
21
+ */
22
+ export function UnresolvedActionHost({ unresolved }: UnresolvedActionHostProps) {
23
+ if (unresolved.length === 0) return null;
24
+
25
+ return (
26
+ <div role="status" data-scene-unresolved style={{ position: 'fixed', bottom: '1rem', left: '1rem', maxWidth: '28rem', padding: '0.625rem 0.75rem', borderRadius: 4, border: '1px solid #c0392b', background: '#fff', color: '#1a1a1a', zIndex: 1000 }}>
27
+ <strong style={{ display: 'block', marginBottom: '0.25rem' }}>This surface cannot do what the document asked</strong>
28
+ <ul style={{ margin: 0, paddingLeft: '1.25rem' }}>
29
+ {unresolved.map(action => <li key={action.id}>{action.action}</li>)}
30
+ </ul>
31
+ </div>
32
+ );
33
+ }
@@ -4,3 +4,10 @@
4
4
  export * from './InteractionScope';
5
5
  export * from './useInteractions';
6
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/scene.react",
3
- "version": "4.0.0",
3
+ "version": "4.1.1",
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": "4.0.0",
54
- "@cratis/scene.model": "4.0.0"
53
+ "@cratis/scene.engine": "4.1.1",
54
+ "@cratis/scene.model": "4.1.1"
55
55
  },
56
56
  "devDependencies": {
57
- "@cratis/scene.engine": "4.0.0",
58
- "@cratis/scene.model": "4.0.0",
57
+ "@cratis/scene.engine": "4.1.1",
58
+ "@cratis/scene.model": "4.1.1",
59
59
  "@storybook/addon-links": "^10.6.0",
60
60
  "@storybook/react": "^10.6.0",
61
61
  "@storybook/react-vite": "^10.6.0",