Explorar o código

fix: focus traps inside popovers (#5317)

David Luzar %!s(int64=2) %!d(string=hai) anos
pai
achega
af31e9dcc2
Modificáronse 4 ficheiros con 54 adicións e 22 borrados
  1. 3 14
      src/components/ColorPicker.tsx
  2. 1 8
      src/components/Dialog.tsx
  3. 37 0
      src/components/Popover.tsx
  4. 13 0
      src/utils.ts

+ 3 - 14
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}
       >
         <div className="color-picker-content--default">
           {renderColors(colors)}

+ 1 - 8
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<HTMLElement>(
-      "button, a, input, select, textarea, div[tabindex]",
-    );
-
-    return focusableElements ? Array.from(focusableElements) : [];
-  };
-
   const onClose = () => {
     (lastActiveElement as HTMLElement).focus();
     props.onCloseRequest();

+ 37 - 0
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<HTMLDivElement>(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) {

+ 13 - 0
src/utils.ts

@@ -668,3 +668,16 @@ export const isPromiseLike = (
     "finally" in value
   );
 };
+
+export const queryFocusableElements = (container: HTMLElement | null) => {
+  const focusableElements = container?.querySelectorAll<HTMLElement>(
+    "button, a, input, select, textarea, div[tabindex], label[tabindex]",
+  );
+
+  return focusableElements
+    ? Array.from(focusableElements).filter(
+        (element) =>
+          element.tabIndex > -1 && !(element as HTMLInputElement).disabled,
+      )
+    : [];
+};