Compare commits

...

4 Commits

Author SHA1 Message Date
lukas 83aefcb849 Improve Backspace behavior in single-select SearchSelect
Django CI/CD / test (push) Successful in 44s
Django CI/CD / build-and-push (push) Successful in 1m15s
2026-06-09 12:25:58 +02:00
lukas c7c196a054 Fix non-filter select visuals 2026-06-09 11:56:01 +02:00
lukas c639196266 Search select JavaScript improvements 2026-06-09 11:48:36 +02:00
lukas ed086c9702 Fix prefetch 2026-06-09 11:37:41 +02:00
8 changed files with 431 additions and 232 deletions
+2
View File
@@ -41,6 +41,7 @@ from common.components.primitives import (
paginated_table_content, paginated_table_content,
) )
from common.components.search_select import ( from common.components.search_select import (
DEFAULT_PREFETCH,
FilterSelect, FilterSelect,
LabeledOption, LabeledOption,
SearchSelect, SearchSelect,
@@ -87,6 +88,7 @@ __all__ = [
"Popover", "Popover",
"PopoverTruncated", "PopoverTruncated",
"SearchField", "SearchField",
"DEFAULT_PREFETCH",
"FilterSelect", "FilterSelect",
"LabeledOption", "LabeledOption",
"SearchSelect", "SearchSelect",
+2 -4
View File
@@ -7,7 +7,7 @@ from django.utils.safestring import SafeText, mark_safe
from common.components.core import Component from common.components.core import Component
from common.components.primitives import Label, Span from common.components.primitives import Label, Span
from common.components.search_select import FilterSelect, LabeledOption from common.components.search_select import DEFAULT_PREFETCH, FilterSelect, LabeledOption
class FilterChoice(NamedTuple): class FilterChoice(NamedTuple):
@@ -91,8 +91,6 @@ def _parse_bool(existing: dict, key: str) -> bool:
# option set; model-backed fields fetch from a search endpoint on demand, with # option set; model-backed fields fetch from a search endpoint on demand, with
# labels embedded in the filter JSON so pills render without a DB round-trip. # labels embedded in the filter JSON so pills render without a DB round-trip.
_FILTER_PREFETCH = 20
# Presence modifiers drive the pinned (Any)/(None) pseudo-options. They are # Presence modifiers drive the pinned (Any)/(None) pseudo-options. They are
# mutually exclusive with value pills (selecting one clears the value set). # mutually exclusive with value pills (selecting one clears the value set).
# Must match JS PRESENCE_MODIFIERS in search_select.js. # Must match JS PRESENCE_MODIFIERS in search_select.js.
@@ -189,7 +187,7 @@ def _model_filter(
modifier=modifier, modifier=modifier,
modifier_options=_modifier_options(nullable, m2m_modifiers), modifier_options=_modifier_options(nullable, m2m_modifiers),
search_url=search_url, search_url=search_url,
prefetch=_FILTER_PREFETCH, prefetch=DEFAULT_PREFETCH,
) )
+9 -1
View File
@@ -60,13 +60,21 @@ _OPTIONS_CLASS = (
"absolute z-10 top-full left-0 right-0 mt-1 overflow-y-auto " "absolute z-10 top-full left-0 right-0 mt-1 overflow-y-auto "
"border border-default-medium rounded-base bg-neutral-secondary-medium shadow-lg" "border border-default-medium rounded-base bg-neutral-secondary-medium shadow-lg"
) )
_OPTION_ROW_CLASS = "px-3 py-2 text-sm text-heading cursor-pointer hover:bg-brand/15" _OPTION_ROW_CLASS = (
"px-3 py-2 text-sm text-heading cursor-pointer "
"hover:bg-brand/15 data-[search-select-highlighted]:bg-brand/15"
)
_NO_RESULTS_CLASS = "px-3 py-2 text-sm italic text-body hidden" _NO_RESULTS_CLASS = "px-3 py-2 text-sm italic text-body hidden"
# Approximate rendered height of one option row (px-3 py-2 text-sm) in rem, # Approximate rendered height of one option row (px-3 py-2 text-sm) in rem,
# used to derive the panel's max-height from items_visible. # used to derive the panel's max-height from items_visible.
_ROW_HEIGHT_REM = 2.25 _ROW_HEIGHT_REM = 2.25
# Default number of rows to fetch on first focus when a search_url is set.
# Shared by filter and form widgets so the dropdown is populated for keyboard
# navigation as soon as the user opens it.
DEFAULT_PREFETCH = 20
# ── FilterSelect styling ─────────────────────────────────────────────────── # ── FilterSelect styling ───────────────────────────────────────────────────
# Inline class strings (ported verbatim from the retired SelectableFilter CSS) # Inline class strings (ported verbatim from the retired SelectableFilter CSS)
# so the filter combobox is fully self-styled — nothing in input.css. JS-added # so the filter combobox is fully self-styled — nothing in input.css. JS-added
+2 -2
View File
@@ -206,8 +206,8 @@ textarea:disabled {
label { label {
@apply mb-2.5 text-sm font-medium text-heading; @apply mb-2.5 text-sm font-medium text-heading;
} }
input:not([type="checkbox"]) { input:not([type="checkbox"]):not([data-search-select-search]) {
@apply mb-3 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs placeholder:text-body; @apply mb-3 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs placeholder:text-body;
} }
input[type="checkbox"] { input[type="checkbox"] {
@apply w-4 h-4 border border-default-medium rounded-xs bg-neutral-secondary-medium focus:ring-2 focus:ring-brand-soft; @apply w-4 h-4 border border-default-medium rounded-xs bg-neutral-secondary-medium focus:ring-2 focus:ring-brand-soft;
+4
View File
@@ -3,6 +3,7 @@ from django.db import transaction
from django.db.models import OuterRef, Subquery from django.db.models import OuterRef, Subquery
from common.components import ( from common.components import (
DEFAULT_PREFETCH,
SearchSelect, SearchSelect,
SearchSelectOption, SearchSelectOption,
searchselect_selected, searchselect_selected,
@@ -75,6 +76,7 @@ class SearchSelectWidget(forms.Widget):
multi_select=False, multi_select=False,
items_visible=5, items_visible=5,
items_scroll=10, items_scroll=10,
prefetch=DEFAULT_PREFETCH,
always_visible=False, always_visible=False,
placeholder="Search…", placeholder="Search…",
attrs=None, attrs=None,
@@ -85,6 +87,7 @@ class SearchSelectWidget(forms.Widget):
self.multi_select = multi_select self.multi_select = multi_select
self.items_visible = items_visible self.items_visible = items_visible
self.items_scroll = items_scroll self.items_scroll = items_scroll
self.prefetch = prefetch
self.always_visible = always_visible self.always_visible = always_visible
self.placeholder = placeholder self.placeholder = placeholder
@@ -107,6 +110,7 @@ class SearchSelectWidget(forms.Widget):
multi_select=self.multi_select, multi_select=self.multi_select,
items_visible=self.items_visible, items_visible=self.items_visible,
items_scroll=self.items_scroll, items_scroll=self.items_scroll,
prefetch=self.prefetch,
always_visible=self.always_visible, always_visible=self.always_visible,
placeholder=self.placeholder, placeholder=self.placeholder,
id=(attrs or {}).get("id", ""), id=(attrs or {}).get("id", ""),
+129 -1
View File
@@ -293,27 +293,85 @@
--leading-5: 20px; --leading-5: 20px;
--radius-base: 12px; --radius-base: 12px;
--color-body: var(--color-gray-600); --color-body: var(--color-gray-600);
--color-body-subtle: var(--color-gray-500);
--color-heading: var(--color-gray-900); --color-heading: var(--color-gray-900);
--color-fg-brand-subtle: var(--color-blue-200);
--color-fg-brand: var(--color-blue-700); --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-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-soft: var(--color-white);
--color-neutral-primary: var(--color-white); --color-neutral-primary: var(--color-white);
--color-neutral-primary-medium: 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-soft: var(--color-gray-50);
--color-neutral-secondary: var(--color-gray-50); --color-neutral-secondary: var(--color-gray-50);
--color-neutral-secondary-medium: var(--color-gray-50); --color-neutral-secondary-medium: var(--color-gray-50);
--color-neutral-secondary-strong: 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: var(--color-gray-100);
--color-neutral-tertiary-medium: var(--color-gray-100); --color-neutral-tertiary-medium: var(--color-gray-100);
--color-neutral-quaternary: var(--color-gray-200); --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-soft: var(--color-blue-100);
--color-brand: var(--color-blue-700); --color-brand: var(--color-blue-700);
--color-brand-medium: var(--color-blue-200); --color-brand-medium: var(--color-blue-200);
--color-brand-strong: var(--color-blue-800); --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: 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: 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: var(--color-gray-200);
--color-default-medium: 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-dark-backdrop: var(--color-gray-950);
--color-accent: #7c3aed; --color-accent: #7c3aed;
} }
@@ -823,12 +881,18 @@
.start-0 { .start-0 {
inset-inline-start: calc(var(--spacing) * 0); inset-inline-start: calc(var(--spacing) * 0);
} }
.end-1 {
inset-inline-end: calc(var(--spacing) * 1);
}
.end-1\.5 { .end-1\.5 {
inset-inline-end: calc(var(--spacing) * 1.5); inset-inline-end: calc(var(--spacing) * 1.5);
} }
.top-0 { .top-0 {
top: calc(var(--spacing) * 0); top: calc(var(--spacing) * 0);
} }
.top-1 {
top: calc(var(--spacing) * 1);
}
.top-1\/2 { .top-1\/2 {
top: calc(1 / 2 * 100%); top: calc(1 / 2 * 100%);
} }
@@ -850,6 +914,9 @@
.bottom-0 { .bottom-0 {
bottom: calc(var(--spacing) * 0); bottom: calc(var(--spacing) * 0);
} }
.bottom-1 {
bottom: calc(var(--spacing) * 1);
}
.bottom-1\.5 { .bottom-1\.5 {
bottom: calc(var(--spacing) * 1.5); bottom: calc(var(--spacing) * 1.5);
} }
@@ -1559,9 +1626,15 @@
text-align: center; text-align: center;
} }
} }
.w-1 {
width: calc(var(--spacing) * 1);
}
.w-1\/2 { .w-1\/2 {
width: calc(1 / 2 * 100%); width: calc(1 / 2 * 100%);
} }
.w-2 {
width: calc(var(--spacing) * 2);
}
.w-2\.5 { .w-2\.5 {
width: calc(var(--spacing) * 2.5); width: calc(var(--spacing) * 2.5);
} }
@@ -1679,6 +1752,9 @@
.shrink-0 { .shrink-0 {
flex-shrink: 0; flex-shrink: 0;
} }
.border-collapse {
border-collapse: collapse;
}
.-translate-x-full { .-translate-x-full {
--tw-translate-x: -100%; --tw-translate-x: -100%;
translate: var(--tw-translate-x) var(--tw-translate-y); translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -1695,6 +1771,10 @@
--tw-translate-x: 100%; --tw-translate-x: 100%;
translate: var(--tw-translate-x) var(--tw-translate-y); 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 { .-translate-y-1\/2 {
--tw-translate-y: calc(calc(1 / 2 * 100%) * -1); --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y); translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -2080,12 +2160,18 @@
.bg-amber-50 { .bg-amber-50 {
background-color: var(--color-amber-50); background-color: var(--color-amber-50);
} }
.bg-amber-500 {
background-color: var(--color-amber-500);
}
.bg-amber-500\/15 { .bg-amber-500\/15 {
background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 15%, transparent); background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-amber-500) 15%, transparent); background-color: color-mix(in oklab, var(--color-amber-500) 15%, transparent);
} }
} }
.bg-black {
background-color: var(--color-black);
}
.bg-black\/70 { .bg-black\/70 {
background-color: color-mix(in srgb, #000 70%, transparent); background-color: color-mix(in srgb, #000 70%, transparent);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@@ -2110,6 +2196,9 @@
background-color: color-mix(in oklab, var(--color-brand) 15%, transparent); background-color: color-mix(in oklab, var(--color-brand) 15%, transparent);
} }
} }
.bg-dark-backdrop {
background-color: var(--color-dark-backdrop);
}
.bg-dark-backdrop\/70 { .bg-dark-backdrop\/70 {
background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 70%, transparent); background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 70%, transparent);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@@ -2128,12 +2217,18 @@
.bg-gray-500 { .bg-gray-500 {
background-color: var(--color-gray-500); background-color: var(--color-gray-500);
} }
.bg-gray-800 {
background-color: var(--color-gray-800);
}
.bg-gray-800\/20 { .bg-gray-800\/20 {
background-color: color-mix(in srgb, oklch(27.8% 0.033 256.848) 20%, transparent); background-color: color-mix(in srgb, oklch(27.8% 0.033 256.848) 20%, transparent);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-gray-800) 20%, transparent); 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 { .bg-gray-900\/50 {
background-color: color-mix(in srgb, oklch(21% 0.034 264.665) 50%, transparent); background-color: color-mix(in srgb, oklch(21% 0.034 264.665) 50%, transparent);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@@ -2263,6 +2358,18 @@
fill: white !important; 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 { .apexcharts-ycrosshairs {
stroke: var(--color-default) !important; stroke: var(--color-default) !important;
.dark & { .dark & {
@@ -2321,6 +2428,9 @@
.px-6 { .px-6 {
padding-inline: calc(var(--spacing) * 6); padding-inline: calc(var(--spacing) * 6);
} }
.py-0 {
padding-block: calc(var(--spacing) * 0);
}
.py-0\.5 { .py-0\.5 {
padding-block: calc(var(--spacing) * 0.5); padding-block: calc(var(--spacing) * 0.5);
} }
@@ -2547,6 +2657,9 @@
.text-balance { .text-balance {
text-wrap: balance; text-wrap: balance;
} }
.text-wrap {
text-wrap: wrap;
}
.whitespace-nowrap { .whitespace-nowrap {
white-space: nowrap; white-space: nowrap;
} }
@@ -2682,6 +2795,9 @@
.line-through { .line-through {
text-decoration-line: line-through; text-decoration-line: line-through;
} }
.no-underline {
text-decoration-line: none;
}
.no-underline\! { .no-underline\! {
text-decoration-line: none !important; text-decoration-line: none !important;
} }
@@ -2748,6 +2864,10 @@
-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,); -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: 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 {
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-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)); transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
@@ -3292,6 +3412,14 @@
background-color: var(--color-brand); background-color: var(--color-brand);
} }
} }
.data-\[search-select-highlighted\]\:bg-brand\/15 {
&[data-search-select-highlighted] {
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-brand) 15%, transparent);
}
}
}
.data-\[search-select-highlighted\]\:outline { .data-\[search-select-highlighted\]\:outline {
&[data-search-select-highlighted] { &[data-search-select-highlighted] {
outline-style: var(--tw-outline-style); outline-style: var(--tw-outline-style);
@@ -4370,7 +4498,7 @@ form input:disabled, select:disabled, textarea:disabled {
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
color: var(--color-heading); color: var(--color-heading);
} }
input:not([type="checkbox"]) { input:not([type="checkbox"]):not([data-search-select-search]) {
margin-bottom: calc(var(--spacing) * 3); margin-bottom: calc(var(--spacing) * 3);
display: block; display: block;
width: 100%; width: 100%;
+274 -224
View File
@@ -21,144 +21,190 @@
* and data-* attributes — so all markup and Tailwind class strings live in one * and data-* attributes — so all markup and Tailwind class strings live in one
* place (the Python components), never duplicated here. * place (the Python components), never duplicated here.
*/ */
(function () { (() => {
"use strict"; "use strict";
var DEBOUNCE_MS = 100; const DEBOUNCE_MS = 100;
// Must match Python common/components/filters.py:_PRESENCE_MODIFIERS. // Must match Python common/components/filters.py:_PRESENCE_MODIFIERS.
// These modifiers are mutually exclusive with value pills — selecting // These modifiers are mutually exclusive with value pills — selecting
// one clears all value pills. Non-presence modifiers (INCLUDES_ALL, // one clears all value pills. Non-presence modifiers (INCLUDES_ALL,
// INCLUDES_ONLY) coexist with value pills. // INCLUDES_ONLY) coexist with value pills.
var PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"]; const PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"];
function initAll() { const initAll = () => {
document.querySelectorAll("[data-search-select]").forEach(function (element) { document.querySelectorAll("[data-search-select]").forEach(element => {
if (element._searchSelectInit) return; if (element._searchSelectInit) return;
element._searchSelectInit = true; element._searchSelectInit = true;
initWidget(element); initWidget(element);
}); });
} };
function initWidget(container) { const initWidget = (container) => {
var search = container.querySelector("[data-search-select-search]"); const search = container.querySelector("[data-search-select-search]");
var options = container.querySelector("[data-search-select-options]"); const options = container.querySelector("[data-search-select-options]");
var pills = container.querySelector("[data-search-select-pills]"); const pills = container.querySelector("[data-search-select-pills]");
if (!search || !options || !pills) return; if (!search || !options || !pills) return;
var name = container.getAttribute("data-name"); const name = container.getAttribute("data-name");
var searchUrl = container.getAttribute("data-search-url"); const searchUrl = container.getAttribute("data-search-url");
var isFilter = container.getAttribute("data-search-select-mode") === "filter"; const isFilter = container.getAttribute("data-search-select-mode") === "filter";
var multi = container.getAttribute("data-multi") === "true"; const multi = container.getAttribute("data-multi") === "true";
var alwaysVisible = container.getAttribute("data-always-visible") === "true"; const alwaysVisible = container.getAttribute("data-always-visible") === "true";
var itemsScroll = parseInt(container.getAttribute("data-items-scroll"), 10) || 10; const prefetch = parseInt(container.getAttribute("data-prefetch"), 10) || 0;
var prefetch = parseInt(container.getAttribute("data-prefetch"), 10) || 0; const syncUrl = container.getAttribute("data-sync-url") === "true";
var syncUrl = container.getAttribute("data-sync-url") === "true";
var noResults = options.querySelector("[data-search-select-no-results]"); const noResults = options.querySelector("[data-search-select-no-results]");
var debounceTimer = null; let debounceTimer = null;
var pendingRequest = null; // in-flight AbortController, so newer queries win let pendingRequest = null; // in-flight AbortController, so newer queries win
var hasPrefetched = false; let hasPrefetched = false;
function showPanel() { const hasVisibleContent = () => {
options.classList.remove("hidden"); const optionRows = options.querySelectorAll("[data-search-select-option]");
} for (let i = 0; i < optionRows.length; i++) {
function hidePanel() { if (optionRows[i].style.display !== "none") return true;
}
if (noResults && !noResults.classList.contains("hidden")) return true;
if (options.querySelector("[data-search-select-modifier-option]")) return true;
return false;
};
const showPanel = () => {
if (alwaysVisible || hasVisibleContent()) {
options.classList.remove("hidden");
}
};
const hidePanel = () => {
if (!alwaysVisible) options.classList.add("hidden"); if (!alwaysVisible) options.classList.add("hidden");
} };
function setNoResults(visible) { const setNoResults = (visible) => {
if (noResults) noResults.classList.toggle("hidden", !visible); if (!noResults) return;
} noResults.classList.toggle("hidden", !visible);
if (visible) showPanel();
};
// ── Highlight tracking (filter mode) ── // ── Highlight tracking (filter mode) ──
var highlightedRow = null; let highlightedRow = null;
function highlightOption(row) { const highlightOption = (row) => {
clearHighlight(); clearHighlight();
if (!row) return; if (!row) return;
row.setAttribute("data-search-select-highlighted", ""); row.setAttribute("data-search-select-highlighted", "");
highlightedRow = row; highlightedRow = row;
row.scrollIntoView({ block: "nearest" }); row.scrollIntoView({ block: "nearest" });
} };
function clearHighlight() { const clearHighlight = () => {
if (highlightedRow) { if (highlightedRow) {
highlightedRow.removeAttribute("data-search-select-highlighted"); highlightedRow.removeAttribute("data-search-select-highlighted");
highlightedRow = null; highlightedRow = null;
} }
} };
function getVisibleOptions() { const getVisibleOptions = () => {
var all = options.querySelectorAll("[data-search-select-option]"); const all = options.querySelectorAll("[data-search-select-option]");
return Array.prototype.filter.call(all, function (row) { return Array.from(all).filter(row => row.style.display !== "none");
return row.style.display !== "none"; };
});
}
function autoHighlight(query) { const autoHighlight = (query) => {
var visible = getVisibleOptions(); const visible = getVisibleOptions();
if (visible.length === 0) { if (visible.length === 0) {
clearHighlight(); clearHighlight();
return; return;
} }
var lower = query.toLowerCase(); const lower = query.toLowerCase();
// 1. Starts-with match // 1. Starts-with match
for (var i = 0; i < visible.length; i++) { for (let i = 0; i < visible.length; i++) {
var label = (visible[i].getAttribute("data-label") || "").toLowerCase(); const label = (visible[i].getAttribute("data-label") || "").toLowerCase();
if (lower && label.startsWith(lower)) { if (lower && label.startsWith(lower)) {
highlightOption(visible[i]); highlightOption(visible[i]);
return; return;
} }
} }
// 2. Substring match (fuzzy-lite) // 2. Substring match (fuzzy-lite)
for (var j = 0; j < visible.length; j++) { for (let j = 0; j < visible.length; j++) {
var subLabel = (visible[j].getAttribute("data-label") || "").toLowerCase(); const subLabel = (visible[j].getAttribute("data-label") || "").toLowerCase();
if (lower && subLabel.indexOf(lower) !== -1) { if (lower && subLabel.includes(lower)) {
highlightOption(visible[j]); highlightOption(visible[j]);
return; return;
} }
} }
// 3. Fallback: first visible option // 3. Fallback: first visible option
highlightOption(visible[0]); highlightOption(visible[0]);
} };
// Get active values in both form and filter modes
const getSelectedValues = () => {
const vals = new Set();
pills.querySelectorAll('input[type="hidden"]').forEach(input => {
vals.add(input.value);
});
pills.querySelectorAll("[data-pill]").forEach(pill => {
const val = pill.getAttribute("data-value");
if (val) vals.add(val);
});
return vals;
};
// ── Render server-fetched rows into the panel ── // ── Render server-fetched rows into the panel ──
function renderRows(items) { const renderRows = (items) => {
options.querySelectorAll("[data-search-select-option]").forEach(function (row) { const selectedVals = getSelectedValues();
const preservedOptions = [];
// Extract existing option data for currently selected values before removing
options.querySelectorAll("[data-search-select-option]").forEach(row => {
const val = row.getAttribute("data-value");
if (selectedVals.has(val)) {
preservedOptions.push(optionFromRow(row));
}
row.remove(); row.remove();
}); });
items.slice(0, itemsScroll).forEach(function (item) {
options.insertBefore(buildRow(item), noResults || null); const renderedValues = new Set();
// Render preserved options first (to keep them at the top)
preservedOptions.forEach(opt => {
options.insertBefore(buildRow(opt), noResults || null);
renderedValues.add(String(opt.value));
}); });
// Render newly fetched items (excluding already rendered preserved ones)
// Fix DOM-limit vs fetch mismatch: Do not slice the items, render all returned items.
items.forEach(item => {
if (!renderedValues.has(String(item.value))) {
options.insertBefore(buildRow(item), noResults || null);
renderedValues.add(String(item.value));
}
});
showPanel(); showPanel();
} };
// ── Clone a server-rendered <template> prototype by name. The server emits // ── Clone a server-rendered <template> prototype by name. The server emits
// the mode-appropriate prototypes, so the JS never names a class. ── // the mode-appropriate prototypes, so the JS never names a class. ──
function cloneTemplate(name) { const cloneTemplate = (name) => {
var template = container.querySelector('template[data-search-select-template="' + name + '"]'); const template = container.querySelector(`template[data-search-select-template="${name}"]`);
return template return template
? template.content.firstElementChild.cloneNode(true) ? template.content.firstElementChild.cloneNode(true)
: null; : null;
} };
function setLabel(node, label) { const setLabel = (node, label) => {
var slot = node.querySelector("[data-search-select-label]"); const slot = node.querySelector("[data-search-select-label]");
if (slot) slot.textContent = label; if (slot) slot.textContent = label;
} };
function applyData(node, data) { const applyData = (node, data = {}) => {
data = data || {}; Object.keys(data).forEach(key => {
Object.keys(data).forEach(function (key) { node.setAttribute(`data-${key}`, data[key]);
node.setAttribute("data-" + key, data[key]);
}); });
} };
// Build an option row by cloning the "row" template (the same prototype the // Build an option row by cloning the "row" template (the same prototype the
// server renders, so fetched and pre-rendered rows are identical). // server renders, so fetched and pre-rendered rows are identical).
function buildRow(option) { const buildRow = (option) => {
var row = cloneTemplate("row"); const row = cloneTemplate("row");
if (!row) return document.createComment("ss-row"); if (!row) return document.createComment("ss-row");
row.setAttribute("data-value", option.value); row.setAttribute("data-value", option.value);
row.setAttribute("data-label", option.label); row.setAttribute("data-label", option.label);
@@ -166,79 +212,76 @@
setLabel(row, option.label); setLabel(row, option.label);
row._searchSelectOption = option; row._searchSelectOption = option;
return row; return row;
} };
// ── Client-side filter of the currently loaded rows. Returns the number of // ── Client-side filter of the currently loaded rows. Returns the number of
// visible rows so the caller decides whether to show the no-results node. ── // visible rows so the caller decides whether to show the no-results node. ──
function filterRows(query) { const filterRows = (query) => {
var lower = query.toLowerCase(); const lower = query.toLowerCase();
var visibleCount = 0; let visibleCount = 0;
options.querySelectorAll("[data-search-select-option]").forEach(function (item) { options.querySelectorAll("[data-search-select-option]").forEach(item => {
var label = (item.getAttribute("data-label") || "").toLowerCase(); const label = (item.getAttribute("data-label") || "").toLowerCase();
var match = label.indexOf(lower) !== -1; const match = label.includes(lower);
item.style.display = match ? "" : "none"; item.style.display = match ? "" : "none";
if (match) visibleCount += 1; if (match) visibleCount += 1;
}); });
return visibleCount; return visibleCount;
} };
// ── Fetch matching rows from the server. The previous in-flight request is // ── Fetch matching rows from the server. The previous in-flight request is
// aborted so a slower earlier response can never overwrite a newer one. ── // aborted so a slower earlier response can never overwrite a newer one. ──
function fetchFromServer(query) { const fetchFromServer = (query) => {
if (pendingRequest) pendingRequest.abort(); if (pendingRequest) pendingRequest.abort();
pendingRequest = new AbortController(); pendingRequest = new AbortController();
var url = searchUrl + "?q=" + encodeURIComponent(query); let url = `${searchUrl}?q=${encodeURIComponent(query)}`;
if (prefetch && !query) url += "&limit=" + prefetch; if (prefetch && !query) url += `&limit=${prefetch}`;
fetch(url, { credentials: "same-origin", signal: pendingRequest.signal }) fetch(url, { credentials: "same-origin", signal: pendingRequest.signal })
.then(function (response) { .then(response => response.json())
return response.json(); .then(items => {
})
.then(function (items) {
pendingRequest = null; pendingRequest = null;
renderRows(items); renderRows(items);
// Re-apply the live query: the box may hold more text than was sent. // Re-apply the live query: the box may hold more text than was sent.
setNoResults(filterRows(search.value.trim()) === 0); setNoResults(filterRows(search.value.trim()) === 0);
if (isFilter) autoHighlight(search.value.trim()); autoHighlight(search.value.trim());
}) })
.catch(function (error) { .catch(error => {
if (error && error.name === "AbortError") return; // superseded if (error?.name === "AbortError") return; // superseded
pendingRequest = null; pendingRequest = null;
setNoResults(true); setNoResults(true);
}); });
} };
// Called on every keystroke. With a search_url, filter the loaded window // Called on every keystroke. With a search_url, filter the loaded window
// instantly (zero latency) and debounce a server request for the rest; // instantly (zero latency) and debounce a server request for the rest;
// no-results stays hidden until the response decides it, to avoid a flash // no-results stays hidden until the response decides it, to avoid a flash
// over an incomplete window. Without a search_url the loaded set is complete, // over an incomplete window. Without a search_url the loaded set is complete,
// so the client-side filter is authoritative. // so the client-side filter is authoritative.
function runSearch() { const runSearch = () => {
var query = search.value.trim(); const query = search.value.trim();
showPanel();
if (searchUrl) { if (searchUrl) {
filterRows(query); filterRows(query);
setNoResults(false); setNoResults(false);
clearTimeout(debounceTimer); clearTimeout(debounceTimer);
debounceTimer = setTimeout(function () { debounceTimer = setTimeout(() => {
fetchFromServer(query); fetchFromServer(query);
}, DEBOUNCE_MS); }, DEBOUNCE_MS);
} else { } else {
setNoResults(filterRows(query) === 0); setNoResults(filterRows(query) === 0);
} }
if (isFilter) autoHighlight(query); autoHighlight(query);
} showPanel();
};
// ── Single-select combobox: the search box shows the committed label; // ── Single-select combobox: the search box shows the committed label;
// focusing clears it to search, blurring restores it (or deselects). ── // focusing clears it to search, blurring restores it (or deselects). ──
if (!multi) container._searchSelectLabel = search.value; if (!multi) container._searchSelectLabel = search.value;
search.addEventListener("focus", function () { search.addEventListener("focus", () => {
if (!multi) { if (!multi) {
// Hide the committed label so the box becomes a fresh search field. // Hide the committed label so the box becomes a fresh search field.
search.value = ""; search.value = "";
container._searchSelectDirty = false; container._searchSelectDirty = false;
} }
showPanel();
if (searchUrl) { if (searchUrl) {
if (prefetch && !hasPrefetched) { if (prefetch && !hasPrefetched) {
// Seed the window immediately on first open (not debounced). // Seed the window immediately on first open (not debounced).
@@ -248,22 +291,33 @@
// Show whatever is already loaded; the server decides no-results. // Show whatever is already loaded; the server decides no-results.
filterRows(search.value.trim()); filterRows(search.value.trim());
setNoResults(false); setNoResults(false);
if (isFilter) autoHighlight(search.value.trim()); autoHighlight(search.value.trim());
} }
} else { } else {
setNoResults(filterRows(search.value.trim()) === 0); setNoResults(filterRows(search.value.trim()) === 0);
if (isFilter) autoHighlight(search.value.trim()); autoHighlight(search.value.trim());
} }
showPanel();
}); });
search.addEventListener("input", function () {
search.addEventListener("input", () => {
clearHighlight(); clearHighlight();
if (!multi) container._searchSelectDirty = true; if (!multi) {
if (!container._searchSelectDirty) {
const label = container._searchSelectLabel || "";
if (search.value.startsWith(label)) {
search.value = search.value.slice(label.length);
}
container._searchSelectDirty = true;
}
}
runSearch(); runSearch();
}); });
if (!multi) { if (!multi) {
search.addEventListener("blur", function () { search.addEventListener("blur", () => {
// Defer so an option click (which fires before blur settles) wins. // Defer so an option click (which fires before blur settles) wins.
setTimeout(function () { setTimeout(() => {
if (container._searchSelectDirty && search.value.trim() === "") { if (container._searchSelectDirty && search.value.trim() === "") {
// User intentionally cleared the box → deselect. // User intentionally cleared the box → deselect.
pills.innerHTML = ""; pills.innerHTML = "";
@@ -278,64 +332,72 @@
}); });
} }
// ── Keyboard navigation (filter mode) ── // ── Keyboard navigation (both form and filter modes) ──
search.addEventListener("keydown", function (event) { search.addEventListener("keydown", (event) => {
if (!isFilter) return; const { key } = event;
var key = event.key;
if (key === "ArrowDown" || key === "ArrowUp" || key === "Enter" || key === "Escape") {
var visible = getVisibleOptions();
if (visible.length === 0) {
if (key === "Escape") hidePanel();
return;
}
if (key === "ArrowDown") { if (!multi && key === "Backspace" && !container._searchSelectDirty) {
event.preventDefault();
search.value = "";
search.dispatchEvent(new Event("input", { bubbles: true }));
return;
}
if (!["ArrowDown", "ArrowUp", "Enter", "Escape"].includes(key)) return;
const visible = getVisibleOptions();
if (visible.length === 0) {
if (key === "Escape") hidePanel();
return;
}
if (key === "ArrowDown") {
event.preventDefault();
showPanel();
const downIdx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
highlightOption(visible[(downIdx + 1) % visible.length]);
} else if (key === "ArrowUp") {
event.preventDefault();
showPanel();
const upIdx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
highlightOption(visible[(upIdx - 1 + visible.length) % visible.length]);
} else if (key === "Enter") {
if (highlightedRow) {
event.preventDefault(); event.preventDefault();
showPanel(); const option = optionFromRow(highlightedRow);
var idx = highlightedRow ? visible.indexOf(highlightedRow) : -1; if (isFilter) {
var next = visible[(idx + 1) % visible.length];
highlightOption(next);
} else if (key === "ArrowUp") {
event.preventDefault();
showPanel();
var idx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
var prev = visible[(idx - 1 + visible.length) % visible.length];
highlightOption(prev);
} else if (key === "Enter") {
if (highlightedRow) {
event.preventDefault();
var option = optionFromRow(highlightedRow);
addFilterPill(option, "include"); addFilterPill(option, "include");
search.value = ""; search.value = "";
clearHighlight(); } else {
hidePanel(); selectOption(option);
} }
} else if (key === "Escape") {
clearHighlight(); clearHighlight();
hidePanel(); hidePanel();
} }
} else if (key === "Escape") {
clearHighlight();
hidePanel();
} }
}); });
// Clicking an option must not blur the input before the click selects. // Clicking an option must not blur the input before the click selects.
options.addEventListener("mousedown", function (event) { options.addEventListener("mousedown", (event) => {
event.preventDefault(); event.preventDefault();
}); });
// ── Option click → select (form mode) or include/exclude (filter mode) ── // ── Option click → select (form mode) or include/exclude (filter mode) ──
options.addEventListener("click", function (event) { options.addEventListener("click", (event) => {
if (isFilter) { if (isFilter) {
handleFilterOptionClick(event); handleFilterOptionClick(event);
return; return;
} }
var row = event.target.closest("[data-search-select-option]"); const row = event.target.closest("[data-search-select-option]");
if (!row) return; if (!row) return;
selectOption(optionFromRow(row)); selectOption(optionFromRow(row));
}); });
function handleFilterOptionClick(event) { const handleFilterOptionClick = (event) => {
// Pinned modifier pseudo-option → set the (mutually exclusive) modifier. // Pinned modifier pseudo-option → set the (mutually exclusive) modifier.
var modifierRow = event.target.closest("[data-search-select-modifier-option]"); const modifierRow = event.target.closest("[data-search-select-modifier-option]");
if (modifierRow) { if (modifierRow) {
setModifier( setModifier(
modifierRow.getAttribute("data-search-select-modifier-option"), modifierRow.getAttribute("data-search-select-modifier-option"),
@@ -344,85 +406,84 @@
return; return;
} }
// Include / exclude button on a value row. // Include / exclude button on a value row.
var button = event.target.closest("[data-search-select-action]"); const button = event.target.closest("[data-search-select-action]");
if (button) { if (button) {
var row = button.closest("[data-search-select-option]"); const row = button.closest("[data-search-select-option]");
if (!row) return; if (!row) return;
addFilterPill(optionFromRow(row), button.getAttribute("data-search-select-action")); addFilterPill(optionFromRow(row), button.getAttribute("data-search-select-action"));
return; return;
} }
// Click on the option row itself → include. // Click on the option row itself → include.
var optionRow = event.target.closest("[data-search-select-option]"); const optionRow = event.target.closest("[data-search-select-option]");
if (optionRow) { if (optionRow) {
addFilterPill(optionFromRow(optionRow), "include"); addFilterPill(optionFromRow(optionRow), "include");
return;
} }
} };
// Add (or re-type) an include/exclude pill for a value. Selecting any value // Add (or re-type) an include/exclude pill for a value. Selecting any value
// clears a presence modifier — NOT_NULL / IS_NULL are mutually exclusive // clears a presence modifier — NOT_NULL / IS_NULL are mutually exclusive
// with value pills. Non-presence modifiers (INCLUDES_ALL / INCLUDES_ONLY) // with value pills. Non-presence modifiers (INCLUDES_ALL / INCLUDES_ONLY)
// persist alongside value pills. // persist alongside value pills.
function addFilterPill(option, kind) { const addFilterPill = (option, kind) => {
var modPill = pills.querySelector("[data-search-select-modifier]"); const modPill = pills.querySelector("[data-search-select-modifier]");
if (modPill) { if (modPill) {
var modVal = modPill.getAttribute("data-search-select-modifier"); const modVal = modPill.getAttribute("data-search-select-modifier");
if (PRESENCE_MODIFIERS.indexOf(modVal) !== -1) { if (PRESENCE_MODIFIERS.includes(modVal)) {
clearModifier(); clearModifier();
} }
} }
var existing = pills.querySelector( const existing = pills.querySelector(
'[data-pill][data-value="' + cssEscape(option.value) + '"]' `[data-pill][data-value="${cssEscape(option.value)}"]`
); );
if (existing) existing.remove(); if (existing) existing.remove();
pills.appendChild(buildFilterValuePill(option, kind)); pills.appendChild(buildFilterValuePill(option, kind));
search.value = ""; search.value = "";
emitChange(null); emitChange(null);
} };
function buildFilterValuePill(option, kind) { const buildFilterValuePill = (option, kind) => {
var pill = cloneTemplate(kind === "include" ? "pill-include" : "pill-exclude"); const pill = cloneTemplate(kind === "include" ? "pill-include" : "pill-exclude");
pill.setAttribute("data-value", option.value); pill.setAttribute("data-value", option.value);
pill.setAttribute("data-label", option.label); pill.setAttribute("data-label", option.label);
applyData(pill, option.data); applyData(pill, option.data);
setLabel(pill, option.label); setLabel(pill, option.label);
return pill; return pill;
} };
// Set the modifier pill. Presence modifiers (NOT_NULL / IS_NULL) clear all // Set the modifier pill. Presence modifiers (NOT_NULL / IS_NULL) clear all
// value pills — they are mutually exclusive. Non-presence modifiers // value pills — they are mutually exclusive. Non-presence modifiers
// (INCLUDES_ALL / INCLUDES_ONLY) are prepended before existing value pills. // (INCLUDES_ALL / INCLUDES_ONLY) are prepended before existing value pills.
function setModifier(modifierValue, label) { const setModifier = (modifierValue, label) => {
// Remove any existing modifier pill to avoid duplicates. // Remove any existing modifier pill to avoid duplicates.
clearModifierPill(); clearModifierPill();
if (PRESENCE_MODIFIERS.indexOf(modifierValue) !== -1) { if (PRESENCE_MODIFIERS.includes(modifierValue)) {
pills.innerHTML = ""; pills.innerHTML = "";
} }
var pill = cloneTemplate("pill-modifier"); const pill = cloneTemplate("pill-modifier");
pill.setAttribute("data-search-select-modifier", modifierValue); pill.setAttribute("data-search-select-modifier", modifierValue);
setLabel(pill, label); setLabel(pill, label);
pills.insertBefore(pill, pills.firstChild); pills.insertBefore(pill, pills.firstChild);
container.setAttribute("data-modifier", modifierValue); container.setAttribute("data-modifier", modifierValue);
hidePanel(); hidePanel();
emitChange(null); emitChange(null);
} };
// Remove the modifier pill and its container attribute. Safe to call when // Remove the modifier pill and its container attribute. Safe to call when
// there is no modifier pill (no-op). Does not touch value pills. // there is no modifier pill (no-op). Does not touch value pills.
function clearModifierPill() { const clearModifierPill = () => {
var modifierPill = pills.querySelector("[data-search-select-modifier]"); const modifierPill = pills.querySelector("[data-search-select-modifier]");
if (modifierPill) modifierPill.remove(); if (modifierPill) modifierPill.remove();
container.removeAttribute("data-modifier"); container.removeAttribute("data-modifier");
} };
function clearModifier() { const clearModifier = () => {
clearModifierPill(); clearModifierPill();
} };
function optionFromRow(row) { const optionFromRow = (row) => {
if (row._searchSelectOption) return row._searchSelectOption; if (row._searchSelectOption) return row._searchSelectOption;
var data = {}; const data = {};
Object.keys(row.dataset).forEach(function (key) { Object.keys(row.dataset).forEach(key => {
if (key !== "value" && key !== "label" && key !== "ssOption") { if (key !== "value" && key !== "label" && key !== "ssOption") {
data[key] = row.dataset[key]; data[key] = row.dataset[key];
} }
@@ -430,15 +491,16 @@
return { return {
value: row.getAttribute("data-value"), value: row.getAttribute("data-value"),
label: row.getAttribute("data-label"), label: row.getAttribute("data-label"),
data: data, data,
}; };
} };
function selectOption(option) { const selectOption = (option) => {
if (multi) { if (multi) {
if (!pills.querySelector('input[value="' + cssEscape(option.value) + '"]')) { if (!pills.querySelector(`input[value="${cssEscape(option.value)}"]`)) {
addPill(option); addPill(option);
} }
search.value = "";
} else { } else {
// Single-select: no pill — show the label in the search box and keep a // Single-select: no pill — show the label in the search box and keep a
// lone hidden input under [data-search-select-pills] for submission. // lone hidden input under [data-search-select-pills] for submission.
@@ -450,36 +512,36 @@
hidePanel(); hidePanel();
} }
emitChange(option); emitChange(option);
} };
function addPill(option) { const addPill = (option) => {
var pill = buildPill(option); const pill = buildPill(option);
if (pill) pills.appendChild(pill); if (pill) pills.appendChild(pill);
pills.appendChild(buildHidden(option.value)); pills.appendChild(buildHidden(option.value));
} };
function buildPill(option) { const buildPill = (option) => {
var pill = cloneTemplate("pill"); const pill = cloneTemplate("pill");
if (!pill) return null; if (!pill) return null;
pill.setAttribute("data-value", option.value); pill.setAttribute("data-value", option.value);
applyData(pill, option.data); applyData(pill, option.data);
setLabel(pill, option.label); setLabel(pill, option.label);
return pill; return pill;
} };
function buildHidden(value) { const buildHidden = (value) => {
var input = document.createElement("input"); const input = document.createElement("input");
input.type = "hidden"; input.type = "hidden";
input.name = name; input.name = name;
input.value = value; input.value = value;
return input; return input;
} };
// ── Pill × → remove ── // ── Pill × → remove ──
pills.addEventListener("click", function (event) { pills.addEventListener("click", (event) => {
var removeButton = event.target.closest("[data-pill-remove]"); const removeButton = event.target.closest("[data-pill-remove]");
if (!removeButton) return; if (!removeButton) return;
var pill = removeButton.closest("[data-pill]"); const pill = removeButton.closest("[data-pill]");
if (!pill) return; if (!pill) return;
if (isFilter) { if (isFilter) {
// Filter pills have no hidden input. // Filter pills have no hidden input.
@@ -491,86 +553,79 @@
emitChange(null); emitChange(null);
return; return;
} }
var value = pill.getAttribute("data-value"); const value = pill.getAttribute("data-value");
pill.remove(); pill.remove();
var hidden = pills.querySelector('input[value="' + cssEscape(value) + '"]'); const hidden = pills.querySelector(`input[value="${cssEscape(value)}"]`);
if (hidden) hidden.remove(); if (hidden) hidden.remove();
emitChange(null); emitChange(null);
}); });
function currentValues() { const currentValues = () => {
return Array.prototype.map.call( return Array.from(pills.querySelectorAll('input[type="hidden"]')).map(input => input.value);
pills.querySelectorAll('input[type="hidden"]'), };
function (input) {
return input.value;
}
);
}
function emitChange(last) { const emitChange = (last) => {
var values = currentValues(); const values = currentValues();
if (syncUrl) syncToUrl(values); if (syncUrl) syncToUrl(values);
container.dispatchEvent( container.dispatchEvent(
new CustomEvent("search-select:change", { new CustomEvent("search-select:change", {
bubbles: true, bubbles: true,
detail: { name: name, values: values, last: last }, detail: { name, values, last },
}) })
); );
} };
function syncToUrl(values) { const syncToUrl = (values) => {
var params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
params.delete(name); params.delete(name);
values.forEach(function (v) { values.forEach(v => {
params.append(name, v); params.append(name, v);
}); });
var qs = params.toString(); const qs = params.toString();
history.replaceState(null, "", qs ? "?" + qs : window.location.pathname); history.replaceState(null, "", qs ? `?${qs}` : window.location.pathname);
} };
// On init, restore from URL params if the server supplied no selected pills. // On init, restore from URL params if the server supplied no selected pills.
if (syncUrl && !pills.querySelector("[data-pill]")) { if (syncUrl && !pills.querySelector("[data-pill]")) {
var initial = new URLSearchParams(window.location.search).getAll(name); const initial = new URLSearchParams(window.location.search).getAll(name);
initial.forEach(function (v) { initial.forEach(v => {
addPill({ value: v, label: v, data: {} }); addPill({ value: v, label: v, data: {} });
}); });
} }
// ── Close panel on outside click ── // ── Close panel on outside click ──
document.addEventListener("click", function (event) { document.addEventListener("click", (event) => {
if (!container.contains(event.target)) hidePanel(); if (!container.contains(event.target)) hidePanel();
}); });
} };
/** Minimal escape for use inside an attribute-value selector. */ /** Minimal escape for use inside an attribute-value selector. */
function cssEscape(value) { const cssEscape = (value) => String(value).replace(/["\\]/g, "\\$&");
return String(value).replace(/["\\]/g, "\\$&");
}
// Serialise each widget's current state onto data-* attributes for the caller. // Serialise each widget's current state onto data-* attributes for the caller.
// Form widgets expose data-values (the submitted hidden-input values); filter // Form widgets expose data-values (the submitted hidden-input values); filter
// widgets expose data-included / data-excluded / data-modifier for the filter // widgets expose data-included / data-excluded / data-modifier for the filter
// bar to read. // bar to read.
window.readSearchSelect = function (form) { window.readSearchSelect = (form) => {
form.querySelectorAll("[data-search-select]").forEach(function (container) { form.querySelectorAll("[data-search-select]").forEach(container => {
var pills = container.querySelector("[data-search-select-pills]"); const pills = container.querySelector("[data-search-select-pills]");
if (container.getAttribute("data-search-select-mode") === "filter") { if (container.getAttribute("data-search-select-mode") === "filter") {
var included = []; const included = [];
var excluded = []; const excluded = [];
var modifier = ""; let modifier = "";
if (pills) { if (pills) {
pills.querySelectorAll("[data-pill]").forEach(function (pill) { pills.querySelectorAll("[data-pill]").forEach(pill => {
var pillModifier = pill.getAttribute("data-search-select-modifier"); const pillModifier = pill.getAttribute("data-search-select-modifier");
if (pillModifier) { if (pillModifier) {
modifier = pillModifier; // last modifier pill wins modifier = pillModifier; // last modifier pill wins
return; // skip value extraction for this pill return; // skip value extraction for this pill
} }
var value = pill.getAttribute("data-value"); const value = pill.getAttribute("data-value");
var label = pill.getAttribute("data-label") || ""; const label = pill.getAttribute("data-label") || "";
if (pill.getAttribute("data-search-select-type") === "exclude") { if (pill.getAttribute("data-search-select-type") === "exclude") {
excluded.push({id: value, label: label}); excluded.push({ id: value, label });
} else { } else {
included.push({id: value, label: label}); included.push({ id: value, label });
} }
}); });
} }
@@ -580,13 +635,8 @@
else container.removeAttribute("data-modifier"); else container.removeAttribute("data-modifier");
return; return;
} }
var values = pills const values = pills
? Array.prototype.map.call( ? Array.from(pills.querySelectorAll('input[type="hidden"]')).map(input => input.value)
pills.querySelectorAll('input[type="hidden"]'),
function (input) {
return input.value;
}
)
: []; : [];
container.setAttribute("data-values", JSON.stringify(values)); container.setAttribute("data-values", JSON.stringify(values));
}); });
+9
View File
@@ -133,6 +133,15 @@ class SearchSelectComponentTest(unittest.TestCase):
self.assertLess(options, option_row) self.assertLess(options, option_row)
self.assertLess(option_row, no_results) self.assertLess(option_row, no_results)
def test_prefetch_attribute_and_defaults(self):
# Default prefetch is 0 in SearchSelect
html_default = SearchSelect(name="t")
self.assertIn('data-prefetch="0"', html_default)
# Custom prefetch is rendered
html_custom = SearchSelect(name="t", prefetch=42)
self.assertIn('data-prefetch="42"', html_custom)
class FilterSelectComponentTest(unittest.TestCase): class FilterSelectComponentTest(unittest.TestCase):
MODIFIERS = [("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")] MODIFIERS = [("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")]