Skip to content

Commit

Permalink
feat(FilePreview): add component (#1880)
Browse files Browse the repository at this point in the history
Co-authored-by: Andrey Morozov <amje@yandex-team.ru>
  • Loading branch information
KirillDyachkovskiy and amje authored Nov 11, 2024
1 parent 75be05e commit b5de671
Show file tree
Hide file tree
Showing 16 changed files with 990 additions and 0 deletions.
148 changes: 148 additions & 0 deletions src/components/FilePreview/FilePreview.scss
Original file line number Diff line number Diff line change
@@ -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%;
}
}
166 changes: 166 additions & 0 deletions src/components/FilePreview/FilePreview.tsx
Original file line number Diff line number Diff line change
@@ -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<FileType, IconData> = {
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<HTMLDivElement>;
actions?: FilePreviewActionProps[];
}

export function FilePreview({
className,
qa,
file,
imageSrc,
description,
onClick,
actions,
}: FilePreviewProps) {
const id = useUniqId();

const [previewSrc, setPreviewSrc] = React.useState<string | undefined>(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<HTMLDivElement> = React.useCallback(
(e) => {
if (onClick) {
onClick(e);
return;
}

if (mobile && isPreviewString) {
showPreviewSheet();
}
},
[isPreviewString, mobile, onClick, showPreviewSheet],
);

return (
<div className={cn(null, className)} data-qa={qa}>
<div
className={cn('card', {clickable, hoverable: clickable || withActions})}
role={clickable ? 'button' : undefined}
onKeyDown={clickable ? onKeyDown : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={clickable ? handleClick : undefined}
>
{isPreviewString ? (
<div className={cn('image-container')}>
<img className={cn('image')} src={previewSrc} alt={file.name} />
</div>
) : (
<div className={cn('icon', {type})}>
<Icon className={cn('icon-svg')} data={FILE_ICON[type]} size={20} />
</div>
)}
<Text className={cn('name')} color="secondary" ellipsis title={file.name}>
{file.name}
</Text>
{Boolean(description) && (
<Text
className={cn('description')}
color="secondary"
ellipsis
title={description}
>
{description}
</Text>
)}
</div>
{actions?.length ? (
<div className={cn('actions', {hide: hideActions})}>
{actions.map((action, index) => (
<FilePreviewAction
key={`${id}-${index}`}
id={`${id}-${index}`}
{...action}
/>
))}
</div>
) : null}

<MobileImagePreview
visible={isPreviewSheetVisible}
onClose={closePreviewSheet}
actions={actions}
previewSrc={previewSrc}
fileName={file.name}
/>
</div>
);
}

FilePreview.displayName = 'FilePreview';
48 changes: 48 additions & 0 deletions src/components/FilePreview/FilePreviewAction.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement | HTMLAnchorElement>;
extraProps?:
| React.ButtonHTMLAttributes<HTMLButtonElement>
| React.AnchorHTMLAttributes<HTMLAnchorElement>;
tooltipExtraProps?: Omit<ActionTooltipProps, 'id' | 'title' | 'children'>;
}

export function FilePreviewAction({
id,
icon,
title,
href,
disabled,
onClick,
extraProps,
tooltipExtraProps,
}: FilePreviewActionProps) {
return (
<ActionTooltip id={id} title={title} {...tooltipExtraProps}>
<Button
onClick={onClick}
aria-describedby={id}
view="raised"
pin="circle-circle"
href={href}
disabled={disabled}
size="s"
extraProps={{'aria-label': title, 'aria-describedby': id, ...extraProps}}
>
{icon}
</Button>
</ActionTooltip>
);
}

FilePreviewAction.displayName = 'FilePreviewAction';
Loading

0 comments on commit b5de671

Please sign in to comment.