@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.
Files changed (110) hide show
  1. package/dist/file-icon/index.d.cts +1 -1
  2. package/dist/file-icon/index.d.ts +1 -1
  3. package/dist/slots-ClRpIzoh.d.cts +88 -0
  4. package/dist/slots-ClRpIzoh.d.ts +88 -0
  5. package/dist/tree/index.cjs +1994 -276
  6. package/dist/tree/index.cjs.map +1 -1
  7. package/dist/tree/index.d.cts +717 -72
  8. package/dist/tree/index.d.ts +717 -72
  9. package/dist/tree/index.mjs +1984 -279
  10. package/dist/tree/index.mjs.map +1 -1
  11. package/package.json +10 -6
  12. package/src/tools/chat/README.md +111 -1
  13. package/src/tools/chat/composer/Composer.tsx +138 -17
  14. package/src/tools/chat/composer/ComposerRichTextarea.tsx +25 -0
  15. package/src/tools/chat/composer/index.ts +22 -0
  16. package/src/tools/chat/composer/slash/README.md +187 -0
  17. package/src/tools/chat/composer/slash/SlashHighlightTextarea.tsx +144 -0
  18. package/src/tools/chat/composer/slash/SlashMenu.tsx +142 -0
  19. package/src/tools/chat/composer/slash/SlashToken.tsx +57 -0
  20. package/src/tools/chat/composer/slash/index.ts +44 -0
  21. package/src/tools/chat/composer/slash/labels.ts +19 -0
  22. package/src/tools/chat/composer/slash/state.ts +168 -0
  23. package/src/tools/chat/composer/slash/types.ts +64 -0
  24. package/src/tools/chat/composer/slash/useSlashCommands.ts +204 -0
  25. package/src/tools/chat/composer/types.ts +8 -0
  26. package/src/tools/chat/shell/SuggestedPrompts.tsx +194 -0
  27. package/src/tools/chat/shell/index.ts +6 -0
  28. package/src/tools/data/Listbox/lazy.tsx +1 -1
  29. package/src/tools/data/Masonry/lazy.tsx +1 -1
  30. package/src/tools/data/Timeline/lazy.tsx +1 -1
  31. package/src/tools/data/Tree/FinderTree.tsx +42 -0
  32. package/src/tools/data/Tree/README.md +337 -208
  33. package/src/tools/data/Tree/TreeDndProvider.tsx +137 -0
  34. package/src/tools/data/Tree/TreeRoot.tsx +170 -55
  35. package/src/tools/data/Tree/__tests__/dnd.test.ts +160 -0
  36. package/src/tools/data/Tree/__tests__/keyboard.test.ts +137 -0
  37. package/src/tools/data/Tree/__tests__/renameUtils.test.ts +52 -0
  38. package/src/tools/data/Tree/__tests__/selection.test.ts +227 -0
  39. package/src/tools/data/Tree/components/TreeDropIndicator.tsx +65 -0
  40. package/src/tools/data/Tree/components/TreeEmptyArea.tsx +160 -0
  41. package/src/tools/data/Tree/components/TreeRenameInput.tsx +114 -0
  42. package/src/tools/data/Tree/components/TreeRow.tsx +92 -8
  43. package/src/tools/data/Tree/components/index.ts +6 -0
  44. package/src/tools/data/Tree/context/TreeContext.tsx +204 -363
  45. package/src/tools/data/Tree/context/TreeContextValue.ts +139 -0
  46. package/src/tools/data/Tree/context/async-children/collect-ids.ts +27 -0
  47. package/src/tools/data/Tree/context/async-children/index.ts +8 -0
  48. package/src/tools/data/Tree/context/async-children/use-async-children.ts +157 -0
  49. package/src/tools/data/Tree/context/clipboard/index.ts +4 -0
  50. package/src/tools/data/Tree/context/clipboard/use-clipboard.ts +115 -0
  51. package/src/tools/data/Tree/context/dnd/index.ts +8 -0
  52. package/src/tools/data/Tree/context/dnd/use-dnd.ts +194 -0
  53. package/src/tools/data/Tree/context/expansion/index.ts +4 -0
  54. package/src/tools/data/Tree/context/expansion/use-expansion.ts +55 -0
  55. package/src/tools/data/Tree/context/hooks.ts +68 -1
  56. package/src/tools/data/Tree/context/index.ts +3 -0
  57. package/src/tools/data/Tree/context/menu/builtin-actions.ts +357 -0
  58. package/src/tools/data/Tree/context/menu/index.ts +10 -0
  59. package/src/tools/data/Tree/context/menu/use-resolved-menu.ts +127 -0
  60. package/src/tools/data/Tree/context/persist/index.ts +4 -0
  61. package/src/tools/data/Tree/context/persist/use-persist-sync.ts +74 -0
  62. package/src/tools/data/Tree/context/rename/index.ts +4 -0
  63. package/src/tools/data/Tree/context/rename/use-rename.ts +113 -0
  64. package/src/tools/data/Tree/context/selection/index.ts +4 -0
  65. package/src/tools/data/Tree/context/selection/use-selection.ts +146 -0
  66. package/src/tools/data/Tree/context/state/index.ts +6 -0
  67. package/src/tools/data/Tree/context/state/initial.ts +41 -0
  68. package/src/tools/data/Tree/context/state/reducer.ts +76 -0
  69. package/src/tools/data/Tree/context/state/types.ts +46 -0
  70. package/src/tools/data/Tree/data/clipboard.ts +33 -0
  71. package/src/tools/data/Tree/data/dnd.ts +123 -0
  72. package/src/tools/data/Tree/data/finderShortcuts.ts +67 -0
  73. package/src/tools/data/Tree/data/index.ts +19 -0
  74. package/src/tools/data/Tree/data/renameUtils.ts +51 -0
  75. package/src/tools/data/Tree/data/selection.ts +157 -0
  76. package/src/tools/data/Tree/hooks/finder-hotkeys/build-ctx.ts +48 -0
  77. package/src/tools/data/Tree/hooks/finder-hotkeys/index.ts +8 -0
  78. package/src/tools/data/Tree/hooks/finder-hotkeys/use-tree-finder-hotkeys.ts +166 -0
  79. package/src/tools/data/Tree/hooks/index.ts +23 -4
  80. package/src/tools/data/Tree/hooks/keyboard/activation.ts +27 -0
  81. package/src/tools/data/Tree/hooks/keyboard/arrow-nav.ts +26 -0
  82. package/src/tools/data/Tree/hooks/keyboard/expand-collapse.ts +54 -0
  83. package/src/tools/data/Tree/hooks/keyboard/index.ts +10 -0
  84. package/src/tools/data/Tree/hooks/keyboard/types.ts +39 -0
  85. package/src/tools/data/Tree/hooks/keyboard/use-tree-keyboard.ts +196 -0
  86. package/src/tools/data/Tree/hooks/type-ahead/index.ts +5 -0
  87. package/src/tools/data/Tree/hooks/type-ahead/match-prefix.ts +42 -0
  88. package/src/tools/data/Tree/hooks/{useTreeTypeAhead.ts → type-ahead/use-tree-type-ahead.ts} +8 -19
  89. package/src/tools/data/Tree/index.tsx +25 -2
  90. package/src/tools/data/Tree/types/activation.ts +30 -0
  91. package/src/tools/data/Tree/types/adapter.ts +70 -0
  92. package/src/tools/data/Tree/types/index.ts +27 -0
  93. package/src/tools/data/Tree/types/labels.ts +97 -0
  94. package/src/tools/data/Tree/types/loader.ts +9 -0
  95. package/src/tools/data/Tree/types/node.ts +38 -0
  96. package/src/tools/data/Tree/types/root-props.ts +142 -0
  97. package/src/tools/data/Tree/types/selection.ts +3 -0
  98. package/src/tools/data/Tree/types/slots.ts +64 -0
  99. package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +85 -0
  100. package/src/tools/forms/MarkdownEditor/index.ts +1 -0
  101. package/src/tools/forms/MarkdownEditor/lazy.tsx +6 -0
  102. package/src/tools/forms/MarkdownEditor/slash/SlashCommandNode.ts +162 -0
  103. package/src/tools/forms/MarkdownEditor/slash/index.ts +4 -0
  104. package/src/tools/forms/MarkdownEditor/slash/syncSlashNode.ts +97 -0
  105. package/src/tools/forms/MarkdownEditor/slash/types.ts +13 -0
  106. package/src/tools/forms/MarkdownEditor/styles.css +18 -0
  107. package/dist/types-j2vhn4Kv.d.cts +0 -241
  108. package/dist/types-j2vhn4Kv.d.ts +0 -241
  109. package/src/tools/data/Tree/hooks/useTreeKeyboard.ts +0 -171
  110. 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
+ }