diff --git a/pages/autosuggest/recovery-test.page.tsx b/pages/autosuggest/recovery-test.page.tsx index da7973a95a..5cab1e81ce 100644 --- a/pages/autosuggest/recovery-test.page.tsx +++ b/pages/autosuggest/recovery-test.page.tsx @@ -29,6 +29,7 @@ export default function () { enteredTextLabel={enteredTextLabel} expandToViewport={true} recoveryText="Try again" + onLoadItems={() => {}} /> diff --git a/src/autosuggest/__tests__/i18n.test.tsx b/src/autosuggest/__tests__/i18n.test.tsx new file mode 100644 index 0000000000..14688abbed --- /dev/null +++ b/src/autosuggest/__tests__/i18n.test.tsx @@ -0,0 +1,85 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; +import { render } from '@testing-library/react'; +import createWrapper from '../../../lib/components/test-utils/dom'; +import Autosuggest, { AutosuggestProps } from '../../../lib/components/autosuggest'; +import '../../__a11y__/to-validate-a11y'; +import statusIconStyles from '../../../lib/components/status-indicator/styles.selectors.js'; +import TestI18nProvider from '../../../lib/components/i18n/testing'; +import itemStyles from '../../../lib/components/internal/components/selectable-item/styles.css.js'; +import { KeyCode } from '@cloudscape-design/test-utils-core/utils'; + +const defaultOptions: AutosuggestProps.Options = [{ value: '1' }, { value: '2' }, { value: '3' }, { value: '4' }]; +const defaultProps: AutosuggestProps = { + value: '', + onChange: () => {}, + options: defaultOptions, +}; + +function renderElement(jsx: React.ReactElement) { + const { container, rerender } = render(jsx); + const wrapper = createWrapper(container).findAutosuggest()!; + return { container, wrapper, rerender }; +} + +describe('i18n provider', () => { + test('supports providing recoveryText', () => { + const { wrapper } = renderElement( + + {}} /> + + ); + wrapper.focus(); + expect(wrapper.findErrorRecoveryButton()!.getElement()).toHaveTextContent('Custom recovery text'); + }); + + test('do not render recovery button if no recovery callback was provided', () => { + const { wrapper } = renderElement( + + + + ); + wrapper.focus(); + expect(wrapper.findErrorRecoveryButton()).toBeNull(); + }); + + test('supports providing errorIconAriaLabel', () => { + const { wrapper } = renderElement( + + + + ); + wrapper.focus(); + const statusIcon = wrapper.findStatusIndicator()!.findByClassName(statusIconStyles.icon)!.getElement(); + expect(statusIcon).toHaveAttribute('aria-label', 'Custom error icon'); + }); + + test('supports providing enteredTextLabel', () => { + const { wrapper } = renderElement( + + + + ); + wrapper.setInputValue('S'); + expect(wrapper.findEnteredTextOption()!.getElement()).toHaveTextContent('Custom use value'); + }); + + test('supports providing selectedAriaLabel', () => { + const { wrapper } = renderElement( + + + + ); + wrapper.focus(); + wrapper.findNativeInput().keydown(KeyCode.down); + wrapper.findNativeInput().keydown(KeyCode.down); + expect( + wrapper + .findDropdown()! + .find('[data-test-index="1"]')! + .findByClassName(itemStyles['screenreader-content'])! + .getElement() + ).toHaveTextContent('Custom selected'); + }); +}); diff --git a/src/autosuggest/internal.tsx b/src/autosuggest/internal.tsx index f268687e20..11ccdc7c79 100644 --- a/src/autosuggest/internal.tsx +++ b/src/autosuggest/internal.tsx @@ -44,7 +44,6 @@ const InternalAutosuggest = React.forwardRef((props: InternalAutosuggestProps, r options, filteringType = 'auto', statusType = 'finished', - recoveryText, placeholder, clearAriaLabel, name, @@ -81,6 +80,7 @@ const InternalAutosuggest = React.forwardRef((props: InternalAutosuggestProps, r const i18n = useInternalI18n('autosuggest'); const errorIconAriaLabel = i18n('errorIconAriaLabel', restProps.errorIconAriaLabel); const selectedAriaLabel = i18n('selectedAriaLabel', restProps.selectedAriaLabel); + const recoveryText = i18n('recoveryText', restProps.recoveryText); if (!enteredTextLabel) { warnOnce('Autosuggest', 'A value for enteredTextLabel must be provided.'); @@ -179,6 +179,7 @@ const InternalAutosuggest = React.forwardRef((props: InternalAutosuggestProps, r errorIconAriaLabel, onRecoveryClick: handleRecoveryClick, filteringResultsText: filteredText, + hasRecoveryCallback: !!onLoadItems, }); const shouldRenderDropdownContent = !isEmpty || dropdownStatus.content; @@ -241,7 +242,7 @@ const InternalAutosuggest = React.forwardRef((props: InternalAutosuggestProps, r /> ) : null } - loopFocus={statusType === 'error' && !!recoveryText} + loopFocus={statusType === 'error' && !!recoveryText && !!onLoadItems} onCloseDropdown={handleCloseDropdown} onDelayedInput={handleDelayedInput} onPressArrowDown={handlePressArrowDown} diff --git a/src/internal/components/dropdown-status/__tests__/dropdown-status.test.tsx b/src/internal/components/dropdown-status/__tests__/dropdown-status.test.tsx index 9f7bb8b00f..3af7a3812c 100644 --- a/src/internal/components/dropdown-status/__tests__/dropdown-status.test.tsx +++ b/src/internal/components/dropdown-status/__tests__/dropdown-status.test.tsx @@ -38,13 +38,25 @@ describe('useDropdownStatus', () => { expect(getContent()).toBe('hello world'); }); - test('renders error indicator', () => { + test('renders error text', () => { const { getContent, getStickyState } = renderComponent({ statusType: 'error', errorText: 'we got a problem', recoveryText: 'do not worry', }); + expect(getStickyState()).toBe('true'); + expect(getContent()).toBe('we got a problem'); + }); + + test('renders error text with recovery button if has recovery callback', () => { + const { getContent, getStickyState } = renderComponent({ + statusType: 'error', + errorText: 'we got a problem', + recoveryText: 'do not worry', + hasRecoveryCallback: true, + }); + expect(getStickyState()).toBe('true'); expect(getContent()).toBe('we got a problem do not worry'); }); @@ -106,6 +118,7 @@ describe('useDropdownStatus', () => { errorText: 'we got a problem', recoveryText: 'do not worry', errorIconAriaLabel: 'error-icon', + hasRecoveryCallback: true, }); expect(getContent()).toBe('we got a problem do not worry'); @@ -139,6 +152,7 @@ describe('useDropdownStatus', () => { isFiltered: true, errorText: 'We got a problem', recoveryText: 'do not worry', + hasRecoveryCallback: true, }); expect(getContent()).toBe('We got a problem do not worry'); }); diff --git a/src/internal/components/dropdown-status/index.tsx b/src/internal/components/dropdown-status/index.tsx index 491de55ac6..d152448514 100644 --- a/src/internal/components/dropdown-status/index.tsx +++ b/src/internal/components/dropdown-status/index.tsx @@ -25,6 +25,11 @@ export interface DropdownStatusPropsExtended extends DropdownStatusProps { * to recover from the error. */ onRecoveryClick?: NonCancelableEventHandler; + /** + * Determines if retry button should be rendered + * in case recoveryText was automatically provided by i18n. + */ + hasRecoveryCallback?: boolean; } function DropdownStatus({ children }: { children: React.ReactNode }) { @@ -43,6 +48,7 @@ type UseDropdownStatus = ({ isNoMatch, isFiltered, noMatch, + hasRecoveryCallback, onRecoveryClick, }: DropdownStatusPropsExtended) => DropdownStatusResult; @@ -64,6 +70,7 @@ export const useDropdownStatus: UseDropdownStatus = ({ isFiltered, noMatch, onRecoveryClick, + hasRecoveryCallback = false, errorIconAriaLabel, }) => { const previousStatusType = usePrevious(statusType); @@ -82,7 +89,7 @@ export const useDropdownStatus: UseDropdownStatus = ({ > {errorText} {' '} - {recoveryText && ( + {!!recoveryText && hasRecoveryCallback && ( fireNonCancelableEvent(onRecoveryClick)} variant="recovery" diff --git a/src/multiselect/__tests__/i18n.test.tsx b/src/multiselect/__tests__/i18n.test.tsx new file mode 100644 index 0000000000..c6a4ae2755 --- /dev/null +++ b/src/multiselect/__tests__/i18n.test.tsx @@ -0,0 +1,85 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; +import { render } from '@testing-library/react'; +import createWrapper from '../../../lib/components/test-utils/dom'; +import Multiselect, { MultiselectProps } from '../../../lib/components/multiselect'; +import '../../__a11y__/to-validate-a11y'; +import TestI18nProvider from '../../../lib/components/i18n/testing'; +import statusIconStyles from '../../../lib/components/status-indicator/styles.selectors.js'; +import itemStyles from '../../../lib/components/internal/components/selectable-item/styles.selectors.js'; + +const defaultOptions: MultiselectProps.Options = [ + { label: 'First', value: '1' }, + { label: 'Second', value: '2' }, + { label: 'Third', value: '3', lang: 'es' }, + { label: 'Fourth', value: '4' }, +]; + +const defaultProps = { + options: defaultOptions, + selectedOptions: [], + onChange: () => {}, +}; + +function renderElement(jsx: React.ReactElement) { + const { container, rerender } = render(jsx); + const wrapper = createWrapper(container).findMultiselect()!; + return { container, wrapper, rerender }; +} +describe('i18n provider', () => { + test('supports providing deselectAriaLabel', () => { + const { wrapper } = renderElement( + + + + ); + expect(wrapper.findToken(1)!.findDismiss().getElement()).toHaveAttribute('aria-label', 'Custom deselect First'); + }); + + test('utilises recoveryText from Select messages', () => { + const { wrapper } = renderElement( + + {}} errorText="Error fetching items" statusType="error" /> + + ); + wrapper.openDropdown(); + expect(wrapper.findErrorRecoveryButton()!.getElement()).toHaveTextContent('Custom recovery text'); + }); + + test('do not render recovery button if no recovery callback was provided', () => { + const { wrapper } = renderElement( + + + + ); + wrapper.openDropdown(); + expect(wrapper.findErrorRecoveryButton()).toBeNull(); + }); + + test('utilises errorIconAriaLabel from Select messages', () => { + const { wrapper } = renderElement( + + + + ); + + wrapper.openDropdown(); + const statusIcon = wrapper.findStatusIndicator()!.findByClassName(statusIconStyles.icon)!.getElement(); + expect(statusIcon).toHaveAttribute('aria-label', 'Custom error icon'); + }); + + test('utilises selectedAriaLabel from Select messages', () => { + const { wrapper } = renderElement( + + + + ); + + wrapper.openDropdown(); + const selectedOption = wrapper.findDropdown()!.find('[data-test-index="1"]'); + expect(selectedOption!.findByClassName(itemStyles['screenreader-content'])!.getElement()).toHaveTextContent( + 'Custom selected First' + ); + }); +}); diff --git a/src/multiselect/__tests__/multiselect.test.tsx b/src/multiselect/__tests__/multiselect.test.tsx index b1250268e0..4aac38878c 100644 --- a/src/multiselect/__tests__/multiselect.test.tsx +++ b/src/multiselect/__tests__/multiselect.test.tsx @@ -8,7 +8,6 @@ import tokenGroupStyles from '../../../lib/components/token-group/styles.css.js' import selectPartsStyles from '../../../lib/components/select/parts/styles.css.js'; import '../../__a11y__/to-validate-a11y'; import statusIconStyles from '../../../lib/components/status-indicator/styles.selectors.js'; -import TestI18nProvider from '../../../lib/components/i18n/testing'; const defaultOptions: MultiselectProps.Options = [ { label: 'First', value: '1' }, @@ -498,14 +497,3 @@ test('Trigger receives focus when autofocus is true', () => { const { wrapper } = renderMultiselect(); expect(document.activeElement).toBe(wrapper.findTrigger().getElement()); }); - -describe('i18n', () => { - test('supports providing deselectAriaLabel from i18n provider', () => { - const { wrapper } = renderMultiselect( - - - - ); - expect(wrapper.findToken(1)!.findDismiss().getElement()).toHaveAttribute('aria-label', 'Custom deselect First'); - }); -}); diff --git a/src/multiselect/internal.tsx b/src/multiselect/internal.tsx index f709231551..e5522cb8c6 100644 --- a/src/multiselect/internal.tsx +++ b/src/multiselect/internal.tsx @@ -57,9 +57,7 @@ const InternalMultiselect = React.forwardRef( loadingText, finishedText, errorText, - recoveryText, noMatch, - selectedAriaLabel, renderHighlightedAriaLive, selectedOptions = [], deselectAriaLabel, @@ -85,6 +83,11 @@ const InternalMultiselect = React.forwardRef( const formFieldContext = useFormFieldContext(restProps); const i18n = useInternalI18n('multiselect'); + const i18nCommon = useInternalI18n('select'); + const recoveryText = i18nCommon('recoveryText', restProps.recoveryText); + const errorIconAriaLabel = i18nCommon('errorIconAriaLabel', restProps.errorIconAriaLabel); + const selectedAriaLabel = i18nCommon('selectedAriaLabel', restProps.selectedAriaLabel); + const { handleLoadMore, handleRecoveryClick, fireLoadItems } = useLoadItems({ onLoadItems, options, @@ -199,7 +202,8 @@ const InternalMultiselect = React.forwardRef( isFiltered, filteringResultsText: filteredText, onRecoveryClick: handleRecoveryClick, - errorIconAriaLabel: restProps.errorIconAriaLabel, + errorIconAriaLabel: errorIconAriaLabel, + hasRecoveryCallback: !!onLoadItems, }); const filter = ( diff --git a/src/property-filter/property-filter-autosuggest.tsx b/src/property-filter/property-filter-autosuggest.tsx index c4040cdc06..3ab84fa067 100644 --- a/src/property-filter/property-filter-autosuggest.tsx +++ b/src/property-filter/property-filter-autosuggest.tsx @@ -157,6 +157,7 @@ const PropertyFilterAutosuggest = React.forwardRef( ...props, isEmpty, onRecoveryClick: handleRecoveryClick, + hasRecoveryCallback: !!onLoadItems, }); let content = null; diff --git a/src/select/__tests__/i18n.test.tsx b/src/select/__tests__/i18n.test.tsx new file mode 100644 index 0000000000..1526d6d27b --- /dev/null +++ b/src/select/__tests__/i18n.test.tsx @@ -0,0 +1,87 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; +import { render } from '@testing-library/react'; +import createWrapper from '../../../lib/components/test-utils/dom'; +import Select, { SelectProps } from '../../../lib/components/select'; +import '../../__a11y__/to-validate-a11y'; +import statusIconStyles from '../../../lib/components/status-indicator/styles.selectors.js'; +import TestI18nProvider from '../../../lib/components/i18n/testing'; +import itemStyles from '../../../lib/components/internal/components/selectable-item/styles.selectors.js'; + +const defaultOptions: SelectProps.Options = [ + { label: 'First', value: '1' }, + { label: 'Second', value: '2' }, + { + label: 'Group', + options: [ + { + label: 'Third', + value: '3', + lang: 'de', + }, + { + label: 'Forth', + value: '4', + }, + ], + }, +]; + +const defaultProps = { + options: defaultOptions, + selectedOption: null, + onChange: () => {}, +}; + +function renderElement(jsx: React.ReactElement) { + const { container, rerender } = render(jsx); + const wrapper = createWrapper(container).findSelect()!; + return { container, wrapper, rerender }; +} + +describe('i18n provider', () => { + test('supports providing recoveryText', () => { + const { wrapper } = renderElement( + + + + ); + wrapper.openDropdown(); + expect(wrapper.findErrorRecoveryButton()).toBeNull(); + }); + + test('supports providing errorIconAriaLabel', () => { + const { wrapper } = renderElement( + + + + ); + wrapper.openDropdown(); + const selectedOption = wrapper.findDropdown()!.find('[data-test-index="1"]'); + expect(selectedOption!.findByClassName(itemStyles['screenreader-content'])!.getElement()).toHaveTextContent( + 'Custom selected' + ); + }); +}); diff --git a/src/select/internal.tsx b/src/select/internal.tsx index 3adbd26d61..83b2485222 100644 --- a/src/select/internal.tsx +++ b/src/select/internal.tsx @@ -54,7 +54,6 @@ const InternalSelect = React.forwardRef( loadingText, finishedText, errorText, - recoveryText, noMatch, triggerVariant = 'label', renderHighlightedAriaLive, @@ -78,6 +77,7 @@ const InternalSelect = React.forwardRef( const i18n = useInternalI18n('select'); const errorIconAriaLabel = i18n('errorIconAriaLabel', restProps.errorIconAriaLabel); const selectedAriaLabel = i18n('selectedAriaLabel', restProps.selectedAriaLabel); + const recoveryText = i18n('recoveryText', restProps.recoveryText); const { handleLoadMore, handleRecoveryClick, fireLoadItems } = useLoadItems({ onLoadItems, @@ -192,6 +192,7 @@ const InternalSelect = React.forwardRef( filteringResultsText: filteredText, errorIconAriaLabel, onRecoveryClick: handleRecoveryClick, + hasRecoveryCallback: !!onLoadItems, }); const menuProps = {