Implement search select component
This commit is contained in:
@@ -25,15 +25,23 @@ from common.components.primitives import (
|
||||
Input,
|
||||
Modal,
|
||||
ModuleScript,
|
||||
Pill,
|
||||
Popover,
|
||||
PopoverTruncated,
|
||||
SearchField,
|
||||
SimpleTable,
|
||||
Span,
|
||||
Label,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableTd,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.components.search_select import (
|
||||
SearchSelect,
|
||||
SearchSelectOption,
|
||||
searchselect_selected,
|
||||
)
|
||||
from common.components.domain import (
|
||||
GameLink,
|
||||
GameStatus,
|
||||
@@ -70,10 +78,16 @@ __all__ = [
|
||||
"Input",
|
||||
"Modal",
|
||||
"ModuleScript",
|
||||
"Pill",
|
||||
"Popover",
|
||||
"PopoverTruncated",
|
||||
"SearchField",
|
||||
"SearchSelect",
|
||||
"SearchSelectOption",
|
||||
"searchselect_selected",
|
||||
"SimpleTable",
|
||||
"Span",
|
||||
"Label",
|
||||
"TableHeader",
|
||||
"TableRow",
|
||||
"TableTd",
|
||||
|
||||
@@ -13,6 +13,7 @@ from common.components.primitives import (
|
||||
Icon,
|
||||
Popover,
|
||||
PopoverTruncated,
|
||||
Span,
|
||||
)
|
||||
from games.models import Game, Purchase, Session
|
||||
|
||||
@@ -29,8 +30,7 @@ def GameLink(
|
||||
display = children if children else [name]
|
||||
link = reverse("games:view_game", args=[game_id])
|
||||
|
||||
return Component(
|
||||
tag_name="span",
|
||||
return Span(
|
||||
attributes=[("class", "truncate-container")],
|
||||
children=[
|
||||
Component(
|
||||
@@ -70,14 +70,12 @@ def GameStatus(
|
||||
outer_class += f" {class_}"
|
||||
dot_color = _STATUS_COLORS.get(status, _STATUS_COLORS["u"])
|
||||
|
||||
dot = Component(
|
||||
tag_name="span",
|
||||
dot = Span(
|
||||
attributes=[("class", f"rounded-xl w-3 h-3 {dot_color}")],
|
||||
children=["\xa0"],
|
||||
)
|
||||
|
||||
return Component(
|
||||
tag_name="span",
|
||||
return Span(
|
||||
attributes=[("class", outer_class)],
|
||||
children=[dot] + (children if isinstance(children, list) else [children]),
|
||||
)
|
||||
@@ -88,8 +86,7 @@ def PriceConverted(
|
||||
) -> SafeText:
|
||||
"""Wrap content in a span that indicates the price was converted."""
|
||||
children = children or []
|
||||
return Component(
|
||||
tag_name="span",
|
||||
return Span(
|
||||
attributes=[
|
||||
("title", "Price is a result of conversion and rounding."),
|
||||
("class", "decoration-dotted underline"),
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.utils.html import escape
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from common.components.core import Component
|
||||
from common.components.primitives import Label, Span
|
||||
|
||||
|
||||
class FilterChoice(NamedTuple):
|
||||
@@ -115,8 +116,7 @@ def _filter_field(label: str, widget) -> SafeText:
|
||||
tag_name="div",
|
||||
attributes=[("class", "flex flex-col gap-1")],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="label",
|
||||
Label(
|
||||
attributes=[("class", _FILTER_LABEL_CLASS)],
|
||||
children=[label],
|
||||
),
|
||||
@@ -143,8 +143,7 @@ def _filter_number(label, name, value="", placeholder="") -> SafeText:
|
||||
|
||||
|
||||
def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText:
|
||||
return Component(
|
||||
tag_name="label",
|
||||
return Label(
|
||||
attributes=[("class", "flex items-center gap-2 text-sm text-heading")],
|
||||
children=[
|
||||
Component(
|
||||
@@ -216,8 +215,7 @@ def RangeSlider(
|
||||
tag_name="div",
|
||||
attributes=[("class", "flex items-center gap-2 mb-1")],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="label",
|
||||
Label(
|
||||
attributes=[
|
||||
("class", _FILTER_LABEL_CLASS),
|
||||
("for", min_input_id),
|
||||
@@ -239,8 +237,7 @@ def RangeSlider(
|
||||
),
|
||||
],
|
||||
),
|
||||
Component(
|
||||
tag_name="span",
|
||||
Span(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -280,8 +277,7 @@ def RangeSlider(
|
||||
),
|
||||
],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="span",
|
||||
Span(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -291,8 +287,7 @@ def RangeSlider(
|
||||
],
|
||||
children=[mark_safe(_RANGE_ICON_SVG)],
|
||||
),
|
||||
Component(
|
||||
tag_name="span",
|
||||
Span(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -444,8 +439,7 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText:
|
||||
],
|
||||
children=["Clear"],
|
||||
),
|
||||
Component(
|
||||
tag_name="span",
|
||||
Span(
|
||||
attributes=[
|
||||
("class", "flex gap-2 items-center"),
|
||||
("id", "save-preset-area"),
|
||||
@@ -510,8 +504,7 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText:
|
||||
("data-preset-list-url", preset_list_url),
|
||||
],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="span",
|
||||
Span(
|
||||
attributes=[("class", "text-sm text-body")],
|
||||
children=["Loading presets..."],
|
||||
),
|
||||
@@ -684,16 +677,14 @@ def _selectable_filter_tag(
|
||||
"""A selected (\u2713) or excluded (\u2717) value pill in the SelectableFilter."""
|
||||
checkmark = "\u2717" if excluded else "\u2713"
|
||||
css = "sf-tag sf-excluded" if excluded else "sf-tag"
|
||||
return Component(
|
||||
tag_name="span",
|
||||
return Span(
|
||||
attributes=[
|
||||
("class", css),
|
||||
("data-value", value),
|
||||
("data-type", "exclude" if excluded else "include"),
|
||||
],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="span",
|
||||
Span(
|
||||
attributes=[("class", "sf-tag-text")],
|
||||
children=[f"{checkmark} {label}"],
|
||||
),
|
||||
@@ -712,8 +703,7 @@ def _selectable_filter_tag(
|
||||
|
||||
def _selectable_filter_modifier_tag(modifier: str, label: str) -> SafeText:
|
||||
"""An active modifier pill ((Any) / (None)) in the SelectableFilter."""
|
||||
return Component(
|
||||
tag_name="span",
|
||||
return Span(
|
||||
attributes=[
|
||||
("class", "sf-modifier-tag active"),
|
||||
("data-modifier", modifier),
|
||||
@@ -732,8 +722,7 @@ def _selectable_filter_modifier_option(modifier: str, label: str) -> SafeText:
|
||||
("data-label", label),
|
||||
],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="span",
|
||||
Span(
|
||||
attributes=[("class", "sf-option-label")],
|
||||
children=[label],
|
||||
),
|
||||
@@ -751,13 +740,11 @@ def _selectable_filter_option(value: str, label: str) -> SafeText:
|
||||
("data-label", label),
|
||||
],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="span",
|
||||
Span(
|
||||
attributes=[("class", "sf-option-label")],
|
||||
children=[label],
|
||||
),
|
||||
Component(
|
||||
tag_name="span",
|
||||
Span(
|
||||
attributes=[("class", "sf-option-buttons")],
|
||||
children=[
|
||||
Component(
|
||||
|
||||
@@ -42,8 +42,7 @@ def _popover_html(
|
||||
"""
|
||||
display_content = wrapped_content if wrapped_content else slot
|
||||
|
||||
span = Component(
|
||||
tag_name="span",
|
||||
span = Span(
|
||||
attributes=[
|
||||
("data-popover-target", id),
|
||||
("class", wrapped_classes),
|
||||
@@ -77,8 +76,7 @@ def _popover_html(
|
||||
"<!-- for Tailwind CSS to generate decoration-dotted CSS "
|
||||
"from Python component -->"
|
||||
),
|
||||
Component(
|
||||
tag_name="span",
|
||||
Span(
|
||||
attributes=[("class", "hidden decoration-dotted")],
|
||||
),
|
||||
],
|
||||
@@ -353,6 +351,74 @@ def Input(
|
||||
)
|
||||
|
||||
|
||||
def Span(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(tag_name="span", attributes=attributes, children=children)
|
||||
|
||||
|
||||
def Label(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(tag_name="label", attributes=attributes, children=children)
|
||||
|
||||
|
||||
# Inline Tailwind utilities for Pill (mirrors the .sf-tag / .sf-remove rules in
|
||||
# input.css, written inline so styling stays encapsulated in the component). The
|
||||
# JS that builds pills client-side (search_select.js) MUST emit these exact class
|
||||
# strings byte-for-byte so Tailwind generates them and server/JS pills match.
|
||||
_PILL_CLASS = (
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded "
|
||||
"bg-brand/15 text-heading"
|
||||
)
|
||||
_PILL_REMOVE_CLASS = "ml-1 text-body hover:text-heading font-bold cursor-pointer"
|
||||
|
||||
|
||||
def Pill(
|
||||
label: str,
|
||||
*,
|
||||
value: str = "",
|
||||
removable: bool = False,
|
||||
extra_class: str = "",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
"""A small label pill, optionally removable (× button).
|
||||
|
||||
Styling is inline Tailwind utilities; ``data-pill`` / ``data-pill-remove``
|
||||
are JS hooks only (no CSS attached). ``value`` (when set) becomes
|
||||
``data-value``; extra ``attributes`` are appended to the outer span.
|
||||
"""
|
||||
attributes = attributes or []
|
||||
pill_class = f"{_PILL_CLASS} {extra_class}".strip()
|
||||
pill_attrs: list[HTMLAttribute] = [("class", pill_class), ("data-pill", "")]
|
||||
if value != "":
|
||||
pill_attrs.append(("data-value", str(value)))
|
||||
pill_attrs.extend(attributes)
|
||||
|
||||
children: list[HTMLTag] = [label]
|
||||
if removable:
|
||||
children.append(
|
||||
Component(
|
||||
tag_name="button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("data-pill-remove", ""),
|
||||
("class", _PILL_REMOVE_CLASS),
|
||||
("aria-label", "Remove"),
|
||||
],
|
||||
children=["×"],
|
||||
)
|
||||
)
|
||||
|
||||
return Component(tag_name="span", attributes=pill_attrs, children=children)
|
||||
|
||||
|
||||
def CsrfInput(request) -> SafeText:
|
||||
"""Hidden CSRF input, equivalent to the `{% csrf_token %}` template tag."""
|
||||
return mark_safe(
|
||||
@@ -421,8 +487,7 @@ def SearchField(
|
||||
tag_name="form",
|
||||
attributes=[("class", "max-w-md")],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="label",
|
||||
Label(
|
||||
attributes=[
|
||||
("for", "search"),
|
||||
("class", "block mb-2.5 text-sm font-medium text-heading sr-only"),
|
||||
@@ -491,8 +556,7 @@ def H1(
|
||||
|
||||
if badge:
|
||||
heading_class = "flex items-center " + heading_class
|
||||
badge_html = Component(
|
||||
tag_name="span",
|
||||
badge_html = Span(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Search field + dropdown select component (pure Python, domain-agnostic).
|
||||
|
||||
Pairs a search box with a dropdown of options. Supports single/multi select;
|
||||
in multi-select, chosen items render as removable ``Pill``s, each backed by a
|
||||
hidden ``<input>`` so an existing ``ModelMultipleChoiceField`` keeps validating.
|
||||
|
||||
This module imports only from ``common.components`` — it has no Django-forms or
|
||||
``games`` knowledge. Styling is inline Tailwind utilities; behavioural hooks are
|
||||
``data-*`` attributes wired up by ``games/static/js/search_select.js``.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import TypedDict
|
||||
|
||||
from django.utils.safestring import SafeText
|
||||
|
||||
from common.components.core import Component, HTMLAttribute
|
||||
from common.components.primitives import Pill
|
||||
|
||||
|
||||
class SearchSelectOption(TypedDict):
|
||||
value: str | int
|
||||
label: str
|
||||
data: dict[str, str] # becomes data-* attrs on the row / pill
|
||||
|
||||
|
||||
# removed border and border-default-medium, see later if it's needed
|
||||
_CONTAINER_CLASS = "relative rounded-base bg-neutral-secondary-medium"
|
||||
_PILLS_CLASS = "flex flex-wrap gap-1 p-2 empty:hidden"
|
||||
_SEARCH_CLASS = (
|
||||
"block w-full border-0 bg-transparent text-sm text-heading p-2 "
|
||||
"focus:ring-0 focus:outline-hidden placeholder:text-body"
|
||||
)
|
||||
_OPTIONS_CLASS = (
|
||||
"absolute z-10 left-0 right-0 mt-1 overflow-y-auto border border-default-medium "
|
||||
"rounded-base bg-neutral-secondary-medium shadow-lg"
|
||||
)
|
||||
_OPTION_ROW_CLASS = "px-3 py-2 text-sm text-heading cursor-pointer hover:bg-brand/15"
|
||||
_NO_RESULTS_CLASS = "px-3 py-2 text-sm italic text-body hidden"
|
||||
|
||||
# Approximate rendered height of one option row (px-3 py-2 text-sm) in rem,
|
||||
# used to derive the panel's max-height from items_visible.
|
||||
_ROW_HEIGHT_REM = 2.25
|
||||
|
||||
|
||||
def _normalize_option(option) -> SearchSelectOption:
|
||||
"""Coerce a dict option or a ``(value, label)`` tuple into the TypedDict."""
|
||||
if isinstance(option, dict):
|
||||
return {
|
||||
"value": option["value"],
|
||||
"label": option["label"],
|
||||
"data": option.get("data") or {},
|
||||
}
|
||||
value, label = option
|
||||
return {"value": value, "label": label, "data": {}}
|
||||
|
||||
|
||||
def _data_attributes(data: dict[str, str]) -> list[HTMLAttribute]:
|
||||
return [(f"data-{key}", str(value)) for key, value in data.items()]
|
||||
|
||||
|
||||
def _hidden_input(name: str, value) -> SafeText:
|
||||
return Component(
|
||||
tag_name="input",
|
||||
attributes=[("type", "hidden"), ("name", name), ("value", str(value))],
|
||||
)
|
||||
|
||||
|
||||
def _option_row(option: SearchSelectOption) -> SafeText:
|
||||
return Component(
|
||||
tag_name="div",
|
||||
attributes=[
|
||||
("data-ss-option", ""),
|
||||
("data-value", str(option["value"])),
|
||||
("data-label", option["label"]),
|
||||
("class", _OPTION_ROW_CLASS),
|
||||
*_data_attributes(option["data"]),
|
||||
],
|
||||
children=[option["label"]],
|
||||
)
|
||||
|
||||
|
||||
def SearchSelect(
|
||||
*,
|
||||
name: str,
|
||||
selected: list[SearchSelectOption] | None = None,
|
||||
options: list[SearchSelectOption] | None = None,
|
||||
search_url: str = "",
|
||||
multi_select: bool = False,
|
||||
always_visible: bool = False,
|
||||
items_visible: int = 5,
|
||||
items_scroll: int = 10,
|
||||
placeholder: str = "Search…",
|
||||
id: str = "",
|
||||
sync_url: bool = False,
|
||||
) -> SafeText:
|
||||
"""Render the search-select widget. See module docstring for the contract."""
|
||||
selected = [_normalize_option(o) for o in (selected or [])]
|
||||
options = [_normalize_option(o) for o in (options or [])]
|
||||
|
||||
# ── Pills + their hidden inputs (the submitted channel) ──
|
||||
pills_children: list[SafeText] = []
|
||||
for option in selected:
|
||||
pills_children.append(
|
||||
Pill(
|
||||
option["label"],
|
||||
value=str(option["value"]),
|
||||
removable=True,
|
||||
attributes=_data_attributes(option["data"]),
|
||||
)
|
||||
)
|
||||
pills_children.append(_hidden_input(name, option["value"]))
|
||||
|
||||
pills = Component(
|
||||
tag_name="div",
|
||||
attributes=[("data-ss-pills", ""), ("class", _PILLS_CLASS)],
|
||||
children=pills_children,
|
||||
)
|
||||
|
||||
# ── Search box (NO name — the query is never submitted) ──
|
||||
search = Component(
|
||||
tag_name="input",
|
||||
attributes=[
|
||||
("data-ss-search", ""),
|
||||
("type", "text"),
|
||||
("placeholder", placeholder),
|
||||
("autocomplete", "off"),
|
||||
("class", _SEARCH_CLASS),
|
||||
],
|
||||
)
|
||||
|
||||
# ── Options panel (pre-rendered only when there is no search_url) ──
|
||||
option_rows = [_option_row(o) for o in options] if not search_url else []
|
||||
no_results = Component(
|
||||
tag_name="div",
|
||||
attributes=[("data-ss-no-results", ""), ("class", _NO_RESULTS_CLASS)],
|
||||
children=["No results"],
|
||||
)
|
||||
options_class = _OPTIONS_CLASS if always_visible else _OPTIONS_CLASS + " hidden"
|
||||
options_panel = Component(
|
||||
tag_name="div",
|
||||
attributes=[
|
||||
("data-ss-options", ""),
|
||||
("style", f"max-height: {items_visible * _ROW_HEIGHT_REM:.2f}rem"),
|
||||
("class", options_class),
|
||||
],
|
||||
children=[*option_rows, no_results],
|
||||
)
|
||||
|
||||
container_attrs: 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-sync-url", "true" if sync_url else "false"),
|
||||
("class", _CONTAINER_CLASS),
|
||||
]
|
||||
if id:
|
||||
container_attrs.append(("id", id))
|
||||
|
||||
return Component(
|
||||
tag_name="div",
|
||||
attributes=container_attrs,
|
||||
children=[pills, search, options_panel],
|
||||
)
|
||||
|
||||
|
||||
def searchselect_selected(
|
||||
values: list,
|
||||
resolver: Callable[[list], Iterable[SearchSelectOption]],
|
||||
) -> list[SearchSelectOption]:
|
||||
"""Resolve ``values`` into ``SearchSelectOption``s via ``resolver``.
|
||||
|
||||
``resolver(values)`` should resolve ONLY the given ids (a ``pk__in`` query)
|
||||
— never iterating all choices, so it stays cheap.
|
||||
"""
|
||||
if not values:
|
||||
return []
|
||||
return [_normalize_option(o) for o in resolver(values)]
|
||||
Reference in New Issue
Block a user