diff --git a/.storybook/image-snapshots/expected/Iconography_List_List.png b/.storybook/image-snapshots/expected/Iconography_List_List.png index 1a48a82b8..1c49268c7 100644 Binary files a/.storybook/image-snapshots/expected/Iconography_List_List.png and b/.storybook/image-snapshots/expected/Iconography_List_List.png differ diff --git a/.storybook/image-snapshots/expected/components_Card_With Icon Only Action.png b/.storybook/image-snapshots/expected/components_Card_With Icon Only Action.png index 02bc0c06a..bfe4bc781 100644 Binary files a/.storybook/image-snapshots/expected/components_Card_With Icon Only Action.png and b/.storybook/image-snapshots/expected/components_Card_With Icon Only Action.png differ diff --git a/.storybook/image-snapshots/expected/components_FileSelector_Area Size.png b/.storybook/image-snapshots/expected/components_FileSelector_Area Size.png deleted file mode 100644 index 4aa45cc17..000000000 Binary files a/.storybook/image-snapshots/expected/components_FileSelector_Area Size.png and /dev/null differ diff --git a/.storybook/image-snapshots/expected/components_FileSelector_Compact Size.png b/.storybook/image-snapshots/expected/components_FileSelector_Compact Size.png deleted file mode 100644 index 30487790c..000000000 Binary files a/.storybook/image-snapshots/expected/components_FileSelector_Compact Size.png and /dev/null differ diff --git a/.storybook/image-snapshots/expected/components_FileSelector_Dark Mode.png b/.storybook/image-snapshots/expected/components_FileSelector_Dark Mode.png new file mode 100644 index 000000000..70762b11a Binary files /dev/null and b/.storybook/image-snapshots/expected/components_FileSelector_Dark Mode.png differ diff --git a/.storybook/image-snapshots/expected/components_FileSelector_Disabled.png b/.storybook/image-snapshots/expected/components_FileSelector_Disabled.png index f54e73466..c395c1398 100644 Binary files a/.storybook/image-snapshots/expected/components_FileSelector_Disabled.png and b/.storybook/image-snapshots/expected/components_FileSelector_Disabled.png differ diff --git a/.storybook/image-snapshots/expected/components_FileSelector_Fill Size.png b/.storybook/image-snapshots/expected/components_FileSelector_Fill Size.png deleted file mode 100644 index 8d9cfe732..000000000 Binary files a/.storybook/image-snapshots/expected/components_FileSelector_Fill Size.png and /dev/null differ diff --git a/.storybook/image-snapshots/expected/components_FileSelector_Only Click.png b/.storybook/image-snapshots/expected/components_FileSelector_Only Click.png deleted file mode 100644 index 60a33362c..000000000 Binary files a/.storybook/image-snapshots/expected/components_FileSelector_Only Click.png and /dev/null differ diff --git a/.storybook/image-snapshots/expected/components_FileSelector_Only Drag.png b/.storybook/image-snapshots/expected/components_FileSelector_Only Drag.png deleted file mode 100644 index bd4832f79..000000000 Binary files a/.storybook/image-snapshots/expected/components_FileSelector_Only Drag.png and /dev/null differ diff --git a/.storybook/image-snapshots/expected/components_FileSelector_Sizes.png b/.storybook/image-snapshots/expected/components_FileSelector_Sizes.png new file mode 100644 index 000000000..19d5daa70 Binary files /dev/null and b/.storybook/image-snapshots/expected/components_FileSelector_Sizes.png differ diff --git a/.storybook/image-snapshots/expected/components_FileSelector_With Error.png b/.storybook/image-snapshots/expected/components_FileSelector_With Error.png index 6a9160128..5ad6d7006 100644 Binary files a/.storybook/image-snapshots/expected/components_FileSelector_With Error.png and b/.storybook/image-snapshots/expected/components_FileSelector_With Error.png differ diff --git a/.storybook/image-snapshots/expected/components_Icon_Flip.png b/.storybook/image-snapshots/expected/components_Icon_Flip.png index 87c8941c2..f0b7dfb9c 100644 Binary files a/.storybook/image-snapshots/expected/components_Icon_Flip.png and b/.storybook/image-snapshots/expected/components_Icon_Flip.png differ diff --git a/package.json b/package.json index f1a889d1e..74a142da6 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "react-container-query": "^0.13.0", "react-cool-portal": "^1.2.0", "react-datepicker": "^4.6.0", - "react-dropzone": "^12.1.0", + "react-dropzone": "^14.2.9", "react-i18next": ">=11.18.6", "react-popper": "^2.2.5", "react-table": "^7.7.0", diff --git a/src/components/FileSelector/FileSelector.enums.tsx b/src/components/FileSelector/FileSelector.enums.tsx deleted file mode 100644 index cbb2e28c9..000000000 --- a/src/components/FileSelector/FileSelector.enums.tsx +++ /dev/null @@ -1,5 +0,0 @@ -export const FileSelectorSizes = { - compact: 'compact', - fill: 'fill', - area: 'area', -} as const; diff --git a/src/components/FileSelector/FileSelector.stories.tsx b/src/components/FileSelector/FileSelector.stories.tsx index 1529a0f17..e123cf174 100644 --- a/src/components/FileSelector/FileSelector.stories.tsx +++ b/src/components/FileSelector/FileSelector.stories.tsx @@ -1,158 +1,275 @@ import { useState } from 'react'; -import { Meta, StoryFn } from '@storybook/react'; +import { Meta, StoryObj } from '@storybook/react'; import { isNonEmptyArray } from 'ramda-adjunct'; -import styled from 'styled-components'; import FileSelector from './FileSelector'; -import { FileSelectorProps } from './FileSelector.types'; -import { FileSelectorSizes } from './FileSelector.enums'; -import { Inline, Padbox, Stack } from '../layout'; -import { SpaceSizes } from '../../theme/space.enums'; -import { TextSizes, TextVariants } from '../Text/Text.enums'; +import { Inline, Padbox, Stack, Surface } from '../layout'; +import { TextVariants } from '../Text/Text.enums'; import { Text } from '../Text'; -import { getColor, getRadii } from '../../utils'; -export default { - title: 'components/FileSelector', +/** + * ```jsx + * import { FileSelector } from '@securityscorecard/design-system'; + * ``` + */ + +const meta = { component: FileSelector, argTypes: { + size: { + control: 'select', + options: ['fill', 'comapct', 'area'], + description: 'Size variant of the FileSelector', + table: { + type: { + summary: "'fill' | 'comapct' | 'area'", + }, + }, + }, + hasError: { + control: 'boolean', + description: 'Sets file selector into errorous state', + table: { + type: { + summary: 'boolean', + }, + }, + }, + isDisabled: { + control: 'boolean', + description: 'Disables the FileSelector', + table: { + type: { + summary: 'boolean', + }, + }, + }, + instructionsText: { + description: + 'Text with file requirements. Availabel for `fill` and `area` sizes.', + table: { + type: { + summary: 'string', + }, + }, + }, + multiple: { + control: 'boolean', + description: 'Allows to select multiple files', + table: { + type: { + summary: 'boolean', + }, + }, + }, accept: { + control: 'object', description: - 'Accepted file mime types (https://www.iana.org/assignments/media-types/media-types.xhtml)', + "Accepted file types, in form of object where keys are file mime types and value is a array with supported file extensions. If an empty array is provided as a value all extensions are accepted.Examples: `{ 'image/png': [ '.png' ] }` will accept only PNG images, `{ 'image/*': [] }` will accept any image with any extension.", + table: { + type: { + summary: 'Record>', + }, + }, }, maxFiles: { + control: 'number', description: 'The maximum number of dropped files', + table: { + type: { + summary: 'number', + }, + }, + }, + onFilesDrop: { + control: { disable: true }, + table: { + type: { + summary: + '(acceptedFiles: T[], fileRejections: FileRejection[], event: DropEvent) => void', + }, + }, + }, + onFilesAccepted: { + control: { disable: true }, + table: { + type: { + summary: '(files: T[], event: DropEvent) => void', + }, + }, + }, + onFilesRejected: { + control: { disable: true }, + table: { + type: { + summary: + '(fileRejections: FileRejection[], event: DropEvent) => void', + }, + }, }, onFileDialogCancel: { - description: 'Callback fired when file dialog is canceled', + control: { disable: true }, + description: + 'Callback fired when file dialog is closed with no selection', + table: { + type: { + summary: '() => void', + }, + }, }, onFileDialogOpen: { + control: { disable: true }, description: 'Callback fired when dialog is opened', + table: { + type: { + summary: '() => void', + }, + }, }, validator: { + control: { disable: true }, description: 'Custom validation function. It must return null if there are no errors.', + table: { + type: { + summary: + '(file: T) => FileError | readonly FileError[] | null', + }, + }, + }, + width: { + control: 'number', + description: + 'Width of the droping zone, takes Number in pixels or any other valid value as String. Available only for `area` size.', + table: { + type: { + summary: 'number | string', + }, + }, + }, + height: { + control: 'number', + description: + 'Height of the droping zone, takes Number in pixels or any other valid value as String. Available only for `area` size.', + table: { + type: { + summary: 'number | string', + }, + }, }, }, -} as Meta; + args: { + instructionsText: 'Pass instructions for uploaded files here', + accept: { + 'image/*': [], + }, + }, +} satisfies Meta; -const FileSelectorTemplate: StoryFn = (args) => ( - -); +export default meta; -export const Playground = FileSelectorTemplate.bind({}); -Playground.parameters = { - screenshot: { skip: true }, +type Story = StoryObj; + +export const Playground: Story = { + parameters: { + screenshot: { skip: true }, + }, }; -Playground.parameters = { - screenshot: { skip: true }, + +export const Sizes: Story = { + render: (args) => ( + + + + + + ), }; -/* Sizes */ -export const FillSize = FileSelectorTemplate.bind({}); -FillSize.args = { - size: FileSelectorSizes.fill, +export const WithError: Story = { + render: Sizes.render, + args: { hasError: true }, }; -export const CompactSize = FileSelectorTemplate.bind({}); -CompactSize.args = { - size: FileSelectorSizes.compact, +export const Disabled: Story = { + render: Sizes.render, + args: { isDisabled: true }, }; -export const AreaSize = FileSelectorTemplate.bind({}); -AreaSize.args = { - size: FileSelectorSizes.area, - width: 400, - height: 300, +export const DarkMode: Story = { + render: Sizes.render, + parameters: { + themes: { + themeOverride: 'Dark', + }, + }, }; -/* States */ -export const WithError = FileSelectorTemplate.bind({}); -WithError.args = { hasError: true }; -export const Disabled = FileSelectorTemplate.bind({}); -Disabled.args = { isDisabled: true }; - -/* Variants */ -export const OnlyDrag = FileSelectorTemplate.bind({}); -OnlyDrag.args = { isClickDisabled: true, dropLabel: 'Drop files here' }; -export const OnlyClick = FileSelectorTemplate.bind({}); -OnlyClick.args = { isDragDisabled: true }; - /* Example */ -const kbToBytes = (kb: number) => kb * 1024; -const bytesToKb = (kb: number) => Math.floor(kb / 1024); -const FileWrapper = styled(Padbox)` - border-radius: ${getRadii('default')}; - border: 1px solid; - border-color: ${({ $hasError, theme }) => - getColor($hasError ? 'error.500' : 'success.500', { theme })}; -`; const File = ({ file, errors = [] }) => ( - - - - {file.name} - - {bytesToKb(file.size)} kB - - - {errors.map((error) => ( - - {error.message} - - ))} - - + + + + {file.name} + {Math.floor(file.size / 1024)} kB + + + {errors.map((error) => ( + + {error.message} + + ))} + + + ); -export const Example = () => { - const [errors, setErrors] = useState([]); - const [files, setFiles] = useState([]); - const handleOnDrop = (acceptedFiles, rejectedFiles) => { - setErrors((prev) => [...prev, ...rejectedFiles]); - setFiles((prev) => [...prev, ...acceptedFiles]); - }; +export const Example: Story = { + render: function Render(args) { + const [errors, setErrors] = useState([]); + const [files, setFiles] = useState([]); + const handleOnDrop = (acceptedFiles, rejectedFiles) => { + setErrors((prev) => [...prev, ...rejectedFiles]); + setFiles((prev) => [...prev, ...acceptedFiles]); + }; - return ( - - - - Accepts only PNG files - - - File size has to be at least 100KB - - - Maximal file size is 400KB - - - You can drop up to 2 files at once - - - - {files.map((file) => ( - - ))} - {errors.map((error) => ( - + - ))} - - ); -}; -Example.parameters = { - screenshot: { skip: true }, + {files.map((file) => ( + + ))} + {errors.map((error) => ( + + ))} + + ); + }, + args: { + accept: { + 'image/png': ['.png'], + }, + instructionsText: + '.png only, file size between 100kB and 400kB, up to 2 files', + maxFiles: 2, + maxFileSize: 400 * 1024, + minFileSize: 100 * 1024, + multiple: true, + }, + parameters: { + screenshot: { skip: true }, + }, }; diff --git a/src/components/FileSelector/FileSelector.tsx b/src/components/FileSelector/FileSelector.tsx index 69835cbac..432b9d1ff 100644 --- a/src/components/FileSelector/FileSelector.tsx +++ b/src/components/FileSelector/FileSelector.tsx @@ -3,27 +3,29 @@ import { useDropzone } from 'react-dropzone'; import { omit } from 'ramda'; import cls from 'classnames'; -import { SpaceSizes } from '../../theme/space.enums'; -import { SSCIconNames } from '../../theme/icons/icons.enums'; -import { getColor, getFormStyle, getRadii, pxToRem } from '../../utils'; -import { Cluster, Padbox } from '../layout'; -import { PaddingTypes } from '../layout/Padbox/Padbox.enums'; +import { Inline, Padbox, Stack, Surface } from '../layout'; import Button from '../ButtonV2/Button'; import { Text } from '../Text'; -import { TextSizes, TextVariants } from '../Text/Text.enums'; import { FileSelectorProps } from './FileSelector.types'; -import { FileSelectorSizes } from './FileSelector.enums'; import { CLX_COMPONENT } from '../../theme/constants'; -import { useLogger } from '../../hooks/useLogger'; +import { pxToRem } from '../../utils'; +import { IconWrapper } from '../IconWrapper'; +import { Link } from '../Link'; -const FileSelectorWrapper = styled(Padbox)<{ $width: number; $height: number }>` - background-color: ${getColor('neutral.0')}; - border: 1px dashed ${getFormStyle('borderColor')}; - border-radius: ${getRadii('large')}; +const FileSelectorRoot = styled(Surface)<{ + $size: FileSelectorProps['size']; + $width?: number | string; + $height?: number | string; + $isDragActive: boolean; + $isFocused: boolean; + $isDisabled: boolean; + $hasError: boolean; +}>` + border-style: dashed; ${({ $size, $width, $height }) => { - if ($size === FileSelectorSizes.compact) return 'display: inline-flex;'; - if ($size === FileSelectorSizes.fill) return 'display: flex;'; + if ($size === 'compact') return 'display: inline-flex; width: max-content'; + if ($size === 'fill') return 'display: flex;'; return css` display: flex; width: ${pxToRem($width)}; @@ -33,47 +35,111 @@ const FileSelectorWrapper = styled(Padbox)<{ $width: number; $height: number }>` `; }}; - ${({ $isDragActive, $isFocused }) => + ${({ $isDragActive, $isFocused, $hasError }) => ($isDragActive || $isFocused) && + !$hasError && css` - border-style: solid; - border-color: ${getColor('primary.600')}; + border: 1px solid var(--sscds-color-border-input-focused); + box-shadow: inset 0 0 0 1px var(--sscds-color-border-input-focused); + background-color: var(--sscds-color-background-surface-hover); `}; ${({ $hasError }) => $hasError && css` - border-style: solid; - border-color: ${getColor('error.500')}; - `} + border: 1px solid var(--sscds-color-border-input-error); + box-shadow: inset 0 0 0 1px var(--sscds-color-border-input-error); + background-color: var(--sscds-color-danger-050); + `}; - ${({ $isDisabled }) => - $isDisabled && + ${({ $hasError, $isDisabled, $isDragActive, $isFocused }) => + !$hasError && + !$isDisabled && + !$isDragActive && + !$isFocused && css` - background: ${getFormStyle('disabledBgColor')}; - border-color: ${getFormStyle('disabledBorderColor')}; + :hover { + border: 1px solid var(--sscds-color-border-surface-hover); + background-color: var(--sscds-color-background-surface-hover); + } `}; `; -const DropTextWrapper = styled(Padbox)` - display: 'flex'; - align-items: 'center'; - ${({ $isCentered }) => - $isCentered && - css` - align-items: center; - justify-content: center; - `} -`; +function CompactContent({ + isDisabled, + hasError, +}: { + isDisabled: boolean; + hasError: boolean; +}) { + return ( + + + + or drop files here + + + ); +} -const FileSelector = ({ - buttonLabel = 'Upload', - dropLabel = 'or drop files here', - isClickDisabled = false, - isDragDisabled = false, +function Content({ + isDisabled, + isDragActive, + isFocused, + hasError, + instructionsText, +}: { + isDisabled: boolean; + isDragActive: boolean; + isFocused: boolean; + hasError: boolean; + instructionsText: string; +}) { + return ( + <> + + + + Drop your file here or{' '} + + browse files + + . + + + {instructionsText} + + + + ); +} + +function FileSelector({ isDisabled = false, hasError = false, - size = FileSelectorSizes.fill, + size = 'fill', multiple, accept, minFileSize, @@ -88,14 +154,13 @@ const FileSelector = ({ onDragOver, onDragLeave, validator, + instructionsText = '', className, ...props -}: FileSelectorProps) => { - const { error } = useLogger('FileSelector'); +}: FileSelectorProps) { const { getRootProps, getInputProps, isDragActive, isFocused } = useDropzone({ disabled: isDisabled, - noClick: isClickDisabled, - noDrag: isDragDisabled, + noDrag: false, multiple, accept, minSize: minFileSize, @@ -112,7 +177,7 @@ const FileSelector = ({ validator, }); const sizes = - size === FileSelectorSizes.area && 'width' in props && 'height' in props + size === 'area' && 'width' in props && 'height' in props ? { width: props.width, height: props.height, @@ -120,67 +185,13 @@ const FileSelector = ({ : {}; const passedProps = omit(['width', 'height'], props); - if (isClickDisabled && isDragDisabled) { - error( - 'Either one of or both "isClickDisabled" and "isDragDisabled" properties must be set to "false".', - ); - return null; - } - - if (isClickDisabled) { - return ( - - - - - {dropLabel} - - - - ); - } - - if (isDragDisabled) { - return ( -
- - -
- ); - } - return ( - - - -
- -
- - - {dropLabel} - - -
-
+ + {size === 'compact' ? ( + + ) : size === 'fill' ? ( + + + + ) : ( + + + + )} + + ); -}; +} + +FileSelector.displayName = 'FileSelector'; export default FileSelector; diff --git a/src/components/FileSelector/FileSelector.types.ts b/src/components/FileSelector/FileSelector.types.ts index c9eb1ca5f..ccd2d29c3 100644 --- a/src/components/FileSelector/FileSelector.types.ts +++ b/src/components/FileSelector/FileSelector.types.ts @@ -1,55 +1,7 @@ +import type { ComponentPropsWithoutRef } from 'react'; import type { DropzoneOptions } from 'react-dropzone'; -import { FileSelectorSizes } from './FileSelector.enums'; - -type CustomFileSelectorProps = { - /** - * Button label text - */ - buttonLabel?: string; - /** - * Drop label text displayed after button - */ - dropLabel?: string; - /** - * Hides button and disable click event so only drop files is available - */ - isClickDisabled?: DropzoneOptions['noClick']; - /** - * Hide drop zone and display only button, drop event is disabled - */ - isDragDisabled?: DropzoneOptions['noDrag']; - /** - * Disables the FileSelector - */ - isDisabled?: DropzoneOptions['disabled']; - /** - * Change FileSelector to error state - */ - hasError?: boolean; - /** - * Minimal file size in bytes - */ - minFileSize?: DropzoneOptions['minSize']; - /** - * Maximal file size in bytes - */ - maxFileSize?: DropzoneOptions['maxSize']; - /** - * Callback fired on file drop or select through native dialog - */ - onFilesDrop?: DropzoneOptions['onDrop']; - /** - * Callback fired when selected files are accepted - */ - onFilesAccepted?: DropzoneOptions['onDropAccepted']; - /** - * Callback fired when selected files are rejected - */ - onFilesRejected?: DropzoneOptions['onDropRejected']; -}; - -type BaseFileSelectorProps = React.HTMLAttributes & +type BaseFileSelectorProps = ComponentPropsWithoutRef<'div'> & Omit< DropzoneOptions, | 'disabled' @@ -65,17 +17,50 @@ type BaseFileSelectorProps = React.HTMLAttributes & | 'onDrop' | 'onDropAccepted' | 'onDropRejected' - > & - CustomFileSelectorProps; + > & { + /** + * Disables the FileSelector + */ + isDisabled?: boolean; + /** + * Sets file selector into errorous state + */ + hasError?: boolean; + /** + * Minimal file size in bytes + */ + minFileSize?: number; + /** + * Maximal file size in bytes + */ + maxFileSize?: number; + /** + * Callback fired on file drop or select through native dialog + */ + onFilesDrop?: DropzoneOptions['onDrop']; + /** + * Callback fired when selected files are accepted + */ + onFilesAccepted?: DropzoneOptions['onDropAccepted']; + /** + * Callback fired when selected files are rejected + */ + onFilesRejected?: DropzoneOptions['onDropRejected']; + /** + * Text with file requirements. Availabel for `fill` and `area` sizes. + */ + instructionsText?: string; + }; export type FileSelectorProps = - | ({ size?: typeof FileSelectorSizes.compact } & BaseFileSelectorProps) - | ({ size?: typeof FileSelectorSizes.fill } & BaseFileSelectorProps) + | ({ + size?: 'fill' | 'compact'; + } & BaseFileSelectorProps) | ({ /** * Size variant of the FileSelector */ - size?: typeof FileSelectorSizes.area; + size: 'area'; /** * Width of the droping zone, takes Number in pixels or any other valid value as String. * Available only for 'area' size. diff --git a/src/components/FileSelector/index.ts b/src/components/FileSelector/index.ts deleted file mode 100644 index ac28490e3..000000000 --- a/src/components/FileSelector/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * as FileSelectorEnums from './FileSelector.enums'; -export { default as FileSelector } from './FileSelector'; -export * from './FileSelector.types'; diff --git a/src/components/FileSelectorButton/FileSelectorButton.stories.tsx b/src/components/FileSelectorButton/FileSelectorButton.stories.tsx new file mode 100644 index 000000000..a7148c53b --- /dev/null +++ b/src/components/FileSelectorButton/FileSelectorButton.stories.tsx @@ -0,0 +1,185 @@ +import { useState } from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import { isNonEmptyArray } from 'ramda-adjunct'; + +import FileSelectorButton from './FileSelectorButton'; +import { Inline, Padbox, Stack, Surface } from '../layout'; +import { SpaceSizes } from '../../theme/space.enums'; +import { TextVariants } from '../Text/Text.enums'; +import { Text } from '../Text'; + +/** + * ```jsx + * import { FileSelectorButton } from '@securityscorecard/design-system'; + * ``` + */ + +const meta = { + component: FileSelectorButton, + argTypes: { + multiple: { + control: 'boolean', + description: 'Allows to select multiple files', + table: { + type: { + summary: 'boolean', + }, + }, + }, + accept: { + control: 'object', + description: + "Accepted file types, in form of object where keys are file mime types and value is a array with supported file extensions. If an empty array is provided as a value all extensions are accepted.Examples: `{ 'image/png': [ '.png' ] }` will accept only PNG images, `{ 'image/*': [] }` will accept any image with any extension.", + table: { + type: { + summary: 'Record>', + }, + }, + }, + maxFiles: { + control: 'number', + description: 'The maximum number of dropped files', + table: { + type: { + summary: 'number', + }, + }, + }, + onFilesDrop: { + control: { disable: true }, + table: { + type: { + summary: + '(acceptedFiles: T[], fileRejections: FileRejection[], event: DropEvent) => void', + }, + }, + }, + onFilesAccepted: { + control: { disable: true }, + table: { + type: { + summary: '(files: T[], event: DropEvent) => void', + }, + }, + }, + onFilesRejected: { + control: { disable: true }, + table: { + type: { + summary: + '(fileRejections: FileRejection[], event: DropEvent) => void', + }, + }, + }, + onFileDialogCancel: { + control: { disable: true }, + description: + 'Callback fired when file dialog is closed with no selection', + table: { + type: { + summary: '() => void', + }, + }, + }, + onFileDialogOpen: { + control: { disable: true }, + description: 'Callback fired when dialog is opened', + table: { + type: { + summary: '() => void', + }, + }, + }, + validator: { + control: { disable: true }, + description: + 'Custom validation function. It must return null if there are no errors.', + table: { + type: { + summary: + '(file: T) => FileError | readonly FileError[] | null', + }, + }, + }, + }, + args: { + accept: { + 'image/*': [], + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + parameters: { + screenshot: { skip: true }, + }, +}; + +/* Example */ +const File = ({ file, errors = [] }) => ( + + + + {file.name} + {Math.floor(file.size / 1024)} kB + + + {errors.map((error) => ( + + {error.message} + + ))} + + + +); + +export const Example: Story = { + render: function Render(args) { + const [errors, setErrors] = useState([]); + const [files, setFiles] = useState([]); + const handleOnDrop = (acceptedFiles, rejectedFiles) => { + setErrors((prev) => [...prev, ...rejectedFiles]); + setFiles((prev) => [...prev, ...acceptedFiles]); + }; + + return ( + + + {files.map((file) => ( + + ))} + {errors.map((error) => ( + + ))} + + ); + }, + args: { + accept: { + 'image/png': ['.png'], + }, + maxFiles: 2, + maxFileSize: 400 * 1024, + minFileSize: 100 * 1024, + multiple: true, + }, + parameters: { + screenshot: { skip: true }, + }, +}; diff --git a/src/components/FileSelectorButton/FileSelectorButton.tsx b/src/components/FileSelectorButton/FileSelectorButton.tsx new file mode 100644 index 000000000..70d4200b9 --- /dev/null +++ b/src/components/FileSelectorButton/FileSelectorButton.tsx @@ -0,0 +1,102 @@ +import type { ComponentPropsWithoutRef } from 'react'; +import { type DropzoneOptions, useDropzone } from 'react-dropzone'; +import cls from 'classnames'; + +import Button from '../ButtonV2/Button'; +import { CLX_COMPONENT } from '../../theme/constants'; + +export type FileSelectorButtonProps = Omit< + DropzoneOptions, + | 'disabled' + | 'preventDropOnDocument' + | 'noClick' + | 'noDrag' + | 'noKeyboard' + | 'noDragEventsBubbling' + | 'getFilesFromEvent' + | 'useFsAccessApi' + | 'minSize' + | 'maxSize' + | 'onDrop' + | 'onDropAccepted' + | 'onDropRejected' +> & { + /** File selector button label */ + label?: string; + /** Expands button to full width of the parrent component */ + isExpanded?: boolean; + /** Disables the FileSelector */ + isDisabled?: boolean; + /** Minimal file size in bytes */ + minFileSize?: number; + /** Maximal file size in bytes */ + maxFileSize?: number; + /** Callback fired on file drop or select through native dialog */ + onFilesDrop?: DropzoneOptions['onDrop']; + /** Callback fired when selected files are accepted */ + onFilesAccepted?: DropzoneOptions['onDropAccepted']; + /** Callback fired when selected files are rejected */ + onFilesRejected?: DropzoneOptions['onDropRejected']; +}; + +function FileSelectorButton({ + label = 'Upload', + isDisabled = false, + isExpanded = false, + multiple = false, + accept, + minFileSize, + maxFileSize, + maxFiles, + onFilesDrop, + onFilesAccepted, + onFilesRejected, + onFileDialogCancel, + onFileDialogOpen, + onDragEnter, + onDragOver, + onDragLeave, + validator, + className, + ...props +}: FileSelectorButtonProps & ComponentPropsWithoutRef<'button'>) { + const { getRootProps, getInputProps } = useDropzone({ + disabled: isDisabled, + noDrag: true, + multiple, + accept, + minSize: minFileSize, + maxSize: maxFileSize, + maxFiles, + onDrop: onFilesDrop, + onDropAccepted: onFilesAccepted, + onDropRejected: onFilesRejected, + onFileDialogCancel, + onFileDialogOpen, + onDragEnter, + onDragOver, + onDragLeave, + validator, + }); + + return ( +
+ + +
+ ); +} + +FileSelectorButton.displayName = 'FileSelectorButton'; + +export default FileSelectorButton; diff --git a/src/components/Text/Text.enums.ts b/src/components/Text/Text.enums.ts index 57d512693..6a3bbb671 100644 --- a/src/components/Text/Text.enums.ts +++ b/src/components/Text/Text.enums.ts @@ -17,6 +17,8 @@ export const TextVariants = { white: 'white', monospace: 'monospace', danger: 'danger', + action: 'action', + disabled: 'disabled', inherit: 'inherit', /** @deprecated */ diff --git a/src/components/Text/Text.tsx b/src/components/Text/Text.tsx index e6d5b2fdd..396a845d6 100644 --- a/src/components/Text/Text.tsx +++ b/src/components/Text/Text.tsx @@ -73,6 +73,12 @@ const monospaceVariant = css` const dangerVariant = css` color: var(--sscds-color-text-danger); `; +const actionVariant = css` + color: var(--sscds-color-text-action); +`; +const disabledVariant = css` + color: var(--sscds-color-text-disabled); +`; const inheritVariant = ` color: inherit; `; @@ -96,6 +102,8 @@ const variants = { [TextVariants.white]: whiteVariant, [TextVariants.monospace]: monospaceVariant, [TextVariants.danger]: dangerVariant, + [TextVariants.disabled]: disabledVariant, + [TextVariants.action]: actionVariant, [TextVariants.inherit]: inheritVariant, /** @deprecated */ diff --git a/src/components/index.ts b/src/components/index.ts index 4c3c23e87..ae8c4ed93 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -33,7 +33,12 @@ export { type ElementLabelProps, } from './ElementLabel/ElementLabel'; export * from './ErrorBoundary'; -export * from './FileSelector'; +export { default as FileSelector } from './FileSelector/FileSelector'; +export type { FileSelectorProps } from './FileSelector/FileSelector.types'; +export { + default as FileSelectorButton, + type FileSelectorButtonProps, +} from './FileSelectorButton/FileSelectorButton'; export * from './Filters'; export * from './FlexContainer'; export * from './forms'; diff --git a/src/theme/icons/upload.ts b/src/theme/icons/upload.ts index 517e9412c..ccf2ba4cb 100644 --- a/src/theme/icons/upload.ts +++ b/src/theme/icons/upload.ts @@ -16,7 +16,7 @@ export const width = 512; export const height = 512; export const unicode = 'e025'; export const svgPathData = - 'M288 109.3V352c0 17.7-14.3 32-32 32s-32-14.3-32-32V109.3l-73.4 73.4c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3l128-128c12.5-12.5 32.8-12.5 45.3 0l128 128c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L288 109.3zM64 352H192c0 35.3 28.7 64 64 64s64-28.7 64-64H448c35.3 0 64 28.7 64 64v32c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V416c0-35.3 28.7-64 64-64zM432 456a24 24 0 1 0 0-48 24 24 0 1 0 0 48z'; + 'M 222.537 31.027 L 249.343 2.922 C 253.003 -0.974 258.987 -0.974 262.687 2.922 L 390.458 137.031 C 394.867 141.755 394.867 149.234 390.458 153.957 L 373.06 172.182 C 368.612 176.827 361.448 176.827 357 172.182 L 279.652 90.977 L 279.652 333.073 C 279.652 339.646 274.574 345 268.316 345 L 243.714 345 C 237.416 345 232.338 339.646 232.338 333.073 L 232.338 91.371 L 154.99 172.576 C 150.582 177.221 143.378 177.221 138.97 172.576 L 121.571 154.312 C 117.123 149.627 117.123 142.109 121.571 137.425 L 222.733 31.224 Z M 100 512.01 C 50 512.05 0 462 0 412.052 L 0 323 C 0 316.925 4.925 312 11 312 L 39 312 C 45.075 312 50 316.925 50 323 L 50 412.047 C 50 437 75 462 99.891 462 L 256 462 L 256 461.975 L 412.109 461.975 C 437 461.975 462 436.975 462 412.022 L 462 322.975 C 462 316.9 466.925 311.975 473 311.975 L 501 311.975 C 507.075 311.975 512 316.9 512 322.975 L 512 412.027 C 512 461.975 462 512.025 412 511.985 L 256 512 L 256 512.025 Z'; export const upload = { prefix: 'ssc' as IconPrefix, diff --git a/yarn.lock b/yarn.lock index 1fea61a7a..ee6a09999 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4602,7 +4602,7 @@ __metadata: react-cool-portal: "npm:^1.2.0" react-datepicker: "npm:^4.6.0" react-dom: "npm:^18.3.1" - react-dropzone: "npm:^12.1.0" + react-dropzone: "npm:^14.2.9" react-i18next: "npm:>=11.18.6" react-is: "npm:>= 18.3.1" react-popper: "npm:^2.2.5" @@ -10616,12 +10616,12 @@ __metadata: languageName: node linkType: hard -"file-selector@npm:^0.5.0": - version: 0.5.0 - resolution: "file-selector@npm:0.5.0" +"file-selector@npm:^0.6.0": + version: 0.6.0 + resolution: "file-selector@npm:0.6.0" dependencies: - tslib: "npm:^2.0.3" - checksum: 10/bbbfc5d9d1602e4bc376d67b62f44576301a8c7ae91dd6d57647b31ea497a37cef4b8a367def31f8a4a4ec6e96db886c2f509a816f8b395c3b6020f1a3dbdb1d + tslib: "npm:^2.4.0" + checksum: 10/6add4098ae07fd1e9050b1e8d3fd9f128680c1d6648c0676af54ace4586e6e5bfcb8fdfa45b69e9131ffd8175bf630d54a445a5facf9be244f85b99ce309183e languageName: node linkType: hard @@ -17535,16 +17535,16 @@ __metadata: languageName: node linkType: hard -"react-dropzone@npm:^12.1.0": - version: 12.1.0 - resolution: "react-dropzone@npm:12.1.0" +"react-dropzone@npm:^14.2.9": + version: 14.2.9 + resolution: "react-dropzone@npm:14.2.9" dependencies: attr-accept: "npm:^2.2.2" - file-selector: "npm:^0.5.0" + file-selector: "npm:^0.6.0" prop-types: "npm:^15.8.1" peerDependencies: - react: ">= 16.8" - checksum: 10/cafea162568d6ac5ec5100dec93a994ace8e4870a81aef7eb356ca621394c5bb7c4f536f275ba1d1bc320092fd546f2c58b89d3f1b2b0a7e3dcc0a76839a502b + react: ">= 16.8 || 18.0.0" + checksum: 10/a8ff584a9dbf952dbd630f4ddf59b0b7a010eff49c3b97b363e30ab357f9cc7b8a0c7694069badeb4cf32361a00f3bbd1063964bd6438e9a68c6fe49ff879a38 languageName: node linkType: hard @@ -20173,7 +20173,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.0, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.6.2": +"tslib@npm:^2.0.0, tslib@npm:^2.1.0, tslib@npm:^2.6.2": version: 2.6.2 resolution: "tslib@npm:2.6.2" checksum: 10/bd26c22d36736513980091a1e356378e8b662ded04204453d353a7f34a4c21ed0afc59b5f90719d4ba756e581a162ecbf93118dc9c6be5acf70aa309188166ca @@ -20187,6 +20187,13 @@ __metadata: languageName: node linkType: hard +"tslib@npm:^2.4.0": + version: 2.7.0 + resolution: "tslib@npm:2.7.0" + checksum: 10/9a5b47ddac65874fa011c20ff76db69f97cf90c78cff5934799ab8894a5342db2d17b4e7613a087046bc1d133d21547ddff87ac558abeec31ffa929c88b7fce6 + languageName: node + linkType: hard + "tsutils@npm:^3.21.0": version: 3.21.0 resolution: "tsutils@npm:3.21.0"