mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-26 06:16:56 +09:00
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>
113 lines
4.4 KiB
TypeScript
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();
|
|
}
|