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

+1 -1
View File
@@ -845,7 +845,7 @@ table th[data-sortt-desc] .svg {
}
/* this is useful to make a left-right (e.g.: title .... operations) layout with default gap, and it wrap for small widths */
.ui.modal .header.flex-left-right,
.modal-dialog .header.flex-left-right,
.flex-left-right {
display: flex;
flex-wrap: wrap;
+7 -10
View File
@@ -1,27 +1,24 @@
/* These are the remnants of the fomantic dimmer module */
.ui.dimmer {
.modal-backdrop {
position: fixed;
display: none;
inset: 0;
background: var(--color-overlay-backdrop);
opacity: 0;
z-index: 1000;
z-index: var(--z-index-modal);
overflow-y: auto;
justify-content: center;
padding: 8px 0;
animation-name: fadein;
animation-duration: .2s;
overscroll-behavior: contain;
user-select: none;
}
.ui.active.dimmer {
.modal-backdrop:has(> [open]) {
display: flex;
opacity: 1;
}
.ui.dimmer > .ui.modal {
.modal-backdrop > .modal-dialog {
position: static;
margin-top: auto !important;
margin-bottom: auto !important;
margin-top: auto;
margin-bottom: auto;
}
+23 -27
View File
@@ -1,17 +1,17 @@
/* to keep the changes minimal and easy to revert or fine tune, the legacy classes
like "small" and "mini" are still kept for the HTML elements, these can be cleaned up in the future */
.ui.modal {
position: absolute;
display: none;
z-index: var(--z-index-modal);
.modal-page-blocked {
overflow: hidden;
}
.modal-dialog {
text-align: left;
background: var(--color-body);
border: none;
margin: 0;
padding: 0;
color: inherit;
box-shadow: 1px 3px 3px 0 var(--color-shadow), 1px 3px 15px 2px var(--color-shadow);
transform-origin: 50% 25%;
border-radius: var(--border-radius);
user-select: text;
will-change: top, left, margin, transform, opacity;
width: fit-content;
/* on small screens, the modal should not exceed 90% of the viewport width;
@@ -22,17 +22,17 @@ like "small" and "mini" are still kept for the HTML elements, these can be clean
min-width: min(800px, max(min(300px, 90vw), 30vw));
}
.ui.modal > :first-child {
.modal-dialog > :first-child {
border-top-left-radius: var(--border-radius);
border-top-right-radius: var(--border-radius);
}
.ui.modal > :last-child {
.modal-dialog > :last-child {
border-bottom-left-radius: var(--border-radius);
border-bottom-right-radius: var(--border-radius);
}
.ui.modal .close-modal {
.modal-dialog .close-modal {
cursor: pointer;
position: absolute;
top: 50%;
@@ -47,7 +47,7 @@ like "small" and "mini" are still kept for the HTML elements, these can be clean
height: 1.5rem;
}
.ui.modal .close-modal::before {
.modal-dialog .close-modal::before {
content: "";
display: block;
width: 100%;
@@ -57,11 +57,11 @@ like "small" and "mini" are still kept for the HTML elements, these can be clean
mask-size: cover;
}
.ui.modal .close-modal:hover {
.modal-dialog .close-modal:hover {
opacity: 1;
}
.ui.modal > .header {
.modal-dialog > .header {
display: block;
position: relative;
font-size: 18px;
@@ -76,8 +76,8 @@ like "small" and "mini" are still kept for the HTML elements, these can be clean
border-top-right-radius: var(--border-radius);
}
.ui.modal > .content,
.ui.modal form > .content {
.modal-dialog > .content,
.modal-dialog form > .content {
display: block;
width: 100%;
font-size: 1em;
@@ -87,9 +87,9 @@ like "small" and "mini" are still kept for the HTML elements, these can be clean
border-radius: 0 0 var(--border-radius) var(--border-radius);
}
.ui.modal > .actions,
.ui.modal .content + .actions,
.ui.modal .content + form > .actions {
.modal-dialog > .actions,
.modal-dialog .content + .actions,
.modal-dialog .content + form > .actions {
background: var(--color-secondary-bg);
padding: 1rem;
border-top: 1px solid var(--color-secondary);
@@ -97,17 +97,13 @@ like "small" and "mini" are still kept for the HTML elements, these can be clean
border-radius: 0 0 var(--border-radius) var(--border-radius);
}
.ui.active.modal {
display: block;
}
.ui.modal .content > form > .actions,
.ui.modal .content > .actions {
.modal-dialog .content > form > .actions,
.modal-dialog .content > .actions {
padding-top: 1em;
text-align: right;
}
.ui.modal .actions > .ui.button {
.modal-dialog .actions > .ui.button {
display: inline-flex;
gap: var(--gap-inline);
align-items: center;
@@ -116,7 +112,7 @@ like "small" and "mini" are still kept for the HTML elements, these can be clean
margin-right: 0;
}
.ui.modal .actions > .ui.button.danger {
.modal-dialog .actions > .ui.button.danger {
display: block;
width: 100%;
margin: 0 auto;
+1 -12
View File
@@ -1,23 +1,12 @@
/* styles for Fomantic transition (toggle): dimmer, modal, dropdown menu */
/* styles for Fomantic transition (toggle): dropdown menu */
/* this is the only place using "hidden" and "visible" classes, it can safely work with tailwind without prefix in the future */
.ui.modal.transition.hidden,
.ui.dropdown > .menu.transition.hidden {
display: none;
visibility: hidden;
}
.ui.modal.transition.visible,
.ui.dropdown > .menu.transition.visible {
display: block;
visibility: visible;
}
/* dimmer uses "active" but not "hidden/visible" classes, no special classes for it here */
/* only modal and dimmer need animation, dropdown menu doesn't */
.ui.modal.transition {
animation-name: fadein;
animation-duration: 100ms;
animation-timing-function: ease-in-out;
}
File diff suppressed because it is too large. Load diff
-1
View File
@@ -1,5 +1,4 @@
import './components/api.js';
import './components/dropdown.js';
import './components/modal.js';
// Hard-forked from Fomantic UI 2.8.7, patches are commented with "GITEA-PATCH"
+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;
-3
View File
@@ -1,12 +1,9 @@
interface JQuery {
fomanticExt: {
onDropdownAfterFiltered?: (this: HTMLElement) => void,
onModalBeforeHidden?: (this: HTMLElement) => void,
}; // fomantic extension
api: any, // fomantic
dimmer: any, // fomantic
dropdown: any; // fomantic
modal: any; // fomantic
transition: any, // fomantic
}
+2
View File
@@ -69,11 +69,13 @@ import {initDevtest} from './modules/devtest.ts';
import {initRepoWatch} from './features/repo-watch.ts';
import {initPackagesView} from './features/packages.ts';
import {initAsyncHashChangeListeners} from './modules/hashchange.ts';
import {initGlobalModal} from './modules/modal.ts';
const initStartTime = performance.now();
const initPerformanceTracer = callInitFunctions([
initGiteaFomantic,
initGlobalModal,
initGlobalComponent,
initGlobalDropdown,
initGlobalFetchAction,
+3 -3
View File
@@ -1,6 +1,6 @@
import {showSuccessToast, showInfoToast, showWarningToast, showErrorToast} from './toast.ts';
import {registerGlobalInitFunc} from './observer.ts';
import {showFomanticModal} from './fomantic/modal.ts';
import {showModal} from './modal.ts';
import {createElementFromHTML} from '../utils/dom.ts';
import {html} from '../utils/html.ts';
import {showGlobalErrorMessage} from './errors.ts';
@@ -25,9 +25,9 @@ function initDevtestPage() {
const modalButtons = document.querySelector('.modal-buttons');
if (modalButtons) {
for (const el of document.querySelectorAll('.ui.modal:not([data-skip-button])')) {
for (const el of document.querySelectorAll<HTMLDialogElement>('.modal-dialog:not([data-skip-button])')) {
const btn = createElementFromHTML(html`<button class="ui button">${el.id}</button`);
btn.addEventListener('click', () => showFomanticModal(el));
btn.addEventListener('click', () => showModal(el));
modalButtons.append(btn);
}
}
+2 -2
View File
@@ -231,10 +231,10 @@ export async function submitFormFetchAction(elForm: HTMLFormElement, opts: Submi
}
async function confirmFetchAction(el: HTMLElement) {
let elModal: HTMLElement | null = null;
let elModal: HTMLDialogElement | null = null;
const dataModalConfirm = el.getAttribute('data-modal-confirm') || '';
if (dataModalConfirm.startsWith('#') && dataModalConfirm.length > 1) {
elModal = document.querySelector<HTMLElement>(`#${CSS.escape(dataModalConfirm.substring(1))}`);
elModal = document.querySelector<HTMLDialogElement>(`#${CSS.escape(dataModalConfirm.substring(1))}`);
if (elModal) {
elModal = createElementFromHTML(elModal.outerHTML);
elModal.removeAttribute('id');
-4
View File
@@ -1,7 +1,5 @@
import {initAriaDropdownPatch} from './fomantic/dropdown.ts';
import {initAriaModalPatch} from './fomantic/modal.ts';
import {initFomanticTransition} from './fomantic/transition.ts';
import {initFomanticDimmer} from './fomantic/dimmer.ts';
import {svg} from '../svg.ts';
export const fomanticMobileScreen = window.matchMedia('only screen and (max-width: 767.98px)');
@@ -20,9 +18,7 @@ export function initGiteaFomantic() {
};
initFomanticTransition();
initFomanticDimmer();
// Use the patches to improve accessibility, these patches are designed to be as independent as possible, make it easy to modify or remove in the future.
initAriaDropdownPatch();
initAriaModalPatch();
}
-31
View File
@@ -1,31 +0,0 @@
import {queryElemChildren} from '../../utils/dom.ts';
export function initFomanticDimmer() {
// stand-in for removed dimmer module
$.fn.dimmer = function (this: any, arg0: string, arg1: any) {
if (arg0 === 'add content') {
const $el = arg1;
const existingDimmer = document.querySelector('body > .ui.dimmer');
if (existingDimmer) {
queryElemChildren(existingDimmer, '*', (el) => el.classList.add('hidden'));
this._dimmer = existingDimmer;
} else {
this._dimmer = document.createElement('div');
this._dimmer.classList.add('ui', 'dimmer');
document.body.append(this._dimmer);
}
this._dimmer.append($el[0]);
} else if (arg0 === 'get dimmer') {
return $(this._dimmer);
} else if (arg0 === 'show') {
this._dimmer.classList.add('active');
document.body.classList.add('tw-overflow-hidden');
} else if (arg0 === 'hide') {
const cb = arg1;
this._dimmer.classList.remove('active');
document.body.classList.remove('tw-overflow-hidden');
cb();
}
return this;
};
}
-86
View File
@@ -1,86 +0,0 @@
import type {FomanticInitFunction, JQueryElem} from '../../types.ts';
import {queryElems} from '../../utils/dom.ts';
import {hideToastsFrom} from '../toast.ts';
type ModalOpts = {
closable?: boolean;
onApprove?: (this: HTMLElement) => boolean | void;
onShow?: (this: HTMLElement) => void | Promise<void>;
onHide?: (this: HTMLElement) => void;
onHidden?: (this: HTMLElement) => void;
};
// thin wrapper around Fomantic's jQuery modal plugin so callers don't have to touch jQuery or fomanticQuery
export function showFomanticModal(el: Element | null, opts: ModalOpts = {}) {
if (!el) return;
const $el = $(el);
if (Object.keys(opts).length) $el.modal(opts);
$el.modal('show');
}
export function hideFomanticModal(el: Element | null) {
if (!el) return;
$(el).modal('hide');
}
const fomanticModalFn = $.fn.modal;
// use our own `$.fn.modal` to patch Fomantic's modal module
export function initAriaModalPatch() {
if ($.fn.modal === ariaModalFn) throw new Error('initAriaModalPatch could only be called once');
$.fn.modal = ariaModalFn;
(ariaModalFn as FomanticInitFunction).settings = fomanticModalFn.settings;
$.fn.fomanticExt.onModalBeforeHidden = onModalBeforeHidden;
$.fn.modal.settings.onApprove = onModalApproveDefault;
}
// the patched `$.fn.modal` modal function
// * it does the one-time attaching on the first call
function ariaModalFn(this: JQueryElem, ...args: Parameters<FomanticInitFunction>) {
const ret = fomanticModalFn.apply(this, args);
if (args[0] === 'show' || args[0]?.autoShow) {
for (const el of this) {
el.querySelector('.close-modal')?.setAttribute('aria-label', 'Close');
// If there is a form in the modal, there might be a "cancel" button before "ok" button (all buttons are "type=submit" by default).
// In such case, the "Enter" key will trigger the "cancel" button instead of "ok" button, then the dialog will be closed.
// It breaks the user experience - the "Enter" key should confirm the dialog and submit the form.
// So, all "cancel" buttons without "[type]" must be marked as "type=button".
// Although there are lint rules for "button type", some HTML code is generated in JS/Vue/Go which isn't linted, so we still need this patch.
for (const button of el.querySelectorAll('form button.cancel:not([type])')) {
button.setAttribute('type', 'button');
}
}
}
return ret;
}
function onModalBeforeHidden(this: HTMLElement) {
const $modal = $(this);
const elModal = $modal[0];
hideToastsFrom(elModal.closest('.ui.dimmer') ?? document.body);
// reset the form after the modal is hidden, after other modal events and handlers (e.g. "onApprove", form submit)
setTimeout(() => {
queryElems(elModal, 'form', (form: HTMLFormElement) => form.reset());
}, 0);
}
function onModalApproveDefault(this: HTMLElement) {
const $modal = $(this);
const selectors = $modal.modal('setting', 'selector');
const elModal = $modal[0];
const elApprove = elModal.querySelector<HTMLElement>(selectors.approve);
const elForm = elApprove?.closest<HTMLFormElement>('form');
if (!elForm) return true; // no form, just allow closing the modal
// "form-fetch-action" can handle network errors gracefully,
// so keep the modal dialog to make users can re-submit the form if anything wrong happens.
if (elForm.matches('.form-fetch-action')) return false;
// There is an abuse for the "modal" + "form" combination, the "Approve" button is a traditional form submit button in the form.
// Then "approve" and "submit" occur at the same time, the modal will be closed immediately before the form is submitted.
// So here we prevent the modal from closing automatically by returning false, add the "is-loading" class to the form element.
if (!elForm.reportValidity()) return false;
elForm.classList.add('is-loading');
return false;
}
+3 -13
View File
@@ -1,12 +1,6 @@
export function initFomanticTransition() {
const transitionNopBehaviors = new Set([
'clear queue', 'stop', 'stop all', 'destroy',
'force repaint', 'repaint', 'reset',
'looping', 'remove looping', 'disable', 'enable',
'set duration', 'save conditions', 'restore conditions',
]);
// stand-in for removed transition module
$.fn.transition = function (arg0: any, arg1?: number, arg2?: (this: HTMLElement) => void) {
$.fn.transition = function (arg0: any) {
if (arg0 === 'is supported') return true;
if (arg0 === 'is animating') return false;
if (arg0 === 'is inward') return false;
@@ -14,10 +8,8 @@ export function initFomanticTransition() {
let argObj: Record<string, any>;
if (typeof arg0 === 'string') {
// many behaviors are no-op now. https://fomantic-ui.com/modules/transition.html#/usage
if (transitionNopBehaviors.has(arg0)) return this;
// now, the arg0 is an animation name, the syntax: (animation, duration, complete)
argObj = {animation: arg0, ...(arg1 && {duration: arg1}), ...(arg2 && {onComplete: arg2})};
if (arg0 === 'stop all') return this;
argObj = {animation: arg0};
} else if (typeof arg0 === 'object') {
argObj = arg0;
} else {
@@ -38,12 +30,10 @@ export function initFomanticTransition() {
el.classList.remove('hidden');
el.classList.add('visible', 'transition');
if (argObj.displayType) el.style.setProperty('display', argObj.displayType, 'important');
argObj.onShow?.call(el);
} else {
el.classList.add('hidden');
el.classList.remove('visible'); // don't remove the transition class because the Fomantic animation style is `.hidden.transition`.
el.style.removeProperty('display');
argObj.onHidden?.call(el);
}
argObj.onComplete?.call(el);
});
+87
View File
@@ -0,0 +1,87 @@
import {userEvent} from 'vitest/browser';
import {createElementFromHTML} from '../utils/dom.ts';
import {hideModal, initGlobalModal, showModal} from './modal.ts';
import {showErrorToast} from './toast.ts';
test('showModal blocks the page and restores focus until closed by actions, backdrop or unconsumed Escape on the topmost modal', async () => {
initGlobalModal();
document.body.innerHTML = `
<button id="opener">Opener</button>
<dialog class="modal-dialog">
<div class="header">Header <button type="button" class="close-modal"></button></div>
<form class="content">
<input name="text">
<div class="actions"><button class="ui cancel button">Cancel</button><button type="button" class="ui ok button">OK</button></div>
</form>
</dialog>
`;
const opener = document.querySelector<HTMLElement>('#opener')!;
const modal = document.querySelector<HTMLDialogElement>('.modal-dialog')!;
const onHidden = vi.fn();
let approve = false;
opener.focus();
showModal(modal, {onApprove: () => approve, onHidden});
const backdrop = modal.parentElement!;
expect(opener.inert).toBe(true);
expect(backdrop.inert).toBe(false);
expect(modal.getAttribute('aria-labelledby')).toBe(modal.querySelector('.header')!.id);
expect(document.body.classList.contains('modal-page-blocked')).toBe(true);
expect(modal.querySelector('.cancel')!.getAttribute('type')).toBe('button');
modal.querySelector('input')!.value = 'changed';
modal.querySelector<HTMLElement>('.ok')!.click();
expect(modal.open).toBe(true);
approve = true;
modal.querySelector<HTMLElement>('.ok')!.click();
expect(onHidden).toHaveBeenCalledOnce();
expect(modal.querySelector('input')!.value).toBe('');
expect(document.body.classList.contains('modal-page-blocked')).toBe(false);
expect(opener.inert).toBe(false);
expect(document.activeElement).toBe(opener);
for (const selector of ['.cancel', '.close-modal']) {
showModal(modal);
modal.querySelector<HTMLElement>(selector)!.click();
expect(modal.open).toBe(false);
}
showModal(modal);
await userEvent.click(modal.querySelector('input')!);
expect(modal.open).toBe(true);
backdrop.dispatchEvent(new PointerEvent('pointerdown'));
backdrop.click();
expect(modal.open).toBe(false);
showModal(modal);
document.addEventListener('keydown', (e) => e.preventDefault(), {once: true});
await userEvent.keyboard('{Escape}');
expect(modal.open).toBe(true);
const nested = createElementFromHTML<HTMLDialogElement>('<dialog class="modal-dialog"></dialog>');
showModal(nested, {onHidden: () => nested.remove()});
const nestedBackdrop = nested.parentElement!;
expect(backdrop.inert).toBe(true);
await userEvent.keyboard('{Escape}');
expect(nestedBackdrop.isConnected).toBe(false);
expect(modal.open).toBe(true);
expect(backdrop.inert).toBe(false);
expect(opener.inert).toBe(true);
expect(modal.contains(document.activeElement)).toBe(true);
showErrorToast('error in modal');
expect(document.querySelector('.toastify.on')).not.toBeNull();
await userEvent.keyboard('{Escape}');
expect(modal.open).toBe(false);
expect(document.querySelector('.toastify.on')).toBeNull();
(document.activeElement as HTMLElement).blur();
showModal(modal);
hideModal(modal);
expect(document.activeElement).toBe(document.body);
showModal(modal, {onHidden: () => modal.remove()});
hideModal(modal);
opener.focus();
expect(backdrop.isConnected).toBe(false);
expect(document.activeElement).toBe(opener);
});
+112
View File
@@ -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();
}
+8 -10
View File
@@ -1,8 +1,6 @@
import '../../fomantic/build/fomantic.js';
import {userEvent} from 'vitest/browser';
import {initGlobalShortcut, makeFullKeyFromEvent} from './shortcut.ts';
import {initGlobalSelectorObserver} from './observer.ts';
import {initGiteaFomantic} from './fomantic.ts';
import {initGlobalModal} from './modal.ts';
function press(key: string, options: KeyboardEventInit = {}, target: HTMLElement = document.body) {
const event = new KeyboardEvent('keydown', {key, bubbles: true, cancelable: true, ...options});
@@ -21,7 +19,7 @@ beforeAll(() => {
<select><option>Value</option></select>
<div contenteditable="true"><span>Editable</span></div>
`;
initGiteaFomantic();
initGlobalModal();
initGlobalShortcut();
initGlobalSelectorObserver(null);
});
@@ -106,7 +104,7 @@ test('normalizes keys and sorts modifiers alphabetically', () => {
}
});
test('shows current shortcuts with ? and closes with Escape or the close button', async () => {
test('shows current shortcuts with ? and closes with Escape or the close button', () => {
const input = document.querySelector('input')!;
const click = vi.spyOn(document.querySelector('button')!, 'click');
press('?', {shiftKey: true}, input);
@@ -115,20 +113,20 @@ test('shows current shortcuts with ? and closes with Escape or the close button'
expect(document.querySelector('#global-shortcut-help')).toBeNull();
press('g');
expect(press('?', {shiftKey: true}).defaultPrevented).toBe(true);
const modal = document.querySelector<HTMLElement>('#global-shortcut-help')!;
expect(modal.classList.contains('active')).toBe(true);
const modal = document.querySelector<HTMLDialogElement>('#global-shortcut-help')!;
expect(modal.open).toBe(true);
expect(modal.querySelector('.shortcut-list tr:nth-child(2) td:last-child')!.textContent).toBe('Issues');
expect(modal.querySelector('.shortcut-list')!.textContent).toContain('Search code');
expect(modal.querySelectorAll('.shortcut-list tr')).toHaveLength(3);
press('g');
press('i');
expect(click).not.toHaveBeenCalled();
await userEvent.keyboard('{Escape}');
expect(modal.classList.contains('active')).toBe(false);
press('Escape');
expect(modal.open).toBe(false);
input.setAttribute('placeholder', '<b>Updated search</b>');
press('?');
expect(document.querySelectorAll('.ui.modal')).toHaveLength(1);
expect(document.querySelectorAll('.modal-dialog')).toHaveLength(1);
expect(modal.querySelector('.shortcut-list')!.textContent).toContain('<b>Updated search</b>');
expect(modal.querySelector('.shortcut-list b')).toBeNull();
expect(modal.querySelectorAll('.shortcut-list tr')).toHaveLength(3);
+7 -8
View File
@@ -1,6 +1,6 @@
import {registerGlobalInitFunc} from './observer.ts';
import {createElementFromHTML, hideElem, toggleElem} from '../utils/dom.ts';
import {showFomanticModal} from './fomantic/modal.ts';
import {showModal} from './modal.ts';
import {html, htmlRaw} from '../utils/html.ts';
type ShortcutHandler = (el: HTMLElement) => boolean;
@@ -69,10 +69,10 @@ function getShortcutTextContent(elem: Element): string {
}
function showShortcutHelp() {
let modal = document.querySelector<HTMLElement>('#global-shortcut-help');
let modal = document.querySelector<HTMLDialogElement>('#global-shortcut-help');
if (!modal) {
modal = createElementFromHTML<HTMLElement>(html`
<div id="global-shortcut-help" class="ui small modal" role="dialog" aria-modal="true">
modal = createElementFromHTML<HTMLDialogElement>(html`
<dialog id="global-shortcut-help" class="modal-dialog">
<div class="header">
Keyboard Shortcuts
<button type="button" class="close-modal"></button>
@@ -82,9 +82,8 @@ function showShortcutHelp() {
<tbody class="shortcut-list"></tbody>
</table>
</div>
</div>
</dialog>
`);
document.body.append(modal);
}
const seenShortcuts = new Set<string>();
@@ -112,7 +111,7 @@ function showShortcutHelp() {
}
list.append(createElementFromHTML(html`<tr><td>${htmlRaw(keysHtml)}</td><td>${label}</td></tr>`));
}
showFomanticModal(modal);
showModal(modal);
}
export function registerShortcutHandler(name: string, handler: ShortcutHandler) {
@@ -151,7 +150,7 @@ export function initGlobalShortcut() {
// Don't trigger shortcuts when typing in input fields or contenteditable areas
let shouldSkip = target.matches('input, textarea, select') || target.isContentEditable;
// Skip shortcuts when a modal or dropdown is open (active), to avoid interfering with their own keyboard handling
shouldSkip = shouldSkip || Boolean(document.querySelector('.ui.modal.active') || document.querySelector('.ui.dropdown.active'));
shouldSkip = shouldSkip || Boolean(document.querySelector('.modal-dialog[open], .ui.dropdown.active'));
if (shouldSkip) {
shortcutPresses.length = 0;
return;
+1 -7
View File
@@ -44,12 +44,11 @@ type ToastifyElement = HTMLElement & {_giteaToastifyInstance?: Toast};
/** See https://github.com/apvarun/toastify-js#api for options */
function showToast(message: string, level: Intent = 'info', {gravity, position, duration, useHtmlBody, preventDuplicates = true, ...other}: ToastOpts = {}): Toast | null {
const parent = document.querySelector('.ui.dimmer.active') ?? document.body;
const duplicateKey = preventDuplicates ? (typeof preventDuplicates === 'string' ? preventDuplicates : `${level}-${message}`) : '';
// prevent showing duplicate toasts with the same level and message, and give visual feedback for end users
if (preventDuplicates) {
const toastEl = parent.querySelector(`:scope > .toastify.on[data-toast-unique-key="${CSS.escape(duplicateKey)}"]`);
const toastEl = document.body.querySelector(`:scope > .toastify.on[data-toast-unique-key="${CSS.escape(duplicateKey)}"]`);
if (toastEl) {
const toastDupNumEl = toastEl.querySelector('.toast-duplicate-number')!;
showElem(toastDupNumEl);
@@ -62,7 +61,6 @@ function showToast(message: string, level: Intent = 'info', {gravity, position,
const {icon, duration: levelDuration} = levels[level];
const bodyHtml = useHtmlBody ? message : htmlEscape(message);
const toast = Toastify({
selector: parent,
text: html`
<div class='toast-icon'>${svgRaw(icon)}</div>
<div class='toast-body'><span class="toast-duplicate-number tw-hidden">1</span>${htmlRaw(bodyHtml)}</div>
@@ -104,10 +102,6 @@ function hideToastByElement(el: Element): void {
(el as ToastifyElement)?._giteaToastifyInstance?.hideToast();
}
export function hideToastsFrom(parent: Element): void {
queryElems(parent, ':scope > .toastify.on', hideToastByElement);
}
export function hideToastsAll(): void {
queryElems(document, '.toastify.on', hideToastByElement);
}