Skip to content

Commit 68c1f17

Browse files
committed
Keyboard updates
1 parent ed64845 commit 68c1f17

4 files changed

Lines changed: 479 additions & 75 deletions

File tree

packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotHeaderDrawerWithCollapsibleGroups.tsx

Lines changed: 7 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import ChatbotConversationHistoryNav, {
44
Conversation,
55
ConversationGroup
66
} from '@patternfly/chatbot/dist/dynamic/ChatbotConversationHistoryNav';
7-
import { Checkbox, MenuItem } from '@patternfly/react-core';
7+
import { Checkbox } from '@patternfly/react-core';
88

99
const pinnedChats: Conversation[] = [
1010
{
@@ -54,37 +54,6 @@ export const ChatbotHeaderDrawerWithCollapsibleGroupsDemo: FunctionComponent = (
5454
const [isSavedPromptsExpanded, setIsSavedPromptsExpanded] = useState(false);
5555
const [isShowingAllChats, setIsShowingAllChats] = useState(false);
5656

57-
const visibleChats = isShowingAllChats ? recentChats : recentChats.slice(0, VISIBLE_CHAT_COUNT);
58-
const hiddenChatCount = recentChats.length - VISIBLE_CHAT_COUNT;
59-
60-
const renderExpandButton = () => {
61-
if (isShowingAllChats) {
62-
return [
63-
<MenuItem
64-
key="show-some-chats"
65-
itemId="show-some-chats"
66-
className="pf-chatbot__menu-item pf-chatbot__menu-item--show-button"
67-
onClick={() => setIsShowingAllChats(false)}
68-
>
69-
Show less
70-
</MenuItem>
71-
];
72-
}
73-
if (hiddenChatCount > 0 && !isShowingAllChats) {
74-
return [
75-
<MenuItem
76-
key="show-all-chats"
77-
itemId="show-all-chats"
78-
className="pf-chatbot__menu-item pf-chatbot__menu-item--show-button"
79-
onClick={() => setIsShowingAllChats(true)}
80-
>
81-
{`Show all (${recentChats.length})`}
82-
</MenuItem>
83-
];
84-
}
85-
return [];
86-
};
87-
8857
const conversations: ConversationGroup[] = [
8958
{
9059
id: 'pinned',
@@ -94,7 +63,12 @@ export const ChatbotHeaderDrawerWithCollapsibleGroupsDemo: FunctionComponent = (
9463
{
9564
id: 'chats',
9665
label: 'Chats',
97-
items: [...visibleChats, ...renderExpandButton()]
66+
items: recentChats,
67+
showAll: {
68+
visibleCount: VISIBLE_CHAT_COUNT,
69+
isExpanded: isShowingAllChats,
70+
onToggle: setIsShowingAllChats
71+
}
9872
},
9973
{
10074
id: 'saved-prompts',

packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,14 @@
123123
border-radius: var(--pf-t--global--border--radius--small);
124124
}
125125

126-
.pf-chatbot__menu-item--show-button {
126+
// "Show all" / "Show less" toggle
127+
// Rendered as a regular MenuItem positioned after the (conditionally rendered)
128+
// overflow items, so it participates in the menu's normal roving tabindex and
129+
// arrow-key navigation, and stays in a fixed position at the bottom of the list.
130+
.pf-chatbot__menu-show-all-toggle {
127131
color: var(--pf-t--global--text--color--link--default);
128132
font-size: var(--pf-t--global--font--size--body--default);
133+
font-weight: var(--pf-t--global--font--weight--body--default);
129134
}
130135

131136
li.pf-chatbot__menu-item:hover::after {

packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx

Lines changed: 300 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { ChatbotDisplayMode } from '../Chatbot/Chatbot';
55
import ChatbotConversationHistoryNav, { Conversation, ConversationGroup } from './ChatbotConversationHistoryNav';
66
import { EmptyStateStatus, Spinner, MenuItem } from '@patternfly/react-core';
77
import { BellIcon, OutlinedCommentsIcon, SearchIcon } from '@patternfly/react-icons';
8-
import { ComponentType } from 'react';
8+
import { ComponentType, useState } from 'react';
99

1010
const ERROR = {
1111
bodyText: (
@@ -749,6 +749,92 @@ describe('ChatbotConversationHistoryNav', () => {
749749
expect(screen.getByRole('button', { name: 'Chats' })).toBeInTheDocument();
750750
});
751751

752+
it('moves focus to the first menu item when a collapsible group is expanded', async () => {
753+
const ExpandableGroupDemo = () => {
754+
const [isExpanded, setIsExpanded] = useState(false);
755+
756+
return (
757+
<ChatbotConversationHistoryNav
758+
onDrawerToggle={onDrawerToggle}
759+
isDrawerOpen={true}
760+
displayMode={ChatbotDisplayMode.embedded}
761+
setIsDrawerOpen={jest.fn()}
762+
conversations={[
763+
{
764+
id: 'saved-prompts',
765+
label: 'Saved prompts',
766+
collapsible: {
767+
isExpanded,
768+
onToggle: setIsExpanded
769+
},
770+
items: [
771+
{ id: '7', text: 'Summarize this document' },
772+
{ id: '8', text: 'Draft a release announcement' }
773+
]
774+
}
775+
]}
776+
/>
777+
);
778+
};
779+
780+
render(<ExpandableGroupDemo />);
781+
782+
fireEvent.click(screen.getByRole('button', { name: 'Saved prompts' }));
783+
784+
await waitFor(() => {
785+
expect(screen.getByRole('menuitem', { name: 'Summarize this document' })).toHaveFocus();
786+
});
787+
});
788+
789+
it("does not render a collapsed group's menu items so they cannot dead-end keyboard navigation", () => {
790+
const groups: ConversationGroup[] = [
791+
{
792+
id: 'chats',
793+
label: 'Chats',
794+
items: [
795+
{ id: '2', text: 'Chat two' },
796+
{ id: '3', text: 'Chat three' }
797+
]
798+
},
799+
{
800+
id: 'saved-prompts',
801+
label: 'Saved prompts',
802+
collapsible: {
803+
isExpanded: false,
804+
onToggle: jest.fn()
805+
},
806+
items: [
807+
{ id: '7', text: 'Summarize this document' },
808+
{ id: '8', text: 'Draft a release announcement' }
809+
]
810+
}
811+
];
812+
813+
render(
814+
<ChatbotConversationHistoryNav
815+
onDrawerToggle={onDrawerToggle}
816+
isDrawerOpen={true}
817+
displayMode={ChatbotDisplayMode.embedded}
818+
setIsDrawerOpen={jest.fn()}
819+
conversations={groups}
820+
/>
821+
);
822+
823+
// Collapsed content should not merely be visually hidden - it should not be in the
824+
// document at all, otherwise the Menu's LI-based arrow key handler will still see it,
825+
// try (and fail) to focus it, and dead-end navigation at the preceding item.
826+
expect(screen.queryByRole('menuitem', { name: 'Summarize this document' })).not.toBeInTheDocument();
827+
expect(screen.queryByRole('menuitem', { name: 'Draft a release announcement' })).not.toBeInTheDocument();
828+
829+
const firstItem = screen.getByRole('menuitem', { name: 'Chat two' });
830+
const lastItem = screen.getByRole('menuitem', { name: 'Chat three' });
831+
832+
lastItem.focus();
833+
fireEvent.keyDown(lastItem, { key: 'ArrowDown' });
834+
835+
expect(firstItem).toHaveFocus();
836+
});
837+
752838
it('collapses and expands a group when collapsible.onToggle is called', async () => {
753839
const onToggle = jest.fn();
754840
const groups: ConversationGroup[] = [
@@ -830,6 +916,219 @@ describe('ChatbotConversationHistoryNav', () => {
830916
expect(screen.getByTestId('group-footer')).toBeInTheDocument();
831917
});
832918

919+
it('keeps focus on the toggle button when show all is expanded', async () => {
920+
const recentChats: Conversation[] = [
921+
{ id: '2', text: 'Chat two' },
922+
{ id: '3', text: 'Chat three' },
923+
{ id: '4', text: 'Chat four' },
924+
{ id: '5', text: 'Chat five' },
925+
{ id: '6', text: 'Chat six' }
926+
];
927+
const VISIBLE_CHAT_COUNT = 3;
928+
929+
const ShowAllDemo = () => {
930+
const [isShowingAllChats, setIsShowingAllChats] = useState(false);
931+
932+
return (
933+
<ChatbotConversationHistoryNav
934+
onDrawerToggle={onDrawerToggle}
935+
isDrawerOpen={true}
936+
displayMode={ChatbotDisplayMode.embedded}
937+
setIsDrawerOpen={jest.fn()}
938+
conversations={[
939+
{
940+
id: 'chats',
941+
label: 'Chats',
942+
items: recentChats,
943+
showAll: {
944+
visibleCount: VISIBLE_CHAT_COUNT,
945+
isExpanded: isShowingAllChats,
946+
onToggle: setIsShowingAllChats
947+
}
948+
}
949+
]}
950+
/>
951+
);
952+
};
953+
954+
render(<ShowAllDemo />);
955+
956+
fireEvent.click(screen.getByRole('menuitem', { name: /Show all \(5\)/i }));
957+
958+
await waitFor(() => {
959+
expect(screen.getByRole('menuitem', { name: 'Show less' })).toHaveFocus();
960+
});
961+
});
962+
963+
it('moves focus back to the toggle when show all is collapsed', async () => {
964+
const recentChats: Conversation[] = [
965+
{ id: '2', text: 'Chat two' },
966+
{ id: '3', text: 'Chat three' },
967+
{ id: '4', text: 'Chat four' },
968+
{ id: '5', text: 'Chat five' }
969+
];
970+
971+
const ShowAllDemo = () => {
972+
const [isShowingAllChats, setIsShowingAllChats] = useState(true);
973+
974+
return (
975+
<ChatbotConversationHistoryNav
976+
onDrawerToggle={onDrawerToggle}
977+
isDrawerOpen={true}
978+
displayMode={ChatbotDisplayMode.embedded}
979+
setIsDrawerOpen={jest.fn()}
980+
conversations={[
981+
{
982+
id: 'chats',
983+
label: 'Chats',
984+
items: recentChats,
985+
showAll: {
986+
visibleCount: 2,
987+
isExpanded: isShowingAllChats,
988+
onToggle: setIsShowingAllChats
989+
}
990+
}
991+
]}
992+
/>
993+
);
994+
};
995+
996+
render(<ShowAllDemo />);
997+
998+
fireEvent.click(screen.getByRole('menuitem', { name: 'Show less' }));
999+
1000+
await waitFor(() => {
1001+
expect(screen.getByRole('menuitem', { name: /Show all \(4\)/i })).toHaveFocus();
1002+
});
1003+
});
1004+
1005+
it('renders overflow items above the show all toggle so it stays in a fixed position', () => {
1006+
const recentChats: Conversation[] = [
1007+
{ id: '2', text: 'Chat two' },
1008+
{ id: '3', text: 'Chat three' },
1009+
{ id: '4', text: 'Chat four' }
1010+
];
1011+
1012+
render(
1013+
<ChatbotConversationHistoryNav
1014+
onDrawerToggle={onDrawerToggle}
1015+
isDrawerOpen={true}
1016+
displayMode={ChatbotDisplayMode.embedded}
1017+
setIsDrawerOpen={jest.fn()}
1018+
conversations={[
1019+
{
1020+
id: 'chats',
1021+
label: 'Chats',
1022+
items: recentChats,
1023+
showAll: {
1024+
visibleCount: 1,
1025+
isExpanded: true,
1026+
onToggle: jest.fn()
1027+
}
1028+
}
1029+
]}
1030+
/>
1031+
);
1032+
1033+
const overflowItem = screen.getByRole('menuitem', { name: 'Chat four' });
1034+
const toggle = screen.getByRole('menuitem', { name: 'Show less' });
1035+
const isToggleAfterOverflowItem = Boolean(
1036+
// eslint-disable-next-line no-bitwise
1037+
overflowItem.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING
1038+
);
1039+
1040+
expect(isToggleAfterOverflowItem).toBe(true);
1041+
});
1042+
1043+
it('allows arrow key navigation to flow through the show all toggle like any other menu item', async () => {
1044+
const recentChats: Conversation[] = [
1045+
{ id: '2', text: 'Chat two' },
1046+
{ id: '3', text: 'Chat three' },
1047+
{ id: '4', text: 'Chat four' }
1048+
];
1049+
1050+
render(
1051+
<ChatbotConversationHistoryNav
1052+
onDrawerToggle={onDrawerToggle}
1053+
isDrawerOpen={true}
1054+
displayMode={ChatbotDisplayMode.embedded}
1055+
setIsDrawerOpen={jest.fn()}
1056+
conversations={[
1057+
{
1058+
id: 'chats',
1059+
label: 'Chats',
1060+
items: recentChats,
1061+
showAll: {
1062+
visibleCount: 2,
1063+
isExpanded: false,
1064+
onToggle: jest.fn()
1065+
}
1066+
}
1067+
]}
1068+
/>
1069+
);
1070+
1071+
const lastVisibleItem = screen.getByRole('menuitem', { name: 'Chat three' });
1072+
const toggle = screen.getByRole('menuitem', { name: /Show all/i });
1073+
1074+
lastVisibleItem.focus();
1075+
fireEvent.keyDown(lastVisibleItem, { key: 'ArrowDown' });
1076+
1077+
await waitFor(() => {
1078+
expect(toggle).toHaveFocus();
1079+
});
1080+
1081+
fireEvent.keyDown(toggle, { key: 'ArrowUp' });
1082+
1083+
await waitFor(() => {
1084+
expect(lastVisibleItem).toHaveFocus();
1085+
});
1086+
});
1087+
1088+
it('preserves custom menu item identity when preceding items change', () => {
1089+
const renderShowAll = () => (
1090+
<MenuItem key="show-all-chats" itemId="show-all-chats">
1091+
Show all
1092+
</MenuItem>
1093+
);
1094+
1095+
const { rerender } = render(
1096+
<ChatbotConversationHistoryNav
1097+
onDrawerToggle={onDrawerToggle}
1098+
isDrawerOpen={true}
1099+
displayMode={ChatbotDisplayMode.fullscreen}
1100+
setIsDrawerOpen={jest.fn()}
1101+
conversations={[
1102+
{
1103+
id: 'chats',
1104+
label: 'Chats',
1105+
items: [initialConversations[0], renderShowAll()]
1106+
}
1107+
]}
1108+
/>
1109+
);
1110+
1111+
const showAllBefore = screen.getByRole('menuitem', { name: 'Show all' });
1112+
1113+
rerender(
1114+
<ChatbotConversationHistoryNav
1115+
onDrawerToggle={onDrawerToggle}
1116+
isDrawerOpen={true}
1117+
displayMode={ChatbotDisplayMode.fullscreen}
1118+
setIsDrawerOpen={jest.fn()}
1119+
conversations={[
1120+
{
1121+
id: 'chats',
1122+
label: 'Chats',
1123+
items: [initialConversations[0], initialConversations[1], initialConversations[2], renderShowAll()]
1124+
}
1125+
]}
1126+
/>
1127+
);
1128+
1129+
expect(screen.getByRole('menuitem', { name: 'Show all' })).toBe(showAllBefore);
1130+
});
1131+
8331132
it('passes collapsible expandableSectionProps from ConversationGroup', () => {
8341133
const groups: ConversationGroup[] = [
8351134
{

0 commit comments

Comments
 (0)