diff --git a/src/components/FileItem/FileItem.props.ts b/src/components/FileItem/FileItem.props.ts new file mode 100644 index 00000000..bef759d7 --- /dev/null +++ b/src/components/FileItem/FileItem.props.ts @@ -0,0 +1,33 @@ +import { MouseEvent } from 'react'; + +import { FileItemConfig } from './FileItem.styles'; +import { FileItemState, FileItemThumbnail } from './types'; + +export type FileItemProps = { + custom?: FileItemConfig; + file: File; + state?: FileItemState; + isInverted?: boolean; + isExtended?: boolean; + thumbnail?: FileItemThumbnail; + uploadedPercentage?: number; + timeLeftText?: string; + alertText?: string; + onReplaceClick?: (e?: MouseEvent) => void; + onRetryClick?: (e?: MouseEvent) => void; + onCloseClick?: (e?: MouseEvent) => void; +}; + +export type Fallback = { + state: FileItemState; + isInverted: boolean; + isExtended: boolean; + thumbnail: FileItemThumbnail; +}; + +export const fallback: Fallback = { + state: 'uploading', + isInverted: false, + isExtended: false, + thumbnail: 'none', +}; diff --git a/src/components/FileItem/FileItem.stories.tsx b/src/components/FileItem/FileItem.stories.tsx new file mode 100644 index 00000000..0e075412 --- /dev/null +++ b/src/components/FileItem/FileItem.stories.tsx @@ -0,0 +1,170 @@ +import { action } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { FileItem } from './FileItem'; +import { mockTextFile, mockImageFile } from './mocks'; + +import { FileItemDocs } from '@/docs-components/FileItemDocs'; +import { TetDocs } from '@/docs-components/TetDocs'; + +const meta = { + title: 'FileItem', + component: FileItem, + tags: ['autodocs'], + argTypes: {}, + args: { + file: mockTextFile(), + state: 'uploaded', + isInverted: false, + isExtended: false, + thumbnail: 'none', + uploadedPercentage: 25, + timeLeftText: '7 seconds left', + alertText: 'Short alert text', + onReplaceClick: action('onReplaceClick'), + onRetryClick: action('onRetryClick'), + onCloseClick: action('onCloseClick'), + }, + parameters: { + docs: { + description: { + component: + 'Enable users to upload specific files, such as images, documents, or videos, to a particular location. The user can perform this action by dragging and dropping files into the designated area or browsing local storage.', + }, + page: () => ( + + + + ), + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + file: mockTextFile(), + state: 'uploaded', + isExtended: false, + thumbnail: 'none', + onCloseClick: action('onCloseClick'), + }, +}; + +export const Uploading: Story = { + args: { + file: mockTextFile(), + state: 'uploading', + uploadedPercentage: 25, + onCloseClick: action('onCloseClick'), + }, +}; + +export const Uploaded: Story = { + args: { + file: mockTextFile(), + state: 'uploaded', + onCloseClick: action('onCloseClick'), + }, +}; + +export const Replaceable: Story = { + args: { + file: mockTextFile(), + state: 'replaceable', + onReplaceClick: action('onReplaceClick'), + onCloseClick: action('onCloseClick'), + }, +}; + +export const Alert: Story = { + args: { + file: mockTextFile(), + state: 'alert', + alertText: 'Short alert text', + onRetryClick: action('onRetryClick'), + onCloseClick: action('onCloseClick'), + }, +}; + +export const ExtendedUploading: Story = { + args: { + file: mockTextFile(), + state: 'uploading', + isExtended: true, + uploadedPercentage: 25, + timeLeftText: '7 seconds left', + onCloseClick: action('onCloseClick'), + }, +}; + +export const ExtendedUploaded: Story = { + args: { + file: mockTextFile(), + state: 'uploaded', + isExtended: true, + onCloseClick: action('onCloseClick'), + }, +}; + +export const ExtendedReplaceable: Story = { + args: { + file: mockTextFile(), + state: 'replaceable', + isExtended: true, + onReplaceClick: action('onReplaceClick'), + onCloseClick: action('onCloseClick'), + }, +}; + +export const ExtendedAlert: Story = { + args: { + file: mockTextFile(), + state: 'alert', + isExtended: true, + alertText: 'Short alert text', + onRetryClick: action('onRetryClick'), + onCloseClick: action('onCloseClick'), + }, +}; + +export const ExtendedUploadingFile: Story = { + args: { + file: mockTextFile(), + state: 'uploading', + isExtended: true, + thumbnail: 'file', + uploadedPercentage: 25, + timeLeftText: '7 seconds left', + onCloseClick: action('onCloseClick'), + }, +}; + +export const ExtendedAlertImage: Story = { + args: { + file: mockImageFile(), + state: 'alert', + isExtended: true, + thumbnail: 'photo', + alertText: 'Short alert text', + onReplaceClick: action('onReplaceClick'), + onRetryClick: action('onRetryClick'), + onCloseClick: action('onCloseClick'), + }, +}; + +export const ExtendedInvertedAlertImage: Story = { + args: { + file: mockImageFile(), + state: 'alert', + isExtended: true, + isInverted: true, + thumbnail: 'photo', + alertText: 'Short alert text', + onReplaceClick: action('onReplaceClick'), + onRetryClick: action('onRetryClick'), + onCloseClick: action('onCloseClick'), + }, +}; diff --git a/src/components/FileItem/FileItem.styles.ts b/src/components/FileItem/FileItem.styles.ts new file mode 100644 index 00000000..9f43a388 --- /dev/null +++ b/src/components/FileItem/FileItem.styles.ts @@ -0,0 +1,65 @@ +import { compressedVariantStyles, extendedVariantStyles } from './components'; +import { FileItemThumbnail } from './types'; + +import type { BaseProps } from '@/types/BaseProps'; + +export type FileItemConfig = BaseProps & { + state?: { + uploading?: BaseProps; + uploaded?: BaseProps; + replaceable?: BaseProps; + alert?: BaseProps; + }; + inverted?: { + yes?: BaseProps; + no?: BaseProps; + }; + thumbnail?: Record; + invertedAlert?: BaseProps; + compressed?: BaseProps; + extended?: BaseProps; +}; + +export const defaultConfig = { + display: 'flex', + flexDirection: 'column', + gap: '$space-component-gap-small', + borderRadius: '$border-radius-large', + state: { + uploading: { + backgroundColor: '$color-interaction-neutral-subtle-normal', + }, + uploaded: { + backgroundColor: '$color-interaction-default-subtle-normal', + }, + replaceable: { + backgroundColor: '$color-interaction-default-subtle-normal', + }, + alert: { + backgroundColor: '$color-interaction-alert-subtle-normal', + }, + }, + inverted: { + yes: { + backgroundColor: '$color-interaction-background-formField', + borderWidth: '$border-width-100', + borderStyle: '$border-style-solid', + borderColor: '$color-interaction-border-neutral-normal', + }, + no: {}, + }, + thumbnail: { + none: {}, + file: {}, + photo: {}, + }, + invertedAlert: { + borderColor: '$color-interaction-border-alert', + }, + compressed: compressedVariantStyles.defaultConfig, + extended: extendedVariantStyles.defaultConfig, +} as const satisfies FileItemConfig; + +export const fileItemStyles = { + defaultConfig, +}; diff --git a/src/components/FileItem/FileItem.test.tsx b/src/components/FileItem/FileItem.test.tsx new file mode 100644 index 00000000..531945fa --- /dev/null +++ b/src/components/FileItem/FileItem.test.tsx @@ -0,0 +1,151 @@ +import { vi } from 'vitest'; + +import { FileItem } from './FileItem'; +import { mockImageFile, mockTextFile } from './mocks'; +import { render, screen, fireEvent } from '../../tests/render'; + +describe('FileItem', () => { + it('should render empty file item', () => { + render(); + const fileItem = screen.getByTestId('file-item'); + expect(fileItem).toBeInTheDocument(); + + const fileName = screen.getByTestId('file-name'); + expect(fileName).toBeInTheDocument(); + + const fileSize = screen.getByTestId('file-size'); + expect(fileSize).toBeInTheDocument(); + + const closeIcon = screen.getByTestId('close-icon'); + expect(closeIcon).toBeInTheDocument(); + }); + + it('should call onReplaceClick after click to Replace button', () => { + const onReplaceClick = vi.fn(); + + render( + , + ); + + const replaceableButton = screen.getByTestId('replaceable-button'); + expect(replaceableButton).toBeInTheDocument(); + + fireEvent.click(replaceableButton); + expect(onReplaceClick).toHaveBeenCalled(); + }); + + it('should call onRetryClick after click to Retry button', () => { + const onRetryClick = vi.fn(); + + render( + , + ); + + const retryButton = screen.getByTestId('retry-button'); + expect(retryButton).toBeInTheDocument(); + + fireEvent.click(retryButton); + expect(onRetryClick).toHaveBeenCalled(); + }); + + it('should call onCloseClick after click to close button', () => { + const onCloseClick = vi.fn(); + + render( + , + ); + + const closeButton = screen.getByTestId('close-icon'); + expect(closeButton).toBeInTheDocument(); + + fireEvent.click(closeButton); + expect(onCloseClick).toHaveBeenCalled(); + }); + + it('should render progress bar in uploading state', () => { + render(); + const progressBar = screen.getByTestId('progress-bar'); + expect(progressBar).toBeInTheDocument(); + }); + + it('should show uploaded percentage in uploading state', () => { + render( + , + ); + const uploadedPercentage = screen.getByTestId('uploaded-percentage'); + expect(uploadedPercentage).toBeInTheDocument(); + expect(uploadedPercentage.innerHTML).toEqual('76.25%'); + }); + + it('should show alert text in compressed alert state', () => { + render( + , + ); + const alertText = screen.getByTestId('alert-text'); + expect(alertText).toBeInTheDocument(); + expect(alertText.innerHTML).toEqual('Short alert text'); + }); + + it('should show alert text in extended alert state', () => { + render( + , + ); + const alertInfo = screen.getByTestId('alert-info'); + expect(alertInfo).toBeInTheDocument(); + expect(alertInfo.innerHTML).toContain('Short alert text'); + }); + + it('should show thumbnail file icon', () => { + render( + , + ); + const thumbnailFile = screen.getByTestId('thumbnail-file'); + expect(thumbnailFile).toBeInTheDocument(); + }); + + it('should show thumbnail photo image', () => { + Object.defineProperty(window.URL, 'createObjectURL', { value: () => '' }); + + render( + , + ); + const thumbnailPhoto = screen.getByTestId('thumbnail-photo'); + expect(thumbnailPhoto).toBeInTheDocument(); + }); +}); diff --git a/src/components/FileItem/FileItem.tsx b/src/components/FileItem/FileItem.tsx new file mode 100644 index 00000000..82952af2 --- /dev/null +++ b/src/components/FileItem/FileItem.tsx @@ -0,0 +1,59 @@ +import { FC } from 'react'; + +import { CompressedVariant, ExtendedVariant } from './components'; +import { FileItemProps, fallback } from './FileItem.props'; +import { stylesBuilder } from './stylesBuilder'; + +import { tet } from '@/tetrisly'; + +export const FileItem: FC = (props) => { + const { + file, + state = fallback.state, + isInverted = fallback.isInverted, + isExtended = fallback.isExtended, + thumbnail = fallback.thumbnail, + uploadedPercentage, + timeLeftText, + alertText, + onReplaceClick, + onRetryClick, + onCloseClick, + } = props; + + const styles = stylesBuilder(props); + + return ( + + {!isExtended && ( + + )} + + {isExtended && ( + + )} + + ); +}; diff --git a/src/components/FileItem/components/CompressedVariant/CompressedVariant.props.ts b/src/components/FileItem/components/CompressedVariant/CompressedVariant.props.ts new file mode 100644 index 00000000..e3f7da1e --- /dev/null +++ b/src/components/FileItem/components/CompressedVariant/CompressedVariant.props.ts @@ -0,0 +1,16 @@ +import { MouseEvent } from 'react'; + +import { CompressedVariantConfig } from './CompressedVariant.styles'; +import { FileItemState } from '../../types'; + +export type CompressedVariantProps = { + custom?: CompressedVariantConfig; + state: FileItemState; + file: File; + isInverted?: boolean; + uploadedPercentage?: number; + alertText?: string; + onReplaceClick?: (e?: MouseEvent) => void; + onRetryClick?: (e?: MouseEvent) => void; + onCloseClick?: (e?: MouseEvent) => void; +}; diff --git a/src/components/FileItem/components/CompressedVariant/CompressedVariant.styles.ts b/src/components/FileItem/components/CompressedVariant/CompressedVariant.styles.ts new file mode 100644 index 00000000..a4d745a2 --- /dev/null +++ b/src/components/FileItem/components/CompressedVariant/CompressedVariant.styles.ts @@ -0,0 +1,74 @@ +import { ProgressBarConfig, progressBarStyles } from '../ProgressBar'; + +import type { BaseProps } from '@/types/BaseProps'; + +export type CompressedVariantConfig = BaseProps & { + innerElements?: { + fileInfo?: BaseProps; + fileName?: BaseProps; + fileSize?: BaseProps; + alertIcon?: BaseProps; + content?: BaseProps; + uploadingContent?: BaseProps; + replaceableContent?: BaseProps; + alertContent?: BaseProps; + notExtendedAlert?: BaseProps; + closeIconButton?: BaseProps; + progressBar?: ProgressBarConfig; + }; +}; + +export const defaultConfig = { + display: 'flex', + flexDirection: 'column', + gap: '$space-component-gap-xSmall', + px: '$space-component-padding-large', + py: '$space-component-padding-small', + innerElements: { + fileInfo: { + display: 'flex', + alignItems: 'center', + gap: '$space-component-gap-small', + }, + fileName: { + text: '$typo-medium-175', + color: '$color-content-primary', + }, + fileSize: { + text: '$typo-medium-175', + color: '$color-content-secondary', + }, + alertIcon: { + color: '$color-content-negative-secondary', + }, + content: { + flexGrow: 1, + mx: '$space-component-padding-large', + }, + uploadingContent: {}, + replaceableContent: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-end', + }, + alertContent: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-end', + }, + notExtendedAlert: { + text: '$typo-body-small', + color: '$color-content-negative-secondary', + }, + closeIconButton: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + }, + progressBar: progressBarStyles.defaultConfig, + }, +} as const satisfies CompressedVariantConfig; + +export const compressedVariantStyles = { + defaultConfig, +}; diff --git a/src/components/FileItem/components/CompressedVariant/CompressedVariant.tsx b/src/components/FileItem/components/CompressedVariant/CompressedVariant.tsx new file mode 100644 index 00000000..b6de324c --- /dev/null +++ b/src/components/FileItem/components/CompressedVariant/CompressedVariant.tsx @@ -0,0 +1,99 @@ +import { FC } from 'react'; + +import { CompressedVariantProps } from './CompressedVariant.props'; +import { stylesBuilder } from './stylesBuilder'; +import { ProgressBar } from '../ProgressBar'; + +import { Button } from '@/components/Button'; +import { Icon } from '@/components/Icon'; +import { IconButton } from '@/components/IconButton'; +import { formatFileSize } from '@/services'; +import { tet } from '@/tetrisly'; + +export const CompressedVariant: FC = ({ + custom, + state, + file, + isInverted, + uploadedPercentage, + alertText, + onReplaceClick, + onRetryClick, + onCloseClick, +}) => { + const styles = stylesBuilder(custom); + const formattedFileSize = formatFileSize(file.size); + + return ( + + + {state === 'alert' && ( + + )} + + + {file.name} + + + {formattedFileSize} + + + + {state === 'uploading' && ( + + + + )} + + {state === 'replaceable' && onReplaceClick && ( + + + to upload + + ), + caption: 'JPG, GIF or PNG. Max size of 800K', + }, + buttonVariant: { + text: 'Choose file...', + }, + inputProps: { + name: 'myFileInput', + multiple: true, + accept: '*', + }, + }, + render: (props) => , +}; diff --git a/src/components/FileUploader/FileUploader.styles.ts b/src/components/FileUploader/FileUploader.styles.ts new file mode 100644 index 00000000..7a858f74 --- /dev/null +++ b/src/components/FileUploader/FileUploader.styles.ts @@ -0,0 +1,29 @@ +import { + ControlConfig, + controlStyles, + DragAndDropFieldConfig, + dragAndDropFieldStyles, +} from './components'; + +import type { BaseProps } from '@/types/BaseProps'; + +export type FileUploaderConfig = BaseProps & { + innerElements: { + control: ControlConfig; + dragAndDropField: DragAndDropFieldConfig; + }; +}; + +export const defaultConfig = { + display: 'flex', + flexDirection: 'column', + gap: '$space-component-gap-small', + innerElements: { + control: controlStyles.defaultConfig, + dragAndDropField: dragAndDropFieldStyles.defaultConfig, + }, +} as const satisfies FileUploaderConfig; + +export const fileUploaderStyles = { + defaultConfig, +}; diff --git a/src/components/FileUploader/FileUploader.tsx b/src/components/FileUploader/FileUploader.tsx new file mode 100644 index 00000000..c6dd4d23 --- /dev/null +++ b/src/components/FileUploader/FileUploader.tsx @@ -0,0 +1,99 @@ +import { FC, ChangeEventHandler, useRef } from 'react'; + +import { ButtonVariant } from './ButtonVariant'; +import { Control } from './components'; +import { DragAndDropVariant } from './DragAndDropVariant'; +import { FileUploaderProps } from './FileUploader.props'; +import { stylesBuilder } from './stylesBuilder'; + +import { useDragOver } from '@/hooks'; +import { tet } from '@/tetrisly'; + +const disableReceivingFocus = { tabIndex: -1 }; + +export const FileUploader: FC = ({ + custom, + label, + helperText, + variant, + state, + inputProps, + dragAndDropVariant, + buttonVariant, + value, + onChange, +}) => { + const inputRef = useRef(null); + const { dragOver, onDragEnter, onDragLeave, onDrop } = useDragOver(); + + const styles = stylesBuilder(custom); + + const handleChooseFileClick = () => { + inputRef.current?.click(); + }; + + const onInputChange: ChangeEventHandler = (e) => { + const { files } = e.target; + + if (files) { + onChange?.(mapFromFileList(files)); + } + }; + + return ( + <> + + + + + {variant === 'drag&drop' && ( + { + e.preventDefault(); + e.stopPropagation(); + onDrop(); + }} + onDragOver={(e) => { + e.preventDefault(); + }} + > + + + )} + + {variant === 'button' && ( + + )} + + + {value && value.length > 0 && ( + {/* */} + )} + + + ); +}; + +const mapFromFileList = (fileList: FileList): File[] => + Array.from(fileList).map((file) => file); diff --git a/src/components/FileUploader/components/Control/Control.props.tsx b/src/components/FileUploader/components/Control/Control.props.tsx new file mode 100644 index 00000000..8cf87ba2 --- /dev/null +++ b/src/components/FileUploader/components/Control/Control.props.tsx @@ -0,0 +1,13 @@ +import { ReactNode } from 'react'; + +import type { ControlConfig } from './Control.styles'; + +import { HelperTextProps } from '@/components/HelperText'; +import { LabelProps } from '@/components/Label'; + +export type ControlProps = { + children?: ReactNode; + custom?: ControlConfig; + label?: LabelProps; + helperText?: HelperTextProps; +}; diff --git a/src/components/FileUploader/components/Control/Control.styles.ts b/src/components/FileUploader/components/Control/Control.styles.ts new file mode 100644 index 00000000..9f9a0134 --- /dev/null +++ b/src/components/FileUploader/components/Control/Control.styles.ts @@ -0,0 +1,13 @@ +import type { BaseProps } from '@/types/BaseProps'; + +export type ControlConfig = BaseProps; + +export const defaultConfig = { + display: 'flex', + flexDirection: 'column', + gap: '$space-component-gap-small', +} as const satisfies ControlConfig; + +export const controlStyles = { + defaultConfig, +}; diff --git a/src/components/FileUploader/components/Control/Control.tsx b/src/components/FileUploader/components/Control/Control.tsx new file mode 100644 index 00000000..6b2d9fcf --- /dev/null +++ b/src/components/FileUploader/components/Control/Control.tsx @@ -0,0 +1,35 @@ +import { FC } from 'react'; + +import { ControlProps } from './Control.props'; +import { stylesBuilder } from './stylesBuilder'; + +import { HelperText } from '@/components/HelperText'; +import { Label } from '@/components/Label'; +import { tet } from '@/tetrisly'; + +export const Control: FC = ({ + children, + custom, + label, + helperText, +}) => { + const styles = stylesBuilder(custom); + + return ( + + {label && ( + + + )} + + {children} + + {helperText && ( + + + + )} + + ); +}; diff --git a/src/components/FileUploader/components/Control/index.ts b/src/components/FileUploader/components/Control/index.ts new file mode 100644 index 00000000..2a7e5380 --- /dev/null +++ b/src/components/FileUploader/components/Control/index.ts @@ -0,0 +1,4 @@ +export * from './Control'; +export type { ControlProps } from './Control.props'; +export type { ControlConfig } from './Control.styles'; +export { controlStyles } from './Control.styles'; diff --git a/src/components/FileUploader/components/Control/stylesBuilder.ts b/src/components/FileUploader/components/Control/stylesBuilder.ts new file mode 100644 index 00000000..a00c5852 --- /dev/null +++ b/src/components/FileUploader/components/Control/stylesBuilder.ts @@ -0,0 +1,17 @@ +import { ControlConfig, defaultConfig } from './Control.styles'; + +import { mergeConfigWithCustom } from '@/services/mergeConfigWithCustom'; +import { BaseProps } from '@/types/BaseProps'; + +type ControlStylesBuilder = { + container: BaseProps; +}; + +export const stylesBuilder = (custom?: ControlConfig): ControlStylesBuilder => { + const container = mergeConfigWithCustom({ + defaultConfig, + custom, + }); + + return { container }; +}; diff --git a/src/components/FileUploader/components/DragAndDropField/DragAndDropField.props.ts b/src/components/FileUploader/components/DragAndDropField/DragAndDropField.props.ts new file mode 100644 index 00000000..eacd222e --- /dev/null +++ b/src/components/FileUploader/components/DragAndDropField/DragAndDropField.props.ts @@ -0,0 +1,16 @@ +import { ReactNode, MouseEvent, MouseEventHandler } from 'react'; + +import { DragAndDropFieldConfig } from './DragAndDropField.styles'; + +import { IconName } from '@/utility-types/IconName'; + +export type DragAndDropFieldProps = { + custom?: DragAndDropFieldConfig; + isExtended?: boolean; + isDragOver?: boolean; + state?: 'default' | 'alert' | 'disabled'; + icon?: IconName<20>; + text?: (onChooseFileClick: MouseEventHandler) => ReactNode; + caption?: ReactNode; + onChooseFileClick?: (e?: MouseEvent) => void; +}; diff --git a/src/components/FileUploader/components/DragAndDropField/DragAndDropField.styles.ts b/src/components/FileUploader/components/DragAndDropField/DragAndDropField.styles.ts new file mode 100644 index 00000000..b7c09ed5 --- /dev/null +++ b/src/components/FileUploader/components/DragAndDropField/DragAndDropField.styles.ts @@ -0,0 +1,138 @@ +import type { BaseProps } from '@/types/BaseProps'; + +type ExtensionConfig = { + common: BaseProps; + dragOver: BaseProps; + default: BaseProps; + alert: BaseProps; + disabled: BaseProps; +}; + +const dragOverConfig = { + borderWidth: '$border-width-medium', + borderStyle: '$border-style-solid', + borderColor: '$color-interaction-border-selected', + backgroundColor: '$color-interaction-default-subtle-normal', +} satisfies BaseProps; + +const focusConfig = { + outlineWidth: { + focus: '$border-width-medium', + }, + outlineStyle: { + focus: '$border-style-solid', + }, + outlineColor: { + focus: '$color-interaction-focus-default', + }, + outlineOffset: { + focus: '$border-width-100', + }, +} satisfies BaseProps; + +const disabledConfig = { + cursor: 'no-drop', + opacity: '$opacity-disabled', + borderWidth: '$border-width-small', + borderStyle: '$border-style-dashed', + borderColor: '$color-interaction-border-neutral-normal', +} satisfies BaseProps; + +export type DragAndDropFieldConfig = BaseProps & { + innerElements: { + icon: BaseProps; + title: BaseProps; + description: BaseProps; + }; + + notExtended: ExtensionConfig; + extended: ExtensionConfig; +}; + +const notExtendedDefaultConfig = { + borderWidth: '$border-width-small', + borderStyle: '$border-style-dashed', + borderColor: { + _: '$color-interaction-border-neutral-normal', + hover: '$color-interaction-border-hover', + }, + ...focusConfig, +} satisfies BaseProps; + +const notExtendedAlertConfig = { + borderWidth: '$border-width-small', + borderStyle: '$border-style-dashed', + borderColor: '$color-interaction-border-alert', + ...focusConfig, +} satisfies BaseProps; + +const notExtendedConfig = { + common: { + px: '$space-component-padding-large', + py: '$space-component-padding-small', + flexDirection: 'row', + gap: '$space-component-gap-small', + justifyContent: 'center', + }, + dragOver: dragOverConfig, + default: notExtendedDefaultConfig, + alert: notExtendedAlertConfig, + disabled: disabledConfig, +} satisfies ExtensionConfig; + +const extendedDefaultConfig = { + borderWidth: '$border-width-small', + borderStyle: '$border-style-dashed', + borderColor: { + _: '$color-interaction-border-neutral-normal', + hover: '$color-interaction-border-hover', + }, + ...focusConfig, +} satisfies BaseProps; + +const extendedAlertConfig = { + borderWidth: '$border-width-small', + borderStyle: '$border-style-dashed', + borderColor: '$color-interaction-border-alert', + ...focusConfig, +} satisfies BaseProps; + +const extendedConfig = { + common: { + padding: '$space-component-padding-2xLarge', + flexDirection: 'column', + gap: '$space-component-gap-large', + }, + dragOver: dragOverConfig, + default: extendedDefaultConfig, + alert: extendedAlertConfig, + disabled: disabledConfig, +} satisfies ExtensionConfig; + +export const defaultConfig = { + display: 'flex', + alignItems: 'center', + borderRadius: '$border-radius-large', + background: '$color-interaction-background-formField', + + notExtended: notExtendedConfig, + extended: extendedConfig, + + innerElements: { + icon: { + color: '$color-content-secondary', + }, + title: { + color: '$color-content-primary', + text: '$typo-medium-175', + }, + description: { + color: '$color-content-secondary', + text: '$typo-medium-150', + }, + }, +} satisfies DragAndDropFieldConfig; + +export const dragAndDropFieldStyles = { + defaultConfig, +}; diff --git a/src/components/FileUploader/components/DragAndDropField/DragAndDropField.tsx b/src/components/FileUploader/components/DragAndDropField/DragAndDropField.tsx new file mode 100644 index 00000000..b3f6aa1f --- /dev/null +++ b/src/components/FileUploader/components/DragAndDropField/DragAndDropField.tsx @@ -0,0 +1,57 @@ +import { FC } from 'react'; + +import { DragAndDropFieldProps } from './DragAndDropField.props'; +import { stylesBuilder } from './stylesBuilder'; + +import { Button } from '@/components/Button'; +import { Icon } from '@/components/Icon'; +import { tet } from '@/tetrisly'; + +const enableReceivingFocus = { tabIndex: 0 }; +const noop = () => {}; + +export const DragAndDropField: FC = (props) => { + const { + isExtended = false, + icon = '20-upload', + text = (onChooseFileClick) => ( + <> + Drag & Drop or + + to upload + + ), + caption = 'JPG, GIF or PNG. Max size of 800K', + state, + onChooseFileClick, + } = props; + + const styles = stylesBuilder(props); + + const withDisabledStyles = state !== 'disabled' ? enableReceivingFocus : {}; + + return ( + + + + + + {text(onChooseFileClick ?? noop)} + + {isExtended && {caption}} + + ); +}; diff --git a/src/components/FileUploader/components/DragAndDropField/index.ts b/src/components/FileUploader/components/DragAndDropField/index.ts new file mode 100644 index 00000000..f3c2c019 --- /dev/null +++ b/src/components/FileUploader/components/DragAndDropField/index.ts @@ -0,0 +1,4 @@ +export * from './DragAndDropField'; +export type { DragAndDropFieldProps } from './DragAndDropField.props'; +export type { DragAndDropFieldConfig } from './DragAndDropField.styles'; +export { dragAndDropFieldStyles } from './DragAndDropField.styles'; diff --git a/src/components/FileUploader/components/DragAndDropField/stylesBuilder.ts b/src/components/FileUploader/components/DragAndDropField/stylesBuilder.ts new file mode 100644 index 00000000..fad66c70 --- /dev/null +++ b/src/components/FileUploader/components/DragAndDropField/stylesBuilder.ts @@ -0,0 +1,42 @@ +import { DragAndDropFieldProps } from './DragAndDropField.props'; +import { defaultConfig } from './DragAndDropField.styles'; + +import { mergeConfigWithCustom } from '@/services/mergeConfigWithCustom'; +import { BaseProps } from '@/types/BaseProps'; + +type DragAndDropFieldStylesBuilder = { + container: BaseProps; + icon: BaseProps; + title: BaseProps; + description: BaseProps; +}; + +export const stylesBuilder = ( + props: DragAndDropFieldProps, +): DragAndDropFieldStylesBuilder => { + const { extended, notExtended, innerElements, ...container } = + mergeConfigWithCustom({ + defaultConfig, + custom: props.custom, + }); + + const { + common: withExtensionStyles, + dragOver, + ...extendedStyles + } = props.isExtended ? extended : notExtended; + const withStateStyles = extendedStyles[props.state ?? 'default']; + const withDragOverStyles = props.isDragOver ? dragOver : {}; + + return { + container: { + ...container, + ...withExtensionStyles, + ...withStateStyles, + ...withDragOverStyles, + }, + icon: innerElements.icon, + title: innerElements.title, + description: innerElements.description, + }; +}; diff --git a/src/components/FileUploader/components/FilesList/FilesList.props.ts b/src/components/FileUploader/components/FilesList/FilesList.props.ts new file mode 100644 index 00000000..b2d22897 --- /dev/null +++ b/src/components/FileUploader/components/FilesList/FilesList.props.ts @@ -0,0 +1,6 @@ +import { FilesListConfig } from './FilesList.styles'; + +export type FilesListProps = { + custom?: FilesListConfig; + files: File[]; +}; diff --git a/src/components/FileUploader/components/FilesList/FilesList.styles.ts b/src/components/FileUploader/components/FilesList/FilesList.styles.ts new file mode 100644 index 00000000..97d19f70 --- /dev/null +++ b/src/components/FileUploader/components/FilesList/FilesList.styles.ts @@ -0,0 +1,13 @@ +import type { BaseProps } from '@/types/BaseProps'; + +export type FilesListConfig = BaseProps; + +export const defaultConfig = { + display: 'flex', + flexDirection: 'column', + gap: '$space-component-gap-small', +} as const satisfies FilesListConfig; + +export const filesListStyles = { + defaultConfig, +}; diff --git a/src/components/FileUploader/components/FilesList/FilesList.tsx b/src/components/FileUploader/components/FilesList/FilesList.tsx new file mode 100644 index 00000000..771fc9ed --- /dev/null +++ b/src/components/FileUploader/components/FilesList/FilesList.tsx @@ -0,0 +1,26 @@ +import { FC } from 'react'; + +import { FilesListProps } from './FilesList.props'; +import { stylesBuilder } from './stylesBuilder'; + +import { FileItem } from '@/components/FileItem'; +import { tet } from '@/tetrisly'; + +export const FilesList: FC = ({ custom, files }) => { + const styles = stylesBuilder(custom); + + return ( + + {files.map((file) => ( + + + + ))} + + ); +}; diff --git a/src/components/FileUploader/components/FilesList/index.ts b/src/components/FileUploader/components/FilesList/index.ts new file mode 100644 index 00000000..9ae84a7e --- /dev/null +++ b/src/components/FileUploader/components/FilesList/index.ts @@ -0,0 +1 @@ +export * from './FilesList'; diff --git a/src/components/FileUploader/components/FilesList/stylesBuilder.ts b/src/components/FileUploader/components/FilesList/stylesBuilder.ts new file mode 100644 index 00000000..08422ff1 --- /dev/null +++ b/src/components/FileUploader/components/FilesList/stylesBuilder.ts @@ -0,0 +1,19 @@ +import { FilesListConfig, defaultConfig } from './FilesList.styles'; + +import { mergeConfigWithCustom } from '@/services/mergeConfigWithCustom'; +import { BaseProps } from '@/types/BaseProps'; + +type FilesListStylesBuilder = { + container: BaseProps; +}; + +export const stylesBuilder = ( + custom?: FilesListConfig, +): FilesListStylesBuilder => { + const container = mergeConfigWithCustom({ + defaultConfig, + custom, + }); + + return { container }; +}; diff --git a/src/components/FileUploader/components/index.ts b/src/components/FileUploader/components/index.ts new file mode 100644 index 00000000..aa5a3d11 --- /dev/null +++ b/src/components/FileUploader/components/index.ts @@ -0,0 +1,3 @@ +export * from './Control'; +export * from './DragAndDropField'; +export * from './FilesList'; diff --git a/src/components/FileUploader/index.ts b/src/components/FileUploader/index.ts new file mode 100644 index 00000000..2f0e759d --- /dev/null +++ b/src/components/FileUploader/index.ts @@ -0,0 +1,3 @@ +export * from './FileUploader'; +export * from './FileUploader.props'; +export { fileUploaderStyles } from './FileUploader.styles'; diff --git a/src/components/FileUploader/stylesBuilder.ts b/src/components/FileUploader/stylesBuilder.ts new file mode 100644 index 00000000..980996f3 --- /dev/null +++ b/src/components/FileUploader/stylesBuilder.ts @@ -0,0 +1,26 @@ +import { ControlConfig, DragAndDropFieldConfig } from './components'; +import { FileUploaderConfig, defaultConfig } from './FileUploader.styles'; + +import { mergeConfigWithCustom } from '@/services/mergeConfigWithCustom'; +import { BaseProps } from '@/types/BaseProps'; + +type FileUploaderStylesBuilder = { + container: BaseProps; + control: ControlConfig; + dragAndDropField: DragAndDropFieldConfig; +}; + +export const stylesBuilder = ( + custom?: FileUploaderConfig, +): FileUploaderStylesBuilder => { + const { innerElements, ...container } = mergeConfigWithCustom({ + defaultConfig, + custom, + }); + + return { + container, + control: innerElements.control, + dragAndDropField: innerElements.dragAndDropField, + }; +}; diff --git a/src/docs-components/FileItemDocs.tsx b/src/docs-components/FileItemDocs.tsx new file mode 100644 index 00000000..5fdee42d --- /dev/null +++ b/src/docs-components/FileItemDocs.tsx @@ -0,0 +1,145 @@ +import { action } from '@storybook/addon-actions'; +import { capitalize } from 'lodash'; + +import { SectionHeader } from './common/SectionHeader'; +import { States } from './common/States'; + +import { FileItem } from '@/components/FileItem'; +import { mockTextFile, mockImageFile } from '@/components/FileItem/mocks'; +import { tet } from '@/tetrisly'; + +type Variants = { + inverted: boolean; + extended: boolean; + thumbnail: 'none' | 'file' | 'photo'; +}; + +const sections: Variants[] = [ + { inverted: false, extended: false, thumbnail: 'none' }, + { inverted: true, extended: false, thumbnail: 'none' }, + { inverted: false, extended: true, thumbnail: 'none' }, + { inverted: true, extended: true, thumbnail: 'none' }, + { inverted: false, extended: true, thumbnail: 'file' }, + { inverted: true, extended: true, thumbnail: 'file' }, + { inverted: false, extended: true, thumbnail: 'photo' }, + { inverted: true, extended: true, thumbnail: 'photo' }, +]; + +export const FileItemDocs = () => ( + + {sections.map((variants) => { + const labels = Object.entries(variants).map( + ([name, value]) => `${capitalize(name)}: ${getVariantLabel(value)}`, + ); + + const file = + variants.thumbnail === 'photo' ? mockImageFile() : mockTextFile(); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); + })} + +); + +const getVariantLabel = (value: Variants[keyof Variants]): string => { + switch (value) { + case true: + return 'Yes'; + case 'file': + return 'File'; + case 'photo': + return 'Photo'; + default: + return 'No'; + } +}; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 04511efc..b8c8f506 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1 +1,2 @@ export { useAction } from './useAction'; +export { useDragOver } from './useDragOver'; diff --git a/src/hooks/useDragOver.ts b/src/hooks/useDragOver.ts new file mode 100644 index 00000000..62b6ac6c --- /dev/null +++ b/src/hooks/useDragOver.ts @@ -0,0 +1,11 @@ +import { useState } from 'react'; + +export const useDragOver = () => { + const [dragOver, setDragOver] = useState(false); + + const onDragEnter = () => setDragOver(true); + const onDragLeave = () => setDragOver(false); + const onDrop = () => setDragOver(false); + + return { dragOver, onDragEnter, onDragLeave, onDrop }; +}; diff --git a/src/index.ts b/src/index.ts index f8d3dbfb..df1422e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,8 @@ export * from './components/Checkbox'; export * from './components/CheckboxGroup'; export * from './components/Counter'; export * from './components/Divider'; +export * from './components/FileItem'; +export * from './components/FileUploader'; export * from './components/HelperText'; export * from './components/Icon'; export * from './components/IconButton'; diff --git a/src/services/files.ts b/src/services/files.ts new file mode 100644 index 00000000..ff3d297c --- /dev/null +++ b/src/services/files.ts @@ -0,0 +1,54 @@ +export const formatFileSize = (bytes: number): string => { + const kilobyte = 1024; + const megabyte = kilobyte * 1024; + const gigabyte = megabyte * 1024; + const terabyte = gigabyte * 1024; + + if (bytes >= terabyte) { + return `${roundToTwoDecimalPlaces(bytes / terabyte)}TB`; + } + + if (bytes >= gigabyte) { + return `${roundToTwoDecimalPlaces(bytes / gigabyte)}GB`; + } + + if (bytes >= megabyte) { + return `${roundToTwoDecimalPlaces(bytes / megabyte)}MB`; + } + + if (bytes >= kilobyte) { + return `${roundToTwoDecimalPlaces(bytes / kilobyte)}KB`; + } + + return `${roundToTwoDecimalPlaces(bytes)}B`; +}; + +const roundTo = (value: number, decimalPlaces: number): number => { + const power = 10 ** decimalPlaces; + return Math.round((value + Number.EPSILON) * power) / power; +}; + +const roundToTwoDecimalPlaces = (value: number): number => roundTo(value, 2); + +export const base64ToBlob = (value: string): Blob => { + const [prefix, payload] = value.split(','); + const [data, format] = prefix.split(';'); + const [, type] = data.split(':'); + + if ( + payload === undefined || + type === undefined || + format?.toLowerCase() !== 'base64' + ) { + throw new Error('Expected valid base64 encoded string'); + } + + const decodedPayload = atob(payload); + const buffer = new Uint8Array(payload.length); + + for (let i = 0; i < decodedPayload.length; i += 1) { + buffer[i] = decodedPayload.charCodeAt(i); + } + + return new Blob([buffer], { type }); +}; diff --git a/src/services/index.ts b/src/services/index.ts index 2279bd2f..aa1f8995 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,6 +1,7 @@ export * from './extractInputProps'; +export * from './fallbackKey'; +export * from './files'; export * from './isKeyOf'; export * from './mergeConfigWithCustom'; export * from './mergeObjects'; export * from './warnInDevelopment'; -export * from './fallbackKey'; diff --git a/src/theme/theme.ts b/src/theme/theme.ts index 8d1bea2d..e512203a 100644 --- a/src/theme/theme.ts +++ b/src/theme/theme.ts @@ -973,7 +973,7 @@ const fixedTokens = { borderStyles: { '$border-style-none': 'none', '$border-style-solid': 'solid', - '$border-style-dashed': 'dashed solid', + '$border-style-dashed': 'dashed', }, fonts: { '$font-family-primary': 'Inter' }, fontSizes: {