@vvedantb/ui 0.4.0 → 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.0",
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",
@@ -52,15 +52,6 @@
52
52
  "sideEffects": [
53
53
  "./src/ui/sonner.css"
54
54
  ],
55
- "scripts": {
56
- "check:tokens": "node tokens/scripts/check-component-tokens.mjs",
57
- "build": "node tokens/scripts/check-exports.mjs && node tokens/scripts/check-component-tokens.mjs && tsc -p tsconfig.json",
58
- "typecheck": "tsc -p tsconfig.json && tsc -p tokens/tsconfig.json",
59
- "test": "node tokens/scripts/check-component-tokens.mjs && vitest run && node --test tokens/test/*.test.mjs",
60
- "verify": "node tokens/scripts/check-component-tokens.mjs && node tokens/verify/run.mjs",
61
- "analyze": "node tokens/verify/analyze.mjs",
62
- "prepack": "node tokens/scripts/check-exports.mjs"
63
- },
64
55
  "peerDependencies": {
65
56
  "@clerk/clerk-react": ">=5",
66
57
  "@radix-ui/react-accordion": "^1.2.12",
@@ -171,5 +162,13 @@
171
162
  "zod": {
172
163
  "optional": true
173
164
  }
165
+ },
166
+ "scripts": {
167
+ "check:tokens": "node tokens/scripts/check-component-tokens.mjs",
168
+ "build": "node tokens/scripts/check-exports.mjs && node tokens/scripts/check-component-tokens.mjs && tsc -p tsconfig.json",
169
+ "typecheck": "tsc -p tsconfig.json && tsc -p tokens/tsconfig.json",
170
+ "test": "node tokens/scripts/check-component-tokens.mjs && vitest run && node --test tokens/test/*.test.mjs",
171
+ "verify": "node tokens/scripts/check-component-tokens.mjs && node tokens/verify/run.mjs",
172
+ "analyze": "node tokens/verify/analyze.mjs"
174
173
  }
175
- }
174
+ }
@@ -0,0 +1,256 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, createRef } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { SessionsHoverCard } from "./SessionsHoverCard";
6
+
7
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
8
+
9
+ let root: Root | null = null;
10
+ let host: HTMLElement | null = null;
11
+
12
+ async function mount(node: React.ReactNode) {
13
+ host = document.createElement("div");
14
+ document.body.appendChild(host);
15
+ root = createRoot(host);
16
+ await act(async () => {
17
+ root!.render(node);
18
+ });
19
+ }
20
+
21
+ afterEach(async () => {
22
+ await act(async () => root?.unmount());
23
+ host?.remove();
24
+ document.body.innerHTML = "";
25
+ root = null;
26
+ host = null;
27
+ });
28
+
29
+ describe("SessionsHoverCard", () => {
30
+ it("renders header, body and footer slots when open", async () => {
31
+ await mount(
32
+ <SessionsHoverCard open header="Head" body="Body text" footer="Foot">
33
+ <button type="button">Row</button>
34
+ </SessionsHoverCard>,
35
+ );
36
+ const text = document.body.textContent ?? "";
37
+ expect(text).toContain("Row");
38
+ expect(text).toContain("Head");
39
+ expect(text).toContain("Body text");
40
+ expect(text).toContain("Foot");
41
+ expect(document.body.innerHTML).toContain("w-64");
42
+ });
43
+
44
+ it("does not call the body render prop or loadContent while closed", async () => {
45
+ const body = vi.fn(() => "x");
46
+ const loadContent = vi.fn(() => "y");
47
+ await mount(
48
+ <>
49
+ <SessionsHoverCard open={false} body={body}>
50
+ <button type="button">A</button>
51
+ </SessionsHoverCard>
52
+ <SessionsHoverCard open={false} loadContent={loadContent}>
53
+ <button type="button">B</button>
54
+ </SessionsHoverCard>
55
+ </>,
56
+ );
57
+ expect(body).not.toHaveBeenCalled();
58
+ expect(loadContent).not.toHaveBeenCalled();
59
+ });
60
+
61
+ it("calls the render prop with open true and loads content once when open", async () => {
62
+ const body = vi.fn((ctx: { open: boolean }) => `open=${ctx.open}`);
63
+ const loadContent = vi.fn(async () => "Loaded preview");
64
+ await mount(
65
+ <>
66
+ <SessionsHoverCard open body={body}>
67
+ <button type="button">A</button>
68
+ </SessionsHoverCard>
69
+ <SessionsHoverCard open loadContent={loadContent} loadingFallback="Loading">
70
+ <button type="button">B</button>
71
+ </SessionsHoverCard>
72
+ </>,
73
+ );
74
+ expect(body).toHaveBeenCalledWith({ open: true });
75
+ expect(document.body.textContent).toContain("open=true");
76
+ expect(loadContent).toHaveBeenCalledTimes(1);
77
+ expect(document.body.textContent).toContain("Loaded preview");
78
+ });
79
+
80
+ it("portals the content to document.body by default", async () => {
81
+ await mount(
82
+ <div id="clip" style={{ overflow: "hidden" }}>
83
+ <SessionsHoverCard open body="Body text">
84
+ <button type="button">Row</button>
85
+ </SessionsHoverCard>
86
+ </div>,
87
+ );
88
+ const content = document.querySelector("[data-radix-popper-content-wrapper]");
89
+ expect(content).not.toBeNull();
90
+ expect(document.getElementById("clip")!.contains(content)).toBe(false);
91
+ expect(document.body.textContent).toContain("Body text");
92
+ });
93
+
94
+ it("keeps the content in place with portal={false}", async () => {
95
+ await mount(
96
+ <div id="clip" style={{ overflow: "hidden" }}>
97
+ <SessionsHoverCard open portal={false} body="Body text">
98
+ <button type="button">Row</button>
99
+ </SessionsHoverCard>
100
+ </div>,
101
+ );
102
+ const content = document.querySelector("[data-radix-popper-content-wrapper]");
103
+ expect(document.getElementById("clip")!.contains(content)).toBe(true);
104
+ });
105
+
106
+ it("swaps the surface without keeping the default one", async () => {
107
+ await mount(
108
+ <>
109
+ <SessionsHoverCard open surface="glass" body="G" className="p-5">
110
+ <button type="button">A</button>
111
+ </SessionsHoverCard>
112
+ </>,
113
+ );
114
+ const card = document.querySelector("[data-state=open][data-side]") as HTMLElement;
115
+ expect(card.className).toContain("glass-panel-strong");
116
+ expect(card.className).toContain("rounded-surface");
117
+ expect(card.className).not.toContain("bg-overlay");
118
+ expect(card.className).not.toContain("rounded-lg");
119
+ expect(card.className).toContain("p-5");
120
+ expect(card.className).not.toContain("p-3");
121
+ });
122
+
123
+ it("uses the floating overlay surface by default", async () => {
124
+ await mount(
125
+ <SessionsHoverCard open body="B">
126
+ <button type="button">A</button>
127
+ </SessionsHoverCard>,
128
+ );
129
+ const card = document.querySelector("[data-state=open][data-side]") as HTMLElement;
130
+ expect(card.className).toContain("bg-overlay");
131
+ expect(card.className).toContain("rounded-lg");
132
+ });
133
+
134
+ it("forwards the ref and props to the child trigger and keeps its handlers", async () => {
135
+ const ref = createRef<HTMLButtonElement>();
136
+ const onClick = vi.fn();
137
+ await mount(
138
+ <SessionsHoverCard ref={ref as never} data-testid="row" body="B">
139
+ <button type="button" onClick={onClick}>
140
+ Row
141
+ </button>
142
+ </SessionsHoverCard>,
143
+ );
144
+ const button = document.querySelector("button") as HTMLButtonElement;
145
+ expect(ref.current).toBe(button);
146
+ expect(button.getAttribute("data-testid")).toBe("row");
147
+ await act(async () => button.click());
148
+ expect(onClick).toHaveBeenCalledTimes(1);
149
+ });
150
+
151
+ it("renders its own trigger element with asChild={false}", async () => {
152
+ await mount(
153
+ <SessionsHoverCard asChild={false} body="B">
154
+ <span>Row</span>
155
+ </SessionsHoverCard>,
156
+ );
157
+ const trigger = document.querySelector("[data-state]") as HTMLElement;
158
+ expect(trigger.tagName).toBe("A");
159
+ expect(trigger.textContent).toBe("Row");
160
+ });
161
+
162
+ describe("focus and pointer", () => {
163
+ function setFocusVisible(el: HTMLElement, value: boolean) {
164
+ const orig = el.matches.bind(el);
165
+ el.matches = (sel: string) => (sel === ":focus-visible" ? value : orig(sel));
166
+ }
167
+
168
+ it("does not open on pointer-driven focus after a context menu", async () => {
169
+ const onOpenChange = vi.fn();
170
+ await mount(
171
+ <SessionsHoverCard openDelay={0} header="Head" onOpenChange={onOpenChange}>
172
+ <button type="button">Row</button>
173
+ </SessionsHoverCard>,
174
+ );
175
+ const row = document.querySelector("button")!;
176
+ setFocusVisible(row, false);
177
+ await act(async () => {
178
+ row.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
179
+ row.dispatchEvent(new FocusEvent("focus", { bubbles: false }));
180
+ row.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
181
+ });
182
+ await act(async () => new Promise((r) => setTimeout(r, 20)));
183
+ expect(onOpenChange).not.toHaveBeenCalledWith(true);
184
+ expect(document.body.textContent).not.toContain("Head");
185
+ });
186
+
187
+ it("opens on keyboard focus-visible", async () => {
188
+ const onOpenChange = vi.fn();
189
+ await mount(
190
+ <SessionsHoverCard openDelay={0} header="Head" onOpenChange={onOpenChange}>
191
+ <button type="button">Row</button>
192
+ </SessionsHoverCard>,
193
+ );
194
+ const row = document.querySelector("button")!;
195
+ setFocusVisible(row, true);
196
+ await act(async () => {
197
+ row.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
198
+ });
199
+ await act(async () => new Promise((r) => setTimeout(r, 20)));
200
+ expect(onOpenChange).toHaveBeenCalledWith(true);
201
+ });
202
+
203
+ // React derives onPointerLeave from native pointerout (with a null or outside
204
+ // relatedTarget). A native pointerleave event does not reach the handler.
205
+ function leave(el: HTMLElement) {
206
+ return act(async () => {
207
+ el.dispatchEvent(new PointerEvent("pointerout", { pointerType: "mouse", bubbles: true, relatedTarget: null }));
208
+ });
209
+ }
210
+
211
+ it("closes on pointer leave when stuck open by non-focus-visible focus", async () => {
212
+ const onOpenChange = vi.fn();
213
+ await mount(
214
+ <SessionsHoverCard defaultOpen header="Head" onOpenChange={onOpenChange}>
215
+ <button type="button">Row</button>
216
+ </SessionsHoverCard>,
217
+ );
218
+ const row = document.querySelector("button")!;
219
+ setFocusVisible(row, false);
220
+ row.focus();
221
+ await leave(row);
222
+ expect(onOpenChange).toHaveBeenCalledWith(false);
223
+ await act(async () => new Promise((r) => setTimeout(r, 20)));
224
+ expect(document.body.textContent).not.toContain("Head");
225
+ });
226
+
227
+ it("stays open on pointer leave with keyboard focus-visible", async () => {
228
+ const onOpenChange = vi.fn();
229
+ await mount(
230
+ <SessionsHoverCard defaultOpen header="Head" onOpenChange={onOpenChange}>
231
+ <button type="button">Row</button>
232
+ </SessionsHoverCard>,
233
+ );
234
+ const row = document.querySelector("button")!;
235
+ setFocusVisible(row, true);
236
+ row.focus();
237
+ await leave(row);
238
+ await act(async () => new Promise((r) => setTimeout(r, 20)));
239
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
240
+ expect(document.body.textContent).toContain("Head");
241
+ });
242
+
243
+ it("does not force-close on pointer leave without focus (Radix owns hover close)", async () => {
244
+ const onOpenChange = vi.fn();
245
+ await mount(
246
+ <SessionsHoverCard defaultOpen closeDelay={1000} header="Head" onOpenChange={onOpenChange}>
247
+ <button type="button">Row</button>
248
+ </SessionsHoverCard>,
249
+ );
250
+ const row = document.querySelector("button")!;
251
+ await leave(row);
252
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
253
+ expect(document.body.textContent).toContain("Head");
254
+ });
255
+ });
256
+ });
@@ -1,4 +1,4 @@
1
- import type { ChangeEvent, ReactNode } from "react";
1
+ import type { ChangeEvent, ReactNode, RefObject } from "react";
2
2
  import { useEffect, useRef, useState } from "react";
3
3
  import { createPortal } from "react-dom";
4
4
  import { AnimatePresence, domAnimation, LazyMotion, m } from "motion/react";
@@ -22,6 +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, placeCaretAtEnd } from "./InputAreaEditor";
25
26
  import { FadePresence } from "./FadePresence";
26
27
  import { InputActions, type InputActionsProps } from "./InputActions";
27
28
  import { motionFast } from "./motion";
@@ -30,8 +31,13 @@ import { useComposerCompact } from "./useComposerCompact";
30
31
  import { useComposerSlashMenu } from "./useComposerSlashMenu";
31
32
  import { useInputAreaPrediction } from "./useInputAreaPrediction";
32
33
 
34
+ const EXPANDED_EDITOR =
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.
33
39
  const COMPACT_EDITOR =
34
- "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";
35
41
  const COMPACT_START_ADDON =
36
42
  "order-first shrink-0 justify-start gap-1 py-0 pl-1.5 pr-0 has-[>button]:ml-0";
37
43
  const COMPACT_END_ADDON =
@@ -67,8 +73,9 @@ export type InputAreaProps = {
67
73
  /** When omitted: the draft has text, or `fileCount` or `chipCount` is above 0. */
68
74
  canSubmit?: boolean;
69
75
 
70
- /** Inputs to the compact pill. */
76
+ /** Files force the stacked card. */
71
77
  fileCount?: number;
78
+ /** Chips never force the stacked card. They only enable send and Backspace removal. */
72
79
  chipCount?: number;
73
80
 
74
81
  showDictate?: boolean;
@@ -86,8 +93,10 @@ export type InputAreaProps = {
86
93
  queue?: ReactNode;
87
94
  /** Attachment strip. Shown inside the input, above the text. */
88
95
  attachments?: ReactNode;
89
- /** Chip row. Shown inside the input, above the text. */
96
+ /** Chip row. Shown inside the input, before the text (see `chipsPlacement`). */
90
97
  chips?: ReactNode;
98
+ /** `inline` (default): chips are non-editable atoms at the start of the same text flow, so wrapped lines start under them. `top`: chips sit in a strip above the text. */
99
+ chipsPlacement?: "inline" | "top";
91
100
  /** Extra rows in the plus menu after the attach row. Include your own separators. */
92
101
  plusMenuExtra?: ReactNode;
93
102
  /** Props merged onto the plus-menu dropdown content, such as `align`, `side` or `sideOffset`. Defaults: `align="start"`, `side="top"`. */
@@ -102,7 +111,7 @@ export type InputAreaProps = {
102
111
  /** Returns a continuation for the draft. Omit to turn ghost text off. */
103
112
  getPrediction?: (text: string) => Promise<string>;
104
113
  predictionEnabled?: boolean;
105
- /** Classes on the ghost suggestion span. Default matches the original app composer. */
114
+ /** Classes for the ghost suggestion. With `chipsPlacement="top"` they sit on the overlay span. Inline mode copies their colour and opacity to the `::after` ghost text. */
106
115
  predictionTextClassName?: string;
107
116
 
108
117
  /** Wrapper around banners, queue and the input. */
@@ -142,6 +151,7 @@ export function InputArea({
142
151
  queue,
143
152
  attachments,
144
153
  chips,
154
+ chipsPlacement = "inline",
145
155
  plusMenuExtra,
146
156
  plusMenuContentProps,
147
157
  slashItems,
@@ -164,7 +174,10 @@ export function InputArea({
164
174
  if (!isControlled) setInternalValue(next);
165
175
  onChange?.(next);
166
176
  };
167
- const inputRef = useRef<HTMLTextAreaElement>(null);
177
+ const inputRef = useRef<HTMLElement>(null);
178
+ // Measure once after mount so a long `defaultValue` expands on first paint.
179
+ const [, setMeasured] = useState(false);
180
+ useEffect(() => setMeasured(true), []);
168
181
  const fileInputRef = useRef<HTMLInputElement>(null);
169
182
  const hasFiles = fileCount > 0;
170
183
 
@@ -176,7 +189,6 @@ export function InputArea({
176
189
  const { chromeRef, compact } = useComposerCompact({
177
190
  value: input,
178
191
  fileCount,
179
- chipCount,
180
192
  });
181
193
 
182
194
  const items = slashItems ?? [];
@@ -195,13 +207,17 @@ export function InputArea({
195
207
  getPrediction,
196
208
  });
197
209
 
210
+ const inlineMode = chipsPlacement === "inline";
211
+
198
212
  const textareaClassName = cn(
199
213
  "bg-transparent! shadow-none border-none",
200
214
  "text-sm md:text-[15px] leading-5 placeholder:text-muted-foreground",
201
- "smooth-typing-input field-sizing-content focus-visible:outline-hidden",
215
+ "smooth-typing-input focus-visible:outline-hidden",
202
216
  compact
203
217
  ? COMPACT_EDITOR
204
- : "min-h-16 max-h-50 w-full self-stretch overflow-y-auto rounded-none px-4 pt-3.5 pb-1 text-left",
218
+ : // `py-0` clears InputGroupTextarea's default `py-3`.
219
+ cn(EXPANDED_EDITOR, "py-0 pt-3.5 pb-1"),
220
+ !inlineMode && "field-sizing-content",
205
221
  );
206
222
 
207
223
  const submitDisabled =
@@ -229,12 +245,18 @@ export function InputArea({
229
245
  const activeElement = document.activeElement;
230
246
  const isInputFocused =
231
247
  activeElement instanceof HTMLInputElement ||
232
- activeElement instanceof HTMLTextAreaElement;
233
- if (!isInputFocused && inputRef.current) {
234
- const textarea = inputRef.current;
235
- textarea.focus();
236
- const length = textarea.value.length;
237
- textarea.setSelectionRange(length, length);
248
+ activeElement instanceof HTMLTextAreaElement ||
249
+ (activeElement instanceof HTMLElement &&
250
+ activeElement.isContentEditable);
251
+ const editor = inputRef.current;
252
+ if (!isInputFocused && editor) {
253
+ editor.focus();
254
+ if (editor instanceof HTMLTextAreaElement) {
255
+ const length = editor.value.length;
256
+ editor.setSelectionRange(length, length);
257
+ } else {
258
+ placeCaretAtEnd(editor);
259
+ }
238
260
  }
239
261
  };
240
262
  window.addEventListener("keydown", handleKeyDown);
@@ -332,6 +354,112 @@ export function InputArea({
332
354
  </div>
333
355
  );
334
356
 
357
+ const comboboxProps = {
358
+ role: slashMenu.comboboxProps.role,
359
+ "aria-multiline": slashMenu.comboboxProps["aria-multiline"],
360
+ "aria-haspopup": slashMenu.comboboxProps["aria-haspopup"],
361
+ "aria-autocomplete": slashMenu.comboboxProps["aria-autocomplete"],
362
+ "aria-expanded": slashMenu.comboboxProps["aria-expanded"],
363
+ "aria-controls": slashMenu.comboboxProps["aria-controls"],
364
+ "aria-activedescendant": slashMenu.comboboxProps["aria-activedescendant"],
365
+ };
366
+
367
+ /** Slash menu and ghost text keys. Returns true when handled. */
368
+ const handleCommonKeyDown = (
369
+ event: Parameters<typeof slashMenu.onKeyDown>[0],
370
+ ): boolean => {
371
+ if (slashMenu.onKeyDown(event)) return true;
372
+ if (prediction.suggestion) {
373
+ if (event.key === "Tab" && !event.shiftKey) {
374
+ event.preventDefault();
375
+ setInput(input + prediction.suggestion);
376
+ return true;
377
+ }
378
+ if (event.key === "Escape") {
379
+ event.preventDefault();
380
+ prediction.dismiss();
381
+ return true;
382
+ }
383
+ }
384
+ return false;
385
+ };
386
+
387
+ const body = (
388
+ <PromptInputBody>
389
+ <div
390
+ className={cn(
391
+ "relative min-w-0",
392
+ compact ? "flex-1" : "w-full self-stretch",
393
+ )}
394
+ >
395
+ {inlineMode ? (
396
+ <InputAreaEditor
397
+ editorRef={inputRef as RefObject<HTMLDivElement | null>}
398
+ value={input}
399
+ onValueChange={setInput}
400
+ placeholder={placeholder}
401
+ suggestion={prediction.suggestion || undefined}
402
+ suggestionClassName={predictionTextClassName}
403
+ chips={chips}
404
+ chipCount={chipCount}
405
+ onRemoveLastChip={onRemoveLastChip}
406
+ className={cn(textareaClassName, "relative z-10")}
407
+ ariaProps={comboboxProps}
408
+ onKeyDown={(event) => {
409
+ handleCommonKeyDown(event);
410
+ }}
411
+ onBlur={slashMenu.onBlur}
412
+ />
413
+ ) : (
414
+ <>
415
+ {prediction.suggestion ? (
416
+ <div
417
+ aria-hidden
418
+ className={cn(
419
+ textareaClassName,
420
+ "pointer-events-none absolute inset-0 z-0 overflow-hidden whitespace-pre-wrap wrap-break-word text-foreground",
421
+ )}
422
+ >
423
+ {input}
424
+ <span className={predictionTextClassName}>
425
+ {prediction.suggestion}
426
+ </span>
427
+ </div>
428
+ ) : null}
429
+ <PromptInputTextarea
430
+ ref={inputRef as RefObject<HTMLTextAreaElement | null>}
431
+ value={input}
432
+ placeholder={placeholder}
433
+ className={cn(
434
+ textareaClassName,
435
+ "relative z-10",
436
+ prediction.suggestion && "caret-current text-transparent",
437
+ )}
438
+ rows={1}
439
+ {...comboboxProps}
440
+ onKeyDown={(event) => {
441
+ if (handleCommonKeyDown(event)) return;
442
+ if (
443
+ event.key === "Backspace" &&
444
+ event.currentTarget.selectionStart === 0 &&
445
+ event.currentTarget.selectionEnd === 0 &&
446
+ chipCount > 0
447
+ ) {
448
+ event.preventDefault();
449
+ onRemoveLastChip?.();
450
+ }
451
+ }}
452
+ onBlur={slashMenu.onBlur}
453
+ onChange={(e) => {
454
+ setInput(e.currentTarget.value);
455
+ }}
456
+ />
457
+ </>
458
+ )}
459
+ </div>
460
+ </PromptInputBody>
461
+ );
462
+
335
463
  return (
336
464
  <LazyMotion features={domAnimation}>
337
465
  <TooltipProvider>
@@ -365,7 +493,7 @@ export function InputArea({
365
493
  "flex w-full min-w-0 [interpolate-size:allow-keywords] transition-[color,box-shadow,border-color,border-radius,height] duration-200",
366
494
  chatInputContainerClass,
367
495
  compact
368
- ? "h-12 flex-row flex-nowrap items-center rounded-full py-1 has-[>textarea]:h-12"
496
+ ? "h-12 flex-row flex-nowrap items-center rounded-full py-1 has-[[data-slot=input-group-control]]:h-12"
369
497
  : "h-auto flex-col items-start rounded-surface",
370
498
  borderClassName,
371
499
  containerClassName,
@@ -390,7 +518,7 @@ export function InputArea({
390
518
  }
391
519
  >
392
520
  {showFileUpload ? attachments : null}
393
- {chips}
521
+ {inlineMode ? null : chips}
394
522
  {compact ? (
395
523
  <InputGroupAddon
396
524
  align="inline-start"
@@ -399,80 +527,7 @@ export function InputArea({
399
527
  {plusButton}
400
528
  </InputGroupAddon>
401
529
  ) : null}
402
- <PromptInputBody>
403
- <div
404
- className={cn(
405
- "relative min-w-0",
406
- compact ? "flex-1" : "w-full self-stretch",
407
- )}
408
- >
409
- {prediction.suggestion ? (
410
- <div
411
- aria-hidden
412
- className={cn(
413
- textareaClassName,
414
- "pointer-events-none absolute inset-0 z-0 overflow-hidden whitespace-pre-wrap wrap-break-word text-foreground",
415
- )}
416
- >
417
- {input}
418
- <span className={predictionTextClassName}>
419
- {prediction.suggestion}
420
- </span>
421
- </div>
422
- ) : null}
423
- <PromptInputTextarea
424
- ref={inputRef}
425
- value={input}
426
- placeholder={placeholder}
427
- className={cn(
428
- textareaClassName,
429
- "relative z-10",
430
- prediction.suggestion &&
431
- "caret-current text-transparent",
432
- )}
433
- rows={1}
434
- role={slashMenu.comboboxProps.role}
435
- aria-multiline={slashMenu.comboboxProps["aria-multiline"]}
436
- aria-haspopup={slashMenu.comboboxProps["aria-haspopup"]}
437
- aria-autocomplete={
438
- slashMenu.comboboxProps["aria-autocomplete"]
439
- }
440
- aria-expanded={slashMenu.comboboxProps["aria-expanded"]}
441
- aria-controls={slashMenu.comboboxProps["aria-controls"]}
442
- aria-activedescendant={
443
- slashMenu.comboboxProps["aria-activedescendant"]
444
- }
445
- onKeyDown={(event) => {
446
- if (slashMenu.onKeyDown(event)) return;
447
- if (prediction.suggestion) {
448
- if (event.key === "Tab" && !event.shiftKey) {
449
- event.preventDefault();
450
- setInput(input + prediction.suggestion);
451
- return;
452
- }
453
- if (event.key === "Escape") {
454
- event.preventDefault();
455
- prediction.dismiss();
456
- return;
457
- }
458
- }
459
- if (
460
- event.key === "Backspace" &&
461
- event.currentTarget.selectionStart === 0 &&
462
- event.currentTarget.selectionEnd === 0 &&
463
- chipCount > 0
464
- ) {
465
- event.preventDefault();
466
- onRemoveLastChip?.();
467
- }
468
- }}
469
- onBlur={slashMenu.onBlur}
470
- onChange={(e) => {
471
- setInput(e.currentTarget.value);
472
- }}
473
- />
474
- </div>
475
- </PromptInputBody>
530
+ {body}
476
531
  {compact ? (
477
532
  <InputGroupAddon
478
533
  align="inline-end"
@@ -0,0 +1,411 @@
1
+ import {
2
+ useLayoutEffect,
3
+ useRef,
4
+ type ClipboardEvent,
5
+ type FocusEventHandler,
6
+ type KeyboardEvent,
7
+ type ReactNode,
8
+ type Ref,
9
+ } from "react";
10
+ import { cn } from "../../utils/cn";
11
+
12
+ export const CHIPS_HOST_ATTR = "data-composer-chips-inline";
13
+
14
+ /** Custom property the ghost `::after` reads its colour from. */
15
+ export const GHOST_COLOR_VAR = "--composer-ghost-color";
16
+
17
+ /**
18
+ * Editor base class. Placeholder and ghost text use `::after`, so both follow
19
+ * the chips on the same line. A class cannot reach a pseudo-element, so the
20
+ * ghost colour comes from `--composer-ghost-color`, read off a probe span that
21
+ * carries `suggestionClassName`. Muted foreground is the fallback.
22
+ */
23
+ export const INLINE_EDITOR_CLASS =
24
+ "relative block w-full cursor-text whitespace-pre-wrap wrap-break-word bg-transparent outline-hidden data-empty:after:pointer-events-none data-empty:after:select-none data-empty:after:text-muted-foreground data-empty:after:content-[attr(data-placeholder)] data-suggestion:after:pointer-events-none data-suggestion:after:select-none data-suggestion:after:text-[color:var(--composer-ghost-color,var(--color-muted-foreground))] data-suggestion:after:content-[attr(data-suggestion)]";
25
+
26
+ // `contents` so each chip is an inline atom in the editor's own flow (same as
27
+ // session-composer chip spans). A wrapping flex host becomes one box and puts
28
+ // the text in a column beside it.
29
+ const CHIPS_HOST_CLASS = "contents empty:hidden [&>*]:mr-1.5";
30
+
31
+ const ZERO_WIDTH_SPACE = "\u200b";
32
+
33
+ /** Reads the plain text of the editor. Skips the chips host. */
34
+ export function extractEditorText(root: HTMLElement): string {
35
+ let out = "";
36
+ const walk = (node: Node, isRoot: boolean) => {
37
+ node.childNodes.forEach((child, index) => {
38
+ if (child instanceof HTMLElement) {
39
+ if (child.hasAttribute(CHIPS_HOST_ATTR)) return;
40
+ if (child.tagName === "BR") {
41
+ // A trailing <br> only holds the line open.
42
+ if (isRoot && index === node.childNodes.length - 1) return;
43
+ out += "\n";
44
+ return;
45
+ }
46
+ if (child.tagName === "DIV" || child.tagName === "P") {
47
+ if (out !== "" && !out.endsWith("\n")) out += "\n";
48
+ walk(child, false);
49
+ return;
50
+ }
51
+ walk(child, false);
52
+ return;
53
+ }
54
+ if (child.nodeType === Node.TEXT_NODE) {
55
+ out += (child.textContent ?? "").replaceAll(ZERO_WIDTH_SPACE, "");
56
+ }
57
+ });
58
+ };
59
+ walk(root, true);
60
+ return out;
61
+ }
62
+
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) {
119
+ const sel = window.getSelection();
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);
162
+ }
163
+
164
+ /** True when nothing but the chips host sits before the caret. */
165
+ function caretAtTextStart(root: HTMLElement): boolean {
166
+ const sel = window.getSelection();
167
+ if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return false;
168
+ const anchor = sel.anchorNode;
169
+ if (!anchor || !root.contains(anchor)) return false;
170
+ const host = chipsHost(root);
171
+ if (host?.contains(anchor)) return false;
172
+ const probe = document.createRange();
173
+ probe.selectNodeContents(root);
174
+ if (host) probe.setStartAfter(host);
175
+ probe.setEnd(anchor, sel.anchorOffset);
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;
188
+ }
189
+
190
+ function insertPlainText(root: HTMLElement, text: string) {
191
+ if (document.execCommand("insertText", false, text)) return;
192
+ const sel = window.getSelection();
193
+ if (!sel || sel.rangeCount === 0) return;
194
+ const range = sel.getRangeAt(0);
195
+ range.deleteContents();
196
+ const node = document.createTextNode(text);
197
+ range.insertNode(node);
198
+ range.setStartAfter(node);
199
+ range.collapse(true);
200
+ sel.removeAllRanges();
201
+ sel.addRange(range);
202
+ root.dispatchEvent(new Event("input", { bubbles: true }));
203
+ }
204
+
205
+ export type InputAreaEditorProps = {
206
+ editorRef?: Ref<HTMLDivElement>;
207
+ value: string;
208
+ onValueChange: (value: string) => void;
209
+ placeholder?: string;
210
+ suggestion?: string;
211
+ /** Classes for the ghost text. Its colour and opacity are copied to the `::after`. */
212
+ suggestionClassName?: string;
213
+ chips?: ReactNode;
214
+ chipCount?: number;
215
+ onRemoveLastChip?: () => void;
216
+ className?: string;
217
+ /** Runs first. Call `preventDefault` to stop the built-in Enter and Backspace handling. */
218
+ onKeyDown?: (event: KeyboardEvent<HTMLDivElement>) => void;
219
+ onBlur?: FocusEventHandler<HTMLDivElement>;
220
+ /** ARIA props, such as the slash menu combobox props. */
221
+ ariaProps?: Record<string, unknown>;
222
+ };
223
+
224
+ /**
225
+ * Contenteditable editor. The chips host is a non-editable inline atom at the
226
+ * start of the same text flow, so wrapped lines start under the chips. React
227
+ * owns the host only. The text nodes after it belong to the DOM and are
228
+ * reconciled against `value`, behind a U+200B caret anchor that keeps an
229
+ * editable position after the chips.
230
+ */
231
+ export function InputAreaEditor({
232
+ editorRef,
233
+ value,
234
+ onValueChange,
235
+ placeholder,
236
+ suggestion,
237
+ suggestionClassName,
238
+ chips,
239
+ chipCount = 0,
240
+ onRemoveLastChip,
241
+ className,
242
+ onKeyDown,
243
+ onBlur,
244
+ ariaProps,
245
+ }: InputAreaEditorProps) {
246
+ const rootRef = useRef<HTMLDivElement | null>(null);
247
+ const composingRef = useRef(false);
248
+ const ghostProbeRef = useRef<HTMLSpanElement | null>(null);
249
+
250
+ const assignEditorRef = (node: HTMLDivElement | null) => {
251
+ rootRef.current = node;
252
+ if (typeof editorRef === "function") editorRef(node);
253
+ else if (editorRef) editorRef.current = node;
254
+ };
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
+
276
+ useLayoutEffect(() => {
277
+ const root = rootRef.current;
278
+ if (!root || composingRef.current) return;
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);
289
+ });
290
+
291
+ // Copy the probe colour to the editor. Opacity below 1 is folded into the
292
+ // colour, so the `::after` needs one variable.
293
+ useLayoutEffect(() => {
294
+ const root = rootRef.current;
295
+ if (!root) return;
296
+ const probe = ghostProbeRef.current;
297
+ const style = probe ? getComputedStyle(probe) : null;
298
+ if (!style?.color) {
299
+ root.style.removeProperty(GHOST_COLOR_VAR);
300
+ return;
301
+ }
302
+ const opacity = Number.parseFloat(style.opacity);
303
+ root.style.setProperty(
304
+ GHOST_COLOR_VAR,
305
+ opacity >= 0 && opacity < 1
306
+ ? `color-mix(in srgb, ${style.color} ${opacity * 100}%, transparent)`
307
+ : style.color,
308
+ );
309
+ }, [suggestion, suggestionClassName]);
310
+
311
+ const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
312
+ onKeyDown?.(event);
313
+ if (event.defaultPrevented) return;
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);
328
+ if (event.key === "Enter") {
329
+ if (event.shiftKey) return;
330
+ event.preventDefault();
331
+ const form = root.closest("form");
332
+ const submitButton = form?.querySelector<HTMLButtonElement>(
333
+ 'button[type="submit"]',
334
+ );
335
+ if (submitButton?.disabled) return;
336
+ form?.requestSubmit();
337
+ return;
338
+ }
339
+ // Nothing visible to delete. The default would eat the U+200B anchor.
340
+ if (event.key === "Backspace" && caretAtTextStart(root)) {
341
+ event.preventDefault();
342
+ if (chipCount > 0) onRemoveLastChip?.();
343
+ }
344
+ };
345
+
346
+ const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
347
+ event.preventDefault();
348
+ const text = event.clipboardData.getData("text/plain");
349
+ if (text) insertPlainText(event.currentTarget, text);
350
+ };
351
+
352
+ return (
353
+ <>
354
+ <div
355
+ ref={setRefs}
356
+ data-slot="input-group-control"
357
+ contentEditable
358
+ suppressContentEditableWarning
359
+ spellCheck
360
+ aria-label={placeholder}
361
+ aria-multiline
362
+ {...ariaProps}
363
+ role="combobox"
364
+ data-empty={value === "" ? "" : undefined}
365
+ data-placeholder={placeholder}
366
+ data-suggestion={suggestion || undefined}
367
+ className={cn(INLINE_EDITOR_CLASS, className)}
368
+ onInput={(event) => {
369
+ if (composingRef.current) return;
370
+ ensureCaretAnchor(event.currentTarget);
371
+ onValueChange(extractEditorText(event.currentTarget));
372
+ }}
373
+ onCompositionStart={() => {
374
+ composingRef.current = true;
375
+ }}
376
+ onCompositionEnd={(event) => {
377
+ composingRef.current = false;
378
+ ensureCaretAnchor(event.currentTarget);
379
+ onValueChange(extractEditorText(event.currentTarget));
380
+ }}
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)}
387
+ onPaste={handlePaste}
388
+ onBlur={onBlur}
389
+ >
390
+ <span
391
+ contentEditable={false}
392
+ {...{ [CHIPS_HOST_ATTR]: "" }}
393
+ className={CHIPS_HOST_CLASS}
394
+ >
395
+ {chips}
396
+ </span>
397
+ </div>
398
+ {suggestion && suggestionClassName ? (
399
+ // Outside the editor, so reconciliation and the text value never see it.
400
+ // Zero-size, not `display: none`, so the computed colour is real.
401
+ <span
402
+ aria-hidden
403
+ data-composer-ghost-probe=""
404
+ className="pointer-events-none absolute size-0 select-none overflow-hidden text-foreground"
405
+ >
406
+ <span ref={ghostProbeRef} className={suggestionClassName} />
407
+ </span>
408
+ ) : null}
409
+ </>
410
+ );
411
+ }
@@ -9,20 +9,18 @@ function getMeasureContext(): CanvasRenderingContext2D | null {
9
9
  return context;
10
10
  }
11
11
 
12
+ /** Chips never force the stacked card: only files, newlines and a wrapped line do. */
12
13
  export function isComposerCompact({
13
14
  value,
14
15
  fileCount,
15
- chipCount,
16
16
  exceedsPill,
17
17
  }: {
18
18
  value: string;
19
19
  fileCount: number;
20
- chipCount: number;
21
20
  exceedsPill: boolean;
22
21
  }): boolean {
23
22
  if (fileCount > 0) return false;
24
- if (chipCount > 0) return false;
25
- if (value.trim().length === 0) return true;
23
+ if (value === "" || value === "\n") return true;
26
24
  if (value.includes("\n")) return false;
27
25
  return !exceedsPill;
28
26
  }
@@ -5,15 +5,14 @@ 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,
11
12
  fileCount,
12
- chipCount,
13
13
  }: {
14
14
  value: string;
15
15
  fileCount: number;
16
- chipCount: number;
17
16
  }): { chromeRef: RefObject<HTMLDivElement | null>; compact: boolean } {
18
17
  "use no memo";
19
18
  const chromeRef = useRef<HTMLDivElement>(null);
@@ -26,7 +25,7 @@ export function useComposerCompact({
26
25
  const editor = editorNode instanceof HTMLElement ? editorNode : null;
27
26
  const font = editor ? getComputedStyle(editor).font : "14px sans-serif";
28
27
  if (editor && wasCompactRef.current) {
29
- const inner = readEditorInnerWidth(editor);
28
+ const inner = readEditorInnerWidth(editor) - readInlineChipsWidth(editor);
30
29
  if (inner > 0) pillWidthRef.current = inner;
31
30
  }
32
31
  const exceedsPill =
@@ -37,7 +36,6 @@ export function useComposerCompact({
37
36
  const compact = isComposerCompact({
38
37
  value,
39
38
  fileCount,
40
- chipCount,
41
39
  exceedsPill,
42
40
  });
43
41
  wasCompactRef.current = compact;
@@ -30,7 +30,7 @@ export function useComposerSlashMenu({
30
30
  value: string;
31
31
  onValueChange: (value: string) => void;
32
32
  items: ComposerSlashItem[];
33
- textareaRef: RefObject<HTMLTextAreaElement | null>;
33
+ textareaRef: RefObject<HTMLElement | null>;
34
34
  onSelect: (item: ComposerSlashItem) => void;
35
35
  /** When false the menu never opens. */
36
36
  enabled?: boolean;
@@ -122,7 +122,7 @@ export function useComposerSlashMenu({
122
122
  if (item) applyItem(item, trigger);
123
123
  };
124
124
 
125
- const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>): boolean => {
125
+ const onKeyDown = (event: KeyboardEvent<HTMLElement>): boolean => {
126
126
  if (!showPopup) return false;
127
127
  if (visibleItems.length > 0) {
128
128
  if (event.key === "ArrowDown") {
package/app/README.md DELETED
@@ -1,100 +0,0 @@
1
- # App helpers (`@vvedantb/ui`)
2
-
3
- Shared web React helpers that were duplicated across apps. Source-exported as the `./app` subpath of `@vvedantb/ui`.
4
-
5
- ## Install
6
-
7
- ```sh
8
- npm install @vvedantb/ui
9
- ```
10
-
11
- ## Peers
12
-
13
- Required: `react` and `react-dom` (>=19). Optional peers of `@vvedantb/ui`, needed only for the app helpers: `@clerk/clerk-react` (`useStableAuth`), `@tanstack/react-hotkeys` and `zod`.
14
-
15
- `sonner` is not a dependency. The update watcher takes the toast API as an option. The app source uses a local class join, which the root does not export (the root `cn` is the clsx and tailwind-merge one). Tailwind must scan `node_modules/@vvedantb/ui/app/src/**/*.{ts,tsx}` so the class names are generated, and the app needs the `@vvedantb/ui/tokens` names (`bg-surface-card`, `divide-separator`, `text-muted-foreground` and so on).
16
-
17
- ```css
18
- @source "../node_modules/@vvedantb/ui/app/src/**/*.{ts,tsx}";
19
- ```
20
-
21
- ## Confirm skip (Alt-click)
22
-
23
- ```tsx
24
- import { ConfirmSkipHint, skipConfirmTitle, useRequestConfirm } from "@vvedantb/ui";
25
-
26
- const { request } = useRequestConfirm();
27
-
28
- <DropdownMenuItem
29
- title={skipConfirmTitle("Delete")}
30
- onSelect={(event) => request(() => setOpen(true), () => remove(), event)}
31
- >
32
- Delete <ConfirmSkipHint />
33
- </DropdownMenuItem>
34
- ```
35
-
36
- - `useRequestConfirm` does not subscribe to Alt. It reads `getAltHeld()` when the request happens, so the menu owner does not re-render when Alt goes down. It returns `{ request }` only.
37
- - `ConfirmSkipHint` stays mounted and gains a `hidden` class when Alt is not held, so an open Radix menu keeps a stable child tree.
38
- - The Alt store resets when the last subscriber leaves, clears on blur and tab hide, and cancels a bare Alt keydown only inside `[role='menu']` or `[data-radix-menu-content]`.
39
- - Also exported: `useAltHeld`, `getAltHeld`, `createAltHeldStore`, `bindToWindow`, `shouldSkipConfirm`, `requestConfirm`.
40
-
41
- ## Update available watcher
42
-
43
- ```ts
44
- import { toast } from "sonner";
45
- import { startUpdateAvailableWatcher } from "@vvedantb/ui";
46
-
47
- useEffect(
48
- () =>
49
- startUpdateAvailableWatcher({
50
- origin: window.location.origin,
51
- toast,
52
- closeConvex: () => void convex.close(),
53
- enabled: !import.meta.env.DEV,
54
- }),
55
- [],
56
- );
57
- ```
58
-
59
- Options: `origin`, `toast`, `closeConvex?`, `pollMs?` (default 60000), `toastId?` (default `app-update-available`), `enabled?` (default true), `reload?` (default `window.location.reload`). On a changed asset fingerprint it shows a sticky "Update available" toast. Refresh calls `closeConvex` (errors ignored), then reloads. `fingerprintFromHtml` and `fetchDeployFingerprint` are exported too. The fingerprint looks for Vite `/assets/*.js` URLs in the index HTML.
60
-
61
- ## Settings chrome
62
-
63
- ```tsx
64
- import { SettingsPage, SettingsSection, SettingsToggleRow } from "@vvedantb/ui";
65
-
66
- <SettingsPage
67
- title="Notifications"
68
- layout={({ title, headerRight, toolbar, tabs, fillHeight, children }) => (
69
- <PageContainer title={title} rightSection={headerRight} toolbar={toolbar} tabs={tabs} noScroll={fillHeight}>
70
- {children}
71
- </PageContainer>
72
- )}
73
- >
74
- <SettingsSection title="Email" bodyVariant="list">
75
- <SettingsToggleRow title="Weekly digest" action={<Switch />} />
76
- </SettingsSection>
77
- </SettingsPage>
78
- ```
79
-
80
- Exports: `SettingsSection`, `SettingsField`, `SettingsStack`, `SettingsToggleRow`, `SettingsEmptyState` (icon is `ComponentType<{ size?: number; className?: string }>`), `SettingsPage`. Without `layout`, `SettingsPage` renders a plain `div` with a heading and the optional slots.
81
-
82
- ## useStableAuth
83
-
84
- ```ts
85
- import { useStableAuth } from "@vvedantb/ui"; // 2000 ms debounce, wraps Clerk useAuth
86
- import { createUseStableAuth } from "@vvedantb/ui";
87
-
88
- const useAuth = createUseStableAuth(useClerkAuth, { debounceMs: 3000 });
89
- ```
90
-
91
- After a signed-in session, a drop to signed-out reports `isLoaded: false` for the debounce window so Convex does not cascade. State is module-level. `resetStableAuthSession()` clears it and is for tests only.
92
-
93
- ## Develop
94
-
95
- ```sh
96
- pnpm --filter @vvedantb/ui typecheck
97
- pnpm --filter @vvedantb/ui test
98
- ```
99
-
100
- Source is in `app/src` and tests in `app/tests`. Publish steps are in the root `README.md`.
package/tokens/README.md DELETED
@@ -1,98 +0,0 @@
1
- # `@vvedantb/ui/tokens`
2
-
3
- Design tokens and shared Tailwind v4 CSS for the vvedantb web apps. The CSS and Tailwind preset are subpaths of `@vvedantb/ui`; the native JS tokens are named exports of the root (`tokens`, `Tokens`). Source-exported: no build step. Paths in the `File` column are relative to `packages/ui/tokens/`.
4
-
5
- ## Exports
6
-
7
- | Export | File | What it is |
8
- | --- | --- | --- |
9
- | `@vvedantb/ui/tokens`, `@vvedantb/ui/tokens/tokens.css` | `src/tokens.css` | Colour, radius, shadow, ease and font tokens (`@theme`), light/dark variables, `dark` variant, `*` border and `body` defaults. In 0.2.1 it also defines shadcn-style aliases (`primary`, `muted-foreground`, `secondary`, `destructive`, `card`, `popover`, `input`, `ring`) that point at the same tokens. |
10
- | `tokens` and `Tokens` from `@vvedantb/ui` | `src/native.ts` | JS colours, radii and spacing for React Native. `@vvedantb/native-ui` keeps its own copy in `src/tokens.ts`; a test keeps the two equal. |
11
- | `@vvedantb/ui/tokens/css/base.css` | `src/css/base.css` | `html` touch/accent, `::selection`, control transitions, pointer cursor, range accent (`@layer base`). |
12
- | `@vvedantb/ui/tokens/css/dialog.css` | `src/css/dialog.css` | Unlayered `max-width: none` for `@vvedantb/ui` compact (bottom-sheet) dialogs. |
13
- | `@vvedantb/ui/tokens/css/modal.css` | `src/css/modal.css` | `.t-modal`, `.is-open`, `.is-closing` and `t-modal-presence-wait` (`@layer components`). |
14
- | `@vvedantb/ui/tokens/css/tabs.css` | `src/css/tabs.css` | `.t-tab`, active colour, `.t-tabs-pill`, small-screen `.t-tabs` scrolling. |
15
- | `@vvedantb/ui/tokens/css/tabs-pills.css` | `src/css/tabs-pills.css` | `--tabs-*` colour variables, `.t-tabs--segmented`, `.t-tabs--pills` and pill hover / active fills. |
16
- | `@vvedantb/ui/tokens/css/utilities.css` | `src/css/utilities.css` | `@utility hit-target`, `@utility reveal-on-hover` and `@utility rounded-surface` (`clamp(0.75rem, var(--radius), 1rem)`). |
17
- | `@vvedantb/ui/tokens/css/scrollbar-thin.css` | `src/css/scrollbar-thin.css` | `.scrollbar-thin` (`@layer utilities`, always emitted). |
18
- | `@vvedantb/ui/tokens/css/animations.css` | `src/css/animations.css` | `animate-aurora`, `animate-fade-in-up`, `animate-indeterminate` as `@theme` (CSS-first apps). |
19
- | `@vvedantb/ui/tokens/css/compat-shadcn-rgb.css`, `@vvedantb/ui/tokens/compat/shadcn-rgb.css` | `src/css/compat-shadcn-rgb.css` | Replaces `tokens.css` for apps with shadcn-style `R G B` channel variables. Defines only the UI-only `--color-*` names as `rgb(var(--x) / alpha)` references, `--radius-field`, `--scrollbar`, `--field-radius` and a `glass-panel-strong` fallback. Import it **or** `tokens.css`, never both. |
20
- | `@vvedantb/ui/tokens/css/index.css` | `src/css/index.css` | base + dialog + utilities + scrollbar-thin + animations, for new CSS-first apps. |
21
- | `@vvedantb/ui/tokens/tailwind-preset` | `src/tailwind-preset.mjs` (+ `.d.mts`) | Tailwind preset for apps that load a JS config with `@config`. |
22
-
23
- Every file was extracted because the same rule (same at-rule context, selector and body) sits in 3 or more of the five apps. The measured duplication is in `verify/ANALYSIS.md`, and the proof that each app compiles to the same CSS after migrating is in `verify/RESULTS.md`. Blocks that only 2 apps share, or that drift in a way that would change an app's CSS, stay in the apps; `verify/ANALYSIS.md` lists them.
24
-
25
- ## Use
26
-
27
- CSS-first app (no `tailwind.config`):
28
-
29
- ```css
30
- @import "tailwindcss";
31
- @import "@vvedantb/ui/tokens";
32
- @import "@vvedantb/ui/tokens/css/animations.css";
33
- @import "@vvedantb/ui/tokens/css/base.css";
34
- /* ...app @layer base rules... */
35
- @import "@vvedantb/ui/tokens/css/modal.css";
36
- @import "@vvedantb/ui/tokens/css/tabs.css";
37
- /* ...app @layer components rules... */
38
- ```
39
-
40
- App with a JS config (`@config "../tailwind.config.ts"`):
41
-
42
- ```ts
43
- import { tokensPreset } from "@vvedantb/ui/tokens/tailwind-preset";
44
-
45
- export default {
46
- presets: [tokensPreset()],
47
- // or tokensPreset({ color: (name) => `color-mix(in oklch, var(--${name}) calc(<alpha-value> * 100%), transparent)` })
48
- content: ["./src/**/*.{ts,tsx}"],
49
- theme: { extend: { /* app-only colours, fonts, animations */ } },
50
- plugins: [tailwindcssAnimate],
51
- };
52
- ```
53
-
54
- The preset holds `darkMode: "class"`, the shared colour map (`border`, `surface.*`, `danger.*`, `field.*`, shadcn aliases `primary`/`secondary`/`destructive`/`card`/`popover`/`input` ...), `borderRadius` lg/md/sm/field, `boxShadow` soft/panel/insetSoft, `ease-smooth`, `screens` xs to 2xl in px, and the aurora / fade-in-up / indeterminate / accordion keyframes. It does not hold plugins, `content`, fonts, `accent`/`muted`/`ring`/`sidebar` (they differ per app) or accordion timings.
55
-
56
- Do not import `css/animations.css` in an app that uses the preset: the preset already defines the same keyframes and animations.
57
-
58
- ### Where to put the imports
59
-
60
- Tailwind inlines an `@import` where it sits, even in the middle of a file (it must be at top level, not inside an `@layer` block). Put each import where the app's own copy of the rules was. Within one layer, later rules win at equal specificity, so moving shared rules above or below app rules can flip the result for elements matched by both. `examples/migration.md` gives the exact position for each app, and `pnpm --filter @vvedantb/ui verify` checks it.
61
-
62
- ### Variables each file needs
63
-
64
- | File | Needs | Provided by |
65
- | --- | --- | --- |
66
- | `base.css` | `--accent`, `--foreground` | tokens.css or the app theme |
67
- | `dialog.css` | none | |
68
- | `modal.css` | `--modal-open-dur`, `--modal-close-dur`, `--modal-scale`, `--modal-scale-close`, `--modal-ease` | app `:root` |
69
- | `tabs.css` | `--tabs-dur`, `--tabs-ease` (app `:root`); `--tabs-text-muted`, `--tabs-text-active`, `--tabs-pill-bg` | colours: tabs-pills.css or the app theme |
70
- | `tabs-pills.css` | `--muted`, `--segment`, `--segment-foreground`, `--default` | tokens.css or the app theme |
71
- | `utilities.css` | `--breakpoint-sm` (Tailwind default or app theme) | Tailwind |
72
- | `scrollbar-thin.css` | `--scrollbar` | tokens.css or the app theme |
73
- | `animations.css` | none | |
74
- | `tailwind-preset` | the CSS variables named in its colour map, `--radius`, `--field-radius` | tokens.css or the app theme |
75
-
76
- ### Using an existing shadcn RGB theme
77
-
78
- See the package `README.md`. With the compat file, `base.css`, `scrollbar-thin.css`, `utilities.css`, `dialog.css`, `modal.css` and `tabs.css` need no `tokens.css`. `base.css` reads `--accent` and `--foreground` and `tabs-pills.css` reads `--muted`, `--segment` and `--default`. Those are not channel-compatible, so skip `tabs-pills.css` and `index.css` (which includes `base.css`) unless the app defines those variables as full colours.
79
-
80
- ### Parameters
81
-
82
- Where apps share a rule but drifted in one value, the value is a custom property whose fallback is the most common value. Set it on `:root` to keep a different value.
83
-
84
- | File | Property | Default | Set by |
85
- | --- | --- | --- | --- |
86
- | `base.css` | `--tokens-control-transition-property` | `background-color, border-color, color, box-shadow, transform, opacity` | app-c: `background-color, color, transform, opacity` |
87
- | `base.css` | `--tokens-control-transition-duration` | `240ms` | app-c: `100ms` |
88
-
89
- ## Develop
90
-
91
- ```sh
92
- pnpm --filter @vvedantb/ui build # export targets exist + tsc (primitives, shell, app)
93
- pnpm --filter @vvedantb/ui test # vitest (app helpers) + offline node --test: exports, CSS structure, Tailwind compile, preset
94
- pnpm --filter @vvedantb/ui analyze # rule-level duplication across the app clones -> verify/ANALYSIS.tables.md
95
- pnpm --filter @vvedantb/ui verify # before/after compile of every app -> verify/RESULTS.md, examples/migration.md
96
- ```
97
-
98
- `analyze` and `verify` need the app clones (`APPS_DIR`, default `/workspace/cvx`; name or symlink the clones `app-a` to `app-e` inside it) and network access the first time: they install Tailwind 4.3.3, the plugins, postcss and jiti into a scratch directory (`VERIFY_DIR`, default `/workspace/pk-tokens-verify`, outside the repo). The migration for each app is data in `verify/migrations/<app>.json`. Each removed block must equal the shared file's block with the same context and selector, or the run fails.