diff --git a/public/components/modal.js b/public/components/modal.js index 778b357..890d290 100644 --- a/public/components/modal.js +++ b/public/components/modal.js @@ -269,3 +269,57 @@ export function closeModal() { _doClose(); } + +// -------------------------------------------------------- +// Inline Blur-Validierung +// -------------------------------------------------------- + +/** + * Aktiviert Blur-Validierung für alle required-Inputs in einem Container. + * @param {HTMLElement} formContainer + */ +export function wireBlurValidation(formContainer) { + formContainer.querySelectorAll('input[required], select[required], textarea[required]').forEach((input) => { + input.addEventListener('blur', () => { + const group = input.closest('.form-field') ?? input.parentElement; + const hasValue = input.value.trim().length > 0; + group?.classList.toggle('form-field--error', !hasValue); + group?.classList.toggle('form-field--valid', hasValue); + }); + }); +} + +// -------------------------------------------------------- +// Submit-Feedback (Checkmark + Shake) +// -------------------------------------------------------- + +/** + * Zeigt Erfolgs-Feedback auf einem Button (Checkmark für 700ms). + * @param {HTMLButtonElement} btn + * @param {string} [originalLabel] + */ +export function btnSuccess(btn, originalLabel) { + const label = originalLabel ?? btn.textContent; + btn.classList.add('btn--success'); + btn.innerHTML = ` + + `; + setTimeout(() => { + btn.classList.remove('btn--success'); + btn.textContent = label; + }, 700); +} + +/** + * Zeigt Fehler-Feedback auf einem Button (Shake-Animation). + * @param {HTMLButtonElement} btn + */ +export function btnError(btn) { + btn.classList.remove('btn--shaking'); + void btn.offsetWidth; // Reflow für Animation-Restart + btn.classList.add('btn--shaking'); + btn.addEventListener('animationend', () => btn.classList.remove('btn--shaking'), { once: true }); +} diff --git a/public/pages/tasks.js b/public/pages/tasks.js index 82a9c69..da28ae2 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 } from '/components/modal.js'; +import { openModal as openSharedModal, closeModal, wireBlurValidation, btnSuccess, btnError } from '/components/modal.js'; import { stagger, vibrate } from '/utils/ux.js'; // -------------------------------------------------------- @@ -240,10 +240,19 @@ function renderModalContent({ task = null, users = [] } = {}) {
- - +
+ + +
+ + Dieses Feld ist erforderlich. +
+
@@ -373,6 +382,9 @@ function openTaskModal({ task = null, users = [] } = {}, container) { // RRULE-Events binden bindRRuleEvents(document, 'task'); + // Blur-Validierung für required-Felder aktivieren + wireBlurValidation(panel); + // Form-Events panel.querySelector('#task-form') ?.addEventListener('submit', (e) => handleFormSubmit(e, container)); @@ -398,6 +410,8 @@ async function handleFormSubmit(e, container) { submitBtn.disabled = true; submitBtn.textContent = 'Wird gespeichert…'; + const originalLabel = taskId ? 'Speichern' : 'Erstellen'; + const rrule = getRRuleValues(document, 'task'); const body = { title: form.title.value.trim(), @@ -420,13 +434,15 @@ async function handleFormSubmit(e, container) { await api.post('/tasks', body); window.oikos.showToast('Aufgabe erstellt.', 'success'); } - closeModal(); + btnSuccess(submitBtn, originalLabel); + setTimeout(() => closeModal(), 700); await loadTasks(container); } catch (err) { errorEl.textContent = err.message; errorEl.hidden = false; submitBtn.disabled = false; - submitBtn.textContent = taskId ? 'Speichern' : 'Erstellen'; + submitBtn.textContent = originalLabel; + btnError(submitBtn); } } diff --git a/public/styles/layout.css b/public/styles/layout.css index f31bcf6..e82e48c 100644 --- a/public/styles/layout.css +++ b/public/styles/layout.css @@ -922,6 +922,35 @@ margin-bottom: var(--space-4); } +/* ── Inline-Validierung ── */ +.form-field { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.form-field--error .input, +.form-field--error .form-input { + border-color: var(--color-danger); +} + +.form-field--valid .input, +.form-field--valid .form-input { + border-color: var(--color-success); +} + +.form-field__error { + display: none; + font-size: var(--text-sm); + color: var(--color-danger); + gap: var(--space-1); + align-items: center; +} + +.form-field--error .form-field__error { + display: flex; +} + /* -------------------------------------------------------- * Skeleton-Loading * -------------------------------------------------------- */ @@ -1285,6 +1314,25 @@ border-color: var(--color-accent-hover); } +/* ── Submit-Feedback Animationen ── */ +@keyframes btn-shake { + 0%, 100% { transform: translateX(0); } + 20% { transform: translateX(-4px); } + 40% { transform: translateX(4px); } + 60% { transform: translateX(-4px); } + 80% { transform: translateX(4px); } +} + +.btn--shaking { + animation: btn-shake 0.3s ease; +} + +.btn--success { + background-color: var(--color-success) !important; + color: #fff !important; + pointer-events: none; +} + /* -------------------------------------------------------- * Print-Styles * -------------------------------------------------------- */