Files
psysonic/src/components/Modal.tsx
T
Psychotoxical 30ccaf51a8 fix(updater): rebuild the update modal on a reusable Modal (#1142)
* feat(ui): add reusable Modal component

Portal + dimmed backdrop (no backdrop-filter — on WebKitGTK the blur bleeds
onto modal content on some GPU stacks), Escape/backdrop/X to close, header
(icon/title/subtitle) + scrollable body + footer slot. First consumer is the
update modal; the other hand-rolled modals migrate onto it over time.

* fix(updater): rebuild the update modal on the reusable Modal

Fixes a user report (Manjaro / AMD / X11): blurry modal text (the eq-popup
backdrop-filter bled onto the content), the version arrow sitting between the
two header rows, and the unclear Skip / Remind buttons. Now uses the no-blur
Modal shell; the header icon aligns with the title; Skip is a clear button and
Remind me later is the accent action when there is no in-app install. Drops the
now-dead update-modal shell CSS.

* docs(changelog): update notification popup fix (PR #1142)
2026-06-21 00:48:26 +02:00

79 lines
2.6 KiB
TypeScript

import { type ReactNode, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
interface ModalProps {
open: boolean;
/** Called on Escape, backdrop click, and the header close button. */
onClose: () => void;
title: ReactNode;
/** Smaller line under the title (e.g. a version range). */
subtitle?: ReactNode;
/** Leading icon, aligned with the title line. */
icon?: ReactNode;
/** Action row pinned to the bottom, above the modal edge. */
footer?: ReactNode;
size?: 'sm' | 'md' | 'lg';
/** Hide the header close (X) button. */
hideClose?: boolean;
/** Accessible label / tooltip for the close button. */
closeLabel?: string;
children: ReactNode;
}
/**
* Reusable modal shell: a portal to `document.body` with an opaque, centered
* card over a dimmed backdrop. Closes on Escape, backdrop click and the header
* X. Deliberately uses **no `backdrop-filter`** — on WebKitGTK (some GPU stacks)
* the blur bleeds onto the modal content, making text look unfocused. The other
* hand-rolled modals migrate onto this over time.
*/
export default function Modal({
open, onClose, title, subtitle, icon, footer, size = 'md', hideClose, closeLabel, children,
}: ModalProps) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div className="ui-modal-backdrop" onClick={onClose}>
<div
className={`ui-modal ui-modal--${size}`}
role="dialog"
aria-modal="true"
onClick={e => e.stopPropagation()}
>
<div className="ui-modal-header">
{icon && <span className="ui-modal-icon">{icon}</span>}
<div className="ui-modal-titles">
<span className="ui-modal-title">{title}</span>
{subtitle != null && <span className="ui-modal-subtitle">{subtitle}</span>}
</div>
{!hideClose && (
<button
type="button"
className="ui-modal-close"
onClick={onClose}
aria-label={closeLabel ?? 'Close'}
data-tooltip={closeLabel}
data-tooltip-pos="bottom"
>
<X size={16} />
</button>
)}
</div>
<div className="ui-modal-body">{children}</div>
{footer && <div className="ui-modal-footer">{footer}</div>}
</div>
</div>,
document.body,
);
}