From 5ca370b1d9de9969607f5969d040fc69cb139b1a Mon Sep 17 00:00:00 2001 From: Guilherme Datilio Ribeiro Date: Tue, 3 Oct 2023 09:44:05 -0300 Subject: [PATCH] New single drag and drop single `FileUploader` (#14660) * feat: added new story * chore: working tooltip without style * chore: working upload file without style * feat: tooltip styling on progress * fix: fixed loading in the state * feat: added style for replace box * chore: improving the code * test: added test to fileUploaderItem * chore: added css to fix layout movement after upload * fix: removed replace functionality * fix: added breakpoint and ellipse to FF * fix: fixed width * fix: fixed styles * fix: fixed styling * fix: change pixel --------- Co-authored-by: Andrea N. Cardona --- .../FileUploader/FileUploader.stories.js | 15 ++ .../FileUploader/FileUploaderItem.tsx | 88 ++++++--- .../src/components/FileUploader/Filename.tsx | 7 +- .../stories/drag-and-drop-single.js | 171 ++++++++++++++++++ .../file-uploader/_file-uploader.scss | 73 +++++++- 5 files changed, 325 insertions(+), 29 deletions(-) create mode 100644 packages/react/src/components/FileUploader/stories/drag-and-drop-single.js diff --git a/packages/react/src/components/FileUploader/FileUploader.stories.js b/packages/react/src/components/FileUploader/FileUploader.stories.js index 2881fd7e5c06..d8115e88c9f8 100644 --- a/packages/react/src/components/FileUploader/FileUploader.stories.js +++ b/packages/react/src/components/FileUploader/FileUploader.stories.js @@ -154,6 +154,21 @@ DragAndDropUploadContainerExampleApplication.argTypes = { onChange: { action: 'onChange' }, }; +export const DragAndDropUploadSingleContainerExampleApplication = (args) => + require('./stories/drag-and-drop-single').default(args); + +DragAndDropUploadSingleContainerExampleApplication.args = { + labelText: 'Drag and drop a file here or click to upload', + name: '', + multiple: false, + accept: ['image/jpeg', 'image/png'], + disabled: false, + tabIndex: 0, +}; +DragAndDropUploadSingleContainerExampleApplication.argTypes = { + onChange: { action: 'onChange' }, +}; + export const Skeleton = () => (
diff --git a/packages/react/src/components/FileUploader/FileUploaderItem.tsx b/packages/react/src/components/FileUploader/FileUploaderItem.tsx index 9b748f152ce7..7bdd5589f377 100644 --- a/packages/react/src/components/FileUploader/FileUploaderItem.tsx +++ b/packages/react/src/components/FileUploader/FileUploaderItem.tsx @@ -7,12 +7,13 @@ import cx from 'classnames'; import PropTypes from 'prop-types'; -import React, { useRef } from 'react'; +import React, { useLayoutEffect, useRef, useState } from 'react'; import Filename from './Filename'; import { keys, matches } from '../../internal/keyboard'; import uid from '../../tools/uniqueId'; import { usePrefix } from '../../internal/usePrefix'; import { ReactAttr } from '../../types/common'; +import { Tooltip } from '../Tooltip'; export interface FileUploaderItemProps extends ReactAttr { /** @@ -40,6 +41,11 @@ export interface FileUploaderItemProps extends ReactAttr { */ name?: string; + /** + * Event handler that is called after files are added to the uploader + */ + onAddFiles?: (event: React.ChangeEvent) => void; + /** * Event handler that is called after removing a file from the file uploader * The event handler signature looks like `onDelete(evt, { uuid })` @@ -78,6 +84,7 @@ function FileUploaderItem({ size, ...other }: FileUploaderItemProps) { + const [isEllipsisApplied, setIsEllipsisApplied] = useState(false); const prefix = usePrefix(); const { current: id } = useRef(uuid || uid()); const classes = cx(`${prefix}--file__selected-file`, { @@ -85,33 +92,68 @@ function FileUploaderItem({ [`${prefix}--file__selected-file--md`]: size === 'md', [`${prefix}--file__selected-file--sm`]: size === 'sm', }); + const isInvalid = invalid + ? `${prefix}--file-filename-container-wrap-invalid` + : `${prefix}--file-filename-container-wrap`; + + const isEllipsisActive = (element: any) => { + setIsEllipsisApplied(element.offsetWidth < element.scrollWidth); + return element.offsetWidth < element.scrollWidth; + }; + + useLayoutEffect(() => { + const element = document.querySelector(`.${prefix}--file-filename`); + isEllipsisActive(element); + }, [prefix, name]); + return ( -

- {name} -

- - { - if (matches(evt as unknown as Event, [keys.Enter, keys.Space])) { + {isEllipsisApplied ? ( +
+ + + +
+ ) : ( +

+ {name} +

+ )} + +
+ + { + if (matches(evt as unknown as Event, [keys.Enter, keys.Space])) { + if (status === 'edit') { + evt.preventDefault(); + onDelete(evt, { uuid: id }); + } + } + }} + onClick={(evt) => { if (status === 'edit') { - evt.preventDefault(); onDelete(evt, { uuid: id }); } - } - }} - onClick={(evt) => { - if (status === 'edit') { - onDelete(evt, { uuid: id }); - } - }} - /> - + }} + /> + +
{invalid && errorSubject && (
+ ); case 'edit': return ( diff --git a/packages/react/src/components/FileUploader/stories/drag-and-drop-single.js b/packages/react/src/components/FileUploader/stories/drag-and-drop-single.js new file mode 100644 index 000000000000..54c451f7c4fc --- /dev/null +++ b/packages/react/src/components/FileUploader/stories/drag-and-drop-single.js @@ -0,0 +1,171 @@ +/** + * Copyright IBM Corp. 2016, 2023 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useState, useEffect, useRef } from 'react'; +import classnames from 'classnames'; +import FileUploaderItem from '../FileUploaderItem'; +import FileUploaderDropContainer from '../FileUploaderDropContainer'; +import FormItem from '../../FormItem'; + +// import uid from '../../../tools/uniqueId'; +import '../FileUploader-story.scss'; + +const prefix = 'cds'; + +// -- copied from internal/tools/uniqueId.js +let lastId = 0; +function uid(prefix = 'id') { + lastId++; + return `${prefix}${lastId}`; +} +// -- end copied + +const ExampleDropContainerApp = (props) => { + const [file, setFile] = useState(); + const uploaderButton = useRef(null); + const handleDrop = (e) => { + e.preventDefault(); + }; + + const handleDragover = (e) => { + e.preventDefault(); + }; + + useEffect(() => { + document.addEventListener('drop', handleDrop); + document.addEventListener('dragover', handleDragover); + return () => { + document.removeEventListener('drop', handleDrop); + document.removeEventListener('dragover', handleDragover); + }; + }, []); + + const uploadFile = async (fileToUpload) => { + // file size validation + if (fileToUpload[0].filesize > 512000) { + const updatedFile = { + ...fileToUpload[0], + status: 'edit', + iconDescription: 'Delete file', + invalid: true, + errorSubject: 'File size exceeds limit', + errorBody: '500kb max file size. Select a new file and try again.', + }; + setFile(updatedFile); + return; + } + + // file type validation + if (fileToUpload.invalidFileType) { + const updatedFile = { + ...fileToUpload[0], + status: 'edit', + iconDescription: 'Delete file', + invalid: true, + errorSubject: 'Invalid file type', + errorBody: `"${fileToUpload.name}" does not have a valid file type.`, + }; + setFile(updatedFile); + return; + } + + // simulate network request time + const rand = Math.random() * 1000; + setTimeout(() => { + const updatedFile = { + ...fileToUpload[0], + status: 'complete', + iconDescription: 'Upload complete', + }; + setFile(updatedFile); + }, rand); + + // show x icon after 1 second + setTimeout(() => { + const updatedFile = { + ...fileToUpload[0], + status: 'edit', + iconDescription: 'Delete file', + }; + setFile(updatedFile); + }, rand + 1000); + }; + + const onAddFilesButton = (event) => { + const file = event.target.files; + + const newFile = [ + { + uuid: uid(), + name: file[0].name, + filesize: file[0].size, + status: 'uploading', + iconDescription: 'Uploading', + invalidFileType: file[0].invalidFileType, + }, + ]; + + setFile(newFile[0]); + uploadFile([newFile[0]]); + }; + + const handleFileUploaderItemClick = () => { + setFile(); + }; + + const labelClasses = classnames(`${prefix}--file--label`, { + // eslint-disable-next-line react/prop-types + [`${prefix}--file--label--disabled`]: props.disabled, + }); + + const helperTextClasses = classnames(`${prefix}--label-description`, { + // eslint-disable-next-line react/prop-types + [`${prefix}--label-description--disabled`]: props.disabled, + }); + + return ( + +

Upload files

+

+ Max file size is 500kb. Supported file types are .jpg and .png. +

+ {file === undefined && ( + + )} + +
+ {file !== undefined && ( + + )} +
+
+ ); +}; + +export default ExampleDropContainerApp; diff --git a/packages/styles/scss/components/file-uploader/_file-uploader.scss b/packages/styles/scss/components/file-uploader/_file-uploader.scss index 712c31410795..f6fefbc3050b 100644 --- a/packages/styles/scss/components/file-uploader/_file-uploader.scss +++ b/packages/styles/scss/components/file-uploader/_file-uploader.scss @@ -4,6 +4,7 @@ // This source code is licensed under the Apache-2.0 license found in the // LICENSE file in the root directory of this source tree. // +@use '../../breakpoint' as *; @use '../../config' as *; @use '../../motion' as *; @@ -139,7 +140,7 @@ display: grid; align-items: center; background-color: $layer; - gap: convert.to-rem(12px) $spacing-05; + gap: convert.to-rem(12px) 0; grid-auto-rows: auto; grid-template-columns: 1fr auto; margin-block-end: $spacing-03; @@ -171,15 +172,66 @@ text-overflow: ellipsis; white-space: nowrap; } + + .#{$prefix}--file-filename-container-wrap { + margin-block-start: 1px; + max-inline-size: 17.5rem; + padding-inline-start: $spacing-05; + @include breakpoint-down(410px) { + max-inline-size: 13.5rem; + } + } + + .#{$prefix}--file-filename-container-wrap-invalid { + max-inline-size: 15.5rem; + + .#{$prefix}--file-filename-tooltip { + inline-size: -webkit-fill-available; + padding-inline-start: $spacing-05; + @-moz-document url-prefix() { + inline-size: -moz-available; + } + } + } + + .#{$prefix}--file-filename-tooltip { + inline-size: -webkit-fill-available; + @-moz-document url-prefix() { + inline-size: -moz-available; + } + } + + .#{$prefix}--file-filename-button { + @include type-style('body-compact-01'); + + overflow: hidden; + padding: 0; + border: none; + background: none; + color: inherit; + cursor: pointer; + font: inherit; + inline-size: -webkit-fill-available; + outline: inherit; + text-overflow: ellipsis; + white-space: nowrap; + + @-moz-document url-prefix() { + inline-size: -moz-available; + } + } + .#{$prefix}--file-filename-button:focus { + outline: revert; + } } .#{$prefix}--file__selected-file--md { - gap: $spacing-03 $spacing-05; + gap: $spacing-03 0; min-block-size: convert.to-rem(40px); } .#{$prefix}--file__selected-file--sm { - gap: $spacing-02 $spacing-05; + gap: $spacing-02 0; min-block-size: convert.to-rem(32px); } @@ -259,21 +311,32 @@ color: $text-primary; padding-block-end: $spacing-03; } - .#{$prefix}--file__state-container { display: flex; align-items: center; justify-content: center; min-inline-size: 1.5rem; - padding-inline-end: $spacing-05; + padding-inline-end: convert.to-rem(12px); .#{$prefix}--loading__svg { stroke: $icon-primary; } } + .#{$prefix}--file__state-container .#{$prefix}--file-loading { + display: flex; + align-items: center; + justify-content: center; + padding: $spacing-02; + border: none; + background-color: transparent; + block-size: $spacing-05; + inline-size: $spacing-06; + } + .#{$prefix}--file__state-container .#{$prefix}--file-complete { fill: $interactive; + inline-size: $spacing-06; &:focus { @include focus-outline('border');