diff --git a/Source/JavaScript/react/for_useInteractionHosts/when_a_document_asks_the_user_something.tsx b/Source/JavaScript/react/for_useInteractionHosts/when_a_document_asks_the_user_something.tsx
new file mode 100644
index 00000000..0d9b1ae8
--- /dev/null
+++ b/Source/JavaScript/react/for_useInteractionHosts/when_a_document_asks_the_user_something.tsx
@@ -0,0 +1,106 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useMemo } from 'react';
+import { InteractionActionKind, InteractionTriggerKind, Behavior, NotificationLevel } from '@cratis/scene.model';
+import { InteractionScope, createBrowserDispatcher, useInteractionHosts, useInteractions } from '../interactions';
+
+const executed: string[] = [];
+
+const confirmThenExecute: Behavior = {
+ name: 'CancelInvoice',
+ bindings: [
+ {
+ trigger: { kind: InteractionTriggerKind.Click } as never,
+ actions: [
+ { kind: InteractionActionKind.Confirm, message: { text: 'Cancel this invoice?' } } as never,
+ { kind: InteractionActionKind.ExecuteCommand, command: 'CancelInvoice' } as never,
+ { kind: InteractionActionKind.Notify, level: NotificationLevel.Info, message: { text: 'Invoice cancelled' } } as never,
+ ],
+ },
+ ],
+};
+
+function Button({ behaviors }: { behaviors: Behavior[] }) {
+ const handlers = useInteractions('element', behaviors);
+ return ;
+}
+
+function Application({ behaviors }: { behaviors: Behavior[] }) {
+ const { host, surfaces } = useInteractionHosts();
+ const dispatcher = useMemo(() => createBrowserDispatcher({
+ ...host,
+ executeCommand: async command => {
+ executed.push(command);
+ return { isSuccess: true };
+ },
+ }), [host]);
+
+ return (
+
+
+ {surfaces}
+
+ );
+}
+
+describe('when a document asks the user something', () => {
+ beforeEach(() => { executed.length = 0; });
+
+ it('should ask in the page rather than in a browser prompt', async () => {
+ render();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+
+ screen.getByRole('alertdialog').getAttribute('aria-label')!.should.equal('Cancel this invoice?');
+ });
+
+ // The reason a confirm exists at all: what follows it runs only if the answer was yes.
+ it('should not run what follows until the question is answered', async () => {
+ render();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+
+ executed.should.have.lengthOf(0);
+ });
+
+ it('should run what follows when the answer is yes', async () => {
+ render();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+ await userEvent.click(screen.getByRole('button', { name: 'OK' }));
+
+ executed.should.eql(['CancelInvoice']);
+ });
+
+ it('should abandon the sequence when the answer is no', async () => {
+ render();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+ // Two buttons say 'Cancel' now - the modelled one and the declining choice - so the question has to
+ // be answered through the dialog it belongs to.
+ await userEvent.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Cancel' }));
+
+ executed.should.have.lengthOf(0);
+ });
+
+ it('should take the question down once it is answered', async () => {
+ render();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+ await userEvent.click(screen.getByRole('button', { name: 'OK' }));
+
+ (screen.queryByRole('alertdialog') === null).should.equal(true);
+ });
+
+ it('should show what the document said after the command ran', async () => {
+ render();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+ await userEvent.click(screen.getByRole('button', { name: 'OK' }));
+
+ screen.getByText('Invoice cancelled').textContent!.should.equal('Invoice cancelled');
+ });
+});
diff --git a/Source/JavaScript/react/for_useInteractionHosts/when_the_host_cannot_do_what_was_asked.tsx b/Source/JavaScript/react/for_useInteractionHosts/when_the_host_cannot_do_what_was_asked.tsx
new file mode 100644
index 00000000..89fff4a8
--- /dev/null
+++ b/Source/JavaScript/react/for_useInteractionHosts/when_the_host_cannot_do_what_was_asked.tsx
@@ -0,0 +1,66 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useMemo } from 'react';
+import { InteractionActionKind, InteractionTriggerKind, Behavior } from '@cratis/scene.model';
+import { InteractionScope, createBrowserDispatcher, useInteractionHosts, useInteractions } from '../interactions';
+
+const navigateSomewhere: Behavior = {
+ name: 'GoToInvoice',
+ bindings: [
+ {
+ trigger: { kind: InteractionTriggerKind.Click } as never,
+ actions: [{ kind: InteractionActionKind.Navigate, screen: 'InvoiceDetails' } as never],
+ },
+ ],
+};
+
+function Button() {
+ const handlers = useInteractions('element', [navigateSomewhere]);
+ return ;
+}
+
+/**
+ * A host that can do nothing at all - a preview surface, or an application half-way through being wired.
+ */
+function Application() {
+ const { host, surfaces } = useInteractionHosts();
+ const dispatcher = useMemo(() => createBrowserDispatcher(host), [host]);
+
+ return (
+
+
+ {surfaces}
+
+ );
+}
+
+describe('when the host cannot do what was asked', () => {
+ it('should say so on the screen rather than do nothing', async () => {
+ render();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Open' }));
+
+ screen.getByText('navigate to InvoiceDetails').textContent!.should.equal('navigate to InvoiceDetails');
+ });
+
+ // A trigger that repeats would otherwise grow the report until it covered the application it is
+ // describing, which turns a useful warning into something to be closed without reading.
+ it('should report the same action once however often it is asked for', async () => {
+ render();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Open' }));
+ await userEvent.click(screen.getByRole('button', { name: 'Open' }));
+ await userEvent.click(screen.getByRole('button', { name: 'Open' }));
+
+ screen.getAllByText('navigate to InvoiceDetails').should.have.lengthOf(1);
+ });
+
+ it('should say nothing when nothing has failed', () => {
+ render();
+
+ (screen.queryByText(/cannot do what the document asked/) === null).should.equal(true);
+ });
+});
diff --git a/Source/JavaScript/react/interactions/ConfirmHost.tsx b/Source/JavaScript/react/interactions/ConfirmHost.tsx
new file mode 100644
index 00000000..4d709c39
--- /dev/null
+++ b/Source/JavaScript/react/interactions/ConfirmHost.tsx
@@ -0,0 +1,42 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { ConfirmRequest } from './ConfirmRequest';
+
+/**
+ * The properties of {@link ConfirmHost}.
+ */
+export interface ConfirmHostProps {
+ /** The question waiting on an answer, or `undefined` when nothing is being asked. */
+ readonly request?: ConfirmRequest;
+
+ /** The label on the agreeing choice. */
+ readonly confirmLabel?: string;
+
+ /** The label on the declining choice. */
+ readonly cancelLabel?: string;
+}
+
+/**
+ * Asks what a document's `confirm` asked, in the page rather than in a browser prompt.
+ *
+ * The browser's own prompt blocks the event loop and cannot be styled or tested, which is why it is the
+ * fallback and not this. Declining is the default action, so dismissing the question stops the sequence - a
+ * confirm exists to gate what follows it, and a gate that opens when ignored is not one.
+ * @param props The {@link ConfirmHostProps}.
+ */
+export function ConfirmHost({ request, confirmLabel = 'OK', cancelLabel = 'Cancel' }: ConfirmHostProps) {
+ if (!request) return null;
+
+ return (
+
+
+
{request.message}
+
+
+
+
+
+
+ );
+}
diff --git a/Source/JavaScript/react/interactions/ConfirmRequest.ts b/Source/JavaScript/react/interactions/ConfirmRequest.ts
new file mode 100644
index 00000000..86cd68c6
--- /dev/null
+++ b/Source/JavaScript/react/interactions/ConfirmRequest.ts
@@ -0,0 +1,19 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+/**
+ * A question an interaction is waiting on the answer to.
+ *
+ * The sequence that asked is suspended until {@link answer} is called, which is the whole point of a confirm:
+ * everything after it in the document runs only if the answer was yes.
+ */
+export interface ConfirmRequest {
+ /** What the document asked, already resolved against the interaction's context. */
+ readonly message: string;
+
+ /**
+ * Answers the question and lets the suspended sequence continue.
+ * @param confirmed Whether the user agreed.
+ */
+ answer(confirmed: boolean): void;
+}
diff --git a/Source/JavaScript/react/interactions/InteractionScope.tsx b/Source/JavaScript/react/interactions/InteractionScope.tsx
index 9aded5e5..9c3ddf2a 100644
--- a/Source/JavaScript/react/interactions/InteractionScope.tsx
+++ b/Source/JavaScript/react/interactions/InteractionScope.tsx
@@ -22,9 +22,29 @@ export interface InteractionScopeValue {
onFindings?: (findings: InteractionFinding[]) => void;
}
+/**
+ * The context a scope uses when it was given none.
+ *
+ * A document whose messages are literal text and whose actions take no arguments resolves nothing, and that is
+ * an ordinary document rather than a degenerate one. Requiring a context anyway would make every host invent
+ * an empty resolver, and an invented one is a place for a mistake to hide.
+ */
+const resolvesNothing: InteractionContext = { resolve: () => undefined };
+
const Scope = createContext(undefined);
-export interface InteractionScopeProps extends InteractionScopeValue {
+/**
+ * The properties of {@link InteractionScope}.
+ *
+ * The context is the one thing an author may leave out, because a document whose messages are literal text and
+ * whose actions take no arguments resolves nothing. Everything beneath a scope still reads a context that is
+ * definitely there, so only the author is spared, not the code doing the work.
+ */
+export interface InteractionScopeProps extends Omit {
+ /** How bindings and localization resolve. Omitted means nothing resolves. */
+ context?: InteractionContext;
+
+ /** What the scope encloses. */
children: ReactNode;
}
@@ -36,7 +56,7 @@ export interface InteractionScopeProps extends InteractionScopeValue {
* additive rule true in a renderer - a module's behavior is still in scope at a button six levels down,
* without the button knowing the module exists.
*/
-export function InteractionScope({ children, dispatcher, context, attachments, onFindings }: InteractionScopeProps) {
+export function InteractionScope({ children, dispatcher, context = resolvesNothing, attachments, onFindings }: InteractionScopeProps) {
const enclosing = useContext(Scope);
const value = useMemo(() => ({
diff --git a/Source/JavaScript/react/interactions/NotificationHost.tsx b/Source/JavaScript/react/interactions/NotificationHost.tsx
new file mode 100644
index 00000000..ddd69240
--- /dev/null
+++ b/Source/JavaScript/react/interactions/NotificationHost.tsx
@@ -0,0 +1,48 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { NotificationLevel } from '@cratis/scene.model';
+import { SceneNotification } from './SceneNotification';
+
+/**
+ * The properties of {@link NotificationHost}.
+ */
+export interface NotificationHostProps {
+ /** What is currently being said, oldest first. */
+ readonly notifications: readonly SceneNotification[];
+
+ /** Called when one is dismissed. */
+ readonly onDismiss: (id: number) => void;
+}
+
+const colours: Record = {
+ [NotificationLevel.Info]: '#1e6fbf',
+ [NotificationLevel.Warning]: '#b8860b',
+ [NotificationLevel.Error]: '#c0392b',
+};
+
+/**
+ * Shows what a document's `notify` asked to say.
+ *
+ * Deliberately plain. An application with a design system of its own passes its own `notify` to the dispatcher
+ * and never renders this; what this exists for is that the default is *something* rather than a console line
+ * nobody is looking at.
+ * @param props The {@link NotificationHostProps}.
+ */
+export function NotificationHost({ notifications, onDismiss }: NotificationHostProps) {
+ if (notifications.length === 0) return null;
+
+ return (
+
+ {notifications.map(notification => (
+
+ {notification.message}
+
+
+ ))}
+
+ );
+}
diff --git a/Source/JavaScript/react/interactions/SceneNotification.ts b/Source/JavaScript/react/interactions/SceneNotification.ts
new file mode 100644
index 00000000..92ec3881
--- /dev/null
+++ b/Source/JavaScript/react/interactions/SceneNotification.ts
@@ -0,0 +1,18 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { NotificationLevel } from '@cratis/scene.model';
+
+/**
+ * Something a document asked to be said, waiting to be seen.
+ */
+export interface SceneNotification {
+ /** Identifies the notification for the lifetime of the surface showing it. */
+ readonly id: number;
+
+ /** How the document classified it. */
+ readonly level: NotificationLevel;
+
+ /** What it says, already resolved against the interaction's context. */
+ readonly message: string;
+}
diff --git a/Source/JavaScript/react/interactions/UnresolvedAction.ts b/Source/JavaScript/react/interactions/UnresolvedAction.ts
new file mode 100644
index 00000000..0da060f3
--- /dev/null
+++ b/Source/JavaScript/react/interactions/UnresolvedAction.ts
@@ -0,0 +1,17 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+/**
+ * An action a document asked for that this host cannot carry out.
+ *
+ * Kept as data rather than written straight to the console, so a surface can show it. A modelled button that
+ * silently does nothing is the hardest kind of defect to trace back to its cause: nothing failed, so nothing
+ * was reported, and the document looks correct because it is.
+ */
+export interface UnresolvedAction {
+ /** Identifies the report for the lifetime of the surface showing it. */
+ readonly id: number;
+
+ /** What the document asked for, phrased as the document phrased it. */
+ readonly action: string;
+}
diff --git a/Source/JavaScript/react/interactions/UnresolvedActionHost.tsx b/Source/JavaScript/react/interactions/UnresolvedActionHost.tsx
new file mode 100644
index 00000000..cfc92e48
--- /dev/null
+++ b/Source/JavaScript/react/interactions/UnresolvedActionHost.tsx
@@ -0,0 +1,33 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { UnresolvedAction } from './UnresolvedAction';
+
+/**
+ * The properties of {@link UnresolvedActionHost}.
+ */
+export interface UnresolvedActionHostProps {
+ /** What has been asked for and could not be done. */
+ readonly unresolved: readonly UnresolvedAction[];
+}
+
+/**
+ * Shows what a document asked for that this host cannot do.
+ *
+ * Only useful while an application is being built, which is why an application that has wired everything will
+ * never see it. It is rendered rather than logged because the person who would fix it is usually looking at
+ * the screen and not at the console.
+ * @param props The {@link UnresolvedActionHostProps}.
+ */
+export function UnresolvedActionHost({ unresolved }: UnresolvedActionHostProps) {
+ if (unresolved.length === 0) return null;
+
+ return (
+
+ This surface cannot do what the document asked
+
+ {unresolved.map(action =>
{action.action}
)}
+
+
+ );
+}
diff --git a/Source/JavaScript/react/interactions/index.ts b/Source/JavaScript/react/interactions/index.ts
index 3803a5fe..635280b2 100644
--- a/Source/JavaScript/react/interactions/index.ts
+++ b/Source/JavaScript/react/interactions/index.ts
@@ -4,3 +4,10 @@
export * from './InteractionScope';
export * from './useInteractions';
export * from './createBrowserDispatcher';
+export * from './ConfirmHost';
+export * from './ConfirmRequest';
+export * from './NotificationHost';
+export * from './SceneNotification';
+export * from './UnresolvedAction';
+export * from './UnresolvedActionHost';
+export * from './useInteractionHosts';
diff --git a/Source/JavaScript/react/interactions/useInteractionHosts.tsx b/Source/JavaScript/react/interactions/useInteractionHosts.tsx
new file mode 100644
index 00000000..ab910693
--- /dev/null
+++ b/Source/JavaScript/react/interactions/useInteractionHosts.tsx
@@ -0,0 +1,101 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { ReactNode, useCallback, useMemo, useRef, useState } from 'react';
+import { NotificationLevel } from '@cratis/scene.model';
+import { BrowserDispatcherHost } from './createBrowserDispatcher';
+import { ConfirmHost } from './ConfirmHost';
+import { ConfirmRequest } from './ConfirmRequest';
+import { NotificationHost } from './NotificationHost';
+import { SceneNotification } from './SceneNotification';
+import { UnresolvedAction } from './UnresolvedAction';
+import { UnresolvedActionHost } from './UnresolvedActionHost';
+
+/**
+ * What {@link useInteractionHosts} gives an application.
+ */
+export interface InteractionHosts {
+ /** The parts of a {@link BrowserDispatcherHost} these surfaces provide. Spread it into the host you build. */
+ readonly host: Pick;
+
+ /** The surfaces themselves. Render them once, anywhere inside the application. */
+ readonly surfaces: ReactNode;
+}
+
+/**
+ * How long a notification stays before it removes itself, in milliseconds.
+ */
+export interface InteractionHostOptions {
+ /** How long a notification stays up. `0` keeps it until it is dismissed. */
+ readonly dismissAfter?: number;
+}
+
+/**
+ * Provides the default surfaces for the actions that need somewhere to appear.
+ *
+ * `notify` and `confirm` are the two actions a document can take that have no backend and no route: they are
+ * purely things the user sees. Without a surface, a host either invents one or the action goes to the console,
+ * which is why these ship with Scene rather than being every application's first chore.
+ *
+ * The three are returned as one unit because they are wired the same way and forgetting to render one of them
+ * is exactly the failure they exist to prevent:
+ *
+ * ```tsx
+ * const { host, surfaces } = useInteractionHosts();
+ * const dispatcher = useMemo(() => createBrowserDispatcher({ ...host, executeCommand, navigate }), [host]);
+ *
+ * return {children}{surfaces};
+ * ```
+ *
+ * An application with a design system of its own passes its own `notify` after the spread and keeps the rest.
+ * @param options The {@link InteractionHostOptions}.
+ * @returns The {@link InteractionHosts}.
+ */
+export function useInteractionHosts(options: InteractionHostOptions = {}): InteractionHosts {
+ const { dismissAfter = 6000 } = options;
+ const [notifications, setNotifications] = useState([]);
+ const [unresolved, setUnresolved] = useState([]);
+ const [request, setRequest] = useState(undefined);
+ const nextId = useRef(0);
+
+ const dismiss = useCallback((id: number) => setNotifications(current => current.filter(_ => _.id !== id)), []);
+
+ const notify = useCallback((level: NotificationLevel, message: string) => {
+ const id = nextId.current++;
+ setNotifications(current => [...current, { id, level, message }]);
+
+ if (dismissAfter > 0) {
+ setTimeout(() => setNotifications(current => current.filter(_ => _.id !== id)), dismissAfter);
+ }
+ }, [dismissAfter]);
+
+ const confirm = useCallback((message: string) => new Promise(resolve => {
+ setRequest({
+ message,
+ answer(confirmed) {
+ // Cleared before resolving, so the sequence that continues cannot find a question on screen
+ // that has already been answered.
+ setRequest(undefined);
+ resolve(confirmed);
+ },
+ });
+ }), []);
+
+ const onUnsupported = useCallback((action: string) => {
+ // Reported once per distinct action. An unsupported action on a hover or an interval would otherwise
+ // grow the list until it covered the application it is trying to tell you about.
+ setUnresolved(current => current.some(_ => _.action === action) ? current : [...current, { id: nextId.current++, action }]);
+ }, []);
+
+ const host = useMemo(() => ({ notify, confirm, onUnsupported }), [notify, confirm, onUnsupported]);
+
+ const surfaces = useMemo(() => (
+ <>
+
+
+
+ >
+ ), [notifications, dismiss, request, unresolved]);
+
+ return { host, surfaces };
+}