@djangocfg/ui-tools 2.1.415 → 2.1.416
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/dist/file-icon/index.d.cts +1 -1
- package/dist/file-icon/index.d.ts +1 -1
- package/dist/slots-ClRpIzoh.d.cts +88 -0
- package/dist/slots-ClRpIzoh.d.ts +88 -0
- package/dist/tree/index.cjs +1994 -276
- package/dist/tree/index.cjs.map +1 -1
- package/dist/tree/index.d.cts +717 -72
- package/dist/tree/index.d.ts +717 -72
- package/dist/tree/index.mjs +1984 -279
- package/dist/tree/index.mjs.map +1 -1
- package/package.json +10 -6
- package/src/tools/chat/README.md +111 -1
- package/src/tools/chat/composer/Composer.tsx +138 -17
- package/src/tools/chat/composer/ComposerRichTextarea.tsx +25 -0
- package/src/tools/chat/composer/index.ts +22 -0
- package/src/tools/chat/composer/slash/README.md +187 -0
- package/src/tools/chat/composer/slash/SlashHighlightTextarea.tsx +144 -0
- package/src/tools/chat/composer/slash/SlashMenu.tsx +142 -0
- package/src/tools/chat/composer/slash/SlashToken.tsx +57 -0
- package/src/tools/chat/composer/slash/index.ts +44 -0
- package/src/tools/chat/composer/slash/labels.ts +19 -0
- package/src/tools/chat/composer/slash/state.ts +168 -0
- package/src/tools/chat/composer/slash/types.ts +64 -0
- package/src/tools/chat/composer/slash/useSlashCommands.ts +204 -0
- package/src/tools/chat/composer/types.ts +8 -0
- package/src/tools/chat/shell/SuggestedPrompts.tsx +194 -0
- package/src/tools/chat/shell/index.ts +6 -0
- package/src/tools/data/Listbox/lazy.tsx +1 -1
- package/src/tools/data/Masonry/lazy.tsx +1 -1
- package/src/tools/data/Timeline/lazy.tsx +1 -1
- package/src/tools/data/Tree/FinderTree.tsx +42 -0
- package/src/tools/data/Tree/README.md +337 -208
- package/src/tools/data/Tree/TreeDndProvider.tsx +137 -0
- package/src/tools/data/Tree/TreeRoot.tsx +170 -55
- package/src/tools/data/Tree/__tests__/dnd.test.ts +160 -0
- package/src/tools/data/Tree/__tests__/keyboard.test.ts +137 -0
- package/src/tools/data/Tree/__tests__/renameUtils.test.ts +52 -0
- package/src/tools/data/Tree/__tests__/selection.test.ts +227 -0
- package/src/tools/data/Tree/components/TreeDropIndicator.tsx +65 -0
- package/src/tools/data/Tree/components/TreeEmptyArea.tsx +160 -0
- package/src/tools/data/Tree/components/TreeRenameInput.tsx +114 -0
- package/src/tools/data/Tree/components/TreeRow.tsx +92 -8
- package/src/tools/data/Tree/components/index.ts +6 -0
- package/src/tools/data/Tree/context/TreeContext.tsx +204 -363
- package/src/tools/data/Tree/context/TreeContextValue.ts +139 -0
- package/src/tools/data/Tree/context/async-children/collect-ids.ts +27 -0
- package/src/tools/data/Tree/context/async-children/index.ts +8 -0
- package/src/tools/data/Tree/context/async-children/use-async-children.ts +157 -0
- package/src/tools/data/Tree/context/clipboard/index.ts +4 -0
- package/src/tools/data/Tree/context/clipboard/use-clipboard.ts +115 -0
- package/src/tools/data/Tree/context/dnd/index.ts +8 -0
- package/src/tools/data/Tree/context/dnd/use-dnd.ts +194 -0
- package/src/tools/data/Tree/context/expansion/index.ts +4 -0
- package/src/tools/data/Tree/context/expansion/use-expansion.ts +55 -0
- package/src/tools/data/Tree/context/hooks.ts +68 -1
- package/src/tools/data/Tree/context/index.ts +3 -0
- package/src/tools/data/Tree/context/menu/builtin-actions.ts +357 -0
- package/src/tools/data/Tree/context/menu/index.ts +10 -0
- package/src/tools/data/Tree/context/menu/use-resolved-menu.ts +127 -0
- package/src/tools/data/Tree/context/persist/index.ts +4 -0
- package/src/tools/data/Tree/context/persist/use-persist-sync.ts +74 -0
- package/src/tools/data/Tree/context/rename/index.ts +4 -0
- package/src/tools/data/Tree/context/rename/use-rename.ts +113 -0
- package/src/tools/data/Tree/context/selection/index.ts +4 -0
- package/src/tools/data/Tree/context/selection/use-selection.ts +146 -0
- package/src/tools/data/Tree/context/state/index.ts +6 -0
- package/src/tools/data/Tree/context/state/initial.ts +41 -0
- package/src/tools/data/Tree/context/state/reducer.ts +76 -0
- package/src/tools/data/Tree/context/state/types.ts +46 -0
- package/src/tools/data/Tree/data/clipboard.ts +33 -0
- package/src/tools/data/Tree/data/dnd.ts +123 -0
- package/src/tools/data/Tree/data/finderShortcuts.ts +67 -0
- package/src/tools/data/Tree/data/index.ts +19 -0
- package/src/tools/data/Tree/data/renameUtils.ts +51 -0
- package/src/tools/data/Tree/data/selection.ts +157 -0
- package/src/tools/data/Tree/hooks/finder-hotkeys/build-ctx.ts +48 -0
- package/src/tools/data/Tree/hooks/finder-hotkeys/index.ts +8 -0
- package/src/tools/data/Tree/hooks/finder-hotkeys/use-tree-finder-hotkeys.ts +166 -0
- package/src/tools/data/Tree/hooks/index.ts +23 -4
- package/src/tools/data/Tree/hooks/keyboard/activation.ts +27 -0
- package/src/tools/data/Tree/hooks/keyboard/arrow-nav.ts +26 -0
- package/src/tools/data/Tree/hooks/keyboard/expand-collapse.ts +54 -0
- package/src/tools/data/Tree/hooks/keyboard/index.ts +10 -0
- package/src/tools/data/Tree/hooks/keyboard/types.ts +39 -0
- package/src/tools/data/Tree/hooks/keyboard/use-tree-keyboard.ts +196 -0
- package/src/tools/data/Tree/hooks/type-ahead/index.ts +5 -0
- package/src/tools/data/Tree/hooks/type-ahead/match-prefix.ts +42 -0
- package/src/tools/data/Tree/hooks/{useTreeTypeAhead.ts → type-ahead/use-tree-type-ahead.ts} +8 -19
- package/src/tools/data/Tree/index.tsx +25 -2
- package/src/tools/data/Tree/types/activation.ts +30 -0
- package/src/tools/data/Tree/types/adapter.ts +70 -0
- package/src/tools/data/Tree/types/index.ts +27 -0
- package/src/tools/data/Tree/types/labels.ts +97 -0
- package/src/tools/data/Tree/types/loader.ts +9 -0
- package/src/tools/data/Tree/types/node.ts +38 -0
- package/src/tools/data/Tree/types/root-props.ts +142 -0
- package/src/tools/data/Tree/types/selection.ts +3 -0
- package/src/tools/data/Tree/types/slots.ts +64 -0
- package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +85 -0
- package/src/tools/forms/MarkdownEditor/index.ts +1 -0
- package/src/tools/forms/MarkdownEditor/lazy.tsx +6 -0
- package/src/tools/forms/MarkdownEditor/slash/SlashCommandNode.ts +162 -0
- package/src/tools/forms/MarkdownEditor/slash/index.ts +4 -0
- package/src/tools/forms/MarkdownEditor/slash/syncSlashNode.ts +97 -0
- package/src/tools/forms/MarkdownEditor/slash/types.ts +13 -0
- package/src/tools/forms/MarkdownEditor/styles.css +18 -0
- package/dist/types-j2vhn4Kv.d.cts +0 -241
- package/dist/types-j2vhn4Kv.d.ts +0 -241
- package/src/tools/data/Tree/hooks/useTreeKeyboard.ts +0 -171
- package/src/tools/data/Tree/types.ts +0 -217
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Slash commands
|
|
2
|
+
|
|
3
|
+
A small, generic `/verb` surface for the chat composer — analogous to
|
|
4
|
+
the `@`-mention system, but layered outside the editor (so it works
|
|
5
|
+
with both the plain `<Textarea>` and the TipTap-based
|
|
6
|
+
`<ComposerRichTextarea>`).
|
|
7
|
+
|
|
8
|
+
## Files
|
|
9
|
+
|
|
10
|
+
| File | Layer |
|
|
11
|
+
|---|---|
|
|
12
|
+
| `types.ts` | Public types — `SlashCommand`, `SlashState`, `SlashConfig`. No React. |
|
|
13
|
+
| `state.ts` | Pure state machine — `parseSlashState`, `filterCommands`, `applyCommand`, `resolveCommandAction`, `extractSlashToken`. No React. |
|
|
14
|
+
| `labels.ts` | Localisable empty-state strings. |
|
|
15
|
+
| `useSlashCommands.ts` | React hook — owns highlight + keyboard, returns `pick` / `clear` / `onKeyDown` / `query`. |
|
|
16
|
+
| `SlashMenu.tsx` | Dropdown — rows and empty-state line. |
|
|
17
|
+
| `SlashHighlightTextarea.tsx` | Overlay-mirror textarea that paints `/verb` as a chip in-place. |
|
|
18
|
+
| `SlashToken.tsx` | Chip for a resolved verb (host-rendered, optional). |
|
|
19
|
+
| `index.ts` | Barrel — public API. |
|
|
20
|
+
|
|
21
|
+
## Behaviour
|
|
22
|
+
|
|
23
|
+
The machine has three states:
|
|
24
|
+
|
|
25
|
+
- `none` — buffer does not start with `/`.
|
|
26
|
+
- `composing` — buffer starts with `/` followed by an optional partial
|
|
27
|
+
verb (`/`, `/cl`, `/clear`). The menu is open; `filter` narrows the
|
|
28
|
+
verb list.
|
|
29
|
+
- `command` — buffer is `/verb<space>...`. The verb resolves and the
|
|
30
|
+
rest is treated as `argument`.
|
|
31
|
+
|
|
32
|
+
Trigger fires only at buffer start. Mentions (which match `@` anywhere
|
|
33
|
+
in the editor) coexist without conflict.
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
import {
|
|
39
|
+
Composer,
|
|
40
|
+
type SlashConfig,
|
|
41
|
+
} from '@djangocfg/ui-tools/chat';
|
|
42
|
+
import { Sparkles, Trash2 } from 'lucide-react';
|
|
43
|
+
|
|
44
|
+
const slash: SlashConfig = {
|
|
45
|
+
commands: [
|
|
46
|
+
{
|
|
47
|
+
id: 'clear',
|
|
48
|
+
token: '/clear',
|
|
49
|
+
label: 'Clear conversation',
|
|
50
|
+
description: 'Discard the current session and start fresh.',
|
|
51
|
+
icon: <Trash2 className="h-3.5 w-3.5" />,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'help',
|
|
55
|
+
token: '/help',
|
|
56
|
+
label: 'Show help',
|
|
57
|
+
description: 'List available commands.',
|
|
58
|
+
icon: <Sparkles className="h-3.5 w-3.5" />,
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
<Composer composer={composer} composerSlots={{ slashCommands: slash }} />;
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The host owns the icon nodes — this module ships zero icon dependencies.
|
|
67
|
+
|
|
68
|
+
## Selection behaviour
|
|
69
|
+
|
|
70
|
+
When the user picks a verb, the hook branches on
|
|
71
|
+
`resolveCommandAction(value, command)`:
|
|
72
|
+
|
|
73
|
+
- **Insert** (default) — the leading `/partial` is replaced with
|
|
74
|
+
`"<token> "` and the caret lands on the argument. The verb stays
|
|
75
|
+
visible in the buffer (highlighted as a chip) and the user submits
|
|
76
|
+
with Send / Enter as a normal chat message. The host reads
|
|
77
|
+
`composer.value` on submit to grab the args, then dispatches
|
|
78
|
+
`command.onExecute?.(args)` itself.
|
|
79
|
+
- **Auto-execute** — opt in with `autoExecute: true`. The hook invokes
|
|
80
|
+
`command.onExecute?.('')` and clears the editor buffer; no message
|
|
81
|
+
is produced. Use this for action commands that should not turn into
|
|
82
|
+
a chat message (e.g. `/clear`, `/settings`, `/help`).
|
|
83
|
+
|
|
84
|
+
`argHint` is a display hint (shown next to the label in the menu,
|
|
85
|
+
e.g. `<text>`, `<host>`). It does not affect selection behavior — use
|
|
86
|
+
`autoExecute` to control that.
|
|
87
|
+
|
|
88
|
+
The hook never invokes `submit()` — the host stays in control of when
|
|
89
|
+
the chat transport actually fires.
|
|
90
|
+
|
|
91
|
+
## Submit gate
|
|
92
|
+
|
|
93
|
+
Commands with `argHint` block submit until an argument is provided. The
|
|
94
|
+
Send button is disabled and Enter is a no-op while the trimmed argument
|
|
95
|
+
after the verb is empty (`/note `, `/summon`). Once the user adds text
|
|
96
|
+
(`/note hello`), Send activates and Enter sends as usual.
|
|
97
|
+
|
|
98
|
+
Commands marked `autoExecute: true` block submit unconditionally — they
|
|
99
|
+
are picked from the menu (which fires `onExecute`), never dispatched
|
|
100
|
+
as a text message via Enter.
|
|
101
|
+
|
|
102
|
+
The gate is a pure helper, `isSubmittableSlash(value, commands)`, and
|
|
103
|
+
is surfaced through the hook as `useSlashCommands().canSubmit`. The
|
|
104
|
+
composer ANDs it with `composer.canSubmit` so every Send surface
|
|
105
|
+
(built-in send action, `slots.SendButton`, TipTap-backed
|
|
106
|
+
`<ComposerRichTextarea>`, plain `<Textarea>`) shares the same gate.
|
|
107
|
+
|
|
108
|
+
## In-input highlight
|
|
109
|
+
|
|
110
|
+
`<SlashHighlightTextarea>` is mounted automatically whenever
|
|
111
|
+
`composerSlots.slashCommands` is set and no `slots.Textarea` is
|
|
112
|
+
overridden. It uses an overlay-mirror technique: a hidden
|
|
113
|
+
`<div>` underneath the textarea renders the same text with
|
|
114
|
+
the `/verb` slice wrapped in a styled span, and the real textarea
|
|
115
|
+
runs with `color: transparent` + `caret-color: currentColor` so the
|
|
116
|
+
native caret + selection stay alive.
|
|
117
|
+
|
|
118
|
+
## TipTap-backed composer
|
|
119
|
+
|
|
120
|
+
The in-editor `/verb` chip works in both composer paths:
|
|
121
|
+
|
|
122
|
+
- **Plain** — `<SlashHighlightTextarea>` (overlay-mirror, mounted
|
|
123
|
+
automatically when `composerSlots.slashCommands` is set and no
|
|
124
|
+
custom `slots.Textarea` is provided).
|
|
125
|
+
- **TipTap** — `<ComposerRichTextarea>` with `slashCommands` set
|
|
126
|
+
uses a `SlashCommandNode` atom extension (lives in
|
|
127
|
+
`@djangocfg/ui-tools/markdown-editor`). The atom flattens to the
|
|
128
|
+
bare `/verb` token in `editor.getText()` and
|
|
129
|
+
`editor.getMarkdown()`, so `composer.value` round-trips to the
|
|
130
|
+
same string the plain mirror produces — the slash hook keeps
|
|
131
|
+
driving the menu off pure strings, oblivious to the node form.
|
|
132
|
+
|
|
133
|
+
Pattern with both `@`-mention and slash chips:
|
|
134
|
+
|
|
135
|
+
```tsx
|
|
136
|
+
function RichTextarea(props: ComposerTextareaProps) {
|
|
137
|
+
return (
|
|
138
|
+
<ComposerRichTextarea
|
|
139
|
+
{...props}
|
|
140
|
+
mentions={mentionConfig}
|
|
141
|
+
slashCommands={commands}
|
|
142
|
+
/>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
<Composer
|
|
147
|
+
composer={composer}
|
|
148
|
+
composerSlots={{ slashCommands: { commands } }}
|
|
149
|
+
slots={{ Textarea: RichTextarea }}
|
|
150
|
+
/>
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The TipTap extension is registered once on first render (gated on
|
|
154
|
+
`slashCommands !== undefined`). Pass `[]` from mount if you may
|
|
155
|
+
register verbs later — the conversion effect reads the current list
|
|
156
|
+
through a ref so runtime additions still light up the chip.
|
|
157
|
+
|
|
158
|
+
## Direct hook usage
|
|
159
|
+
|
|
160
|
+
Hosts that drive their own editor (without `<Composer>`) can wire the
|
|
161
|
+
hook up by hand:
|
|
162
|
+
|
|
163
|
+
```tsx
|
|
164
|
+
const slash = useSlashCommands({
|
|
165
|
+
value,
|
|
166
|
+
commands,
|
|
167
|
+
onApply: setValue,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<div>
|
|
172
|
+
<textarea
|
|
173
|
+
value={value}
|
|
174
|
+
onChange={(e) => setValue(e.target.value)}
|
|
175
|
+
onKeyDown={slash.onKeyDown}
|
|
176
|
+
/>
|
|
177
|
+
{slash.isOpen ? (
|
|
178
|
+
<SlashMenu
|
|
179
|
+
matches={slash.matches}
|
|
180
|
+
highlight={slash.highlight}
|
|
181
|
+
onPick={slash.pick}
|
|
182
|
+
onHighlight={slash.setHighlight}
|
|
183
|
+
/>
|
|
184
|
+
) : null}
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
```
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `<SlashHighlightTextarea>` — drop-in replacement for the composer's
|
|
5
|
+
* default plain `<Textarea>` that visually highlights a leading
|
|
6
|
+
* `/verb` token while preserving the native textarea editing model.
|
|
7
|
+
*
|
|
8
|
+
* Technique: overlay-mirror.
|
|
9
|
+
*
|
|
10
|
+
* - A hidden `<div role="presentation">` is rendered behind the
|
|
11
|
+
* `<textarea>` with identical typography + padding. The leading
|
|
12
|
+
* `/verb` slice is wrapped in `<span class="slash-token-highlight">`;
|
|
13
|
+
* the rest of the buffer is plain text.
|
|
14
|
+
* - The real `<textarea>` sits on top with `color: transparent` and a
|
|
15
|
+
* visible `caret-color` so the caret + selection still feel native.
|
|
16
|
+
* - Scroll position is mirrored on `onScroll` so the highlight
|
|
17
|
+
* tracks long drafts.
|
|
18
|
+
*
|
|
19
|
+
* This module is mounted by `Composer.tsx` whenever the host wires
|
|
20
|
+
* `composerSlots.slashCommands` and has not overridden `slots.Textarea`.
|
|
21
|
+
* Hosts that swap in `ComposerRichTextarea` keep the slash menu but lose
|
|
22
|
+
* the in-input highlight for now (TipTap node is a future iteration).
|
|
23
|
+
*/
|
|
24
|
+
import { useLayoutEffect, useRef } from 'react';
|
|
25
|
+
|
|
26
|
+
import { Textarea } from '@djangocfg/ui-core/components';
|
|
27
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
28
|
+
|
|
29
|
+
import { extractSlashToken } from './state';
|
|
30
|
+
import type { ComposerTextareaProps } from '../types';
|
|
31
|
+
|
|
32
|
+
export interface SlashHighlightTextareaProps extends ComposerTextareaProps {
|
|
33
|
+
/**
|
|
34
|
+
* Visual classes applied to the real textarea — passed through from
|
|
35
|
+
* the composer's per-size class table so the mirror can copy them.
|
|
36
|
+
*/
|
|
37
|
+
textareaClassName?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Tailwind classes the textarea, mirror, and overlay share so their
|
|
42
|
+
* box geometry stays pixel-aligned. Border / shadow / focus rings live
|
|
43
|
+
* on the surface wrapper, not here.
|
|
44
|
+
*/
|
|
45
|
+
const SHARED_TYPO = cn(
|
|
46
|
+
'block w-full resize-none whitespace-pre-wrap break-words',
|
|
47
|
+
'font-sans',
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
export function SlashHighlightTextarea({
|
|
51
|
+
composer,
|
|
52
|
+
placeholder,
|
|
53
|
+
disabled,
|
|
54
|
+
size: _size,
|
|
55
|
+
className,
|
|
56
|
+
textareaClassName,
|
|
57
|
+
}: SlashHighlightTextareaProps) {
|
|
58
|
+
const mirrorRef = useRef<HTMLDivElement>(null);
|
|
59
|
+
const taRef = composer.textareaRef;
|
|
60
|
+
|
|
61
|
+
// Keep the mirror's scroll synced with the textarea's. The mirror is
|
|
62
|
+
// already `overflow: hidden` but on tall drafts the textarea scrolls
|
|
63
|
+
// internally — we translate the highlight slice up by the same delta
|
|
64
|
+
// so it visually tracks. Cheap; ~one ref read per scroll event.
|
|
65
|
+
useLayoutEffect(() => {
|
|
66
|
+
const ta = taRef.current;
|
|
67
|
+
const mirror = mirrorRef.current;
|
|
68
|
+
if (!ta || !mirror) return;
|
|
69
|
+
const sync = () => {
|
|
70
|
+
mirror.scrollTop = ta.scrollTop;
|
|
71
|
+
mirror.scrollLeft = ta.scrollLeft;
|
|
72
|
+
};
|
|
73
|
+
ta.addEventListener('scroll', sync, { passive: true });
|
|
74
|
+
// Also resync on value change (caller of useLayoutEffect rerun
|
|
75
|
+
// covers this) so the initial frame is aligned too.
|
|
76
|
+
sync();
|
|
77
|
+
return () => ta.removeEventListener('scroll', sync);
|
|
78
|
+
}, [taRef, composer.value]);
|
|
79
|
+
|
|
80
|
+
const value = composer.value;
|
|
81
|
+
const slice = extractSlashToken(value);
|
|
82
|
+
const token = slice?.token ?? '';
|
|
83
|
+
const rest = slice ? value.slice(slice.end) : value;
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div className={cn('relative min-w-0 flex-1', className)}>
|
|
87
|
+
{/* Mirror — sits *under* the textarea (z-0) and renders the same
|
|
88
|
+
text with the slash token wrapped in a highlight span. */}
|
|
89
|
+
{slice ? (
|
|
90
|
+
<div
|
|
91
|
+
ref={mirrorRef}
|
|
92
|
+
aria-hidden
|
|
93
|
+
className={cn(
|
|
94
|
+
'pointer-events-none absolute inset-0 z-0 overflow-hidden',
|
|
95
|
+
'text-foreground',
|
|
96
|
+
SHARED_TYPO,
|
|
97
|
+
textareaClassName,
|
|
98
|
+
// Wipe out any focus-ring / border classes the textarea
|
|
99
|
+
// chain might have carried in — the mirror is decoration.
|
|
100
|
+
'border-0 bg-transparent shadow-none ring-0',
|
|
101
|
+
)}
|
|
102
|
+
>
|
|
103
|
+
{/* No padding, no font-family change — the mirror must occupy
|
|
104
|
+
exactly the same pixel width as the underlying textarea so
|
|
105
|
+
the caret position lines up with the visible characters.
|
|
106
|
+
Decoration via color + subtle radius only. */}
|
|
107
|
+
<span
|
|
108
|
+
className={cn(
|
|
109
|
+
'rounded-sm',
|
|
110
|
+
'bg-primary/15 text-primary',
|
|
111
|
+
)}
|
|
112
|
+
>
|
|
113
|
+
{token}
|
|
114
|
+
</span>
|
|
115
|
+
{/* Use a zero-width space when `rest` is empty so the mirror
|
|
116
|
+
keeps a baseline row — otherwise an empty `/` row collapses
|
|
117
|
+
and the highlight box loses its line height. */}
|
|
118
|
+
<span>{rest.length > 0 ? rest : ''}</span>
|
|
119
|
+
</div>
|
|
120
|
+
) : null}
|
|
121
|
+
|
|
122
|
+
<Textarea
|
|
123
|
+
{...composer.textareaProps}
|
|
124
|
+
rows={1}
|
|
125
|
+
placeholder={placeholder}
|
|
126
|
+
aria-label={placeholder}
|
|
127
|
+
aria-multiline="true"
|
|
128
|
+
aria-keyshortcuts="Enter"
|
|
129
|
+
disabled={disabled}
|
|
130
|
+
className={cn(
|
|
131
|
+
'relative z-10',
|
|
132
|
+
// The textarea text itself is invisible; the caret stays
|
|
133
|
+
// visible via `caret-color`. The mirror behind it paints
|
|
134
|
+
// the actual glyphs.
|
|
135
|
+
slice ? '!text-transparent caret-foreground' : 'text-foreground',
|
|
136
|
+
'flex-1 resize-none border-0 bg-transparent shadow-none',
|
|
137
|
+
'focus-visible:ring-0 focus-visible:outline-none',
|
|
138
|
+
SHARED_TYPO,
|
|
139
|
+
textareaClassName,
|
|
140
|
+
)}
|
|
141
|
+
/>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `<SlashMenu>` — the filtered command list shown above the composer
|
|
5
|
+
* while the slash machine is in `composing` state.
|
|
6
|
+
*
|
|
7
|
+
* Keyboard navigation (↑/↓/Enter/Tab/Esc) is owned by
|
|
8
|
+
* `useSlashCommands` — this component is a pure render of the
|
|
9
|
+
* `matches` + `highlight` it produces, plus click/hover handlers.
|
|
10
|
+
*
|
|
11
|
+
* Empty matches still render (showing a muted "No commands match" line)
|
|
12
|
+
* — keeps the dropdown anchored while the user fixes a typo instead of
|
|
13
|
+
* silently disappearing on them.
|
|
14
|
+
*/
|
|
15
|
+
import { forwardRef } from 'react';
|
|
16
|
+
|
|
17
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
DEFAULT_SLASH_LABELS,
|
|
21
|
+
formatEmptyState,
|
|
22
|
+
type SlashMenuLabels,
|
|
23
|
+
} from './labels';
|
|
24
|
+
import type { SlashCommand } from './types';
|
|
25
|
+
|
|
26
|
+
export interface SlashMenuProps {
|
|
27
|
+
/** Filtered verbs to show (from `useSlashCommands.matches`). */
|
|
28
|
+
matches: readonly SlashCommand[];
|
|
29
|
+
/** Index of the highlighted row. */
|
|
30
|
+
highlight: number;
|
|
31
|
+
/** Current filter query — only used for the empty-state line. */
|
|
32
|
+
query?: string;
|
|
33
|
+
/** Called when a row is clicked. */
|
|
34
|
+
onPick: (command: SlashCommand) => void;
|
|
35
|
+
/** Optional pointer-move highlight sync. */
|
|
36
|
+
onHighlight?: (index: number) => void;
|
|
37
|
+
/** Localised label overrides for the empty state. */
|
|
38
|
+
labels?: Partial<SlashMenuLabels>;
|
|
39
|
+
className?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const SlashMenu = forwardRef<HTMLDivElement, SlashMenuProps>(
|
|
43
|
+
function SlashMenu(
|
|
44
|
+
{
|
|
45
|
+
matches,
|
|
46
|
+
highlight,
|
|
47
|
+
query = '',
|
|
48
|
+
onPick,
|
|
49
|
+
onHighlight,
|
|
50
|
+
labels,
|
|
51
|
+
className,
|
|
52
|
+
},
|
|
53
|
+
ref,
|
|
54
|
+
) {
|
|
55
|
+
const L = { ...DEFAULT_SLASH_LABELS, ...labels };
|
|
56
|
+
const isEmpty = matches.length === 0;
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
ref={ref}
|
|
61
|
+
role="listbox"
|
|
62
|
+
aria-label="Slash commands"
|
|
63
|
+
className={cn(
|
|
64
|
+
'overflow-hidden rounded-xl bg-popover text-popover-foreground',
|
|
65
|
+
'ring-1 ring-border shadow-lg shadow-black/10',
|
|
66
|
+
className,
|
|
67
|
+
)}
|
|
68
|
+
>
|
|
69
|
+
<div className="p-1">
|
|
70
|
+
{isEmpty ? (
|
|
71
|
+
<div
|
|
72
|
+
role="option"
|
|
73
|
+
aria-disabled
|
|
74
|
+
aria-selected={false}
|
|
75
|
+
className="px-2 py-1.5 text-[12px] text-muted-foreground"
|
|
76
|
+
>
|
|
77
|
+
{formatEmptyState(L.emptyState, query)}
|
|
78
|
+
</div>
|
|
79
|
+
) : (
|
|
80
|
+
matches.map((cmd, i) => {
|
|
81
|
+
const isActive = i === highlight;
|
|
82
|
+
return (
|
|
83
|
+
<button
|
|
84
|
+
key={cmd.id}
|
|
85
|
+
type="button"
|
|
86
|
+
role="option"
|
|
87
|
+
aria-selected={isActive}
|
|
88
|
+
onMouseDown={(e) => {
|
|
89
|
+
// Keep focus in the editor — submit/blur must not fire.
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
onPick(cmd);
|
|
92
|
+
}}
|
|
93
|
+
onMouseEnter={() => onHighlight?.(i)}
|
|
94
|
+
onMouseMove={() => onHighlight?.(i)}
|
|
95
|
+
className={cn(
|
|
96
|
+
'flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left transition-colors',
|
|
97
|
+
isActive ? 'bg-accent' : 'hover:bg-accent/60',
|
|
98
|
+
)}
|
|
99
|
+
>
|
|
100
|
+
{cmd.icon ? (
|
|
101
|
+
<span
|
|
102
|
+
className={cn(
|
|
103
|
+
'flex h-6 w-6 shrink-0 items-center justify-center rounded-md',
|
|
104
|
+
'bg-muted/40 text-primary',
|
|
105
|
+
)}
|
|
106
|
+
>
|
|
107
|
+
{cmd.icon}
|
|
108
|
+
</span>
|
|
109
|
+
) : null}
|
|
110
|
+
{/* Header + description form one tight group; the icon
|
|
111
|
+
is the visual anchor, the token is a structural
|
|
112
|
+
label. Column auto-sizes — no dead air between
|
|
113
|
+
`/clear` and "Clear conversation". */}
|
|
114
|
+
<span className="flex min-w-0 flex-col gap-0.5">
|
|
115
|
+
<span className="flex items-baseline gap-2">
|
|
116
|
+
<span className="text-[12px] font-semibold text-foreground">
|
|
117
|
+
{cmd.token}
|
|
118
|
+
</span>
|
|
119
|
+
{cmd.argHint ? (
|
|
120
|
+
<span className="text-[11px] text-muted-foreground/60">
|
|
121
|
+
{cmd.argHint}
|
|
122
|
+
</span>
|
|
123
|
+
) : null}
|
|
124
|
+
<span className="truncate text-[12px] text-muted-foreground">
|
|
125
|
+
{cmd.label}
|
|
126
|
+
</span>
|
|
127
|
+
</span>
|
|
128
|
+
{cmd.description ? (
|
|
129
|
+
<span className="truncate text-[11px] text-muted-foreground/70">
|
|
130
|
+
{cmd.description}
|
|
131
|
+
</span>
|
|
132
|
+
) : null}
|
|
133
|
+
</span>
|
|
134
|
+
</button>
|
|
135
|
+
);
|
|
136
|
+
})
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
},
|
|
142
|
+
);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `<SlashToken>` — the styled chip a resolved slash verb renders as
|
|
5
|
+
* once the machine reaches `command` state. Intended for `blockStart`
|
|
6
|
+
* or `inlineStart` composer slots so the editor reads `[/clear] args`
|
|
7
|
+
* — the verb is a token, the rest is free-text argument.
|
|
8
|
+
*/
|
|
9
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
10
|
+
|
|
11
|
+
import type { SlashCommand } from './types';
|
|
12
|
+
|
|
13
|
+
export interface SlashTokenProps {
|
|
14
|
+
command: SlashCommand;
|
|
15
|
+
/** Called to clear the verb (drop it back to plain text). */
|
|
16
|
+
onClear?: () => void;
|
|
17
|
+
/** Optional close-icon node. Default: a small `×` glyph. */
|
|
18
|
+
clearIcon?: React.ReactNode;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function SlashToken({
|
|
23
|
+
command,
|
|
24
|
+
onClear,
|
|
25
|
+
clearIcon,
|
|
26
|
+
className,
|
|
27
|
+
}: SlashTokenProps) {
|
|
28
|
+
return (
|
|
29
|
+
<span
|
|
30
|
+
className={cn(
|
|
31
|
+
'inline-flex h-6 items-center gap-1 rounded-md px-1.5',
|
|
32
|
+
'bg-primary/10 text-primary',
|
|
33
|
+
className,
|
|
34
|
+
)}
|
|
35
|
+
>
|
|
36
|
+
{command.icon ? (
|
|
37
|
+
<span className="flex h-3 w-3 shrink-0 items-center justify-center">
|
|
38
|
+
{command.icon}
|
|
39
|
+
</span>
|
|
40
|
+
) : null}
|
|
41
|
+
<span className="text-[12px] font-semibold">{command.token}</span>
|
|
42
|
+
{onClear ? (
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
onMouseDown={(e) => {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
onClear();
|
|
48
|
+
}}
|
|
49
|
+
aria-label={`Clear ${command.token}`}
|
|
50
|
+
className="ml-0.5 rounded-sm text-primary/60 transition-colors hover:text-primary"
|
|
51
|
+
>
|
|
52
|
+
{clearIcon ?? <span aria-hidden>×</span>}
|
|
53
|
+
</button>
|
|
54
|
+
) : null}
|
|
55
|
+
</span>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Slash commands — the `/verb` surface for the chat composer.
|
|
5
|
+
*
|
|
6
|
+
* Public exports:
|
|
7
|
+
* - Types (`SlashCommand`, `SlashState`, `SlashConfig`, `SlashCommandAction`)
|
|
8
|
+
* - Pure helpers (`parseSlashState`, `filterCommands`, `applyCommand`,
|
|
9
|
+
* `resolveCommandAction`, `extractSlashToken`)
|
|
10
|
+
* - React hook (`useSlashCommands`)
|
|
11
|
+
* - UI primitives (`SlashMenu`, `SlashToken`, `SlashHighlightTextarea`)
|
|
12
|
+
* - Labels (`DEFAULT_SLASH_LABELS`, `SlashMenuLabels`)
|
|
13
|
+
*
|
|
14
|
+
* Integration: when the host passes `composerSlots.slashCommands` to
|
|
15
|
+
* `<Composer>`, the composer mounts an internal `<SlashMenu>` as a
|
|
16
|
+
* floating popover anchored over the input surface, and (when no
|
|
17
|
+
* custom `slots.Textarea` is provided) swaps the plain textarea for
|
|
18
|
+
* `<SlashHighlightTextarea>` so the `/verb` chip is visible in-flow.
|
|
19
|
+
*/
|
|
20
|
+
export type { SlashCommand, SlashState, SlashConfig } from './types';
|
|
21
|
+
export {
|
|
22
|
+
parseSlashState,
|
|
23
|
+
filterCommands,
|
|
24
|
+
applyCommand,
|
|
25
|
+
resolveCommandAction,
|
|
26
|
+
extractSlashToken,
|
|
27
|
+
isSubmittableSlash,
|
|
28
|
+
type SlashCommandAction,
|
|
29
|
+
} from './state';
|
|
30
|
+
export {
|
|
31
|
+
useSlashCommands,
|
|
32
|
+
type UseSlashCommandsOptions,
|
|
33
|
+
type UseSlashCommandsReturn,
|
|
34
|
+
} from './useSlashCommands';
|
|
35
|
+
export { SlashMenu, type SlashMenuProps } from './SlashMenu';
|
|
36
|
+
export { SlashToken, type SlashTokenProps } from './SlashToken';
|
|
37
|
+
export {
|
|
38
|
+
SlashHighlightTextarea,
|
|
39
|
+
type SlashHighlightTextareaProps,
|
|
40
|
+
} from './SlashHighlightTextarea';
|
|
41
|
+
export {
|
|
42
|
+
DEFAULT_SLASH_LABELS,
|
|
43
|
+
type SlashMenuLabels,
|
|
44
|
+
} from './labels';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slash-menu UI labels — i18n is the host's job. Pass overrides via
|
|
3
|
+
* the `<SlashMenu labels={...}>` prop. Strings are kept short so the
|
|
4
|
+
* empty-state line stays on one row in narrow composers.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface SlashMenuLabels {
|
|
8
|
+
/** Empty-state line. `{query}` is replaced with the user's filter. */
|
|
9
|
+
emptyState: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_SLASH_LABELS: SlashMenuLabels = {
|
|
13
|
+
emptyState: 'No commands match "{query}"',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Substitute `{query}` placeholders in a label template. */
|
|
17
|
+
export function formatEmptyState(template: string, query: string): string {
|
|
18
|
+
return template.replace('{query}', query);
|
|
19
|
+
}
|