"""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``. Option sourcing follows two axes. *Population*: options are either rendered inline up front (``options=``, no ``search_url``) or fetched from ``search_url``. *Completeness*: without a ``search_url`` the inline set is the whole dataset and filtering is purely client-side; with a ``search_url`` the loaded rows are a window, so the JS filters the loaded rows instantly on each keystroke while issuing a debounced server request for the rest. ``prefetch`` (rows to load on first open, ``0`` = none) seeds that window so the panel is populated before the user types. """ 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 # The pills and the search box share one flex-wrap row (with padding) so the # widget reads as a single clickable field; the pills wrapper uses `contents` # so its pills/hidden inputs flow as direct participants of that row, inline # with the search input. The options panel is absolute, so it sits outside the # flex flow. (border omitted intentionally — see if it's needed later.) _CONTAINER_CLASS = ( "relative flex flex-wrap items-center gap-1 p-2 " "rounded-base bg-neutral-secondary-medium" ) _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" ) # top-full anchors the panel to the container's bottom edge: as an absolutely # positioned child of the flex field, its static position would otherwise be # centered by items-center and overlap the search box. _OPTIONS_CLASS = ( "absolute z-10 top-full 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 # ── FilterSelect styling ─────────────────────────────────────────────────── # Inline class strings (ported verbatim from the retired SelectableFilter CSS) # so the filter combobox is fully self-styled — nothing in input.css. The # JS-built filter rows/pills in search_select.js mirror these byte-for-byte. _FILTER_INCLUDE_PILL_CLASS = ( "inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded " "bg-brand/15 text-heading" ) _FILTER_EXCLUDE_PILL_CLASS = ( "inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded " "bg-red-500/15 text-red-600 line-through decoration-red-400" ) _FILTER_MODIFIER_PILL_CLASS = ( "inline-flex items-center px-2 py-0.5 text-sm rounded " "bg-amber-500/15 text-amber-600 cursor-pointer" ) _FILTER_PILL_REMOVE_CLASS = "ml-1 text-body hover:text-heading font-bold cursor-pointer" _FILTER_OPTION_ROW_CLASS = ( "flex items-center justify-between px-2 py-1 rounded text-sm " "hover:bg-neutral-secondary-strong cursor-pointer" ) _FILTER_OPTION_LABEL_CLASS = "truncate text-body" _FILTER_OPTION_BUTTONS_CLASS = "flex gap-1 ml-2 shrink-0" # text-body keeps the +/− readable on dark backgrounds; hover:border-brand-strong # keeps the edge visible against the brand hover fill. _FILTER_ACTION_BUTTON_CLASS = ( "w-5 h-5 flex items-center justify-center text-xs font-bold rounded text-body " "border border-brand " "hover:bg-brand hover:text-white hover:border-brand-strong" ) _FILTER_MODIFIER_ROW_CLASS = ( "px-2 py-1 text-sm text-body hover:bg-neutral-secondary-strong cursor-pointer" ) 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 _label_slot(text: str, *, extra_class: str = "") -> SafeText: """A ```` holding a row/pill's visible label. JS fills this one node when cloning the shape from a ``