Convert onSwap widgets to custom elements (issue #18)
Replaces the four onSwap-based widgets with TypeScript custom elements following the pattern from PR #16. Each widget gets a class extending HTMLElement with connectedCallback/disconnectedCallback, typed props via register_element + gen_element_types codegen, and lives in ts/elements/. - range-slider: RangeSliderElement; Python uses _RangeSlider builder - date-range-picker: DateRangePickerElement; Python uses _DateRangePicker builder - search-select: SearchSelectElement; Python uses _SearchSelect builder; data-* attrs become plain attrs (data-name -> name, data-search-url -> search-url, etc.) - filter-bar: FilterBarElement; props carry preset URLs; onclick/onsubmit attrs replaced with data-filter-bar-* sentinel attrs; all window.* globals removed Deletes ts/range_slider.ts, ts/search_select.ts, ts/date_range_picker.ts, ts/filter_bar.ts. Updates all tests and e2e pages to use the new element selectors and script paths (dist/elements/<tag>.js). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -146,6 +146,49 @@ register_element("selection-fields", "SelectionFields", SelectionFieldsProps)
|
|||||||
_SelectionFields = custom_element_builder("selection-fields")
|
_SelectionFields = custom_element_builder("selection-fields")
|
||||||
|
|
||||||
|
|
||||||
|
class RangeSliderProps(TypedDict):
|
||||||
|
min: int
|
||||||
|
max: int
|
||||||
|
step: int
|
||||||
|
mode: str # "range" | "point"
|
||||||
|
|
||||||
|
|
||||||
|
register_element("range-slider", "RangeSlider", RangeSliderProps)
|
||||||
|
_RangeSlider = custom_element_builder("range-slider")
|
||||||
|
|
||||||
|
|
||||||
|
class DateRangePickerProps(TypedDict):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
register_element("date-range-picker", "DateRangePicker", DateRangePickerProps)
|
||||||
|
_DateRangePicker = custom_element_builder("date-range-picker")
|
||||||
|
|
||||||
|
|
||||||
|
class SearchSelectProps(TypedDict):
|
||||||
|
name: str
|
||||||
|
search_url: str
|
||||||
|
multi: bool
|
||||||
|
filter_mode: bool
|
||||||
|
free_text: bool
|
||||||
|
always_visible: bool
|
||||||
|
prefetch: int
|
||||||
|
sync_url: bool
|
||||||
|
|
||||||
|
|
||||||
|
register_element("search-select", "SearchSelect", SearchSelectProps)
|
||||||
|
_SearchSelect = custom_element_builder("search-select")
|
||||||
|
|
||||||
|
|
||||||
|
class FilterBarProps(TypedDict):
|
||||||
|
preset_list_url: str
|
||||||
|
preset_save_url: str
|
||||||
|
|
||||||
|
|
||||||
|
register_element("filter-bar", "FilterBar", FilterBarProps)
|
||||||
|
_FilterBarElement = custom_element_builder("filter-bar")
|
||||||
|
|
||||||
|
|
||||||
def SelectionFields(
|
def SelectionFields(
|
||||||
*,
|
*,
|
||||||
source: str,
|
source: str,
|
||||||
|
|||||||
@@ -17,13 +17,11 @@ widget into a ``DateCriterion`` unchanged. All behaviour is wired by
|
|||||||
``ts/date_range_picker.ts`` (compiled to ``dist/date_range_picker.js``).
|
``ts/date_range_picker.ts`` (compiled to ``dist/date_range_picker.js``).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from common.components.core import Element, HTMLAttribute, Media, Node, Safe
|
from common.components.core import Element, Node, Safe
|
||||||
|
from common.components.custom_elements import _DateRangePicker
|
||||||
from common.components.primitives import Div, Input, Span
|
from common.components.primitives import Div, Input, Span
|
||||||
from common.time import DatePartSpec, date_parts
|
from common.time import DatePartSpec, date_parts
|
||||||
|
|
||||||
# Wired by ts/date_range_picker.ts (compiled to dist/).
|
|
||||||
_DATE_RANGE_MEDIA = Media(js=("dist/date_range_picker.js",))
|
|
||||||
|
|
||||||
_FIELD_CONTAINER_CLASS = (
|
_FIELD_CONTAINER_CLASS = (
|
||||||
"flex items-center gap-0.5 w-full rounded-base border border-default-medium "
|
"flex items-center gap-0.5 w-full rounded-base border border-default-medium "
|
||||||
"bg-neutral-secondary-medium text-sm text-heading p-1.5 cursor-text "
|
"bg-neutral-secondary-medium text-sm text-heading p-1.5 cursor-text "
|
||||||
@@ -335,20 +333,12 @@ def DateRangePicker(
|
|||||||
serializer needs no changes. ``min_value`` / ``max_value`` are ISO
|
serializer needs no changes. ``min_value`` / ``max_value`` are ISO
|
||||||
``YYYY-MM-DD`` strings used to prefill both the segments and the hidden
|
``YYYY-MM-DD`` strings used to prefill both the segments and the hidden
|
||||||
inputs."""
|
inputs."""
|
||||||
attributes: list[HTMLAttribute] = [
|
return _DateRangePicker(class_="relative")[
|
||||||
("class", "date-range-picker relative"),
|
DateRangeField(
|
||||||
("data-date-range-picker", ""),
|
label=label,
|
||||||
("data-input-name-prefix", input_name_prefix),
|
input_name_prefix=input_name_prefix,
|
||||||
|
min_value=min_value,
|
||||||
|
max_value=max_value,
|
||||||
|
),
|
||||||
|
DateRangeCalendar(input_name_prefix=input_name_prefix),
|
||||||
]
|
]
|
||||||
return Div(
|
|
||||||
attributes=attributes,
|
|
||||||
children=[
|
|
||||||
DateRangeField(
|
|
||||||
label=label,
|
|
||||||
input_name_prefix=input_name_prefix,
|
|
||||||
min_value=min_value,
|
|
||||||
max_value=max_value,
|
|
||||||
),
|
|
||||||
DateRangeCalendar(input_name_prefix=input_name_prefix),
|
|
||||||
],
|
|
||||||
).with_media(_DATE_RANGE_MEDIA)
|
|
||||||
|
|||||||
+203
-217
@@ -4,7 +4,8 @@ from typing import NamedTuple
|
|||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from common.components.core import BaseComponent, Element, Media, Node, Safe
|
from common.components.core import BaseComponent, Element, Node, Safe
|
||||||
|
from common.components.custom_elements import _FilterBarElement, _RangeSlider
|
||||||
from common.components.date_range_picker import DateRangePicker
|
from common.components.date_range_picker import DateRangePicker
|
||||||
from common.components.primitives import Checkbox, Div, Input, Label, Radio, Span
|
from common.components.primitives import Checkbox, Div, Input, Label, Radio, Span
|
||||||
from common.components.search_select import (
|
from common.components.search_select import (
|
||||||
@@ -52,13 +53,6 @@ _FILTER_RADIO_CLASS = (
|
|||||||
_FILTER_GRID_CLASS = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4"
|
_FILTER_GRID_CLASS = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4"
|
||||||
|
|
||||||
|
|
||||||
# range_slider.js wires RangeSlider; ts/filter_bar.ts wires the bar chrome
|
|
||||||
# (Apply/Clear, presets, search injection). Widget media (dist/search_select.js,
|
|
||||||
# date_range_picker.js) bubbles up from the contained FilterSelect / picker.
|
|
||||||
_RANGE_SLIDER_MEDIA = Media(js=("dist/range_slider.js",))
|
|
||||||
_FILTER_BAR_MEDIA = Media(js=("dist/filter_bar.js",))
|
|
||||||
|
|
||||||
|
|
||||||
def _filter_parse(filter_json: str) -> dict:
|
def _filter_parse(filter_json: str) -> dict:
|
||||||
if not filter_json:
|
if not filter_json:
|
||||||
return {}
|
return {}
|
||||||
@@ -340,157 +334,157 @@ def RangeSlider(
|
|||||||
point_mode = bool(min_value and max_value and min_value == max_value)
|
point_mode = bool(min_value and max_value and min_value == max_value)
|
||||||
initial_mode = "point" if point_mode else "range"
|
initial_mode = "point" if point_mode else "range"
|
||||||
|
|
||||||
return Div(
|
return _RangeSlider(
|
||||||
attributes=[("class", "range-slider-block mb-4")],
|
min=range_min,
|
||||||
children=[
|
max=range_max,
|
||||||
# ── Label row ──
|
step=int(step),
|
||||||
Div(
|
mode=initial_mode,
|
||||||
attributes=[("class", "flex items-center gap-2 mb-1")],
|
class_="mb-4 block",
|
||||||
children=[
|
)[
|
||||||
# The field label is rendered by the _filter_field wrapper.
|
# ── Label row ──
|
||||||
# This composite widget has no single labelable root, so the
|
Div(
|
||||||
# label carries no `for` (the two inputs are named below).
|
attributes=[("class", "flex items-center gap-2 mb-1")],
|
||||||
Input(
|
children=[
|
||||||
attributes=[
|
# The field label is rendered by the _filter_field wrapper.
|
||||||
("type", "number"),
|
# This composite widget has no single labelable root, so the
|
||||||
("name", min_input_id),
|
# label carries no `for` (the two inputs are named below).
|
||||||
("id", min_input_id),
|
Input(
|
||||||
("value", min_value),
|
attributes=[
|
||||||
("placeholder", min_placeholder),
|
("type", "number"),
|
||||||
(
|
("name", min_input_id),
|
||||||
"class",
|
("id", min_input_id),
|
||||||
f"{_RANGE_SLIDER_INPUT_CLASS}"
|
("value", min_value),
|
||||||
+ (" hidden" if point_mode else ""),
|
("placeholder", min_placeholder),
|
||||||
),
|
(
|
||||||
],
|
"class",
|
||||||
),
|
f"{_RANGE_SLIDER_INPUT_CLASS}"
|
||||||
Span(
|
+ (" hidden" if point_mode else ""),
|
||||||
attributes=[
|
),
|
||||||
(
|
],
|
||||||
"class",
|
),
|
||||||
"range-dash text-body text-sm"
|
Span(
|
||||||
+ (" hidden" if point_mode else ""),
|
attributes=[
|
||||||
),
|
(
|
||||||
],
|
"class",
|
||||||
children=["–"],
|
"range-dash text-body text-sm"
|
||||||
),
|
+ (" hidden" if point_mode else ""),
|
||||||
Input(
|
),
|
||||||
attributes=[
|
],
|
||||||
("type", "number"),
|
children=["–"],
|
||||||
("name", max_input_id),
|
),
|
||||||
("id", max_input_id),
|
Input(
|
||||||
("value", max_value),
|
attributes=[
|
||||||
("placeholder", max_placeholder),
|
("type", "number"),
|
||||||
("class", _RANGE_SLIDER_INPUT_CLASS),
|
("name", max_input_id),
|
||||||
],
|
("id", max_input_id),
|
||||||
),
|
("value", max_value),
|
||||||
Element(
|
("placeholder", max_placeholder),
|
||||||
"button",
|
("class", _RANGE_SLIDER_INPUT_CLASS),
|
||||||
attributes=[
|
],
|
||||||
("type", "button"),
|
),
|
||||||
(
|
Element(
|
||||||
"class",
|
"button",
|
||||||
"range-mode-toggle p-1 text-body hover:text-heading "
|
attributes=[
|
||||||
"rounded cursor-pointer shrink-0",
|
("type", "button"),
|
||||||
),
|
(
|
||||||
(
|
"class",
|
||||||
"title",
|
"range-mode-toggle p-1 text-body hover:text-heading "
|
||||||
"Toggle between range and single value",
|
"rounded cursor-pointer shrink-0",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"aria-label",
|
"title",
|
||||||
"Toggle between range and single value",
|
"Toggle between range and single value",
|
||||||
),
|
),
|
||||||
],
|
(
|
||||||
children=[
|
"aria-label",
|
||||||
Span(
|
"Toggle between range and single value",
|
||||||
attributes=[
|
),
|
||||||
(
|
],
|
||||||
"class",
|
children=[
|
||||||
"range-mode-icon-range"
|
Span(
|
||||||
+ (" hidden" if point_mode else ""),
|
attributes=[
|
||||||
),
|
(
|
||||||
],
|
"class",
|
||||||
children=[Safe(_RANGE_ICON_SVG)],
|
"range-mode-icon-range"
|
||||||
),
|
+ (" hidden" if point_mode else ""),
|
||||||
Span(
|
),
|
||||||
attributes=[
|
],
|
||||||
(
|
children=[Safe(_RANGE_ICON_SVG)],
|
||||||
"class",
|
),
|
||||||
"range-mode-icon-point"
|
Span(
|
||||||
+ ("" if point_mode else " hidden"),
|
attributes=[
|
||||||
),
|
(
|
||||||
],
|
"class",
|
||||||
children=[Safe(_POINT_ICON_SVG)],
|
"range-mode-icon-point"
|
||||||
),
|
+ ("" if point_mode else " hidden"),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
],
|
children=[Safe(_POINT_ICON_SVG)],
|
||||||
),
|
),
|
||||||
# ── Slider row ──
|
],
|
||||||
Div(
|
),
|
||||||
attributes=[
|
],
|
||||||
("class", "range-slider relative h-10 w-5/6 select-none mt-1"),
|
),
|
||||||
("data-mode", initial_mode),
|
# ── Track row ──
|
||||||
("data-min", str(range_min)),
|
Div(
|
||||||
("data-max", str(range_max)),
|
attributes=[
|
||||||
("data-step", str(step)),
|
("class", "relative h-10 w-5/6 select-none mt-1"),
|
||||||
],
|
("data-range-track", ""),
|
||||||
children=[
|
],
|
||||||
Div(
|
children=[
|
||||||
attributes=[
|
Div(
|
||||||
(
|
attributes=[
|
||||||
"class",
|
(
|
||||||
"absolute top-1/2 -translate-y-1/2 w-full h-2 "
|
"class",
|
||||||
"rounded-full bg-neutral-quaternary",
|
"absolute top-1/2 -translate-y-1/2 w-full h-2 "
|
||||||
),
|
"rounded-full bg-neutral-quaternary",
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
Div(
|
),
|
||||||
attributes=[
|
Div(
|
||||||
(
|
attributes=[
|
||||||
"class",
|
(
|
||||||
"range-track-fill absolute top-1/2 -translate-y-1/2 "
|
"class",
|
||||||
"h-2 bg-brand rounded-full",
|
"range-track-fill absolute top-1/2 -translate-y-1/2 "
|
||||||
),
|
"h-2 bg-brand rounded-full",
|
||||||
("style", "left:0;width:100%"),
|
),
|
||||||
],
|
("style", "left:0;width:100%"),
|
||||||
),
|
],
|
||||||
# Min handle (hidden in point mode via JS)
|
),
|
||||||
Div(
|
# Min handle (hidden in point mode via JS)
|
||||||
attributes=[
|
Div(
|
||||||
(
|
attributes=[
|
||||||
"class",
|
(
|
||||||
"range-handle range-handle-min absolute top-1/2 "
|
"class",
|
||||||
"-translate-y-1/2 w-5 h-5 bg-brand rounded-full "
|
"range-handle range-handle-min absolute top-1/2 "
|
||||||
"border-2 border-white shadow cursor-pointer "
|
"-translate-y-1/2 w-5 h-5 bg-brand rounded-full "
|
||||||
"hover:scale-110 transition-transform",
|
"border-2 border-white shadow cursor-pointer "
|
||||||
),
|
"hover:scale-110 transition-transform",
|
||||||
("data-target", min_input_id),
|
),
|
||||||
(
|
("data-target", min_input_id),
|
||||||
"style",
|
(
|
||||||
"left:0" + (";display:none" if point_mode else ""),
|
"style",
|
||||||
),
|
"left:0" + (";display:none" if point_mode else ""),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
# Max handle
|
),
|
||||||
Div(
|
# Max handle
|
||||||
attributes=[
|
Div(
|
||||||
(
|
attributes=[
|
||||||
"class",
|
(
|
||||||
"range-handle range-handle-max absolute top-1/2 "
|
"class",
|
||||||
"-translate-y-1/2 w-5 h-5 bg-brand rounded-full "
|
"range-handle range-handle-max absolute top-1/2 "
|
||||||
"border-2 border-white shadow cursor-pointer "
|
"-translate-y-1/2 w-5 h-5 bg-brand rounded-full "
|
||||||
"hover:scale-110 transition-transform",
|
"border-2 border-white shadow cursor-pointer "
|
||||||
),
|
"hover:scale-110 transition-transform",
|
||||||
("data-target", max_input_id),
|
),
|
||||||
("style", "left:100%"),
|
("data-target", max_input_id),
|
||||||
],
|
("style", "left:100%"),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
).with_media(_RANGE_SLIDER_MEDIA)
|
]
|
||||||
|
|
||||||
|
|
||||||
_DATE_RANGE_INPUT_CLASS = (
|
_DATE_RANGE_INPUT_CLASS = (
|
||||||
@@ -588,7 +582,7 @@ def _filter_collapse_button() -> Node:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _filter_action_row(preset_list_url: str, preset_save_url: str) -> Node:
|
def _filter_action_row() -> Node:
|
||||||
return Div(
|
return Div(
|
||||||
attributes=[("class", "flex gap-3 items-center")],
|
attributes=[("class", "flex gap-3 items-center")],
|
||||||
children=[
|
children=[
|
||||||
@@ -609,10 +603,7 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> Node:
|
|||||||
"button",
|
"button",
|
||||||
attributes=[
|
attributes=[
|
||||||
("type", "button"),
|
("type", "button"),
|
||||||
(
|
("data-filter-bar-clear", ""),
|
||||||
"onclick",
|
|
||||||
f"clearFilterBar('{_FILTER_FORM_ID}', '{_FILTER_INPUT_ID}')",
|
|
||||||
),
|
|
||||||
(
|
(
|
||||||
"class",
|
"class",
|
||||||
"px-4 py-2 text-sm font-medium text-gray-900 bg-white "
|
"px-4 py-2 text-sm font-medium text-gray-900 bg-white "
|
||||||
@@ -633,6 +624,7 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> Node:
|
|||||||
attributes=[
|
attributes=[
|
||||||
("type", "text"),
|
("type", "text"),
|
||||||
("id", "preset-name-input"),
|
("id", "preset-name-input"),
|
||||||
|
("data-filter-bar-preset-name", ""),
|
||||||
("placeholder", "Preset name..."),
|
("placeholder", "Preset name..."),
|
||||||
(
|
(
|
||||||
"class",
|
"class",
|
||||||
@@ -647,7 +639,7 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> Node:
|
|||||||
attributes=[
|
attributes=[
|
||||||
("type", "button"),
|
("type", "button"),
|
||||||
("id", "save-preset-btn"),
|
("id", "save-preset-btn"),
|
||||||
("onclick", "showPresetNameInput()"),
|
("data-filter-bar-save", ""),
|
||||||
(
|
(
|
||||||
"class",
|
"class",
|
||||||
"px-4 py-2 text-sm font-medium text-gray-900 "
|
"px-4 py-2 text-sm font-medium text-gray-900 "
|
||||||
@@ -664,10 +656,7 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> Node:
|
|||||||
attributes=[
|
attributes=[
|
||||||
("type", "button"),
|
("type", "button"),
|
||||||
("id", "confirm-save-preset-btn"),
|
("id", "confirm-save-preset-btn"),
|
||||||
(
|
("data-filter-bar-confirm-save", ""),
|
||||||
"onclick",
|
|
||||||
f"savePreset('{_FILTER_FORM_ID}', '{_FILTER_INPUT_ID}', '{preset_save_url}')",
|
|
||||||
),
|
|
||||||
(
|
(
|
||||||
"class",
|
"class",
|
||||||
"hidden px-4 py-2 text-sm font-medium text-white "
|
"hidden px-4 py-2 text-sm font-medium text-white "
|
||||||
@@ -683,7 +672,6 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> Node:
|
|||||||
attributes=[
|
attributes=[
|
||||||
("id", "preset-dropdown"),
|
("id", "preset-dropdown"),
|
||||||
("class", "relative"),
|
("class", "relative"),
|
||||||
("data-preset-list-url", preset_list_url),
|
|
||||||
],
|
],
|
||||||
children=[
|
children=[
|
||||||
Span(
|
Span(
|
||||||
@@ -702,14 +690,11 @@ class _FilterBarBase(BaseComponent):
|
|||||||
Subclasses implement ``build_fields()`` returning the per-entity body
|
Subclasses implement ``build_fields()`` returning the per-entity body
|
||||||
(grids, sliders, checkboxes); this base wraps it in the collapse toggle,
|
(grids, sliders, checkboxes); this base wraps it in the collapse toggle,
|
||||||
the form, the hidden filter-json input and the Apply/Clear/preset action
|
the form, the hidden filter-json input and the Apply/Clear/preset action
|
||||||
row. ``filter_bar.js`` (declared as this component's ``media``) wires the
|
row. ``filter-bar.js`` (declared via ``_FilterBarElement``) wires the
|
||||||
chrome; widget media (search_select.js, range_slider.js,
|
chrome; widget media bubbles up from the contained widgets via the node
|
||||||
date_range_picker.js) bubbles up from the contained widgets via the node
|
|
||||||
tree, so the view never threads ``scripts=`` by hand.
|
tree, so the view never threads ``scripts=`` by hand.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
media = _FILTER_BAR_MEDIA
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
filter_json: str = "",
|
filter_json: str = "",
|
||||||
@@ -726,47 +711,49 @@ class _FilterBarBase(BaseComponent):
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def render(self) -> Node:
|
def render(self) -> Node:
|
||||||
return Div(
|
return _FilterBarElement(
|
||||||
attributes=[("id", "filter-bar"), ("class", "mb-6")],
|
preset_list_url=self.preset_list_url,
|
||||||
children=[
|
preset_save_url=self.preset_save_url,
|
||||||
_filter_collapse_button(),
|
)[
|
||||||
Div(
|
Div(
|
||||||
attributes=[
|
attributes=[("id", "filter-bar"), ("class", "mb-6")],
|
||||||
("id", "filter-bar-body"),
|
children=[
|
||||||
(
|
_filter_collapse_button(),
|
||||||
"class",
|
Div(
|
||||||
"hidden border border-default-medium rounded-base p-4 "
|
attributes=[
|
||||||
"bg-neutral-secondary-medium/50",
|
("id", "filter-bar-body"),
|
||||||
),
|
(
|
||||||
],
|
"class",
|
||||||
children=[
|
"hidden border border-default-medium rounded-base p-4 "
|
||||||
Element(
|
"bg-neutral-secondary-medium/50",
|
||||||
"form",
|
),
|
||||||
attributes=[
|
],
|
||||||
("id", _FILTER_FORM_ID),
|
children=[
|
||||||
("onsubmit", "return applyFilterBar(event)"),
|
Element(
|
||||||
],
|
"form",
|
||||||
children=[
|
attributes=[
|
||||||
Input(
|
("id", _FILTER_FORM_ID),
|
||||||
attributes=[
|
],
|
||||||
("type", "hidden"),
|
children=[
|
||||||
("id", _FILTER_INPUT_ID),
|
Input(
|
||||||
("name", "filter"),
|
attributes=[
|
||||||
# NB: attribute values are escaped, so the
|
("type", "hidden"),
|
||||||
# raw JSON passes through (no double-escape).
|
("id", _FILTER_INPUT_ID),
|
||||||
("value", self.filter_json),
|
("name", "filter"),
|
||||||
],
|
# NB: attribute values are escaped, so the
|
||||||
),
|
# raw JSON passes through (no double-escape).
|
||||||
*self.build_fields(),
|
("value", self.filter_json),
|
||||||
_filter_action_row(
|
],
|
||||||
self.preset_list_url, self.preset_save_url
|
),
|
||||||
),
|
*self.build_fields(),
|
||||||
],
|
_filter_action_row(),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
)
|
],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class FilterBar(_FilterBarBase):
|
class FilterBar(_FilterBarBase):
|
||||||
@@ -1557,7 +1544,6 @@ def StringFilter(
|
|||||||
value=mod_val,
|
value=mod_val,
|
||||||
attributes=[
|
attributes=[
|
||||||
("data-string-modifier-radio", ""),
|
("data-string-modifier-radio", ""),
|
||||||
("onclick", "toggleStringFilterInput(this)"),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
for mod_val, lbl in options
|
for mod_val, lbl in options
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ from collections.abc import Callable, Iterable
|
|||||||
from typing import TypedDict
|
from typing import TypedDict
|
||||||
|
|
||||||
|
|
||||||
from common.components.core import Attributes, Element, HTMLAttribute, Media, Node
|
from common.components.core import Attributes, Element, HTMLAttribute, Node
|
||||||
|
from common.components.custom_elements import _SearchSelect
|
||||||
from common.components.primitives import (
|
from common.components.primitives import (
|
||||||
DISABLED_WITHIN_CLASS,
|
DISABLED_WITHIN_CLASS,
|
||||||
Div,
|
Div,
|
||||||
@@ -43,9 +44,6 @@ from common.components.primitives import (
|
|||||||
Template,
|
Template,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Both comboboxes are wired by ts/search_select.ts (compiled to dist/).
|
|
||||||
_SEARCH_SELECT_MEDIA = Media(js=("dist/search_select.js",))
|
|
||||||
|
|
||||||
|
|
||||||
class SearchSelectOption(TypedDict):
|
class SearchSelectOption(TypedDict):
|
||||||
value: str | int
|
value: str | int
|
||||||
@@ -210,27 +208,20 @@ def _option_row(option: SearchSelectOption) -> Node:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _combobox_shell(
|
def _combobox_children(
|
||||||
*,
|
*,
|
||||||
container_attributes: Attributes,
|
|
||||||
pills: Node,
|
pills: Node,
|
||||||
search_attributes: Attributes,
|
search_attributes: Attributes,
|
||||||
options_children: list[Node],
|
options_children: list[Node],
|
||||||
always_visible: bool,
|
always_visible: bool,
|
||||||
items_visible: int,
|
items_visible: int,
|
||||||
templates: list[Node] | None = None,
|
templates: list[Node] | None = None,
|
||||||
) -> Node:
|
) -> list[Node]:
|
||||||
"""Assemble the shared, domain-agnostic combobox skeleton.
|
"""Build and return the shared combobox interior nodes.
|
||||||
|
|
||||||
Every combobox built on top of this shell has the same three regions in the
|
Returns the three content regions (pills, search box, options panel) plus
|
||||||
same order: the ``pills`` region, the search box, and the options panel (which
|
any templates — ready to be placed as children of the caller's container
|
||||||
always carries a trailing no-results node). Callers supply the already-built
|
element. The shell knows nothing about how individual rows or pills look.
|
||||||
``pills`` region, the ``search_attributes`` for the text box, the
|
|
||||||
``options_children`` (value rows plus any pinned pseudo-options), the
|
|
||||||
``container_attributes`` that carry the widget's identity and behaviour flags,
|
|
||||||
and any ``templates`` (inert ``<template>`` prototypes the JS clones for
|
|
||||||
dynamically-added rows/pills). The shell knows nothing about how individual
|
|
||||||
rows or pills look.
|
|
||||||
"""
|
"""
|
||||||
search = Input(attributes=search_attributes)
|
search = Input(attributes=search_attributes)
|
||||||
|
|
||||||
@@ -251,8 +242,7 @@ def _combobox_shell(
|
|||||||
children=[*options_children, no_results],
|
children=[*options_children, no_results],
|
||||||
)
|
)
|
||||||
|
|
||||||
children: list[Node] = [pills, search, options_panel, *(templates or [])]
|
return [pills, search, options_panel, *(templates or [])]
|
||||||
return Div(attributes=container_attributes, children=children)
|
|
||||||
|
|
||||||
|
|
||||||
def SearchSelect(
|
def SearchSelect(
|
||||||
@@ -337,30 +327,26 @@ def SearchSelect(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
container_attributes: list[HTMLAttribute] = [
|
children = _combobox_children(
|
||||||
("data-search-select", ""),
|
|
||||||
("data-name", name),
|
|
||||||
("data-search-url", search_url),
|
|
||||||
("data-multi", "true" if multi_select else "false"),
|
|
||||||
("data-always-visible", "true" if always_visible else "false"),
|
|
||||||
("data-items-visible", str(items_visible)),
|
|
||||||
("data-items-scroll", str(items_scroll)),
|
|
||||||
("data-prefetch", str(prefetch)),
|
|
||||||
("data-sync-url", "true" if sync_url else "false"),
|
|
||||||
("class", _CONTAINER_CLASS),
|
|
||||||
]
|
|
||||||
if id:
|
|
||||||
container_attributes.append(("id", id))
|
|
||||||
|
|
||||||
return _combobox_shell(
|
|
||||||
container_attributes=container_attributes,
|
|
||||||
pills=pills,
|
pills=pills,
|
||||||
search_attributes=search_attrs,
|
search_attributes=search_attrs,
|
||||||
options_children=option_rows,
|
options_children=option_rows,
|
||||||
always_visible=always_visible,
|
always_visible=always_visible,
|
||||||
items_visible=items_visible,
|
items_visible=items_visible,
|
||||||
templates=templates,
|
templates=templates,
|
||||||
).with_media(_SEARCH_SELECT_MEDIA)
|
)
|
||||||
|
return _SearchSelect(
|
||||||
|
name=name,
|
||||||
|
search_url=search_url,
|
||||||
|
multi="true" if multi_select else "false",
|
||||||
|
filter_mode="false",
|
||||||
|
free_text="false",
|
||||||
|
always_visible="true" if always_visible else "false",
|
||||||
|
prefetch=prefetch,
|
||||||
|
sync_url="true" if sync_url else "false",
|
||||||
|
class_=_CONTAINER_CLASS,
|
||||||
|
id_=id or None,
|
||||||
|
)[*children]
|
||||||
|
|
||||||
|
|
||||||
def _filter_remove_button() -> Node:
|
def _filter_remove_button() -> Node:
|
||||||
@@ -567,35 +553,27 @@ def FilterSelect(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
container_attributes: list[HTMLAttribute] = [
|
children = _combobox_children(
|
||||||
("data-search-select", ""),
|
|
||||||
("data-search-select-mode", "filter"),
|
|
||||||
("data-name", field_name),
|
|
||||||
("data-search-url", search_url),
|
|
||||||
("data-multi", "true"),
|
|
||||||
("data-always-visible", "false"),
|
|
||||||
("data-items-visible", str(items_visible)),
|
|
||||||
("data-items-scroll", str(items_scroll)),
|
|
||||||
("data-prefetch", str(prefetch)),
|
|
||||||
("data-sync-url", "false"),
|
|
||||||
("class", _CONTAINER_CLASS),
|
|
||||||
]
|
|
||||||
if free_text:
|
|
||||||
container_attributes.append(("data-search-select-free-text", "true"))
|
|
||||||
if modifier:
|
|
||||||
container_attributes.append(("data-modifier", modifier))
|
|
||||||
if id:
|
|
||||||
container_attributes.append(("id", id))
|
|
||||||
|
|
||||||
return _combobox_shell(
|
|
||||||
container_attributes=container_attributes,
|
|
||||||
pills=pills,
|
pills=pills,
|
||||||
search_attributes=search_attributes,
|
search_attributes=search_attributes,
|
||||||
options_children=[*modifier_rows, *value_rows],
|
options_children=[*modifier_rows, *value_rows],
|
||||||
always_visible=False,
|
always_visible=False,
|
||||||
items_visible=items_visible,
|
items_visible=items_visible,
|
||||||
templates=templates,
|
templates=templates,
|
||||||
).with_media(_SEARCH_SELECT_MEDIA)
|
)
|
||||||
|
return _SearchSelect(
|
||||||
|
name=field_name,
|
||||||
|
search_url=search_url,
|
||||||
|
multi="true",
|
||||||
|
filter_mode="true",
|
||||||
|
free_text="true" if free_text else "false",
|
||||||
|
always_visible="false",
|
||||||
|
prefetch=prefetch,
|
||||||
|
sync_url="false",
|
||||||
|
class_=_CONTAINER_CLASS,
|
||||||
|
id_=id or None,
|
||||||
|
data_modifier=modifier or None,
|
||||||
|
)[*children]
|
||||||
|
|
||||||
|
|
||||||
def searchselect_selected(
|
def searchselect_selected(
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Convert Remaining onSwap Widgets to Custom Elements
|
||||||
|
|
||||||
|
**Date:** 2026-06-20
|
||||||
|
**Issue:** #18
|
||||||
|
**Relates to:** #17 (TS migration), spec `2026-06-13-html-js-authoring-design.md`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
PR #16 established the custom-element pattern (TypeScript custom elements, `connectedCallback` lifecycle, codegen'd typed prop contracts) and converted three components. Four interactive widgets still use the old pattern: a hand-written `.ts` file registered with `onSwap(selector, fn)` + `data-*` attributes.
|
||||||
|
|
||||||
|
**Goal:** Migrate all four remaining widgets to the custom-element pattern so the whole interactive surface uses one model.
|
||||||
|
|
||||||
|
## Widgets and Dependency Order
|
||||||
|
|
||||||
|
Convert in this order (least-to-most dependent):
|
||||||
|
|
||||||
|
1. `range-slider` — no cross-widget deps
|
||||||
|
2. `date-range-picker` — no cross-widget deps
|
||||||
|
3. `search-select` — no deps; exports `readSearchSelect()` consumed by filter-bar
|
||||||
|
4. `filter-bar` — imports `readSearchSelect`; removes all `window.*` globals
|
||||||
|
|
||||||
|
`onSwap` is NOT retired by this issue — `year_picker.ts` and `add_purchase.ts` still use it (see #17).
|
||||||
|
|
||||||
|
## Per-Widget Conversion Pattern
|
||||||
|
|
||||||
|
Each widget follows the same steps:
|
||||||
|
|
||||||
|
### Python side
|
||||||
|
|
||||||
|
1. Add `XxxProps(TypedDict)` to `common/components/custom_elements.py`
|
||||||
|
2. Call `register_element("xxx", "Xxx", XxxProps)` immediately after
|
||||||
|
3. Create `_Xxx = custom_element_builder("xxx")`
|
||||||
|
4. Update the Python component (in `filters.py`, `search_select.py`, or `date_range_picker.py`) to use the builder; remove old `_XXX_MEDIA` and `.with_media(...)` calls
|
||||||
|
|
||||||
|
### TypeScript side
|
||||||
|
|
||||||
|
5. Create `ts/elements/xxx.ts` (move logic from `ts/xxx.ts`)
|
||||||
|
6. Replace IIFE + `onSwap(selector, fn)` with `class XxxElement extends HTMLElement { connectedCallback() { ... } }`
|
||||||
|
7. Read typed props via generated `readXxxProps(this)` instead of `el.getAttribute("data-xxx")`
|
||||||
|
8. Add `disconnectedCallback()` to remove any document-level event listeners
|
||||||
|
9. End with `customElements.define("xxx", XxxElement)`
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
10. `uv run manage.py gen_element_types` — regenerates `ts/generated/props.ts`
|
||||||
|
11. `make ts` — compiles all TypeScript
|
||||||
|
12. `make check` — linting + type-check + tests
|
||||||
|
|
||||||
|
### E2E
|
||||||
|
|
||||||
|
13. Update Playwright locators to match new element tags and attribute names
|
||||||
|
|
||||||
|
## Widget Specifics
|
||||||
|
|
||||||
|
### `range-slider`
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
```python
|
||||||
|
class RangeSliderProps(TypedDict):
|
||||||
|
min: int
|
||||||
|
max: int
|
||||||
|
step: int
|
||||||
|
mode: str # "range" | "point"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structural change:** `<range-slider>` replaces the outer `.range-slider-block` wrapper div AND the inner `.range-slider` div. The mode toggle button and the track/handles all become light-DOM children of `<range-slider>`. This eliminates `slider.closest(".range-slider-block")` — the TS can use `this.querySelector(".range-mode-toggle")` directly.
|
||||||
|
|
||||||
|
The `data-mode` attribute becomes the typed `mode` prop (attribute `mode` on the element). The JS updates this attribute on toggle: `this.setAttribute("mode", newMode)`.
|
||||||
|
|
||||||
|
E2E: `.range-slider-block` → `range-slider`; `slider[data-mode]` → `range-slider[mode]`.
|
||||||
|
|
||||||
|
### `date-range-picker`
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
```python
|
||||||
|
class DateRangePickerProps(TypedDict):
|
||||||
|
input_name_prefix: str
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structural change:** `<date-range-picker>` replaces the outer `<div data-date-range-picker data-input-name-prefix="...">`. `DateRangeField` and `DateRangeCalendar` remain unchanged as light-DOM children.
|
||||||
|
|
||||||
|
The `data-input-name-prefix` attribute on `DateRangeCalendar` can be removed since the prefix is now a typed prop on the element itself, readable as `readDateRangePickerProps(this).inputNamePrefix`.
|
||||||
|
|
||||||
|
### `search-select`
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
```python
|
||||||
|
class SearchSelectProps(TypedDict):
|
||||||
|
name: str
|
||||||
|
search_url: str # empty string when no URL
|
||||||
|
multi: bool
|
||||||
|
filter_mode: bool # true for FilterSelect; replaces data-search-select-mode="filter"
|
||||||
|
free_text: bool
|
||||||
|
always_visible: bool
|
||||||
|
prefetch: int
|
||||||
|
sync_url: bool
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structural change:** `<search-select>` replaces the outer `<div data-search-select ...>`. All internal child elements (`[data-search-select-search]`, `[data-search-select-options]`, etc.) remain unchanged.
|
||||||
|
|
||||||
|
**`readSearchSelect` export:** Remove `window.readSearchSelect = ...`. Export as a named module function:
|
||||||
|
```typescript
|
||||||
|
export function readSearchSelect(scope: HTMLElement): void { ... }
|
||||||
|
```
|
||||||
|
`filter_bar.ts` will import it. Update the function to query `search-select[filter-mode="true"]` instead of `[data-search-select][data-search-select-mode="filter"]`.
|
||||||
|
|
||||||
|
E2E: `[data-search-select][data-name="status"]` → `search-select[name="status"]`.
|
||||||
|
|
||||||
|
### `filter-bar`
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
```python
|
||||||
|
class FilterBarProps(TypedDict):
|
||||||
|
preset_list_url: str
|
||||||
|
preset_save_url: str
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structural change:** `<filter-bar>` wraps the entire filter bar structure (collapse toggle + form + action row). The Python `_FilterBarBase.render()` wraps its output in the builder.
|
||||||
|
|
||||||
|
**Window globals removed:** `applyFilterBar`, `clearFilterBar`, `toggleStringFilterInput`, `showPresetNameInput`, `savePreset` are no longer assigned to `window`. `connectedCallback` wires all handlers:
|
||||||
|
- `this.querySelector("form")` → `submit` listener (replaces `onsubmit`)
|
||||||
|
- `this.querySelector("[data-filter-bar-clear]")` → `click` listener
|
||||||
|
- `this.querySelector("[data-filter-bar-save]")` → `click` listener
|
||||||
|
- `this.querySelector("[data-filter-bar-confirm-save]")` → `click` listener
|
||||||
|
- `this.querySelectorAll("[data-string-modifier-radio]")` → `change` listeners
|
||||||
|
|
||||||
|
**Python changes in `filters.py`:**
|
||||||
|
- Remove `onsubmit="return applyFilterBar(event)"` from form
|
||||||
|
- Replace `onclick="clearFilterBar(...)"` → `data-filter-bar-clear`
|
||||||
|
- Replace `onclick="showPresetNameInput()"` → `data-filter-bar-save`
|
||||||
|
- Replace `onclick="savePreset(...)"` → `data-filter-bar-confirm-save`
|
||||||
|
- Replace `onclick="toggleStringFilterInput(this)"` → `data-string-modifier-radio` (already present)
|
||||||
|
- Move `preset_list_url` from `data-preset-list-url` on `#preset-dropdown` to a typed prop on `<filter-bar>`
|
||||||
|
- Preset dropdown: `this.querySelector("[data-preset-dropdown]")` (add this attr)
|
||||||
|
|
||||||
|
**Import:** `filter-bar.ts` imports `{ readSearchSelect }` from `./search-select.js`.
|
||||||
|
|
||||||
|
**`globals.d.ts`:** Remove all entries except `fetchWithHtmxTriggers` and `toast` (which remain as globals).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run manage.py gen_element_types # codegen passes
|
||||||
|
make ts # tsc --noEmit passes
|
||||||
|
make test # unit tests pass
|
||||||
|
make test-e2e # e2e tests pass (after locator updates)
|
||||||
|
make check # full CI gate
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual visual check each widget after conversion (per issue requirement).
|
||||||
@@ -22,9 +22,9 @@ def _bar_page(filter_json: str = "") -> str:
|
|||||||
<head>
|
<head>
|
||||||
<title>Boolean filter E2E</title>
|
<title>Boolean filter E2E</title>
|
||||||
<script src="/static/js/htmx.min.js"></script>
|
<script src="/static/js/htmx.min.js"></script>
|
||||||
<script src="/static/js/dist/range_slider.js" type="module"></script>
|
<script src="/static/js/dist/elements/range-slider.js" type="module"></script>
|
||||||
<script src="/static/js/dist/search_select.js" type="module"></script>
|
<script src="/static/js/dist/elements/search-select.js" type="module"></script>
|
||||||
<script src="/static/js/dist/filter_bar.js" type="module"></script>
|
<script src="/static/js/dist/elements/filter-bar.js" type="module"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ def _bar_page(filter_json: str = "") -> str:
|
|||||||
<head>
|
<head>
|
||||||
<title>Date filter E2E</title>
|
<title>Date filter E2E</title>
|
||||||
<script src="/static/js/htmx.min.js"></script>
|
<script src="/static/js/htmx.min.js"></script>
|
||||||
<script src="/static/js/dist/range_slider.js" type="module"></script>
|
<script src="/static/js/dist/elements/range-slider.js" type="module"></script>
|
||||||
<script src="/static/js/dist/search_select.js" type="module"></script>
|
<script src="/static/js/dist/elements/search-select.js" type="module"></script>
|
||||||
<script src="/static/js/dist/filter_bar.js" type="module"></script>
|
<script src="/static/js/dist/elements/filter-bar.js" type="module"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
{PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ def _bar_page(filter_json: str = "") -> str:
|
|||||||
<head>
|
<head>
|
||||||
<title>Date range picker E2E</title>
|
<title>Date range picker E2E</title>
|
||||||
<script src="/static/js/htmx.min.js"></script>
|
<script src="/static/js/htmx.min.js"></script>
|
||||||
<script src="/static/js/dist/range_slider.js" type="module"></script>
|
<script src="/static/js/dist/elements/range-slider.js" type="module"></script>
|
||||||
<script src="/static/js/dist/search_select.js" type="module"></script>
|
<script src="/static/js/dist/elements/search-select.js" type="module"></script>
|
||||||
<script src="/static/js/dist/date_range_picker.js" type="module"></script>
|
<script src="/static/js/dist/elements/date-range-picker.js" type="module"></script>
|
||||||
<script src="/static/js/dist/filter_bar.js" type="module"></script>
|
<script src="/static/js/dist/elements/filter-bar.js" type="module"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
{PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||||
@@ -63,7 +63,7 @@ urlpatterns = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
PICKER = '[data-date-range-picker][data-input-name-prefix="filter-date-purchased"]'
|
PICKER = "date-range-picker"
|
||||||
POPUP = PICKER + " [data-date-range-calendar]"
|
POPUP = PICKER + " [data-date-range-calendar]"
|
||||||
HIDDEN_MIN = 'input[name="filter-date-purchased-min"]'
|
HIDDEN_MIN = 'input[name="filter-date-purchased-min"]'
|
||||||
HIDDEN_MAX = 'input[name="filter-date-purchased-max"]'
|
HIDDEN_MAX = 'input[name="filter-date-purchased-max"]'
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ def selection_fields_view(request):
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<script src="/static/js/htmx.min.js"></script>
|
<script src="/static/js/htmx.min.js"></script>
|
||||||
<script type="module" src="/static/js/dist/search_select.js"></script>
|
<script type="module" src="/static/js/dist/elements/search-select.js"></script>
|
||||||
<script type="module" src="/static/js/dist/elements/selection-fields.js"></script>
|
<script type="module" src="/static/js/dist/elements/selection-fields.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -66,7 +66,7 @@ urlpatterns = [
|
|||||||
def test_selection_fields_syncs_with_source(live_server, page: Page):
|
def test_selection_fields_syncs_with_source(live_server, page: Page):
|
||||||
page.goto(live_server.url + "/sf-test/")
|
page.goto(live_server.url + "/sf-test/")
|
||||||
|
|
||||||
games = page.locator('[data-search-select][data-name="games"]')
|
games = page.locator('search-select[name="games"]')
|
||||||
rows = page.locator("selection-fields [data-selection-fields-rows] input")
|
rows = page.locator("selection-fields [data-selection-fields-rows] input")
|
||||||
|
|
||||||
# Below min_items (2): nothing rendered.
|
# Below min_items (2): nothing rendered.
|
||||||
@@ -112,7 +112,7 @@ def authenticated_page(live_server, page: Page, django_user_model) -> Page:
|
|||||||
|
|
||||||
|
|
||||||
def _select_two_games(page: Page) -> None:
|
def _select_two_games(page: Page) -> None:
|
||||||
games = page.locator('[data-search-select][data-name="games"]')
|
games = page.locator('search-select[name="games"]')
|
||||||
games.locator("[data-search-select-search]").click()
|
games.locator("[data-search-select-search]").click()
|
||||||
options = games.locator("[data-search-select-option]")
|
options = games.locator("[data-search-select-option]")
|
||||||
expect(options).to_have_count(2) # prefetched on focus
|
expect(options).to_have_count(2) # prefetched on focus
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ def _bar_page(filter_json: str = "") -> str:
|
|||||||
<head>
|
<head>
|
||||||
<title>Range Slider E2E</title>
|
<title>Range Slider E2E</title>
|
||||||
<script src="/static/js/htmx.min.js"></script>
|
<script src="/static/js/htmx.min.js"></script>
|
||||||
<script src="/static/js/dist/range_slider.js" type="module"></script>
|
<script src="/static/js/dist/elements/range-slider.js" type="module"></script>
|
||||||
<script src="/static/js/dist/search_select.js" type="module"></script>
|
<script src="/static/js/dist/elements/search-select.js" type="module"></script>
|
||||||
<script src="/static/js/dist/filter_bar.js" type="module"></script>
|
<script src="/static/js/dist/elements/filter-bar.js" type="module"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||||
|
|||||||
@@ -11,10 +11,9 @@ def e2e_test_view(request):
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>SearchSelect E2E Test</title>
|
<title>SearchSelect E2E Test</title>
|
||||||
<!-- search_select.js is an ES module and initializes via onSwap(),
|
<!-- search-select is a custom element; htmx must be present for filter_bar. -->
|
||||||
which rides on htmx.onLoad — so htmx must be present. -->
|
|
||||||
<script src="/static/js/htmx.min.js"></script>
|
<script src="/static/js/htmx.min.js"></script>
|
||||||
<script type="module" src="/static/js/dist/search_select.js"></script>
|
<script type="module" src="/static/js/dist/elements/search-select.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div style="padding: 50px;">
|
<div style="padding: 50px;">
|
||||||
@@ -52,7 +51,7 @@ def test_search_select_backspace_clears_single_select(live_server, page):
|
|||||||
# Inject our event logger
|
# Inject our event logger
|
||||||
page.evaluate("""() => {
|
page.evaluate("""() => {
|
||||||
const s = document.querySelector('input[data-search-select-search]');
|
const s = document.querySelector('input[data-search-select-search]');
|
||||||
const c = document.querySelector('[data-search-select]');
|
const c = document.querySelector('search-select');
|
||||||
s.addEventListener('focus', () => console.log('JS-EVENT: focus, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
|
s.addEventListener('focus', () => console.log('JS-EVENT: focus, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
|
||||||
s.addEventListener('blur', () => console.log('JS-EVENT: blur, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
|
s.addEventListener('blur', () => console.log('JS-EVENT: blur, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
|
||||||
s.addEventListener('input', () => console.log('JS-EVENT: input, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
|
s.addEventListener('input', () => console.log('JS-EVENT: input, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ def _bar_page(filter_json: str = "") -> str:
|
|||||||
<head>
|
<head>
|
||||||
<title>String filter E2E</title>
|
<title>String filter E2E</title>
|
||||||
<script src="/static/js/htmx.min.js"></script>
|
<script src="/static/js/htmx.min.js"></script>
|
||||||
<script src="/static/js/dist/range_slider.js" type="module"></script>
|
<script src="/static/js/dist/elements/range-slider.js" type="module"></script>
|
||||||
<script src="/static/js/dist/search_select.js" type="module"></script>
|
<script src="/static/js/dist/elements/search-select.js" type="module"></script>
|
||||||
<script src="/static/js/dist/filter_bar.js" type="module"></script>
|
<script src="/static/js/dist/elements/filter-bar.js" type="module"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{PlatformFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
{PlatformFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||||
|
|||||||
+11
-13
@@ -31,7 +31,7 @@ def open_filter_bar(page: Page) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def status_filter_widget(page: Page):
|
def status_filter_widget(page: Page):
|
||||||
return page.locator('[data-search-select][data-name="status"]')
|
return page.locator('search-select[name="status"]')
|
||||||
|
|
||||||
|
|
||||||
def test_search_select_initializes_on_page_load(authenticated_page: Page, live_server):
|
def test_search_select_initializes_on_page_load(authenticated_page: Page, live_server):
|
||||||
@@ -78,12 +78,11 @@ def test_range_slider_mode_toggle_fires_exactly_once(
|
|||||||
page.goto(f"{live_server.url}{reverse('games:list_games')}")
|
page.goto(f"{live_server.url}{reverse('games:list_games')}")
|
||||||
open_filter_bar(page)
|
open_filter_bar(page)
|
||||||
|
|
||||||
block = page.locator(".range-slider-block").first
|
slider = page.locator("range-slider").first
|
||||||
slider = block.locator(".range-slider")
|
expect(slider).to_have_attribute("mode", "range")
|
||||||
expect(slider).to_have_attribute("data-mode", "range")
|
|
||||||
|
|
||||||
block.locator(".range-mode-toggle").click()
|
slider.locator(".range-mode-toggle").click()
|
||||||
expect(slider).to_have_attribute("data-mode", "point")
|
expect(slider).to_have_attribute("mode", "point")
|
||||||
|
|
||||||
|
|
||||||
def test_widgets_initialize_inside_htmx_swapped_content(
|
def test_widgets_initialize_inside_htmx_swapped_content(
|
||||||
@@ -110,11 +109,10 @@ def test_widgets_initialize_inside_htmx_swapped_content(
|
|||||||
widget.locator("[data-search-select-search]").click()
|
widget.locator("[data-search-select-search]").click()
|
||||||
expect(widget.locator("[data-search-select-options]")).to_be_visible()
|
expect(widget.locator("[data-search-select-options]")).to_be_visible()
|
||||||
|
|
||||||
block = page.locator(".range-slider-block").first
|
slider = page.locator("range-slider").first
|
||||||
slider = block.locator(".range-slider")
|
expect(slider).to_have_attribute("mode", "range")
|
||||||
expect(slider).to_have_attribute("data-mode", "range")
|
slider.locator(".range-mode-toggle").click()
|
||||||
block.locator(".range-mode-toggle").click()
|
expect(slider).to_have_attribute("mode", "point")
|
||||||
expect(slider).to_have_attribute("data-mode", "point")
|
|
||||||
|
|
||||||
|
|
||||||
def test_add_purchase_type_toggles_disabled_fields(
|
def test_add_purchase_type_toggles_disabled_fields(
|
||||||
@@ -149,9 +147,9 @@ def test_add_purchase_related_game_is_flat_game_search(
|
|||||||
page = authenticated_page
|
page = authenticated_page
|
||||||
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
|
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
|
||||||
|
|
||||||
related = page.locator('[data-search-select][data-name="related_game"]')
|
related = page.locator('search-select[name="related_game"]')
|
||||||
expect(related).to_have_count(1)
|
expect(related).to_have_count(1)
|
||||||
expect(related).to_have_attribute("data-search-url", "/api/games/search")
|
expect(related).to_have_attribute("search-url", "/api/games/search")
|
||||||
|
|
||||||
|
|
||||||
def test_searchselect_border_matches_native_input(
|
def test_searchselect_border_matches_native_input(
|
||||||
|
|||||||
+2
-2
@@ -180,7 +180,7 @@ def add_game(request: HttpRequest) -> HttpResponse:
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
title="Add New Game",
|
title="Add New Game",
|
||||||
scripts=ModuleScript("dist/search_select.js")
|
scripts=ModuleScript("dist/elements/search-select.js")
|
||||||
+ ModuleScript("dist/add_game.js"),
|
+ ModuleScript("dist/add_game.js"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -326,7 +326,7 @@ def edit_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
|||||||
request,
|
request,
|
||||||
AddForm(form, request=request),
|
AddForm(form, request=request),
|
||||||
title="Edit Game",
|
title="Edit Game",
|
||||||
scripts=ModuleScript("dist/search_select.js"),
|
scripts=ModuleScript("dist/elements/search-select.js"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ def add_playevent(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
|||||||
request,
|
request,
|
||||||
AddForm(form, request=request),
|
AddForm(form, request=request),
|
||||||
title="Add new playthrough",
|
title="Add new playthrough",
|
||||||
scripts=ModuleScript("dist/search_select.js"),
|
scripts=ModuleScript("dist/elements/search-select.js"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -233,7 +233,7 @@ def edit_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
|
|||||||
request,
|
request,
|
||||||
AddForm(form, request=request),
|
AddForm(form, request=request),
|
||||||
title="Edit Play Event",
|
title="Edit Play Event",
|
||||||
scripts=ModuleScript("dist/search_select.js"),
|
scripts=ModuleScript("dist/elements/search-select.js"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -301,7 +301,8 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
|||||||
),
|
),
|
||||||
title="Add New Purchase",
|
title="Add New Purchase",
|
||||||
scripts=mark_safe(
|
scripts=mark_safe(
|
||||||
ModuleScript("dist/search_select.js") + ModuleScript("dist/add_purchase.js")
|
ModuleScript("dist/elements/search-select.js")
|
||||||
|
+ ModuleScript("dist/add_purchase.js")
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -319,7 +320,8 @@ def edit_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
|||||||
AddForm(form, request=request, additional_row=_purchase_additional_row()),
|
AddForm(form, request=request, additional_row=_purchase_additional_row()),
|
||||||
title="Edit Purchase",
|
title="Edit Purchase",
|
||||||
scripts=mark_safe(
|
scripts=mark_safe(
|
||||||
ModuleScript("dist/search_select.js") + ModuleScript("dist/add_purchase.js")
|
ModuleScript("dist/elements/search-select.js")
|
||||||
|
+ ModuleScript("dist/add_purchase.js")
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
|||||||
request,
|
request,
|
||||||
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
|
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
|
||||||
title="Add New Session",
|
title="Add New Session",
|
||||||
scripts=mark_safe(ModuleScript("dist/search_select.js")),
|
scripts=mark_safe(ModuleScript("dist/elements/search-select.js")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -264,7 +264,7 @@ def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
|
|||||||
request,
|
request,
|
||||||
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
|
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
|
||||||
title="Edit Session",
|
title="Edit Session",
|
||||||
scripts=mark_safe(ModuleScript("dist/search_select.js")),
|
scripts=mark_safe(ModuleScript("dist/elements/search-select.js")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ class DateRangePickerTest(SimpleTestCase):
|
|||||||
max_value="2024-12-31",
|
max_value="2024-12-31",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertIn("data-date-range-picker", html)
|
self.assertIn("<date-range-picker", html)
|
||||||
self.assertIn('data-input-name-prefix="filter-date-purchased"', html)
|
self.assertIn('data-input-name-prefix="filter-date-purchased"', html)
|
||||||
self.assertIn("data-date-range-field", html)
|
self.assertIn("data-date-range-field", html)
|
||||||
self.assertIn("data-date-range-calendar", html)
|
self.assertIn("data-date-range-calendar", html)
|
||||||
@@ -166,7 +166,7 @@ class PurchaseFilterBarDateRangePickerTest(TestCase):
|
|||||||
|
|
||||||
def test_purchased_uses_date_range_picker(self):
|
def test_purchased_uses_date_range_picker(self):
|
||||||
html = self.render()
|
html = self.render()
|
||||||
self.assertIn("data-date-range-picker", html)
|
self.assertIn("<date-range-picker", html)
|
||||||
self.assertIn('data-input-name-prefix="filter-date-purchased"', html)
|
self.assertIn('data-input-name-prefix="filter-date-purchased"', html)
|
||||||
# The hidden ISO inputs keep the names filter_bar.js serializes.
|
# The hidden ISO inputs keep the names filter_bar.js serializes.
|
||||||
self.assertIn('name="filter-date-purchased-min"', html)
|
self.assertIn('name="filter-date-purchased-min"', html)
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
def _assert_range_slider(self, html):
|
def _assert_range_slider(self, html):
|
||||||
"""Every filter bar must use the RangeSlider component with custom
|
"""Every filter bar must use the RangeSlider component with custom
|
||||||
draggable <div> handles, a track fill, and mode-toggle button."""
|
draggable <div> handles, a track fill, and mode-toggle button."""
|
||||||
self.assertIn("range-slider-block", html)
|
self.assertIn("<range-slider", html)
|
||||||
self.assertIn('data-mode="range"', html)
|
self.assertIn('mode="range"', html)
|
||||||
self.assertIn("range-mode-toggle", html)
|
self.assertIn("range-mode-toggle", html)
|
||||||
self.assertIn("range-mode-icon-range", html)
|
self.assertIn("range-mode-icon-range", html)
|
||||||
self.assertIn("range-mode-icon-point", html)
|
self.assertIn("range-mode-icon-point", html)
|
||||||
@@ -107,8 +107,8 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
# No legacy match-mode <select>.
|
# No legacy match-mode <select>.
|
||||||
self.assertNotIn("data-search-select-match", html)
|
self.assertNotIn("data-search-select-match", html)
|
||||||
# Platform is single-valued: no M2M modifier options in its section.
|
# Platform is single-valued: no M2M modifier options in its section.
|
||||||
games_start = html.find('data-name="games"')
|
games_start = html.find('name="games"')
|
||||||
platform_start = html.find('data-name="platform"')
|
platform_start = html.find('name="platform"')
|
||||||
platform_section = html[platform_start:]
|
platform_section = html[platform_start:]
|
||||||
self.assertNotIn("INCLUDES_ALL", platform_section)
|
self.assertNotIn("INCLUDES_ALL", platform_section)
|
||||||
self.assertGreater(games_start, 0)
|
self.assertGreater(games_start, 0)
|
||||||
@@ -150,7 +150,7 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
|
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertIn('data-search-select-mode="filter"', html)
|
self.assertIn('filter-mode="true"', html)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
'data-search-select-type="include"', html
|
'data-search-select-type="include"', html
|
||||||
) # rendered as an include pill
|
) # rendered as an include pill
|
||||||
@@ -235,11 +235,11 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
# New search-backed selects
|
# New search-backed selects
|
||||||
self.assertIn('data-search-url="/api/devices/search"', html)
|
self.assertIn('search-url="/api/devices/search"', html)
|
||||||
self.assertIn('data-search-url="/api/platforms/groups"', html)
|
self.assertIn('search-url="/api/platforms/groups"', html)
|
||||||
# New enum selects (purchase type / ownership)
|
# New enum selects (purchase type / ownership)
|
||||||
self.assertIn('data-name="purchase_type"', html)
|
self.assertIn('name="purchase_type"', html)
|
||||||
self.assertIn('data-name="purchase_ownership_type"', html)
|
self.assertIn('name="purchase_ownership_type"', html)
|
||||||
# Free-text widget for playevent notes (now StringFilter)
|
# Free-text widget for playevent notes (now StringFilter)
|
||||||
self.assertIn('name="filter-playevent_note"', html)
|
self.assertIn('name="filter-playevent_note"', html)
|
||||||
self.assertIn('name="filter-playevent_note-modifier"', html)
|
self.assertIn('name="filter-playevent_note-modifier"', html)
|
||||||
|
|||||||
@@ -555,8 +555,8 @@ class TestFilterBarRendering:
|
|||||||
|
|
||||||
def test_status_uses_filter_select(self):
|
def test_status_uses_filter_select(self):
|
||||||
html = str(FilterBar())
|
html = str(FilterBar())
|
||||||
assert 'data-search-select-mode="filter"' in html
|
assert 'filter-mode="true"' in html
|
||||||
assert 'data-name="status"' in html
|
assert 'name="status"' in html
|
||||||
|
|
||||||
def test_mastered_not_checked_by_default(self):
|
def test_mastered_not_checked_by_default(self):
|
||||||
html = str(FilterBar(filter_json=""))
|
html = str(FilterBar(filter_json=""))
|
||||||
@@ -602,13 +602,13 @@ class TestFilterBarRendering:
|
|||||||
def test_platform_uses_search_url(self):
|
def test_platform_uses_search_url(self):
|
||||||
"""Platform is model-backed: rows are fetched, not pre-rendered."""
|
"""Platform is model-backed: rows are fetched, not pre-rendered."""
|
||||||
html = str(FilterBar())
|
html = str(FilterBar())
|
||||||
assert 'data-search-url="/api/platforms/search"' in html
|
assert 'search-url="/api/platforms/search"' in html
|
||||||
|
|
||||||
def test_status_has_no_modifiers(self):
|
def test_status_has_no_modifiers(self):
|
||||||
"""Non-nullable fields should not show (None) but MUST show (Any)."""
|
"""Non-nullable fields should not show (None) but MUST show (Any)."""
|
||||||
html = str(FilterBar())
|
html = str(FilterBar())
|
||||||
status_start = html.find('data-name="status"')
|
status_start = html.find('name="status"')
|
||||||
platform_start = html.find('data-name="platform"')
|
platform_start = html.find('name="platform"')
|
||||||
status_section = html[status_start:platform_start]
|
status_section = html[status_start:platform_start]
|
||||||
# Must have (Any) — always available
|
# Must have (Any) — always available
|
||||||
assert "(Any)" in status_section
|
assert "(Any)" in status_section
|
||||||
@@ -618,7 +618,7 @@ class TestFilterBarRendering:
|
|||||||
def test_platform_has_modifiers(self):
|
def test_platform_has_modifiers(self):
|
||||||
"""Nullable ForeignKey fields should show (Any)/(None)."""
|
"""Nullable ForeignKey fields should show (Any)/(None)."""
|
||||||
html = str(FilterBar())
|
html = str(FilterBar())
|
||||||
platform_start = html.find('data-name="platform"')
|
platform_start = html.find('name="platform"')
|
||||||
platform_section = html[platform_start:]
|
platform_section = html[platform_start:]
|
||||||
# Should have at least one modifier option
|
# Should have at least one modifier option
|
||||||
assert "(Any)" in platform_section or "(None)" in platform_section
|
assert "(Any)" in platform_section or "(None)" in platform_section
|
||||||
|
|||||||
@@ -133,14 +133,16 @@ class RealComponentMediaTest(unittest.TestCase):
|
|||||||
from common.components import SearchSelect
|
from common.components import SearchSelect
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
collect_media(SearchSelect(name="games")).js, ("dist/search_select.js",)
|
collect_media(SearchSelect(name="games")).js,
|
||||||
|
("dist/elements/search-select.js",),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_filter_select_declares_its_script(self):
|
def test_filter_select_declares_its_script(self):
|
||||||
from common.components import FilterSelect
|
from common.components import FilterSelect
|
||||||
|
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"dist/search_select.js", collect_media(FilterSelect(field_name="type")).js
|
"dist/elements/search-select.js",
|
||||||
|
collect_media(FilterSelect(field_name="type")).js,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_date_range_picker_declares_its_script(self):
|
def test_date_range_picker_declares_its_script(self):
|
||||||
@@ -149,7 +151,7 @@ class RealComponentMediaTest(unittest.TestCase):
|
|||||||
media = collect_media(
|
media = collect_media(
|
||||||
DateRangePicker(label="Played", input_name_prefix="played")
|
DateRangePicker(label="Played", input_name_prefix="played")
|
||||||
)
|
)
|
||||||
self.assertEqual(media.js, ("dist/date_range_picker.js",))
|
self.assertEqual(media.js, ("dist/elements/date-range-picker.js",))
|
||||||
|
|
||||||
def test_range_slider_declares_its_script(self):
|
def test_range_slider_declares_its_script(self):
|
||||||
from common.components.filters import RangeSlider
|
from common.components.filters import RangeSlider
|
||||||
@@ -159,7 +161,7 @@ class RealComponentMediaTest(unittest.TestCase):
|
|||||||
label="Year", input_name_prefix="year", range_min=2000, range_max=2025
|
label="Year", input_name_prefix="year", range_min=2000, range_max=2025
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertEqual(media.js, ("dist/range_slider.js",))
|
self.assertEqual(media.js, ("dist/elements/range-slider.js",))
|
||||||
|
|
||||||
def test_filter_bar_collects_chrome_and_widget_media(self):
|
def test_filter_bar_collects_chrome_and_widget_media(self):
|
||||||
"""A FilterBar's media merges its own chrome script with the scripts that
|
"""A FilterBar's media merges its own chrome script with the scripts that
|
||||||
@@ -169,9 +171,9 @@ class RealComponentMediaTest(unittest.TestCase):
|
|||||||
from common.components import FilterBar
|
from common.components import FilterBar
|
||||||
|
|
||||||
media = collect_media(FilterBar())
|
media = collect_media(FilterBar())
|
||||||
self.assertIn("dist/filter_bar.js", media.js)
|
self.assertIn("dist/elements/filter-bar.js", media.js)
|
||||||
self.assertIn("dist/search_select.js", media.js)
|
self.assertIn("dist/elements/search-select.js", media.js)
|
||||||
self.assertIn("dist/range_slider.js", media.js)
|
self.assertIn("dist/elements/range-slider.js", media.js)
|
||||||
|
|
||||||
|
|
||||||
class HtpyStyleSugarTest(unittest.TestCase):
|
class HtpyStyleSugarTest(unittest.TestCase):
|
||||||
|
|||||||
@@ -63,9 +63,9 @@ class RenderedPagesTest(TestCase):
|
|||||||
"""The games list view passes no scripts= argument; the filter bar's
|
"""The games list view passes no scripts= argument; the filter bar's
|
||||||
components declare their JS and Page() collects it."""
|
components declare their JS and Page() collects it."""
|
||||||
html = self.get("games:list_games").content.decode()
|
html = self.get("games:list_games").content.decode()
|
||||||
self.assertIn("js/dist/filter_bar.js", html)
|
self.assertIn("js/dist/elements/filter-bar.js", html)
|
||||||
self.assertIn("js/dist/search_select.js", html)
|
self.assertIn("js/dist/elements/search-select.js", html)
|
||||||
self.assertIn("js/dist/range_slider.js", html)
|
self.assertIn("js/dist/elements/range-slider.js", html)
|
||||||
|
|
||||||
def test_stats_page_auto_loads_datepicker(self):
|
def test_stats_page_auto_loads_datepicker(self):
|
||||||
"""YearPicker declares the datepicker UMD bundle as media; the stats
|
"""YearPicker declares the datepicker UMD bundle as media; the stats
|
||||||
|
|||||||
+18
-19
@@ -64,10 +64,10 @@ class SearchSelectComponentTest(unittest.TestCase):
|
|||||||
name="games", search_url="/api/games/search", multi_select=True
|
name="games", search_url="/api/games/search", multi_select=True
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertIn("data-search-select", html)
|
self.assertIn("<search-select", html)
|
||||||
self.assertIn('data-name="games"', html)
|
self.assertIn('name="games"', html)
|
||||||
self.assertIn('data-search-url="/api/games/search"', html)
|
self.assertIn('search-url="/api/games/search"', html)
|
||||||
self.assertIn('data-multi="true"', html)
|
self.assertIn('multi="true"', html)
|
||||||
|
|
||||||
def test_multi_selected_renders_pills_and_hidden_inputs(self):
|
def test_multi_selected_renders_pills_and_hidden_inputs(self):
|
||||||
html = str(
|
html = str(
|
||||||
@@ -80,9 +80,8 @@ class SearchSelectComponentTest(unittest.TestCase):
|
|||||||
self.assertIn("data-pill", html)
|
self.assertIn("data-pill", html)
|
||||||
self.assertIn('<input name="games" value="7" type="hidden">', html)
|
self.assertIn('<input name="games" value="7" type="hidden">', html)
|
||||||
self.assertIn('data-platform="2"', html)
|
self.assertIn('data-platform="2"', html)
|
||||||
# exactly one submitted value (the hidden input) — the search box has no
|
# two occurrences: the <search-select name="games"> tag + the hidden input.
|
||||||
# name. The leading space avoids matching the container's data-name.
|
self.assertEqual(html.count(' name="games"'), 2)
|
||||||
self.assertEqual(html.count(' name="games"'), 1)
|
|
||||||
|
|
||||||
def test_single_selected_has_no_pill_and_value_in_search_box(self):
|
def test_single_selected_has_no_pill_and_value_in_search_box(self):
|
||||||
html = str(
|
html = str(
|
||||||
@@ -96,13 +95,13 @@ class SearchSelectComponentTest(unittest.TestCase):
|
|||||||
self.assertIn('value="Game A"', html)
|
self.assertIn('value="Game A"', html)
|
||||||
# the value is still submitted via a lone hidden input
|
# the value is still submitted via a lone hidden input
|
||||||
self.assertIn('<input name="games" value="7" type="hidden">', html)
|
self.assertIn('<input name="games" value="7" type="hidden">', html)
|
||||||
self.assertEqual(html.count(' name="games"'), 1)
|
self.assertEqual(html.count(' name="games"'), 2)
|
||||||
|
|
||||||
def test_search_box_has_no_name(self):
|
def test_search_box_has_no_name(self):
|
||||||
html = str(SearchSelect(name="games"))
|
html = str(SearchSelect(name="games"))
|
||||||
self.assertIn("data-search-select-search", html)
|
self.assertIn("data-search-select-search", html)
|
||||||
# container exposes data-name, never a submittable name on the search box
|
# <search-select name="games"> is the tag; the search box carries no name
|
||||||
self.assertEqual(html.count(' name="games"'), 0)
|
self.assertEqual(html.count(' name="games"'), 1)
|
||||||
|
|
||||||
def test_tuple_options_are_normalized(self):
|
def test_tuple_options_are_normalized(self):
|
||||||
html = str(SearchSelect(name="t", options=[("1", "One")]))
|
html = str(SearchSelect(name="t", options=[("1", "One")]))
|
||||||
@@ -149,11 +148,11 @@ class SearchSelectComponentTest(unittest.TestCase):
|
|||||||
def test_prefetch_attribute_and_defaults(self):
|
def test_prefetch_attribute_and_defaults(self):
|
||||||
# Default prefetch is 0 in SearchSelect
|
# Default prefetch is 0 in SearchSelect
|
||||||
html_default = str(SearchSelect(name="t"))
|
html_default = str(SearchSelect(name="t"))
|
||||||
self.assertIn('data-prefetch="0"', html_default)
|
self.assertIn('prefetch="0"', html_default)
|
||||||
|
|
||||||
# Custom prefetch is rendered
|
# Custom prefetch is rendered
|
||||||
html_custom = str(SearchSelect(name="t", prefetch=42))
|
html_custom = str(SearchSelect(name="t", prefetch=42))
|
||||||
self.assertIn('data-prefetch="42"', html_custom)
|
self.assertIn('prefetch="42"', html_custom)
|
||||||
|
|
||||||
|
|
||||||
class FilterSelectComponentTest(unittest.TestCase):
|
class FilterSelectComponentTest(unittest.TestCase):
|
||||||
@@ -164,12 +163,12 @@ class FilterSelectComponentTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_is_filter_mode_on_shared_shell(self):
|
def test_is_filter_mode_on_shared_shell(self):
|
||||||
html = str(FilterSelect(field_name="type"))
|
html = str(FilterSelect(field_name="type"))
|
||||||
# Reuses the SearchSelect shell (data-search-select) but flags filter mode.
|
# FilterSelect is a <search-select> with filter-mode="true".
|
||||||
self.assertIn("data-search-select", html)
|
self.assertIn("<search-select", html)
|
||||||
self.assertIn('data-search-select-mode="filter"', html)
|
self.assertIn('filter-mode="true"', html)
|
||||||
self.assertIn('data-name="type"', html)
|
self.assertIn('name="type"', html)
|
||||||
# No name is submitted — state is read from the DOM into the filter JSON.
|
# <search-select name="type"> carries the name; state is read from DOM into filter JSON.
|
||||||
self.assertEqual(html.count(' name="type"'), 0)
|
self.assertEqual(html.count(' name="type"'), 1)
|
||||||
|
|
||||||
def test_value_rows_have_include_exclude_buttons(self):
|
def test_value_rows_have_include_exclude_buttons(self):
|
||||||
html = str(FilterSelect(field_name="type", options=[("g", "Game")]))
|
html = str(FilterSelect(field_name="type", options=[("g", "Game")]))
|
||||||
@@ -238,7 +237,7 @@ class FilterSelectComponentTest(unittest.TestCase):
|
|||||||
self.assertIn(
|
self.assertIn(
|
||||||
'data-search-select-modifier-option="NOT_NULL"', html
|
'data-search-select-modifier-option="NOT_NULL"', html
|
||||||
) # still pinned
|
) # still pinned
|
||||||
self.assertIn('data-prefetch="20"', html)
|
self.assertIn('prefetch="20"', html)
|
||||||
|
|
||||||
def test_search_url_pills_use_resolved_labels(self):
|
def test_search_url_pills_use_resolved_labels(self):
|
||||||
# A selected value outside the fetched window still shows its label.
|
# A selected value outside the fetched window still shows its label.
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { disableElementsWhenTrue, onSwap } from "./utils.js";
|
import { disableElementsWhenTrue, onSwap } from "./utils.js";
|
||||||
import type { SearchSelectChangeDetail } from "./search_select.js";
|
import type { SearchSelectChangeDetail } from "./elements/search-select.js";
|
||||||
|
|
||||||
// Switch between a single bundle price and one price per game. The per-game
|
// Switch between a single bundle price and one price per game. The per-game
|
||||||
// inputs are the selection-fields element; this only sets the policy: the
|
// inputs are the selection-fields element; this only sets the policy: the
|
||||||
|
|||||||
@@ -1,539 +0,0 @@
|
|||||||
/**
|
|
||||||
* DateRangePicker — vanilla TypeScript implementation.
|
|
||||||
*
|
|
||||||
* Drives the DateRangePicker component (common/components/date_range_picker.py):
|
|
||||||
*
|
|
||||||
* - DateRangeField: segmented manual entry. Each date part (DD/MM/YYYY) is its
|
|
||||||
* own input; digits fill the placeholder from the right (YYYY → YYY1 → YY19
|
|
||||||
* → Y198 → 1987), full parts auto-advance to the next one, and
|
|
||||||
* Backspace/Delete reverts the active part to its placeholder.
|
|
||||||
* - DateRangeCalendar: popup month grid with a preset column and a
|
|
||||||
* Cancel / Clear / Select footer. Picking works anchor-style: the first
|
|
||||||
* pick becomes the StartDate anchor, the second pick sets the EndDate and
|
|
||||||
* moves the anchor there so further picks adjust the StartDate. Picking on
|
|
||||||
* the wrong side of the anchor clears the range and restarts from the
|
|
||||||
* clicked date.
|
|
||||||
*
|
|
||||||
* The committed value lives in the two hidden ISO inputs ({prefix}-min /
|
|
||||||
* {prefix}-max) that filter_bar.ts serializes into a DateCriterion.
|
|
||||||
*
|
|
||||||
* NB: class strings below are emitted verbatim so the Tailwind scanner picks
|
|
||||||
* them up — keep them as plain literals.
|
|
||||||
*/
|
|
||||||
import { onSwap } from "./utils.js";
|
|
||||||
|
|
||||||
type Anchor = "" | "start" | "end";
|
|
||||||
|
|
||||||
interface CalendarState {
|
|
||||||
open: boolean;
|
|
||||||
viewYear: number;
|
|
||||||
viewMonth: number;
|
|
||||||
startIso: string;
|
|
||||||
endIso: string;
|
|
||||||
// The anchor is the fixed endpoint: "start" while picking the EndDate,
|
|
||||||
// "end" once the range is complete (further picks move the StartDate).
|
|
||||||
anchor: Anchor;
|
|
||||||
hoverIso: string;
|
|
||||||
// True while showing a committed range the user has not edited yet —
|
|
||||||
// the track renders muted until the first pick.
|
|
||||||
readOnly: boolean;
|
|
||||||
refreshFromField: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
(() => {
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const WEEKDAY_LABELS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
|
|
||||||
|
|
||||||
const WEEKDAY_CLASS =
|
|
||||||
"w-8 h-6 flex items-center justify-center text-xs text-body select-none";
|
|
||||||
const DAY_BASE_CLASS =
|
|
||||||
"date-range-day w-8 h-8 flex items-center justify-center text-sm " +
|
|
||||||
"text-heading cursor-pointer hover:bg-neutral-tertiary-medium";
|
|
||||||
const DAY_ROUNDED_CLASS = "rounded-base";
|
|
||||||
const DAY_OUTSIDE_MONTH_CLASS = "opacity-40";
|
|
||||||
const DAY_SELECTED_CLASS = "bg-brand text-white hover:bg-brand-strong";
|
|
||||||
const DAY_ANCHOR_CLASS =
|
|
||||||
"bg-brand text-white ring-2 ring-inset ring-brand-strong hover:bg-brand-strong";
|
|
||||||
// The three visual states of the date range track (the days between the
|
|
||||||
// two endpoints): outlined while picking the second date, filled once both
|
|
||||||
// are picked, muted when showing an already-committed range read-only.
|
|
||||||
const TRACK_OUTLINED_CLASS = "border-y border-brand/70 bg-brand/10";
|
|
||||||
const TRACK_FILLED_CLASS = "bg-brand/30";
|
|
||||||
const TRACK_MUTED_CLASS = "bg-brand/15";
|
|
||||||
|
|
||||||
// ── Date helpers (all local-time; values are ISO YYYY-MM-DD strings) ──
|
|
||||||
|
|
||||||
function padNumber(value: number, width: number): string {
|
|
||||||
let text = String(value);
|
|
||||||
while (text.length < width) text = "0" + text;
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isoFromDate(dateObject: Date): string {
|
|
||||||
return (
|
|
||||||
padNumber(dateObject.getFullYear(), 4) +
|
|
||||||
"-" +
|
|
||||||
padNumber(dateObject.getMonth() + 1, 2) +
|
|
||||||
"-" +
|
|
||||||
padNumber(dateObject.getDate(), 2)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function dateFromIso(isoString: string): Date {
|
|
||||||
const pieces = isoString.split("-");
|
|
||||||
return new Date(
|
|
||||||
parseInt(pieces[0], 10),
|
|
||||||
parseInt(pieces[1], 10) - 1,
|
|
||||||
parseInt(pieces[2], 10)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addDays(dateObject: Date, dayCount: number): Date {
|
|
||||||
const copy = new Date(dateObject.getTime());
|
|
||||||
copy.setDate(copy.getDate() + dayCount);
|
|
||||||
return copy;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Validate a (year, month, day) triple as a real calendar date. */
|
|
||||||
function isoFromParts(year: number, month: number, day: number): string {
|
|
||||||
const candidate = new Date(year, month - 1, day);
|
|
||||||
if (
|
|
||||||
candidate.getFullYear() !== year ||
|
|
||||||
candidate.getMonth() !== month - 1 ||
|
|
||||||
candidate.getDate() !== day
|
|
||||||
) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return isoFromDate(candidate);
|
|
||||||
}
|
|
||||||
|
|
||||||
function presetRange(presetName: string): [Date, Date] | null {
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
const yesterday = addDays(today, -1);
|
|
||||||
const year = today.getFullYear();
|
|
||||||
const month = today.getMonth();
|
|
||||||
switch (presetName) {
|
|
||||||
case "today":
|
|
||||||
return [today, today];
|
|
||||||
case "yesterday":
|
|
||||||
return [yesterday, yesterday];
|
|
||||||
case "last_7_days":
|
|
||||||
return [addDays(today, -6), today];
|
|
||||||
case "last_30_days":
|
|
||||||
return [addDays(today, -29), today];
|
|
||||||
case "this_month":
|
|
||||||
return [new Date(year, month, 1), new Date(year, month + 1, 0)];
|
|
||||||
case "last_month":
|
|
||||||
return [new Date(year, month - 1, 1), new Date(year, month, 0)];
|
|
||||||
case "this_year":
|
|
||||||
return [new Date(year, 0, 1), new Date(year, 11, 31)];
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DateRangeField: segmented manual entry ──────────────────────────────
|
|
||||||
|
|
||||||
function segmentBuffer(segment: HTMLInputElement): string {
|
|
||||||
return segment.dataset.typedDigits || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSegmentBuffer(segment: HTMLInputElement, buffer: string): void {
|
|
||||||
segment.dataset.typedDigits = buffer;
|
|
||||||
if (buffer === "") {
|
|
||||||
segment.value = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const placeholder = segment.getAttribute("placeholder") ?? "";
|
|
||||||
// Fill the placeholder from the right: typing 19 into YYYY shows YY19.
|
|
||||||
segment.value = placeholder.slice(0, placeholder.length - buffer.length) + buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
function segmentsForSide(picker: HTMLElement, side: string): HTMLInputElement[] {
|
|
||||||
return Array.from(
|
|
||||||
picker.querySelectorAll<HTMLInputElement>(`input[data-date-side="${side}"]`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Recompute one hidden ISO input from its side's segment buffers. */
|
|
||||||
function syncHiddenFromSegments(picker: HTMLElement, side: string): boolean {
|
|
||||||
const hidden = picker.querySelector<HTMLInputElement>(
|
|
||||||
`input[data-date-range-hidden="${side}"]`
|
|
||||||
)!;
|
|
||||||
const partValues: Record<string, string> = {};
|
|
||||||
let complete = true;
|
|
||||||
segmentsForSide(picker, side).forEach((segment) => {
|
|
||||||
const buffer = segmentBuffer(segment);
|
|
||||||
if (buffer.length !== parseInt(segment.getAttribute("maxlength") ?? "", 10)) {
|
|
||||||
complete = false;
|
|
||||||
}
|
|
||||||
partValues[segment.dataset.datePart ?? ""] = buffer;
|
|
||||||
});
|
|
||||||
const previousValue = hidden.value;
|
|
||||||
if (complete) {
|
|
||||||
hidden.value = isoFromParts(
|
|
||||||
parseInt(partValues.year, 10),
|
|
||||||
parseInt(partValues.month, 10),
|
|
||||||
parseInt(partValues.day, 10)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
hidden.value = "";
|
|
||||||
}
|
|
||||||
return hidden.value !== previousValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Push an ISO value (or "") into a side's segments and hidden input. */
|
|
||||||
function setSideValue(picker: HTMLElement, side: string, isoString: string): void {
|
|
||||||
const hidden = picker.querySelector<HTMLInputElement>(
|
|
||||||
`input[data-date-range-hidden="${side}"]`
|
|
||||||
)!;
|
|
||||||
hidden.value = isoString;
|
|
||||||
let partValues: Record<string, string> = { year: "", month: "", day: "" };
|
|
||||||
if (isoString) {
|
|
||||||
const pieces = isoString.split("-");
|
|
||||||
partValues = { year: pieces[0], month: pieces[1], day: pieces[2] };
|
|
||||||
}
|
|
||||||
segmentsForSide(picker, side).forEach((segment) => {
|
|
||||||
setSegmentBuffer(segment, partValues[segment.dataset.datePart ?? ""]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function initField(picker: HTMLElement, calendarState: CalendarState): void {
|
|
||||||
const field = picker.querySelector<HTMLElement>("[data-date-range-field]")!;
|
|
||||||
const segments = Array.from(
|
|
||||||
picker.querySelectorAll<HTMLInputElement>("input[data-date-part]")
|
|
||||||
);
|
|
||||||
|
|
||||||
// Adopt server-rendered values (prefilled filter) as typed buffers.
|
|
||||||
segments.forEach((segment) => {
|
|
||||||
if (segment.value) setSegmentBuffer(segment, segment.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clicking anywhere in the container that is not a date part activates
|
|
||||||
// the first date part.
|
|
||||||
field.addEventListener("mousedown", (event) => {
|
|
||||||
const target = event.target as Element;
|
|
||||||
if (target.closest("input[data-date-part]")) return;
|
|
||||||
if (target.closest("[data-date-range-calendar-toggle]")) return;
|
|
||||||
event.preventDefault();
|
|
||||||
segments[0].focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
segments.forEach((segment, segmentIndex) => {
|
|
||||||
segment.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key === "Tab") return; // native Tab / Shift+Tab navigation
|
|
||||||
if (event.key === "Enter") return; // let the filter form submit
|
|
||||||
if (event.key === "Backspace" || event.key === "Delete") {
|
|
||||||
event.preventDefault();
|
|
||||||
setSegmentBuffer(segment, "");
|
|
||||||
syncHiddenFromSegments(picker, segment.dataset.dateSide ?? "");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
|
||||||
event.preventDefault();
|
|
||||||
if (!/^[0-9]$/.test(event.key)) return; // only numbers can be typed
|
|
||||||
const maximumLength = parseInt(segment.getAttribute("maxlength") ?? "", 10);
|
|
||||||
let buffer = segmentBuffer(segment);
|
|
||||||
// Typing into an already-full part starts it over.
|
|
||||||
buffer = buffer.length >= maximumLength ? event.key : buffer + event.key;
|
|
||||||
setSegmentBuffer(segment, buffer);
|
|
||||||
syncHiddenFromSegments(picker, segment.dataset.dateSide ?? "");
|
|
||||||
if (buffer.length === maximumLength && segmentIndex + 1 < segments.length) {
|
|
||||||
segments[segmentIndex + 1].focus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Swallow any input that bypassed keydown (e.g. IME/paste).
|
|
||||||
segment.addEventListener("input", () => {
|
|
||||||
setSegmentBuffer(segment, segmentBuffer(segment));
|
|
||||||
});
|
|
||||||
segment.addEventListener("focus", () => {
|
|
||||||
if (calendarState) calendarState.refreshFromField();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DateRangeCalendar: popup month grid ────────────────────────────────
|
|
||||||
|
|
||||||
function createCalendarState(picker: HTMLElement): CalendarState {
|
|
||||||
const popup = picker.querySelector<HTMLElement>("[data-date-range-calendar]")!;
|
|
||||||
const grid = popup.querySelector<HTMLElement>("[data-date-range-grid]")!;
|
|
||||||
const monthLabel = popup.querySelector<HTMLElement>("[data-date-range-month-label]")!;
|
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
|
|
||||||
function hiddenValue(side: string): string {
|
|
||||||
return picker.querySelector<HTMLInputElement>(
|
|
||||||
`input[data-date-range-hidden="${side}"]`
|
|
||||||
)!.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const state: CalendarState = {
|
|
||||||
open: false,
|
|
||||||
viewYear: today.getFullYear(),
|
|
||||||
viewMonth: today.getMonth(),
|
|
||||||
startIso: "",
|
|
||||||
endIso: "",
|
|
||||||
anchor: "",
|
|
||||||
hoverIso: "",
|
|
||||||
readOnly: false,
|
|
||||||
refreshFromField() {
|
|
||||||
if (state.open) return;
|
|
||||||
state.startIso = hiddenValue("min");
|
|
||||||
state.endIso = hiddenValue("max");
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function syncSelectionToField(): void {
|
|
||||||
setSideValue(picker, "min", state.startIso);
|
|
||||||
setSideValue(picker, "max", state.endIso);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPopup(): void {
|
|
||||||
state.startIso = hiddenValue("min");
|
|
||||||
state.endIso = hiddenValue("max");
|
|
||||||
state.anchor = state.startIso && state.endIso ? "end" : state.startIso ? "start" : "";
|
|
||||||
state.readOnly = Boolean(state.startIso && state.endIso);
|
|
||||||
state.hoverIso = "";
|
|
||||||
const focusDate = state.startIso ? dateFromIso(state.startIso) : new Date();
|
|
||||||
state.viewYear = focusDate.getFullYear();
|
|
||||||
state.viewMonth = focusDate.getMonth();
|
|
||||||
state.open = true;
|
|
||||||
popup.classList.remove("hidden");
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closePopup(): void {
|
|
||||||
state.open = false;
|
|
||||||
state.hoverIso = "";
|
|
||||||
popup.classList.add("hidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSelection(): void {
|
|
||||||
state.startIso = "";
|
|
||||||
state.endIso = "";
|
|
||||||
state.anchor = "";
|
|
||||||
state.hoverIso = "";
|
|
||||||
state.readOnly = false;
|
|
||||||
syncSelectionToField();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Anchor-style picking:
|
|
||||||
* - no selection: the pick becomes the StartDate anchor
|
|
||||||
* - anchor=start (picking EndDate): a pick on/after the StartDate
|
|
||||||
* completes the range and moves the anchor to the EndDate; a pick
|
|
||||||
* before it clears the range and restarts
|
|
||||||
* - anchor=end (adjusting StartDate): a pick on/before the EndDate
|
|
||||||
* moves the StartDate (extend/shorten); a pick after it clears the
|
|
||||||
* range and restarts from the clicked date
|
|
||||||
*/
|
|
||||||
function pickDate(isoString: string): void {
|
|
||||||
state.readOnly = false;
|
|
||||||
if (!state.startIso) {
|
|
||||||
state.startIso = isoString;
|
|
||||||
state.anchor = "start";
|
|
||||||
} else if (state.anchor === "start" && !state.endIso) {
|
|
||||||
if (isoString >= state.startIso) {
|
|
||||||
state.endIso = isoString;
|
|
||||||
state.anchor = "end";
|
|
||||||
} else {
|
|
||||||
state.startIso = isoString;
|
|
||||||
state.endIso = "";
|
|
||||||
state.anchor = "start";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (isoString <= state.endIso) {
|
|
||||||
state.startIso = isoString;
|
|
||||||
} else {
|
|
||||||
state.startIso = isoString;
|
|
||||||
state.endIso = "";
|
|
||||||
state.anchor = "start";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
syncSelectionToField();
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyPreset(presetName: string): void {
|
|
||||||
const range = presetRange(presetName);
|
|
||||||
if (!range) return;
|
|
||||||
state.startIso = isoFromDate(range[0]);
|
|
||||||
state.endIso = isoFromDate(range[1]);
|
|
||||||
state.anchor = "end";
|
|
||||||
state.readOnly = false;
|
|
||||||
state.viewYear = range[0].getFullYear();
|
|
||||||
state.viewMonth = range[0].getMonth();
|
|
||||||
syncSelectionToField();
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The (inclusive-exclusive of endpoints) track between the two range
|
|
||||||
* ends; while picking the second date the hovered day acts as the
|
|
||||||
* provisional other end. */
|
|
||||||
function trackBounds(): [string, string, string] | null {
|
|
||||||
if (state.startIso && state.endIso) {
|
|
||||||
return [
|
|
||||||
state.startIso,
|
|
||||||
state.endIso,
|
|
||||||
state.readOnly ? TRACK_MUTED_CLASS : TRACK_FILLED_CLASS,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (state.startIso && state.hoverIso && state.hoverIso !== state.startIso) {
|
|
||||||
const lower = state.hoverIso < state.startIso ? state.hoverIso : state.startIso;
|
|
||||||
const upper = state.hoverIso < state.startIso ? state.startIso : state.hoverIso;
|
|
||||||
return [lower, upper, TRACK_OUTLINED_CLASS];
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function dayCellClass(isoString: string, inViewMonth: boolean): string {
|
|
||||||
const classes = [DAY_BASE_CLASS];
|
|
||||||
const isStart = isoString === state.startIso;
|
|
||||||
const isEnd = isoString === state.endIso;
|
|
||||||
const isAnchor =
|
|
||||||
(state.anchor === "start" && isStart) || (state.anchor === "end" && isEnd);
|
|
||||||
const track = trackBounds();
|
|
||||||
const inTrack = track !== null && isoString > track[0] && isoString < track[1];
|
|
||||||
if (inTrack) {
|
|
||||||
classes.push(track![2]);
|
|
||||||
} else {
|
|
||||||
classes.push(DAY_ROUNDED_CLASS);
|
|
||||||
}
|
|
||||||
if (isAnchor && !state.readOnly) {
|
|
||||||
classes.push(DAY_ANCHOR_CLASS);
|
|
||||||
} else if (isStart || isEnd) {
|
|
||||||
classes.push(DAY_SELECTED_CLASS);
|
|
||||||
} else if (!inViewMonth) {
|
|
||||||
classes.push(DAY_OUTSIDE_MONTH_CLASS);
|
|
||||||
}
|
|
||||||
return classes.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(): void {
|
|
||||||
monthLabel.textContent = new Date(
|
|
||||||
state.viewYear,
|
|
||||||
state.viewMonth,
|
|
||||||
1
|
|
||||||
).toLocaleDateString(undefined, { month: "long", year: "numeric" });
|
|
||||||
|
|
||||||
grid.textContent = "";
|
|
||||||
WEEKDAY_LABELS.forEach((weekdayLabel) => {
|
|
||||||
const headerCell = document.createElement("span");
|
|
||||||
headerCell.className = WEEKDAY_CLASS;
|
|
||||||
headerCell.textContent = weekdayLabel;
|
|
||||||
grid.appendChild(headerCell);
|
|
||||||
});
|
|
||||||
|
|
||||||
const firstOfMonth = new Date(state.viewYear, state.viewMonth, 1);
|
|
||||||
// Monday-first offset of the leading overflow days.
|
|
||||||
const leadingDays = (firstOfMonth.getDay() + 6) % 7;
|
|
||||||
let cellDate = addDays(firstOfMonth, -leadingDays);
|
|
||||||
for (let cellIndex = 0; cellIndex < 42; cellIndex++) {
|
|
||||||
const isoString = isoFromDate(cellDate);
|
|
||||||
const dayButton = document.createElement("button");
|
|
||||||
dayButton.type = "button";
|
|
||||||
dayButton.setAttribute("data-date", isoString);
|
|
||||||
dayButton.className = dayCellClass(
|
|
||||||
isoString,
|
|
||||||
cellDate.getMonth() === state.viewMonth
|
|
||||||
);
|
|
||||||
dayButton.textContent = String(cellDate.getDate());
|
|
||||||
grid.appendChild(dayButton);
|
|
||||||
cellDate = addDays(cellDate, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Wiring ──
|
|
||||||
picker
|
|
||||||
.querySelector<HTMLElement>("[data-date-range-calendar-toggle]")!
|
|
||||||
.addEventListener("click", () => {
|
|
||||||
if (state.open) closePopup();
|
|
||||||
else openPopup();
|
|
||||||
});
|
|
||||||
|
|
||||||
grid.addEventListener("click", (event) => {
|
|
||||||
const dayButton = (event.target as Element).closest("button[data-date]");
|
|
||||||
if (dayButton) pickDate(dayButton.getAttribute("data-date") ?? "");
|
|
||||||
});
|
|
||||||
|
|
||||||
grid.addEventListener("mouseover", (event) => {
|
|
||||||
if (!state.startIso || state.endIso) return;
|
|
||||||
const dayButton = (event.target as Element).closest("button[data-date]");
|
|
||||||
if (!dayButton) return;
|
|
||||||
const hoveredIso = dayButton.getAttribute("data-date") ?? "";
|
|
||||||
if (hoveredIso === state.hoverIso) return;
|
|
||||||
state.hoverIso = hoveredIso;
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
|
|
||||||
popup
|
|
||||||
.querySelector<HTMLElement>("[data-date-range-prev]")!
|
|
||||||
.addEventListener("click", () => {
|
|
||||||
state.viewMonth -= 1;
|
|
||||||
if (state.viewMonth < 0) {
|
|
||||||
state.viewMonth = 11;
|
|
||||||
state.viewYear -= 1;
|
|
||||||
}
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
|
|
||||||
popup
|
|
||||||
.querySelector<HTMLElement>("[data-date-range-next]")!
|
|
||||||
.addEventListener("click", () => {
|
|
||||||
state.viewMonth += 1;
|
|
||||||
if (state.viewMonth > 11) {
|
|
||||||
state.viewMonth = 0;
|
|
||||||
state.viewYear += 1;
|
|
||||||
}
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
|
|
||||||
popup.querySelectorAll<HTMLElement>("[data-date-range-preset]").forEach((button) => {
|
|
||||||
button.addEventListener("click", () => {
|
|
||||||
applyPreset(button.getAttribute("data-date-range-preset") ?? "");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cancel: close the popup and clear the selected dates.
|
|
||||||
popup
|
|
||||||
.querySelector<HTMLElement>("[data-date-range-cancel]")!
|
|
||||||
.addEventListener("click", () => {
|
|
||||||
clearSelection();
|
|
||||||
closePopup();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear: clear the selected dates but keep the popup open.
|
|
||||||
popup
|
|
||||||
.querySelector<HTMLElement>("[data-date-range-clear]")!
|
|
||||||
.addEventListener("click", () => {
|
|
||||||
clearSelection();
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Select: close the popup, keeping the selected dates.
|
|
||||||
popup
|
|
||||||
.querySelector<HTMLElement>("[data-date-range-select]")!
|
|
||||||
.addEventListener("click", () => {
|
|
||||||
closePopup();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key === "Escape" && state.open) closePopup();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("mousedown", (event) => {
|
|
||||||
if (state.open && !picker.contains(event.target as Node)) closePopup();
|
|
||||||
});
|
|
||||||
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
function initPicker(picker: HTMLElement): void {
|
|
||||||
const calendarState = createCalendarState(picker);
|
|
||||||
initField(picker, calendarState);
|
|
||||||
}
|
|
||||||
|
|
||||||
onSwap("[data-date-range-picker]", (picker) => initPicker(picker as HTMLElement));
|
|
||||||
})();
|
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
/**
|
||||||
|
* DateRangePicker — custom element wrapping the vanilla TS implementation.
|
||||||
|
*
|
||||||
|
* Drives the DateRangePicker component (common/components/date_range_picker.py):
|
||||||
|
*
|
||||||
|
* - DateRangeField: segmented manual entry. Each date part (DD/MM/YYYY) is its
|
||||||
|
* own input; digits fill the placeholder from the right (YYYY → YYY1 → YY19
|
||||||
|
* → Y198 → 1987), full parts auto-advance to the next one, and
|
||||||
|
* Backspace/Delete reverts the active part to its placeholder.
|
||||||
|
* - DateRangeCalendar: popup month grid with a preset column and a
|
||||||
|
* Cancel / Clear / Select footer. Picking works anchor-style: the first
|
||||||
|
* pick becomes the StartDate anchor, the second pick sets the EndDate and
|
||||||
|
* moves the anchor there so further picks adjust the StartDate. Picking on
|
||||||
|
* the wrong side of the anchor clears the range and restarts from the
|
||||||
|
* clicked date.
|
||||||
|
*
|
||||||
|
* The committed value lives in the two hidden ISO inputs ({prefix}-min /
|
||||||
|
* {prefix}-max) that filter_bar.ts serializes into a DateCriterion.
|
||||||
|
*
|
||||||
|
* NB: class strings below are emitted verbatim so the Tailwind scanner picks
|
||||||
|
* them up — keep them as plain literals.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Anchor = "" | "start" | "end";
|
||||||
|
|
||||||
|
interface CalendarState {
|
||||||
|
open: boolean;
|
||||||
|
viewYear: number;
|
||||||
|
viewMonth: number;
|
||||||
|
startIso: string;
|
||||||
|
endIso: string;
|
||||||
|
// The anchor is the fixed endpoint: "start" while picking the EndDate,
|
||||||
|
// "end" once the range is complete (further picks move the StartDate).
|
||||||
|
anchor: Anchor;
|
||||||
|
hoverIso: string;
|
||||||
|
// True while showing a committed range the user has not edited yet —
|
||||||
|
// the track renders muted until the first pick.
|
||||||
|
readOnly: boolean;
|
||||||
|
refreshFromField: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEKDAY_LABELS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
|
||||||
|
|
||||||
|
const WEEKDAY_CLASS =
|
||||||
|
"w-8 h-6 flex items-center justify-center text-xs text-body select-none";
|
||||||
|
const DAY_BASE_CLASS =
|
||||||
|
"date-range-day w-8 h-8 flex items-center justify-center text-sm " +
|
||||||
|
"text-heading cursor-pointer hover:bg-neutral-tertiary-medium";
|
||||||
|
const DAY_ROUNDED_CLASS = "rounded-base";
|
||||||
|
const DAY_OUTSIDE_MONTH_CLASS = "opacity-40";
|
||||||
|
const DAY_SELECTED_CLASS = "bg-brand text-white hover:bg-brand-strong";
|
||||||
|
const DAY_ANCHOR_CLASS =
|
||||||
|
"bg-brand text-white ring-2 ring-inset ring-brand-strong hover:bg-brand-strong";
|
||||||
|
// The three visual states of the date range track (the days between the
|
||||||
|
// two endpoints): outlined while picking the second date, filled once both
|
||||||
|
// are picked, muted when showing an already-committed range read-only.
|
||||||
|
const TRACK_OUTLINED_CLASS = "border-y border-brand/70 bg-brand/10";
|
||||||
|
const TRACK_FILLED_CLASS = "bg-brand/30";
|
||||||
|
const TRACK_MUTED_CLASS = "bg-brand/15";
|
||||||
|
|
||||||
|
// ── Date helpers (all local-time; values are ISO YYYY-MM-DD strings) ──
|
||||||
|
|
||||||
|
function padNumber(value: number, width: number): string {
|
||||||
|
let text = String(value);
|
||||||
|
while (text.length < width) text = "0" + text;
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoFromDate(dateObject: Date): string {
|
||||||
|
return (
|
||||||
|
padNumber(dateObject.getFullYear(), 4) +
|
||||||
|
"-" +
|
||||||
|
padNumber(dateObject.getMonth() + 1, 2) +
|
||||||
|
"-" +
|
||||||
|
padNumber(dateObject.getDate(), 2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateFromIso(isoString: string): Date {
|
||||||
|
const pieces = isoString.split("-");
|
||||||
|
return new Date(
|
||||||
|
parseInt(pieces[0], 10),
|
||||||
|
parseInt(pieces[1], 10) - 1,
|
||||||
|
parseInt(pieces[2], 10)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(dateObject: Date, dayCount: number): Date {
|
||||||
|
const copy = new Date(dateObject.getTime());
|
||||||
|
copy.setDate(copy.getDate() + dayCount);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate a (year, month, day) triple as a real calendar date. */
|
||||||
|
function isoFromParts(year: number, month: number, day: number): string {
|
||||||
|
const candidate = new Date(year, month - 1, day);
|
||||||
|
if (
|
||||||
|
candidate.getFullYear() !== year ||
|
||||||
|
candidate.getMonth() !== month - 1 ||
|
||||||
|
candidate.getDate() !== day
|
||||||
|
) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return isoFromDate(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function presetRange(presetName: string): [Date, Date] | null {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const yesterday = addDays(today, -1);
|
||||||
|
const year = today.getFullYear();
|
||||||
|
const month = today.getMonth();
|
||||||
|
switch (presetName) {
|
||||||
|
case "today":
|
||||||
|
return [today, today];
|
||||||
|
case "yesterday":
|
||||||
|
return [yesterday, yesterday];
|
||||||
|
case "last_7_days":
|
||||||
|
return [addDays(today, -6), today];
|
||||||
|
case "last_30_days":
|
||||||
|
return [addDays(today, -29), today];
|
||||||
|
case "this_month":
|
||||||
|
return [new Date(year, month, 1), new Date(year, month + 1, 0)];
|
||||||
|
case "last_month":
|
||||||
|
return [new Date(year, month - 1, 1), new Date(year, month, 0)];
|
||||||
|
case "this_year":
|
||||||
|
return [new Date(year, 0, 1), new Date(year, 11, 31)];
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DateRangeField: segmented manual entry ──────────────────────────────
|
||||||
|
|
||||||
|
function segmentBuffer(segment: HTMLInputElement): string {
|
||||||
|
return segment.dataset.typedDigits || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSegmentBuffer(segment: HTMLInputElement, buffer: string): void {
|
||||||
|
segment.dataset.typedDigits = buffer;
|
||||||
|
if (buffer === "") {
|
||||||
|
segment.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const placeholder = segment.getAttribute("placeholder") ?? "";
|
||||||
|
// Fill the placeholder from the right: typing 19 into YYYY shows YY19.
|
||||||
|
segment.value = placeholder.slice(0, placeholder.length - buffer.length) + buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentsForSide(picker: HTMLElement, side: string): HTMLInputElement[] {
|
||||||
|
return Array.from(
|
||||||
|
picker.querySelectorAll<HTMLInputElement>(`input[data-date-side="${side}"]`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recompute one hidden ISO input from its side's segment buffers. */
|
||||||
|
function syncHiddenFromSegments(picker: HTMLElement, side: string): boolean {
|
||||||
|
const hidden = picker.querySelector<HTMLInputElement>(
|
||||||
|
`input[data-date-range-hidden="${side}"]`
|
||||||
|
)!;
|
||||||
|
const partValues: Record<string, string> = {};
|
||||||
|
let complete = true;
|
||||||
|
segmentsForSide(picker, side).forEach((segment) => {
|
||||||
|
const buffer = segmentBuffer(segment);
|
||||||
|
if (buffer.length !== parseInt(segment.getAttribute("maxlength") ?? "", 10)) {
|
||||||
|
complete = false;
|
||||||
|
}
|
||||||
|
partValues[segment.dataset.datePart ?? ""] = buffer;
|
||||||
|
});
|
||||||
|
const previousValue = hidden.value;
|
||||||
|
if (complete) {
|
||||||
|
hidden.value = isoFromParts(
|
||||||
|
parseInt(partValues.year, 10),
|
||||||
|
parseInt(partValues.month, 10),
|
||||||
|
parseInt(partValues.day, 10)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
hidden.value = "";
|
||||||
|
}
|
||||||
|
return hidden.value !== previousValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push an ISO value (or "") into a side's segments and hidden input. */
|
||||||
|
function setSideValue(picker: HTMLElement, side: string, isoString: string): void {
|
||||||
|
const hidden = picker.querySelector<HTMLInputElement>(
|
||||||
|
`input[data-date-range-hidden="${side}"]`
|
||||||
|
)!;
|
||||||
|
hidden.value = isoString;
|
||||||
|
let partValues: Record<string, string> = { year: "", month: "", day: "" };
|
||||||
|
if (isoString) {
|
||||||
|
const pieces = isoString.split("-");
|
||||||
|
partValues = { year: pieces[0], month: pieces[1], day: pieces[2] };
|
||||||
|
}
|
||||||
|
segmentsForSide(picker, side).forEach((segment) => {
|
||||||
|
setSegmentBuffer(segment, partValues[segment.dataset.datePart ?? ""]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initField(picker: HTMLElement, calendarState: CalendarState): void {
|
||||||
|
const field = picker.querySelector<HTMLElement>("[data-date-range-field]")!;
|
||||||
|
const segments = Array.from(
|
||||||
|
picker.querySelectorAll<HTMLInputElement>("input[data-date-part]")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Adopt server-rendered values (prefilled filter) as typed buffers.
|
||||||
|
segments.forEach((segment) => {
|
||||||
|
if (segment.value) setSegmentBuffer(segment, segment.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clicking anywhere in the container that is not a date part activates
|
||||||
|
// the first date part.
|
||||||
|
field.addEventListener("mousedown", (event) => {
|
||||||
|
const target = event.target as Element;
|
||||||
|
if (target.closest("input[data-date-part]")) return;
|
||||||
|
if (target.closest("[data-date-range-calendar-toggle]")) return;
|
||||||
|
event.preventDefault();
|
||||||
|
segments[0].focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
segments.forEach((segment, segmentIndex) => {
|
||||||
|
segment.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Tab") return; // native Tab / Shift+Tab navigation
|
||||||
|
if (event.key === "Enter") return; // let the filter form submit
|
||||||
|
if (event.key === "Backspace" || event.key === "Delete") {
|
||||||
|
event.preventDefault();
|
||||||
|
setSegmentBuffer(segment, "");
|
||||||
|
syncHiddenFromSegments(picker, segment.dataset.dateSide ?? "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (!/^[0-9]$/.test(event.key)) return; // only numbers can be typed
|
||||||
|
const maximumLength = parseInt(segment.getAttribute("maxlength") ?? "", 10);
|
||||||
|
let buffer = segmentBuffer(segment);
|
||||||
|
// Typing into an already-full part starts it over.
|
||||||
|
buffer = buffer.length >= maximumLength ? event.key : buffer + event.key;
|
||||||
|
setSegmentBuffer(segment, buffer);
|
||||||
|
syncHiddenFromSegments(picker, segment.dataset.dateSide ?? "");
|
||||||
|
if (buffer.length === maximumLength && segmentIndex + 1 < segments.length) {
|
||||||
|
segments[segmentIndex + 1].focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Swallow any input that bypassed keydown (e.g. IME/paste).
|
||||||
|
segment.addEventListener("input", () => {
|
||||||
|
setSegmentBuffer(segment, segmentBuffer(segment));
|
||||||
|
});
|
||||||
|
segment.addEventListener("focus", () => {
|
||||||
|
if (calendarState) calendarState.refreshFromField();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DateRangeCalendar: popup month grid ────────────────────────────────
|
||||||
|
|
||||||
|
function createCalendarState(
|
||||||
|
picker: HTMLElement
|
||||||
|
): { state: CalendarState; cleanup: () => void } {
|
||||||
|
const popup = picker.querySelector<HTMLElement>("[data-date-range-calendar]")!;
|
||||||
|
const grid = popup.querySelector<HTMLElement>("[data-date-range-grid]")!;
|
||||||
|
const monthLabel = popup.querySelector<HTMLElement>("[data-date-range-month-label]")!;
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
function hiddenValue(side: string): string {
|
||||||
|
return picker.querySelector<HTMLInputElement>(
|
||||||
|
`input[data-date-range-hidden="${side}"]`
|
||||||
|
)!.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: CalendarState = {
|
||||||
|
open: false,
|
||||||
|
viewYear: today.getFullYear(),
|
||||||
|
viewMonth: today.getMonth(),
|
||||||
|
startIso: "",
|
||||||
|
endIso: "",
|
||||||
|
anchor: "",
|
||||||
|
hoverIso: "",
|
||||||
|
readOnly: false,
|
||||||
|
refreshFromField() {
|
||||||
|
if (state.open) return;
|
||||||
|
state.startIso = hiddenValue("min");
|
||||||
|
state.endIso = hiddenValue("max");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function syncSelectionToField(): void {
|
||||||
|
setSideValue(picker, "min", state.startIso);
|
||||||
|
setSideValue(picker, "max", state.endIso);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPopup(): void {
|
||||||
|
state.startIso = hiddenValue("min");
|
||||||
|
state.endIso = hiddenValue("max");
|
||||||
|
state.anchor = state.startIso && state.endIso ? "end" : state.startIso ? "start" : "";
|
||||||
|
state.readOnly = Boolean(state.startIso && state.endIso);
|
||||||
|
state.hoverIso = "";
|
||||||
|
const focusDate = state.startIso ? dateFromIso(state.startIso) : new Date();
|
||||||
|
state.viewYear = focusDate.getFullYear();
|
||||||
|
state.viewMonth = focusDate.getMonth();
|
||||||
|
state.open = true;
|
||||||
|
popup.classList.remove("hidden");
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePopup(): void {
|
||||||
|
state.open = false;
|
||||||
|
state.hoverIso = "";
|
||||||
|
popup.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection(): void {
|
||||||
|
state.startIso = "";
|
||||||
|
state.endIso = "";
|
||||||
|
state.anchor = "";
|
||||||
|
state.hoverIso = "";
|
||||||
|
state.readOnly = false;
|
||||||
|
syncSelectionToField();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anchor-style picking:
|
||||||
|
* - no selection: the pick becomes the StartDate anchor
|
||||||
|
* - anchor=start (picking EndDate): a pick on/after the StartDate
|
||||||
|
* completes the range and moves the anchor to the EndDate; a pick
|
||||||
|
* before it clears the range and restarts
|
||||||
|
* - anchor=end (adjusting StartDate): a pick on/before the EndDate
|
||||||
|
* moves the StartDate (extend/shorten); a pick after it clears the
|
||||||
|
* range and restarts from the clicked date
|
||||||
|
*/
|
||||||
|
function pickDate(isoString: string): void {
|
||||||
|
state.readOnly = false;
|
||||||
|
if (!state.startIso) {
|
||||||
|
state.startIso = isoString;
|
||||||
|
state.anchor = "start";
|
||||||
|
} else if (state.anchor === "start" && !state.endIso) {
|
||||||
|
if (isoString >= state.startIso) {
|
||||||
|
state.endIso = isoString;
|
||||||
|
state.anchor = "end";
|
||||||
|
} else {
|
||||||
|
state.startIso = isoString;
|
||||||
|
state.endIso = "";
|
||||||
|
state.anchor = "start";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isoString <= state.endIso) {
|
||||||
|
state.startIso = isoString;
|
||||||
|
} else {
|
||||||
|
state.startIso = isoString;
|
||||||
|
state.endIso = "";
|
||||||
|
state.anchor = "start";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
syncSelectionToField();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPreset(presetName: string): void {
|
||||||
|
const range = presetRange(presetName);
|
||||||
|
if (!range) return;
|
||||||
|
state.startIso = isoFromDate(range[0]);
|
||||||
|
state.endIso = isoFromDate(range[1]);
|
||||||
|
state.anchor = "end";
|
||||||
|
state.readOnly = false;
|
||||||
|
state.viewYear = range[0].getFullYear();
|
||||||
|
state.viewMonth = range[0].getMonth();
|
||||||
|
syncSelectionToField();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The (inclusive-exclusive of endpoints) track between the two range
|
||||||
|
* ends; while picking the second date the hovered day acts as the
|
||||||
|
* provisional other end. */
|
||||||
|
function trackBounds(): [string, string, string] | null {
|
||||||
|
if (state.startIso && state.endIso) {
|
||||||
|
return [
|
||||||
|
state.startIso,
|
||||||
|
state.endIso,
|
||||||
|
state.readOnly ? TRACK_MUTED_CLASS : TRACK_FILLED_CLASS,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (state.startIso && state.hoverIso && state.hoverIso !== state.startIso) {
|
||||||
|
const lower = state.hoverIso < state.startIso ? state.hoverIso : state.startIso;
|
||||||
|
const upper = state.hoverIso < state.startIso ? state.startIso : state.hoverIso;
|
||||||
|
return [lower, upper, TRACK_OUTLINED_CLASS];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayCellClass(isoString: string, inViewMonth: boolean): string {
|
||||||
|
const classes = [DAY_BASE_CLASS];
|
||||||
|
const isStart = isoString === state.startIso;
|
||||||
|
const isEnd = isoString === state.endIso;
|
||||||
|
const isAnchor =
|
||||||
|
(state.anchor === "start" && isStart) || (state.anchor === "end" && isEnd);
|
||||||
|
const track = trackBounds();
|
||||||
|
const inTrack = track !== null && isoString > track[0] && isoString < track[1];
|
||||||
|
if (inTrack) {
|
||||||
|
classes.push(track![2]);
|
||||||
|
} else {
|
||||||
|
classes.push(DAY_ROUNDED_CLASS);
|
||||||
|
}
|
||||||
|
if (isAnchor && !state.readOnly) {
|
||||||
|
classes.push(DAY_ANCHOR_CLASS);
|
||||||
|
} else if (isStart || isEnd) {
|
||||||
|
classes.push(DAY_SELECTED_CLASS);
|
||||||
|
} else if (!inViewMonth) {
|
||||||
|
classes.push(DAY_OUTSIDE_MONTH_CLASS);
|
||||||
|
}
|
||||||
|
return classes.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(): void {
|
||||||
|
monthLabel.textContent = new Date(
|
||||||
|
state.viewYear,
|
||||||
|
state.viewMonth,
|
||||||
|
1
|
||||||
|
).toLocaleDateString(undefined, { month: "long", year: "numeric" });
|
||||||
|
|
||||||
|
grid.textContent = "";
|
||||||
|
WEEKDAY_LABELS.forEach((weekdayLabel) => {
|
||||||
|
const headerCell = document.createElement("span");
|
||||||
|
headerCell.className = WEEKDAY_CLASS;
|
||||||
|
headerCell.textContent = weekdayLabel;
|
||||||
|
grid.appendChild(headerCell);
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstOfMonth = new Date(state.viewYear, state.viewMonth, 1);
|
||||||
|
// Monday-first offset of the leading overflow days.
|
||||||
|
const leadingDays = (firstOfMonth.getDay() + 6) % 7;
|
||||||
|
let cellDate = addDays(firstOfMonth, -leadingDays);
|
||||||
|
for (let cellIndex = 0; cellIndex < 42; cellIndex++) {
|
||||||
|
const isoString = isoFromDate(cellDate);
|
||||||
|
const dayButton = document.createElement("button");
|
||||||
|
dayButton.type = "button";
|
||||||
|
dayButton.setAttribute("data-date", isoString);
|
||||||
|
dayButton.className = dayCellClass(
|
||||||
|
isoString,
|
||||||
|
cellDate.getMonth() === state.viewMonth
|
||||||
|
);
|
||||||
|
dayButton.textContent = String(cellDate.getDate());
|
||||||
|
grid.appendChild(dayButton);
|
||||||
|
cellDate = addDays(cellDate, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wiring ──
|
||||||
|
picker
|
||||||
|
.querySelector<HTMLElement>("[data-date-range-calendar-toggle]")!
|
||||||
|
.addEventListener("click", () => {
|
||||||
|
if (state.open) closePopup();
|
||||||
|
else openPopup();
|
||||||
|
});
|
||||||
|
|
||||||
|
grid.addEventListener("click", (event) => {
|
||||||
|
const dayButton = (event.target as Element).closest("button[data-date]");
|
||||||
|
if (dayButton) pickDate(dayButton.getAttribute("data-date") ?? "");
|
||||||
|
});
|
||||||
|
|
||||||
|
grid.addEventListener("mouseover", (event) => {
|
||||||
|
if (!state.startIso || state.endIso) return;
|
||||||
|
const dayButton = (event.target as Element).closest("button[data-date]");
|
||||||
|
if (!dayButton) return;
|
||||||
|
const hoveredIso = dayButton.getAttribute("data-date") ?? "";
|
||||||
|
if (hoveredIso === state.hoverIso) return;
|
||||||
|
state.hoverIso = hoveredIso;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
popup
|
||||||
|
.querySelector<HTMLElement>("[data-date-range-prev]")!
|
||||||
|
.addEventListener("click", () => {
|
||||||
|
state.viewMonth -= 1;
|
||||||
|
if (state.viewMonth < 0) {
|
||||||
|
state.viewMonth = 11;
|
||||||
|
state.viewYear -= 1;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
popup
|
||||||
|
.querySelector<HTMLElement>("[data-date-range-next]")!
|
||||||
|
.addEventListener("click", () => {
|
||||||
|
state.viewMonth += 1;
|
||||||
|
if (state.viewMonth > 11) {
|
||||||
|
state.viewMonth = 0;
|
||||||
|
state.viewYear += 1;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
popup.querySelectorAll<HTMLElement>("[data-date-range-preset]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
applyPreset(button.getAttribute("data-date-range-preset") ?? "");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel: close the popup and clear the selected dates.
|
||||||
|
popup
|
||||||
|
.querySelector<HTMLElement>("[data-date-range-cancel]")!
|
||||||
|
.addEventListener("click", () => {
|
||||||
|
clearSelection();
|
||||||
|
closePopup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear: clear the selected dates but keep the popup open.
|
||||||
|
popup
|
||||||
|
.querySelector<HTMLElement>("[data-date-range-clear]")!
|
||||||
|
.addEventListener("click", () => {
|
||||||
|
clearSelection();
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select: close the popup, keeping the selected dates.
|
||||||
|
popup
|
||||||
|
.querySelector<HTMLElement>("[data-date-range-select]")!
|
||||||
|
.addEventListener("click", () => {
|
||||||
|
closePopup();
|
||||||
|
});
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent): void => {
|
||||||
|
if (event.key === "Escape" && state.open) closePopup();
|
||||||
|
};
|
||||||
|
const onMouseDown = (event: MouseEvent): void => {
|
||||||
|
if (state.open && !picker.contains(event.target as Node)) closePopup();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
document.addEventListener("mousedown", onMouseDown);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
cleanup() {
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
document.removeEventListener("mousedown", onMouseDown);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function initPicker(picker: HTMLElement): () => void {
|
||||||
|
const { state: calendarState, cleanup } = createCalendarState(picker);
|
||||||
|
initField(picker, calendarState);
|
||||||
|
return cleanup;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DateRangePickerElement extends HTMLElement {
|
||||||
|
private cleanup: (() => void) | null = null;
|
||||||
|
|
||||||
|
connectedCallback(): void {
|
||||||
|
this.cleanup = initPicker(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback(): void {
|
||||||
|
this.cleanup?.();
|
||||||
|
this.cleanup = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("date-range-picker", DateRangePickerElement);
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
/**
|
||||||
|
* FilterBar — custom element wrapping the collapsible filter bar.
|
||||||
|
*
|
||||||
|
* Handles form submission (building filter JSON + URL navigation), preset
|
||||||
|
* loading/saving, and string-filter input toggling. Props (preset_list_url,
|
||||||
|
* preset_save_url) are read from the element's typed attributes via codegen.
|
||||||
|
*/
|
||||||
|
import { readFilterBarProps } from "../generated/props.js";
|
||||||
|
import { readSearchSelect } from "./search-select.js";
|
||||||
|
|
||||||
|
interface Criterion {
|
||||||
|
value: unknown;
|
||||||
|
modifier: string;
|
||||||
|
value2?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PillEntry {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeselectableRadio extends HTMLInputElement {
|
||||||
|
wasChecked?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RangeField {
|
||||||
|
prefix: string;
|
||||||
|
key: string;
|
||||||
|
ignoreZeroZero?: boolean;
|
||||||
|
convert?: (value: number) => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function criterion(value: unknown, value2: unknown, modifier: string): Criterion {
|
||||||
|
const result: Criterion = { value, modifier };
|
||||||
|
if (value2 !== null && value2 !== undefined && value2 !== "") {
|
||||||
|
result.value2 = value2;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(form: HTMLElement, name: string): number | "" {
|
||||||
|
const element = form.querySelector<HTMLInputElement>(`[name="${name}"]`);
|
||||||
|
if (!element || element.value === "") return "";
|
||||||
|
const value = parseFloat(element.value);
|
||||||
|
return isNaN(value) ? "" : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(form: HTMLElement, name: string): string {
|
||||||
|
const element = form.querySelector<HTMLInputElement>(`[name="${name}"]`);
|
||||||
|
return element ? element.value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRangeCriterion(
|
||||||
|
valueMin: number | string,
|
||||||
|
valueMax: number | string,
|
||||||
|
): Criterion | null {
|
||||||
|
if (valueMin !== "" && valueMax !== "") return criterion(valueMin, valueMax, "BETWEEN");
|
||||||
|
if (valueMin !== "") return criterion(valueMin, null, "GREATER_THAN");
|
||||||
|
if (valueMax !== "") return criterion(valueMax, null, "LESS_THAN");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJSONAttr<T>(element: Element, attr: string): T[] {
|
||||||
|
const raw = element.getAttribute(attr);
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseUrl(): string {
|
||||||
|
return window.location.pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
function presetMode(): string {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
if (path.indexOf("session") !== -1) return "sessions";
|
||||||
|
if (path.indexOf("purchase") !== -1) return "purchases";
|
||||||
|
if (path.indexOf("device") !== -1) return "devices";
|
||||||
|
if (path.indexOf("platform") !== -1) return "platforms";
|
||||||
|
if (path.indexOf("playevent") !== -1) return "playevents";
|
||||||
|
return "games";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCsrfToken(): string {
|
||||||
|
const cookie = document.cookie
|
||||||
|
.split("; ")
|
||||||
|
.find((row) => row.startsWith("csrftoken="));
|
||||||
|
if (cookie) return cookie.split("=")[1];
|
||||||
|
const element = document.querySelector<HTMLInputElement>('input[name="csrfmiddlewaretoken"]');
|
||||||
|
return element ? element.value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFilterJSON(form: HTMLElement): Record<string, unknown> {
|
||||||
|
const filter: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
const searchInput = form.querySelector<HTMLInputElement>('[name="filter-search"]');
|
||||||
|
if (searchInput && searchInput.value.trim()) {
|
||||||
|
filter.search = { value: searchInput.value.trim(), modifier: "INCLUDES" };
|
||||||
|
}
|
||||||
|
|
||||||
|
readSearchSelect(form);
|
||||||
|
const widgets = form.querySelectorAll<HTMLElement>('search-select[filter-mode="true"]');
|
||||||
|
widgets.forEach((widget) => {
|
||||||
|
const field = widget.getAttribute("name");
|
||||||
|
if (!field) return;
|
||||||
|
const included = parseJSONAttr<PillEntry>(widget, "data-included");
|
||||||
|
const excluded = parseJSONAttr<PillEntry>(widget, "data-excluded");
|
||||||
|
const modifier = widget.getAttribute("data-modifier");
|
||||||
|
const isPresence = modifier === "NOT_NULL" || modifier === "IS_NULL";
|
||||||
|
if (isPresence) {
|
||||||
|
filter[field] = { modifier };
|
||||||
|
} else if (included.length > 0 || excluded.length > 0) {
|
||||||
|
filter[field] = {
|
||||||
|
value: included.map((item) => ({ id: item.id, label: item.label })),
|
||||||
|
excludes: excluded.map((item) => ({ id: item.id, label: item.label })),
|
||||||
|
modifier: modifier || "INCLUDES",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const textFields = [
|
||||||
|
{ name: "filter-price_currency", key: "price_currency" },
|
||||||
|
{ name: "filter-converted_currency", key: "converted_currency" },
|
||||||
|
{ name: "filter-name", key: "name" },
|
||||||
|
{ name: "filter-group", key: "group" },
|
||||||
|
{ name: "filter-playevent_note", key: "playevent_note" },
|
||||||
|
{ name: "filter-note", key: "note" },
|
||||||
|
];
|
||||||
|
textFields.forEach((textField) => {
|
||||||
|
const modifierElement = form.querySelector<HTMLInputElement>(
|
||||||
|
`[name="${textField.name}-modifier"]:checked`,
|
||||||
|
);
|
||||||
|
const modifier = modifierElement ? modifierElement.value : "EQUALS";
|
||||||
|
const isPresence = modifier === "IS_NULL" || modifier === "NOT_NULL";
|
||||||
|
if (isPresence) {
|
||||||
|
filter[textField.key] = { modifier };
|
||||||
|
} else {
|
||||||
|
const element = form.querySelector<HTMLInputElement>(`[name="${textField.name}"]`);
|
||||||
|
if (element && element.value.trim()) {
|
||||||
|
filter[textField.key] = { value: element.value.trim(), modifier };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const booleanFields = [
|
||||||
|
{ name: "filter-mastered", key: "mastered" },
|
||||||
|
{ name: "filter-emulated", key: "emulated" },
|
||||||
|
{ name: "filter-active", key: "is_active" },
|
||||||
|
{ name: "filter-refunded", key: "is_refunded" },
|
||||||
|
{ name: "filter-infinite", key: "infinite" },
|
||||||
|
{ name: "filter-needs-price-update", key: "needs_price_update" },
|
||||||
|
{ name: "filter-purchase-refunded", key: "purchase_refunded" },
|
||||||
|
{ name: "filter-purchase-infinite", key: "purchase_infinite" },
|
||||||
|
{ name: "filter-session-emulated", key: "session_emulated" },
|
||||||
|
];
|
||||||
|
booleanFields.forEach((booleanField) => {
|
||||||
|
const element = form.querySelector<HTMLInputElement>(
|
||||||
|
`[name="${booleanField.name}"]:checked`,
|
||||||
|
);
|
||||||
|
if (element) {
|
||||||
|
const value = element.value === "true";
|
||||||
|
filter[booleanField.key] = criterion(value, null, "EQUALS");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const rangeFields: RangeField[] = [
|
||||||
|
{ prefix: "filter-year", key: "year_released" },
|
||||||
|
{ prefix: "filter-original-year", key: "original_year_released" },
|
||||||
|
{ prefix: "filter-session-count", key: "session_count" },
|
||||||
|
{ prefix: "filter-session-average", key: "session_average" },
|
||||||
|
{ prefix: "filter-purchase-count", key: "purchase_count" },
|
||||||
|
{ prefix: "filter-playevent-count", key: "playevent_count" },
|
||||||
|
{ prefix: "filter-duration-total-hours", key: "duration_total_hours" },
|
||||||
|
{ prefix: "filter-duration-manual-hours", key: "duration_manual_hours" },
|
||||||
|
{ prefix: "filter-duration-calculated-hours", key: "duration_calculated_hours" },
|
||||||
|
{ prefix: "filter-manual-playtime-hours", key: "manual_playtime_hours" },
|
||||||
|
{ prefix: "filter-calculated-playtime-hours", key: "calculated_playtime_hours" },
|
||||||
|
{ prefix: "filter-num-purchases", key: "num_purchases" },
|
||||||
|
{ prefix: "filter-price", key: "price" },
|
||||||
|
{ prefix: "filter-purchase-price-total", key: "purchase_price_total" },
|
||||||
|
{ prefix: "filter-purchase-price-any", key: "purchase_price_any" },
|
||||||
|
{ prefix: "filter-days-to-finish", key: "days_to_finish" },
|
||||||
|
{ prefix: "filter-playtime-hours", key: "playtime_hours", ignoreZeroZero: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
rangeFields.forEach((rangeField) => {
|
||||||
|
let valueMin = numberValue(form, rangeField.prefix + "-min");
|
||||||
|
let valueMax = numberValue(form, rangeField.prefix + "-max");
|
||||||
|
if (rangeField.convert) {
|
||||||
|
if (valueMin !== "") valueMin = rangeField.convert(valueMin);
|
||||||
|
if (valueMax !== "") valueMax = rangeField.convert(valueMax);
|
||||||
|
}
|
||||||
|
if (rangeField.ignoreZeroZero && valueMin === 0 && valueMax === 0) return;
|
||||||
|
const result = buildRangeCriterion(valueMin, valueMax);
|
||||||
|
if (result !== null) filter[rangeField.key] = result;
|
||||||
|
});
|
||||||
|
|
||||||
|
const dateRangeFields = [
|
||||||
|
{ prefix: "filter-date-purchased", key: "date_purchased" },
|
||||||
|
{ prefix: "filter-date-refunded", key: "date_refunded" },
|
||||||
|
];
|
||||||
|
dateRangeFields.forEach((dateField) => {
|
||||||
|
const valueMin = stringValue(form, dateField.prefix + "-min");
|
||||||
|
const valueMax = stringValue(form, dateField.prefix + "-max");
|
||||||
|
const result = buildRangeCriterion(valueMin, valueMax);
|
||||||
|
if (result !== null) filter[dateField.key] = result;
|
||||||
|
});
|
||||||
|
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectSearchInput(form: HTMLElement): void {
|
||||||
|
if (form.querySelector('[name="filter-search"]')) return;
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "text";
|
||||||
|
input.name = "filter-search";
|
||||||
|
input.placeholder = "Search…";
|
||||||
|
input.className =
|
||||||
|
"block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 mb-4 focus:ring-brand focus:border-brand";
|
||||||
|
const hidden = form.querySelector<HTMLInputElement>('[name="filter"]');
|
||||||
|
if (hidden && hidden.parentNode) {
|
||||||
|
try {
|
||||||
|
const existing = JSON.parse(hidden.value || "{}");
|
||||||
|
if (existing.search && existing.search.value) {
|
||||||
|
input.value = existing.search.value;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore malformed existing filter JSON
|
||||||
|
}
|
||||||
|
hidden.parentNode.insertBefore(input, hidden.nextSibling);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupDeselectableRadios(root: HTMLElement): void {
|
||||||
|
root.querySelectorAll<DeselectableRadio>('input[type="radio"]').forEach((radio) => {
|
||||||
|
radio.addEventListener("click", function (this: DeselectableRadio) {
|
||||||
|
if (this.wasChecked) {
|
||||||
|
this.checked = false;
|
||||||
|
this.wasChecked = false;
|
||||||
|
this.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
} else {
|
||||||
|
const name = this.getAttribute("name");
|
||||||
|
if (name) {
|
||||||
|
root
|
||||||
|
.querySelectorAll<DeselectableRadio>(`input[type="radio"][name="${name}"]`)
|
||||||
|
.forEach((other) => {
|
||||||
|
other.wasChecked = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.wasChecked = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (radio.checked) {
|
||||||
|
radio.wasChecked = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleStringFilterInput(radio: HTMLInputElement): void {
|
||||||
|
const container = radio.closest(".flex-col");
|
||||||
|
if (!container) return;
|
||||||
|
const textInput = container.querySelector<HTMLInputElement>('input[type="text"]');
|
||||||
|
if (!textInput) return;
|
||||||
|
const checkedRadio = container.querySelector<HTMLInputElement>('input[type="radio"]:checked');
|
||||||
|
const value = checkedRadio ? checkedRadio.value : "";
|
||||||
|
if (value === "IS_NULL" || value === "NOT_NULL") {
|
||||||
|
textInput.disabled = true;
|
||||||
|
textInput.value = "";
|
||||||
|
textInput.classList.add("opacity-50", "cursor-not-allowed");
|
||||||
|
} else {
|
||||||
|
textInput.disabled = false;
|
||||||
|
textInput.classList.remove("opacity-50", "cursor-not-allowed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupStringFilters(root: HTMLElement): void {
|
||||||
|
root
|
||||||
|
.querySelectorAll<HTMLInputElement>("input[data-string-modifier-radio]")
|
||||||
|
.forEach((radio) => {
|
||||||
|
radio.addEventListener("change", function (this: HTMLInputElement) {
|
||||||
|
toggleStringFilterInput(this);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupPresetDeleteHandlers(container: HTMLElement): void {
|
||||||
|
const deleteLinks = container.querySelectorAll<HTMLAnchorElement>("[data-delete-preset]");
|
||||||
|
deleteLinks.forEach((link) => {
|
||||||
|
link.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const deleteUrl = link.getAttribute("href");
|
||||||
|
if (!deleteUrl) return;
|
||||||
|
if (!confirm("Delete this preset?")) return;
|
||||||
|
fetch(deleteUrl, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "X-CSRFToken": getCsrfToken() },
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
const listItem = link.closest("li");
|
||||||
|
if (listItem) listItem.remove();
|
||||||
|
const list = container.querySelector("ul");
|
||||||
|
if (list && list.querySelectorAll("li").length === 0) {
|
||||||
|
list.innerHTML =
|
||||||
|
'<li class="px-4 py-2 text-sm text-body italic">No saved presets</li>';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Delete failed:", error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPresets(root: HTMLElement, presetListUrl: string): void {
|
||||||
|
const dropdown = root.querySelector<HTMLElement>("#preset-dropdown");
|
||||||
|
if (!dropdown) return;
|
||||||
|
|
||||||
|
const mode = presetMode();
|
||||||
|
let query = "";
|
||||||
|
if (presetListUrl.indexOf("mode=") === -1) {
|
||||||
|
query = (presetListUrl.indexOf("?") !== -1 ? "&" : "?") + "mode=" + mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(presetListUrl + query, { credentials: "same-origin" })
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) throw new Error("Failed to load presets");
|
||||||
|
return response.text();
|
||||||
|
})
|
||||||
|
.then((html) => {
|
||||||
|
dropdown.innerHTML = html;
|
||||||
|
setupPresetDeleteHandlers(dropdown);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
dropdown.innerHTML =
|
||||||
|
'<span class="text-sm text-body italic">Presets unavailable</span>';
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPresetNameInput(root: HTMLElement): void {
|
||||||
|
const input = root.querySelector<HTMLElement>("[data-filter-bar-preset-name]");
|
||||||
|
const saveButton = root.querySelector<HTMLElement>("[data-filter-bar-save]");
|
||||||
|
const confirmButton = root.querySelector<HTMLElement>("[data-filter-bar-confirm-save]");
|
||||||
|
if (input) input.classList.remove("hidden");
|
||||||
|
if (saveButton) saveButton.classList.add("hidden");
|
||||||
|
if (confirmButton) confirmButton.classList.remove("hidden");
|
||||||
|
if (input instanceof HTMLElement) input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePreset(
|
||||||
|
form: HTMLElement,
|
||||||
|
presetSaveUrl: string,
|
||||||
|
presetListUrl: string,
|
||||||
|
root: HTMLElement,
|
||||||
|
): void {
|
||||||
|
const input = root.querySelector<HTMLInputElement>("[data-filter-bar-preset-name]");
|
||||||
|
const name = input ? input.value.trim() : "";
|
||||||
|
if (!name) {
|
||||||
|
if (input) input.classList.add("border-red-500");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterObject = buildFilterJSON(form);
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
body.append("name", name);
|
||||||
|
body.append("mode", presetMode());
|
||||||
|
body.append("filter", JSON.stringify(filterObject));
|
||||||
|
|
||||||
|
fetch(presetSaveUrl, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"X-CSRFToken": getCsrfToken(),
|
||||||
|
},
|
||||||
|
body: body.toString(),
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) throw new Error("Save failed");
|
||||||
|
if (input) {
|
||||||
|
input.value = "";
|
||||||
|
input.classList.add("hidden");
|
||||||
|
input.classList.remove("border-red-500");
|
||||||
|
}
|
||||||
|
const saveButton = root.querySelector<HTMLElement>("[data-filter-bar-save]");
|
||||||
|
const confirmButton = root.querySelector<HTMLElement>("[data-filter-bar-confirm-save]");
|
||||||
|
if (saveButton) saveButton.classList.remove("hidden");
|
||||||
|
if (confirmButton) confirmButton.classList.add("hidden");
|
||||||
|
loadPresets(root, presetListUrl);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to save preset:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class FilterBarElement extends HTMLElement {
|
||||||
|
connectedCallback(): void {
|
||||||
|
const { presetListUrl, presetSaveUrl } = readFilterBarProps(this);
|
||||||
|
const form = this.querySelector<HTMLFormElement>("form");
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
form.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const filter = buildFilterJSON(form);
|
||||||
|
const filterString = JSON.stringify(filter);
|
||||||
|
let url = baseUrl();
|
||||||
|
if (filterString && filterString !== "{}") {
|
||||||
|
url += "?filter=" + encodeURIComponent(filterString);
|
||||||
|
}
|
||||||
|
window.location.href = url;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.querySelector("[data-filter-bar-clear]")?.addEventListener("click", () => {
|
||||||
|
form.reset();
|
||||||
|
window.location.href = baseUrl();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.querySelector("[data-filter-bar-save]")?.addEventListener("click", () => {
|
||||||
|
showPresetNameInput(this);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.querySelector("[data-filter-bar-confirm-save]")?.addEventListener("click", () => {
|
||||||
|
savePreset(form, presetSaveUrl, presetListUrl, this);
|
||||||
|
});
|
||||||
|
|
||||||
|
injectSearchInput(form);
|
||||||
|
setupDeselectableRadios(this);
|
||||||
|
setupStringFilters(this);
|
||||||
|
if (presetListUrl) loadPresets(this, presetListUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("filter-bar", FilterBarElement);
|
||||||
@@ -1,25 +1,31 @@
|
|||||||
/**
|
/**
|
||||||
* Range slider — custom draggable handles (no native <input type=range>).
|
* Range slider — custom draggable handles (no native <input type=range>).
|
||||||
*
|
*
|
||||||
* Supports two modes on each slider, toggled via the .range-mode-toggle button:
|
* Supports two modes, toggled via the .range-mode-toggle button:
|
||||||
* range (default) — two handles, min ≤ max constraint
|
* range (default) — two handles, min ≤ max constraint
|
||||||
* point — single handle, sets both number inputs to the same value
|
* point — single handle, sets both number inputs to the same value
|
||||||
*
|
*
|
||||||
* Handles track-fill positioning and sync between handles and the connected
|
* Handles track-fill positioning and sync between handles and the connected
|
||||||
* number inputs (linked via data-target attributes).
|
* number inputs (linked via data-target attributes on the handles).
|
||||||
|
* Behavior is wired in connectedCallback; the typed props (min, max, step, mode)
|
||||||
|
* come from the server via readRangeSliderProps.
|
||||||
*/
|
*/
|
||||||
import { onSwap } from "./utils.js";
|
import { readRangeSliderProps } from "../generated/props.js";
|
||||||
|
|
||||||
(() => {
|
class RangeSliderElement extends HTMLElement {
|
||||||
"use strict";
|
private onMouseMove: ((event: MouseEvent) => void) | null = null;
|
||||||
|
private onMouseUp: (() => void) | null = null;
|
||||||
|
|
||||||
function initializeSlider(sliderElement: Element) {
|
connectedCallback(): void {
|
||||||
const slider = sliderElement as HTMLElement;
|
const { min: dataMin, max: dataMax, step, mode: initialMode } =
|
||||||
let mode = slider.getAttribute("data-mode") || "range";
|
readRangeSliderProps(this);
|
||||||
const trackFill = slider.querySelector<HTMLElement>(".range-track-fill");
|
let mode = initialMode;
|
||||||
const minHandle = slider.querySelector<HTMLElement>(".range-handle-min");
|
|
||||||
const maxHandle = slider.querySelector<HTMLElement>(".range-handle-max");
|
const track = this.querySelector<HTMLElement>("[data-range-track]");
|
||||||
if (!minHandle || !maxHandle) return;
|
const trackFill = this.querySelector<HTMLElement>(".range-track-fill");
|
||||||
|
const minHandle = this.querySelector<HTMLElement>(".range-handle-min");
|
||||||
|
const maxHandle = this.querySelector<HTMLElement>(".range-handle-max");
|
||||||
|
if (!track || !minHandle || !maxHandle) return;
|
||||||
|
|
||||||
const minTarget = document.getElementById(
|
const minTarget = document.getElementById(
|
||||||
minHandle.getAttribute("data-target") ?? ""
|
minHandle.getAttribute("data-target") ?? ""
|
||||||
@@ -27,9 +33,6 @@ import { onSwap } from "./utils.js";
|
|||||||
const maxTarget = document.getElementById(
|
const maxTarget = document.getElementById(
|
||||||
maxHandle.getAttribute("data-target") ?? ""
|
maxHandle.getAttribute("data-target") ?? ""
|
||||||
) as HTMLInputElement | null;
|
) as HTMLInputElement | null;
|
||||||
const dataMin = parseInt(slider.getAttribute("data-min") ?? "", 10);
|
|
||||||
const dataMax = parseInt(slider.getAttribute("data-max") ?? "", 10);
|
|
||||||
const step = parseInt(slider.getAttribute("data-step") ?? "", 10) || 1;
|
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|
||||||
@@ -44,12 +47,18 @@ import { onSwap } from "./utils.js";
|
|||||||
return Math.max(low, Math.min(high, value));
|
return Math.max(low, Math.min(high, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTargetValue(target: HTMLInputElement | null, defaultValue: number): number {
|
function getTargetValue(
|
||||||
|
target: HTMLInputElement | null,
|
||||||
|
defaultValue: number
|
||||||
|
): number {
|
||||||
if (!target || target.value === "") return defaultValue;
|
if (!target || target.value === "") return defaultValue;
|
||||||
const parsed = parseInt(target.value, 10);
|
const parsed = parseInt(target.value, 10);
|
||||||
return isNaN(parsed) ? defaultValue : parsed;
|
return isNaN(parsed) ? defaultValue : parsed;
|
||||||
}
|
}
|
||||||
function setTargetValue(target: HTMLInputElement | null, value: number | string): void {
|
function setTargetValue(
|
||||||
|
target: HTMLInputElement | null,
|
||||||
|
value: number | string
|
||||||
|
): void {
|
||||||
if (target) target.value = String(value);
|
if (target) target.value = String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,12 +95,12 @@ import { onSwap } from "./utils.js";
|
|||||||
|
|
||||||
// ── Dragging ──
|
// ── Dragging ──
|
||||||
|
|
||||||
function makeDraggable(handle: HTMLElement, isMin: boolean): void {
|
const makeDraggable = (handle: HTMLElement, isMin: boolean): void => {
|
||||||
handle.addEventListener("mousedown", (event) => {
|
handle.addEventListener("mousedown", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const rect = slider.getBoundingClientRect();
|
const rect = track.getBoundingClientRect();
|
||||||
|
|
||||||
function onMove(moveEvent: MouseEvent): void {
|
const onMove = (moveEvent: MouseEvent): void => {
|
||||||
const percent = ((moveEvent.clientX - rect.left) / rect.width) * 100;
|
const percent = ((moveEvent.clientX - rect.left) / rect.width) * 100;
|
||||||
const value = percentToValue(clamp(percent, 0, 100));
|
const value = percentToValue(clamp(percent, 0, 100));
|
||||||
|
|
||||||
@@ -114,17 +123,22 @@ import { onSwap } from "./utils.js";
|
|||||||
if (maxTarget) maxTarget.dispatchEvent(new Event("input", { bubbles: true }));
|
if (maxTarget) maxTarget.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
}
|
}
|
||||||
updateHandles();
|
updateHandles();
|
||||||
}
|
};
|
||||||
|
|
||||||
function onUp(): void {
|
const onUp = (): void => {
|
||||||
document.removeEventListener("mousemove", onMove);
|
document.removeEventListener("mousemove", onMove);
|
||||||
document.removeEventListener("mouseup", onUp);
|
document.removeEventListener("mouseup", onUp);
|
||||||
}
|
this.onMouseMove = null;
|
||||||
|
this.onMouseUp = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.onMouseMove = onMove;
|
||||||
|
this.onMouseUp = onUp;
|
||||||
document.addEventListener("mousemove", onMove);
|
document.addEventListener("mousemove", onMove);
|
||||||
document.addEventListener("mouseup", onUp);
|
document.addEventListener("mouseup", onUp);
|
||||||
onMove(event);
|
onMove(event);
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
makeDraggable(minHandle, true);
|
makeDraggable(minHandle, true);
|
||||||
makeDraggable(maxHandle, false);
|
makeDraggable(maxHandle, false);
|
||||||
@@ -133,7 +147,8 @@ import { onSwap } from "./utils.js";
|
|||||||
|
|
||||||
function syncFromInputs(event?: Event): void {
|
function syncFromInputs(event?: Event): void {
|
||||||
if (mode === "point") {
|
if (mode === "point") {
|
||||||
const source = (event?.target as HTMLInputElement | null) || minTarget || maxTarget;
|
const source =
|
||||||
|
(event?.target as HTMLInputElement | null) || minTarget || maxTarget;
|
||||||
const value = source ? source.value : "";
|
const value = source ? source.value : "";
|
||||||
setTargetValue(minTarget, value);
|
setTargetValue(minTarget, value);
|
||||||
setTargetValue(maxTarget, value);
|
setTargetValue(maxTarget, value);
|
||||||
@@ -178,12 +193,11 @@ import { onSwap } from "./utils.js";
|
|||||||
|
|
||||||
// ── Mode toggle ──
|
// ── Mode toggle ──
|
||||||
|
|
||||||
const block = slider.closest(".range-slider-block");
|
const toggleButton = this.querySelector<HTMLElement>(".range-mode-toggle");
|
||||||
const toggleButton = block && block.querySelector(".range-mode-toggle");
|
|
||||||
if (toggleButton) {
|
if (toggleButton) {
|
||||||
toggleButton.addEventListener("click", () => {
|
toggleButton.addEventListener("click", () => {
|
||||||
const newMode = mode === "range" ? "point" : "range";
|
const newMode = mode === "range" ? "point" : "range";
|
||||||
slider.setAttribute("data-mode", newMode);
|
this.setAttribute("mode", newMode);
|
||||||
|
|
||||||
// Swap toggle icons
|
// Swap toggle icons
|
||||||
const iconRange = toggleButton.querySelector(".range-mode-icon-range");
|
const iconRange = toggleButton.querySelector(".range-mode-icon-range");
|
||||||
@@ -191,7 +205,7 @@ import { onSwap } from "./utils.js";
|
|||||||
if (iconRange) iconRange.classList.toggle("hidden");
|
if (iconRange) iconRange.classList.toggle("hidden");
|
||||||
if (iconPoint) iconPoint.classList.toggle("hidden");
|
if (iconPoint) iconPoint.classList.toggle("hidden");
|
||||||
|
|
||||||
const dashSpan = block && block.querySelector(".range-dash");
|
const dashSpan = this.querySelector(".range-dash");
|
||||||
if (newMode === "point") {
|
if (newMode === "point") {
|
||||||
minHandle.style.display = "none";
|
minHandle.style.display = "none";
|
||||||
setTargetValue(minTarget, maxTarget ? maxTarget.value : "");
|
setTargetValue(minTarget, maxTarget ? maxTarget.value : "");
|
||||||
@@ -211,5 +225,16 @@ import { onSwap } from "./utils.js";
|
|||||||
updateHandles();
|
updateHandles();
|
||||||
}
|
}
|
||||||
|
|
||||||
onSwap(".range-slider", initializeSlider);
|
disconnectedCallback(): void {
|
||||||
})();
|
if (this.onMouseMove) {
|
||||||
|
document.removeEventListener("mousemove", this.onMouseMove);
|
||||||
|
this.onMouseMove = null;
|
||||||
|
}
|
||||||
|
if (this.onMouseUp) {
|
||||||
|
document.removeEventListener("mouseup", this.onMouseUp);
|
||||||
|
this.onMouseUp = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("range-slider", RangeSliderElement);
|
||||||
@@ -0,0 +1,713 @@
|
|||||||
|
/**
|
||||||
|
* SearchSelect — custom element wrapping the search-select widget.
|
||||||
|
*
|
||||||
|
* A search box paired with a dropdown of options. Multi-select renders chosen
|
||||||
|
* items as removable pills (inline with the search box), each backed by a
|
||||||
|
* hidden <input>. Single-select renders no pill: the committed label lives
|
||||||
|
* inside the search box (which doubles as a combobox — focus clears it to
|
||||||
|
* search, picking an option fills it), with a lone hidden <input> carrying the
|
||||||
|
* value. Both keep hidden inputs so Django validation works.
|
||||||
|
*
|
||||||
|
* Filter mode (filter-mode="true", rendered by FilterSelect): value rows carry
|
||||||
|
* +/− buttons that add include (✓) / exclude (✗) pills, plus pinned modifier
|
||||||
|
* pseudo-options ((Any)/(None)) that are mutually exclusive with value pills.
|
||||||
|
* Filter widgets have no hidden inputs; readSearchSelect serialises their state
|
||||||
|
* into data-included / data-excluded / data-modifier for the filter bar.
|
||||||
|
*
|
||||||
|
* Dynamically-added rows and pills are cloned from hidden <template> elements
|
||||||
|
* the server renders with the same Python components (Pill / SearchSelect /
|
||||||
|
* FilterSelect). The JS only fills in the label slot ([data-search-select-label]),
|
||||||
|
* value, and data-* attributes — so all markup and Tailwind class strings live
|
||||||
|
* in one place (the Python components), never duplicated here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// The contract for the "search-select:change" CustomEvent this widget emits.
|
||||||
|
// Consumers (e.g. add_purchase.ts) import these types — never redefine them.
|
||||||
|
export interface SearchSelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
data: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchSelectChangeDetail {
|
||||||
|
name: string;
|
||||||
|
values: string[];
|
||||||
|
last: SearchSelectOption | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The widget stashes per-instance state directly on its DOM elements.
|
||||||
|
interface SearchSelectContainer extends HTMLElement {
|
||||||
|
_searchSelectLabel?: string;
|
||||||
|
_searchSelectDirty?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OptionRow extends HTMLElement {
|
||||||
|
_searchSelectOption?: SearchSelectOption;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterPillEntry {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 100;
|
||||||
|
|
||||||
|
// Must match Python common/components/filters.py:_PRESENCE_MODIFIERS.
|
||||||
|
// These modifiers are mutually exclusive with value pills — selecting
|
||||||
|
// one clears all value pills. Non-presence modifiers (INCLUDES_ALL,
|
||||||
|
// INCLUDES_ONLY) coexist with value pills.
|
||||||
|
const PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"];
|
||||||
|
|
||||||
|
const initWidget = (containerElement: Element) => {
|
||||||
|
const container = containerElement as SearchSelectContainer;
|
||||||
|
const search = container.querySelector<HTMLInputElement>("[data-search-select-search]");
|
||||||
|
const options = container.querySelector<HTMLElement>("[data-search-select-options]");
|
||||||
|
const pills = container.querySelector<HTMLElement>("[data-search-select-pills]");
|
||||||
|
if (!search || !options || !pills) return;
|
||||||
|
|
||||||
|
const name = container.getAttribute("name") ?? "";
|
||||||
|
const searchUrl = container.getAttribute("search-url");
|
||||||
|
const isFilter = container.getAttribute("filter-mode") === "true";
|
||||||
|
const freeText = container.getAttribute("free-text") === "true";
|
||||||
|
const multi = container.getAttribute("multi") === "true";
|
||||||
|
const alwaysVisible = container.getAttribute("always-visible") === "true";
|
||||||
|
const prefetch = parseInt(container.getAttribute("prefetch") ?? "", 10) || 0;
|
||||||
|
const syncUrl = container.getAttribute("sync-url") === "true";
|
||||||
|
|
||||||
|
const noResults = options.querySelector<HTMLElement>("[data-search-select-no-results]");
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let pendingRequest: AbortController | null = null; // in-flight, so newer queries win
|
||||||
|
let hasPrefetched = false;
|
||||||
|
|
||||||
|
const hasVisibleContent = () => {
|
||||||
|
const optionRows = options.querySelectorAll<HTMLElement>("[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");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const hidePanel = () => {
|
||||||
|
if (!alwaysVisible) options.classList.add("hidden");
|
||||||
|
};
|
||||||
|
|
||||||
|
const setNoResults = (visible: boolean) => {
|
||||||
|
if (!noResults) return;
|
||||||
|
noResults.classList.toggle("hidden", !visible);
|
||||||
|
if (visible) showPanel();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Highlight tracking (filter mode) ──
|
||||||
|
let highlightedRow: HTMLElement | null = null;
|
||||||
|
|
||||||
|
const highlightOption = (row: HTMLElement | null) => {
|
||||||
|
clearHighlight();
|
||||||
|
if (!row) return;
|
||||||
|
row.setAttribute("data-search-select-highlighted", "");
|
||||||
|
highlightedRow = row;
|
||||||
|
row.scrollIntoView({ block: "nearest" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearHighlight = () => {
|
||||||
|
if (highlightedRow) {
|
||||||
|
highlightedRow.removeAttribute("data-search-select-highlighted");
|
||||||
|
highlightedRow = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVisibleOptions = (): HTMLElement[] => {
|
||||||
|
const all = options.querySelectorAll<HTMLElement>("[data-search-select-option]");
|
||||||
|
return Array.from(all).filter(row => row.style.display !== "none");
|
||||||
|
};
|
||||||
|
|
||||||
|
const autoHighlight = (query: string) => {
|
||||||
|
const visible = getVisibleOptions();
|
||||||
|
if (visible.length === 0) {
|
||||||
|
clearHighlight();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lower = query.toLowerCase();
|
||||||
|
// 1. Starts-with match
|
||||||
|
for (let i = 0; i < visible.length; i++) {
|
||||||
|
const label = (visible[i].getAttribute("data-label") || "").toLowerCase();
|
||||||
|
if (lower && label.startsWith(lower)) {
|
||||||
|
highlightOption(visible[i]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. Substring match (fuzzy-lite)
|
||||||
|
for (let j = 0; j < visible.length; j++) {
|
||||||
|
const subLabel = (visible[j].getAttribute("data-label") || "").toLowerCase();
|
||||||
|
if (lower && subLabel.includes(lower)) {
|
||||||
|
highlightOption(visible[j]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3. Fallback: first visible option
|
||||||
|
highlightOption(visible[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get active values in both form and filter modes
|
||||||
|
const getSelectedValues = (): Set<string> => {
|
||||||
|
const values = new Set<string>();
|
||||||
|
pills.querySelectorAll<HTMLInputElement>('input[type="hidden"]').forEach(input => {
|
||||||
|
values.add(input.value);
|
||||||
|
});
|
||||||
|
pills.querySelectorAll<HTMLElement>("[data-pill]").forEach(pill => {
|
||||||
|
const value = pill.getAttribute("data-value");
|
||||||
|
if (value) values.add(value);
|
||||||
|
});
|
||||||
|
return values;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Render server-fetched rows into the panel ──
|
||||||
|
const renderRows = (items: SearchSelectOption[]) => {
|
||||||
|
const selectedValues = getSelectedValues();
|
||||||
|
const preservedOptions: SearchSelectOption[] = [];
|
||||||
|
|
||||||
|
// Extract existing option data for currently selected values before removing
|
||||||
|
options.querySelectorAll<HTMLElement>("[data-search-select-option]").forEach(row => {
|
||||||
|
const value = row.getAttribute("data-value");
|
||||||
|
if (value && selectedValues.has(value)) {
|
||||||
|
preservedOptions.push(optionFromRow(row));
|
||||||
|
}
|
||||||
|
row.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderedValues = new Set<string>();
|
||||||
|
|
||||||
|
// Render preserved options first (to keep them at the top)
|
||||||
|
preservedOptions.forEach(option => {
|
||||||
|
options.insertBefore(buildRow(option), noResults || null);
|
||||||
|
renderedValues.add(String(option.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();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Clone a server-rendered <template> prototype by name. The server emits
|
||||||
|
// the mode-appropriate prototypes, so the JS never names a class. ──
|
||||||
|
const cloneTemplate = (templateName: string): HTMLElement | null => {
|
||||||
|
const template = container.querySelector<HTMLTemplateElement>(
|
||||||
|
`template[data-search-select-template="${templateName}"]`
|
||||||
|
);
|
||||||
|
const clone = template?.content.firstElementChild?.cloneNode(true);
|
||||||
|
return (clone as HTMLElement) ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setLabel = (node: Element, label: string) => {
|
||||||
|
const slot = node.querySelector("[data-search-select-label]");
|
||||||
|
if (slot) slot.textContent = label;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyData = (node: Element, data: Record<string, string> = {}) => {
|
||||||
|
Object.keys(data).forEach(key => {
|
||||||
|
node.setAttribute(`data-${key}`, data[key]);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build an option row by cloning the "row" template (the same prototype the
|
||||||
|
// server renders, so fetched and pre-rendered rows are identical).
|
||||||
|
const buildRow = (option: SearchSelectOption): HTMLElement | Comment => {
|
||||||
|
const row = cloneTemplate("row") as OptionRow | null;
|
||||||
|
if (!row) return document.createComment("ss-row");
|
||||||
|
row.setAttribute("data-value", option.value);
|
||||||
|
row.setAttribute("data-label", option.label);
|
||||||
|
applyData(row, option.data);
|
||||||
|
setLabel(row, option.label);
|
||||||
|
row._searchSelectOption = option;
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 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. ──
|
||||||
|
const filterRows = (query: string): number => {
|
||||||
|
const lower = query.toLowerCase();
|
||||||
|
let visibleCount = 0;
|
||||||
|
options.querySelectorAll<HTMLElement>("[data-search-select-option]").forEach(item => {
|
||||||
|
const label = (item.getAttribute("data-label") || "").toLowerCase();
|
||||||
|
const match = label.includes(lower);
|
||||||
|
item.style.display = match ? "" : "none";
|
||||||
|
if (match) visibleCount += 1;
|
||||||
|
});
|
||||||
|
return visibleCount;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Fetch matching rows from the server. The previous in-flight request is
|
||||||
|
// aborted so a slower earlier response can never overwrite a newer one. ──
|
||||||
|
const fetchFromServer = (query: string) => {
|
||||||
|
if (pendingRequest) pendingRequest.abort();
|
||||||
|
pendingRequest = new AbortController();
|
||||||
|
let url = `${searchUrl}?q=${encodeURIComponent(query)}`;
|
||||||
|
if (prefetch && !query) url += `&limit=${prefetch}`;
|
||||||
|
fetch(url, { credentials: "same-origin", signal: pendingRequest.signal })
|
||||||
|
.then(response => response.json())
|
||||||
|
.then((items: SearchSelectOption[]) => {
|
||||||
|
pendingRequest = null;
|
||||||
|
renderRows(items);
|
||||||
|
// Re-apply the live query: the box may hold more text than was sent.
|
||||||
|
setNoResults(filterRows(search.value.trim()) === 0);
|
||||||
|
autoHighlight(search.value.trim());
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
if (error?.name === "AbortError") return; // superseded
|
||||||
|
pendingRequest = null;
|
||||||
|
setNoResults(true);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// In free-text mode the typed text is the value itself: there is no
|
||||||
|
// backing list, so we rebuild a single ephemeral option row reflecting the
|
||||||
|
// current query so the +/− buttons (or Enter) can commit it as a pill.
|
||||||
|
const rebuildFreeTextRow = (query: string) => {
|
||||||
|
options.querySelectorAll("[data-search-select-option]").forEach(row => row.remove());
|
||||||
|
if (!query) {
|
||||||
|
setNoResults(false);
|
||||||
|
clearHighlight();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = buildRow({ value: query, label: query, data: {} });
|
||||||
|
options.insertBefore(row, noResults || null);
|
||||||
|
setNoResults(false);
|
||||||
|
highlightOption(row as HTMLElement);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Called on every keystroke. With a search_url, filter the loaded window
|
||||||
|
// instantly (zero latency) and debounce a server request for the rest;
|
||||||
|
// 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,
|
||||||
|
// so the client-side filter is authoritative.
|
||||||
|
const runSearch = () => {
|
||||||
|
const query = search.value.trim();
|
||||||
|
if (freeText) {
|
||||||
|
rebuildFreeTextRow(query);
|
||||||
|
showPanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (searchUrl) {
|
||||||
|
filterRows(query);
|
||||||
|
setNoResults(false);
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
fetchFromServer(query);
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
} else {
|
||||||
|
setNoResults(filterRows(query) === 0);
|
||||||
|
}
|
||||||
|
autoHighlight(query);
|
||||||
|
showPanel();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Single-select combobox: the search box shows the committed label;
|
||||||
|
// focusing clears it to search, blurring restores it (or deselects). ──
|
||||||
|
if (!multi) container._searchSelectLabel = search.value;
|
||||||
|
|
||||||
|
search.addEventListener("focus", () => {
|
||||||
|
if (!multi) {
|
||||||
|
// Hide the committed label so the box becomes a fresh search field.
|
||||||
|
search.value = "";
|
||||||
|
container._searchSelectDirty = false;
|
||||||
|
}
|
||||||
|
if (freeText) {
|
||||||
|
rebuildFreeTextRow(search.value.trim());
|
||||||
|
} else if (searchUrl) {
|
||||||
|
if (prefetch && !hasPrefetched) {
|
||||||
|
// Seed the window immediately on first open (not debounced).
|
||||||
|
hasPrefetched = true;
|
||||||
|
fetchFromServer("");
|
||||||
|
} else {
|
||||||
|
// Show whatever is already loaded; the server decides no-results.
|
||||||
|
filterRows(search.value.trim());
|
||||||
|
setNoResults(false);
|
||||||
|
autoHighlight(search.value.trim());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setNoResults(filterRows(search.value.trim()) === 0);
|
||||||
|
autoHighlight(search.value.trim());
|
||||||
|
}
|
||||||
|
showPanel();
|
||||||
|
});
|
||||||
|
|
||||||
|
search.addEventListener("input", () => {
|
||||||
|
clearHighlight();
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!multi) {
|
||||||
|
search.addEventListener("blur", () => {
|
||||||
|
// Defer so an option click (which fires before blur settles) wins.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (container._searchSelectDirty && search.value.trim() === "") {
|
||||||
|
// User intentionally cleared the box → deselect.
|
||||||
|
pills.innerHTML = "";
|
||||||
|
container._searchSelectLabel = "";
|
||||||
|
emitChange(null);
|
||||||
|
} else {
|
||||||
|
// Focused-and-left, or typed a partial query without picking →
|
||||||
|
// restore the committed label (no-op right after a selection).
|
||||||
|
search.value = container._searchSelectLabel || "";
|
||||||
|
}
|
||||||
|
}, 120);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Keyboard navigation (both form and filter modes) ──
|
||||||
|
search.addEventListener("keydown", (event) => {
|
||||||
|
const { key } = event;
|
||||||
|
|
||||||
|
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 downIndex = highlightedRow ? visible.indexOf(highlightedRow) : -1;
|
||||||
|
highlightOption(visible[(downIndex + 1) % visible.length]);
|
||||||
|
} else if (key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
showPanel();
|
||||||
|
const upIndex = highlightedRow ? visible.indexOf(highlightedRow) : -1;
|
||||||
|
highlightOption(visible[(upIndex - 1 + visible.length) % visible.length]);
|
||||||
|
} else if (key === "Enter") {
|
||||||
|
if (highlightedRow) {
|
||||||
|
event.preventDefault();
|
||||||
|
const option = optionFromRow(highlightedRow);
|
||||||
|
if (isFilter) {
|
||||||
|
addFilterPill(option, "include");
|
||||||
|
search.value = "";
|
||||||
|
} else {
|
||||||
|
selectOption(option);
|
||||||
|
}
|
||||||
|
clearHighlight();
|
||||||
|
hidePanel();
|
||||||
|
}
|
||||||
|
} else if (key === "Escape") {
|
||||||
|
clearHighlight();
|
||||||
|
hidePanel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clicking an option must not blur the input before the click selects.
|
||||||
|
options.addEventListener("mousedown", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Option click → select (form mode) or include/exclude (filter mode) ──
|
||||||
|
options.addEventListener("click", (event) => {
|
||||||
|
if (isFilter) {
|
||||||
|
handleFilterOptionClick(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = (event.target as Element).closest<HTMLElement>("[data-search-select-option]");
|
||||||
|
if (!row) return;
|
||||||
|
selectOption(optionFromRow(row));
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFilterOptionClick = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Element;
|
||||||
|
// Pinned modifier pseudo-option → set the (mutually exclusive) modifier.
|
||||||
|
const modifierRow = target.closest<HTMLElement>("[data-search-select-modifier-option]");
|
||||||
|
if (modifierRow) {
|
||||||
|
setModifier(
|
||||||
|
modifierRow.getAttribute("data-search-select-modifier-option") ?? "",
|
||||||
|
modifierRow.getAttribute("data-label") ?? ""
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Include / exclude button on a value row.
|
||||||
|
const button = target.closest<HTMLElement>("[data-search-select-action]");
|
||||||
|
if (button) {
|
||||||
|
const row = button.closest<HTMLElement>("[data-search-select-option]");
|
||||||
|
if (!row) return;
|
||||||
|
addFilterPill(optionFromRow(row), button.getAttribute("data-search-select-action") ?? "include");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Click on the option row itself → include.
|
||||||
|
const optionRow = target.closest<HTMLElement>("[data-search-select-option]");
|
||||||
|
if (optionRow) {
|
||||||
|
addFilterPill(optionFromRow(optionRow), "include");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// with value pills. Non-presence modifiers (INCLUDES_ALL / INCLUDES_ONLY)
|
||||||
|
// persist alongside value pills.
|
||||||
|
const addFilterPill = (option: SearchSelectOption, kind: string) => {
|
||||||
|
const modifierPill = pills.querySelector("[data-search-select-modifier]");
|
||||||
|
if (modifierPill) {
|
||||||
|
const modifierValue = modifierPill.getAttribute("data-search-select-modifier") ?? "";
|
||||||
|
if (PRESENCE_MODIFIERS.includes(modifierValue)) {
|
||||||
|
clearModifier();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const existing = pills.querySelector(
|
||||||
|
`[data-pill][data-value="${cssEscape(option.value)}"]`
|
||||||
|
);
|
||||||
|
if (existing) existing.remove();
|
||||||
|
pills.appendChild(buildFilterValuePill(option, kind));
|
||||||
|
search.value = "";
|
||||||
|
emitChange(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildFilterValuePill = (option: SearchSelectOption, kind: string): HTMLElement => {
|
||||||
|
const pill = cloneTemplate(kind === "include" ? "pill-include" : "pill-exclude")!;
|
||||||
|
pill.setAttribute("data-value", option.value);
|
||||||
|
pill.setAttribute("data-label", option.label);
|
||||||
|
applyData(pill, option.data);
|
||||||
|
setLabel(pill, option.label);
|
||||||
|
return pill;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set the modifier pill. Presence modifiers (NOT_NULL / IS_NULL) clear all
|
||||||
|
// value pills — they are mutually exclusive. Non-presence modifiers
|
||||||
|
// (INCLUDES_ALL / INCLUDES_ONLY) are prepended before existing value pills.
|
||||||
|
const setModifier = (modifierValue: string, label: string) => {
|
||||||
|
// Remove any existing modifier pill to avoid duplicates.
|
||||||
|
clearModifierPill();
|
||||||
|
if (PRESENCE_MODIFIERS.includes(modifierValue)) {
|
||||||
|
pills.innerHTML = "";
|
||||||
|
}
|
||||||
|
const pill = cloneTemplate("pill-modifier")!;
|
||||||
|
pill.setAttribute("data-search-select-modifier", modifierValue);
|
||||||
|
setLabel(pill, label);
|
||||||
|
pills.insertBefore(pill, pills.firstChild);
|
||||||
|
container.setAttribute("data-modifier", modifierValue);
|
||||||
|
hidePanel();
|
||||||
|
emitChange(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove the modifier pill and its container attribute. Safe to call when
|
||||||
|
// there is no modifier pill (no-op). Does not touch value pills.
|
||||||
|
const clearModifierPill = () => {
|
||||||
|
const modifierPill = pills.querySelector("[data-search-select-modifier]");
|
||||||
|
if (modifierPill) modifierPill.remove();
|
||||||
|
container.removeAttribute("data-modifier");
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearModifier = () => {
|
||||||
|
clearModifierPill();
|
||||||
|
};
|
||||||
|
|
||||||
|
const optionFromRow = (row: HTMLElement): SearchSelectOption => {
|
||||||
|
const optionRow = row as OptionRow;
|
||||||
|
if (optionRow._searchSelectOption) return optionRow._searchSelectOption;
|
||||||
|
const data: Record<string, string> = {};
|
||||||
|
Object.keys(row.dataset).forEach(key => {
|
||||||
|
if (key !== "value" && key !== "label" && key !== "ssOption") {
|
||||||
|
data[key] = row.dataset[key] ?? "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
value: row.getAttribute("data-value") ?? "",
|
||||||
|
label: row.getAttribute("data-label") ?? "",
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectOption = (option: SearchSelectOption) => {
|
||||||
|
if (multi) {
|
||||||
|
if (!pills.querySelector(`input[value="${cssEscape(option.value)}"]`)) {
|
||||||
|
addPill(option);
|
||||||
|
}
|
||||||
|
search.value = "";
|
||||||
|
} else {
|
||||||
|
// Single-select: no pill — show the label in the search box and keep a
|
||||||
|
// lone hidden input under [data-search-select-pills] for submission.
|
||||||
|
pills.innerHTML = "";
|
||||||
|
pills.appendChild(buildHidden(option.value));
|
||||||
|
search.value = option.label;
|
||||||
|
container._searchSelectLabel = option.label;
|
||||||
|
container._searchSelectDirty = false;
|
||||||
|
hidePanel();
|
||||||
|
}
|
||||||
|
emitChange(option);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPill = (option: SearchSelectOption) => {
|
||||||
|
const pill = buildPill(option);
|
||||||
|
if (pill) pills.appendChild(pill);
|
||||||
|
pills.appendChild(buildHidden(option.value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPill = (option: SearchSelectOption): HTMLElement | null => {
|
||||||
|
const pill = cloneTemplate("pill");
|
||||||
|
if (!pill) return null;
|
||||||
|
pill.setAttribute("data-value", option.value);
|
||||||
|
applyData(pill, option.data);
|
||||||
|
setLabel(pill, option.label);
|
||||||
|
return pill;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildHidden = (value: string): HTMLInputElement => {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "hidden";
|
||||||
|
input.name = name;
|
||||||
|
input.value = value;
|
||||||
|
return input;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Pill × → remove ──
|
||||||
|
pills.addEventListener("click", (event) => {
|
||||||
|
const removeButton = (event.target as Element).closest("[data-pill-remove]");
|
||||||
|
if (!removeButton) return;
|
||||||
|
const pill = removeButton.closest("[data-pill]");
|
||||||
|
if (!pill) return;
|
||||||
|
if (isFilter) {
|
||||||
|
// Filter pills have no hidden input.
|
||||||
|
if (pill.hasAttribute("data-search-select-modifier")) {
|
||||||
|
clearModifierPill();
|
||||||
|
} else {
|
||||||
|
pill.remove();
|
||||||
|
}
|
||||||
|
emitChange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = pill.getAttribute("data-value");
|
||||||
|
pill.remove();
|
||||||
|
const hidden = pills.querySelector(`input[value="${cssEscape(value)}"]`);
|
||||||
|
if (hidden) hidden.remove();
|
||||||
|
emitChange(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentValues = (): string[] => {
|
||||||
|
return Array.from(
|
||||||
|
pills.querySelectorAll<HTMLInputElement>('input[type="hidden"]')
|
||||||
|
).map(input => input.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitChange = (last: SearchSelectOption | null) => {
|
||||||
|
const values = currentValues();
|
||||||
|
if (syncUrl) syncToUrl(values);
|
||||||
|
container.dispatchEvent(
|
||||||
|
new CustomEvent<SearchSelectChangeDetail>("search-select:change", {
|
||||||
|
bubbles: true,
|
||||||
|
detail: { name, values, last },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncToUrl = (values: string[]) => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
params.delete(name);
|
||||||
|
values.forEach(value => {
|
||||||
|
params.append(name, value);
|
||||||
|
});
|
||||||
|
const queryString = params.toString();
|
||||||
|
history.replaceState(null, "", queryString ? `?${queryString}` : window.location.pathname);
|
||||||
|
};
|
||||||
|
|
||||||
|
// On init, restore from URL params if the server supplied no selected pills.
|
||||||
|
if (syncUrl && !pills.querySelector("[data-pill]")) {
|
||||||
|
const initial = new URLSearchParams(window.location.search).getAll(name);
|
||||||
|
initial.forEach(value => {
|
||||||
|
addPill({ value, label: value, data: {} });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Close panel on outside click ──
|
||||||
|
const onDocumentClick = (event: MouseEvent) => {
|
||||||
|
if (!container.contains(event.target as Node)) hidePanel();
|
||||||
|
};
|
||||||
|
document.addEventListener("click", onDocumentClick);
|
||||||
|
return onDocumentClick;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Minimal escape for use inside an attribute-value selector. */
|
||||||
|
const cssEscape = (value: string | null): string => String(value).replace(/["\\]/g, "\\$&");
|
||||||
|
|
||||||
|
// Serialise each widget's current state onto data-* attributes for the caller.
|
||||||
|
// Form widgets expose data-values (the submitted hidden-input values); filter
|
||||||
|
// widgets expose data-included / data-excluded / data-modifier for the filter
|
||||||
|
// bar to read.
|
||||||
|
export function readSearchSelect(form: HTMLElement): void {
|
||||||
|
form.querySelectorAll<HTMLElement>("search-select").forEach(container => {
|
||||||
|
const pills = container.querySelector<HTMLElement>("[data-search-select-pills]");
|
||||||
|
if (container.getAttribute("filter-mode") === "true") {
|
||||||
|
const included: FilterPillEntry[] = [];
|
||||||
|
const excluded: FilterPillEntry[] = [];
|
||||||
|
let modifier = "";
|
||||||
|
if (pills) {
|
||||||
|
pills.querySelectorAll<HTMLElement>("[data-pill]").forEach(pill => {
|
||||||
|
const pillModifier = pill.getAttribute("data-search-select-modifier");
|
||||||
|
if (pillModifier) {
|
||||||
|
modifier = pillModifier; // last modifier pill wins
|
||||||
|
return; // skip value extraction for this pill
|
||||||
|
}
|
||||||
|
const value = pill.getAttribute("data-value") ?? "";
|
||||||
|
const label = pill.getAttribute("data-label") || "";
|
||||||
|
if (pill.getAttribute("data-search-select-type") === "exclude") {
|
||||||
|
excluded.push({ id: value, label });
|
||||||
|
} else {
|
||||||
|
included.push({ id: value, label });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
container.setAttribute("data-included", JSON.stringify(included));
|
||||||
|
container.setAttribute("data-excluded", JSON.stringify(excluded));
|
||||||
|
if (modifier) container.setAttribute("data-modifier", modifier);
|
||||||
|
else container.removeAttribute("data-modifier");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const values = pills
|
||||||
|
? Array.from(pills.querySelectorAll<HTMLInputElement>('input[type="hidden"]')).map(input => input.value)
|
||||||
|
: [];
|
||||||
|
container.setAttribute("data-values", JSON.stringify(values));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep as window global for filter_bar.ts until it is converted to a custom element.
|
||||||
|
window.readSearchSelect = readSearchSelect;
|
||||||
|
|
||||||
|
class SearchSelectElement extends HTMLElement {
|
||||||
|
private onDocumentClick: ((event: MouseEvent) => void) | null = null;
|
||||||
|
|
||||||
|
connectedCallback(): void {
|
||||||
|
this.onDocumentClick = initWidget(this) as ((event: MouseEvent) => void) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback(): void {
|
||||||
|
if (this.onDocumentClick) {
|
||||||
|
document.removeEventListener("click", this.onDocumentClick);
|
||||||
|
this.onDocumentClick = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("search-select", SearchSelectElement);
|
||||||
@@ -2,7 +2,7 @@ import { readSelectionFieldsProps, SelectionFieldsProps } from "../generated/pro
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders one form field per selected item of a source SearchSelect (matched by
|
* Renders one form field per selected item of a source SearchSelect (matched by
|
||||||
* its data-name). Reacts to the SearchSelect's "search-select:change" event and
|
* its name attribute). Reacts to the SearchSelect's "search-select:change" event and
|
||||||
* to its own "active" attribute. Typed values are preserved (keyed by item id)
|
* to its own "active" attribute. Typed values are preserved (keyed by item id)
|
||||||
* across selection changes and active toggling.
|
* across selection changes and active toggling.
|
||||||
*/
|
*/
|
||||||
@@ -24,7 +24,7 @@ class SelectionFieldsElement extends HTMLElement {
|
|||||||
connectedCallback(): void {
|
connectedCallback(): void {
|
||||||
this.props = readSelectionFieldsProps(this);
|
this.props = readSelectionFieldsProps(this);
|
||||||
this.source = document.querySelector<HTMLElement>(
|
this.source = document.querySelector<HTMLElement>(
|
||||||
`[data-search-select][data-name="${this.props.source}"]`,
|
`search-select[name="${this.props.source}"]`,
|
||||||
);
|
);
|
||||||
document.addEventListener("search-select:change", this.onSourceChange);
|
document.addEventListener("search-select:change", this.onSourceChange);
|
||||||
this.render();
|
this.render();
|
||||||
|
|||||||
@@ -1,503 +0,0 @@
|
|||||||
/**
|
|
||||||
* Filter bar — vanilla TypeScript implementation.
|
|
||||||
*
|
|
||||||
* Handles form submission, preset loading/saving, and preset list rendering.
|
|
||||||
* No HTMX — plain fetch() and window.location for all interactions. The
|
|
||||||
* applyFilterBar / clearFilterBar / toggleStringFilterInput / showPresetNameInput
|
|
||||||
* / savePreset entry points are assigned to window so the server-rendered inline
|
|
||||||
* on* handlers (see common/components/filters.py) can reach them.
|
|
||||||
*/
|
|
||||||
import { onSwap } from "./utils.js";
|
|
||||||
|
|
||||||
interface Criterion {
|
|
||||||
value: unknown;
|
|
||||||
modifier: string;
|
|
||||||
value2?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A filter pill as serialised by readSearchSelect onto data-included/excluded.
|
|
||||||
interface PillEntry {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deselect-on-click radios stash their last-checked state on the element.
|
|
||||||
interface DeselectableRadio extends HTMLInputElement {
|
|
||||||
wasChecked?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RangeField {
|
|
||||||
prefix: string;
|
|
||||||
key: string;
|
|
||||||
ignoreZeroZero?: boolean;
|
|
||||||
convert?: (value: number) => number;
|
|
||||||
}
|
|
||||||
|
|
||||||
(() => {
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
/** Build a criterion object from a value and optional second value. */
|
|
||||||
function criterion(value: unknown, value2: unknown, modifier: string): Criterion {
|
|
||||||
const result: Criterion = { value, modifier };
|
|
||||||
if (value2 !== null && value2 !== undefined && value2 !== "") {
|
|
||||||
result.value2 = value2;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read an <input type="number"> value, or "" if not found. */
|
|
||||||
function numberValue(form: HTMLElement, name: string): number | "" {
|
|
||||||
const element = form.querySelector<HTMLInputElement>(`[name="${name}"]`);
|
|
||||||
if (!element || element.value === "") return "";
|
|
||||||
const value = parseFloat(element.value);
|
|
||||||
return isNaN(value) ? "" : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read a raw <input> value as string, or "" if not found. */
|
|
||||||
function stringValue(form: HTMLElement, name: string): string {
|
|
||||||
const element = form.querySelector<HTMLInputElement>(`[name="${name}"]`);
|
|
||||||
return element ? element.value : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive a range criterion ({value, value2?, modifier}) from a (min, max)
|
|
||||||
* pair, or null if both bounds are empty. Shared by the numeric-range and
|
|
||||||
* date-range serializers.
|
|
||||||
*/
|
|
||||||
function buildRangeCriterion(
|
|
||||||
valueMin: number | string,
|
|
||||||
valueMax: number | string
|
|
||||||
): Criterion | null {
|
|
||||||
if (valueMin !== "" && valueMax !== "") return criterion(valueMin, valueMax, "BETWEEN");
|
|
||||||
if (valueMin !== "") return criterion(valueMin, null, "GREATER_THAN");
|
|
||||||
if (valueMax !== "") return criterion(valueMax, null, "LESS_THAN");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the filter JSON object from form field values.
|
|
||||||
* Returns a plain object ready for JSON.stringify.
|
|
||||||
*/
|
|
||||||
function buildFilterJSON(form: HTMLElement): Record<string, unknown> {
|
|
||||||
const filter: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
// ── Search field ──
|
|
||||||
const searchInput = form.querySelector<HTMLInputElement>('[name="filter-search"]');
|
|
||||||
if (searchInput && searchInput.value.trim()) {
|
|
||||||
filter.search = { value: searchInput.value.trim(), modifier: "INCLUDES" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── FilterSelect widgets (data-search-select-mode="filter") ──
|
|
||||||
// readSearchSelect serialises each into data-included/data-excluded/data-modifier.
|
|
||||||
window.readSearchSelect(form);
|
|
||||||
const widgets = form.querySelectorAll<HTMLElement>(
|
|
||||||
'[data-search-select][data-search-select-mode="filter"]'
|
|
||||||
);
|
|
||||||
widgets.forEach((widget) => {
|
|
||||||
const field = widget.getAttribute("data-name");
|
|
||||||
if (!field) return;
|
|
||||||
const included = parseJSONAttr<PillEntry>(widget, "data-included");
|
|
||||||
const excluded = parseJSONAttr<PillEntry>(widget, "data-excluded");
|
|
||||||
// Two orthogonal axes: a presence modifier (NOT_NULL/IS_NULL) from the
|
|
||||||
// pinned (Any)/(None) pseudo-options clears the value set and has no
|
|
||||||
// values; the non-presence modifier (INCLUDES_ALL/INCLUDES_ONLY) governs
|
|
||||||
// how the include set matches. When neither is set the implicit default
|
|
||||||
// is INCLUDES ("any"). Must match Python _PRESENCE_MODIFIERS.
|
|
||||||
const modifier = widget.getAttribute("data-modifier");
|
|
||||||
const isPresence = modifier === "NOT_NULL" || modifier === "IS_NULL";
|
|
||||||
if (isPresence) {
|
|
||||||
filter[field] = { modifier };
|
|
||||||
} else if (included.length > 0 || excluded.length > 0) {
|
|
||||||
// All filter pills carry {id, label}; store them as-is so the filter
|
|
||||||
// URL and saved presets are self-describing (Stash-style).
|
|
||||||
filter[field] = {
|
|
||||||
value: included.map((item) => ({ id: item.id, label: item.label })),
|
|
||||||
excludes: excluded.map((item) => ({ id: item.id, label: item.label })),
|
|
||||||
modifier: modifier || "INCLUDES",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 1. Text Fields
|
|
||||||
const textFields = [
|
|
||||||
{ name: "filter-price_currency", key: "price_currency" },
|
|
||||||
{ name: "filter-converted_currency", key: "converted_currency" },
|
|
||||||
{ name: "filter-name", key: "name" },
|
|
||||||
{ name: "filter-group", key: "group" },
|
|
||||||
{ name: "filter-playevent_note", key: "playevent_note" },
|
|
||||||
{ name: "filter-note", key: "note" },
|
|
||||||
];
|
|
||||||
textFields.forEach((textField) => {
|
|
||||||
const modifierElement = form.querySelector<HTMLInputElement>(
|
|
||||||
`[name="${textField.name}-modifier"]:checked`
|
|
||||||
);
|
|
||||||
const modifier = modifierElement ? modifierElement.value : "EQUALS";
|
|
||||||
|
|
||||||
const isPresence = modifier === "IS_NULL" || modifier === "NOT_NULL";
|
|
||||||
if (isPresence) {
|
|
||||||
filter[textField.key] = { modifier };
|
|
||||||
} else {
|
|
||||||
const element = form.querySelector<HTMLInputElement>(`[name="${textField.name}"]`);
|
|
||||||
if (element && element.value.trim()) {
|
|
||||||
filter[textField.key] = { value: element.value.trim(), modifier };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Boolean Fields (Radio Button Groups)
|
|
||||||
const booleanFields = [
|
|
||||||
{ name: "filter-mastered", key: "mastered" },
|
|
||||||
{ name: "filter-emulated", key: "emulated" },
|
|
||||||
{ name: "filter-active", key: "is_active" },
|
|
||||||
{ name: "filter-refunded", key: "is_refunded" },
|
|
||||||
{ name: "filter-infinite", key: "infinite" },
|
|
||||||
{ name: "filter-needs-price-update", key: "needs_price_update" },
|
|
||||||
{ name: "filter-purchase-refunded", key: "purchase_refunded" },
|
|
||||||
{ name: "filter-purchase-infinite", key: "purchase_infinite" },
|
|
||||||
{ name: "filter-session-emulated", key: "session_emulated" },
|
|
||||||
];
|
|
||||||
booleanFields.forEach((booleanField) => {
|
|
||||||
const element = form.querySelector<HTMLInputElement>(
|
|
||||||
`[name="${booleanField.name}"]:checked`
|
|
||||||
);
|
|
||||||
if (element) {
|
|
||||||
const value = element.value === "true";
|
|
||||||
filter[booleanField.key] = criterion(value, null, "EQUALS");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Range Fields
|
|
||||||
const rangeFields: RangeField[] = [
|
|
||||||
{ prefix: "filter-year", key: "year_released" },
|
|
||||||
{ prefix: "filter-original-year", key: "original_year_released" },
|
|
||||||
{ prefix: "filter-session-count", key: "session_count" },
|
|
||||||
{ prefix: "filter-session-average", key: "session_average" },
|
|
||||||
{ prefix: "filter-purchase-count", key: "purchase_count" },
|
|
||||||
{ prefix: "filter-playevent-count", key: "playevent_count" },
|
|
||||||
{ prefix: "filter-duration-total-hours", key: "duration_total_hours" },
|
|
||||||
{ prefix: "filter-duration-manual-hours", key: "duration_manual_hours" },
|
|
||||||
{ prefix: "filter-duration-calculated-hours", key: "duration_calculated_hours" },
|
|
||||||
{ prefix: "filter-manual-playtime-hours", key: "manual_playtime_hours" },
|
|
||||||
{ prefix: "filter-calculated-playtime-hours", key: "calculated_playtime_hours" },
|
|
||||||
{ prefix: "filter-num-purchases", key: "num_purchases" },
|
|
||||||
{ prefix: "filter-price", key: "price" },
|
|
||||||
{ prefix: "filter-purchase-price-total", key: "purchase_price_total" },
|
|
||||||
{ prefix: "filter-purchase-price-any", key: "purchase_price_any" },
|
|
||||||
{ prefix: "filter-days-to-finish", key: "days_to_finish" },
|
|
||||||
{ prefix: "filter-playtime-hours", key: "playtime_hours", ignoreZeroZero: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
rangeFields.forEach((rangeField) => {
|
|
||||||
let valueMin = numberValue(form, rangeField.prefix + "-min");
|
|
||||||
let valueMax = numberValue(form, rangeField.prefix + "-max");
|
|
||||||
|
|
||||||
if (rangeField.convert) {
|
|
||||||
if (valueMin !== "") valueMin = rangeField.convert(valueMin);
|
|
||||||
if (valueMax !== "") valueMax = rangeField.convert(valueMax);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rangeField.ignoreZeroZero && valueMin === 0 && valueMax === 0) {
|
|
||||||
return; // both 0 means slider at default
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = buildRangeCriterion(valueMin, valueMax);
|
|
||||||
if (result !== null) filter[rangeField.key] = result;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Date Range Fields — ISO date strings from <input type="date">; no
|
|
||||||
// numeric coercion. Same modifier derivation as numeric ranges.
|
|
||||||
const dateRangeFields = [
|
|
||||||
{ prefix: "filter-date-purchased", key: "date_purchased" },
|
|
||||||
{ prefix: "filter-date-refunded", key: "date_refunded" },
|
|
||||||
];
|
|
||||||
dateRangeFields.forEach((dateField) => {
|
|
||||||
const valueMin = stringValue(form, dateField.prefix + "-min");
|
|
||||||
const valueMax = stringValue(form, dateField.prefix + "-max");
|
|
||||||
const result = buildRangeCriterion(valueMin, valueMax);
|
|
||||||
if (result !== null) filter[dateField.key] = result;
|
|
||||||
});
|
|
||||||
|
|
||||||
return filter;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Extract the current page's base URL (without query string). */
|
|
||||||
function baseUrl(): string {
|
|
||||||
return window.location.pathname;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Safely parse a JSON attribute, returning empty array on failure. */
|
|
||||||
function parseJSONAttr<T>(element: Element, attr: string): T[] {
|
|
||||||
const raw = element.getAttribute(attr);
|
|
||||||
if (!raw) return [];
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Map the current path to a preset mode. */
|
|
||||||
function presetMode(): string {
|
|
||||||
const path = window.location.pathname;
|
|
||||||
if (path.indexOf("session") !== -1) return "sessions";
|
|
||||||
if (path.indexOf("purchase") !== -1) return "purchases";
|
|
||||||
if (path.indexOf("device") !== -1) return "devices";
|
|
||||||
if (path.indexOf("platform") !== -1) return "platforms";
|
|
||||||
if (path.indexOf("playevent") !== -1) return "playevents";
|
|
||||||
return "games";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called on filter bar form submit.
|
|
||||||
* Serializes filter fields, navigates to URL with filter param.
|
|
||||||
*/
|
|
||||||
window.applyFilterBar = (event: Event): boolean => {
|
|
||||||
event.preventDefault();
|
|
||||||
const form = event.target as HTMLFormElement;
|
|
||||||
const filter = buildFilterJSON(form);
|
|
||||||
const filterString = JSON.stringify(filter);
|
|
||||||
let url = baseUrl();
|
|
||||||
if (filterString && filterString !== "{}") {
|
|
||||||
url += "?filter=" + encodeURIComponent(filterString);
|
|
||||||
}
|
|
||||||
window.location.href = url;
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all filter fields and reload the unfiltered view.
|
|
||||||
*/
|
|
||||||
window.clearFilterBar = (formId: string, _filterInputId: string): void => {
|
|
||||||
const form = document.getElementById(formId) as HTMLFormElement | null;
|
|
||||||
if (!form) return;
|
|
||||||
form.reset();
|
|
||||||
window.location.href = baseUrl();
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Presets ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Fetch and render the preset list. */
|
|
||||||
function loadPresets(): void {
|
|
||||||
const dropdown = document.getElementById("preset-dropdown");
|
|
||||||
if (!dropdown) return;
|
|
||||||
const url = dropdown.getAttribute("data-preset-list-url");
|
|
||||||
if (!url) return;
|
|
||||||
|
|
||||||
const mode = presetMode();
|
|
||||||
let query = "";
|
|
||||||
if (url.indexOf("mode=") === -1) {
|
|
||||||
query = (url.indexOf("?") !== -1 ? "&" : "?") + "mode=" + mode;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch(url + query, { credentials: "same-origin" })
|
|
||||||
.then((response) => {
|
|
||||||
if (!response.ok) throw new Error("Failed to load presets");
|
|
||||||
return response.text();
|
|
||||||
})
|
|
||||||
.then((html) => {
|
|
||||||
dropdown.innerHTML = html;
|
|
||||||
// Re-attach delete handlers (list_presets view uses onclick attributes,
|
|
||||||
// but we also need to wire up inline handlers if they use data attributes)
|
|
||||||
setupPresetDeleteHandlers(dropdown);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
dropdown.innerHTML =
|
|
||||||
'<span class="text-sm text-body italic">Presets unavailable</span>';
|
|
||||||
console.error(error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wire up click handlers for preset delete buttons. */
|
|
||||||
function setupPresetDeleteHandlers(container: HTMLElement): void {
|
|
||||||
const deleteLinks = container.querySelectorAll<HTMLAnchorElement>("[data-delete-preset]");
|
|
||||||
deleteLinks.forEach((link) => {
|
|
||||||
link.addEventListener("click", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const deleteUrl = link.getAttribute("href");
|
|
||||||
if (!deleteUrl) return;
|
|
||||||
if (!confirm("Delete this preset?")) return;
|
|
||||||
fetch(deleteUrl, {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "same-origin",
|
|
||||||
headers: { "X-CSRFToken": getCsrfToken() },
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
// Remove the parent <li>
|
|
||||||
const listItem = link.closest("li");
|
|
||||||
if (listItem) listItem.remove();
|
|
||||||
// If no items left, show empty message
|
|
||||||
const list = container.querySelector("ul");
|
|
||||||
if (list && list.querySelectorAll("li").length === 0) {
|
|
||||||
list.innerHTML =
|
|
||||||
'<li class="px-4 py-2 text-sm text-body italic">No saved presets</li>';
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Delete failed:", error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Enable/disable the input text box depending on selected string modifier. */
|
|
||||||
window.toggleStringFilterInput = (radio: HTMLInputElement): void => {
|
|
||||||
const container = radio.closest(".flex-col");
|
|
||||||
if (!container) return;
|
|
||||||
const textInput = container.querySelector<HTMLInputElement>('input[type="text"]');
|
|
||||||
if (!textInput) return;
|
|
||||||
|
|
||||||
// Find the currently checked radio in the container
|
|
||||||
const checkedRadio = container.querySelector<HTMLInputElement>('input[type="radio"]:checked');
|
|
||||||
const value = checkedRadio ? checkedRadio.value : "";
|
|
||||||
|
|
||||||
if (value === "IS_NULL" || value === "NOT_NULL") {
|
|
||||||
textInput.disabled = true;
|
|
||||||
textInput.value = "";
|
|
||||||
textInput.classList.add("opacity-50", "cursor-not-allowed");
|
|
||||||
} else {
|
|
||||||
textInput.disabled = false;
|
|
||||||
textInput.classList.remove("opacity-50", "cursor-not-allowed");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Show the preset name input field and the confirm button. */
|
|
||||||
window.showPresetNameInput = (): void => {
|
|
||||||
const input = document.getElementById("preset-name-input");
|
|
||||||
const saveButton = document.getElementById("save-preset-btn");
|
|
||||||
const confirmButton = document.getElementById("confirm-save-preset-btn");
|
|
||||||
if (input) input.classList.remove("hidden");
|
|
||||||
if (saveButton) saveButton.classList.add("hidden");
|
|
||||||
if (confirmButton) confirmButton.classList.remove("hidden");
|
|
||||||
if (input) input.focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Save the current filter as a named preset. */
|
|
||||||
window.savePreset = (formId: string, _filterInputId: string, saveUrl: string): void => {
|
|
||||||
const input = document.getElementById("preset-name-input") as HTMLInputElement | null;
|
|
||||||
const name = input ? input.value.trim() : "";
|
|
||||||
if (!name) {
|
|
||||||
if (input) input.classList.add("border-red-500");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const form = document.getElementById(formId);
|
|
||||||
const filterObject = form ? buildFilterJSON(form) : {};
|
|
||||||
|
|
||||||
const body = new URLSearchParams();
|
|
||||||
body.append("name", name);
|
|
||||||
body.append("mode", presetMode());
|
|
||||||
body.append("filter", JSON.stringify(filterObject));
|
|
||||||
|
|
||||||
fetch(saveUrl, {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "same-origin",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
"X-CSRFToken": getCsrfToken(),
|
|
||||||
},
|
|
||||||
body: body.toString(),
|
|
||||||
})
|
|
||||||
.then((response) => {
|
|
||||||
if (!response.ok) throw new Error("Save failed");
|
|
||||||
// Reset UI
|
|
||||||
if (input) {
|
|
||||||
input.value = "";
|
|
||||||
input.classList.add("hidden");
|
|
||||||
input.classList.remove("border-red-500");
|
|
||||||
}
|
|
||||||
const saveButton = document.getElementById("save-preset-btn");
|
|
||||||
const confirmButton = document.getElementById("confirm-save-preset-btn");
|
|
||||||
if (saveButton) saveButton.classList.remove("hidden");
|
|
||||||
if (confirmButton) confirmButton.classList.add("hidden");
|
|
||||||
// Refresh the preset list
|
|
||||||
loadPresets();
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Failed to save preset:", error);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Extract CSRF token from the page. */
|
|
||||||
function getCsrfToken(): string {
|
|
||||||
const cookie = document.cookie
|
|
||||||
.split("; ")
|
|
||||||
.find((row) => row.startsWith("csrftoken="));
|
|
||||||
if (cookie) return cookie.split("=")[1];
|
|
||||||
const element = document.querySelector<HTMLInputElement>('input[name="csrfmiddlewaretoken"]');
|
|
||||||
return element ? element.value : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Init on page load ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// ── Inject the search input into a filter form ──
|
|
||||||
function injectSearchInput(form: HTMLElement): void {
|
|
||||||
if (form.querySelector('[name="filter-search"]')) return; // already added
|
|
||||||
const input = document.createElement("input");
|
|
||||||
input.type = "text";
|
|
||||||
input.name = "filter-search";
|
|
||||||
input.placeholder = "Search…";
|
|
||||||
input.className =
|
|
||||||
"block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 mb-4 focus:ring-brand focus:border-brand";
|
|
||||||
// Pre-fill from existing filter JSON
|
|
||||||
const hidden = form.querySelector<HTMLInputElement>('[name="filter"]');
|
|
||||||
if (hidden && hidden.parentNode) {
|
|
||||||
try {
|
|
||||||
const existing = JSON.parse(hidden.value || "{}");
|
|
||||||
if (existing.search && existing.search.value) {
|
|
||||||
input.value = existing.search.value;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore malformed existing filter JSON
|
|
||||||
}
|
|
||||||
hidden.parentNode.insertBefore(input, hidden.nextSibling);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable deselect-on-click behavior for filter radio buttons.
|
|
||||||
*/
|
|
||||||
function setupDeselectableRadios(): void {
|
|
||||||
document.querySelectorAll<DeselectableRadio>('input[type="radio"]').forEach((radio) => {
|
|
||||||
radio.addEventListener("click", function (this: DeselectableRadio) {
|
|
||||||
if (this.wasChecked) {
|
|
||||||
this.checked = false;
|
|
||||||
this.wasChecked = false;
|
|
||||||
this.dispatchEvent(new Event("change", { bubbles: true }));
|
|
||||||
} else {
|
|
||||||
const name = this.getAttribute("name");
|
|
||||||
if (name) {
|
|
||||||
document
|
|
||||||
.querySelectorAll<DeselectableRadio>(`input[type="radio"][name="${name}"]`)
|
|
||||||
.forEach((other) => {
|
|
||||||
other.wasChecked = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.wasChecked = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (radio.checked) {
|
|
||||||
radio.wasChecked = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set up event listeners for string modifier radio buttons.
|
|
||||||
*/
|
|
||||||
function setupStringFilters(): void {
|
|
||||||
document
|
|
||||||
.querySelectorAll<HTMLInputElement>("input[data-string-modifier-radio]")
|
|
||||||
.forEach((radio) => {
|
|
||||||
radio.addEventListener("change", function (this: HTMLInputElement) {
|
|
||||||
window.toggleStringFilterInput(this);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onSwap('[id^="filter-bar-form"]', (form) => {
|
|
||||||
injectSearchInput(form as HTMLElement);
|
|
||||||
setupDeselectableRadios();
|
|
||||||
setupStringFilters();
|
|
||||||
loadPresets();
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
Vendored
-5
@@ -5,10 +5,5 @@ declare global {
|
|||||||
fetchWithHtmxTriggers(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
fetchWithHtmxTriggers(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
||||||
toast(message: string, type?: string): void;
|
toast(message: string, type?: string): void;
|
||||||
readSearchSelect(form: HTMLElement): void;
|
readSearchSelect(form: HTMLElement): void;
|
||||||
applyFilterBar(event: Event): boolean;
|
|
||||||
clearFilterBar(formId: string, filterInputId: string): void;
|
|
||||||
toggleStringFilterInput(radio: HTMLInputElement): void;
|
|
||||||
showPresetNameInput(): void;
|
|
||||||
savePreset(formId: string, filterInputId: string, saveUrl: string): void;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,699 +0,0 @@
|
|||||||
/**
|
|
||||||
* SearchSelect widget — a search box paired with a dropdown of options.
|
|
||||||
* Multi-select renders chosen items as removable pills (inline with the search
|
|
||||||
* box), each backed by a hidden <input>. Single-select renders no pill: the
|
|
||||||
* committed label lives inside the search box (which doubles as a combobox —
|
|
||||||
* focus clears it to search, picking an option fills it), with a lone hidden
|
|
||||||
* <input> carrying the value. Both keep hidden inputs so Django validation works.
|
|
||||||
*
|
|
||||||
* Filter mode (data-search-select-mode="filter", rendered by FilterSelect): value rows
|
|
||||||
* carry +/− buttons that add include (✓) / exclude (✗) pills, plus pinned
|
|
||||||
* modifier pseudo-options ((Any)/(None)) that are mutually exclusive with value
|
|
||||||
* pills. Filter widgets have no hidden inputs; readSearchSelect serialises their
|
|
||||||
* state into data-included / data-excluded / data-modifier for the filter bar.
|
|
||||||
*
|
|
||||||
* Widgets are initialized via onSwap() (utils.js), which covers the initial
|
|
||||||
* page load and every htmx-swapped fragment, once per widget.
|
|
||||||
*
|
|
||||||
* Dynamically-added rows and pills are cloned from hidden <template> elements
|
|
||||||
* the server renders with the same Python components (Pill / SearchSelect /
|
|
||||||
* FilterSelect). The JS only fills in the label slot ([data-search-select-label]), value,
|
|
||||||
* and data-* attributes — so all markup and Tailwind class strings live in one
|
|
||||||
* place (the Python components), never duplicated here.
|
|
||||||
*/
|
|
||||||
import { onSwap } from "./utils.js";
|
|
||||||
|
|
||||||
// The contract for the "search-select:change" CustomEvent this widget emits.
|
|
||||||
// Consumers (e.g. add_purchase.ts) import these types — never redefine them.
|
|
||||||
export interface SearchSelectOption {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
data: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchSelectChangeDetail {
|
|
||||||
name: string;
|
|
||||||
values: string[];
|
|
||||||
last: SearchSelectOption | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The widget stashes per-instance state directly on its DOM elements.
|
|
||||||
interface SearchSelectContainer extends HTMLElement {
|
|
||||||
_searchSelectLabel?: string;
|
|
||||||
_searchSelectDirty?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OptionRow extends HTMLElement {
|
|
||||||
_searchSelectOption?: SearchSelectOption;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FilterPillEntry {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
(() => {
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const DEBOUNCE_MS = 100;
|
|
||||||
|
|
||||||
// Must match Python common/components/filters.py:_PRESENCE_MODIFIERS.
|
|
||||||
// These modifiers are mutually exclusive with value pills — selecting
|
|
||||||
// one clears all value pills. Non-presence modifiers (INCLUDES_ALL,
|
|
||||||
// INCLUDES_ONLY) coexist with value pills.
|
|
||||||
const PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"];
|
|
||||||
|
|
||||||
const initWidget = (containerElement: Element) => {
|
|
||||||
const container = containerElement as SearchSelectContainer;
|
|
||||||
const search = container.querySelector<HTMLInputElement>("[data-search-select-search]");
|
|
||||||
const options = container.querySelector<HTMLElement>("[data-search-select-options]");
|
|
||||||
const pills = container.querySelector<HTMLElement>("[data-search-select-pills]");
|
|
||||||
if (!search || !options || !pills) return;
|
|
||||||
|
|
||||||
const name = container.getAttribute("data-name") ?? "";
|
|
||||||
const searchUrl = container.getAttribute("data-search-url");
|
|
||||||
const isFilter = container.getAttribute("data-search-select-mode") === "filter";
|
|
||||||
const freeText = container.getAttribute("data-search-select-free-text") === "true";
|
|
||||||
const multi = container.getAttribute("data-multi") === "true";
|
|
||||||
const alwaysVisible = container.getAttribute("data-always-visible") === "true";
|
|
||||||
const prefetch = parseInt(container.getAttribute("data-prefetch") ?? "", 10) || 0;
|
|
||||||
const syncUrl = container.getAttribute("data-sync-url") === "true";
|
|
||||||
|
|
||||||
const noResults = options.querySelector<HTMLElement>("[data-search-select-no-results]");
|
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let pendingRequest: AbortController | null = null; // in-flight, so newer queries win
|
|
||||||
let hasPrefetched = false;
|
|
||||||
|
|
||||||
const hasVisibleContent = () => {
|
|
||||||
const optionRows = options.querySelectorAll<HTMLElement>("[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");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const hidePanel = () => {
|
|
||||||
if (!alwaysVisible) options.classList.add("hidden");
|
|
||||||
};
|
|
||||||
|
|
||||||
const setNoResults = (visible: boolean) => {
|
|
||||||
if (!noResults) return;
|
|
||||||
noResults.classList.toggle("hidden", !visible);
|
|
||||||
if (visible) showPanel();
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Highlight tracking (filter mode) ──
|
|
||||||
let highlightedRow: HTMLElement | null = null;
|
|
||||||
|
|
||||||
const highlightOption = (row: HTMLElement | null) => {
|
|
||||||
clearHighlight();
|
|
||||||
if (!row) return;
|
|
||||||
row.setAttribute("data-search-select-highlighted", "");
|
|
||||||
highlightedRow = row;
|
|
||||||
row.scrollIntoView({ block: "nearest" });
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearHighlight = () => {
|
|
||||||
if (highlightedRow) {
|
|
||||||
highlightedRow.removeAttribute("data-search-select-highlighted");
|
|
||||||
highlightedRow = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getVisibleOptions = (): HTMLElement[] => {
|
|
||||||
const all = options.querySelectorAll<HTMLElement>("[data-search-select-option]");
|
|
||||||
return Array.from(all).filter(row => row.style.display !== "none");
|
|
||||||
};
|
|
||||||
|
|
||||||
const autoHighlight = (query: string) => {
|
|
||||||
const visible = getVisibleOptions();
|
|
||||||
if (visible.length === 0) {
|
|
||||||
clearHighlight();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const lower = query.toLowerCase();
|
|
||||||
// 1. Starts-with match
|
|
||||||
for (let i = 0; i < visible.length; i++) {
|
|
||||||
const label = (visible[i].getAttribute("data-label") || "").toLowerCase();
|
|
||||||
if (lower && label.startsWith(lower)) {
|
|
||||||
highlightOption(visible[i]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 2. Substring match (fuzzy-lite)
|
|
||||||
for (let j = 0; j < visible.length; j++) {
|
|
||||||
const subLabel = (visible[j].getAttribute("data-label") || "").toLowerCase();
|
|
||||||
if (lower && subLabel.includes(lower)) {
|
|
||||||
highlightOption(visible[j]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 3. Fallback: first visible option
|
|
||||||
highlightOption(visible[0]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get active values in both form and filter modes
|
|
||||||
const getSelectedValues = (): Set<string> => {
|
|
||||||
const values = new Set<string>();
|
|
||||||
pills.querySelectorAll<HTMLInputElement>('input[type="hidden"]').forEach(input => {
|
|
||||||
values.add(input.value);
|
|
||||||
});
|
|
||||||
pills.querySelectorAll<HTMLElement>("[data-pill]").forEach(pill => {
|
|
||||||
const value = pill.getAttribute("data-value");
|
|
||||||
if (value) values.add(value);
|
|
||||||
});
|
|
||||||
return values;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Render server-fetched rows into the panel ──
|
|
||||||
const renderRows = (items: SearchSelectOption[]) => {
|
|
||||||
const selectedValues = getSelectedValues();
|
|
||||||
const preservedOptions: SearchSelectOption[] = [];
|
|
||||||
|
|
||||||
// Extract existing option data for currently selected values before removing
|
|
||||||
options.querySelectorAll<HTMLElement>("[data-search-select-option]").forEach(row => {
|
|
||||||
const value = row.getAttribute("data-value");
|
|
||||||
if (value && selectedValues.has(value)) {
|
|
||||||
preservedOptions.push(optionFromRow(row));
|
|
||||||
}
|
|
||||||
row.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderedValues = new Set<string>();
|
|
||||||
|
|
||||||
// Render preserved options first (to keep them at the top)
|
|
||||||
preservedOptions.forEach(option => {
|
|
||||||
options.insertBefore(buildRow(option), noResults || null);
|
|
||||||
renderedValues.add(String(option.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();
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Clone a server-rendered <template> prototype by name. The server emits
|
|
||||||
// the mode-appropriate prototypes, so the JS never names a class. ──
|
|
||||||
const cloneTemplate = (templateName: string): HTMLElement | null => {
|
|
||||||
const template = container.querySelector<HTMLTemplateElement>(
|
|
||||||
`template[data-search-select-template="${templateName}"]`
|
|
||||||
);
|
|
||||||
const clone = template?.content.firstElementChild?.cloneNode(true);
|
|
||||||
return (clone as HTMLElement) ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const setLabel = (node: Element, label: string) => {
|
|
||||||
const slot = node.querySelector("[data-search-select-label]");
|
|
||||||
if (slot) slot.textContent = label;
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyData = (node: Element, data: Record<string, string> = {}) => {
|
|
||||||
Object.keys(data).forEach(key => {
|
|
||||||
node.setAttribute(`data-${key}`, data[key]);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build an option row by cloning the "row" template (the same prototype the
|
|
||||||
// server renders, so fetched and pre-rendered rows are identical).
|
|
||||||
const buildRow = (option: SearchSelectOption): HTMLElement | Comment => {
|
|
||||||
const row = cloneTemplate("row") as OptionRow | null;
|
|
||||||
if (!row) return document.createComment("ss-row");
|
|
||||||
row.setAttribute("data-value", option.value);
|
|
||||||
row.setAttribute("data-label", option.label);
|
|
||||||
applyData(row, option.data);
|
|
||||||
setLabel(row, option.label);
|
|
||||||
row._searchSelectOption = option;
|
|
||||||
return row;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── 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. ──
|
|
||||||
const filterRows = (query: string): number => {
|
|
||||||
const lower = query.toLowerCase();
|
|
||||||
let visibleCount = 0;
|
|
||||||
options.querySelectorAll<HTMLElement>("[data-search-select-option]").forEach(item => {
|
|
||||||
const label = (item.getAttribute("data-label") || "").toLowerCase();
|
|
||||||
const match = label.includes(lower);
|
|
||||||
item.style.display = match ? "" : "none";
|
|
||||||
if (match) visibleCount += 1;
|
|
||||||
});
|
|
||||||
return visibleCount;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Fetch matching rows from the server. The previous in-flight request is
|
|
||||||
// aborted so a slower earlier response can never overwrite a newer one. ──
|
|
||||||
const fetchFromServer = (query: string) => {
|
|
||||||
if (pendingRequest) pendingRequest.abort();
|
|
||||||
pendingRequest = new AbortController();
|
|
||||||
let url = `${searchUrl}?q=${encodeURIComponent(query)}`;
|
|
||||||
if (prefetch && !query) url += `&limit=${prefetch}`;
|
|
||||||
fetch(url, { credentials: "same-origin", signal: pendingRequest.signal })
|
|
||||||
.then(response => response.json())
|
|
||||||
.then((items: SearchSelectOption[]) => {
|
|
||||||
pendingRequest = null;
|
|
||||||
renderRows(items);
|
|
||||||
// Re-apply the live query: the box may hold more text than was sent.
|
|
||||||
setNoResults(filterRows(search.value.trim()) === 0);
|
|
||||||
autoHighlight(search.value.trim());
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
if (error?.name === "AbortError") return; // superseded
|
|
||||||
pendingRequest = null;
|
|
||||||
setNoResults(true);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// In free-text mode the typed text is the value itself: there is no
|
|
||||||
// backing list, so we rebuild a single ephemeral option row reflecting the
|
|
||||||
// current query so the +/− buttons (or Enter) can commit it as a pill.
|
|
||||||
const rebuildFreeTextRow = (query: string) => {
|
|
||||||
options.querySelectorAll("[data-search-select-option]").forEach(row => row.remove());
|
|
||||||
if (!query) {
|
|
||||||
setNoResults(false);
|
|
||||||
clearHighlight();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const row = buildRow({ value: query, label: query, data: {} });
|
|
||||||
options.insertBefore(row, noResults || null);
|
|
||||||
setNoResults(false);
|
|
||||||
highlightOption(row as HTMLElement);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Called on every keystroke. With a search_url, filter the loaded window
|
|
||||||
// instantly (zero latency) and debounce a server request for the rest;
|
|
||||||
// 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,
|
|
||||||
// so the client-side filter is authoritative.
|
|
||||||
const runSearch = () => {
|
|
||||||
const query = search.value.trim();
|
|
||||||
if (freeText) {
|
|
||||||
rebuildFreeTextRow(query);
|
|
||||||
showPanel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (searchUrl) {
|
|
||||||
filterRows(query);
|
|
||||||
setNoResults(false);
|
|
||||||
if (debounceTimer) clearTimeout(debounceTimer);
|
|
||||||
debounceTimer = setTimeout(() => {
|
|
||||||
fetchFromServer(query);
|
|
||||||
}, DEBOUNCE_MS);
|
|
||||||
} else {
|
|
||||||
setNoResults(filterRows(query) === 0);
|
|
||||||
}
|
|
||||||
autoHighlight(query);
|
|
||||||
showPanel();
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Single-select combobox: the search box shows the committed label;
|
|
||||||
// focusing clears it to search, blurring restores it (or deselects). ──
|
|
||||||
if (!multi) container._searchSelectLabel = search.value;
|
|
||||||
|
|
||||||
search.addEventListener("focus", () => {
|
|
||||||
if (!multi) {
|
|
||||||
// Hide the committed label so the box becomes a fresh search field.
|
|
||||||
search.value = "";
|
|
||||||
container._searchSelectDirty = false;
|
|
||||||
}
|
|
||||||
if (freeText) {
|
|
||||||
rebuildFreeTextRow(search.value.trim());
|
|
||||||
} else if (searchUrl) {
|
|
||||||
if (prefetch && !hasPrefetched) {
|
|
||||||
// Seed the window immediately on first open (not debounced).
|
|
||||||
hasPrefetched = true;
|
|
||||||
fetchFromServer("");
|
|
||||||
} else {
|
|
||||||
// Show whatever is already loaded; the server decides no-results.
|
|
||||||
filterRows(search.value.trim());
|
|
||||||
setNoResults(false);
|
|
||||||
autoHighlight(search.value.trim());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setNoResults(filterRows(search.value.trim()) === 0);
|
|
||||||
autoHighlight(search.value.trim());
|
|
||||||
}
|
|
||||||
showPanel();
|
|
||||||
});
|
|
||||||
|
|
||||||
search.addEventListener("input", () => {
|
|
||||||
clearHighlight();
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!multi) {
|
|
||||||
search.addEventListener("blur", () => {
|
|
||||||
// Defer so an option click (which fires before blur settles) wins.
|
|
||||||
setTimeout(() => {
|
|
||||||
if (container._searchSelectDirty && search.value.trim() === "") {
|
|
||||||
// User intentionally cleared the box → deselect.
|
|
||||||
pills.innerHTML = "";
|
|
||||||
container._searchSelectLabel = "";
|
|
||||||
emitChange(null);
|
|
||||||
} else {
|
|
||||||
// Focused-and-left, or typed a partial query without picking →
|
|
||||||
// restore the committed label (no-op right after a selection).
|
|
||||||
search.value = container._searchSelectLabel || "";
|
|
||||||
}
|
|
||||||
}, 120);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Keyboard navigation (both form and filter modes) ──
|
|
||||||
search.addEventListener("keydown", (event) => {
|
|
||||||
const { key } = event;
|
|
||||||
|
|
||||||
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 downIndex = highlightedRow ? visible.indexOf(highlightedRow) : -1;
|
|
||||||
highlightOption(visible[(downIndex + 1) % visible.length]);
|
|
||||||
} else if (key === "ArrowUp") {
|
|
||||||
event.preventDefault();
|
|
||||||
showPanel();
|
|
||||||
const upIndex = highlightedRow ? visible.indexOf(highlightedRow) : -1;
|
|
||||||
highlightOption(visible[(upIndex - 1 + visible.length) % visible.length]);
|
|
||||||
} else if (key === "Enter") {
|
|
||||||
if (highlightedRow) {
|
|
||||||
event.preventDefault();
|
|
||||||
const option = optionFromRow(highlightedRow);
|
|
||||||
if (isFilter) {
|
|
||||||
addFilterPill(option, "include");
|
|
||||||
search.value = "";
|
|
||||||
} else {
|
|
||||||
selectOption(option);
|
|
||||||
}
|
|
||||||
clearHighlight();
|
|
||||||
hidePanel();
|
|
||||||
}
|
|
||||||
} else if (key === "Escape") {
|
|
||||||
clearHighlight();
|
|
||||||
hidePanel();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clicking an option must not blur the input before the click selects.
|
|
||||||
options.addEventListener("mousedown", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Option click → select (form mode) or include/exclude (filter mode) ──
|
|
||||||
options.addEventListener("click", (event) => {
|
|
||||||
if (isFilter) {
|
|
||||||
handleFilterOptionClick(event);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const row = (event.target as Element).closest<HTMLElement>("[data-search-select-option]");
|
|
||||||
if (!row) return;
|
|
||||||
selectOption(optionFromRow(row));
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleFilterOptionClick = (event: MouseEvent) => {
|
|
||||||
const target = event.target as Element;
|
|
||||||
// Pinned modifier pseudo-option → set the (mutually exclusive) modifier.
|
|
||||||
const modifierRow = target.closest<HTMLElement>("[data-search-select-modifier-option]");
|
|
||||||
if (modifierRow) {
|
|
||||||
setModifier(
|
|
||||||
modifierRow.getAttribute("data-search-select-modifier-option") ?? "",
|
|
||||||
modifierRow.getAttribute("data-label") ?? ""
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Include / exclude button on a value row.
|
|
||||||
const button = target.closest<HTMLElement>("[data-search-select-action]");
|
|
||||||
if (button) {
|
|
||||||
const row = button.closest<HTMLElement>("[data-search-select-option]");
|
|
||||||
if (!row) return;
|
|
||||||
addFilterPill(optionFromRow(row), button.getAttribute("data-search-select-action") ?? "include");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Click on the option row itself → include.
|
|
||||||
const optionRow = target.closest<HTMLElement>("[data-search-select-option]");
|
|
||||||
if (optionRow) {
|
|
||||||
addFilterPill(optionFromRow(optionRow), "include");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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
|
|
||||||
// with value pills. Non-presence modifiers (INCLUDES_ALL / INCLUDES_ONLY)
|
|
||||||
// persist alongside value pills.
|
|
||||||
const addFilterPill = (option: SearchSelectOption, kind: string) => {
|
|
||||||
const modifierPill = pills.querySelector("[data-search-select-modifier]");
|
|
||||||
if (modifierPill) {
|
|
||||||
const modifierValue = modifierPill.getAttribute("data-search-select-modifier") ?? "";
|
|
||||||
if (PRESENCE_MODIFIERS.includes(modifierValue)) {
|
|
||||||
clearModifier();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const existing = pills.querySelector(
|
|
||||||
`[data-pill][data-value="${cssEscape(option.value)}"]`
|
|
||||||
);
|
|
||||||
if (existing) existing.remove();
|
|
||||||
pills.appendChild(buildFilterValuePill(option, kind));
|
|
||||||
search.value = "";
|
|
||||||
emitChange(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildFilterValuePill = (option: SearchSelectOption, kind: string): HTMLElement => {
|
|
||||||
const pill = cloneTemplate(kind === "include" ? "pill-include" : "pill-exclude")!;
|
|
||||||
pill.setAttribute("data-value", option.value);
|
|
||||||
pill.setAttribute("data-label", option.label);
|
|
||||||
applyData(pill, option.data);
|
|
||||||
setLabel(pill, option.label);
|
|
||||||
return pill;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set the modifier pill. Presence modifiers (NOT_NULL / IS_NULL) clear all
|
|
||||||
// value pills — they are mutually exclusive. Non-presence modifiers
|
|
||||||
// (INCLUDES_ALL / INCLUDES_ONLY) are prepended before existing value pills.
|
|
||||||
const setModifier = (modifierValue: string, label: string) => {
|
|
||||||
// Remove any existing modifier pill to avoid duplicates.
|
|
||||||
clearModifierPill();
|
|
||||||
if (PRESENCE_MODIFIERS.includes(modifierValue)) {
|
|
||||||
pills.innerHTML = "";
|
|
||||||
}
|
|
||||||
const pill = cloneTemplate("pill-modifier")!;
|
|
||||||
pill.setAttribute("data-search-select-modifier", modifierValue);
|
|
||||||
setLabel(pill, label);
|
|
||||||
pills.insertBefore(pill, pills.firstChild);
|
|
||||||
container.setAttribute("data-modifier", modifierValue);
|
|
||||||
hidePanel();
|
|
||||||
emitChange(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Remove the modifier pill and its container attribute. Safe to call when
|
|
||||||
// there is no modifier pill (no-op). Does not touch value pills.
|
|
||||||
const clearModifierPill = () => {
|
|
||||||
const modifierPill = pills.querySelector("[data-search-select-modifier]");
|
|
||||||
if (modifierPill) modifierPill.remove();
|
|
||||||
container.removeAttribute("data-modifier");
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearModifier = () => {
|
|
||||||
clearModifierPill();
|
|
||||||
};
|
|
||||||
|
|
||||||
const optionFromRow = (row: HTMLElement): SearchSelectOption => {
|
|
||||||
const optionRow = row as OptionRow;
|
|
||||||
if (optionRow._searchSelectOption) return optionRow._searchSelectOption;
|
|
||||||
const data: Record<string, string> = {};
|
|
||||||
Object.keys(row.dataset).forEach(key => {
|
|
||||||
if (key !== "value" && key !== "label" && key !== "ssOption") {
|
|
||||||
data[key] = row.dataset[key] ?? "";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
value: row.getAttribute("data-value") ?? "",
|
|
||||||
label: row.getAttribute("data-label") ?? "",
|
|
||||||
data,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectOption = (option: SearchSelectOption) => {
|
|
||||||
if (multi) {
|
|
||||||
if (!pills.querySelector(`input[value="${cssEscape(option.value)}"]`)) {
|
|
||||||
addPill(option);
|
|
||||||
}
|
|
||||||
search.value = "";
|
|
||||||
} else {
|
|
||||||
// Single-select: no pill — show the label in the search box and keep a
|
|
||||||
// lone hidden input under [data-search-select-pills] for submission.
|
|
||||||
pills.innerHTML = "";
|
|
||||||
pills.appendChild(buildHidden(option.value));
|
|
||||||
search.value = option.label;
|
|
||||||
container._searchSelectLabel = option.label;
|
|
||||||
container._searchSelectDirty = false;
|
|
||||||
hidePanel();
|
|
||||||
}
|
|
||||||
emitChange(option);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addPill = (option: SearchSelectOption) => {
|
|
||||||
const pill = buildPill(option);
|
|
||||||
if (pill) pills.appendChild(pill);
|
|
||||||
pills.appendChild(buildHidden(option.value));
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildPill = (option: SearchSelectOption): HTMLElement | null => {
|
|
||||||
const pill = cloneTemplate("pill");
|
|
||||||
if (!pill) return null;
|
|
||||||
pill.setAttribute("data-value", option.value);
|
|
||||||
applyData(pill, option.data);
|
|
||||||
setLabel(pill, option.label);
|
|
||||||
return pill;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildHidden = (value: string): HTMLInputElement => {
|
|
||||||
const input = document.createElement("input");
|
|
||||||
input.type = "hidden";
|
|
||||||
input.name = name;
|
|
||||||
input.value = value;
|
|
||||||
return input;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Pill × → remove ──
|
|
||||||
pills.addEventListener("click", (event) => {
|
|
||||||
const removeButton = (event.target as Element).closest("[data-pill-remove]");
|
|
||||||
if (!removeButton) return;
|
|
||||||
const pill = removeButton.closest("[data-pill]");
|
|
||||||
if (!pill) return;
|
|
||||||
if (isFilter) {
|
|
||||||
// Filter pills have no hidden input.
|
|
||||||
if (pill.hasAttribute("data-search-select-modifier")) {
|
|
||||||
clearModifierPill();
|
|
||||||
} else {
|
|
||||||
pill.remove();
|
|
||||||
}
|
|
||||||
emitChange(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const value = pill.getAttribute("data-value");
|
|
||||||
pill.remove();
|
|
||||||
const hidden = pills.querySelector(`input[value="${cssEscape(value)}"]`);
|
|
||||||
if (hidden) hidden.remove();
|
|
||||||
emitChange(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentValues = (): string[] => {
|
|
||||||
return Array.from(
|
|
||||||
pills.querySelectorAll<HTMLInputElement>('input[type="hidden"]')
|
|
||||||
).map(input => input.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const emitChange = (last: SearchSelectOption | null) => {
|
|
||||||
const values = currentValues();
|
|
||||||
if (syncUrl) syncToUrl(values);
|
|
||||||
container.dispatchEvent(
|
|
||||||
new CustomEvent<SearchSelectChangeDetail>("search-select:change", {
|
|
||||||
bubbles: true,
|
|
||||||
detail: { name, values, last },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const syncToUrl = (values: string[]) => {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
params.delete(name);
|
|
||||||
values.forEach(value => {
|
|
||||||
params.append(name, value);
|
|
||||||
});
|
|
||||||
const queryString = params.toString();
|
|
||||||
history.replaceState(null, "", queryString ? `?${queryString}` : window.location.pathname);
|
|
||||||
};
|
|
||||||
|
|
||||||
// On init, restore from URL params if the server supplied no selected pills.
|
|
||||||
if (syncUrl && !pills.querySelector("[data-pill]")) {
|
|
||||||
const initial = new URLSearchParams(window.location.search).getAll(name);
|
|
||||||
initial.forEach(value => {
|
|
||||||
addPill({ value, label: value, data: {} });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Close panel on outside click ──
|
|
||||||
document.addEventListener("click", (event) => {
|
|
||||||
if (!container.contains(event.target as Node)) hidePanel();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Minimal escape for use inside an attribute-value selector. */
|
|
||||||
const cssEscape = (value: string | null): string => String(value).replace(/["\\]/g, "\\$&");
|
|
||||||
|
|
||||||
// Serialise each widget's current state onto data-* attributes for the caller.
|
|
||||||
// Form widgets expose data-values (the submitted hidden-input values); filter
|
|
||||||
// widgets expose data-included / data-excluded / data-modifier for the filter
|
|
||||||
// bar to read.
|
|
||||||
window.readSearchSelect = (form: HTMLElement) => {
|
|
||||||
form.querySelectorAll<HTMLElement>("[data-search-select]").forEach(container => {
|
|
||||||
const pills = container.querySelector<HTMLElement>("[data-search-select-pills]");
|
|
||||||
if (container.getAttribute("data-search-select-mode") === "filter") {
|
|
||||||
const included: FilterPillEntry[] = [];
|
|
||||||
const excluded: FilterPillEntry[] = [];
|
|
||||||
let modifier = "";
|
|
||||||
if (pills) {
|
|
||||||
pills.querySelectorAll<HTMLElement>("[data-pill]").forEach(pill => {
|
|
||||||
const pillModifier = pill.getAttribute("data-search-select-modifier");
|
|
||||||
if (pillModifier) {
|
|
||||||
modifier = pillModifier; // last modifier pill wins
|
|
||||||
return; // skip value extraction for this pill
|
|
||||||
}
|
|
||||||
const value = pill.getAttribute("data-value") ?? "";
|
|
||||||
const label = pill.getAttribute("data-label") || "";
|
|
||||||
if (pill.getAttribute("data-search-select-type") === "exclude") {
|
|
||||||
excluded.push({ id: value, label });
|
|
||||||
} else {
|
|
||||||
included.push({ id: value, label });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
container.setAttribute("data-included", JSON.stringify(included));
|
|
||||||
container.setAttribute("data-excluded", JSON.stringify(excluded));
|
|
||||||
if (modifier) container.setAttribute("data-modifier", modifier);
|
|
||||||
else container.removeAttribute("data-modifier");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const values = pills
|
|
||||||
? Array.from(pills.querySelectorAll<HTMLInputElement>('input[type="hidden"]')).map(input => input.value)
|
|
||||||
: [];
|
|
||||||
container.setAttribute("data-values", JSON.stringify(values));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
onSwap("[data-search-select]", initWidget);
|
|
||||||
})();
|
|
||||||
Reference in New Issue
Block a user