From b5de67126a76467853957df6d8e5436cc460886e Mon Sep 17 00:00:00 2001 From: Kirill Dyachkovskiy <81510334+KirillDyachkovskiy@users.noreply.github.com> Date: Mon, 11 Nov 2024 14:45:07 +0300 Subject: [PATCH] feat(FilePreview): add component (#1880) Co-authored-by: Andrey Morozov --- src/components/FilePreview/FilePreview.scss | 148 +++++++++++++++ src/components/FilePreview/FilePreview.tsx | 166 +++++++++++++++++ .../FilePreview/FilePreviewAction.tsx | 48 +++++ .../MobileImagePreview.scss | 45 +++++ .../MobileImagePreview/MobileImagePreview.tsx | 66 +++++++ src/components/FilePreview/README.md | 104 +++++++++++ .../FilePreview/__stories__/Docs.mdx | 27 +++ .../__stories__/FilePreview.stories.tsx | 160 ++++++++++++++++ .../__tests__/FilePreview.test.tsx | 171 ++++++++++++++++++ src/components/FilePreview/i18n/en.json | 3 + src/components/FilePreview/i18n/index.ts | 8 + src/components/FilePreview/i18n/ru.json | 3 + src/components/FilePreview/index.ts | 3 + src/components/FilePreview/types.ts | 13 ++ src/components/FilePreview/utils.ts | 24 +++ src/components/index.ts | 1 + 16 files changed, 990 insertions(+) create mode 100644 src/components/FilePreview/FilePreview.scss create mode 100644 src/components/FilePreview/FilePreview.tsx create mode 100644 src/components/FilePreview/FilePreviewAction.tsx create mode 100644 src/components/FilePreview/MobileImagePreview/MobileImagePreview.scss create mode 100644 src/components/FilePreview/MobileImagePreview/MobileImagePreview.tsx create mode 100644 src/components/FilePreview/README.md create mode 100644 src/components/FilePreview/__stories__/Docs.mdx create mode 100644 src/components/FilePreview/__stories__/FilePreview.stories.tsx create mode 100644 src/components/FilePreview/__tests__/FilePreview.test.tsx create mode 100644 src/components/FilePreview/i18n/en.json create mode 100644 src/components/FilePreview/i18n/index.ts create mode 100644 src/components/FilePreview/i18n/ru.json create mode 100644 src/components/FilePreview/index.ts create mode 100644 src/components/FilePreview/types.ts create mode 100644 src/components/FilePreview/utils.ts diff --git a/src/components/FilePreview/FilePreview.scss b/src/components/FilePreview/FilePreview.scss new file mode 100644 index 000000000..10c9a9e7c --- /dev/null +++ b/src/components/FilePreview/FilePreview.scss @@ -0,0 +1,148 @@ +@use 'sass:math'; +@use '../variables'; + +$block: '.#{variables.$ns}file-preview'; + +$smallRoundedButtonSize: 24px; + +#{$block} { + --_-box-shadow: none; + --_-border-radius: 4px; + --_-color-base-background: transparent; + + position: relative; + + width: 120px; + + &:hover, + &:focus-within { + #{$block}__actions:not(#{$block}__actions_hide) { + opacity: 1; + } + + --_-color-base-background: var(--g-color-base-simple-hover, rgba(0, 0, 0, 0.05)); + } + + &__actions { + position: absolute; + inset-block-start: -1 * math.div($smallRoundedButtonSize, 2); + inset-inline-end: -1 * math.div($smallRoundedButtonSize, 2); + z-index: 1; + + display: flex; + gap: 4px; + + opacity: 0; + } + + &:hover { + --_-color-base-background: var(--g-color-base-simple-hover); + } + + &__card { + display: flex; + flex-direction: column; + align-items: center; + + position: relative; + outline: none; + + box-shadow: var(--gc-card-box-shadow); + border-radius: var(--_-border-radius); + padding: 4px 10px; + + &_clickable { + cursor: pointer; + } + + &_hoverable { + background-color: var(--_-color-base-background); + } + + &::after { + position: absolute; + inset: 0; + border-radius: var(--_-border-radius); + pointer-events: none; + } + + &:hover { + --_-box-shadow: 0px 3px 10px var(--g-color-sfx-shadow); + } + + &:focus::after { + content: ''; + box-shadow: 0 0 0 2px var(--g-color-line-misc); + } + + &:focus:not(:focus-visible)::after { + box-shadow: none; + } + } + + &__icon { + display: flex; + justify-content: center; + align-items: center; + + border-radius: 4px; + background-color: var(--g-color-base-generic-medium); + + height: 40px; + width: 40px; + + &-svg { + color: var(--g-color-base-background); + } + + &_type { + &_image, + &_video, + &_code, + &_archive, + &_music { + background-color: var(--g-color-base-misc-heavy); + } + &_text { + background-color: var(--g-color-base-info-heavy); + } + &_pdf { + background-color: var(--g-color-base-danger-medium); + } + &_table { + background-color: var(--g-color-base-positive-medium); + } + } + } + + &__name { + margin-block-start: 4px; + } + + &__name, + &__description { + text-align: center; + width: 100%; + } + + &__image-container { + position: relative; + + border-radius: 4px; + overflow: hidden; + + height: 64px; + width: 96px; + } + + &-image { + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + + object-fit: cover; + + height: 100%; + width: 100%; + } +} diff --git a/src/components/FilePreview/FilePreview.tsx b/src/components/FilePreview/FilePreview.tsx new file mode 100644 index 000000000..563387533 --- /dev/null +++ b/src/components/FilePreview/FilePreview.tsx @@ -0,0 +1,166 @@ +import React from 'react'; + +import { + FileZipper as ArchiveIcon, + Code as CodeIcon, + FileQuestion as DefaultIcon, + Picture as ImageIcon, + MusicNote as MusicIcon, + LogoAcrobat as PdfIcon, + LayoutHeaderCellsLarge as TableIcon, + TextAlignLeft as TextIcon, + Filmstrip as VideoIcon, +} from '@gravity-ui/icons'; + +import {useActionHandlers, useUniqId} from '../../hooks'; +import {useBoolean} from '../../hooks/private'; +import {Icon} from '../Icon'; +import type {IconData} from '../Icon'; +import {Text} from '../Text'; +import {useMobile} from '../mobile'; +import type {QAProps} from '../types'; +import {block} from '../utils/cn'; + +import {FilePreviewAction} from './FilePreviewAction'; +import type {FilePreviewActionProps} from './FilePreviewAction'; +import {MobileImagePreview} from './MobileImagePreview/MobileImagePreview'; +import type {FileType} from './types'; +import {getFileType} from './utils'; + +import './FilePreview.scss'; + +const cn = block('file-preview'); + +const FILE_ICON: Record = { + default: DefaultIcon, + image: ImageIcon, + video: VideoIcon, + code: CodeIcon, + archive: ArchiveIcon, + music: MusicIcon, + text: TextIcon, + pdf: PdfIcon, + table: TableIcon, +}; + +export interface FilePreviewProps extends QAProps { + className?: string; + + file: File; + imageSrc?: string; + description?: string; + + onClick?: React.MouseEventHandler; + actions?: FilePreviewActionProps[]; +} + +export function FilePreview({ + className, + qa, + file, + imageSrc, + description, + onClick, + actions, +}: FilePreviewProps) { + const id = useUniqId(); + + const [previewSrc, setPreviewSrc] = React.useState(imageSrc); + const [isPreviewSheetVisible, showPreviewSheet, closePreviewSheet] = useBoolean(false); + const mobile = useMobile(); + const type = getFileType(file); + + const {onKeyDown} = useActionHandlers(onClick); + + React.useEffect(() => { + if (imageSrc) return undefined; + + try { + const createdUrl = URL.createObjectURL(file); + + setPreviewSrc(createdUrl); + + return () => { + URL.revokeObjectURL(createdUrl); + }; + } catch (error: unknown) { + return undefined; + } + }, [file, imageSrc]); + + const clickable = Boolean(onClick); + const withActions = Boolean(actions?.length); + + const isPreviewString = typeof previewSrc === 'string'; + const hideActions = isPreviewString && mobile; + + const handleClick: React.MouseEventHandler = React.useCallback( + (e) => { + if (onClick) { + onClick(e); + return; + } + + if (mobile && isPreviewString) { + showPreviewSheet(); + } + }, + [isPreviewString, mobile, onClick, showPreviewSheet], + ); + + return ( +
+
+ {isPreviewString ? ( +
+ {file.name} +
+ ) : ( +
+ +
+ )} + + {file.name} + + {Boolean(description) && ( + + {description} + + )} +
+ {actions?.length ? ( +
+ {actions.map((action, index) => ( + + ))} +
+ ) : null} + + +
+ ); +} + +FilePreview.displayName = 'FilePreview'; diff --git a/src/components/FilePreview/FilePreviewAction.tsx b/src/components/FilePreview/FilePreviewAction.tsx new file mode 100644 index 000000000..be75401df --- /dev/null +++ b/src/components/FilePreview/FilePreviewAction.tsx @@ -0,0 +1,48 @@ +import React from 'react'; + +import {ActionTooltip} from '../ActionTooltip'; +import type {ActionTooltipProps} from '../ActionTooltip'; +import {Button} from '../Button'; + +export interface FilePreviewActionProps { + id?: string; + icon: React.ReactNode; + title: string; + href?: string; + disabled?: boolean; + onClick?: React.MouseEventHandler; + extraProps?: + | React.ButtonHTMLAttributes + | React.AnchorHTMLAttributes; + tooltipExtraProps?: Omit; +} + +export function FilePreviewAction({ + id, + icon, + title, + href, + disabled, + onClick, + extraProps, + tooltipExtraProps, +}: FilePreviewActionProps) { + return ( + + + + ); +} + +FilePreviewAction.displayName = 'FilePreviewAction'; diff --git a/src/components/FilePreview/MobileImagePreview/MobileImagePreview.scss b/src/components/FilePreview/MobileImagePreview/MobileImagePreview.scss new file mode 100644 index 000000000..5c0b27d21 --- /dev/null +++ b/src/components/FilePreview/MobileImagePreview/MobileImagePreview.scss @@ -0,0 +1,45 @@ +@use '../../variables'; +@use '@gravity-ui/uikit/styles/mixins'; + +$block: '.#{variables.$ns}mobile-image-preview'; + +#{$block} { + $previewButtonsTop: 15px; + + &__sheet-content { + padding: 0; + } + + &__container { + height: 85vh; + position: relative; + display: flex; + align-items: center; + justify-content: center; + } + + &__image { + width: auto; + max-width: 100%; + max-height: 100%; + } + + &__back-button { + position: absolute; + inset-block-start: $previewButtonsTop; + inset-inline-start: 15px; + } + + &__action-buttons { + position: absolute; + inset-block-start: $previewButtonsTop; + inset-inline-end: 15px; + display: flex; + flex-direction: column-reverse; + gap: 12px; + } + + &__error-label { + @include mixins.text-body-2; + } +} diff --git a/src/components/FilePreview/MobileImagePreview/MobileImagePreview.tsx b/src/components/FilePreview/MobileImagePreview/MobileImagePreview.tsx new file mode 100644 index 000000000..76c837bf3 --- /dev/null +++ b/src/components/FilePreview/MobileImagePreview/MobileImagePreview.tsx @@ -0,0 +1,66 @@ +import React from 'react'; + +import {ArrowLeft as ArrowLeftIcon} from '@gravity-ui/icons'; + +import {Button} from '../../Button'; +import {Icon} from '../../Icon'; +import {Sheet} from '../../Sheet'; +import {block} from '../../utils/cn'; +import type {FilePreviewActionProps} from '../FilePreviewAction'; +import i18n from '../i18n'; + +import './MobileImagePreview.scss'; + +const cn = block('mobile-image-preview'); + +export interface FilePreviewProps { + fileName?: string; + previewSrc?: string; + visible: boolean; + onClose: () => void; + actions?: FilePreviewActionProps[]; +} + +export function MobileImagePreview({ + previewSrc, + visible, + onClose, + actions, + fileName, +}: FilePreviewProps) { + const [showError, setShowError] = React.useState(false); + const showSheet = Boolean(previewSrc && visible); + + const handleImagesError = () => { + setShowError(true); + }; + + return ( + +
+ {showError ? ( +
{i18n('label_image-preview-error')}
+ ) : ( + {fileName} + )} + +
+ {actions?.map((action) => ( + + ))} +
+
+
+ ); +} + +MobileImagePreview.displayName = 'MobileImagePreview'; diff --git a/src/components/FilePreview/README.md b/src/components/FilePreview/README.md new file mode 100644 index 000000000..bf7221a6f --- /dev/null +++ b/src/components/FilePreview/README.md @@ -0,0 +1,104 @@ + + +## FilePreview + + + +```tsx +import {FilePreview} from '@gravity-ui/uikit'; +``` + +A component for displaying the file. + + + +```tsx + action('onClick')} + actions={[ + { + icon: , + title: 'Link', + onClick: () => action('onLink'), + }, + { + icon: , + title: 'Close', + onClick: () => action('onClose'), + }, + ]} +/> +``` + + + + + + + + + + + +### Properties + +| Name | Description | Type | Required | Default | +| :------------------ | :--------------------------------------------------------------------------------------------------------------- | :------------------------- | :------: | :------ | +| file | The File interface provides information about files and allows JavaScript in a web page to access their content. | `File` | yes | | +| imageSrc | source for image preview | `string` | | | +| description | Description displayed under the file name | `string` | | | +| className | Class name for the file container | `string` | | | +| onClick | Click handler for the file container | `function` | | | +| [actions](#actions) | Аn array of interactive actions | `FilePreviewActionProps[]` | | `[]` | + +#### Actions + +For a file, you can prescribe actions that will be visible when you hover over it. + +| Name | Description | Type | Required | Default | +| ---------- | ------------------------------ | ------------------------------------------------------------------------------------ | -------- | ------- | +| id | Action id | `String` | | | +| icon | Action icon | `String` | ✓ | | +| title | Action hint on hover | `String` | ✓ | | +| onClick | Action click handler | `function` | | | +| href | Action button href | `String` | | | +| extraProps | Additional action button props | `ButtonHTMLAttributes \| AnchorHTMLAttributes` | | | diff --git a/src/components/FilePreview/__stories__/Docs.mdx b/src/components/FilePreview/__stories__/Docs.mdx new file mode 100644 index 000000000..1f76cc01c --- /dev/null +++ b/src/components/FilePreview/__stories__/Docs.mdx @@ -0,0 +1,27 @@ +import { + Meta, + Markdown, + Canvas, + AnchorMdx, + CodeOrSourceMdx, + HeadersMdx, +} from '@storybook/addon-docs'; +import * as Stories from './FilePreview.stories'; +import Readme from '../README.md?raw'; + +export const FilePreviewExample = () => ; + + + + + {Readme} + diff --git a/src/components/FilePreview/__stories__/FilePreview.stories.tsx b/src/components/FilePreview/__stories__/FilePreview.stories.tsx new file mode 100644 index 000000000..bd2caf7c1 --- /dev/null +++ b/src/components/FilePreview/__stories__/FilePreview.stories.tsx @@ -0,0 +1,160 @@ +import React from 'react'; + +import {CircleExclamation, Link, Xmark} from '@gravity-ui/icons'; +import {action} from '@storybook/addon-actions'; +import type {Meta, StoryFn} from '@storybook/react'; + +import {Flex} from '../../layout'; +import type {FilePreviewProps} from '../FilePreview'; +import {FilePreview} from '../FilePreview'; +import {FILE_TYPES} from '../types'; + +export default { + title: 'Components/Data Display/FilePreview', + component: FilePreview, + parameters: { + a11y: { + element: '#storybook-root', + config: { + rules: [ + { + id: 'color-contrast', + enabled: false, + }, + ], + }, + }, + }, +} as Meta; + +const DefaultTemplate: StoryFn = (args) => { + return ; +}; + +export const Default = DefaultTemplate.bind({}); +Default.args = { + file: {name: 'my-file.docs', type: 'text/docs'} as File, + onClick: () => action('onClick'), + actions: [ + { + icon: , + onClick: () => action('onLink'), + title: 'Link', + }, + { + icon: , + onClick: () => action('onClose'), + title: 'Close', + }, + ], +}; + +const CollageTemplate: StoryFn = () => { + return ( +
+ {FILE_TYPES.map((fileType) => ( + action('onClick')} + actions={[ + { + icon: , + title: 'open on drive', + onClick: () => action('onLink'), + }, + { + icon: , + title: 'delete a file', + onClick: () => action('onClose'), + }, + ]} + /> + ))} + , + title: 'some hint', + onClick: () => action('onHint'), + }, + { + icon: , + onClick: () => action('onClose'), + title: 'Close', + }, + ]} + /> +
+ ); +}; + +export const Collage = CollageTemplate.bind({}); + +const NoClickableTemplate: StoryFn> = (args) => { + return ( + + + , + onClick: () => action('Are you sure you want to delete the file?'), + title: 'Close', + }, + ]} + /> + action('onClick')} + actions={[ + { + icon: , + onClick: () => action('Are you sure you want to delete the file?'), + title: 'Close', + }, + ]} + /> + + ); +}; + +export const NoClickable = NoClickableTemplate.bind({}); + +const WithoutActionTooltipTemplate: StoryFn> = (args) => { + return ( + + action('onClick')} + actions={[ + { + icon: , + onClick: () => action('onClose'), + title: 'Close', + tooltipExtraProps: { + disabled: true, + }, + }, + ]} + /> + + ); +}; + +export const WithoutActionTooltip = WithoutActionTooltipTemplate.bind({}); diff --git a/src/components/FilePreview/__tests__/FilePreview.test.tsx b/src/components/FilePreview/__tests__/FilePreview.test.tsx new file mode 100644 index 000000000..fba4915c5 --- /dev/null +++ b/src/components/FilePreview/__tests__/FilePreview.test.tsx @@ -0,0 +1,171 @@ +import React from 'react'; + +import {CircleExclamation} from '@gravity-ui/icons'; +import userEvent from '@testing-library/user-event'; + +import {render, screen} from '../../../../test-utils/utils'; +import {FilePreview} from '../FilePreview'; + +describe('FilePreview', () => { + test('Renders base content', () => { + const fileName = 'Some file name'; + const fileType = 'image/png'; + const imageSrc = + 'https://storage.yandexcloud.net/uikit-storybook-assets/changelog-dialog-picture-2.png'; + + render(); + + expect(screen.getByText(fileName)).toBeInTheDocument(); + }); + + test('Renders preview image', () => { + const fileName = 'Some file name'; + const fileType = 'image/png'; + const imageSrc = + 'https://storage.yandexcloud.net/uikit-storybook-assets/changelog-dialog-picture-2.png'; + + render(); + + const previewImage = screen.getByRole('img'); + + expect(previewImage).toHaveAttribute('src', imageSrc); + }); + + test('Call onClick handler', async () => { + const qyId = 'file-preview'; + const fileName = 'Some file name'; + const fileType = 'image/png'; + + const clickHandler = jest.fn(); + + render( + , title: 'some hint'}, + {icon: , title: 'second hint'}, + ]} + />, + ); + + const filePreview = screen.getByText(fileName); + + const user = userEvent.setup(); + await user.click(filePreview); + + expect(clickHandler).toBeCalled(); + }); + + test('Renders actions', () => { + const fileName = 'Some file name'; + const fileType = 'image/png'; + const imageSrc = + 'https://storage.yandexcloud.net/uikit-storybook-assets/changelog-dialog-picture-2.png'; + + const firstActionText = 'some hint'; + const secondActionText = 'second hint'; + + render( + , title: firstActionText}, + {icon: , title: secondActionText}, + ]} + />, + ); + + const firstAction = screen.getByRole('button', {name: firstActionText}); + expect(firstAction).toBeDefined(); + + const secondAction = screen.getByRole('button', {name: secondActionText}); + expect(secondAction).toBeDefined(); + }); + + test('Call action click handlers', async () => { + const fileName = 'Some file name'; + const fileType = 'image/png'; + + const firstActionsClickHandler = jest.fn(); + const secondActionsClickHandler = jest.fn(); + + render( + , + title: 'some hint', + onClick: firstActionsClickHandler, + }, + { + icon: , + title: 'second hint', + onClick: secondActionsClickHandler, + }, + ]} + />, + ); + + const actionButtons = screen.getAllByRole('button'); + + const user = userEvent.setup(); + for (const actionButton of actionButtons) { + await user.click(actionButton); + } + + expect(firstActionsClickHandler).toBeCalled(); + expect(secondActionsClickHandler).toBeCalled(); + }); + + test("Don't Call disabled action click handler", async () => { + const fileName = 'Some file name'; + const fileType = 'image/png'; + + const mockFn = jest.fn(); + + const TestCase = () => { + const [disabled, setDisabled] = React.useState(false); + const [clicksCount, setClicksCount] = React.useState(0); + + const actionsClickHandler = () => { + mockFn(); + setClicksCount((prev) => prev + 1); + + if (clicksCount === 4) { + setDisabled(true); + } + }; + + return ( + , + title: 'some hint', + onClick: actionsClickHandler, + }, + ]} + /> + ); + }; + + render(); + + const actionButtons = screen.getAllByRole('button'); + + const user = userEvent.setup(); + for (const actionButton of actionButtons) { + for (let i = 0; i < 10; i++) { + await user.click(actionButton); + } + } + + expect(mockFn).toBeCalledTimes(5); + }); +}); diff --git a/src/components/FilePreview/i18n/en.json b/src/components/FilePreview/i18n/en.json new file mode 100644 index 000000000..5a2b2e2ee --- /dev/null +++ b/src/components/FilePreview/i18n/en.json @@ -0,0 +1,3 @@ +{ + "label_image-preview-error": "Failed to load image" +} diff --git a/src/components/FilePreview/i18n/index.ts b/src/components/FilePreview/i18n/index.ts new file mode 100644 index 000000000..87647e661 --- /dev/null +++ b/src/components/FilePreview/i18n/index.ts @@ -0,0 +1,8 @@ +import {addComponentKeysets} from '../../../i18n'; + +import en from './en.json'; +import ru from './ru.json'; + +const COMPONENT = 'FilePreview'; + +export default addComponentKeysets({en, ru}, COMPONENT); diff --git a/src/components/FilePreview/i18n/ru.json b/src/components/FilePreview/i18n/ru.json new file mode 100644 index 000000000..792489935 --- /dev/null +++ b/src/components/FilePreview/i18n/ru.json @@ -0,0 +1,3 @@ +{ + "label_image-preview-error": "Не удалось загрузить изображение" +} diff --git a/src/components/FilePreview/index.ts b/src/components/FilePreview/index.ts new file mode 100644 index 000000000..23ce3a875 --- /dev/null +++ b/src/components/FilePreview/index.ts @@ -0,0 +1,3 @@ +export {FilePreview} from './FilePreview'; +export type {FilePreviewProps} from './FilePreview'; +export type {FileType} from './types'; diff --git a/src/components/FilePreview/types.ts b/src/components/FilePreview/types.ts new file mode 100644 index 000000000..d92b862a4 --- /dev/null +++ b/src/components/FilePreview/types.ts @@ -0,0 +1,13 @@ +export const FILE_TYPES = [ + 'default', + 'image', + 'video', + 'code', + 'archive', + 'music', + 'text', + 'pdf', + 'table', +] as const; + +export type FileType = (typeof FILE_TYPES)[number]; diff --git a/src/components/FilePreview/utils.ts b/src/components/FilePreview/utils.ts new file mode 100644 index 000000000..67afcd645 --- /dev/null +++ b/src/components/FilePreview/utils.ts @@ -0,0 +1,24 @@ +import type {FileType} from './types'; +import {FILE_TYPES} from './types'; + +const isFilePreviewFileType = (str: string): str is FileType => + FILE_TYPES.includes(str.toLowerCase() as FileType); + +export function getFileType(fileType: File['type']): FileType; +export function getFileType(file: File): FileType; + +export function getFileType(arg: File | File['type']): FileType { + const fileType: File['type'] = typeof arg === 'string' ? arg : arg.type; + + if (isFilePreviewFileType(fileType)) { + return fileType; + } + + const splittedFileType = fileType.split('/')[0]; + + if (isFilePreviewFileType(splittedFileType)) { + return splittedFileType; + } + + return 'default'; +} diff --git a/src/components/index.ts b/src/components/index.ts index a69f922db..b7cc647ad 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -20,6 +20,7 @@ export * from './Dialog'; export * from './Disclosure'; export * from './Divider'; export * from './DropdownMenu'; +export * from './FilePreview'; export * from './Hotkey'; export * from './Icon'; export * from './AvatarStack';