@intentface/ui 0.0.0-stage → 0.7.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/LICENSE +21 -0
- package/README.md +147 -2
- package/dist/ask-user/ask-user.d.ts +104 -0
- package/dist/ask-user/ask-user.js +245 -0
- package/dist/ask-user/index.d.ts +3 -0
- package/dist/ask-user/index.js +3 -0
- package/dist/ask-user/index.parts.d.ts +1 -0
- package/dist/ask-user/index.parts.js +5 -0
- package/dist/attachments/attachments.d.ts +73 -0
- package/dist/attachments/attachments.js +121 -0
- package/dist/attachments/index.d.ts +3 -0
- package/dist/attachments/index.js +3 -0
- package/dist/attachments/index.parts.d.ts +1 -0
- package/dist/attachments/index.parts.js +5 -0
- package/dist/chip/chip.d.ts +45 -0
- package/dist/chip/chip.js +43 -0
- package/dist/chip/index.d.ts +2 -0
- package/dist/chip/index.js +2 -0
- package/dist/chip/index.parts.d.ts +1 -0
- package/dist/chip/index.parts.js +5 -0
- package/dist/chip-markdown.d.ts +25 -0
- package/dist/chip-markdown.js +54 -0
- package/dist/composer/actions.d.ts +35 -0
- package/dist/composer/actions.js +76 -0
- package/dist/composer/ask-user-machine.d.ts +52 -0
- package/dist/composer/ask-user-machine.js +164 -0
- package/dist/composer/attachments-machine.d.ts +34 -0
- package/dist/composer/attachments-machine.js +91 -0
- package/dist/composer/attachments.d.ts +16 -0
- package/dist/composer/attachments.js +32 -0
- package/dist/composer/command-list.d.ts +49 -0
- package/dist/composer/command-list.js +360 -0
- package/dist/composer/commands.d.ts +28 -0
- package/dist/composer/commands.js +56 -0
- package/dist/composer/container.d.ts +3 -0
- package/dist/composer/container.js +32 -0
- package/dist/composer/controller.d.ts +3 -0
- package/dist/composer/controller.js +6 -0
- package/dist/composer/editor-dom.d.ts +57 -0
- package/dist/composer/editor-dom.js +517 -0
- package/dist/composer/fuzzy.d.ts +4 -0
- package/dist/composer/fuzzy.js +49 -0
- package/dist/composer/index.d.ts +25 -0
- package/dist/composer/index.js +13 -0
- package/dist/composer/index.parts.d.ts +11 -0
- package/dist/composer/index.parts.js +15 -0
- package/dist/composer/internals.d.ts +30 -0
- package/dist/composer/internals.js +102 -0
- package/dist/composer/keyboard.d.ts +61 -0
- package/dist/composer/keyboard.js +75 -0
- package/dist/composer/panel.d.ts +28 -0
- package/dist/composer/panel.js +76 -0
- package/dist/composer/placeholder.d.ts +10 -0
- package/dist/composer/placeholder.js +8 -0
- package/dist/composer/popover.d.ts +12 -0
- package/dist/composer/popover.js +62 -0
- package/dist/composer/prefix-detection.d.ts +23 -0
- package/dist/composer/prefix-detection.js +67 -0
- package/dist/composer/root.d.ts +21 -0
- package/dist/composer/root.js +124 -0
- package/dist/composer/segments.d.ts +108 -0
- package/dist/composer/segments.js +366 -0
- package/dist/composer/store.d.ts +98 -0
- package/dist/composer/store.js +457 -0
- package/dist/composer/textarea.d.ts +39 -0
- package/dist/composer/textarea.js +64 -0
- package/dist/composer/trigger-tracker.d.ts +12 -0
- package/dist/composer/trigger-tracker.js +90 -0
- package/dist/composer/types.d.ts +127 -0
- package/dist/composer/types.js +4 -0
- package/dist/composer/use-composer-editor.d.ts +43 -0
- package/dist/composer/use-composer-editor.js +653 -0
- package/dist/generative/catalog.d.ts +177 -0
- package/dist/generative/catalog.js +20 -0
- package/dist/generative/index.d.ts +26 -0
- package/dist/generative/index.js +20 -0
- package/dist/generative/json-schema.d.ts +45 -0
- package/dist/generative/json-schema.js +216 -0
- package/dist/generative/markdown.d.ts +21 -0
- package/dist/generative/markdown.js +76 -0
- package/dist/generative/tree.d.ts +56 -0
- package/dist/generative/tree.js +365 -0
- package/dist/generative/use-tree.d.ts +12 -0
- package/dist/generative/use-tree.js +64 -0
- package/dist/internal/change-event-details.d.ts +12 -0
- package/dist/internal/change-event-details.js +16 -0
- package/dist/internal/collapsible.d.ts +22 -0
- package/dist/internal/collapsible.js +94 -0
- package/dist/internal/collection.d.ts +32 -0
- package/dist/internal/collection.js +72 -0
- package/dist/internal/iso-layout-effect.d.ts +2 -0
- package/dist/internal/iso-layout-effect.js +6 -0
- package/dist/internal/portal.d.ts +6 -0
- package/dist/internal/portal.js +18 -0
- package/dist/internal/primitive-props.d.ts +4 -0
- package/dist/internal/primitive-props.js +3 -0
- package/dist/internal/render/baseTypes.d.ts +18 -0
- package/dist/internal/render/baseTypes.js +4 -0
- package/dist/internal/render/empty.d.ts +3 -0
- package/dist/internal/render/empty.js +5 -0
- package/dist/internal/render/getReactElementRef.d.ts +5 -0
- package/dist/internal/render/getReactElementRef.js +15 -0
- package/dist/internal/render/getStateAttributesProps.d.ts +4 -0
- package/dist/internal/render/getStateAttributesProps.js +23 -0
- package/dist/internal/render/mergeObjects.d.ts +1 -0
- package/dist/internal/render/mergeObjects.js +14 -0
- package/dist/internal/render/mergeProps.d.ts +55 -0
- package/dist/internal/render/mergeProps.js +183 -0
- package/dist/internal/render/positioning.d.ts +33 -0
- package/dist/internal/render/positioning.js +112 -0
- package/dist/internal/render/reactVersion.d.ts +3 -0
- package/dist/internal/render/reactVersion.js +7 -0
- package/dist/internal/render/resolveClassName.d.ts +8 -0
- package/dist/internal/render/resolveClassName.js +12 -0
- package/dist/internal/render/resolveStyle.d.ts +8 -0
- package/dist/internal/render/resolveStyle.js +12 -0
- package/dist/internal/render/transition.d.ts +47 -0
- package/dist/internal/render/transition.js +259 -0
- package/dist/internal/render/types.d.ts +74 -0
- package/dist/internal/render/types.js +3 -0
- package/dist/internal/render/useMergedRefs.d.ts +21 -0
- package/dist/internal/render/useMergedRefs.js +102 -0
- package/dist/internal/render/useRefWithInit.d.ts +10 -0
- package/dist/internal/render/useRefWithInit.js +12 -0
- package/dist/internal/render/useRender.d.ts +71 -0
- package/dist/internal/render/useRender.js +12 -0
- package/dist/internal/render/useRenderElement.d.ts +62 -0
- package/dist/internal/render/useRenderElement.js +157 -0
- package/dist/internal/render/warn.d.ts +1 -0
- package/dist/internal/render/warn.js +15 -0
- package/dist/internal/safe-polygon.d.ts +21 -0
- package/dist/internal/safe-polygon.js +179 -0
- package/dist/internal/scroll-into-view-within.d.ts +1 -0
- package/dist/internal/scroll-into-view-within.js +26 -0
- package/dist/internal/standard-schema.d.ts +38 -0
- package/dist/internal/standard-schema.js +4 -0
- package/dist/internal/state-mappings.d.ts +8 -0
- package/dist/internal/state-mappings.js +17 -0
- package/dist/internal/use-controlled.d.ts +19 -0
- package/dist/internal/use-controlled.js +33 -0
- package/dist/message/index.d.ts +3 -0
- package/dist/message/index.js +3 -0
- package/dist/message/index.parts.d.ts +1 -0
- package/dist/message/index.parts.js +5 -0
- package/dist/message/message.d.ts +58 -0
- package/dist/message/message.js +93 -0
- package/dist/message-utils.d.ts +55 -0
- package/dist/message-utils.js +79 -0
- package/dist/nav/index.d.ts +4 -0
- package/dist/nav/index.js +5 -0
- package/dist/nav/index.parts.d.ts +3 -0
- package/dist/nav/index.parts.js +7 -0
- package/dist/nav/nav.d.ts +152 -0
- package/dist/nav/nav.js +660 -0
- package/dist/nav/store.d.ts +50 -0
- package/dist/nav/store.js +95 -0
- package/dist/reasoning/index.d.ts +3 -0
- package/dist/reasoning/index.js +3 -0
- package/dist/reasoning/index.parts.d.ts +1 -0
- package/dist/reasoning/index.parts.js +5 -0
- package/dist/reasoning/reasoning.d.ts +38 -0
- package/dist/reasoning/reasoning.js +67 -0
- package/dist/shell/index.d.ts +5 -0
- package/dist/shell/index.js +5 -0
- package/dist/shell/index.parts.d.ts +3 -0
- package/dist/shell/index.parts.js +7 -0
- package/dist/shell/shell.d.ts +108 -0
- package/dist/shell/shell.js +390 -0
- package/dist/shell/store.d.ts +112 -0
- package/dist/shell/store.js +134 -0
- package/dist/steps/index.d.ts +2 -0
- package/dist/steps/index.js +2 -0
- package/dist/steps/index.parts.d.ts +1 -0
- package/dist/steps/index.parts.js +5 -0
- package/dist/steps/steps.d.ts +90 -0
- package/dist/steps/steps.js +116 -0
- package/dist/tabs/anchored.d.ts +59 -0
- package/dist/tabs/anchored.js +109 -0
- package/dist/tabs/hover.d.ts +33 -0
- package/dist/tabs/hover.js +169 -0
- package/dist/tabs/index.d.ts +5 -0
- package/dist/tabs/index.js +4 -0
- package/dist/tabs/index.parts.d.ts +4 -0
- package/dist/tabs/index.parts.js +8 -0
- package/dist/tabs/store.d.ts +131 -0
- package/dist/tabs/store.js +248 -0
- package/dist/tabs/tabs.d.ts +217 -0
- package/dist/tabs/tabs.js +458 -0
- package/dist/thread/geometry.d.ts +23 -0
- package/dist/thread/geometry.js +48 -0
- package/dist/thread/index.d.ts +4 -0
- package/dist/thread/index.js +3 -0
- package/dist/thread/index.parts.d.ts +1 -0
- package/dist/thread/index.parts.js +5 -0
- package/dist/thread/stores.d.ts +21 -0
- package/dist/thread/stores.js +63 -0
- package/dist/thread/thread.d.ts +57 -0
- package/dist/thread/thread.js +663 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.js +17 -0
- package/package.json +125 -4
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// Headless attachments: generic structural parts, accept matching, and the
|
|
3
|
+
// platform-default ingestion (a blob URL that doubles as the id — the
|
|
4
|
+
// browser-native way to reference a picked File; override via
|
|
5
|
+
// Composer.Attachments' create/destroy for upload-to-storage etc.). Policy
|
|
6
|
+
// (accepted types, counts, sizes), media taxonomy, send serialization, and copy
|
|
7
|
+
// are the consumer's. Card visuals belong to the styled layer.
|
|
8
|
+
import { useEffect, useState } from "react";
|
|
9
|
+
import { createPortal } from "react-dom";
|
|
10
|
+
import { useRenderElement } from "../internal/render/useRenderElement.js";
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Default ingestion — platform-native, no policy: a picked File becomes an
|
|
13
|
+
// item referencing it by blob URL. Overridable per composer (create/destroy).
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Every createObjectURL call mints a fresh `blob:<origin>/<uuid>`, so the URL
|
|
16
|
+
// is also a unique id — even for the same File added twice.
|
|
17
|
+
export const toAttachmentItem = (file) => {
|
|
18
|
+
const url = URL.createObjectURL(file);
|
|
19
|
+
return { id: url, url, filename: file.name, mediaType: file.type, fileSize: file.size };
|
|
20
|
+
};
|
|
21
|
+
export const revokeAttachmentUrl = (item) => {
|
|
22
|
+
if (item.url?.startsWith("blob:")) {
|
|
23
|
+
URL.revokeObjectURL(item.url);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
export const revokeAllAttachmentUrls = (items) => {
|
|
27
|
+
for (const item of items) {
|
|
28
|
+
revokeAttachmentUrl(item);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Accept matching — standard HTML `accept` semantics.
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
export const matchesAccept = (file, accept) => {
|
|
35
|
+
if (!accept || accept.trim() === "") {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
const patterns = accept
|
|
39
|
+
.split(",")
|
|
40
|
+
.map((pattern) => pattern.trim())
|
|
41
|
+
.filter(Boolean);
|
|
42
|
+
return patterns.some((pattern) => {
|
|
43
|
+
if (pattern.endsWith("/*")) {
|
|
44
|
+
const prefix = pattern.slice(0, -1);
|
|
45
|
+
return file.type.startsWith(prefix);
|
|
46
|
+
}
|
|
47
|
+
return file.type === pattern;
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* The tray container. Mount it only when there are items to show; it renders
|
|
52
|
+
* no layout of its own.
|
|
53
|
+
* Renders a `<div>` element.
|
|
54
|
+
*/
|
|
55
|
+
export const AttachmentsRoot = ({ className, render, style, ...elementProps }) => useRenderElement("div", { className, render, style }, { props: [{ "data-attachments": "" }, elementProps] });
|
|
56
|
+
// Generic structural slot for one attachment. It carries no media taxonomy —
|
|
57
|
+
// categorization (image/pdf/file, icons, thumbnails) belongs to the styled
|
|
58
|
+
// layer, which reads the item's raw mediaType however it wants.
|
|
59
|
+
/**
|
|
60
|
+
* One attachment, as a structural slot with no media taxonomy of its own.
|
|
61
|
+
* Read the item's `mediaType` and decide what an image or a PDF looks like.
|
|
62
|
+
* Renders a `<div>` element.
|
|
63
|
+
*/
|
|
64
|
+
export const AttachmentsItem = ({ className, render, style, ...elementProps }) => useRenderElement("div", { className, render, style }, { props: [{ "data-attachments-item": "" }, elementProps] });
|
|
65
|
+
/**
|
|
66
|
+
* The removal affordance. Named "Remove attachment" by default, or
|
|
67
|
+
* "Remove {filename}" when `filename` is set, so a row of them does not
|
|
68
|
+
* announce identically.
|
|
69
|
+
* Renders a `<button>` element.
|
|
70
|
+
*/
|
|
71
|
+
export const AttachmentsRemove = ({ onRemove, filename, className, render, style, ...elementProps }) => useRenderElement("button", { className, render, style }, {
|
|
72
|
+
props: [
|
|
73
|
+
{
|
|
74
|
+
"aria-label": filename ? `Remove ${filename}` : "Remove attachment",
|
|
75
|
+
"data-attachments-remove": "",
|
|
76
|
+
onClick: onRemove,
|
|
77
|
+
},
|
|
78
|
+
elementProps,
|
|
79
|
+
],
|
|
80
|
+
});
|
|
81
|
+
/**
|
|
82
|
+
* The drop overlay. `portalSelector` only moves where it renders (e.g. over an
|
|
83
|
+
* app shell region); where files can be dropped is `Composer.Attachments`'
|
|
84
|
+
* `globalDrop`, not this part.
|
|
85
|
+
* Renders a `<div>` element.
|
|
86
|
+
*/
|
|
87
|
+
export const AttachmentsDropzone = ({ visible = false, keepMounted = false, portalSelector, children, className, render, style, ...elementProps }) => {
|
|
88
|
+
const [portalTarget, setPortalTarget] = useState(null);
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!portalSelector)
|
|
91
|
+
return;
|
|
92
|
+
setPortalTarget(document.querySelector(portalSelector));
|
|
93
|
+
}, [portalSelector]);
|
|
94
|
+
const content = useRenderElement("div", { className, render, style }, {
|
|
95
|
+
enabled: visible || keepMounted,
|
|
96
|
+
state: { visible },
|
|
97
|
+
props: [{ "data-attachments-dropzone": "", children }, elementProps],
|
|
98
|
+
});
|
|
99
|
+
if (!content)
|
|
100
|
+
return null;
|
|
101
|
+
if (portalTarget)
|
|
102
|
+
return createPortal(content, portalTarget);
|
|
103
|
+
return content;
|
|
104
|
+
};
|
|
105
|
+
// role="alert" (assertive): a rejected pick/drop is a user-action failure that
|
|
106
|
+
// should announce immediately. The copy inside stays the consumer's.
|
|
107
|
+
/**
|
|
108
|
+
* The validation slot, as a live region: whatever appears inside announces
|
|
109
|
+
* immediately. Validation emits a code, never copy, so the message is yours.
|
|
110
|
+
* Renders a `<span>` element with `role="alert"`.
|
|
111
|
+
*/
|
|
112
|
+
export const AttachmentsError = ({ className, render, style, ...elementProps }) => useRenderElement("span", { className, render, style }, { props: [{ role: "alert", "data-attachments-error": "" }, elementProps] });
|
|
113
|
+
/**
|
|
114
|
+
* The file-picker button, named "Add attachment" by default. The package owns
|
|
115
|
+
* no file input; wire this to your own.
|
|
116
|
+
* Renders a `<button>` element.
|
|
117
|
+
*/
|
|
118
|
+
export const AttachmentsTrigger = ({ className, render, style, ...elementProps }) => useRenderElement("button", { className, render, style }, {
|
|
119
|
+
// Default overridable name — the trigger is typically icon-only.
|
|
120
|
+
props: [{ "aria-label": "Add attachment", "data-attachments-trigger": "" }, elementProps],
|
|
121
|
+
});
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export type { AttachmentErrorCode, AttachmentItem, AttachmentsDropzoneProps, AttachmentsDropzoneState, AttachmentsErrorProps, AttachmentsItemProps, AttachmentsRemoveProps, AttachmentsRootProps, AttachmentsTriggerProps, } from "./attachments.js";
|
|
2
|
+
export { matchesAccept, revokeAllAttachmentUrls, revokeAttachmentUrl, toAttachmentItem, } from "./attachments.js";
|
|
3
|
+
export * as Attachments from "./index.parts.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { AttachmentsDropzone as Dropzone, AttachmentsError as Error, AttachmentsItem as Item, AttachmentsRemove as Remove, AttachmentsRoot as Root, AttachmentsTrigger as Trigger, } from "./attachments.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Part names for the `Attachments` namespace. Deliberately carries no
|
|
2
|
+
// "use client" directive: this module and index.ts must stay server-resolvable
|
|
3
|
+
// so a React Server Component can reach Attachments.Root and its siblings
|
|
4
|
+
// through them. The directive lives one level down, on attachments.tsx.
|
|
5
|
+
export { AttachmentsDropzone as Dropzone, AttachmentsError as Error, AttachmentsItem as Item, AttachmentsRemove as Remove, AttachmentsRoot as Root, AttachmentsTrigger as Trigger, } from "./attachments.js";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type ReactElement, type ReactNode } from "react";
|
|
2
|
+
import type { PrimitiveProps } from "../internal/primitive-props.js";
|
|
3
|
+
type ChipPreviewProps = {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Marker child whose content becomes the preview body. Renders nothing inline:
|
|
8
|
+
* `Chip.Root` inspects its children and routes this through
|
|
9
|
+
* `renderWithPreview`, so the package never owns a popup.
|
|
10
|
+
*/
|
|
11
|
+
export declare const ChipPreview: (_props: ChipPreviewProps) => ReactNode;
|
|
12
|
+
export type ChipState = {
|
|
13
|
+
/** Opaque styling hook, surfaced as data-variant. */
|
|
14
|
+
variant: string | undefined;
|
|
15
|
+
};
|
|
16
|
+
export type ChipRootProps = PrimitiveProps<"span", ChipState> & {
|
|
17
|
+
variant?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Called when a Chip.Preview child is present. Receives the rendered badge
|
|
20
|
+
* element and the preview content; compose your popup of choice around
|
|
21
|
+
* them. Without it, the preview content is ignored.
|
|
22
|
+
*/
|
|
23
|
+
renderWithPreview?: (badge: ReactElement, preview: ReactNode) => ReactNode;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* The inline token surface. Flows with the surrounding text rather than
|
|
27
|
+
* breaking the line box, and lifts a `Chip.Preview` child into whatever
|
|
28
|
+
* surface `renderWithPreview` builds.
|
|
29
|
+
* Renders a `<span>` element.
|
|
30
|
+
*/
|
|
31
|
+
export declare const ChipRoot: ({ variant, renderWithPreview, className, render, style, children, ...elementProps }: ChipRootProps) => string | number | bigint | boolean | ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | null | undefined;
|
|
32
|
+
export type ChipIconProps = PrimitiveProps<"span">;
|
|
33
|
+
/**
|
|
34
|
+
* Leading inline icon, baseline-aligned to the label and `aria-hidden`, so
|
|
35
|
+
* decoration stays out of the chip's accessible name.
|
|
36
|
+
* Renders a `<span>` element.
|
|
37
|
+
*/
|
|
38
|
+
export declare const ChipIcon: ({ className, render, style, ...elementProps }: ChipIconProps) => ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
|
|
39
|
+
export type ChipLabelProps = PrimitiveProps<"span">;
|
|
40
|
+
/**
|
|
41
|
+
* The chip's text, which reads as part of the sentence around it.
|
|
42
|
+
* Renders a `<span>` element.
|
|
43
|
+
*/
|
|
44
|
+
export declare const ChipLabel: ({ className, render, style, ...elementProps }: ChipLabelProps) => ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// Headless inline chip. Renders an unstyled <span> flow-surface with
|
|
3
|
+
// data-variant for styling hooks. Popups are not owned by this layer: a
|
|
4
|
+
// Chip.Preview child is captured and handed to `renderWithPreview` so the
|
|
5
|
+
// styled layer can compose its own hover-card/popover around the badge.
|
|
6
|
+
// Every part supports the Base UI render prop.
|
|
7
|
+
import { Children, isValidElement } from "react";
|
|
8
|
+
import { useRenderElement } from "../internal/render/useRenderElement.js";
|
|
9
|
+
/**
|
|
10
|
+
* Marker child whose content becomes the preview body. Renders nothing inline:
|
|
11
|
+
* `Chip.Root` inspects its children and routes this through
|
|
12
|
+
* `renderWithPreview`, so the package never owns a popup.
|
|
13
|
+
*/
|
|
14
|
+
export const ChipPreview = (_props) => null;
|
|
15
|
+
/**
|
|
16
|
+
* The inline token surface. Flows with the surrounding text rather than
|
|
17
|
+
* breaking the line box, and lifts a `Chip.Preview` child into whatever
|
|
18
|
+
* surface `renderWithPreview` builds.
|
|
19
|
+
* Renders a `<span>` element.
|
|
20
|
+
*/
|
|
21
|
+
export const ChipRoot = ({ variant, renderWithPreview, className, render, style, children, ...elementProps }) => {
|
|
22
|
+
const childArray = Children.toArray(children);
|
|
23
|
+
const previewChild = childArray.find((child) => isValidElement(child) && child.type === ChipPreview);
|
|
24
|
+
const inlineChildren = childArray.filter((child) => !isValidElement(child) || child.type !== ChipPreview);
|
|
25
|
+
const badge = useRenderElement("span", { className, render, style }, {
|
|
26
|
+
state: { variant },
|
|
27
|
+
props: [{ "data-chip": "", children: inlineChildren }, elementProps],
|
|
28
|
+
});
|
|
29
|
+
if (!previewChild || !renderWithPreview)
|
|
30
|
+
return badge;
|
|
31
|
+
return renderWithPreview(badge, previewChild.props.children);
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Leading inline icon, baseline-aligned to the label and `aria-hidden`, so
|
|
35
|
+
* decoration stays out of the chip's accessible name.
|
|
36
|
+
* Renders a `<span>` element.
|
|
37
|
+
*/
|
|
38
|
+
export const ChipIcon = ({ className, render, style, ...elementProps }) => useRenderElement("span", { className, render, style }, { props: [{ "aria-hidden": true, "data-chip-icon": "" }, elementProps] });
|
|
39
|
+
/**
|
|
40
|
+
* The chip's text, which reads as part of the sentence around it.
|
|
41
|
+
* Renders a `<span>` element.
|
|
42
|
+
*/
|
|
43
|
+
export const ChipLabel = ({ className, render, style, ...elementProps }) => useRenderElement("span", { className, render, style }, { props: [{ "data-chip-label": "" }, elementProps] });
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ChipIcon as Icon, ChipLabel as Label, ChipPreview as Preview, ChipRoot as Root, } from "./chip.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Part names for the `Chip` namespace. Deliberately carries no "use client":
|
|
2
|
+
// this module and index.ts must stay server-resolvable so a React Server
|
|
3
|
+
// Component can reach Chip.Root / Chip.Label through them. The directive lives
|
|
4
|
+
// one level down, on chip.tsx.
|
|
5
|
+
export { ChipIcon as Icon, ChipLabel as Label, ChipPreview as Preview, ChipRoot as Root, } from "./chip.js";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opaque wire string. The styled layer owns the concrete union and narrows
|
|
3
|
+
* at its own boundary.
|
|
4
|
+
*/
|
|
5
|
+
export type ChipIconKey = string;
|
|
6
|
+
export declare const CHIP_REF_PATTERN: RegExp;
|
|
7
|
+
export type ChipData = {
|
|
8
|
+
prefix: string;
|
|
9
|
+
value: string;
|
|
10
|
+
label: string;
|
|
11
|
+
icon?: ChipIconKey;
|
|
12
|
+
};
|
|
13
|
+
export type ChipSegment = {
|
|
14
|
+
type: "text";
|
|
15
|
+
text: string;
|
|
16
|
+
} | {
|
|
17
|
+
type: "chip";
|
|
18
|
+
label: string;
|
|
19
|
+
prefix: string;
|
|
20
|
+
value: string;
|
|
21
|
+
icon?: ChipIconKey;
|
|
22
|
+
};
|
|
23
|
+
export declare const escapeMarkdownLink: (input: string) => string;
|
|
24
|
+
export declare const encodeChipMarkdown: (chip: ChipData) => string;
|
|
25
|
+
export declare const parseChipSegments: (text: string) => ChipSegment[];
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Wire format for inline chip references inside a message text part.
|
|
2
|
+
// Shape: [Label](chip:prefix:value?icon=…). The icon rides along in the
|
|
3
|
+
// token, so a rendered message reconstructs the chip from its own text
|
|
4
|
+
// alone — no sidecar metadata array. Presentation that's derivable at render
|
|
5
|
+
// time (e.g. a per-prefix variant) is the renderer's job, not wire data.
|
|
6
|
+
// Every class excludes "[" so no group can consume past the next one — without
|
|
7
|
+
// that this backtracks quadratically, and message text is untrusted.
|
|
8
|
+
export const CHIP_REF_PATTERN = /\[([^[\]]+)\]\(chip:([^[:)]+):([^[)?]+)(?:\?([^[)]*))?\)/g;
|
|
9
|
+
export const escapeMarkdownLink = (input) => input.replace(/[[\]()\\]/g, (match) => `\\${match}`);
|
|
10
|
+
// Tolerate legacy/un-encoded values — a stray "%" would otherwise throw and
|
|
11
|
+
// take the whole message render down with it.
|
|
12
|
+
const safeDecode = (value) => {
|
|
13
|
+
try {
|
|
14
|
+
return decodeURIComponent(value);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
export const encodeChipMarkdown = (chip) => {
|
|
21
|
+
const params = new URLSearchParams();
|
|
22
|
+
if (chip.icon)
|
|
23
|
+
params.set("icon", chip.icon);
|
|
24
|
+
const query = params.toString();
|
|
25
|
+
const value = encodeURIComponent(chip.value);
|
|
26
|
+
return `[${escapeMarkdownLink(chip.label)}](chip:${chip.prefix}:${value}${query ? `?${query}` : ""})`;
|
|
27
|
+
};
|
|
28
|
+
export const parseChipSegments = (text) => {
|
|
29
|
+
const segments = [];
|
|
30
|
+
let lastIndex = 0;
|
|
31
|
+
for (const match of text.matchAll(CHIP_REF_PATTERN)) {
|
|
32
|
+
const start = match.index;
|
|
33
|
+
if (start > lastIndex) {
|
|
34
|
+
segments.push({ type: "text", text: text.slice(lastIndex, start) });
|
|
35
|
+
}
|
|
36
|
+
const [token, label = "", prefix = "", rawValue = "", rawQuery] = match;
|
|
37
|
+
const params = new URLSearchParams(rawQuery ?? "");
|
|
38
|
+
const icon = params.get("icon");
|
|
39
|
+
segments.push({
|
|
40
|
+
type: "chip",
|
|
41
|
+
label,
|
|
42
|
+
prefix,
|
|
43
|
+
value: safeDecode(rawValue),
|
|
44
|
+
...(icon ? { icon } : {}),
|
|
45
|
+
});
|
|
46
|
+
lastIndex = start + token.length;
|
|
47
|
+
}
|
|
48
|
+
if (lastIndex < text.length) {
|
|
49
|
+
segments.push({ type: "text", text: text.slice(lastIndex) });
|
|
50
|
+
}
|
|
51
|
+
return segments;
|
|
52
|
+
};
|
|
53
|
+
// Building editor nodes from these segments lives with the document model in
|
|
54
|
+
// composer/segments.ts — this module is only the neutral wire format.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { PrimitiveProps } from "../internal/primitive-props.js";
|
|
2
|
+
export type ComposerContextWindowState = {
|
|
3
|
+
/** Open (data-open) while the strip has content and no panel is active. */
|
|
4
|
+
open: boolean;
|
|
5
|
+
};
|
|
6
|
+
export type ComposerContextWindowProps = PrimitiveProps<"div", ComposerContextWindowState>;
|
|
7
|
+
export declare const ComposerContextWindow: ({ children, className, render, style, ...elementProps }: ComposerContextWindowProps) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
|
|
8
|
+
export type ComposerActionsProps = PrimitiveProps<"div">;
|
|
9
|
+
export declare const ComposerActions: ({ className, render, style, ...elementProps }: ComposerActionsProps) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
|
|
10
|
+
export type UseComposerSubmitOptions = {
|
|
11
|
+
isGenerating?: boolean;
|
|
12
|
+
onStop?: () => void;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
};
|
|
15
|
+
export type ComposerSubmitState = {
|
|
16
|
+
/** "button" while generating (click stops), "submit" otherwise. */
|
|
17
|
+
type: "button" | "submit";
|
|
18
|
+
disabled: boolean;
|
|
19
|
+
isGenerating: boolean;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Send↔stop behavior: auto-disables while empty or submitting, flips the
|
|
23
|
+
* button type while generating, and aborts on Escape inside this composer
|
|
24
|
+
* (unless something closer — the command list — already handled it).
|
|
25
|
+
*/
|
|
26
|
+
export declare const useComposerSubmit: ({ isGenerating, onStop, disabled, }: UseComposerSubmitOptions) => ComposerSubmitState;
|
|
27
|
+
export type ComposerSubmitButtonState = {
|
|
28
|
+
/** Present as data-generating while the stop affordance is active. */
|
|
29
|
+
generating: boolean;
|
|
30
|
+
};
|
|
31
|
+
export type ComposerSubmitProps = PrimitiveProps<"button", ComposerSubmitButtonState> & {
|
|
32
|
+
isGenerating?: boolean;
|
|
33
|
+
onStop?: () => void;
|
|
34
|
+
};
|
|
35
|
+
export declare const ComposerSubmit: ({ disabled, isGenerating, onStop, onClick, className, render, style, ...elementProps }: ComposerSubmitProps) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// Composer.ContextWindow / Actions / Submit. ContextWindow is content-driven
|
|
3
|
+
// like the panels — open (data-open / data-closed) while it has content and no
|
|
4
|
+
// panel is active, yielding to an active panel. Submit owns the send↔stop
|
|
5
|
+
// behavior via useComposerSubmit and renders a plain button — the styled layer
|
|
6
|
+
// applies the same hook to its own button component.
|
|
7
|
+
import { Children, useEffect } from "react";
|
|
8
|
+
import { useRenderElement } from "../internal/render/useRenderElement.js";
|
|
9
|
+
import { openStateMapping } from "../internal/state-mappings.js";
|
|
10
|
+
import { useAsRef } from "./internals.js";
|
|
11
|
+
import { useComposer, useComposerContextStore } from "./store.js";
|
|
12
|
+
export const ComposerContextWindow = ({ children, className, render, style, ...elementProps }) => {
|
|
13
|
+
// Yield while any native panel is active — the strip slides back out once they
|
|
14
|
+
// close. (Union of the native slices; no central registry.)
|
|
15
|
+
const anyPanelActive = useComposer((composer) => composer.commands.active || composer.askUser.active);
|
|
16
|
+
const hasContent = Children.toArray(children).length > 0;
|
|
17
|
+
const open = hasContent && !anyPanelActive;
|
|
18
|
+
return useRenderElement("div", { className, render, style }, {
|
|
19
|
+
state: { open },
|
|
20
|
+
stateAttributesMapping: openStateMapping,
|
|
21
|
+
props: [{ "data-composer-context-window": "", children }, elementProps],
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
export const ComposerActions = ({ className, render, style, ...elementProps }) => useRenderElement("div", { className, render, style }, { props: [{ "data-composer-actions": "" }, elementProps] });
|
|
25
|
+
/**
|
|
26
|
+
* Send↔stop behavior: auto-disables while empty or submitting, flips the
|
|
27
|
+
* button type while generating, and aborts on Escape inside this composer
|
|
28
|
+
* (unless something closer — the command list — already handled it).
|
|
29
|
+
*/
|
|
30
|
+
export const useComposerSubmit = ({ isGenerating = false, onStop, disabled, }) => {
|
|
31
|
+
const hasContent = useComposer((composer) => composer.textarea.hasContent);
|
|
32
|
+
const isSubmitting = useComposer((composer) => composer.isSubmitting);
|
|
33
|
+
const attachments = useComposer((composer) => composer.attachments);
|
|
34
|
+
// While generating, hand the stop to Composer.Root, which calls it on Escape.
|
|
35
|
+
const store = useComposerContextStore();
|
|
36
|
+
const onStopRef = useAsRef(onStop);
|
|
37
|
+
// Without an onStop there is nothing to stop, so Escape stays free for surfaces around it.
|
|
38
|
+
const hasOnStop = onStop !== undefined;
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!isGenerating || !hasOnStop)
|
|
41
|
+
return;
|
|
42
|
+
const stopGenerating = () => onStopRef.current?.();
|
|
43
|
+
store.stopGeneratingRef.current = stopGenerating;
|
|
44
|
+
return () => {
|
|
45
|
+
if (store.stopGeneratingRef.current !== stopGenerating)
|
|
46
|
+
return;
|
|
47
|
+
store.stopGeneratingRef.current = null;
|
|
48
|
+
};
|
|
49
|
+
}, [isGenerating, hasOnStop, store]);
|
|
50
|
+
const autoDisabled = disabled ?? ((!hasContent && attachments.items.length === 0) || isSubmitting);
|
|
51
|
+
return {
|
|
52
|
+
type: isGenerating ? "button" : "submit",
|
|
53
|
+
disabled: isGenerating ? false : autoDisabled,
|
|
54
|
+
isGenerating,
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
export const ComposerSubmit = ({ disabled, isGenerating = false, onStop, onClick, className, render, style, ...elementProps }) => {
|
|
58
|
+
const submit = useComposerSubmit({ isGenerating, onStop, disabled });
|
|
59
|
+
return useRenderElement("button", { className, render, style }, {
|
|
60
|
+
state: { generating: isGenerating },
|
|
61
|
+
props: [
|
|
62
|
+
{
|
|
63
|
+
type: submit.type,
|
|
64
|
+
"data-composer-submit": "",
|
|
65
|
+
// Default overridable names for the (typically icon-only) morphing
|
|
66
|
+
// control — both states announce, not just the stop affordance.
|
|
67
|
+
"aria-label": isGenerating ? "Stop generating" : "Send message",
|
|
68
|
+
disabled: submit.disabled,
|
|
69
|
+
// Replaces (not chains) the consumer's onClick while generating —
|
|
70
|
+
// a stop click must never fall through to submit handlers.
|
|
71
|
+
onClick: isGenerating ? () => onStop?.() : onClick,
|
|
72
|
+
},
|
|
73
|
+
elementProps,
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { AskUserQuestion, ComposerAnswerEntry } from "./types.js";
|
|
2
|
+
export type AnswerEntry = {
|
|
3
|
+
selected: Set<string>;
|
|
4
|
+
freeText: string;
|
|
5
|
+
};
|
|
6
|
+
export type AskUserState = {
|
|
7
|
+
step: number;
|
|
8
|
+
answers: Map<number, AnswerEntry>;
|
|
9
|
+
};
|
|
10
|
+
export declare const INITIAL_ASK_USER_STATE: AskUserState;
|
|
11
|
+
export type AskUserEffect = {
|
|
12
|
+
type: "clear-input";
|
|
13
|
+
} | {
|
|
14
|
+
type: "set-input-text";
|
|
15
|
+
text: string;
|
|
16
|
+
} | {
|
|
17
|
+
type: "focus-input";
|
|
18
|
+
} | {
|
|
19
|
+
type: "focus-options";
|
|
20
|
+
} | {
|
|
21
|
+
type: "reset-highlight";
|
|
22
|
+
} | {
|
|
23
|
+
type: "submit-answers";
|
|
24
|
+
answers: ComposerAnswerEntry[];
|
|
25
|
+
};
|
|
26
|
+
export type AskUserAction = {
|
|
27
|
+
type: "toggle-option";
|
|
28
|
+
label: string;
|
|
29
|
+
} | {
|
|
30
|
+
type: "select-option";
|
|
31
|
+
label: string;
|
|
32
|
+
} | {
|
|
33
|
+
type: "clear-selections";
|
|
34
|
+
} | {
|
|
35
|
+
type: "continue-step";
|
|
36
|
+
freeText: string;
|
|
37
|
+
} | {
|
|
38
|
+
type: "dismiss-step";
|
|
39
|
+
} | {
|
|
40
|
+
type: "step-back";
|
|
41
|
+
currentText: string;
|
|
42
|
+
} | {
|
|
43
|
+
type: "step-forward";
|
|
44
|
+
currentText: string;
|
|
45
|
+
};
|
|
46
|
+
export type AskUserTransition = {
|
|
47
|
+
next: AskUserState;
|
|
48
|
+
effects: AskUserEffect[];
|
|
49
|
+
};
|
|
50
|
+
export declare const compileAnswers: (state: AskUserState, questions: AskUserQuestion[]) => ComposerAnswerEntry[];
|
|
51
|
+
export declare const isLastStep: (state: AskUserState, questions: AskUserQuestion[]) => boolean;
|
|
52
|
+
export declare const transitionAskUser: (state: AskUserState, questions: AskUserQuestion[], action: AskUserAction) => AskUserTransition;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Ask-user — pure state machine for the questionnaire flow. State is a current
|
|
2
|
+
// step plus a map of per-step answers; every transition is an immutable map
|
|
3
|
+
// operation, so the store only owns the state cell and the effect execution.
|
|
4
|
+
export const INITIAL_ASK_USER_STATE = {
|
|
5
|
+
step: 0,
|
|
6
|
+
answers: new Map(),
|
|
7
|
+
};
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Answer helpers — immutable operations on the per-step answer map, plus the
|
|
10
|
+
// projection/query the store reads.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
const emptyEntry = () => ({
|
|
13
|
+
selected: new Set(),
|
|
14
|
+
freeText: "",
|
|
15
|
+
});
|
|
16
|
+
const cloneAnswers = (source) => new Map(source);
|
|
17
|
+
// Record `text` as the step's free-text answer. Single-select treats text and
|
|
18
|
+
// a chosen option as mutually exclusive, so any selection is dropped;
|
|
19
|
+
// multi-select keeps existing selections alongside the text. Empty text is a
|
|
20
|
+
// no-op (returns an untouched clone).
|
|
21
|
+
const writeFreeText = (answers, step, text, multiSelect) => {
|
|
22
|
+
const next = cloneAnswers(answers);
|
|
23
|
+
if (text.length === 0)
|
|
24
|
+
return next;
|
|
25
|
+
const previous = next.get(step) ?? emptyEntry();
|
|
26
|
+
next.set(step, {
|
|
27
|
+
selected: multiSelect ? previous.selected : new Set(),
|
|
28
|
+
freeText: text,
|
|
29
|
+
});
|
|
30
|
+
return next;
|
|
31
|
+
};
|
|
32
|
+
// Remove the step's entry entirely (skip / dismiss).
|
|
33
|
+
const skipStep = (answers, step) => {
|
|
34
|
+
const next = cloneAnswers(answers);
|
|
35
|
+
next.delete(step);
|
|
36
|
+
return next;
|
|
37
|
+
};
|
|
38
|
+
// Toggle an option on the step's entry. Multi-select toggles membership and
|
|
39
|
+
// keeps free text; single-select replaces both (option and text are mutually
|
|
40
|
+
// exclusive).
|
|
41
|
+
const toggleAnswer = (answers, step, label, multiSelect) => {
|
|
42
|
+
const next = cloneAnswers(answers);
|
|
43
|
+
const previous = next.get(step) ?? emptyEntry();
|
|
44
|
+
const selected = new Set(previous.selected);
|
|
45
|
+
if (multiSelect) {
|
|
46
|
+
if (selected.has(label))
|
|
47
|
+
selected.delete(label);
|
|
48
|
+
else
|
|
49
|
+
selected.add(label);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
selected.clear();
|
|
53
|
+
selected.add(label);
|
|
54
|
+
}
|
|
55
|
+
next.set(step, { selected, freeText: multiSelect ? previous.freeText : "" });
|
|
56
|
+
return next;
|
|
57
|
+
};
|
|
58
|
+
// Project the collected answers onto the public ComposerAnswerEntry union, one
|
|
59
|
+
// entry per question in order. The chosen branch encodes the invariant:
|
|
60
|
+
// single-select carries `option` *or* `text`; multi-select carries both;
|
|
61
|
+
// an unanswered question is bare `{ question }`.
|
|
62
|
+
export const compileAnswers = (state, questions) => questions.map(({ question, multiSelect }, index) => {
|
|
63
|
+
const entry = state.answers.get(index);
|
|
64
|
+
if (!entry)
|
|
65
|
+
return { question };
|
|
66
|
+
const selected = [...entry.selected];
|
|
67
|
+
const text = entry.freeText.trim();
|
|
68
|
+
if (multiSelect) {
|
|
69
|
+
if (selected.length === 0 && text === "")
|
|
70
|
+
return { question };
|
|
71
|
+
return { question, options: selected, text };
|
|
72
|
+
}
|
|
73
|
+
const firstSelected = selected[0];
|
|
74
|
+
if (firstSelected !== undefined)
|
|
75
|
+
return { question, option: firstSelected };
|
|
76
|
+
if (text !== "")
|
|
77
|
+
return { question, text };
|
|
78
|
+
return { question };
|
|
79
|
+
});
|
|
80
|
+
export const isLastStep = (state, questions) => state.step >= questions.length - 1;
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Transitions — the decide half of the flow: map an action onto the next state
|
|
83
|
+
// plus the effects to run. transitionAskUser is the single entry point.
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Commit `nextAnswers`, advance one step, and reset the input — then either
|
|
86
|
+
// arm the next question (blurred, highlight reset) or compile and submit on
|
|
87
|
+
// the last step (input focused for the follow-up message).
|
|
88
|
+
const advanceStep = (state, questions, nextAnswers) => {
|
|
89
|
+
const next = { step: state.step + 1, answers: nextAnswers };
|
|
90
|
+
if (state.step < questions.length - 1) {
|
|
91
|
+
return {
|
|
92
|
+
next,
|
|
93
|
+
effects: [{ type: "clear-input" }, { type: "reset-highlight" }, { type: "focus-options" }],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
next,
|
|
98
|
+
effects: [
|
|
99
|
+
{ type: "clear-input" },
|
|
100
|
+
{ type: "submit-answers", answers: compileAnswers(next, questions) },
|
|
101
|
+
{ type: "focus-input" },
|
|
102
|
+
],
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
// Navigate to `targetStep`, preserving any in-progress free text on the step
|
|
106
|
+
// being left and restoring the target step's saved text into the input.
|
|
107
|
+
const transitionToStep = (state, targetStep, currentText) => {
|
|
108
|
+
if (targetStep === state.step)
|
|
109
|
+
return { next: state, effects: [] };
|
|
110
|
+
const trimmed = currentText.trim();
|
|
111
|
+
const answers = cloneAnswers(state.answers);
|
|
112
|
+
if (trimmed.length > 0) {
|
|
113
|
+
const previous = answers.get(state.step) ?? emptyEntry();
|
|
114
|
+
answers.set(state.step, { ...previous, freeText: trimmed });
|
|
115
|
+
}
|
|
116
|
+
const next = { step: targetStep, answers };
|
|
117
|
+
return {
|
|
118
|
+
next,
|
|
119
|
+
effects: [
|
|
120
|
+
{ type: "set-input-text", text: answers.get(targetStep)?.freeText ?? "" },
|
|
121
|
+
{ type: "reset-highlight" },
|
|
122
|
+
{ type: "focus-options" },
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
// The decide half of the ask-user flow, mirroring interpretAskUserKey: map an
|
|
127
|
+
// action onto the next state plus the effects to run. Composed actions
|
|
128
|
+
// (select-option = toggle + advance) resolve atomically here, so no caller
|
|
129
|
+
// ever chains transitions across a stale state snapshot.
|
|
130
|
+
export const transitionAskUser = (state, questions, action) => {
|
|
131
|
+
const multiSelect = !!questions[state.step]?.multiSelect;
|
|
132
|
+
switch (action.type) {
|
|
133
|
+
case "toggle-option":
|
|
134
|
+
return {
|
|
135
|
+
next: {
|
|
136
|
+
...state,
|
|
137
|
+
answers: toggleAnswer(state.answers, state.step, action.label, multiSelect),
|
|
138
|
+
},
|
|
139
|
+
effects: multiSelect ? [] : [{ type: "clear-input" }],
|
|
140
|
+
};
|
|
141
|
+
case "select-option": {
|
|
142
|
+
const toggled = toggleAnswer(state.answers, state.step, action.label, multiSelect);
|
|
143
|
+
if (multiSelect)
|
|
144
|
+
return { next: { ...state, answers: toggled }, effects: [] };
|
|
145
|
+
return advanceStep(state, questions, toggled);
|
|
146
|
+
}
|
|
147
|
+
case "clear-selections": {
|
|
148
|
+
const previous = state.answers.get(state.step);
|
|
149
|
+
if (!previous || previous.selected.size === 0)
|
|
150
|
+
return { next: state, effects: [] };
|
|
151
|
+
const answers = cloneAnswers(state.answers);
|
|
152
|
+
answers.set(state.step, { selected: new Set(), freeText: previous.freeText });
|
|
153
|
+
return { next: { ...state, answers }, effects: [] };
|
|
154
|
+
}
|
|
155
|
+
case "continue-step":
|
|
156
|
+
return advanceStep(state, questions, writeFreeText(state.answers, state.step, action.freeText.trim(), multiSelect));
|
|
157
|
+
case "dismiss-step":
|
|
158
|
+
return advanceStep(state, questions, skipStep(state.answers, state.step));
|
|
159
|
+
case "step-back":
|
|
160
|
+
return transitionToStep(state, Math.max(0, state.step - 1), action.currentText);
|
|
161
|
+
case "step-forward":
|
|
162
|
+
return transitionToStep(state, Math.min(questions.length - 1, state.step + 1), action.currentText);
|
|
163
|
+
}
|
|
164
|
+
};
|