From a6384fc003b8564083af1f61d3dc09732fe74353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sun, 7 Jun 2026 09:01:18 +0200 Subject: [PATCH] Improve search select --- common/components/search_select.py | 61 ++++++---- games/static/base.css | 185 +---------------------------- games/static/js/search_select.js | 53 ++++++++- tests/test_search_select.py | 15 ++- 4 files changed, 109 insertions(+), 205 deletions(-) diff --git a/common/components/search_select.py b/common/components/search_select.py index 4addc9c..4108b38 100644 --- a/common/components/search_select.py +++ b/common/components/search_select.py @@ -26,9 +26,13 @@ class SearchSelectOption(TypedDict): # removed border and border-default-medium, see later if it's needed _CONTAINER_CLASS = "relative rounded-base bg-neutral-secondary-medium" -_PILLS_CLASS = "flex flex-wrap gap-1 p-2 empty:hidden" +# The pills and the search box share one flex-wrap row so the widget reads as a +# single field; the pills wrapper uses `contents` so its pills/hidden inputs +# flow as direct participants of that row, inline with the search input. +_FIELD_CLASS = "flex flex-wrap items-center gap-1 p-2" +_PILLS_CLASS = "contents" _SEARCH_CLASS = ( - "block w-full border-0 bg-transparent text-sm text-heading p-2 " + "flex-1 min-w-[8rem] border-0 bg-transparent text-sm text-heading " "focus:ring-0 focus:outline-hidden placeholder:text-body" ) _OPTIONS_CLASS = ( @@ -99,17 +103,27 @@ def SearchSelect( options = [_normalize_option(o) for o in (options or [])] # ── Pills + their hidden inputs (the submitted channel) ── + # Multi-select renders a removable Pill per value; single-select renders no + # pill — the committed label shows inside the search box instead, with a + # lone hidden input carrying the value. Both keep the hidden input(s) inside + # `[data-ss-pills]` so the JS reads/writes values uniformly. pills_children: list[SafeText] = [] - for option in selected: - pills_children.append( - Pill( - option["label"], - value=str(option["value"]), - removable=True, - attributes=_data_attributes(option["data"]), + search_value = "" + if multi_select: + for option in selected: + pills_children.append( + Pill( + option["label"], + value=str(option["value"]), + removable=True, + attributes=_data_attributes(option["data"]), + ) ) - ) + pills_children.append(_hidden_input(name, option["value"])) + elif selected: + option = selected[0] pills_children.append(_hidden_input(name, option["value"])) + search_value = option["label"] pills = Component( tag_name="div", @@ -118,15 +132,22 @@ def SearchSelect( ) # ── Search box (NO name — the query is never submitted) ── - search = Component( - tag_name="input", - attributes=[ - ("data-ss-search", ""), - ("type", "text"), - ("placeholder", placeholder), - ("autocomplete", "off"), - ("class", _SEARCH_CLASS), - ], + search_attrs: list[HTMLAttribute] = [ + ("data-ss-search", ""), + ("type", "text"), + ("placeholder", placeholder), + ("autocomplete", "off"), + ("class", _SEARCH_CLASS), + ] + if search_value: + search_attrs.append(("value", search_value)) + search = Component(tag_name="input", attributes=search_attrs) + + # ── Field row: pills + search box combined into one visual field ── + field = Component( + tag_name="div", + attributes=[("data-ss-field", ""), ("class", _FIELD_CLASS)], + children=[pills, search], ) # ── Options panel (pre-rendered only when there is no search_url) ── @@ -164,7 +185,7 @@ def SearchSelect( return Component( tag_name="div", attributes=container_attrs, - children=[pills, search, options_panel], + children=[field, options_panel], ) diff --git a/games/static/base.css b/games/static/base.css index 0279af6..f5b7945 100644 --- a/games/static/base.css +++ b/games/static/base.css @@ -293,85 +293,27 @@ --leading-5: 20px; --radius-base: 12px; --color-body: var(--color-gray-600); - --color-body-subtle: var(--color-gray-500); --color-heading: var(--color-gray-900); - --color-fg-brand-subtle: var(--color-blue-200); --color-fg-brand: var(--color-blue-700); - --color-fg-brand-strong: var(--color-blue-900); - --color-fg-success: var(--color-emerald-700); - --color-fg-success-strong: var(--color-emerald-900); - --color-fg-danger: var(--color-rose-700); - --color-fg-danger-strong: var(--color-rose-900); - --color-fg-warning-subtle: var(--color-orange-600); - --color-fg-warning: var(--color-orange-900); - --color-fg-yellow: var(--color-yellow-400); --color-fg-disabled: var(--color-gray-400); - --color-fg-purple: var(--color-purple-600); - --color-fg-cyan: var(--color-cyan-600); - --color-fg-indigo: var(--color-indigo-600); - --color-fg-pink: var(--color-pink-600); - --color-fg-lime: var(--color-lime-600); --color-neutral-primary-soft: var(--color-white); --color-neutral-primary: var(--color-white); --color-neutral-primary-medium: var(--color-white); - --color-neutral-primary-strong: var(--color-white); --color-neutral-secondary-soft: var(--color-gray-50); --color-neutral-secondary: var(--color-gray-50); --color-neutral-secondary-medium: var(--color-gray-50); --color-neutral-secondary-strong: var(--color-gray-50); - --color-neutral-secondary-strongest: var(--color-gray-50); - --color-neutral-tertiary-soft: var(--color-gray-100); --color-neutral-tertiary: var(--color-gray-100); --color-neutral-tertiary-medium: var(--color-gray-100); --color-neutral-quaternary: var(--color-gray-200); - --color-neutral-quaternary-medium: var(--color-gray-200); - --color-gray: var(--color-gray-300); - --color-brand-softer: var(--color-blue-50); --color-brand-soft: var(--color-blue-100); --color-brand: var(--color-blue-700); --color-brand-medium: var(--color-blue-200); --color-brand-strong: var(--color-blue-800); - --color-success-soft: var(--color-emerald-50); - --color-success: var(--color-emerald-700); - --color-success-medium: var(--color-emerald-100); - --color-success-strong: var(--color-emerald-800); - --color-danger-soft: var(--color-rose-50); - --color-danger: var(--color-rose-700); - --color-danger-medium: var(--color-rose-100); - --color-danger-strong: var(--color-rose-800); - --color-warning-soft: var(--color-orange-50); - --color-warning: var(--color-orange-500); - --color-warning-medium: var(--color-orange-100); - --color-warning-strong: var(--color-orange-700); - --color-dark-soft: var(--color-gray-800); --color-dark: var(--color-gray-800); - --color-dark-strong: var(--color-gray-900); - --color-disabled: var(--color-gray-100); - --color-purple: var(--color-purple-500); - --color-sky: var(--color-sky-500); - --color-teal: var(--color-teal-600); - --color-pink: var(--color-pink-600); - --color-cyan: var(--color-cyan-500); - --color-fuchsia: var(--color-fuchsia-600); - --color-indigo: var(--color-indigo-600); - --color-orange: var(--color-orange-400); - --color-buffer: var(--color-white); - --color-buffer-medium: var(--color-white); - --color-buffer-strong: var(--color-white); - --color-muted: var(--color-gray-50); - --color-light-subtle: var(--color-gray-100); --color-light: var(--color-gray-100); - --color-light-medium: var(--color-gray-100); - --color-default-subtle: var(--color-gray-200); --color-default: var(--color-gray-200); --color-default-medium: var(--color-gray-200); - --color-default-strong: var(--color-gray-200); - --color-success-subtle: var(--color-emerald-200); - --color-danger-subtle: var(--color-rose-200); - --color-warning-subtle: var(--color-orange-200); - --color-brand-subtle: var(--color-blue-200); - --color-brand-light: var(--color-blue-600); - --color-dark-subtle: var(--color-gray-800); --color-dark-backdrop: var(--color-gray-950); --color-accent: #7c3aed; } @@ -878,18 +820,12 @@ .start-0 { inset-inline-start: calc(var(--spacing) * 0); } - .end-1 { - inset-inline-end: calc(var(--spacing) * 1); - } .end-1\.5 { inset-inline-end: calc(var(--spacing) * 1.5); } .top-0 { top: calc(var(--spacing) * 0); } - .top-1 { - top: calc(var(--spacing) * 1); - } .top-1\/2 { top: calc(1 / 2 * 100%); } @@ -911,9 +847,6 @@ .bottom-0 { bottom: calc(var(--spacing) * 0); } - .bottom-1 { - bottom: calc(var(--spacing) * 1); - } .bottom-1\.5 { bottom: calc(var(--spacing) * 1.5); } @@ -1482,6 +1415,9 @@ .block { display: block; } + .contents { + display: contents; + } .flex { display: flex; } @@ -1540,15 +1476,9 @@ .h-full { height: 100%; } - .max-h-40 { - max-height: calc(var(--spacing) * 40); - } .max-h-full { max-height: 100%; } - .min-h-\[28px\] { - min-height: 28px; - } .min-h-screen { min-height: 100vh; } @@ -1617,15 +1547,9 @@ text-align: center; } } - .w-1 { - width: calc(var(--spacing) * 1); - } .w-1\/2 { width: calc(1 / 2 * 100%); } - .w-2 { - width: calc(var(--spacing) * 2); - } .w-2\.5 { width: calc(var(--spacing) * 2.5); } @@ -1722,6 +1646,9 @@ border-color: var(--color-brand); } } + .min-w-\[8rem\] { + min-width: 8rem; + } .flex-1 { flex: 1; } @@ -1734,9 +1661,6 @@ .shrink-0 { flex-shrink: 0; } - .border-collapse { - border-collapse: collapse; - } .-translate-x-full { --tw-translate-x: -100%; translate: var(--tw-translate-x) var(--tw-translate-y); @@ -1753,10 +1677,6 @@ --tw-translate-x: 100%; translate: var(--tw-translate-x) var(--tw-translate-y); } - .-translate-y-1 { - --tw-translate-y: calc(var(--spacing) * -1); - translate: var(--tw-translate-x) var(--tw-translate-y); - } .-translate-y-1\/2 { --tw-translate-y: calc(calc(1 / 2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); @@ -1799,9 +1719,6 @@ .list-disc { list-style-type: disc; } - .appearance-none { - appearance: none; - } .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); } @@ -2145,9 +2062,6 @@ .bg-amber-50 { background-color: var(--color-amber-50); } - .bg-black { - background-color: var(--color-black); - } .bg-black\/70 { background-color: color-mix(in srgb, #000 70%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -2172,9 +2086,6 @@ background-color: color-mix(in oklab, var(--color-brand) 15%, transparent); } } - .bg-dark-backdrop { - background-color: var(--color-dark-backdrop); - } .bg-dark-backdrop\/70 { background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 70%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -2193,18 +2104,12 @@ .bg-gray-500 { background-color: var(--color-gray-500); } - .bg-gray-800 { - background-color: var(--color-gray-800); - } .bg-gray-800\/20 { background-color: color-mix(in srgb, oklch(27.8% 0.033 256.848) 20%, transparent); @supports (color: color-mix(in lab, red, red)) { background-color: color-mix(in oklab, var(--color-gray-800) 20%, transparent); } } - .bg-gray-900 { - background-color: var(--color-gray-900); - } .bg-gray-900\/50 { background-color: color-mix(in srgb, oklch(21% 0.034 264.665) 50%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -2319,18 +2224,6 @@ fill: white !important; } } - .apexcharts-gridline { - stroke: var(--color-default) !important; - .dark & { - stroke: var(--color-default) !important; - } - } - .apexcharts-xcrosshairs { - stroke: var(--color-default) !important; - .dark & { - stroke: var(--color-default) !important; - } - } .apexcharts-ycrosshairs { stroke: var(--color-default) !important; .dark & { @@ -2389,9 +2282,6 @@ .px-6 { padding-inline: calc(var(--spacing) * 6); } - .py-0 { - padding-block: calc(var(--spacing) * 0); - } .py-0\.5 { padding-block: calc(var(--spacing) * 0.5); } @@ -2453,9 +2343,6 @@ color: heading !important; } } - .pb-1 { - padding-bottom: calc(var(--spacing) * 1); - } .pb-16 { padding-bottom: calc(var(--spacing) * 16); } @@ -2622,9 +2509,6 @@ .text-balance { text-wrap: balance; } - .text-wrap { - text-wrap: wrap; - } .whitespace-nowrap { white-space: nowrap; } @@ -2751,9 +2635,6 @@ .italic { font-style: italic; } - .no-underline { - text-decoration-line: none; - } .no-underline\! { text-decoration-line: none !important; } @@ -2817,10 +2698,6 @@ -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } - .backdrop-filter { - -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); - backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); - } .transition { transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events; transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); @@ -2985,11 +2862,6 @@ background-color: var(--color-gray-50); } } - .empty\:hidden { - &:empty { - display: none; - } - } .hover\:scale-110 { &:hover { @media (hover: hover) { @@ -4042,51 +3914,6 @@ } } } - .\[\&\:\:-webkit-slider-thumb\]\:relative { - &::-webkit-slider-thumb { - position: relative; - } - } - .\[\&\:\:-webkit-slider-thumb\]\:z-10 { - &::-webkit-slider-thumb { - z-index: 10; - } - } - .\[\&\:\:-webkit-slider-thumb\]\:z-20 { - &::-webkit-slider-thumb { - z-index: 20; - } - } - .\[\&\:\:-webkit-slider-thumb\]\:h-4 { - &::-webkit-slider-thumb { - height: calc(var(--spacing) * 4); - } - } - .\[\&\:\:-webkit-slider-thumb\]\:w-4 { - &::-webkit-slider-thumb { - width: calc(var(--spacing) * 4); - } - } - .\[\&\:\:-webkit-slider-thumb\]\:cursor-pointer { - &::-webkit-slider-thumb { - cursor: pointer; - } - } - .\[\&\:\:-webkit-slider-thumb\]\:appearance-none { - &::-webkit-slider-thumb { - appearance: none; - } - } - .\[\&\:\:-webkit-slider-thumb\]\:rounded-full { - &::-webkit-slider-thumb { - border-radius: calc(infinity * 1px); - } - } - .\[\&\:\:-webkit-slider-thumb\]\:bg-brand { - &::-webkit-slider-thumb { - background-color: var(--color-brand); - } - } .\[\&\:first-of-type_button\]\:rounded-s-lg { &:first-of-type button { border-start-start-radius: var(--radius-lg); diff --git a/games/static/js/search_select.js b/games/static/js/search_select.js index 99cc7c0..654ecf9 100644 --- a/games/static/js/search_select.js +++ b/games/static/js/search_select.js @@ -1,7 +1,10 @@ /** * SearchSelect widget — a search box paired with a dropdown of options. - * Single/multi select; chosen items render as removable pills, each backed by a - * hidden so existing Django form validation keeps working. + * Multi-select renders chosen items as removable pills (inline with the search + * box), each backed by a hidden . Single-select renders no pill: the + * committed label lives inside the search box (which doubles as a combobox — + * focus clears it to search, picking an option fills it), with a lone hidden + * carrying the value. Both keep hidden inputs so Django validation works. * * Mirrors selectable_filter.js: initAll() on DOMContentLoaded + htmx:afterSwap, * each widget guarded with el._ssInit. @@ -120,8 +123,44 @@ } } - search.addEventListener("focus", runSearch); - search.addEventListener("input", runSearch); + // ── Single-select combobox: the search box shows the committed label; + // focusing clears it to search, blurring restores it (or deselects). ── + if (!multi) container._ssLabel = search.value; + + search.addEventListener("focus", function () { + if (!multi) { + // Hide the committed label so the box becomes a fresh search field. + search.value = ""; + container._ssDirty = false; + } + runSearch(); + }); + search.addEventListener("input", function () { + if (!multi) container._ssDirty = true; + runSearch(); + }); + if (!multi) { + search.addEventListener("blur", function () { + // Defer so an option click (which fires before blur settles) wins. + setTimeout(function () { + if (container._ssDirty && search.value.trim() === "") { + // User intentionally cleared the box → deselect. + pills.innerHTML = ""; + container._ssLabel = ""; + emitChange(null); + } else { + // Focused-and-left, or typed a partial query without picking → + // restore the committed label (no-op right after a selection). + search.value = container._ssLabel || ""; + } + }, 120); + }); + } + + // Clicking an option must not blur the input before the click selects. + options.addEventListener("mousedown", function (e) { + e.preventDefault(); + }); // ── Option click → select ── options.addEventListener("click", function (e) { @@ -152,9 +191,13 @@ addPill(option); } } else { + // Single-select: no pill — show the label in the search box and keep a + // lone hidden input under [data-ss-pills] for submission. pills.innerHTML = ""; - addPill(option); + pills.appendChild(buildHidden(option.value)); search.value = option.label; + container._ssLabel = option.label; + container._ssDirty = false; hidePanel(); } emitChange(option); diff --git a/tests/test_search_select.py b/tests/test_search_select.py index 1f6599a..9b82672 100644 --- a/tests/test_search_select.py +++ b/tests/test_search_select.py @@ -63,9 +63,10 @@ class SearchSelectComponentTest(unittest.TestCase): self.assertIn('data-search-url="/api/games/search"', html) self.assertIn('data-multi="true"', html) - def test_selected_renders_pills_and_hidden_inputs(self): + def test_multi_selected_renders_pills_and_hidden_inputs(self): html = SearchSelect( name="games", + multi_select=True, selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}], ) self.assertIn("data-pill", html) @@ -75,6 +76,18 @@ class SearchSelectComponentTest(unittest.TestCase): # name. The leading space avoids matching the container's data-name. self.assertEqual(html.count(' name="games"'), 1) + def test_single_selected_has_no_pill_and_value_in_search_box(self): + html = SearchSelect( + name="games", + selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}], + ) + # single-select renders no pill — the label lives in the search box + self.assertNotIn("data-pill", html) + self.assertIn('value="Game A"', html) + # the value is still submitted via a lone hidden input + self.assertIn('', html) + self.assertEqual(html.count(' name="games"'), 1) + def test_search_box_has_no_name(self): html = SearchSelect(name="games") self.assertIn("data-ss-search", html)