feat: blur-triggered inline validation and submit button feedback
Task 13: wireBlurValidation() activates error/valid state on required fields after blur. Task 14: btnSuccess() shows a checkmark for 700ms then closes the modal; btnError() triggers a shake animation on failure. Both wired into the tasks form submit handler. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -269,3 +269,57 @@ export function closeModal() {
|
|||||||
|
|
||||||
_doClose();
|
_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 = `
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2.5" aria-hidden="true">
|
||||||
|
<polyline points="20 6 9 17 4 12"/>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|||||||
+23
-7
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import { api } from '/api.js';
|
import { api } from '/api.js';
|
||||||
import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.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';
|
import { stagger, vibrate } from '/utils/ux.js';
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -240,10 +240,19 @@ function renderModalContent({ task = null, users = [] } = {}) {
|
|||||||
<input type="hidden" id="task-id" value="${task?.id ?? ''}">
|
<input type="hidden" id="task-id" value="${task?.id ?? ''}">
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="label" for="task-title">Titel *</label>
|
<div class="form-field">
|
||||||
<input class="input" type="text" id="task-title" name="title"
|
<label class="label" for="task-title">Titel *</label>
|
||||||
value="${task?.title ?? ''}" placeholder="Was muss erledigt werden?"
|
<input class="input" type="text" id="task-title" name="title"
|
||||||
required autocomplete="off">
|
value="${task?.title ?? ''}" placeholder="Was muss erledigt werden?"
|
||||||
|
required autocomplete="off">
|
||||||
|
<div class="form-field__error">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12" y2="16.01"/>
|
||||||
|
</svg>
|
||||||
|
Dieses Feld ist erforderlich.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -373,6 +382,9 @@ function openTaskModal({ task = null, users = [] } = {}, container) {
|
|||||||
// RRULE-Events binden
|
// RRULE-Events binden
|
||||||
bindRRuleEvents(document, 'task');
|
bindRRuleEvents(document, 'task');
|
||||||
|
|
||||||
|
// Blur-Validierung für required-Felder aktivieren
|
||||||
|
wireBlurValidation(panel);
|
||||||
|
|
||||||
// Form-Events
|
// Form-Events
|
||||||
panel.querySelector('#task-form')
|
panel.querySelector('#task-form')
|
||||||
?.addEventListener('submit', (e) => handleFormSubmit(e, container));
|
?.addEventListener('submit', (e) => handleFormSubmit(e, container));
|
||||||
@@ -398,6 +410,8 @@ async function handleFormSubmit(e, container) {
|
|||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
submitBtn.textContent = 'Wird gespeichert…';
|
submitBtn.textContent = 'Wird gespeichert…';
|
||||||
|
|
||||||
|
const originalLabel = taskId ? 'Speichern' : 'Erstellen';
|
||||||
|
|
||||||
const rrule = getRRuleValues(document, 'task');
|
const rrule = getRRuleValues(document, 'task');
|
||||||
const body = {
|
const body = {
|
||||||
title: form.title.value.trim(),
|
title: form.title.value.trim(),
|
||||||
@@ -420,13 +434,15 @@ async function handleFormSubmit(e, container) {
|
|||||||
await api.post('/tasks', body);
|
await api.post('/tasks', body);
|
||||||
window.oikos.showToast('Aufgabe erstellt.', 'success');
|
window.oikos.showToast('Aufgabe erstellt.', 'success');
|
||||||
}
|
}
|
||||||
closeModal();
|
btnSuccess(submitBtn, originalLabel);
|
||||||
|
setTimeout(() => closeModal(), 700);
|
||||||
await loadTasks(container);
|
await loadTasks(container);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorEl.textContent = err.message;
|
errorEl.textContent = err.message;
|
||||||
errorEl.hidden = false;
|
errorEl.hidden = false;
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
submitBtn.textContent = taskId ? 'Speichern' : 'Erstellen';
|
submitBtn.textContent = originalLabel;
|
||||||
|
btnError(submitBtn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -922,6 +922,35 @@
|
|||||||
margin-bottom: var(--space-4);
|
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
|
* Skeleton-Loading
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
@@ -1285,6 +1314,25 @@
|
|||||||
border-color: var(--color-accent-hover);
|
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
|
* Print-Styles
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
|
|||||||
Reference in New Issue
Block a user