@ai-composer/dom 0.0.0-stage → 0.1.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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +5 -2
  3. package/dist/icons.d.ts +11 -0
  4. package/dist/icons.d.ts.map +1 -0
  5. package/dist/icons.js +46 -0
  6. package/dist/icons.js.map +1 -0
  7. package/dist/index.d.ts +20 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +20 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/mount.d.ts +41 -0
  12. package/dist/mount.d.ts.map +1 -0
  13. package/dist/mount.js +168 -0
  14. package/dist/mount.js.map +1 -0
  15. package/dist/package.json +25 -0
  16. package/dist/parse.d.ts +13 -0
  17. package/dist/parse.d.ts.map +1 -0
  18. package/dist/parse.js +133 -0
  19. package/dist/parse.js.map +1 -0
  20. package/dist/popup.d.ts +34 -0
  21. package/dist/popup.d.ts.map +1 -0
  22. package/dist/popup.js +121 -0
  23. package/dist/popup.js.map +1 -0
  24. package/dist/render.d.ts +17 -0
  25. package/dist/render.d.ts.map +1 -0
  26. package/dist/render.js +84 -0
  27. package/dist/render.js.map +1 -0
  28. package/dist/selection.d.ts +13 -0
  29. package/dist/selection.d.ts.map +1 -0
  30. package/dist/selection.js +110 -0
  31. package/dist/selection.js.map +1 -0
  32. package/dist/suggestions.d.ts +23 -0
  33. package/dist/suggestions.d.ts.map +1 -0
  34. package/dist/suggestions.js +113 -0
  35. package/dist/suggestions.js.map +1 -0
  36. package/dist/surface.d.ts +40 -0
  37. package/dist/surface.d.ts.map +1 -0
  38. package/dist/surface.js +372 -0
  39. package/dist/surface.js.map +1 -0
  40. package/dist/template.d.ts +26 -0
  41. package/dist/template.d.ts.map +1 -0
  42. package/dist/template.js +47 -0
  43. package/dist/template.js.map +1 -0
  44. package/package.json +21 -3
  45. package/project.json +56 -0
  46. package/src/icons.ts +51 -0
  47. package/src/index.ts +50 -0
  48. package/src/mount.ts +218 -0
  49. package/src/parse.ts +135 -0
  50. package/src/popup.ts +153 -0
  51. package/src/render.ts +88 -0
  52. package/src/selection.ts +141 -0
  53. package/src/suggestions.ts +146 -0
  54. package/src/surface.test.ts +266 -0
  55. package/src/surface.ts +433 -0
  56. package/src/template.ts +80 -0
  57. package/tsconfig.json +15 -0
  58. package/tsconfig.lib.json +15 -0
  59. package/tsconfig.spec.json +16 -0
  60. package/vitest.config.ts +16 -0
@@ -0,0 +1,141 @@
1
+ /**
2
+ * DOM selection ↔ model selection mapping. All coordinates are computed as
3
+ * document-wide offsets over the same segment order the renderer produces:
4
+ * text characters, `<br>` = 1, chip = 1.
5
+ */
6
+
7
+ import {
8
+ clampSelection,
9
+ createSelection,
10
+ fromGlobalOffset,
11
+ globalRange,
12
+ type AIComposerDocument,
13
+ type SelectionState,
14
+ } from '@ai-composer/core';
15
+
16
+ interface DomPoint {
17
+ node: Node;
18
+ offset: number;
19
+ }
20
+
21
+ function isChip(node: Node): boolean {
22
+ return node instanceof HTMLElement && node.hasAttribute('data-aic-node');
23
+ }
24
+
25
+ /** Global offset of a DOM point inside the host, or null when outside. */
26
+ function globalOffsetOfPoint(host: HTMLElement, target: Node, targetOffset: number): number | null {
27
+ let offset = 0;
28
+ let found = false;
29
+
30
+ const visit = (current: Node): boolean => {
31
+ if (found) return true;
32
+ if (current === target) {
33
+ offset += targetOffset;
34
+ found = true;
35
+ return true;
36
+ }
37
+ if (current.nodeType === Node.TEXT_NODE) {
38
+ offset += current.textContent?.length ?? 0;
39
+ return false;
40
+ }
41
+ if (current.nodeName === 'BR' || isChip(current)) {
42
+ offset += 1;
43
+ return false;
44
+ }
45
+ for (const child of Array.from(current.childNodes)) {
46
+ if (visit(child)) return true;
47
+ }
48
+ return false;
49
+ };
50
+
51
+ visit(host);
52
+ return found ? offset : null;
53
+ }
54
+
55
+ /** DOM point at a global offset, or the host end as fallback. */
56
+ function domPointAtGlobal(host: HTMLElement, globalOffset: number): DomPoint {
57
+ let remaining = globalOffset;
58
+
59
+ const pointBefore = (node: Node): DomPoint => ({ node: node.parentNode ?? host, offset: indexOfChild(node) });
60
+ const indexOfChild = (node: Node): number =>
61
+ node.parentNode ? Array.prototype.indexOf.call(node.parentNode.childNodes, node) : 0;
62
+
63
+ const visit = (parent: Node): DomPoint | null => {
64
+ for (const child of Array.from(parent.childNodes)) {
65
+ if (child.nodeType === Node.TEXT_NODE) {
66
+ const length = child.textContent?.length ?? 0;
67
+ if (remaining <= length) return { node: child, offset: remaining };
68
+ remaining -= length;
69
+ continue;
70
+ }
71
+ if (child.nodeName === 'BR' || isChip(child)) {
72
+ if (remaining <= 0) return pointBefore(child);
73
+ remaining -= 1;
74
+ if (remaining <= 0) return { node: child.parentNode ?? host, offset: indexOfChild(child) + 1 };
75
+ continue;
76
+ }
77
+ const nested = visit(child);
78
+ if (nested) return nested;
79
+ }
80
+ return null;
81
+ };
82
+
83
+ return visit(host) ?? { node: host, offset: host.childNodes.length };
84
+ }
85
+
86
+ /** Read the DOM selection inside `host` as a model selection. */
87
+ export function domSelectionToModel(
88
+ host: HTMLElement,
89
+ document_: AIComposerDocument,
90
+ ): SelectionState | null {
91
+ const selection = host.ownerDocument.getSelection();
92
+ if (!selection || selection.rangeCount === 0) return null;
93
+ const anchorNode = selection.anchorNode;
94
+ const focusNode = selection.focusNode;
95
+ if (!anchorNode || !focusNode || !host.contains(anchorNode) || !host.contains(focusNode)) {
96
+ return null;
97
+ }
98
+
99
+ const anchorOffset = globalOffsetOfPoint(host, anchorNode, selection.anchorOffset);
100
+ const focusOffset = globalOffsetOfPoint(host, focusNode, selection.focusOffset);
101
+ if (anchorOffset === null || focusOffset === null) return null;
102
+
103
+ return clampSelection(
104
+ document_,
105
+ createSelection(fromGlobalOffset(document_, anchorOffset), fromGlobalOffset(document_, focusOffset)),
106
+ );
107
+ }
108
+
109
+ /** Apply a model selection to the DOM (caret restore after renders). */
110
+ export function applyModelSelection(
111
+ host: HTMLElement,
112
+ document_: AIComposerDocument,
113
+ selection: SelectionState,
114
+ ): void {
115
+ const ownerDocument = host.ownerDocument;
116
+ const domSelection = ownerDocument.getSelection();
117
+ if (!domSelection) return;
118
+
119
+ const { start, end } = globalRange(document_, selection);
120
+ const startPoint = domPointAtGlobal(host, start);
121
+ const endPoint = start === end ? startPoint : domPointAtGlobal(host, end);
122
+
123
+ try {
124
+ const range = ownerDocument.createRange();
125
+ range.setStart(startPoint.node, startPoint.offset);
126
+ range.setEnd(endPoint.node, endPoint.offset);
127
+ domSelection.removeAllRanges();
128
+ domSelection.addRange(range);
129
+ } catch {
130
+ // Browsers can reject boundary points mid-render; the next render retries.
131
+ }
132
+ }
133
+
134
+ /** Caret to the end of the host content. */
135
+ export function caretToEnd(host: HTMLElement, document_: AIComposerDocument): SelectionState {
136
+ const length = document_.nodes.reduce(
137
+ (sum, node) => sum + (node.type === 'text' ? node.text.length : 1),
138
+ 0,
139
+ );
140
+ return createSelection(fromGlobalOffset(document_, length));
141
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Suggestion menu — a true popup (ADR-0001 style projection): it opens at the
3
+ * caret where the trigger key was hit, never reserving DOM space, and flips /
4
+ * clamps itself to the viewport (see ./popup). Keyboard navigation lives in
5
+ * the editable surface; this module renders and handles clicks.
6
+ */
7
+
8
+ import type { AIComposer, SuggestionItem, Unsubscribe } from '@ai-composer/core';
9
+ import { attachCaretAnchoredPopup, type CaretAnchor } from './popup';
10
+
11
+ export interface SuggestionListOptions {
12
+ /** Custom item renderer; default is a simple label/description row. */
13
+ renderItem?: (item: SuggestionItem, active: boolean) => HTMLElement;
14
+ /** The editable host to wire aria-controls / aria-activedescendant onto. */
15
+ inputHost?: HTMLElement;
16
+ /** Preferred popup placement relative to the caret; flips to fit the viewport. */
17
+ placement?: 'above' | 'below';
18
+ /** Decorative keyboard-hints footer (default true). */
19
+ showKbdHints?: boolean;
20
+ }
21
+
22
+ export interface SuggestionList {
23
+ readonly element: HTMLElement;
24
+ destroy(): void;
25
+ }
26
+
27
+ let listCounter = 0;
28
+
29
+ /** Decorative footer: ↑↓ navigate · ↵ select · esc close (aria-hidden). */
30
+ function kbdHintsFooter(): HTMLElement {
31
+ const li = document.createElement('li');
32
+ li.className = 'aic-suggestion-footer';
33
+ li.setAttribute('role', 'presentation');
34
+ li.setAttribute('aria-hidden', 'true');
35
+ const hints: Array<[string, string]> = [
36
+ ['↑↓', 'navigate'],
37
+ ['↵', 'select'],
38
+ ['esc', 'close'],
39
+ ];
40
+ hints.forEach(([key, action], index) => {
41
+ const kbd = document.createElement('kbd');
42
+ kbd.textContent = key;
43
+ li.append(kbd, document.createTextNode(action));
44
+ if (index < hints.length - 1) li.append(document.createElement('span'));
45
+ });
46
+ return li;
47
+ }
48
+
49
+ export function createSuggestionList(
50
+ editor: AIComposer,
51
+ element?: HTMLElement,
52
+ options: SuggestionListOptions = {},
53
+ ): SuggestionList {
54
+ const list = element ?? document.createElement('ul');
55
+ if (!list.id) list.id = `aic-suggestions-${(listCounter += 1)}`;
56
+ list.className = 'aic-suggestions';
57
+ list.setAttribute('role', 'listbox');
58
+ list.setAttribute('aria-hidden', 'true');
59
+ list.hidden = true;
60
+
61
+ let anchor: CaretAnchor | null = null;
62
+
63
+ const defaultItem = (item: SuggestionItem, active: boolean): HTMLElement => {
64
+ const li = document.createElement('li');
65
+ li.className = 'aic-suggestion' + (active ? ' aic-active' : '');
66
+ li.setAttribute('role', 'option');
67
+ if (item.description) {
68
+ const label = document.createElement('span');
69
+ label.className = 'aic-suggestion-label';
70
+ label.textContent = item.label;
71
+ const description = document.createElement('span');
72
+ description.className = 'aic-suggestion-description';
73
+ description.textContent = item.description;
74
+ li.append(label, description);
75
+ } else {
76
+ li.textContent = item.label;
77
+ }
78
+ return li;
79
+ };
80
+
81
+ const renderItem = options.renderItem ?? defaultItem;
82
+
83
+ const sync = (state: ReturnType<AIComposer['getState']>): void => {
84
+ const open = state.activeTrigger !== null && state.suggestions.length > 0;
85
+ list.hidden = !open;
86
+ list.setAttribute('aria-hidden', String(!open));
87
+
88
+ const input = options.inputHost;
89
+ if (input) {
90
+ input.setAttribute('aria-controls', list.id);
91
+ input.setAttribute('aria-expanded', String(open));
92
+ if (open && state.activeSuggestionIndex >= 0) {
93
+ input.setAttribute('aria-activedescendant', `${list.id}-option-${state.activeSuggestionIndex}`);
94
+ } else {
95
+ input.removeAttribute('aria-activedescendant');
96
+ }
97
+ }
98
+
99
+ list.textContent = '';
100
+ if (!open) return;
101
+
102
+ if (input && !anchor) {
103
+ // Anchors to the caret inside the input host; the popup itself must be
104
+ // attached to a positioned ancestor (`.aic-input-wrap`) — by the time it
105
+ // opens it has been appended there.
106
+ anchor = attachCaretAnchoredPopup(list, input, { placement: options.placement });
107
+ }
108
+
109
+ state.suggestions.forEach((item, index) => {
110
+ const active = index === state.activeSuggestionIndex;
111
+ const li = renderItem(item, active);
112
+ li.id = `${list.id}-option-${index}`;
113
+ li.setAttribute('role', 'option');
114
+ li.setAttribute('aria-selected', String(active));
115
+ li.addEventListener('mousedown', (event) => event.preventDefault()); // keep caret in place
116
+ li.addEventListener('click', () => {
117
+ void editor.acceptSuggestion(item);
118
+ });
119
+ list.appendChild(li);
120
+ });
121
+
122
+ if (options.showKbdHints !== false) {
123
+ list.appendChild(kbdHintsFooter());
124
+ }
125
+
126
+ // Position after items exist so the measured height is real.
127
+ anchor?.update();
128
+ };
129
+
130
+ const unsubscribe: Unsubscribe = editor.subscribe(sync);
131
+ const offDestroy = editor.on('destroy', () => destroy());
132
+
133
+ function destroy(): void {
134
+ unsubscribe();
135
+ offDestroy();
136
+ anchor?.destroy();
137
+ anchor = null;
138
+ list.textContent = '';
139
+ list.hidden = true;
140
+ list.remove();
141
+ }
142
+
143
+ sync(editor.getState());
144
+
145
+ return { element: list, destroy };
146
+ }
@@ -0,0 +1,266 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ createAttachmentNode,
4
+ createAIComposer,
5
+ createDefaultNodeRegistry,
6
+ createMentionNode,
7
+ createTextNode,
8
+ } from '@ai-composer/core';
9
+ import { createEditableSurface } from './surface';
10
+ import { parseEditableHost, htmlToPlainText } from './parse';
11
+ import { mountAIComposer } from './mount';
12
+
13
+ const registry = createDefaultNodeRegistry();
14
+
15
+ let container: HTMLElement;
16
+
17
+ beforeEach(() => {
18
+ container = document.createElement('div');
19
+ document.body.appendChild(container);
20
+ });
21
+
22
+ describe('parse + render round-trip', () => {
23
+ it('parses text and chips back into the model', () => {
24
+ const editor = createAIComposer({
25
+ value: [createTextNode('hi '), createMentionNode({ id: 'u1', label: 'Ada' }), createTextNode('!')],
26
+ });
27
+ const host = document.createElement('div');
28
+ const surface = createEditableSurface(editor, host);
29
+ container.appendChild(host);
30
+
31
+ const parsed = parseEditableHost(host, editor.nodes);
32
+ expect(parsed.nodes.map((n) => (n.type === 'text' ? n.text : n.type))).toEqual([
33
+ 'hi ',
34
+ 'mention',
35
+ '!',
36
+ ]);
37
+ surface.destroy();
38
+ });
39
+
40
+ it('flattens unknown markup to plain text (sanitization)', () => {
41
+ const host = document.createElement('div');
42
+ host.innerHTML = 'a<div onclick="evil()"><b>b</b></div><script>alert(1)</script>c';
43
+ const parsed = parseEditableHost(host, registry);
44
+ expect(parsed.nodes).toHaveLength(1);
45
+ expect((parsed.nodes[0] as { text: string }).text).toBe('abc');
46
+ });
47
+
48
+ it('converts <br> to newlines', () => {
49
+ const host = document.createElement('div');
50
+ host.innerHTML = 'one<br>two';
51
+ const parsed = parseEditableHost(host, registry);
52
+ expect((parsed.nodes[0] as { text: string }).text).toBe('one\ntwo');
53
+ });
54
+
55
+ it('extracts plain text from HTML on the paste path', () => {
56
+ expect(htmlToPlainText('<p>Hello</p><p><b>World</b></p>')).toContain('Hello');
57
+ expect(htmlToPlainText('a<br>b')).toBe('a\nb');
58
+ });
59
+ });
60
+
61
+ describe('editable surface', () => {
62
+ it('shows attachments in the attachments slot, not inline with input text', () => {
63
+ const editor = createAIComposer({
64
+ value: [createTextNode('Review this'), createAttachmentNode({
65
+ id: 'f1',
66
+ name: 'spec.pdf',
67
+ mimeType: 'application/pdf',
68
+ })],
69
+ });
70
+ const mounted = mountAIComposer(container, editor, { mode: 'chat' });
71
+ const inlineAttachment = mounted.input.querySelector('[data-aic-node="attachment"]');
72
+
73
+ expect(inlineAttachment?.hasAttribute('hidden')).toBe(true);
74
+ expect(mounted.slots.attachments.querySelectorAll('.aic-attachment-name')).toHaveLength(1);
75
+ expect(mounted.slots.attachments.textContent).toContain('spec.pdf');
76
+ expect(parseEditableHost(mounted.input, editor.nodes).nodes.map((node) => node.type)).toEqual([
77
+ 'text',
78
+ 'attachment',
79
+ ]);
80
+
81
+ mounted.destroy();
82
+ });
83
+
84
+ it('renders the document into the host', () => {
85
+ const editor = createAIComposer({ value: 'hello' });
86
+ const host = document.createElement('div');
87
+ const surface = createEditableSurface(editor, host);
88
+ expect(host.textContent).toBe('hello');
89
+ surface.destroy();
90
+ });
91
+
92
+ it('syncs user DOM edits back into the model', () => {
93
+ const editor = createAIComposer();
94
+ const host = document.createElement('div');
95
+ const surface = createEditableSurface(editor, host);
96
+ container.appendChild(host);
97
+
98
+ host.textContent = 'typed by user';
99
+ host.dispatchEvent(new Event('input', { bubbles: true }));
100
+
101
+ expect(editor.serialize('text')).toBe('typed by user');
102
+ surface.destroy();
103
+ });
104
+
105
+ it('re-renders on programmatic model changes', () => {
106
+ const editor = createAIComposer();
107
+ const host = document.createElement('div');
108
+ const surface = createEditableSurface(editor, host);
109
+ editor.insertNode(createMentionNode({ id: 'u1', label: 'Ada' }));
110
+ expect(host.textContent).toBe('@Ada ');
111
+ expect(host.querySelector('[data-aic-node="mention"]')).not.toBeNull();
112
+ surface.destroy();
113
+ });
114
+
115
+ it('Enter submits when suggestions are closed (submitKey=enter)', () => {
116
+ const onSubmit = vi.fn();
117
+ const editor = createAIComposer({ value: 'hello', submit: { onSubmit } });
118
+ const host = document.createElement('div');
119
+ const surface = createEditableSurface(editor, host);
120
+ container.appendChild(host);
121
+
122
+ const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
123
+ host.dispatchEvent(enter);
124
+
125
+ expect(enter.defaultPrevented).toBe(true);
126
+ expect(onSubmit).toHaveBeenCalledTimes(1);
127
+ surface.destroy();
128
+ });
129
+
130
+ it('Shift+Enter inserts a newline instead of submitting', () => {
131
+ const onSubmit = vi.fn();
132
+ const editor = createAIComposer({ value: 'hello', submit: { onSubmit } });
133
+ const host = document.createElement('div');
134
+ const surface = createEditableSurface(editor, host);
135
+ container.appendChild(host);
136
+
137
+ host.dispatchEvent(
138
+ new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, bubbles: true, cancelable: true }),
139
+ );
140
+
141
+ expect(onSubmit).not.toHaveBeenCalled();
142
+ expect(editor.serialize('text')).toBe('hello\n');
143
+ surface.destroy();
144
+ });
145
+
146
+ it('undo/redo keyboard shortcuts route through history', () => {
147
+ const editor = createAIComposer();
148
+ const host = document.createElement('div');
149
+ const surface = createEditableSurface(editor, host);
150
+ container.appendChild(host);
151
+
152
+ host.textContent = 'step';
153
+ host.dispatchEvent(new Event('input', { bubbles: true }));
154
+ expect(editor.serialize('text')).toBe('step');
155
+
156
+ host.dispatchEvent(
157
+ new KeyboardEvent('keydown', { key: 'z', metaKey: true, bubbles: true, cancelable: true }),
158
+ );
159
+ expect(editor.getState().empty).toBe(true);
160
+
161
+ host.dispatchEvent(
162
+ new KeyboardEvent('keydown', { key: 'z', metaKey: true, shiftKey: true, bubbles: true, cancelable: true }),
163
+ );
164
+ expect(editor.serialize('text')).toBe('step');
165
+ surface.destroy();
166
+ });
167
+
168
+ it('reflects disabled/readonly state on the host', () => {
169
+ const editor = createAIComposer();
170
+ const host = document.createElement('div');
171
+ const surface = createEditableSurface(editor, host);
172
+ expect(host.getAttribute('contenteditable')).toBe('true');
173
+
174
+ editor.setReadonly(true);
175
+ expect(host.getAttribute('contenteditable')).toBe('false');
176
+ expect(host.hasAttribute('data-aic-readonly')).toBe(true);
177
+
178
+ editor.setReadonly(false);
179
+ editor.setDisabled(true);
180
+ expect(host.getAttribute('contenteditable')).toBe('false');
181
+ surface.destroy();
182
+ });
183
+
184
+ it('placeholder attributes are managed for CSS', () => {
185
+ const editor = createAIComposer({ placeholder: 'Ask anything…' });
186
+ const host = document.createElement('div');
187
+ const surface = createEditableSurface(editor, host);
188
+ expect(host.getAttribute('data-placeholder')).toBe('Ask anything…');
189
+ expect(host.hasAttribute('data-aic-empty')).toBe(true);
190
+ surface.destroy();
191
+ });
192
+ });
193
+
194
+ describe('mountAIComposer (vanilla)', () => {
195
+ it('builds the chat layout and submits from the toolbar', async () => {
196
+ const onSubmit = vi.fn();
197
+ const editor = createAIComposer({ value: 'ping', mode: 'chat', submit: { onSubmit } });
198
+ const mounted = mountAIComposer(container, editor);
199
+
200
+ expect(mounted.root.getAttribute('data-aic-mode')).toBe('chat');
201
+ expect(mounted.slots.toolbar).toBeDefined();
202
+ expect(mounted.input.getAttribute('role')).toBe('textbox');
203
+
204
+ const submitButton = mounted.slots.toolbar.querySelector<HTMLButtonElement>('[data-aic-action="submit"]');
205
+ expect(submitButton).not.toBeNull();
206
+ submitButton!.click();
207
+ await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
208
+
209
+ mounted.destroy();
210
+ expect(container.textContent).toBe('');
211
+ });
212
+
213
+ it('compact renders inline send; modes switch live without remount', () => {
214
+ const editor = createAIComposer({ mode: 'compact', value: 'draft' });
215
+ const mounted = mountAIComposer(container, editor);
216
+
217
+ expect(mounted.root.getAttribute('data-aic-mode')).toBe('compact');
218
+ // Compact keeps one line: chips + input + send laid out by CSS.
219
+ expect(mounted.slots.toolbar.hidden).toBe(false);
220
+ expect(mounted.slots.toolbar.querySelector('[data-aic-action="submit"]')).not.toBeNull();
221
+ expect(mounted.slots.header.hidden).toBe(true);
222
+ expect(mounted.slots.footer.hidden).toBe(true);
223
+ // Compact is multiline-capable (wraps + grows); single line stays flagged.
224
+ expect(mounted.input.getAttribute('aria-multiline')).toBe('true');
225
+ expect(mounted.input.hasAttribute('data-aic-multiline')).toBe(false);
226
+
227
+ editor.setMode('chat');
228
+ expect(mounted.root.getAttribute('data-aic-mode')).toBe('chat');
229
+ expect(mounted.slots.toolbar.hidden).toBe(false);
230
+ expect(mounted.slots.toolbar.querySelector('[data-aic-action="submit"]')).not.toBeNull();
231
+ // Same surface instance — the draft and undo history survive the switch.
232
+ expect(editor.serialize('text')).toBe('draft');
233
+ expect(mounted.input.textContent).toBe('draft');
234
+ mounted.destroy();
235
+ });
236
+
237
+ it('multiline detection flags wrapped/newline content (data-aic-multiline)', () => {
238
+ const editor = createAIComposer({ mode: 'compact' });
239
+ const mounted = mountAIComposer(container, editor);
240
+
241
+ mounted.input.textContent = 'line one\nline two';
242
+ mounted.input.dispatchEvent(new Event('input', { bubbles: true }));
243
+ expect(mounted.input.hasAttribute('data-aic-multiline')).toBe(true);
244
+ // Mirrored onto the root — theme CSS uses plain attribute selectors.
245
+ expect(mounted.root.hasAttribute('data-aic-multiline')).toBe(true);
246
+
247
+ editor.setValue('single line');
248
+ expect(mounted.input.hasAttribute('data-aic-multiline')).toBe(false);
249
+ expect(mounted.root.hasAttribute('data-aic-multiline')).toBe(false);
250
+ mounted.destroy();
251
+ });
252
+
253
+ it('typing through the mount updates state and events', () => {
254
+ const editor = createAIComposer({ mode: 'chat' });
255
+ const changes: string[] = [];
256
+ editor.on('change', (event) => changes.push(event.source));
257
+ const mounted = mountAIComposer(container, editor);
258
+
259
+ mounted.input.textContent = 'hello';
260
+ mounted.input.dispatchEvent(new Event('input', { bubbles: true }));
261
+
262
+ expect(editor.serialize('text')).toBe('hello');
263
+ expect(changes).toContain('user');
264
+ mounted.destroy();
265
+ });
266
+ });