@@ -5,7 +5,7 @@ import { ChatbotDisplayMode } from '../Chatbot/Chatbot';
55import ChatbotConversationHistoryNav , { Conversation , ConversationGroup } from './ChatbotConversationHistoryNav' ;
66import { EmptyStateStatus , Spinner , MenuItem } from '@patternfly/react-core' ;
77import { BellIcon , OutlinedCommentsIcon , SearchIcon } from '@patternfly/react-icons' ;
8- import { ComponentType } from 'react' ;
8+ import { ComponentType , useState } from 'react' ;
99
1010const 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 : / S h o w a l l \( 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 : / S h o w a l l \( 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 : / S h o w a l l / 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