mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-26 06:16:56 +09:00
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>
This commit is contained in:
1 parent
7610336d74
commit
e645afbb3f
95 files changed
+607
-1719
No files matched your search
@@ -0,0 +1,112 @@
|
||||
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();
|
||||
}
|
||||
Reference in new issue
Block a user