diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotHeaderDrawerWithExpandableGroups.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotHeaderDrawerWithExpandableGroups.tsx new file mode 100644 index 00000000..86200f1b --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotHeaderDrawerWithExpandableGroups.tsx @@ -0,0 +1,104 @@ +import { useState, FunctionComponent } from 'react'; +import { ChatbotDisplayMode } from '@patternfly/chatbot/dist/dynamic/Chatbot'; +import ChatbotConversationHistoryNav, { + Conversation, + ConversationGroup +} from '@patternfly/chatbot/dist/dynamic/ChatbotConversationHistoryNav'; +import { Checkbox } from '@patternfly/react-core'; + +const pinnedChats: Conversation[] = [ + { + id: '1', + text: 'Red Hat products and services' + } +]; + +const recentChats: Conversation[] = [ + { + id: '2', + text: 'Enterprise Linux installation and setup' + }, + { + id: '3', + text: 'Troubleshoot system crash' + }, + { + id: '4', + text: 'Ansible security and updates' + }, + { + id: '5', + text: 'Red Hat certification' + }, + { + id: '6', + text: 'Lightspeed user documentation' + } +]; + +const savedPrompts: Conversation[] = [ + { + id: '7', + text: 'Summarize this document' + }, + { + id: '8', + text: 'Draft a release announcement' + } +]; + +const VISIBLE_CHAT_COUNT = 3; + +export const ChatbotHeaderDrawerWithExpandableGroupsDemo: FunctionComponent = () => { + const [isOpen, setIsOpen] = useState(true); + const [isSavedPromptsExpanded, setIsSavedPromptsExpanded] = useState(false); + const [isShowingAllChats, setIsShowingAllChats] = useState(false); + + const conversations: ConversationGroup[] = [ + { + id: 'pinned', + label: 'Pinned chats', + items: pinnedChats + }, + { + id: 'chats', + label: 'Chats', + items: recentChats, + showAll: { + visibleCount: VISIBLE_CHAT_COUNT, + isExpanded: isShowingAllChats, + onToggle: setIsShowingAllChats, + label: isShowingAllChats ? 'Show less' : 'Show all' + } + }, + { + id: 'saved-prompts', + label: 'Saved prompts', + expandable: { + isExpanded: isSavedPromptsExpanded, + onToggle: setIsSavedPromptsExpanded + }, + items: savedPrompts + } + ]; + + return ( + <> + setIsOpen(!isOpen)} + id="expandable-groups-drawer-visible" + name="expandable-groups-drawer-visible" + /> + setIsOpen(!isOpen)} + isDrawerOpen={isOpen} + setIsDrawerOpen={setIsOpen} + conversations={conversations} + drawerContent={
Drawer content
} + /> + + ); +}; 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 03eb020a..c152b779 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 @@ -35,7 +35,8 @@ propComponents: 'MessageBarWithAttachMenuProps', 'SourceDetailsMenuItem', 'ChatbotConversationHistoryNav', - 'Conversation' + 'Conversation', + 'ConversationGroup' ] sortValue: 2 --- @@ -401,6 +402,16 @@ To help users track important conversations, add a "pin" option to the conversat ``` +### Grouped and expandable conversation history + +For grouped chat history, pass a `ConversationGroup[]` to describe each section's label, items, and behavior. Each group's `items` array accepts `Conversation` objects or custom menu content such as a **Show all** action. Use `expandable` on a group to make that section expandable, and `footer` for content rendered after the group's menu list. + +The object form `{ [groupLabel: string]: Conversation[] }` remains supported as shorthand for simple static groups. + +```js file="./ChatbotHeaderDrawerWithExpandableGroups.tsx" + +``` + ### Renaming conversations in chat history drawer You can allow users to rename a conversation in the chat history drawer by implementing a modal that opens upon clicking a "Rename" (or similar) action. When doing so, you must ensure the following: diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss index 8b2fa2ec..eec98d43 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss @@ -60,12 +60,8 @@ --pf-v6-c-menu__item-main--ColumnGap: var(--pf-t--global--spacer--md); } - .pf-chatbot__menu-item-header > .pf-v6-c-menu__group-title { - color: var(--pf-t--global--text--color--subtle); - font-weight: var(--pf-t--global--font--weight--body--bold); - font-size: var(--pf-t--global--icon--size--font--sm); - --pf-v6-c-menu__group-title--PaddingInlineStart: var(--pf-t--global--spacer--sm); - --pf-v6-c-menu__group-title--PaddingInlineEnd: var(--pf-t--global--spacer--sm); + .pf-chatbot__menu-item-header > .pf-v6-c-menu__group-title, + .pf-chatbot__menu-item-header--expandable .pf-chatbot__menu-group-toggle { position: -webkit-sticky; position: sticky; top: 0; @@ -73,6 +69,47 @@ z-index: var(--pf-t--global--z-index--md); } + .pf-chatbot__menu-item-header > .pf-v6-c-menu__group-title, + .pf-chatbot__menu-item-header--expandable .pf-chatbot__menu-group-toggle > .pf-v6-c-expandable-section__toggle { + color: var(--pf-t--global--text--color--subtle); + font-weight: var(--pf-t--global--font--weight--body--bold); + font-size: var(--pf-t--global--icon--size--font--sm); + } + + .pf-chatbot__menu-group-toggle { + padding-block-start: var(--pf-t--global--spacer--sm); + padding-block-end: var(--pf-t--global--spacer--sm); + } + + .pf-chatbot__menu-item-header > .pf-v6-c-menu__group-title { + --pf-v6-c-menu__group-title--PaddingInlineStart: var(--pf-t--global--spacer--sm); + --pf-v6-c-menu__group-title--PaddingInlineEnd: var(--pf-t--global--spacer--sm); + } + + .pf-chatbot__menu-item-header--expandable .pf-chatbot__menu-group-toggle { + width: 100%; + + .pf-v6-c-expandable-section__toggle { + margin: 0; + } + + .pf-v6-c-button.pf-m-link { + --pf-v6-c-button--PaddingBlockStart: 0; + --pf-v6-c-button--PaddingBlockEnd: 0; + --pf-v6-c-button--PaddingInlineStart: var(--pf-t--global--spacer--sm); + --pf-v6-c-button--PaddingInlineEnd: var(--pf-t--global--spacer--sm); + color: inherit; + font-weight: inherit; + font-size: inherit; + justify-content: flex-start; + width: 100%; + } + + .pf-v6-c-expandable-section__toggle-icon { + color: var(--pf-t--global--text--color--subtle); + } + } + .pf-chatbot__menu-item { --pf-v6-c-menu__item--PaddingInlineStart: var(--pf-t--global--spacer--sm); --pf-v6-c-menu__item--PaddingInlineEnd: var(--pf-t--global--spacer--sm); @@ -86,6 +123,16 @@ border-radius: var(--pf-t--global--border--radius--small); } + // "Show all" / "Show less" toggle + // Rendered as a regular MenuItem positioned after the (conditionally rendered) + // overflow items, so it participates in the menu's normal roving tabindex and + // arrow-key navigation, and stays in a fixed position at the bottom of the list. + .pf-chatbot__menu-show-all-toggle { + color: var(--pf-t--global--text--color--link--default); + font-size: var(--pf-t--global--font--size--body--default); + font-weight: var(--pf-t--global--font--weight--body--default); + } + li.pf-chatbot__menu-item:hover::after { position: absolute; inset: 0; diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx index e030dcff..478d8fe5 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx @@ -2,10 +2,10 @@ import '@testing-library/jest-dom'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { ChatbotDisplayMode } from '../Chatbot/Chatbot'; -import ChatbotConversationHistoryNav, { Conversation } from './ChatbotConversationHistoryNav'; -import { EmptyStateStatus, Spinner } from '@patternfly/react-core'; +import ChatbotConversationHistoryNav, { Conversation, ConversationGroup } from './ChatbotConversationHistoryNav'; +import { EmptyStateStatus, Spinner, MenuItem } from '@patternfly/react-core'; import { BellIcon, OutlinedCommentsIcon, SearchIcon } from '@patternfly/react-icons'; -import { ComponentType } from 'react'; +import { ComponentType, useState } from 'react'; const ERROR = { bodyText: ( @@ -715,4 +715,552 @@ describe('ChatbotConversationHistoryNav', () => { ); expect(screen.getByTestId('bell')).toBeInTheDocument(); }); + + it('renders static and expandable groups from ConversationGroup[]', () => { + const groups: ConversationGroup[] = [ + { + id: 'pinned', + label: 'Pinned chats', + items: initialConversations + }, + { + id: 'chats', + label: 'Chats', + expandable: { + isExpanded: true, + onToggle: jest.fn() + }, + items: [{ id: '2', text: 'Chatbot extension' }] + } + ]; + + render( + + ); + + expect(screen.getByRole('heading', { name: 'Pinned chats', level: 3 })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Pinned chats' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Chats' })).toBeInTheDocument(); + }); + + it('labels each MenuList with aria-labelledby referencing its visible label', () => { + const groups: ConversationGroup[] = [ + { + id: 'pinned', + label: 'Pinned chats', + items: initialConversations + }, + { + id: 'chats', + label: 'Chats', + expandable: { + isExpanded: true, + onToggle: jest.fn() + }, + items: [{ id: '2', text: 'Chatbot extension' }] + } + ]; + + render( + + ); + + const pinnedHeading = screen.getByRole('heading', { name: 'Pinned chats', level: 3 }); + expect(pinnedHeading).toHaveAttribute('id', 'chatbot-nav-group-pinned-label'); + expect(pinnedHeading.closest('section')).toHaveAttribute('aria-labelledby', 'chatbot-nav-group-pinned-label'); + + expect(screen.getByRole('menu', { name: 'Pinned chats' })).toHaveAttribute( + 'aria-labelledby', + 'chatbot-nav-group-pinned-label' + ); + expect( + document.querySelector('ul.pf-v6-c-menu__list[aria-labelledby="chatbot-nav-group-pinned-label"]') + ).toBeTruthy(); + + const chatsToggle = screen.getByRole('button', { name: 'Chats' }); + expect(chatsToggle).toHaveAttribute('id', 'chatbot-nav-group-chats-toggle'); + + expect(screen.getByRole('menu', { name: 'Chats' })).toHaveAttribute( + 'aria-labelledby', + 'chatbot-nav-group-chats-toggle' + ); + expect( + document.querySelector('ul.pf-v6-c-menu__list[aria-labelledby="chatbot-nav-group-chats-toggle"]') + ).toBeTruthy(); + }); + + it('labels grouped object conversations on the menu list element', () => { + render( + + ); + + expect(document.querySelector('ul.pf-v6-c-menu__list')).toHaveAttribute( + 'aria-labelledby', + 'chatbot-nav-group-Today-label' + ); + }); + + it('labels each menu list in a shared static menu with its group title id', () => { + const groups: ConversationGroup[] = [ + { + id: 'pinned', + label: 'Pinned chats', + items: initialConversations + }, + { + id: 'recent', + label: 'Recent chats', + items: [{ id: '2', text: 'Chatbot extension' }] + } + ]; + + render( + + ); + + expect(screen.getByRole('menu', { name: 'Pinned chats' })).toHaveAttribute( + 'aria-labelledby', + 'chatbot-nav-group-pinned-label' + ); + expect(screen.getByRole('menu', { name: 'Recent chats' })).toHaveAttribute( + 'aria-labelledby', + 'chatbot-nav-group-recent-label' + ); + }); + + it('does not autofocus the first menu item when an expandable group is expanded', async () => { + const ExpandableGroupDemo = () => { + const [isExpanded, setIsExpanded] = useState(false); + + return ( + + ); + }; + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Saved prompts' })); + + await waitFor(() => { + expect(screen.getByRole('menuitem', { name: 'Summarize this document' })).toBeInTheDocument(); + }); + expect(screen.getByRole('menuitem', { name: 'Summarize this document' })).not.toHaveFocus(); + }); + + it('does not render a collapsed expandable group menu', () => { + const groups: ConversationGroup[] = [ + { + id: 'chats', + label: 'Chats', + items: [ + { id: '2', text: 'Chat two' }, + { id: '3', text: 'Chat three' } + ] + }, + { + id: 'saved-prompts', + label: 'Saved prompts', + expandable: { + isExpanded: false, + onToggle: jest.fn() + }, + items: [ + { id: '7', text: 'Summarize this document' }, + { id: '8', text: 'Draft a release announcement' } + ] + } + ]; + + render( + + ); + + // Collapsed expandable groups render their toggle outside the menu and omit the menu + // entirely until expanded, so their items are not part of arrow-key navigation. + expect(screen.getAllByRole('menu')).toHaveLength(1); + expect(screen.getByRole('menu', { name: 'Chats' })).toHaveAttribute( + 'aria-labelledby', + 'chatbot-nav-group-chats-label' + ); + expect(screen.queryByRole('menuitem', { name: 'Summarize this document' })).not.toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Draft a release announcement' })).not.toBeInTheDocument(); + + const firstItem = screen.getByRole('menuitem', { name: 'Chat two' }); + const lastItem = screen.getByRole('menuitem', { name: 'Chat three' }); + + lastItem.focus(); + fireEvent.keyDown(lastItem, { key: 'ArrowDown' }); + + expect(firstItem).toHaveFocus(); + }); + + it('collapses and expands a group when expandable.onToggle is called', async () => { + const onToggle = jest.fn(); + const groups: ConversationGroup[] = [ + { + id: 'chats', + label: 'Chats', + expandable: { + isExpanded: true, + onToggle + }, + items: [{ id: '2', text: 'Chatbot extension' }] + } + ]; + + const { rerender } = render( + + ); + + expect(screen.getByRole('menuitem', { name: /Chatbot extension/i })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Chats' })); + expect(onToggle).toHaveBeenCalledWith(false); + + rerender( + + ); + + await waitFor(() => { + expect(screen.queryByRole('menuitem', { name: /Chatbot extension/i })).not.toBeInTheDocument(); + }); + }); + + it('renders custom menu items and footers supplied in ConversationGroup', () => { + const groups: ConversationGroup[] = [ + { + id: 'chats', + label: 'Chats', + items: [ + initialConversations[0], + + Show all + + ], + footer:
Footer content
+ } + ]; + + render( + + ); + + expect(screen.getByRole('menuitem', { name: 'Show all' })).toBeInTheDocument(); + expect(screen.getByTestId('group-footer')).toBeInTheDocument(); + }); + + it('moves focus to the first overflow item when show all is expanded', async () => { + const recentChats: Conversation[] = [ + { id: '2', text: 'Chat two' }, + { id: '3', text: 'Chat three' }, + { id: '4', text: 'Chat four' }, + { id: '5', text: 'Chat five' }, + { id: '6', text: 'Chat six' } + ]; + const VISIBLE_CHAT_COUNT = 3; + + const ShowAllDemo = () => { + const [isShowingAllChats, setIsShowingAllChats] = useState(false); + + return ( + + ); + }; + + render(); + + fireEvent.click(screen.getByRole('menuitem', { name: /Show all/i })); + + await waitFor(() => { + expect(screen.getByRole('menuitem', { name: 'Chat five' })).toHaveFocus(); + }); + }); + + it('keeps focus on the toggle when show all is collapsed', async () => { + const recentChats: Conversation[] = [ + { id: '2', text: 'Chat two' }, + { id: '3', text: 'Chat three' }, + { id: '4', text: 'Chat four' }, + { id: '5', text: 'Chat five' } + ]; + + const ShowAllDemo = () => { + const [isShowingAllChats, setIsShowingAllChats] = useState(true); + + return ( + + ); + }; + + render(); + + fireEvent.click(screen.getByRole('menuitem', { name: 'Show less' })); + + await waitFor(() => { + expect(screen.getByRole('menuitem', { name: /Show all/i })).toHaveFocus(); + }); + }); + + it('renders overflow items above the show all toggle so it stays in a fixed position', () => { + const recentChats: Conversation[] = [ + { id: '2', text: 'Chat two' }, + { id: '3', text: 'Chat three' }, + { id: '4', text: 'Chat four' } + ]; + + render( + + ); + + const overflowItem = screen.getByRole('menuitem', { name: 'Chat four' }); + const toggle = screen.getByRole('menuitem', { name: 'Show less' }); + const isToggleAfterOverflowItem = Boolean( + // eslint-disable-next-line no-bitwise + overflowItem.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING + ); + + expect(isToggleAfterOverflowItem).toBe(true); + }); + + it('allows arrow key navigation to flow through the show all toggle like any other menu item', async () => { + const recentChats: Conversation[] = [ + { id: '2', text: 'Chat two' }, + { id: '3', text: 'Chat three' }, + { id: '4', text: 'Chat four' } + ]; + + render( + + ); + + const lastVisibleItem = screen.getByRole('menuitem', { name: 'Chat three' }); + const toggle = screen.getByRole('menuitem', { name: /Show all/i }); + + lastVisibleItem.focus(); + fireEvent.keyDown(lastVisibleItem, { key: 'ArrowDown' }); + + await waitFor(() => { + expect(toggle).toHaveFocus(); + }); + + fireEvent.keyDown(toggle, { key: 'ArrowUp' }); + + await waitFor(() => { + expect(lastVisibleItem).toHaveFocus(); + }); + }); + + it('preserves custom menu item identity when preceding items change', () => { + const renderShowAll = () => ( + + Show all + + ); + + const { rerender } = render( + + ); + + const showAllBefore = screen.getByRole('menuitem', { name: 'Show all' }); + + rerender( + + ); + + expect(screen.getByRole('menuitem', { name: 'Show all' })).toBe(showAllBefore); + }); + + it('passes expandableSectionProps from expandable ConversationGroup', () => { + const groups: ConversationGroup[] = [ + { + id: 'saved', + label: 'Saved prompts', + expandable: { + isExpanded: true, + onToggle: jest.fn(), + expandableSectionProps: { className: 'test-expandable-section' } + }, + items: initialConversations + } + ]; + + render( + + ); + + expect(document.querySelector('.test-expandable-section')).toBeInTheDocument(); + }); }); diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx index bf21815f..4232d031 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx @@ -1,8 +1,8 @@ // ============================================================================ // Chatbot Header - Chatbot Conversation History Nav // ============================================================================ -import type { KeyboardEvent, FunctionComponent } from 'react'; -import { useRef, Fragment } from 'react'; +import type { KeyboardEvent, FunctionComponent, ReactNode, Ref, RefObject } from 'react'; +import { useEffect, useRef, Fragment, isValidElement } from 'react'; // Import PatternFly components import { @@ -42,7 +42,11 @@ import { MenuContent, MenuItemProps, MenuGroupProps, - MenuContentProps + MenuContentProps, + ExpandableSection, + ExpandableSectionToggle, + ExpandableSectionProps, + ExpandableSectionToggleProps } from '@patternfly/react-core'; import { RhUiClockIcon, RhUiCommentIcon, RhUiEditFillIcon } from '@patternfly/react-icons'; @@ -51,6 +55,189 @@ import ConversationHistoryDropdown from './ChatbotConversationHistoryDropdown'; import LoadingState from './LoadingState'; import HistoryEmptyState, { HistoryEmptyStateProps } from './EmptyState'; +const isConversation = (item: unknown): item is Conversation => + Boolean(item && typeof item === 'object' && 'id' in item && 'text' in item && !('items' in item)); + +const isConversationGroup = (item: unknown): item is ConversationGroup => + Boolean(item && typeof item === 'object' && 'id' in item && 'label' in item && 'items' in item); + +const isConversationGroupArray = (items: unknown[]): items is ConversationGroup[] => + items.length > 0 && isConversationGroup(items[0]); + +const getExpandableGroupContentId = (groupId: string) => `chatbot-nav-group-${groupId}-content`; + +const getGroupLabelId = (groupId: string) => `chatbot-nav-group-${groupId}-label`; + +const getExpandableGroupToggleId = (groupId: string) => `chatbot-nav-group-${groupId}-toggle`; + +const getShowAllToggleId = (groupId: string) => `chatbot-nav-group-${groupId}-show-all-toggle`; + +const getStaticMenuLabelId = (group: { id: string; menuGroupProps?: MenuGroupProps }) => + group.menuGroupProps?.titleId ?? getGroupLabelId(group.id); + +const getMenuListLabelledBy = (group: { + id: string; + expandable?: { isExpanded: boolean; onToggle: (isExpanded: boolean) => void }; + menuGroupProps?: MenuGroupProps; +}) => { + if (group.expandable) { + return group.menuGroupProps?.['aria-labelledby'] ?? getExpandableGroupToggleId(group.id); + } + + return group.menuGroupProps?.['aria-labelledby'] ?? getStaticMenuLabelId(group); +}; + +const getMenuListProps = (group: { + id: string; + label: ReactNode; + expandable?: { isExpanded: boolean; onToggle: (isExpanded: boolean) => void }; + menuGroupProps?: MenuGroupProps; + menuListProps?: Omit; +}): Omit => ({ + ...group.menuListProps, + 'aria-labelledby': group.menuListProps?.['aria-labelledby'] ?? getMenuListLabelledBy(group) +}); + +const focusElementById = (id: string) => { + document.getElementById(id)?.focus(); +}; + +// Matches PatternFly's Menu "view more" example: expanding moves focus to the first +// newly revealed item; collapsing keeps focus on the toggle as it updates to "Show all". +const useShowAllFocusManagement = ( + isExpanded: boolean, + toggleId: string, + firstOverflowItemRef: Ref, + hasOverflowItems: boolean +) => { + const wasExpandedRef = useRef(isExpanded); + + useEffect(() => { + if (hasOverflowItems && isExpanded !== wasExpandedRef.current) { + if (isExpanded) { + (firstOverflowItemRef as RefObject).current?.focus(); + } else { + focusElementById(toggleId); + } + } + + wasExpandedRef.current = isExpanded; + }, [isExpanded, toggleId, firstOverflowItemRef, hasOverflowItems]); +}; + +interface ShowAllGroupBodyProps { + group: ConversationGroup; + getNavItem: ( + conversation: Conversation, + itemOverrides?: Partial, + itemRef?: Ref + ) => ReactNode; +} + +// The toggle is rendered as a real MenuItem (rather than a standalone button) so it +// participates in the Menu's built-in roving tabindex and arrow-key navigation just +// like any other conversation item, instead of being skipped over or interrupting +// arrow-key handling. Since it keeps a stable key and DOM position regardless of how +// many overflow items are shown, it also keeps focus across clicks without being +// unmounted or recreated. +const ShowAllGroupBody: FunctionComponent = ({ group, getNavItem }) => { + const { visibleCount, isExpanded, onToggle, label } = group.showAll!; + const conversationItems = group.items.filter(isConversation); + const alwaysVisibleItems = conversationItems.slice(0, visibleCount); + const overflowItems = conversationItems.slice(visibleCount); + const hasOverflowItems = overflowItems.length > 0; + const toggleId = getShowAllToggleId(group.id); + const firstOverflowItemRef = useRef(null); + const toggleLabel = label ?? (isExpanded ? 'Show less' : 'Show all'); + + useShowAllFocusManagement(isExpanded, toggleId, firstOverflowItemRef, hasOverflowItems); + + return ( + <> + + {alwaysVisibleItems.map((chat) => ( + {getNavItem(chat)} + ))} + {isExpanded && + overflowItems.map((chat, index) => ( + + {getNavItem(chat, undefined, index === 0 ? firstOverflowItemRef : undefined)} + + ))} + {hasOverflowItems && ( + onToggle(!isExpanded)} + > + {toggleLabel} + + )} + + {group.footer} + + ); +}; + +interface ExpandableConversationGroupProps { + group: ConversationGroup; + children: ReactNode; +} + +const ExpandableConversationGroup: FunctionComponent = ({ group, children }) => { + const toggleId = getExpandableGroupToggleId(group.id); + const contentId = getExpandableGroupContentId(group.id); + const { isExpanded, onToggle, expandableSectionProps, expandableSectionToggleProps } = group.expandable!; + + return ( +
+ + {group.label} + + + {isExpanded && children} + +
+ ); +}; + +type ConversationMenuSegment = + | { type: 'static'; groups: ConversationGroup[] } + | { type: 'expandable'; group: ConversationGroup }; + +const buildConversationMenuSegments = (groups: ConversationGroup[]): ConversationMenuSegment[] => + groups.reduce((segments, group) => { + if (group.expandable) { + return [...segments, { type: 'expandable', group }]; + } + + const last = segments[segments.length - 1]; + if (last?.type === 'static') { + last.groups.push(group); + return segments; + } + + return [...segments, { type: 'static', groups: [group] }]; + }, []); + export interface Conversation { /** Conversation id */ id: string; @@ -73,6 +260,55 @@ export interface Conversation { /** Custom dropdown ID to ensure uniqueness across demo instances */ dropdownId?: string; } + +export interface ConversationGroupShowAll { + /** Number of items visible when collapsed */ + visibleCount: number; + /** Whether all items are shown */ + isExpanded: boolean; + /** Callback when show all / show less is toggled */ + onToggle: (isExpanded: boolean) => void; + /** Custom content rendered in the show all / show less toggle. Defaults to "Show all" when collapsed and "Show less" when expanded. */ + label?: ReactNode; +} + +export interface ConversationGroupExpandable { + /** Whether the group content is expanded */ + isExpanded: boolean; + /** Callback when the group is toggled */ + onToggle: (isExpanded: boolean) => void; + /** Additional props applied to ExpandableSection */ + expandableSectionProps?: Omit; + /** Additional props applied to ExpandableSectionToggle */ + expandableSectionToggleProps?: ExpandableSectionToggleProps; +} + +export interface ConversationGroup { + /** Unique group id */ + id: string; + /** Group label rendered as a MenuGroup heading or expandable toggle label */ + label: ReactNode; + /** Conversation items or custom menu content such as a "Show all" action */ + items: (Conversation | ReactNode)[]; + /** Content rendered after the group's menu list */ + footer?: ReactNode; + /** Custom group header that replaces the default label or expandable toggle */ + header?: ReactNode; + /** When set, the group renders as an expandable section */ + expandable?: ConversationGroupExpandable; + /** When set, truncates the list with an expandable show all / show less section */ + showAll?: ConversationGroupShowAll; + /** Additional props applied to the conversation menu group */ + menuGroupProps?: MenuGroupProps; + /** Additional props applied to the conversation list */ + menuListProps?: Omit; +} + +export type Conversations = + | (Conversation | ReactNode)[] + | ConversationGroup[] + | { [key: string]: (Conversation | ReactNode)[] }; + export interface ChatbotConversationHistoryNavProps extends DrawerProps { /** Function called to toggle drawer */ onDrawerToggle: (event: React.KeyboardEvent | React.MouseEvent | React.TransitionEvent) => void; @@ -85,7 +321,7 @@ export interface ChatbotConversationHistoryNavProps extends DrawerProps { /** Callback function for when an item is selected */ onSelectActiveItem?: (event?: React.MouseEvent, itemId?: string | number) => void; /** Items shown in chat history */ - conversations: Conversation[] | { [key: string]: Conversation[] }; + conversations: Conversations; /** Additional button props for new chat button. */ newChatButtonProps?: ButtonProps; /** Additional props applied to conversation menu group. If conversations is an object, you should pass an object of MenuGroupProps for each group. */ @@ -211,13 +447,15 @@ export const ChatbotConversationHistoryNav: FunctionComponent - item && typeof item === 'object' && 'id' in item && 'text' in item; - - const getNavItem = (conversation: Conversation) => ( + const getNavItem = ( + conversation: Conversation, + itemOverrides?: Partial, + itemRef?: Ref + ) => ( })} /* eslint-disable indent */ {...(conversation.menuItems @@ -233,45 +471,118 @@ export const ChatbotConversationHistoryNav: FunctionComponent {conversation.text} ); - const buildConversations = () => { - if (Array.isArray(conversations)) { + const renderConversationItems = (items: (Conversation | ReactNode)[], keyPrefix = '') => + items.map((item, index) => { + if (isConversation(item)) { + return {getNavItem(item)}; + } + + const key = isValidElement(item) && item.key != null ? String(item.key) : `${keyPrefix}-${index}`; + + return {item}; + }); + + const renderGroupBody = (group: ConversationGroup) => { + if (group.showAll) { + return ; + } + + return ( + <> + {renderConversationItems(group.items, group.id)} + {group.footer} + + ); + }; + + const renderConversationGroup = (group: ConversationGroup) => { + if (group.header) { return ( - - {conversations.map((conversation) => { - if (isConversation(conversation)) { - return {getNavItem(conversation)}; - } else { - return conversation; - } - })} - +
+ {group.header} + {renderGroupBody(group)} +
); - } else { - return ( - <> - {Object.keys(conversations).map((navGroup) => ( - - - {conversations[navGroup].map((conversation: Conversation) => ( - {getNavItem(conversation)} - ))} - - - ))} - + } + + const labelId = getStaticMenuLabelId(group); + + return ( + + {renderGroupBody(group)} + + ); + }; + + const renderConversationMenu = (content: ReactNode, key?: string) => ( + + {content} + + ); + + const renderConversationMenuSegment = (segment: ConversationMenuSegment) => { + if (segment.type === 'static') { + return renderConversationMenu( + <>{segment.groups.map(renderConversationGroup)}, + segment.groups.map((group) => group.id).join('-') ); } + + return ( + + {renderConversationMenu(renderGroupBody(segment.group))} + + ); + }; + + const normalizeObjectGroups = (groupedConversations: { [key: string]: (Conversation | ReactNode)[] }) => + Object.keys(groupedConversations).map((groupKey) => ({ + id: groupKey, + label: groupKey, + items: groupedConversations[groupKey], + menuGroupProps: + menuGroupProps && typeof menuGroupProps === 'object' && groupKey in menuGroupProps + ? (menuGroupProps as { [key: string]: MenuGroupProps })[groupKey] + : undefined, + menuListProps: + menuListProps && typeof menuListProps === 'object' && groupKey in menuListProps + ? (menuListProps as { [key: string]: Omit })[groupKey] + : undefined + })); + + const buildConversations = () => { + if (Array.isArray(conversations)) { + if (isConversationGroupArray(conversations)) { + return <>{buildConversationMenuSegments(conversations).map(renderConversationMenuSegment)}; + } + + return renderConversationMenu({renderConversationItems(conversations)}); + } + + return ( + <>{buildConversationMenuSegments(normalizeObjectGroups(conversations)).map(renderConversationMenuSegment)} + ); }; // Menu Content @@ -289,17 +600,7 @@ export const ChatbotConversationHistoryNav: FunctionComponent; } - return ( - - {buildConversations()} - - ); + return buildConversations(); }; const renderDrawerContent = () => (