diff --git a/pages/file-input/permutations.page.tsx b/pages/file-input/permutations.page.tsx new file mode 100644 index 0000000000..874626250f --- /dev/null +++ b/pages/file-input/permutations.page.tsx @@ -0,0 +1,40 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import FileInput, { FileInputProps } from '~components/file-input'; + +import createPermutations from '../utils/permutations'; +import PermutationsView from '../utils/permutations-view'; +import ScreenshotArea from '../utils/screenshot-area'; + +const permutations = createPermutations([ + { + value: [[]], + onChange: [() => {}], + ariaLabel: ['prompt file input'], + variant: ['icon', 'button'], + children: ['Upload file'], + disabled: [false, true], + }, + { + value: [[]], + onChange: [() => {}], + ariaLabel: ['prompt file input'], + variant: ['icon', 'button'], + children: ['Upload file'], + disabled: [true], + disabledReason: ["You don't have access to upload files."], + }, +]); + +export default function ExpandableSectionPermutations() { + return ( + <> +

File input permutations

+ + } /> + + + ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index f2b6b8d3b0..76d683e334 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -8870,6 +8870,19 @@ is provided by its parent form field component.", "optional": true, "type": "string", }, + { + "description": "Renders the file input as disabled and file selection.", + "name": "disabled", + "optional": true, + "type": "boolean", + }, + { + "description": "Provides a reason why the file input is disabled (only when \`disabled\` is \`true\`). +If provided, the file input becomes focusable.", + "name": "disabledReason", + "optional": true, + "type": "string", + }, { "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must @@ -8969,6 +8982,13 @@ Make sure that you add a listener to this event to update your application state "optional": true, "type": "string", }, + { + "description": "Specifies if the control is disabled. +This prevents the user from modifying the value. A disabled control is not focusable.", + "name": "disabled", + "optional": true, + "type": "boolean", + }, { "description": "An object containing all the localized strings required by the component: * \`removeFileAriaLabel\` (function): A function to render the ARIA label for file token remove button. diff --git a/src/file-input/__tests__/file-input.test.tsx b/src/file-input/__tests__/file-input.test.tsx index e32287d3a9..a245ce5afe 100644 --- a/src/file-input/__tests__/file-input.test.tsx +++ b/src/file-input/__tests__/file-input.test.tsx @@ -133,6 +133,21 @@ describe('FileInput input', () => { 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); }); + + test('disables the native input and the trigger button when `disabled` prop is assigned', () => { + const wrapper = render({ disabled: true }); + const input = wrapper.findNativeInput().getElement(); + + expect(input).toBeDisabled(); + expect(wrapper.findTrigger().isDisabled()).toBe(true); + }); + + test('shows the disabled upon focus when `disabledReason` prop is assigned', () => { + const wrapper = render({ disabled: true, disabledReason: 'Test disabled reason' }); + wrapper.findTrigger().focus(); + + expect(wrapper.findTrigger().findDisabledReason()!.getElement()).toHaveTextContent('Test disabled reason'); + }); }); describe('ref', () => { @@ -172,4 +187,36 @@ describe('a11y', () => { }); await expect(wrapper.getElement()).toValidateA11y(); }); + + describe('when disabled', () => { + test('decorative button is not focusable by keyboard', () => { + const wrapper = render({ disabled: true }); + + expect(wrapper.findTrigger().getElement()).toHaveAttribute('disabled'); + expect(wrapper.findTrigger().getElement()).toHaveAttribute('tabIndex', '-1'); + }); + + test('native input is not focusable', () => { + const wrapper = render({ disabled: true }); + wrapper.findNativeInput().focus(); + expect(wrapper.findNativeInput().getElement()).not.toHaveFocus(); + }); + }); + + describe('when disabled with disabledReason', () => { + test('decorative button is focusable by keyboard', () => { + const wrapper = render({ disabled: true, disabledReason: 'Test disabled reason' }); + wrapper.findTrigger().focus(); + expect(wrapper.findTrigger().getElement()).toHaveFocus(); + + expect(wrapper.findTrigger().getElement()).not.toHaveAttribute('disabled'); + expect(wrapper.findTrigger().getElement()).not.toHaveAttribute('tabIndex'); + }); + + test('native input is not focusable', () => { + const wrapper = render({ disabled: true, disabledReason: 'Test disabled reason' }); + wrapper.findNativeInput().focus(); + expect(wrapper.findNativeInput().getElement()).not.toHaveFocus(); + }); + }); }); diff --git a/src/file-input/interfaces.ts b/src/file-input/interfaces.ts index 76dc99c9fa..55fa6eb4a1 100644 --- a/src/file-input/interfaces.ts +++ b/src/file-input/interfaces.ts @@ -48,6 +48,17 @@ export interface FileInputProps extends BaseComponentProps, FormFieldCommonValid * If you want to clear the selection, use empty array. */ value: ReadonlyArray; + + /** + * Renders the file input as disabled and file selection. + */ + disabled?: boolean; + + /** + * Provides a reason why the file input is disabled (only when `disabled` is `true`). + * If provided, the file input becomes focusable. + */ + disabledReason?: string; } export namespace FileInputProps { diff --git a/src/file-input/internal.tsx b/src/file-input/internal.tsx index 2a6d07ffea..6a7c85290b 100644 --- a/src/file-input/internal.tsx +++ b/src/file-input/internal.tsx @@ -42,6 +42,8 @@ const InternalFileInput = React.forwardRef( onChange, variant = 'button', children, + disabled, + disabledReason, __internalRootRef = null, __inputClassName, __inputNativeAttributes, @@ -76,10 +78,12 @@ const InternalFileInput = React.forwardRef( checkControlled('FileInput', 'value', value, 'onChange', onChange); + const shouldDecorativeButtonGetFocus = disabled && Boolean(disabledReason); const nativeAttributes: React.HTMLAttributes = { 'aria-label': ariaLabel || children, 'aria-labelledby': joinStrings(formFieldContext.ariaLabelledby, uploadButtonLabelId), 'aria-describedby': formFieldContext.ariaDescribedby, + 'aria-hidden': shouldDecorativeButtonGetFocus, ...__inputNativeAttributes, }; if (formFieldContext.invalid) { @@ -137,6 +141,7 @@ const InternalFileInput = React.forwardRef( hidden={false} multiple={multiple} accept={accept} + disabled={disabled} onChange={onUploadInputChange} onFocus={onUploadInputFocus} onBlur={onUploadInputBlur} @@ -151,12 +156,15 @@ const InternalFileInput = React.forwardRef( iconName="upload" variant={variant === 'icon' ? 'icon' : undefined} formAction="none" + disabled={disabled} + ariaLabel={ariaLabel} + disabledReason={disabledReason} onClick={onUploadButtonClick} className={clsx(styles['file-input-button'], { [styles['force-focus-outline-button']]: isFocused && variant === 'button', [styles['force-focus-outline-icon']]: isFocused && variant === 'icon', })} - __nativeAttributes={{ tabIndex: -1, 'aria-hidden': true }} + __nativeAttributes={shouldDecorativeButtonGetFocus ? { tabIndex } : { tabIndex: -1, 'aria-hidden': true }} > {variant === 'button' && children} diff --git a/src/file-token-group/interfaces.ts b/src/file-token-group/interfaces.ts index 1541e224f3..3163d3ae8e 100644 --- a/src/file-token-group/interfaces.ts +++ b/src/file-token-group/interfaces.ts @@ -58,6 +58,11 @@ export interface FileTokenGroupProps extends BaseComponentProps { * user from modifying the value. A read-only control is still focusable. */ readOnly?: boolean; + /** + * Specifies if the control is disabled. + * This prevents the user from modifying the value. A disabled control is not focusable. + */ + disabled?: boolean; /** * An object containing all the localized strings required by the component: * * `removeFileAriaLabel` (function): A function to render the ARIA label for file token remove button.