Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, FunctionComponent } from 'react';
import { useState, useEffect, useRef, FunctionComponent } from 'react';
import { ChatbotDisplayMode } from '@patternfly/chatbot/dist/dynamic/Chatbot';
import ChatbotConversationHistoryNav, {
Conversation
Expand Down Expand Up @@ -71,8 +71,28 @@ export const ChatbotHeaderTitleDemo: FunctionComponent = () => {
const [hasError, setHasError] = useState(false);
const [isEmpty, setIsEmpty] = useState(false);
const [hasNoResults, setHasNoResults] = useState(false);
const [announcement, setAnnouncement] = useState('');
const [debouncedAnnouncement, setDebouncedAnnouncement] = useState('');
const announcementTimeoutRef = useRef<NodeJS.Timeout>();
const displayMode = ChatbotDisplayMode.embedded;

// Debounce announcement updates to prevent screen reader overload
useEffect(() => {
if (announcementTimeoutRef.current) {
clearTimeout(announcementTimeoutRef.current);
}

announcementTimeoutRef.current = setTimeout(() => {
setDebouncedAnnouncement(announcement);
}, 500);

return () => {
if (announcementTimeoutRef.current) {
clearTimeout(announcementTimeoutRef.current);
}
};
}, [announcement]);

const findMatchingItems = (targetValue: string) => {
const filteredConversations = Object.entries(initialConversations).reduce((acc, [key, items]) => {
const filteredItems = items.filter((item) => item.text.toLowerCase().includes(targetValue.toLowerCase()));
Expand Down Expand Up @@ -168,12 +188,23 @@ export const ChatbotHeaderTitleDemo: FunctionComponent = () => {
handleTextInputChange={(value: string) => {
if (value === '') {
setConversations(initialConversations);
setAnnouncement('');
setDebouncedAnnouncement('');
setHasNoResults(false);
} else {
// this is where you would perform search on the items in the drawer
// and update the state
const newConversations: { [key: string]: Conversation[] } = findMatchingItems(value);
const totalCount = Object.values(newConversations).flat().length;
const newAnnouncement =
totalCount === 1
? `${totalCount} conversation matches "${value}"`
: `${totalCount} conversations match "${value}"`;
setAnnouncement(newAnnouncement);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More of a nit since it's example code, but it might be good to maybe have a debounce when setting this announcement text. Granted it may be due to the dual live regions in the component code as well, but when trying to search "red" in the example, VO stops to tell me the results for just "r"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I gave this a go! Let me know what you think.

setConversations(newConversations);
}
// this is where you would perform search on the items in the drawer
// and update the state
const newConversations: { [key: string]: Conversation[] } = findMatchingItems(value);
setConversations(newConversations);
}}
searchInputScreenReaderText={debouncedAnnouncement}
drawerContent={<div>Drawer content</div>}
isLoading={isLoading}
errorState={hasError ? ERROR : undefined}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@
// Chatbot Header - Menu
// ============================================================================
.pf-chatbot__history {
// hide from view but not assistive technologies
// https://css-tricks.com/inclusively-hidden/
.pf-chatbot__filter-announcement {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}

.pf-chatbot__drawer-backdrop {
position: absolute;
border-radius: var(--pf-t--global--border--radius--medium);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@
const NO_RESULTS = {
bodyText: 'Adjust your search query and try again. Check your spelling or try a more general term.',
titleText: 'No results found',
icon: SearchIcon as ComponentType<any>

Check warning on line 28 in packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx

View workflow job for this annotation

GitHub Actions / call-build-lint-test-workflow / lint

Unexpected any. Specify a different type
};

const EMPTY_STATE = {
bodyText: 'Access timely assistance by starting a conversation with an AI model.',
titleText: 'Start a new chat',
icon: OutlinedCommentsIcon as ComponentType<any>

Check warning on line 34 in packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx

View workflow job for this annotation

GitHub Actions / call-build-lint-test-workflow / lint

Unexpected any. Specify a different type
};

const ERROR_WITHOUT_BUTTON = {
Expand Down Expand Up @@ -348,7 +348,7 @@
).toBeTruthy();
expect(screen.getByRole('button', { name: /Close drawer panel/i })).toBeTruthy();
expect(screen.getByRole('button', { name: /Loading... Reload/i })).toBeTruthy();
expect(screen.getByRole('textbox', { name: /Filter menu items/i })).toBeTruthy();
expect(screen.getByRole('textbox', { name: /Search previous conversations/i })).toBeTruthy();
expect(screen.getByRole('heading', { name: /Could not load chat history/i })).toBeTruthy();
});

Expand All @@ -372,7 +372,7 @@
).toBeTruthy();
expect(screen.getByRole('button', { name: /Close drawer panel/i })).toBeTruthy();
expect(screen.queryByRole('button', { name: /Loading... Reload/i })).toBeFalsy();
expect(screen.getByRole('textbox', { name: /Filter menu items/i })).toBeTruthy();
expect(screen.getByRole('textbox', { name: /Search previous conversations/i })).toBeTruthy();
expect(screen.getByRole('heading', { name: /Could not load chat history/i })).toBeTruthy();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ export interface ChatbotConversationHistoryNavProps extends DrawerProps {
navTitleIcon?: React.ReactNode;
/** Title header level */
navTitleProps?: Partial<TitleProps>;
/** Visually hidden text that gets announced by assistive technologies. Should be used to convey the result count when the search input value changes. */
searchInputScreenReaderText?: string;
}

export const ChatbotConversationHistoryNav: FunctionComponent<ChatbotConversationHistoryNavProps> = ({
Expand All @@ -157,7 +159,7 @@ export const ChatbotConversationHistoryNav: FunctionComponent<ChatbotConversatio
onNewChat,
newChatButtonProps,
searchInputPlaceholder = 'Search previous conversations...',
searchInputAriaLabel = 'Filter menu items',
searchInputAriaLabel = 'Search previous conversations',
searchInputProps,
handleTextInputChange,
displayMode,
Expand All @@ -179,6 +181,7 @@ export const ChatbotConversationHistoryNav: FunctionComponent<ChatbotConversatio
title = 'Chat history',
navTitleProps,
navTitleIcon = <OutlinedClockIcon />,
searchInputScreenReaderText,
...props
}: ChatbotConversationHistoryNavProps) => {
const drawerRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -309,6 +312,9 @@ export const ChatbotConversationHistoryNav: FunctionComponent<ChatbotConversatio
placeholder={searchInputPlaceholder}
{...searchInputProps}
/>
{searchInputScreenReaderText && (
<div className="pf-chatbot__filter-announcement">{searchInputScreenReaderText}</div>
)}
</div>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ describe('ChatbotHeaderMenu', () => {
it('should call onMenuToggle when ChatbotHeaderMenu button is clicked', () => {
const onMenuToggle = jest.fn();
render(<ChatbotHeaderMenu className="custom-header-menu" onMenuToggle={onMenuToggle} />);
fireEvent.click(screen.getByRole('button', { name: 'Toggle menu' }));
fireEvent.click(screen.getByRole('button', { name: 'Chat history menu' }));

expect(onMenuToggle).toHaveBeenCalled();
});
Expand Down
4 changes: 2 additions & 2 deletions packages/module/src/ChatbotHeader/ChatbotHeaderMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ const ChatbotHeaderMenuBase: FunctionComponent<ChatbotHeaderMenuProps> = ({
className,
onMenuToggle,
tooltipProps,
menuAriaLabel = 'Toggle menu',
menuAriaLabel = 'Chat history menu',
innerRef,
tooltipContent = 'Menu',
tooltipContent = 'Chat history menu',
isCompact,
...props
}: ChatbotHeaderMenuProps) => (
Expand Down
8 changes: 4 additions & 4 deletions packages/module/src/MessageBox/JumpButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,20 @@ import userEvent from '@testing-library/user-event';
describe('JumpButton', () => {
it('should render top button correctly', () => {
render(<JumpButton position="top" onClick={jest.fn()} />);
expect(screen.getByRole('button', { name: /Jump top/i })).toBeTruthy();
expect(screen.getByRole('button', { name: /Back to top/i })).toBeTruthy();
});
it('should render bottom button correctly', () => {
render(<JumpButton position="bottom" onClick={jest.fn()} />);
expect(screen.getByRole('button', { name: /Jump bottom/i })).toBeTruthy();
expect(screen.getByRole('button', { name: /Back to bottom/i })).toBeTruthy();
});
it('should call onClick appropriately', async () => {
const spy = jest.fn();
render(<JumpButton position="bottom" onClick={spy} />);
await userEvent.click(screen.getByRole('button', { name: /Jump bottom/i }));
await userEvent.click(screen.getByRole('button', { name: /Back to bottom/i }));
expect(spy).toHaveBeenCalledTimes(1);
});
it('should be hidden if isHidden prop is used', async () => {
render(<JumpButton position="bottom" onClick={jest.fn()} isHidden />);
expect(screen.queryByRole('button', { name: /Jump bottom/i })).toBeFalsy();
expect(screen.queryByRole('button', { name: /Back to bottom/i })).toBeFalsy();
});
});
24 changes: 20 additions & 4 deletions packages/module/src/MessageBox/JumpButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import type { FunctionComponent } from 'react';

// Import PatternFly components
import { Button, Tooltip, Icon } from '@patternfly/react-core';
import { Button, Tooltip, Icon, TooltipProps, ButtonProps } from '@patternfly/react-core';

import { ArrowUpIcon } from '@patternfly/react-icons/dist/esm/icons/arrow-up-icon';
import { ArrowDownIcon } from '@patternfly/react-icons/dist/esm/icons/arrow-down-icon';
Expand All @@ -16,16 +16,32 @@ export interface JumpButtonProps {
onClick: () => void;
/** Flag to change the visibilty of the button */
isHidden?: boolean;
/** Additional props passed to jump buttons */
jumpButtonProps?: ButtonProps;
/** Additional props passed to tooltip */
jumpButtonTooltipProps?: TooltipProps;
}

const JumpButton: FunctionComponent<JumpButtonProps> = ({ position, isHidden, onClick }: JumpButtonProps) =>
const JumpButton: FunctionComponent<JumpButtonProps> = ({
position,
isHidden,
onClick,
jumpButtonProps,
jumpButtonTooltipProps
}: JumpButtonProps) =>
isHidden ? null : (
<Tooltip id={`pf-chatbot__tooltip--jump-${position}`} content={`Back to ${position}`} position="top">
<Tooltip
id={`pf-chatbot__tooltip--jump-${position}`}
content={`Back to ${position}`}
position="top"
{...jumpButtonTooltipProps}
>
<Button
variant="plain"
className={`pf-chatbot__jump pf-chatbot__jump--${position}`}
aria-label={`Jump ${position}`}
aria-label={`Back to ${position}`}
onClick={onClick}
{...jumpButtonProps}
>
<Icon iconSize="lg" isInline>
{position === 'top' ? <ArrowUpIcon /> : <ArrowDownIcon />}
Expand Down
4 changes: 2 additions & 2 deletions packages/module/src/MessageBox/MessageBox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ describe('MessageBox', () => {
});

await waitFor(() => {
userEvent.click(screen.getByRole('button', { name: /Jump bottom/i }));
userEvent.click(screen.getByRole('button', { name: /Back to bottom/i }));
expect(spy).toHaveBeenCalled();
});
});
Expand All @@ -85,7 +85,7 @@ describe('MessageBox', () => {
region.dispatchEvent(new Event('scroll'));
});
await waitFor(() => {
userEvent.click(screen.getByRole('button', { name: /Jump top/i }));
userEvent.click(screen.getByRole('button', { name: /Back to top/i }));
expect(spy).toHaveBeenCalled();
});
});
Expand Down
23 changes: 22 additions & 1 deletion packages/module/src/MessageBox/MessageBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
WheelEventHandler
} from 'react';
import JumpButton from './JumpButton';
import { ButtonProps, TooltipProps } from '@patternfly/react-core';

export interface MessageBoxProps extends HTMLProps<HTMLDivElement> {
/** Content that can be announced, such as a new message, for screen readers */
Expand All @@ -38,6 +39,14 @@ export interface MessageBoxProps extends HTMLProps<HTMLDivElement> {
onScrollToBottomClick?: () => void;
/** Flag to enable automatic scrolling when new messages are added */
enableSmartScroll?: boolean;
/** Props passed to top jump button */
jumpButtonTopProps?: ButtonProps;
/** Props passed to bottom jump button */
jumpButtonBottomProps?: ButtonProps;
/** Props passed to top jump button tooltip */
jumpButtonTopTooltipProps?: TooltipProps;
/** Props passed to top jump button tooltip */
jumpButtonBottomTooltipProps?: TooltipProps;
}

export interface MessageBoxHandle extends HTMLDivElement {
Expand All @@ -60,6 +69,10 @@ export const MessageBox = forwardRef(
onScrollToTopClick,
onScrollToBottomClick,
enableSmartScroll = false,
jumpButtonTopProps,
jumpButtonBottomProps,
jumpButtonBottomTooltipProps,
jumpButtonTopTooltipProps,
...props
}: MessageBoxProps,
ref: ForwardedRef<MessageBoxHandle | null>
Expand Down Expand Up @@ -305,7 +318,13 @@ export const MessageBox = forwardRef(

return (
<>
<JumpButton position="top" isHidden={isOverflowing && atTop} onClick={scrollToTop} />
<JumpButton
position="top"
isHidden={isOverflowing && atTop}
onClick={scrollToTop}
jumpButtonProps={jumpButtonTopProps}
jumpButtonTooltipProps={jumpButtonTopTooltipProps}
/>
<div
role="region"
tabIndex={0}
Expand All @@ -324,6 +343,8 @@ export const MessageBox = forwardRef(
position="bottom"
isHidden={isOverflowing && atBottom}
onClick={() => scrollToBottom({ resumeSmartScroll: true })}
jumpButtonProps={jumpButtonBottomProps}
jumpButtonTooltipProps={jumpButtonBottomTooltipProps}
/>
</>
);
Expand Down
Loading