feat(post-form): Field Notes styling, disclosure, feedback states (U5)
Migrate Get Location/Weather + required-field validation out of the template's inline scripts into the bundle: generalize the field lookup so weather_desc (now a <select>) is found, gate Get Weather until coords exist, and add idle/loading/success/error states. Build the 'More options' <details> (native, KTD5) around the advanced trio, auto-open when any has a value. Add Field Notes CSS: select/number styling, EasyMDE dark theme, disclosure, button spinner, and server form-message states. Refs R5, R11, R12, R18, R19, AE3, KTD5.
This commit is contained in:
@@ -212,12 +212,204 @@ function initPhotoPicker() {
|
||||
}, true);
|
||||
}
|
||||
|
||||
/* ── Field helper ────────────────────────────────────────── */
|
||||
// Attribute selector matches input, select AND textarea — unlike the old
|
||||
// input[...] lookup, which broke once weather_desc became a <select> (U2).
|
||||
function field(name) {
|
||||
return document.querySelector('[name="data[' + name + ']"]');
|
||||
}
|
||||
|
||||
/* ── "More options" disclosure (U5, KTD5) ─────────────────────
|
||||
* The blueprint renders the advanced trio (hero_image/force_connect/featured)
|
||||
* flat; here we relocate their .form-field wrappers into a native <details> so
|
||||
* they collapse by default with full keyboard/AT support. Auto-opens if any
|
||||
* advanced field already carries a value (edit / draft restore).
|
||||
*/
|
||||
function initDisclosure() {
|
||||
var flagged = Array.prototype.slice.call(document.querySelectorAll('.advanced-field'));
|
||||
if (!flagged.length) return;
|
||||
|
||||
var wrappers = [];
|
||||
flagged.forEach(function (el) {
|
||||
var w = el.closest('.form-field');
|
||||
if (w && wrappers.indexOf(w) === -1) wrappers.push(w);
|
||||
});
|
||||
if (!wrappers.length) return;
|
||||
|
||||
var details = document.createElement('details');
|
||||
details.className = 'more-options';
|
||||
var summary = document.createElement('summary');
|
||||
summary.className = 'more-options__summary';
|
||||
summary.textContent = 'More options';
|
||||
details.appendChild(summary);
|
||||
|
||||
wrappers[0].parentNode.insertBefore(details, wrappers[0]);
|
||||
wrappers.forEach(function (w) { details.appendChild(w); });
|
||||
|
||||
var hasValue = wrappers.some(function (w) {
|
||||
var text = w.querySelector('input[type="text"]');
|
||||
if (text && text.value.trim()) return true;
|
||||
var toggle = w.querySelector('input[type="radio"]:checked');
|
||||
if (toggle && toggle.value && toggle.value !== '0') return true;
|
||||
return false;
|
||||
});
|
||||
if (hasValue) details.open = true;
|
||||
}
|
||||
|
||||
/* ── Get Location / Get Weather (U5, R18) ─────────────────────
|
||||
* Migrated out of the template's inline scripts so it can read the now-<select>
|
||||
* weather_desc field and expose idle/loading/success/error states.
|
||||
*/
|
||||
var WMO_MAP = {
|
||||
0: 'Sunny', 1: 'Partly cloudy', 2: 'Partly cloudy', 3: 'Cloudy',
|
||||
45: 'Foggy', 48: 'Foggy',
|
||||
51: 'Drizzle', 53: 'Drizzle', 55: 'Drizzle', 56: 'Drizzle', 57: 'Drizzle',
|
||||
61: 'Rain', 63: 'Rain', 65: 'Rain', 66: 'Rain', 67: 'Rain', 80: 'Rain', 81: 'Rain', 82: 'Rain',
|
||||
71: 'Snow', 73: 'Snow', 75: 'Snow', 77: 'Snow', 85: 'Snow', 86: 'Snow',
|
||||
95: 'Thunderstorm', 96: 'Thunderstorm', 99: 'Thunderstorm'
|
||||
};
|
||||
|
||||
function setStatus(el, msg, kind) {
|
||||
if (!el) return;
|
||||
el.className = 'form-status' + (kind ? ' form-status--' + kind : '');
|
||||
el.textContent = msg || '';
|
||||
}
|
||||
|
||||
function initGeo() {
|
||||
var locBtn = document.getElementById('get-location');
|
||||
var wxBtn = document.getElementById('get-weather');
|
||||
if (!locBtn && !wxBtn) return;
|
||||
|
||||
var locStatus = document.getElementById('location-status');
|
||||
var wxStatus = document.getElementById('weather-status');
|
||||
|
||||
function coords() {
|
||||
var latEl = field('lat');
|
||||
var lngEl = field('lng');
|
||||
var lat = latEl ? latEl.value.trim() : '';
|
||||
var lng = lngEl ? lngEl.value.trim() : '';
|
||||
return (lat && lng) ? { lat: lat, lng: lng } : null;
|
||||
}
|
||||
|
||||
// Get Weather is meaningless without coordinates — gate it (R18).
|
||||
function syncWeatherEnabled() {
|
||||
if (!wxBtn) return;
|
||||
var ok = !!coords();
|
||||
wxBtn.disabled = !ok;
|
||||
wxBtn.title = ok ? '' : 'Get location first';
|
||||
}
|
||||
syncWeatherEnabled();
|
||||
|
||||
if (locBtn) {
|
||||
locBtn.addEventListener('click', function () {
|
||||
if (!navigator.geolocation) {
|
||||
setStatus(locStatus, 'Geolocation is not supported on this device.', 'err');
|
||||
return;
|
||||
}
|
||||
locBtn.classList.add('is-loading');
|
||||
locBtn.disabled = true;
|
||||
setStatus(locStatus, 'Getting location…');
|
||||
navigator.geolocation.getCurrentPosition(function (pos) {
|
||||
var lat = pos.coords.latitude.toFixed(6);
|
||||
var lng = pos.coords.longitude.toFixed(6);
|
||||
var latEl = field('lat');
|
||||
var lngEl = field('lng');
|
||||
if (latEl) latEl.value = lat;
|
||||
if (lngEl) lngEl.value = lng;
|
||||
locBtn.classList.remove('is-loading');
|
||||
locBtn.disabled = false;
|
||||
setStatus(locStatus, '✓ Location captured · ' + lat + ', ' + lng, 'ok');
|
||||
syncWeatherEnabled();
|
||||
}, function (err) {
|
||||
locBtn.classList.remove('is-loading');
|
||||
locBtn.disabled = false;
|
||||
setStatus(locStatus, '✗ ' + (err && err.message ? err.message : 'Could not get location') + ' — enter coordinates manually if needed.', 'err');
|
||||
}, { enableHighAccuracy: true, timeout: 15000 });
|
||||
});
|
||||
}
|
||||
|
||||
if (wxBtn) {
|
||||
wxBtn.addEventListener('click', function () {
|
||||
var c = coords();
|
||||
if (!c) {
|
||||
setStatus(wxStatus, 'Get location first, then fetch weather.', 'err');
|
||||
return;
|
||||
}
|
||||
wxBtn.classList.add('is-loading');
|
||||
wxBtn.disabled = true;
|
||||
setStatus(wxStatus, 'Fetching weather…');
|
||||
var url = 'https://api.open-meteo.com/v1/forecast?latitude=' + c.lat +
|
||||
'&longitude=' + c.lng + '¤t=temperature_2m,weather_code&temperature_unit=celsius';
|
||||
fetch(url).then(function (r) { return r.json(); }).then(function (data) {
|
||||
var temp = Math.round(data.current.temperature_2m);
|
||||
var desc = WMO_MAP[data.current.weather_code] || 'Cloudy';
|
||||
var tempEl = field('weather_temp_c');
|
||||
var descEl = field('weather_desc');
|
||||
if (tempEl) tempEl.value = temp;
|
||||
if (descEl) descEl.value = desc; // works for <select> too (R6)
|
||||
wxBtn.classList.remove('is-loading');
|
||||
syncWeatherEnabled();
|
||||
setStatus(wxStatus, '✓ Weather set · ' + desc + ' · ' + temp + '°C (edit above if needed)', 'ok');
|
||||
}).catch(function () {
|
||||
wxBtn.classList.remove('is-loading');
|
||||
syncWeatherEnabled();
|
||||
setStatus(wxStatus, '✗ Could not fetch weather — set it manually above.', 'err');
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Blocking required-field validation (U5, R19) ─────────────
|
||||
* Replaces the template's inline validator. Reads the EasyMDE-synced content
|
||||
* value (initEditor keeps the textarea current) and shows per-field messages.
|
||||
*/
|
||||
function initValidation() {
|
||||
var REQUIRED = { title: 'Title', content: 'Content' };
|
||||
var form = document.querySelector('form[name="new-entry"]');
|
||||
if (!form) return;
|
||||
|
||||
function clearErrors() {
|
||||
form.querySelectorAll('.field-error').forEach(function (el) { el.remove(); });
|
||||
form.querySelectorAll('.field-invalid').forEach(function (el) { el.classList.remove('field-invalid'); });
|
||||
}
|
||||
|
||||
function showError(el, msg) {
|
||||
el.classList.add('field-invalid');
|
||||
var err = document.createElement('span');
|
||||
err.className = 'field-error';
|
||||
err.textContent = msg;
|
||||
el.parentNode.insertBefore(err, el.nextSibling);
|
||||
}
|
||||
|
||||
// Bubble phase: runs after initEditor's capture-phase codemirror.save(), so
|
||||
// the content textarea already holds the live value.
|
||||
form.addEventListener('submit', function (e) {
|
||||
clearErrors();
|
||||
var firstInvalid = null;
|
||||
Object.keys(REQUIRED).forEach(function (name) {
|
||||
var el = field(name);
|
||||
if (el && !String(el.value).trim()) {
|
||||
showError(el, REQUIRED[name] + ' is required.');
|
||||
if (!firstInvalid) firstInvalid = el;
|
||||
}
|
||||
});
|
||||
if (firstInvalid) {
|
||||
e.preventDefault();
|
||||
firstInvalid.focus();
|
||||
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Boot ────────────────────────────────────────────────── */
|
||||
function boot() {
|
||||
// Exposed for later units (U6 draft restore) and the Playwright specs;
|
||||
// null when this bundle loads on a page without the content field.
|
||||
window.postFormEditor = initEditor();
|
||||
initPhotoPicker();
|
||||
initDisclosure();
|
||||
initGeo();
|
||||
initValidation();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
|
||||
Reference in New Issue
Block a user