diff --git a/src/__tests__/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/__snapshots__/test-utils-selectors.test.tsx.snap index ae6802387a..6ec6aa69ea 100644 --- a/src/__tests__/__snapshots__/test-utils-selectors.test.tsx.snap +++ b/src/__tests__/__snapshots__/test-utils-selectors.test.tsx.snap @@ -256,8 +256,6 @@ exports[`test-utils selectors 1`] = ` "awsui_file-option-thumbnail-image_ezgb4", "awsui_hints_1ubbm", "awsui_root_1ubbm", - "awsui_upload-button_4xu1k", - "awsui_upload-input_4xu1k", ], "flashbar": [ "awsui_action-button_1q84n", @@ -339,6 +337,8 @@ exports[`test-utils selectors 1`] = ` "awsui_description_1wepg", "awsui_disabled_15o6u", "awsui_dropdown_qwoo0", + "awsui_file-input-button_181f9", + "awsui_file-input_181f9", "awsui_filter-container_z5mul", "awsui_filtering-match-highlight_1p2cx", "awsui_handle_sdha6", @@ -359,6 +359,7 @@ exports[`test-utils selectors 1`] = ` "awsui_placeholder_18eso", "awsui_recovery_vrgzu", "awsui_root_11n0s", + "awsui_root_181f9", "awsui_root_1fcus", "awsui_root_1kjc7", "awsui_root_1qprf", diff --git a/src/file-upload/file-input/index.tsx b/src/file-upload/file-input/index.tsx deleted file mode 100644 index eea8b61a3e..0000000000 --- a/src/file-upload/file-input/index.tsx +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import React, { ChangeEvent, ForwardedRef, useEffect, useRef, useState } from 'react'; -import clsx from 'clsx'; - -import InternalButton from '../../button/internal'; -import { useFormFieldContext } from '../../contexts/form-field'; -import ScreenreaderOnly from '../../internal/components/screenreader-only'; -import { FormFieldValidationControlProps } from '../../internal/context/form-field-context'; -import useForwardFocus from '../../internal/hooks/forward-focus'; -import { useUniqueId } from '../../internal/hooks/use-unique-id'; -import { joinStrings } from '../../internal/utils/strings'; -import { FileUploadProps } from '../interfaces'; - -import styles from './styles.css.js'; - -interface FileInputProps extends FormFieldValidationControlProps { - accept?: string; - ariaRequired?: boolean; - multiple: boolean; - value: readonly File[]; - onChange: (files: File[]) => void; - children: React.ReactNode; -} - -export default React.forwardRef(FileInput); - -function FileInput( - { accept, ariaRequired, multiple, value, onChange, children, ...restProps }: FileInputProps, - ref: ForwardedRef -) { - const uploadInputRef = useRef(null); - const uploadButtonLabelId = useUniqueId('upload-button-label'); - const formFieldContext = useFormFieldContext(restProps); - const selfControlId = useUniqueId('upload-input'); - const controlId = formFieldContext.controlId ?? selfControlId; - - useForwardFocus(ref, uploadInputRef); - - const [isFocused, setIsFocused] = useState(false); - - const onUploadButtonClick = () => uploadInputRef.current?.click(); - - const onUploadInputFocus = () => setIsFocused(true); - - const onUploadInputBlur = () => setIsFocused(false); - - const onUploadInputChange = ({ target }: ChangeEvent) => { - onChange(target.files ? Array.from(target.files) : []); - }; - - const nativeAttributes: React.HTMLAttributes = { - 'aria-labelledby': joinStrings(formFieldContext.ariaLabelledby, uploadButtonLabelId), - 'aria-describedby': formFieldContext.ariaDescribedby, - }; - if (formFieldContext.invalid) { - nativeAttributes['aria-invalid'] = true; - } - if (ariaRequired) { - nativeAttributes['aria-required'] = true; - } - - // Synchronizing component's value with the native file input state. - useEffect(() => { - // The DataTransfer is not available in jsdom. - if (window.DataTransfer) { - const dataTransfer = new DataTransfer(); - for (const file of value) { - dataTransfer.items.add(file); - } - uploadInputRef.current!.files = dataTransfer.files; - } - if (uploadInputRef.current) { - uploadInputRef.current.value = ''; // reset value to allow calling onChange when the same file is uploaded again - } - }, [value]); - - return ( -
- {/* This is the actual interactive and accessible file-upload element. */} - {/* It is visually hidden to achieve the desired UX design. */} - - - {/* The button is decorative. It dispatches clicks to the file input and is ARIA-hidden. */} - {/* When the input is focused the focus outline is forced on the button. */} - - {children} - - - {/* The file input needs to be labelled with provided content. Can't use the button because it is ARIA-hidden. */} - {children} -
- ); -} diff --git a/src/file-upload/file-input/styles.scss b/src/file-upload/file-input/styles.scss deleted file mode 100644 index ddf919cc5d..0000000000 --- a/src/file-upload/file-input/styles.scss +++ /dev/null @@ -1,30 +0,0 @@ -/* - Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - SPDX-License-Identifier: Apache-2.0 -*/ - -@use '../../internal/styles/tokens' as awsui; -@use '../../internal/styles' as styles; -@use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible; - -.upload-button, -.upload-input { - /* used in test-utils */ -} - -.file-input-container { - position: relative; -} - -.upload-input { - position: absolute; - clip: rect(0, 0, 0, 0); -} - -.upload-button { - @include focus-visible.when-visible-unfocused { - &.force-focus-outline { - @include styles.focus-highlight(awsui.$space-button-focus-outline-gutter); - } - } -} diff --git a/src/file-upload/internal.tsx b/src/file-upload/internal.tsx index d647de7aea..0af85798e1 100644 --- a/src/file-upload/internal.tsx +++ b/src/file-upload/internal.tsx @@ -12,6 +12,7 @@ import { useFormFieldContext } from '../contexts/form-field'; import { ConstraintText, FormFieldError, FormFieldWarning } from '../form-field/internal'; import { getBaseProps } from '../internal/base-component'; import InternalFileDropzone, { useFilesDragging } from '../internal/components/file-dropzone'; +import InternalFileInput from '../internal/components/file-input'; import TokenList from '../internal/components/token-list'; import { fireNonCancelableEvent } from '../internal/events'; import checkControlled from '../internal/hooks/check-controlled'; @@ -22,12 +23,11 @@ import { useUniqueId } from '../internal/hooks/use-unique-id'; import { joinStrings } from '../internal/utils/strings'; import InternalSpaceBetween from '../space-between/internal'; import { Token } from '../token-group/token'; -import FileInput from './file-input'; import { FileOption } from './file-option'; import { FileUploadProps } from './interfaces'; +import fileInputStyles from '../internal/components/file-input/styles.css.js'; import tokenListStyles from '../internal/components/token-list/styles.css.js'; -import fileInputStyles from './file-input/styles.css.js'; import styles from './styles.css.js'; type InternalFileUploadProps = FileUploadProps & InternalBaseComponentProps; @@ -65,7 +65,7 @@ function InternalFileUpload( }, listItemSelector: `.${tokenListStyles['list-item']}`, showMoreSelector: `.${tokenListStyles.toggle}`, - fallbackSelector: `.${fileInputStyles['upload-input']}`, + fallbackSelector: `.${fileInputStyles['file-input']}`, }); const baseProps = getBaseProps(restProps); @@ -128,19 +128,19 @@ function InternalFileUpload( {i18nStrings.dropzoneText(multiple)} ) : ( - handleFilesChange(event.detail.value)} value={value} {...restProps} ariaDescribedby={ariaDescribedBy} invalid={invalid} > {i18nStrings.uploadButtonText(multiple)} - + )} {(constraintText || errorText || warningText) && ( diff --git a/src/internal/components/file-input/__tests__/file-input.test.tsx b/src/internal/components/file-input/__tests__/file-input.test.tsx new file mode 100644 index 0000000000..c020081e62 --- /dev/null +++ b/src/internal/components/file-input/__tests__/file-input.test.tsx @@ -0,0 +1,157 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { fireEvent, render as testingLibraryRender, screen } from '@testing-library/react'; + +import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; + +import '../../../../__a11y__/to-validate-a11y'; +import InternalFileInput, { FileInputProps } from '../../../../../lib/components/internal/components/file-input'; +import FileInputWrapper from '../../../../../lib/components/test-utils/dom/internal/file-input'; + +jest.mock('@cloudscape-design/component-toolkit/internal', () => ({ + ...jest.requireActual('@cloudscape-design/component-toolkit/internal'), + warnOnce: jest.fn(), +})); + +jest.mock('../../../../../lib/components/internal/utils/date-time', () => ({ + formatDateTime: () => '2020-06-01T00:00:00', +})); + +const onChange = jest.fn(); + +afterEach(() => { + (warnOnce as jest.Mock).mockReset(); + onChange.mockReset(); +}); + +const defaultProps: FileInputProps = { + value: [], + onChange, +}; + +const file1 = new File([new Blob(['Test content 1'], { type: 'text/plain' })], 'test-file-1.txt', { + type: 'text/plain', + lastModified: 1590962400000, +}); +const file2 = new File([new Blob(['Test content 2'], { type: 'text/plain' })], 'test-file-2.txt', { + type: 'image/png', + lastModified: 1590962400000, +}); + +function render(props: Partial) { + const renderResult = testingLibraryRender( +
+ Choose files +
Test label
+
+ ); + const element = renderResult.container.querySelector(`.${FileInputWrapper.rootSelector}`)!; + return new FileInputWrapper(element)!; +} + +describe('FileInput input', () => { + test('`multiple` property is assigned', () => { + expect(render({ multiple: false }).findNativeInput().getElement()).not.toHaveAttribute('multiple'); + expect(render({ multiple: true }).findNativeInput().getElement()).toHaveAttribute('multiple'); + }); + + test('`accept` property is assigned', () => { + expect(render({ accept: 'custom' }).findNativeInput().getElement()).toHaveAttribute('accept', 'custom'); + }); + + test('`ariaRequired` property is assigned', () => { + expect(render({ ariaRequired: false }).findNativeInput().getElement()).not.toHaveAttribute('aria-required'); + expect(render({ ariaRequired: true }).findNativeInput().getElement()).toHaveAttribute('aria-required'); + }); + + test('`ariaLabelledby` property is assigned', () => { + render({ ariaLabelledby: 'test-label' }); + expect(screen.getByLabelText('Test label')).toBeDefined(); + }); + + test('`ariaLabelledby` is joined with `uploadButtonText`', () => { + const wrapper = render({ ariaLabelledby: 'test-label' }); + expect(wrapper.findNativeInput().getElement()).toHaveAccessibleName('Test label Choose files'); + }); + + test('`ariaDescribedby` property is assigned', () => { + const uploadButton = render({ ariaDescribedby: 'test-label' }).findNativeInput().getElement(); + expect(uploadButton).toHaveAccessibleDescription('Test label'); + }); + + test('`invalid` property is assigned', () => { + expect(render({ invalid: false }).findNativeInput().getElement()).not.toBeInvalid(); + expect(render({ invalid: true }).findNativeInput().getElement()).toBeInvalid(); + }); + + test('`text` property is assigned', () => { + expect(render({}).findTrigger().getElement()).toHaveTextContent('Choose files'); + }); + + test('uses `text` as aria label when `ariaLabel` is undefined', () => { + expect(render({}).findNativeInput().getElement()).toHaveAttribute('aria-label', 'Choose files'); + }); + + test('`ariaLabel` takes precedence if both `ariaLabel` and `text` are defined', () => { + expect(render({ ariaLabel: 'aria label' }).findNativeInput().getElement()).toHaveAttribute( + 'aria-label', + 'aria label' + ); + }); + + test('dev warning is issued when `variant` is icon and `ariaLabel` is undefined', () => { + render({ variant: 'icon' }); + expect(warnOnce).toHaveBeenCalledTimes(1); + expect(warnOnce).toHaveBeenCalledWith('FileInput', 'Aria label is required with icon variant.'); + }); + + test('dev warning is issued when `onChange` handler is missing', () => { + render({ onChange: undefined }); + + expect(warnOnce).toHaveBeenCalledTimes(1); + expect(warnOnce).toHaveBeenCalledWith( + 'FileInput', + 'You provided `value` prop without an `onChange` handler. This will render a read-only component. If the component should be mutable, set an `onChange` handler.' + ); + }); + + test('file upload button can be assigned aria-invalid', () => { + const wrapper = render({ invalid: true }); + expect(wrapper.findNativeInput().getElement()).toHaveAttribute('aria-invalid', 'true'); + }); + + test('file input fires onChange with files in details', () => { + const wrapper = render({ multiple: true }); + const input = wrapper.findNativeInput().getElement(); + Object.defineProperty(input, 'files', { value: [file1, file2] }); + fireEvent(input, new CustomEvent('change', { bubbles: true })); + + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ detail: { value: [file1, file2] } })); + // additional equality check, because `expect.objectContaining` above thinks file1 === file2 + expect((onChange as jest.Mock).mock.lastCall[0].detail.value[0]).toBe(file1); + expect((onChange as jest.Mock).mock.lastCall[0].detail.value[1]).toBe(file2); + }); +}); + +describe('a11y', () => { + test('multiple empty', async () => { + const wrapper = render({ multiple: true, value: [] }); + await expect(wrapper.getElement()).toValidateA11y(); + }); + + test('single', async () => { + const wrapper = render({ + value: [file1], + }); + await expect(wrapper.getElement()).toValidateA11y(); + }); + + test('multiple', async () => { + const wrapper = render({ + multiple: true, + value: [file1, file2], + }); + await expect(wrapper.getElement()).toValidateA11y(); + }); +}); diff --git a/src/internal/components/file-input/index.tsx b/src/internal/components/file-input/index.tsx new file mode 100644 index 0000000000..6409e67a03 --- /dev/null +++ b/src/internal/components/file-input/index.tsx @@ -0,0 +1,126 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { ChangeEvent, Ref, useEffect, useRef, useState } from 'react'; +import clsx from 'clsx'; + +import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; + +import InternalButton from '../../../button/internal'; +import { useFormFieldContext } from '../../../contexts/form-field'; +import ScreenreaderOnly from '../../components/screenreader-only'; +import { fireNonCancelableEvent } from '../../events'; +import checkControlled from '../../hooks/check-controlled'; +import useForwardFocus from '../../hooks/forward-focus'; +import { useUniqueId } from '../../hooks/use-unique-id'; +import { joinStrings } from '../../utils/strings'; +import { FileInputProps } from './interfaces'; + +import styles from './styles.css.js'; + +export { FileInputProps }; + +const InternalFileInput = React.forwardRef( + ( + { + accept, + ariaRequired, + ariaLabel, + multiple = false, + value, + onChange, + variant = 'button', + children, + ...restProps + }: FileInputProps, + ref: Ref + ) => { + const uploadInputRef = useRef(null); + const uploadButtonLabelId = useUniqueId('upload-button-label'); + const formFieldContext = useFormFieldContext(restProps); + const selfControlId = useUniqueId('upload-input'); + const controlId = formFieldContext.controlId ?? selfControlId; + + useForwardFocus(ref, uploadInputRef); + + const [isFocused, setIsFocused] = useState(false); + const onUploadButtonClick = () => uploadInputRef.current?.click(); + const onUploadInputFocus = () => setIsFocused(true); + const onUploadInputBlur = () => setIsFocused(false); + + const onUploadInputChange = ({ target }: ChangeEvent) => { + fireNonCancelableEvent(onChange, { value: target.files ? Array.from(target.files) : [] }); + }; + + checkControlled('FileInput', 'value', value, 'onChange', onChange); + + const nativeAttributes: React.HTMLAttributes = { + 'aria-label': ariaLabel || children, + 'aria-labelledby': joinStrings(formFieldContext.ariaLabelledby, uploadButtonLabelId), + 'aria-describedby': formFieldContext.ariaDescribedby, + }; + if (formFieldContext.invalid) { + nativeAttributes['aria-invalid'] = true; + } + if (ariaRequired) { + nativeAttributes['aria-required'] = true; + } + + if (variant === 'icon' && !ariaLabel) { + warnOnce('FileInput', 'Aria label is required with icon variant.'); + } + + // Synchronizing component's value with the native file input state. + useEffect(() => { + /* istanbul ignore next: The DataTransfer is not available in jsdom. */ + if (window.DataTransfer) { + const dataTransfer = new DataTransfer(); + for (const file of value) { + dataTransfer.items.add(file); + } + uploadInputRef.current!.files = dataTransfer.files; + } + if (uploadInputRef.current) { + uploadInputRef.current.value = ''; // reset value to allow calling onChange when the same file is uploaded again + } + }, [value]); + + return ( +
+ {/* This is the actual interactive and accessible file-upload element. */} + {/* It is visually hidden to achieve the desired UX design. */} + + + {/* The button is decorative. It dispatches clicks to the file input and is ARIA-hidden. */} + {/* When the input is focused the focus outline is forced on the button. */} + + {variant === 'button' && children} + + + {/* The file input needs to be labelled with provided content. Can't use the button because it is ARIA-hidden. */} + {ariaLabel || children} +
+ ); + } +); + +export default InternalFileInput; diff --git a/src/internal/components/file-input/interfaces.ts b/src/internal/components/file-input/interfaces.ts new file mode 100644 index 0000000000..7bf9ade9ac --- /dev/null +++ b/src/internal/components/file-input/interfaces.ts @@ -0,0 +1,64 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { BaseComponentProps } from '../../base-component'; +import { FormFieldCommonValidationControlProps } from '../../context/form-field-context'; +import { NonCancelableEventHandler } from '../../events'; + +export interface FileInputProps extends BaseComponentProps, FormFieldCommonValidationControlProps { + /** + * Variant of the file input. Defaults to "button". + */ + variant?: 'button' | 'icon'; + + /** + * Adds `aria-label` to the file input element. Use this to provide an accessible name for file inputs + * that don't have visible text, and to distinguish between multiple file inputs with identical visible text. + */ + ariaLabel?: string; + + /** + * Text displayed in the file input component. Used as the aria label if ariaLabel is not defined. + * @displayname text + */ + children?: string; + + /** + * Specifies the native file input `accept` attribute to describe the allow-list of file types. + */ + accept?: string; + + /** + * Specifies whether to add aria-required to the file upload control. + */ + ariaRequired?: boolean; + + /** + * Specifies the native file input `multiple` attribute to allow users entering more than one file. + */ + multiple?: boolean; + + /** + * Called when the user selects new file(s), or removes a file. + * The event `detail` contains the current value of the component. + */ + onChange: NonCancelableEventHandler; + + /** + * Specifies the currently selected file(s). + * If you want to clear the selection, use empty array. + */ + value: ReadonlyArray; +} + +export namespace FileInputProps { + export interface ChangeDetail { + value: File[]; + } + + export interface Ref { + /** + * Sets focus on the file upload button. + */ + focus(): void; + } +} diff --git a/src/internal/components/file-input/styles.scss b/src/internal/components/file-input/styles.scss new file mode 100644 index 0000000000..4faf20f6a5 --- /dev/null +++ b/src/internal/components/file-input/styles.scss @@ -0,0 +1,24 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ +@use '../../styles/tokens' as awsui; +@use '../../styles' as styles; +@use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible; + +.root { + position: relative; +} + +.file-input { + position: absolute; + clip: rect(0, 0, 0, 0); + + &-button { + @include focus-visible.when-visible-unfocused { + &.force-focus-outline { + @include styles.focus-highlight(awsui.$space-button-focus-outline-gutter); + } + } + } +} diff --git a/src/test-utils/dom/file-upload/index.ts b/src/test-utils/dom/file-upload/index.ts index 623d02de89..836d6fd883 100644 --- a/src/test-utils/dom/file-upload/index.ts +++ b/src/test-utils/dom/file-upload/index.ts @@ -4,10 +4,10 @@ import { ComponentWrapper, ElementWrapper } from '@cloudscape-design/test-utils- import ButtonWrapper from '../button'; -import fileUploadInputSelectors from '../../../file-upload/file-input/styles.selectors.js'; import fileUploadOptionSelectors from '../../../file-upload/file-option/styles.selectors.js'; import fileUploadSelectors from '../../../file-upload/styles.selectors.js'; import formFieldStyles from '../../../form-field/styles.selectors.js'; +import fileUploadInputSelectors from '../../../internal/components/file-input/styles.selectors.js'; import tokenListSelectors from '../../../internal/components/token-list/styles.selectors.js'; import tokenGroupSelectors from '../../../token-group/styles.selectors.js'; @@ -15,11 +15,11 @@ export default class FileUploadWrapper extends ComponentWrapper { static rootSelector: string = fileUploadSelectors.root; findUploadButton(): ButtonWrapper { - return this.findComponent(`.${fileUploadInputSelectors['upload-button']}`, ButtonWrapper)!; + return this.findComponent(`.${fileUploadInputSelectors['file-input-button']}`, ButtonWrapper)!; } findNativeInput(): ElementWrapper { - return this.findByClassName(fileUploadInputSelectors['upload-input'])!; + return this.findByClassName(fileUploadInputSelectors['file-input'])!; } findFileTokens(): Array { diff --git a/src/test-utils/dom/internal/file-input.ts b/src/test-utils/dom/internal/file-input.ts new file mode 100644 index 0000000000..59148ccba0 --- /dev/null +++ b/src/test-utils/dom/internal/file-input.ts @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ComponentWrapper, ElementWrapper } from '@cloudscape-design/test-utils-core/dom'; + +import ButtonWrapper from '../button'; + +import selectors from '../../../internal/components/file-input/styles.selectors.js'; + +export default class FileInputWrapper extends ComponentWrapper { + static rootSelector: string = selectors.root; + + findTrigger(): ButtonWrapper { + return this.findComponent(`.${selectors['file-input-button']}`, ButtonWrapper)!; + } + + findNativeInput(): ElementWrapper { + return this.findByClassName(selectors['file-input'])!; + } +}