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:
2026-06-20 14:22:59 +02:00
parent 4652f1ff55
commit 82416e149d
33 changed files with 2301 additions and 2168 deletions
+43
View File
@@ -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,
+10 -20
View File
@@ -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
View File
@@ -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
+38 -60
View File
@@ -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).
+3 -3
View File
@@ -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")}
+3 -3
View File
@@ -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")}
+5 -5
View File
@@ -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"]'
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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")}
+3 -4
View File
@@ -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 + '"'));
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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"),
) )
+2 -2
View File
@@ -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"),
) )
+4 -2
View File
@@ -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")
), ),
) )
+2 -2
View File
@@ -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")),
) )
+2 -2
View File
@@ -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)
+9 -9
View File
@@ -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)
+6 -6
View File
@@ -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
+9 -7
View File
@@ -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):
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
-539
View File
@@ -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));
})();
+557
View File
@@ -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);
+437
View File
@@ -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);
+713
View File
@@ -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 -2
View File
@@ -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();
-503
View File
@@ -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();
});
})();
-5
View File
@@ -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;
} }
} }
-699
View File
@@ -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);
})();