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)