wtforms-htmx 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wtforms_htmx/__init__.py +18 -0
- wtforms_htmx/form.py +761 -0
- wtforms_htmx/headers.py +55 -0
- wtforms_htmx/meta.py +416 -0
- wtforms_htmx/mutations.py +311 -0
- wtforms_htmx/partial.py +308 -0
- wtforms_htmx/py.typed +0 -0
- wtforms_htmx/tree.py +147 -0
- wtforms_htmx/triggers.py +182 -0
- wtforms_htmx/wrapper.py +24 -0
- wtforms_htmx-0.1.0.dist-info/METADATA +87 -0
- wtforms_htmx-0.1.0.dist-info/RECORD +14 -0
- wtforms_htmx-0.1.0.dist-info/WHEEL +4 -0
- wtforms_htmx-0.1.0.dist-info/licenses/LICENSE.rst +28 -0
wtforms_htmx/headers.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Read the htmx request headers the same way on htmx 2 and htmx 4.
|
|
2
|
+
|
|
3
|
+
Both versions are normalized to element ids. ``hx-trigger-name`` is
|
|
4
|
+
ignored: htmx 4 has no such header.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from typing import NamedTuple
|
|
11
|
+
from urllib.parse import unquote
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _HtmxRequestInfo(NamedTuple):
|
|
15
|
+
"""What an htmx request carries, whatever the htmx version."""
|
|
16
|
+
|
|
17
|
+
htmx: bool
|
|
18
|
+
boosted: bool
|
|
19
|
+
source_id: str | None
|
|
20
|
+
target_id: str | None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _element_id(identifier: str | None) -> str | None:
|
|
24
|
+
"""Extract the id from an htmx 4 ``tag#id`` element identifier.
|
|
25
|
+
|
|
26
|
+
htmx 4 percent-encodes the id, so it is decoded here.
|
|
27
|
+
"""
|
|
28
|
+
if not identifier:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
_, separator, element_id = identifier.partition("#")
|
|
32
|
+
return unquote(element_id) if separator else None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_request(headers: Mapping[str, str] | None) -> _HtmxRequestInfo:
|
|
36
|
+
"""Normalize raw request headers, whatever the case of their names."""
|
|
37
|
+
lowered = {name.lower(): value for name, value in (headers or {}).items()}
|
|
38
|
+
htmx = "hx-request" in lowered
|
|
39
|
+
boosted = "hx-boosted" in lowered
|
|
40
|
+
# ``HX-Source`` is htmx 4 only, ``HX-Trigger`` htmx 2 only: presence picks
|
|
41
|
+
# the dialect, no version flag needed.
|
|
42
|
+
if (source := lowered.get("hx-source")) is not None:
|
|
43
|
+
return _HtmxRequestInfo(
|
|
44
|
+
htmx=htmx,
|
|
45
|
+
boosted=boosted,
|
|
46
|
+
source_id=_element_id(source),
|
|
47
|
+
target_id=_element_id(lowered.get("hx-target")),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return _HtmxRequestInfo(
|
|
51
|
+
htmx=htmx,
|
|
52
|
+
boosted=boosted,
|
|
53
|
+
source_id=lowered.get("hx-trigger") or None,
|
|
54
|
+
target_id=lowered.get("hx-target") or None,
|
|
55
|
+
)
|
wtforms_htmx/meta.py
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""HtmxMeta: form-wide htmx settings, field binding and field rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from markupsafe import Markup
|
|
10
|
+
from wtforms.fields.choices import SelectFieldBase
|
|
11
|
+
from wtforms.meta import DefaultMeta
|
|
12
|
+
from wtforms.widgets import Button
|
|
13
|
+
from wtforms.widgets import SubmitInput
|
|
14
|
+
|
|
15
|
+
from .tree import _root_of
|
|
16
|
+
from .tree import _state
|
|
17
|
+
from .tree import _walk_fields
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from collections.abc import Mapping
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HtmxMeta(DefaultMeta):
|
|
25
|
+
"""WTForms :class:`~wtforms.meta.DefaultMeta` with htmx-aware rendering defaults.
|
|
26
|
+
|
|
27
|
+
Form-wide settings for all htmx-enhanced behaviors. Per-field behavior
|
|
28
|
+
is declared on the field itself, with the
|
|
29
|
+
:meth:`.triggers() <wtforms.fields.core.UnboundField.triggers>` /
|
|
30
|
+
:meth:`.triggered_by() <wtforms.fields.core.UnboundField.triggered_by>`
|
|
31
|
+
methods available on any field declaration.
|
|
32
|
+
|
|
33
|
+
A sub-form, in a :class:`~wtforms.fields.FormField` or a
|
|
34
|
+
:class:`~wtforms.fields.FieldList` of them, uses the ``htmx_*`` settings
|
|
35
|
+
of its parent form, unless its own ``Meta`` sets them.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
htmx_endpoint: str = ""
|
|
39
|
+
"""URL of every request the library emits. Default: the current URL."""
|
|
40
|
+
|
|
41
|
+
htmx_method: str = "post"
|
|
42
|
+
"""Lowercase HTTP verb for every auto-wired request (``"post"`` by
|
|
43
|
+
default → :htmx-attr:`hx-post`).
|
|
44
|
+
|
|
45
|
+
Set to ``"get"`` / ``"put"`` / ``"patch"`` / ``"delete"`` to change
|
|
46
|
+
the verb the library emits on inline validation, datalist refresh,
|
|
47
|
+
:meth:`.triggers() <wtforms.fields.core.UnboundField.triggers>` /
|
|
48
|
+
:meth:`.triggered_by() <wtforms.fields.core.UnboundField.triggered_by>`
|
|
49
|
+
trigger declarations and submit-source buttons. The same verb is
|
|
50
|
+
used everywhere. The library never mixes :htmx-attr:`hx-get` and
|
|
51
|
+
:htmx-attr:`hx-post` on one element.
|
|
52
|
+
|
|
53
|
+
The view must read the request data that matches the verb: a
|
|
54
|
+
``"get"`` form builds itself from the query string, and so must any
|
|
55
|
+
callback that reads the request. See :doc:`/how-to/raw_htmx`.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
field_wrapper: Callable | None = None
|
|
59
|
+
"""Optional ``field_wrapper(self, field, render_kw) -> str`` method
|
|
60
|
+
invoked around every field whose ``wrapper=`` constructor kwarg is
|
|
61
|
+
left to its default (``True``). A function without ``self`` needs
|
|
62
|
+
:func:`staticmethod`. A per-field ``wrapper=`` callable takes
|
|
63
|
+
``(field, render_kw)``, without ``self``.
|
|
64
|
+
|
|
65
|
+
It renders the markup around each field (label, input, errors)
|
|
66
|
+
inside a container with an :mdn-global-attr:`id`. A field re-rendered by a mutation
|
|
67
|
+
or by inline validation goes through it too. Without one, the
|
|
68
|
+
``field()`` call renders the bare widget. See :ref:`field-wrapper`.
|
|
69
|
+
|
|
70
|
+
Per-field overrides go through the ``wrapper=`` constructor kwarg::
|
|
71
|
+
|
|
72
|
+
name = StringField() # uses Meta.field_wrapper
|
|
73
|
+
save = SubmitField("Save", wrapper=False) # bare, no wrapper
|
|
74
|
+
fancy = StringField(wrapper=custom_wrap) # custom callable
|
|
75
|
+
|
|
76
|
+
The library reads ``wrapper=`` from every field declaration, so a
|
|
77
|
+
field class with its own ``wrapper`` argument does not receive it.
|
|
78
|
+
|
|
79
|
+
A single wrapper does not fit every field family. See
|
|
80
|
+
:doc:`/how-to/render_fields` for the per-family recipe.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
htmx_validation_trigger: str = "blur changed"
|
|
84
|
+
"""Full :htmx-attr:`hx-trigger` string for inline validation (event + modifiers
|
|
85
|
+
+ optional ``delay:Xms``), e.g. ``"blur changed"`` (default) or
|
|
86
|
+
``"change delay:200ms"``. A select, and a list of radio buttons or
|
|
87
|
+
checkboxes, always validates on ``change``."""
|
|
88
|
+
|
|
89
|
+
# No ``changed``: it never fires on a from:body listener on htmx 4.0.0
|
|
90
|
+
# (htmx#4033, fixed by htmx#4035 and htmx#4096 but unreleased).
|
|
91
|
+
htmx_default_trigger: str = "input delay:200ms"
|
|
92
|
+
"""Default :htmx-attr:`hx-trigger` event for
|
|
93
|
+
:meth:`.triggers() <wtforms.fields.core.UnboundField.triggers>` /
|
|
94
|
+
:meth:`.triggered_by() <wtforms.fields.core.UnboundField.triggered_by>`
|
|
95
|
+
declarations when no ``on=`` is given."""
|
|
96
|
+
|
|
97
|
+
htmx_swap: str = "outerHTML"
|
|
98
|
+
"""Default :htmx-attr:`hx-swap` for inline validation and for field
|
|
99
|
+
targets of trigger declarations. Buttons and fields with a DOM
|
|
100
|
+
selector target use ``outerHTML`` instead. Override either per field
|
|
101
|
+
with ``render_kw``."""
|
|
102
|
+
|
|
103
|
+
htmx_inline_validation: bool = True
|
|
104
|
+
"""Whether fields validate when the user leaves them.
|
|
105
|
+
See :doc:`/how-to/inline_validation`."""
|
|
106
|
+
|
|
107
|
+
htmx_submit_widgets: tuple[type, ...] = (Button, SubmitInput)
|
|
108
|
+
"""Tuple of widget classes recognized as submit sources.
|
|
109
|
+
|
|
110
|
+
Extend to register custom button-emitting widgets::
|
|
111
|
+
|
|
112
|
+
class MyForm(HtmxForm):
|
|
113
|
+
class Meta:
|
|
114
|
+
htmx_submit_widgets = HtmxMeta.htmx_submit_widgets + (MyButton,)
|
|
115
|
+
|
|
116
|
+
A registered widget must render its ``value`` attribute from
|
|
117
|
+
``field._value()``, unless the template passes ``value=``. Otherwise
|
|
118
|
+
the value sent with htmx differs from the one a plain submit sends.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
htmx_auto_mutate: bool = True
|
|
122
|
+
"""Whether the form automatically calls :meth:`HtmxFormMixin.mutate` at the
|
|
123
|
+
end of construction so views don't have to.
|
|
124
|
+
|
|
125
|
+
Set to ``False`` to require explicit :meth:`form.mutate()
|
|
126
|
+
<HtmxFormMixin.mutate>` calls (typically
|
|
127
|
+
in tests that need to inspect pre-mutation state, or views that must
|
|
128
|
+
run logic before the mutation, such as authorizing the clicked button).
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def _htmx_request_attr(self) -> str:
|
|
133
|
+
"""The ``hx-<verb>`` attribute name derived from ``htmx_method``."""
|
|
134
|
+
return f"hx-{self.htmx_method}"
|
|
135
|
+
|
|
136
|
+
def bind_field(self, form, unbound_field, options):
|
|
137
|
+
"""Copy the trigger declarations and the ``wrapper=`` choice onto the bound field.
|
|
138
|
+
|
|
139
|
+
A DataList gets its refresh attributes here, so a template that calls
|
|
140
|
+
``field.widget(...)`` keeps them.
|
|
141
|
+
"""
|
|
142
|
+
field = super().bind_field(form, unbound_field, options)
|
|
143
|
+
if triggers := getattr(unbound_field, "_htmx_triggers", None):
|
|
144
|
+
field._htmx_triggers = dict(triggers)
|
|
145
|
+
|
|
146
|
+
if triggered_by := getattr(unbound_field, "_htmx_triggered_by", None):
|
|
147
|
+
field._htmx_triggered_by = dict(triggered_by)
|
|
148
|
+
|
|
149
|
+
if hasattr(unbound_field, "_htmx_wrapper"):
|
|
150
|
+
field._htmx_wrapper = unbound_field._htmx_wrapper
|
|
151
|
+
|
|
152
|
+
self._inject_datalist_attrs(field)
|
|
153
|
+
return field
|
|
154
|
+
|
|
155
|
+
def _inject_datalist_attrs(self, field) -> None:
|
|
156
|
+
"""Add the refresh attributes to the field's DataList, when its choices are callable.
|
|
157
|
+
|
|
158
|
+
The datalist sends its own request and swaps in the new ``<option>``
|
|
159
|
+
children, so the input keeps focus and caret. ``render_kw`` on the
|
|
160
|
+
DataList wins.
|
|
161
|
+
"""
|
|
162
|
+
datalist = getattr(field, "_datalist", None)
|
|
163
|
+
if datalist is None or isinstance(datalist, str):
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
if not callable(datalist._raw_choices):
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
rk = dict(datalist.render_kw or {})
|
|
170
|
+
rk.setdefault(self._htmx_request_attr, self.htmx_endpoint)
|
|
171
|
+
rk.setdefault("hx-trigger", _from_clause(_DATALIST_TRIGGER, f"#{field.id}"))
|
|
172
|
+
rk.setdefault("hx-target", "this")
|
|
173
|
+
rk.setdefault("hx-swap", "innerHTML")
|
|
174
|
+
# Select the options, not the <datalist>, which innerHTML would nest.
|
|
175
|
+
rk.setdefault("hx-select", f"#{datalist.id} > option")
|
|
176
|
+
rk.setdefault("hx-include", "closest form")
|
|
177
|
+
datalist.render_kw = rk
|
|
178
|
+
|
|
179
|
+
def _compute_trigger_attrs(self, holder):
|
|
180
|
+
"""Build the hx-* attributes for the triggers ``holder`` listens to.
|
|
181
|
+
|
|
182
|
+
One ``from:body target:`` clause per event group of sources, a bare
|
|
183
|
+
event for a self-trigger, a selector target, or a field this one
|
|
184
|
+
posts for. Return ``{}`` when there is no trigger.
|
|
185
|
+
"""
|
|
186
|
+
attrs: dict[str, Any] = {}
|
|
187
|
+
incoming = _state(holder, "_htmx_trigger_sources") or []
|
|
188
|
+
selector_target = _state(holder, "_htmx_selector_target")
|
|
189
|
+
outgoing = _outgoing_events(holder)
|
|
190
|
+
if not incoming and selector_target is None and not outgoing:
|
|
191
|
+
return attrs
|
|
192
|
+
|
|
193
|
+
link_event = self.htmx_default_trigger
|
|
194
|
+
extras = [
|
|
195
|
+
_from_clause(event, f"[{_SOURCE_ATTR}~={_source_token(holder, index)}]")
|
|
196
|
+
for index, (event, _names) in enumerate(_source_groups(holder, link_event))
|
|
197
|
+
]
|
|
198
|
+
own_events = [
|
|
199
|
+
entry.event or link_event
|
|
200
|
+
for source_id, _name, entry in incoming
|
|
201
|
+
if source_id == holder.id
|
|
202
|
+
]
|
|
203
|
+
extras.extend(own_events)
|
|
204
|
+
# The request of an own event already runs the callbacks of the fields
|
|
205
|
+
# this one feeds: an outgoing event at the same moment would send a
|
|
206
|
+
# second request.
|
|
207
|
+
covered = {_event_name(event) for event in own_events}
|
|
208
|
+
extras.extend(event for event in outgoing if _event_name(event) not in covered)
|
|
209
|
+
if selector_target is not None:
|
|
210
|
+
extras.append(selector_target[1].event or link_event)
|
|
211
|
+
|
|
212
|
+
# Every source posts its declaration itself: nothing to listen to.
|
|
213
|
+
if not extras:
|
|
214
|
+
return attrs
|
|
215
|
+
|
|
216
|
+
attrs["hx-trigger"] = ", ".join(extras)
|
|
217
|
+
|
|
218
|
+
attrs.setdefault(self._htmx_request_attr, self.htmx_endpoint)
|
|
219
|
+
attrs.setdefault("hx-include", "closest form")
|
|
220
|
+
if selector_target is not None:
|
|
221
|
+
attrs["hx-target"] = selector_target[0]
|
|
222
|
+
attrs.setdefault("hx-swap", "outerHTML")
|
|
223
|
+
else:
|
|
224
|
+
attrs.setdefault("hx-swap", self.htmx_swap)
|
|
225
|
+
|
|
226
|
+
return attrs
|
|
227
|
+
|
|
228
|
+
def render_field(self, field, render_kw):
|
|
229
|
+
"""Add the computed htmx attributes, then render through the field's wrapper.
|
|
230
|
+
|
|
231
|
+
``wrapper=True`` uses ``field_wrapper``, ``False`` the bare widget, a
|
|
232
|
+
callable replaces ``field_wrapper``. The options of a choices field are
|
|
233
|
+
always rendered bare.
|
|
234
|
+
"""
|
|
235
|
+
if isinstance(field, SelectFieldBase._Option):
|
|
236
|
+
merged = {**(field.render_kw or {}), **(render_kw or {})}
|
|
237
|
+
self._inject_submit_vals(field, merged)
|
|
238
|
+
return Markup(field.widget(field, **merged))
|
|
239
|
+
|
|
240
|
+
# Precedence: library attrs < field render_kw < call-site render_kw.
|
|
241
|
+
lib_attrs = self._compute_trigger_attrs(field)
|
|
242
|
+
if tokens := _source_tokens(field):
|
|
243
|
+
lib_attrs[_SOURCE_ATTR] = " ".join(tokens)
|
|
244
|
+
|
|
245
|
+
merged = {**lib_attrs, **(field.render_kw or {}), **(render_kw or {})}
|
|
246
|
+
self._inject_submit_vals(field, merged)
|
|
247
|
+
|
|
248
|
+
wrapper = _state(field, "_htmx_wrapper", True)
|
|
249
|
+
if wrapper is False:
|
|
250
|
+
return Markup(field.widget(field, **merged))
|
|
251
|
+
|
|
252
|
+
if callable(wrapper):
|
|
253
|
+
return Markup(wrapper(field, merged))
|
|
254
|
+
|
|
255
|
+
if self.field_wrapper is not None:
|
|
256
|
+
return Markup(self.field_wrapper(field, merged))
|
|
257
|
+
|
|
258
|
+
return Markup(field.widget(field, **merged))
|
|
259
|
+
|
|
260
|
+
def _inject_submit_vals(self, field, merged) -> None:
|
|
261
|
+
"""Re-send a submit control's own ``name``/``value`` pair as ``hx-vals``.
|
|
262
|
+
|
|
263
|
+
Workaround for htmx#4073. The value matches what the widget renders,
|
|
264
|
+
so htmx and a plain submit send the same pair. An explicit ``hx-vals``
|
|
265
|
+
is left alone.
|
|
266
|
+
"""
|
|
267
|
+
# htmx 4 drops the pair of a submit control with its own request attribute
|
|
268
|
+
# (htmx#4073, fixed but unreleased). Remove once a release has the fix.
|
|
269
|
+
if not isinstance(field.widget, self.htmx_submit_widgets):
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
if self._htmx_request_attr not in merged:
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
if "hx-vals" in merged:
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
merged["hx-vals"] = json.dumps(
|
|
279
|
+
{field.name: _rendered_submit_value(field, merged)}
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _rendered_submit_value(field: Any, merged: Mapping[str, Any]) -> str:
|
|
284
|
+
"""Return the ``value`` attribute a submit widget renders for ``field``.
|
|
285
|
+
|
|
286
|
+
Workaround for htmx#4073. A call-time ``value=`` wins, ``SubmitInput``
|
|
287
|
+
renders the label, other widgets render ``field._value()``.
|
|
288
|
+
"""
|
|
289
|
+
if "value" in merged:
|
|
290
|
+
return str(merged["value"])
|
|
291
|
+
|
|
292
|
+
if isinstance(field.widget, SubmitInput):
|
|
293
|
+
return str(field.label.text)
|
|
294
|
+
|
|
295
|
+
return str(field._value())
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# No ``changed``: see ``HtmxMeta.htmx_default_trigger``.
|
|
299
|
+
_DATALIST_TRIGGER = "input delay:200ms"
|
|
300
|
+
|
|
301
|
+
# An attribute selector keeps the target: value free of spaces, which have
|
|
302
|
+
# no syntax that both htmx 2 and htmx 4 parse (htmx#4010).
|
|
303
|
+
_SOURCE_ATTR = "data-wtforms-htmx-for"
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
_TYPING_EVENTS = frozenset({"input", "keyup", "keydown", "keypress"})
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _event_name(event: str) -> str:
|
|
310
|
+
"""Return the bare event name of an ``hx-trigger`` clause, ``blur`` as ``focusout``."""
|
|
311
|
+
return _bubbling(event).split(" ", 1)[0].split("[", 1)[0]
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _is_source_posted(source_name: str, entry: Any) -> bool:
|
|
315
|
+
"""Tell whether the source sends the request of a declaration itself.
|
|
316
|
+
|
|
317
|
+
It does for any event but typing and polling. The target listens for
|
|
318
|
+
those, so a request is sent while the user types in the source. The
|
|
319
|
+
server cannot tell which event sent a request (htmx#4161), so a source
|
|
320
|
+
cannot both validate on blur and post its typing.
|
|
321
|
+
"""
|
|
322
|
+
if source_name == "*" or entry.event is None:
|
|
323
|
+
return False
|
|
324
|
+
|
|
325
|
+
name = _event_name(entry.event)
|
|
326
|
+
return name not in _TYPING_EVENTS and name != "every"
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _outgoing_events(holder: Any) -> list[str]:
|
|
330
|
+
"""Return the events on which ``holder`` posts for the fields it feeds."""
|
|
331
|
+
events: list[str] = []
|
|
332
|
+
for listener in _walk_fields(_root_of(holder._form)):
|
|
333
|
+
for source_id, source_name, entry in (
|
|
334
|
+
_state(listener, "_htmx_trigger_sources") or []
|
|
335
|
+
):
|
|
336
|
+
if source_id == listener.id or source_name != holder.name:
|
|
337
|
+
continue
|
|
338
|
+
|
|
339
|
+
if _is_source_posted(source_name, entry) and entry.event not in events:
|
|
340
|
+
events.append(entry.event)
|
|
341
|
+
|
|
342
|
+
return events
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _source_groups(holder: Any, default_event: str) -> list[tuple[str, list[str]]]:
|
|
346
|
+
"""Group the fields ``holder`` listens to by event.
|
|
347
|
+
|
|
348
|
+
Return ``(event, [source_name, ...])`` pairs in declaration order,
|
|
349
|
+
without self-triggers and the declarations a source posts itself. ``"*"`` stands for every field. One clause per
|
|
350
|
+
group, because htmx 2 only fires the first ``target:`` clause of an
|
|
351
|
+
event (htmx#4155).
|
|
352
|
+
"""
|
|
353
|
+
groups: dict[str, list[str]] = {}
|
|
354
|
+
for source_id, source_name, entry in _state(holder, "_htmx_trigger_sources") or []:
|
|
355
|
+
if source_id == holder.id or _is_source_posted(source_name, entry):
|
|
356
|
+
continue
|
|
357
|
+
|
|
358
|
+
event = _bubbling(entry.event or default_event)
|
|
359
|
+
groups.setdefault(event, []).append(source_name)
|
|
360
|
+
|
|
361
|
+
return list(groups.items())
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _source_token(holder: Any, index: int) -> str:
|
|
365
|
+
"""Return the marker a source carries for ``holder``'s ``index``-th event group."""
|
|
366
|
+
return holder.id if index == 0 else f"{holder.id}--{index}"
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _source_tokens(field: Any) -> list[str]:
|
|
370
|
+
"""Return the markers of every element listening to ``field``.
|
|
371
|
+
|
|
372
|
+
A source matches by its name, or as a descendant of a container
|
|
373
|
+
source, so FieldList rows added later match too.
|
|
374
|
+
"""
|
|
375
|
+
name = field.name
|
|
376
|
+
tokens = []
|
|
377
|
+
for listener in _walk_fields(_root_of(field._form)):
|
|
378
|
+
if not _state(listener, "_htmx_trigger_sources"):
|
|
379
|
+
continue
|
|
380
|
+
|
|
381
|
+
# A field of a plain WTForms sub-form has no htmx meta and listens to nothing.
|
|
382
|
+
default = getattr(listener.meta, "htmx_default_trigger", None)
|
|
383
|
+
if default is None:
|
|
384
|
+
continue
|
|
385
|
+
|
|
386
|
+
for index, (_event, names) in enumerate(_source_groups(listener, default)):
|
|
387
|
+
if any(
|
|
388
|
+
source == "*" or name == source or name.startswith(f"{source}-")
|
|
389
|
+
for source in names
|
|
390
|
+
):
|
|
391
|
+
tokens.append(_source_token(listener, index))
|
|
392
|
+
|
|
393
|
+
return tokens
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# Events that do not bubble, mapped to their bubbling equivalents.
|
|
397
|
+
_BUBBLING_EVENTS = {"blur": "focusout", "focus": "focusin"}
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _bubbling(event: str) -> str:
|
|
401
|
+
"""Replace a non-bubbling event at the start of ``event`` with its bubbling equivalent."""
|
|
402
|
+
name, sep, rest = event.partition(" ")
|
|
403
|
+
bare = name.split("[", 1)[0]
|
|
404
|
+
if bare in _BUBBLING_EVENTS:
|
|
405
|
+
name = _BUBBLING_EVENTS[bare] + name[len(bare) :]
|
|
406
|
+
|
|
407
|
+
return f"{name}{sep}{rest}"
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _from_clause(event: str, target: str) -> str:
|
|
411
|
+
"""Return an ``hx-trigger`` clause for ``event`` fired on ``target``.
|
|
412
|
+
|
|
413
|
+
htmx resolves ``from:`` once, so ``from:#id`` breaks when the source is
|
|
414
|
+
swapped. Listening on ``body`` and filtering on the target does not.
|
|
415
|
+
"""
|
|
416
|
+
return f"{_bubbling(event)} from:body target:{target}"
|