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 = [] } = {}) {
@@ -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
* -------------------------------------------------------- */