@vvedantb/ui 0.4.1 → 0.4.2
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Shared React 19 UI for web apps: one JavaScript entry (primitives, shell pieces, app helpers, native tokens), design-token CSS, shared Tailwind v4 CSS and a Tailwind preset.",
|
|
6
6
|
"type": "module",
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
import { SpeechInput } from "../../ai-elements/speech-input";
|
|
23
23
|
import { chatInputContainerClass } from "../../ui/surface-classes";
|
|
24
24
|
import { ComposerSlashMenu } from "./ComposerSlashMenu";
|
|
25
|
-
import { InputAreaEditor } from "./InputAreaEditor";
|
|
25
|
+
import { InputAreaEditor, placeCaretAtEnd } from "./InputAreaEditor";
|
|
26
26
|
import { FadePresence } from "./FadePresence";
|
|
27
27
|
import { InputActions, type InputActionsProps } from "./InputActions";
|
|
28
28
|
import { motionFast } from "./motion";
|
|
@@ -33,8 +33,11 @@ import { useInputAreaPrediction } from "./useInputAreaPrediction";
|
|
|
33
33
|
|
|
34
34
|
const EXPANDED_EDITOR =
|
|
35
35
|
"min-h-16 max-h-50 w-full self-stretch overflow-y-auto rounded-none px-4 pt-3.5 pb-1 text-left";
|
|
36
|
+
// Block, not flex: chips and text must share one inline flow, or a click lands
|
|
37
|
+
// the caret inside a chip. The 36px box (py-2 + leading-5) centres the line, and
|
|
38
|
+
// `align-top` keeps chips level with the text as the old flex row did.
|
|
36
39
|
const COMPACT_EDITOR =
|
|
37
|
-
"
|
|
40
|
+
"block min-h-9 max-h-9 min-w-0 w-auto overflow-hidden whitespace-pre! rounded-none px-1 py-2 text-left leading-5 [&>[data-composer-chips-inline]>*]:align-top";
|
|
38
41
|
const COMPACT_START_ADDON =
|
|
39
42
|
"order-first shrink-0 justify-start gap-1 py-0 pl-1.5 pr-0 has-[>button]:ml-0";
|
|
40
43
|
const COMPACT_END_ADDON =
|
|
@@ -252,14 +255,7 @@ export function InputArea({
|
|
|
252
255
|
const length = editor.value.length;
|
|
253
256
|
editor.setSelectionRange(length, length);
|
|
254
257
|
} else {
|
|
255
|
-
|
|
256
|
-
if (sel) {
|
|
257
|
-
const range = document.createRange();
|
|
258
|
-
range.selectNodeContents(editor);
|
|
259
|
-
range.collapse(false);
|
|
260
|
-
sel.removeAllRanges();
|
|
261
|
-
sel.addRange(range);
|
|
262
|
-
}
|
|
258
|
+
placeCaretAtEnd(editor);
|
|
263
259
|
}
|
|
264
260
|
}
|
|
265
261
|
};
|
|
@@ -28,6 +28,8 @@ export const INLINE_EDITOR_CLASS =
|
|
|
28
28
|
// the text in a column beside it.
|
|
29
29
|
const CHIPS_HOST_CLASS = "contents empty:hidden [&>*]:mr-1.5";
|
|
30
30
|
|
|
31
|
+
const ZERO_WIDTH_SPACE = "\u200b";
|
|
32
|
+
|
|
31
33
|
/** Reads the plain text of the editor. Skips the chips host. */
|
|
32
34
|
export function extractEditorText(root: HTMLElement): string {
|
|
33
35
|
let out = "";
|
|
@@ -50,7 +52,7 @@ export function extractEditorText(root: HTMLElement): string {
|
|
|
50
52
|
return;
|
|
51
53
|
}
|
|
52
54
|
if (child.nodeType === Node.TEXT_NODE) {
|
|
53
|
-
out += (child.textContent ?? "").
|
|
55
|
+
out += (child.textContent ?? "").replaceAll(ZERO_WIDTH_SPACE, "");
|
|
54
56
|
}
|
|
55
57
|
});
|
|
56
58
|
};
|
|
@@ -58,14 +60,105 @@ export function extractEditorText(root: HTMLElement): string {
|
|
|
58
60
|
return out;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
function
|
|
63
|
+
function chipsHost(root: HTMLElement): HTMLElement | null {
|
|
64
|
+
for (const child of root.children) {
|
|
65
|
+
if (child instanceof HTMLElement && child.hasAttribute(CHIPS_HOST_ATTR)) return child;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Returns the text node right after the chips host, creating it if missing.
|
|
72
|
+
* It starts with U+200B, so the caret always has an editable position after
|
|
73
|
+
* the chips, even with an empty draft. `extractEditorText` strips U+200B.
|
|
74
|
+
*/
|
|
75
|
+
function ensureCaretAnchor(root: HTMLElement): Text {
|
|
76
|
+
const existing = findCaretAnchor(root);
|
|
77
|
+
if (existing) return existing;
|
|
78
|
+
const host = chipsHost(root);
|
|
79
|
+
const anchor = document.createTextNode(ZERO_WIDTH_SPACE);
|
|
80
|
+
root.insertBefore(anchor, host ? host.nextSibling : root.firstChild);
|
|
81
|
+
return anchor;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function findCaretAnchor(root: HTMLElement): Text | null {
|
|
85
|
+
const host = chipsHost(root);
|
|
86
|
+
const next = host ? host.nextSibling : root.firstChild;
|
|
87
|
+
return next instanceof Text && next.data.startsWith(ZERO_WIDTH_SPACE) ? next : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function setSelection(
|
|
91
|
+
anchorNode: Node,
|
|
92
|
+
anchorOffset: number,
|
|
93
|
+
focusNode: Node = anchorNode,
|
|
94
|
+
focusOffset: number = anchorOffset,
|
|
95
|
+
) {
|
|
96
|
+
window.getSelection()?.setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** End of the text: the last text node, or the end of the editor after a line break. */
|
|
100
|
+
function textEnd(root: HTMLElement): [Node, number] {
|
|
101
|
+
ensureCaretAnchor(root);
|
|
102
|
+
const last = root.lastChild;
|
|
103
|
+
if (last instanceof Text) return [last, last.length];
|
|
104
|
+
return [root, root.childNodes.length];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Puts a collapsed caret at the end of the text, after the chips host. */
|
|
108
|
+
export function placeCaretAtEnd(root: HTMLElement) {
|
|
109
|
+
const [node, offset] = textEnd(root);
|
|
110
|
+
setSelection(node, offset);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Moves any selection end that sits inside or before the chips host to the
|
|
115
|
+
* first text position after it. The host is React's, so the caret must never
|
|
116
|
+
* rest in it and an edit must never delete it.
|
|
117
|
+
*/
|
|
118
|
+
export function normaliseSelection(root: HTMLElement) {
|
|
62
119
|
const sel = window.getSelection();
|
|
63
|
-
if (!sel) return;
|
|
64
|
-
const range =
|
|
65
|
-
range
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
120
|
+
if (!sel || sel.rangeCount === 0) return;
|
|
121
|
+
const range = sel.getRangeAt(0);
|
|
122
|
+
const { startContainer, startOffset, endContainer, endOffset } = range;
|
|
123
|
+
if (!root.contains(startContainer) || !root.contains(endContainer)) return;
|
|
124
|
+
const anchor = ensureCaretAnchor(root);
|
|
125
|
+
const first = document.createRange();
|
|
126
|
+
first.setStart(anchor, 1);
|
|
127
|
+
// The host comes first, so only the start can be before the text unless both are.
|
|
128
|
+
if (first.comparePoint(startContainer, startOffset) >= 0) return;
|
|
129
|
+
const endBefore = first.comparePoint(endContainer, endOffset) < 0;
|
|
130
|
+
const [end, endAt]: [Node, number] = endBefore ? [anchor, 1] : [endContainer, endOffset];
|
|
131
|
+
const backwards =
|
|
132
|
+
!range.collapsed &&
|
|
133
|
+
sel.anchorNode === endContainer &&
|
|
134
|
+
sel.anchorOffset === endOffset;
|
|
135
|
+
if (backwards) setSelection(end, endAt, anchor, 1);
|
|
136
|
+
else setSelection(anchor, 1, end, endAt);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Selects the text only, never the chips host. */
|
|
140
|
+
function selectText(root: HTMLElement) {
|
|
141
|
+
const anchor = ensureCaretAnchor(root);
|
|
142
|
+
const [node, offset] = textEnd(root);
|
|
143
|
+
setSelection(anchor, 1, node, offset);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Width the inline chips take before the text on the first line. The host is
|
|
148
|
+
* `display: contents` and has no box, so this measures where the text starts.
|
|
149
|
+
*/
|
|
150
|
+
export function readInlineChipsWidth(root: HTMLElement): number {
|
|
151
|
+
const anchor = findCaretAnchor(root);
|
|
152
|
+
if (!anchor || !chipsHost(root)?.childElementCount) return 0;
|
|
153
|
+
// A text offset, not an element boundary: Chromium gives no rect for the latter.
|
|
154
|
+
const textStart = document.createRange();
|
|
155
|
+
textStart.setStart(anchor, 0);
|
|
156
|
+
const style = getComputedStyle(root);
|
|
157
|
+
const contentLeft =
|
|
158
|
+
root.getBoundingClientRect().left +
|
|
159
|
+
root.clientLeft +
|
|
160
|
+
(Number.parseFloat(style.paddingLeft) || 0);
|
|
161
|
+
return Math.max(0, textStart.getBoundingClientRect().left - contentLeft);
|
|
69
162
|
}
|
|
70
163
|
|
|
71
164
|
/** True when nothing but the chips host sits before the caret. */
|
|
@@ -74,13 +167,24 @@ function caretAtTextStart(root: HTMLElement): boolean {
|
|
|
74
167
|
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return false;
|
|
75
168
|
const anchor = sel.anchorNode;
|
|
76
169
|
if (!anchor || !root.contains(anchor)) return false;
|
|
77
|
-
const host = root
|
|
170
|
+
const host = chipsHost(root);
|
|
78
171
|
if (host?.contains(anchor)) return false;
|
|
79
172
|
const probe = document.createRange();
|
|
80
173
|
probe.selectNodeContents(root);
|
|
81
174
|
if (host) probe.setStartAfter(host);
|
|
82
175
|
probe.setEnd(anchor, sel.anchorOffset);
|
|
83
|
-
|
|
176
|
+
const before = probe.cloneContents();
|
|
177
|
+
return (
|
|
178
|
+
(before.textContent ?? "").replaceAll(ZERO_WIDTH_SPACE, "") === "" &&
|
|
179
|
+
before.querySelector("br, div, p") === null
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Clicks on chip controls, such as remove buttons, keep their own behaviour. */
|
|
184
|
+
function isChipControl(target: EventTarget | null): boolean {
|
|
185
|
+
if (!(target instanceof Element)) return false;
|
|
186
|
+
const control = target.closest("button, a, input, [role=button]");
|
|
187
|
+
return control?.closest(`[${CHIPS_HOST_ATTR}]`) != null;
|
|
84
188
|
}
|
|
85
189
|
|
|
86
190
|
function insertPlainText(root: HTMLElement, text: string) {
|
|
@@ -121,7 +225,8 @@ export type InputAreaEditorProps = {
|
|
|
121
225
|
* Contenteditable editor. The chips host is a non-editable inline atom at the
|
|
122
226
|
* start of the same text flow, so wrapped lines start under the chips. React
|
|
123
227
|
* owns the host only. The text nodes after it belong to the DOM and are
|
|
124
|
-
* reconciled against `value
|
|
228
|
+
* reconciled against `value`, behind a U+200B caret anchor that keeps an
|
|
229
|
+
* editable position after the chips.
|
|
125
230
|
*/
|
|
126
231
|
export function InputAreaEditor({
|
|
127
232
|
editorRef,
|
|
@@ -142,23 +247,45 @@ export function InputAreaEditor({
|
|
|
142
247
|
const composingRef = useRef(false);
|
|
143
248
|
const ghostProbeRef = useRef<HTMLSpanElement | null>(null);
|
|
144
249
|
|
|
145
|
-
const
|
|
250
|
+
const assignEditorRef = (node: HTMLDivElement | null) => {
|
|
146
251
|
rootRef.current = node;
|
|
147
252
|
if (typeof editorRef === "function") editorRef(node);
|
|
148
253
|
else if (editorRef) editorRef.current = node;
|
|
149
254
|
};
|
|
150
255
|
|
|
256
|
+
const keepCaretOutsideChips = (root: HTMLElement) => {
|
|
257
|
+
if (!composingRef.current) normaliseSelection(root);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// Native listeners: React has no `selectionchange`, and its `onBeforeInput`
|
|
261
|
+
// does not see deletions.
|
|
262
|
+
const setRefs = (node: HTMLDivElement) => {
|
|
263
|
+
assignEditorRef(node);
|
|
264
|
+
const keepSelectionOutsideChips = () => {
|
|
265
|
+
if (document.activeElement === node) keepCaretOutsideChips(node);
|
|
266
|
+
};
|
|
267
|
+
document.addEventListener("selectionchange", keepSelectionOutsideChips);
|
|
268
|
+
node.addEventListener("beforeinput", keepSelectionOutsideChips);
|
|
269
|
+
return () => {
|
|
270
|
+
document.removeEventListener("selectionchange", keepSelectionOutsideChips);
|
|
271
|
+
node.removeEventListener("beforeinput", keepSelectionOutsideChips);
|
|
272
|
+
assignEditorRef(null);
|
|
273
|
+
};
|
|
274
|
+
};
|
|
275
|
+
|
|
151
276
|
useLayoutEffect(() => {
|
|
152
277
|
const root = rootRef.current;
|
|
153
278
|
if (!root || composingRef.current) return;
|
|
154
|
-
if (extractEditorText(root)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
279
|
+
if (extractEditorText(root) !== value) {
|
|
280
|
+
Array.from(root.childNodes).forEach((child) => {
|
|
281
|
+
if (!(child instanceof HTMLElement && child.hasAttribute(CHIPS_HOST_ATTR))) {
|
|
282
|
+
root.removeChild(child);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
root.appendChild(document.createTextNode(ZERO_WIDTH_SPACE + value));
|
|
286
|
+
if (document.activeElement === root) placeCaretAtEnd(root);
|
|
287
|
+
}
|
|
288
|
+
ensureCaretAnchor(root);
|
|
162
289
|
});
|
|
163
290
|
|
|
164
291
|
// Copy the probe colour to the editor. Opacity below 1 is folded into the
|
|
@@ -185,8 +312,20 @@ export function InputAreaEditor({
|
|
|
185
312
|
onKeyDown?.(event);
|
|
186
313
|
if (event.defaultPrevented) return;
|
|
187
314
|
const root = event.currentTarget;
|
|
315
|
+
if (composingRef.current || event.nativeEvent.isComposing) return;
|
|
316
|
+
if (
|
|
317
|
+
(event.metaKey || event.ctrlKey) &&
|
|
318
|
+
!event.altKey &&
|
|
319
|
+
!event.shiftKey &&
|
|
320
|
+
event.key.toLowerCase() === "a"
|
|
321
|
+
) {
|
|
322
|
+
event.preventDefault();
|
|
323
|
+
selectText(root);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
// Before the default action, so a selection never spans the chips host.
|
|
327
|
+
normaliseSelection(root);
|
|
188
328
|
if (event.key === "Enter") {
|
|
189
|
-
if (composingRef.current || event.nativeEvent.isComposing) return;
|
|
190
329
|
if (event.shiftKey) return;
|
|
191
330
|
event.preventDefault();
|
|
192
331
|
const form = root.closest("form");
|
|
@@ -197,13 +336,10 @@ export function InputAreaEditor({
|
|
|
197
336
|
form?.requestSubmit();
|
|
198
337
|
return;
|
|
199
338
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
chipCount > 0 &&
|
|
203
|
-
caretAtTextStart(root)
|
|
204
|
-
) {
|
|
339
|
+
// Nothing visible to delete. The default would eat the U+200B anchor.
|
|
340
|
+
if (event.key === "Backspace" && caretAtTextStart(root)) {
|
|
205
341
|
event.preventDefault();
|
|
206
|
-
onRemoveLastChip?.();
|
|
342
|
+
if (chipCount > 0) onRemoveLastChip?.();
|
|
207
343
|
}
|
|
208
344
|
};
|
|
209
345
|
|
|
@@ -231,6 +367,7 @@ export function InputAreaEditor({
|
|
|
231
367
|
className={cn(INLINE_EDITOR_CLASS, className)}
|
|
232
368
|
onInput={(event) => {
|
|
233
369
|
if (composingRef.current) return;
|
|
370
|
+
ensureCaretAnchor(event.currentTarget);
|
|
234
371
|
onValueChange(extractEditorText(event.currentTarget));
|
|
235
372
|
}}
|
|
236
373
|
onCompositionStart={() => {
|
|
@@ -238,9 +375,15 @@ export function InputAreaEditor({
|
|
|
238
375
|
}}
|
|
239
376
|
onCompositionEnd={(event) => {
|
|
240
377
|
composingRef.current = false;
|
|
378
|
+
ensureCaretAnchor(event.currentTarget);
|
|
241
379
|
onValueChange(extractEditorText(event.currentTarget));
|
|
242
380
|
}}
|
|
243
381
|
onKeyDown={handleKeyDown}
|
|
382
|
+
onFocus={(event) => keepCaretOutsideChips(event.currentTarget)}
|
|
383
|
+
onMouseUp={(event) => {
|
|
384
|
+
if (!isChipControl(event.target)) keepCaretOutsideChips(event.currentTarget);
|
|
385
|
+
}}
|
|
386
|
+
onKeyUp={(event) => keepCaretOutsideChips(event.currentTarget)}
|
|
244
387
|
onPaste={handlePaste}
|
|
245
388
|
onBlur={onBlur}
|
|
246
389
|
>
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
isComposerCompact,
|
|
6
6
|
readEditorInnerWidth,
|
|
7
7
|
} from "./composerCompact";
|
|
8
|
+
import { readInlineChipsWidth } from "./InputAreaEditor";
|
|
8
9
|
|
|
9
10
|
export function useComposerCompact({
|
|
10
11
|
value,
|
|
@@ -24,10 +25,7 @@ export function useComposerCompact({
|
|
|
24
25
|
const editor = editorNode instanceof HTMLElement ? editorNode : null;
|
|
25
26
|
const font = editor ? getComputedStyle(editor).font : "14px sans-serif";
|
|
26
27
|
if (editor && wasCompactRef.current) {
|
|
27
|
-
const
|
|
28
|
-
editor.querySelector<HTMLElement>("[data-composer-chips-inline]")
|
|
29
|
-
?.offsetWidth ?? 0;
|
|
30
|
-
const inner = readEditorInnerWidth(editor) - chipsWidth;
|
|
28
|
+
const inner = readEditorInnerWidth(editor) - readInlineChipsWidth(editor);
|
|
31
29
|
if (inner > 0) pillWidthRef.current = inner;
|
|
32
30
|
}
|
|
33
31
|
const exceedsPill =
|