Phase 3: declare component media that bubbles through the node tree

The JS-bearing widgets now declare their script dependencies, so a view
no longer needs to know which scripts a component requires:

- SearchSelect / FilterSelect → search_select.js
- RangeSlider → range_slider.js
- DateRangePicker → date_range_picker.js
- YearPicker → datepicker.umd.js (external, from Phase 2)
- FilterBar chrome → filter_bar.js

Because the filter-bar internals now build a node tree (the legacy
Component() string-builder calls became Element/Div), each bar's
collect_media() returns its own filter_bar.js merged with the scripts
that bubble up from the FilterSelect / RangeSlider / DateRangePicker
widgets it contains — exactly the set the views thread by hand today.

Adds Node.with_media() so a function-built node can declare media
without a full BaseComponent subclass, and tests proving the bubbling.

Note: the six *FilterBar functions still share the _filter_bar chrome
helper rather than a BaseComponent class hierarchy; folding them into
one is a follow-up that does not affect media collection (Phase 4).

https://claude.ai/code/session_01BKurBhE3Qj25p7Bfsg7EeK
This commit is contained in:
Claude
2026-06-13 07:24:29 +00:00
parent 4031657bb5
commit 0819ddb87d
5 changed files with 116 additions and 44 deletions
+18 -7
View File
@@ -110,14 +110,25 @@ class Node:
"""Total media of this node and its subtree."""
return self.media
# `__html__` marks the value HTML-safe for Django (conditional_escape).
# `__str__` lets f-strings, ``str()`` and ``"".join(str(...))`` render the
# node — the bridge that keeps most existing composition working.
def __html__(self) -> str:
return self._render()
def with_media(self, media: Media) -> "Node":
"""Attach JS dependencies to this node and return it (for fluent use).
def __str__(self) -> str:
return self._render()
Lets a function-built node declare its media without becoming a full
``BaseComponent`` subclass: ``return Div(...).with_media(Media(js=...))``.
"""
self.media = self.media + media
return self
# A node's rendered output is always safe HTML by construction (Element
# escapes unsafe children; Safe wraps trusted markup; Fragment escapes plain
# strings). So both `__html__` (Django's conditional_escape hook) and
# `__str__` return a SafeString — this is what keeps ``str(node)`` safe when
# fed back into a child list or template, matching the old SafeText shims.
def __html__(self) -> SafeText:
return mark_safe(self._render())
def __str__(self) -> SafeText:
return mark_safe(self._render())
def _child_key(child: object) -> tuple[str, bool]:
+14 -11
View File
@@ -19,10 +19,13 @@ widget into a ``DateCriterion`` unchanged. All behaviour is wired by
from django.utils.safestring import SafeText, mark_safe
from common.components.core import Component, HTMLAttribute
from common.components.core import Element, HTMLAttribute, Media, Node
from common.components.primitives import Div, Input, Span
from common.time import DatePartSpec, date_parts
# Wired by date_range_picker.js.
_DATE_RANGE_MEDIA = Media(js=("date_range_picker.js",))
_FIELD_CONTAINER_CLASS = (
"flex items-center gap-0.5 w-full rounded-base border border-default-medium "
"bg-neutral-secondary-medium text-sm text-heading p-1.5 cursor-text "
@@ -195,8 +198,8 @@ def DateRangeField(
children=[""],
),
_segment_group(side="max", label=label, iso_value=max_value),
Component(
tag_name="button",
Element(
"button",
attributes=[
("type", "button"),
("data-date-range-calendar-toggle", ""),
@@ -214,8 +217,8 @@ def DateRangeField(
def _calendar_nav_button(direction: str, arrow: str, label: str) -> SafeText:
return Component(
tag_name="button",
return Element(
"button",
attributes=[
("type", "button"),
(f"data-date-range-{direction}", ""),
@@ -227,8 +230,8 @@ def _calendar_nav_button(direction: str, arrow: str, label: str) -> SafeText:
def _footer_button(action: str, label: str, button_class: str) -> SafeText:
return Component(
tag_name="button",
return Element(
"button",
attributes=[
("type", "button"),
(f"data-date-range-{action}", ""),
@@ -243,8 +246,8 @@ def DateRangeCalendar(*, input_name_prefix: str) -> SafeText:
(filled client-side into ``[data-date-range-grid]``), and the
Cancel / Clear / Select footer. Hidden until the calendar toggle opens it."""
preset_buttons = [
Component(
tag_name="button",
Element(
"button",
attributes=[
("type", "button"),
("data-date-range-preset", preset_value),
@@ -328,7 +331,7 @@ def DateRangePicker(
input_name_prefix: str,
min_value: str = "",
max_value: str = "",
) -> SafeText:
) -> Node:
"""A date-range widget: segmented manual entry plus a calendar popup.
Drop-in replacement for ``DateRangeFilter`` — exposes the same hidden
@@ -352,4 +355,4 @@ def DateRangePicker(
),
DateRangeCalendar(input_name_prefix=input_name_prefix),
],
)
).with_media(_DATE_RANGE_MEDIA)
+24 -17
View File
@@ -5,7 +5,7 @@ from typing import NamedTuple
from django.db import models
from django.utils.safestring import SafeText, mark_safe
from common.components.core import Component
from common.components.core import Element, Media
from common.components.date_range_picker import DateRangePicker
from common.components.primitives import Checkbox, Div, Input, Label, Radio, Span
from common.components.search_select import (
@@ -53,6 +53,13 @@ _FILTER_RADIO_CLASS = (
_FILTER_GRID_CLASS = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4"
# range_slider.js wires RangeSlider; filter_bar.js wires the bar chrome
# (Apply/Clear, presets, search injection). Widget media (search_select.js,
# date_range_picker.js) bubbles up from the contained FilterSelect / picker.
_RANGE_SLIDER_MEDIA = Media(js=("range_slider.js",))
_FILTER_BAR_MEDIA = Media(js=("filter_bar.js",))
def _filter_parse(filter_json: str) -> dict:
if not filter_json:
return {}
@@ -376,8 +383,8 @@ def RangeSlider(
("class", _RANGE_SLIDER_INPUT_CLASS),
],
),
Component(
tag_name="button",
Element(
"button",
attributes=[
("type", "button"),
(
@@ -482,7 +489,7 @@ def RangeSlider(
],
),
],
)
).with_media(_RANGE_SLIDER_MEDIA)
_DATE_RANGE_INPUT_CLASS = (
@@ -555,8 +562,8 @@ _FILTER_INPUT_ID = "filter-json-input"
def _filter_collapse_button() -> SafeText:
return Component(
tag_name="button",
return Element(
"button",
attributes=[
("type", "button"),
# Slider handles are positioned in percentages, so initializing
@@ -584,8 +591,8 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText:
return Div(
attributes=[("class", "flex gap-3 items-center")],
children=[
Component(
tag_name="button",
Element(
"button",
attributes=[
("type", "submit"),
(
@@ -597,8 +604,8 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText:
],
children=["Apply"],
),
Component(
tag_name="button",
Element(
"button",
attributes=[
("type", "button"),
(
@@ -634,8 +641,8 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText:
),
],
),
Component(
tag_name="button",
Element(
"button",
attributes=[
("type", "button"),
("id", "save-preset-btn"),
@@ -651,8 +658,8 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText:
],
children=["Save Preset"],
),
Component(
tag_name="button",
Element(
"button",
attributes=[
("type", "button"),
("id", "confirm-save-preset-btn"),
@@ -706,8 +713,8 @@ def _filter_bar(fields, filter_json, preset_list_url, preset_save_url) -> SafeTe
),
],
children=[
Component(
tag_name="form",
Element(
"form",
attributes=[
("id", _FILTER_FORM_ID),
("onsubmit", "return applyFilterBar(event)"),
@@ -730,7 +737,7 @@ def _filter_bar(fields, filter_json, preset_list_url, preset_save_url) -> SafeTe
],
),
],
)
).with_media(_FILTER_BAR_MEDIA)
def FilterBar(
+12 -9
View File
@@ -23,9 +23,12 @@ from typing import TypedDict
from django.utils.safestring import SafeText
from common.components.core import Component, HTMLAttribute
from common.components.core import Element, HTMLAttribute, Media, Node
from common.components.primitives import Div, Input, Pill, Span, Template
# Both comboboxes are wired by search_select.js.
_SEARCH_SELECT_MEDIA = Media(js=("search_select.js",))
class SearchSelectOption(TypedDict):
value: str | int
@@ -322,12 +325,12 @@ def SearchSelect(
always_visible=always_visible,
items_visible=items_visible,
templates=templates,
)
).with_media(_SEARCH_SELECT_MEDIA)
def _filter_remove_button() -> SafeText:
return Component(
tag_name="button",
def _filter_remove_button() -> Node:
return Element(
"button",
attributes=[
("type", "button"),
("data-pill-remove", ""),
@@ -369,9 +372,9 @@ def _filter_modifier_pill(modifier_value: str, label: str) -> SafeText:
)
def _filter_action_button(action: str, symbol: str, title: str) -> SafeText:
return Component(
tag_name="button",
def _filter_action_button(action: str, symbol: str, title: str) -> Node:
return Element(
"button",
attributes=[
("type", "button"),
("data-search-select-action", action),
@@ -557,7 +560,7 @@ def FilterSelect(
always_visible=False,
items_visible=items_visible,
templates=templates,
)
).with_media(_SEARCH_SELECT_MEDIA)
def searchselect_selected(