diff --git a/.changeset/poor-walls-help.md b/.changeset/poor-walls-help.md new file mode 100644 index 0000000000..e5f9b4c37c --- /dev/null +++ b/.changeset/poor-walls-help.md @@ -0,0 +1,5 @@ +--- +'react-select': patch +--- + +Optional props to enable clear indicator to be keyboard accessible diff --git a/packages/react-select/src/Select.tsx b/packages/react-select/src/Select.tsx index 798f6126b2..85cbff4aef 100644 --- a/packages/react-select/src/Select.tsx +++ b/packages/react-select/src/Select.tsx @@ -176,6 +176,8 @@ export interface Props< instanceId?: number | string; /** Is the select value clearable */ isClearable?: boolean; + /** enabled clear indicator to accessible via keyboard and screen-reader */ + enableAccessibleClearIndicator?: boolean; /** Is the select disabled */ isDisabled: boolean; /** Is the select in a state of loading (async) */ @@ -734,7 +736,6 @@ export default class Select< `${instancePrefix}-option` ) : []; - const focusedValue = clearFocusValueOnUpdate ? getNextFocusedValue(state, selectValue) : null; @@ -743,7 +744,6 @@ export default class Select< focusableOptionsWithIds, focusedOption ); - newMenuOptionsState = { selectValue, focusedOption, @@ -764,7 +764,8 @@ export default class Select< let newAriaSelection = ariaSelection; - let hasKeptFocus = isFocused && prevWasFocused; + let hasKeptFocus = + isFocused && (prevWasFocused || ariaSelection?.action === 'clear'); if (isFocused && !hasKeptFocus) { // If `value` or `defaultValue` props are not empty then announce them @@ -1860,7 +1861,8 @@ export default class Select< renderClearIndicator() { const { ClearIndicator } = this.getComponents(); const { commonProps } = this; - const { isDisabled, isLoading } = this.props; + const { isDisabled, isLoading, enableAccessibleClearIndicator } = + this.props; const { isFocused } = this.state; if ( @@ -1884,6 +1886,12 @@ export default class Select< {...commonProps} innerProps={innerProps} isFocused={isFocused} + enableAccessibleClearIndicator={enableAccessibleClearIndicator} + handleClearingValue={() => { + this.openAfterFocus = false; + this.focusInput(); + this.clearValue(); + }} /> ); } diff --git a/packages/react-select/src/__tests__/Select.test.tsx b/packages/react-select/src/__tests__/Select.test.tsx index 740935512a..0e640944f2 100644 --- a/packages/react-select/src/__tests__/Select.test.tsx +++ b/packages/react-select/src/__tests__/Select.test.tsx @@ -2488,7 +2488,6 @@ test('accessibility > A11yTexts can be provided through ariaLiveMessages prop', keyCode: 13, key: 'Enter', }); - expect(container.querySelector(liveRegionEventId)!.textContent).toMatch( 'CUSTOM: option 0 is selected.' ); @@ -2514,22 +2513,38 @@ test('accessibility > announces already selected values when focused', () => { ); }); -test('accessibility > announces cleared values', () => { - let { container } = render( - - ); - const liveRegionSelectionId = '#aria-selection'; - /** - * announce deselected value - */ - fireEvent.focus(container.querySelector('input.react-select__input')!); - fireEvent.mouseDown( - container.querySelector('.react-select__clear-indicator')! - ); - expect(container.querySelector(liveRegionSelectionId)!.textContent).toMatch( - 'All selected options have been cleared.' - ); -}); +cases( + 'accessibility > announces cleared values', + ({ props = BASIC_PROPS }) => { + let { container } = render( + + ); + const liveRegionSelectionId = '#aria-selection'; + /** + * announce deselected value + */ + fireEvent.focus(container.querySelector('input.react-select__input')!); + fireEvent.mouseDown( + container.querySelector('.react-select__clear-indicator')! + ); + expect(container.querySelector(liveRegionSelectionId)!.textContent).toMatch( + 'All selected options have been cleared.' + ); + }, + { + 'mouse only clear indicator': { + props: { + ...BASIC_PROPS, + isClearable: true, + }, + }, + 'clear indicator button': { + ...BASIC_PROPS, + isClearable: true, + enableAccessibleClearIndicator: true, + }, + } +); test('closeMenuOnSelect prop > when passed as false it should not call onMenuClose on selecting option', () => { let onMenuCloseSpy = jest.fn(); @@ -2926,48 +2941,81 @@ cases( } ); -test('clear select by clicking on clear button > should not call onMenuOpen', () => { - let onChangeSpy = jest.fn(); - let props = { ...BASIC_PROPS, onChange: onChangeSpy }; - let { container } = render( - - ); +cases( + 'clear select by clicking on clear button > should not call onMenuOpen', + ({ props = BASIC_PROPS }) => { + let onChangeSpy = jest.fn(); + let { container } = render( + + ); - expect(container.querySelectorAll('.react-select__multi-value').length).toBe( - 1 - ); - fireEvent.mouseDown( - container.querySelector('.react-select__clear-indicator')!, - { button: 0 } - ); - expect(onChangeSpy).toBeCalledWith([], { - action: 'clear', - name: BASIC_PROPS.name, - removedValues: [{ label: '0', value: 'zero' }], - }); -}); + expect( + container.querySelectorAll('.react-select__multi-value').length + ).toBe(1); + fireEvent.mouseDown( + container.querySelector('.react-select__clear-indicator')!, + { button: 0 } + ); + expect(onChangeSpy).toBeCalledWith([], { + action: 'clear', + name: BASIC_PROPS.name, + removedValues: [{ label: '0', value: 'zero' }], + }); + }, + { + 'mouse only clear indicator': { + props: { + ...BASIC_PROPS, + isClearable: true, + }, + }, + 'clear indicator button': { + ...BASIC_PROPS, + isClearable: true, + enableAccessibleClearIndicator: true, + }, + } +); -test('clearing select using clear button to not call onMenuOpen or onMenuClose', () => { - let onMenuCloseSpy = jest.fn(); - let onMenuOpenSpy = jest.fn(); - let props = { - ...BASIC_PROPS, - onMenuClose: onMenuCloseSpy, - onMenuOpen: onMenuOpenSpy, - }; - let { container } = render( - - ); - expect(container.querySelectorAll('.react-select__multi-value').length).toBe( - 1 - ); - fireEvent.mouseDown( - container.querySelector('.react-select__clear-indicator')!, - { button: 0 } - ); - expect(onMenuOpenSpy).not.toHaveBeenCalled(); - expect(onMenuCloseSpy).not.toHaveBeenCalled(); -}); +cases( + 'clearing select using clear button to not call onMenuOpen or onMenuClose', + ({ props = BASIC_PROPS }) => { + let onMenuCloseSpy = jest.fn(); + let onMenuOpenSpy = jest.fn(); + + let { container } = render( + + ); + expect( + container.querySelectorAll('.react-select__multi-value').length + ).toBe(1); + fireEvent.mouseDown( + container.querySelector('.react-select__clear-indicator')!, + { button: 0 } + ); + expect(onMenuOpenSpy).not.toHaveBeenCalled(); + expect(onMenuCloseSpy).not.toHaveBeenCalled(); + }, + { + 'mouse only clear indicator': { + props: { + ...BASIC_PROPS, + isClearable: true, + }, + }, + 'clear indicator button': { + ...BASIC_PROPS, + isClearable: true, + enableAccessibleClearIndicator: true, + }, + } +); test('multi select > calls onChange when option is selected and isSearchable is false', () => { let onChangeSpy = jest.fn(); @@ -3154,20 +3202,34 @@ test('hitting spacebar should select option if isSearchable is false', () => { ); }); -test('hitting escape does not call onChange if menu is Open', () => { - let onChangeSpy = jest.fn(); - let props = { ...BASIC_PROPS, onChange: onChangeSpy }; - let { container } = render( - - ); +cases( + 'hitting escape does not call onChange if menu is Open', + ({ props = BASIC_PROPS }) => { + let onChangeSpy = jest.fn(); + let finalProps = { ...props, onChange: onChangeSpy }; + let { container } = render( + + ); - // focus the first option - fireEvent.keyDown(container.querySelector('.react-select__menu')!, { - keyCode: 40, - key: 'ArrowDown', - }); - expect(onChangeSpy).not.toHaveBeenCalled(); -}); + // focus the first option + fireEvent.keyDown(container.querySelector('.react-select__menu')!, { + keyCode: 40, + key: 'ArrowDown', + }); + expect(onChangeSpy).not.toHaveBeenCalled(); + }, + { + 'mouse only clear indicator': { + props: { + ...BASIC_PROPS, + }, + }, + 'clear indicator button': { + ...BASIC_PROPS, + enableAccessibleClearIndicator: true, + }, + } +); test('multi select > removes the selected option from the menu options when isSearchable is false', () => { let { container, rerender } = render( @@ -3210,38 +3272,53 @@ test('hitting ArrowUp key on closed select should focus last element', () => { ).toEqual('16'); }); -test('close menu on hitting escape and clear input value if menu is open even if escapeClearsValue and isClearable are true', () => { - let onMenuCloseSpy = jest.fn(); - let onInputChangeSpy = jest.fn(); - let props = { - ...BASIC_PROPS, - onInputChange: onInputChangeSpy, - onMenuClose: onMenuCloseSpy, - value: OPTIONS[0], - }; - let { container } = render( - - ); - fireEvent.keyDown(container.querySelector('.react-select')!, { - keyCode: 27, - key: 'Escape', - }); - expect( - container.querySelector('.react-select__single-value')!.textContent - ).toEqual('0'); +cases( + 'close menu on hitting escape and clear input value if menu is open even if escapeClearsValue and isClearable are true', + ({ props = BASIC_PROPS }) => { + let onMenuCloseSpy = jest.fn(); + let onInputChangeSpy = jest.fn(); - expect(onMenuCloseSpy).toHaveBeenCalled(); - // once by onMenuClose and other is direct - expect(onInputChangeSpy).toHaveBeenCalledTimes(2); - expect(onInputChangeSpy).toHaveBeenCalledWith('', { - action: 'menu-close', - prevInputValue: '', - }); - expect(onInputChangeSpy).toHaveBeenLastCalledWith('', { - action: 'menu-close', - prevInputValue: '', - }); -}); + let finalProps = { + ...props, + onInputChange: onInputChangeSpy, + onMenuClose: onMenuCloseSpy, + value: OPTIONS[0], + }; + let { container } = render( + + ); + fireEvent.keyDown(container.querySelector('.react-select')!, { + keyCode: 27, + key: 'Escape', + }); + expect( + container.querySelector('.react-select__single-value')!.textContent + ).toEqual('0'); + + expect(onMenuCloseSpy).toHaveBeenCalled(); + // once by onMenuClose and other is direct + expect(onInputChangeSpy).toHaveBeenCalledTimes(2); + expect(onInputChangeSpy).toHaveBeenCalledWith('', { + action: 'menu-close', + prevInputValue: '', + }); + expect(onInputChangeSpy).toHaveBeenLastCalledWith('', { + action: 'menu-close', + prevInputValue: '', + }); + }, + { + 'mouse only clear indicator': { + props: { + ...BASIC_PROPS, + }, + }, + 'clear indicator button': { + ...BASIC_PROPS, + enableAccessibleClearIndicator: true, + }, + } +); test('to not clear value when hitting escape if escapeClearsValue is false (default) and isClearable is false', () => { let onChangeSpy = jest.fn(); @@ -3271,35 +3348,67 @@ test('to not clear value when hitting escape if escapeClearsValue is true and is expect(onChangeSpy).not.toHaveBeenCalled(); }); -test('to not clear value when hitting escape if escapeClearsValue is false (default) and isClearable is true', () => { - let onChangeSpy = jest.fn(); - let props = { ...BASIC_PROPS, onChange: onChangeSpy, value: OPTIONS[0] }; - let { container } = render(); +cases( + 'to not clear value when hitting escape if escapeClearsValue is false (default) and isClearable is true', + ({ props = BASIC_PROPS }) => { + let onChangeSpy = jest.fn(); + let finalProps = { ...props, onChange: onChangeSpy, value: OPTIONS[0] }; + let { container } = render(); - fireEvent.keyDown(container.querySelector('.react-select')!, { - keyCode: 27, - key: 'Escape', - }); - expect(onChangeSpy).not.toHaveBeenCalled(); -}); + fireEvent.keyDown(container.querySelector('.react-select')!, { + keyCode: 27, + key: 'Escape', + }); + expect(onChangeSpy).not.toHaveBeenCalled(); + }, + { + 'mouse only clear indicator': { + props: { + ...BASIC_PROPS, + }, + }, + 'clear indicator button': { + ...BASIC_PROPS, + enableAccessibleClearIndicator: true, + }, + } +); -test('to clear value when hitting escape if escapeClearsValue and isClearable are true', () => { - let onInputChangeSpy = jest.fn(); - let props = { ...BASIC_PROPS, onChange: onInputChangeSpy, value: OPTIONS[0] }; - let { container } = render( - - ); +cases( + 'to clear value when hitting escape if escapeClearsValue and isClearable are true', + ({ props = BASIC_PROPS }) => { + let onInputChangeSpy = jest.fn(); + let finalProps = { + ...props, + onChange: onInputChangeSpy, + value: OPTIONS[0], + }; + let { container } = render( + + ); - fireEvent.keyDown(container.querySelector('.react-select')!, { - keyCode: 27, - key: 'Escape', - }); - expect(onInputChangeSpy).toHaveBeenCalledWith(null, { - action: 'clear', - name: BASIC_PROPS.name, - removedValues: [{ label: '0', value: 'zero' }], - }); -}); + fireEvent.keyDown(container.querySelector('.react-select')!, { + keyCode: 27, + key: 'Escape', + }); + expect(onInputChangeSpy).toHaveBeenCalledWith(null, { + action: 'clear', + name: BASIC_PROPS.name, + removedValues: [{ label: '0', value: 'zero' }], + }); + }, + { + 'mouse only clear indicator': { + props: { + ...BASIC_PROPS, + }, + }, + 'clear indicator button': { + ...BASIC_PROPS, + enableAccessibleClearIndicator: true, + }, + } +); test('hitting spacebar should not select option if isSearchable is true (default)', () => { let onChangeSpy = jest.fn(); @@ -3376,3 +3485,43 @@ cases( }, } ); + +test('enableAccessibleClearIndicator is false > render non-interactive clear indicator', () => { + let props = { + ...BASIC_PROPS, + value: OPTIONS[0], + }; + let { container } = render(); + expect( + container.querySelector('div.react-select__clear-indicator') + ).toBeVisible(); +}); + +test('enableAccessibleClearIndicator is true > clear indicator is focusable and clear value', async () => { + let onChangeSpy = jest.fn(); + let props = { + ...BASIC_PROPS, + value: OPTIONS[0], + onChange: onChangeSpy, + }; + let { container } = render( + + ); + expect( + container.querySelector('button.react-select__clear-indicator')! + ).toBeVisible(); + + userEvent.click(container.querySelector('.react-select__input')!); + userEvent.tab(); + expect( + container.querySelector('button.react-select__clear-indicator')! + ).toHaveFocus(); + + fireEvent.click(container.querySelector('.react-select__clear-indicator')!); + + expect(onChangeSpy).toHaveBeenCalledWith(null, { + action: 'clear', + name: BASIC_PROPS.name, + removedValues: [{ label: '0', value: 'zero' }], + }); +}); diff --git a/packages/react-select/src/__tests__/__snapshots__/Async.test.tsx.snap b/packages/react-select/src/__tests__/__snapshots__/Async.test.tsx.snap index b304477288..06995bb046 100644 --- a/packages/react-select/src/__tests__/__snapshots__/Async.test.tsx.snap +++ b/packages/react-select/src/__tests__/__snapshots__/Async.test.tsx.snap @@ -146,6 +146,8 @@ exports[`defaults - snapshot 1`] = ` transition: color 150ms; color: hsl(0, 0%, 80%); padding: 8px; + border: none; + background: none; box-sizing: border-box; } @@ -153,6 +155,12 @@ exports[`defaults - snapshot 1`] = ` color: hsl(0, 0%, 60%); } +.emotion-9:focus { + border-color: #2684FF; + outline: unset; + box-shadow: 0 0 0 2px #2684FF inset; +} + .emotion-10 { display: inline-block; fill: currentColor; diff --git a/packages/react-select/src/__tests__/__snapshots__/AsyncCreatable.test.tsx.snap b/packages/react-select/src/__tests__/__snapshots__/AsyncCreatable.test.tsx.snap index b304477288..06995bb046 100644 --- a/packages/react-select/src/__tests__/__snapshots__/AsyncCreatable.test.tsx.snap +++ b/packages/react-select/src/__tests__/__snapshots__/AsyncCreatable.test.tsx.snap @@ -146,6 +146,8 @@ exports[`defaults - snapshot 1`] = ` transition: color 150ms; color: hsl(0, 0%, 80%); padding: 8px; + border: none; + background: none; box-sizing: border-box; } @@ -153,6 +155,12 @@ exports[`defaults - snapshot 1`] = ` color: hsl(0, 0%, 60%); } +.emotion-9:focus { + border-color: #2684FF; + outline: unset; + box-shadow: 0 0 0 2px #2684FF inset; +} + .emotion-10 { display: inline-block; fill: currentColor; diff --git a/packages/react-select/src/__tests__/__snapshots__/Creatable.test.tsx.snap b/packages/react-select/src/__tests__/__snapshots__/Creatable.test.tsx.snap index b304477288..06995bb046 100644 --- a/packages/react-select/src/__tests__/__snapshots__/Creatable.test.tsx.snap +++ b/packages/react-select/src/__tests__/__snapshots__/Creatable.test.tsx.snap @@ -146,6 +146,8 @@ exports[`defaults - snapshot 1`] = ` transition: color 150ms; color: hsl(0, 0%, 80%); padding: 8px; + border: none; + background: none; box-sizing: border-box; } @@ -153,6 +155,12 @@ exports[`defaults - snapshot 1`] = ` color: hsl(0, 0%, 60%); } +.emotion-9:focus { + border-color: #2684FF; + outline: unset; + box-shadow: 0 0 0 2px #2684FF inset; +} + .emotion-10 { display: inline-block; fill: currentColor; diff --git a/packages/react-select/src/__tests__/__snapshots__/Select.test.tsx.snap b/packages/react-select/src/__tests__/__snapshots__/Select.test.tsx.snap index 95d4fe295d..c980dcf972 100644 --- a/packages/react-select/src/__tests__/__snapshots__/Select.test.tsx.snap +++ b/packages/react-select/src/__tests__/__snapshots__/Select.test.tsx.snap @@ -146,6 +146,8 @@ exports[`snapshot - defaults 1`] = ` transition: color 150ms; color: hsl(0, 0%, 80%); padding: 8px; + border: none; + background: none; box-sizing: border-box; } @@ -153,6 +155,12 @@ exports[`snapshot - defaults 1`] = ` color: hsl(0, 0%, 60%); } +.emotion-9:focus { + border-color: #2684FF; + outline: unset; + box-shadow: 0 0 0 2px #2684FF inset; +} + .emotion-10 { display: inline-block; fill: currentColor; diff --git a/packages/react-select/src/__tests__/__snapshots__/StateManaged.test.tsx.snap b/packages/react-select/src/__tests__/__snapshots__/StateManaged.test.tsx.snap index 66836e4a95..135969b70c 100644 --- a/packages/react-select/src/__tests__/__snapshots__/StateManaged.test.tsx.snap +++ b/packages/react-select/src/__tests__/__snapshots__/StateManaged.test.tsx.snap @@ -146,6 +146,8 @@ exports[`defaults > snapshot 1`] = ` transition: color 150ms; color: hsl(0, 0%, 80%); padding: 8px; + border: none; + background: none; box-sizing: border-box; } @@ -153,6 +155,12 @@ exports[`defaults > snapshot 1`] = ` color: hsl(0, 0%, 60%); } +.emotion-9:focus { + border-color: #2684FF; + outline: unset; + box-shadow: 0 0 0 2px #2684FF inset; +} + .emotion-10 { display: inline-block; fill: currentColor; diff --git a/packages/react-select/src/components/LiveRegion.tsx b/packages/react-select/src/components/LiveRegion.tsx index 463fe01fc5..c7bb0b7cfe 100644 --- a/packages/react-select/src/components/LiveRegion.tsx +++ b/packages/react-select/src/components/LiveRegion.tsx @@ -204,7 +204,6 @@ const LiveRegion = < {ariaGuidance} ); - return ( {/* We use 'aria-describedby' linked to this component for the initial focus */} diff --git a/packages/react-select/src/components/indicators.tsx b/packages/react-select/src/components/indicators.tsx index 51a2ecec28..6937691d9a 100644 --- a/packages/react-select/src/components/indicators.tsx +++ b/packages/react-select/src/components/indicators.tsx @@ -92,10 +92,18 @@ const baseCSS = < ':hover': { color: isFocused ? colors.neutral80 : colors.neutral40, }, + border: 'none', + background: 'none', + ':focus': { + borderColor: !isFocused ? colors.primary : 'none', + outline: 'unset', + boxShadow: !isFocused ? `0 0 0 2px ${colors.primary} inset` : 'unset', + }, }), }); export const dropdownIndicatorCSS = baseCSS; + export const DropdownIndicator = < Option, IsMulti extends boolean, @@ -128,6 +136,10 @@ export interface ClearIndicatorProps< innerProps: JSX.IntrinsicElements['div']; /** The focused state of the select. */ isFocused: boolean; + /** Handle clearing the value and focus*/ + handleClearingValue: () => void; + /** Enabled clear indictor to be acessible via keyboard and screen-reader */ + enableAccessibleClearIndicator?: boolean; } export const clearIndicatorCSS = baseCSS; @@ -138,15 +150,37 @@ export const ClearIndicator = < >( props: ClearIndicatorProps ) => { - const { children, innerProps } = props; + const { + children, + innerProps, + enableAccessibleClearIndicator, + handleClearingValue: onClearValue, + } = props; + + const clearIndicatorStyle = getStyleProps(props, 'clearIndicator', { + indicator: true, + 'clear-indicator': true, + }); + + if (enableAccessibleClearIndicator) { + return ( + + { + e.preventDefault(); + onClearValue(); + }} + > + {children || } + + + ); + } + return ( - + {children || } );