diff --git a/.changeset/quiet-carrots-flash.md b/.changeset/quiet-carrots-flash.md new file mode 100644 index 000000000..ce93a5421 --- /dev/null +++ b/.changeset/quiet-carrots-flash.md @@ -0,0 +1,5 @@ +--- +'@ebay/ui-core-react': minor +--- + +feat: add new EbayFilePreviewCard component diff --git a/README.md b/README.md index 442073249..273fbafa3 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ eBayUI React components * [x] [ebay-fake-menu-button](src/ebay-fake-menu-button) * [x] [ebay-fake-tabs](src/ebay-fake-tabs) * [x] [ebay-field](src/ebay-field) +* [x] [ebay-file-preview-card](src/ebay-file-preview-card) * [x] [ebay-filter](src/ebay-filter) * [x] [ebay-filter-menu](src/ebay-filter-menu) * [x] [ebay-filter-menu-button](src/ebay-filter-menu-button) diff --git a/src/ebay-file-preview-card/README.md b/src/ebay-file-preview-card/README.md new file mode 100644 index 000000000..b18df2b67 --- /dev/null +++ b/src/ebay-file-preview-card/README.md @@ -0,0 +1,69 @@ +# EbayFilePreviewCard + +## Demo + +[Storybook](https://opensource.ebay.com/ebayui-core-react/main/?path=/docs/media-ebay-file-preview-card--docs) + +## Usage + +### Import JS + +```jsx harmony +import { EbayFilePreviewCard } from '@ebay/ui-core-react/ebay-file-preview-card' +``` + +### Import following styles from SKIN + +```jsx harmony +import '@ebay/skin/file-preview-card' +``` + +### Or import styles using SCSS/CSS + +```jsx harmony +import '@ebay/skin/file-preview-card.css' +``` + +### Import icons + +Add the below icons to the `EbaySvg` component. + +Note: Make sure that `EbaySvg` is only rendered on the server so it does not affect the client bundle size. + +```tsx + +``` + +```jsx harmony + action('onCancel')(e)} +/> +``` + +## Attributes + +| Name | Type | Required | Description | Data | +| ---------------------- | ------------------------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------ | ---- | +| `a11yCancelUploadText` | `String` | No | a11y text for cancel upload button. | | +| `as` | `React.ElementType` | No | Element type for the preview card, default is `div` | | +| `deleteText` | `String` | No | Text for delete button. | | +| `status` | `String` | No | Status of the file, can be `"uploading"` | | +| `labelText` | `String` | No | Text to display in the label. | | +| `footerTitle` | `String` | No | Title to display beneath the file, usually the filename. | | +| `footerSubtitle` | `String` | No | Subtitle to display beneath the file title. | | +| `infoText` | `String` | No | Text to display info in file if not image. | | +| `file` | `File` or `{name: string, type?: File[type], src?: string }` | No | File object, can be raw platform `File` or an object containing `name`, `type`, and a `src` for the preview. | | +| `menuActions` | `{event: string, label: string }[]` | No | Array of menu actions, containing event and label. | | +| `seeMore` | `Number` | No | Passing a number here will convert the card to a "see more" card. | | +| `a11ySeeMoreText` | `String` | No | a11y text for see more button. | + +## Events + +| Name | Type | Required | Description | Data | +| -------------- | ---------------------------- | -------- | --------------------------------------------------- | --------------------------------------------------------------- | +| `onMenuAction` | `EbayMenuSelectEventHandler` | No | Triggered when an action is selected from the menu. | `event, {index: number, checked: number[], eventName?: string}` | +| `onSeeMore` | `EbayEventHandler` | No | Triggered when the see more button is clicked. | `event` | +| `onDelete` | `EbayEventHandler` | No | Triggered when the delete button is clicked. | `event` | +| `onCancel` | `EbayEventHandler` | No | Triggered when the cancel button is clicked. | `event` | diff --git a/src/ebay-file-preview-card/__tests__/__snapshots__/render.spec.tsx.snap b/src/ebay-file-preview-card/__tests__/__snapshots__/render.spec.tsx.snap new file mode 100644 index 000000000..040dd426a --- /dev/null +++ b/src/ebay-file-preview-card/__tests__/__snapshots__/render.spec.tsx.snap @@ -0,0 +1,383 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders as span element 1`] = ` + + +
+ file-name.jpg + +
+ +
+
+`; + +exports[` renders with document 1`] = ` + +
+
+ file-name.jpg + + + +
+
+
+`; + +exports[` renders with image, delete button and footer 1`] = ` + +
+
+ file-name.jpg + +
+ +
+
+`; + +exports[` renders with multi action button 1`] = ` + +
+
+ file-name.jpg + + + +
+
+
+`; + +exports[` renders with see more button 1`] = ` + +
+
+ file-name.jpg + +
+
+
+`; + +exports[` renders with status uploading 1`] = ` + +
+
+ + + + +
+
+
+`; + +exports[` renders with video 1`] = ` + +
+
+ file-name.jpg + + + +
+
+
+`; + +exports[` should render with mock file 1`] = ` + +
+
+ + +
+ TXT +
+
+ +
+
+`; + +exports[` should render with video 1`] = ` + +
+
+
+
+
+`; diff --git a/src/ebay-file-preview-card/__tests__/index.spec.tsx b/src/ebay-file-preview-card/__tests__/index.spec.tsx new file mode 100644 index 000000000..773738348 --- /dev/null +++ b/src/ebay-file-preview-card/__tests__/index.spec.tsx @@ -0,0 +1,143 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import '@testing-library/jest-dom' +import { EbayFilePreviewCard } from '../' + +describe('', () => { + it('should call onCancel', async () => { + const onCancelClick = jest.fn() + render( + + ) + + const buttonEl = screen.getByRole('button', { name: 'Cancel upload' }) + expect(buttonEl).toBeInTheDocument() + await userEvent.click(buttonEl) + expect(onCancelClick).toHaveBeenCalled() + }) + it('should call onDelete', async () => { + const onDeleteClick = jest.fn() + render( + + ) + + const buttonEl = screen.getByRole('button', { name: 'Delete text' }) + expect(buttonEl).toBeInTheDocument() + await userEvent.click(buttonEl) + expect(onDeleteClick).toHaveBeenCalled() + }) + it('should call multi action menu delete call', async () => { + const onDeleteClick = jest.fn() + const onMenuAction = jest.fn() + render( + + ) + + const buttonEl = screen.getByRole('button') + expect(buttonEl).toBeInTheDocument() + await userEvent.click(buttonEl) + const deleteEl = screen.getByRole('menuitem', { name: 'Delete' }) + await userEvent.click(deleteEl) + expect(onDeleteClick).toHaveBeenCalled() + expect(onMenuAction).not.toHaveBeenCalled() + }) + it('should call multi action menu delete call', async () => { + const onDeleteClick = jest.fn() + const onMenuAction = jest.fn() + render( + + ) + + const buttonEl = screen.getByRole('button') + expect(buttonEl).toBeInTheDocument() + await userEvent.click(buttonEl) + const editEl = screen.getByRole('menuitem', { name: 'Edit' }) + await userEvent.click(editEl) + expect(onMenuAction).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + checked: [0], + eventName: 'edit', + index: 0 + }) + ) + expect(onDeleteClick).not.toHaveBeenCalled() + }) + it('should call see more', async () => { + const onSeeMoreMock = jest.fn() + const { getByRole } = render( + + ) + + const buttonEl = getByRole('button', { name: 'See more' }) + expect(buttonEl).toBeInTheDocument() + await userEvent.click(buttonEl) + expect(onSeeMoreMock).toHaveBeenCalled() + }) + +}) diff --git a/src/ebay-file-preview-card/__tests__/index.stories.tsx b/src/ebay-file-preview-card/__tests__/index.stories.tsx new file mode 100644 index 000000000..024d54b39 --- /dev/null +++ b/src/ebay-file-preview-card/__tests__/index.stories.tsx @@ -0,0 +1,215 @@ +import React from 'react' +import { Meta, StoryFn } from '@storybook/react' +import { EbayFilePreviewCard } from '..' + +const meta: Meta = { + title: 'media/ebay-file-preview-card', + component: EbayFilePreviewCard, + argTypes: { + a11yCancelUploadText: { + type: 'string', + control: { type: 'text' }, + description: 'a11y text for cancel upload button' + }, + file: { + description: + 'File object, can be raw platform `File` or an object containing `name`, `type`, and a `src` for the preview', + table: { + category: 'File' + } + }, + status: { + type: 'string', + control: { type: 'text' }, + description: + 'Status of the file, can be `"uploading"` or `undefined`' + }, + infoText: { + type: 'string', + control: { type: 'text' }, + description: 'Text to display info in file if not image' + }, + menuActions: { + description: + 'Array of menu actions, containing `event` and `label`', + table: { + category: 'Menu Actions' + } + }, + seeMore: { + type: 'number', + control: { type: 'number' }, + description: + 'Passing a number here will convert the card to a "see more" card' + }, + footerTitle: { + type: 'string', + control: { type: 'text' }, + description: + 'Title to display beneath the file, usually the filename' + }, + footerSubtitle: { + type: 'string', + control: { type: 'text' }, + description: 'Subtitle to display beneath the file title' + }, + onMenuAction: { + action: 'onMenuAction', + description: 'Triggered when an action is selected from the menu. ', + table: { + category: 'Events', + defaultValue: { + summary: 'name, event /* from ebay-menu-button */' + } + } + }, + onSeeMore: { + action: 'onSeeMore', + description: 'Triggered when the see more button is clicked', + table: { + category: 'Events', + defaultValue: { + summary: '' + } + } + }, + onDelete: { + action: 'onDelete', + description: 'Triggered when the delete button is clicked', + table: { + category: 'Events', + defaultValue: { + summary: '' + } + } + }, + onCancel: { + action: 'onCancel', + description: 'Triggered when the cancel button is clicked', + table: { + category: 'Events', + defaultValue: { + summary: '' + } + } + } + } +} +export default meta + +export const Default: StoryFn = (args) => ( + +) + +export const Image: StoryFn = (args) => ( + +) + +export const ImageFooter: StoryFn = (args) => ( + +) + +export const Video: StoryFn = (args) => ( + +) + +export const Document: StoryFn = (args) => ( + +) + +export const MultipleActions: StoryFn = (args) => ( + +) + +export const SeeMore: StoryFn = (args) => ( + +) diff --git a/src/ebay-file-preview-card/__tests__/render.spec.tsx b/src/ebay-file-preview-card/__tests__/render.spec.tsx new file mode 100644 index 000000000..e23aad778 --- /dev/null +++ b/src/ebay-file-preview-card/__tests__/render.spec.tsx @@ -0,0 +1,188 @@ +import React from 'react' +import { render } from '@testing-library/react' +import { EbayFilePreviewCard } from '../' + +describe('', () => { + it('renders with status uploading', () => { + const { asFragment } = render( + + ) + expect(asFragment()).toMatchSnapshot() + }) + it('renders as span element', () => { + const { asFragment } = render( + + ) + expect(asFragment()).toMatchSnapshot() + }) + it('renders with image, delete button and footer', () => { + const { asFragment } = render( + + ) + expect(asFragment()).toMatchSnapshot() + }) + it('renders with video', () => { + const { asFragment } = render( + + ) + const snapshot = asFragment() + const fakeMenuButtonElement = snapshot.querySelectorAll( + 'button.menu-button__button' + ) + fakeMenuButtonElement.forEach((button) => { + button.setAttribute('aria-controls', '1234') + }) + expect(snapshot).toMatchSnapshot() + }) + it('renders with document', () => { + const { asFragment } = render( + + ) + const snapshot = asFragment() + const fakeMenuButtonElement = snapshot.querySelectorAll( + 'button.menu-button__button' + ) + fakeMenuButtonElement.forEach((button) => { + button.setAttribute('aria-controls', '1234') + }) + expect(snapshot).toMatchSnapshot() + }) + it('renders with multi action button', () => { + const { asFragment } = render( + + ) + const snapshot = asFragment() + const fakeMenuButtonElement = snapshot.querySelectorAll( + 'button.menu-button__button' + ) + fakeMenuButtonElement.forEach((button) => { + button.setAttribute('aria-controls', '1234') + }) + expect(snapshot).toMatchSnapshot() + }) + it('renders with see more button', () => { + const { asFragment } = render( + + ) + expect(asFragment()).toMatchSnapshot() + }) + it('should render with video', async () => { + const { asFragment } = render( + + ) + + expect(asFragment()).toMatchSnapshot() + }) + it('should render with mock file', async () => { + const mockFile = new File(['file content'], 'example.txt', { + type: 'text/plain' + }) + const { asFragment } = render( + + ) + + expect(asFragment()).toMatchSnapshot() + }) +}) diff --git a/src/ebay-file-preview-card/file-preview-action.tsx b/src/ebay-file-preview-card/file-preview-action.tsx new file mode 100644 index 000000000..ab2fe98f1 --- /dev/null +++ b/src/ebay-file-preview-card/file-preview-action.tsx @@ -0,0 +1,94 @@ +import React, { FC } from 'react' +import { EbayEventHandler } from '../common/event-utils/types' +import { EbayIconButton } from '../ebay-icon-button' +import { EbayMenuButton, EbayMenuButtonItem } from '../ebay-menu-button' +import { + FilePreviewCardMenuAction, + FilePreviewCardMenuActionHandler +} from './types' + +export type EbayFilePreviewActionProps = { + menuActions?: FilePreviewCardMenuAction[] + deleteText?: string + status?: 'uploading' + a11yCancelUploadText?: string + onMenuAction?: FilePreviewCardMenuActionHandler + onCancel?: EbayEventHandler + onDelete?: EbayEventHandler +} + +const EbayFilePreviewAction: FC = ({ + status, + menuActions, + onMenuAction, + deleteText, + onCancel, + onDelete, + a11yCancelUploadText +}) => { + const handleMenuSelect: FilePreviewCardMenuActionHandler = ( + e, + selectedProps + ) => { + if (selectedProps) { + const index = selectedProps.checked?.[0] + const eventName = + menuActions && index !== undefined && index in menuActions + ? menuActions[index].event + : null + + if (eventName && onMenuAction) { + onMenuAction(e, { ...selectedProps, eventName }) + } else if (onDelete) { + // on multiple action menu click, the Delete click will trigger onDelete, not onMenuAction. + // This is the current behavior on marko's ebay-ui + onDelete(e) + } + } + } + + if (status === 'uploading') { + return ( + + ) + } + if (menuActions?.length) { + return ( + <> + + {menuActions.map((action) => ( + + {action.label} + + ))} + + + {deleteText} + + + + ) + } + return ( + + ) +} + +export default EbayFilePreviewAction diff --git a/src/ebay-file-preview-card/file-preview-card.tsx b/src/ebay-file-preview-card/file-preview-card.tsx new file mode 100644 index 000000000..78a3905c7 --- /dev/null +++ b/src/ebay-file-preview-card/file-preview-card.tsx @@ -0,0 +1,112 @@ +import React, { FC, useMemo, ComponentProps, ElementType } from 'react' +import { EbayEventHandler } from '../common/event-utils/types' +import EbayFilePreviewAction from './file-preview-action' +import EbayFilePreviewContent from './file-preview-content' +import EbayFilePreviewLabel from './file-preview-label' +import { + FilePreviewCardMenuAction, + FilePreviewCardMenuActionHandler, + FilePreviewType +} from './types' + +export type EbayFilePreviewCardProps = ComponentProps<'div'> & { + a11yCancelUploadText?: string + as?: ElementType + deleteText?: string + file?: File | FilePreviewType + status?: 'uploading' + infoText?: string + menuActions?: FilePreviewCardMenuAction[] + seeMore?: number + a11ySeeMoreText?: string + footerTitle?: string + footerSubtitle?: string + onMenuAction?: FilePreviewCardMenuActionHandler + onSeeMore?: EbayEventHandler + onDelete?: EbayEventHandler + onCancel?: EbayEventHandler +} + +const EbayFileInput: FC = ({ + a11yCancelUploadText, + status, + as: CardEl = 'div', + file: rawFile, + seeMore, + deleteText, + footerTitle, + footerSubtitle, + a11ySeeMoreText, + menuActions, + infoText, + onCancel, + onDelete, + onMenuAction, + onSeeMore, + ...rest +}) => { + const previewFile = useMemo(() => { + if (!rawFile) return undefined + let file = rawFile as Exclude + let type + if (rawFile?.type?.startsWith('image')) { + type = 'image' + } else if (rawFile?.type?.startsWith('video')) { + type = 'video' + } + if (rawFile instanceof File) { + file = { + name: rawFile.name, + type, + src: type ? URL.createObjectURL(rawFile) : undefined + } + } + file.type = type + return file + }, [rawFile]) + + return ( + +
+ + {/* + in Marko implementation, when there is seeMore prop, + there is no menu action button or delete button + */} + {seeMore && seeMore > 0 ? ( + + ) : ( + + )} + +
+ {footerTitle && ( +
+ {footerTitle} + {footerSubtitle && {footerSubtitle}} +
+ )} +
+ ) +} + +export default EbayFileInput diff --git a/src/ebay-file-preview-card/file-preview-content.tsx b/src/ebay-file-preview-card/file-preview-content.tsx new file mode 100644 index 000000000..9d9aa2b9a --- /dev/null +++ b/src/ebay-file-preview-card/file-preview-content.tsx @@ -0,0 +1,44 @@ +import React, { FC } from 'react' +import cx from 'classnames' +import { EbayProgressSpinner } from '../ebay-progress-spinner' +import { EbayIcon } from '../ebay-icon' +import { FilePreviewType } from './types' + +export type EbayFilePreviewContentProps = { + file?: FilePreviewType + status?: 'uploading' + seeMore?: number +} + +const EbayFilePreviewContent: FC = ({ + file, + status, + seeMore +}) => { + if (status === 'uploading') { + return + } + + switch (file?.type) { + case 'video': + return