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();