From 7eb06ed9059da497e16d91aafc8f416b807d0b4e Mon Sep 17 00:00:00 2001 From: Ulas Date: Sat, 4 Apr 2026 21:31:50 +0200 Subject: [PATCH] fix(modal): replace native prompt() with custom modal dialogs Native browser prompt() is unreliable on mobile browsers and PWAs, often requiring multiple clicks to close. Replace all prompt() calls with custom promptModal() and selectModal() functions that use the existing modal system with proper focus management and animations. Affected pages: shopping (create/rename list), tasks (add subtask), meals (choose shopping list). Fixes #12 --- CHANGELOG.md | 5 ++ package.json | 2 +- public/components/modal.js | 146 +++++++++++++++++++++++++++++++++++++ public/pages/meals.js | 11 ++- public/pages/shopping.js | 13 ++-- public/pages/tasks.js | 8 +- public/styles/layout.css | 12 +++ 7 files changed, 180 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 124fcb3..d1a70fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.1] - 2026-04-04 + +### Fixed +- Replace native `prompt()` dialogs with custom modals in shopping (create/rename list), tasks (add subtask), and meals (choose shopping list) - native prompts were unreliable on mobile/PWA, requiring multiple clicks to close (#12) + ## [0.8.0] - 2026-04-04 ### Added diff --git a/package.json b/package.json index 2466e5b..d860142 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.8.0", + "version": "0.8.1", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.", "main": "server/index.js", "type": "module", diff --git a/public/components/modal.js b/public/components/modal.js index 5327314..1045064 100644 --- a/public/components/modal.js +++ b/public/components/modal.js @@ -276,6 +276,152 @@ export function closeModal() { _doClose(); } +// -------------------------------------------------------- +// promptModal - Ersatz für native prompt() +// -------------------------------------------------------- + +/** + * Öffnet ein Modal mit Textfeld als Ersatz für native prompt(). + * Gibt ein Promise zurück: string bei OK, null bei Cancel/Escape. + * + * @param {string} label - Beschriftung / Frage + * @param {string} [defaultValue=''] - Vorausgefüllter Wert + * @returns {Promise} + */ +export function promptModal(label, defaultValue = '') { + return new Promise((resolve) => { + let resolved = false; + + function finish(value) { + if (resolved) return; + resolved = true; + closeModal(); + resolve(value); + } + + openModal({ + title: label, + size: 'sm', + content: ` +
+
+ +
+ +
`, + onSave(panel) { + const form = panel.querySelector('#prompt-modal-form'); + const input = panel.querySelector('#prompt-modal-input'); + const cancel = panel.querySelector('#prompt-modal-cancel'); + + form.addEventListener('submit', (e) => { + e.preventDefault(); + finish(input.value.trim() || null); + }); + + cancel.addEventListener('click', () => finish(null)); + + // Escape soll null liefern (closeModal wird über onEscape bereits ausgelöst) + const escHandler = (e) => { + if (e.key === 'Escape') { + document.removeEventListener('keydown', escHandler); + finish(null); + } + }; + document.addEventListener('keydown', escHandler); + + // Overlay-Click soll null liefern + const overlay = panel.closest('.modal-overlay'); + if (overlay) { + overlay.addEventListener('click', (e) => { + if (e.target === overlay) finish(null); + }); + } + + // Input fokussieren und Text selektieren + setTimeout(() => { + input.focus(); + input.select(); + }, 50); + }, + }); + }); +} + +// -------------------------------------------------------- +// selectModal - Ersatz für native prompt() mit Auswahlliste +// -------------------------------------------------------- + +/** + * Öffnet ein Modal mit Select-Dropdown als Ersatz für native prompt() bei Listenauswahl. + * + * @param {string} label - Beschriftung / Frage + * @param {{ value: string|number, label: string }[]} options - Auswahloptionen + * @returns {Promise} + */ +export function selectModal(label, options) { + return new Promise((resolve) => { + let resolved = false; + + function finish(value) { + if (resolved) return; + resolved = true; + closeModal(); + resolve(value); + } + + const optionsHtml = options + .map((o) => ``) + .join(''); + + openModal({ + title: label, + size: 'sm', + content: ` +
+
+ +
+ +
`, + onSave(panel) { + const form = panel.querySelector('#select-modal-form'); + const select = panel.querySelector('#select-modal-input'); + const cancel = panel.querySelector('#select-modal-cancel'); + + form.addEventListener('submit', (e) => { + e.preventDefault(); + finish(select.value); + }); + + cancel.addEventListener('click', () => finish(null)); + + const escHandler = (e) => { + if (e.key === 'Escape') { + document.removeEventListener('keydown', escHandler); + finish(null); + } + }; + document.addEventListener('keydown', escHandler); + + const overlay = panel.closest('.modal-overlay'); + if (overlay) { + overlay.addEventListener('click', (e) => { + if (e.target === overlay) finish(null); + }); + } + }, + }); + }); +} + // -------------------------------------------------------- // Inline Blur-Validierung // -------------------------------------------------------- diff --git a/public/pages/meals.js b/public/pages/meals.js index 2600b90..7d487be 100644 --- a/public/pages/meals.js +++ b/public/pages/meals.js @@ -5,7 +5,7 @@ */ import { api } from '/api.js'; -import { openModal as openSharedModal, closeModal as closeSharedModal } from '/components/modal.js'; +import { openModal as openSharedModal, closeModal as closeSharedModal, selectModal } from '/components/modal.js'; import { stagger } from '/utils/ux.js'; import { t, formatDate } from '/i18n.js'; import { esc } from '/utils/html.js'; @@ -695,11 +695,10 @@ async function transferMeal(mealId) { let listId = state.lists[0].id; if (state.lists.length > 1) { - const names = state.lists.map((l, i) => `${i + 1}. ${l.name}`).join('\n'); - const choice = prompt(`Auf welche Einkaufsliste?\n${names}\nNummer eingeben:`); - const n = parseInt(choice, 10); - if (!n || n < 1 || n > state.lists.length) return; - listId = state.lists[n - 1].id; + const options = state.lists.map((l) => ({ value: l.id, label: l.name })); + const choice = await selectModal(t('meals.transferToShoppingList'), options); + if (choice === null) return; + listId = Number(choice); } try { diff --git a/public/pages/shopping.js b/public/pages/shopping.js index bbef484..da88fad 100644 --- a/public/pages/shopping.js +++ b/public/pages/shopping.js @@ -8,6 +8,7 @@ import { api } from '/api.js'; import { stagger, vibrate } from '/utils/ux.js'; import { t } from '/i18n.js'; import { esc } from '/utils/html.js'; +import { promptModal } from '/components/modal.js'; // -------------------------------------------------------- // Konstanten @@ -571,10 +572,10 @@ function wireTabBar(container) { } if (target.dataset.action === 'new-list') { - const name = prompt(t('shopping.newListPrompt')); - if (!name?.trim()) return; + const name = await promptModal(t('shopping.newListPrompt')); + if (!name) return; try { - const data = await api.post('/shopping', { name: name.trim() }); + const data = await api.post('/shopping', { name }); state.lists.push({ ...data.data, item_total: 0, item_checked: 0 }); await switchList(data.data.id, container); } catch (err) { @@ -652,10 +653,10 @@ function wireListContentEvents(container) { // ---- Liste umbenennen ---- if (action === 'rename-list') { - const newName = prompt(t('shopping.renameListPrompt'), state.activeList?.name); - if (!newName?.trim() || newName.trim() === state.activeList?.name) return; + const newName = await promptModal(t('shopping.renameListPrompt'), state.activeList?.name ?? ''); + if (!newName || newName === state.activeList?.name) return; try { - const data = await api.put(`/shopping/${state.activeListId}`, { name: newName.trim() }); + const data = await api.put(`/shopping/${state.activeListId}`, { name: newName }); const idx = state.lists.findIndex((l) => l.id === state.activeListId); if (idx >= 0) state.lists[idx].name = data.data.name; state.activeList = data.data; diff --git a/public/pages/tasks.js b/public/pages/tasks.js index 39fa71c..54427a3 100644 --- a/public/pages/tasks.js +++ b/public/pages/tasks.js @@ -6,7 +6,7 @@ import { api } from '/api.js'; import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js'; -import { openModal as openSharedModal, closeModal, wireBlurValidation, btnSuccess, btnError } from '/components/modal.js'; +import { openModal as openSharedModal, closeModal, wireBlurValidation, btnSuccess, btnError, promptModal } from '/components/modal.js'; import { stagger, vibrate } from '/utils/ux.js'; import { t, formatDate } from '/i18n.js'; import { esc } from '/utils/html.js'; @@ -481,10 +481,10 @@ async function handleDeleteTask(id, container) { } async function handleAddSubtask(parentId, container) { - const title = prompt(t('tasks.subtaskPrompt')); - if (!title?.trim()) return; + const title = await promptModal(t('tasks.subtaskPrompt')); + if (!title) return; try { - await api.post('/tasks', { title: title.trim(), parent_task_id: parentId }); + await api.post('/tasks', { title, parent_task_id: parentId }); await loadTasks(container); } catch (err) { window.oikos.showToast(err.message, 'danger'); diff --git a/public/styles/layout.css b/public/styles/layout.css index 42c5c49..b000ccb 100644 --- a/public/styles/layout.css +++ b/public/styles/layout.css @@ -690,6 +690,18 @@ gap: var(--space-4); } +.form-stack { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: var(--space-2); +} + .modal-panel__footer { display: flex; align-items: center;