@vvedantb/ui 0.3.2 → 0.4.0-rc.1
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 +66 -19
- package/app/README.md +8 -8
- package/package.json +5 -30
- package/src/composer/Composer.tsx +1 -0
- package/src/composer/index.ts +1 -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 +513 -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/index.ts +21 -0
- package/src/ui/button.tsx +11 -2
- package/src/ui/dropdown-menu.tsx +35 -7
- package/tokens/README.md +2 -2
- package/src/sonner.ts +0 -2
package/README.md
CHANGED
|
@@ -4,18 +4,15 @@ Shared React 19 web UI. Source-exported: no build step.
|
|
|
4
4
|
|
|
5
5
|
| Import | What it is |
|
|
6
6
|
| --- | --- |
|
|
7
|
-
| `@vvedantb/ui` | Primitives (Radix-based), AI elements, `playChime` and `
|
|
8
|
-
| `@vvedantb/ui/
|
|
9
|
-
| `@vvedantb/ui/
|
|
10
|
-
| `@vvedantb/ui/
|
|
11
|
-
| `@vvedantb/ui/
|
|
12
|
-
| `@vvedantb/ui/
|
|
13
|
-
| `@vvedantb/ui/
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
| `@vvedantb/ui/tokens/compat/shadcn-rgb.css` | Same file as `tokens/css/compat-shadcn-rgb.css`. |
|
|
17
|
-
| `@vvedantb/ui/tokens/tailwind-preset` | Tailwind preset for apps that load a JS config with `@config`. |
|
|
18
|
-
| `@vvedantb/ui/tokens/native` | JS colours, radii and spacing. No CSS, no React DOM. |
|
|
7
|
+
| `@vvedantb/ui` | The only JavaScript entry. Primitives (Radix-based), AI elements, audio (`playChime`, `useReplyChime`, `playTone`), `toast` and `SonnerToaster`, the composer (`InputArea`, deprecated 0.3 `Composer`), shell (`SidebarHeader`, `PillTabs`, `SessionsHoverCard`), app helpers (confirm skip, update watcher, settings chrome, `useStableAuth`), `cn`, and the native tokens `tokens` and `Tokens`. See `app/README.md` in the repository for the app helpers. |
|
|
8
|
+
| `@vvedantb/ui/tokens` | `tokens.css`: colour, radius, shadow, ease and font tokens for Tailwind v4. CSS. |
|
|
9
|
+
| `@vvedantb/ui/tokens/tokens.css` | Same file as `@vvedantb/ui/tokens`. CSS. |
|
|
10
|
+
| `@vvedantb/ui/tokens/css/*.css` | `index`, `base`, `dialog`, `modal`, `tabs`, `tabs-pills`, `utilities`, `scrollbar-thin`, `animations`, `compat-shadcn-rgb`. CSS. |
|
|
11
|
+
| `@vvedantb/ui/tokens/compat/shadcn-rgb.css` | Same file as `tokens/css/compat-shadcn-rgb.css`. CSS. |
|
|
12
|
+
| `@vvedantb/ui/tokens/tailwind-preset` | Tailwind preset (`.mjs`) for `@config "@vvedantb/ui/tokens/tailwind-preset"`. Kept as a build-tool entry: Tailwind needs a resolvable package path, and it is not a React API. |
|
|
13
|
+
| `@vvedantb/ui/package.json` | Package metadata. |
|
|
14
|
+
|
|
15
|
+
Removed in 0.4.0-rc.1: the `/cn`, `/sonner`, `/audio`, `/shell`, `/app`, `/composer` and `/tokens/native` subpaths. Import from `@vvedantb/ui`. See [`MIGRATION.md`](MIGRATION.md).
|
|
19
16
|
|
|
20
17
|
```css
|
|
21
18
|
@import "tailwindcss";
|
|
@@ -34,15 +31,66 @@ useReplyChime({ enabled: soundOn, isStreaming, threadId });
|
|
|
34
31
|
- `playChime()`: soft rising two-note chime (WebAudio). One shared `AudioContext`, created on first use. It is a no-op on the server and when autoplay blocks audio. Calls less than 1 second apart play once.
|
|
35
32
|
- `playTone(tone | tones)`: plays `{ frequency, startOffsetS?, durationS?, peakGain?, type? }` through the same floor.
|
|
36
33
|
- Audio floor: every package sound shares one 1 second minimum gap. Route your own sounds through it with `withAudioFloor(() => play())` or `if (tryAcquireAudioFloor()) play()`.
|
|
37
|
-
-
|
|
34
|
+
- The root barrel has no top-level side effects, so an app that imports only the audio symbols bundles only audio code.
|
|
38
35
|
- `useReplyChime`: chimes once when `isStreaming` goes from `true` to `false` for the same `threadId`. It is silent on the first sight of a thread, when `threadId` changes, and when `threadId` is null or undefined. While `enabled` is `false` it still tracks the edge, so enabling mid-turn does not replay a finish.
|
|
39
36
|
|
|
40
37
|
## Composer
|
|
41
38
|
|
|
39
|
+
### InputArea (recommended)
|
|
40
|
+
|
|
41
|
+
`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.
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
import { InputArea, InputBanners, buildComposerSlashItems } from "@vvedantb/ui";
|
|
45
|
+
|
|
46
|
+
<InputArea
|
|
47
|
+
value={draft}
|
|
48
|
+
onChange={setDraft}
|
|
49
|
+
onSubmit={(text) => send(text)}
|
|
50
|
+
onStop={abort}
|
|
51
|
+
isStreaming={isStreaming}
|
|
52
|
+
fileCount={files.length}
|
|
53
|
+
chipCount={chips.length}
|
|
54
|
+
onFilesSelected={addFiles}
|
|
55
|
+
onRemoveLastChip={removeLastChip}
|
|
56
|
+
showDictate
|
|
57
|
+
slashItems={slashItems}
|
|
58
|
+
onSlashSelect={(item, { openFilePicker }) => run(item, openFilePicker)}
|
|
59
|
+
attachments={<AttachmentStrip />}
|
|
60
|
+
chips={<ChipRow />}
|
|
61
|
+
plusMenuExtra={<SkillPickerAndConnectionRows />}
|
|
62
|
+
getPrediction={complete}
|
|
63
|
+
className={composerClass}
|
|
64
|
+
borderClassName={borderClass}
|
|
65
|
+
submitButtonClassName={submitBg}
|
|
66
|
+
submitIconClassName={submitText}
|
|
67
|
+
/>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
| Prop | Default | Notes |
|
|
71
|
+
| --- | --- | --- |
|
|
72
|
+
| `value`, `onChange`, `defaultValue` | uncontrolled, `""` | `onSubmit` clears the draft through `onChange("")` first. Restore it yourself if sending fails. |
|
|
73
|
+
| `onSubmit(text)` | none | Trimmed text. Not called while `isSending`, or when send is disabled. |
|
|
74
|
+
| `onStop`, `isStreaming`, `isSending`, `isUploading` | off | While streaming, stop shows next to send, and send queues. Sending or uploading shows a spinner and disables send. |
|
|
75
|
+
| `canSubmit` | text, or `fileCount` or `chipCount` above 0 | Overrides when send is enabled. |
|
|
76
|
+
| `fileCount`, `chipCount` | `0` | Compact mode needs them: files or chips keep the card open. |
|
|
77
|
+
| `showDictate`, `onDictateTranscription` | off, append to draft | |
|
|
78
|
+
| `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. |
|
|
79
|
+
| `banners`, `promoBanner`, `queue`, `attachments`, `chips`, `plusMenuExtra` | none | Slots. `plusMenuExtra` goes after the attach row: include your own `DropdownMenuSeparator`. |
|
|
80
|
+
| `slashItems`, `onSlashSelect`, `renderSlashItem` | menu off | Build items with `buildComposerSlashItems`. `renderSlashItem(item, row)` wraps a row, for example in a hover card. |
|
|
81
|
+
| `getPrediction`, `predictionEnabled` | off, `true` | Ghost text. Tab accepts, Esc dismisses. Paused while the `/` menu is open. |
|
|
82
|
+
| `className`, `containerClassName`, `borderClassName`, `submitButtonClassName`, `submitIconClassName`, `placeholder` | none | The app passes its own class strings for an exact match. |
|
|
83
|
+
|
|
84
|
+
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-INPUTAREA.md`.
|
|
85
|
+
|
|
86
|
+
### Composer (deprecated)
|
|
87
|
+
|
|
88
|
+
> **Deprecated in 0.4.0-rc.1.** 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-rc.1.
|
|
89
|
+
|
|
42
90
|
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
91
|
|
|
44
92
|
```tsx
|
|
45
|
-
import { Composer, ComposerIconButton } from "@vvedantb/ui";
|
|
93
|
+
import { Composer, ComposerIconButton } from "@vvedantb/ui";
|
|
46
94
|
|
|
47
95
|
<Composer
|
|
48
96
|
value={draft}
|
|
@@ -113,7 +161,7 @@ Rules for a custom input:
|
|
|
113
161
|
To build without `Composer`, assemble the parts yourself. `useComposerPrediction` takes the draft string and reads no element. `ComposerGhostText` draws the draft invisibly, then the ghost text, so give it the same padding and font classes as your editor and place it under the editor.
|
|
114
162
|
|
|
115
163
|
```tsx
|
|
116
|
-
import { ComposerGhostText, ComposerShell, useComposerPrediction } from "@vvedantb/ui
|
|
164
|
+
import { ComposerGhostText, ComposerShell, useComposerPrediction } from "@vvedantb/ui";
|
|
117
165
|
|
|
118
166
|
function MentionComposer({ draft, setDraft, send }: Props) {
|
|
119
167
|
const prediction = useComposerPrediction({ value: draft, enabled: true, getPrediction });
|
|
@@ -133,7 +181,7 @@ Fixture: `composer/examples/` (see its README). It has a `tokens.css` page and a
|
|
|
133
181
|
## Sessions hover card
|
|
134
182
|
|
|
135
183
|
```tsx
|
|
136
|
-
import { SessionsHoverCard } from "@vvedantb/ui
|
|
184
|
+
import { SessionsHoverCard } from "@vvedantb/ui";
|
|
137
185
|
|
|
138
186
|
<SessionsHoverCard
|
|
139
187
|
header="Session title"
|
|
@@ -149,8 +197,7 @@ import { SessionsHoverCard } from "@vvedantb/ui/shell";
|
|
|
149
197
|
## Toaster
|
|
150
198
|
|
|
151
199
|
```tsx
|
|
152
|
-
import { toast } from "
|
|
153
|
-
import { SonnerToaster } from "@vvedantb/ui"; // or "@vvedantb/ui/sonner"
|
|
200
|
+
import { SonnerToaster, toast } from "@vvedantb/ui";
|
|
154
201
|
|
|
155
202
|
<SonnerToaster
|
|
156
203
|
theme={theme}
|
|
@@ -210,6 +257,6 @@ The compat file includes a fallback `glass-panel-strong` (`rgb(var(--card) / 0.9
|
|
|
210
257
|
|
|
211
258
|
A fixture that renders the main components against sample light and dark RGB variables is in `tokens/examples/compat-shadcn-rgb/`.
|
|
212
259
|
|
|
213
|
-
`@clerk/clerk-react`, `@tanstack/react-hotkeys` and `zod` are optional peers. Install
|
|
260
|
+
`@clerk/clerk-react`, `@tanstack/react-hotkeys`, `motion` and `zod` are optional peers (`peerDependenciesMeta`). Install `@clerk/clerk-react` only to use `useStableAuth`, `@tanstack/react-hotkeys` and `zod` only for the features that use them, and `motion` only to use the motion presets, `InputArea`, the 0.3 `Composer` or the AI elements and primitives that animate.
|
|
214
261
|
|
|
215
262
|
Full documentation: the repository `README.md`. Upgrading from `@vvedantb/tokens`, `@vvedantb/shell` or `@vvedantb/app-kit`: `MIGRATION.md`.
|
package/app/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# `@vvedantb/ui
|
|
1
|
+
# App helpers (`@vvedantb/ui`)
|
|
2
2
|
|
|
3
3
|
Shared web React helpers that were duplicated across apps. Source-exported as the `./app` subpath of `@vvedantb/ui`.
|
|
4
4
|
|
|
@@ -10,9 +10,9 @@ npm install @vvedantb/ui
|
|
|
10
10
|
|
|
11
11
|
## Peers
|
|
12
12
|
|
|
13
|
-
Required: `react` and `react-dom` (>=19). Optional peers of `@vvedantb/ui`, needed only for
|
|
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
14
|
|
|
15
|
-
`sonner` is not a dependency. The update watcher takes the toast API as an option.
|
|
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
16
|
|
|
17
17
|
```css
|
|
18
18
|
@source "../node_modules/@vvedantb/ui/app/src/**/*.{ts,tsx}";
|
|
@@ -21,7 +21,7 @@ Required: `react` and `react-dom` (>=19). Optional peers of `@vvedantb/ui`, need
|
|
|
21
21
|
## Confirm skip (Alt-click)
|
|
22
22
|
|
|
23
23
|
```tsx
|
|
24
|
-
import { ConfirmSkipHint, skipConfirmTitle, useRequestConfirm } from "@vvedantb/ui
|
|
24
|
+
import { ConfirmSkipHint, skipConfirmTitle, useRequestConfirm } from "@vvedantb/ui";
|
|
25
25
|
|
|
26
26
|
const { request } = useRequestConfirm();
|
|
27
27
|
|
|
@@ -42,7 +42,7 @@ const { request } = useRequestConfirm();
|
|
|
42
42
|
|
|
43
43
|
```ts
|
|
44
44
|
import { toast } from "sonner";
|
|
45
|
-
import { startUpdateAvailableWatcher } from "@vvedantb/ui
|
|
45
|
+
import { startUpdateAvailableWatcher } from "@vvedantb/ui";
|
|
46
46
|
|
|
47
47
|
useEffect(
|
|
48
48
|
() =>
|
|
@@ -61,7 +61,7 @@ Options: `origin`, `toast`, `closeConvex?`, `pollMs?` (default 60000), `toastId?
|
|
|
61
61
|
## Settings chrome
|
|
62
62
|
|
|
63
63
|
```tsx
|
|
64
|
-
import { SettingsPage, SettingsSection, SettingsToggleRow } from "@vvedantb/ui
|
|
64
|
+
import { SettingsPage, SettingsSection, SettingsToggleRow } from "@vvedantb/ui";
|
|
65
65
|
|
|
66
66
|
<SettingsPage
|
|
67
67
|
title="Notifications"
|
|
@@ -82,8 +82,8 @@ Exports: `SettingsSection`, `SettingsField`, `SettingsStack`, `SettingsToggleRow
|
|
|
82
82
|
## useStableAuth
|
|
83
83
|
|
|
84
84
|
```ts
|
|
85
|
-
import { useStableAuth } from "@vvedantb/ui
|
|
86
|
-
import { createUseStableAuth } from "@vvedantb/ui
|
|
85
|
+
import { useStableAuth } from "@vvedantb/ui"; // 2000 ms debounce, wraps Clerk useAuth
|
|
86
|
+
import { createUseStableAuth } from "@vvedantb/ui";
|
|
87
87
|
|
|
88
88
|
const useAuth = createUseStableAuth(useClerkAuth, { debounceMs: 3000 });
|
|
89
89
|
```
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0-rc.1",
|
|
4
4
|
"license": "MIT",
|
|
5
|
-
"description": "Shared React 19 UI for web apps: primitives, shell pieces, app helpers,
|
|
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",
|
|
7
7
|
"publishConfig": {
|
|
8
8
|
"access": "public"
|
|
@@ -17,36 +17,8 @@
|
|
|
17
17
|
"types": "./src/index.ts",
|
|
18
18
|
"default": "./src/index.ts"
|
|
19
19
|
},
|
|
20
|
-
"./cn": {
|
|
21
|
-
"types": "./src/utils/cn.ts",
|
|
22
|
-
"default": "./src/utils/cn.ts"
|
|
23
|
-
},
|
|
24
|
-
"./sonner": {
|
|
25
|
-
"types": "./src/sonner.ts",
|
|
26
|
-
"default": "./src/sonner.ts"
|
|
27
|
-
},
|
|
28
|
-
"./audio": {
|
|
29
|
-
"types": "./src/audio/index.ts",
|
|
30
|
-
"default": "./src/audio/index.ts"
|
|
31
|
-
},
|
|
32
|
-
"./composer": {
|
|
33
|
-
"types": "./src/composer/index.ts",
|
|
34
|
-
"default": "./src/composer/index.ts"
|
|
35
|
-
},
|
|
36
|
-
"./shell": {
|
|
37
|
-
"types": "./shell/src/index.ts",
|
|
38
|
-
"default": "./shell/src/index.ts"
|
|
39
|
-
},
|
|
40
|
-
"./app": {
|
|
41
|
-
"types": "./app/src/index.ts",
|
|
42
|
-
"default": "./app/src/index.ts"
|
|
43
|
-
},
|
|
44
20
|
"./tokens": "./tokens/src/tokens.css",
|
|
45
21
|
"./tokens/tokens.css": "./tokens/src/tokens.css",
|
|
46
|
-
"./tokens/native": {
|
|
47
|
-
"types": "./tokens/src/native.ts",
|
|
48
|
-
"default": "./tokens/src/native.ts"
|
|
49
|
-
},
|
|
50
22
|
"./tokens/css/index.css": "./tokens/src/css/index.css",
|
|
51
23
|
"./tokens/css/base.css": "./tokens/src/css/base.css",
|
|
52
24
|
"./tokens/css/dialog.css": "./tokens/src/css/dialog.css",
|
|
@@ -193,6 +165,9 @@
|
|
|
193
165
|
"@tanstack/react-hotkeys": {
|
|
194
166
|
"optional": true
|
|
195
167
|
},
|
|
168
|
+
"motion": {
|
|
169
|
+
"optional": true
|
|
170
|
+
},
|
|
196
171
|
"zod": {
|
|
197
172
|
"optional": true
|
|
198
173
|
}
|
|
@@ -303,6 +303,7 @@ const ComposerGhostText = React.forwardRef<HTMLDivElement, ComposerGhostTextProp
|
|
|
303
303
|
* Chat composer: autosizing textarea, plus-menu and action slots, send/stop
|
|
304
304
|
* button and optional ghost-text predictions. The ref points at the textarea.
|
|
305
305
|
*/
|
|
306
|
+
/** @deprecated Use `InputArea` from `@vvedantb/ui`. Kept unchanged for existing consumers. */
|
|
306
307
|
const Composer = React.forwardRef<HTMLTextAreaElement, ComposerProps>(function Composer(
|
|
307
308
|
{
|
|
308
309
|
value: valueProp,
|
package/src/composer/index.ts
CHANGED
|
@@ -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
|
+
}
|