diff --git a/src/input-group/input-group-list-addon.tsx b/src/input-group/input-group-list-addon.tsx index f37c07339..67954396d 100644 --- a/src/input-group/input-group-list-addon.tsx +++ b/src/input-group/input-group-list-addon.tsx @@ -1,10 +1,13 @@ import { OpenChangeReason } from "@floating-ui/react"; +import findIndex from "lodash/findIndex"; +import isEqual from "lodash/isEqual"; import React, { useEffect, useRef, useState } from "react"; import { VisuallyHidden, concatIds } from "../shared/accessibility"; import { DropdownList, DropdownListState, ExpandableElement, + useDropdownListState, } from "../shared/dropdown-list-v2"; import { ElementWithDropdown } from "../shared/dropdown-wrapper"; import { @@ -76,6 +79,12 @@ export const Component = ( const [selected, setSelected] = useState(selectedOption); const [showOptions, setShowOptions] = useState(false); const [focused, setFocused] = useState(false); + const { context, setSelectedIndex, onKeyDown } = useDropdownListState({ + options, + disabled, + readOnly, + onOpenChange: setShowOptions, + }); const [internalId] = useState(() => SimpleIdGenerator.generate()); const listboxId = `${internalId}-listbox`; const instructionId = `${internalId}-instruction`; @@ -90,6 +99,13 @@ export const Component = ( // ============================================================================= useEffect(() => { setSelected(selectedOption); + setSelectedIndex( + selectedOption + ? findIndex(options, (option) => { + return isEqual(option, selectedOption); + }) + : -1 + ); }, [selectedOption]); // ============================================================================= @@ -123,6 +139,7 @@ export const Component = ( const handleListItemClick = (item: T, extractedValue: V) => { selectorRef.current?.focus(); setSelected(item); + setSelectedIndex(findIndex(options, (option) => isEqual(option, item))); setShowOptions(false); triggerOptionDisplayCallback(false); @@ -209,11 +226,12 @@ export const Component = ( aria-labelledby={concatIds(ariaLabelledBy, comboboxLabelId)} aria-describedby={concatIds(ariaDescribedBy, instructionId)} aria-invalid={ariaInvalid} + onKeyDown={onKeyDown} > {renderSelectorContent()} - Press space to open options + Press Space or Enter to open options ); @@ -283,7 +301,7 @@ export const Component = ( }; return ( - + ({ dropdownZIndex, maxSelectable, dropdownRootNode, - dropdownWidth + dropdownWidth, }: InputMultiSelectProps): JSX.Element => { // ============================================================================= // CONST, STATE @@ -60,6 +62,12 @@ export const InputMultiSelect = ({ const [selected, setSelected] = useState(selectedOptions || []); const [showOptions, setShowOptions] = useState(false); const [focused, setFocused] = useState(false); + const { context, onKeyDown, setSelectedIndex } = useDropdownListState({ + options, + disabled, + readOnly, + onOpenChange: setShowOptions, + }); const [internalId] = useState(() => SimpleIdGenerator.generate()); const nodeRef = useRef(null); @@ -70,6 +78,13 @@ export const InputMultiSelect = ({ // ============================================================================= useEffect(() => { setSelected(selectedOptions || []); + setSelectedIndex( + selectedOptions?.[0] + ? findIndex(options, (option) => + isEqual(option, selectedOptions[0]) + ) + : -1 + ); }, [selectedOptions]); // ============================================================================= @@ -78,9 +93,11 @@ export const InputMultiSelect = ({ const handleSelectAllClick = () => { if ((selected && selected.length > 0) || maxSelectable) { setSelected([]); + setSelectedIndex(-1); performOnSelectOptions([]); } else { setSelected(options); + setSelectedIndex(0); performOnSelectOptions(options); } }; @@ -103,6 +120,7 @@ export const InputMultiSelect = ({ } setSelected(selectedCopy); + setSelectedIndex(findIndex(options, (option) => isEqual(option, item))); performOnSelectOptions(selectedCopy); }; @@ -231,6 +249,7 @@ export const InputMultiSelect = ({ aria-labelledby={ariaLabelledBy} aria-describedby={ariaDescribedBy} aria-invalid={ariaInvalid} + onKeyDown={onKeyDown} > {renderSelectorContent()} @@ -270,7 +289,7 @@ export const InputMultiSelect = ({ }; return ( - + ({ alignment, dropdownZIndex, dropdownRootNode, - dropdownWidth + dropdownWidth, }: InputSelectProps): JSX.Element => { // ============================================================================= // CONST, STATE @@ -61,6 +64,12 @@ export const InputSelect = ({ const [selected, setSelected] = useState(selectedOption); const [showOptions, setShowOptions] = useState(false); const [focused, setFocused] = useState(false); + const { context, setSelectedIndex, onKeyDown } = useDropdownListState({ + options, + disabled, + readOnly, + onOpenChange: setShowOptions, + }); const [internalId] = useState(() => SimpleIdGenerator.generate()); const nodeRef = useRef(null); @@ -72,6 +81,13 @@ export const InputSelect = ({ // ============================================================================= useEffect(() => { setSelected(selectedOption); + setSelectedIndex( + selectedOption + ? findIndex(options, (option) => + isEqual(option, selectedOption) + ) + : -1 + ); }, [selectedOption]); // ============================================================================= @@ -80,6 +96,7 @@ export const InputSelect = ({ const handleListItemClick = (item: T, extractedValue: V) => { selectorRef.current?.focus(); setSelected(item); + setSelectedIndex(findIndex(options, (option) => isEqual(option, item))); setShowOptions(false); triggerOptionDisplayCallback(false); @@ -234,6 +251,7 @@ export const InputSelect = ({ aria-labelledby={ariaLabelledBy} aria-describedby={ariaDescribedBy} aria-invalid={ariaInvalid} + onKeyDown={onKeyDown} > {renderSelectorContent()} @@ -270,7 +288,7 @@ export const InputSelect = ({ }; return ( - + ({ className, @@ -57,6 +61,11 @@ export const PredictiveTextInput = ({ ); const [isOpen, setIsOpen] = useState(false); const [isFocused, setIsFocused] = useState(false); + const { context } = useDropdownListState({ + options, + disabled, + readOnly, + }); const [internalId] = useState(() => SimpleIdGenerator.generate()); const [resultAnnouncement, setResultAnnouncement] = useState( @@ -346,7 +355,7 @@ export const PredictiveTextInput = ({ }; return ( - + - - + ); }; diff --git a/src/shared/dropdown-list-v2/dropdown-list-state.tsx b/src/shared/dropdown-list-v2/dropdown-list-state.tsx index fd110fd51..1b5e76dbe 100644 --- a/src/shared/dropdown-list-v2/dropdown-list-state.tsx +++ b/src/shared/dropdown-list-v2/dropdown-list-state.tsx @@ -1,32 +1,122 @@ import React, { useState } from "react"; -interface DropdownListStateProps { - children: React.ReactNode; +interface DropdownListState extends DropdownListStateContext { + context: DropdownListStateContext; } interface DropdownListStateContextProps { focusedIndex: number; setFocusedIndex: React.Dispatch>; + selectedIndex: number; + setSelectedIndex: React.Dispatch>; + focusFirstItem: () => void; + focusLastItem: () => void; + onKeyDown: (event: React.KeyboardEvent) => void; } +interface DropdownListStateProps { + children: React.ReactNode; + context: DropdownListStateContext; +} + +interface UseDropdownListStateOptions { + options: T[] | undefined; + disabled?: boolean | undefined; + readOnly?: boolean | undefined; + onOpenChange?: ((isOpen: boolean) => void) | undefined; +} + +const noop = () => { + // do nothing +}; + export const DropdownListStateContext = React.createContext({ focusedIndex: -1, - setFocusedIndex: () => { - // do nothing - }, + setFocusedIndex: noop, + selectedIndex: -1, + setSelectedIndex: noop, + focusFirstItem: noop, + focusLastItem: noop, + onKeyDown: noop, }); -export const DropdownListState = ({ children }: DropdownListStateProps) => { +export const useDropdownListState = ({ + options, + disabled, + readOnly, + onOpenChange, +}: UseDropdownListStateOptions): DropdownListState => { const [focusedIndex, setFocusedIndex] = useState(-1); + const [selectedIndex, setSelectedIndex] = useState(-1); + + const focusFirstItem = () => setFocusedIndex(0); + const focusLastItem = () => { + if (options?.length) { + setFocusedIndex(options.length - 1); + } + }; + + const getPreviousItemIndex = (index: number) => { + if (!options) { + return index; + } + return Math.max(index - 1, 0); + }; + + const getNextItemIndex = (index: number) => { + if (!options) { + return index; + } + return Math.min(index + 1, options.length - 1); + }; + + const onKeyDown = (event: React.KeyboardEvent) => { + if (disabled || readOnly || !options?.length) { + return; + } + + event.stopPropagation(); + + if (event.key === "ArrowDown") { + event.preventDefault(); + const index = selectedIndex > -1 ? selectedIndex : focusedIndex; + setFocusedIndex(getNextItemIndex(index)); + onOpenChange?.(true); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + const index = selectedIndex > -1 ? selectedIndex : focusedIndex; + setFocusedIndex(getPreviousItemIndex(index)); + onOpenChange?.(true); + } else if (event.key === "Home") { + event.preventDefault(); + setFocusedIndex(0); + onOpenChange?.(true); + } else if (event.key === "End") { + event.preventDefault(); + setFocusedIndex(options.length - 1); + onOpenChange?.(true); + } + }; + + const context = { + focusedIndex, + setFocusedIndex, + selectedIndex, + setSelectedIndex, + focusFirstItem, + focusLastItem, + onKeyDown, + }; + return { ...context, context }; +}; +export const DropdownListState = ({ + children, + context, +}: DropdownListStateProps) => { return ( - + {children} ); diff --git a/src/shared/dropdown-list-v2/dropdown-list.tsx b/src/shared/dropdown-list-v2/dropdown-list.tsx index a9e00f261..8830b0a11 100644 --- a/src/shared/dropdown-list-v2/dropdown-list.tsx +++ b/src/shared/dropdown-list-v2/dropdown-list.tsx @@ -90,11 +90,18 @@ export const DropdownList = ({ const { focusedIndex, setFocusedIndex } = useContext( DropdownListStateContext ); - const { elementWidth, setFloatingRef, getFloatingProps, styles } = - useDropdownRender(); + const { + elementWidth, + setFloatingRef, + getFloatingProps, + styles, + resized, + open, + } = useDropdownRender(); const [searchValue, setSearchValue] = useState(""); const [displayListItems, setDisplayListItems] = useState(listItems ?? []); const itemsLoadStateChanged = useCompare(itemsLoadState); + const resizedChanged = useCompare(resized); const mounted = useIsMounted(); const nodeRef = useRef(null); @@ -156,6 +163,22 @@ export const DropdownList = ({ }); }); + const focusListItem = (index: number) => { + // Cannot go further than first or last element + let clampedIndex = index; + if (index < 0) { + clampedIndex = 0; + } else if (index >= displayListItems.length) { + clampedIndex = displayListItems.length - 1; + } + virtuosoRef.current?.scrollToIndex({ + index: clampedIndex, + align: "center", + }); + setFocusedIndex(clampedIndex); + setTimeout(() => listItemRefs.current[clampedIndex]?.focus(), 200); + }; + // ========================================================================= // EVENT HANDLERS // ========================================================================= @@ -163,25 +186,25 @@ export const DropdownList = ({ switch (event.code) { case "ArrowDown": event.preventDefault(); - // Cannot go further than last element - if (focusedIndex < displayListItems.length - 1) { - const upcomingIndex = focusedIndex + 1; - listItemRefs.current[upcomingIndex]?.focus(); - setFocusedIndex(upcomingIndex); - } + focusListItem(focusedIndex + 1); break; case "ArrowUp": event.preventDefault(); - // Cannot go further than first element if (focusedIndex > 0) { - const upcomingIndex = focusedIndex - 1; - listItemRefs.current[upcomingIndex]?.focus(); - setFocusedIndex(upcomingIndex); + focusListItem(focusedIndex - 1); } else if (focusedIndex === 0 && searchInputRef.current) { searchInputRef.current.focus(); setFocusedIndex(-1); } break; + case "Home": + event.preventDefault(); + focusListItem(0); + break; + case "End": + event.preventDefault(); + focusListItem(displayListItems.length - 1); + break; case "Space": case "Enter": if ( @@ -259,46 +282,45 @@ export const DropdownList = ({ }, [listItemRefs, listItems, setFocusedIndex, topScrollItem]); useEffect(() => { - if (mounted) { - // only run on mount + if (!open || disableItemFocus || !listItems) return; + + if (!resized || !resizedChanged) { + // only run when dropdown has completed resizing return; } - if (disableItemFocus || !listItems) return; - - const index = listItems.findIndex((item) => - checkListItemSelected(item) - ); + let timeout: NodeJS.Timeout; // Focus search input if there is one if (searchInputRef.current) { setFocusedIndex(-1); - setTimeout(() => searchInputRef.current?.focus(), 200); // Wait for animation - } else if (focusedIndex > 0) { + timeout = setTimeout(() => searchInputRef.current?.focus(), 200); // Wait for animation + } else if (focusedIndex >= 0 && focusedIndex < listItems.length) { // Else focus on the specified element virtuosoRef.current?.scrollToIndex({ index: focusedIndex, align: "center", }); - setTimeout(() => listItemRefs.current[focusedIndex]?.focus(), 200); - } else if (index !== -1) { - // Else focus on the selected element - virtuosoRef.current?.scrollToIndex({ index, align: "center" }); - setFocusedIndex(index); - setTimeout(() => listItemRefs.current[index]?.focus(), 200); + timeout = setTimeout( + () => listItemRefs.current[focusedIndex]?.focus(), + 200 + ); } else { // Else focus on the first list item virtuosoRef.current?.scrollToIndex({ index: 0 }); setFocusedIndex(0); - setTimeout(() => listItemRefs.current[0]?.focus(), 200); + timeout = setTimeout(() => listItemRefs.current[0]?.focus(), 200); } + + return () => clearTimeout(timeout); }, [ checkListItemSelected, disableItemFocus, focusedIndex, listItems, - mounted, setFocusedIndex, + resizedChanged, + resized, ]); useEffect(() => { diff --git a/src/shared/dropdown-list-v2/expandable-element.tsx b/src/shared/dropdown-list-v2/expandable-element.tsx index c5fc608f1..52a60e8da 100644 --- a/src/shared/dropdown-list-v2/expandable-element.tsx +++ b/src/shared/dropdown-list-v2/expandable-element.tsx @@ -15,6 +15,7 @@ interface ExpandableElementProps popupRole: AriaAttributes["aria-haspopup"]; readOnly: boolean | undefined; variant?: DropdownVariantType | undefined; + onKeyDown?: ((e: React.KeyboardEvent) => void) | undefined; } export const Component = ( diff --git a/src/shared/dropdown-wrapper/element-with-dropdown.tsx b/src/shared/dropdown-wrapper/element-with-dropdown.tsx index cf063fc47..8fc7b4fee 100644 --- a/src/shared/dropdown-wrapper/element-with-dropdown.tsx +++ b/src/shared/dropdown-wrapper/element-with-dropdown.tsx @@ -22,16 +22,21 @@ import { createContext, useContext, useRef, + useState, } from "react"; import { useResizeDetector } from "react-resize-detector"; import { ThemeContext } from "styled-components"; import { useFloatingChild } from "../../overlay/use-floating-context"; import { Breakpoint } from "../../theme"; +import { useDebouncedCallback } from "../../util/use-debounce"; import { DropdownAlignmentType } from "./types"; export interface DropdownRenderProps { elementWidth: number; styles: CSSProperties; + /** if height has been calculated for the dropdown */ + resized: boolean; + open: boolean; setFloatingRef: (node: HTMLElement | null) => void; getFloatingProps: ( userProps?: React.HTMLProps @@ -79,6 +84,8 @@ const DEFAULT_Z_INDEX = 50; export const DropdownRenderContext = createContext({ elementWidth: 0, styles: {}, + resized: false, + open: false, setFloatingRef: () => { // noop }, @@ -138,6 +145,8 @@ export const ElementWithDropdown = ({ // ============================================================================= // CONST, STATE, REF // ============================================================================= + const [resized, setResized] = useState(false); + const setResizedDebounced = useDebouncedCallback(setResized, 100); const theme = useContext(ThemeContext); const mobileBreakpoint = Breakpoint["sm-max"]({ theme }); const elementRef = useRef(null); @@ -162,6 +171,10 @@ export const ElementWithDropdown = ({ const { refs, floatingStyles, context } = useFloating({ open: isOpen, onOpenChange: (open, _event, reason) => { + if (!open) { + setResized(false); + } + if (reason === "escape-key") { onDismiss?.(); } else if (open && !isOpen) { @@ -181,6 +194,10 @@ export const ElementWithDropdown = ({ size({ // shrink to fit available vertical space apply({ availableHeight, elements }) { + if (!resized) { + setResizedDebounced(true); + } + if ( !fitAvailableHeight || availableHeight >= elements.floating.scrollHeight @@ -221,13 +238,15 @@ export const ElementWithDropdown = ({ // ============================================================================= // RENDER FUNCTIONS // ============================================================================= - const dropdownRenderProps = { + const dropdownRenderProps: DropdownRenderProps = { elementWidth: referenceWidth, styles: { ...styles, ...floatingStyles, zIndex: customZIndex ?? parentZIndex ?? DEFAULT_Z_INDEX, }, + resized, + open: isOpen, // the actual open state, as dropdown may remain mounted while the close animation has not completed setFloatingRef: refs.setFloating, getFloatingProps, }; diff --git a/src/time-range-picker/combobox-picker/combobox-picker.tsx b/src/time-range-picker/combobox-picker/combobox-picker.tsx index 72869efbc..66e281cbf 100644 --- a/src/time-range-picker/combobox-picker/combobox-picker.tsx +++ b/src/time-range-picker/combobox-picker/combobox-picker.tsx @@ -7,7 +7,10 @@ import { } from "../../form/form-label.style"; import { ClearIconContainer } from "../../input-range-select/input-range-select.style"; import { ClearIcon } from "../../input/input.style"; -import { DropdownListState } from "../../shared/dropdown-list-v2"; +import { + DropdownListState, + useDropdownListState, +} from "../../shared/dropdown-list-v2"; import { DropdownList } from "../../shared/dropdown-list-v2/dropdown-list"; import { ElementWithDropdown } from "../../shared/dropdown-wrapper"; import { RangeInputInnerContainer } from "../../shared/range-input-inner-container"; @@ -75,6 +78,20 @@ export const ComboboxPicker = ({ : []; }, [startOptions, initialStartTimeVal]); + const { context: startDropdownContext } = useDropdownListState({ + options: startOptions, + disabled, + readOnly, + onOpenChange: setDropdownOpen, + }); + + const { context: endDropdownContext } = useDropdownListState({ + options: startOptions, + disabled, + readOnly, + onOpenChange: setDropdownOpen, + }); + // ========================================================================= // EFFECTS // ========================================================================= @@ -417,7 +434,13 @@ export const ComboboxPicker = ({ return ( - + void>( + callback: T, + delay: number +): T => { + const timeoutId = useRef>(); + + useEffect(() => { + return () => { + if (timeoutId.current) { + clearTimeout(timeoutId.current); + } + }; + }, []); + + return function (...args: Parameters) { + if (timeoutId.current) { + clearTimeout(timeoutId.current); + } + timeoutId.current = setTimeout(() => callback(...args), delay); + } as T; +}; diff --git a/tests/input-group/input-group-list-addon.spec.tsx b/tests/input-group/input-group-list-addon.spec.tsx index 83196577f..110daed87 100644 --- a/tests/input-group/input-group-list-addon.spec.tsx +++ b/tests/input-group/input-group-list-addon.spec.tsx @@ -12,7 +12,7 @@ const DROPDOWN_TESTID = "dropdown-list"; const OPTIONS = ["Option 1", "Option 2", "Option 3"]; const LABEL = "Test label"; const DESCRIPTION = "Test subtitle"; -const DROPDOWN_INSTRUCTION = "Press space to open options"; +const DROPDOWN_INSTRUCTION = "Press Space or Enter to open options"; const ERROR = "Error message"; describe("InputGroup - List addon", () => { @@ -586,4 +586,302 @@ describe("InputGroup - List addon", () => { expect(textbox).toBeInvalid(); }); }); + + describe("keyboard behaviour", () => { + describe("dropdown is closed", () => { + it("should focus the next item with Arrow Down", async () => { + const user = userEvent.setup(); + + render( + + ); + + await act(async () => { + await user.keyboard("{Tab}"); + await user.keyboard("{ArrowDown}"); + }); + + // opens the list and focuses the first item + await waitFor(() => { + expect(screen.queryByTestId(DROPDOWN_TESTID)).toBeVisible(); + expect( + screen.queryByRole("option", { name: "Option 1" }) + ).toHaveFocus(); + }); + }); + + it("should focus the next item with Arrow Down, given there is a selected option", async () => { + const user = userEvent.setup(); + + render( + + ); + + await act(async () => { + await user.keyboard("{Tab}"); + await user.keyboard("{ArrowDown}"); + }); + + await waitFor(() => { + expect(screen.queryByTestId(DROPDOWN_TESTID)).toBeVisible(); + expect( + screen.queryByRole("option", { name: "Option 3" }) + ).toHaveFocus(); + }); + }); + + it("should focus the previous item with Arrow Up", async () => { + const user = userEvent.setup(); + + render( + + ); + + await act(async () => { + await user.keyboard("{Tab}"); + await user.keyboard("{ArrowUp}"); + }); + + // opens the list and focuses the last item + await waitFor(() => { + expect(screen.queryByTestId(DROPDOWN_TESTID)).toBeVisible(); + expect( + screen.queryByRole("option", { name: "Option 1" }) + ).toHaveFocus(); + }); + }); + + it("should focus the previous item with Arrow Up, given there is a selected option", async () => { + const user = userEvent.setup(); + + render( + + ); + + await act(async () => { + await user.keyboard("{Tab}"); + await user.keyboard("{ArrowUp}"); + }); + + await waitFor(() => { + expect(screen.queryByTestId(DROPDOWN_TESTID)).toBeVisible(); + expect( + screen.queryByRole("option", { name: "Option 2" }) + ).toHaveFocus(); + }); + }); + + it("should open the dropdown and focus the first item with Home", async () => { + const user = userEvent.setup(); + + render( + + ); + + await act(async () => { + await user.keyboard("{Tab}"); + await user.keyboard("{Home}"); + }); + + await waitFor(() => { + expect(screen.queryByTestId(DROPDOWN_TESTID)).toBeVisible(); + expect( + screen.queryByRole("option", { name: "Option 1" }) + ).toHaveFocus(); + }); + }); + + it("should open the dropdown and focus the last item with End", async () => { + const user = userEvent.setup(); + + render( + + ); + + await act(async () => { + await user.keyboard("{Tab}"); + await user.keyboard("{End}"); + }); + + await waitFor(() => { + expect(screen.queryByTestId(DROPDOWN_TESTID)).toBeVisible(); + expect( + screen.queryByRole("option", { name: "Option 3" }) + ).toHaveFocus(); + }); + }); + }); + + describe("dropdown is opened", () => { + it("should focus the next item with Arrow Down", async () => { + const user = userEvent.setup(); + + render( + + ); + + await act(async () => { + await user.keyboard("{Tab}"); + await user.keyboard("{Home}"); + }); + + // opens the list and focuses the first item + await waitFor(() => { + expect(screen.queryByTestId(DROPDOWN_TESTID)).toBeVisible(); + expect( + screen.queryByRole("option", { name: "Option 1" }) + ).toHaveFocus(); + }); + + await act(async () => { + await user.keyboard("{ArrowDown}"); + }); + + // focuses the next item + await waitFor(() => { + expect( + screen.queryByRole("option", { name: "Option 2" }) + ).toHaveFocus(); + }); + + await act(async () => { + await user.keyboard("{ArrowDown}"); + }); + + // focuses the next item + await waitFor(() => { + expect( + screen.queryByRole("option", { name: "Option 3" }) + ).toHaveFocus(); + }); + + await act(async () => { + await user.keyboard("{ArrowDown}"); + }); + + // stays on the last item once the end of the list is reached + expect( + screen.queryByRole("option", { name: "Option 3" }) + ).toHaveFocus(); + }); + + it("should focus the previous item with Arrow Up", async () => { + const user = userEvent.setup(); + + render( + + ); + + await act(async () => { + await user.keyboard("{Tab}"); + await user.keyboard("{End}"); + }); + + // opens the list and focuses the last item + await waitFor(() => { + expect(screen.queryByTestId(DROPDOWN_TESTID)).toBeVisible(); + expect( + screen.queryByRole("option", { name: "Option 3" }) + ).toHaveFocus(); + }); + + await act(async () => { + await user.keyboard("{ArrowUp}"); + }); + + // focuses the previous item + await waitFor(() => { + expect( + screen.queryByRole("option", { name: "Option 2" }) + ).toHaveFocus(); + }); + + await act(async () => { + await user.keyboard("{ArrowUp}"); + }); + + // focuses the previous item + await waitFor(() => { + expect( + screen.queryByRole("option", { name: "Option 1" }) + ).toHaveFocus(); + }); + + await act(async () => { + await user.keyboard("{ArrowUp}"); + }); + + // stays on the first item once the top of the list is reached + expect( + screen.queryByRole("option", { name: "Option 1" }) + ).toHaveFocus(); + }); + }); + }); }); diff --git a/tests/input-multi-select/input-multi-select.spec.tsx b/tests/input-multi-select/input-multi-select.spec.tsx index 145266744..8b5d261d9 100644 --- a/tests/input-multi-select/input-multi-select.spec.tsx +++ b/tests/input-multi-select/input-multi-select.spec.tsx @@ -98,7 +98,7 @@ describe("InputMultiSelect", () => { }); describe("focus/blur behaviour", () => { - it("should call onBlur via outside click", async () => { + it("should call onBlur via outside click after dropdown is toggled", async () => { const user = userEvent.setup(); const mockOnBlur = jest.fn(); @@ -401,4 +401,260 @@ describe("InputMultiSelect", () => { expect(screen.queryByText("Option 1")).not.toBeInTheDocument(); }); }); + + describe("keyboard behaviour", () => { + describe("dropdown is closed", () => { + it("should focus the next item with Arrow Down", async () => { + const user = userEvent.setup(); + + render( + + ); + + await act(async () => { + await user.keyboard("{Tab}"); + await user.keyboard("{ArrowDown}"); + }); + + // opens the list and focuses the first item + await waitFor(() => { + expect(screen.queryByTestId(DROPDOWN_TESTID)).toBeVisible(); + expect( + screen.queryByRole("option", { name: "Option 1" }) + ).toHaveFocus(); + }); + }); + + it("should focus the next item with Arrow Down, given there is a selected option", async () => { + const user = userEvent.setup(); + + render( + + ); + + await act(async () => { + await user.keyboard("{Tab}"); + await user.keyboard("{ArrowDown}"); + }); + + await waitFor(() => { + expect(screen.queryByTestId(DROPDOWN_TESTID)).toBeVisible(); + expect( + screen.queryByRole("option", { name: "Option 3" }) + ).toHaveFocus(); + }); + }); + + it("should focus the previous item with Arrow Up", async () => { + const user = userEvent.setup(); + + render( + + ); + + await act(async () => { + await user.keyboard("{Tab}"); + await user.keyboard("{ArrowUp}"); + }); + + // opens the list and focuses the first item + await waitFor(() => { + expect(screen.queryByTestId(DROPDOWN_TESTID)).toBeVisible(); + expect( + screen.queryByRole("option", { name: "Option 1" }) + ).toHaveFocus(); + }); + }); + + it("should focus the previous item with Arrow Up, given there is a selected option", async () => { + const user = userEvent.setup(); + + render( + + ); + + await act(async () => { + await user.keyboard("{Tab}"); + await user.keyboard("{ArrowUp}"); + }); + + await waitFor(() => { + expect(screen.queryByTestId(DROPDOWN_TESTID)).toBeVisible(); + expect( + screen.queryByRole("option", { name: "Option 2" }) + ).toHaveFocus(); + }); + }); + + it("should focus the first item with Home", async () => { + const user = userEvent.setup(); + + render( + + ); + + await act(async () => { + await user.keyboard("{Tab}"); + await user.keyboard("{Home}"); + }); + + await waitFor(() => { + expect(screen.queryByTestId(DROPDOWN_TESTID)).toBeVisible(); + expect( + screen.queryByRole("option", { name: "Option 1" }) + ).toHaveFocus(); + }); + }); + + it("should focus the last item with End", async () => { + const user = userEvent.setup(); + + render( + + ); + + await act(async () => { + await user.keyboard("{Tab}"); + await user.keyboard("{End}"); + }); + + await waitFor(() => { + expect(screen.queryByTestId(DROPDOWN_TESTID)).toBeVisible(); + expect( + screen.queryByRole("option", { name: "Option 3" }) + ).toHaveFocus(); + }); + }); + }); + + describe("dropdown is opened", () => { + it("should focus the next item with Arrow Down", async () => { + const user = userEvent.setup(); + + render( + + ); + + await act(async () => { + await user.keyboard("{Tab}{Home}"); + }); + + // opens the list and focuses the first item + await waitFor(() => { + expect(screen.queryByTestId(DROPDOWN_TESTID)).toBeVisible(); + expect( + screen.queryByRole("option", { name: "Option 1" }) + ).toHaveFocus(); + }); + + await act(async () => { + await user.keyboard("{ArrowDown}"); + }); + + // focuses the next item + await waitFor(() => { + expect( + screen.queryByRole("option", { name: "Option 2" }) + ).toHaveFocus(); + }); + + await act(async () => { + await user.keyboard("{ArrowDown}"); + }); + + // focuses the next item + await waitFor(() => { + expect( + screen.queryByRole("option", { name: "Option 3" }) + ).toHaveFocus(); + }); + + await act(async () => { + await user.keyboard("{ArrowDown}"); + }); + + // stays on the last item once the end of the list is reached + expect( + screen.queryByRole("option", { name: "Option 3" }) + ).toHaveFocus(); + }); + + it("should focus the previous item with Arrow Up", async () => { + const user = userEvent.setup(); + + render( + + ); + + await act(async () => { + await user.keyboard("{Tab}{End}"); + }); + + // opens the list and focuses the last item + await waitFor(() => { + expect(screen.queryByTestId(DROPDOWN_TESTID)).toBeVisible(); + expect( + screen.queryByRole("option", { name: "Option 3" }) + ).toHaveFocus(); + }); + + await act(async () => { + await user.keyboard("{ArrowUp}"); + }); + + // focuses the previous item + await waitFor(() => { + expect( + screen.queryByRole("option", { name: "Option 2" }) + ).toHaveFocus(); + }); + + await act(async () => { + await user.keyboard("{ArrowUp}"); + }); + + // focuses the previous item + await waitFor(() => { + expect( + screen.queryByRole("option", { name: "Option 1" }) + ).toHaveFocus(); + }); + + await act(async () => { + await user.keyboard("{ArrowUp}"); + }); + + // stays on the first item once the top of the list is reached + expect( + screen.queryByRole("option", { name: "Option 1" }) + ).toHaveFocus(); + }); + }); + }); }); diff --git a/tests/input-select/input-select.spec.tsx b/tests/input-select/input-select.spec.tsx index dd318fc17..0b756f603 100644 --- a/tests/input-select/input-select.spec.tsx +++ b/tests/input-select/input-select.spec.tsx @@ -97,7 +97,7 @@ describe("InputSelect", () => { }); describe("focus/blur behaviour", () => { - it("should call onBlur via outside click", async () => { + fit("should call onBlur via outside click after option is selected", async () => { const user = userEvent.setup(); const mockOnBlur = jest.fn(); @@ -128,9 +128,43 @@ describe("InputSelect", () => { expect(mockOnBlur).toHaveBeenCalledTimes(0); - await act(async () => { - await user.click(document.body); - }); + await user.click(document.body); + + expect(mockOnBlur).toHaveBeenCalledTimes(1); + }); + + it("should call onBlur via outside click after dropdown is toggled", async () => { + const user = userEvent.setup(); + const mockOnBlur = jest.fn(); + + render( + + ); + + await user.click(screen.getByTestId(FIELD_TESTID)); + + await waitFor(() => screen.getByTestId(DROPDOWN_TESTID)); + await waitFor(() => + expect( + screen.queryByRole("option", { name: "Option 1" }) + ).toHaveFocus() + ); + + expect(mockOnBlur).toHaveBeenCalledTimes(0); + + await user.click(screen.getByTestId(FIELD_TESTID)); + + await waitForElementToBeRemoved(() => + screen.queryByTestId(DROPDOWN_TESTID) + ); + + expect(mockOnBlur).toHaveBeenCalledTimes(0); + + await user.click(document.body); expect(mockOnBlur).toHaveBeenCalledTimes(1); }); @@ -402,4 +436,246 @@ describe("InputSelect", () => { expect(screen.queryByText("Option 1")).not.toBeInTheDocument(); }); }); + + describe("keyboard behaviour", () => { + describe("dropdown is closed", () => { + it("should focus the next item with Arrow Down", async () => { + const user = userEvent.setup(); + + render( + + ); + + await act(async () => { + await user.keyboard("{Tab}"); + await user.keyboard("{ArrowDown}"); + }); + + // opens the list and focuses the first item + await waitFor(() => { + expect(screen.queryByTestId(DROPDOWN_TESTID)).toBeVisible(); + expect( + screen.queryByRole("option", { name: "Option 1" }) + ).toHaveFocus(); + }); + }); + + it("should focus the next item with Arrow Down, given there is a selected option", async () => { + const user = userEvent.setup(); + + render( + + ); + + await act(async () => { + await user.keyboard("{Tab}"); + await user.keyboard("{ArrowDown}"); + }); + + await waitFor(() => { + expect(screen.queryByTestId(DROPDOWN_TESTID)).toBeVisible(); + expect( + screen.queryByRole("option", { name: "Option 3" }) + ).toHaveFocus(); + }); + }); + + it("should focus the previous item with Arrow Up", async () => { + const user = userEvent.setup(); + + render( + + ); + + await act(async () => { + await user.keyboard("{Tab}"); + await user.keyboard("{ArrowUp}"); + }); + + // opens the list and focuses the first item + await waitFor(() => { + expect(screen.queryByTestId(DROPDOWN_TESTID)).toBeVisible(); + expect( + screen.queryByRole("option", { name: "Option 1" }) + ).toHaveFocus(); + }); + }); + + it("should focus the previous item with Arrow Up, given there is a selected option", async () => { + const user = userEvent.setup(); + + render( + + ); + + await act(async () => { + await user.keyboard("{Tab}"); + await user.keyboard("{ArrowUp}"); + }); + + await waitFor(() => { + expect(screen.queryByTestId(DROPDOWN_TESTID)).toBeVisible(); + expect( + screen.queryByRole("option", { name: "Option 2" }) + ).toHaveFocus(); + }); + }); + + it("should focus the first item with Home", async () => { + const user = userEvent.setup(); + + render( + + ); + + await act(async () => { + await user.keyboard("{Tab}"); + await user.keyboard("{Home}"); + }); + + await waitFor(() => { + expect(screen.queryByTestId(DROPDOWN_TESTID)).toBeVisible(); + expect( + screen.queryByRole("option", { name: "Option 1" }) + ).toHaveFocus(); + }); + }); + + it("should focus the last item with End", async () => { + const user = userEvent.setup(); + + render( + + ); + + await act(async () => { + await user.keyboard("{Tab}"); + await user.keyboard("{End}"); + }); + + await waitFor(() => { + expect(screen.queryByTestId(DROPDOWN_TESTID)).toBeVisible(); + expect( + screen.queryByRole("option", { name: "Option 3" }) + ).toHaveFocus(); + }); + }); + }); + + describe("dropdown is opened", () => { + it("should focus the next item with Arrow Down", async () => { + const user = userEvent.setup(); + + render( + + ); + + await act(async () => { + await user.keyboard("{Tab}{Home}"); + }); + + // opens the list and focuses the first item + await waitFor(() => { + expect(screen.queryByTestId(DROPDOWN_TESTID)).toBeVisible(); + expect( + screen.queryByRole("option", { name: "Option 1" }) + ).toHaveFocus(); + }); + + await act(async () => { + await user.keyboard("{ArrowDown}"); + }); + + // focuses the next item + await waitFor(() => { + expect( + screen.queryByRole("option", { name: "Option 2" }) + ).toHaveFocus(); + }); + + await act(async () => { + await user.keyboard("{ArrowDown}"); + }); + + // focuses the next item + await waitFor(() => { + expect( + screen.queryByRole("option", { name: "Option 3" }) + ).toHaveFocus(); + }); + + await act(async () => { + await user.keyboard("{ArrowDown}"); + }); + + // stays on the last item once the end of the list is reached + expect( + screen.queryByRole("option", { name: "Option 3" }) + ).toHaveFocus(); + }); + + it("should focus the previous item with Arrow Up", async () => { + const user = userEvent.setup(); + + render( + + ); + + await act(async () => { + await user.keyboard("{Tab}{End}"); + }); + + // opens the list and focuses the last item + await waitFor(() => { + expect(screen.queryByTestId(DROPDOWN_TESTID)).toBeVisible(); + expect( + screen.queryByRole("option", { name: "Option 3" }) + ).toHaveFocus(); + }); + + await act(async () => { + await user.keyboard("{ArrowUp}"); + }); + + // focuses the previous item + await waitFor(() => { + expect( + screen.queryByRole("option", { name: "Option 2" }) + ).toHaveFocus(); + }); + + await act(async () => { + await user.keyboard("{ArrowUp}"); + }); + + // focuses the previous item + await waitFor(() => { + expect( + screen.queryByRole("option", { name: "Option 1" }) + ).toHaveFocus(); + }); + + await act(async () => { + await user.keyboard("{ArrowUp}"); + }); + + // stays on the first item once the top of the list is reached + expect( + screen.queryByRole("option", { name: "Option 1" }) + ).toHaveFocus(); + }); + }); + }); }); diff --git a/tests/phone-number-input/phone-number-input.spec.tsx b/tests/phone-number-input/phone-number-input.spec.tsx index 06f12e392..8c7b7e4bd 100644 --- a/tests/phone-number-input/phone-number-input.spec.tsx +++ b/tests/phone-number-input/phone-number-input.spec.tsx @@ -7,7 +7,7 @@ const SELECTOR_TESTID = "selector"; const LABEL = "Test label"; const DESCRIPTION = "Test subtitle"; const DROPDOWN_LABEL = `${LABEL} Country code`; -const DROPDOWN_INSTRUCTION = "Press space to open options"; +const DROPDOWN_INSTRUCTION = "Press Space or Enter to open options"; const TEXTBOX_LABEL = `${LABEL} Enter phone number`; const ERROR = "Error message"; diff --git a/tests/predictive-text-input/predictive-text-input.spec.tsx b/tests/predictive-text-input/predictive-text-input.spec.tsx index b43c2ebbd..bba62f07b 100644 --- a/tests/predictive-text-input/predictive-text-input.spec.tsx +++ b/tests/predictive-text-input/predictive-text-input.spec.tsx @@ -492,9 +492,11 @@ describe("PredictiveTextInput", () => { await user.keyboard("{ArrowDown}"); }); - expect( - screen.getByRole("option", { name: "Option 1" }) - ).toHaveFocus(); + await waitFor(() => { + expect( + screen.getByRole("option", { name: "Option 1" }) + ).toHaveFocus(); + }); await act(async () => { await user.keyboard("{Escape}");