diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/AttachmentEdit.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/AttachmentEdit.tsx index 4a3101f70..18412b34b 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/AttachmentEdit.tsx +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/AttachmentEdit.tsx @@ -19,7 +19,7 @@ export const AttachmentEditModalExample: FunctionComponent = () => { id="modal-compact-edit" name="modal-compact-edit" > - + { + const [isModalOpen, setIsModalOpen] = useState(false); + const [isCompact, setIsCompact] = useState(false); + const [hasNav, setHasNav] = useState(false); + + const handleModalToggle = (_event: ReactMouseEvent | MouseEvent | KeyboardEvent) => { + setIsModalOpen(!isModalOpen); + }; + + return ( + <> + setHasNav(!hasNav)} + id="modal-compact-image-has-nav" + name="modal-compact-image-has-nav" + > + setIsCompact(!isCompact)} + id="modal-compact-image-preview" + name="modal-compact-image-preview" + > + + { + // eslint-disable-next-line no-console + console.log('Clicked close button'); + }} + images={ + hasNav + ? /* eslint-disable indent */ + [ + { fileName: 'image1.png', fileSize: '134KB', image: Preview one }, + { fileName: 'image2.png', fileSize: '134KB', image: Preview two } + ] + : [{ fileName: 'image.png', fileSize: '134KB', image: One }] + /* eslint-enable indent */ + } + > + + ); +}; diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md index e9691222b..d694921f7 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md @@ -48,6 +48,8 @@ import userAvatar from './user_avatar.svg'; import squareImg from './PF-social-color-square.svg'; import { CSSProperties, useState, Fragment, FunctionComponent, MouseEvent as ReactMouseEvent, KeyboardEvent as ReactKeyboardEvent, Ref, isValidElement, cloneElement, Children, ReactNode, useRef, useEffect } from 'react'; import FilePreview from '@patternfly/chatbot/dist/dynamic/FilePreview'; +import ImagePreview from '@patternfly/chatbot/dist/dynamic/ImagePreview'; +import filePreview from './file-preview.svg'; The `content` prop of the `` component is passed to a `` component (from [react-markdown](https://remarkjs.github.io/react-markdown/)), which is configured to translate plain text strings into PatternFly [`` components](/components/content) and code blocks into PatternFly [`` components.](/components/code-block) @@ -189,7 +191,7 @@ If you are using [model context protocol (MCP)](https://www.redhat.com/en/blog/m ### Messages with deep thinking -You can share details about the "thought process" behind an LLM's response, also known as deep thinking. To display a customizable, expandable card with these details, pass `deepThinking` to `` and provide a subheading (optional) and content body. +You can share details about the "thought process" behind an LLM's response, also known as deep thinking. To display a customizable, expandable card with these details, pass `deepThinking` to `` and provide a subheading (optional) and content body. Because this is an evolving area, this card content is currently fully customizable. @@ -274,6 +276,14 @@ To allow users to edit an attached file, load a new code editor within the ChatB ``` +### Image preview + +To allow users to preview images, load a modal that contains a view of the file name, file size, and the image. Users can toggle between multiple images by using pagination controls at the bottom of the modal. Return users to the main ChatBot window once they close the modal. + +```js file="./ImagePreview.tsx" + +``` + ### File preview If the contents of an attachment cannot be previewed, load a file preview modal with a view of the file name and an unavailable message. When users close the modal, return to the main ChatBot window. diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/PreviewAttachment.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/PreviewAttachment.tsx index 26b6475af..22a4c0eea 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/PreviewAttachment.tsx +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/PreviewAttachment.tsx @@ -19,7 +19,7 @@ export const PreviewAttachmentExample: FunctionComponent = () => { id="modal-compact-preview" name="modal-compact-preview" > - + + + + + + + + + diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss index c5ce6c31d..08180607c 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss @@ -2,18 +2,6 @@ // 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); diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx index 453299644..ab2a60d5f 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx @@ -327,7 +327,7 @@ export const ChatbotConversationHistoryNav: FunctionComponent {searchInputScreenReaderText && ( -
{searchInputScreenReaderText}
+
{searchInputScreenReaderText}
)} )} diff --git a/packages/module/src/FileDetails/FileDetails.scss b/packages/module/src/FileDetails/FileDetails.scss index dfdba5c67..06958f9f2 100644 --- a/packages/module/src/FileDetails/FileDetails.scss +++ b/packages/module/src/FileDetails/FileDetails.scss @@ -11,10 +11,20 @@ height: 24px; } +.pf-chatbot__image-icon { + color: var(--pf-t--global--icon--color--status--info--default); + width: 24px; + height: 24px; +} + .pf-chatbot__code-fileName { font-size: var(--pf-t--global--font--size--body--default); } +.pf-chatbot__code-file-size { + color: var(--pf-t--global--text--color--subtle); +} + // This is used in demos only .pf-chatbot__file-details-example { background: var(--pf-t--global--background--color--secondary--default); diff --git a/packages/module/src/FileDetails/FileDetails.test.tsx b/packages/module/src/FileDetails/FileDetails.test.tsx index e9665c167..1d7d2f0ba 100644 --- a/packages/module/src/FileDetails/FileDetails.test.tsx +++ b/packages/module/src/FileDetails/FileDetails.test.tsx @@ -11,6 +11,7 @@ describe('FileDetails', () => { it('should render file details correctly if an extension we support is passed in', () => { render(); expect(screen.getByText('test')).toBeTruthy(); + expect(screen.queryByText('test.txt')).toBeFalsy(); expect(screen.getByText('TEXT')).toBeTruthy(); expect(screen.getByTestId('language')).toBeTruthy(); }); @@ -19,4 +20,19 @@ describe('FileDetails', () => { expect(screen.getByText('test')).toBeTruthy(); expect(screen.queryByTestId('language')).toBeFalsy(); }); + it('should support image formats by rendering extension differently', () => { + render(); + expect(screen.getByText('test')).toBeTruthy(); + expect(screen.queryByText('test.svg')).toBeFalsy(); + expect(screen.queryByTestId('language')).toBeFalsy(); + }); + it('should handle truncation differently', () => { + render(); + expect(screen.getByText('test.svg')).toBeTruthy(); + expect(screen.queryByTestId('language')).toBeFalsy(); + }); + it('should include file size if prop passed in', () => { + render(); + expect(screen.getByText('100MB')).toBeTruthy(); + }); }); diff --git a/packages/module/src/FileDetails/FileDetails.tsx b/packages/module/src/FileDetails/FileDetails.tsx index fe003e74c..0e604d1eb 100644 --- a/packages/module/src/FileDetails/FileDetails.tsx +++ b/packages/module/src/FileDetails/FileDetails.tsx @@ -1,7 +1,7 @@ import { PropsWithChildren } from 'react'; -import { Flex, Stack, StackItem, Truncate } from '@patternfly/react-core'; +import { Flex, FlexItem, Truncate } from '@patternfly/react-core'; import path from 'path-browserify'; -interface FileDetailsProps { +export interface FileDetailsProps { /** Class name applied to container */ className?: string; /** Name of file, including extension */ @@ -10,8 +10,24 @@ interface FileDetailsProps { languageTestId?: string; /** Class name applied to file name */ fileNameClassName?: string; + /** File size */ + fileSize?: string; + /** Whether to truncate file name */ + hasTruncation?: boolean; } +// manually added for image modal +// based on https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Formats/Image_types +export const IMAGE_FORMATS = { + png: 'PNG', + apng: 'APNG', + avif: 'AVIF', + gif: 'GIF', + jpg: 'JPEG', + svg: 'SVG', + webp: 'WebP' +}; + // source https://gist.github.com/ppisarczyk/43962d06686722d26d176fad46879d41 // FIXME We could probably check against the PF Language hash to trim this down to what the code editor supports. // I can also see an argument to leaving this open, or researching what the third-party we use for uploads is using @@ -678,7 +694,6 @@ export const extensionToLanguage = { viw: 'SQL', db2: 'SQLPL', ston: 'STON', - svg: 'SVG', sage: 'Sage', sagews: 'Sage', sls: 'Scheme', @@ -935,54 +950,96 @@ export const extensionToLanguage = { ppt: 'Presentation', pptx: 'Presentation', odp: 'Presentation', - pdf: 'PDF' + pdf: 'PDF', + // manually added for image modal + // based on https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Formats/Image_types + ...IMAGE_FORMATS }; export const FileDetails = ({ className, fileName, fileNameClassName, - languageTestId + languageTestId, + fileSize, + hasTruncation = true }: PropsWithChildren) => { const language = extensionToLanguage[path.extname(fileName).slice(1)]?.toUpperCase(); + const isImage = IMAGE_FORMATS[path.extname(fileName).slice(1)] ? true : false; return ( - - - + {isImage ? ( + - - - - - - - - - - - - - - {language && ( - - {language} - + + ) : ( + )} - + + + + + + + {hasTruncation ? ( + + ) : ( + fileName + )} + + + {!isImage && language && ( + + {language} + + )} + + + {fileSize && {fileSize}} + ); }; diff --git a/packages/module/src/FileDetails/__snapshots__/FileDetails.test.tsx.snap b/packages/module/src/FileDetails/__snapshots__/FileDetails.test.tsx.snap index e2c79dd21..b0530fab2 100644 --- a/packages/module/src/FileDetails/__snapshots__/FileDetails.test.tsx.snap +++ b/packages/module/src/FileDetails/__snapshots__/FileDetails.test.tsx.snap @@ -9,6 +9,7 @@ exports[`FileDetails should render file details 1`] = ` class="pf-v6-l-flex pf-m-align-items-center pf-m-align-self-center pf-m-justify-content-center pf-chatbot__code-icon" >
- - - test + + + test + + - - -
-
- TEXT +
+
+ TEXT +
+
diff --git a/packages/module/src/FileDetailsLabel/FileDetailsLabel.test.tsx b/packages/module/src/FileDetailsLabel/FileDetailsLabel.test.tsx index cc38a832e..a4cfd2985 100644 --- a/packages/module/src/FileDetailsLabel/FileDetailsLabel.test.tsx +++ b/packages/module/src/FileDetailsLabel/FileDetailsLabel.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; import FileDetailsLabel from './FileDetailsLabel'; import userEvent from '@testing-library/user-event'; +import { BellIcon } from '@patternfly/react-icons'; describe('FileDetailsLabel', () => { it('should render file details label', () => { @@ -18,6 +19,19 @@ describe('FileDetailsLabel', () => { expect(screen.getByText('test')).toBeTruthy(); expect(screen.queryByTestId('language')).toBeFalsy(); }); + it('should pass file size down', () => { + render(); + expect(screen.getByText('100MB')).toBeTruthy(); + }); + it('should pass truncation prop down as true by default', () => { + render(); + expect(screen.getByText('test')).toBeTruthy(); + expect(screen.queryByText('test.svg')).toBeFalsy(); + }); + it('should pass truncation prop down when false', () => { + render(); + expect(screen.getByText('test.svg')).toBeTruthy(); + }); it('should not show spinner by default', () => { render(); expect(screen.queryByTestId('spinner')).toBeFalsy(); @@ -42,6 +56,12 @@ describe('FileDetailsLabel', () => { }); it('should use closeButtonAriaLabel prop appropriately', () => { render(); - screen.getByRole('button', { name: /Delete file/i }); + expect(screen.getByRole('button', { name: /Delete file/i })).toBeTruthy(); + }); + it('should support custom close icon', () => { + render( + } /> + ); + expect(screen.getByTestId('bell')).toBeTruthy(); }); }); diff --git a/packages/module/src/FileDetailsLabel/FileDetailsLabel.tsx b/packages/module/src/FileDetailsLabel/FileDetailsLabel.tsx index 49f22c4b9..735709999 100644 --- a/packages/module/src/FileDetailsLabel/FileDetailsLabel.tsx +++ b/packages/module/src/FileDetailsLabel/FileDetailsLabel.tsx @@ -4,7 +4,7 @@ import FileDetails from '../FileDetails'; import { Spinner } from '@patternfly/react-core'; import { TimesIcon } from '@patternfly/react-icons'; -interface FileDetailsLabelProps { +export interface FileDetailsLabelProps { /** Name of file, including extension */ fileName: string; /** Unique id of file */ @@ -21,6 +21,12 @@ interface FileDetailsLabelProps { languageTestId?: string; /** Custom test id for the loading spinner in the component */ spinnerTestId?: string; + /** File size */ + fileSize?: string; + /** Whether to truncate file name */ + hasTruncation?: boolean; + /** Icon used for close button */ + closeButtonIcon?: React.ReactNode; } export const FileDetailsLabel = ({ @@ -31,7 +37,11 @@ export const FileDetailsLabel = ({ onClose, closeButtonAriaLabel, languageTestId, - spinnerTestId + spinnerTestId, + fileSize, + hasTruncation = true, + closeButtonIcon = , + ...props }: PropsWithChildren) => { const handleClose = (event) => { onClose && onClose(event, fileName, fileId); @@ -45,17 +55,20 @@ export const FileDetailsLabel = ({ type="button" variant="plain" aria-label={closeButtonAriaLabel ?? `Close ${fileName}`} - icon={} + icon={closeButtonIcon} onClick={handleClose} /> } {...(onClick && { onClick: (event) => onClick(event, fileName, fileId) })} + {...props} >
{isLoading && }
diff --git a/packages/module/src/FileDetailsLabel/__snapshots__/FileDetailsLabel.test.tsx.snap b/packages/module/src/FileDetailsLabel/__snapshots__/FileDetailsLabel.test.tsx.snap index edccc1a47..9fbb3b952 100644 --- a/packages/module/src/FileDetailsLabel/__snapshots__/FileDetailsLabel.test.tsx.snap +++ b/packages/module/src/FileDetailsLabel/__snapshots__/FileDetailsLabel.test.tsx.snap @@ -21,6 +21,7 @@ exports[`FileDetailsLabel should render file details label 1`] = ` class="pf-v6-l-flex pf-m-align-items-center pf-m-align-self-center pf-m-justify-content-center pf-chatbot__code-icon" >
- - - test + + + test + + - - -
-
- TEXT +
+
+ TEXT +
+
diff --git a/packages/module/src/ImagePreview/ImagePreview.scss b/packages/module/src/ImagePreview/ImagePreview.scss new file mode 100644 index 000000000..629833a5c --- /dev/null +++ b/packages/module/src/ImagePreview/ImagePreview.scss @@ -0,0 +1,61 @@ +.pf-chatbot__image-preview-body { + display: flex; + flex-direction: column; + gap: var(--pf-t--global--spacer--lg); + --pf-v6-c-label--MaxWidth: initial; + --pf-v6-c-modal-box--ZIndex: var(--pf-t--global--z-index--2xl); + + img { + flex: 1 0 0; + align-self: stretch; + } + .pf-chatbot__file-label { + min-width: fit-content; + } +} + +.pf-chatbot__image-preview-stack { + height: unset; +} + +.pf-v6-c-modal-box__footer.pf-chatbot__image-preview-footer { + padding-block-start: var(--pf-t--global--spacer--sm); +} + +.pf-chatbot__image-preview-footer-buttons { + display: flex; + gap: var(--pf-t--global--spacer--xs); + align-items: center; + justify-content: space-between; + flex: 1; + + .pf-v6-c-button { + border-radius: var(--pf-t--global--border--radius--pill); + padding: var(--pf-t--global--spacer--sm); + width: 2.31rem; + height: 2.31rem; + display: flex; + align-items: center; + justify-content: center; + } + button:disabled, + button[disabled] { + .pf-v6-c-icon__content { + color: var(--pf-t--global--icon--color--disabled); + } + } + .pf-v6-c-button__text { + display: flex; + align-items: center; + } + // Interactive states + .pf-v6-c-button:hover, + .pf-v6-c-button:focus { + .pf-v6-c-button__icon { + color: var(--pf-t--global--icon--color--regular); + } + } + .pf-v6-c-button__icon { + color: var(--pf-t--global--icon--color--subtle); + } +} diff --git a/packages/module/src/ImagePreview/ImagePreview.test.tsx b/packages/module/src/ImagePreview/ImagePreview.test.tsx new file mode 100644 index 000000000..396cfa0dd --- /dev/null +++ b/packages/module/src/ImagePreview/ImagePreview.test.tsx @@ -0,0 +1,253 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import ImagePreview from './ImagePreview'; +import { ChatbotDisplayMode } from '../Chatbot'; + +const mockImages = [ + { + fileName: 'image1.jpg', + fileSize: '2.5 MB', + image: Test image 1 + }, + { + fileName: 'image2.png', + fileSize: '1.8 MB', + image: Test image 2 + }, + { + fileName: 'image3.gif', + image: Test image 3 + } +]; + +const defaultProps = { + isModalOpen: true, + handleModalToggle: jest.fn(), + images: mockImages +}; + +describe('ImagePreview', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders modal when isModalOpen is true', () => { + render(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('does not render modal when isModalOpen is false', () => { + render(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('displays custom title when provided', () => { + const customTitle = 'Custom image preview'; + render(); + expect(screen.getByRole('heading', { name: customTitle })).toBeInTheDocument(); + }); + + it('displays default title when no title provided', () => { + render(); + expect(screen.getByRole('heading', { name: /Preview images/i })).toBeInTheDocument(); + }); + + it('calls handleModalToggle when modal is closed', () => { + const mockHandleToggle = jest.fn(); + render(); + const closeButton = screen.getByRole('button', { name: /close/i }); + fireEvent.click(closeButton); + expect(mockHandleToggle).toHaveBeenCalledTimes(1); + }); + + it('displays first image by default', () => { + render(); + expect(screen.getByText('image1.jpg')).toBeInTheDocument(); + expect(screen.getByText('2.5 MB')).toBeInTheDocument(); + expect(screen.getByAltText('Test image 1')).toBeInTheDocument(); + }); + + it('displays page counter correctly', () => { + render(); + expect(screen.getByText('1/3')).toBeInTheDocument(); + }); + + it('navigates to next image when next button is clicked', () => { + const mockOnNextClick = jest.fn(); + render(); + const nextButton = screen.getByRole('button', { name: /Go to next image/i }); + fireEvent.click(nextButton); + expect(mockOnNextClick).toHaveBeenCalled(); + expect(screen.getByText('2/3')).toBeInTheDocument(); + expect(screen.getByText('image2.png')).toBeInTheDocument(); + }); + + it('navigates to previous image when previous button is clicked', () => { + const mockOnPreviousClick = jest.fn(); + render(); + // First go to page 2 + const nextButton = screen.getByRole('button', { name: /Go to next image/i }); + fireEvent.click(nextButton); + // Then go back to page 1 + const previousButton = screen.getByRole('button', { name: /Go to previous image/i }); + fireEvent.click(previousButton); + expect(mockOnPreviousClick).toHaveBeenCalled(); + expect(screen.getByText('1/3')).toBeInTheDocument(); + }); + + it('calls onSetPage when page changes', () => { + const mockOnSetPage = jest.fn(); + render(); + const nextButton = screen.getByRole('button', { name: /Go to next image/i }); + fireEvent.click(nextButton); + expect(mockOnSetPage).toHaveBeenCalledWith(expect.any(Object), 2); + }); + + it('disables previous button on first page', () => { + render(); + const previousButton = screen.getByRole('button', { name: /Go to previous image/i }); + expect(previousButton).toBeDisabled(); + }); + + it('disables next button on last page', () => { + render(); + // Navigate to last page + const nextButton = screen.getByRole('button', { name: /Go to next image/i }); + fireEvent.click(nextButton); // page 2 + fireEvent.click(nextButton); // page 3 + expect(nextButton).toBeDisabled(); + }); + + it('disables both navigation buttons when isDisabled is true', () => { + render(); + const previousButton = screen.getByRole('button', { name: /Go to previous image/i }); + const nextButton = screen.getByRole('button', { name: /Go to next image/i }); + expect(previousButton).toBeDisabled(); + expect(nextButton).toBeDisabled(); + }); + + it('uses custom aria labels for pagination', () => { + const customLabels = { + paginationAriaLabel: 'Custom pagination', + toPreviousPageAriaLabel: 'Go to previous image', + toNextPageAriaLabel: 'Go to next image' + }; + + render(); + expect(screen.getByRole('navigation', { name: 'Custom pagination' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Go to previous image' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Go to next image' })).toBeInTheDocument(); + }); + + it('renders with compact mode when isCompact is true', () => { + render(); + const modal = screen.getByRole('dialog'); + expect(modal).toHaveClass('pf-m-compact'); + }); + + it('applies custom className when provided', () => { + const customClassName = 'custom-image-preview'; + render(); + const modal = screen.getByRole('dialog'); + expect(modal).toHaveClass(customClassName); + }); + + it('applies display mode class correctly', () => { + render(); + const modal = screen.getByRole('dialog'); + expect(modal).toHaveClass('pf-chatbot__image-preview-modal--embedded'); + }); + + it('passes additional props to ChatbotModal', () => { + const modalClass = 'custom-modal-class'; + const additionalProps = { + 'data-testid': 'modal', + className: modalClass + }; + render(); + const modal = screen.getByTestId('modal'); + expect(modal).toBeInTheDocument(); + expect(modal).toBeInTheDocument(); + expect(modal).toHaveClass(modalClass); + }); + + it('passes modalHeaderProps correctly', () => { + const headerClass = 'custom-modal-header-class'; + const headerProps = { + 'data-testid': 'header', + className: headerClass + }; + render(); + expect(screen.getByTestId('header')).toBeInTheDocument(); + expect(screen.getByTestId('header')).toHaveClass(headerClass); + }); + + it('passes modalBodyProps correctly', () => { + const bodyClass = 'custom-modal-body-class'; + const bodyProps = { + 'data-testid': 'body', + className: bodyClass + }; + render(); + expect(screen.getByTestId('body')).toBeInTheDocument(); + expect(screen.getByTestId('body')).toHaveClass(bodyClass); + }); + + it('handles single image without pagination', () => { + const singleImage = [mockImages[0]]; + render(); + expect(screen.queryByText('1/1')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Go to previous image/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Go to next image/i })).not.toBeInTheDocument(); + }); + + it('calls onCloseFileDetailsLabel when file details close button is clicked', () => { + const mockOnClose = jest.fn(); + render(); + const closeButton = screen.getByRole('button', { name: /Close image1.jpg/i }); + fireEvent.click(closeButton); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('passes fileDetailsLabelProps correctly to FileDetailsLabel', () => { + const customFileDetailsProps = { + 'data-testid': 'custom-file-details' + }; + render(); + expect(screen.getByTestId('custom-file-details')).toBeInTheDocument(); + }); + + it('displays file details for current page when navigating', () => { + render(); + // Initially shows first image details + expect(screen.getByText('image1.jpg')).toBeInTheDocument(); + expect(screen.getByText('2.5 MB')).toBeInTheDocument(); + + // Navigate to second page + const nextButton = screen.getByRole('button', { name: /Go to next image/i }); + fireEvent.click(nextButton); + + // Should now show second image details + expect(screen.getByText('image2.png')).toBeInTheDocument(); + expect(screen.getByText('1.8 MB')).toBeInTheDocument(); + + // Navigate to third page + fireEvent.click(nextButton); + + // Should now show third image details (no file size) + expect(screen.getByText('image3.gif')).toBeInTheDocument(); + expect(screen.queryByText(/MB/)).not.toBeInTheDocument(); + }); + + it('sets hasTruncation to false on FileDetailsLabel', () => { + const longFileName = 'very-long-filename-that-would-normally-be-truncated-in-other-contexts.jpg'; + const imageWithLongName = { + fileName: longFileName, + fileSize: '1.0 MB', + image: Test image with long name + }; + render(); + expect(screen.getByText(longFileName)).toBeInTheDocument(); + }); +}); diff --git a/packages/module/src/ImagePreview/ImagePreview.tsx b/packages/module/src/ImagePreview/ImagePreview.tsx new file mode 100644 index 000000000..62a3aeaa0 --- /dev/null +++ b/packages/module/src/ImagePreview/ImagePreview.tsx @@ -0,0 +1,200 @@ +import { + Button, + ButtonVariant, + Icon, + ModalBody, + ModalBodyProps, + ModalFooter, + ModalHeader, + ModalHeaderProps, + Stack, + StackItem +} from '@patternfly/react-core'; +import { + useState, + useEffect, + type FunctionComponent, + MouseEvent as ReactMouseEvent, + KeyboardEvent as ReactKeyboardEvent +} from 'react'; +import { ChatbotDisplayMode } from '../Chatbot'; +import ChatbotModal, { ChatbotModalProps } from '../ChatbotModal'; +import FileDetailsLabel, { FileDetailsLabelProps } from '../FileDetailsLabel'; +import { TrashIcon } from '@patternfly/react-icons'; + +export interface ImagePreviewProps extends Omit { + /** Class applied to modal */ + className?: string; + /** Function that handles modal toggle */ + handleModalToggle: (event: React.MouseEvent | MouseEvent | KeyboardEvent) => void; + /** Whether modal is open */ + isModalOpen: boolean; + /** Title of modal */ + title?: string; + /** Display mode for the Chatbot parent; this influences the styles applied */ + displayMode?: ChatbotDisplayMode; + /** Sets modal to compact styling. */ + isCompact?: boolean; + /** Additional props passed to modal header */ + modalHeaderProps?: ModalHeaderProps; + /** Additional props passed to modal body */ + modalBodyProps?: ModalBodyProps; + /** Images displayed in modal */ + images: { fileName: string; fileSize?: string; image: React.ReactNode }[]; + /** Flag indicating if the pagination is disabled. */ + isDisabled?: boolean; + /** Accessible label for the pagination component. */ + paginationAriaLabel?: string; + /** Accessible label for the button which moves to the next page. */ + toNextPageAriaLabel?: string; + /** Accessible label for the button which moves to the previous page. */ + toPreviousPageAriaLabel?: string; + /** Function called when user clicks to navigate to next page. */ + onNextClick?: (event: React.SyntheticEvent, page: number) => void; + /** Function called when user clicks to navigate to previous page. */ + onPreviousClick?: (event: React.SyntheticEvent, page: number) => void; + /** Function called when page is changed. */ + onSetPage?: (event: React.MouseEvent | React.KeyboardEvent | MouseEvent, newPage: number) => void; + /** Callback function for when file details label close button is clicked */ + onCloseFileDetailsLabel?: (event: React.MouseEvent, fileName: string, fileId?: string | number) => void; + /** Props passed to file details label */ + fileDetailsLabelProps?: Omit; + /** Text shown in navigation */ + paginationContent?: string; + /** Navigation progress announced to assistive devices. Should state the current page/image. */ + screenreaderText?: string; +} + +const ImagePreview: FunctionComponent = ({ + isModalOpen, + displayMode = ChatbotDisplayMode.default, + isCompact, + className, + handleModalToggle, + title = 'Preview images', + modalHeaderProps, + modalBodyProps, + images, + isDisabled, + onSetPage, + onPreviousClick, + toNextPageAriaLabel = 'Go to next image', + toPreviousPageAriaLabel = 'Go to previous image', + onNextClick, + paginationAriaLabel, + onCloseFileDetailsLabel, + fileDetailsLabelProps, + paginationContent, + screenreaderText, + ...props +}: ImagePreviewProps) => { + const [page, setPage] = useState(1); + const paginationText = paginationContent || `${page}/${images.length}`; + + useEffect(() => { + if (images.length === 0 || page > images.length) { + setPage(1); + } + }, [images.length, page]); + + const handleNewPage = (_evt: ReactMouseEvent | ReactKeyboardEvent | MouseEvent, newPage: number) => { + setPage(newPage); + onSetPage && onSetPage(_evt, newPage); + }; + + return ( + + + + {images.length > 0 && images[page - 1] && ( + + + } + {...fileDetailsLabelProps} + /> + + +
{images[page - 1].image}
+
+
+ )} +
+ {images.length > 1 && ( + + + + )} +
+ ); +}; + +export default ImagePreview; diff --git a/packages/module/src/ImagePreview/index.ts b/packages/module/src/ImagePreview/index.ts new file mode 100644 index 000000000..1b79ce184 --- /dev/null +++ b/packages/module/src/ImagePreview/index.ts @@ -0,0 +1,3 @@ +export { default } from './ImagePreview'; + +export * from './ImagePreview'; diff --git a/packages/module/src/MessageBox/MessageBox.scss b/packages/module/src/MessageBox/MessageBox.scss index 921b815fd..e300b4586 100644 --- a/packages/module/src/MessageBox/MessageBox.scss +++ b/packages/module/src/MessageBox/MessageBox.scss @@ -23,18 +23,6 @@ margin-top: auto !important; } -// hide from view but not assistive technologies -// https://css-tricks.com/inclusively-hidden/ -.pf-chatbot__messagebox-announcement { - clip: rect(0 0 0 0); - clip-path: inset(50%); - height: 1px; - overflow: hidden; - position: absolute; - white-space: nowrap; - width: 1px; -} - @media screen and (min-width: 64rem) { .pf-chatbot--embedded, .pf-chatbot--drawer, diff --git a/packages/module/src/MessageBox/MessageBox.tsx b/packages/module/src/MessageBox/MessageBox.tsx index 38f185d5c..1da0873fa 100644 --- a/packages/module/src/MessageBox/MessageBox.tsx +++ b/packages/module/src/MessageBox/MessageBox.tsx @@ -335,7 +335,7 @@ export const MessageBox = forwardRef( {...(enableSmartScroll ? { ...smartScrollHandlers } : {})} > {children} -
+
{announcement}
diff --git a/packages/module/src/index.ts b/packages/module/src/index.ts index f453819e6..cc4b75c38 100644 --- a/packages/module/src/index.ts +++ b/packages/module/src/index.ts @@ -57,6 +57,9 @@ export * from './FileDropZone'; export { default as FilePreview } from './FilePreview'; export * from './FilePreview'; +export { default as ImagePreview } from './ImagePreview'; +export * from './ImagePreview'; + export { default as LoadingMessage } from './LoadingMessage'; export * from './LoadingMessage'; diff --git a/packages/module/src/main.scss b/packages/module/src/main.scss index ec8beb743..fbec41029 100644 --- a/packages/module/src/main.scss +++ b/packages/module/src/main.scss @@ -16,6 +16,7 @@ @import './FileDetailsLabel/FileDetailsLabel'; @import './FileDropZone/FileDropZone'; @import './FilePreview/FilePreview.scss'; +@import './ImagePreview/ImagePreview'; @import './Message/Message'; @import './Message/CodeBlockMessage/CodeBlockMessage'; @import './Message/ImageMessage/ImageMessage'; @@ -41,3 +42,15 @@ left: 0 !important; right: auto !important; } + +// hide from view but not assistive technologies +// https://css-tricks.com/inclusively-hidden/ +.pf-chatbot-m-hidden { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; +}