diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index fd284752..76a66b5b 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -129,19 +129,7 @@ const Picker = ({ const handleKeyDown = (event: React.KeyboardEvent) => { let handled = false; - if (event.key === KEYS.TAB) { - handled = true; - const { activeElement } = document; - if (event.shiftKey) { - if (activeElement === firstItem.current) { - colorInput.current?.focus(); - event.preventDefault(); - } - } else if (activeElement === colorInput.current) { - firstItem.current?.focus(); - event.preventDefault(); - } - } else if (isArrowKey(event.key)) { + if (isArrowKey(event.key)) { handled = true; const { activeElement } = document; const isRTL = getLanguage().rtl; @@ -272,7 +260,8 @@ const Picker = ({ gallery.current = el; } }} - tabIndex={0} + // to allow focusing by clicking but not by tabbing + tabIndex={-1} >
{renderColors(colors)} diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index 06615101..f406a8bb 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -9,6 +9,7 @@ import { back, close } from "./icons"; import { Island } from "./Island"; import { Modal } from "./Modal"; import { AppState } from "../types"; +import { queryFocusableElements } from "../utils"; export interface DialogProps { children: React.ReactNode; @@ -64,14 +65,6 @@ export const Dialog = (props: DialogProps) => { return () => islandNode.removeEventListener("keydown", handleKeyDown); }, [islandNode, props.autofocus]); - const queryFocusableElements = (node: HTMLElement) => { - const focusableElements = node.querySelectorAll( - "button, a, input, select, textarea, div[tabindex]", - ); - - return focusableElements ? Array.from(focusableElements) : []; - }; - const onClose = () => { (lastActiveElement as HTMLElement).focus(); props.onCloseRequest(); diff --git a/src/components/Popover.tsx b/src/components/Popover.tsx index ca47d728..ba424f3a 100644 --- a/src/components/Popover.tsx +++ b/src/components/Popover.tsx @@ -1,6 +1,8 @@ import React, { useLayoutEffect, useRef, useEffect } from "react"; import "./Popover.scss"; import { unstable_batchedUpdates } from "react-dom"; +import { queryFocusableElements } from "../utils"; +import { KEYS } from "../keys"; type Props = { top?: number; @@ -27,6 +29,41 @@ export const Popover = ({ }: Props) => { const popoverRef = useRef(null); + const container = popoverRef.current; + + useEffect(() => { + if (!container) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === KEYS.TAB) { + const focusableElements = queryFocusableElements(container); + const { activeElement } = document; + const currentIndex = focusableElements.findIndex( + (element) => element === activeElement, + ); + + if (currentIndex === 0 && event.shiftKey) { + focusableElements[focusableElements.length - 1].focus(); + event.preventDefault(); + event.stopImmediatePropagation(); + } else if ( + currentIndex === focusableElements.length - 1 && + !event.shiftKey + ) { + focusableElements[0].focus(); + event.preventDefault(); + event.stopImmediatePropagation(); + } + } + }; + + container.addEventListener("keydown", handleKeyDown); + + return () => container.removeEventListener("keydown", handleKeyDown); + }, [container]); + // ensure the popover doesn't overflow the viewport useLayoutEffect(() => { if (fitInViewport && popoverRef.current) { diff --git a/src/utils.ts b/src/utils.ts index d81ddfec..2e651ef8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -668,3 +668,16 @@ export const isPromiseLike = ( "finally" in value ); }; + +export const queryFocusableElements = (container: HTMLElement | null) => { + const focusableElements = container?.querySelectorAll( + "button, a, input, select, textarea, div[tabindex], label[tabindex]", + ); + + return focusableElements + ? Array.from(focusableElements).filter( + (element) => + element.tabIndex > -1 && !(element as HTMLInputElement).disabled, + ) + : []; +};