Files
govoplan-core/webui/src/components/Dialog.tsx
T

198 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useId, useRef, type CSSProperties, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext";
import { shouldCloseDialogOnBackdrop } from "./dialogInteractions";
import {
dialogIsTopmost,
nextDialogActivationOrder,
registerDialog,
type DialogStackId
} from "./dialogStack";
import type { PlatformInterfaceIdentityProps } from "../types";
import { DialogActions, type DialogActionAlignment } from "./DialogAnatomy";
export type DialogSize = "small" | "default" | "large" | "wide" | "full";
export type DialogVariant = "default" | "administration";
export type DialogBodyPadding = "none" | "compact" | "default";
export type DialogProps = PlatformInterfaceIdentityProps & {
open: boolean;
title: ReactNode;
children: ReactNode;
footer?: ReactNode;
description?: ReactNode;
notices?: ReactNode;
size?: DialogSize;
variant?: DialogVariant;
bodyPadding?: DialogBodyPadding;
footerAlignment?: DialogActionAlignment;
role?: "dialog" | "alertdialog";
ariaDescribedBy?: string;
closeLabel?: string;
closeOnBackdrop?: boolean;
showCloseButton?: boolean;
closeDisabled?: boolean;
onClose?: () => void;
className?: string;
backdropClassName?: string;
headerClassName?: string;
titleClassName?: string;
bodyClassName?: string;
footerClassName?: string;
portal?: boolean;
panelStyle?: CSSProperties;
backdropStyle?: CSSProperties;
};
function joinClasses(...classes: Array<string | undefined | false>) {
return classes.filter(Boolean).join(" ");
}
export default function Dialog({
open,
title,
children,
footer,
description,
notices,
size = "default",
variant = "default",
bodyPadding = "default",
footerAlignment = "end",
role = "dialog",
ariaDescribedBy,
closeLabel = "i18n:govoplan-core.close.bbfa773e",
closeOnBackdrop = true,
showCloseButton = true,
closeDisabled = false,
onClose,
className = "",
backdropClassName = "",
headerClassName = "",
titleClassName = "",
bodyClassName = "",
footerClassName = "",
portal = false,
panelStyle,
backdropStyle,
interfaceId,
helpContextId,
helpModuleId,
helpTopicId
}: DialogProps) {
const titleId = useId();
const canClose = Boolean(onClose) && !closeDisabled;
const panelRef = useRef<HTMLElement | null>(null);
const stackIdRef = useRef<DialogStackId>(Symbol("govoplan-dialog"));
const activationOrderRef = useRef<number | null>(null);
const canCloseRef = useRef(canClose);
const onCloseRef = useRef(onClose);
const restoreFocusRef = useRef<HTMLElement | null>(
typeof document !== "undefined" && typeof HTMLElement !== "undefined" && document.activeElement instanceof HTMLElement
? document.activeElement
: null
);
canCloseRef.current = canClose;
onCloseRef.current = onClose;
if (!open) activationOrderRef.current = null;
else if (activationOrderRef.current === null) activationOrderRef.current = nextDialogActivationOrder();
const { translateText } = usePlatformLanguage();
const renderedTitle = translateReactNode(title, translateText);
const renderedChildren = translateReactNode(children, translateText);
const renderedFooter = translateReactNode(footer, translateText);
const renderedDescription = translateReactNode(description, translateText);
const renderedNotices = translateReactNode(notices, translateText);
const translatedCloseLabel = translateText(closeLabel);
useEffect(() => {
if (open || typeof document === "undefined") return undefined;
const rememberFocusedElement = () => {
const activeElement = document.activeElement;
if (!(activeElement instanceof HTMLElement)) return;
if (panelRef.current?.contains(activeElement)) return;
restoreFocusRef.current = activeElement;
};
rememberFocusedElement();
document.addEventListener("focusin", rememberFocusedElement);
return () => document.removeEventListener("focusin", rememberFocusedElement);
}, [open]);
useEffect(() => {
if (!open || typeof document === "undefined") return undefined;
const panel = panelRef.current;
const activationOrder = activationOrderRef.current;
if (!panel || activationOrder === null) return undefined;
return registerDialog({
id: stackIdRef.current,
activationOrder,
panel,
restoreFocus: restoreFocusRef.current,
canClose: () => canCloseRef.current,
onClose: () => onCloseRef.current?.()
}, document.activeElement);
}, [open]);
if (!open) return null;
const dialog = (
<div
className={joinClasses("dialog-backdrop", backdropClassName)}
style={backdropStyle}
role="presentation"
onMouseDown={(event) => {
if (
dialogIsTopmost(stackIdRef.current)
&& shouldCloseDialogOnBackdrop(event.target, event.currentTarget, closeOnBackdrop, canClose)
) onClose?.();
}}
>
<section
ref={panelRef}
tabIndex={-1}
className={joinClasses("dialog-panel", `dialog-panel-size-${size}`, `dialog-panel-${variant}`, className)}
style={panelStyle}
role={role}
aria-modal="true"
data-dialog-stack-state="topmost"
data-help-scope="dialog"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
data-help-module-id={helpModuleId}
data-help-topic-id={helpTopicId}
data-help-key={typeof title === "string" ? title : undefined}
aria-labelledby={titleId}
aria-describedby={ariaDescribedBy}
>
<div className={joinClasses("dialog-header", headerClassName)}>
<h2 id={titleId} className={joinClasses("dialog-title", titleClassName)}>{renderedTitle}</h2>
{showCloseButton && onClose && (
<button
type="button"
className="dialog-close"
onClick={onClose}
aria-label={translatedCloseLabel}
disabled={closeDisabled}
>
×
</button>
)}
</div>
<div className={joinClasses("dialog-body", `dialog-body-padding-${bodyPadding}`, bodyClassName)}>
{description && <div className="dialog-description">{renderedDescription}</div>}
{notices && <div className="dialog-notices">{renderedNotices}</div>}
{renderedChildren}
</div>
{footer && <div className={joinClasses("dialog-footer", footerClassName)}><DialogActions align={footerAlignment}>{renderedFooter}</DialogActions></div>}
</section>
</div>
);
return portal && typeof document !== "undefined"
? createPortal(dialog, document.body)
: dialog;
}