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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user