From ed64845a1d653d82cf96e3ab5ef1135bc8873edb Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Mon, 20 Jul 2026 16:12:55 -0400 Subject: [PATCH 1/7] feat(History): Add expandable sections Assisted-by: Cursor --- ...atbotHeaderDrawerWithCollapsibleGroups.tsx | 129 +++++++++++++ .../extensions/chatbot/examples/UI/UI.md | 13 +- .../ChatbotConversationHistoryNav.scss | 54 +++++- .../ChatbotConversationHistoryNav.test.tsx | 145 +++++++++++++- .../ChatbotConversationHistoryNav.tsx | 182 ++++++++++++++---- 5 files changed, 480 insertions(+), 43 deletions(-) create mode 100644 packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotHeaderDrawerWithCollapsibleGroups.tsx diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotHeaderDrawerWithCollapsibleGroups.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotHeaderDrawerWithCollapsibleGroups.tsx new file mode 100644 index 000000000..82d4f6ee6 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotHeaderDrawerWithCollapsibleGroups.tsx @@ -0,0 +1,129 @@ +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, MenuItem } 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 ChatbotHeaderDrawerWithCollapsibleGroupsDemo: FunctionComponent = () => { + const [isOpen, setIsOpen] = useState(true); + const [isSavedPromptsExpanded, setIsSavedPromptsExpanded] = useState(false); + const [isShowingAllChats, setIsShowingAllChats] = useState(false); + + const visibleChats = isShowingAllChats ? recentChats : recentChats.slice(0, VISIBLE_CHAT_COUNT); + const hiddenChatCount = recentChats.length - VISIBLE_CHAT_COUNT; + + const renderExpandButton = () => { + if (isShowingAllChats) { + return [ + setIsShowingAllChats(false)} + > + Show less + + ]; + } + if (hiddenChatCount > 0 && !isShowingAllChats) { + return [ + setIsShowingAllChats(true)} + > + {`Show all (${recentChats.length})`} + + ]; + } + return []; + }; + + const conversations: ConversationGroup[] = [ + { + id: 'pinned', + label: 'Pinned chats', + items: pinnedChats + }, + { + id: 'chats', + label: 'Chats', + items: [...visibleChats, ...renderExpandButton()] + }, + { + id: 'saved-prompts', + label: 'Saved prompts', + collapsible: { + isExpanded: isSavedPromptsExpanded, + onToggle: setIsSavedPromptsExpanded + }, + items: savedPrompts + } + ]; + + return ( + <> + setIsOpen(!isOpen)} + id="collapsible-groups-drawer-visible" + name="collapsible-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 03eb020a7..5e314f4d2 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 collapsible 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 `collapsible` 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="./ChatbotHeaderDrawerWithCollapsibleGroups.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 8b2fa2ecc..b8d04b6ca 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--collapsible .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--collapsible .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--collapsible .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,11 @@ border-radius: var(--pf-t--global--border--radius--small); } + .pf-chatbot__menu-item--show-button { + color: var(--pf-t--global--text--color--link--default); + font-size: var(--pf-t--global--font--size--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 e030dcff3..a9f2ee11b 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx @@ -2,8 +2,8 @@ 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'; @@ -715,4 +715,145 @@ describe('ChatbotConversationHistoryNav', () => { ); expect(screen.getByTestId('bell')).toBeInTheDocument(); }); + + it('renders static and collapsible groups from ConversationGroup[]', () => { + const groups: ConversationGroup[] = [ + { + id: 'pinned', + label: 'Pinned chats', + items: initialConversations + }, + { + id: 'chats', + label: 'Chats', + collapsible: { + 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('collapses and expands a group when collapsible.onToggle is called', async () => { + const onToggle = jest.fn(); + const groups: ConversationGroup[] = [ + { + id: 'chats', + label: 'Chats', + collapsible: { + 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('passes collapsible expandableSectionProps from ConversationGroup', () => { + const groups: ConversationGroup[] = [ + { + id: 'saved', + label: 'Saved prompts', + collapsible: { + 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 bf21815f8..38be3a7c8 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx @@ -1,7 +1,7 @@ // ============================================================================ // Chatbot Header - Chatbot Conversation History Nav // ============================================================================ -import type { KeyboardEvent, FunctionComponent } from 'react'; +import type { KeyboardEvent, FunctionComponent, ReactNode } from 'react'; import { useRef, Fragment } from 'react'; // Import PatternFly components @@ -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'; @@ -73,6 +77,42 @@ export interface Conversation { /** Custom dropdown ID to ensure uniqueness across demo instances */ dropdownId?: string; } + +export interface ConversationGroupCollapsible { + /** 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 collapsible 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 collapsible toggle */ + header?: ReactNode; + /** When set, the group renders as a collapsible section */ + collapsible?: ConversationGroupCollapsible; + /** 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 +125,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. */ @@ -160,6 +200,15 @@ export interface ChatbotConversationHistoryNavProps extends DrawerProps { menuContentProps?: Omit; } +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]); + export const ChatbotConversationHistoryNav: FunctionComponent = ({ onDrawerToggle, isDrawerOpen, @@ -211,9 +260,6 @@ export const ChatbotConversationHistoryNav: FunctionComponent - item && typeof item === 'object' && 'id' in item && 'text' in item; - const getNavItem = (conversation: Conversation) => ( ); + const renderConversationItems = (items: (Conversation | ReactNode)[], keyPrefix = '') => + items.map((item, index) => { + if (isConversation(item)) { + return {getNavItem(item)}; + } + + return {item}; + }); + + const renderGroupBody = (group: ConversationGroup) => ( + <> + {renderConversationItems(group.items, group.id)} + {group.footer} + + ); + + const renderConversationGroup = (group: ConversationGroup) => { + if (group.header) { + return ( +
+ {group.header} + {renderGroupBody(group)} +
+ ); + } + + if (group.collapsible) { + const toggleId = `chatbot-nav-group-${group.id}-toggle`; + const contentId = `chatbot-nav-group-${group.id}-content`; + const { isExpanded, onToggle, expandableSectionProps, expandableSectionToggleProps } = group.collapsible; + + return ( +
+ + {group.label} + + + {renderGroupBody(group)} + +
+ ); + } + + return ( + + {renderGroupBody(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 <>{conversations.map(renderConversationGroup)}; + } + return ( - - {conversations.map((conversation) => { - if (isConversation(conversation)) { - return {getNavItem(conversation)}; - } else { - return conversation; - } - })} + )}> + {renderConversationItems(conversations)} ); - } else { - return ( - <> - {Object.keys(conversations).map((navGroup) => ( - - - {conversations[navGroup].map((conversation: Conversation) => ( - {getNavItem(conversation)} - ))} - - - ))} - - ); } + + return <>{normalizeObjectGroups(conversations).map(renderConversationGroup)}; }; // Menu Content From 2b516cd9be48c01f64d0fa5efe07400950c5033e Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Wed, 29 Jul 2026 13:15:30 -0400 Subject: [PATCH 2/7] Make the keyboard actions work Assisted-by: Cursor --- ...atbotHeaderDrawerWithCollapsibleGroups.tsx | 41 +-- .../ChatbotConversationHistoryNav.scss | 7 +- .../ChatbotConversationHistoryNav.test.tsx | 301 +++++++++++++++++- .../ChatbotConversationHistoryNav.tsx | 226 ++++++++++--- 4 files changed, 489 insertions(+), 86 deletions(-) diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotHeaderDrawerWithCollapsibleGroups.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotHeaderDrawerWithCollapsibleGroups.tsx index 82d4f6ee6..395dc5c0c 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotHeaderDrawerWithCollapsibleGroups.tsx +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotHeaderDrawerWithCollapsibleGroups.tsx @@ -4,7 +4,7 @@ import ChatbotConversationHistoryNav, { Conversation, ConversationGroup } from '@patternfly/chatbot/dist/dynamic/ChatbotConversationHistoryNav'; -import { Checkbox, MenuItem } from '@patternfly/react-core'; +import { Checkbox } from '@patternfly/react-core'; const pinnedChats: Conversation[] = [ { @@ -54,37 +54,6 @@ export const ChatbotHeaderDrawerWithCollapsibleGroupsDemo: FunctionComponent = ( const [isSavedPromptsExpanded, setIsSavedPromptsExpanded] = useState(false); const [isShowingAllChats, setIsShowingAllChats] = useState(false); - const visibleChats = isShowingAllChats ? recentChats : recentChats.slice(0, VISIBLE_CHAT_COUNT); - const hiddenChatCount = recentChats.length - VISIBLE_CHAT_COUNT; - - const renderExpandButton = () => { - if (isShowingAllChats) { - return [ - setIsShowingAllChats(false)} - > - Show less - - ]; - } - if (hiddenChatCount > 0 && !isShowingAllChats) { - return [ - setIsShowingAllChats(true)} - > - {`Show all (${recentChats.length})`} - - ]; - } - return []; - }; - const conversations: ConversationGroup[] = [ { id: 'pinned', @@ -94,7 +63,13 @@ export const ChatbotHeaderDrawerWithCollapsibleGroupsDemo: FunctionComponent = ( { id: 'chats', label: 'Chats', - items: [...visibleChats, ...renderExpandButton()] + items: recentChats, + showAll: { + visibleCount: VISIBLE_CHAT_COUNT, + isExpanded: isShowingAllChats, + onToggle: setIsShowingAllChats, + label: isShowingAllChats ? 'Show less' : 'Show all' + } }, { id: 'saved-prompts', diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss index b8d04b6ca..9b70740c3 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss @@ -123,9 +123,14 @@ border-radius: var(--pf-t--global--border--radius--small); } - .pf-chatbot__menu-item--show-button { + // "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 { diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx index a9f2ee11b..78c1ef67e 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx @@ -5,7 +5,7 @@ import { ChatbotDisplayMode } from '../Chatbot/Chatbot'; 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: ( @@ -749,6 +749,92 @@ describe('ChatbotConversationHistoryNav', () => { expect(screen.getByRole('button', { name: 'Chats' })).toBeInTheDocument(); }); + it('moves focus to the first menu item when a collapsible 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' })).toHaveFocus(); + }); + }); + + it("does not render a collapsed group's menu items so they cannot dead-end keyboard navigation", () => { + const groups: ConversationGroup[] = [ + { + id: 'chats', + label: 'Chats', + items: [ + { id: '2', text: 'Chat two' }, + { id: '3', text: 'Chat three' } + ] + }, + { + id: 'saved-prompts', + label: 'Saved prompts', + collapsible: { + isExpanded: false, + onToggle: jest.fn() + }, + items: [ + { id: '7', text: 'Summarize this document' }, + { id: '8', text: 'Draft a release announcement' } + ] + } + ]; + + render( + + ); + + // Collapsed content should not merely be visually hidden - it should not be in the + // document at all, otherwise the Menu's LI-based arrow key handler will still see it, + // try (and fail) to focus it, and dead-end navigation at the preceding item. + 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 collapsible.onToggle is called', async () => { const onToggle = jest.fn(); const groups: ConversationGroup[] = [ @@ -830,6 +916,219 @@ describe('ChatbotConversationHistoryNav', () => { expect(screen.getByTestId('group-footer')).toBeInTheDocument(); }); + it('keeps focus on the toggle button 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: 'Show less' })).toHaveFocus(); + }); + }); + + it('moves focus back to 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 collapsible expandableSectionProps from ConversationGroup', () => { const groups: ConversationGroup[] = [ { diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx index 38be3a7c8..495bf028f 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx @@ -2,7 +2,7 @@ // Chatbot Header - Chatbot Conversation History Nav // ============================================================================ import type { KeyboardEvent, FunctionComponent, ReactNode } from 'react'; -import { useRef, Fragment } from 'react'; +import { useLayoutEffect, useRef, Fragment, isValidElement } from 'react'; // Import PatternFly components import { @@ -55,6 +55,148 @@ 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 getCollapsibleGroupContentId = (groupId: string) => `chatbot-nav-group-${groupId}-content`; + +const focusFirstGroupMenuItem = (contentId: string) => { + document + .getElementById(contentId) + ?.querySelector('ul button:not(:disabled), ul a:not([aria-disabled="true"])') + ?.focus(); +}; + +const getShowAllToggleId = (groupId: string) => `chatbot-nav-group-${groupId}-show-all-toggle`; + +const focusElementById = (id: string) => { + document.getElementById(id)?.focus(); +}; + +const useFocusFirstMenuItemOnExpand = (isExpanded: boolean, contentId: string) => { + const wasExpandedRef = useRef(isExpanded); + + useLayoutEffect(() => { + if (isExpanded && !wasExpandedRef.current) { + focusFirstGroupMenuItem(contentId); + } + + wasExpandedRef.current = isExpanded; + }, [isExpanded, contentId]); +}; + +// The toggle keeps focus on itself in both directions (rather than moving focus into +// the revealed content) since it stays mounted at a fixed position in the list. +const useShowAllFocusManagement = (isExpanded: boolean, toggleId: string, hasOverflowItems: boolean) => { + const wasExpandedRef = useRef(isExpanded); + + useLayoutEffect(() => { + if (hasOverflowItems && isExpanded !== wasExpandedRef.current) { + focusElementById(toggleId); + } + + wasExpandedRef.current = isExpanded; + }, [isExpanded, toggleId, hasOverflowItems]); +}; + +interface ShowAllGroupBodyProps { + group: ConversationGroup; + getNavItem: (conversation: Conversation) => 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 toggleLabel = label ?? (isExpanded ? 'Show less' : 'Show all'); + + useShowAllFocusManagement(isExpanded, toggleId, hasOverflowItems); + + return ( + <> + + {alwaysVisibleItems.map((chat) => ( + {getNavItem(chat)} + ))} + {isExpanded && overflowItems.map((chat) => {getNavItem(chat)})} + {hasOverflowItems && ( + onToggle(!isExpanded)} + > + {toggleLabel} + + )} + + {group.footer} + + ); +}; + +interface CollapsibleConversationGroupProps { + group: ConversationGroup; + children: ReactNode; +} + +const CollapsibleConversationGroup: FunctionComponent = ({ group, children }) => { + const toggleId = `chatbot-nav-group-${group.id}-toggle`; + const contentId = getCollapsibleGroupContentId(group.id); + const { isExpanded, onToggle, expandableSectionProps, expandableSectionToggleProps } = group.collapsible!; + + useFocusFirstMenuItemOnExpand(isExpanded, contentId); + + return ( +
+ + {group.label} + + + {/* Only mount the collapsed group's menu items while expanded. ExpandableSection + only toggles a `hidden` attribute on its content wrapper, so if the items stayed + mounted while collapsed, the Menu's arrow-key handler (which finds navigable + elements via getElementsByTagName('LI') across the whole menu) would still see + them, try to focus them, and silently fail since hidden elements aren't focusable + - dead-ending keyboard navigation at the preceding item. */} + {isExpanded && children} + +
+ ); +}; + export interface Conversation { /** Conversation id */ id: string; @@ -78,6 +220,17 @@ export interface Conversation { 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 ConversationGroupCollapsible { /** Whether the group content is expanded */ isExpanded: boolean; @@ -102,6 +255,8 @@ export interface ConversationGroup { header?: ReactNode; /** When set, the group renders as a collapsible section */ collapsible?: ConversationGroupCollapsible; + /** 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 */ @@ -200,15 +355,6 @@ export interface ChatbotConversationHistoryNavProps extends DrawerProps { menuContentProps?: Omit; } -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]); - export const ChatbotConversationHistoryNav: FunctionComponent = ({ onDrawerToggle, isDrawerOpen, @@ -290,15 +436,23 @@ export const ChatbotConversationHistoryNav: FunctionComponent{getNavItem(item)}; } - return {item}; + const key = isValidElement(item) && item.key != null ? String(item.key) : `${keyPrefix}-${index}`; + + return {item}; }); - const renderGroupBody = (group: ConversationGroup) => ( - <> - {renderConversationItems(group.items, group.id)} - {group.footer} - - ); + const renderGroupBody = (group: ConversationGroup) => { + if (group.showAll) { + return ; + } + + return ( + <> + {renderConversationItems(group.items, group.id)} + {group.footer} + + ); + }; const renderConversationGroup = (group: ConversationGroup) => { if (group.header) { @@ -311,36 +465,10 @@ export const ChatbotConversationHistoryNav: FunctionComponent - - {group.label} - - - {renderGroupBody(group)} - - + + {renderGroupBody(group)} + ); } @@ -378,11 +506,7 @@ export const ChatbotConversationHistoryNav: FunctionComponent{conversations.map(renderConversationGroup)}; } - return ( - )}> - {renderConversationItems(conversations)} - - ); + return {renderConversationItems(conversations)}; } return <>{normalizeObjectGroups(conversations).map(renderConversationGroup)}; From 4d1ccc2e49b96ae07f5fa31caa7cd6da858781f5 Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Mon, 3 Aug 2026 15:17:53 -0400 Subject: [PATCH 3/7] Address feedback --- ...atbotHeaderDrawerWithExpandableGroups.tsx} | 8 +- .../extensions/chatbot/examples/UI/UI.md | 6 +- .../ChatbotConversationHistoryNav.scss | 6 +- .../ChatbotConversationHistoryNav.test.tsx | 63 ++++++++-- .../ChatbotConversationHistoryNav.tsx | 117 ++++++++++++------ 5 files changed, 137 insertions(+), 63 deletions(-) rename packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/{ChatbotHeaderDrawerWithCollapsibleGroups.tsx => ChatbotHeaderDrawerWithExpandableGroups.tsx} (91%) diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotHeaderDrawerWithCollapsibleGroups.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotHeaderDrawerWithExpandableGroups.tsx similarity index 91% rename from packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotHeaderDrawerWithCollapsibleGroups.tsx rename to packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotHeaderDrawerWithExpandableGroups.tsx index 395dc5c0c..86200f1b9 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotHeaderDrawerWithCollapsibleGroups.tsx +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotHeaderDrawerWithExpandableGroups.tsx @@ -49,7 +49,7 @@ const savedPrompts: Conversation[] = [ const VISIBLE_CHAT_COUNT = 3; -export const ChatbotHeaderDrawerWithCollapsibleGroupsDemo: FunctionComponent = () => { +export const ChatbotHeaderDrawerWithExpandableGroupsDemo: FunctionComponent = () => { const [isOpen, setIsOpen] = useState(true); const [isSavedPromptsExpanded, setIsSavedPromptsExpanded] = useState(false); const [isShowingAllChats, setIsShowingAllChats] = useState(false); @@ -74,7 +74,7 @@ export const ChatbotHeaderDrawerWithCollapsibleGroupsDemo: FunctionComponent = ( { id: 'saved-prompts', label: 'Saved prompts', - collapsible: { + expandable: { isExpanded: isSavedPromptsExpanded, onToggle: setIsSavedPromptsExpanded }, @@ -88,8 +88,8 @@ export const ChatbotHeaderDrawerWithCollapsibleGroupsDemo: FunctionComponent = ( label="Display drawer" isChecked={isOpen} onChange={() => setIsOpen(!isOpen)} - id="collapsible-groups-drawer-visible" - name="collapsible-groups-drawer-visible" + id="expandable-groups-drawer-visible" + name="expandable-groups-drawer-visible" /> .pf-v6-c-menu__group-title, - .pf-chatbot__menu-item-header--collapsible .pf-chatbot__menu-group-toggle { + .pf-chatbot__menu-item-header--expandable .pf-chatbot__menu-group-toggle { position: -webkit-sticky; position: sticky; top: 0; @@ -70,7 +70,7 @@ } .pf-chatbot__menu-item-header > .pf-v6-c-menu__group-title, - .pf-chatbot__menu-item-header--collapsible .pf-chatbot__menu-group-toggle > .pf-v6-c-expandable-section__toggle { + .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); @@ -86,7 +86,7 @@ --pf-v6-c-menu__group-title--PaddingInlineEnd: var(--pf-t--global--spacer--sm); } - .pf-chatbot__menu-item-header--collapsible .pf-chatbot__menu-group-toggle { + .pf-chatbot__menu-item-header--expandable .pf-chatbot__menu-group-toggle { width: 100%; .pf-v6-c-expandable-section__toggle { diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx index 78c1ef67e..63171bf44 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx @@ -716,7 +716,7 @@ describe('ChatbotConversationHistoryNav', () => { expect(screen.getByTestId('bell')).toBeInTheDocument(); }); - it('renders static and collapsible groups from ConversationGroup[]', () => { + it('renders static and expandable groups from ConversationGroup[]', () => { const groups: ConversationGroup[] = [ { id: 'pinned', @@ -726,7 +726,7 @@ describe('ChatbotConversationHistoryNav', () => { { id: 'chats', label: 'Chats', - collapsible: { + expandable: { isExpanded: true, onToggle: jest.fn() }, @@ -749,7 +749,44 @@ describe('ChatbotConversationHistoryNav', () => { expect(screen.getByRole('button', { name: 'Chats' })).toBeInTheDocument(); }); - it('moves focus to the first menu item when a collapsible group is expanded', async () => { + it('labels each MenuGroup 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'); + + const chatsToggle = screen.getByRole('button', { name: 'Chats' }); + expect(chatsToggle).toHaveAttribute('id', 'chatbot-nav-group-chats-toggle'); + expect(chatsToggle.closest('section')).toHaveAttribute('aria-labelledby', 'chatbot-nav-group-chats-toggle'); + }); + + it('moves focus to the first menu item when an expandable group is expanded', async () => { const ExpandableGroupDemo = () => { const [isExpanded, setIsExpanded] = useState(false); @@ -763,7 +800,7 @@ describe('ChatbotConversationHistoryNav', () => { { id: 'saved-prompts', label: 'Saved prompts', - collapsible: { + expandable: { isExpanded, onToggle: setIsExpanded }, @@ -799,7 +836,7 @@ describe('ChatbotConversationHistoryNav', () => { { id: 'saved-prompts', label: 'Saved prompts', - collapsible: { + expandable: { isExpanded: false, onToggle: jest.fn() }, @@ -835,13 +872,13 @@ describe('ChatbotConversationHistoryNav', () => { expect(firstItem).toHaveFocus(); }); - it('collapses and expands a group when collapsible.onToggle is called', async () => { + it('collapses and expands a group when expandable.onToggle is called', async () => { const onToggle = jest.fn(); const groups: ConversationGroup[] = [ { id: 'chats', label: 'Chats', - collapsible: { + expandable: { isExpanded: true, onToggle }, @@ -873,7 +910,7 @@ describe('ChatbotConversationHistoryNav', () => { conversations={[ { ...groups[0], - collapsible: { + expandable: { isExpanded: false, onToggle } @@ -916,7 +953,7 @@ describe('ChatbotConversationHistoryNav', () => { expect(screen.getByTestId('group-footer')).toBeInTheDocument(); }); - it('keeps focus on the toggle button when show all is expanded', async () => { + 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' }, @@ -956,11 +993,11 @@ describe('ChatbotConversationHistoryNav', () => { fireEvent.click(screen.getByRole('menuitem', { name: /Show all/i })); await waitFor(() => { - expect(screen.getByRole('menuitem', { name: 'Show less' })).toHaveFocus(); + expect(screen.getByRole('menuitem', { name: 'Chat five' })).toHaveFocus(); }); }); - it('moves focus back to the toggle when show all is collapsed', async () => { + 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' }, @@ -1129,12 +1166,12 @@ describe('ChatbotConversationHistoryNav', () => { expect(screen.getByRole('menuitem', { name: 'Show all' })).toBe(showAllBefore); }); - it('passes collapsible expandableSectionProps from ConversationGroup', () => { + it('passes expandableSectionProps from expandable ConversationGroup', () => { const groups: ConversationGroup[] = [ { id: 'saved', label: 'Saved prompts', - collapsible: { + expandable: { isExpanded: true, onToggle: jest.fn(), expandableSectionProps: { className: 'test-expandable-section' } diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx index 495bf028f..732ff0244 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, ReactNode } from 'react'; -import { useLayoutEffect, useRef, Fragment, isValidElement } from 'react'; +import type { KeyboardEvent, FunctionComponent, ReactNode, Ref, RefObject } from 'react'; +import { useEffect, useLayoutEffect, useRef, Fragment, isValidElement } from 'react'; // Import PatternFly components import { @@ -64,7 +64,11 @@ const isConversationGroup = (item: unknown): item is ConversationGroup => const isConversationGroupArray = (items: unknown[]): items is ConversationGroup[] => items.length > 0 && isConversationGroup(items[0]); -const getCollapsibleGroupContentId = (groupId: string) => `chatbot-nav-group-${groupId}-content`; +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 focusFirstGroupMenuItem = (contentId: string) => { document @@ -91,23 +95,36 @@ const useFocusFirstMenuItemOnExpand = (isExpanded: boolean, contentId: string) = }, [isExpanded, contentId]); }; -// The toggle keeps focus on itself in both directions (rather than moving focus into -// the revealed content) since it stays mounted at a fixed position in the list. -const useShowAllFocusManagement = (isExpanded: boolean, toggleId: string, hasOverflowItems: boolean) => { +// 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); - useLayoutEffect(() => { + useEffect(() => { if (hasOverflowItems && isExpanded !== wasExpandedRef.current) { - focusElementById(toggleId); + if (isExpanded) { + (firstOverflowItemRef as RefObject).current?.focus(); + } else { + focusElementById(toggleId); + } } wasExpandedRef.current = isExpanded; - }, [isExpanded, toggleId, hasOverflowItems]); + }, [isExpanded, toggleId, firstOverflowItemRef, hasOverflowItems]); }; interface ShowAllGroupBodyProps { group: ConversationGroup; - getNavItem: (conversation: Conversation) => ReactNode; + getNavItem: ( + conversation: Conversation, + itemOverrides?: Partial, + itemRef?: Ref + ) => ReactNode; } // The toggle is rendered as a real MenuItem (rather than a standalone button) so it @@ -123,9 +140,10 @@ const ShowAllGroupBody: FunctionComponent = ({ group, get 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, hasOverflowItems); + useShowAllFocusManagement(isExpanded, toggleId, firstOverflowItemRef, hasOverflowItems); return ( <> @@ -133,7 +151,12 @@ const ShowAllGroupBody: FunctionComponent = ({ group, get {alwaysVisibleItems.map((chat) => ( {getNavItem(chat)} ))} - {isExpanded && overflowItems.map((chat) => {getNavItem(chat)})} + {isExpanded && + overflowItems.map((chat, index) => ( + + {getNavItem(chat, undefined, index === 0 ? firstOverflowItemRef : undefined)} + + ))} {hasOverflowItems && ( = ({ group, get ); }; -interface CollapsibleConversationGroupProps { +interface ExpandableConversationGroupProps { group: ConversationGroup; children: ReactNode; } -const CollapsibleConversationGroup: FunctionComponent = ({ group, children }) => { - const toggleId = `chatbot-nav-group-${group.id}-toggle`; - const contentId = getCollapsibleGroupContentId(group.id); - const { isExpanded, onToggle, expandableSectionProps, expandableSectionToggleProps } = group.collapsible!; +const ExpandableConversationGroup: FunctionComponent = ({ group, children }) => { + const toggleId = getExpandableGroupToggleId(group.id); + const contentId = getExpandableGroupContentId(group.id); + const { isExpanded, onToggle, expandableSectionProps, expandableSectionToggleProps } = group.expandable!; useFocusFirstMenuItemOnExpand(isExpanded, contentId); return ( -
+ {group.label} + + } + aria-labelledby={toggleId} + {...group.menuGroupProps} > - - {group.label} - -
+ ); }; @@ -231,7 +258,7 @@ export interface ConversationGroupShowAll { label?: ReactNode; } -export interface ConversationGroupCollapsible { +export interface ConversationGroupExpandable { /** Whether the group content is expanded */ isExpanded: boolean; /** Callback when the group is toggled */ @@ -245,16 +272,16 @@ export interface ConversationGroupCollapsible { export interface ConversationGroup { /** Unique group id */ id: string; - /** Group label rendered as a MenuGroup heading or collapsible toggle label */ + /** 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 collapsible toggle */ + /** Custom group header that replaces the default label or expandable toggle */ header?: ReactNode; - /** When set, the group renders as a collapsible section */ - collapsible?: ConversationGroupCollapsible; + /** 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 */ @@ -406,10 +433,15 @@ export const ChatbotConversationHistoryNav: FunctionComponent ( + const getNavItem = ( + conversation: Conversation, + itemOverrides?: Partial, + itemRef?: Ref + ) => ( })} /* eslint-disable indent */ {...(conversation.menuItems @@ -425,6 +457,7 @@ export const ChatbotConversationHistoryNav: FunctionComponent {conversation.text} @@ -464,14 +497,16 @@ export const ChatbotConversationHistoryNav: FunctionComponent + {renderGroupBody(group)} - + ); } + const labelId = group.menuGroupProps?.titleId ?? getGroupLabelId(group.id); + return ( {renderGroupBody(group)} From 8aa580e741d7ffa946c2e5e3ac53405ac305f879 Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Tue, 4 Aug 2026 16:14:20 -0400 Subject: [PATCH 4/7] Address feedback --- .../ChatbotConversationHistoryNav.test.tsx | 58 ++++++- .../ChatbotConversationHistoryNav.tsx | 154 ++++++++++-------- 2 files changed, 135 insertions(+), 77 deletions(-) diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx index 63171bf44..d51c73cc6 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx @@ -767,7 +767,7 @@ describe('ChatbotConversationHistoryNav', () => { } ]; - render( + const { container } = render( { expect(pinnedHeading).toHaveAttribute('id', 'chatbot-nav-group-pinned-label'); expect(pinnedHeading.closest('section')).toHaveAttribute('aria-labelledby', 'chatbot-nav-group-pinned-label'); + const staticMenu = container.querySelector('.pf-v6-c-menu.pf-chatbot__history-menu'); + expect(staticMenu).toHaveAttribute('aria-labelledby', 'chatbot-nav-group-pinned-label'); + const chatsToggle = screen.getByRole('button', { name: 'Chats' }); expect(chatsToggle).toHaveAttribute('id', 'chatbot-nav-group-chats-toggle'); - expect(chatsToggle.closest('section')).toHaveAttribute('aria-labelledby', 'chatbot-nav-group-chats-toggle'); + + const expandableMenu = container.querySelectorAll('.pf-v6-c-menu.pf-chatbot__history-menu')[1]; + expect(expandableMenu).toHaveAttribute('aria-labelledby', 'chatbot-nav-group-chats-toggle'); }); - it('moves focus to the first menu item when an expandable group is expanded', async () => { + it('labels a shared static menu with all group title ids', () => { + const groups: ConversationGroup[] = [ + { + id: 'pinned', + label: 'Pinned chats', + items: initialConversations + }, + { + id: 'recent', + label: 'Recent chats', + items: [{ id: '2', text: 'Chatbot extension' }] + } + ]; + + const { container } = render( + + ); + + expect(container.querySelector('.pf-v6-c-menu.pf-chatbot__history-menu')).toHaveAttribute( + 'aria-labelledby', + 'chatbot-nav-group-pinned-label 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); @@ -819,11 +854,12 @@ describe('ChatbotConversationHistoryNav', () => { fireEvent.click(screen.getByRole('button', { name: 'Saved prompts' })); await waitFor(() => { - expect(screen.getByRole('menuitem', { name: 'Summarize this document' })).toHaveFocus(); + 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 group's menu items so they cannot dead-end keyboard navigation", () => { + it('does not render a collapsed expandable group menu', () => { const groups: ConversationGroup[] = [ { id: 'chats', @@ -847,7 +883,7 @@ describe('ChatbotConversationHistoryNav', () => { } ]; - render( + const { container } = render( { /> ); - // Collapsed content should not merely be visually hidden - it should not be in the - // document at all, otherwise the Menu's LI-based arrow key handler will still see it, - // try (and fail) to focus it, and dead-end navigation at the preceding item. + // 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(container.querySelectorAll('.pf-v6-c-menu.pf-chatbot__history-menu')).toHaveLength(1); + expect(container.querySelector('.pf-v6-c-menu.pf-chatbot__history-menu')).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(); diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx index 732ff0244..3e95b4c1c 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx @@ -2,7 +2,7 @@ // Chatbot Header - Chatbot Conversation History Nav // ============================================================================ import type { KeyboardEvent, FunctionComponent, ReactNode, Ref, RefObject } from 'react'; -import { useEffect, useLayoutEffect, useRef, Fragment, isValidElement } from 'react'; +import { useEffect, useRef, Fragment, isValidElement } from 'react'; // Import PatternFly components import { @@ -70,31 +70,12 @@ const getGroupLabelId = (groupId: string) => `chatbot-nav-group-${groupId}-label const getExpandableGroupToggleId = (groupId: string) => `chatbot-nav-group-${groupId}-toggle`; -const focusFirstGroupMenuItem = (contentId: string) => { - document - .getElementById(contentId) - ?.querySelector('ul button:not(:disabled), ul a:not([aria-disabled="true"])') - ?.focus(); -}; - const getShowAllToggleId = (groupId: string) => `chatbot-nav-group-${groupId}-show-all-toggle`; const focusElementById = (id: string) => { document.getElementById(id)?.focus(); }; -const useFocusFirstMenuItemOnExpand = (isExpanded: boolean, contentId: string) => { - const wasExpandedRef = useRef(isExpanded); - - useLayoutEffect(() => { - if (isExpanded && !wasExpandedRef.current) { - focusFirstGroupMenuItem(contentId); - } - - wasExpandedRef.current = isExpanded; - }, [isExpanded, contentId]); -}; - // 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 = ( @@ -184,27 +165,21 @@ const ExpandableConversationGroup: FunctionComponent - {group.label} - - } - aria-labelledby={toggleId} - {...group.menuGroupProps} > + + {group.label} + - {/* Only mount the collapsed group's menu items while expanded. ExpandableSection - only toggles a `hidden` attribute on its content wrapper, so if the items stayed - mounted while collapsed, the Menu's arrow-key handler (which finds navigable - elements via getElementsByTagName('LI') across the whole menu) would still see - them, try to focus them, and silently fail since hidden elements aren't focusable - - dead-ending keyboard navigation at the preceding item. */} {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] }]; + }, []); + +const getStaticMenuLabelId = (group: ConversationGroup) => group.menuGroupProps?.titleId ?? getGroupLabelId(group.id); + +const getStaticMenuLabelledBy = (groups: ConversationGroup[]) => + groups + .map((group) => group.menuGroupProps?.['aria-labelledby'] ?? getStaticMenuLabelId(group)) + .join(' ') + .trim(); + export interface Conversation { /** Conversation id */ id: string; @@ -497,15 +493,7 @@ export const ChatbotConversationHistoryNav: FunctionComponent - {renderGroupBody(group)} - - ); - } - - const labelId = group.menuGroupProps?.titleId ?? getGroupLabelId(group.id); + const labelId = getStaticMenuLabelId(group); return ( ( + + {content} + + ); + + const renderConversationMenuSegment = (segment: ConversationMenuSegment) => { + if (segment.type === 'static') { + return renderConversationMenu( + getStaticMenuLabelledBy(segment.groups), + <>{segment.groups.map(renderConversationGroup)}, + segment.groups.map((group) => group.id).join('-') + ); + } + + const toggleId = getExpandableGroupToggleId(segment.group.id); + + return ( + + {renderConversationMenu( + segment.group.menuGroupProps?.['aria-labelledby'] ?? toggleId, + renderGroupBody(segment.group) + )} + + ); + }; + const normalizeObjectGroups = (groupedConversations: { [key: string]: (Conversation | ReactNode)[] }) => Object.keys(groupedConversations).map((groupKey) => ({ id: groupKey, @@ -540,13 +563,18 @@ export const ChatbotConversationHistoryNav: FunctionComponent { if (Array.isArray(conversations)) { if (isConversationGroupArray(conversations)) { - return <>{conversations.map(renderConversationGroup)}; + return <>{buildConversationMenuSegments(conversations).map(renderConversationMenuSegment)}; } - return {renderConversationItems(conversations)}; + return renderConversationMenu( + undefined, + {renderConversationItems(conversations)} + ); } - return <>{normalizeObjectGroups(conversations).map(renderConversationGroup)}; + return ( + <>{buildConversationMenuSegments(normalizeObjectGroups(conversations)).map(renderConversationMenuSegment)} + ); }; // Menu Content @@ -564,17 +592,7 @@ export const ChatbotConversationHistoryNav: FunctionComponent; } - return ( - - {buildConversations()} - - ); + return buildConversations(); }; const renderDrawerContent = () => ( From d8d55f86211a3797e086b7f4b4da27f1005de2e8 Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Tue, 4 Aug 2026 17:46:06 -0400 Subject: [PATCH 5/7] Move aria-labelledby --- .../ChatbotConversationHistoryNav.test.tsx | 53 +++++++++++++----- .../ChatbotConversationHistoryNav.tsx | 55 ++++++++++++------- 2 files changed, 75 insertions(+), 33 deletions(-) diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx index d51c73cc6..36c186812 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx @@ -749,7 +749,7 @@ describe('ChatbotConversationHistoryNav', () => { expect(screen.getByRole('button', { name: 'Chats' })).toBeInTheDocument(); }); - it('labels each MenuGroup with aria-labelledby referencing its visible label', () => { + it('labels each MenuList with aria-labelledby referencing its visible label', () => { const groups: ConversationGroup[] = [ { id: 'pinned', @@ -767,7 +767,7 @@ describe('ChatbotConversationHistoryNav', () => { } ]; - const { container } = render( + render( { expect(pinnedHeading).toHaveAttribute('id', 'chatbot-nav-group-pinned-label'); expect(pinnedHeading.closest('section')).toHaveAttribute('aria-labelledby', 'chatbot-nav-group-pinned-label'); - const staticMenu = container.querySelector('.pf-v6-c-menu.pf-chatbot__history-menu'); - expect(staticMenu).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'); - const expandableMenu = container.querySelectorAll('.pf-v6-c-menu.pf-chatbot__history-menu')[1]; - expect(expandableMenu).toHaveAttribute('aria-labelledby', '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 a shared static menu with all group title ids', () => { + it('labels each menu list in a shared static menu with its group title id', () => { const groups: ConversationGroup[] = [ { id: 'pinned', @@ -805,7 +828,7 @@ describe('ChatbotConversationHistoryNav', () => { } ]; - const { container } = render( + render( { /> ); - expect(container.querySelector('.pf-v6-c-menu.pf-chatbot__history-menu')).toHaveAttribute( + 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-pinned-label chatbot-nav-group-recent-label' + 'chatbot-nav-group-recent-label' ); }); @@ -883,7 +910,7 @@ describe('ChatbotConversationHistoryNav', () => { } ]; - const { container } = render( + 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(container.querySelectorAll('.pf-v6-c-menu.pf-chatbot__history-menu')).toHaveLength(1); - expect(container.querySelector('.pf-v6-c-menu.pf-chatbot__history-menu')).toHaveAttribute( + expect(screen.getAllByRole('menu')).toHaveLength(1); + expect(screen.getByRole('menu', { name: 'Chats' })).toHaveAttribute( 'aria-labelledby', 'chatbot-nav-group-chats-label' ); diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx index 3e95b4c1c..34bdadde6 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx @@ -72,6 +72,35 @@ const getExpandableGroupToggleId = (groupId: string) => `chatbot-nav-group-${gro 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 getMenuListLabelText = (label: ReactNode) => + typeof label === 'string' || typeof label === 'number' ? String(label) : undefined; + +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(); }; @@ -128,7 +157,7 @@ const ShowAllGroupBody: FunctionComponent = ({ group, get return ( <> - + {alwaysVisibleItems.map((chat) => ( {getNavItem(chat)} ))} @@ -212,14 +241,6 @@ const buildConversationMenuSegments = (groups: ConversationGroup[]): Conversatio return [...segments, { type: 'static', groups: [group] }]; }, []); -const getStaticMenuLabelId = (group: ConversationGroup) => group.menuGroupProps?.titleId ?? getGroupLabelId(group.id); - -const getStaticMenuLabelledBy = (groups: ConversationGroup[]) => - groups - .map((group) => group.menuGroupProps?.['aria-labelledby'] ?? getStaticMenuLabelId(group)) - .join(' ') - .trim(); - export interface Conversation { /** Conversation id */ id: string; @@ -477,7 +498,9 @@ export const ChatbotConversationHistoryNav: FunctionComponent - {renderConversationItems(group.items, group.id)} + + {renderConversationItems(group.items, group.id)} + {group.footer} ); @@ -510,14 +533,13 @@ export const ChatbotConversationHistoryNav: FunctionComponent ( + const renderConversationMenu = (content: ReactNode, key?: string) => ( {content} @@ -527,20 +549,14 @@ export const ChatbotConversationHistoryNav: FunctionComponent { if (segment.type === 'static') { return renderConversationMenu( - getStaticMenuLabelledBy(segment.groups), <>{segment.groups.map(renderConversationGroup)}, segment.groups.map((group) => group.id).join('-') ); } - const toggleId = getExpandableGroupToggleId(segment.group.id); - return ( - {renderConversationMenu( - segment.group.menuGroupProps?.['aria-labelledby'] ?? toggleId, - renderGroupBody(segment.group) - )} + {renderConversationMenu(renderGroupBody(segment.group))} ); }; @@ -567,7 +583,6 @@ export const ChatbotConversationHistoryNav: FunctionComponent{renderConversationItems(conversations)} ); } From 6d3235d8e8f3d3446ef90d30f22c6471db5c220c Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Wed, 5 Aug 2026 10:09:10 -0400 Subject: [PATCH 6/7] Fix linter issues --- .../ChatbotConversationHistoryNav.test.tsx | 8 ++++++-- .../ChatbotConversationHistoryNav.tsx | 11 ++--------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx index 36c186812..478d8fe5a 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx @@ -785,7 +785,9 @@ describe('ChatbotConversationHistoryNav', () => { 'aria-labelledby', 'chatbot-nav-group-pinned-label' ); - expect(document.querySelector('ul.pf-v6-c-menu__list[aria-labelledby="chatbot-nav-group-pinned-label"]')).toBeTruthy(); + 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'); @@ -794,7 +796,9 @@ describe('ChatbotConversationHistoryNav', () => { 'aria-labelledby', 'chatbot-nav-group-chats-toggle' ); - expect(document.querySelector('ul.pf-v6-c-menu__list[aria-labelledby="chatbot-nav-group-chats-toggle"]')).toBeTruthy(); + 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', () => { diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx index 34bdadde6..4232d0312 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx @@ -87,9 +87,6 @@ const getMenuListLabelledBy = (group: { return group.menuGroupProps?.['aria-labelledby'] ?? getStaticMenuLabelId(group); }; -const getMenuListLabelText = (label: ReactNode) => - typeof label === 'string' || typeof label === 'number' ? String(label) : undefined; - const getMenuListProps = (group: { id: string; label: ReactNode; @@ -498,9 +495,7 @@ export const ChatbotConversationHistoryNav: FunctionComponent - - {renderConversationItems(group.items, group.id)} - + {renderConversationItems(group.items, group.id)} {group.footer} ); @@ -582,9 +577,7 @@ export const ChatbotConversationHistoryNav: FunctionComponent{buildConversationMenuSegments(conversations).map(renderConversationMenuSegment)}; } - return renderConversationMenu( - {renderConversationItems(conversations)} - ); + return renderConversationMenu({renderConversationItems(conversations)}); } return ( From 2d02e61b909c238cd1162c08cf861e5113b997b6 Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Thu, 6 Aug 2026 14:45:47 -0400 Subject: [PATCH 7/7] Adjust spacing --- .../ChatbotConversationHistoryNav.scss | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss index eec98d439..052b7c2b9 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss @@ -77,8 +77,8 @@ } .pf-chatbot__menu-group-toggle { - padding-block-start: var(--pf-t--global--spacer--sm); - padding-block-end: var(--pf-t--global--spacer--sm); + padding-block-start: var(--pf-t--global--spacer--xs); + padding-block-end: var(--pf-t--global--spacer--xs); } .pf-chatbot__menu-item-header > .pf-v6-c-menu__group-title { @@ -94,8 +94,7 @@ } .pf-v6-c-button.pf-m-link { - --pf-v6-c-button--PaddingBlockStart: 0; - --pf-v6-c-button--PaddingBlockEnd: 0; + --pf-v6-c-button--BorderRadius: var(--pf-t--global--border--radius--small); --pf-v6-c-button--PaddingInlineStart: var(--pf-t--global--spacer--sm); --pf-v6-c-button--PaddingInlineEnd: var(--pf-t--global--spacer--sm); color: inherit;