Skip to main content

max / goingson

Keep a select's off-list value instead of dropping to the first option A select fed a value no option carries renders with nothing selected, so the browser falls back to the first option and the next save writes a value nobody chose. The backup retention default of 10 against a 1/3/7/14/0 list did exactly that: opening Settings and saving rewrote it to 1. renderFormField now carries the stray value as its own leading option, so it stays selected, round-trips through a save, and is visible rather than silent. Comparison is stringly typed because callers pass a number for value and strings for opt.value.
Author: Max Johnson <me@maxj.phd> · 2026-07-26 20:56 UTC
Signed with PGP, not checked
Commit: b72c6d8abb7fca5cb3f437a1f11284eb38c7edb2
Parent: 840f2b3
1 file changed, +20 insertions, -3 deletions
@@ -153,7 +153,9 @@
153 153 * @param {*} [field.value] - Current value
154 154 * @param {string} [field.placeholder]
155 155 * @param {boolean} [field.required]
156 - * @param {Array<{value, label, selected?}>} [field.options] - For select
156 + * @param {Array<{value, label, selected?}>} [field.options] - For select. A value
157 + * matching no option is kept as a generated leading option rather than being
158 + * dropped to the browser's first-option fallback.
157 159 * @param {string} [field.hint] - Help text under input (HTML-escaped)
158 160 * @param {string} [field.hintExtraHtml] - Raw HTML appended after hint (NOT escaped; caller must sanitize)
159 161 * @param {string} [field.error] - Error text (renders has-error variant)
@@ -185,11 +187,26 @@
185 187 inputHtml = `<textarea class="form-textarea" id="${inputId}" name="${field.name}" ${required} ${placeholder} ${extraAttrs}>${esc(value)}</textarea>`;
186 188 break;
187 189 case 'select': {
188 - const optionsHtml = (field.options || []).map(opt => {
190 + const options = field.options || [];
191 + // A select fed a value no option carries renders with nothing
192 + // selected, so the browser falls back to the first option and the
193 + // next save writes a value nobody chose (the backup retention
194 + // default of 10 against a 1/3/7/14/0 list did exactly that).
195 + // Carry the stray value as its own option instead: it stays
196 + // selected, it round-trips through a save untouched, and it is
197 + // visible rather than silent. Compared as strings because callers
198 + // routinely pass a number for value and strings for opt.value.
199 + const matched = options.some(opt => opt.selected || String(opt.value) === String(value));
200 + let strayHtml = '';
201 + if (value !== '' && !matched) {
202 + console.warn(`renderFormField: select "${field.name}" has no option for value ${JSON.stringify(value)}; keeping the value as-is.`);
203 + strayHtml = `<option value="${escAttr(String(value))}" selected data-unmatched="true">${esc(String(value))}</option>`;
204 + }
205 + const optionsHtml = options.map(opt => {
189 206 const selected = (opt.selected || opt.value === value) ? 'selected' : '';
190 207 return `<option value="${escAttr(opt.value)}" ${selected}>${esc(opt.label)}</option>`;
191 208 }).join('');
192 - inputHtml = `<select class="form-select" id="${inputId}" name="${field.name}" ${required} ${extraAttrs}>${optionsHtml}</select>`;
209 + inputHtml = `<select class="form-select" id="${inputId}" name="${field.name}" ${required} ${extraAttrs}>${strayHtml}${optionsHtml}</select>`;
193 210 break;
194 211 }
195 212 case 'checkbox':