Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce custom design of FileInputField (#244) #601

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 161 additions & 8 deletions src/components/FileInputField/FileInputField.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import PropTypes from 'prop-types';
import React, { useContext } from 'react';
import React, {
useContext,
useEffect,
useState,
} from 'react';
import { withGlobalProps } from '../../providers/globalProps';
import { classNames } from '../../helpers/classNames/classNames';
import { classNames } from '../../helpers/classNames';
import { transferProps } from '../../helpers/transferProps';
import { TranslationsContext } from '../../providers/translations';
import { getRootSizeClassName } from '../_helpers/getRootSizeClassName';
import { getRootValidationStateClassName } from '../_helpers/getRootValidationStateClassName';
import { resolveContextOrProp } from '../_helpers/resolveContextOrProp';
import { InputGroupContext } from '../InputGroup';
import { Text } from '../Text';
import { FormLayoutContext } from '../FormLayout';
import styles from './FileInputField.module.scss';

Expand All @@ -17,34 +25,122 @@ export const FileInputField = React.forwardRef((props, ref) => {
isLabelVisible,
label,
layout,
onChange,
onDragEnter,
onDragLeave,
onDragOver,
onDrop,
required,
size,
validationState,
validationText,
...restProps
} = props;

const context = useContext(FormLayoutContext);
const formLayoutContext = useContext(FormLayoutContext);
const inputGroupContext = useContext(InputGroupContext);
const translations = useContext(TranslationsContext);

const [selectedFileNames, setSelectedFileNames] = useState([]);
const [isDragAndDropSupported, setIsDragAndDropSupported] = useState(false);
const [isDragging, setIsDragging] = useState(false);

const handleFileChange = (files) => {
if (files.length === 0) {
setSelectedFileNames([]);
return;
}

const fileNames = [];

[...files].forEach((file) => {
fileNames.push(file.name);
});

setSelectedFileNames(fileNames);
};

const handleInputChange = (event) => {
handleFileChange(event.target.files);

if (props?.onChange) {
props.onChange(event);
}
};

const handleClick = (event) => {
event.currentTarget.previousElementSibling.click();
};

const handleDrop = (event) => {
event.preventDefault();
handleFileChange(event.dataTransfer.files);
setIsDragging(false);

if (props?.onDrop) {
props.onDrop(event);
}
};

const handleDragOver = (event) => {
event.preventDefault();
setIsDragging(true);

if (props?.onDragOver) {
props.onDragOver(event);
}
};
Comment on lines +85 to +92
Copy link
Member Author

@adamkudrna adamkudrna Mar 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My suspicion is this function is evaluated many times per second (which corresponds to how often the dragover event is being fired). Is it OK?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is how onDragOver is supposed to behave.

I think that setIsDragging is handleDragOver is not necessary. We should be able to solve it without it just using onDragEnter and onDragLeave. We only have to solve problem that onDragLeave is called when dragging over child element.

This can be solved with change in FileInputField.module.scss:

// 2. Prevent pointer events on all children of the root element to not to trigger drag events on children.

@layer components.file-input-field {
// Foundation
.root {
    @include foundation.root();
    
    * {
        pointer-events: none; // 2.
    }
}

Then setIsDragging(true); can be removed from handleDragOver.

CAUTION: handleDragOver must be present here to prevent default behaviour. See https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/File_drag_and_drop#prevent_the_browsers_default_drag_behavior


const handleDragEnter = (event) => {
setIsDragging(true);

if (props?.onDragEnter) {
props.onDragEnter(event);
}
};

const handleDragLeave = (event) => {
setIsDragging(false);

if (props?.onDragLeave) {
props.onDragLeave(event);
}
};

useEffect(() => {
setIsDragAndDropSupported('draggable' in document.createElement('span'));
}, []);

return (
<label
className={classNames(
styles.root,
fullWidth && styles.isRootFullWidth,
context && styles.isRootInFormLayout,
resolveContextOrProp(context && context.layout, layout) === 'horizontal'
formLayoutContext && styles.isRootInFormLayout,
resolveContextOrProp(formLayoutContext && formLayoutContext.layout, layout) === 'horizontal'
? styles.isRootLayoutHorizontal
: styles.isRootLayoutVertical,
disabled && styles.isRootDisabled,
resolveContextOrProp(inputGroupContext && inputGroupContext.disabled, disabled) && styles.isRootDisabled,
inputGroupContext && styles.isRootGrouped,
isDragging && styles.isRootDragging,
required && styles.isRootRequired,
getRootSizeClassName(
resolveContextOrProp(inputGroupContext && inputGroupContext.size, size),
styles,
),
getRootValidationStateClassName(validationState, styles),
)}
htmlFor={id}
id={id && `${id}__label`}
onDragEnter={!disabled && isDragAndDropSupported ? handleDragEnter : undefined}
onDragLeave={!disabled && isDragAndDropSupported ? handleDragLeave : undefined}
onDragOver={!disabled && isDragAndDropSupported ? handleDragOver : undefined}
onDrop={!disabled && isDragAndDropSupported ? handleDrop : undefined}
>
<div
className={classNames(
styles.label,
!isLabelVisible && styles.isLabelHidden,
(!isLabelVisible || inputGroupContext) && styles.isLabelHidden,
)}
id={id && `${id}__labelText`}
>
Expand All @@ -54,12 +150,37 @@ export const FileInputField = React.forwardRef((props, ref) => {
<div className={styles.inputContainer}>
<input
{...transferProps(restProps)}
disabled={disabled}
className={styles.input}
disabled={resolveContextOrProp(inputGroupContext && inputGroupContext.disabled, disabled)}
id={id}
onChange={handleInputChange}
ref={ref}
required={required}
tabIndex={-1}
type="file"
/>
<button
className={styles.dropZone}
onClick={handleClick}
type="button"
>
<Text lines={1}>
Copy link
Member Author

@adamkudrna adamkudrna Mar 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know why but dragleave is fired when this element is hovered during dragging. It doesn't happen in Spirit. That's why I double-check the isDragging state is on by calling the hook (repeatedly) on the dragover event. I'm certainly open to any improvements.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spirit – vanilla JS implementation:

Spirit – React implementation:

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{!selectedFileNames.length && (
<>
<span className={styles.dropZoneLink}>{translations.FileInputField.browse}</span>
{isDragAndDropSupported && ` ${translations.FileInputField.drop}`}
</>
)}
{selectedFileNames.length === 1 && selectedFileNames[0]}
{selectedFileNames.length > 1 && (
<>
{selectedFileNames.length}
{' '}
{translations.FileInputField.filesSelected}
</>
)}
</Text>
</button>
</div>
{helpText && (
<div
Expand Down Expand Up @@ -89,7 +210,13 @@ FileInputField.defaultProps = {
id: undefined,
isLabelVisible: true,
layout: 'vertical',
onChange: () => {},
onDragEnter: () => {},
onDragLeave: () => {},
onDragOver: () => {},
onDrop: () => {},
required: false,
size: 'medium',
validationState: null,
validationText: null,
};
Expand Down Expand Up @@ -134,10 +261,36 @@ FileInputField.propTypes = {
*
*/
layout: PropTypes.oneOf(['horizontal', 'vertical']),
/**
* Callback fired when the value of the input changes.
*/
onChange: PropTypes.func,
/**
* Callback fired when a drag event enters the field.
*/
onDragEnter: PropTypes.func,
/**
* Callback fired when a drag event leaves the field.
*/
onDragLeave: PropTypes.func,
/**
* Callback fired when a drag event is over the field.
*/
onDragOver: PropTypes.func,
/**
* Callback fired when a file is dropped onto the field.
*/
onDrop: PropTypes.func,
/**
* If `true`, the input will be required.
*/
required: PropTypes.bool,
/**
* Size of the field.
*
* Ignored if the component is rendered within `InputGroup` component as the value is inherited in such case.
*/
size: PropTypes.oneOf(['small', 'medium', 'large']),
/**
* Alter the field to provide feedback based on validation result.
*/
Expand Down
79 changes: 78 additions & 1 deletion src/components/FileInputField/FileInputField.module.scss
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
// 1. The drop zone is constructed as a button to support keyboard operation.

@use "../../styles/tools/form-fields/box-field-elements";
@use "../../styles/tools/form-fields/box-field-layout";
@use "../../styles/tools/form-fields/box-field-sizes";
@use "../../styles/tools/form-fields/foundation";
@use "../../styles/tools/form-fields/variants";
@use "../../styles/tools/accessibility";
@use "../../styles/tools/links";
@use "../../styles/tools/transition";
@use "../../styles/tools/reset";
@use "settings";

@layer components.file-input-field {
// Foundation
Expand All @@ -18,6 +25,46 @@
@include box-field-elements.input-container();
}

.input {
@include accessibility.hide-text();
}

.dropZone {
--rui-local-color: #{settings.$drop-zone-color};
--rui-local-border-color: #{settings.$drop-zone-border-color};
--rui-local-background: #{settings.$drop-zone-background-color};

@include reset.button(); // 1.
@include box-field-elements.base();

display: flex;
align-items: center;
justify-content: start;
font-weight: settings.$drop-zone-font-weight;
font-size: var(--rui-local-font-size);
line-height: settings.$drop-zone-line-height;
font-family: settings.$drop-zone-font-family;
border-style: dashed;
}

.isRootDragging input + .dropZone {
--rui-local-border-color: #{settings.$drop-zone-dragging-border-color};
}

.root:not(.isRootDisabled) .dropZone:hover {
--rui-local-border-color: #{settings.$drop-zone-hover-border-color};
}

.input:not(:disabled):active + .dropZone {
--rui-local-border-color: #{settings.$drop-zone-active-border-color};
}

.dropZoneLink {
@include links.base();

cursor: pointer;
}

.helpText,
.validationText {
@include foundation.help-text();
Expand All @@ -28,6 +75,18 @@
}

// States
.isRootDisabled {
--rui-local-color: #{settings.$drop-zone-disabled-color};
--rui-local-border-color: #{settings.$drop-zone-disabled-border-color};
--rui-local-background: #{settings.$drop-zone-disabled-background-color};

@include variants.disabled-state();
}

.isRootDisabled .dropZoneLink {
cursor: inherit;
}

.isRootStateInvalid {
@include variants.validation(invalid);
}
Expand Down Expand Up @@ -56,10 +115,28 @@
}

.isRootFullWidth {
@include box-field-layout.full-width();
@include box-field-layout.full-width($input-element-selector: ".dropZone");
}

.isRootInFormLayout {
@include box-field-layout.in-form-layout();
}

// Sizes
.isRootSizeSmall {
@include box-field-sizes.size(small);
}

.isRootSizeMedium {
@include box-field-sizes.size(medium);
}

.isRootSizeLarge {
@include box-field-sizes.size(large);
}

// Groups
.isRootGrouped {
@include box-field-elements.in-group-layout($input-element-selector: ".dropZone");
}
}
9 changes: 9 additions & 0 deletions src/components/FileInputField/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ layout perspective, FileInputFields work just like any other form fields.

## Sizes

Aside from the default (medium) size, two additional sizes are available: small
and large.

```docoff-react-preview
<FileInputField label="Attachment" size="small" />
<FileInputField label="Attachment" />
<FileInputField label="Attachment" size="large" />
```

Full-width fields span the full width of a parent:

```docoff-react-preview
Expand Down
Loading
Loading