198 lines
6.6 KiB
TypeScript
198 lines
6.6 KiB
TypeScript
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;
|
||
}
|