Skip to content

Commit cecd54d

Browse files
committed
Introduce custom design of FileInputField (#244)
1 parent 25d7df3 commit cecd54d

File tree

19 files changed

+410
-81
lines changed

19 files changed

+410
-81
lines changed

src/components/FileInputField/FileInputField.jsx

Lines changed: 146 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
import PropTypes from 'prop-types';
2-
import React, { useContext } from 'react';
2+
import React, {
3+
useContext,
4+
useImperativeHandle,
5+
useRef,
6+
useState,
7+
} from 'react';
38
import { withGlobalProps } from '../../providers/globalProps';
4-
import { classNames } from '../../helpers/classNames/classNames';
9+
import { classNames } from '../../helpers/classNames';
510
import { transferProps } from '../../helpers/transferProps';
11+
import { TranslationsContext } from '../../providers/translations';
12+
import { getRootSizeClassName } from '../_helpers/getRootSizeClassName';
613
import { getRootValidationStateClassName } from '../_helpers/getRootValidationStateClassName';
714
import { resolveContextOrProp } from '../_helpers/resolveContextOrProp';
15+
import { InputGroupContext } from '../InputGroup';
16+
import { Text } from '../Text';
817
import { FormLayoutContext } from '../FormLayout';
918
import styles from './FileInputField.module.scss';
1019

@@ -17,79 +26,182 @@ export const FileInputField = React.forwardRef((props, ref) => {
1726
isLabelVisible,
1827
label,
1928
layout,
29+
multiple,
30+
onFilesChanged,
2031
required,
32+
size,
2133
validationState,
2234
validationText,
2335
...restProps
2436
} = props;
2537

26-
const context = useContext(FormLayoutContext);
38+
const internalInputRef = useRef();
39+
40+
// We need to have a reference to the input element to be able to call its methods,
41+
// but at the same time we want to expose this reference to the parent component for
42+
// case someone wants to call input methods from outside the component.
43+
useImperativeHandle(ref, () => internalInputRef.current);
44+
45+
const formLayoutContext = useContext(FormLayoutContext);
46+
const inputGroupContext = useContext(InputGroupContext);
47+
const translations = useContext(TranslationsContext);
48+
49+
const [selectedFileNames, setSelectedFileNames] = useState([]);
50+
const [isDragging, setIsDragging] = useState(false);
51+
52+
const handleFileChange = (files, event) => {
53+
if (files.length === 0) {
54+
setSelectedFileNames([]);
55+
return;
56+
}
57+
58+
// Mimic the native behavior of the `input` element: if multiple files are selected and the input
59+
// does not accept multiple files, no files are processed.
60+
if (files.length > 1 && !multiple) {
61+
setSelectedFileNames([]);
62+
return;
63+
}
64+
65+
const fileNames = [];
66+
67+
[...files].forEach((file) => {
68+
fileNames.push(file.name);
69+
});
70+
71+
setSelectedFileNames(fileNames);
72+
onFilesChanged(files, event);
73+
};
74+
75+
const handleInputChange = (event) => {
76+
handleFileChange(event.target.files, event);
77+
};
78+
79+
const handleClick = () => {
80+
internalInputRef?.current.click();
81+
};
82+
83+
const handleDrop = (event) => {
84+
event.preventDefault();
85+
handleFileChange(event.dataTransfer.files, event);
86+
setIsDragging(false);
87+
};
88+
89+
const handleDragOver = (event) => {
90+
if (!isDragging) {
91+
setIsDragging(true);
92+
}
93+
event.preventDefault();
94+
};
95+
96+
const handleDragLeave = () => {
97+
if (isDragging) {
98+
setIsDragging(false);
99+
}
100+
};
27101

28102
return (
29-
<label
103+
<div
30104
className={classNames(
31105
styles.root,
32106
fullWidth && styles.isRootFullWidth,
33-
context && styles.isRootInFormLayout,
34-
resolveContextOrProp(context && context.layout, layout) === 'horizontal'
107+
formLayoutContext && styles.isRootInFormLayout,
108+
resolveContextOrProp(formLayoutContext && formLayoutContext.layout, layout) === 'horizontal'
35109
? styles.isRootLayoutHorizontal
36110
: styles.isRootLayoutVertical,
37-
disabled && styles.isRootDisabled,
111+
resolveContextOrProp(inputGroupContext && inputGroupContext.disabled, disabled) && styles.isRootDisabled,
112+
inputGroupContext && styles.isRootGrouped,
113+
isDragging && styles.isRootDragging,
38114
required && styles.isRootRequired,
115+
getRootSizeClassName(
116+
resolveContextOrProp(inputGroupContext && inputGroupContext.size, size),
117+
styles,
118+
),
39119
getRootValidationStateClassName(validationState, styles),
40120
)}
41-
htmlFor={id}
42-
id={id && `${id}__label`}
121+
id={`${id}__root`}
122+
onDragLeave={!disabled ? handleDragLeave : undefined}
123+
onDragOver={!disabled ? handleDragOver : undefined}
124+
onDrop={!disabled ? handleDrop : undefined}
43125
>
44-
<div
126+
<label
45127
className={classNames(
46128
styles.label,
47-
!isLabelVisible && styles.isLabelHidden,
129+
(!isLabelVisible || inputGroupContext) && styles.isLabelHidden,
48130
)}
49-
id={id && `${id}__labelText`}
131+
htmlFor={id}
132+
id={`${id}__labelText`}
50133
>
51134
{label}
52-
</div>
135+
</label>
53136
<div className={styles.field}>
54137
<div className={styles.inputContainer}>
55138
<input
56139
{...transferProps(restProps)}
57-
disabled={disabled}
140+
className={styles.input}
141+
disabled={resolveContextOrProp(inputGroupContext && inputGroupContext.disabled, disabled)}
58142
id={id}
59-
ref={ref}
143+
multiple={multiple}
144+
onChange={handleInputChange}
145+
ref={internalInputRef}
60146
required={required}
147+
tabIndex={-1}
61148
type="file"
62149
/>
150+
<button
151+
className={styles.dropZone}
152+
disabled={resolveContextOrProp(inputGroupContext && inputGroupContext.disabled, disabled)}
153+
onClick={handleClick}
154+
type="button"
155+
>
156+
<Text lines={1}>
157+
{!selectedFileNames.length && (
158+
<>
159+
<span className={styles.dropZoneLink}>{translations.FileInputField.browse}</span>
160+
{' '}
161+
{translations.FileInputField.drop}
162+
</>
163+
)}
164+
{selectedFileNames.length === 1 && selectedFileNames[0]}
165+
{selectedFileNames.length > 1 && (
166+
<>
167+
{selectedFileNames.length}
168+
{' '}
169+
{translations.FileInputField.filesSelected}
170+
</>
171+
)}
172+
</Text>
173+
</button>
63174
</div>
64175
{helpText && (
65176
<div
66177
className={styles.helpText}
67-
id={id && `${id}__helpText`}
178+
id={`${id}__helpText`}
68179
>
69180
{helpText}
70181
</div>
71182
)}
72183
{validationText && (
73184
<div
74185
className={styles.validationText}
75-
id={id && `${id}__validationText`}
186+
id={`${id}__validationText`}
76187
>
77188
{validationText}
78189
</div>
79190
)}
80191
</div>
81-
</label>
192+
</div>
82193
);
83194
});
84195

85196
FileInputField.defaultProps = {
86197
disabled: false,
87198
fullWidth: false,
88199
helpText: null,
89-
id: undefined,
90200
isLabelVisible: true,
91201
layout: 'vertical',
202+
multiple: false,
92203
required: false,
204+
size: 'medium',
93205
validationState: null,
94206
validationText: null,
95207
};
@@ -116,7 +228,7 @@ FileInputField.propTypes = {
116228
* * `<ID>__helpText`
117229
* * `<ID>__validationText`
118230
*/
119-
id: PropTypes.string,
231+
id: PropTypes.string.isRequired,
120232
/**
121233
* If `false`, the label will be visually hidden (but remains accessible by assistive
122234
* technologies).
@@ -134,10 +246,24 @@ FileInputField.propTypes = {
134246
*
135247
*/
136248
layout: PropTypes.oneOf(['horizontal', 'vertical']),
249+
/**
250+
* If `true`, the input will accept multiple files.
251+
*/
252+
multiple: PropTypes.bool,
253+
/**
254+
* Callback fired when the value of the input changes.
255+
*/
256+
onFilesChanged: PropTypes.func.isRequired,
137257
/**
138258
* If `true`, the input will be required.
139259
*/
140260
required: PropTypes.bool,
261+
/**
262+
* Size of the field.
263+
*
264+
* Ignored if the component is rendered within `InputGroup` component as the value is inherited in such case.
265+
*/
266+
size: PropTypes.oneOf(['small', 'medium', 'large']),
141267
/**
142268
* Alter the field to provide feedback based on validation result.
143269
*/

src/components/FileInputField/FileInputField.module.scss

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1+
// 1. The drop zone is constructed as a button to support keyboard operation.
2+
// 2. Prevent pointer events on all children of the root element to not to trigger drag events on children.
3+
14
@use "../../styles/tools/form-fields/box-field-elements";
25
@use "../../styles/tools/form-fields/box-field-layout";
6+
@use "../../styles/tools/form-fields/box-field-sizes";
37
@use "../../styles/tools/form-fields/foundation";
48
@use "../../styles/tools/form-fields/variants";
59
@use "../../styles/tools/accessibility";
10+
@use "../../styles/tools/links";
11+
@use "../../styles/tools/transition";
12+
@use "../../styles/tools/reset";
13+
@use "settings";
614

715
@layer components.file-input-field {
816
// Foundation
@@ -18,6 +26,54 @@
1826
@include box-field-elements.input-container();
1927
}
2028

29+
.input {
30+
@include accessibility.hide-text();
31+
}
32+
33+
.dropZone {
34+
--rui-local-color: #{settings.$drop-zone-color};
35+
--rui-local-border-color: #{settings.$drop-zone-border-color};
36+
--rui-local-background: #{settings.$drop-zone-background-color};
37+
38+
@include reset.button(); // 1.
39+
@include box-field-elements.base();
40+
41+
display: flex;
42+
align-items: center;
43+
justify-content: start;
44+
font-weight: settings.$drop-zone-font-weight;
45+
font-size: var(--rui-local-font-size);
46+
line-height: settings.$drop-zone-line-height;
47+
font-family: settings.$drop-zone-font-family;
48+
border-style: dashed;
49+
}
50+
51+
.isRootDragging .dropZone {
52+
--rui-local-border-color: #{settings.$drop-zone-dragging-border-color};
53+
}
54+
55+
.isRootDisabled .dropZone {
56+
cursor: settings.$drop-zone-disabled-cursor;
57+
}
58+
59+
.root:not(.isRootDisabled, .isRootDragging) .dropZone:hover {
60+
--rui-local-border-color: #{settings.$drop-zone-hover-border-color};
61+
}
62+
63+
.root:not(.isRootDisabled, .isRootDragging) .dropZone:active {
64+
--rui-local-border-color: #{settings.$drop-zone-active-border-color};
65+
}
66+
67+
.dropZoneLink {
68+
@include links.base();
69+
70+
&::before {
71+
content: "";
72+
position: absolute;
73+
inset: 0;
74+
}
75+
}
76+
2177
.helpText,
2278
.validationText {
2379
@include foundation.help-text();
@@ -28,6 +84,18 @@
2884
}
2985

3086
// States
87+
.isRootDisabled {
88+
--rui-local-color: #{settings.$drop-zone-disabled-color};
89+
--rui-local-border-color: #{settings.$drop-zone-disabled-border-color};
90+
--rui-local-background: #{settings.$drop-zone-disabled-background-color};
91+
92+
@include variants.disabled-state();
93+
}
94+
95+
.isRootDisabled .dropZoneLink {
96+
cursor: inherit;
97+
}
98+
3199
.isRootStateInvalid {
32100
@include variants.validation(invalid);
33101
}
@@ -56,10 +124,28 @@
56124
}
57125

58126
.isRootFullWidth {
59-
@include box-field-layout.full-width();
127+
@include box-field-layout.full-width($input-element-selector: ".dropZone");
60128
}
61129

62130
.isRootInFormLayout {
63131
@include box-field-layout.in-form-layout();
64132
}
133+
134+
// Sizes
135+
.isRootSizeSmall {
136+
@include box-field-sizes.size(small);
137+
}
138+
139+
.isRootSizeMedium {
140+
@include box-field-sizes.size(medium);
141+
}
142+
143+
.isRootSizeLarge {
144+
@include box-field-sizes.size(large);
145+
}
146+
147+
// Groups
148+
.isRootGrouped {
149+
@include box-field-elements.in-group-layout($input-element-selector: ".dropZone");
150+
}
65151
}

0 commit comments

Comments
 (0)