@polymorfa/react 0.1.0-dev.20260922094209

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.
@@ -0,0 +1,843 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { VoiceNoteRecorder, localAttachmentFromFile, } from "@polymorfa/browser";
3
+ import { CHAT_ICONS, EMOJI_CATEGORY_ICONS, EMOJI_PICKER_CATEGORIES, applyQuickReply, emojiInCategory, filterQuickReplies, findQuickReplyQuery, formatElapsed, insertText, placePopover, quickReplyLabel, readRecentEmoji, recentEmojiEntries, recordRecentEmoji, searchEmoji, ENGLISH_MESSAGES, appearanceToCssVariables, formatDayLabel, formatFileSize, formatMessageTime, injectComponentStyles, isImageAttachment, layoutMessages, safeAttachmentUrl, slotClassName, themeClassName, } from "@polymorfa/ui";
4
+ import { memo, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
5
+ import { usePolymorfa } from "./context.js";
6
+ import { useController, safeSubscribe, useOwnedController, useResolvedController, } from "./hooks.js";
7
+ // Runs before paint in the browser, and quietly on the server.
8
+ const useIsomorphicLayoutEffect = typeof document === "undefined" ? useEffect : useLayoutEffect;
9
+ function slotStyle(styles) {
10
+ if (styles === undefined)
11
+ return undefined;
12
+ const style = {};
13
+ for (const [name, value] of Object.entries(styles))
14
+ style[name.startsWith("--")
15
+ ? name
16
+ : name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] = value;
17
+ return style;
18
+ }
19
+ function createSlots(appearance, classNames) {
20
+ const styles = new Map();
21
+ return (slot, base) => {
22
+ if (!styles.has(slot))
23
+ styles.set(slot, slotStyle(appearance.elements[slot]?.styles));
24
+ const style = styles.get(slot);
25
+ return {
26
+ className: slotClassName(appearance, slot, base, classNames),
27
+ ...(style === undefined ? {} : { style }),
28
+ "data-slot": slot,
29
+ };
30
+ };
31
+ }
32
+ /** Slot resolver that keeps its identity while the inputs are unchanged. */
33
+ export function useSlots(classNames) {
34
+ const { appearance } = usePolymorfa();
35
+ const key = classNames === undefined ? "" : JSON.stringify(classNames);
36
+ // `key` stands in for `classNames`, which callers usually pass inline.
37
+ return useMemo(() => createSlots(appearance, classNames), [appearance, key]);
38
+ }
39
+ function useStyles(appearance) {
40
+ const unstyled = appearance.unstyled === true;
41
+ useEffect(() => {
42
+ if (!unstyled)
43
+ injectComponentStyles();
44
+ }, [unstyled]);
45
+ }
46
+ /** Root props: theme classes, direction, CSS variables, and the slot. */
47
+ export function useShell(slots, slot, base, className) {
48
+ const configuration = usePolymorfa();
49
+ const { appearance, locale } = configuration;
50
+ useStyles(appearance);
51
+ const variables = useMemo(() => ({
52
+ ...appearanceToCssVariables(appearance),
53
+ "--pmfa-drawer-width": appearance.layout.drawerWidth,
54
+ }), [appearance]);
55
+ const slotProps = slots(slot, base);
56
+ return {
57
+ dir: appearance.layout.direction === "auto"
58
+ ? locale.direction
59
+ : appearance.layout.direction,
60
+ className: [
61
+ themeClassName(appearance.theme),
62
+ slotProps.className,
63
+ className,
64
+ ]
65
+ .filter((value) => value !== undefined && value !== "")
66
+ .join(" "),
67
+ style: slotProps.style === undefined
68
+ ? variables
69
+ : { ...variables, ...slotProps.style },
70
+ "data-slot": slot,
71
+ };
72
+ }
73
+ export function text(configuration, key, values = {}) {
74
+ const template = configuration.locale.messages[key] ?? ENGLISH_MESSAGES[key];
75
+ return template.replace(/\{(\w+)\}/g, (_, name) => values[name] ?? "");
76
+ }
77
+ export function Icon({ name, className = "pmfa-icon", }) {
78
+ return (_jsx("svg", { className: className, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: CHAT_ICONS[name] }) }));
79
+ }
80
+ /** Subscribe to a controller that may be absent. */
81
+ const NO_SUBSCRIPTION = () => () => undefined;
82
+ const NO_SNAPSHOT = () => undefined;
83
+ function useOptionalController(controller) {
84
+ return useSyncExternalStore(controller === undefined ? NO_SUBSCRIPTION : safeSubscribe(controller), controller?.getSnapshot ?? NO_SNAPSHOT, controller?.getSnapshot ?? NO_SNAPSHOT);
85
+ }
86
+ /** A value's latest version, for stable callbacks. */
87
+ function useLatest(value) {
88
+ const ref = useRef(value);
89
+ useIsomorphicLayoutEffect(() => {
90
+ ref.current = value;
91
+ });
92
+ return ref;
93
+ }
94
+ function authorName(configuration, message) {
95
+ return text(configuration, message?.direction === "outbound" ? "chat.you" : "chat.contact");
96
+ }
97
+ function snippet(configuration, message) {
98
+ return message.text.trim() !== ""
99
+ ? message.text
100
+ : (message.attachments?.[0]?.name ??
101
+ text(configuration, "chat.attachment"));
102
+ }
103
+ function AttachmentView({ attachment, configuration, slots, }) {
104
+ // Unsafe schemes such as `javascript:` never reach `href` or `src`.
105
+ const url = safeAttachmentUrl(attachment.url);
106
+ const source = safeAttachmentUrl(attachment.previewUrl) ?? url;
107
+ if (isImageAttachment(attachment) && source !== undefined) {
108
+ const image = (_jsx("img", { src: source, alt: attachment.name, loading: "lazy", decoding: "async" }));
109
+ return url === undefined ? (_jsx("div", { ...slots("attachment", "pmfa-att pmfa-att-media"), children: image })) : (_jsx("a", { ...slots("attachment", "pmfa-att pmfa-att-media"), href: url, target: "_blank", rel: "noopener noreferrer", children: image }));
110
+ }
111
+ const body = (_jsxs(_Fragment, { children: [_jsx("span", { className: "pmfa-att-icon", children: _jsx(Icon, { name: "file" }) }), _jsxs("span", { className: "pmfa-att-body", children: [_jsx("span", { className: "pmfa-att-name", children: attachment.name }), _jsx("span", { className: "pmfa-att-size", children: formatFileSize(attachment.size, configuration.locale.code) })] })] }));
112
+ return url === undefined ? (_jsx("div", { ...slots("attachment", "pmfa-att pmfa-att-file"), children: body })) : (_jsx("a", { ...slots("attachment", "pmfa-att pmfa-att-file"), href: url, target: "_blank", rel: "noopener noreferrer", title: text(configuration, "chat.openAttachment", {
113
+ name: attachment.name,
114
+ }), children: body }));
115
+ }
116
+ const MessageItem = memo(function MessageItem({ message, quoted, groupStart, groupEnd, configuration, slots, canReply, canRetry, onReply, onRetry, onJump, renderMessage, renderAttachment, }) {
117
+ const outbound = message.direction === "outbound";
118
+ const time = formatMessageTime(message.createdAt, configuration.locale.code);
119
+ const status = message.status === "failed"
120
+ ? text(configuration, "chat.failed")
121
+ : message.status === "pending"
122
+ ? text(configuration, "chat.sending")
123
+ : outbound
124
+ ? text(configuration, "chat.sent")
125
+ : undefined;
126
+ const retry = canRetry &&
127
+ outbound &&
128
+ message.status === "failed" &&
129
+ message.clientId !== undefined;
130
+ const custom = renderMessage?.(message);
131
+ return (_jsxs("li", { ...slots("message", [
132
+ "pmfa-msg",
133
+ `pmfa-msg-${outbound ? "out" : "in"}`,
134
+ `pmfa-msg-${message.status}`,
135
+ groupStart ? "pmfa-msg-start" : "",
136
+ groupEnd ? "pmfa-msg-end" : "",
137
+ ]
138
+ .filter(Boolean)
139
+ .join(" ")), "data-message-id": message.id, tabIndex: -1, children: [quoted !== undefined && (_jsxs("button", { type: "button", ...slots("replyQuote", "pmfa-quote"), onClick: () => onJump(quoted.id), children: [_jsx("span", { className: "pmfa-sr", children: text(configuration, "chat.jumpToReply") }), _jsx("span", { className: "pmfa-quote-name", children: authorName(configuration, quoted) }), _jsx("span", { className: "pmfa-quote-text", children: snippet(configuration, quoted) })] })), _jsxs("div", { className: "pmfa-row", children: [_jsx("div", { ...slots("bubble", "pmfa-bubble"), children: custom ?? (_jsxs(_Fragment, { children: [(message.attachments?.length ?? 0) > 0 && (_jsx("div", { className: "pmfa-atts", children: message.attachments?.map((attachment) => (_jsx(AttachmentItem, { attachment: attachment, message: message, configuration: configuration, slots: slots, renderAttachment: renderAttachment }, attachment.id))) })), message.text !== "" && (_jsx("span", { className: "pmfa-text", children: message.text }))] })) }), (canReply || retry) && (_jsxs("div", { ...slots("messageActions", "pmfa-actions-msg"), role: "group", "aria-label": text(configuration, "chat.messageActions"), children: [retry && (_jsx("button", { type: "button", ...slots("retryButton", "pmfa-btn pmfa-btn-ghost pmfa-btn-icon pmfa-action-retry"), "aria-label": text(configuration, "chat.retry"), title: text(configuration, "chat.retry"), onClick: () => onRetry(message), children: _jsx(Icon, { name: "retry" }) })), canReply && (_jsx("button", { type: "button", ...slots("replyButton", "pmfa-btn pmfa-btn-ghost pmfa-btn-icon"), "aria-label": text(configuration, "chat.reply"), title: text(configuration, "chat.reply"), onClick: () => onReply(message), children: _jsx(Icon, { name: "reply" }) }))] }))] }), _jsxs("span", { ...slots("messageMeta", "pmfa-meta"), children: [time !== undefined && (_jsx("time", { dateTime: new Date(message.createdAt).toISOString(), children: time })), outbound && (_jsx(Icon, { name: message.status, className: "pmfa-icon pmfa-status" })), status !== undefined &&
140
+ (message.status === "failed" ? (_jsx("span", { children: status })) : (_jsx("span", { className: "pmfa-sr", children: status })))] }), message.error !== undefined && (_jsx("span", { className: "pmfa-msg-error", role: "alert", children: message.error }))] }));
141
+ });
142
+ function AttachmentItem({ attachment, message, configuration, slots, renderAttachment, }) {
143
+ return (renderAttachment?.(attachment, message) ?? (_jsx(AttachmentView, { attachment: attachment, configuration: configuration, slots: slots })));
144
+ }
145
+ /** Keeps the newest message in view while the reader is already at the end. */
146
+ function useStickToBottom(dependency) {
147
+ const ref = useRef(null);
148
+ const pinned = useRef(true);
149
+ useIsomorphicLayoutEffect(() => {
150
+ const node = ref.current;
151
+ if (node !== null && pinned.current)
152
+ node.scrollTop = node.scrollHeight;
153
+ }, [dependency]);
154
+ const onScroll = useCallback(() => {
155
+ const node = ref.current;
156
+ if (node !== null)
157
+ pinned.current =
158
+ node.scrollHeight - node.scrollTop - node.clientHeight < 32;
159
+ }, []);
160
+ return { ref, onScroll };
161
+ }
162
+ function prefersReducedMotion() {
163
+ return (typeof matchMedia === "function" &&
164
+ matchMedia("(prefers-reduced-motion: reduce)").matches);
165
+ }
166
+ function ConversationLog({ controller, rootProps, slots, renderMessage, renderAttachment, onReply, }) {
167
+ const snapshot = useController(controller);
168
+ const configuration = usePolymorfa();
169
+ const scroll = useStickToBottom(snapshot.messages);
170
+ const entries = useMemo(() => layoutMessages(snapshot.messages), [snapshot.messages]);
171
+ const byId = useMemo(() => new Map(snapshot.messages.map((message) => [message.id, message])), [snapshot.messages]);
172
+ const latestReply = useLatest(onReply);
173
+ const reply = useCallback((message) => latestReply.current?.(message), [latestReply]);
174
+ const retry = useCallback((message) => {
175
+ if (message.clientId !== undefined)
176
+ void controller.retry(message.clientId).catch(() => undefined);
177
+ }, [controller]);
178
+ const jump = useCallback((id) => {
179
+ const target = scroll.ref.current?.querySelector(`[data-message-id="${id.replace(/["\\]/g, "\\$&")}"]`);
180
+ if (target === null || target === undefined)
181
+ return;
182
+ target.scrollIntoView?.({
183
+ block: "center",
184
+ behavior: prefersReducedMotion() ? "auto" : "smooth",
185
+ });
186
+ target.focus({ preventScroll: true });
187
+ target.classList.remove("pmfa-msg-flash");
188
+ target.classList.add("pmfa-msg-flash");
189
+ setTimeout(() => target.classList.remove("pmfa-msg-flash"), 1200);
190
+ }, [scroll.ref]);
191
+ const loadMore = useCallback(() => void controller.loadMore(), [controller]);
192
+ const canRetry = typeof controller.retry === "function";
193
+ const now = Date.now();
194
+ const dayLabels = {
195
+ today: text(configuration, "chat.today"),
196
+ yesterday: text(configuration, "chat.yesterday"),
197
+ };
198
+ return (_jsx("div", { ...rootProps, ref: scroll.ref, onScroll: scroll.onScroll, "data-pmfa": "message-list", role: "log", "aria-live": "polite", "aria-label": text(configuration, "chat.title"), children: _jsxs("ol", { className: "pmfa-items", children: [snapshot.hasMore && (_jsx("li", { className: "pmfa-loadmore", children: _jsx("button", { type: "button", ...slots("loadMore", "pmfa-btn"), onClick: loadMore, children: text(configuration, "chat.loadMore") }) })), entries.length === 0 ? (_jsx("li", { ...slots("empty", "pmfa-empty"), children: text(configuration, "chat.empty") })) : (entries.map((entry) => entry.kind === "date" ? (_jsx("li", { ...slots("dateSeparator", "pmfa-date"), children: formatDayLabel(entry.time, configuration.locale.code, dayLabels, now) }, entry.key)) : (_jsx(MessageItem, { message: entry.message, quoted: entry.message.replyTo === undefined
199
+ ? undefined
200
+ : byId.get(entry.message.replyTo), groupStart: entry.groupStart, groupEnd: entry.groupEnd, configuration: configuration, slots: slots, canReply: onReply !== undefined, canRetry: canRetry, onReply: reply, onRetry: retry, onJump: jump, renderMessage: renderMessage, renderAttachment: renderAttachment }, entry.key))))] }) }));
201
+ }
202
+ export function MessageList({ controller, createController, className, classNames, ...viewProps }) {
203
+ const resolved = useResolvedController(controller, createController);
204
+ const slots = useSlots(classNames);
205
+ const root = useShell(slots, "messageList", "pmfa-list", className);
206
+ return (_jsx(ConversationLog, { ...viewProps, controller: resolved, rootProps: root, slots: slots }));
207
+ }
208
+ // ── Composer ──────────────────────────────────────────────────────────
209
+ let attachmentCounter = 0;
210
+ function attachmentFromFile(file) {
211
+ attachmentCounter += 1;
212
+ return {
213
+ id: globalThis.crypto?.randomUUID?.() ??
214
+ `local-${Date.now()}-${attachmentCounter}`,
215
+ name: file.name,
216
+ size: file.size,
217
+ contentType: file.type === "" ? "application/octet-stream" : file.type,
218
+ file,
219
+ };
220
+ }
221
+ function errorText(cause) {
222
+ return cause instanceof Error ? cause.message : String(cause);
223
+ }
224
+ const supportsFieldSizing = typeof CSS !== "undefined" &&
225
+ typeof CSS.supports === "function" &&
226
+ CSS.supports("field-sizing", "content");
227
+ function hasFiles(event) {
228
+ return [...(event.dataTransfer?.types ?? [])].includes("Files");
229
+ }
230
+ function rejectionCleared(previous, next) {
231
+ if (previous.text !== next.text)
232
+ return true;
233
+ if (previous.sending && !next.sending && next.error === undefined)
234
+ return true;
235
+ return (next.status === "ready" &&
236
+ !next.sending &&
237
+ next.error === undefined &&
238
+ next.text === "" &&
239
+ next.attachments.length === 0 &&
240
+ next.replyTo === undefined);
241
+ }
242
+ function AttachmentChip({ attachment, configuration, slots, onRemove, }) {
243
+ const percent = Math.round(attachment.progress * 100);
244
+ return (_jsxs("li", { ...slots("attachmentChip", `pmfa-chip pmfa-chip-${attachment.status}`), "data-attachment-id": attachment.id, children: [_jsx(Icon, { name: attachment.status === "failed" ? "failed" : "file" }), _jsxs("span", { className: "pmfa-chip-body", children: [_jsx("span", { className: "pmfa-chip-name", children: attachment.name }), attachment.status === "uploading" && (_jsx("span", { className: "pmfa-progress", role: "progressbar", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": percent, "aria-label": text(configuration, "composer.uploading", {
245
+ name: attachment.name,
246
+ }), children: _jsx("span", { style: { width: `${percent}%` } }) })), _jsx("span", { className: "pmfa-chip-status", "aria-live": "polite", children: attachment.status === "failed" ? ((attachment.error ?? text(configuration, "composer.uploadFailed"))) : attachment.status === "uploading" ? (_jsx("span", { className: "pmfa-sr", children: text(configuration, "composer.uploading", {
247
+ name: attachment.name,
248
+ }) })) : (formatFileSize(attachment.size, configuration.locale.code)) })] }), _jsx("button", { type: "button", className: "pmfa-btn pmfa-btn-ghost pmfa-btn-icon", "aria-label": text(configuration, "composer.removeNamedAttachment", {
249
+ name: attachment.name,
250
+ }), onClick: () => onRemove(attachment.id), children: _jsx(Icon, { name: "close" }) })] }));
251
+ }
252
+ const EMOJI_COLUMNS = 8;
253
+ function gridColumns(cells) {
254
+ const first = cells[0];
255
+ if (first === undefined)
256
+ return EMOJI_COLUMNS;
257
+ let columns = 0;
258
+ for (const cell of cells) {
259
+ if (cell.offsetTop !== first.offsetTop)
260
+ break;
261
+ columns += 1;
262
+ }
263
+ return columns > 0 && columns < cells.length ? columns : EMOJI_COLUMNS;
264
+ }
265
+ function narrowViewport() {
266
+ return (typeof matchMedia === "function" && matchMedia("(max-width: 600px)").matches);
267
+ }
268
+ function EmojiPicker({ anchor, configuration, slots, onPick, onClose, }) {
269
+ const popover = useRef(null);
270
+ const search = useRef(null);
271
+ const grid = useRef(null);
272
+ const id = useId();
273
+ const [recent, setRecent] = useState(() => readRecentEmoji());
274
+ const [category, setCategory] = useState(() => recent.length > 0 ? "recent" : "smileys");
275
+ const [query, setQuery] = useState("");
276
+ const [active, setActive] = useState(0);
277
+ const latestClose = useLatest(onClose);
278
+ const entries = useMemo(() => query.trim() !== ""
279
+ ? searchEmoji(query)
280
+ : category === "recent"
281
+ ? recentEmojiEntries(recent)
282
+ : emojiInCategory(category), [query, category, recent]);
283
+ const activeIndex = Math.min(active, Math.max(0, entries.length - 1));
284
+ useIsomorphicLayoutEffect(() => {
285
+ const node = popover.current;
286
+ if (node === null)
287
+ return;
288
+ const place = () => {
289
+ const target = anchor.current;
290
+ if (target === null)
291
+ return;
292
+ const rect = target.getBoundingClientRect();
293
+ const width = node.offsetWidth || 360;
294
+ const height = node.offsetHeight || 400;
295
+ const direction = getComputedStyle(target).direction === "rtl" ? "rtl" : "ltr";
296
+ const spot = placePopover(rect, { width, height }, { width: window.innerWidth, height: window.innerHeight }, { direction });
297
+ node.style.setProperty("--pmfa-pop-top", `${spot.top}px`);
298
+ node.style.setProperty("--pmfa-pop-left", `${spot.left}px`);
299
+ node.style.setProperty("--pmfa-pop-max", `${spot.maxHeight}px`);
300
+ node.dataset.placement = spot.placement;
301
+ node.removeAttribute("data-measuring");
302
+ };
303
+ place();
304
+ window.addEventListener("resize", place);
305
+ window.addEventListener("scroll", place, true);
306
+ return () => {
307
+ window.removeEventListener("resize", place);
308
+ window.removeEventListener("scroll", place, true);
309
+ };
310
+ }, [anchor]);
311
+ useEffect(() => {
312
+ // On small screens the search field would raise the keyboard over the
313
+ // sheet, so focus goes to the sheet itself.
314
+ if (narrowViewport())
315
+ popover.current?.focus({ preventScroll: true });
316
+ else
317
+ search.current?.focus({ preventScroll: true });
318
+ const onPointer = (event) => {
319
+ const path = event.composedPath();
320
+ if ((popover.current !== null && path.includes(popover.current)) ||
321
+ (anchor.current !== null && path.includes(anchor.current)))
322
+ return;
323
+ latestClose.current(false);
324
+ };
325
+ document.addEventListener("pointerdown", onPointer, true);
326
+ return () => document.removeEventListener("pointerdown", onPointer, true);
327
+ }, [anchor, latestClose]);
328
+ const cells = () => [
329
+ ...(grid.current?.querySelectorAll(".pmfa-emoji-cell") ?? []),
330
+ ];
331
+ const focusCell = (index) => {
332
+ const list = cells();
333
+ const next = Math.max(0, Math.min(index, list.length - 1));
334
+ setActive(next);
335
+ list[next]?.focus();
336
+ list[next]?.scrollIntoView?.({ block: "nearest" });
337
+ };
338
+ const pick = (emoji) => {
339
+ setRecent(recordRecentEmoji(emoji));
340
+ onPick(emoji);
341
+ };
342
+ const selectTab = (next) => {
343
+ setCategory(next);
344
+ setQuery("");
345
+ setActive(0);
346
+ if (grid.current !== null)
347
+ grid.current.parentElement?.scrollTo?.({ top: 0 });
348
+ };
349
+ const heading = query.trim() !== ""
350
+ ? text(configuration, "composer.emojiSearch")
351
+ : text(configuration, `composer.emojiCategory.${category}`);
352
+ return (_jsxs("div", { ref: popover, ...slots("emojiPicker", "pmfa-popover pmfa-emoji"), role: "dialog", "aria-label": text(configuration, "composer.emojiPicker"), tabIndex: -1, "data-measuring": "", onKeyDown: (event) => {
353
+ if (event.key !== "Escape" || event.nativeEvent.isComposing)
354
+ return;
355
+ event.preventDefault();
356
+ event.stopPropagation();
357
+ latestClose.current(true);
358
+ }, children: [_jsxs("div", { className: "pmfa-emoji-search", children: [_jsx(Icon, { name: "search" }), _jsx("input", { ref: search, type: "search", className: "pmfa-input", "aria-label": text(configuration, "composer.emojiSearch"), placeholder: text(configuration, "composer.emojiSearch"), value: query, "aria-controls": `${id}-panel`, onChange: (event) => {
359
+ setQuery(event.currentTarget.value);
360
+ setActive(0);
361
+ }, onKeyDown: (event) => {
362
+ if (event.key === "ArrowDown") {
363
+ event.preventDefault();
364
+ focusCell(0);
365
+ }
366
+ else if (event.key === "Enter" && entries[0] !== undefined) {
367
+ event.preventDefault();
368
+ pick(entries[0].emoji);
369
+ }
370
+ } })] }), _jsx("div", { className: "pmfa-emoji-tabs", role: "tablist", "aria-label": text(configuration, "composer.emojiCategories"), onKeyDown: (event) => {
371
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight")
372
+ return;
373
+ event.preventDefault();
374
+ const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
375
+ const step = (event.key === "ArrowRight") !== rtl ? 1 : -1;
376
+ const index = EMOJI_PICKER_CATEGORIES.indexOf(category);
377
+ const next = EMOJI_PICKER_CATEGORIES[(index + step + EMOJI_PICKER_CATEGORIES.length) %
378
+ EMOJI_PICKER_CATEGORIES.length] ?? "smileys";
379
+ selectTab(next);
380
+ event.currentTarget
381
+ .querySelector(`[data-category="${next}"]`)
382
+ ?.focus();
383
+ }, children: EMOJI_PICKER_CATEGORIES.map((name) => {
384
+ const selected = query.trim() === "" && name === category;
385
+ return (_jsx("button", { type: "button", role: "tab", className: "pmfa-emoji-tab", "data-category": name, id: `${id}-tab-${name}`, "aria-selected": selected, "aria-controls": `${id}-panel`, tabIndex: name === (query.trim() === "" ? category : "recent") ? 0 : -1, "aria-label": text(configuration, `composer.emojiCategory.${name}`), title: text(configuration, `composer.emojiCategory.${name}`), onClick: () => selectTab(name), children: _jsx("span", { "aria-hidden": "true", children: EMOJI_CATEGORY_ICONS[name] }) }, name));
386
+ }) }), _jsxs("div", { className: "pmfa-emoji-body", id: `${id}-panel`, role: "tabpanel", "aria-label": heading, children: [_jsx("p", { className: "pmfa-emoji-heading", "aria-hidden": "true", children: heading }), entries.length === 0 ? (_jsx("p", { className: "pmfa-emoji-empty", children: text(configuration, query.trim() === ""
387
+ ? "composer.emojiNoRecent"
388
+ : "composer.emojiNoResults") })) : (_jsx("div", { ref: grid, className: "pmfa-emoji-grid", role: "group", "aria-label": heading, onKeyDown: (event) => {
389
+ const list = cells();
390
+ const index = list.indexOf(event.target);
391
+ if (index < 0)
392
+ return;
393
+ const columns = gridColumns(list);
394
+ const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
395
+ const moves = {
396
+ ArrowRight: rtl ? -1 : 1,
397
+ ArrowLeft: rtl ? 1 : -1,
398
+ ArrowDown: columns,
399
+ ArrowUp: -columns,
400
+ Home: -index,
401
+ End: list.length - 1 - index,
402
+ };
403
+ const move = moves[event.key];
404
+ if (move === undefined)
405
+ return;
406
+ event.preventDefault();
407
+ if (event.key === "ArrowUp" && index < columns) {
408
+ search.current?.focus();
409
+ return;
410
+ }
411
+ focusCell(index + move);
412
+ }, children: entries.map((entry, index) => (_jsx("button", { type: "button", className: "pmfa-emoji-cell", tabIndex: index === activeIndex ? 0 : -1, "aria-label": entry.name, title: entry.name, onFocus: () => setActive(index), onClick: () => pick(entry.emoji), children: entry.emoji }, `${entry.emoji}-${index}`))) }))] })] }));
413
+ }
414
+ const LEVEL_BARS = 28;
415
+ function RecordingBar({ snapshot, configuration, slots, autoSend, onCancel, onStop, }) {
416
+ // A rolling history of input levels, newest last.
417
+ const [history, setHistory] = useState({ revision: snapshot.revision, levels: [] });
418
+ if (history.revision !== snapshot.revision) {
419
+ setHistory({
420
+ revision: snapshot.revision,
421
+ levels: [...history.levels, snapshot.level].slice(-LEVEL_BARS),
422
+ });
423
+ }
424
+ const levels = [
425
+ ...Array.from({
426
+ length: LEVEL_BARS - history.levels.length,
427
+ }).fill(0),
428
+ ...history.levels,
429
+ ];
430
+ const stopLabel = text(configuration, autoSend ? "composer.sendVoiceNote" : "composer.stopRecording");
431
+ return (_jsxs("div", { ...slots("recordingBar", "pmfa-recording"), role: "group", "aria-label": text(configuration, "composer.recording"), children: [_jsx("button", { type: "button", className: "pmfa-btn pmfa-btn-ghost pmfa-btn-icon pmfa-rec-cancel", "aria-label": text(configuration, "composer.cancelRecording"), title: text(configuration, "composer.cancelRecording"), onClick: onCancel, children: _jsx(Icon, { name: "trash" }) }), _jsxs("div", { className: "pmfa-recording-status", children: [_jsx("span", { className: "pmfa-rec-dot", "aria-hidden": "true" }), _jsx("span", { className: "pmfa-rec-time", role: "timer", children: formatElapsed(snapshot.elapsed) }), _jsx("span", { className: "pmfa-rec-level", role: "meter", "aria-label": text(configuration, "composer.recordingLevel"), "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": Math.round(snapshot.level * 100), children: levels.map((level, index) => (_jsx("span", { style: {
432
+ "--pmfa-level": String(Math.max(0.12, level)),
433
+ } }, index))) })] }), _jsx("button", { type: "button", className: "pmfa-btn pmfa-btn-primary pmfa-btn-icon pmfa-send", "aria-label": stopLabel, title: stopLabel, disabled: snapshot.status !== "recording", onClick: onStop, children: _jsx(Icon, { name: autoSend ? "sendArrow" : "sent" }) })] }));
434
+ }
435
+ const voiceErrorKeys = {
436
+ "permission-denied": "composer.microphoneDenied",
437
+ unavailable: "composer.microphoneUnavailable",
438
+ failed: "composer.recordingFailed",
439
+ };
440
+ const isVoiceSupported = () => VoiceNoteRecorder.isSupported();
441
+ const notSupported = () => false;
442
+ export function ComposeBox({ controller, createController, onSent, className, classNames, attachments = true, accept, multiple = true, conversation, messages, placeholder, startActions, endActions, emoji = true, voiceNotes, voiceNoteAutoSend = true, quickReplies, onQuickReply, maxRows = 8, }) {
443
+ const resolved = useResolvedController(controller, createController);
444
+ const snapshot = useController(resolved);
445
+ const conversationSnapshot = useOptionalController(conversation);
446
+ const configuration = usePolymorfa();
447
+ const slots = useSlots(classNames);
448
+ const root = useShell(slots, "composer", "pmfa-composer", className);
449
+ const inputRef = useRef(null);
450
+ const fileRef = useRef(null);
451
+ const emojiButtonRef = useRef(null);
452
+ const micRef = useRef(null);
453
+ const pendingCaret = useRef(undefined);
454
+ const menuId = useId();
455
+ const [dragging, setDragging] = useState(false);
456
+ const [rejection, setRejection] = useState(undefined);
457
+ const [emojiOpen, setEmojiOpen] = useState(false);
458
+ const [caret, setCaret] = useState(0);
459
+ const [dismissedText, setDismissedText] = useState(undefined);
460
+ const [activeOption, setActiveOption] = useState(0);
461
+ const [announcement, setAnnouncement] = useState("");
462
+ // A rejected file's message clears once the text changes, a send
463
+ // succeeds, or the composer resets.
464
+ const [previousSnapshot, setPreviousSnapshot] = useState(snapshot);
465
+ if (previousSnapshot !== snapshot) {
466
+ setPreviousSnapshot(snapshot);
467
+ if (rejection !== undefined && rejectionCleared(previousSnapshot, snapshot))
468
+ setRejection(undefined);
469
+ }
470
+ // Voice notes
471
+ const voiceSupported = useSyncExternalStore(NO_SUBSCRIPTION, isVoiceSupported, notSupported);
472
+ const voiceEnabled = (voiceNotes ?? true) && voiceSupported;
473
+ const recorderRef = useRef(undefined);
474
+ const [recorder, setRecorder] = useState(undefined);
475
+ const recording = useOptionalController(recorder);
476
+ useEffect(() => () => {
477
+ recorderRef.current?.dispose();
478
+ recorderRef.current = undefined;
479
+ }, []);
480
+ const recorderActive = recording !== undefined &&
481
+ (recording.status === "recording" || recording.status === "stopping");
482
+ const voiceError = recording?.status === "error" && recording.error !== undefined
483
+ ? text(configuration, voiceErrorKeys[recording.error])
484
+ : undefined;
485
+ useIsomorphicLayoutEffect(() => {
486
+ const input = inputRef.current;
487
+ if (input === null)
488
+ return;
489
+ if (!supportsFieldSizing) {
490
+ input.style.height = "auto";
491
+ input.style.height = `${input.scrollHeight + 2}px`;
492
+ }
493
+ const target = pendingCaret.current;
494
+ if (target !== undefined && input.value === snapshot.text) {
495
+ pendingCaret.current = undefined;
496
+ input.setSelectionRange(target, target);
497
+ }
498
+ }, [snapshot.text]);
499
+ const canSend = !snapshot.sending &&
500
+ (snapshot.text.trim() !== "" ||
501
+ snapshot.attachments.some(({ status }) => status === "ready"));
502
+ const showMic = voiceEnabled &&
503
+ !snapshot.sending &&
504
+ snapshot.text.trim() === "" &&
505
+ !snapshot.attachments.some(({ status }) => status === "ready");
506
+ const submit = () => {
507
+ if (!canSend)
508
+ return;
509
+ void resolved.submit().then(() => {
510
+ // A failed send resolves with the error in the snapshot.
511
+ if (resolved.getSnapshot().status !== "error")
512
+ onSent?.();
513
+ }, () => undefined);
514
+ };
515
+ const addFiles = (files) => {
516
+ if (files === null || files === undefined)
517
+ return;
518
+ setRejection(undefined);
519
+ for (const file of [...files])
520
+ resolved
521
+ .addAttachment(attachmentFromFile(file))
522
+ .catch((cause) => setRejection(errorText(cause)));
523
+ };
524
+ const remove = useCallback((id) => resolved.cancelAttachment(id), [resolved]);
525
+ const replaceText = (value, nextCaret) => {
526
+ pendingCaret.current = nextCaret;
527
+ setRejection(undefined);
528
+ setCaret(nextCaret);
529
+ resolved.setText(value);
530
+ };
531
+ const insertEmoji = (value) => {
532
+ const input = inputRef.current;
533
+ const current = resolved.getSnapshot().text;
534
+ const start = input?.selectionStart ?? current.length;
535
+ const end = input?.selectionEnd ?? start;
536
+ const edit = insertText(current, value, start, end);
537
+ replaceText(edit.text, edit.caret);
538
+ };
539
+ const closeEmoji = useCallback((restoreFocus) => {
540
+ setEmojiOpen(false);
541
+ if (restoreFocus)
542
+ emojiButtonRef.current?.focus();
543
+ }, []);
544
+ // Quick replies
545
+ const quickMatch = quickReplies !== undefined && quickReplies.length > 0 && !recorderActive
546
+ ? findQuickReplyQuery(snapshot.text, Math.min(caret, snapshot.text.length))
547
+ : undefined;
548
+ const options = quickMatch === undefined || quickReplies === undefined
549
+ ? []
550
+ : filterQuickReplies(quickReplies, quickMatch.query);
551
+ const menuOpen = options.length > 0 && dismissedText !== snapshot.text;
552
+ const [previousQuery, setPreviousQuery] = useState(quickMatch?.query);
553
+ if (previousQuery !== quickMatch?.query) {
554
+ setPreviousQuery(quickMatch?.query);
555
+ setActiveOption(0);
556
+ }
557
+ const activeIndex = Math.min(activeOption, Math.max(0, options.length - 1));
558
+ const chooseOption = (option) => {
559
+ if (quickMatch === undefined)
560
+ return;
561
+ const edit = applyQuickReply(snapshot.text, quickMatch, option);
562
+ replaceText(edit.text, edit.caret);
563
+ onQuickReply?.(option);
564
+ inputRef.current?.focus();
565
+ };
566
+ const startRecording = () => {
567
+ let active = recorderRef.current;
568
+ if (active === undefined) {
569
+ active = new VoiceNoteRecorder();
570
+ recorderRef.current = active;
571
+ setRecorder(active);
572
+ }
573
+ setEmojiOpen(false);
574
+ const current = active;
575
+ void current.start().then(() => {
576
+ if (current.getSnapshot().status === "recording")
577
+ setAnnouncement(text(configuration, "composer.recording"));
578
+ });
579
+ };
580
+ const cancelRecording = () => {
581
+ recorderRef.current?.cancel();
582
+ setAnnouncement(text(configuration, "composer.recordingCancelled"));
583
+ requestAnimationFrameSafe(() => micRef.current?.focus());
584
+ };
585
+ const stopRecording = () => {
586
+ const active = recorderRef.current;
587
+ if (active === undefined)
588
+ return;
589
+ void active.stop().then(async (file) => {
590
+ setAnnouncement(text(configuration, "composer.recordingStopped"));
591
+ requestAnimationFrameSafe(() => inputRef.current?.focus());
592
+ if (file === undefined)
593
+ return;
594
+ const attachment = localAttachmentFromFile(file);
595
+ try {
596
+ await resolved.addAttachment(attachment);
597
+ }
598
+ catch (cause) {
599
+ setRejection(errorText(cause));
600
+ return;
601
+ }
602
+ if (!voiceNoteAutoSend)
603
+ return;
604
+ const after = resolved.getSnapshot();
605
+ const added = after.attachments.find(({ id }) => id === attachment.id);
606
+ if (added?.status !== "ready" ||
607
+ after.sending ||
608
+ after.attachments.some(({ status }) => status !== "ready"))
609
+ return;
610
+ await resolved.submit().then(() => {
611
+ if (resolved.getSnapshot().status !== "error")
612
+ onSent?.();
613
+ }, () => undefined);
614
+ });
615
+ };
616
+ const pool = messages ?? conversationSnapshot?.messages;
617
+ const replied = snapshot.replyTo === undefined
618
+ ? undefined
619
+ : pool?.find(({ id }) => id === snapshot.replyTo);
620
+ const error = snapshot.error ?? rejection ?? voiceError;
621
+ const inputSlot = slots("composerInput", "pmfa-input");
622
+ const combobox = quickReplies !== undefined && quickReplies.length > 0
623
+ ? {
624
+ role: "combobox",
625
+ "aria-autocomplete": "list",
626
+ "aria-expanded": menuOpen,
627
+ "aria-controls": menuId,
628
+ ...(menuOpen
629
+ ? { "aria-activedescendant": `${menuId}-${activeIndex}` }
630
+ : {}),
631
+ }
632
+ : {};
633
+ return (_jsxs("form", { ...root, "data-pmfa": "compose-box", ...(dragging ? { "data-dragging": "" } : {}), ...(recorderActive ? { "data-recording": "" } : {}), onSubmit: (event) => {
634
+ event.preventDefault();
635
+ submit();
636
+ }, onDragEnter: (event) => {
637
+ if (!attachments || !hasFiles(event))
638
+ return;
639
+ event.preventDefault();
640
+ setDragging(true);
641
+ }, onDragOver: (event) => {
642
+ if (!attachments || !hasFiles(event))
643
+ return;
644
+ event.preventDefault();
645
+ if (event.dataTransfer)
646
+ event.dataTransfer.dropEffect = "copy";
647
+ }, onDragLeave: (event) => {
648
+ if (!event.currentTarget.contains(event.relatedTarget))
649
+ setDragging(false);
650
+ }, onDrop: (event) => {
651
+ if (!attachments || !hasFiles(event))
652
+ return;
653
+ event.preventDefault();
654
+ setDragging(false);
655
+ addFiles(event.dataTransfer.files);
656
+ }, children: [_jsx("div", { className: "pmfa-drop-hint", "aria-hidden": "true", children: text(configuration, "composer.dropHint") }), _jsx("span", { className: "pmfa-sr", "aria-live": "polite", children: announcement }), menuOpen && (_jsxs("div", { ...slots("quickReplyMenu", "pmfa-qr"), children: [_jsx("div", { className: "pmfa-qr-title", "aria-hidden": "true", children: text(configuration, "composer.quickReplies") }), _jsx("ul", { id: menuId, role: "listbox", "aria-label": text(configuration, "composer.quickReplies"), children: options.map((option, index) => (_jsxs("li", { id: `${menuId}-${index}`, role: "option", className: "pmfa-qr-option", "aria-selected": index === activeIndex, onMouseDown: (event) => event.preventDefault(), onMouseMove: () => {
657
+ if (index !== activeIndex)
658
+ setActiveOption(index);
659
+ }, onClick: () => chooseOption(option), children: [_jsxs("span", { className: "pmfa-qr-head", children: [_jsx("span", { className: "pmfa-qr-shortcut", children: quickReplyLabel(option) }), option.description !== undefined && (_jsx("span", { className: "pmfa-qr-description", children: option.description }))] }), _jsx("span", { className: "pmfa-qr-text", children: option.text })] }, option.id))) })] })), snapshot.replyTo !== undefined && (_jsxs("div", { ...slots("replyBanner", "pmfa-reply-banner"), children: [_jsxs("span", { className: "pmfa-reply-body", children: [_jsx("span", { className: "pmfa-reply-name", children: text(configuration, "composer.replyingTo", {
660
+ name: authorName(configuration, replied),
661
+ }) }), replied !== undefined && (_jsx("span", { className: "pmfa-reply-text", children: snippet(configuration, replied) }))] }), _jsx("button", { type: "button", className: "pmfa-btn pmfa-btn-ghost pmfa-btn-icon", "aria-label": text(configuration, "composer.cancelReply"), onClick: () => {
662
+ resolved.setReplyTo();
663
+ inputRef.current?.focus();
664
+ }, children: _jsx(Icon, { name: "close" }) })] })), snapshot.attachments.length > 0 && (_jsx("ul", { className: "pmfa-chips", "aria-label": text(configuration, "composer.attachments"), children: snapshot.attachments.map((attachment) => (_jsx(AttachmentChip, { attachment: attachment, configuration: configuration, slots: slots, onRemove: remove }, attachment.id))) })), recorderActive && recording !== undefined && (_jsx(RecordingBar, { snapshot: recording, configuration: configuration, slots: slots, autoSend: voiceNoteAutoSend, onCancel: cancelRecording, onStop: stopRecording })), _jsxs("div", { ...slots("composerToolbar", "pmfa-composer-row"), hidden: recorderActive, children: [startActions !== undefined && startActions !== null && (_jsx("div", { className: "pmfa-composer-actions pmfa-composer-start", children: startActions })), emoji && (_jsx("button", { ref: emojiButtonRef, type: "button", ...slots("emojiButton", "pmfa-btn pmfa-btn-ghost pmfa-btn-icon pmfa-emoji-button"), "aria-label": text(configuration, "composer.emoji"), title: text(configuration, "composer.emoji"), "aria-haspopup": "dialog", "aria-expanded": emojiOpen, onClick: () => setEmojiOpen((open) => !open), children: _jsx(Icon, { name: "smiley" }) })), attachments && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", ...slots("composerAttach", "pmfa-btn pmfa-btn-ghost pmfa-btn-icon"), "aria-label": text(configuration, "composer.attach"), title: text(configuration, "composer.attach"), onClick: () => fileRef.current?.click(), children: _jsx(Icon, { name: "attach" }) }), _jsx("input", { ref: fileRef, type: "file", className: "pmfa-sr", tabIndex: -1, "aria-hidden": "true", accept: accept, multiple: multiple, onChange: (event) => {
665
+ addFiles(event.currentTarget.files);
666
+ event.currentTarget.value = "";
667
+ } })] })), _jsx("textarea", { ref: inputRef, ...inputSlot, style: {
668
+ ...inputSlot.style,
669
+ "--pmfa-composer-max-rows": String(Math.max(1, maxRows)),
670
+ }, ...combobox, "aria-label": text(configuration, "composer.label"), rows: 1, placeholder: placeholder ?? text(configuration, "composer.placeholder"), value: snapshot.text, onChange: (event) => {
671
+ setRejection(undefined);
672
+ setCaret(event.currentTarget.selectionStart);
673
+ resolved.setText(event.currentTarget.value);
674
+ }, onSelect: (event) => setCaret(event.currentTarget.selectionStart), onPaste: (event) => {
675
+ const files = event.clipboardData?.files;
676
+ if (!attachments || files === undefined || files.length === 0)
677
+ return;
678
+ event.preventDefault();
679
+ addFiles(files);
680
+ }, onKeyDown: (event) => {
681
+ if (event.nativeEvent.isComposing)
682
+ return;
683
+ if (menuOpen) {
684
+ const option = options[activeIndex];
685
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
686
+ event.preventDefault();
687
+ const step = event.key === "ArrowDown" ? 1 : -1;
688
+ setActiveOption((activeIndex + step + options.length) % options.length);
689
+ return;
690
+ }
691
+ if ((event.key === "Enter" && !event.shiftKey) ||
692
+ event.key === "Tab") {
693
+ if (option === undefined)
694
+ return;
695
+ event.preventDefault();
696
+ chooseOption(option);
697
+ return;
698
+ }
699
+ if (event.key === "Escape") {
700
+ event.preventDefault();
701
+ event.stopPropagation();
702
+ setDismissedText(snapshot.text);
703
+ return;
704
+ }
705
+ }
706
+ if (event.key === "Enter" && !event.shiftKey) {
707
+ event.preventDefault();
708
+ submit();
709
+ }
710
+ } }), endActions !== undefined && endActions !== null && (_jsx("div", { className: "pmfa-composer-actions pmfa-composer-end", children: endActions })), showMic ? (_jsx("button", { ref: micRef, type: "button", ...slots("voiceButton", "pmfa-btn pmfa-btn-ghost pmfa-btn-icon pmfa-voice"), "aria-label": text(configuration, "composer.voiceNote"), title: text(configuration, "composer.voiceNote"), "aria-busy": recording?.status === "requesting", disabled: recording?.status === "requesting", onClick: startRecording, children: _jsx(Icon, { name: "mic" }) })) : (_jsx("button", { type: "submit", ...slots("composerSend", "pmfa-btn pmfa-btn-primary pmfa-btn-icon pmfa-send"), "aria-label": snapshot.sending
711
+ ? text(configuration, "composer.sending")
712
+ : text(configuration, "composer.send"), title: text(configuration, "composer.send"), disabled: !canSend, children: _jsx(Icon, { name: "sendArrow" }) }))] }), emojiOpen && emoji && !recorderActive && (_jsx(EmojiPicker, { anchor: emojiButtonRef, configuration: configuration, slots: slots, onPick: insertEmoji, onClose: closeEmoji })), error !== undefined && (_jsx("p", { ...slots("error", "pmfa-error"), role: "alert", children: error }))] }));
713
+ }
714
+ function requestAnimationFrameSafe(callback) {
715
+ if (typeof requestAnimationFrame === "function")
716
+ requestAnimationFrame(callback);
717
+ else
718
+ setTimeout(callback, 0);
719
+ }
720
+ function focusFirst(root) {
721
+ if (root === null)
722
+ return;
723
+ const target = root.querySelector("textarea") ??
724
+ root.querySelector(".pmfa-drawer-header button") ??
725
+ root;
726
+ target.focus({ preventScroll: true });
727
+ }
728
+ export function ChatDrawer({ open = true, onClose, composer, title, className, classNames, conversation, controller, createController, composerController, createComposerController, composerProps, onReply, autoFocus = false, ...viewProps }) {
729
+ const configuration = usePolymorfa();
730
+ const slots = useSlots(classNames);
731
+ const root = useShell(slots, "drawer", "pmfa-drawer", className);
732
+ const panel = useRef(null);
733
+ const titleId = useId();
734
+ const resolved = useResolvedController(controller ?? conversation, createController);
735
+ const composing = useOwnedController(composerController, createComposerController);
736
+ const latestClose = useLatest(onClose);
737
+ const onKeyDown = (event) => {
738
+ if (event.key !== "Escape" ||
739
+ event.defaultPrevented ||
740
+ event.nativeEvent.isComposing ||
741
+ latestClose.current === undefined)
742
+ return;
743
+ event.preventDefault();
744
+ latestClose.current();
745
+ };
746
+ // Focus moves when `open` becomes true after mount, or on mount with
747
+ // `autoFocus`. Closing returns focus to where it was.
748
+ const lastOpen = useRef(undefined);
749
+ const moveFocus = useRef(false);
750
+ const latestAutoFocus = useLatest(autoFocus);
751
+ useEffect(() => {
752
+ // An effect re-run with the same `open` (StrictMode, `<Activity>`) keeps
753
+ // the earlier decision.
754
+ if (lastOpen.current !== open) {
755
+ moveFocus.current =
756
+ open &&
757
+ (lastOpen.current === false ||
758
+ (lastOpen.current === undefined && latestAutoFocus.current));
759
+ lastOpen.current = open;
760
+ }
761
+ if (!moveFocus.current || typeof document === "undefined")
762
+ return;
763
+ const previous = document.activeElement;
764
+ focusFirst(panel.current);
765
+ return () => {
766
+ if (previous instanceof HTMLElement && previous.isConnected)
767
+ previous.focus({ preventScroll: true });
768
+ };
769
+ }, [open, latestAutoFocus]);
770
+ const reply = useMemo(() => onReply === undefined && composing === undefined
771
+ ? undefined
772
+ : (message) => {
773
+ onReply?.(message);
774
+ if (composing !== undefined) {
775
+ composing.setReplyTo(message.id);
776
+ panel.current?.querySelector("textarea")?.focus();
777
+ }
778
+ }, [onReply, composing]);
779
+ if (!open)
780
+ return null;
781
+ const heading = title ?? text(configuration, "chat.title");
782
+ const footer = composer ??
783
+ (composing === undefined ? undefined : (_jsx(ComposeBox, { ...composerProps, controller: composing, conversation: resolved })));
784
+ return (_jsxs("aside", { ...root, ref: panel, tabIndex: -1, onKeyDown: onKeyDown, "data-pmfa": "chat-drawer", role: "dialog", "aria-modal": "false", "aria-labelledby": titleId, children: [_jsxs("header", { ...slots("drawerHeader", "pmfa-drawer-header"), children: [_jsx("h2", { ...slots("drawerTitle", "pmfa-drawer-title"), id: titleId, children: heading }), onClose !== undefined && (_jsx("button", { type: "button", ...slots("drawerClose", "pmfa-btn pmfa-btn-ghost pmfa-btn-icon"), "aria-label": text(configuration, "common.close"), onClick: onClose, children: _jsx(Icon, { name: "close" }) }))] }), _jsx(ConversationLog, { ...viewProps, ...(reply === undefined ? {} : { onReply: reply }), controller: resolved, rootProps: slots("messageList", "pmfa-list"), slots: slots }), _jsx(DrawerError, { controller: resolved, slots: slots }), footer !== undefined && (_jsx("div", { className: "pmfa-drawer-footer", children: footer }))] }));
785
+ }
786
+ function DrawerError({ controller, slots, }) {
787
+ const snapshot = useController(controller);
788
+ const configuration = usePolymorfa();
789
+ if (snapshot.status !== "error")
790
+ return null;
791
+ return (_jsx("p", { ...slots("error", "pmfa-error"), role: "alert", children: snapshot.error ?? text(configuration, "chat.loadError") }));
792
+ }
793
+ // ── Template builder ──────────────────────────────────────────────────
794
+ function Field({ label, slots, children, }) {
795
+ return (_jsxs("label", { ...slots("field", "pmfa-field"), children: [_jsx("span", { ...slots("label", "pmfa-label"), children: label }), children] }));
796
+ }
797
+ export function TemplateBuilder({ controller, createController, renderPreview, className, classNames, }) {
798
+ const resolved = useResolvedController(controller, createController);
799
+ const snapshot = useController(resolved);
800
+ const configuration = usePolymorfa();
801
+ const slots = useSlots(classNames);
802
+ const root = useShell(slots, "templateBuilder", "pmfa-tb", className);
803
+ const definition = snapshot.draft?.definition;
804
+ const busy = snapshot.status === "saving" ||
805
+ snapshot.status === "previewing" ||
806
+ snapshot.status === "submitting";
807
+ const unsaved = snapshot.templateId === undefined || snapshot.dirty;
808
+ const input = slots("input", "pmfa-input");
809
+ return (_jsxs("section", { ...root, "data-pmfa": "template-builder", children: [_jsx("h2", { className: "pmfa-tb-title", children: text(configuration, "templates.title") }), _jsxs("div", { className: "pmfa-tb-grid", children: [_jsxs("div", { className: "pmfa-tb-form", children: [_jsx(Field, { label: text(configuration, "templates.name"), slots: slots, children: _jsx("input", { ...input, "data-field": "name", value: snapshot.draft?.name ?? "", onChange: (event) => resolved.setName(event.currentTarget.value) }) }), definition?.header?.format === "text" && (_jsx(Field, { label: text(configuration, "templates.header"), slots: slots, children: _jsx("input", { ...input, "data-field": "header", value: definition.header.text, onChange: (event) => resolved.updateDefinition({
810
+ header: { format: "text", text: event.currentTarget.value },
811
+ }) }) })), _jsx(Field, { label: text(configuration, "templates.body"), slots: slots, children: _jsx("textarea", { ...input, rows: 4, "data-field": "body", value: definition?.body ?? "", onChange: (event) => resolved.setBody(event.currentTarget.value) }) }), definition?.footer !== undefined && (_jsx(Field, { label: text(configuration, "templates.footer"), slots: slots, children: _jsx("input", { ...input, "data-field": "footer", value: definition.footer, onChange: (event) => resolved.updateDefinition({
812
+ footer: event.currentTarget.value,
813
+ }) }) })), (definition?.variables.length ?? 0) > 0 && (_jsxs("fieldset", { ...slots("field", "pmfa-field"), children: [_jsx("legend", { children: text(configuration, "templates.variables") }), _jsx("div", { className: "pmfa-pairs", children: definition?.variables.map((variable) => (_jsxs("label", { className: "pmfa-pair", children: [_jsx("span", { children: `{{${variable.name}}}` }), _jsx("input", { ...input, "aria-label": text(configuration, "templates.variableExample", {
814
+ name: variable.name,
815
+ }), value: variable.example, onChange: (event) => resolved.setVariableExample(variable.name, event.currentTarget.value) })] }, variable.name))) })] })), (definition?.buttons?.length ?? 0) > 0 && (_jsxs("fieldset", { ...slots("field", "pmfa-field"), children: [_jsx("legend", { children: text(configuration, "templates.buttons") }), _jsx("div", { className: "pmfa-pairs", children: definition?.buttons?.map((templateButton, index) => (_jsxs("label", { className: "pmfa-pair", children: [_jsx("span", { children: templateButton.type.replace("_", " ") }), _jsx("input", { ...input, "aria-label": text(configuration, "templates.buttonText", {
816
+ index: String(index + 1),
817
+ }), value: templateButton.text ?? "", onChange: (event) => {
818
+ const buttons = [...(definition.buttons ?? [])];
819
+ const current = buttons[index];
820
+ if (current !== undefined)
821
+ buttons[index] = {
822
+ ...current,
823
+ text: event.currentTarget.value,
824
+ };
825
+ resolved.updateDefinition({ buttons });
826
+ } })] }, `${templateButton.type}-${index}`))) })] })), (definition?.carousel?.cards.length ?? 0) > 0 && (_jsxs("fieldset", { ...slots("field", "pmfa-field"), children: [_jsx("legend", { children: text(configuration, "templates.cards") }), definition?.carousel?.cards.map((card, index) => (_jsx("textarea", { ...input, "aria-label": text(configuration, "templates.cardBody", {
827
+ index: String(index + 1),
828
+ }), value: card.body, onChange: (event) => {
829
+ const cards = [...(definition.carousel?.cards ?? [])];
830
+ const current = cards[index];
831
+ if (current !== undefined)
832
+ cards[index] = {
833
+ ...current,
834
+ body: event.currentTarget.value,
835
+ };
836
+ resolved.updateDefinition({ carousel: { cards } });
837
+ } }, `card-${index}`)))] })), snapshot.localIssues.map((issue) => (_jsx("p", { ...slots("error", "pmfa-error"), role: "alert", children: issue.message }, `${issue.code}-${issue.path ?? ""}`))), snapshot.error !== undefined && (_jsx("p", { ...slots("error", "pmfa-error"), role: "alert", children: snapshot.error.message })), _jsxs("div", { ...slots("actions", "pmfa-actions"), children: [_jsx("button", { type: "button", ...slots("primaryButton", "pmfa-btn pmfa-btn-primary"), disabled: busy ||
838
+ snapshot.draft === undefined ||
839
+ snapshot.localIssues.length > 0, onClick: () => void resolved.save(), children: text(configuration, "templates.save") }), _jsx("button", { type: "button", ...slots("button", "pmfa-btn"), disabled: busy || unsaved, onClick: () => void resolved.refreshPreview(), children: text(configuration, "templates.preview") }), _jsx("button", { type: "button", ...slots("button", "pmfa-btn"), disabled: busy || unsaved, onClick: () => void resolved.submitToMeta(), children: text(configuration, "templates.submit") })] })] }), _jsx("div", { ...slots("preview", "pmfa-tb-aside"), children: snapshot.preview ? ((renderPreview?.(snapshot.preview.rendered) ?? (_jsx(TemplatePreviewOutput, { preview: snapshot.preview.rendered })))) : (_jsx("p", { className: "pmfa-hint", children: text(configuration, "templates.previewHint") })) })] })] }));
840
+ }
841
+ function TemplatePreviewOutput({ preview, }) {
842
+ return (_jsxs("output", { className: "pmfa-preview", children: [_jsxs("div", { className: "pmfa-preview-bubble", children: [preview.header?.text && (_jsx("strong", { className: "pmfa-preview-header", children: preview.header.text })), _jsx("p", { className: "pmfa-preview-body", children: preview.body }), preview.footer && (_jsx("small", { className: "pmfa-preview-footer", children: preview.footer }))] }), preview.buttons.map((templateButton, index) => (_jsx("span", { className: "pmfa-preview-button", children: templateButton.text }, `${templateButton.type}-${index}`))), preview.cards.length > 0 && (_jsx("div", { className: "pmfa-preview-cards", children: preview.cards.map((card, index) => (_jsxs("article", { className: "pmfa-preview-card", children: [_jsx("p", { className: "pmfa-preview-body", children: card.body }), card.buttons.map((templateButton, buttonIndex) => (_jsx("span", { className: "pmfa-preview-button", children: templateButton.text }, `${templateButton.type}-${buttonIndex}`)))] }, `card-${index}`))) }))] }));
843
+ }