@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.1",
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
- "flex min-h-9 max-h-9 min-w-0 w-auto flex-1 items-center self-center overflow-hidden whitespace-pre! rounded-none px-1 py-2 text-left leading-5";
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
- const sel = window.getSelection();
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 ?? "").replace(/\u200b/g, "");
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 placeCaretAtEnd(el: HTMLElement) {
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 = document.createRange();
65
- range.selectNodeContents(el);
66
- range.collapse(false);
67
- sel.removeAllRanges();
68
- sel.addRange(range);
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.querySelector(`[${CHIPS_HOST_ATTR}]`);
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
- return probe.toString().replace(/\u200b/g, "") === "";
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 setRefs = (node: HTMLDivElement | null) => {
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) === value) return;
155
- Array.from(root.childNodes).forEach((child) => {
156
- if (!(child instanceof HTMLElement && child.hasAttribute(CHIPS_HOST_ATTR))) {
157
- root.removeChild(child);
158
- }
159
- });
160
- if (value !== "") root.appendChild(document.createTextNode(value));
161
- if (document.activeElement === root) placeCaretAtEnd(root);
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
- if (
201
- event.key === "Backspace" &&
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 chipsWidth =
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 =