diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Canvas.md b/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Canvas.md index e7e38cf6..8a7d68e6 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Canvas.md +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Canvas.md @@ -6,6 +6,7 @@ source: demo import { useState, useRef, useCallback, useEffect, FunctionComponent, ReactNode } from 'react'; import { +Avatar, Brand, Divider, Drawer, @@ -21,6 +22,9 @@ Flex, FlexItem, Label, MenuToggle, +Nav, +NavItem, +NavList, Popover, Select, SelectList, @@ -43,9 +47,17 @@ ChatbotHeaderMain, ChatbotHeaderMenu, ChatbotHeaderTitle } from '@patternfly/chatbot/dist/dynamic/ChatbotHeader'; +import SettingsForm from '@patternfly/chatbot/dist/dynamic/Settings'; import RhUiImageFillIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-image-fill-icon'; import RhUiAddIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-add-icon'; import RhUiTaskFillIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-task-fill-icon'; +import RhUiEditFillIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-edit-fill-icon'; +import RhUiSettingsFillIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-settings-fill-icon'; +import RhUiUploadIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-upload-icon'; +import RhUiCopyFillIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-copy-fill-icon'; +import RhUiDownloadIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-download-icon'; +import RhUiPlayFillIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-play-fill-icon'; +import RhUiBackupIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-backup-icon'; import RhUiNotificationFillIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-notification-fill-icon'; import RhUiCalendarFillIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-calendar-fill-icon'; import RhUiSaveIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-save-icon'; @@ -53,6 +65,8 @@ import RhUiRedoIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-redo-icon import RhUiUndoIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-undo-icon'; import RhUiServerUploadFillIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-server-upload-fill-icon'; import RhUiAiInfoIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-ai-info-icon'; +import RhMicronsCloseIcon from '@patternfly/react-icons/dist/esm/icons/rh-microns-close-icon'; +import RhUiExportIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-export-icon'; import { useDropzone } from 'react-dropzone'; import PFIconLogoColor from '../UI/PF-IconLogo-Color.svg'; import PFIconLogoReverse from '../UI/PF-IconLogo-Reverse.svg'; @@ -86,17 +100,18 @@ The general recommended structure is as follows: ## Class names -| Class name | Purpose | -| --- | --- | -| `pf-chatbot__canvas` | Container for the fullscreen canvas layout and its background. | -| `pf-chatbot__canvas-drawer` | Styles the drawer that contains the canvas panel. | -| `pf-chatbot__canvas-section` | Styles the section used within the drawer for focus. | -| `pf-chatbot__canvas-body` | Ensures the drawer content body fills the available height. | -| `pf-chatbot__canvas-column` | Arranges the canvas header, content, and footer vertically. | -| `pf-chatbot__canvas-panel` | Styles the canvas drawer panel and removes the default panel spacing. | -| `pf-chatbot__canvas-panel-body` | Provides the canvas panel content area and its spacing. | -| `pf-chatbot__canvas-head` | Styles and positions the canvas panel header and close action. | -| `pf-chatbot__canvas-editor` | Makes the code editor fill the available canvas space. | +| Class name | Purpose | +| ------------------------------- | --------------------------------------------------------------------- | +| `pf-chatbot__canvas` | Container for the fullscreen canvas layout and its background. | +| `pf-chatbot__canvas-drawer` | Styles the drawer that contains the canvas panel. | +| `pf-chatbot__canvas-section` | Styles the section used within the drawer for focus. | +| `pf-chatbot__canvas-body` | Ensures the drawer content body fills the available height. | +| `pf-chatbot__canvas-column` | Arranges the canvas header, content, and footer vertically. | +| `pf-chatbot__canvas-panel` | Styles the canvas drawer panel and removes the default panel spacing. | +| `pf-chatbot__canvas-panel-body` | Provides the canvas panel content area and its spacing. | +| `pf-chatbot__canvas-head` | Styles and positions the canvas panel header and close action. | +| `pf-chatbot__canvas-editor` | Makes the code editor fill the available canvas space. | +| `pf-chatbot__canvas-docked-nav` | Optional vertical navigation rail for a canvas layout. | ## Demos @@ -107,3 +122,11 @@ This demo shows canvas mode being used to render a PatternFly [code editor](/com ```js file="./Canvas.tsx" isFullscreen ``` + +### With docked navigation + +This demo shows canvas mode with an optional docked navigation rail. The rail can toggle chat history and start a new chat without closing the canvas. + +```js file="./CanvasWithDockedNav.tsx" isFullscreen + +``` diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Canvas.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Canvas.tsx index be922e6c..375bbc51 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Canvas.tsx +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Canvas.tsx @@ -1,10 +1,10 @@ import { useState, useRef, useCallback, useEffect, FunctionComponent, ReactNode } from 'react'; import { Brand, + Button, Divider, Drawer, DrawerActions, - DrawerCloseButton, DrawerContent, DrawerContentBody, DrawerHead, @@ -39,16 +39,17 @@ import ChatbotHeader, { ChatbotHeaderMenu, ChatbotHeaderTitle } from '@patternfly/chatbot/dist/dynamic/ChatbotHeader'; -import RhUiAiInfoIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-ai-info-icon'; -import RhUiImageFillIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-image-fill-icon'; -import RhUiAddIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-add-icon'; -import RhUiTaskFillIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-task-fill-icon'; -import RhUiSaveIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-save-icon'; -import RhUiRedoIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-redo-icon'; -import RhUiUndoIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-undo-icon'; -import RhUiServerUploadFillIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-server-upload-fill-icon'; -import RhUiNotificationFillIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-notification-fill-icon'; -import RhUiCalendarFillIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-calendar-fill-icon'; +import { RhUiAiInfoIcon } from '@patternfly/react-icons/dist/esm/icons/rh-ui-ai-info-icon'; +import { RhUiImageFillIcon } from '@patternfly/react-icons/dist/esm/icons/rh-ui-image-fill-icon'; +import { RhUiAddIcon } from '@patternfly/react-icons/dist/esm/icons/rh-ui-add-icon'; +import { RhUiTaskFillIcon } from '@patternfly/react-icons/dist/esm/icons/rh-ui-task-fill-icon'; +import { RhUiSaveIcon } from '@patternfly/react-icons/dist/esm/icons/rh-ui-save-icon'; +import { RhUiRedoIcon } from '@patternfly/react-icons/dist/esm/icons/rh-ui-redo-icon'; +import { RhUiUndoIcon } from '@patternfly/react-icons/dist/esm/icons/rh-ui-undo-icon'; +import { RhUiServerUploadFillIcon } from '@patternfly/react-icons/dist/esm/icons/rh-ui-server-upload-fill-icon'; +import { RhUiNotificationFillIcon } from '@patternfly/react-icons/dist/esm/icons/rh-ui-notification-fill-icon'; +import { RhUiCalendarFillIcon } from '@patternfly/react-icons/dist/esm/icons/rh-ui-calendar-fill-icon'; +import { RhMicronsCloseIcon } from '@patternfly/react-icons/dist/esm/icons/rh-microns-close-icon'; import { useDropzone } from 'react-dropzone'; import PFIconLogoColor from '../UI/PF-IconLogo-Color.svg'; import PFIconLogoReverse from '../UI/PF-IconLogo-Reverse.svg'; @@ -139,6 +140,7 @@ export const Canvas: FunctionComponent = () => { const editorRef = useRef(null); const messageInputRef = useRef(null); const messageActionsRef = useRef(null); + const canvasCloseButtonRef = useRef(null); const canvasSectionRef = useRef(null); const wasCanvasOpen = useRef(isCanvasOpen); const shouldFocusCanvasCloseButton = useRef(false); @@ -412,11 +414,16 @@ export const Canvas: FunctionComponent = () => { - - - - - + +
+
diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/CanvasWithDockedNav.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/CanvasWithDockedNav.tsx new file mode 100644 index 00000000..2ad54d4d --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/CanvasWithDockedNav.tsx @@ -0,0 +1,698 @@ +import { ReactNode, useEffect, useRef, useState } from 'react'; +import { useDropzone } from 'react-dropzone'; +import { + Avatar, + Brand, + Button, + Divider, + Dropdown, + DropdownItem, + DropdownList, + Drawer, + DrawerActions, + DrawerContent, + DrawerContentBody, + DrawerHead, + DrawerPanelContent, + Flex, + FlexItem, + Label, + Masthead, + MastheadBrand, + MastheadContent, + MastheadLogo, + MastheadMain, + MastheadToggle, + MenuToggle, + Nav, + NavItem, + NavList, + Select, + SelectList, + SelectOption, + Switch, + Title, + PageToggleButton, + Popover, + Tooltip +} from '@patternfly/react-core'; +import { CodeEditor, CodeEditorControl, Language } from '@patternfly/react-code-editor'; +import Chatbot, { ChatbotDisplayMode } from '@patternfly/chatbot/dist/dynamic/Chatbot'; +import ChatbotContent from '@patternfly/chatbot/dist/dynamic/ChatbotContent'; +import ChatbotFooter, { ChatbotFootnote } from '@patternfly/chatbot/dist/dynamic/ChatbotFooter'; +import MessageBar from '@patternfly/chatbot/dist/dynamic/MessageBar'; +import MessageBox from '@patternfly/chatbot/dist/dynamic/MessageBox'; +import Message from '@patternfly/chatbot/dist/dynamic/Message'; +import ChatbotConversationHistoryNav from '@patternfly/chatbot/dist/dynamic/ChatbotConversationHistoryNav'; +import { RhUiAddIcon } from '@patternfly/react-icons/dist/esm/icons/rh-ui-add-icon'; +import { RhUiAiInfoIcon } from '@patternfly/react-icons/dist/esm/icons/rh-ui-ai-info-icon'; +import { RhUiCalendarFillIcon } from '@patternfly/react-icons/dist/esm/icons/rh-ui-calendar-fill-icon'; +import { RhUiExportIcon } from '@patternfly/react-icons/dist/esm/icons/rh-ui-export-icon'; +import { RhUiImageFillIcon } from '@patternfly/react-icons/dist/esm/icons/rh-ui-image-fill-icon'; +import { RhUiNotificationFillIcon } from '@patternfly/react-icons/dist/esm/icons/rh-ui-notification-fill-icon'; +import { RhUiEditFillIcon } from '@patternfly/react-icons/dist/esm/icons/rh-ui-edit-fill-icon'; +import { RhUiRedoIcon } from '@patternfly/react-icons/dist/esm/icons/rh-ui-redo-icon'; +import { RhUiServerUploadFillIcon } from '@patternfly/react-icons/dist/esm/icons/rh-ui-server-upload-fill-icon'; +import { RhUiSettingsFillIcon } from '@patternfly/react-icons/dist/esm/icons/rh-ui-settings-fill-icon'; +import { RhUiTaskFillIcon } from '@patternfly/react-icons/dist/esm/icons/rh-ui-task-fill-icon'; +import { RhUiUndoIcon } from '@patternfly/react-icons/dist/esm/icons/rh-ui-undo-icon'; +import { RhMicronsCloseIcon } from '@patternfly/react-icons/dist/esm/icons/rh-microns-close-icon'; +import PFIconLogoColor from '../UI/PF-IconLogo-Color.svg'; +import userAvatar from '../Messages/user_avatar.svg'; +import '@patternfly/react-core/dist/styles/base.css'; +import '@patternfly/chatbot/dist/css/main.css'; + +const sampleCode = `apiVersion: v1 +kind: ConfigMap +metadata: + name: canvas-demo +data: + greeting: Hello, how can I help you today? +`; + +const initialMessages = [ + { + id: '1', + role: 'bot', + content: 'Text message from the bot. This message can wrap several lines.', + name: 'Bot', + timestamp: '1:30 PM' + }, + { + id: '2', + role: 'user', + content: 'Canvas mode enabled - I want to live edit code', + name: 'You', + avatar: userAvatar, + avatarProps: { isBordered: true }, + timestamp: '1:30 PM' + }, + { + id: '3', + role: 'bot', + content: 'The canvas mode text editor is ready. Edit, share or ask me to change something for you.', + name: 'Bot', + timestamp: '1:30 PM', + attachments: [{ name: 'canvas mode.yaml', id: 'canvas-mode' }] + } +]; + +const conversations = [ + { id: '1', text: 'Canvas mode enabled - I want to live edit code' }, + { id: '2', text: 'Review deployment options' } +]; + +interface PlainTextSettingsFormProps { + fields: { id: string; label: string; field: ReactNode }[]; +} + +const PlainTextSettingsForm = ({ fields }: PlainTextSettingsFormProps) => ( +
+
+ {fields.map(({ id, label, field }) => ( +
+
+ {label} +
+ {field} +
+ ))} +
+
+); + +const hamburgerHoverStyles = ` + .pf-chatbot__canvas-docked-nav .pf-v6-c-masthead__logo.pf-m-compact { + display: revert; + } + + .pf-chatbot__canvas-docked-nav .pf-v6-c-nav.pf-m-docked .pf-v6-c-nav__link-text { + display: none; + } + + .pf-chatbot__canvas-history-toggle.pf-v6-c-button.pf-m-hamburger:is(:hover, :focus-visible) { + --pf-v6-c-button--hamburger-icon--top--path: var(--pf-v6-c-button--hamburger-icon--top--collapse--path); + --pf-v6-c-button--hamburger-icon--arrow--path: var(--pf-v6-c-button--hamburger-icon--arrow--collapse--path); + --pf-v6-c-button--hamburger-icon--bottom--path: var(--pf-v6-c-button--hamburger-icon--bottom--collapse--path); + --pf-v6-c-button__icon--TransitionDelay: 0s; + --pf-v6-c-button__icon--ScaleX: var(--pf-v6-c-button--m-hamburger__icon--m-expand--ScaleX); + } + + .pf-chatbot__canvas-history-toggle.pf-v6-c-button.pf-m-hamburger[aria-expanded="true"]:is(:hover, :focus-visible) { + --pf-v6-c-button__icon--ScaleX: var(--pf-v6-c-button--m-hamburger__icon--m-collapse--ScaleX); + } +`; + +const SettingsPanel = ({ onClose }) => { + const [openDropdown, setOpenDropdown] = useState(); + const [theme, setTheme] = useState('System'); + const [language, setLanguage] = useState('Auto-detect'); + const [voice, setVoice] = useState('Bot'); + const [isAnalyticsShared, setIsAnalyticsShared] = useState(true); + const closeButtonRef = useRef(null); + + useEffect(() => { + window.requestAnimationFrame(() => closeButtonRef.current?.focus()); + }, []); + + const dropdownField = (id, label, value, options, onSelect) => ( + { + onSelect(String(selectedValue)); + setOpenDropdown(undefined); + }} + onOpenChange={(isOpen: boolean) => setOpenDropdown(isOpen ? id : undefined)} + shouldFocusToggleOnSelect + shouldFocusFirstItemOnOpen + toggle={(toggleRef) => ( + setOpenDropdown(openDropdown === id ? undefined : id)} + isExpanded={openDropdown === id} + aria-label={`${value}, ${label}`} + > + {value} + + )} + > + + {options.map((option) => ( + + {option} + + ))} + + + ); + + const fields = [ + { + id: 'theme', + label: 'Theme', + field: dropdownField('theme', 'Theme', theme, ['System', 'Light', 'Dark'], setTheme) + }, + { + id: 'language', + label: 'Language', + field: dropdownField('language', 'Language', language, ['Auto-detect', 'English'], setLanguage) + }, + { id: 'voice', label: 'Voice', field: dropdownField('voice', 'Voice', voice, ['Bot', 'User'], setVoice) }, + { + id: 'analytics', + label: 'Share analytics', + field: ( + setIsAnalyticsShared(checked)} + /> + ) + }, + { id: 'archived-chat', label: 'Archived chats', field: }, + { id: 'archive-all', label: 'Archived all chats', field: }, + { + id: 'delete-all', + label: 'Delete all chats', + field: ( + + ) + } + ]; + + return ( +
+
+ + + Settings + + +
+
+ ); +}; + +export const CanvasWithDockedNavDemo = () => { + const [messages, setMessages] = useState(initialMessages); + const [code, setCode] = useState(sampleCode); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [areSettingsOpen, setAreSettingsOpen] = useState(false); + const [isCanvasOpen, setIsCanvasOpen] = useState(true); + const [showCanvasLabel, setShowCanvasLabel] = useState(true); + const [isGeneratedAiPopoverOpen, setIsGeneratedAiPopoverOpen] = useState(false); + const [isAttachMenuOpen, setIsAttachMenuOpen] = useState(false); + const [isModelSelectOpen, setIsModelSelectOpen] = useState(false); + const [selectedModel, setSelectedModel] = useState('Granite 7B'); + const [announcement, setAnnouncement] = useState(); + const [isSendButtonDisabled, setIsSendButtonDisabled] = useState(false); + const newChatRef = useRef(null); + const settingsRef = useRef(null); + const messageInputRef = useRef(null); + const canvasSectionRef = useRef(null); + const scrollToBottomRef = useRef(null); + const messageActionsRef = useRef(null); + const canvasCloseButtonRef = useRef(null); + const focusCanvasAfterAttachSelection = useRef(false); + + const closeSettings = () => { + setAreSettingsOpen(false); + window.requestAnimationFrame(() => settingsRef.current?.focus()); + }; + + const focusMessageInput = () => { + window.requestAnimationFrame(() => messageInputRef.current?.focus()); + }; + + const focusMessageActions = () => { + window.requestAnimationFrame(() => messageActionsRef.current?.focus()); + }; + + const focusCanvasSection = () => { + window.requestAnimationFrame(() => { + canvasSectionRef.current?.focus(); + focusCanvasAfterAttachSelection.current = false; + }); + }; + + const handleGeneratedAiLabelKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + event.currentTarget.click(); + } + }; + const { open, getInputProps } = useDropzone({ + multiple: true, + // eslint-disable-next-line no-console + onDropAccepted: () => console.log('fileUploaded') + }); + + const startNewChat = () => { + setMessages([]); + setIsDrawerOpen(false); + setAreSettingsOpen(false); + }; + + const openCanvas = () => { + setShowCanvasLabel(true); + setIsCanvasOpen(true); + focusCanvasSection(); + }; + + const closeCanvasMode = () => { + setShowCanvasLabel(false); + setIsCanvasOpen(false); + focusMessageInput(); + }; + + const sendMessage = (content) => { + const date = new Date(); + const userMessage = { + id: Date.now().toString(), + role: 'user', + content, + name: 'You', + avatar: userAvatar, + avatarProps: { isBordered: true }, + timestamp: `${date.toLocaleDateString()} ${date.toLocaleTimeString()}` + }; + const newMessages = [...messages, userMessage]; + newMessages.push({ + id: `${Date.now()}-loading`, + role: 'bot', + name: 'Bot', + isLoading: true, + timestamp: `${date.toLocaleDateString()} ${date.toLocaleTimeString()}` + }); + setMessages(newMessages); + setAnnouncement(`Message from You: ${content}. Message from Bot is loading.`); + setIsSendButtonDisabled(true); + setTimeout(() => { + const loadedMessages = [...newMessages]; + loadedMessages.pop(); + loadedMessages.push({ + id: `${Date.now()}-bot`, + role: 'bot', + content: 'API response from Bot goes here', + name: 'Bot', + isLoading: false, + actions: { + // eslint-disable-next-line no-console + positive: { onClick: () => console.log('Good response') }, + // eslint-disable-next-line no-console + negative: { onClick: () => console.log('Bad response') }, + // eslint-disable-next-line no-console + copy: { onClick: () => console.log('Copy') }, + // eslint-disable-next-line no-console + download: { onClick: () => console.log('Download') }, + // eslint-disable-next-line no-console + listen: { onClick: () => console.log('Listen') } + }, + timestamp: date.toLocaleString() + }); + setMessages(loadedMessages); + setAnnouncement('Message from Bot: API response from Bot goes here'); + setIsSendButtonDisabled(false); + }, 5000); + }; + + const attachMenuItems = ( + <> + + }> + Alerts + + }> + Events + + }> + Logs + + + + + } onClick={open}> + Upload from computer + + }> + {`${isCanvasOpen ? 'Disable' : 'Enable'} canvas mode`} + + + + ); + + const dockedNav = ( + <> + +
+ + + + setIsDrawerOpen((open) => !open)} + /> + + + + + + + + + + + + + + + +
+ + ); + + const editorControls = [ + { icon: , label: 'Undo' }, + { icon: , label: 'Redo' }, + { icon: , label: 'Export' } + ].map(({ icon, label }) => ( + + )); + + const canvasPanel = ( + +
+ + + + + Edit code + + + + setIsGeneratedAiPopoverOpen(true)} + onHide={() => setIsGeneratedAiPopoverOpen(false)} + > + + + + + + +
+
+
+
+
+
+ +
+
+
+
+ ); + + return ( + <> + + +
+ setIsDrawerOpen((open) => !open)} + activeItemId="1" + conversations={{ Today: conversations }} + onNewChat={startNewChat} + drawerCloseButtonProps={{ 'aria-label': 'Close chat history' }} + drawerContent={ +
+ {areSettingsOpen ? ( + + ) : ( + + + +
+ + + {messages.map((message) => ( + ({ + ...attachment, + onClick: openCanvas + }))} + /> + ))} +
+
+
+ + { + if (!isOpen) { + if (focusCanvasAfterAttachSelection.current) { + focusCanvasAfterAttachSelection.current = false; + } else { + focusMessageActions(); + } + } + }, + onAttachMenuSelect: (_event, value) => { + if (value === 'canvas') { + if (showCanvasLabel) { + closeCanvasMode(); + } else { + focusCanvasAfterAttachSelection.current = true; + openCanvas(); + setIsAttachMenuOpen(false); + return; + } + } + setIsAttachMenuOpen(false); + focusMessageActions(); + } + }} + buttonProps={{ + attach: { + innerRef: messageActionsRef, + icon: , + tooltipContent: 'Message actions', + 'aria-label': 'Message actions' + } + }} + additionalActions={ + <> + {showCanvasLabel && ( + + )} + + + } + /> + + +
+
+
+
+ )} +
+ } + /> +
+
+ + ); +}; diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Chatbot.md b/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Chatbot.md index 92924d61..96d2cb19 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Chatbot.md +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Chatbot.md @@ -37,6 +37,7 @@ import MessageBox from '@patternfly/chatbot/dist/dynamic/MessageBox'; import Message from '@patternfly/chatbot/dist/dynamic/Message'; import Compare from '@patternfly/chatbot/dist/dynamic/Compare'; import ChatbotConversationHistoryNav from '@patternfly/chatbot/dist/dynamic/ChatbotConversationHistoryNav'; +import SettingsForm from '@patternfly/chatbot/dist/dynamic/Settings'; import ChatbotHeader, { ChatbotHeaderMain, @@ -55,9 +56,12 @@ import RhUiAddIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-add-icon'; import RhUiClipboardIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-clipboard-icon'; import RhUiCodeIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-code-icon'; import RhUiUploadIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-upload-icon'; -import { BarsIcon } from '@patternfly/react-icons/dist/esm/icons/bars-icon'; -import { RhUiBuildFillIcon, RhUiCopyFillIcon } from '@patternfly/react-icons'; +import RhUiBuildFillIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-build-fill-icon'; +import RhUiCopyFillIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-copy-fill-icon'; import RhUiImageFillIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-image-fill-icon'; +import RhUiEditFillIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-edit-fill-icon'; +import RhUiSettingsFillIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-settings-fill-icon'; +import RhMicronsCloseIcon from '@patternfly/react-icons/dist/esm/icons/rh-microns-close-icon'; import { Button, Divider, @@ -188,6 +192,14 @@ This demo showcases how the ChatBot can be rendered in different display modes t ``` +### Fullscreen ChatBot with docked navigation + +This demo shows a headerless fullscreen ChatBot with a vertical navigation rail for switching between chat history, a new chat, and settings. + +```js file="./FullscreenDockedNav.tsx" isFullscreen + +``` + ### Comparing ChatBots To let users compare how different ChatBots respond to the same prompt, you can add multiple ChatBots within the same window. The following demo illustrates a comparison view pattern that allows users to toggle between different conversations in a single ChatBot window. diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/FullscreenDockedNav.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/FullscreenDockedNav.tsx new file mode 100644 index 00000000..0775d88c --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/FullscreenDockedNav.tsx @@ -0,0 +1,385 @@ +import { FunctionComponent, ReactNode, useEffect, useRef, useState } from 'react'; +import { + Avatar, + Brand, + Button, + Divider, + Dropdown, + DropdownItem, + DropdownList, + Masthead, + MastheadBrand, + MastheadContent, + MastheadLogo, + MastheadMain, + MastheadToggle, + Flex, + MenuToggle, + Nav, + NavItem, + NavList, + PageToggleButton, + Switch, + Title, + Tooltip +} from '@patternfly/react-core'; +import Chatbot, { ChatbotDisplayMode } from '@patternfly/chatbot/dist/dynamic/Chatbot'; +import ChatbotContent from '@patternfly/chatbot/dist/dynamic/ChatbotContent'; +import ChatbotWelcomePrompt from '@patternfly/chatbot/dist/dynamic/ChatbotWelcomePrompt'; +import ChatbotFooter, { ChatbotFootnote } from '@patternfly/chatbot/dist/dynamic/ChatbotFooter'; +import MessageBar from '@patternfly/chatbot/dist/dynamic/MessageBar'; +import MessageBox from '@patternfly/chatbot/dist/dynamic/MessageBox'; +import Message, { MessageProps } from '@patternfly/chatbot/dist/dynamic/Message'; +import ChatbotConversationHistoryNav from '@patternfly/chatbot/dist/dynamic/ChatbotConversationHistoryNav'; +import { RhUiEditFillIcon } from '@patternfly/react-icons/dist/esm/icons/rh-ui-edit-fill-icon'; +import { RhUiSettingsFillIcon } from '@patternfly/react-icons/dist/esm/icons/rh-ui-settings-fill-icon'; +import { RhMicronsCloseIcon } from '@patternfly/react-icons/dist/esm/icons/rh-microns-close-icon'; +import PFIconLogoColor from '../UI/PF-IconLogo-Color.svg'; +import userAvatar from '../Messages/user_avatar.svg'; +import '@patternfly/react-core/dist/styles/base.css'; +import '@patternfly/chatbot/dist/css/main.css'; + +const initialMessages: MessageProps[] = [ + { + id: '1', + role: 'user', + content: 'Show me how a fullscreen chatbot can use docked navigation.', + name: 'You', + avatar: userAvatar, + timestamp: new Date().toLocaleString(), + avatarProps: { isBordered: true } + }, + { + id: '2', + role: 'bot', + content: 'Use the rail on the left to switch between the conversation, history, and a new chat.', + name: 'Bot', + timestamp: new Date().toLocaleString() + } +]; + +const welcomePrompts = [ + { + title: 'Topic 1', + message: 'Helpful prompt for Topic 1' + }, + { + title: 'Topic 2', + message: 'Helpful prompt for Topic 2' + } +]; + +const conversations = [ + { id: '1', text: 'Fullscreen chatbot navigation' }, + { id: '2', text: 'Review deployment options' } +]; + +interface PlainTextSettingsFormProps { + fields: { id: string; label: string; field: ReactNode }[]; +} + +const PlainTextSettingsForm = ({ fields }: PlainTextSettingsFormProps) => ( +
+
+ {fields.map(({ id, label, field }) => ( +
+
+ {label} +
+ {field} +
+ ))} +
+
+); + +const hamburgerHoverStyles = ` + .pf-chatbot__canvas-docked-nav .pf-v6-c-masthead__logo.pf-m-compact { + display: revert; + } + + .pf-chatbot__canvas-docked-nav .pf-v6-c-nav.pf-m-docked .pf-v6-c-nav__link-text { + display: none; + } + + .pf-chatbot__canvas-history-toggle.pf-v6-c-button.pf-m-hamburger:is(:hover, :focus-visible) { + --pf-v6-c-button--hamburger-icon--top--path: var(--pf-v6-c-button--hamburger-icon--top--collapse--path); + --pf-v6-c-button--hamburger-icon--arrow--path: var(--pf-v6-c-button--hamburger-icon--arrow--collapse--path); + --pf-v6-c-button--hamburger-icon--bottom--path: var(--pf-v6-c-button--hamburger-icon--bottom--collapse--path); + --pf-v6-c-button__icon--TransitionDelay: 0s; + --pf-v6-c-button__icon--ScaleX: var(--pf-v6-c-button--m-hamburger__icon--m-expand--ScaleX); + } + + .pf-chatbot__canvas-history-toggle.pf-v6-c-button.pf-m-hamburger[aria-expanded="true"]:is(:hover, :focus-visible) { + --pf-v6-c-button__icon--ScaleX: var(--pf-v6-c-button--m-hamburger__icon--m-collapse--ScaleX); + } +`; + +const SettingsPanel = ({ onClose }) => { + const [openDropdown, setOpenDropdown] = useState(); + const [theme, setTheme] = useState('System'); + const [language, setLanguage] = useState('Auto-detect'); + const [voice, setVoice] = useState('Bot'); + const [isAnalyticsShared, setIsAnalyticsShared] = useState(true); + const closeButtonRef = useRef(null); + + useEffect(() => { + window.requestAnimationFrame(() => closeButtonRef.current?.focus()); + }, []); + + const dropdownField = (id, label, value, options, onSelect) => ( + { + onSelect(String(selectedValue)); + setOpenDropdown(undefined); + }} + onOpenChange={(isOpen: boolean) => setOpenDropdown(isOpen ? id : undefined)} + shouldFocusToggleOnSelect + shouldFocusFirstItemOnOpen + toggle={(toggleRef) => ( + setOpenDropdown(openDropdown === id ? undefined : id)} + isExpanded={openDropdown === id} + aria-label={`${value}, ${label}`} + > + {value} + + )} + > + + {options.map((option) => ( + + {option} + + ))} + + + ); + + const fields = [ + { + id: 'theme', + label: 'Theme', + field: dropdownField('theme', 'Theme', theme, ['System', 'Light', 'Dark'], setTheme) + }, + { + id: 'language', + label: 'Language', + field: dropdownField('language', 'Language', language, ['Auto-detect', 'English'], setLanguage) + }, + { id: 'voice', label: 'Voice', field: dropdownField('voice', 'Voice', voice, ['Bot', 'User'], setVoice) }, + { + id: 'analytics', + label: 'Share analytics', + field: ( + setIsAnalyticsShared(checked)} + /> + ) + }, + { id: 'archived-chat', label: 'Archived chats', field: }, + { id: 'archive-all', label: 'Archived all chats', field: }, + { + id: 'delete-all', + label: 'Delete all chats', + field: ( + + ) + } + ]; + + return ( +
+
+ + + Settings + + +
+
+ ); +}; + +export const FullscreenDockedNav: FunctionComponent = () => { + const [messages, setMessages] = useState(initialMessages); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [areSettingsOpen, setAreSettingsOpen] = useState(false); + const [announcement, setAnnouncement] = useState(); + const newChatRef = useRef(null); + const settingsRef = useRef(null); + + const closeSettings = () => { + setAreSettingsOpen(false); + window.requestAnimationFrame(() => settingsRef.current?.focus()); + }; + + const startNewChat = () => { + setMessages([]); + setIsDrawerOpen(false); + setAreSettingsOpen(false); + }; + + const sendMessage = (content: string) => { + const message: MessageProps = { + id: Date.now().toString(), + role: 'user', + content, + name: 'You', + avatar: userAvatar, + timestamp: new Date().toLocaleString(), + avatarProps: { isBordered: true } + }; + setMessages((currentMessages) => [...currentMessages, message]); + setAnnouncement(`Message from You: ${content}`); + }; + + const dockedNav = ( + <> + +
+ + + + setIsDrawerOpen((open) => !open)} + /> + + + + + + + + + + + + + + + +
+ + ); + + return ( + +
+ setIsDrawerOpen((open) => !open)} + conversations={{ Today: conversations }} + activeItemId="1" + onNewChat={startNewChat} + drawerCloseButtonProps={{ 'aria-label': 'Close chat history' }} + drawerContent={ +
+ {areSettingsOpen ? ( + + ) : ( +
+ + + + {messages.map((message) => ( + + ))} + + + + + + +
+ )} +
+ } + /> +
+
+ ); +}; diff --git a/packages/module/src/Canvas/Canvas.scss b/packages/module/src/Canvas/Canvas.scss index 7fb3ef27..43b593f1 100644 --- a/packages/module/src/Canvas/Canvas.scss +++ b/packages/module/src/Canvas/Canvas.scss @@ -17,6 +17,10 @@ --pf-chatbot__canvas--BackgroundColor: var(--pf-t--global--background--color--glass--primary--default); } +.pf-chatbot__canvas-docked-nav { + border-inline-end: var(--pf-t--global--border--width--regular) solid var(--pf-t--global--border--color--subtle); +} + // Drawer // ---------------------------------------------------------------------------- .pf-chatbot__canvas-drawer { @@ -56,6 +60,14 @@ min-height: 0; } +.pf-chatbot__canvas-chat-content { + padding-block-start: var(--pf-t--global--spacer--lg); +} + +.pf-chatbot__canvas-chat-footer { + padding-block-end: var(--pf-t--global--spacer--md); +} + // Panel // ---------------------------------------------------------------------------- .pf-chatbot__canvas-section { diff --git a/packages/module/src/Chatbot/Chatbot.scss b/packages/module/src/Chatbot/Chatbot.scss index 7bc17af8..ccee03c9 100644 --- a/packages/module/src/Chatbot/Chatbot.scss +++ b/packages/module/src/Chatbot/Chatbot.scss @@ -141,6 +141,19 @@ } } +// ============================================================================ +// Chatbot With Docked Nav +// ============================================================================ +.pf-chatbot--docked-nav { + .pf-chatbot-container { + flex-direction: row; + } + + .pf-chatbot__header-container { + display: none; + } +} + .pf-chatbot-container--embedded { min-height: 100%; } @@ -180,4 +193,3 @@ border: unset; } } - diff --git a/packages/module/src/Chatbot/Chatbot.test.tsx b/packages/module/src/Chatbot/Chatbot.test.tsx index 147a8a55..228a1ac3 100644 --- a/packages/module/src/Chatbot/Chatbot.test.tsx +++ b/packages/module/src/Chatbot/Chatbot.test.tsx @@ -36,4 +36,15 @@ describe('Chatbot', () => { ); expect(screen.getByTestId('chatbot')).toHaveClass('pf-m-compact'); }); + + it('should render docked navigation and apply the docked navigation class', () => { + render( + Navigation}> + Chatbot Content + + ); + + expect(screen.getByTestId('chatbot')).toHaveClass('pf-chatbot--docked-nav'); + expect(screen.getByRole('navigation', { name: 'Chatbot navigation' })).toBeInTheDocument(); + }); }); diff --git a/packages/module/src/Chatbot/Chatbot.tsx b/packages/module/src/Chatbot/Chatbot.tsx index f70325d6..3e53e02b 100644 --- a/packages/module/src/Chatbot/Chatbot.tsx +++ b/packages/module/src/Chatbot/Chatbot.tsx @@ -20,6 +20,8 @@ export interface ChatbotProps { ariaLabel?: string; /** Density of information within the ChatBot */ isCompact?: boolean; + /** Optional navigation rail displayed before the chatbot content */ + dockedNav?: React.ReactNode; } export enum ChatbotDisplayMode { @@ -38,10 +40,11 @@ const ChatbotBase: FunctionComponent = ({ innerRef, ariaLabel, isCompact, + dockedNav, ...props }: ChatbotProps) => (
{/* Ref is intended for use with skip to chatbot links, etc. */} @@ -52,6 +55,7 @@ const ChatbotBase: FunctionComponent = ({ tabIndex={-1} ref={innerRef} > + {dockedNav && } {children} ) : undefined} diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx index e7634ba7..0d0c134f 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx @@ -161,17 +161,23 @@ describe('ChatbotConversationHistoryNav', () => { }); it('should close the drawer when escape key is pressed', async () => { - render( - - ); + const HistoryNavigationTest = () => { + const [isDrawerOpen, setIsDrawerOpen] = useState(true); + + return ( + + ); + }; + + render(); fireEvent.keyDown(screen.getByPlaceholderText(/Search/i), { key: 'Escape', @@ -180,8 +186,8 @@ describe('ChatbotConversationHistoryNav', () => { charCode: 27 }); - waitFor(() => { - expect(screen.queryByText('ChatBot documentation')).not.toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByRole('dialog', { hidden: true })).toHaveAttribute('hidden'); }); }); diff --git a/packages/module/src/MessageBar/MessageBar.test.tsx b/packages/module/src/MessageBar/MessageBar.test.tsx index f9ecd039..07a278ae 100644 --- a/packages/module/src/MessageBar/MessageBar.test.tsx +++ b/packages/module/src/MessageBar/MessageBar.test.tsx @@ -227,6 +227,23 @@ describe('Message bar', () => { await userEvent.click(attachButton); expect(attachToggleClickSpy).toHaveBeenCalledTimes(1); }); + it('can toggle attach menu without an optional toggle callback', async () => { + const setIsAttachMenuOpen = jest.fn(); + render( + + ); + + await userEvent.click(screen.getByRole('button', { name: 'Attach' })); + + expect(setIsAttachMenuOpen).toHaveBeenCalledWith(true); + }); it('can pass searchInputProps to search input in AttachMenu', () => { render( void; + onAttachMenuToggleClick?: () => void; /** A callback for when the input value in the menu changes. */ onAttachMenuInputChange?: (value: string) => void; /** Function callback called when user selects item in menu. */ @@ -368,7 +368,7 @@ export const MessageBarBase: FunctionComponent = ({ const handleAttachMenuToggle = () => { attachMenuProps?.setIsAttachMenuOpen && attachMenuProps?.setIsAttachMenuOpen(!attachMenuProps?.isAttachMenuOpen); - attachMenuProps?.onAttachMenuToggleClick(); + attachMenuProps?.onAttachMenuToggleClick?.(); }; const handleSpeechRecognition = (message) => { @@ -525,7 +525,9 @@ export const MessageBarBase: FunctionComponent = ({ {...(attachMenuProps && { handleTextInputChange: attachMenuProps.onAttachMenuInputChange })} popperProps={{ direction: 'up', distance: 8 }} searchInputPlaceholder={attachMenuProps?.attachMenuInputPlaceholder} - {...attachMenuProps} + menuSearchProps={attachMenuProps?.menuSearchProps} + menuSearchInputProps={attachMenuProps?.menuSearchInputProps} + searchInputProps={attachMenuProps?.searchInputProps} /> ); }