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:
silverwindandwxiaoguang authored and GitHub committed 2026-10-20 12:32:27 +02:00
1 parent 7610336d74
commit e645afbb3f
95 files changed
+607 -1719

No files matched your search

+3 -3
View File
@@ -1,7 +1,7 @@
import {checkAppUrl} from '../common-page.ts';
import {hideElem, queryElems, showElem, toggleElem} from '../../utils/dom.ts';
import {POST} from '../../modules/fetch.ts';
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
import {showModal} from '../../modules/modal.ts';
import {pathEscape} from '../../utils/url.ts';
import {registerGlobalInitFunc} from '../../modules/observer.ts';
@@ -274,7 +274,7 @@ function initAdminNotice() {
const pageContent = document.querySelector('.page-content.admin.notice');
if (!pageContent) return;
const detailModal = document.querySelector<HTMLDivElement>('#detail-modal')!;
const detailModal = document.querySelector<HTMLDialogElement>('#detail-modal')!;
// Attach view detail modals
queryElems(pageContent, '.view-detail', (el) => el.addEventListener('click', (e) => {
@@ -282,7 +282,7 @@ function initAdminNotice() {
const elNoticeDesc = el.closest('tr')!.querySelector('.notice-description')!;
const elModalDesc = detailModal.querySelector('.content pre')!;
elModalDesc.textContent = elNoticeDesc.textContent;
showFomanticModal(detailModal);
showModal(detailModal);
}));
// Select actions
+4 -4
View File
@@ -1,5 +1,5 @@
import {addDelegatedEventListener, hideElem, isElemVisible, showElem, toggleElem} from '../utils/dom.ts';
import {showFomanticModal} from '../modules/fomantic/modal.ts';
import {showModal} from '../modules/modal.ts';
import {camelize} from 'vue';
import {applyAutoFocus} from './common-page.ts';
@@ -90,7 +90,7 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) {
// to make the template variables get correctly escaped in the URL.
e.preventDefault();
const modalSelector = el.getAttribute('data-modal')!;
const elModal = document.querySelector(modalSelector);
const elModal = document.querySelector<HTMLDialogElement>(modalSelector);
if (!elModal) throw new Error('no modal for this action');
const modalAttrPrefix = 'data-modal-';
@@ -123,11 +123,11 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) {
}
}
showFomanticModal(elModal);
showModal(elModal);
}
export function initGlobalButtons(): void {
// There are many "cancel button" elements in modal dialogs, Fomantic UI expects they are button-like elements but never submit a form.
// There are many "cancel button" elements in modal dialogs, they are expected to be button-like elements but never submit a form.
// However, Gitea misuses the modal dialog and put the cancel buttons inside forms, so we must prevent the form submission.
// There are a few cancel buttons in non-modal forms, and there are some dynamically created forms (eg: the "Edit Issue Content")
addDelegatedEventListener(document, 'click', 'form button.ui.cancel.button', (_ /* el */, e) => e.preventDefault());
+1 -1
View File
@@ -126,7 +126,7 @@ function autoFocusEnd(el: HTMLInputElement | HTMLTextAreaElement) {
export function applyAutoFocus(container: Element) {
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/autofocus
// "autofocus" behavior is defined by the standard: when a container (e.g.: dialog) becomes visible, focus the element with "autofocus" attribute
// Fomantic UI already supports it for its modal dialog, we need to cover more cases (e.g.: ".show-panel" button)
// The native modal dialog already supports it, we need to cover more cases (e.g.: ".show-panel" button)
// Here is just a simple support, we don't expect more than one element that need "autofocus" appearing in the same container
container.querySelector<HTMLElement>('[autofocus]')?.focus();
// Also, apply our autoFocusEnd behavior
+7 -11
View File
@@ -1,8 +1,7 @@
import {svg} from '../../svg.ts';
import {html, htmlRaw} from '../../utils/html.ts';
import {createElementFromHTML} from '../../utils/dom.ts';
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
import {hideToastsAll} from '../../modules/toast.ts';
import {showModal} from '../../modules/modal.ts';
const {i18n} = window.config;
@@ -12,27 +11,24 @@ type ConfirmModalOptions = {
confirmButtonColor?: 'primary' | 'red' | 'green' | 'blue';
};
export function createConfirmModal({header = '', content = '', confirmButtonColor = 'primary'}:ConfirmModalOptions = {}): HTMLElement {
export function createConfirmModal({header = '', content = '', confirmButtonColor = 'primary'}:ConfirmModalOptions = {}): HTMLDialogElement {
const headerHtml = header ? html`<div class="header">${header}</div>` : '';
return createElementFromHTML(html`
<div class="ui g-modal-confirm modal">
<dialog class="modal-dialog">
${htmlRaw(headerHtml)}
<div class="content">${content}</div>
<div class="actions">
<button class="ui cancel button">${htmlRaw(svg('octicon-x'))} ${i18n.modal_cancel}</button>
<button class="ui ${confirmButtonColor} ok button" autofocus>${htmlRaw(svg('octicon-check'))} ${i18n.modal_confirm}</button>
</div>
</div>
</dialog>
`.trim());
}
export function confirmModal(modal: HTMLElement | ConfirmModalOptions): Promise<boolean> {
if (!(modal instanceof HTMLElement)) modal = createConfirmModal(modal);
// hide existing toasts when we need to show a new modal, otherwise the toasts only interfere the UI
// it's fine to do so because the modal is triggered by user's explicit action, so the user should already have read the toast messages
hideToastsAll();
export function confirmModal(modal: HTMLDialogElement | ConfirmModalOptions): Promise<boolean> {
if (!(modal instanceof HTMLDialogElement)) modal = createConfirmModal(modal);
return new Promise((resolve) => {
showFomanticModal(modal, {
showModal(modal, {
onApprove() {
resolve(true);
},
+3 -3
View File
@@ -1,5 +1,5 @@
import {queryElems, toggleElem} from '../../utils/dom.ts';
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
import {showModal} from '../../modules/modal.ts';
import {submitFormFetchAction} from '../../modules/fetch-action.ts';
import {registerGlobalInitFunc} from '../../modules/observer.ts';
@@ -7,7 +7,7 @@ function nameHasScope(name: string): boolean {
return /.*[^/]\/[^/].*/.test(name);
}
function initLabelEditModal(elModal: HTMLElement) {
function initLabelEditModal(elModal: HTMLDialogElement) {
const elLabelId = elModal.querySelector<HTMLInputElement>('input[name="id"]')!;
const elNameInput = elModal.querySelector<HTMLInputElement>('.label-name-input')!;
const elExclusiveField = elModal.querySelector('.label-exclusive-input-field')!;
@@ -59,7 +59,7 @@ function initLabelEditModal(elModal: HTMLElement) {
form.action = isEdit ? `${curPageLink}/edit` : `${curPageLink}/new`;
toggleElem(elIsArchivedField, isEdit);
syncModalUi();
showFomanticModal(elModal, {
showModal(elModal, {
onApprove() {
if (!form.checkValidity()) {
form.reportValidity();
+2 -2
View File
@@ -1,5 +1,5 @@
import {toggleElem} from '../utils/dom.ts';
import {showFomanticModal} from '../modules/fomantic/modal.ts';
import {showModal} from '../modules/modal.ts';
import {trString} from '../modules/i18n.ts';
import {registerGlobalEventFunc} from '../modules/observer.ts';
@@ -20,7 +20,7 @@ function initRepoCreateBranchButton() {
const fromSpanName = el.getAttribute('data-modal-from-span') || '#modal-create-branch-from-span';
document.querySelector(fromSpanName)!.textContent = el.getAttribute('data-branch-from');
showFomanticModal(document.querySelector(el.getAttribute('data-modal')!));
showModal(document.querySelector(el.getAttribute('data-modal')!)!);
});
}
}
+8 -9
View File
@@ -4,7 +4,8 @@ import {GET, POST} from '../modules/fetch.ts';
import {createElementFromHTML, showElem} from '../utils/dom.ts';
import {parseIssuePageInfo} from '../utils.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {hideFomanticModal, showFomanticModal} from '../modules/fomantic/modal.ts';
import {hideModal, showModal} from '../modules/modal.ts';
import {confirmModal} from './comp/ConfirmModal.ts';
import {html, htmlRaw} from '../utils/html.ts';
import type {JQueryElem} from '../types.ts';
@@ -14,8 +15,8 @@ let i18nTextDeleteFromHistory: string;
let i18nTextDeleteFromHistoryConfirm: string;
function showContentHistoryDetail(issueBaseUrl: string, commentId: string, historyId: string, itemTitleHtml: string) {
const elDetailDialog = createElementFromHTML(html`
<div class="ui modal content-history-detail-dialog">
const elDetailDialog = createElementFromHTML<HTMLDialogElement>(html`
<dialog class="modal-dialog content-history-detail-dialog">
<div class="header flex-left-right">
<div>${htmlRaw(itemTitleHtml)}</div>
<div class="ui dropdown dialog-header-options tw-mx-4 tw-hidden">
@@ -28,9 +29,8 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
<button type="button" class="close-modal"></button>
</div>
<div class="comment-diff-data is-loading"></div>
</div>
</dialog>
`);
document.body.append(elDetailDialog);
const elOptionsDropdown = elDetailDialog.querySelector('.ui.dropdown.dialog-header-options')!;
const $fomanticDropdownOptions = fomanticQuery(elOptionsDropdown);
$fomanticDropdownOptions.dropdown({
@@ -39,8 +39,7 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
async onChange(_value: string, _text: string, $item: JQueryElem) {
const optionItem = $item.data('option-item');
if (optionItem === 'delete') {
// TODO: to use confirmModal instead of window.confirm, need to support multiple modals on the same page first.
if (window.confirm(i18nTextDeleteFromHistoryConfirm)) {
if (await confirmModal({content: i18nTextDeleteFromHistoryConfirm, confirmButtonColor: 'red'})) {
try {
const params = new URLSearchParams();
params.append('comment_id', commentId);
@@ -50,7 +49,7 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
const resp = await response.json();
if (resp.ok) {
hideFomanticModal(elDetailDialog);
hideModal(elDetailDialog);
} else {
showErrorToast(resp.message);
}
@@ -67,7 +66,7 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
$fomanticDropdownOptions.dropdown('clear', true);
},
});
showFomanticModal(elDetailDialog, {
showModal(elDetailDialog, {
async onShow() {
try {
const params = new URLSearchParams();
+3 -3
View File
@@ -14,7 +14,7 @@ import {ComboMarkdownEditor, getComboMarkdownEditor, initComboMarkdownEditor} fr
import {GET, POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {showFomanticModal} from '../modules/fomantic/modal.ts';
import {showModal} from '../modules/modal.ts';
import {ignoreAreYouSure} from '../modules/are-you-sure.ts';
import {registerGlobalEventFunc, registerGlobalInitFunc} from '../modules/observer.ts';
import type {FomanticApiResponse} from '../types.ts';
@@ -325,10 +325,10 @@ export function initRepoIssueReferenceIssue() {
const poster = el.getAttribute('data-poster-username');
const reference = el.getAttribute('data-reference')!;
const modalSelector = el.getAttribute('data-modal')!;
const modal = document.querySelector(modalSelector)!;
const modal = document.querySelector<HTMLDialogElement>(modalSelector)!;
const textarea = modal.querySelector<HTMLTextAreaElement>('textarea[name="content"]')!;
textarea.value = `${content}\n\n_Originally posted by @${poster} in ${reference}_`;
showFomanticModal(modal);
showModal(modal);
});
}
+3 -3
View File
@@ -2,7 +2,7 @@ import {contrastColor} from '../utils/color.ts';
import {createSortable} from '../modules/sortable.ts';
import {POST} from '../modules/fetch.ts';
import {performFetchActionRequest} from '../modules/fetch-action.ts';
import {hideFomanticModal} from '../modules/fomantic/modal.ts';
import {hideModal} from '../modules/modal.ts';
import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
import type {SortableEvent} from 'sortablejs';
import {toggleFullScreen} from '../utils.ts';
@@ -82,7 +82,7 @@ function initRepoProjectSortable(): void {
}
function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
const elModal = document.querySelector<HTMLElement>('.ui.modal#project-column-modal-edit')!;
const elModal = document.querySelector<HTMLDialogElement>('#project-column-modal-edit')!;
const elForm = elModal.querySelector<HTMLFormElement>('form')!;
const elColumnId = elForm.querySelector<HTMLInputElement>('input[name="id"]')!;
@@ -140,7 +140,7 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
queryElemChildren(elBoardColumn, '.divider', (divider: HTMLElement) => divider.style.removeProperty('color'));
}
hideFomanticModal(elModal);
hideModal(elModal);
} finally {
elForm.classList.remove('is-loading');
}
+6 -6
View File
@@ -3,7 +3,7 @@ import {hideToastsAll, showErrorToast} from '../modules/toast.ts';
import {getComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
import {hideElem} from '../utils/dom.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {hideFomanticModal, showFomanticModal} from '../modules/fomantic/modal.ts';
import {hideModal, showModal} from '../modules/modal.ts';
import {registerGlobalEventFunc, registerGlobalInitFunc} from '../modules/observer.ts';
import {htmlEscape} from '../utils/html.ts';
import {compareVersions} from 'compare-versions';
@@ -84,7 +84,7 @@ function initGenerateReleaseNotes(elForm: HTMLFormElement) {
const textMissingTag = buttonShowModal.getAttribute('data-text-missing-tag')!;
const generateUrl = buttonShowModal.getAttribute('data-generate-url')!;
const elModal = document.querySelector('#generate-release-notes-modal')!;
const elModal = document.querySelector<HTMLDialogElement>('#generate-release-notes-modal')!;
const doSubmit = async (tagName: string) => {
const elPreviousTag = elModal.querySelector<HTMLSelectElement>('[name=previous_tag]')!;
@@ -111,18 +111,18 @@ function initGenerateReleaseNotes(elForm: HTMLFormElement) {
} else {
comboEditor.value(data.content);
}
hideModal(elModal);
comboEditor.focus();
} finally {
elModal.classList.remove('loading', 'disabled');
hideFomanticModal(elModal);
comboEditor.focus();
}
};
let inited = false;
const doShowModal = () => {
hideToastsAll();
const tagName = tagNameInput.value.trim();
if (!tagName) {
hideToastsAll();
showErrorToast(textMissingTag, {duration: 3000});
tagNameInput.focus();
return;
@@ -140,7 +140,7 @@ function initGenerateReleaseNotes(elForm: HTMLFormElement) {
}
$dropdown.dropdown('set selected', guessPreviousReleaseTag(tagName, existingTags));
showFomanticModal(elModal, {
showModal(elModal, {
onApprove: () => {
doSubmit(tagName); // don't await, need to return false to keep the modal
return false;