Compare commits
4 Commits
6f4841eaaa
...
83aefcb849
| Author | SHA1 | Date | |
|---|---|---|---|
|
83aefcb849
|
|||
|
c7c196a054
|
|||
|
c639196266
|
|||
|
ed086c9702
|
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -206,7 +206,7 @@ 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"] {
|
||||||
|
|||||||
@@ -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
@@ -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%;
|
||||||
|
|||||||
+258
-208
@@ -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 = () => {
|
||||||
|
const optionRows = options.querySelectorAll("[data-search-select-option]");
|
||||||
|
for (let i = 0; i < optionRows.length; i++) {
|
||||||
|
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");
|
options.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
function hidePanel() {
|
};
|
||||||
|
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));
|
||||||
});
|
});
|
||||||
showPanel();
|
|
||||||
|
// 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();
|
||||||
|
};
|
||||||
|
|
||||||
// ── 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,12 +332,19 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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") {
|
if (!multi && key === "Backspace" && !container._searchSelectDirty) {
|
||||||
var visible = getVisibleOptions();
|
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 (visible.length === 0) {
|
||||||
if (key === "Escape") hidePanel();
|
if (key === "Escape") hidePanel();
|
||||||
return;
|
return;
|
||||||
@@ -292,21 +353,23 @@
|
|||||||
if (key === "ArrowDown") {
|
if (key === "ArrowDown") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
showPanel();
|
showPanel();
|
||||||
var idx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
|
const downIdx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
|
||||||
var next = visible[(idx + 1) % visible.length];
|
highlightOption(visible[(downIdx + 1) % visible.length]);
|
||||||
highlightOption(next);
|
|
||||||
} else if (key === "ArrowUp") {
|
} else if (key === "ArrowUp") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
showPanel();
|
showPanel();
|
||||||
var idx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
|
const upIdx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
|
||||||
var prev = visible[(idx - 1 + visible.length) % visible.length];
|
highlightOption(visible[(upIdx - 1 + visible.length) % visible.length]);
|
||||||
highlightOption(prev);
|
|
||||||
} else if (key === "Enter") {
|
} else if (key === "Enter") {
|
||||||
if (highlightedRow) {
|
if (highlightedRow) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
var option = optionFromRow(highlightedRow);
|
const option = optionFromRow(highlightedRow);
|
||||||
|
if (isFilter) {
|
||||||
addFilterPill(option, "include");
|
addFilterPill(option, "include");
|
||||||
search.value = "";
|
search.value = "";
|
||||||
|
} else {
|
||||||
|
selectOption(option);
|
||||||
|
}
|
||||||
clearHighlight();
|
clearHighlight();
|
||||||
hidePanel();
|
hidePanel();
|
||||||
}
|
}
|
||||||
@@ -314,28 +377,27 @@
|
|||||||
clearHighlight();
|
clearHighlight();
|
||||||
hidePanel();
|
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));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)")]
|
||||||
|
|||||||
Reference in New Issue
Block a user