From e8301ad3a70f0a7fca6e96a1c8a733b9080d5492 Mon Sep 17 00:00:00 2001 From: HugoFara Date: Wed, 4 Nov 2026 14:12:50 +0100 Subject: [PATCH] fix(ui): ask about unsaved term changes in an in-app dialog The term dialogs used window.confirm(), which stops the page, ignores the theme and shows English text in every locale. confirmDialog() is a promise-based Bulma dialog that sits above the term dialogs. Escape and clicks outside keep the changes, and the safe button has the focus. window.confirm() remains only for when the dialog cannot be built, and the browser's own leave-page warning is unchanged. Closing is now asynchronous, so the multi-word modal releases its focus trap after closing rather than before: the user may keep editing. --- CHANGELOG.md | 3 + locale/ar/common.json | 6 +- locale/de/common.json | 6 +- locale/en/common.json | 6 +- locale/es/common.json | 6 +- locale/fa/common.json | 6 +- locale/fr/common.json | 6 +- locale/it/common.json | 6 +- locale/ja/common.json | 6 +- locale/pt/common.json | 6 +- locale/ru/common.json | 6 +- locale/zh/common.json | 6 +- src/frontend/css/base/styles.css | 21 +++ .../vocabulary/components/multi_word_modal.ts | 10 +- .../vocabulary/components/word_edit_form.ts | 11 +- .../vocabulary/components/word_modal.ts | 7 +- .../stores/multi_word_form_store.ts | 16 +- .../js/shared/components/confirm_dialog.ts | 160 ++++++++++++++++++ tests/frontend/ui/confirm_dialog.test.ts | 116 +++++++++++++ .../components/word_edit_form.test.ts | 29 ++-- .../vocabulary/multi_word_modal.test.ts | 19 +++ .../stores/multi_word_form_store.test.ts | 22 ++- tests/frontend/vocabulary/word_modal.test.ts | 37 ++-- 23 files changed, 444 insertions(+), 73 deletions(-) create mode 100644 src/frontend/js/shared/components/confirm_dialog.ts create mode 100644 tests/frontend/ui/confirm_dialog.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 22273632d..b79775003 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ ones are marked like "v1.0.0-fork". ### Fixed in Unreleased +* The unsaved-changes prompt in the term dialogs is now an in-app dialog that + follows the theme and the interface language, instead of the browser's. + * The "Email not verified" banner no longer follows accounts that have no email address. diff --git a/locale/ar/common.json b/locale/ar/common.json index ae84be8e1..ba3101e3d 100644 --- a/locale/ar/common.json +++ b/locale/ar/common.json @@ -45,5 +45,9 @@ "status_learned": "مُتعلَّم", "status_well_known": "معروف جيدًا", "status_ignored": "متجاهَل", - "status_unknown": "غير معروف" + "status_unknown": "غير معروف", + "dialog.discard_title": "هل تريد تجاهل التغييرات؟", + "dialog.discard_message": "لم تُحفظ تغييراتك على هذا المصطلح.", + "dialog.discard": "تجاهل", + "dialog.keep_editing": "متابعة التعديل" } diff --git a/locale/de/common.json b/locale/de/common.json index b123a8642..4a22daf3f 100644 --- a/locale/de/common.json +++ b/locale/de/common.json @@ -45,5 +45,9 @@ "status_learned": "Gelernt", "status_well_known": "Bekannt", "status_ignored": "Ignoriert", - "status_unknown": "Unbekannt" + "status_unknown": "Unbekannt", + "dialog.discard_title": "Änderungen verwerfen?", + "dialog.discard_message": "Ihre Änderungen an diesem Begriff wurden nicht gespeichert.", + "dialog.discard": "Verwerfen", + "dialog.keep_editing": "Weiter bearbeiten" } diff --git a/locale/en/common.json b/locale/en/common.json index 9304b2288..1811a1520 100644 --- a/locale/en/common.json +++ b/locale/en/common.json @@ -45,5 +45,9 @@ "status_learned": "Learned", "status_well_known": "Well Known", "status_ignored": "Ignored", - "status_unknown": "Unknown" + "status_unknown": "Unknown", + "dialog.discard_title": "Discard your changes?", + "dialog.discard_message": "Your changes to this term have not been saved.", + "dialog.discard": "Discard", + "dialog.keep_editing": "Keep editing" } diff --git a/locale/es/common.json b/locale/es/common.json index 710e0df50..50d041d80 100644 --- a/locale/es/common.json +++ b/locale/es/common.json @@ -45,5 +45,9 @@ "status_learned": "Aprendida", "status_well_known": "Bien conocida", "status_ignored": "Ignorada", - "status_unknown": "Desconocida" + "status_unknown": "Desconocida", + "dialog.discard_title": "¿Descartar los cambios?", + "dialog.discard_message": "Los cambios en este término no se han guardado.", + "dialog.discard": "Descartar", + "dialog.keep_editing": "Seguir editando" } diff --git a/locale/fa/common.json b/locale/fa/common.json index 6d3f59a1f..3da10a384 100644 --- a/locale/fa/common.json +++ b/locale/fa/common.json @@ -45,5 +45,9 @@ "status_learned": "یادگرفته", "status_well_known": "کاملاً شناخته‌شده", "status_ignored": "نادیده‌گرفته", - "status_unknown": "ناشناخته" + "status_unknown": "ناشناخته", + "dialog.discard_title": "تغییرات کنار گذاشته شوند؟", + "dialog.discard_message": "تغییرات شما در این واژه ذخیره نشده است.", + "dialog.discard": "کنار گذاشتن", + "dialog.keep_editing": "ادامهٔ ویرایش" } diff --git a/locale/fr/common.json b/locale/fr/common.json index 7e57ad1d2..f9910dd98 100644 --- a/locale/fr/common.json +++ b/locale/fr/common.json @@ -45,5 +45,9 @@ "status_learned": "Apprise", "status_well_known": "Bien connue", "status_ignored": "Ignorée", - "status_unknown": "Inconnue" + "status_unknown": "Inconnue", + "dialog.discard_title": "Abandonner vos modifications ?", + "dialog.discard_message": "Vos modifications de ce terme n'ont pas été enregistrées.", + "dialog.discard": "Abandonner", + "dialog.keep_editing": "Continuer à modifier" } diff --git a/locale/it/common.json b/locale/it/common.json index 7f576290c..fc82e3dfe 100644 --- a/locale/it/common.json +++ b/locale/it/common.json @@ -45,5 +45,9 @@ "status_learned": "Appresa", "status_well_known": "Ben conosciuta", "status_ignored": "Ignorata", - "status_unknown": "Sconosciuta" + "status_unknown": "Sconosciuta", + "dialog.discard_title": "Annullare le modifiche?", + "dialog.discard_message": "Le modifiche a questo termine non sono state salvate.", + "dialog.discard": "Annulla modifiche", + "dialog.keep_editing": "Continua a modificare" } diff --git a/locale/ja/common.json b/locale/ja/common.json index ad64fee16..904099a6f 100644 --- a/locale/ja/common.json +++ b/locale/ja/common.json @@ -45,5 +45,9 @@ "status_learned": "習得済み", "status_well_known": "熟知", "status_ignored": "無視", - "status_unknown": "不明" + "status_unknown": "不明", + "dialog.discard_title": "変更を破棄しますか?", + "dialog.discard_message": "この用語への変更は保存されていません。", + "dialog.discard": "破棄", + "dialog.keep_editing": "編集を続ける" } diff --git a/locale/pt/common.json b/locale/pt/common.json index cd92c7994..0fc0075f3 100644 --- a/locale/pt/common.json +++ b/locale/pt/common.json @@ -45,5 +45,9 @@ "status_learned": "Aprendida", "status_well_known": "Bem conhecida", "status_ignored": "Ignorada", - "status_unknown": "Desconhecida" + "status_unknown": "Desconhecida", + "dialog.discard_title": "Descartar as alterações?", + "dialog.discard_message": "As alterações neste termo não foram guardadas.", + "dialog.discard": "Descartar", + "dialog.keep_editing": "Continuar a editar" } diff --git a/locale/ru/common.json b/locale/ru/common.json index 37ddb3d4e..b674659bd 100644 --- a/locale/ru/common.json +++ b/locale/ru/common.json @@ -45,5 +45,9 @@ "status_learned": "Изучено", "status_well_known": "Хорошо известно", "status_ignored": "Игнорируется", - "status_unknown": "Неизвестно" + "status_unknown": "Неизвестно", + "dialog.discard_title": "Отменить изменения?", + "dialog.discard_message": "Изменения этого термина не сохранены.", + "dialog.discard": "Отменить изменения", + "dialog.keep_editing": "Продолжить редактирование" } diff --git a/locale/zh/common.json b/locale/zh/common.json index d075e49ea..d9182c468 100644 --- a/locale/zh/common.json +++ b/locale/zh/common.json @@ -45,5 +45,9 @@ "status_learned": "已学会", "status_well_known": "熟知", "status_ignored": "已忽略", - "status_unknown": "未知" + "status_unknown": "未知", + "dialog.discard_title": "放弃更改?", + "dialog.discard_message": "您对此词条的更改尚未保存。", + "dialog.discard": "放弃", + "dialog.keep_editing": "继续编辑" } diff --git a/src/frontend/css/base/styles.css b/src/frontend/css/base/styles.css index 826086e67..127bf11a9 100644 --- a/src/frontend/css/base/styles.css +++ b/src/frontend/css/base/styles.css @@ -3353,3 +3353,24 @@ Searchable Select Component (Alpine.js) border-bottom: none; padding-bottom: 0; } + +/* In-app confirmation, above the term dialogs and .lwt-modal */ +.confirm-dialog { + z-index: 10001; +} + +.confirm-dialog .modal-background { + background-color: rgba(10, 10, 10, 0.6); +} + +.confirm-dialog .modal-card { + border-radius: 0.75rem; + max-width: 28rem; + overflow: hidden; + width: calc(100% - 2rem); +} + +.confirm-dialog__actions { + gap: 0.5rem; + justify-content: flex-end; +} diff --git a/src/frontend/js/modules/vocabulary/components/multi_word_modal.ts b/src/frontend/js/modules/vocabulary/components/multi_word_modal.ts index bbb6f09f7..5b6727960 100644 --- a/src/frontend/js/modules/vocabulary/components/multi_word_modal.ts +++ b/src/frontend/js/modules/vocabulary/components/multi_word_modal.ts @@ -66,7 +66,7 @@ export interface MultiWordModalData { init(): void; // Methods - close(): void; + close(): Promise; save(): Promise; setStatus(status: number): void; clearGeneralError(): void; @@ -224,9 +224,11 @@ export function multiWordModalData(): MultiWordModalData { /** * Close the modal. */ - close(): void { - releaseFocus(); - this.store.close(); + async close(): Promise { + // Only once it has closed: the user may choose to keep editing. + if (await this.store.close()) { + releaseFocus(); + } }, /** diff --git a/src/frontend/js/modules/vocabulary/components/word_edit_form.ts b/src/frontend/js/modules/vocabulary/components/word_edit_form.ts index 6cb4bc1fd..571e8d846 100644 --- a/src/frontend/js/modules/vocabulary/components/word_edit_form.ts +++ b/src/frontend/js/modules/vocabulary/components/word_edit_form.ts @@ -8,6 +8,7 @@ * @since 3.0.0 */ +import { confirmDiscardChanges } from '@shared/components/confirm_dialog'; import Alpine from 'alpinejs'; import type { WordFormStoreState, SaveResult } from '../stores/word_form_store'; import type { WordStoreState } from '../stores/word_store'; @@ -110,7 +111,7 @@ export interface WordEditFormData { hasFieldError(field: FormDataField): boolean; getFieldError(field: FormDataField): string | null; save(): Promise; - cancel(): void; + cancel(): Promise; addTag(tag: string): void; removeTag(tag: string): void; filterTags(): void; @@ -323,11 +324,9 @@ export function wordEditFormData(): WordEditFormData { } }, - cancel(): void { - if (this.isDirty) { - if (!confirm('You have unsaved changes. Are you sure you want to cancel?')) { - return; - } + async cancel(): Promise { + if (this.isDirty && !await confirmDiscardChanges()) { + return; } // Call the onCancelled callback if set, otherwise signal to return to info view diff --git a/src/frontend/js/modules/vocabulary/components/word_modal.ts b/src/frontend/js/modules/vocabulary/components/word_modal.ts index 9f8411a91..1dbeb9b8c 100644 --- a/src/frontend/js/modules/vocabulary/components/word_modal.ts +++ b/src/frontend/js/modules/vocabulary/components/word_modal.ts @@ -9,6 +9,7 @@ * @since 3.0.0 */ +import { confirmDiscardChanges } from '@shared/components/confirm_dialog'; import Alpine from 'alpinejs'; import type { WordStoreState, WordData } from '../stores/word_store'; import type { WordFormStoreState } from '../stores/word_form_store'; @@ -100,7 +101,7 @@ export interface WordModalData { init(): void; // Methods - close(): void; + close(): Promise; speakWord(): void; setStatus(status: number): Promise; markWellKnown(): Promise; @@ -276,10 +277,10 @@ export function wordModalData(): WordModalData { return !!this.word?.wordId; }, - close(): void { + async close(): Promise { // Check for unsaved changes if (this.formStore.isDirty) { - if (!confirm('You have unsaved changes. Are you sure you want to close?')) { + if (!await confirmDiscardChanges()) { return; } this.formStore.reset(); diff --git a/src/frontend/js/modules/vocabulary/stores/multi_word_form_store.ts b/src/frontend/js/modules/vocabulary/stores/multi_word_form_store.ts index f7c9741f1..d24d1dbe5 100644 --- a/src/frontend/js/modules/vocabulary/stores/multi_word_form_store.ts +++ b/src/frontend/js/modules/vocabulary/stores/multi_word_form_store.ts @@ -8,6 +8,7 @@ * @since 3.0.0 */ +import { confirmDiscardChanges } from '@shared/components/confirm_dialog'; import Alpine from 'alpinejs'; import { TermsApi, type MultiWordData, type MultiWordInput } from '@modules/vocabulary/api/terms_api'; import { updateWordStatusInDOM } from '@modules/text/pages/reading/text_renderer'; @@ -80,7 +81,7 @@ export interface MultiWordFormStoreState { // Methods loadForEdit(textId: number, position: number, text: string, wordCount: number, wordId?: number): Promise; reset(): void; - close(): void; + close(): Promise; validate(): boolean; validateField(field: keyof MultiWordFormData): void; save(): Promise; @@ -296,15 +297,16 @@ function createMultiWordFormStore(): MultiWordFormStoreState { }, /** - * Close the modal. + * Close the modal, unless the user chooses to keep their changes. + * + * @returns Whether it closed */ - close(): void { - if (this.isDirty) { - if (!confirm('You have unsaved changes. Are you sure you want to close?')) { - return; - } + async close(): Promise { + if (this.isDirty && !await confirmDiscardChanges()) { + return false; } this.reset(); + return true; }, /** diff --git a/src/frontend/js/shared/components/confirm_dialog.ts b/src/frontend/js/shared/components/confirm_dialog.ts new file mode 100644 index 000000000..8b33892c1 --- /dev/null +++ b/src/frontend/js/shared/components/confirm_dialog.ts @@ -0,0 +1,160 @@ +/** + * In-app confirmation dialog. + * + * Use this rather than `window.confirm()`, which stops the whole page, ignores + * the theme and shows a browser-chosen title. The native dialog remains only + * as a fallback for when this one cannot be built. + * + * Choosing either button, pressing Escape or clicking outside settles the + * promise. Everything except the confirm button counts as declining, and the + * decline button has the focus, so a stray Enter loses nothing. + * + * @license Unlicense + * @since 3.8.1 + */ + +import { t } from '@shared/i18n/translator'; + +export interface ConfirmDialogOptions { + /** The question, shown as the dialog's heading. */ + title: string; + /** What will happen if the user confirms. */ + message: string; + /** Label of the confirming button. */ + confirmLabel: string; + /** Label of the declining button. */ + cancelLabel: string; + /** Show the confirming button as destructive. */ + danger?: boolean; +} + +/** The dialog on screen, so a second request joins it rather than stacking. */ +let pending: Promise | null = null; + +/** + * Build a Bulma modal with the given text. + * + * @param options Dialog text + * + * @returns The root element and its two buttons + */ +function buildDialog(options: ConfirmDialogOptions): { + root: HTMLElement; + confirmButton: HTMLButtonElement; + cancelButton: HTMLButtonElement; + background: HTMLElement; +} { + const root = document.createElement('div'); + root.className = 'modal is-active confirm-dialog'; + root.setAttribute('role', 'alertdialog'); + root.setAttribute('aria-modal', 'true'); + root.setAttribute('aria-labelledby', 'confirm-dialog-title'); + root.setAttribute('aria-describedby', 'confirm-dialog-message'); + + const background = document.createElement('div'); + background.className = 'modal-background'; + + const card = document.createElement('div'); + card.className = 'modal-card'; + + const body = document.createElement('section'); + body.className = 'modal-card-body'; + + const title = document.createElement('p'); + title.id = 'confirm-dialog-title'; + title.className = 'title is-5 mb-2'; + title.textContent = options.title; + + const message = document.createElement('p'); + message.id = 'confirm-dialog-message'; + message.textContent = options.message; + + const footer = document.createElement('footer'); + footer.className = 'modal-card-foot confirm-dialog__actions'; + + const cancelButton = document.createElement('button'); + cancelButton.type = 'button'; + cancelButton.className = 'button'; + cancelButton.textContent = options.cancelLabel; + + const confirmButton = document.createElement('button'); + confirmButton.type = 'button'; + confirmButton.className = options.danger ? 'button is-danger' : 'button is-primary'; + confirmButton.textContent = options.confirmLabel; + + body.append(title, message); + footer.append(cancelButton, confirmButton); + card.append(body, footer); + root.append(background, card); + + return { root, confirmButton, cancelButton, background }; +} + +/** + * Ask the user to confirm something. + * + * @param options Dialog text + * + * @returns Promise resolving to true if the user confirmed + */ +export function confirmDialog(options: ConfirmDialogOptions): Promise { + if (pending) return pending; + + let dialog: ReturnType; + try { + dialog = buildDialog(options); + document.body.append(dialog.root); + } catch { + return Promise.resolve(window.confirm(`${options.title}\n\n${options.message}`)); + } + + const { root, confirmButton, cancelButton, background } = dialog; + const previouslyFocused = document.activeElement as HTMLElement | null; + + pending = new Promise((resolve) => { + const settle = (confirmed: boolean): void => { + document.removeEventListener('keydown', onKeydown, true); + root.remove(); + pending = null; + previouslyFocused?.focus?.(); + resolve(confirmed); + }; + + // Capturing, and stopped here: the dialogs underneath also listen for + // Escape on the document, and would close behind this one. + const onKeydown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopImmediatePropagation(); + settle(false); + } else if (event.key === 'Tab') { + event.preventDefault(); + event.stopImmediatePropagation(); + (document.activeElement === cancelButton ? confirmButton : cancelButton).focus(); + } + }; + + document.addEventListener('keydown', onKeydown, true); + confirmButton.addEventListener('click', () => settle(true)); + cancelButton.addEventListener('click', () => settle(false)); + background.addEventListener('click', () => settle(false)); + }); + + cancelButton.focus(); + return pending; +} + +/** + * Ask whether to throw away unsaved changes. + * + * @returns Promise resolving to true if the changes can be discarded + */ +export function confirmDiscardChanges(): Promise { + return confirmDialog({ + title: t('common.dialog.discard_title'), + message: t('common.dialog.discard_message'), + confirmLabel: t('common.dialog.discard'), + cancelLabel: t('common.dialog.keep_editing'), + danger: true + }); +} diff --git a/tests/frontend/ui/confirm_dialog.test.ts b/tests/frontend/ui/confirm_dialog.test.ts new file mode 100644 index 000000000..b69a9368b --- /dev/null +++ b/tests/frontend/ui/confirm_dialog.test.ts @@ -0,0 +1,116 @@ +/** + * Tests for confirm_dialog.ts - in-app confirmation dialog + */ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { confirmDialog } from '../../../src/frontend/js/shared/components/confirm_dialog'; + +const options = { + title: 'Discard your changes?', + message: 'They have not been saved.', + confirmLabel: 'Discard', + cancelLabel: 'Keep editing', + danger: true +}; + +function button(label: string): HTMLButtonElement { + const found = Array.from(document.querySelectorAll('button')) + .find((el) => el.textContent === label); + if (!found) throw new Error(`No button "${label}"`); + return found; +} + +describe('confirm_dialog.ts', () => { + afterEach(() => { + document.querySelector('.confirm-dialog [class*="is-danger"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + it('shows the text as a modal alert, without the native dialog', () => { + const native = vi.spyOn(window, 'confirm'); + + confirmDialog(options); + + const dialog = document.querySelector('.confirm-dialog'); + expect(dialog?.getAttribute('role')).toBe('alertdialog'); + expect(dialog?.getAttribute('aria-modal')).toBe('true'); + expect(dialog?.textContent).toContain('They have not been saved.'); + expect(native).not.toHaveBeenCalled(); + }); + + it('resolves true on the confirm button and removes itself', async () => { + const answer = confirmDialog(options); + + button('Discard').click(); + + await expect(answer).resolves.toBe(true); + expect(document.querySelector('.confirm-dialog')).toBeNull(); + }); + + it('resolves false on the cancel button', async () => { + const answer = confirmDialog(options); + + button('Keep editing').click(); + + await expect(answer).resolves.toBe(false); + }); + + it('resolves false on a click outside the card', async () => { + const answer = confirmDialog(options); + + document.querySelector('.confirm-dialog .modal-background')?.click(); + + await expect(answer).resolves.toBe(false); + }); + + it('takes Escape for itself, so the dialog underneath stays open', async () => { + const underneath = vi.fn(); + document.addEventListener('keydown', underneath); + const answer = confirmDialog(options); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + await expect(answer).resolves.toBe(false); + expect(underneath).not.toHaveBeenCalled(); + document.removeEventListener('keydown', underneath); + }); + + it('focuses the safe choice, keeps Tab inside, and restores focus', async () => { + const input = document.createElement('input'); + document.body.append(input); + input.focus(); + + const answer = confirmDialog(options); + expect(document.activeElement).toBe(button('Keep editing')); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })); + expect(document.activeElement).toBe(button('Discard')); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })); + expect(document.activeElement).toBe(button('Keep editing')); + + button('Keep editing').click(); + await answer; + expect(document.activeElement).toBe(input); + }); + + it('joins a dialog already on screen rather than stacking another', () => { + const first = confirmDialog(options); + const second = confirmDialog(options); + + expect(second).toBe(first); + expect(document.querySelectorAll('.confirm-dialog')).toHaveLength(1); + }); + + it('falls back to the native dialog when it cannot be built', async () => { + vi.spyOn(document, 'createElement').mockImplementation(() => { + throw new Error('no DOM'); + }); + const native = vi.spyOn(window, 'confirm').mockReturnValue(true); + + await expect(confirmDialog(options)).resolves.toBe(true); + + expect(native).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/frontend/vocabulary/components/word_edit_form.test.ts b/tests/frontend/vocabulary/components/word_edit_form.test.ts index 6ea91bc57..d25753af3 100644 --- a/tests/frontend/vocabulary/components/word_edit_form.test.ts +++ b/tests/frontend/vocabulary/components/word_edit_form.test.ts @@ -3,6 +3,12 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +// In-app confirmation +const mockConfirmDiscard = vi.hoisted(() => vi.fn()); +vi.mock('../../../../src/frontend/js/shared/components/confirm_dialog', () => ({ + confirmDiscardChanges: mockConfirmDiscard +})); + // Mock Alpine.js vi.mock('alpinejs', () => ({ default: { @@ -378,32 +384,29 @@ describe('word_edit_form.ts', () => { expect(mockFormStore.shouldReturnToInfo).toBe(true); }); - it('shows confirmation when dirty', () => { + it('shows confirmation when dirty', async () => { mockFormStore.isDirty = true; - const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + mockConfirmDiscard.mockResolvedValue(false); - const component = wordEditFormData(); - component.cancel(); + await wordEditFormData().cancel(); - expect(confirmSpy).toHaveBeenCalled(); + expect(mockConfirmDiscard).toHaveBeenCalled(); }); - it('does not cancel if user declines confirmation', () => { + it('does not cancel if user declines confirmation', async () => { mockFormStore.isDirty = true; - vi.spyOn(window, 'confirm').mockReturnValue(false); + mockConfirmDiscard.mockResolvedValue(false); - const component = wordEditFormData(); - component.cancel(); + await wordEditFormData().cancel(); expect(mockFormStore.shouldReturnToInfo).toBe(false); }); - it('cancels if user confirms', () => { + it('cancels if user confirms', async () => { mockFormStore.isDirty = true; - vi.spyOn(window, 'confirm').mockReturnValue(true); + mockConfirmDiscard.mockResolvedValue(true); - const component = wordEditFormData(); - component.cancel(); + await wordEditFormData().cancel(); expect(mockFormStore.shouldReturnToInfo).toBe(true); }); diff --git a/tests/frontend/vocabulary/multi_word_modal.test.ts b/tests/frontend/vocabulary/multi_word_modal.test.ts index fb8f57439..dab599876 100644 --- a/tests/frontend/vocabulary/multi_word_modal.test.ts +++ b/tests/frontend/vocabulary/multi_word_modal.test.ts @@ -1,6 +1,7 @@ /** * Tests for multi_word_modal.ts - Alpine.js component for multi-word expression editing. */ +import * as focusTrap from '../../../src/frontend/js/shared/accessibility/focus_trap'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import Alpine from 'alpinejs'; @@ -193,6 +194,24 @@ describe('multi_word_modal.ts', () => { expect(mockStore.close).toHaveBeenCalled(); }); + + it('keeps the focus trap when the user keeps editing', async () => { + mockStore.close = vi.fn().mockResolvedValue(false); + const releaseFocus = vi.spyOn(focusTrap, 'releaseFocus'); + + await multiWordModalData().close(); + + expect(releaseFocus).not.toHaveBeenCalled(); + }); + + it('releases the focus trap once closed', async () => { + mockStore.close = vi.fn().mockResolvedValue(true); + const releaseFocus = vi.spyOn(focusTrap, 'releaseFocus'); + + await multiWordModalData().close(); + + expect(releaseFocus).toHaveBeenCalled(); + }); }); describe('save', () => { diff --git a/tests/frontend/vocabulary/stores/multi_word_form_store.test.ts b/tests/frontend/vocabulary/stores/multi_word_form_store.test.ts index 57e70153e..4aac3ce17 100644 --- a/tests/frontend/vocabulary/stores/multi_word_form_store.test.ts +++ b/tests/frontend/vocabulary/stores/multi_word_form_store.test.ts @@ -3,6 +3,12 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +// In-app confirmation +const mockConfirmDiscard = vi.hoisted(() => vi.fn()); +vi.mock('../../../../src/frontend/js/shared/components/confirm_dialog', () => ({ + confirmDiscardChanges: mockConfirmDiscard +})); + // Mock Alpine.js const mockAlpineStore = vi.fn(); vi.mock('alpinejs', () => ({ @@ -305,7 +311,7 @@ describe('multi_word_form_store.ts', () => { expect(storeData.isVisible).toBe(false); }); - it('prompts confirmation when dirty and resets on confirm', () => { + it('prompts confirmation when dirty and resets on confirm', async () => { storeData.originalData = { text: 'test', textLc: 'test', @@ -318,15 +324,15 @@ describe('multi_word_form_store.ts', () => { storeData.formData.translation = 'changed'; storeData.isVisible = true; - vi.spyOn(window, 'confirm').mockReturnValue(true); + mockConfirmDiscard.mockResolvedValue(true); - storeData.close(); + await expect(storeData.close()).resolves.toBe(true); - expect(window.confirm).toHaveBeenCalled(); + expect(mockConfirmDiscard).toHaveBeenCalled(); expect(storeData.isVisible).toBe(false); }); - it('does not close when dirty and user cancels', () => { + it('does not close when dirty and user cancels', async () => { storeData.originalData = { text: 'test', textLc: 'test', @@ -339,11 +345,11 @@ describe('multi_word_form_store.ts', () => { storeData.formData.translation = 'changed'; storeData.isVisible = true; - vi.spyOn(window, 'confirm').mockReturnValue(false); + mockConfirmDiscard.mockResolvedValue(false); - storeData.close(); + await expect(storeData.close()).resolves.toBe(false); - expect(window.confirm).toHaveBeenCalled(); + expect(mockConfirmDiscard).toHaveBeenCalled(); expect(storeData.isVisible).toBe(true); }); }); diff --git a/tests/frontend/vocabulary/word_modal.test.ts b/tests/frontend/vocabulary/word_modal.test.ts index 3ac329231..879bf5f2f 100644 --- a/tests/frontend/vocabulary/word_modal.test.ts +++ b/tests/frontend/vocabulary/word_modal.test.ts @@ -7,6 +7,12 @@ import Alpine from 'alpinejs'; // Use vi.hoisted to ensure mock function is available during hoisting const mockSpeechDispatcher = vi.hoisted(() => vi.fn()); +// In-app confirmation +const mockConfirmDiscard = vi.hoisted(() => vi.fn()); +vi.mock('../../../src/frontend/js/shared/components/confirm_dialog', () => ({ + confirmDiscardChanges: mockConfirmDiscard +})); + // Mock Alpine.js vi.mock('alpinejs', () => ({ default: { @@ -279,42 +285,31 @@ describe('word_modal.ts', () => { }); describe('close', () => { - it('closes modal without confirmation when form is not dirty', () => { + it('closes modal without confirmation when form is not dirty', async () => { mockFormStore.isDirty = false; - const component = wordModalData(); - component.close(); + await wordModalData().close(); - expect(confirm).not.toHaveBeenCalled(); + expect(mockConfirmDiscard).not.toHaveBeenCalled(); expect(mockWordStore.closeEditModal).toHaveBeenCalled(); }); - it('prompts for confirmation when form is dirty', () => { + it('resets form and closes modal when user confirms', async () => { mockFormStore.isDirty = true; - const component = wordModalData(); - - component.close(); + mockConfirmDiscard.mockResolvedValue(true); - expect(confirm).toHaveBeenCalledWith('You have unsaved changes. Are you sure you want to close?'); - }); - - it('resets form and closes modal when user confirms', () => { - mockFormStore.isDirty = true; - vi.mocked(confirm).mockReturnValue(true); - const component = wordModalData(); - - component.close(); + await wordModalData().close(); + expect(confirm).not.toHaveBeenCalled(); expect(mockFormStore.reset).toHaveBeenCalled(); expect(mockWordStore.closeEditModal).toHaveBeenCalled(); }); - it('does not close modal when user cancels', () => { + it('does not close modal when user cancels', async () => { mockFormStore.isDirty = true; - vi.mocked(confirm).mockReturnValue(false); - const component = wordModalData(); + mockConfirmDiscard.mockResolvedValue(false); - component.close(); + await wordModalData().close(); expect(mockFormStore.reset).not.toHaveBeenCalled(); expect(mockWordStore.closeEditModal).not.toHaveBeenCalled();