From b6f43573a2b66946aed7e666bd95b02ab5b4081e Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:46:50 -0400 Subject: [PATCH] Show an empty-string option as chosen in pickers A single-choice picker treats an empty value as a choice when an option carries it, so the Referrer policy picker shows and selects Default. --- ui/admin/index.html | 2 +- ui/js/admin-app-form.js | 8 ++++---- ui/js/admin-auth.js | 2 +- ui/js/admin-color-control.js | 2 +- ui/js/admin-list.js | 6 +++--- ui/js/admin-logic.js | 10 ++++++++++ ui/js/admin-settings.js | 6 +++--- ui/js/admin-shared.js | 2 +- ui/js/admin-widget-form.js | 8 ++++---- ui/js/admin.js | 16 ++++++++-------- ui/js/listbox.js | 14 ++++---------- ui/js/widget-config-form.js | 8 ++++---- ui/test/admin-logic.test.mjs | 18 ++++++++++++++++++ ui/test/listbox.test.mjs | 5 +++++ 14 files changed, 67 insertions(+), 40 deletions(-) diff --git a/ui/admin/index.html b/ui/admin/index.html index 52f27f5c..bcd31ca1 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -300,6 +300,6 @@

General

- + diff --git a/ui/js/admin-app-form.js b/ui/js/admin-app-form.js index 47ea9f68..d2c0f702 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -18,7 +18,7 @@ import { failureIsMissingApiPath, nextActiveIndex, sameIconName, -} from '/js/admin-logic.js?v=fc7f0836'; +} from '/js/admin-logic.js?v=1cca36f5'; import { t } from '/js/i18n.js?v=ed7c5565'; import { toast, @@ -31,10 +31,10 @@ import { reveal, setTogDisabled, swapContent, -} from '/js/admin-shared.js?v=da7df247'; -import { createListbox } from '/js/listbox.js?v=4afb5969'; +} from '/js/admin-shared.js?v=5e6a023a'; +import { createListbox } from '/js/listbox.js?v=7d7867ae'; import { MAX_LABELS } from '/js/badge-logic.js?v=5c72bd4c'; -import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=82916010'; +import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=64eef54d'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=b0881957'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; diff --git a/ui/js/admin-auth.js b/ui/js/admin-auth.js index 261fb8af..6fc95c7a 100644 --- a/ui/js/admin-auth.js +++ b/ui/js/admin-auth.js @@ -1,4 +1,4 @@ -import { apiGet, apiPost } from '/js/admin-shared.js?v=da7df247'; +import { apiGet, apiPost } from '/js/admin-shared.js?v=5e6a023a'; import { initI18n, t } from '/js/i18n.js?v=ed7c5565'; import { loginErrorKey } from '/js/admin-error.js?v=b0881957'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; diff --git a/ui/js/admin-color-control.js b/ui/js/admin-color-control.js index 8be515eb..873e2c38 100644 --- a/ui/js/admin-color-control.js +++ b/ui/js/admin-color-control.js @@ -1,4 +1,4 @@ -import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=da7df247'; +import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=5e6a023a'; import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { qa, q } from '/js/utils.js?v=dc4ac52e'; diff --git a/ui/js/admin-list.js b/ui/js/admin-list.js index 8dc5a4ef..7b724752 100644 --- a/ui/js/admin-list.js +++ b/ui/js/admin-list.js @@ -13,13 +13,13 @@ import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; import { snapshotItems } from '/js/admin-save-logic.js?v=5613a9fa'; -import { reorderItems } from '/js/admin-logic.js?v=fc7f0836'; +import { reorderItems } from '/js/admin-logic.js?v=1cca36f5'; import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=29e09aad'; -import { paintIcon } from '/js/admin-shared.js?v=da7df247'; +import { paintIcon } from '/js/admin-shared.js?v=5e6a023a'; import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=dc4ac52e'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { t } from '/js/i18n.js?v=ed7c5565'; -import { sizeLabel } from '/js/admin-widget-form.js?v=047fef40'; +import { sizeLabel } from '/js/admin-widget-form.js?v=e0966417'; import { widgetGlyph } from '/js/widget-glyphs.js?v=b383b7c8'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; diff --git a/ui/js/admin-logic.js b/ui/js/admin-logic.js index 1a1e529b..50569c2c 100644 --- a/ui/js/admin-logic.js +++ b/ui/js/admin-logic.js @@ -336,3 +336,13 @@ export function createDirtyTracker(read) { }, }; } + +/** What a single-choice picker shows as chosen. An empty string is a choice + only when an option carries it. + @param {unknown} value @param {{ value?: unknown, group?: unknown }[]} options + @returns {string[]} */ +export function singleChoice(value, options) { + if (value == null) return []; + const v = String(value); + return v !== '' || options.some(o => o.group == null && String(o.value) === '') ? [v] : []; +} diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index 967e42f0..d3bae93c 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -1,4 +1,4 @@ -import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=da7df247'; +import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=5e6a023a'; import { socketProbeAdvice } from '/js/admin-error.js?v=b0881957'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; import { t } from '/js/i18n.js?v=ed7c5565'; @@ -9,12 +9,12 @@ import { needsCurrentPassword, createDirtyTracker, BLOCK, -} from '/js/admin-logic.js?v=fc7f0836'; +} from '/js/admin-logic.js?v=1cca36f5'; import { confirmText, promptModal } from '/js/modal.js?v=6b0320bd'; import { el, inp, setUserText } from '/js/utils.js?v=dc4ac52e'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { serialWrites } from '/js/admin-save-logic.js?v=5613a9fa'; -import { renderColorControl } from '/js/admin-color-control.js?v=82916010'; +import { renderColorControl } from '/js/admin-color-control.js?v=64eef54d'; import { BACKDROP } from '/js/background.js?v=d6f2e701'; import { ALLOWED_HOSTS_MAX, diff --git a/ui/js/admin-shared.js b/ui/js/admin-shared.js index 7734dfd8..177ddf86 100644 --- a/ui/js/admin-shared.js +++ b/ui/js/admin-shared.js @@ -1,5 +1,5 @@ /* Stateless helpers shared by the admin modules. Mutable state stays out. */ -import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=fc7f0836'; +import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=1cca36f5'; import { el, isolate, q } from '/js/utils.js?v=dc4ac52e'; import { t } from '/js/i18n.js?v=ed7c5565'; import { errorAdvice } from '/js/admin-error.js?v=b0881957'; diff --git a/ui/js/admin-widget-form.js b/ui/js/admin-widget-form.js index d04e086a..c1c63da1 100644 --- a/ui/js/admin-widget-form.js +++ b/ui/js/admin-widget-form.js @@ -1,9 +1,9 @@ import { state } from '/js/admin-state.js?v=af772a1b'; -import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=da7df247'; -import { createListbox } from '/js/listbox.js?v=4afb5969'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=c4755d57'; +import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=5e6a023a'; +import { createListbox } from '/js/listbox.js?v=7d7867ae'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=294805cc'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=fc7f0836'; +import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=1cca36f5'; import { t } from '/js/i18n.js?v=ed7c5565'; import { focusFirst, q, qi, qa } from '/js/utils.js?v=dc4ac52e'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; diff --git a/ui/js/admin.js b/ui/js/admin.js index c0648aba..0d47b958 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -1,8 +1,8 @@ -import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=92c6949c'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=73ed7df1'; +import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=deccbdee'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=104be7f0'; import { recoveryShown } from '/js/config-recovery.js?v=85138697'; -import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=02db1758'; -import { resolveAdminSection } from '/js/admin-logic.js?v=fc7f0836'; +import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=14782161'; +import { resolveAdminSection } from '/js/admin-logic.js?v=1cca36f5'; import { afterImport, buildAppItem, @@ -23,7 +23,7 @@ import { showBgFields, showWallpaperFile, wireUnsplashKey, -} from '/js/admin-settings.js?v=9ffe0860'; +} from '/js/admin-settings.js?v=584aae78'; import { apiGet, apiPost, @@ -37,13 +37,13 @@ import { setReauthHandler, toast, withheldText, -} from '/js/admin-shared.js?v=da7df247'; +} from '/js/admin-shared.js?v=5e6a023a'; import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=047fef40'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=e0966417'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=8b39e9d0'; -import { createListbox } from '/js/listbox.js?v=4afb5969'; +import { createListbox } from '/js/listbox.js?v=7d7867ae'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=ed7c5565'; import { loadLocalIcons } from '/js/icons.js?v=5ac01d72'; diff --git a/ui/js/listbox.js b/ui/js/listbox.js index d574e6c4..ef9a591e 100644 --- a/ui/js/listbox.js +++ b/ui/js/listbox.js @@ -6,7 +6,7 @@ Do not use popover. The top layer makes iOS collapse its browser toolbar, which exposes a strip of page canvas and misplaces the menu. */ -import { nextActiveIndex } from '/js/admin-logic.js?v=fc7f0836'; +import { nextActiveIndex, singleChoice } from '/js/admin-logic.js?v=1cca36f5'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { formatList } from '/js/format-number.js?v=abcd4ae4'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; @@ -81,9 +81,7 @@ export function createListbox( } = /** @type {any} */ ({}), ) { let opts = options.slice(); - let chosen = multiple - ? new Set((Array.isArray(value) ? value : []).map(String)) - : new Set(value == null || value === '' ? [] : [String(value)]); + let chosen = multiple ? new Set((Array.isArray(value) ? value : []).map(String)) : new Set(singleChoice(value, opts)); /* The list is not a descendant of its button, so the two are tied by id. */ const listId = id ? `${id}-list` : `lb-${++seq}`; @@ -331,17 +329,13 @@ export function createListbox( list, getValue, setValue(v) { - chosen = multiple - ? new Set((Array.isArray(v) ? v : []).map(String)) - : new Set(v == null || v === '' ? [] : [String(v)]); + chosen = multiple ? new Set((Array.isArray(v) ? v : []).map(String)) : new Set(singleChoice(v, opts)); paint(); }, setOptions(next, v) { opts = (next || []).slice(); if (v !== undefined) { - chosen = multiple - ? new Set((Array.isArray(v) ? v : []).map(String)) - : new Set(v == null || v === '' ? [] : [String(v)]); + chosen = multiple ? new Set((Array.isArray(v) ? v : []).map(String)) : new Set(singleChoice(v, opts)); } else if (!multiple) { /* A refreshed list may no longer carry what was chosen. */ const still = [...chosen].filter(c => opts.some(o => String(o.value) === c)); diff --git a/ui/js/widget-config-form.js b/ui/js/widget-config-form.js index c1ec50a9..d6f1e097 100644 --- a/ui/js/widget-config-form.js +++ b/ui/js/widget-config-form.js @@ -3,9 +3,9 @@ import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=da7df247'; -import { createListbox } from '/js/listbox.js?v=4afb5969'; -import { renderColorControl } from '/js/admin-color-control.js?v=82916010'; +import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=5e6a023a'; +import { createListbox } from '/js/listbox.js?v=7d7867ae'; +import { renderColorControl } from '/js/admin-color-control.js?v=64eef54d'; import { seedCarried, applyOptionSet, @@ -13,7 +13,7 @@ import { requiredFieldMissing, groupBounds, visibleFieldFlags, -} from '/js/admin-logic.js?v=fc7f0836'; +} from '/js/admin-logic.js?v=1cca36f5'; import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=b0881957'; import { qi } from '/js/utils.js?v=dc4ac52e'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; diff --git a/ui/test/admin-logic.test.mjs b/ui/test/admin-logic.test.mjs index 69194cfb..922819fe 100644 --- a/ui/test/admin-logic.test.mjs +++ b/ui/test/admin-logic.test.mjs @@ -27,6 +27,7 @@ import { failureIsMissingApiPath, createDirtyTracker, BLOCK, + singleChoice, } from '../js/admin-logic.js'; test('createDirtyTracker is clean until the value moves, and clean again after reset', () => { @@ -651,3 +652,20 @@ test('sameIconName treats nothing as nothing', () => { assert.equal(sameIconName(null, undefined), false); assert.equal(sameIconName('---', '***'), false); }); + +test('an empty string is a choice when an option carries it', () => { + const opts = [ + { value: '', label: 'Default' }, + { value: 'no-referrer', label: 'no-referrer' }, + ]; + assert.deepEqual(singleChoice('', opts), ['']); + assert.deepEqual(singleChoice('no-referrer', opts), ['no-referrer']); +}); + +test('an empty string or nothing is no choice otherwise', () => { + const opts = [{ value: 'a' }, { group: 'Heading', value: '' }]; + assert.deepEqual(singleChoice('', opts), []); + assert.deepEqual(singleChoice(null, opts), []); + assert.deepEqual(singleChoice(undefined, opts), []); + assert.deepEqual(singleChoice(3, [{ value: 3 }]), ['3']); +}); diff --git a/ui/test/listbox.test.mjs b/ui/test/listbox.test.mjs index a321d334..d667cae1 100644 --- a/ui/test/listbox.test.mjs +++ b/ui/test/listbox.test.mjs @@ -222,3 +222,8 @@ test('the language picker marks each language name with its language', () => { 'the button does not carry it', ); }); + +test('every single-choice value goes through singleChoice', () => { + assert.equal((src.match(/new Set\(singleChoice\(/g) || []).length, 3); + assert.doesNotMatch(src, /=== '' \? \[\] :/); +});