diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotMessageBarResourceTagging.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotMessageBarResourceTagging.tsx new file mode 100644 index 000000000..8be325988 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotMessageBarResourceTagging.tsx @@ -0,0 +1,233 @@ +import { useState, FunctionComponent, useRef, useEffect } from 'react'; +import { MessageBar } from '@patternfly/chatbot/dist/dynamic/MessageBar'; +import { Label, LabelGroup, Menu, MenuContent, MenuItem, MenuList, Popper } from '@patternfly/react-core'; + +interface Resource { + id: string; + name: string; + type: string; +} + +export const ChatbotMessageBarResourceTaggingExample: FunctionComponent = () => { + const [message, setMessage] = useState(''); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [selectedResources, setSelectedResources] = useState([]); + const [filteredResources, setFilteredResources] = useState([]); + const [triggerPosition, setTriggerPosition] = useState(-1); + const [searchTerm, setSearchTerm] = useState(''); + const [activeItemIndex, setActiveItemIndex] = useState(0); + + const textareaRef = useRef(null); + const menuRef = useRef(null); + + // Sample resources + const availableResources: Resource[] = [ + { id: '1', name: 'pod/auth-operator', type: 'Pod' }, + { id: '2', name: 'deployment/frontend-app', type: 'Deployment' }, + { id: '3', name: 'service/backend-api', type: 'Service' }, + { id: '4', name: 'configmap/app-config', type: 'ConfigMap' }, + { id: '5', name: 'secret/db-credentials', type: 'Secret' }, + { id: '6', name: 'pod/redis-cache', type: 'Pod' }, + { id: '7', name: 'deployment/nginx-proxy', type: 'Deployment' }, + { id: '8', name: 'service/auth-service', type: 'Service' } + ]; + + const handleSend = (msg: string | number) => { + alert(`Sending message: ${msg}\nWith resources: ${selectedResources.map((r) => r.name).join(', ')}`); + setSelectedResources([]); + setMessage(''); + }; + + const handleChange = (_event: React.ChangeEvent, value: string | number) => { + const newValue = value.toString(); + setMessage(newValue); + + // Check if "#" was just typed + const lastChar = newValue[newValue.length - 1]; + const cursorPos = textareaRef.current?.selectionStart || 0; + + if (lastChar === '#') { + setTriggerPosition(cursorPos - 1); + setIsMenuOpen(true); + setSearchTerm(''); + setFilteredResources(availableResources); + setActiveItemIndex(0); + } else if (isMenuOpen && triggerPosition >= 0) { + // Extract the search term after the "#" + const textAfterTrigger = newValue.substring(triggerPosition + 1, cursorPos); + + // Check if we've moved away from the tag or pressed space + if (textAfterTrigger.includes(' ') || cursorPos < triggerPosition) { + setIsMenuOpen(false); + setTriggerPosition(-1); + } else { + setSearchTerm(textAfterTrigger); + // Filter resources based on search term + const filtered = availableResources.filter((resource) => + resource.name.toLowerCase().includes(textAfterTrigger.toLowerCase()) + ); + setFilteredResources(filtered); + setActiveItemIndex(0); + } + } + }; + + const handleResourceSelect = (resource: Resource) => { + if (!textareaRef.current) { + return; + } + + // Get the text before the "#" and after the current cursor position + const beforeTag = message.substring(0, triggerPosition); + const cursorPos = textareaRef.current.selectionStart || 0; + const afterCursor = message.substring(cursorPos); + + // Build new message with the full resource name, keeping the "#" + const newMessage = `${beforeTag}#${resource.name} ${afterCursor}`; + + // Update state - MessageBar will sync via its internal useEffect + setMessage(newMessage); + + // Add resource to selected resources if not already added + if (!selectedResources.find((r) => r.id === resource.id)) { + setSelectedResources([...selectedResources, resource]); + } + + // Close the menu and reset + setIsMenuOpen(false); + setTriggerPosition(-1); + setSearchTerm(''); + + // Focus textarea and set cursor position after the inserted resource + setTimeout(() => { + if (textareaRef.current) { + const newCursorPos = beforeTag.length + resource.name.length + 2; // +2 for "#" and space + textareaRef.current.focus(); + textareaRef.current.setSelectionRange(newCursorPos, newCursorPos); + } + }, 0); + }; + + const handleRemoveResource = (resourceId: string) => { + setSelectedResources(selectedResources.filter((r) => r.id !== resourceId)); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (!isMenuOpen || filteredResources.length === 0) { + return; + } + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + setActiveItemIndex((prev) => (prev + 1) % filteredResources.length); + break; + case 'ArrowUp': + event.preventDefault(); + setActiveItemIndex((prev) => (prev - 1 + filteredResources.length) % filteredResources.length); + break; + case 'Enter': + if (isMenuOpen) { + event.preventDefault(); + const selectedResource = filteredResources[activeItemIndex]; + if (selectedResource) { + handleResourceSelect(selectedResource); + } + } + break; + case 'Escape': + if (isMenuOpen) { + event.preventDefault(); + setIsMenuOpen(false); + setTriggerPosition(-1); + } + break; + } + }; + + // Close menu when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + menuRef.current && + !menuRef.current.contains(event.target as Node) && + textareaRef.current && + !textareaRef.current.contains(event.target as Node) + ) { + setIsMenuOpen(false); + setTriggerPosition(-1); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + const menu = ( + { + const resource = filteredResources.find((r) => r.id === itemId?.toString()); + if (resource) { + handleResourceSelect(resource); + } + }} + > + + + {filteredResources.length > 0 ? ( + filteredResources.map((resource, index) => ( + + {resource.name} + + )) + ) : ( + No resources found + )} + + + + ); + + return ( +
+ + {selectedResources.length > 0 && ( +
+ + {selectedResources.map((resource) => ( + + ))} + +
+ )} + +
+ ); +}; diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/UI.md b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/UI.md index fb035c4c3..7ef4d5254 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/UI.md +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/UI.md @@ -74,7 +74,7 @@ import { BellIcon, CalendarAltIcon, ClipboardIcon, CodeIcon, ThumbtackIcon, Uplo import { useDropzone } from 'react-dropzone'; import ChatbotConversationHistoryNav from '@patternfly/chatbot/dist/dynamic/ChatbotConversationHistoryNav'; -import { Button, DropdownItem, DropdownList, Checkbox, MenuToggle, Select, SelectList, SelectOption } from '@patternfly/react-core'; +import { Button, DropdownItem, DropdownList, Checkbox, Label, LabelGroup, Menu, MenuContent, MenuItem, MenuList, MenuToggle, Popper, Select, SelectList, SelectOption } from '@patternfly/react-core'; import OutlinedWindowRestoreIcon from '@patternfly/react-icons/dist/esm/icons/outlined-window-restore-icon'; import ExpandIcon from '@patternfly/react-icons/dist/esm/icons/expand-icon'; @@ -291,6 +291,23 @@ Attachments can also be added to the ChatBot via [drag and drop.](/extensions/ch ``` +### Message bar with resource tagging + +You can implement custom keyboard logic to create a typeahead-style dropdown that opens when users type special characters. This example demonstrates a resource tagging feature where: + +1. Typing "#" opens a dropdown menu of available resources +2. The menu automatically filters as you continue typing +3. Selecting a resource autofills the name in the input +4. A dismissable label appears above the message input showing the selected resource +5. Multiple resources can be tagged in a single message +6. Arrow keys navigate the menu (ArrowUp/ArrowDown), Enter selects, Escape closes + +This pattern is useful for mentioning resources, users, channels, or other entities within chat messages. + +```js file="./ChatbotMessageBarResourceTagging.tsx" + +``` + ### Footer with message bar and footnote A simple footer with a message bar and footnote would have this code structure: diff --git a/packages/module/src/MessageBar/MessageBar.tsx b/packages/module/src/MessageBar/MessageBar.tsx index 578f21e82..d53471291 100644 --- a/packages/module/src/MessageBar/MessageBar.tsx +++ b/packages/module/src/MessageBar/MessageBar.tsx @@ -168,6 +168,13 @@ export const MessageBarBase: FunctionComponent = ({ const topMargin = '1rem'; + // Sync internal state when value prop changes (controlled component behavior) + useEffect(() => { + if (value !== undefined && value !== message) { + setMessage(value); + } + }, [value, message]); + const setInitialLineHeight = (field: HTMLTextAreaElement) => { field.style.setProperty('line-height', '1rem'); const parent = field.parentElement;