diff --git a/src/components/FileInputField/FileInputField.jsx b/src/components/FileInputField/FileInputField.jsx
index bccae7ef8..eb70f6534 100644
--- a/src/components/FileInputField/FileInputField.jsx
+++ b/src/components/FileInputField/FileInputField.jsx
@@ -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';
@@ -17,54 +26,156 @@ 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 (
-
+
{helpText && (
{helpText}
@@ -72,13 +183,13 @@ export const FileInputField = React.forwardRef((props, ref) => {
{validationText && (
{validationText}
)}
-
+
);
});
@@ -86,10 +197,11 @@ FileInputField.defaultProps = {
disabled: false,
fullWidth: false,
helpText: null,
- id: undefined,
isLabelVisible: true,
layout: 'vertical',
+ multiple: false,
required: false,
+ size: 'medium',
validationState: null,
validationText: null,
};
@@ -116,7 +228,7 @@ FileInputField.propTypes = {
* * `__helpText`
* * `__validationText`
*/
- id: PropTypes.string,
+ id: PropTypes.string.isRequired,
/**
* If `false`, the label will be visually hidden (but remains accessible by assistive
* technologies).
@@ -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.
*/
diff --git a/src/components/FileInputField/FileInputField.module.scss b/src/components/FileInputField/FileInputField.module.scss
index fe1da7768..e71e75a9d 100644
--- a/src/components/FileInputField/FileInputField.module.scss
+++ b/src/components/FileInputField/FileInputField.module.scss
@@ -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
@@ -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();
@@ -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);
}
@@ -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");
+ }
}
diff --git a/src/components/FileInputField/README.md b/src/components/FileInputField/README.md
index 87bc22574..8f80aef26 100644
--- a/src/components/FileInputField/README.md
+++ b/src/components/FileInputField/README.md
@@ -13,7 +13,7 @@ import { FileInputField } from '@react-ui-org/react-ui';
And use it:
```docoff-react-preview
-
+ {}} />
```
See [API](#api) for all available options.
@@ -48,12 +48,37 @@ 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
+ {}}
+ size="small"
+/>
+ {}}
+/>
+ {}}
+ size="large"
+/>
+```
+
Full-width fields span the full width of a parent:
```docoff-react-preview
{}}
/>
```
@@ -68,8 +93,10 @@ dangerous to hide labels from users in most cases. Keep in mind you should
```docoff-react-preview
{}}
/>
```
@@ -81,14 +108,18 @@ supports this kind of layout as well.
```docoff-react-preview
{}}
/>
{}}
/>
```
@@ -100,18 +131,24 @@ filled.
```docoff-react-preview
{}}
/>
{}}
/>
{}}
/>
```
@@ -126,17 +163,23 @@ have.
```docoff-react-preview
{}}
validationState="valid"
validationText="Looks good!"
/>
{}}
validationState="invalid"
validationText="Your file is too big. Please select something smaller."
/>
{}}
validationState="warning"
validationText={`
You selected more than 10 files.
@@ -152,7 +195,44 @@ It's possible to disable the whole input.
```docoff-react-preview
{}}
+/>
+```
+
+## Handling Files
+
+Files selected by the user are handled by providing a custom function to the
+`onFilesChanged` prop. The `onFilesChanged` function is then called on the
+`change` event of the `input` element and on the `drop` event of the root
+`div` element.
+
+```docoff-react-preview
+ {
+ // Do something with the files…
+ console.log('Files selected:', files);
+ }}
+/>
+```
+
+### Multiple Files
+
+By default, users can select only one file. To allow selecting multiple files,
+set the `multiple` prop to `true`.
+
+```docoff-react-preview
+ {
+ // Do something with the files…
+ console.log('Files selected:', files);
+ }}
/>
```
@@ -172,8 +252,9 @@ to improve its accessibility.
Choose up to 10 files. Allowed extensions are .pdf, .jpg, .jpeg, or .png.
Size limit is 10 MB.
`}
+ id="my-file"
label="Attachment"
- multiple
+ onFilesChanged={() => {}}
/>
```
diff --git a/src/components/FileInputField/__tests__/FileInputField.test.jsx b/src/components/FileInputField/__tests__/FileInputField.test.jsx
index 66f579520..77881c584 100644
--- a/src/components/FileInputField/__tests__/FileInputField.test.jsx
+++ b/src/components/FileInputField/__tests__/FileInputField.test.jsx
@@ -13,13 +13,16 @@ import { formLayoutProviderTest } from '../../../../tests/providerTests/formLayo
import { isLabelVisibleTest } from '../../../../tests/propTests/isLabelVisibleTest';
import { labelPropTest } from '../../../../tests/propTests/labelPropTest';
import { layoutPropTest } from '../../../../tests/propTests/layoutPropTest';
+import { sizePropTest } from '../../../../tests/propTests/sizePropTest';
import { requiredPropTest } from '../../../../tests/propTests/requiredPropTest';
import { validationStatePropTest } from '../../../../tests/propTests/validationStatePropTest';
import { validationTextPropTest } from '../../../../tests/propTests/validationTextPropTest';
import { FileInputField } from '../FileInputField';
const mandatoryProps = {
+ id: 'id',
label: 'label',
+ onFilesChanged: () => {},
};
describe('rendering', () => {
@@ -33,7 +36,6 @@ describe('rendering', () => {
[
{
helpText: 'help text',
- id: 'id',
validationText: 'validation text',
},
(rootElement) => {
@@ -41,13 +43,14 @@ describe('rendering', () => {
expect(within(rootElement).getByText('label')).toHaveAttribute('id', 'id__labelText');
expect(within(rootElement).getByText('help text')).toHaveAttribute('id', 'id__helpText');
expect(within(rootElement).getByText('validation text')).toHaveAttribute('id', 'id__validationText');
- expect(rootElement).toHaveAttribute('id', 'id__label');
+ expect(rootElement).toHaveAttribute('id', 'id__root');
},
],
...isLabelVisibleTest(),
...labelPropTest(),
...layoutPropTest,
...requiredPropTest,
+ ...sizePropTest,
...validationStatePropTest,
...validationTextPropTest,
])('renders with props: "%s"', (testedProps, assert) => {
@@ -68,12 +71,13 @@ describe('functionality', () => {
render((
));
const file = new File(['hello'], 'hello.png', { type: 'image/png' });
- await userEvent.upload(screen.getByLabelText('label'), file);
+ await userEvent.upload(screen.getByTestId('id'), file);
expect(spy).toHaveBeenCalled();
});
});
diff --git a/src/components/FileInputField/_settings.scss b/src/components/FileInputField/_settings.scss
new file mode 100644
index 000000000..1b882d49f
--- /dev/null
+++ b/src/components/FileInputField/_settings.scss
@@ -0,0 +1,15 @@
+@use "../../styles/theme/typography";
+
+$drop-zone-color: var(--rui-color-text-primary);
+$drop-zone-disabled-color: var(--rui-color-text-primary-disabled);
+$drop-zone-border-color: var(--rui-color-border-primary);
+$drop-zone-hover-border-color: var(--rui-color-border-primary-hover);
+$drop-zone-active-border-color: var(--rui-color-border-primary-active);
+$drop-zone-dragging-border-color: var(--rui-color-border-primary-active);
+$drop-zone-disabled-border-color: var(--rui-color-border-primary);
+$drop-zone-background-color: var(--rui-color-background-basic);
+$drop-zone-disabled-background-color: var(--rui-color-background-disabled);
+$drop-zone-disabled-cursor: var(--rui-cursor-not-allowed);
+$drop-zone-font-weight: typography.$font-weight-base;
+$drop-zone-line-height: typography.$line-height-base;
+$drop-zone-font-family: typography.$font-family-base;
diff --git a/src/components/FormLayout/README.md b/src/components/FormLayout/README.md
index 8c9f713d0..7bacef204 100644
--- a/src/components/FormLayout/README.md
+++ b/src/components/FormLayout/README.md
@@ -389,6 +389,7 @@ React.createElement(() => {
/>
{}}
/>
-
+ {}} />
```
diff --git a/src/components/Modal/README.md b/src/components/Modal/README.md
index 77fb431b2..cab4e6d5f 100644
--- a/src/components/Modal/README.md
+++ b/src/components/Modal/README.md
@@ -946,7 +946,7 @@ React.createElement(() => {
{ label: 'Project manager', value: 'project-manager' },
]}
/>
-
+ {}} />