@sybilion/uilib 1.3.33 → 1.3.35

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 (54) hide show
  1. package/dist/esm/components/ui/Chat/ChatChrome/ChatChrome.js +2 -2
  2. package/dist/esm/components/ui/Chat/ChatPrompt/ChatPrompt.helpers.js +18 -4
  3. package/dist/esm/components/ui/Chat/ChatPrompt/ChatPrompt.js +39 -52
  4. package/dist/esm/components/ui/Chat/ChatPrompt/ChatPrompt.styl.js +2 -2
  5. package/dist/esm/components/ui/Chat/ChatPrompt/ChatPromptComposer.js +25 -0
  6. package/dist/esm/components/ui/Chat/ChatPrompt/chatPromptDoc.js +17 -0
  7. package/dist/esm/components/ui/Chat/ChatPrompt/useChatPromptEditor.js +163 -0
  8. package/dist/esm/components/ui/Tooltip/Tooltip.styl.js +1 -1
  9. package/dist/esm/index.js +2 -0
  10. package/dist/esm/tiptap/slash-mention/SlashSuggestionList.js +53 -0
  11. package/dist/esm/tiptap/slash-mention/SlashSuggestionList.styl.js +7 -0
  12. package/dist/esm/tiptap/slash-mention/createSlashMentionExtension.js +151 -0
  13. package/dist/esm/tiptap/slash-mention/defaultChatSlashItems.js +12 -0
  14. package/dist/esm/types/src/components/ui/Chat/Chat.types.d.ts +3 -0
  15. package/dist/esm/types/src/components/ui/Chat/ChatChrome/ChatChrome.d.ts +1 -1
  16. package/dist/esm/types/src/components/ui/Chat/ChatChrome/ChatChrome.types.d.ts +5 -0
  17. package/dist/esm/types/src/components/ui/Chat/ChatPrompt/ChatPrompt.d.ts +1 -1
  18. package/dist/esm/types/src/components/ui/Chat/ChatPrompt/ChatPrompt.helpers.d.ts +6 -0
  19. package/dist/esm/types/src/components/ui/Chat/ChatPrompt/ChatPromptComposer.d.ts +13 -0
  20. package/dist/esm/types/src/components/ui/Chat/ChatPrompt/chatPromptDoc.d.ts +3 -0
  21. package/dist/esm/types/src/components/ui/Chat/ChatPrompt/useChatPromptEditor.d.ts +20 -0
  22. package/dist/esm/types/src/components/ui/Chat/index.d.ts +1 -0
  23. package/dist/esm/types/src/components/ui/Input/Input.d.ts +1 -1
  24. package/dist/esm/types/src/docs/pages/ChatSlashCommandsPage.d.ts +1 -0
  25. package/dist/esm/types/src/index.d.ts +1 -0
  26. package/dist/esm/types/src/tiptap/slash-mention/SlashSuggestionList.d.ts +12 -0
  27. package/dist/esm/types/src/tiptap/slash-mention/createSlashMentionExtension.d.ts +21 -0
  28. package/dist/esm/types/src/tiptap/slash-mention/defaultChatSlashItems.d.ts +4 -0
  29. package/dist/esm/types/src/tiptap/slash-mention/index.d.ts +5 -0
  30. package/dist/esm/types/src/tiptap/slash-mention/types.d.ts +25 -0
  31. package/package.json +15 -1
  32. package/src/components/ui/Chat/Chat.types.ts +4 -0
  33. package/src/components/ui/Chat/ChatChrome/ChatChrome.tsx +4 -0
  34. package/src/components/ui/Chat/ChatChrome/ChatChrome.types.ts +5 -0
  35. package/src/components/ui/Chat/ChatPrompt/ChatPrompt.helpers.ts +43 -0
  36. package/src/components/ui/Chat/ChatPrompt/ChatPrompt.styl +33 -5
  37. package/src/components/ui/Chat/ChatPrompt/ChatPrompt.styl.d.ts +2 -1
  38. package/src/components/ui/Chat/ChatPrompt/ChatPrompt.tsx +50 -106
  39. package/src/components/ui/Chat/ChatPrompt/ChatPromptComposer.tsx +93 -0
  40. package/src/components/ui/Chat/ChatPrompt/chatPromptDoc.ts +18 -0
  41. package/src/components/ui/Chat/ChatPrompt/useChatPromptEditor.ts +214 -0
  42. package/src/components/ui/Chat/index.ts +1 -0
  43. package/src/components/ui/Tooltip/Tooltip.styl +1 -0
  44. package/src/docs/pages/ChatSlashCommandsPage.tsx +139 -0
  45. package/src/docs/pages/TooltipPage.tsx +1 -1
  46. package/src/docs/registry.ts +6 -0
  47. package/src/index.ts +1 -0
  48. package/src/tiptap/slash-mention/SlashSuggestionList.styl +48 -0
  49. package/src/tiptap/slash-mention/SlashSuggestionList.styl.d.ts +11 -0
  50. package/src/tiptap/slash-mention/SlashSuggestionList.tsx +109 -0
  51. package/src/tiptap/slash-mention/createSlashMentionExtension.ts +217 -0
  52. package/src/tiptap/slash-mention/defaultChatSlashItems.ts +18 -0
  53. package/src/tiptap/slash-mention/index.ts +16 -0
  54. package/src/tiptap/slash-mention/types.ts +29 -0
@@ -0,0 +1,93 @@
1
+ import {
2
+ type ChangeEvent,
3
+ type KeyboardEvent as ReactKeyboardEvent,
4
+ useCallback,
5
+ useRef,
6
+ } from 'react';
7
+
8
+ import type { Editor } from '@tiptap/core';
9
+ import { EditorContent } from '@tiptap/react';
10
+ import { PaperclipIcon, SendHorizontalIcon } from 'lucide-react';
11
+
12
+ import { Button } from '../../Button';
13
+ import type { ChatAttachmentDropItem } from '../Chat.types';
14
+ import S from './ChatPrompt.styl';
15
+
16
+ export type ChatPromptComposerProps = {
17
+ editor: Editor;
18
+ disabled: boolean;
19
+ trimmedMessage: string;
20
+ attachments: ChatAttachmentDropItem[];
21
+ attachmentAccept?: string;
22
+ onAttachmentFiles?: (files: File[]) => void;
23
+ onComposerKeyDown: (event: ReactKeyboardEvent) => void;
24
+ };
25
+
26
+ export function ChatPromptComposer({
27
+ editor,
28
+ disabled,
29
+ trimmedMessage,
30
+ attachments,
31
+ attachmentAccept,
32
+ onAttachmentFiles,
33
+ onComposerKeyDown,
34
+ }: ChatPromptComposerProps) {
35
+ const fileInputRef = useRef<HTMLInputElement>(null);
36
+ const showAttachButton = Boolean(attachmentAccept && onAttachmentFiles);
37
+
38
+ const handleFileInputChange = useCallback(
39
+ (e: ChangeEvent<HTMLInputElement>) => {
40
+ const files = Array.from(e.target.files ?? []);
41
+ e.target.value = '';
42
+ if (files.length > 0) {
43
+ onAttachmentFiles?.(files);
44
+ }
45
+ },
46
+ [onAttachmentFiles],
47
+ );
48
+
49
+ const canSubmit = Boolean(trimmedMessage || attachments.length > 0);
50
+
51
+ return (
52
+ <div className={S.composer}>
53
+ {showAttachButton ? (
54
+ <>
55
+ <input
56
+ ref={fileInputRef}
57
+ type="file"
58
+ accept={attachmentAccept}
59
+ multiple
60
+ className={S.fileInput}
61
+ disabled={disabled}
62
+ onChange={handleFileInputChange}
63
+ />
64
+ <Button
65
+ type="button"
66
+ variant="ghost"
67
+ icon
68
+ size="sm"
69
+ className={S.attachButton}
70
+ aria-label="Attach file"
71
+ disabled={disabled}
72
+ onClick={e => {
73
+ e.preventDefault();
74
+ fileInputRef.current?.click();
75
+ }}
76
+ >
77
+ <PaperclipIcon size={16} />
78
+ </Button>
79
+ </>
80
+ ) : null}
81
+
82
+ <div className={S.editorWrap} onKeyDown={onComposerKeyDown}>
83
+ <EditorContent editor={editor} className={S.editorMount} />
84
+ </div>
85
+
86
+ <div className={S.submitColumn}>
87
+ <Button type="submit" size="sm" disabled={disabled || !canSubmit}>
88
+ <SendHorizontalIcon size={16} />
89
+ </Button>
90
+ </div>
91
+ </div>
92
+ );
93
+ }
@@ -0,0 +1,18 @@
1
+ import type { JSONContent } from '@tiptap/core';
2
+
3
+ export const CHAT_PROMPT_EMPTY_DOC: JSONContent = {
4
+ type: 'doc',
5
+ content: [{ type: 'paragraph' }],
6
+ };
7
+
8
+ export function chatPromptParagraphDoc(text: string): JSONContent {
9
+ return {
10
+ type: 'doc',
11
+ content: [
12
+ {
13
+ type: 'paragraph',
14
+ content: text ? [{ type: 'text', text }] : [],
15
+ },
16
+ ],
17
+ };
18
+ }
@@ -0,0 +1,214 @@
1
+ import {
2
+ type KeyboardEvent as ReactKeyboardEvent,
3
+ useCallback,
4
+ useEffect,
5
+ useLayoutEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from 'react';
10
+
11
+ import {
12
+ DEFAULT_CHAT_SLASH_ITEMS,
13
+ createSlashMentionExtension,
14
+ } from '#uilib/tiptap/slash-mention';
15
+ import type { SlashCommandItem } from '#uilib/tiptap/slash-mention/types';
16
+ import type { AnyExtension, Editor } from '@tiptap/core';
17
+ import { Placeholder } from '@tiptap/extensions';
18
+ import { useEditor } from '@tiptap/react';
19
+ import StarterKit from '@tiptap/starter-kit';
20
+
21
+ import {
22
+ chatPromptSafeEditorDom,
23
+ syncChatPromptComposerHeight,
24
+ } from './ChatPrompt.helpers';
25
+ import { CHAT_PROMPT_EMPTY_DOC, chatPromptParagraphDoc } from './chatPromptDoc';
26
+
27
+ export type UseChatPromptEditorOptions = {
28
+ disabled: boolean;
29
+ placeholder?: string;
30
+ slashCommandItems?: SlashCommandItem[];
31
+ prefillMessage?: string | null;
32
+ /** Staged attachment count — Enter-to-send when text empty but files present. */
33
+ attachmentsCount?: number;
34
+ /** Called when user presses Enter to send (after guards). */
35
+ onEnterSubmit: () => void;
36
+ };
37
+
38
+ export type UseChatPromptEditorResult = {
39
+ editor: Editor | null;
40
+ trimmedMessage: string;
41
+ resetAfterSend: () => void;
42
+ handleComposerKeyDown: (event: ReactKeyboardEvent) => void;
43
+ };
44
+
45
+ export function useChatPromptEditor({
46
+ disabled,
47
+ placeholder,
48
+ slashCommandItems,
49
+ prefillMessage,
50
+ attachmentsCount = 0,
51
+ onEnterSubmit,
52
+ }: UseChatPromptEditorOptions): UseChatPromptEditorResult {
53
+ const slashOpenRef = useRef(false);
54
+ const suggestionActiveUpdater = useCallback((active: boolean) => {
55
+ slashOpenRef.current = active;
56
+ }, []);
57
+
58
+ const slashFinger = JSON.stringify(slashCommandItems ?? null);
59
+ const slashItemsStable = useMemo(() => {
60
+ return slashCommandItems ?? DEFAULT_CHAT_SLASH_ITEMS;
61
+ }, [slashCommandItems, slashFinger]);
62
+
63
+ const placeholderText = useMemo(() => {
64
+ if (placeholder === undefined || placeholder === null) {
65
+ return 'Type a message…';
66
+ }
67
+ const t = String(placeholder).trim();
68
+ return t === '' ? 'Type a message…' : t;
69
+ }, [placeholder]);
70
+
71
+ const ariaLabelComposer = useMemo(() => {
72
+ if (placeholder === undefined || placeholder === null) return 'Message';
73
+ const t = String(placeholder).trim();
74
+ return t === '' ? 'Message' : t;
75
+ }, [placeholder]);
76
+
77
+ const extensions = useMemo(() => {
78
+ const exts: AnyExtension[] = [
79
+ StarterKit.configure({
80
+ heading: false,
81
+ bulletList: false,
82
+ orderedList: false,
83
+ blockquote: false,
84
+ codeBlock: false,
85
+ horizontalRule: false,
86
+ }),
87
+ Placeholder.configure({
88
+ placeholder: placeholderText,
89
+ showOnlyWhenEditable: true,
90
+ showOnlyCurrent: false,
91
+ }),
92
+ ];
93
+ if (slashItemsStable.length > 0) {
94
+ exts.push(
95
+ createSlashMentionExtension({
96
+ items: slashItemsStable,
97
+ onSuggestionUiActiveChange: suggestionActiveUpdater,
98
+ }),
99
+ );
100
+ }
101
+ return exts;
102
+ }, [slashItemsStable, placeholderText, suggestionActiveUpdater]);
103
+
104
+ const [plainDraft, setPlainDraft] = useState('');
105
+
106
+ const editorDomRef = useRef<HTMLElement | null>(null);
107
+
108
+ const bindEditorDom = useCallback(({ editor }: { editor: Editor }) => {
109
+ setPlainDraft(editor.getText());
110
+ queueMicrotask(() => {
111
+ const dom = chatPromptSafeEditorDom(editor);
112
+ if (!dom) return;
113
+ editorDomRef.current = dom;
114
+ syncChatPromptComposerHeight(dom, editor.getText());
115
+ });
116
+ }, []);
117
+
118
+ const trimmedMessage = plainDraft.trim();
119
+
120
+ const editor = useEditor(
121
+ {
122
+ extensions,
123
+ content: CHAT_PROMPT_EMPTY_DOC,
124
+ editable: !disabled,
125
+ immediatelyRender: true,
126
+ editorProps: {
127
+ attributes: {
128
+ spellcheck: 'true',
129
+ 'aria-label': ariaLabelComposer,
130
+ },
131
+ },
132
+ onTransaction: ({ editor: ed }) => {
133
+ const dom = chatPromptSafeEditorDom(ed);
134
+ if (dom) {
135
+ syncChatPromptComposerHeight(dom, ed.getText());
136
+ }
137
+ setPlainDraft(ed.getText());
138
+ },
139
+ onCreate: bindEditorDom,
140
+ },
141
+ [extensions, disabled, bindEditorDom, ariaLabelComposer],
142
+ );
143
+
144
+ useEffect(() => {
145
+ if (!editor) return;
146
+ editor.setEditable(!disabled);
147
+ }, [editor, disabled]);
148
+
149
+ useEffect(() => {
150
+ if (!editor) return;
151
+ const bind = () => {
152
+ editorDomRef.current = chatPromptSafeEditorDom(editor);
153
+ };
154
+ bind();
155
+ let rafId: number | null = null;
156
+ if (typeof window !== 'undefined') {
157
+ rafId = window.requestAnimationFrame(bind);
158
+ }
159
+ return () => {
160
+ if (rafId != null) cancelAnimationFrame(rafId);
161
+ };
162
+ }, [editor]);
163
+
164
+ useEffect(() => {
165
+ if (!editor || prefillMessage == null || prefillMessage === '') return;
166
+ editor.commands.setContent(chatPromptParagraphDoc(prefillMessage));
167
+ const t = editor.getText();
168
+ setPlainDraft(t);
169
+ queueMicrotask(() => {
170
+ const dom = chatPromptSafeEditorDom(editor);
171
+ if (dom) syncChatPromptComposerHeight(dom, t);
172
+ });
173
+ }, [editor, prefillMessage]);
174
+
175
+ useLayoutEffect(() => {
176
+ if (!editor) return;
177
+ const dom = chatPromptSafeEditorDom(editor);
178
+ if (!dom) return;
179
+ syncChatPromptComposerHeight(dom, plainDraft);
180
+ }, [editor, plainDraft]);
181
+
182
+ const onEnterSubmitRef = useRef(onEnterSubmit);
183
+ onEnterSubmitRef.current = onEnterSubmit;
184
+
185
+ const resetAfterSend = useCallback(() => {
186
+ if (!editor) return;
187
+ editor.commands.clearContent();
188
+ queueMicrotask(() => {
189
+ const dom = chatPromptSafeEditorDom(editor);
190
+ if (dom) syncChatPromptComposerHeight(dom, '');
191
+ });
192
+ setPlainDraft('');
193
+ }, [editor]);
194
+
195
+ const handleComposerKeyDown = useCallback(
196
+ (e: ReactKeyboardEvent) => {
197
+ if (!(e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey))
198
+ return;
199
+ if (!editorDomRef.current?.contains(e.target as Node)) return;
200
+ if (slashOpenRef.current) return;
201
+ if (!trimmedMessage && attachmentsCount === 0) return;
202
+ e.preventDefault();
203
+ onEnterSubmitRef.current();
204
+ },
205
+ [attachmentsCount, trimmedMessage],
206
+ );
207
+
208
+ return {
209
+ editor,
210
+ trimmedMessage,
211
+ resetAfterSend,
212
+ handleComposerKeyDown,
213
+ };
214
+ }
@@ -40,4 +40,5 @@ export type {
40
40
  UserTextFileAttachment,
41
41
  } from './Chat.types';
42
42
  export { MessageRole } from './Chat.types';
43
+ export type { SlashCommandItem } from '#uilib/tiptap/slash-mention/types';
43
44
  export { CsvIcon } from '../../icons/CsvIcon/CsvIcon';
@@ -3,6 +3,7 @@
3
3
  .tooltipContent
4
4
  z-index 50
5
5
  width fit-content
6
+ min-width 100%
6
7
  padding 6px 12px
7
8
  border-radius var(--p-3)
8
9
  background-color var(--popover)
@@ -0,0 +1,139 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { Link } from 'react-router-dom';
3
+
4
+ import {
5
+ ChatChrome,
6
+ Message,
7
+ MessageRole,
8
+ type SlashCommandItem,
9
+ } from '#uilib/components/ui/Chat';
10
+ import { PageContentSection } from '#uilib/components/ui/Page';
11
+ import { ScrollRef } from '@homecode/ui';
12
+
13
+ import { AppPageHeader } from '../components/AppPageHeader/AppPageHeader';
14
+ import { DocsHeaderActions } from '../docsHeaderActions';
15
+
16
+ const NO_QUICK_REPLY_KEYS: ReadonlySet<string> = new Set();
17
+
18
+ /** Sample items so the docs demo still shows a `/` palette (`DEFAULT_CHAT_SLASH_ITEMS` is empty). */
19
+ const DOCS_SAMPLE_SLASH_ITEMS: SlashCommandItem[] = [
20
+ {
21
+ id: 'sample-command',
22
+ label: 'sample-command',
23
+ description:
24
+ 'Demo only — define `slashCommandItems` in your app to list real commands.',
25
+ },
26
+ ];
27
+
28
+ const ASSISTANT_REPLY_TEXT =
29
+ 'Demo reply. Picked slash text is `/sample-command` via TipTap Mention (plain-text round-trip with renderText).';
30
+
31
+ function makeMessage(role: MessageRole, text: string): Message {
32
+ return {
33
+ id: crypto.randomUUID(),
34
+ role,
35
+ text,
36
+ timestamp: Date.now(),
37
+ };
38
+ }
39
+
40
+ export default function ChatSlashCommandsPage() {
41
+ const [messages, setMessages] = useState<Message[]>([]);
42
+ const [isLoading, setIsLoading] = useState(false);
43
+ const replyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
44
+ const scrollRef = useRef<ScrollRef>(null);
45
+
46
+ useEffect(() => {
47
+ return () => {
48
+ if (replyTimeoutRef.current != null) {
49
+ clearTimeout(replyTimeoutRef.current);
50
+ }
51
+ };
52
+ }, []);
53
+
54
+ const isEmpty = messages.length === 0 && !isLoading;
55
+ const isLastMessageFromUser =
56
+ messages.length > 0 &&
57
+ messages[messages.length - 1]?.role === MessageRole.USER;
58
+
59
+ const onSubmit = useCallback(
60
+ (raw: string) => {
61
+ const text = raw.trim();
62
+ if (!text || isLoading) return;
63
+
64
+ setMessages(prev => [...prev, makeMessage(MessageRole.USER, text)]);
65
+ setIsLoading(true);
66
+
67
+ if (replyTimeoutRef.current != null) {
68
+ clearTimeout(replyTimeoutRef.current);
69
+ }
70
+ replyTimeoutRef.current = setTimeout(() => {
71
+ replyTimeoutRef.current = null;
72
+ setMessages(prev => [
73
+ ...prev,
74
+ makeMessage(MessageRole.ASSISTANT, ASSISTANT_REPLY_TEXT),
75
+ ]);
76
+ setIsLoading(false);
77
+ }, 900);
78
+ },
79
+ [isLoading],
80
+ );
81
+
82
+ return (
83
+ <>
84
+ <AppPageHeader
85
+ breadcrumbs={[{ label: 'Chat' }, { label: 'Chat slash commands' }]}
86
+ title="Chat slash commands"
87
+ subheader={`Slash palette uses TipTap Mention with "/" as the trigger. Library default item list is empty — pass slashCommandItems from the app (this page uses a sample item for the demo).`}
88
+ actions={<DocsHeaderActions />}
89
+ />
90
+ <PageContentSection>
91
+ <p style={{ marginBottom: 16, fontSize: 14, lineHeight: 1.5 }}>
92
+ Same{' '}
93
+ <Link className="underline underline-offset-2" to="/docs/chat">
94
+ Chat
95
+ </Link>{' '}
96
+ shell below: scrolling history, empty state, disclaimer, composer.
97
+ Type <kbd className="font-mono">/</kbd> at line start or after a
98
+ space; pick with arrows + Enter or click, then send.
99
+ </p>
100
+ <ChatChrome
101
+ showResizeHandle={false}
102
+ resizeHandle={undefined}
103
+ onClose={undefined}
104
+ isEmpty={isEmpty}
105
+ renderPresets={() => null}
106
+ messages={messages}
107
+ onQuickReply={() => {}}
108
+ suppressedQuickReplyKeys={NO_QUICK_REPLY_KEYS}
109
+ isLoading={isLoading}
110
+ scriptContinueLabel={undefined}
111
+ onScriptContinue={undefined}
112
+ showBranchActionsRow={false}
113
+ showSyntheticBranchButtons={false}
114
+ unusedBranchKeys={[]}
115
+ isScriptComplete={false}
116
+ onGenerateDashboard={undefined}
117
+ generatingDashboard={false}
118
+ onGenerateDashboardClick={() => {}}
119
+ showInlinePresets={false}
120
+ isLastMessageFromUser={isLastMessageFromUser}
121
+ scrollRef={scrollRef}
122
+ effectiveScopeId="docs-chat-slash-inline"
123
+ onPromptSubmit={onSubmit}
124
+ onChatDeleted={() => {}}
125
+ slashCommandItems={DOCS_SAMPLE_SLASH_ITEMS}
126
+ promptPlaceholder='Ask something or type "/" for demo commands…'
127
+ emptyState={{
128
+ title: 'Try a slash command',
129
+ description:
130
+ 'This demo mounts one sample slash item. Production: pass slashCommandItems to ChatChrome so `/` opens your palette.',
131
+ additionalContent: (
132
+ <p>Optional empty-state slot via additionalContent.</p>
133
+ ),
134
+ }}
135
+ />
136
+ </PageContentSection>
137
+ </>
138
+ );
139
+ }
@@ -41,7 +41,7 @@ export default function TooltipPage() {
41
41
  <p style={{ margin: '0 0 8px', fontWeight: 600 }}>Over trigger</p>
42
42
  <div
43
43
  style={{
44
- maxWidth: 280,
44
+ maxWidth: 500,
45
45
  border: '1px dashed var(--border)',
46
46
  padding: 8,
47
47
  }}
@@ -109,6 +109,12 @@ export const DOC_REGISTRY: DocEntry[] = [
109
109
  section: 'Chat',
110
110
  load: () => import('./pages/ChatPage'),
111
111
  },
112
+ {
113
+ slug: 'chat-slash-commands',
114
+ title: 'Chat slash commands',
115
+ section: 'Chat',
116
+ load: () => import('./pages/ChatSlashCommandsPage'),
117
+ },
112
118
  {
113
119
  slug: 'chat-user-csv-attachment',
114
120
  title: 'Chat user CSV attachment',
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ export { DEFAULT_THEME_ACTIVE_COLOR } from './docs/lib/theme';
11
11
  export * from './sybilion-auth';
12
12
  export * from './types/sybilionDatasetSnapshots';
13
13
  export * from './contexts/chat-context';
14
+ export * from './tiptap/slash-mention';
14
15
  export * from './types/chat-api.types';
15
16
  export * from './components/ui/AnalysesSelector';
16
17
  export * from './components/ui/AnalysisLineIcon';
@@ -0,0 +1,48 @@
1
+ @import 'lib/theme.styl';
2
+
3
+ .root
4
+ display flex
5
+ flex-direction column
6
+ min-width 220px
7
+ max-width min(340px, 90vw)
8
+ max-height min(260px, 40vh)
9
+ overflow-y auto
10
+ padding var(--p-1)
11
+ margin 0
12
+ list-style none
13
+ background var(--popover, var(--card))
14
+ color var(--foreground)
15
+ border 1px solid var(--border)
16
+ border-radius var(--radius-lg, 8px)
17
+ box-shadow 0 8px 24px rgba(0, 0, 0, 0.12)
18
+ font-size var(--text-sm)
19
+
20
+ :global(.dark) &
21
+ box-shadow 0 8px 24px rgba(0, 0, 0, 0.5)
22
+
23
+ .item
24
+ display flex
25
+ flex-direction column
26
+ align-items flex-start
27
+ width 100%
28
+ padding var(--p-1) var(--p-2)
29
+ margin 0
30
+ border none
31
+ border-radius var(--radius-md, 6px)
32
+ background transparent
33
+ color inherit
34
+ text-align left
35
+ cursor pointer
36
+
37
+ &:hover
38
+ background var(--muted)
39
+
40
+ .itemHighlighted
41
+ background var(--muted)
42
+
43
+ .itemLabel
44
+ font-weight 600
45
+
46
+ .itemDesc
47
+ font-size var(--text-xs)
48
+ color var(--muted-foreground)
@@ -0,0 +1,11 @@
1
+ // This file is automatically generated.
2
+ // Please do not change this file!
3
+ interface CssExports {
4
+ 'item': string;
5
+ 'itemDesc': string;
6
+ 'itemHighlighted': string;
7
+ 'itemLabel': string;
8
+ 'root': string;
9
+ }
10
+ export const cssExports: CssExports;
11
+ export default cssExports;
@@ -0,0 +1,109 @@
1
+ import cn from 'classnames';
2
+ import type { RefObject } from 'react';
3
+ import { useCallback, useEffect, useImperativeHandle, useState } from 'react';
4
+
5
+ import S from './SlashSuggestionList.styl';
6
+ import type { SlashCommandItem } from './types';
7
+
8
+ export type SlashSuggestionListHandle = {
9
+ onKeyboardEvent: (event: KeyboardEvent) => boolean;
10
+ };
11
+
12
+ type SlashSuggestionListInnerProps = {
13
+ items: SlashCommandItem[];
14
+ command: (item: SlashCommandItem) => void;
15
+ listHandleRef: RefObject<SlashSuggestionListHandle | null>;
16
+ };
17
+
18
+ function SlashSuggestionListInner({
19
+ items,
20
+ command,
21
+ listHandleRef,
22
+ }: SlashSuggestionListInnerProps) {
23
+ const [selected, setSelected] = useState(0);
24
+
25
+ useEffect(() => {
26
+ setSelected(s => Math.min(s, Math.max(items.length - 1, 0)));
27
+ }, [items.length]);
28
+
29
+ const safeSel = Math.min(selected, Math.max(items.length - 1, 0));
30
+
31
+ const onPick = useCallback(
32
+ (index: number) => {
33
+ const item = items[index];
34
+ if (!item) return;
35
+ command(item);
36
+ },
37
+ [command, items],
38
+ );
39
+
40
+ const onKeyboardEvent = useCallback(
41
+ (event: KeyboardEvent): boolean => {
42
+ if (items.length === 0) return false;
43
+ if (event.key === 'ArrowDown') {
44
+ event.preventDefault();
45
+ setSelected(s => Math.min(s + 1, items.length - 1));
46
+ return true;
47
+ }
48
+ if (event.key === 'ArrowUp') {
49
+ event.preventDefault();
50
+ setSelected(s => Math.max(s - 1, 0));
51
+ return true;
52
+ }
53
+ if (event.key === 'Enter') {
54
+ event.preventDefault();
55
+ const max = Math.max(items.length - 1, 0);
56
+ const idx = Math.min(selected, max);
57
+ const item = items[idx];
58
+ if (item) command(item);
59
+ return true;
60
+ }
61
+ return false;
62
+ },
63
+ [command, items, selected],
64
+ );
65
+
66
+ useImperativeHandle(listHandleRef, () => ({ onKeyboardEvent }), [
67
+ onKeyboardEvent,
68
+ ]);
69
+
70
+ return (
71
+ <div className={S.root} role="listbox" aria-label="Slash commands">
72
+ {items.map((item, idx) => (
73
+ <button
74
+ key={`${item.id}-${idx}`}
75
+ type="button"
76
+ role="option"
77
+ aria-selected={idx === safeSel}
78
+ className={cn(S.item, idx === safeSel && S.itemHighlighted)}
79
+ onMouseDown={e => e.preventDefault()}
80
+ onMouseEnter={() => setSelected(idx)}
81
+ onClick={() => onPick(idx)}
82
+ >
83
+ <span className={S.itemLabel}>/{item.label}</span>
84
+ {item.description ? (
85
+ <span className={S.itemDesc}>{item.description}</span>
86
+ ) : null}
87
+ </button>
88
+ ))}
89
+ </div>
90
+ );
91
+ }
92
+
93
+ /** Props forwarded from Tiptap suggestion renderer + our wiring. */
94
+ export type SlashSuggestionListProps = {
95
+ items: SlashCommandItem[];
96
+ command: (item: SlashCommandItem) => void;
97
+ listHandleRef: RefObject<SlashSuggestionListHandle | null>;
98
+ };
99
+
100
+ export function SlashSuggestionList(props: SlashSuggestionListProps) {
101
+ if (props.items.length === 0) return null;
102
+ return (
103
+ <SlashSuggestionListInner
104
+ items={props.items}
105
+ command={props.command}
106
+ listHandleRef={props.listHandleRef}
107
+ />
108
+ );
109
+ }