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:
@@ -65,3 +65,176 @@
|
||||
}
|
||||
.photo-card.is-error .photo-card__state { background: rgba(176, 0, 32, 0.85); }
|
||||
.photo-card.is-converting .photo-card__thumb { opacity: 0.4; }
|
||||
|
||||
/* Photo picker add button + hint (U5 polish) */
|
||||
.photo-picker__add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.photo-picker.is-busy .photo-picker__add {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
.photo-picker__hint:empty { display: none; }
|
||||
.photo-picker__reauth-hint {
|
||||
display: none;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-ink-muted);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.photo-picker__reauth-hint.is-shown { display: block; }
|
||||
|
||||
/* ── Field Notes styling for the new controls (U5, R11/R12) ── */
|
||||
|
||||
/* Select fields (weather condition, transport mode) match the text inputs. */
|
||||
.post-form-wrap select {
|
||||
width: 100%;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-base);
|
||||
padding: 0.875rem 1rem;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-ink);
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%2390887E' d='M1 1l5 5 5-5'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 1rem center;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
.post-form-wrap select:focus {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 1px;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
.post-form-wrap input[type="number"] {
|
||||
width: 100%;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-base);
|
||||
padding: 0.875rem 1rem;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-ink);
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
.post-form-wrap input[type="number"]:focus {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 1px;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* "More options" disclosure */
|
||||
.more-options {
|
||||
margin-bottom: var(--space-5);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
.more-options__summary {
|
||||
cursor: pointer;
|
||||
padding: 0.875rem 1rem;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-ink);
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
.more-options__summary::-webkit-details-marker { display: none; }
|
||||
.more-options__summary::before {
|
||||
content: '▸';
|
||||
margin-right: var(--space-2);
|
||||
color: var(--color-ink-muted);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.more-options[open] .more-options__summary::before { transform: rotate(90deg); }
|
||||
.more-options[open] .more-options__summary { border-bottom: 1px solid var(--color-border); }
|
||||
.more-options > .form-field { padding: 0 1rem; }
|
||||
.more-options > .form-field:first-of-type { padding-top: var(--space-4); }
|
||||
.more-options > .form-field:last-child { padding-bottom: var(--space-4); margin-bottom: 0; }
|
||||
|
||||
/* Button loading spinner (Get Location / Get Weather) */
|
||||
.btn-action.is-loading { position: relative; color: transparent; pointer-events: none; }
|
||||
.btn-action.is-loading::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: -8px 0 0 -8px;
|
||||
border: 2px solid var(--color-ink-muted);
|
||||
border-top-color: var(--color-accent);
|
||||
border-radius: 50%;
|
||||
animation: post-spin 0.7s linear infinite;
|
||||
}
|
||||
.btn-action[disabled] { opacity: 0.5; cursor: not-allowed; }
|
||||
@keyframes post-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Grav form save/validation messages (success + error from the server) */
|
||||
.post-form-wrap .form-messages { margin-bottom: var(--space-5); }
|
||||
.post-form-wrap .form-message {
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.post-form-wrap .form-message.notice,
|
||||
.post-form-wrap .form-message.error {
|
||||
background: var(--color-accent-light);
|
||||
color: var(--color-ink);
|
||||
border: 1px solid var(--color-error);
|
||||
}
|
||||
.post-form-wrap .form-message.success {
|
||||
background: var(--color-accent-light);
|
||||
color: var(--color-ink);
|
||||
border: 1px solid var(--color-accent);
|
||||
}
|
||||
|
||||
/* ── EasyMDE — Field Notes dark theme (U5) ─────────────────── */
|
||||
.EasyMDEContainer .CodeMirror {
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-ink);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
padding: var(--space-1);
|
||||
}
|
||||
.EasyMDEContainer .CodeMirror-cursor { border-color: var(--color-ink); }
|
||||
.EasyMDEContainer .CodeMirror-selected { background: var(--color-accent-light) !important; }
|
||||
.EasyMDEContainer .editor-toolbar {
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-bottom: none;
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
opacity: 1;
|
||||
}
|
||||
.EasyMDEContainer .editor-toolbar button {
|
||||
color: var(--color-ink) !important;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
.EasyMDEContainer .editor-toolbar button:hover,
|
||||
.EasyMDEContainer .editor-toolbar button.active {
|
||||
background: var(--color-accent-light);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
.EasyMDEContainer .editor-toolbar i.separator { border-color: var(--color-border); }
|
||||
.EasyMDEContainer .editor-preview,
|
||||
.EasyMDEContainer .editor-preview-side {
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.EasyMDEContainer .editor-preview a { color: var(--color-accent); }
|
||||
|
||||
@@ -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