@vvedantb/ui 0.3.1 → 0.4.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -4
- package/package.json +1 -1
- package/src/composer/Composer.tsx +53 -7
- package/src/composer/index.ts +2 -0
- package/src/composer/input-area/ComposerSlashMenu.tsx +137 -0
- package/src/composer/input-area/ComposerSlashRow.tsx +75 -0
- package/src/composer/input-area/ConnectionStatusMenuItem.tsx +34 -0
- package/src/composer/input-area/FadePresence.tsx +31 -0
- package/src/composer/input-area/InputActions.tsx +63 -0
- package/src/composer/input-area/InputArea.tsx +504 -0
- package/src/composer/input-area/InputBanners.tsx +61 -0
- package/src/composer/input-area/PromoBanner.tsx +53 -0
- package/src/composer/input-area/composerCompact.ts +52 -0
- package/src/composer/input-area/composerPrediction.ts +51 -0
- package/src/composer/input-area/composerSlashItems.ts +206 -0
- package/src/composer/input-area/composerSlashOptionId.ts +7 -0
- package/src/composer/input-area/composerSlashPosition.ts +40 -0
- package/src/composer/input-area/composerSlashTrigger.ts +50 -0
- package/src/composer/input-area/index.ts +30 -0
- package/src/composer/input-area/inputPresets.ts +14 -0
- package/src/composer/input-area/motion.ts +7 -0
- package/src/composer/input-area/types.ts +25 -0
- package/src/composer/input-area/useComposerCompact.ts +46 -0
- package/src/composer/input-area/useComposerSlashMenu.ts +203 -0
- package/src/composer/input-area/useInputAreaPrediction.ts +49 -0
- package/src/ui/button.tsx +11 -2
- package/src/ui/dropdown-menu.tsx +35 -7
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Shared React 19 web UI. Source-exported: no build step.
|
|
|
6
6
|
| --- | --- |
|
|
7
7
|
| `@vvedantb/ui` | Primitives (Radix-based), AI elements, `playChime` and `useReplyChime`. |
|
|
8
8
|
| `@vvedantb/ui/sonner` | `SonnerToaster`, its helpers and `toast`. |
|
|
9
|
-
| `@vvedantb/ui/composer` | `Composer`, `ComposerShell`, `ComposerGhostText`, `ComposerIconButton`, `useComposerPrediction` and the pure prediction helpers. |
|
|
9
|
+
| `@vvedantb/ui/composer` | `InputArea` (recommended) and its building blocks. Also the deprecated 0.3 `Composer`, `ComposerShell`, `ComposerGhostText`, `ComposerIconButton`, `useComposerPrediction` and the pure prediction helpers. |
|
|
10
10
|
| `@vvedantb/ui/audio` | `playChime`, `useReplyChime`, `playTone` and the shared audio floor. |
|
|
11
11
|
| `@vvedantb/ui/cn` | `cn` class helper (clsx + tailwind-merge). |
|
|
12
12
|
| `@vvedantb/ui/shell` | Router-free `SidebarHeader`, `PillTabs` and `SessionsHoverCard`. |
|
|
@@ -39,6 +39,57 @@ useReplyChime({ enabled: soundOn, isStreaming, threadId });
|
|
|
39
39
|
|
|
40
40
|
## Composer
|
|
41
41
|
|
|
42
|
+
### InputArea (recommended)
|
|
43
|
+
|
|
44
|
+
`InputArea` is the chat composer extracted from an app: a pill that grows into a card, a plus menu, dictate, stop and send, ghost text, a `/` menu, and slots for banners, a queue, attachments and chips. It is presentational. It makes no requests and reads no router, billing or storage. The app passes data in and handles callbacks. It adds its own `TooltipProvider` and `LazyMotion`, so it needs no app provider.
|
|
45
|
+
|
|
46
|
+
```tsx
|
|
47
|
+
import { InputArea, InputBanners, buildComposerSlashItems } from "@vvedantb/ui/composer";
|
|
48
|
+
|
|
49
|
+
<InputArea
|
|
50
|
+
value={draft}
|
|
51
|
+
onChange={setDraft}
|
|
52
|
+
onSubmit={(text) => send(text)}
|
|
53
|
+
onStop={abort}
|
|
54
|
+
isStreaming={isStreaming}
|
|
55
|
+
fileCount={files.length}
|
|
56
|
+
chipCount={chips.length}
|
|
57
|
+
onFilesSelected={addFiles}
|
|
58
|
+
onRemoveLastChip={removeLastChip}
|
|
59
|
+
showDictate
|
|
60
|
+
slashItems={slashItems}
|
|
61
|
+
onSlashSelect={(item, { openFilePicker }) => run(item, openFilePicker)}
|
|
62
|
+
attachments={<AttachmentStrip />}
|
|
63
|
+
chips={<ChipRow />}
|
|
64
|
+
plusMenuExtra={<SkillPickerAndConnectionRows />}
|
|
65
|
+
getPrediction={complete}
|
|
66
|
+
className={composerClass}
|
|
67
|
+
borderClassName={borderClass}
|
|
68
|
+
submitButtonClassName={submitBg}
|
|
69
|
+
submitIconClassName={submitText}
|
|
70
|
+
/>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
| Prop | Default | Notes |
|
|
74
|
+
| --- | --- | --- |
|
|
75
|
+
| `value`, `onChange`, `defaultValue` | uncontrolled, `""` | `onSubmit` clears the draft through `onChange("")` first. Restore it yourself if sending fails. |
|
|
76
|
+
| `onSubmit(text)` | none | Trimmed text. Not called while `isSending`, or when send is disabled. |
|
|
77
|
+
| `onStop`, `isStreaming`, `isSending`, `isUploading` | off | While streaming, stop shows next to send, and send queues. Sending or uploading shows a spinner and disables send. |
|
|
78
|
+
| `canSubmit` | text, or `fileCount` or `chipCount` above 0 | Overrides when send is enabled. |
|
|
79
|
+
| `fileCount`, `chipCount` | `0` | Compact mode needs them: files or chips keep the card open. |
|
|
80
|
+
| `showDictate`, `onDictateTranscription` | off, append to draft | |
|
|
81
|
+
| `showFileUpload`, `onFilesSelected`, `onAttachFile`, `attachAccept` | on, none, click the hidden input, images and PDF | The hidden input and drop handling appear only with `onFilesSelected`. `onAttachFile` replaces the click. |
|
|
82
|
+
| `banners`, `promoBanner`, `queue`, `attachments`, `chips`, `plusMenuExtra` | none | Slots. `plusMenuExtra` goes after the attach row: include your own `DropdownMenuSeparator`. |
|
|
83
|
+
| `slashItems`, `onSlashSelect`, `renderSlashItem` | menu off | Build items with `buildComposerSlashItems`. `renderSlashItem(item, row)` wraps a row, for example in a hover card. |
|
|
84
|
+
| `getPrediction`, `predictionEnabled` | off, `true` | Ghost text. Tab accepts, Esc dismisses. Paused while the `/` menu is open. |
|
|
85
|
+
| `className`, `containerClassName`, `borderClassName`, `submitButtonClassName`, `submitIconClassName`, `placeholder` | none | The app passes its own class strings for an exact match. |
|
|
86
|
+
|
|
87
|
+
Building blocks: `InputActions`, `ConnectionStatusMenuItem` (icon, label, description, `connected`, `onClick`, `title`), `InputBanners`, `PromoBanner` (`show`, `onUpgrade`, `onDismiss`, text as children), `FadePresence`, `ComposerSlashMenu`, `ComposerSlashRow`, and the hooks and helpers listed in the changelog. Parity against the original: `composer/examples/parity/PARITY.md`. App migration: `composer/MIGRATION.md`.
|
|
88
|
+
|
|
89
|
+
### Composer (deprecated)
|
|
90
|
+
|
|
91
|
+
> **Deprecated in 0.4.0.** Use `InputArea`. The 0.3 `Composer` was written from scratch and does not match the app composer. It stays exported and unchanged for existing consumers, and is not removed in 0.4.0.
|
|
92
|
+
|
|
42
93
|
Chat composer with an autosizing textarea, a footer with a plus-menu slot, action slots and a send or stop button. It also shows optional ghost-text predictions. It makes no network calls: the app supplies `getPrediction`.
|
|
43
94
|
|
|
44
95
|
```tsx
|
|
@@ -59,8 +110,10 @@ import { Composer, ComposerIconButton } from "@vvedantb/ui"; // or "@vvedantb/ui
|
|
|
59
110
|
| Prop | Default | Notes |
|
|
60
111
|
| --- | --- | --- |
|
|
61
112
|
| `value`, `defaultValue`, `onChange` | uncontrolled, `""` | Pass `value` to control it. When controlled, clear the value yourself after `onSubmit`. When uncontrolled, the composer clears after submit. |
|
|
62
|
-
| `onSubmit(text)` | none | Called with the trimmed draft on Enter or the send button.
|
|
63
|
-
| `streaming`, `onStop` | `false` | The stop button
|
|
113
|
+
| `onSubmit(text)` | none | Called with the trimmed draft on Enter or the send button. By default it is not called for an empty draft, or when `disabled` or (with `stopMode="replace"`) `streaming`. A `canSend` override that allows an empty draft calls it with `""`. If it returns a rejected promise, an uncontrolled composer restores the draft. |
|
|
114
|
+
| `streaming`, `onStop` | `false` | The stop button calls `onStop`. With `stopMode="replace"` it replaces send and Enter does not submit. Typing stays on. |
|
|
115
|
+
| `stopMode` | `"replace"` | `"alongside"` shows stop and send together while streaming, in the order `trailingActions`, stop, send. Send stays usable to queue the next message: Enter and the send button submit while streaming when `canSend` allows. Not streaming, only send shows. Predictions stay off while streaming. |
|
|
116
|
+
| `canSend` | trimmed draft is not empty | `boolean` or `(draft: string) => boolean`. Overrides whether send is enabled. The function gets the live draft (not trimmed). `disabled` and, in replace mode, `streaming` still block send. With attachments, use `canSend={(draft) => draft.trim() !== "" \|\| hasAttachments}`. `onSubmit` then gets `""` for an empty draft. |
|
|
64
117
|
| `disabled` | `false` | Disables the textarea and send. |
|
|
65
118
|
| `placeholder` | `"Message"` | Also the textarea `aria-label`. |
|
|
66
119
|
| `layout` | `"stacked"` | `"stacked"`: input above, footer bar with `plusMenu` left and `trailingActions` plus send or stop right. `"inline"`: one row with `plusMenu`, the input and the actions. Attachments stay above the row. |
|
|
@@ -124,7 +177,7 @@ function MentionComposer({ draft, setDraft, send }: Props) {
|
|
|
124
177
|
}
|
|
125
178
|
```
|
|
126
179
|
|
|
127
|
-
`ComposerShell` props: `layout`, `disabled`, `streaming`, `canSend`, `onSend`, `onStop`, `plusMenu`, `trailingActions`, `attachments`, `className` and `children` (the input region). In the shell the Enter, Tab and Esc keys are yours to wire when you do not use `Composer`.
|
|
180
|
+
`ComposerShell` props: `layout`, `disabled`, `streaming`, `canSend` (always a boolean: `Composer` resolves the function form), `stopMode` (`"replace"` or `"alongside"`, where the buttons render), `onSend`, `onStop`, `plusMenu`, `trailingActions`, `attachments`, `className` and `children` (the input region). In the shell the Enter, Tab and Esc keys are yours to wire when you do not use `Composer`.
|
|
128
181
|
|
|
129
182
|
Fixture: `composer/examples/` (see its README). It has a `tokens.css` page and a `compat-shadcn-rgb.css` page, each with light and dark.
|
|
130
183
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0-rc.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Shared React 19 UI for web apps: primitives, shell pieces, app helpers, design tokens, shared Tailwind v4 CSS and a Tailwind preset.",
|
|
6
6
|
"type": "module",
|
|
@@ -15,6 +15,12 @@ import { useComposerPrediction } from "./useComposerPrediction";
|
|
|
15
15
|
export type ComposerSlotContext = { disabled: boolean };
|
|
16
16
|
export type ComposerTrailingContext = { disabled: boolean; streaming: boolean };
|
|
17
17
|
export type ComposerLayout = "stacked" | "inline";
|
|
18
|
+
/**
|
|
19
|
+
* How stop behaves while `streaming`. `"replace"` (default): stop takes the
|
|
20
|
+
* place of send. `"alongside"`: stop and send show together, in the order
|
|
21
|
+
* trailing actions, stop, send.
|
|
22
|
+
*/
|
|
23
|
+
export type ComposerStopMode = "replace" | "alongside";
|
|
18
24
|
|
|
19
25
|
export type ComposerInputRenderContext = {
|
|
20
26
|
value: string;
|
|
@@ -44,13 +50,30 @@ export type ComposerProps = {
|
|
|
44
50
|
defaultValue?: string;
|
|
45
51
|
onChange?: (value: string) => void;
|
|
46
52
|
|
|
47
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Called with the trimmed draft on Enter or the send button. By default it
|
|
55
|
+
* is not called for an empty draft. A `canSend` override that allows an
|
|
56
|
+
* empty draft calls it with `""`.
|
|
57
|
+
*/
|
|
48
58
|
onSubmit?: (value: string) => void | Promise<void>;
|
|
49
59
|
onStop?: () => void;
|
|
50
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* Shows the stop button. With `stopMode="replace"` (default) it takes the
|
|
62
|
+
* place of send and Enter does not submit. Typing stays enabled.
|
|
63
|
+
*/
|
|
51
64
|
streaming?: boolean;
|
|
52
|
-
/** Disables the textarea and the send button. */
|
|
65
|
+
/** Disables the textarea and the send button. `canSend` cannot override it. */
|
|
53
66
|
disabled?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Overrides whether send is enabled. Default: the trimmed draft is not
|
|
69
|
+
* empty. `disabled` and, with `stopMode="replace"`, `streaming` still block
|
|
70
|
+
* send. A function receives the live draft (not trimmed), so check
|
|
71
|
+
* `draft.trim()` yourself. Use it to allow an empty draft when attachments
|
|
72
|
+
* exist: `canSend={(draft) => draft.trim() !== "" || hasAttachments}`.
|
|
73
|
+
*/
|
|
74
|
+
canSend?: boolean | ((draft: string) => boolean);
|
|
75
|
+
/** `"replace"` (default) or `"alongside"`. See `ComposerStopMode`. */
|
|
76
|
+
stopMode?: ComposerStopMode;
|
|
54
77
|
|
|
55
78
|
placeholder?: string;
|
|
56
79
|
/** Maximum textarea height in px. The textarea scrolls after it. Default 200. */
|
|
@@ -124,8 +147,10 @@ export type ComposerShellProps = {
|
|
|
124
147
|
layout?: ComposerLayout;
|
|
125
148
|
disabled?: boolean;
|
|
126
149
|
streaming?: boolean;
|
|
127
|
-
/** Enables the send button. */
|
|
150
|
+
/** Enables the send button. Always a boolean: `Composer` resolves its function form. */
|
|
128
151
|
canSend?: boolean;
|
|
152
|
+
/** `"replace"` (default) or `"alongside"`. Alongside while streaming: trailing actions, stop, send. */
|
|
153
|
+
stopMode?: ComposerStopMode;
|
|
129
154
|
onSend?: () => void;
|
|
130
155
|
onStop?: () => void;
|
|
131
156
|
plusMenu?: SlotNode<ComposerSlotContext>;
|
|
@@ -147,6 +172,7 @@ function ComposerShell({
|
|
|
147
172
|
disabled = false,
|
|
148
173
|
streaming = false,
|
|
149
174
|
canSend = false,
|
|
175
|
+
stopMode = "replace",
|
|
150
176
|
onSend,
|
|
151
177
|
onStop,
|
|
152
178
|
plusMenu,
|
|
@@ -162,7 +188,7 @@ function ComposerShell({
|
|
|
162
188
|
: trailingActions;
|
|
163
189
|
const inline = layout === "inline";
|
|
164
190
|
|
|
165
|
-
const
|
|
191
|
+
const stopButton = (
|
|
166
192
|
<ComposerIconButton
|
|
167
193
|
variant="default"
|
|
168
194
|
aria-label="Stop generating"
|
|
@@ -171,7 +197,8 @@ function ComposerShell({
|
|
|
171
197
|
>
|
|
172
198
|
<IconPlayerStop className="size-4" aria-hidden="true" />
|
|
173
199
|
</ComposerIconButton>
|
|
174
|
-
)
|
|
200
|
+
);
|
|
201
|
+
const sendButton = (
|
|
175
202
|
<ComposerIconButton
|
|
176
203
|
variant="default"
|
|
177
204
|
aria-label="Send message"
|
|
@@ -182,6 +209,16 @@ function ComposerShell({
|
|
|
182
209
|
<IconArrowUp className="size-4" aria-hidden="true" />
|
|
183
210
|
</ComposerIconButton>
|
|
184
211
|
);
|
|
212
|
+
const action = !streaming ? (
|
|
213
|
+
sendButton
|
|
214
|
+
) : stopMode === "alongside" ? (
|
|
215
|
+
<>
|
|
216
|
+
{stopButton}
|
|
217
|
+
{sendButton}
|
|
218
|
+
</>
|
|
219
|
+
) : (
|
|
220
|
+
stopButton
|
|
221
|
+
);
|
|
185
222
|
|
|
186
223
|
return (
|
|
187
224
|
<div
|
|
@@ -189,6 +226,7 @@ function ComposerShell({
|
|
|
189
226
|
data-layout={layout}
|
|
190
227
|
data-disabled={disabled || undefined}
|
|
191
228
|
data-streaming={streaming || undefined}
|
|
229
|
+
data-stop-mode={stopMode}
|
|
192
230
|
className={cn(
|
|
193
231
|
"group/composer relative flex w-full min-w-0 flex-col rounded-field border border-border bg-field-background text-sm transition-[border-color,box-shadow] duration-200 ease-smooth",
|
|
194
232
|
"has-[[data-composer-input]:focus-visible]:border-focus-border has-[[data-composer-input]:focus-visible]:ring-2 has-[[data-composer-input]:focus-visible]:ring-focus-ring",
|
|
@@ -265,6 +303,7 @@ const ComposerGhostText = React.forwardRef<HTMLDivElement, ComposerGhostTextProp
|
|
|
265
303
|
* Chat composer: autosizing textarea, plus-menu and action slots, send/stop
|
|
266
304
|
* button and optional ghost-text predictions. The ref points at the textarea.
|
|
267
305
|
*/
|
|
306
|
+
/** @deprecated Use `InputArea` from `@vvedantb/ui/composer`. Kept unchanged for existing consumers. */
|
|
268
307
|
const Composer = React.forwardRef<HTMLTextAreaElement, ComposerProps>(function Composer(
|
|
269
308
|
{
|
|
270
309
|
value: valueProp,
|
|
@@ -274,6 +313,8 @@ const Composer = React.forwardRef<HTMLTextAreaElement, ComposerProps>(function C
|
|
|
274
313
|
onStop,
|
|
275
314
|
streaming = false,
|
|
276
315
|
disabled = false,
|
|
316
|
+
canSend: canSendProp,
|
|
317
|
+
stopMode = "replace",
|
|
277
318
|
placeholder = "Message",
|
|
278
319
|
maxHeight = DEFAULT_MAX_HEIGHT,
|
|
279
320
|
autoFocus,
|
|
@@ -343,7 +384,11 @@ const Composer = React.forwardRef<HTMLTextAreaElement, ComposerProps>(function C
|
|
|
343
384
|
};
|
|
344
385
|
React.useLayoutEffect(syncScroll, [value, ghost]);
|
|
345
386
|
|
|
346
|
-
const
|
|
387
|
+
const override = typeof canSendProp === "function" ? canSendProp(value) : canSendProp;
|
|
388
|
+
const canSend =
|
|
389
|
+
!disabled &&
|
|
390
|
+
(stopMode === "alongside" || !streaming) &&
|
|
391
|
+
(override ?? value.trim() !== "");
|
|
347
392
|
|
|
348
393
|
const submit = () => {
|
|
349
394
|
if (!canSend) return;
|
|
@@ -454,6 +499,7 @@ const Composer = React.forwardRef<HTMLTextAreaElement, ComposerProps>(function C
|
|
|
454
499
|
disabled={disabled}
|
|
455
500
|
streaming={streaming}
|
|
456
501
|
canSend={canSend}
|
|
502
|
+
stopMode={stopMode}
|
|
457
503
|
onSend={submit}
|
|
458
504
|
onStop={onStop}
|
|
459
505
|
plusMenu={plusMenu}
|
package/src/composer/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ export type {
|
|
|
6
6
|
ComposerProps,
|
|
7
7
|
ComposerShellProps,
|
|
8
8
|
ComposerSlotContext,
|
|
9
|
+
ComposerStopMode,
|
|
9
10
|
ComposerTrailingContext,
|
|
10
11
|
} from "./Composer";
|
|
11
12
|
export { useComposerPrediction } from "./useComposerPrediction";
|
|
@@ -21,3 +22,4 @@ export {
|
|
|
21
22
|
visibleComposerPrediction,
|
|
22
23
|
} from "./prediction";
|
|
23
24
|
export type { ComposerPredictionAction, ComposerPredictionState } from "./prediction";
|
|
25
|
+
export * from "./input-area";
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { m } from "motion/react";
|
|
4
|
+
import { motionFast } from "./motion";
|
|
5
|
+
import type { ComposerSlashPlacement } from "./composerSlashPosition";
|
|
6
|
+
import type { ComposerSlashItem } from "./composerSlashItems";
|
|
7
|
+
import { filterAndGroupSlashItems } from "./composerSlashItems";
|
|
8
|
+
import { optionId } from "./composerSlashOptionId";
|
|
9
|
+
import { ComposerSlashRow } from "./ComposerSlashRow";
|
|
10
|
+
|
|
11
|
+
interface ComposerSlashMenuProps {
|
|
12
|
+
listboxId: string;
|
|
13
|
+
placement: ComposerSlashPlacement;
|
|
14
|
+
items: ComposerSlashItem[];
|
|
15
|
+
query: string;
|
|
16
|
+
selectedIndex: number;
|
|
17
|
+
onHoverIndex: (index: number) => void;
|
|
18
|
+
onSelectItem: (item: ComposerSlashItem) => void;
|
|
19
|
+
/** Wrap the row, for example in a hover card. Return `row` to keep the default. */
|
|
20
|
+
renderItem?: (item: ComposerSlashItem, row: ReactNode) => ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ComposerSlashMenu({
|
|
24
|
+
listboxId,
|
|
25
|
+
placement,
|
|
26
|
+
items,
|
|
27
|
+
query,
|
|
28
|
+
selectedIndex,
|
|
29
|
+
onHoverIndex,
|
|
30
|
+
onSelectItem,
|
|
31
|
+
renderItem,
|
|
32
|
+
}: ComposerSlashMenuProps) {
|
|
33
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
34
|
+
const groups = filterAndGroupSlashItems(items, query);
|
|
35
|
+
const flatItems = groups.flatMap((group) => group.items);
|
|
36
|
+
const indexById = new Map(flatItems.map((item, index) => [item.id, index]));
|
|
37
|
+
const isAbove = placement.placement === "above";
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const list = listRef.current;
|
|
41
|
+
if (!list) return;
|
|
42
|
+
const selected = list.querySelector<HTMLElement>(
|
|
43
|
+
'[data-slash-picker-item][data-selected="true"]',
|
|
44
|
+
);
|
|
45
|
+
selected?.scrollIntoView({ block: "nearest" });
|
|
46
|
+
}, [selectedIndex, flatItems.length]);
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
className="fixed z-50"
|
|
51
|
+
style={{
|
|
52
|
+
left: placement.left,
|
|
53
|
+
top: placement.top,
|
|
54
|
+
width: placement.width,
|
|
55
|
+
transform: isAbove ? "translateY(-100%)" : undefined,
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
<m.div
|
|
59
|
+
initial={{ opacity: 0, scale: 0.95 }}
|
|
60
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
61
|
+
exit={{ opacity: 0, scale: 0.95 }}
|
|
62
|
+
transition={motionFast}
|
|
63
|
+
style={{ maxHeight: placement.maxHeight }}
|
|
64
|
+
className={
|
|
65
|
+
"flex flex-col overflow-hidden rounded-surface " +
|
|
66
|
+
"bg-popover/95 text-popover-foreground backdrop-blur-md smooth-shadow-ring-lg " +
|
|
67
|
+
(isAbove ? "origin-bottom" : "origin-top")
|
|
68
|
+
}
|
|
69
|
+
>
|
|
70
|
+
{flatItems.length > 0 ? (
|
|
71
|
+
<div
|
|
72
|
+
ref={listRef}
|
|
73
|
+
id={listboxId}
|
|
74
|
+
role="listbox"
|
|
75
|
+
aria-label="Commands"
|
|
76
|
+
className="scrollbar scroll-fade min-h-0 flex-1 overflow-y-auto overscroll-contain py-1"
|
|
77
|
+
>
|
|
78
|
+
{groups.map((group) => (
|
|
79
|
+
<div key={group.group}>
|
|
80
|
+
<div className="px-3 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
81
|
+
{group.group}
|
|
82
|
+
</div>
|
|
83
|
+
{group.items.map((item) => {
|
|
84
|
+
const index = indexById.get(item.id);
|
|
85
|
+
if (index === undefined) return null;
|
|
86
|
+
const row = (
|
|
87
|
+
<ComposerSlashRow
|
|
88
|
+
label={item.label}
|
|
89
|
+
description={item.description}
|
|
90
|
+
icon={item.icon}
|
|
91
|
+
badge={item.badge}
|
|
92
|
+
active={item.active}
|
|
93
|
+
/>
|
|
94
|
+
);
|
|
95
|
+
const isSelected = index === selectedIndex;
|
|
96
|
+
return (
|
|
97
|
+
<button
|
|
98
|
+
key={item.id}
|
|
99
|
+
id={optionId(listboxId, item.id)}
|
|
100
|
+
type="button"
|
|
101
|
+
role="option"
|
|
102
|
+
aria-selected={isSelected}
|
|
103
|
+
data-slash-picker-item
|
|
104
|
+
data-selected={isSelected ? "true" : "false"}
|
|
105
|
+
onMouseEnter={() => onHoverIndex(index)}
|
|
106
|
+
onMouseDown={(event) => {
|
|
107
|
+
event.preventDefault();
|
|
108
|
+
onSelectItem(item);
|
|
109
|
+
}}
|
|
110
|
+
className={
|
|
111
|
+
"mx-1 flex w-[calc(100%-0.5rem)] min-w-0 items-center rounded-lg px-2 py-1.5 text-left text-sm max-sm:py-2.5 " +
|
|
112
|
+
"transition-[background-color] active:scale-[0.99] active:duration-100 active:ease-out " +
|
|
113
|
+
(isSelected
|
|
114
|
+
? "bg-primary/15 font-medium text-foreground dark:bg-primary/25"
|
|
115
|
+
: "text-foreground/90 hover:bg-muted")
|
|
116
|
+
}
|
|
117
|
+
>
|
|
118
|
+
{renderItem ? (
|
|
119
|
+
renderItem(item, row)
|
|
120
|
+
) : (
|
|
121
|
+
row
|
|
122
|
+
)}
|
|
123
|
+
</button>
|
|
124
|
+
);
|
|
125
|
+
})}
|
|
126
|
+
</div>
|
|
127
|
+
))}
|
|
128
|
+
</div>
|
|
129
|
+
) : (
|
|
130
|
+
<div className="px-3 py-2.5 text-xs leading-relaxed text-muted-foreground">
|
|
131
|
+
No matches.
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
</m.div>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import {
|
|
3
|
+
IconBook2,
|
|
4
|
+
IconFile,
|
|
5
|
+
IconLock,
|
|
6
|
+
IconPaperclip,
|
|
7
|
+
IconPlus,
|
|
8
|
+
} from "@tabler/icons-react";
|
|
9
|
+
import type { SlashIconKey } from "./composerSlashItems";
|
|
10
|
+
|
|
11
|
+
function previewOneLine(text: string, maxLength = 72): string {
|
|
12
|
+
const singleLine = text.replace(/\s+/g, " ").trim();
|
|
13
|
+
if (singleLine.length <= maxLength) return singleLine;
|
|
14
|
+
return `${singleLine.slice(0, maxLength - 1)}…`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function SlashIcon({ icon }: { icon: SlashIconKey }): ReactNode {
|
|
18
|
+
const className = "size-4 shrink-0 text-muted-foreground";
|
|
19
|
+
switch (icon) {
|
|
20
|
+
case "plus":
|
|
21
|
+
return <IconPlus className={className} />;
|
|
22
|
+
case "paperclip":
|
|
23
|
+
return <IconPaperclip className={className} />;
|
|
24
|
+
case "lock":
|
|
25
|
+
return <IconLock className={className} />;
|
|
26
|
+
case "book":
|
|
27
|
+
return <IconBook2 className={className} />;
|
|
28
|
+
case "file":
|
|
29
|
+
return <IconFile className={className} />;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function ComposerSlashRow({
|
|
34
|
+
label,
|
|
35
|
+
description,
|
|
36
|
+
icon,
|
|
37
|
+
badge,
|
|
38
|
+
active,
|
|
39
|
+
}: {
|
|
40
|
+
label: string;
|
|
41
|
+
description?: string;
|
|
42
|
+
icon: SlashIconKey;
|
|
43
|
+
badge?: string;
|
|
44
|
+
active?: boolean;
|
|
45
|
+
}): ReactNode {
|
|
46
|
+
const detail = description ? previewOneLine(description) : null;
|
|
47
|
+
return (
|
|
48
|
+
<span className="flex w-full min-w-0 items-center gap-2">
|
|
49
|
+
<SlashIcon icon={icon} />
|
|
50
|
+
<span className="flex min-w-0 flex-1 flex-col gap-0.5 overflow-hidden">
|
|
51
|
+
<span className="flex min-w-0 items-center gap-1.5">
|
|
52
|
+
<span className="flex min-w-0 flex-1 items-center gap-0.5 overflow-hidden">
|
|
53
|
+
<span className="shrink-0 text-muted-foreground">/</span>
|
|
54
|
+
<span className="truncate">{label}</span>
|
|
55
|
+
</span>
|
|
56
|
+
{badge ? (
|
|
57
|
+
<span className="flex shrink-0 items-center gap-1 rounded-md bg-surface-secondary px-1.5 py-0.5 text-[10px] font-medium leading-none text-muted-foreground">
|
|
58
|
+
{badge}
|
|
59
|
+
</span>
|
|
60
|
+
) : null}
|
|
61
|
+
{active ? (
|
|
62
|
+
<span className="shrink-0 text-[10px] font-medium text-muted-foreground">
|
|
63
|
+
On
|
|
64
|
+
</span>
|
|
65
|
+
) : null}
|
|
66
|
+
</span>
|
|
67
|
+
{detail ? (
|
|
68
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
69
|
+
{detail}
|
|
70
|
+
</span>
|
|
71
|
+
) : null}
|
|
72
|
+
</span>
|
|
73
|
+
</span>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { DropdownMenuItem } from "../../ui/dropdown-menu";
|
|
3
|
+
|
|
4
|
+
export type ConnectionStatusMenuItemProps = {
|
|
5
|
+
/** Brand mark with its own status dot. The app owns it. */
|
|
6
|
+
icon: ReactNode;
|
|
7
|
+
label: ReactNode;
|
|
8
|
+
/** Shown under the label while `connected` is true. */
|
|
9
|
+
description?: ReactNode;
|
|
10
|
+
connected: boolean;
|
|
11
|
+
onClick?: () => void;
|
|
12
|
+
title?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Menu row that shows a connection state, for example an external tool hub. */
|
|
16
|
+
export function ConnectionStatusMenuItem({
|
|
17
|
+
icon,
|
|
18
|
+
label,
|
|
19
|
+
description,
|
|
20
|
+
connected,
|
|
21
|
+
onClick,
|
|
22
|
+
title,
|
|
23
|
+
}: ConnectionStatusMenuItemProps) {
|
|
24
|
+
return (
|
|
25
|
+
<DropdownMenuItem
|
|
26
|
+
startContent={icon}
|
|
27
|
+
description={connected ? description : undefined}
|
|
28
|
+
title={title}
|
|
29
|
+
onClick={onClick}
|
|
30
|
+
>
|
|
31
|
+
{label}
|
|
32
|
+
</DropdownMenuItem>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { AnimatePresence, domAnimation, LazyMotion, m } from "motion/react";
|
|
3
|
+
import { motionFast } from "./motion";
|
|
4
|
+
|
|
5
|
+
export type FadePresenceProps = {
|
|
6
|
+
show: boolean;
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
className?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Fades children in and out as `show` changes. Does not animate on first mount. Brings its own `LazyMotion`, so `m` components animate without an app provider. */
|
|
12
|
+
export function FadePresence({ show, children, className }: FadePresenceProps) {
|
|
13
|
+
return (
|
|
14
|
+
<LazyMotion features={domAnimation}>
|
|
15
|
+
<AnimatePresence initial={false}>
|
|
16
|
+
{show ? (
|
|
17
|
+
<m.div
|
|
18
|
+
key="fade-presence"
|
|
19
|
+
className={className}
|
|
20
|
+
initial={{ opacity: 0 }}
|
|
21
|
+
animate={{ opacity: 1 }}
|
|
22
|
+
exit={{ opacity: 0 }}
|
|
23
|
+
transition={motionFast}
|
|
24
|
+
>
|
|
25
|
+
{children}
|
|
26
|
+
</m.div>
|
|
27
|
+
) : null}
|
|
28
|
+
</AnimatePresence>
|
|
29
|
+
</LazyMotion>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { IconPaperclip, IconPlus } from "@tabler/icons-react";
|
|
3
|
+
import { Button } from "../../ui/button";
|
|
4
|
+
import {
|
|
5
|
+
DropdownMenu,
|
|
6
|
+
DropdownMenuContent,
|
|
7
|
+
DropdownMenuItem,
|
|
8
|
+
DropdownMenuSeparator,
|
|
9
|
+
DropdownMenuTrigger,
|
|
10
|
+
} from "../../ui/dropdown-menu";
|
|
11
|
+
|
|
12
|
+
export type InputActionsProps = {
|
|
13
|
+
showFileUpload?: boolean;
|
|
14
|
+
onAttachFile?: () => void;
|
|
15
|
+
compact?: boolean;
|
|
16
|
+
/** Extra menu rows after the attach row, such as a skill picker and a connection item. Include your own separators. */
|
|
17
|
+
extra?: ReactNode;
|
|
18
|
+
attachLabel?: ReactNode;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function InputActions({
|
|
22
|
+
showFileUpload = false,
|
|
23
|
+
onAttachFile,
|
|
24
|
+
compact = false,
|
|
25
|
+
extra,
|
|
26
|
+
attachLabel = "Attach file",
|
|
27
|
+
}: InputActionsProps) {
|
|
28
|
+
return (
|
|
29
|
+
<DropdownMenu>
|
|
30
|
+
<DropdownMenuTrigger asChild>
|
|
31
|
+
<Button
|
|
32
|
+
size="icon"
|
|
33
|
+
variant="ghost"
|
|
34
|
+
className={`relative shrink-0 rounded-full max-sm:hit-target ${
|
|
35
|
+
compact
|
|
36
|
+
? "size-8 bg-surface-tertiary text-foreground hover:bg-surface-tertiary/80 hover:text-foreground"
|
|
37
|
+
: ""
|
|
38
|
+
}`}
|
|
39
|
+
startContent={<IconPlus width={18} />}
|
|
40
|
+
/>
|
|
41
|
+
</DropdownMenuTrigger>
|
|
42
|
+
<DropdownMenuContent
|
|
43
|
+
aria-label="Tool options"
|
|
44
|
+
side="top"
|
|
45
|
+
collisionPadding={12}
|
|
46
|
+
className="max-h-[min(24rem,calc(100dvh-6rem))] max-w-[min(20rem,calc(100vw-1.5rem))] overflow-y-auto"
|
|
47
|
+
>
|
|
48
|
+
{showFileUpload ? (
|
|
49
|
+
<>
|
|
50
|
+
<DropdownMenuItem
|
|
51
|
+
startContent={<IconPaperclip size={18} />}
|
|
52
|
+
onClick={onAttachFile}
|
|
53
|
+
>
|
|
54
|
+
{attachLabel}
|
|
55
|
+
</DropdownMenuItem>
|
|
56
|
+
{extra ? <DropdownMenuSeparator /> : null}
|
|
57
|
+
</>
|
|
58
|
+
) : null}
|
|
59
|
+
{extra}
|
|
60
|
+
</DropdownMenuContent>
|
|
61
|
+
</DropdownMenu>
|
|
62
|
+
);
|
|
63
|
+
}
|