Files
excalidraw/packages/excalidraw/components/Dialog.tsx
T
Ryan Di 47cbb5b6fb refactor: single source of truths with editor interface (#10178)
* refactor device to editor interface and derive styles panel

* allow host app to control form factor and ui mode

* add editor interface event listener

* put new props inside UIOptions

* refactor: move related apis into one file

* expose getFormFactor

* privatize the setting of desktop mode and fix snapshots

* refactor and fix test

* remove unimplemented code

* export getFormFactor()

* replace `getFormFactor` with `getEditorInterface`

* remove dead & useless

* comment

* fix ts

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-11-03 23:34:17 +01:00

138 lines
3.8 KiB
TypeScript

import clsx from "clsx";
import React, { useEffect, useState } from "react";
import { KEYS, queryFocusableElements } from "@excalidraw/common";
import { useSetAtom } from "../editor-jotai";
import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { t } from "../i18n";
import {
useExcalidrawContainer,
useEditorInterface,
useExcalidrawSetAppState,
} from "./App";
import { Island } from "./Island";
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
import { Modal } from "./Modal";
import { CloseIcon } from "./icons";
import "./Dialog.scss";
export type DialogSize = number | "small" | "regular" | "wide" | undefined;
export interface DialogProps {
children: React.ReactNode;
className?: string;
size?: DialogSize;
onCloseRequest(): void;
title: React.ReactNode | false;
autofocus?: boolean;
closeOnClickOutside?: boolean;
}
function getDialogSize(size: DialogSize): number {
if (size && typeof size === "number") {
return size;
}
switch (size) {
case "small":
return 550;
case "wide":
return 1024;
case "regular":
default:
return 800;
}
}
export const Dialog = (props: DialogProps) => {
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
const [lastActiveElement] = useState(document.activeElement);
const { id } = useExcalidrawContainer();
const isFullscreen = useEditorInterface().formFactor === "phone";
useEffect(() => {
if (!islandNode) {
return;
}
const focusableElements = queryFocusableElements(islandNode);
setTimeout(() => {
if (focusableElements.length > 0 && props.autofocus !== false) {
// If there's an element other than close, focus it.
(focusableElements[1] || focusableElements[0]).focus();
}
});
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === KEYS.TAB) {
const focusableElements = queryFocusableElements(islandNode);
const { activeElement } = document;
const currentIndex = focusableElements.findIndex(
(element) => element === activeElement,
);
if (currentIndex === 0 && event.shiftKey) {
focusableElements[focusableElements.length - 1].focus();
event.preventDefault();
} else if (
currentIndex === focusableElements.length - 1 &&
!event.shiftKey
) {
focusableElements[0].focus();
event.preventDefault();
}
}
};
islandNode.addEventListener("keydown", handleKeyDown);
return () => islandNode.removeEventListener("keydown", handleKeyDown);
}, [islandNode, props.autofocus]);
const setAppState = useExcalidrawSetAppState();
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
const onClose = () => {
setAppState({ openMenu: null });
setIsLibraryMenuOpen(false);
(lastActiveElement as HTMLElement).focus();
props.onCloseRequest();
};
return (
<Modal
className={clsx("Dialog", props.className, {
"Dialog--fullscreen": isFullscreen,
})}
labelledBy="dialog-title"
maxWidth={getDialogSize(props.size)}
onCloseRequest={onClose}
closeOnClickOutside={props.closeOnClickOutside}
>
<Island ref={setIslandNode}>
{props.title && (
<h2 id={`${id}-dialog-title`} className="Dialog__title">
<span className="Dialog__titleContent">{props.title}</span>
</h2>
)}
{isFullscreen && (
<button
className="Dialog__close"
onClick={onClose}
title={t("buttons.close")}
aria-label={t("buttons.close")}
type="button"
>
{CloseIcon}
</button>
)}
<div className="Dialog__content">{props.children}</div>
</Island>
</Modal>
);
};