diff --git a/src/components/Accordion/__snapshots__/Accordion.test.tsx.snap b/src/components/Accordion/__snapshots__/Accordion.test.tsx.snap index 5b9a66fbb..a8c075b75 100644 --- a/src/components/Accordion/__snapshots__/Accordion.test.tsx.snap +++ b/src/components/Accordion/__snapshots__/Accordion.test.tsx.snap @@ -564,6 +564,8 @@ exports[`Accordion Accordion renders custom content and its buttons are clickabl >
  • + ); + const { getByTestId } = render( + additionalItem} + /> + ); + const item5 = getByTestId('User5').closest('li'); + const additionalItemElement = getByTestId('additional-item').closest('li'); + item5.focus(); + expect(item5).toHaveFocus(); + fireEvent.keyDown(item5, { key: 'ArrowDown' }); + expect(additionalItemElement).toHaveFocus(); + }); }); diff --git a/src/components/List/List.tsx b/src/components/List/List.tsx index b6e87ddb9..88942f2fc 100644 --- a/src/components/List/List.tsx +++ b/src/components/List/List.tsx @@ -8,7 +8,7 @@ import { eventKeys, focusable, mergeClasses, - SELECTORS, + interactWithFirstInteractiveElement, } from '../../shared/utilities'; import styles from './list.module.scss'; @@ -45,7 +45,7 @@ export const List = ({ listClassNames, { [styles.vertical]: layout === 'vertical' }, ]); - const [focusIndex, setFocusIndex] = useState(null); + const [focusIndex, setFocusIndex] = useState(0); const itemRefs: React.MutableRefObject = useRef< (HTMLElement | null)[] >([]); @@ -74,6 +74,15 @@ export const List = ({ const arrowIncrement: boolean = htmlDir === 'rtl' ? arrowLeft : arrowRight; const end: boolean = event?.key === eventKeys.END; const home: boolean = event?.key === eventKeys.HOME; + const enter: boolean = event?.key === eventKeys.ENTER; + if (enter) { + event?.preventDefault(); + const li = itemRefs?.current?.[index]; + if (li) { + interactWithFirstInteractiveElement(li); + } + return; + } // If additional item is present, add 1 to the total items. const totalItems = renderAdditionalItem ? items.length + 1 : items.length; if ( @@ -102,11 +111,9 @@ export const List = ({ (arrowDecrement && layout === 'horizontal')) && nextIndex === additionalItemIndex + 1) ) { - return [ - ...(itemRefs.current[additionalItemIndex]?.querySelectorAll( - SELECTORS - ) as unknown as HTMLElement[]), - ].filter((el: HTMLElement) => focusable(el)); + return [itemRefs.current[additionalItemIndex]].filter( + (el: HTMLElement) => focusable(el) + ); } while ( (nextIndex >= 0 && nextIndex <= additionalItemIndex) || @@ -117,11 +124,9 @@ export const List = ({ const nextFocusableItem: HTMLElement | null = external ? nextItem.parentElement : nextItem; - return [ - ...(nextFocusableItem.querySelectorAll( - SELECTORS - ) as unknown as HTMLElement[]), - ].filter((el: HTMLElement) => focusable(el)); + return [nextFocusableItem].filter((el: HTMLElement) => + focusable(el) + ); } nextIndex += step; } @@ -140,9 +145,7 @@ export const List = ({ ? itemRefs.current[index].parentElement : itemRefs.current[index]; const getFocusableElements = (): HTMLElement[] => { - return [ - ...(item.querySelectorAll(SELECTORS) as unknown as HTMLElement[]), - ].filter((el: HTMLElement) => focusable(el)); + return [item].filter((el: HTMLElement) => focusable(el)); }; const focusableElements: HTMLElement[] = getFocusableElements(); const focusableElement: HTMLElement | null = focusableElements?.[0]; @@ -185,6 +188,9 @@ export const List = ({ } ref={itemRef} style={itemStyle} + role="option" + aria-selected={focusIndex === additionalItemIndex ? 'true' : 'false'} + tabIndex={focusIndex === additionalItemIndex ? 0 : -1} > {renderAdditionalItem?.(additionalItem)}
  • @@ -214,6 +220,8 @@ export const List = ({ } ref={itemRef} style={itemStyle} + role="option" + tabIndex={focusIndex === index ? 0 : -1} > {renderItem(item)} diff --git a/src/components/List/__snapshots__/List.test.tsx.snap b/src/components/List/__snapshots__/List.test.tsx.snap index 32f01f9a6..bd9fb783f 100644 --- a/src/components/List/__snapshots__/List.test.tsx.snap +++ b/src/components/List/__snapshots__/List.test.tsx.snap @@ -19,7 +19,9 @@ exports[`List List is horizontal 1`] = ` >
  • = React.forwardRef( size === StepperSize.Small && layout === 'horizontal' && ( = React.forwardRef(
  • {layout === 'vertical' && (
    = React.forwardRef( )} {size !== StepperSize.Small && (
    { + let container: HTMLElement; + + beforeEach(() => { + // Create a fresh container for each test + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + // Clean up after each test + document.body.removeChild(container); + }); + + test('should return false if container is null', () => { + expect( + interactWithFirstInteractiveElement(null as unknown as HTMLElement) + ).toBe(false); + }); + + test('should click on checkbox inputs', () => { + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + const clickSpy = jest.spyOn(checkbox, 'click'); + + container.appendChild(checkbox); + + const result = interactWithFirstInteractiveElement(container); + + expect(result).toBe(true); + expect(clickSpy).toHaveBeenCalled(); + }); + + test('should click on button elements', () => { + const button = document.createElement('button'); + const clickSpy = jest.spyOn(button, 'click'); + + container.appendChild(button); + + const result = interactWithFirstInteractiveElement(container); + + expect(result).toBe(true); + expect(clickSpy).toHaveBeenCalled(); + }); + + test('should focus on text input elements', () => { + const input = document.createElement('input'); + input.type = 'text'; + const focusSpy = jest.spyOn(input, 'focus'); + + container.appendChild(input); + + const result = interactWithFirstInteractiveElement(container); + + expect(result).toBe(true); + expect(focusSpy).toHaveBeenCalled(); + }); + + test('should click on anchor elements with href', () => { + const anchor = document.createElement('a'); + anchor.href = '#test'; + const clickSpy = jest.spyOn(anchor, 'click'); + + container.appendChild(anchor); + + const result = interactWithFirstInteractiveElement(container); + + expect(result).toBe(true); + expect(clickSpy).toHaveBeenCalled(); + }); + + test('should focus on textarea elements', () => { + const textarea = document.createElement('textarea'); + const focusSpy = jest.spyOn(textarea, 'focus'); + + container.appendChild(textarea); + + const result = interactWithFirstInteractiveElement(container); + + expect(result).toBe(true); + expect(focusSpy).toHaveBeenCalled(); + }); + + test('should toggle details elements', () => { + const details = document.createElement('details'); + + container.appendChild(details); + + const result = interactWithFirstInteractiveElement(container); + + expect(result).toBe(true); + expect(details.open).toBe(true); + }); + + test('should focus on elements with tabindex', () => { + const div = document.createElement('div'); + div.setAttribute('tabindex', '0'); + const focusSpy = jest.spyOn(div, 'focus'); + + container.appendChild(div); + + const result = interactWithFirstInteractiveElement(container); + + expect(result).toBe(true); + expect(focusSpy).toHaveBeenCalled(); + }); + + test('should not interact with elements with tabindex="-1"', () => { + const div = document.createElement('div'); + div.setAttribute('tabindex', '-1'); + const focusSpy = jest.spyOn(div, 'focus'); + + container.appendChild(div); + + // Add a button to ensure we have an interactive element + const button = document.createElement('button'); + const buttonClickSpy = jest.spyOn(button, 'click'); + container.appendChild(button); + + const result = interactWithFirstInteractiveElement(container); + + expect(result).toBe(true); + expect(focusSpy).not.toHaveBeenCalled(); + expect(buttonClickSpy).toHaveBeenCalled(); + }); + + test('should respect priority order of interactive elements', () => { + // Create elements in reverse priority order + const tabIndexDiv = document.createElement('div'); + tabIndexDiv.setAttribute('tabindex', '0'); + const tabIndexFocusSpy = jest.spyOn(tabIndexDiv, 'focus'); + + const textarea = document.createElement('textarea'); + const textareaFocusSpy = jest.spyOn(textarea, 'focus'); + + const anchor = document.createElement('a'); + anchor.href = '#test'; + const anchorClickSpy = jest.spyOn(anchor, 'click'); + + const button = document.createElement('button'); + const buttonClickSpy = jest.spyOn(button, 'click'); + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + const checkboxClickSpy = jest.spyOn(checkbox, 'click'); + + // Add elements to container in reverse priority order + container.appendChild(tabIndexDiv); + container.appendChild(textarea); + container.appendChild(anchor); + container.appendChild(button); + container.appendChild(checkbox); + + const result = interactWithFirstInteractiveElement(container); + + expect(result).toBe(true); + // Checkbox should be clicked as it has highest priority + expect(checkboxClickSpy).toHaveBeenCalled(); + // Other elements should not be interacted with + expect(buttonClickSpy).not.toHaveBeenCalled(); + expect(anchorClickSpy).not.toHaveBeenCalled(); + expect(textareaFocusSpy).not.toHaveBeenCalled(); + expect(tabIndexFocusSpy).not.toHaveBeenCalled(); + }); + + test('should use SELECTORS as fallback when no specific interactive element is found', () => { + // Create a custom element that matches SELECTORS but not the specific selectors + const iframe = document.createElement('iframe'); + const focusSpy = jest.spyOn(iframe, 'focus'); + + container.appendChild(iframe); + + const result = interactWithFirstInteractiveElement(container); + + expect(result).toBe(true); + expect(focusSpy).toHaveBeenCalled(); + }); + + test('should click on container itself if no interactive element is found', () => { + // Create a div with no interactive elements + const clickSpy = jest.spyOn(container, 'click'); + + // Mock querySelector to return null for all selectors + const originalQuerySelector = container.querySelector; + container.querySelector = jest.fn().mockReturnValue(null); + + const result = interactWithFirstInteractiveElement(container); + + expect(result).toBe(true); + expect(clickSpy).toHaveBeenCalled(); + + // Restore original querySelector + container.querySelector = originalQuerySelector; + }); + + test('should click on input elements with type checkbox, radio, button, submit, or reset when using SELECTORS fallback', () => { + // Mock the specific selectors to not find anything + const originalQuerySelector = container.querySelector; + + // First mock to return null for specific selectors, then return the input for SELECTORS + container.querySelector = jest.fn().mockImplementation((selector) => { + if (selector === SELECTORS) { + const input = document.createElement('input'); + input.type = 'submit'; + return input; + } + return null; + }); + + const result = interactWithFirstInteractiveElement(container); + + expect(result).toBe(true); + // The mock implementation should have been called with SELECTORS + expect(container.querySelector).toHaveBeenCalledWith(SELECTORS); + + // Restore original querySelector + container.querySelector = originalQuerySelector; + }); + + test('should focus on input elements with types other than checkbox, radio, button, submit, or reset when using SELECTORS fallback', () => { + // Mock the specific selectors to not find anything + const originalQuerySelector = container.querySelector; + + // Create an input element with a type that should be focused, not clicked + const input = document.createElement('input'); + input.type = 'text'; + const focusSpy = jest.spyOn(input, 'focus'); + + // Mock querySelector to return null for specific selectors but our input for SELECTORS + container.querySelector = jest.fn().mockImplementation((selector) => { + if (selector === SELECTORS) { + return input; + } + return null; + }); + + const result = interactWithFirstInteractiveElement(container); + + expect(result).toBe(true); + expect(focusSpy).toHaveBeenCalled(); + + // Restore original querySelector + container.querySelector = originalQuerySelector; + }); + + test('should toggle open state of details elements when using SELECTORS fallback', () => { + // Mock the specific selectors to not find anything + const originalQuerySelector = container.querySelector; + + // Create a details element + const details = document.createElement('details'); + + // Mock querySelector to return null for specific selectors but our details for SELECTORS + container.querySelector = jest.fn().mockImplementation((selector) => { + if (selector === SELECTORS) { + return details; + } + return null; + }); + + const result = interactWithFirstInteractiveElement(container); + + expect(result).toBe(true); + expect(details.open).toBe(true); + + // Restore original querySelector + container.querySelector = originalQuerySelector; + }); + + test('should click on button and anchor elements when using SELECTORS fallback', () => { + // Mock the specific selectors to not find anything + const originalQuerySelector = container.querySelector; + + // Test with button + const button = document.createElement('button'); + const buttonClickSpy = jest.spyOn(button, 'click'); + + // Mock querySelector to return null for specific selectors but our button for SELECTORS + container.querySelector = jest.fn().mockImplementation((selector) => { + if (selector === SELECTORS) { + return button; + } + return null; + }); + + let result = interactWithFirstInteractiveElement(container); + + expect(result).toBe(true); + expect(buttonClickSpy).toHaveBeenCalled(); + + // Reset mocks + jest.clearAllMocks(); + + // Test with anchor + const anchor = document.createElement('a'); + anchor.href = '#test'; + const anchorClickSpy = jest.spyOn(anchor, 'click'); + + // Update mock to return anchor + container.querySelector = jest.fn().mockImplementation((selector) => { + if (selector === SELECTORS) { + return anchor; + } + return null; + }); + + result = interactWithFirstInteractiveElement(container); + + expect(result).toBe(true); + expect(anchorClickSpy).toHaveBeenCalled(); + + // Restore original querySelector + container.querySelector = originalQuerySelector; + }); + + test('should focus on other interactive elements when using SELECTORS fallback', () => { + // Mock the specific selectors to not find anything + const originalQuerySelector = container.querySelector; + + // Create a select element (which should be focused) + const select = document.createElement('select'); + const focusSpy = jest.spyOn(select, 'focus'); + + // Mock querySelector to return null for specific selectors but our select for SELECTORS + container.querySelector = jest.fn().mockImplementation((selector) => { + if (selector === SELECTORS) { + return select; + } + return null; + }); + + const result = interactWithFirstInteractiveElement(container); + + expect(result).toBe(true); + expect(focusSpy).toHaveBeenCalled(); + + // Restore original querySelector + container.querySelector = originalQuerySelector; + }); +}); diff --git a/src/shared/utilities/interactiveElements.ts b/src/shared/utilities/interactiveElements.ts new file mode 100644 index 000000000..f5e691944 --- /dev/null +++ b/src/shared/utilities/interactiveElements.ts @@ -0,0 +1,102 @@ +import { SELECTORS } from './types'; + +/** + * Interacts with the first interactive element found within a container element + * based on a priority order of selectors. + * + * @param containerElement - The DOM element to search within + * @returns boolean - Whether an interactive element was found and interacted with + */ +export function interactWithFirstInteractiveElement( + containerElement: HTMLElement +): boolean { + if (!containerElement) return false; + + // Define interactive elements in priority order with their selectors and actions + const interactiveElements = [ + // Handle different input types with specific behaviors + { + selector: 'input[type="checkbox"], input[type="radio"]', + action: 'click', + }, + { + selector: + 'input[type="button"], input[type="submit"], input[type="reset"]', + action: 'click', + }, + { + selector: + 'input[type="text"], input[type="email"], input[type="password"], input[type="search"], input[type="tel"], input[type="url"], input[type="number"], input[type="date"], input[type="datetime-local"], input[type="month"], input[type="time"], input[type="week"], input[type="color"]', + action: 'focus', + }, + { selector: 'button', action: 'click' }, + { selector: 'a[href]', action: 'click' }, + { selector: 'textarea', action: 'focus' }, + { selector: 'select', action: 'focus' }, + { selector: 'details', action: 'toggle' }, + { selector: '[tabindex]:not([tabindex="-1"])', action: 'focus' }, + { selector: 'iframe, object, embed', action: 'focus' }, + ]; + + // Try to find and interact with elements in priority order + const elementFound = interactiveElements.some(({ selector, action }) => { + const element = containerElement.querySelector(selector) as HTMLElement; + if (!element) { + return false; + } + switch (action) { + case 'click': + element.click(); + break; + case 'focus': + element.focus(); + break; + case 'toggle': + if (element instanceof HTMLDetailsElement) { + element.open = !element.open; + } else { + element.click(); + } + break; + default: + element.click(); + } + return true; + }); + + // If no specific interactive element was found, try using the SELECTORS constant + if (!elementFound) { + // Use SELECTORS as a fallback + const anyInteractiveElement = containerElement.querySelector( + SELECTORS + ) as HTMLElement; + if (anyInteractiveElement) { + if (anyInteractiveElement.tagName === 'INPUT') { + const inputElement = anyInteractiveElement as HTMLInputElement; + const inputType = inputElement.type.toLowerCase(); + + if ( + ['checkbox', 'radio', 'button', 'submit', 'reset'].includes(inputType) + ) { + inputElement.click(); + } else { + inputElement.focus(); + } + } else if (anyInteractiveElement.tagName === 'DETAILS') { + const detailsElement = anyInteractiveElement as HTMLDetailsElement; + detailsElement.open = !detailsElement.open; + } else if (['BUTTON', 'A'].includes(anyInteractiveElement.tagName)) { + anyInteractiveElement.click(); + } else { + anyInteractiveElement.focus(); + } + return true; + } else { + // If no interactive element found at all, click the container element itself + containerElement.click(); + return true; + } + } + + return elementFound; +} diff --git a/src/shared/utilities/types.ts b/src/shared/utilities/types.ts index b3cbe7da0..3fbfeed63 100644 --- a/src/shared/utilities/types.ts +++ b/src/shared/utilities/types.ts @@ -1,6 +1,8 @@ export const SELECTORS: string = 'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"]), iframe, object, embed'; +export const NON_TABBABLE_SELECTORS: string = '[tabindex="-1"]'; + export const focusable = (el: HTMLElement | null): boolean => { return ( !el?.hasAttribute('data-disabled') && // optionally use a data attribute as a way to exclude certain elements without hiding them from screen readers to improve usability.