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" .}} - + +
+
+ {{svg "octicon-file"}} + {{ctx.Locale.Tr "repo.editor.commit_empty_file_header"}} +
+
+

{{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 @@ - + +
+
+ {{svg "octicon-file"}} + {{ctx.Locale.Tr "repo.editor.commit_empty_file_header"}} +
+
+

{{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 `` element. +export function initModalClose() { + document.addEventListener('click', (event) => { + const dialog = document.querySelector('dialog[open]'); + // No open dialogs on page, nothing to do. + if (dialog === null) return; + + const target = event.target as HTMLElement; + // User clicked dialog itself (not it's content), likely ::backdrop, so close it. + if (dialog === target) dialog.close(); + }); +}