From 6a2026e14bbe36bcfddba3fa6b52b11e753ce2b4 Mon Sep 17 00:00:00 2001 From: Adam Kudrna Date: Mon, 3 Mar 2025 18:42:01 +0100 Subject: [PATCH 1/2] Fix link validation colors --- src/styles/theme/_form-fields.scss | 10 +++++----- src/styles/tools/form-fields/_variants.scss | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/styles/theme/_form-fields.scss b/src/styles/theme/_form-fields.scss index f7cd2b5b2..bf3f71d98 100644 --- a/src/styles/theme/_form-fields.scss +++ b/src/styles/theme/_form-fields.scss @@ -33,15 +33,15 @@ $horizontal-full-width-label-width: var(--rui-FormField--horizontal--full-width_ // Form fields: links in validation states $link-validation-colors: ( invalid: ( + default: var(--rui-color-feedback-danger), + hover: var(--rui-color-feedback-danger-hover), + active: var(--rui-color-feedback-danger-active), + ), + valid: ( default: var(--rui-color-feedback-success), hover: var(--rui-color-feedback-success-hover), active: var(--rui-color-feedback-success-active), ), - valid: ( - default: var(--rui-color-feedback-valid), - hover: var(--rui-color-feedback-valid-hover), - active: var(--rui-color-feedback-valid-active), - ), warning: ( default: var(--rui-color-feedback-warning), hover: var(--rui-color-feedback-warning-hover), diff --git a/src/styles/tools/form-fields/_variants.scss b/src/styles/tools/form-fields/_variants.scss index d132eecb4..339a08d36 100644 --- a/src/styles/tools/form-fields/_variants.scss +++ b/src/styles/tools/form-fields/_variants.scss @@ -143,9 +143,9 @@ } @mixin validation($variant) { - --rui-local-link-color: map.get(theme.$link-validation-colors, $variant, default); - --rui-local-link-color-hover: map.get(theme.$link-validation-colors, $variant, hover); - --rui-local-link-color-active: map.get(theme.$link-validation-colors, $variant, active); + --rui-local-link-color: #{map.get(theme.$link-validation-colors, $variant, default)}; + --rui-local-link-color-hover: #{map.get(theme.$link-validation-colors, $variant, hover)}; + --rui-local-link-color-active: #{map.get(theme.$link-validation-colors, $variant, active)}; @include _get-theme(validation, $variant); } From 7addc9ab0b0f03af100f2a6b509ff8582774ee6e Mon Sep 17 00:00:00 2001 From: Adam Kudrna Date: Mon, 3 Mar 2025 18:42:51 +0100 Subject: [PATCH 2/2] Introduce custom design of `FileInputField` (#244) --- .../FileInputField/FileInputField.jsx | 166 +++++++++++++++--- .../FileInputField/FileInputField.module.scss | 88 +++++++++- src/components/FileInputField/README.md | 85 ++++++++- .../__tests__/FileInputField.test.jsx | 12 +- src/components/FileInputField/_settings.scss | 15 ++ src/components/FormLayout/README.md | 1 + .../InputGroup/InputGroup.module.scss | 6 +- src/components/InputGroup/README.md | 2 +- src/components/Modal/README.md | 2 +- src/styles/elements/_links.scss | 16 +- src/styles/generic/_focus.scss | 2 +- src/styles/tools/_accessibility.scss | 8 +- src/styles/tools/_links.scss | 17 ++ .../form-fields/_box-field-elements.scss | 30 +++- .../tools/form-fields/_box-field-layout.scss | 4 +- .../tools/form-fields/_box-field-sizes.scss | 16 +- src/styles/tools/form-fields/_variants.scss | 14 +- src/translations/en.js | 5 + webpack.config.babel.js | 2 +- 19 files changed, 410 insertions(+), 81 deletions(-) create mode 100644 src/components/FileInputField/_settings.scss create mode 100644 src/styles/tools/_links.scss 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(() => { /> {}} /> - + {}} />