Files
silverwindandwxiaoguang e645afbb3f refactor: replace Fomantic modal with native dialog (#39846)
Replace Fomantic's hard-forked jQuery modal with native `<dialog>`
elements, advancing https://github.com/go-gitea/gitea/issues/29849. The
UI is visually unchanged. Dialogs open with `show()` over an `inert`
page, so tooltips, popups and toasts mounted on `<body>` stay usable
above them, unlike with `showModal()`
(https://github.com/whatwg/html/issues/9936).

- Remove the Fomantic modal and dimmer, add `showModal` and `hideModal`
- Declare modals as `<dialog class="modal-dialog">`, shown inside a
`.modal-backdrop`, instead of `ui modal` with its unused size classes
- Remove two modals that could never be opened
- Block the page while a modal is open, close the topmost one on Escape
and restore focus to its opener
- Allow stacked modals, use a confirm modal instead of `window.confirm`
for deleting content history
- Hide popups and toasts when a modal opens or closes
- Keep the release notes modal open when generating notes fails

Fixes: https://github.com/go-gitea/gitea/issues/39643
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-20 12:32:27 +02:00

113 lines
4.4 KiB
TypeScript

import {hideAll} from 'tippy.js';
import {generateElemId, queryElems} from '../utils/dom.ts';
import {hideToastsAll} from './toast.ts';
type ModalOpts = {
onApprove?: () => boolean | void, // return false to keep the modal open
onShow?: () => void | Promise<void>,
onHidden?: () => void,
};
const modalStates = new WeakMap<Element, {opts: ModalOpts, opener: Element | null}>();
const topModal = () => Array.from(document.querySelectorAll<HTMLDialogElement>('.modal-dialog[open]')).at(-1);
function blockPage(elBackdrop?: Element | null) {
const wasBlocked = document.body.classList.contains('modal-page-blocked');
const shouldBlock = Boolean(elBackdrop);
if (wasBlocked !== shouldBlock) {
// keep the page width when its scrollbar disappears
document.body.style.marginRight = shouldBlock ? `${window.innerWidth - document.documentElement.clientWidth}px` : '';
document.body.classList.toggle('modal-page-blocked', shouldBlock);
}
for (const el of document.body.children) {
(el as HTMLElement).inert = shouldBlock && el !== elBackdrop;
}
}
export function initGlobalModal() {
// on window, so popups handling Escape on document can consume it first
window.addEventListener('keydown', (e) => {
if (e.key !== 'Escape' || e.defaultPrevented || e.isComposing) return;
const elModal = topModal();
if (elModal) hideModal(elModal);
});
}
function defaultApprove(elApprove: Element): boolean {
const elForm = elApprove.closest('form');
if (!elForm) return true;
// if there is traditional form, keep the modal open while the form submits
// "form-fetch-action" form's submit (is-loading) is handled by the fetch-action module
if (!elForm.matches('.form-fetch-action') && elForm.reportValidity()) {
elForm.classList.add('is-loading');
}
return false;
}
function createModalBackdrop(elModal: HTMLDialogElement): HTMLElement {
const elBackdrop = document.createElement('div');
elBackdrop.className = 'modal-backdrop';
elBackdrop.append(elModal);
const elAriaLabelTarget = elModal.querySelector(':scope > .header, :scope > .content');
if (elAriaLabelTarget) {
elAriaLabelTarget.id ||= generateElemId('_modal_label_');
elModal.setAttribute('aria-labelledby', elAriaLabelTarget.id);
}
let backdropPressed = false;
elBackdrop.addEventListener('pointerdown', (event) => { backdropPressed = event.target === elBackdrop });
elBackdrop.addEventListener('click', (event) => {
const target = event.target as Element;
if (target === elBackdrop) {
if (backdropPressed) hideModal(elModal);
} else if (target.closest('.close-modal, .actions .cancel')) {
hideModal(elModal);
} else {
const elApprove = target.closest('.actions :is(.approve, .ok)');
if (!elApprove) return;
const {onApprove} = modalStates.get(elModal)!.opts;
if ((onApprove ? onApprove() : defaultApprove(elApprove)) !== false) hideModal(elModal);
}
});
document.body.append(elBackdrop);
return elBackdrop;
}
export function showModal(elModal: HTMLDialogElement, opts: ModalOpts = {}) {
if (elModal.open) return;
const elBackdrop = elModal.parentElement?.matches('.modal-backdrop') ? elModal.parentElement : createModalBackdrop(elModal);
modalStates.set(elModal, {opts, opener: document.activeElement});
elModal.querySelector('.close-modal')?.setAttribute('aria-label', 'Close');
// Avoid "cancel" buttons submitting the form, otherwise "Enter" in the form clicks this "cancel" button before the "ok" button
queryElems(elModal, 'form button.cancel:not([type])', (button) => button.setAttribute('type', 'button'));
hideToastsAll();
hideAll();
blockPage(elBackdrop);
elModal.show(); // not showModal: popups and toasts mounted on body must stay usable above it, see https://github.com/whatwg/html/issues/9936
opts.onShow?.();
}
export function hideModal(elModal: HTMLDialogElement) {
if (!elModal.open) return;
const {opts, opener} = modalStates.get(elModal)!;
const elBackdrop = elModal.parentElement!;
elModal.close();
blockPage(topModal()?.parentElement);
// don't leave the focus in the closed dialog when the opener is gone
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
if (opener instanceof HTMLElement) opener.focus();
hideToastsAll();
queryElems(elModal, 'form', (form: HTMLFormElement) => form.reset());
opts.onHidden?.();
if (!elBackdrop.contains(elModal)) elBackdrop.remove();
}