Dialog.tsx 3.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. import clsx from "clsx";
  2. import React, { useEffect, useState } from "react";
  3. import { useCallbackRefState } from "../hooks/useCallbackRefState";
  4. import { t } from "../i18n";
  5. import {
  6. useExcalidrawContainer,
  7. useDevice,
  8. useExcalidrawSetAppState,
  9. } from "../components/App";
  10. import { KEYS } from "../keys";
  11. import "./Dialog.scss";
  12. import { back, CloseIcon } from "./icons";
  13. import { Island } from "./Island";
  14. import { Modal } from "./Modal";
  15. import { AppState } from "../types";
  16. import { queryFocusableElements } from "../utils";
  17. import { useSetAtom } from "jotai";
  18. import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
  19. export interface DialogProps {
  20. children: React.ReactNode;
  21. className?: string;
  22. small?: boolean;
  23. onCloseRequest(): void;
  24. title: React.ReactNode;
  25. autofocus?: boolean;
  26. theme?: AppState["theme"];
  27. closeOnClickOutside?: boolean;
  28. }
  29. export const Dialog = (props: DialogProps) => {
  30. const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
  31. const [lastActiveElement] = useState(document.activeElement);
  32. const { id } = useExcalidrawContainer();
  33. useEffect(() => {
  34. if (!islandNode) {
  35. return;
  36. }
  37. const focusableElements = queryFocusableElements(islandNode);
  38. if (focusableElements.length > 0 && props.autofocus !== false) {
  39. // If there's an element other than close, focus it.
  40. (focusableElements[1] || focusableElements[0]).focus();
  41. }
  42. const handleKeyDown = (event: KeyboardEvent) => {
  43. if (event.key === KEYS.TAB) {
  44. const focusableElements = queryFocusableElements(islandNode);
  45. const { activeElement } = document;
  46. const currentIndex = focusableElements.findIndex(
  47. (element) => element === activeElement,
  48. );
  49. if (currentIndex === 0 && event.shiftKey) {
  50. focusableElements[focusableElements.length - 1].focus();
  51. event.preventDefault();
  52. } else if (
  53. currentIndex === focusableElements.length - 1 &&
  54. !event.shiftKey
  55. ) {
  56. focusableElements[0].focus();
  57. event.preventDefault();
  58. }
  59. }
  60. };
  61. islandNode.addEventListener("keydown", handleKeyDown);
  62. return () => islandNode.removeEventListener("keydown", handleKeyDown);
  63. }, [islandNode, props.autofocus]);
  64. const setAppState = useExcalidrawSetAppState();
  65. const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
  66. const onClose = () => {
  67. setAppState({ openMenu: null });
  68. setIsLibraryMenuOpen(false);
  69. (lastActiveElement as HTMLElement).focus();
  70. props.onCloseRequest();
  71. };
  72. return (
  73. <Modal
  74. className={clsx("Dialog", props.className)}
  75. labelledBy="dialog-title"
  76. maxWidth={props.small ? 550 : 800}
  77. onCloseRequest={onClose}
  78. theme={props.theme}
  79. closeOnClickOutside={props.closeOnClickOutside}
  80. >
  81. <Island ref={setIslandNode}>
  82. <h2 id={`${id}-dialog-title`} className="Dialog__title">
  83. <span className="Dialog__titleContent">{props.title}</span>
  84. <button
  85. className="Modal__close"
  86. onClick={onClose}
  87. title={t("buttons.close")}
  88. aria-label={t("buttons.close")}
  89. >
  90. {useDevice().isMobile ? back : CloseIcon}
  91. </button>
  92. </h2>
  93. <div className="Dialog__content">{props.children}</div>
  94. </Island>
  95. </Modal>
  96. );
  97. };