diff --git a/packages/module/patternfly-docs/content/extensions/virtual-assistant/examples/Chatbot/Chatbot.md b/packages/module/patternfly-docs/content/extensions/virtual-assistant/examples/Chatbot/Chatbot.md index 10a05a0de..c73cb88f5 100644 --- a/packages/module/patternfly-docs/content/extensions/virtual-assistant/examples/Chatbot/Chatbot.md +++ b/packages/module/patternfly-docs/content/extensions/virtual-assistant/examples/Chatbot/Chatbot.md @@ -25,6 +25,7 @@ import ChatbotContent from '@patternfly/virtual-assistant/dist/dynamic/ChatbotCo import ChatbotWelcomePrompt from '@patternfly/virtual-assistant/dist/dynamic/ChatbotWelcomePrompt'; import MessageBox from '@patternfly/virtual-assistant/dist/dynamic/MessageBox'; import Message from '@patternfly/virtual-assistant/dist/dynamic/Message'; +import ChatbotToggle from '@patternfly/virtual-assistant/dist/dynamic/ChatbotToggle'; ### Container @@ -71,3 +72,14 @@ To provide users with a more specific direction, you can also include optional w ```js file="./ChatbotWelcomePrompt.tsx" ``` + +### Skip to content + +To provide page context, we recommend using a "skip to chatbot" button. This allows you to skip past other content on the page, directly to the chatbot content, using a [PatternFly skip to content component](/components/skip-to-content). To display this button, you must tab into the main window. +
+
+When using default or docked modes, we recommend putting focus on the toggle if the chatbot is closed, and the chatbot when it is open. For fullscreen and embedded, we recommend putting the focus on the first focusable item in the chatbot, such as a menu toggle. This can be seen in our more fully-featured demos for the [default, embedded, and fullscreen chatbot](patternfly-ai/chatbot/chatbot-container/react-demos/basic-chatbot) and the [embedded chatbot](/patternfly-ai/chatbot/chatbot-container/react-demos/embedded-chatbot). + +```js file="./SkipToContent.tsx" isFullscreen + +``` diff --git a/packages/module/patternfly-docs/content/extensions/virtual-assistant/examples/Chatbot/SkipToContent.tsx b/packages/module/patternfly-docs/content/extensions/virtual-assistant/examples/Chatbot/SkipToContent.tsx new file mode 100644 index 000000000..e3fbefd9f --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/virtual-assistant/examples/Chatbot/SkipToContent.tsx @@ -0,0 +1,40 @@ +import React from 'react'; + +import { SkipToContent } from '@patternfly/react-core'; +import ChatbotToggle from '@patternfly/virtual-assistant/dist/dynamic/ChatbotToggle'; +import Chatbot, { ChatbotDisplayMode } from '@patternfly/virtual-assistant/dist/dynamic/Chatbot'; + +export const ChatbotDemo: React.FunctionComponent = () => { + const [chatbotVisible, setChatbotVisible] = React.useState(true); + const toggleRef = React.useRef(null); + const chatbotRef = React.useRef(null); + const displayMode = ChatbotDisplayMode.default; + + const handleSkipToContent = (e) => { + e.preventDefault(); + if (!chatbotVisible && toggleRef.current) { + toggleRef.current.focus(); + } + if (chatbotVisible && chatbotRef.current) { + chatbotRef.current.focus(); + } + }; + + return ( + <> + + Skip to chatbot + + setChatbotVisible(!chatbotVisible)} + id="chatbot-toggle" + ref={toggleRef} + /> + +   + + + ); +}; diff --git a/packages/module/patternfly-docs/content/extensions/virtual-assistant/examples/demos/Chatbot.md b/packages/module/patternfly-docs/content/extensions/virtual-assistant/examples/demos/Chatbot.md index 499594394..2c27a48f2 100644 --- a/packages/module/patternfly-docs/content/extensions/virtual-assistant/examples/demos/Chatbot.md +++ b/packages/module/patternfly-docs/content/extensions/virtual-assistant/examples/demos/Chatbot.md @@ -77,6 +77,8 @@ This demo displays a basic chatbot, which includes: 6. A [``](/patternfly-ai/chatbot/chatbot-conversation-history) toggled open and closed by the ` in the ``. +7. A "skip to chatbot" button that allows you to skip to the chatbot content via the [PatternFly skip to content component](/components/skip-to-content). To display this button you must tab into the main window. + ```js file="./Chatbot.tsx" isFullscreen ``` @@ -85,7 +87,7 @@ This demo displays a basic chatbot, which includes: This demo displays an embedded chatbot. Embedded chatbots are meant to be placed within a page in your product. This demo includes: -1. A [PatternFly page](/components/page) with a sidebar and masthead +1. A [PatternFly page](/components/page) with a sidebar, "skip to chatbot" button, and masthead. To display the "skip to chatbot" button you must tab into the main window. 2. A [``](/patternfly-ai/chatbot/chatbot-container) container. 3. A [``](/patternfly-ai/chatbot/chatbot-header) with all built sub-components laid out, including a `` 4. [`` and ``](/patternfly-ai/chatbot/chatbot-container#content-and-message-box) with: diff --git a/packages/module/patternfly-docs/content/extensions/virtual-assistant/examples/demos/Chatbot.tsx b/packages/module/patternfly-docs/content/extensions/virtual-assistant/examples/demos/Chatbot.tsx index c40667a3f..498568682 100644 --- a/packages/module/patternfly-docs/content/extensions/virtual-assistant/examples/demos/Chatbot.tsx +++ b/packages/module/patternfly-docs/content/extensions/virtual-assistant/examples/demos/Chatbot.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Bullseye, Brand, DropdownList, DropdownItem, DropdownGroup } from '@patternfly/react-core'; +import { Bullseye, Brand, DropdownList, DropdownItem, DropdownGroup, SkipToContent } from '@patternfly/react-core'; import ChatbotToggle from '@patternfly/virtual-assistant/dist/dynamic/ChatbotToggle'; import Chatbot, { ChatbotDisplayMode } from '@patternfly/virtual-assistant/dist/dynamic/Chatbot'; @@ -175,6 +175,9 @@ export const ChatbotDemo: React.FunctionComponent = () => { ); const [announcement, setAnnouncement] = React.useState(); const scrollToBottomRef = React.useRef(null); + const toggleRef = React.useRef(null); + const chatbotRef = React.useRef(null); + const historyRef = React.useRef(null); // Autu-scrolls to the latest message React.useEffect(() => { @@ -299,14 +302,46 @@ export const ChatbotDemo: React.FunctionComponent = () => { ); + const handleSkipToContent = (e) => { + e.preventDefault(); + /* eslint-disable indent */ + switch (displayMode) { + case ChatbotDisplayMode.default: + if (!chatbotVisible && toggleRef.current) { + toggleRef.current.focus(); + } + if (chatbotVisible && chatbotRef.current) { + chatbotRef.current.focus(); + } + break; + + case ChatbotDisplayMode.docked: + if (chatbotRef.current) { + chatbotRef.current.focus(); + } + break; + default: + if (historyRef.current) { + historyRef.current.focus(); + } + break; + } + /* eslint-enable indent */ + }; + return ( <> + + Skip to chatbot + setChatbotVisible(!chatbotVisible)} + id="chatbot-toggle" + ref={toggleRef} /> - + { @@ -337,7 +372,11 @@ export const ChatbotDemo: React.FunctionComponent = () => { <> - setIsDrawerOpen(!isDrawerOpen)} /> + setIsDrawerOpen(!isDrawerOpen)} + /> { const [isSidebarOpen, setIsSidebarOpen] = React.useState(false); const [announcement, setAnnouncement] = React.useState(); const scrollToBottomRef = React.useRef(null); + const historyRef = React.useRef(null); + const displayMode = ChatbotDisplayMode.embedded; // Autu-scrolls to the latest message React.useEffect(() => { @@ -320,8 +323,22 @@ export const EmbeddedChatbotDemo: React.FunctionComponent = () => { ); + const skipToChatbot = (event: React.MouseEvent) => { + event.preventDefault(); + if (historyRef.current) { + historyRef.current.focus(); + } + }; + + const skipToContent = ( + /* You can also add a SkipToContent for your main content here */ + + Skip to chatbot + + ); + return ( - + { <> - setIsDrawerOpen(!isDrawerOpen)} /> + setIsDrawerOpen(!isDrawerOpen)} + /> {horizontalLogo} diff --git a/packages/module/patternfly-docs/generated/patternfly-ai/chatbot/chatbot-container/react/skip-to-content.png b/packages/module/patternfly-docs/generated/patternfly-ai/chatbot/chatbot-container/react/skip-to-content.png new file mode 100644 index 000000000..1ebf516ec Binary files /dev/null and b/packages/module/patternfly-docs/generated/patternfly-ai/chatbot/chatbot-container/react/skip-to-content.png differ diff --git a/packages/module/src/Chatbot/Chatbot.scss b/packages/module/src/Chatbot/Chatbot.scss index cc56d949c..6437e6489 100644 --- a/packages/module/src/Chatbot/Chatbot.scss +++ b/packages/module/src/Chatbot/Chatbot.scss @@ -13,7 +13,6 @@ border-radius: var(--pf-t--global--border--radius--medium); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); font-size: var(--pf-t--chatbot--font-size); - overflow: hidden; z-index: var(--pf-t--global--z-index--md); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; @@ -64,3 +63,27 @@ html.pf-chatbot-allow--docked { border-radius: 0; box-shadow: none; } + +.pf-chatbot-container { + height: 100%; + width: 100%; + display: flex; + flex-direction: column; + border-radius: var(--pf-t--global--border--radius--medium); + overflow: hidden; + + // Hide chatbot + &--hidden { + pointer-events: none; + } +} + +.pf-chatbot-container--embedded { + min-height: 100%; +} + +.pf-chatbot-container--docked, +.pf-chatbot-container--embedded, +.pf-chatbot-container--fullscreen { + border-radius: unset; +} diff --git a/packages/module/src/Chatbot/Chatbot.tsx b/packages/module/src/Chatbot/Chatbot.tsx index 7802e3436..ff4bd9a84 100644 --- a/packages/module/src/Chatbot/Chatbot.tsx +++ b/packages/module/src/Chatbot/Chatbot.tsx @@ -13,6 +13,10 @@ export interface ChatbotProps { isVisible?: boolean; /** Custom classname for the Chatbot component */ className?: string; + /** Ref applied to chatbot */ + innerRef?: React.Ref; + /** Custom aria label applied to focusable container */ + ariaLabel?: string; } export enum ChatbotDisplayMode { @@ -22,11 +26,14 @@ export enum ChatbotDisplayMode { fullscreen = 'fullscreen' } -export const Chatbot: React.FunctionComponent = ({ +const ChatbotBase: React.FunctionComponent = ({ children, displayMode = ChatbotDisplayMode.default, isVisible = true, - className + className, + innerRef, + ariaLabel, + ...props }: ChatbotProps) => { // Configure docked mode React.useEffect(() => { @@ -49,10 +56,26 @@ export const Chatbot: React.FunctionComponent = ({ variants={motionChatbot} initial="hidden" animate={isVisible ? 'visible' : 'hidden'} + {...props} > - {isVisible ? children : undefined} + {/* Ref is intended for use with skip to chatbot links, etc. */} + {/* Motion.div does not accept refs */} + {isVisible ? ( +
+ {children} +
+ ) : undefined} ); }; +const Chatbot = React.forwardRef((props: ChatbotProps, ref: React.Ref) => ( + +)); + export default Chatbot; diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss index 80052d1c5..fb23e7703 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss @@ -4,6 +4,7 @@ .pf-chatbot__history { .pf-chatbot__drawer-backdrop { position: absolute; + border-radius: var(--pf-t--global--border--radius--medium); } // Drawer input // ---------------------------------------------------------------------------- @@ -153,3 +154,13 @@ } } } + +.pf-chatbot--docked, +.pf-chatbot--embedded, +.pf-chatbot--fullscreen { + .pf-chatbot__history { + .pf-chatbot__drawer-backdrop { + border-radius: unset; + } + } +} diff --git a/packages/module/src/ChatbotFooter/ChatbotFooter.scss b/packages/module/src/ChatbotFooter/ChatbotFooter.scss index 15d974718..d8918c35c 100644 --- a/packages/module/src/ChatbotFooter/ChatbotFooter.scss +++ b/packages/module/src/ChatbotFooter/ChatbotFooter.scss @@ -10,6 +10,7 @@ display: flex; flex-direction: column; row-gap: var(--pf-chatbot__footer--RowGap); + position: relative; // this is so focus ring on parent chatbot doesn't include footer } .pf-chatbot__footer-container { padding: 0 var(--pf-t--global--spacer--lg) var(--pf-t--global--spacer--lg) var(--pf-t--global--spacer--lg); diff --git a/packages/module/src/ChatbotHeader/ChatbotHeader.scss b/packages/module/src/ChatbotHeader/ChatbotHeader.scss index 8e76f8f9c..095afbfdc 100644 --- a/packages/module/src/ChatbotHeader/ChatbotHeader.scss +++ b/packages/module/src/ChatbotHeader/ChatbotHeader.scss @@ -8,7 +8,7 @@ display: grid; grid-template-columns: 1fr auto; gap: var(--pf-t--global--spacer--sm); - position: relative; + position: relative; // this is so focus ring on parent chatbot doesn't include header background-color: var(--pf-t--chatbot--background); justify-content: space-between; padding: var(--pf-t--global--spacer--lg); diff --git a/packages/module/src/ChatbotHeader/ChatbotHeaderMenu.tsx b/packages/module/src/ChatbotHeader/ChatbotHeaderMenu.tsx index d1d18c90c..4ac5be10f 100644 --- a/packages/module/src/ChatbotHeader/ChatbotHeaderMenu.tsx +++ b/packages/module/src/ChatbotHeader/ChatbotHeaderMenu.tsx @@ -12,13 +12,16 @@ export interface ChatbotHeaderMenuProps { tooltipProps?: TooltipProps; /** Aria label for menu */ menuAriaLabel?: string; + /** Ref applied to menu */ + innerRef?: React.Ref; } -export const ChatbotHeaderMenu: React.FunctionComponent = ({ +const ChatbotHeaderMenuBase: React.FunctionComponent = ({ className, onMenuToggle, tooltipProps, - menuAriaLabel = 'Toggle menu' + menuAriaLabel = 'Toggle menu', + innerRef }: ChatbotHeaderMenuProps) => (
@@ -27,6 +30,7 @@ export const ChatbotHeaderMenu: React.FunctionComponent variant="plain" onClick={onMenuToggle} aria-label={menuAriaLabel} + ref={innerRef} icon={ @@ -37,4 +41,8 @@ export const ChatbotHeaderMenu: React.FunctionComponent
); -export default ChatbotHeaderMenu; +export const ChatbotHeaderMenu = React.forwardRef( + (props: ChatbotHeaderMenuProps, ref: React.Ref) => ( + + ) +); diff --git a/packages/module/src/ChatbotToggle/ChatbotToggle.tsx b/packages/module/src/ChatbotToggle/ChatbotToggle.tsx index 6a9d76b4d..f7692c912 100644 --- a/packages/module/src/ChatbotToggle/ChatbotToggle.tsx +++ b/packages/module/src/ChatbotToggle/ChatbotToggle.tsx @@ -1,15 +1,10 @@ // ============================================================================ // Chatbot Toggle // ============================================================================ - import React from 'react'; - -// Import PatternFly components import { Button, ButtonProps, Tooltip, TooltipProps, Icon } from '@patternfly/react-core'; import AngleDownIcon from '@patternfly/react-icons/dist/esm/icons/angle-down-icon'; -// Import Chatbot components - export interface ChatbotToggleProps extends ButtonProps { /** Contents of the tooltip applied to the toggle button */ toolTipLabel?: React.ReactNode; @@ -23,6 +18,8 @@ export interface ChatbotToggleProps extends ButtonProps { toggleButtonLabel?: string; /** An image displayed in the chatbot toggle when it is closed */ closedToggleIcon?: () => JSX.Element; + /** Ref applied to toggle */ + innerRef?: React.Ref; } const ChatIcon = () => ( @@ -44,13 +41,14 @@ const ChatIcon = () => ( ); -export const ChatbotToggle: React.FunctionComponent = ({ +const ChatbotToggleBase: React.FunctionComponent = ({ toolTipLabel, isChatbotVisible, onToggleChatbot, tooltipProps, toggleButtonLabel, closedToggleIcon: ClosedToggleIcon, + innerRef, ...props }: ChatbotToggleProps) => { // Configure icon @@ -66,6 +64,7 @@ export const ChatbotToggle: React.FunctionComponent = ({ onClick={onToggleChatbot} aria-expanded={isChatbotVisible} icon={{icon}} + ref={innerRef} {...props} > {/* Notification dot placeholder */} @@ -74,4 +73,8 @@ export const ChatbotToggle: React.FunctionComponent = ({ ); }; +const ChatbotToggle = React.forwardRef((props: ChatbotToggleProps, ref: React.Ref) => ( + +)); + export default ChatbotToggle; diff --git a/packages/module/src/MessageBox/MessageBox.tsx b/packages/module/src/MessageBox/MessageBox.tsx index f16555a0e..ed8e2b4e7 100644 --- a/packages/module/src/MessageBox/MessageBox.tsx +++ b/packages/module/src/MessageBox/MessageBox.tsx @@ -13,18 +13,27 @@ export interface MessageBoxProps extends React.HTMLProps { children: React.ReactNode; /** Custom classname for the MessageBox component */ className?: string; + /** Ref applied to message box */ + innerRef?: React.Ref; } -const MessageBox: React.FunctionComponent = ({ +const MessageBoxBase: React.FunctionComponent = ({ announcement, ariaLabel = 'Scrollable message log', children, + innerRef, className }: MessageBoxProps) => { const [atTop, setAtTop] = React.useState(false); const [atBottom, setAtBottom] = React.useState(true); const [isOverflowing, setIsOverflowing] = React.useState(false); - const messageBoxRef = React.useRef(null); + const defaultRef = React.useRef(null); + let messageBoxRef; + if (innerRef) { + messageBoxRef = innerRef; + } else { + messageBoxRef = defaultRef; + } // Configure handlers const handleScroll = React.useCallback(() => { @@ -83,7 +92,7 @@ const MessageBox: React.FunctionComponent = ({ tabIndex={0} aria-label={ariaLabel} className={`pf-chatbot__messagebox ${className ?? ''}`} - ref={messageBoxRef} + ref={innerRef ?? messageBoxRef} > {children}
@@ -95,4 +104,8 @@ const MessageBox: React.FunctionComponent = ({ ); }; +export const MessageBox = React.forwardRef((props: MessageBoxProps, ref: React.Ref) => ( + +)); + export default MessageBox; diff --git a/packages/module/src/ResponseActions/ResponseActions.test.tsx b/packages/module/src/ResponseActions/ResponseActions.test.tsx index 74a1d4326..85ef6b930 100644 --- a/packages/module/src/ResponseActions/ResponseActions.test.tsx +++ b/packages/module/src/ResponseActions/ResponseActions.test.tsx @@ -31,11 +31,11 @@ describe('ResponseActions', () => { it('should be able to change aria labels', () => { const actions = [ - { type: 'positive', ariaLabel: /Thumbs up/i }, - { type: 'negative', ariaLabel: /Thumbs down/i }, - { type: 'copy', ariaLabel: /Copy the message/i }, - { type: 'share', ariaLabel: /Share it with friends/i }, - { type: 'listen', ariaLabel: /Listen up/i } + { type: 'positive', ariaLabel: 'Thumbs up' }, + { type: 'negative', ariaLabel: 'Thumbs down' }, + { type: 'copy', ariaLabel: 'Copy the message' }, + { type: 'share', ariaLabel: 'Share it with friends' }, + { type: 'listen', ariaLabel: 'Listen up' } ]; actions.forEach(({ type, ariaLabel }) => { render();