@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 +10 -11
- package/shell/src/SessionsHoverCard.test.tsx +256 -0
- package/src/composer/input-area/InputArea.tsx +146 -91
- package/src/composer/input-area/InputAreaEditor.tsx +411 -0
- package/src/composer/input-area/composerCompact.ts +2 -4
- package/src/composer/input-area/useComposerCompact.ts +2 -4
- package/src/composer/input-area/useComposerSlashMenu.ts +2 -2
- package/app/README.md +0 -100
- package/tokens/README.md +0 -98
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",
|
|
@@ -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
|
-
"
|
|
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
|
-
/**
|
|
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,
|
|
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
|
|
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<
|
|
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
|
|
215
|
+
"smooth-typing-input focus-visible:outline-hidden",
|
|
202
216
|
compact
|
|
203
217
|
? COMPACT_EDITOR
|
|
204
|
-
:
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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-[
|
|
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
|
-
|
|
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 (
|
|
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<
|
|
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<
|
|
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.
|