Skip to content
Merged
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
166 changes: 146 additions & 20 deletions src/components/FileInputField/FileInputField.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import PropTypes from 'prop-types';
import React, { useContext } from 'react';
import React, {
useContext,
useImperativeHandle,
useRef,
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,79 +26,182 @@ export const FileInputField = React.forwardRef((props, ref) => {
isLabelVisible,
label,
layout,
multiple,
onFilesChanged,
required,
size,
validationState,
validationText,
...restProps
} = props;

const context = useContext(FormLayoutContext);
const internalInputRef = useRef();

// We need to have a reference to the input element to be able to call its methods,
// but at the same time we want to expose this reference to the parent component for
// case someone wants to call input methods from outside the component.
useImperativeHandle(ref, () => internalInputRef.current);

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

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

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

// Mimic the native behavior of the `input` element: if multiple files are selected and the input
// does not accept multiple files, no files are processed.
if (files.length > 1 && !multiple) {
setSelectedFileNames([]);
return;
}

const fileNames = [];

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

setSelectedFileNames(fileNames);
onFilesChanged(files, event);
};

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

const handleClick = () => {
internalInputRef?.current.click();
};

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

const handleDragOver = (event) => {
if (!isDragging) {
setIsDragging(true);
}
event.preventDefault();
};

const handleDragLeave = () => {
if (isDragging) {
setIsDragging(false);
}
};

return (
<label
<div
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`}
id={`${id}__root`}
onDragLeave={!disabled ? handleDragLeave : undefined}
onDragOver={!disabled ? handleDragOver : undefined}
onDrop={!disabled ? handleDrop : undefined}
>
<div
<label
className={classNames(
styles.label,
!isLabelVisible && styles.isLabelHidden,
(!isLabelVisible || inputGroupContext) && styles.isLabelHidden,
)}
id={id && `${id}__labelText`}
htmlFor={id}
id={`${id}__labelText`}
>
{label}
</div>
</label>
<div className={styles.field}>
<div className={styles.inputContainer}>
<input
{...transferProps(restProps)}
disabled={disabled}
className={styles.input}
disabled={resolveContextOrProp(inputGroupContext && inputGroupContext.disabled, disabled)}
id={id}
ref={ref}
multiple={multiple}
onChange={handleInputChange}
ref={internalInputRef}
required={required}
tabIndex={-1}
type="file"
/>
<button
className={styles.dropZone}
disabled={resolveContextOrProp(inputGroupContext && inputGroupContext.disabled, disabled)}
onClick={handleClick}
type="button"
>
<Text lines={1}>
{!selectedFileNames.length && (
<>
<span className={styles.dropZoneLink}>{translations.FileInputField.browse}</span>
{' '}
{translations.FileInputField.drop}
</>
)}
{selectedFileNames.length === 1 && selectedFileNames[0]}
{selectedFileNames.length > 1 && (
<>
{selectedFileNames.length}
{' '}
{translations.FileInputField.filesSelected}
</>
)}
</Text>
</button>
</div>
{helpText && (
<div
className={styles.helpText}
id={id && `${id}__helpText`}
id={`${id}__helpText`}
>
{helpText}
</div>
)}
{validationText && (
<div
className={styles.validationText}
id={id && `${id}__validationText`}
id={`${id}__validationText`}
>
{validationText}
</div>
)}
</div>
</label>
</div>
);
});

FileInputField.defaultProps = {
disabled: false,
fullWidth: false,
helpText: null,
id: undefined,
isLabelVisible: true,
layout: 'vertical',
multiple: false,
required: false,
size: 'medium',
validationState: null,
validationText: null,
};
Expand All @@ -116,7 +228,7 @@ FileInputField.propTypes = {
* * `<ID>__helpText`
* * `<ID>__validationText`
*/
id: PropTypes.string,
id: PropTypes.string.isRequired,
/**
* If `false`, the label will be visually hidden (but remains accessible by assistive
* technologies).
Expand All @@ -134,10 +246,24 @@ FileInputField.propTypes = {
*
*/
layout: PropTypes.oneOf(['horizontal', 'vertical']),
/**
* If `true`, the input will accept multiple files.
*/
multiple: PropTypes.bool,
/**
* Callback fired when the value of the input changes.
*/
onFilesChanged: PropTypes.func.isRequired,
/**
* 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
88 changes: 87 additions & 1 deletion src/components/FileInputField/FileInputField.module.scss
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
// 1. The drop zone is constructed as a button to support keyboard operation.
// 2. Prevent pointer events on all children of the root element to not to trigger drag events on children.

@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 +26,54 @@
@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 .dropZone {
--rui-local-border-color: #{settings.$drop-zone-dragging-border-color};
}

.isRootDisabled .dropZone {
cursor: settings.$drop-zone-disabled-cursor;
}

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

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

.dropZoneLink {
@include links.base();

&::before {
content: "";
position: absolute;
inset: 0;
}
}

.helpText,
.validationText {
@include foundation.help-text();
Expand All @@ -28,6 +84,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 +124,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");
}
}
Loading