"""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. JS-added # rows/pills are cloned from server-rendered