diff --git a/templates/repo/editor/edit.tmpl b/templates/repo/editor/edit.tmpl
index 39379a977c..c25cbce315 100644
--- a/templates/repo/editor/edit.tmpl
+++ b/templates/repo/editor/edit.tmpl
@@ -51,24 +51,26 @@
{{template "repo/editor/commit_form" .}}
-
-
-
-
{{ctx.Locale.Tr "repo.editor.commit_empty_file_text"}}
-
-
-
-
-
-
+
{{template "base/footer" .}}
diff --git a/templates/repo/editor/patch.tmpl b/templates/repo/editor/patch.tmpl
index 1f046a8d4e..7712836953 100644
--- a/templates/repo/editor/patch.tmpl
+++ b/templates/repo/editor/patch.tmpl
@@ -34,24 +34,26 @@
-
-
-
-
{{ctx.Locale.Tr "repo.editor.commit_empty_file_text"}}
-
-
-
-
-
-
+
{{template "base/footer" .}}
diff --git a/tests/e2e/dimmer.test.e2e.ts b/tests/e2e/dimmer.test.e2e.ts
index 48084b0e52..ed8d116e1f 100644
--- a/tests/e2e/dimmer.test.e2e.ts
+++ b/tests/e2e/dimmer.test.e2e.ts
@@ -56,19 +56,16 @@ test('Dimmed overflow', async ({page}, workerInfo) => {
await page.locator('#commit-button').click();
// Expect a 'are you sure, this file is empty' modal.
- await expect(page.locator('.ui.dimmer')).toBeVisible();
- await expect(page.locator('.ui.dimmer .header')).toContainText('Commit an empty file');
+ await expect(page.locator('#edit-empty-content-modal')).toBeVisible();
+ await expect(page.locator('#edit-empty-content-modal header')).toContainText('Commit an empty file');
await save_visual(page);
- // Trickery to check that the dimmer covers the whole page.
- const viewport = page.viewportSize();
- const box = await page.locator('.ui.dimmer').boundingBox();
- expect(box.x).toBe(0);
- expect(box.y).toBe(0);
- expect(box.width).toBe(viewport.width);
- expect(box.height).toBe(viewport.height);
-
// Trickery to check the page cannot be scrolled.
- const {scrollHeight, clientHeight} = await page.evaluate(() => document.body);
- expect(scrollHeight).toBe(clientHeight);
+ const {overflow} = await page.evaluate(() => {
+ const s = getComputedStyle(document.body);
+ return {
+ overflow: s.overflow,
+ };
+ });
+ expect(overflow).toBe('hidden');
});
diff --git a/tests/e2e/modal.test.e2e.ts b/tests/e2e/modal.test.e2e.ts
new file mode 100644
index 0000000000..dbcfcb3ea4
--- /dev/null
+++ b/tests/e2e/modal.test.e2e.ts
@@ -0,0 +1,45 @@
+// @watch start
+// templates/repo/editor/edit.tmpl
+// templates/repo/editor/patch.tmpl
+// web_src/js/features/repo-editor.js
+// web_src/css/modules/dialog.ts
+// web_src/css/modules/dialog.css
+// @watch end
+
+import {expect} from '@playwright/test';
+import {save_visual, dynamic_id, test} from './utils_e2e.ts';
+
+test.use({user: 'user2'});
+
+test('Dialog modal', async ({page}, workerInfo) => {
+ test.skip(['Mobile Safari', 'webkit'].includes(workerInfo.project.name), 'keyboard shortcuts do not work');
+ let response = await page.goto('/user2/repo1/_new/master', {waitUntil: 'domcontentloaded'});
+ expect(response?.status()).toBe(200);
+
+ const filename = `${dynamic_id()}.md`;
+
+ await page.getByPlaceholder('Name your fileā¦').fill(filename);
+ await page.locator('.monaco-editor').click();
+ await page.keyboard.type('Hi, nice to meet you. Can I talk about ');
+
+ await page.locator('.quick-pull-choice input[value="direct"]').click();
+ await page.getByRole('button', {name: 'Commit changes'}).click();
+
+ response = await page.goto(`/user2/repo1/_edit/master/${filename}`, {waitUntil: 'domcontentloaded'});
+ expect(response?.status()).toBe(200);
+
+ await page.locator('.monaco-editor-container').click();
+ await page.keyboard.press('ControlOrMeta+A');
+ await page.keyboard.press('Backspace');
+
+ await page.locator('#commit-button').click();
+ await save_visual(page);
+ await expect(page.locator('#edit-empty-content-modal')).toBeVisible();
+
+ await page.locator('#edit-empty-content-modal .cancel').click();
+ await expect(page.locator('#edit-empty-content-modal')).toBeHidden();
+
+ await page.locator('#commit-button').click();
+ await page.locator('#edit-empty-content-modal .ok').click();
+ await expect(page).toHaveURL(`/user2/repo1/src/branch/master/${filename}`);
+});
diff --git a/web_src/css/index.css b/web_src/css/index.css
index 7b0fa45916..8cb25d8185 100644
--- a/web_src/css/index.css
+++ b/web_src/css/index.css
@@ -18,6 +18,7 @@
@import "./modules/checkbox.css";
@import "./modules/modal.css";
@import "./modules/dimmer.css";
+@import "./modules/dialog.css";
@import "./modules/switch.css";
@import "./modules/dropdown.css";
diff --git a/web_src/css/modules/dialog.css b/web_src/css/modules/dialog.css
new file mode 100644
index 0000000000..711d54b3ea
--- /dev/null
+++ b/web_src/css/modules/dialog.css
@@ -0,0 +1,98 @@
+body:has(dialog[open]) {
+ overflow: hidden;
+}
+
+dialog {
+ align-items: center;
+ justify-content: center;
+ position: fixed;
+ text-align: left;
+ border: none;
+ background: var(--color-body);
+ box-shadow:
+ 1px 3px 3px 0 var(--color-shadow),
+ 1px 3px 15px 2px var(--color-shadow);
+ border-radius: 0.28571429rem;
+ outline: none;
+ padding: 0;
+ max-width: min(800px, 90vw);
+ width: fit-content;
+ z-index: 1001;
+
+ pointer-events: auto;
+ touch-action: auto;
+}
+
+dialog[open],
+dialog:target {
+ display: flex;
+}
+
+dialog::backdrop {
+ background-color: var(--color-overlay-backdrop);
+ will-change: opacity;
+ opacity: 0;
+ animation-name: fadein;
+ animation-duration: 100ms;
+ animation-timing-function: ease-in-out;
+}
+
+dialog[open]::backdrop {
+ opacity: 1;
+}
+
+dialog header {
+ font-size: 1.42857143rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-family: var(--fonts-regular);
+ color: var(--color-text-dark);
+ margin: 0;
+ padding: 1.25rem 1.5rem;
+ box-shadow: none;
+ border-top-left-radius: var(--border-radius);
+ border-top-right-radius: var(--border-radius);
+ border-bottom: 1px solid var(--color-secondary);
+}
+
+dialog article {
+ display: block;
+ width: 100%;
+ font-size: 1em;
+ line-height: 1.4;
+}
+
+dialog .content {
+ padding: 1.5em;
+ background: var(--color-body);
+ color: var(--color-text);
+}
+
+dialog .actions {
+ background: var(--color-secondary-bg);
+ border-color: var(--color-secondary);
+ display: flex;
+ gap: 1em;
+ justify-content: flex-end;
+ padding: 1rem;
+}
+
+/* positive/negative action buttons */
+dialog .actions .ui.button {
+ display: inline-flex;
+ align-items: center;
+ padding: 10px 12px;
+ margin-right: 0;
+}
+
+dialog .actions .ui.button.danger {
+ display: block;
+ width: 100%;
+ margin: 0 auto;
+ text-align: center;
+}
+
+dialog .actions .ui.button .svg {
+ margin-right: 5px;
+}
diff --git a/web_src/js/features/repo-editor.js b/web_src/js/features/repo-editor.js
index ac4fc8a75e..44db51e98f 100644
--- a/web_src/js/features/repo-editor.js
+++ b/web_src/js/features/repo-editor.js
@@ -6,6 +6,7 @@ import {initMarkupContent} from '../markup/content.js';
import {attachRefIssueContextPopup} from './contextpopup.js';
import {POST} from '../modules/fetch.js';
import {initTab} from '../modules/tab.ts';
+import {showModal} from '../modules/modal.ts';
function initEditPreviewTab($form) {
const $tabMenu = $form.find('.tabular.menu');
@@ -183,14 +184,8 @@ export function initRepoEditor() {
commitButton?.addEventListener('click', (e) => {
// A modal which asks if an empty file should be committed
if (!$editArea.val()) {
- $('#edit-empty-content-modal')
- .modal({
- onApprove() {
- document.querySelector('.edit.form').requestSubmit();
- },
- })
- .modal('show');
e.preventDefault();
+ showModal('edit-empty-content-modal', () => { document.querySelector('.edit.form').requestSubmit()});
}
});
})();
diff --git a/web_src/js/index.js b/web_src/js/index.js
index 98e57ef450..dd4da29e18 100644
--- a/web_src/js/index.js
+++ b/web_src/js/index.js
@@ -86,6 +86,7 @@ import {initDirAuto} from './modules/dirauto.js';
import {initRepositorySearch} from './features/repo-search.js';
import {initColorPickers} from './features/colorpicker.js';
import {initRepoMilestoneEditor} from './features/repo-milestone.js';
+import {initModalClose} from './modules/modal.ts';
// Init Gitea's Fomantic settings
initGiteaFomantic();
@@ -189,6 +190,7 @@ onDomReady(() => {
initRepoDiffView();
initScopedAccessTokenCategories();
initColorPickers();
+ initModalClose();
// Deactivate CSS-only noJS usability supplements
document.body.classList.remove('no-js');
diff --git a/web_src/js/modules/modal.ts b/web_src/js/modules/modal.ts
new file mode 100644
index 0000000000..290dccee70
--- /dev/null
+++ b/web_src/js/modules/modal.ts
@@ -0,0 +1,33 @@
+// Copyright 2025 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+// showModal will show the given modal and run `onApprove` if the approve/ok/yes
+// button is pressed.
+export function showModal(modalID: string, onApprove: () => void) {
+ const modal = document.getElementById(modalID) as HTMLDialogElement;
+ // Move the modal to ``, to avoid inheriting any bad CSS or if the
+ // parent becomes `display: hidden`.
+ document.body.append(modal);
+
+ // Close the modal if the cancel button is pressed.
+ modal.querySelector('.cancel')?.addEventListener('click', () => {
+ modal.close();
+ }, {once: true, passive: true});
+ modal.querySelector('.ok')?.addEventListener('click', onApprove, {once: true, passive: true});
+
+ // The modal is ready to be shown.
+ modal.showModal();
+}
+
+// NOTE: Can be replaced in late 2026 with `closedBy` attribute on `