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 && (
+
+
+
+ )}
+
+ {state === 'alert' && onRetryClick && (
+
+
+
+ )}
+
+
+
+
+
+
+
+ {state === 'alert' && alertText !== undefined && (
+
+ {alertText}
+
+ )}
+
+ );
+};
diff --git a/src/components/FileItem/components/CompressedVariant/index.ts b/src/components/FileItem/components/CompressedVariant/index.ts
new file mode 100644
index 00000000..d0358e28
--- /dev/null
+++ b/src/components/FileItem/components/CompressedVariant/index.ts
@@ -0,0 +1,3 @@
+export * from './CompressedVariant';
+export { compressedVariantStyles } from './CompressedVariant.styles';
+export type { CompressedVariantConfig } from './CompressedVariant.styles';
diff --git a/src/components/FileItem/components/CompressedVariant/stylesBuilder.ts b/src/components/FileItem/components/CompressedVariant/stylesBuilder.ts
new file mode 100644
index 00000000..183b6a5d
--- /dev/null
+++ b/src/components/FileItem/components/CompressedVariant/stylesBuilder.ts
@@ -0,0 +1,47 @@
+import {
+ CompressedVariantConfig,
+ defaultConfig,
+} from './CompressedVariant.styles';
+import { ProgressBarConfig } from '../ProgressBar';
+
+import { mergeConfigWithCustom } from '@/services/mergeConfigWithCustom';
+import { BaseProps } from '@/types/BaseProps';
+
+type CompressedVariantStylesBuilder = {
+ container: BaseProps;
+ fileInfo: BaseProps;
+ fileName: BaseProps;
+ fileSize: BaseProps;
+ alertIcon: BaseProps;
+ content: BaseProps;
+ uploadingContent: BaseProps;
+ replaceableContent: BaseProps;
+ alertContent: BaseProps;
+ notExtendedAlert: BaseProps;
+ closeIconButton: BaseProps;
+ progressBar: ProgressBarConfig;
+};
+
+export const stylesBuilder = (
+ custom?: CompressedVariantConfig,
+): CompressedVariantStylesBuilder => {
+ const { innerElements, ...container } = mergeConfigWithCustom({
+ defaultConfig,
+ custom,
+ });
+
+ return {
+ container,
+ fileInfo: innerElements.fileInfo,
+ fileName: innerElements.fileName,
+ fileSize: innerElements.fileSize,
+ alertIcon: innerElements.alertIcon,
+ content: innerElements.content,
+ uploadingContent: innerElements.uploadingContent,
+ replaceableContent: innerElements.replaceableContent,
+ alertContent: innerElements.alertContent,
+ notExtendedAlert: innerElements.notExtendedAlert,
+ closeIconButton: innerElements.closeIconButton,
+ progressBar: innerElements.progressBar,
+ };
+};
diff --git a/src/components/FileItem/components/ExtendedVariant/ExtendedVariant.props.ts b/src/components/FileItem/components/ExtendedVariant/ExtendedVariant.props.ts
new file mode 100644
index 00000000..f37eb2d9
--- /dev/null
+++ b/src/components/FileItem/components/ExtendedVariant/ExtendedVariant.props.ts
@@ -0,0 +1,18 @@
+import { MouseEvent } from 'react';
+
+import { ExtendedVariantConfig } from './ExtendedVariant.styles';
+import { FileItemState, FileItemThumbnail } from '../../types';
+
+export type ExtendedVariantProps = {
+ custom?: ExtendedVariantConfig;
+ state: FileItemState;
+ file: File;
+ isInverted?: boolean;
+ thumbnail: FileItemThumbnail;
+ uploadedPercentage?: number;
+ timeLeftText?: string;
+ alertText?: string;
+ onReplaceClick?: (e?: MouseEvent) => void;
+ onRetryClick?: (e?: MouseEvent) => void;
+ onCloseClick?: (e?: MouseEvent) => void;
+};
diff --git a/src/components/FileItem/components/ExtendedVariant/ExtendedVariant.styles.ts b/src/components/FileItem/components/ExtendedVariant/ExtendedVariant.styles.ts
new file mode 100644
index 00000000..24b557a0
--- /dev/null
+++ b/src/components/FileItem/components/ExtendedVariant/ExtendedVariant.styles.ts
@@ -0,0 +1,111 @@
+import { ProgressBarConfig, progressBarStyles } from '../ProgressBar';
+
+import type { BaseProps } from '@/types/BaseProps';
+
+export type ExtendedVariantConfig = BaseProps & {
+ thumbnailWrapper?: BaseProps;
+ fileDetails?: BaseProps;
+ topDetails?: BaseProps;
+ bottomDetails?: BaseProps;
+ innerElements?: {
+ fileThumbnail?: BaseProps;
+ photoThumbnail?: BaseProps;
+ fileName?: BaseProps;
+ fileSize?: BaseProps;
+ timeLeft?: BaseProps;
+ uploadedPercentage?: BaseProps;
+ uploadingContent?: BaseProps;
+ replaceableContent?: BaseProps;
+ alertContent?: BaseProps;
+ closeIconButton?: BaseProps;
+ alert?: BaseProps;
+ alertIcon?: BaseProps;
+ fileSizeAlert?: BaseProps;
+ progressBar?: ProgressBarConfig;
+ };
+};
+
+export const defaultConfig = {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '$space-component-gap-small',
+ padding: '$space-component-padding-large',
+ thumbnailWrapper: {
+ display: 'flex',
+ flexDirection: 'row',
+ gap: '$space-component-gap-large',
+ },
+ fileDetails: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '$space-component-gap-xSmall',
+ flexGrow: 1,
+ },
+ topDetails: {
+ display: 'flex',
+ justifyContent: 'space-between',
+ },
+ bottomDetails: {
+ display: 'flex',
+ justifyContent: 'space-between',
+ },
+ innerElements: {
+ fileThumbnail: {
+ paddingTop: '$space-component-padding-xSmall',
+ },
+ photoThumbnail: {
+ paddingTop: '$space-component-padding-small',
+ },
+ fileName: {
+ text: '$typo-medium-175',
+ color: '$color-content-primary',
+ },
+ fileSize: {
+ text: '$typo-medium-175',
+ color: '$color-content-secondary',
+ },
+ timeLeft: {
+ text: '$typo-medium-175',
+ color: '$color-content-secondary',
+ },
+ uploadedPercentage: {
+ text: '$typo-medium-175',
+ color: '$color-content-secondary',
+ },
+ uploadingContent: {},
+ replaceableContent: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'flex-end',
+ },
+ alertContent: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'flex-end',
+ },
+ closeIconButton: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ },
+ alert: {
+ display: 'flex',
+ gap: '$space-component-gap-xSmall',
+ alignItems: 'center',
+ text: '$typo-body-small',
+ color: '$color-content-negative-secondary',
+ },
+ alertIcon: {
+ color: '$color-content-negative-secondary',
+ },
+ fileSizeAlert: {
+ text: '$typo-body-small',
+ color: '$color-content-secondary',
+ },
+ progressBar: progressBarStyles.defaultConfig,
+ },
+} as const satisfies ExtendedVariantConfig;
+
+export const extendedVariantStyles = {
+ defaultConfig,
+};
diff --git a/src/components/FileItem/components/ExtendedVariant/ExtendedVariant.tsx b/src/components/FileItem/components/ExtendedVariant/ExtendedVariant.tsx
new file mode 100644
index 00000000..4d1a240a
--- /dev/null
+++ b/src/components/FileItem/components/ExtendedVariant/ExtendedVariant.tsx
@@ -0,0 +1,146 @@
+import { FC } from 'react';
+
+import { ExtendedVariantProps } from './ExtendedVariant.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 ExtendedVariant: FC = ({
+ custom,
+ state,
+ file,
+ isInverted,
+ thumbnail,
+ uploadedPercentage,
+ timeLeftText,
+ alertText,
+ onReplaceClick,
+ onRetryClick,
+ onCloseClick,
+}) => {
+ const styles = stylesBuilder(custom);
+ const formattedFileSize = formatFileSize(file.size);
+
+ return (
+
+
+ {thumbnail === 'file' && (
+
+
+
+ )}
+
+ {thumbnail === 'photo' && (
+
+
+
+ )}
+
+
+
+ {file.name}
+
+
+ {state === 'replaceable' && onReplaceClick && (
+
+
+
+ )}
+
+ {state === 'alert' && onRetryClick && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ {state !== 'alert' && (
+
+ {formattedFileSize}
+
+ )}
+
+ {state === 'uploading' && timeLeftText !== undefined && (
+
+ {timeLeftText && ` • ${timeLeftText}`}
+
+ )}
+
+
+ {state === 'uploading' && uploadedPercentage !== undefined && (
+
+ {uploadedPercentage}%
+
+ )}
+
+
+ {state === 'alert' && (
+
+ {alertText !== undefined && (
+ <>
+ {' '}
+ {alertText}
+ >
+ )}
+
+
+ {' '}
+ • {formattedFileSize}
+
+
+ )}
+
+
+
+ {state === 'uploading' && (
+
+ )}
+
+ );
+};
diff --git a/src/components/FileItem/components/ExtendedVariant/index.ts b/src/components/FileItem/components/ExtendedVariant/index.ts
new file mode 100644
index 00000000..41012304
--- /dev/null
+++ b/src/components/FileItem/components/ExtendedVariant/index.ts
@@ -0,0 +1,3 @@
+export * from './ExtendedVariant';
+export { extendedVariantStyles } from './ExtendedVariant.styles';
+export type { ExtendedVariantConfig } from './ExtendedVariant.styles';
diff --git a/src/components/FileItem/components/ExtendedVariant/stylesBuilder.ts b/src/components/FileItem/components/ExtendedVariant/stylesBuilder.ts
new file mode 100644
index 00000000..0b16786f
--- /dev/null
+++ b/src/components/FileItem/components/ExtendedVariant/stylesBuilder.ts
@@ -0,0 +1,65 @@
+import { ExtendedVariantConfig, defaultConfig } from './ExtendedVariant.styles';
+import { ProgressBarConfig } from '../ProgressBar';
+
+import { mergeConfigWithCustom } from '@/services/mergeConfigWithCustom';
+import { BaseProps } from '@/types/BaseProps';
+
+type ExtendedVariantStylesBuilder = {
+ container: BaseProps;
+ thumbnailWrapper: BaseProps;
+ fileDetails: BaseProps;
+ topDetails: BaseProps;
+ bottomDetails: BaseProps;
+ fileThumbnail: BaseProps;
+ photoThumbnail: BaseProps;
+ fileName: BaseProps;
+ fileSize: BaseProps;
+ timeLeft: BaseProps;
+ uploadedPercentage: BaseProps;
+ uploadingContent: BaseProps;
+ replaceableContent: BaseProps;
+ alertContent: BaseProps;
+ closeIconButton: BaseProps;
+ alert: BaseProps;
+ alertIcon: BaseProps;
+ fileSizeAlert: BaseProps;
+ progressBar: ProgressBarConfig;
+};
+
+export const stylesBuilder = (
+ custom?: ExtendedVariantConfig,
+): ExtendedVariantStylesBuilder => {
+ const {
+ innerElements,
+ thumbnailWrapper,
+ fileDetails,
+ topDetails,
+ bottomDetails,
+ ...container
+ } = mergeConfigWithCustom({
+ defaultConfig,
+ custom,
+ });
+
+ return {
+ container,
+ thumbnailWrapper,
+ fileDetails,
+ topDetails,
+ bottomDetails,
+ fileThumbnail: innerElements.fileThumbnail,
+ photoThumbnail: innerElements.photoThumbnail,
+ fileName: innerElements.fileName,
+ fileSize: innerElements.fileSize,
+ timeLeft: innerElements.timeLeft,
+ uploadedPercentage: innerElements.uploadedPercentage,
+ uploadingContent: innerElements.uploadingContent,
+ replaceableContent: innerElements.replaceableContent,
+ alertContent: innerElements.alertContent,
+ closeIconButton: innerElements.closeIconButton,
+ alert: innerElements.alert,
+ alertIcon: innerElements.alertIcon,
+ fileSizeAlert: innerElements.fileSizeAlert,
+ progressBar: innerElements.progressBar,
+ };
+};
diff --git a/src/components/FileItem/components/ProgressBar/ProgressBar.props.ts b/src/components/FileItem/components/ProgressBar/ProgressBar.props.ts
new file mode 100644
index 00000000..4e2c2ebd
--- /dev/null
+++ b/src/components/FileItem/components/ProgressBar/ProgressBar.props.ts
@@ -0,0 +1,7 @@
+import { ProgressBarConfig } from './ProgressBar.styles';
+
+export type ProgressBarProps = {
+ custom?: ProgressBarConfig;
+ isInverted?: boolean;
+ progressPercentage?: number;
+};
diff --git a/src/components/FileItem/components/ProgressBar/ProgressBar.styles.ts b/src/components/FileItem/components/ProgressBar/ProgressBar.styles.ts
new file mode 100644
index 00000000..048ed86b
--- /dev/null
+++ b/src/components/FileItem/components/ProgressBar/ProgressBar.styles.ts
@@ -0,0 +1,34 @@
+import type { BaseProps } from '@/types/BaseProps';
+
+export type ProgressBarConfig = BaseProps & {
+ innerElements?: {
+ track?: BaseProps;
+ notInvertedTrack?: BaseProps;
+ invertedTrack?: BaseProps;
+ progress?: BaseProps;
+ };
+};
+
+export const defaultConfig = {
+ innerElements: {
+ track: {
+ h: '4px',
+ borderRadius: '$border-radius-small',
+ },
+ notInvertedTrack: {
+ backgroundColor: '$color-interaction-inverted-normal',
+ },
+ invertedTrack: {
+ backgroundColor: '$color-interaction-neutral-subtle-normal',
+ },
+ progress: {
+ h: '100%',
+ borderRadius: '$border-radius-small',
+ backgroundColor: '$color-interaction-default-normal',
+ },
+ },
+} as const satisfies ProgressBarConfig;
+
+export const progressBarStyles = {
+ defaultConfig,
+};
diff --git a/src/components/FileItem/components/ProgressBar/ProgressBar.tsx b/src/components/FileItem/components/ProgressBar/ProgressBar.tsx
new file mode 100644
index 00000000..0abe3089
--- /dev/null
+++ b/src/components/FileItem/components/ProgressBar/ProgressBar.tsx
@@ -0,0 +1,22 @@
+import { FC } from 'react';
+
+import { ProgressBarProps } from './ProgressBar.props';
+import { stylesBuilder } from './stylesBuilder';
+
+import { tet } from '@/tetrisly';
+
+export const ProgressBar: FC = ({
+ custom,
+ isInverted,
+ progressPercentage = 0,
+}) => {
+ const styles = stylesBuilder(custom, isInverted);
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/src/components/FileItem/components/ProgressBar/index.ts b/src/components/FileItem/components/ProgressBar/index.ts
new file mode 100644
index 00000000..1a779a1c
--- /dev/null
+++ b/src/components/FileItem/components/ProgressBar/index.ts
@@ -0,0 +1,3 @@
+export * from './ProgressBar';
+export { progressBarStyles } from './ProgressBar.styles';
+export type { ProgressBarConfig } from './ProgressBar.styles';
diff --git a/src/components/FileItem/components/ProgressBar/stylesBuilder.ts b/src/components/FileItem/components/ProgressBar/stylesBuilder.ts
new file mode 100644
index 00000000..7539e942
--- /dev/null
+++ b/src/components/FileItem/components/ProgressBar/stylesBuilder.ts
@@ -0,0 +1,33 @@
+import { ProgressBarConfig, defaultConfig } from './ProgressBar.styles';
+
+import { mergeConfigWithCustom } from '@/services/mergeConfigWithCustom';
+import { BaseProps } from '@/types/BaseProps';
+
+type CornerDialogStylesBuilder = {
+ container: BaseProps;
+ track: BaseProps;
+ progress: BaseProps;
+};
+
+export const stylesBuilder = (
+ custom?: ProgressBarConfig,
+ isInverted?: boolean,
+): CornerDialogStylesBuilder => {
+ const { innerElements, ...container } = mergeConfigWithCustom({
+ defaultConfig,
+ custom,
+ });
+
+ const withInvertedStyles = isInverted
+ ? innerElements.invertedTrack
+ : innerElements.notInvertedTrack;
+
+ return {
+ container,
+ track: {
+ ...innerElements.track,
+ ...withInvertedStyles,
+ },
+ progress: innerElements.progress,
+ };
+};
diff --git a/src/components/FileItem/components/index.ts b/src/components/FileItem/components/index.ts
new file mode 100644
index 00000000..4cb0a084
--- /dev/null
+++ b/src/components/FileItem/components/index.ts
@@ -0,0 +1,3 @@
+export * from './CompressedVariant';
+export * from './ExtendedVariant';
+export * from './ProgressBar';
diff --git a/src/components/FileItem/index.ts b/src/components/FileItem/index.ts
new file mode 100644
index 00000000..323c06aa
--- /dev/null
+++ b/src/components/FileItem/index.ts
@@ -0,0 +1 @@
+export * from './FileItem';
diff --git a/src/components/FileItem/mocks.ts b/src/components/FileItem/mocks.ts
new file mode 100644
index 00000000..c445fda8
--- /dev/null
+++ b/src/components/FileItem/mocks.ts
@@ -0,0 +1,12 @@
+import { base64ToBlob } from '@/services';
+
+export const mockTextFile = (): File =>
+ new File(['foo bar baz'], 'Name', { type: 'text/plain' });
+
+export const mockImageFile = (): File => {
+ const imageBlob = base64ToBlob(
+ 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQIAHAAcAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wgARCAAgACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAwQFBv/EABgBAAIDAAAAAAAAAAAAAAAAAAMEAAEC/9oADAMBAAIQAxAAAAEooNQcrOSGtUnnbzSrWP1I45g//8QAHBAAAwACAwEAAAAAAAAAAAAAAQIDAAQREhMi/9oACAEBAAEFAmFaN6suTp2xH4MqsmJvpTAgpiEohok82XQ1kz9xVnisKluFDtH68bvt/wD/xAAbEQACAQUAAAAAAAAAAAAAAAAAASECAxARIv/aAAgBAwEBPwHWH3CK7bUn/8QAHBEAAgICAwAAAAAAAAAAAAAAAAEDEQIhEzFB/9oACAECAQE/AbFfolx7fZHIstH/xAAhEAABBAEEAwEAAAAAAAAAAAABAAIDERITISJBEDFhMv/aAAgBAQAGPwIyNk1AVyB8WCv1uqlblXaLoXghcjuqw292rYKPwLgTksSQZKWE/sOWGmB9RcOu6TREDkSv/8QAHRABAAIDAAMBAAAAAAAAAAAAAQARITFBUWFxgf/aAAgBAQABPyElqP0hRAPhIHUQayS+R0zhmo7iZgye50lSZHmUtAsRi/caMTbslfU5dRaX21Up2iinpCqlr6iDFjxC0FiIYPvqf//aAAwDAQACAAMAAAAQQs0E/8QAGREBAQADAQAAAAAAAAAAAAAAAQARITFB/9oACAEDAQE/EOMlqauJA+r/xAAaEQABBQEAAAAAAAAAAAAAAAABABARIUFx/9oACAECAQE/ELThaA0dUitIK//EACAQAQACAgICAwEAAAAAAAAAAAERIQAxQWFxsVGRwdH/2gAIAQEAAT8Qfl5TXLAdfGHAihE4k0IJicbEmRM58MYkJCjnxiqFVUp8p/PrC6nZgX8a5DDWyQITJNdlCJDNNyvrL320HhZ6xLaeS99n7WBgWRCX03UmPn5qAQhOmGPOcd0BsdPL1g0qCJIml3RlK22LNyNDnrP/2Q==',
+ );
+
+ return new File([imageBlob], 'Name', { type: 'image/jpeg' });
+};
diff --git a/src/components/FileItem/stylesBuilder.ts b/src/components/FileItem/stylesBuilder.ts
new file mode 100644
index 00000000..b4f5ec90
--- /dev/null
+++ b/src/components/FileItem/stylesBuilder.ts
@@ -0,0 +1,45 @@
+import { CompressedVariantConfig, ExtendedVariantConfig } from './components';
+import { FileItemProps, fallback } from './FileItem.props';
+import { defaultConfig } from './FileItem.styles';
+
+import { mergeConfigWithCustom } from '@/services/mergeConfigWithCustom';
+import { BaseProps } from '@/types/BaseProps';
+
+type FileItemStylesBuilder = {
+ container: BaseProps;
+ compressed: CompressedVariantConfig;
+ extended: ExtendedVariantConfig;
+};
+
+export const stylesBuilder = (props: FileItemProps): FileItemStylesBuilder => {
+ const {
+ state,
+ inverted,
+ thumbnail,
+ invertedAlert,
+ compressed,
+ extended,
+ ...container
+ } = mergeConfigWithCustom({
+ defaultConfig,
+ custom: props.custom,
+ });
+
+ const withStateStyles = state[props.state ?? fallback.state];
+ const withInvertedStyles = props.isInverted ? inverted.yes : inverted.no;
+ const withThumbnailStyles = thumbnail[props.thumbnail ?? fallback.thumbnail];
+ const withInvertedAlertStyles =
+ props.state === 'alert' && props.isInverted ? invertedAlert : {};
+
+ return {
+ container: {
+ ...container,
+ ...withStateStyles,
+ ...withInvertedStyles,
+ ...withThumbnailStyles,
+ ...withInvertedAlertStyles,
+ },
+ compressed,
+ extended,
+ };
+};
diff --git a/src/components/FileItem/types.ts b/src/components/FileItem/types.ts
new file mode 100644
index 00000000..c7691906
--- /dev/null
+++ b/src/components/FileItem/types.ts
@@ -0,0 +1,3 @@
+export type FileItemState = 'uploading' | 'uploaded' | 'replaceable' | 'alert';
+
+export type FileItemThumbnail = 'none' | 'file' | 'photo';
diff --git a/src/components/FileUploader/ButtonVariant.tsx b/src/components/FileUploader/ButtonVariant.tsx
new file mode 100644
index 00000000..23d4aadb
--- /dev/null
+++ b/src/components/FileUploader/ButtonVariant.tsx
@@ -0,0 +1,15 @@
+import { FC, MouseEvent } from 'react';
+
+import { Button } from '@/components/Button';
+
+export type ButtonVariantProps = {
+ label?: string;
+ onChooseFileClick?: (e?: MouseEvent) => void;
+};
+
+export const ButtonVariant: FC = ({
+ label,
+ onChooseFileClick,
+}) => (
+
+);
diff --git a/src/components/FileUploader/DragAndDropVariant.tsx b/src/components/FileUploader/DragAndDropVariant.tsx
new file mode 100644
index 00000000..71c17903
--- /dev/null
+++ b/src/components/FileUploader/DragAndDropVariant.tsx
@@ -0,0 +1,20 @@
+import { ReactNode, FC, MouseEvent, MouseEventHandler } from 'react';
+
+import { DragAndDropField, DragAndDropFieldConfig } from './components';
+
+import { IconName } from '@/utility-types/IconName';
+
+export type DragAndDropVariantProps = {
+ custom?: DragAndDropFieldConfig;
+ isExtended?: boolean;
+ isDragOver?: boolean;
+ state?: 'default' | 'alert' | 'disabled';
+ icon?: IconName<20>;
+ text?: (onChooseFileClick: MouseEventHandler) => ReactNode;
+ caption?: ReactNode;
+ onChooseFileClick?: (e?: MouseEvent) => void;
+};
+
+export const DragAndDropVariant: FC = (props) => (
+
+);
diff --git a/src/components/FileUploader/FileUploader.props.ts b/src/components/FileUploader/FileUploader.props.ts
new file mode 100644
index 00000000..9fbfed23
--- /dev/null
+++ b/src/components/FileUploader/FileUploader.props.ts
@@ -0,0 +1,37 @@
+import { ReactNode, MouseEventHandler } from 'react';
+
+import type { FileUploaderConfig } from './FileUploader.styles';
+
+import { HelperTextProps } from '@/components/HelperText';
+import { LabelProps } from '@/components/Label';
+import { IconName } from '@/utility-types/IconName';
+
+export type FileUploaderProps = {
+ custom?: FileUploaderConfig;
+ label?: LabelProps;
+ helperText?: HelperTextProps;
+ state?: 'default' | 'alert' | 'disabled';
+ variant: 'drag&drop' | 'button';
+ inputProps?: InputFileProps;
+ dragAndDropVariant?: DragAndDropVariantProps;
+ buttonVariant?: ButtonVariantProps;
+ value?: File[];
+ onChange?: (files: File[]) => void;
+};
+
+export type InputFileProps = {
+ accept?: string;
+ name?: string;
+ multiple?: boolean;
+};
+
+export type DragAndDropVariantProps = {
+ isExtended?: boolean;
+ icon?: IconName<20>;
+ text?: (onChooseFileClick: MouseEventHandler) => ReactNode;
+ caption?: string;
+};
+
+export type ButtonVariantProps = {
+ text?: string;
+};
diff --git a/src/components/FileUploader/FileUploader.stories.tsx b/src/components/FileUploader/FileUploader.stories.tsx
new file mode 100644
index 00000000..36b92ebc
--- /dev/null
+++ b/src/components/FileUploader/FileUploader.stories.tsx
@@ -0,0 +1,84 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { FC, useState } from 'react';
+
+import { FileUploader } from './FileUploader';
+import { FileUploaderProps } from './FileUploader.props';
+
+import { Button } from '@/components/Button';
+import { TetDocs } from '@/docs-components/TetDocs';
+
+const meta = {
+ title: 'FileUploader',
+ component: FileUploader,
+ tags: ['autodocs'],
+ argTypes: {},
+ args: {},
+ 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: () => (
+
+ TBD
+
+ ),
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const FileUploaderDemo: FC = (props) => {
+ const [files, setFiles] = useState([]);
+
+ return ;
+};
+
+export const Default: Story = {
+ args: {
+ label: {
+ label: 'Label',
+ },
+ helperText: {
+ text: 'Helper text',
+ },
+ variant: 'drag&drop',
+ dragAndDropVariant: {
+ isExtended: false,
+ icon: '20-upload',
+ text: (onChooseFileClick) => (
+ <>
+ Drag & Drop or
+
+ 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: {