"""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 ```` 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" # The pills and the search box share one flex-wrap row so the widget reads as a # single field; the pills wrapper uses `contents` so its pills/hidden inputs # flow as direct participants of that row, inline with the search input. _FIELD_CLASS = "flex flex-wrap items-center gap-1 p-2" _PILLS_CLASS = "contents" _SEARCH_CLASS = ( "flex-1 min-w-[8rem] border-0 bg-transparent text-sm text-heading " "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) ── # Multi-select renders a removable Pill per value; single-select renders no # pill — the committed label shows inside the search box instead, with a # lone hidden input carrying the value. Both keep the hidden input(s) inside # `[data-ss-pills]` so the JS reads/writes values uniformly. pills_children: list[SafeText] = [] search_value = "" if multi_select: 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"])) elif selected: option = selected[0] pills_children.append(_hidden_input(name, option["value"])) search_value = option["label"] 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_attrs: list[HTMLAttribute] = [ ("data-ss-search", ""), ("type", "text"), ("placeholder", placeholder), ("autocomplete", "off"), ("class", _SEARCH_CLASS), ] if search_value: search_attrs.append(("value", search_value)) search = Component(tag_name="input", attributes=search_attrs) # ── Field row: pills + search box combined into one visual field ── field = Component( tag_name="div", attributes=[("data-ss-field", ""), ("class", _FIELD_CLASS)], children=[pills, search], ) # ── 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=[field, 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)]