diff --git a/src/components/Dropdown/Dropdown.test.tsx b/src/components/Dropdown/Dropdown.test.tsx index 563fe4fd9..910189657 100644 --- a/src/components/Dropdown/Dropdown.test.tsx +++ b/src/components/Dropdown/Dropdown.test.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useRef, useState } from 'react'; import Enzyme from 'enzyme'; import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; import MatchMediaMock from 'jest-matchmedia-mock'; @@ -9,8 +9,10 @@ import { ButtonWidth, DefaultButton, } from '../Button'; -import { IconName } from '../Icon'; +import { Icon, IconName } from '../Icon'; import { List } from '../List'; +import { Stack } from '../Stack'; +import { TextInput } from '../Inputs'; import { render, screen, waitFor } from '@testing-library/react'; Enzyme.configure({ adapter: new Adapter() }); @@ -46,26 +48,26 @@ const Overlay = () => ( /> ); +const dropdownProps: object = { + trigger: 'click', + classNames: 'my-dropdown-class', + style: {}, + dropdownClassNames: 'my-dropdown-class', + dropdownStyle: { + color: 'red', + }, + placement: 'bottom-start', + overlay: Overlay(), + offset: 0, + positionStrategy: 'absolute', + disabled: false, + closeOnDropdownClick: true, + portal: false, +}; + const DropdownComponent = (): JSX.Element => { const [visible, setVisibility] = useState(false); - const dropdownProps: object = { - trigger: 'click', - classNames: 'my-dropdown-class', - style: {}, - dropdownClassNames: 'my-dropdown-class', - dropdownStyle: { - color: 'red', - }, - placement: 'bottom-start', - overlay: Overlay(), - offset: 0, - positionStrategy: 'absolute', - disabled: false, - closeOnDropdownClick: true, - portal: false, - }; - return ( { ); }; +const ComplexDropdownComponent = (): JSX.Element => { + const inputRef: React.MutableRefObject = + useRef(null); + const [visible, setVisibility] = useState(false); + + return ( + setVisibility(isVisible)} + > +
+ + + + +
+
+ ); +}; + describe('Dropdown', () => { beforeAll(() => { matchMedia = new MatchMediaMock(); @@ -132,4 +164,21 @@ describe('Dropdown', () => { const dropdownButton = screen.getByRole('button'); expect(dropdownButton.id).toBe('test-button-id'); }); + + test('Should support ariaRef prop for complex dropdown references', async () => { + const { container } = render(); + const dropdownAriaRef = screen.getByTestId('test-input-id'); + expect(dropdownAriaRef.getAttribute('aria-controls')).toBeTruthy(); + expect(dropdownAriaRef.getAttribute('aria-expanded')).toBe('false'); + expect(dropdownAriaRef.getAttribute('aria-haspopup')).toBe('true'); + expect(dropdownAriaRef.getAttribute('role')).toBe('combobox'); + dropdownAriaRef.click(); + await waitFor(() => screen.getByText('User profile 1')); + const option1 = screen.getByText('User profile 1'); + expect(option1).toBeTruthy(); + expect(container.querySelector('.dropdown-wrapper')?.classList).toContain( + 'my-dropdown-class' + ); + expect(dropdownAriaRef.getAttribute('aria-expanded')).toBe('true'); + }); }); diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx index cd16629b4..efa8b2073 100644 --- a/src/components/Dropdown/Dropdown.tsx +++ b/src/components/Dropdown/Dropdown.tsx @@ -40,6 +40,7 @@ export const Dropdown: FC = React.memo( React.forwardRef( ( { + ariaRef, children, classNames, closeOnDropdownClick = true, @@ -243,6 +244,27 @@ export const Dropdown: FC = React.memo( if (child.props.id && dropdownReferenceId !== child.props.id) { setReferenceElementId(child.props.id); } + // If there's an ariaRef, apply the a11y attributes to it, rather than the immediate child. + if (ariaRef && ariaRef.current) { + ariaRef.current.setAttribute('aria-controls', dropdownId); + ariaRef.current.setAttribute('aria-expanded', `${mergedVisible}`); + ariaRef.current.setAttribute('aria-haspopup', 'true'); + + if (!ariaRef.current.hasAttribute('role')) { + ariaRef.current.setAttribute('role', 'button'); + } + + return cloneElement(child, { + ...{ + [TRIGGER_TO_HANDLER_MAP_ON_ENTER[trigger]]: toggle(true), + }, + id: dropdownReferenceId, + onClick: handleReferenceClick, + onKeyDown: handleReferenceKeyDown, + className: referenceWrapperClassNames, + }); + } + return cloneElement(child, { ...{ [TRIGGER_TO_HANDLER_MAP_ON_ENTER[trigger]]: toggle(true), diff --git a/src/components/Dropdown/Dropdown.types.ts b/src/components/Dropdown/Dropdown.types.ts index 276f2e28f..74cf16c91 100644 --- a/src/components/Dropdown/Dropdown.types.ts +++ b/src/components/Dropdown/Dropdown.types.ts @@ -16,6 +16,12 @@ export const TRIGGER_TO_HANDLER_MAP_ON_LEAVE = { }; export interface DropdownProps { + /** + * The ref of element that should implement the following props: + * 'aria-controls', 'aria-expanded', 'aria-haspopup', 'role' + * @default child + */ + ariaRef?: React.MutableRefObject; /** * Class names of the main wrapper */ diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index 50a5f312b..e0b4dde59 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -722,6 +722,7 @@ export const Select: FC = React.forwardRef( {/* When Dropdown is hidden, place Pills outside the reference element */} {!dropdownVisible && showPills() ? getPills() : null} = React.forwardRef( onVisibleChange={(isVisible) => setDropdownVisibility(isVisible)} overlay={isLoading ? spinner : } showDropdown={showDropdown} - tabIndex={-1} // Defer focus to the TextInput visible={ dropdownVisible && (showEmptyDropdown || diff --git a/src/components/Select/__snapshots__/Select.test.tsx.snap b/src/components/Select/__snapshots__/Select.test.tsx.snap index 346ddc830..0e5699393 100644 --- a/src/components/Select/__snapshots__/Select.test.tsx.snap +++ b/src/components/Select/__snapshots__/Select.test.tsx.snap @@ -9,13 +9,8 @@ exports[`Select Renders with default value 1`] = ` class="select-dropdown-main-wrapper main-wrapper" >