Extract _combobox_shell from SearchSelect
Pull the domain-agnostic combobox skeleton (pills region, search box, options panel with its no-results node, outer container) into a private _combobox_shell helper. SearchSelect now builds its form-specific pills and option rows and delegates assembly to the shell. Rendered markup is byte-identical; a structural test guards the fixed region order so future builders (e.g. a filter variant) can share the shell without drift. https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
This commit is contained in:
@@ -84,6 +84,50 @@ def _option_row(option: SearchSelectOption) -> SafeText:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _combobox_shell(
|
||||||
|
*,
|
||||||
|
container_attributes: list[HTMLAttribute],
|
||||||
|
pills: SafeText,
|
||||||
|
search_attributes: list[HTMLAttribute],
|
||||||
|
options_children: list[SafeText],
|
||||||
|
always_visible: bool,
|
||||||
|
items_visible: int,
|
||||||
|
) -> SafeText:
|
||||||
|
"""Assemble the shared, domain-agnostic combobox skeleton.
|
||||||
|
|
||||||
|
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), and the
|
||||||
|
``container_attributes`` that carry the widget's identity and behaviour flags.
|
||||||
|
The shell knows nothing about how individual rows or pills look.
|
||||||
|
"""
|
||||||
|
search = Component(tag_name="input", attributes=search_attributes)
|
||||||
|
|
||||||
|
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=[*options_children, no_results],
|
||||||
|
)
|
||||||
|
|
||||||
|
return Component(
|
||||||
|
tag_name="div",
|
||||||
|
attributes=container_attributes,
|
||||||
|
children=[pills, search, options_panel],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def SearchSelect(
|
def SearchSelect(
|
||||||
*,
|
*,
|
||||||
name: str,
|
name: str,
|
||||||
@@ -144,27 +188,11 @@ def SearchSelect(
|
|||||||
search_attrs.append(("autofocus", ""))
|
search_attrs.append(("autofocus", ""))
|
||||||
if search_value:
|
if search_value:
|
||||||
search_attrs.append(("value", search_value))
|
search_attrs.append(("value", search_value))
|
||||||
search = Component(tag_name="input", attributes=search_attrs)
|
|
||||||
|
|
||||||
# ── Options panel (pre-rendered only when there is no search_url) ──
|
# ── 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 []
|
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] = [
|
container_attributes: list[HTMLAttribute] = [
|
||||||
("data-search-select", ""),
|
("data-search-select", ""),
|
||||||
("data-name", name),
|
("data-name", name),
|
||||||
("data-search-url", search_url),
|
("data-search-url", search_url),
|
||||||
@@ -176,12 +204,15 @@ def SearchSelect(
|
|||||||
("class", _CONTAINER_CLASS),
|
("class", _CONTAINER_CLASS),
|
||||||
]
|
]
|
||||||
if id:
|
if id:
|
||||||
container_attrs.append(("id", id))
|
container_attributes.append(("id", id))
|
||||||
|
|
||||||
return Component(
|
return _combobox_shell(
|
||||||
tag_name="div",
|
container_attributes=container_attributes,
|
||||||
attributes=container_attrs,
|
pills=pills,
|
||||||
children=[pills, search, options_panel],
|
search_attributes=search_attrs,
|
||||||
|
options_children=option_rows,
|
||||||
|
always_visible=always_visible,
|
||||||
|
items_visible=items_visible,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,20 @@ class SearchSelectComponentTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertNotIn('data-ss-option=""', html)
|
self.assertNotIn('data-ss-option=""', html)
|
||||||
|
|
||||||
|
def test_shell_region_order_pills_search_options(self):
|
||||||
|
# The shared shell assembles the three regions in a fixed order; option
|
||||||
|
# rows precede the trailing no-results node inside the options panel.
|
||||||
|
html = SearchSelect(name="t", options=[("1", "One")])
|
||||||
|
pills = html.index("data-ss-pills")
|
||||||
|
search = html.index("data-ss-search")
|
||||||
|
options = html.index("data-ss-options")
|
||||||
|
option_row = html.index('data-ss-option=""')
|
||||||
|
no_results = html.index("data-ss-no-results")
|
||||||
|
self.assertLess(pills, search)
|
||||||
|
self.assertLess(search, options)
|
||||||
|
self.assertLess(options, option_row)
|
||||||
|
self.assertLess(option_row, no_results)
|
||||||
|
|
||||||
|
|
||||||
class SearchLabelTest(django.test.TestCase):
|
class SearchLabelTest(django.test.TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
Reference in New Issue
Block a user