Convert onSwap widgets to custom elements (issue #18)
Replaces the four onSwap-based widgets with TypeScript custom elements following the pattern from PR #16. Each widget gets a class extending HTMLElement with connectedCallback/disconnectedCallback, typed props via register_element + gen_element_types codegen, and lives in ts/elements/. - range-slider: RangeSliderElement; Python uses _RangeSlider builder - date-range-picker: DateRangePickerElement; Python uses _DateRangePicker builder - search-select: SearchSelectElement; Python uses _SearchSelect builder; data-* attrs become plain attrs (data-name -> name, data-search-url -> search-url, etc.) - filter-bar: FilterBarElement; props carry preset URLs; onclick/onsubmit attrs replaced with data-filter-bar-* sentinel attrs; all window.* globals removed Deletes ts/range_slider.ts, ts/search_select.ts, ts/date_range_picker.ts, ts/filter_bar.ts. Updates all tests and e2e pages to use the new element selectors and script paths (dist/elements/<tag>.js). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -146,6 +146,49 @@ register_element("selection-fields", "SelectionFields", SelectionFieldsProps)
|
||||
_SelectionFields = custom_element_builder("selection-fields")
|
||||
|
||||
|
||||
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(
|
||||
*,
|
||||
source: str,
|
||||
|
||||
@@ -17,13 +17,11 @@ widget into a ``DateCriterion`` unchanged. All behaviour is wired by
|
||||
``ts/date_range_picker.ts`` (compiled to ``dist/date_range_picker.js``).
|
||||
"""
|
||||
|
||||
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.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 = (
|
||||
"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 "
|
||||
@@ -335,20 +333,12 @@ def DateRangePicker(
|
||||
serializer needs no changes. ``min_value`` / ``max_value`` are ISO
|
||||
``YYYY-MM-DD`` strings used to prefill both the segments and the hidden
|
||||
inputs."""
|
||||
attributes: list[HTMLAttribute] = [
|
||||
("class", "date-range-picker relative"),
|
||||
("data-date-range-picker", ""),
|
||||
("data-input-name-prefix", input_name_prefix),
|
||||
return _DateRangePicker(class_="relative")[
|
||||
DateRangeField(
|
||||
label=label,
|
||||
input_name_prefix=input_name_prefix,
|
||||
min_value=min_value,
|
||||
max_value=max_value,
|
||||
),
|
||||
DateRangeCalendar(input_name_prefix=input_name_prefix),
|
||||
]
|
||||
return Div(
|
||||
attributes=attributes,
|
||||
children=[
|
||||
DateRangeField(
|
||||
label=label,
|
||||
input_name_prefix=input_name_prefix,
|
||||
min_value=min_value,
|
||||
max_value=max_value,
|
||||
),
|
||||
DateRangeCalendar(input_name_prefix=input_name_prefix),
|
||||
],
|
||||
).with_media(_DATE_RANGE_MEDIA)
|
||||
|
||||
+203
-217
@@ -4,7 +4,8 @@ from typing import NamedTuple
|
||||
|
||||
from django.db import models
|
||||
|
||||
from 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.primitives import Checkbox, Div, Input, Label, Radio, Span
|
||||
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"
|
||||
|
||||
|
||||
# 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:
|
||||
if not filter_json:
|
||||
return {}
|
||||
@@ -340,157 +334,157 @@ def RangeSlider(
|
||||
point_mode = bool(min_value and max_value and min_value == max_value)
|
||||
initial_mode = "point" if point_mode else "range"
|
||||
|
||||
return Div(
|
||||
attributes=[("class", "range-slider-block mb-4")],
|
||||
children=[
|
||||
# ── Label row ──
|
||||
Div(
|
||||
attributes=[("class", "flex items-center gap-2 mb-1")],
|
||||
children=[
|
||||
# The field label is rendered by the _filter_field wrapper.
|
||||
# This composite widget has no single labelable root, so the
|
||||
# label carries no `for` (the two inputs are named below).
|
||||
Input(
|
||||
attributes=[
|
||||
("type", "number"),
|
||||
("name", min_input_id),
|
||||
("id", min_input_id),
|
||||
("value", min_value),
|
||||
("placeholder", min_placeholder),
|
||||
(
|
||||
"class",
|
||||
f"{_RANGE_SLIDER_INPUT_CLASS}"
|
||||
+ (" hidden" if point_mode else ""),
|
||||
),
|
||||
],
|
||||
),
|
||||
Span(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"range-dash text-body text-sm"
|
||||
+ (" hidden" if point_mode else ""),
|
||||
),
|
||||
],
|
||||
children=["–"],
|
||||
),
|
||||
Input(
|
||||
attributes=[
|
||||
("type", "number"),
|
||||
("name", max_input_id),
|
||||
("id", max_input_id),
|
||||
("value", max_value),
|
||||
("placeholder", max_placeholder),
|
||||
("class", _RANGE_SLIDER_INPUT_CLASS),
|
||||
],
|
||||
),
|
||||
Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
(
|
||||
"class",
|
||||
"range-mode-toggle p-1 text-body hover:text-heading "
|
||||
"rounded cursor-pointer shrink-0",
|
||||
),
|
||||
(
|
||||
"title",
|
||||
"Toggle between range and single value",
|
||||
),
|
||||
(
|
||||
"aria-label",
|
||||
"Toggle between range and single value",
|
||||
),
|
||||
],
|
||||
children=[
|
||||
Span(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"range-mode-icon-range"
|
||||
+ (" hidden" if point_mode else ""),
|
||||
),
|
||||
],
|
||||
children=[Safe(_RANGE_ICON_SVG)],
|
||||
),
|
||||
Span(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"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),
|
||||
("data-min", str(range_min)),
|
||||
("data-max", str(range_max)),
|
||||
("data-step", str(step)),
|
||||
],
|
||||
children=[
|
||||
Div(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"absolute top-1/2 -translate-y-1/2 w-full h-2 "
|
||||
"rounded-full bg-neutral-quaternary",
|
||||
),
|
||||
],
|
||||
),
|
||||
Div(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"range-track-fill absolute top-1/2 -translate-y-1/2 "
|
||||
"h-2 bg-brand rounded-full",
|
||||
),
|
||||
("style", "left:0;width:100%"),
|
||||
],
|
||||
),
|
||||
# Min handle (hidden in point mode via JS)
|
||||
Div(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"range-handle range-handle-min absolute top-1/2 "
|
||||
"-translate-y-1/2 w-5 h-5 bg-brand rounded-full "
|
||||
"border-2 border-white shadow cursor-pointer "
|
||||
"hover:scale-110 transition-transform",
|
||||
),
|
||||
("data-target", min_input_id),
|
||||
(
|
||||
"style",
|
||||
"left:0" + (";display:none" if point_mode else ""),
|
||||
),
|
||||
],
|
||||
),
|
||||
# Max handle
|
||||
Div(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"range-handle range-handle-max absolute top-1/2 "
|
||||
"-translate-y-1/2 w-5 h-5 bg-brand rounded-full "
|
||||
"border-2 border-white shadow cursor-pointer "
|
||||
"hover:scale-110 transition-transform",
|
||||
),
|
||||
("data-target", max_input_id),
|
||||
("style", "left:100%"),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
).with_media(_RANGE_SLIDER_MEDIA)
|
||||
return _RangeSlider(
|
||||
min=range_min,
|
||||
max=range_max,
|
||||
step=int(step),
|
||||
mode=initial_mode,
|
||||
class_="mb-4 block",
|
||||
)[
|
||||
# ── Label row ──
|
||||
Div(
|
||||
attributes=[("class", "flex items-center gap-2 mb-1")],
|
||||
children=[
|
||||
# The field label is rendered by the _filter_field wrapper.
|
||||
# This composite widget has no single labelable root, so the
|
||||
# label carries no `for` (the two inputs are named below).
|
||||
Input(
|
||||
attributes=[
|
||||
("type", "number"),
|
||||
("name", min_input_id),
|
||||
("id", min_input_id),
|
||||
("value", min_value),
|
||||
("placeholder", min_placeholder),
|
||||
(
|
||||
"class",
|
||||
f"{_RANGE_SLIDER_INPUT_CLASS}"
|
||||
+ (" hidden" if point_mode else ""),
|
||||
),
|
||||
],
|
||||
),
|
||||
Span(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"range-dash text-body text-sm"
|
||||
+ (" hidden" if point_mode else ""),
|
||||
),
|
||||
],
|
||||
children=["–"],
|
||||
),
|
||||
Input(
|
||||
attributes=[
|
||||
("type", "number"),
|
||||
("name", max_input_id),
|
||||
("id", max_input_id),
|
||||
("value", max_value),
|
||||
("placeholder", max_placeholder),
|
||||
("class", _RANGE_SLIDER_INPUT_CLASS),
|
||||
],
|
||||
),
|
||||
Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
(
|
||||
"class",
|
||||
"range-mode-toggle p-1 text-body hover:text-heading "
|
||||
"rounded cursor-pointer shrink-0",
|
||||
),
|
||||
(
|
||||
"title",
|
||||
"Toggle between range and single value",
|
||||
),
|
||||
(
|
||||
"aria-label",
|
||||
"Toggle between range and single value",
|
||||
),
|
||||
],
|
||||
children=[
|
||||
Span(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"range-mode-icon-range"
|
||||
+ (" hidden" if point_mode else ""),
|
||||
),
|
||||
],
|
||||
children=[Safe(_RANGE_ICON_SVG)],
|
||||
),
|
||||
Span(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"range-mode-icon-point"
|
||||
+ ("" if point_mode else " hidden"),
|
||||
),
|
||||
],
|
||||
children=[Safe(_POINT_ICON_SVG)],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
# ── Track row ──
|
||||
Div(
|
||||
attributes=[
|
||||
("class", "relative h-10 w-5/6 select-none mt-1"),
|
||||
("data-range-track", ""),
|
||||
],
|
||||
children=[
|
||||
Div(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"absolute top-1/2 -translate-y-1/2 w-full h-2 "
|
||||
"rounded-full bg-neutral-quaternary",
|
||||
),
|
||||
],
|
||||
),
|
||||
Div(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"range-track-fill absolute top-1/2 -translate-y-1/2 "
|
||||
"h-2 bg-brand rounded-full",
|
||||
),
|
||||
("style", "left:0;width:100%"),
|
||||
],
|
||||
),
|
||||
# Min handle (hidden in point mode via JS)
|
||||
Div(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"range-handle range-handle-min absolute top-1/2 "
|
||||
"-translate-y-1/2 w-5 h-5 bg-brand rounded-full "
|
||||
"border-2 border-white shadow cursor-pointer "
|
||||
"hover:scale-110 transition-transform",
|
||||
),
|
||||
("data-target", min_input_id),
|
||||
(
|
||||
"style",
|
||||
"left:0" + (";display:none" if point_mode else ""),
|
||||
),
|
||||
],
|
||||
),
|
||||
# Max handle
|
||||
Div(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"range-handle range-handle-max absolute top-1/2 "
|
||||
"-translate-y-1/2 w-5 h-5 bg-brand rounded-full "
|
||||
"border-2 border-white shadow cursor-pointer "
|
||||
"hover:scale-110 transition-transform",
|
||||
),
|
||||
("data-target", max_input_id),
|
||||
("style", "left:100%"),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
_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(
|
||||
attributes=[("class", "flex gap-3 items-center")],
|
||||
children=[
|
||||
@@ -609,10 +603,7 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> Node:
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
(
|
||||
"onclick",
|
||||
f"clearFilterBar('{_FILTER_FORM_ID}', '{_FILTER_INPUT_ID}')",
|
||||
),
|
||||
("data-filter-bar-clear", ""),
|
||||
(
|
||||
"class",
|
||||
"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=[
|
||||
("type", "text"),
|
||||
("id", "preset-name-input"),
|
||||
("data-filter-bar-preset-name", ""),
|
||||
("placeholder", "Preset name..."),
|
||||
(
|
||||
"class",
|
||||
@@ -647,7 +639,7 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> Node:
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("id", "save-preset-btn"),
|
||||
("onclick", "showPresetNameInput()"),
|
||||
("data-filter-bar-save", ""),
|
||||
(
|
||||
"class",
|
||||
"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=[
|
||||
("type", "button"),
|
||||
("id", "confirm-save-preset-btn"),
|
||||
(
|
||||
"onclick",
|
||||
f"savePreset('{_FILTER_FORM_ID}', '{_FILTER_INPUT_ID}', '{preset_save_url}')",
|
||||
),
|
||||
("data-filter-bar-confirm-save", ""),
|
||||
(
|
||||
"class",
|
||||
"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=[
|
||||
("id", "preset-dropdown"),
|
||||
("class", "relative"),
|
||||
("data-preset-list-url", preset_list_url),
|
||||
],
|
||||
children=[
|
||||
Span(
|
||||
@@ -702,14 +690,11 @@ class _FilterBarBase(BaseComponent):
|
||||
Subclasses implement ``build_fields()`` returning the per-entity body
|
||||
(grids, sliders, checkboxes); this base wraps it in the collapse toggle,
|
||||
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
|
||||
chrome; widget media (search_select.js, range_slider.js,
|
||||
date_range_picker.js) bubbles up from the contained widgets via the node
|
||||
row. ``filter-bar.js`` (declared via ``_FilterBarElement``) wires the
|
||||
chrome; widget media bubbles up from the contained widgets via the node
|
||||
tree, so the view never threads ``scripts=`` by hand.
|
||||
"""
|
||||
|
||||
media = _FILTER_BAR_MEDIA
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
filter_json: str = "",
|
||||
@@ -726,47 +711,49 @@ class _FilterBarBase(BaseComponent):
|
||||
raise NotImplementedError
|
||||
|
||||
def render(self) -> Node:
|
||||
return Div(
|
||||
attributes=[("id", "filter-bar"), ("class", "mb-6")],
|
||||
children=[
|
||||
_filter_collapse_button(),
|
||||
Div(
|
||||
attributes=[
|
||||
("id", "filter-bar-body"),
|
||||
(
|
||||
"class",
|
||||
"hidden border border-default-medium rounded-base p-4 "
|
||||
"bg-neutral-secondary-medium/50",
|
||||
),
|
||||
],
|
||||
children=[
|
||||
Element(
|
||||
"form",
|
||||
attributes=[
|
||||
("id", _FILTER_FORM_ID),
|
||||
("onsubmit", "return applyFilterBar(event)"),
|
||||
],
|
||||
children=[
|
||||
Input(
|
||||
attributes=[
|
||||
("type", "hidden"),
|
||||
("id", _FILTER_INPUT_ID),
|
||||
("name", "filter"),
|
||||
# NB: attribute values are escaped, so the
|
||||
# raw JSON passes through (no double-escape).
|
||||
("value", self.filter_json),
|
||||
],
|
||||
),
|
||||
*self.build_fields(),
|
||||
_filter_action_row(
|
||||
self.preset_list_url, self.preset_save_url
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
return _FilterBarElement(
|
||||
preset_list_url=self.preset_list_url,
|
||||
preset_save_url=self.preset_save_url,
|
||||
)[
|
||||
Div(
|
||||
attributes=[("id", "filter-bar"), ("class", "mb-6")],
|
||||
children=[
|
||||
_filter_collapse_button(),
|
||||
Div(
|
||||
attributes=[
|
||||
("id", "filter-bar-body"),
|
||||
(
|
||||
"class",
|
||||
"hidden border border-default-medium rounded-base p-4 "
|
||||
"bg-neutral-secondary-medium/50",
|
||||
),
|
||||
],
|
||||
children=[
|
||||
Element(
|
||||
"form",
|
||||
attributes=[
|
||||
("id", _FILTER_FORM_ID),
|
||||
],
|
||||
children=[
|
||||
Input(
|
||||
attributes=[
|
||||
("type", "hidden"),
|
||||
("id", _FILTER_INPUT_ID),
|
||||
("name", "filter"),
|
||||
# NB: attribute values are escaped, so the
|
||||
# raw JSON passes through (no double-escape).
|
||||
("value", self.filter_json),
|
||||
],
|
||||
),
|
||||
*self.build_fields(),
|
||||
_filter_action_row(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class FilterBar(_FilterBarBase):
|
||||
@@ -1557,7 +1544,6 @@ def StringFilter(
|
||||
value=mod_val,
|
||||
attributes=[
|
||||
("data-string-modifier-radio", ""),
|
||||
("onclick", "toggleStringFilterInput(this)"),
|
||||
],
|
||||
)
|
||||
for mod_val, lbl in options
|
||||
|
||||
@@ -33,7 +33,8 @@ from collections.abc import Callable, Iterable
|
||||
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 (
|
||||
DISABLED_WITHIN_CLASS,
|
||||
Div,
|
||||
@@ -43,9 +44,6 @@ from common.components.primitives import (
|
||||
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):
|
||||
value: str | int
|
||||
@@ -210,27 +208,20 @@ def _option_row(option: SearchSelectOption) -> Node:
|
||||
)
|
||||
|
||||
|
||||
def _combobox_shell(
|
||||
def _combobox_children(
|
||||
*,
|
||||
container_attributes: Attributes,
|
||||
pills: Node,
|
||||
search_attributes: Attributes,
|
||||
options_children: list[Node],
|
||||
always_visible: bool,
|
||||
items_visible: int,
|
||||
templates: list[Node] | None = None,
|
||||
) -> Node:
|
||||
"""Assemble the shared, domain-agnostic combobox skeleton.
|
||||
) -> list[Node]:
|
||||
"""Build and return the shared combobox interior nodes.
|
||||
|
||||
Every combobox built on top of this shell has the same three regions in the
|
||||
same order: the ``pills`` region, the search box, and the options panel (which
|
||||
always carries a trailing no-results node). Callers supply the already-built
|
||||
``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.
|
||||
Returns the three content regions (pills, search box, options panel) plus
|
||||
any templates — ready to be placed as children of the caller's container
|
||||
element. The shell knows nothing about how individual rows or pills look.
|
||||
"""
|
||||
search = Input(attributes=search_attributes)
|
||||
|
||||
@@ -251,8 +242,7 @@ def _combobox_shell(
|
||||
children=[*options_children, no_results],
|
||||
)
|
||||
|
||||
children: list[Node] = [pills, search, options_panel, *(templates or [])]
|
||||
return Div(attributes=container_attributes, children=children)
|
||||
return [pills, search, options_panel, *(templates or [])]
|
||||
|
||||
|
||||
def SearchSelect(
|
||||
@@ -337,30 +327,26 @@ def SearchSelect(
|
||||
)
|
||||
)
|
||||
|
||||
container_attributes: list[HTMLAttribute] = [
|
||||
("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,
|
||||
children = _combobox_children(
|
||||
pills=pills,
|
||||
search_attributes=search_attrs,
|
||||
options_children=option_rows,
|
||||
always_visible=always_visible,
|
||||
items_visible=items_visible,
|
||||
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:
|
||||
@@ -567,35 +553,27 @@ def FilterSelect(
|
||||
)
|
||||
)
|
||||
|
||||
container_attributes: list[HTMLAttribute] = [
|
||||
("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,
|
||||
children = _combobox_children(
|
||||
pills=pills,
|
||||
search_attributes=search_attributes,
|
||||
options_children=[*modifier_rows, *value_rows],
|
||||
always_visible=False,
|
||||
items_visible=items_visible,
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user