@sybilion/uilib 1.3.36 → 1.3.37

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.
@@ -48,6 +48,7 @@ function useChatPromptEditor({ disabled, placeholder, slashCommandItems, prefill
48
48
  if (slashItemsStable.length > 0) {
49
49
  exts.push(createSlashMentionExtension({
50
50
  items: slashItemsStable,
51
+ suggestionPlacement: 'above',
51
52
  onSuggestionUiActiveChange: suggestionActiveUpdater,
52
53
  }));
53
54
  }
@@ -4,7 +4,7 @@ import { Button } from '../../Button/Button.js';
4
4
  import { ChatChrome } from '../ChatChrome/ChatChrome.js';
5
5
  import { useChatPanelChromeModel } from './useChatPanelChromeModel.js';
6
6
 
7
- function ChatSheet({ triggerLabel = 'Open Chat', triggerAriaLabel, actionsRef, renderTrigger, presets, scopeId, onMessage, onScriptComplete, onGenerateDashboard, renderMessageChart, emptyState, allowedAttachments, allowPdfAttachments, onAttachmentsDropped, inline = false, }) {
7
+ function ChatSheet({ triggerLabel = 'Open Chat', triggerAriaLabel, actionsRef, renderTrigger, presets, scopeId, onMessage, onScriptComplete, onGenerateDashboard, renderMessageChart, emptyState, allowedAttachments, allowPdfAttachments, onAttachmentsDropped, slashCommandItems, inline = false, }) {
8
8
  const model = useChatPanelChromeModel({
9
9
  embedAsPage: inline,
10
10
  presets,
@@ -17,6 +17,7 @@ function ChatSheet({ triggerLabel = 'Open Chat', triggerAriaLabel, actionsRef, r
17
17
  allowedAttachments,
18
18
  allowPdfAttachments,
19
19
  onAttachmentsDropped,
20
+ slashCommandItems,
20
21
  });
21
22
  if (actionsRef) {
22
23
  actionsRef.current = {
@@ -22,7 +22,7 @@ const CHAT_NAV_COLLAPSE_BREAKPOINT_PX = 1400;
22
22
  const CHAT_QUERY_PARAM = 'chat';
23
23
  const CHAT_OPEN_VALUE = 'open';
24
24
  const PROMPT_QUERY_PARAM = 'prompt';
25
- function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onScriptComplete, onGenerateDashboard, renderMessageChart, emptyState, allowedAttachments, allowPdfAttachments, onAttachmentsDropped, }) {
25
+ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onScriptComplete, onGenerateDashboard, renderMessageChart, emptyState, allowedAttachments, allowPdfAttachments, onAttachmentsDropped, slashCommandItems, }) {
26
26
  const effectiveScopeId = scopeId ?? NO_SCOPE_FALLBACK;
27
27
  const isMobile = useIsMobile();
28
28
  const { chatPanelContainer, isOpen: sidebarNavOpen, setOpen: setSidebarNavOpen, chatWidthPx, setChatWidthPx, getShellWidth, setChatPanelOpen, } = useSidebar();
@@ -786,6 +786,7 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
786
786
  allowedAttachments,
787
787
  allowPdfAttachments,
788
788
  onAttachmentsDropped,
789
+ slashCommandItems,
789
790
  };
790
791
  const toggleOpen = () => onOpenChange(!isOpen);
791
792
  return {
@@ -4,17 +4,30 @@ import { ReactRenderer } from '@tiptap/react';
4
4
  import { SlashSuggestionList } from './SlashSuggestionList.js';
5
5
  import { filterSlashItems } from './defaultChatSlashItems.js';
6
6
 
7
- function slashMentionSuggestionRender(uiRef) {
7
+ const SUGGESTION_GAP_PX = 4;
8
+ function placeSlashSuggestionPopup(popupElement, clientRect, placement) {
9
+ if (!clientRect)
10
+ return;
11
+ const el = popupElement;
12
+ const popupHeight = el.getBoundingClientRect().height;
13
+ const spaceBelow = window.innerHeight - clientRect.bottom - SUGGESTION_GAP_PX;
14
+ const spaceAbove = clientRect.top - SUGGESTION_GAP_PX;
15
+ let showAbove = placement === 'above';
16
+ if (placement === 'auto') {
17
+ showAbove = spaceBelow < popupHeight && spaceAbove >= spaceBelow;
18
+ }
19
+ const top = showAbove
20
+ ? Math.max(SUGGESTION_GAP_PX, clientRect.top - popupHeight - SUGGESTION_GAP_PX)
21
+ : clientRect.bottom + SUGGESTION_GAP_PX;
22
+ el.style.left = `${clientRect.left}px`;
23
+ el.style.top = `${top}px`;
24
+ }
25
+ function slashMentionSuggestionRender(uiRef, placement = 'below') {
8
26
  let popup = null;
9
27
  const place = (props) => {
10
28
  if (!popup?.element)
11
29
  return;
12
- const rect = props.clientRect?.();
13
- if (!rect)
14
- return;
15
- const el = popup.element;
16
- el.style.left = `${rect.left}px`;
17
- el.style.top = `${rect.bottom + 4}px`;
30
+ placeSlashSuggestionPopup(popup.element, props.clientRect?.() ?? null, placement);
18
31
  };
19
32
  return {
20
33
  onStart: props => {
@@ -33,6 +46,7 @@ function slashMentionSuggestionRender(uiRef) {
33
46
  popup.element.style.zIndex = '10002';
34
47
  document.body.append(popup.element);
35
48
  place(props);
49
+ requestAnimationFrame(() => place(props));
36
50
  },
37
51
  onUpdate: props => {
38
52
  if (!popup)
@@ -43,6 +57,7 @@ function slashMentionSuggestionRender(uiRef) {
43
57
  listHandleRef: uiRef,
44
58
  });
45
59
  place(props);
60
+ requestAnimationFrame(() => place(props));
46
61
  },
47
62
  onExit: () => {
48
63
  popup?.destroy();
@@ -88,7 +103,7 @@ function allowSlashTrigger({ state, range, }) {
88
103
  const before = state.doc.textBetween(range.from - 1, range.from);
89
104
  return /\s/.test(before);
90
105
  }
91
- function createSlashMentionExtension({ items: resolvedItems, slashChar = '/', pluginKey, onItemCommand, onSuggestionUiActiveChange, }) {
106
+ function createSlashMentionExtension({ items: resolvedItems, slashChar = '/', pluginKey, onItemCommand, suggestionPlacement = 'below', onSuggestionUiActiveChange, }) {
92
107
  const uiRef = {
93
108
  current: null,
94
109
  };
@@ -131,7 +146,7 @@ function createSlashMentionExtension({ items: resolvedItems, slashChar = '/', pl
131
146
  return null;
132
147
  },
133
148
  render: () => {
134
- const menu = slashMentionSuggestionRender(uiRef);
149
+ const menu = slashMentionSuggestionRender(uiRef, suggestionPlacement);
135
150
  return {
136
151
  ...menu,
137
152
  onStart: props => {
@@ -19,4 +19,4 @@ export interface ChatSheetProps extends Omit<UseChatPanelChromeModelInput, 'embe
19
19
  */
20
20
  inline?: boolean;
21
21
  }
22
- export declare function ChatSheet({ triggerLabel, triggerAriaLabel, actionsRef, renderTrigger, presets, scopeId, onMessage, onScriptComplete, onGenerateDashboard, renderMessageChart, emptyState, allowedAttachments, allowPdfAttachments, onAttachmentsDropped, inline, }: ChatSheetProps): import("react/jsx-runtime").JSX.Element;
22
+ export declare function ChatSheet({ triggerLabel, triggerAriaLabel, actionsRef, renderTrigger, presets, scopeId, onMessage, onScriptComplete, onGenerateDashboard, renderMessageChart, emptyState, allowedAttachments, allowPdfAttachments, onAttachmentsDropped, slashCommandItems, inline, }: ChatSheetProps): import("react/jsx-runtime").JSX.Element;
@@ -1,4 +1,5 @@
1
1
  import { ChatPreset, type ScriptCompletePayload } from '#uilib/components/ui/Chat/Chat.types';
2
+ import type { SlashCommandItem } from '#uilib/tiptap/slash-mention/types';
2
3
  import type { ChatChromeProps } from '../ChatChrome';
3
4
  import type { ChatAttachmentDropItem } from '../ChatChrome/ChatChrome.types';
4
5
  import type { ChatEmptyStateConfig } from '../ChatEmptyState/ChatEmptyState.types';
@@ -22,6 +23,8 @@ export type UseChatPanelChromeModelInput = {
22
23
  /** When true, PDF drops are accepted and parsed to plain text. */
23
24
  allowPdfAttachments?: boolean;
24
25
  onAttachmentsDropped?: (items: ChatAttachmentDropItem[]) => void | Promise<void>;
26
+ /** Slash menu (`/`) in the composer; omit or pass empty to disable. */
27
+ slashCommandItems?: SlashCommandItem[];
25
28
  };
26
29
  export type UseChatPanelChromeModelResult = {
27
30
  chromeProps: ChatChromeProps;
@@ -31,4 +34,4 @@ export type UseChatPanelChromeModelResult = {
31
34
  newChat: () => void;
32
35
  chatPanelContainer: HTMLElement | null;
33
36
  };
34
- export declare function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onScriptComplete, onGenerateDashboard, renderMessageChart, emptyState, allowedAttachments, allowPdfAttachments, onAttachmentsDropped, }: UseChatPanelChromeModelInput): UseChatPanelChromeModelResult;
37
+ export declare function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onScriptComplete, onGenerateDashboard, renderMessageChart, emptyState, allowedAttachments, allowPdfAttachments, onAttachmentsDropped, slashCommandItems, }: UseChatPanelChromeModelInput): UseChatPanelChromeModelResult;
@@ -1 +1,2 @@
1
- export declare function DocsHeaderActions(): import("react/jsx-runtime").JSX.Element;
1
+ import { ChatSheetProps } from '#uilib/components/ui/Chat';
2
+ export declare function DocsHeaderActions(props: ChatSheetProps): import("react/jsx-runtime").JSX.Element;
@@ -1,8 +1,8 @@
1
1
  import type { MutableRefObject } from 'react';
2
2
  import type { SuggestionProps } from '@tiptap/suggestion';
3
3
  import { type SlashSuggestionListHandle } from './SlashSuggestionList';
4
- import type { CreateSlashMentionExtensionOptions, SlashCommandItem } from './types';
5
- export declare function slashMentionSuggestionRender(uiRef: MutableRefObject<SlashSuggestionListHandle | null>): {
4
+ import type { CreateSlashMentionExtensionOptions, SlashCommandItem, SlashSuggestionPlacement } from './types';
5
+ export declare function slashMentionSuggestionRender(uiRef: MutableRefObject<SlashSuggestionListHandle | null>, placement?: SlashSuggestionPlacement): {
6
6
  onStart?: (props: SuggestionProps<SlashCommandItem, SlashCommandItem>) => void;
7
7
  onUpdate?: (props: SuggestionProps<SlashCommandItem, SlashCommandItem>) => void;
8
8
  onExit?: (props: SuggestionProps<SlashCommandItem, SlashCommandItem>) => void;
@@ -18,4 +18,4 @@ export declare function slashMentionSuggestionRender(uiRef: MutableRefObject<Sla
18
18
  export type CreateSlashMentionExtensionConfiguredOptions = CreateSlashMentionExtensionOptions & {
19
19
  onSuggestionUiActiveChange?: (active: boolean) => void;
20
20
  };
21
- export declare function createSlashMentionExtension({ items: resolvedItems, slashChar, pluginKey, onItemCommand, onSuggestionUiActiveChange, }: CreateSlashMentionExtensionConfiguredOptions): import("@tiptap/core").Node<import("@tiptap/extension-mention").MentionOptions<any, import("@tiptap/extension-mention").MentionNodeAttrs>, any>;
21
+ export declare function createSlashMentionExtension({ items: resolvedItems, slashChar, pluginKey, onItemCommand, suggestionPlacement, onSuggestionUiActiveChange, }: CreateSlashMentionExtensionConfiguredOptions): import("@tiptap/core").Node<import("@tiptap/extension-mention").MentionOptions<any, import("@tiptap/extension-mention").MentionNodeAttrs>, any>;
@@ -1,4 +1,4 @@
1
- export type { SlashCommandItem, SlashOnItemCommand, SlashItemCommandContext, CreateSlashMentionExtensionOptions, } from './types';
1
+ export type { SlashCommandItem, SlashOnItemCommand, SlashItemCommandContext, SlashSuggestionPlacement, CreateSlashMentionExtensionOptions, } from './types';
2
2
  export { DEFAULT_CHAT_SLASH_ITEMS, filterSlashItems, } from './defaultChatSlashItems';
3
3
  export { createSlashMentionExtension, slashMentionSuggestionRender, } from './createSlashMentionExtension';
4
4
  export type { CreateSlashMentionExtensionConfiguredOptions } from './createSlashMentionExtension';
@@ -13,6 +13,8 @@ export type SlashItemCommandContext = {
13
13
  * If provided, run before default mention insertion. Return true to skip inserting a mention node.
14
14
  */
15
15
  export type SlashOnItemCommand = (ctx: SlashItemCommandContext) => boolean;
16
+ /** Where the slash palette opens relative to the caret. */
17
+ export type SlashSuggestionPlacement = 'below' | 'above' | 'auto';
16
18
  export type CreateSlashMentionExtensionOptions = {
17
19
  /** Items shown in the slash menu (filtered by query after `/`). */
18
20
  items: SlashCommandItem[];
@@ -22,4 +24,9 @@ export type CreateSlashMentionExtensionOptions = {
22
24
  pluginKey?: import('@tiptap/pm/state').PluginKey;
23
25
  /** Custom handler (e.g. insert a block node instead of a mention). */
24
26
  onItemCommand?: SlashOnItemCommand;
27
+ /**
28
+ * Palette position vs caret. Default `below`.
29
+ * Use `above` for bottom-anchored composers (chat prompt); `auto` flips by viewport space.
30
+ */
31
+ suggestionPlacement?: SlashSuggestionPlacement;
25
32
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sybilion/uilib",
3
- "version": "1.3.36",
3
+ "version": "1.3.37",
4
4
  "description": "Sybilion Design System — React UI components (Webpack + Stylus)",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -94,6 +94,7 @@ export function useChatPromptEditor({
94
94
  exts.push(
95
95
  createSlashMentionExtension({
96
96
  items: slashItemsStable,
97
+ suggestionPlacement: 'above',
97
98
  onSuggestionUiActiveChange: suggestionActiveUpdater,
98
99
  }),
99
100
  );
@@ -47,6 +47,7 @@ export function ChatSheet({
47
47
  allowedAttachments,
48
48
  allowPdfAttachments,
49
49
  onAttachmentsDropped,
50
+ slashCommandItems,
50
51
  inline = false,
51
52
  }: ChatSheetProps) {
52
53
  const model = useChatPanelChromeModel({
@@ -61,6 +62,7 @@ export function ChatSheet({
61
62
  allowedAttachments,
62
63
  allowPdfAttachments,
63
64
  onAttachmentsDropped,
65
+ slashCommandItems,
64
66
  });
65
67
 
66
68
  if (actionsRef) {
@@ -38,6 +38,7 @@ import useEvent from '#uilib/hooks/useEvent';
38
38
  import { useIsMobile } from '#uilib/hooks/useIsMobile';
39
39
  import { useQueryParams } from '#uilib/hooks/useQueryParams';
40
40
  import logger from '#uilib/lib/logger';
41
+ import type { SlashCommandItem } from '#uilib/tiptap/slash-mention/types';
41
42
  import { mergePresetFreeformDefaults } from '#uilib/utils/chatPresetMerge';
42
43
  import { ScrollRef } from '@homecode/ui';
43
44
 
@@ -70,6 +71,8 @@ export type UseChatPanelChromeModelInput = {
70
71
  onAttachmentsDropped?: (
71
72
  items: ChatAttachmentDropItem[],
72
73
  ) => void | Promise<void>;
74
+ /** Slash menu (`/`) in the composer; omit or pass empty to disable. */
75
+ slashCommandItems?: SlashCommandItem[];
73
76
  };
74
77
 
75
78
  export type UseChatPanelChromeModelResult = {
@@ -118,6 +121,7 @@ export function useChatPanelChromeModel({
118
121
  allowedAttachments,
119
122
  allowPdfAttachments,
120
123
  onAttachmentsDropped,
124
+ slashCommandItems,
121
125
  }: UseChatPanelChromeModelInput): UseChatPanelChromeModelResult {
122
126
  const effectiveScopeId = scopeId ?? NO_SCOPE_FALLBACK;
123
127
  const isMobile = useIsMobile();
@@ -616,103 +620,107 @@ export function useChatPanelChromeModel({
616
620
  ],
617
621
  );
618
622
 
619
- const submitPreset = useCallback(async (preset: ChatPreset) => {
620
- const script = preset.script;
621
- const scriptGraph = isPresetScriptGraph(script);
622
- const hasLinearScript = Array.isArray(script) && script.length > 0;
623
- const hasReplies = preset.replies && Object.keys(preset.replies).length > 0;
624
- const isLocalDemo =
625
- hasLinearScript ||
626
- scriptGraph ||
627
- Boolean(preset.answer?.trim()) ||
628
- Boolean(hasReplies);
629
-
630
- if (!isLocalDemo) {
631
- if (!currentChatId) return;
632
- endLocalDemoFlow(currentChatId);
633
- await handlePromptSubmit(preset.text);
634
- return;
635
- }
623
+ const submitPreset = useCallback(
624
+ async (preset: ChatPreset) => {
625
+ const script = preset.script;
626
+ const scriptGraph = isPresetScriptGraph(script);
627
+ const hasLinearScript = Array.isArray(script) && script.length > 0;
628
+ const hasReplies =
629
+ preset.replies && Object.keys(preset.replies).length > 0;
630
+ const isLocalDemo =
631
+ hasLinearScript ||
632
+ scriptGraph ||
633
+ Boolean(preset.answer?.trim()) ||
634
+ Boolean(hasReplies);
635
+
636
+ if (!isLocalDemo) {
637
+ if (!currentChatId) return;
638
+ endLocalDemoFlow(currentChatId);
639
+ await handlePromptSubmit(preset.text);
640
+ return;
641
+ }
636
642
 
637
- setLocalUiBusy(true);
638
- try {
639
- if (!currentChatId) return;
640
- setScriptCompleteByChatId(prev => {
641
- const next = { ...prev };
642
- delete next[currentChatId];
643
- return next;
644
- });
645
- addMessage(currentChatId, MessageRole.USER, preset.text);
646
- await new Promise(resolve => setTimeout(resolve, 1000));
647
- const firstAssistant = scriptGraph
648
- ? script.initialMessage
649
- : (preset.answer ?? '');
650
- addMessage(currentChatId, MessageRole.ASSISTANT, firstAssistant);
651
-
652
- setScriptByChatId(prev => {
653
- const next = { ...prev };
654
- if (hasLinearScript) {
655
- next[currentChatId] = {
656
- lines: script,
657
- nextIndex: 0,
658
- presetId: preset.id,
659
- };
660
- } else {
643
+ setLocalUiBusy(true);
644
+ try {
645
+ if (!currentChatId) return;
646
+ setScriptCompleteByChatId(prev => {
647
+ const next = { ...prev };
661
648
  delete next[currentChatId];
649
+ return next;
650
+ });
651
+ addMessage(currentChatId, MessageRole.USER, preset.text);
652
+ await new Promise(resolve => setTimeout(resolve, 1000));
653
+ const firstAssistant = scriptGraph
654
+ ? script.initialMessage
655
+ : (preset.answer ?? '');
656
+ addMessage(currentChatId, MessageRole.ASSISTANT, firstAssistant);
657
+
658
+ setScriptByChatId(prev => {
659
+ const next = { ...prev };
660
+ if (hasLinearScript) {
661
+ next[currentChatId] = {
662
+ lines: script,
663
+ nextIndex: 0,
664
+ presetId: preset.id,
665
+ };
666
+ } else {
667
+ delete next[currentChatId];
668
+ }
669
+ return next;
670
+ });
671
+ setQuickReplyBranchesByChat(prev => {
672
+ const next = { ...prev };
673
+ if (scriptGraph) {
674
+ next[currentChatId] = branchesFromPresetScriptGraph(script);
675
+ } else if (hasReplies && !hasLinearScript) {
676
+ next[currentChatId] = preset.replies!;
677
+ } else {
678
+ delete next[currentChatId];
679
+ }
680
+ return next;
681
+ });
682
+ if (scriptGraph || (hasReplies && !hasLinearScript)) {
683
+ setUsedScriptBranchKeysByChat(prev => ({
684
+ ...prev,
685
+ [currentChatId]: [],
686
+ }));
662
687
  }
663
- return next;
664
- });
665
- setQuickReplyBranchesByChat(prev => {
666
- const next = { ...prev };
667
688
  if (scriptGraph) {
668
- next[currentChatId] = branchesFromPresetScriptGraph(script);
669
- } else if (hasReplies && !hasLinearScript) {
670
- next[currentChatId] = preset.replies!;
671
- } else {
672
- delete next[currentChatId];
673
- }
674
- return next;
675
- });
676
- if (scriptGraph || (hasReplies && !hasLinearScript)) {
677
- setUsedScriptBranchKeysByChat(prev => ({
678
- ...prev,
679
- [currentChatId]: [],
680
- }));
681
- }
682
- if (scriptGraph) {
683
- const fromMerged = presetsWithFreeform.find(p => p.id === preset.id);
684
- let freeformNext = {
685
- ...(fromMerged?.freeformNext ?? {}),
686
- ...(preset.freeformNext ?? {}),
687
- };
688
- if (Object.keys(freeformNext).length === 0) {
689
- const ks = Object.keys(branchesFromPresetScriptGraph(script));
690
- if (ks.length === 1) {
691
- freeformNext = { initial: ks[0] };
689
+ const fromMerged = presetsWithFreeform.find(p => p.id === preset.id);
690
+ let freeformNext = {
691
+ ...(fromMerged?.freeformNext ?? {}),
692
+ ...(preset.freeformNext ?? {}),
693
+ };
694
+ if (Object.keys(freeformNext).length === 0) {
695
+ const ks = Object.keys(branchesFromPresetScriptGraph(script));
696
+ if (ks.length === 1) {
697
+ freeformNext = { initial: ks[0] };
698
+ }
692
699
  }
700
+ setIntakeByChatId(prev => ({
701
+ ...prev,
702
+ [currentChatId!]: {
703
+ presetId: preset.id,
704
+ freeformNext,
705
+ scriptStepId: 'initial',
706
+ answers: {},
707
+ },
708
+ }));
693
709
  }
694
- setIntakeByChatId(prev => ({
695
- ...prev,
696
- [currentChatId!]: {
697
- presetId: preset.id,
698
- freeformNext,
699
- scriptStepId: 'initial',
700
- answers: {},
701
- },
702
- }));
710
+ } catch (error) {
711
+ logger.error('Error sending chat message:', error);
712
+ } finally {
713
+ setLocalUiBusy(false);
703
714
  }
704
- } catch (error) {
705
- logger.error('Error sending chat message:', error);
706
- } finally {
707
- setLocalUiBusy(false);
708
- }
709
- }, [
710
- currentChatId,
711
- endLocalDemoFlow,
712
- handlePromptSubmit,
713
- addMessage,
714
- presetsWithFreeform,
715
- ]);
715
+ },
716
+ [
717
+ currentChatId,
718
+ endLocalDemoFlow,
719
+ handlePromptSubmit,
720
+ addMessage,
721
+ presetsWithFreeform,
722
+ ],
723
+ );
716
724
 
717
725
  const resolvedEmptyState = useMemo((): ChatEmptyStateProps | undefined => {
718
726
  if (!emptyState) return undefined;
@@ -1040,6 +1048,7 @@ export function useChatPanelChromeModel({
1040
1048
  allowedAttachments,
1041
1049
  allowPdfAttachments,
1042
1050
  onAttachmentsDropped,
1051
+ slashCommandItems,
1043
1052
  };
1044
1053
 
1045
1054
  const toggleOpen = () => onOpenChange(!isOpen);
@@ -1,9 +1,9 @@
1
- import { ChatSheet } from '#uilib/components/ui/Chat';
1
+ import { ChatSheet, ChatSheetProps } from '#uilib/components/ui/Chat';
2
2
  import { MessageSquare } from 'lucide-react';
3
3
 
4
4
  import { DOCS_CHAT_SCOPE_ID } from './docsConstants';
5
5
 
6
- export function DocsHeaderActions() {
6
+ export function DocsHeaderActions(props: ChatSheetProps) {
7
7
  return (
8
8
  <ChatSheet
9
9
  triggerLabel={
@@ -21,6 +21,7 @@ export function DocsHeaderActions() {
21
21
  <p>Optional empty-state slot via additionalContent.</p>
22
22
  ),
23
23
  }}
24
+ {...props}
24
25
  />
25
26
  );
26
27
  }
@@ -85,7 +85,9 @@ export default function ChatSlashCommandsPage() {
85
85
  breadcrumbs={[{ label: 'Chat' }, { label: 'Chat slash commands' }]}
86
86
  title="Chat slash commands"
87
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 />}
88
+ actions={
89
+ <DocsHeaderActions slashCommandItems={DOCS_SAMPLE_SLASH_ITEMS} />
90
+ }
89
91
  />
90
92
  <PageContentSection>
91
93
  <p style={{ marginBottom: 16, fontSize: 14, lineHeight: 1.5 }}>
@@ -14,10 +14,42 @@ import { filterSlashItems } from './defaultChatSlashItems';
14
14
  import type {
15
15
  CreateSlashMentionExtensionOptions,
16
16
  SlashCommandItem,
17
+ SlashSuggestionPlacement,
17
18
  } from './types';
18
19
 
20
+ const SUGGESTION_GAP_PX = 4;
21
+
22
+ function placeSlashSuggestionPopup(
23
+ popupElement: HTMLElement,
24
+ clientRect: DOMRect | null | undefined,
25
+ placement: SlashSuggestionPlacement,
26
+ ): void {
27
+ if (!clientRect) return;
28
+
29
+ const el = popupElement;
30
+ const popupHeight = el.getBoundingClientRect().height;
31
+ const spaceBelow = window.innerHeight - clientRect.bottom - SUGGESTION_GAP_PX;
32
+ const spaceAbove = clientRect.top - SUGGESTION_GAP_PX;
33
+
34
+ let showAbove = placement === 'above';
35
+ if (placement === 'auto') {
36
+ showAbove = spaceBelow < popupHeight && spaceAbove >= spaceBelow;
37
+ }
38
+
39
+ const top = showAbove
40
+ ? Math.max(
41
+ SUGGESTION_GAP_PX,
42
+ clientRect.top - popupHeight - SUGGESTION_GAP_PX,
43
+ )
44
+ : clientRect.bottom + SUGGESTION_GAP_PX;
45
+
46
+ el.style.left = `${clientRect.left}px`;
47
+ el.style.top = `${top}px`;
48
+ }
49
+
19
50
  export function slashMentionSuggestionRender(
20
51
  uiRef: MutableRefObject<SlashSuggestionListHandle | null>,
52
+ placement: SlashSuggestionPlacement = 'below',
21
53
  ): {
22
54
  onStart?: (
23
55
  props: SuggestionProps<SlashCommandItem, SlashCommandItem>,
@@ -45,11 +77,11 @@ export function slashMentionSuggestionRender(
45
77
  >,
46
78
  ) => {
47
79
  if (!popup?.element) return;
48
- const rect = props.clientRect?.();
49
- if (!rect) return;
50
- const el = popup.element;
51
- el.style.left = `${rect.left}px`;
52
- el.style.top = `${rect.bottom + 4}px`;
80
+ placeSlashSuggestionPopup(
81
+ popup.element,
82
+ props.clientRect?.() ?? null,
83
+ placement,
84
+ );
53
85
  };
54
86
 
55
87
  return {
@@ -69,6 +101,7 @@ export function slashMentionSuggestionRender(
69
101
  popup.element.style.zIndex = '10002';
70
102
  document.body.append(popup.element);
71
103
  place(props);
104
+ requestAnimationFrame(() => place(props));
72
105
  },
73
106
  onUpdate: props => {
74
107
  if (!popup) return;
@@ -78,6 +111,7 @@ export function slashMentionSuggestionRender(
78
111
  listHandleRef: uiRef,
79
112
  });
80
113
  place(props);
114
+ requestAnimationFrame(() => place(props));
81
115
  },
82
116
  onExit: () => {
83
117
  popup?.destroy();
@@ -149,6 +183,7 @@ export function createSlashMentionExtension({
149
183
  slashChar = '/',
150
184
  pluginKey,
151
185
  onItemCommand,
186
+ suggestionPlacement = 'below',
152
187
  onSuggestionUiActiveChange,
153
188
  }: CreateSlashMentionExtensionConfiguredOptions) {
154
189
  const uiRef: MutableRefObject<SlashSuggestionListHandle | null> = {
@@ -199,7 +234,7 @@ export function createSlashMentionExtension({
199
234
  return null;
200
235
  },
201
236
  render: () => {
202
- const menu = slashMentionSuggestionRender(uiRef);
237
+ const menu = slashMentionSuggestionRender(uiRef, suggestionPlacement);
203
238
  return {
204
239
  ...menu,
205
240
  onStart: props => {
@@ -2,6 +2,7 @@ export type {
2
2
  SlashCommandItem,
3
3
  SlashOnItemCommand,
4
4
  SlashItemCommandContext,
5
+ SlashSuggestionPlacement,
5
6
  CreateSlashMentionExtensionOptions,
6
7
  } from './types';
7
8
  export {
@@ -17,6 +17,9 @@ export type SlashItemCommandContext = {
17
17
  */
18
18
  export type SlashOnItemCommand = (ctx: SlashItemCommandContext) => boolean;
19
19
 
20
+ /** Where the slash palette opens relative to the caret. */
21
+ export type SlashSuggestionPlacement = 'below' | 'above' | 'auto';
22
+
20
23
  export type CreateSlashMentionExtensionOptions = {
21
24
  /** Items shown in the slash menu (filtered by query after `/`). */
22
25
  items: SlashCommandItem[];
@@ -26,4 +29,9 @@ export type CreateSlashMentionExtensionOptions = {
26
29
  pluginKey?: import('@tiptap/pm/state').PluginKey;
27
30
  /** Custom handler (e.g. insert a block node instead of a mention). */
28
31
  onItemCommand?: SlashOnItemCommand;
32
+ /**
33
+ * Palette position vs caret. Default `below`.
34
+ * Use `above` for bottom-anchored composers (chat prompt); `auto` flips by viewport space.
35
+ */
36
+ suggestionPlacement?: SlashSuggestionPlacement;
29
37
  };