max / goingson
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': |