diff --git a/.changeset/tasty-cats-hear.md b/.changeset/tasty-cats-hear.md new file mode 100644 index 0000000000..f4ce284b01 --- /dev/null +++ b/.changeset/tasty-cats-hear.md @@ -0,0 +1,6 @@ +--- +"@navikt/ds-react": patch +"@navikt/ds-css": patch +--- + +Combobox: Support PageUp/PageDown in dropdown list. diff --git a/@navikt/core/css/form/combobox.css b/@navikt/core/css/form/combobox.css index 6d0fe3fd27..7c21750bbc 100644 --- a/@navikt/core/css/form/combobox.css +++ b/@navikt/core/css/form/combobox.css @@ -263,6 +263,7 @@ border-radius: var(--a-border-radius-medium); background-color: var(--ac-combobox-list-bg, var(--a-surface-default)); color: var(--ac-combobox-list-text, var(--a-text-default)); + overscroll-behavior: contain; } .navds-combobox__list--closed { diff --git a/@navikt/core/react/src/form/combobox/FilteredOptions/filteredOptionsContext.tsx b/@navikt/core/react/src/form/combobox/FilteredOptions/filteredOptionsContext.tsx index c51e67072f..459400b555 100644 --- a/@navikt/core/react/src/form/combobox/FilteredOptions/filteredOptionsContext.tsx +++ b/@navikt/core/react/src/form/combobox/FilteredOptions/filteredOptionsContext.tsx @@ -140,7 +140,7 @@ const FilteredOptionsProvider = ({ if (disabled || readOnly) { return; } - virtualFocus.moveFocusToTop(); + virtualFocus.resetFocus(); if (newState ?? !isInternalListOpen) { setHideCaret(!!maxSelected?.isLimitReached); } diff --git a/@navikt/core/react/src/form/combobox/FilteredOptions/useVirtualFocus.ts b/@navikt/core/react/src/form/combobox/FilteredOptions/useVirtualFocus.ts index f8b524bbe0..ac852a0ddd 100644 --- a/@navikt/core/react/src/form/combobox/FilteredOptions/useVirtualFocus.ts +++ b/@navikt/core/react/src/form/combobox/FilteredOptions/useVirtualFocus.ts @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; export type VirtualFocusType = { activeElement: HTMLElement | undefined; @@ -10,6 +10,9 @@ export type VirtualFocusType = { moveFocusToElement: (id: string) => void; moveFocusToTop: () => void; moveFocusToBottom: () => void; + moveFocusUpBy: (numberOfElements: number) => void; + moveFocusDownBy: (numberOfElements: number) => void; + resetFocus: () => void; }; const useVirtualFocus = ( @@ -40,11 +43,6 @@ const useVirtualFocus = ( : false; }; - const _moveFocusAndScrollTo = (_element?: HTMLElement) => { - setActiveElement(_element); - _element?.scrollIntoView?.({ block: "nearest" }); - }; - const moveFocusUp = () => { if (!activeElement) { return; @@ -55,14 +53,14 @@ const useVirtualFocus = ( if (_currentIndex === 0) { setActiveElement(undefined); } else { - _moveFocusAndScrollTo(elementAbove); + setActiveElement(elementAbove); } }; const moveFocusDown = () => { const elementsAbleToReceiveFocus = getElementsAbleToReceiveFocus(); if (!activeElement) { - _moveFocusAndScrollTo(elementsAbleToReceiveFocus[0]); + setActiveElement(elementsAbleToReceiveFocus[0]); return; } const _currentIndex = elementsAbleToReceiveFocus.indexOf(activeElement); @@ -70,13 +68,17 @@ const useVirtualFocus = ( return; } - _moveFocusAndScrollTo(elementsAbleToReceiveFocus[_currentIndex + 1]); + setActiveElement(elementsAbleToReceiveFocus[_currentIndex + 1]); }; - const moveFocusToTop = () => _moveFocusAndScrollTo(undefined); + const resetFocus = () => setActiveElement(undefined); + const moveFocusToTop = () => { + const elementsAbleToReceiveFocus = getElementsAbleToReceiveFocus(); + setActiveElement(elementsAbleToReceiveFocus[0]); + }; const moveFocusToBottom = () => { const elementsAbleToReceiveFocus = getElementsAbleToReceiveFocus(); - return _moveFocusAndScrollTo( + setActiveElement( elementsAbleToReceiveFocus[elementsAbleToReceiveFocus.length - 1], ); }; @@ -89,6 +91,32 @@ const useVirtualFocus = ( } }; + const moveFocusUpBy = (numberOfElements: number) => { + if (!activeElement) { + return; + } + const elementsAbleToReceiveFocus = getElementsAbleToReceiveFocus(); + const currentIndex = elementsAbleToReceiveFocus.indexOf(activeElement); + const newIndex = Math.max(currentIndex - numberOfElements, 0); + setActiveElement(elementsAbleToReceiveFocus[newIndex]); + }; + + const moveFocusDownBy = (numberOfElements: number) => { + const elementsAbleToReceiveFocus = getElementsAbleToReceiveFocus(); + const currentIndex = activeElement + ? elementsAbleToReceiveFocus.indexOf(activeElement) + : -1; + const newIndex = Math.min( + currentIndex + numberOfElements, + elementsAbleToReceiveFocus.length - 1, + ); + setActiveElement(elementsAbleToReceiveFocus[newIndex]); + }; + + useEffect(() => { + activeElement?.scrollIntoView?.({ block: "nearest" }); + }, [activeElement]); + return { activeElement, getElementById, @@ -99,6 +127,9 @@ const useVirtualFocus = ( moveFocusToElement, moveFocusToTop, moveFocusToBottom, + moveFocusUpBy, + moveFocusDownBy, + resetFocus, }; }; diff --git a/@navikt/core/react/src/form/combobox/Input/Input.tsx b/@navikt/core/react/src/form/combobox/Input/Input.tsx index 89b52c6ceb..09926669e3 100644 --- a/@navikt/core/react/src/form/combobox/Input/Input.tsx +++ b/@navikt/core/react/src/form/combobox/Input/Input.tsx @@ -134,14 +134,6 @@ const Input = forwardRef( case "Accept": onEnter(e); break; - case "Home": - toggleIsListOpen(false); - virtualFocus.moveFocusToTop(); - break; - case "End": - toggleIsListOpen(true); - virtualFocus.moveFocusToBottom(); - break; default: break; } @@ -202,6 +194,24 @@ const Input = forwardRef( } virtualFocus.moveFocusUp(); } + } else if (e.key === "Home") { + e.preventDefault(); + virtualFocus.moveFocusToTop(); + } else if (e.key === "End") { + e.preventDefault(); + if (virtualFocus.activeElement === null || !isListOpen) { + toggleIsListOpen(true); + } + virtualFocus.moveFocusToBottom(); + } else if (e.key === "PageUp") { + e.preventDefault(); + virtualFocus.moveFocusUpBy(6); + } else if (e.key === "PageDown") { + e.preventDefault(); + if (virtualFocus.activeElement === null || !isListOpen) { + toggleIsListOpen(true); + } + virtualFocus.moveFocusDownBy(6); } }, [ @@ -230,10 +240,9 @@ const Input = forwardRef( } else if (filteredOptions.length === 0) { toggleIsListOpen(false); } - virtualFocus.moveFocusToTop(); onChange(newValue); }, - [filteredOptions.length, virtualFocus, onChange, toggleIsListOpen], + [filteredOptions.length, onChange, toggleIsListOpen], ); return ( diff --git a/@navikt/core/react/src/form/combobox/__tests__/combobox.test.tsx b/@navikt/core/react/src/form/combobox/__tests__/combobox.test.tsx index 26d76384b7..ba183c0e0b 100644 --- a/@navikt/core/react/src/form/combobox/__tests__/combobox.test.tsx +++ b/@navikt/core/react/src/form/combobox/__tests__/combobox.test.tsx @@ -291,4 +291,40 @@ describe("Render combobox", () => { ); }); }); + + describe("has keyboard navigation", () => { + test("for PageDown and PageUp", async () => { + render(); + + const combobox = screen.getByRole("combobox", { + name: "Hva er dine favorittfrukter?", + }); + + const pressKey = async (key: string) => { + await act(async () => { + await userEvent.keyboard(`{${key}}`); + }); + }; + + const hasVirtualFocus = (option: string) => + expect(combobox.getAttribute("aria-activedescendant")).toBe( + screen.getByRole("option", { name: option }).id, + ); + + await act(async () => { + await userEvent.click(combobox); + }); + + await pressKey("ArrowDown"); + hasVirtualFocus("apple"); + await pressKey("PageDown"); + hasVirtualFocus("mango"); + await pressKey("PageDown"); + hasVirtualFocus("watermelon"); + await pressKey("PageUp"); + hasVirtualFocus("mango"); + await pressKey("PageUp"); + hasVirtualFocus("apple"); + }); + }); });