hyperclass 0.0.3__tar.gz → 0.0.4__tar.gz
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.
- {hyperclass-0.0.3 → hyperclass-0.0.4}/PKG-INFO +57 -4
- {hyperclass-0.0.3 → hyperclass-0.0.4}/README.md +56 -3
- {hyperclass-0.0.3 → hyperclass-0.0.4}/hyperclass/__init__.py +1 -1
- {hyperclass-0.0.3 → hyperclass-0.0.4}/hyperclass/html.py +64 -8
- {hyperclass-0.0.3 → hyperclass-0.0.4}/hyperclass/htmx.py +1 -1
- {hyperclass-0.0.3 → hyperclass-0.0.4}/hyperclass/wsgi.py +7 -7
- {hyperclass-0.0.3 → hyperclass-0.0.4}/hyperclass.egg-info/PKG-INFO +57 -4
- {hyperclass-0.0.3 → hyperclass-0.0.4}/pyproject.toml +1 -1
- {hyperclass-0.0.3 → hyperclass-0.0.4}/tests/test_bookmarks.py +5 -0
- {hyperclass-0.0.3 → hyperclass-0.0.4}/tests/test_html.py +52 -0
- {hyperclass-0.0.3 → hyperclass-0.0.4}/tests/test_htmx.py +17 -1
- hyperclass-0.0.4/tests/test_package.py +5 -0
- {hyperclass-0.0.3 → hyperclass-0.0.4}/tests/test_wsgi.py +10 -0
- hyperclass-0.0.3/tests/test_package.py +0 -5
- {hyperclass-0.0.3 → hyperclass-0.0.4}/LICENSE +0 -0
- {hyperclass-0.0.3 → hyperclass-0.0.4}/hyperclass/__main__.py +0 -0
- {hyperclass-0.0.3 → hyperclass-0.0.4}/hyperclass/css.py +0 -0
- {hyperclass-0.0.3 → hyperclass-0.0.4}/hyperclass.egg-info/SOURCES.txt +0 -0
- {hyperclass-0.0.3 → hyperclass-0.0.4}/hyperclass.egg-info/dependency_links.txt +0 -0
- {hyperclass-0.0.3 → hyperclass-0.0.4}/hyperclass.egg-info/requires.txt +0 -0
- {hyperclass-0.0.3 → hyperclass-0.0.4}/hyperclass.egg-info/top_level.txt +0 -0
- {hyperclass-0.0.3 → hyperclass-0.0.4}/setup.cfg +0 -0
- {hyperclass-0.0.3 → hyperclass-0.0.4}/tests/test_cli.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hyperclass
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.4
|
|
4
4
|
Summary: Build interactive web applications as Python class hierarchies.
|
|
5
5
|
Author: Grant Jenks
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -50,7 +50,7 @@ from dataclasses import dataclass
|
|
|
50
50
|
|
|
51
51
|
from hyperclass import (
|
|
52
52
|
App, button, css, div, form, get, grid, hx, input, outer_morph,
|
|
53
|
-
post, rem,
|
|
53
|
+
name, post, rem,
|
|
54
54
|
)
|
|
55
55
|
|
|
56
56
|
|
|
@@ -64,11 +64,17 @@ class card(div):
|
|
|
64
64
|
)
|
|
65
65
|
|
|
66
66
|
|
|
67
|
+
class guest_name(input):
|
|
68
|
+
name = name.name
|
|
69
|
+
placeholder = "Your name"
|
|
70
|
+
required = True
|
|
71
|
+
|
|
72
|
+
|
|
67
73
|
class guest_form(form):
|
|
68
74
|
style = css(display=grid, gap=.75 * rem)
|
|
69
75
|
|
|
70
76
|
def content(self):
|
|
71
|
-
yield
|
|
77
|
+
yield guest_name()
|
|
72
78
|
yield button("Say hello", type="submit")
|
|
73
79
|
|
|
74
80
|
|
|
@@ -179,6 +185,39 @@ class greeting(card):
|
|
|
179
185
|
Text and attribute values are escaped by default. `markup(...)` is the explicit
|
|
180
186
|
escape hatch for trusted HTML.
|
|
181
187
|
|
|
188
|
+
## HTML attributes inherit too
|
|
189
|
+
|
|
190
|
+
Non-private class values become default HTML attributes:
|
|
191
|
+
|
|
192
|
+
~~~python
|
|
193
|
+
from hyperclass import a, input, name
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class external_link(a):
|
|
197
|
+
target = "_blank"
|
|
198
|
+
rel = "noreferrer"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class url_field(input):
|
|
202
|
+
type = "url"
|
|
203
|
+
name = name.url
|
|
204
|
+
required = True
|
|
205
|
+
autocomplete = "url"
|
|
206
|
+
~~~
|
|
207
|
+
|
|
208
|
+
The defaults follow the same base-to-derived order as styles. Subclasses and
|
|
209
|
+
multiple-inheritance mixins can override them. Attributes passed to an instance
|
|
210
|
+
win last:
|
|
211
|
+
|
|
212
|
+
~~~python
|
|
213
|
+
external_link("Same tab", href="/", target="_self", rel=None)
|
|
214
|
+
~~~
|
|
215
|
+
|
|
216
|
+
`None` and `False` suppress an inherited attribute. Underscores in Python names
|
|
217
|
+
become hyphens, so `aria_label` renders as `aria-label`. Boolean `True` renders
|
|
218
|
+
as a valueless HTML attribute. An `hx = hx.get(...)` class default expands into
|
|
219
|
+
the corresponding htmx attributes.
|
|
220
|
+
|
|
182
221
|
## CSS is Python too
|
|
183
222
|
|
|
184
223
|
Base styles, pseudo-states, and media rules live on the component:
|
|
@@ -203,7 +242,7 @@ class primary_button(button):
|
|
|
203
242
|
Pages collect only the rules used by their element tree. Python inheritance and
|
|
204
243
|
the CSS cascade cooperate instead of imitating one another.
|
|
205
244
|
|
|
206
|
-
## Classes and
|
|
245
|
+
## Classes, IDs, and names are selectors
|
|
207
246
|
|
|
208
247
|
Classes can be used directly anywhere a selector is expected:
|
|
209
248
|
|
|
@@ -226,6 +265,19 @@ assert id.unread_count is id.unread_count
|
|
|
226
265
|
As an HTML attribute, `id.unread_count` renders as `unread-count`. As a
|
|
227
266
|
selector, it renders as `#unread-count`.
|
|
228
267
|
|
|
268
|
+
Form names work the same way while preserving Python underscores:
|
|
269
|
+
|
|
270
|
+
~~~python
|
|
271
|
+
from hyperclass import name
|
|
272
|
+
|
|
273
|
+
input(name=name.search_query)
|
|
274
|
+
request.form[name.search_query]
|
|
275
|
+
hx.get(search, include=name.search_query, target=id.results)
|
|
276
|
+
~~~
|
|
277
|
+
|
|
278
|
+
As an attribute, `name.search_query` renders as `search_query`. As a selector,
|
|
279
|
+
it renders as `[name="search_query"]`. Repeated access returns the same object.
|
|
280
|
+
|
|
229
281
|
## Routes are references, not strings
|
|
230
282
|
|
|
231
283
|
Application subclasses collect decorated method routes:
|
|
@@ -286,6 +338,7 @@ return a more specific `Response`.
|
|
|
286
338
|
|
|
287
339
|
The underlying values remain available as `request.form`, `request.query`,
|
|
288
340
|
`.get(...)`, `.getlist(...)`, and `.int(...)` when explicit parsing is clearer.
|
|
341
|
+
Those accessors accept first-class `name.*` objects as well as strings.
|
|
289
342
|
|
|
290
343
|
## WSGI and htmx 4
|
|
291
344
|
|
|
@@ -20,7 +20,7 @@ from dataclasses import dataclass
|
|
|
20
20
|
|
|
21
21
|
from hyperclass import (
|
|
22
22
|
App, button, css, div, form, get, grid, hx, input, outer_morph,
|
|
23
|
-
post, rem,
|
|
23
|
+
name, post, rem,
|
|
24
24
|
)
|
|
25
25
|
|
|
26
26
|
|
|
@@ -34,11 +34,17 @@ class card(div):
|
|
|
34
34
|
)
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
class guest_name(input):
|
|
38
|
+
name = name.name
|
|
39
|
+
placeholder = "Your name"
|
|
40
|
+
required = True
|
|
41
|
+
|
|
42
|
+
|
|
37
43
|
class guest_form(form):
|
|
38
44
|
style = css(display=grid, gap=.75 * rem)
|
|
39
45
|
|
|
40
46
|
def content(self):
|
|
41
|
-
yield
|
|
47
|
+
yield guest_name()
|
|
42
48
|
yield button("Say hello", type="submit")
|
|
43
49
|
|
|
44
50
|
|
|
@@ -149,6 +155,39 @@ class greeting(card):
|
|
|
149
155
|
Text and attribute values are escaped by default. `markup(...)` is the explicit
|
|
150
156
|
escape hatch for trusted HTML.
|
|
151
157
|
|
|
158
|
+
## HTML attributes inherit too
|
|
159
|
+
|
|
160
|
+
Non-private class values become default HTML attributes:
|
|
161
|
+
|
|
162
|
+
~~~python
|
|
163
|
+
from hyperclass import a, input, name
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class external_link(a):
|
|
167
|
+
target = "_blank"
|
|
168
|
+
rel = "noreferrer"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class url_field(input):
|
|
172
|
+
type = "url"
|
|
173
|
+
name = name.url
|
|
174
|
+
required = True
|
|
175
|
+
autocomplete = "url"
|
|
176
|
+
~~~
|
|
177
|
+
|
|
178
|
+
The defaults follow the same base-to-derived order as styles. Subclasses and
|
|
179
|
+
multiple-inheritance mixins can override them. Attributes passed to an instance
|
|
180
|
+
win last:
|
|
181
|
+
|
|
182
|
+
~~~python
|
|
183
|
+
external_link("Same tab", href="/", target="_self", rel=None)
|
|
184
|
+
~~~
|
|
185
|
+
|
|
186
|
+
`None` and `False` suppress an inherited attribute. Underscores in Python names
|
|
187
|
+
become hyphens, so `aria_label` renders as `aria-label`. Boolean `True` renders
|
|
188
|
+
as a valueless HTML attribute. An `hx = hx.get(...)` class default expands into
|
|
189
|
+
the corresponding htmx attributes.
|
|
190
|
+
|
|
152
191
|
## CSS is Python too
|
|
153
192
|
|
|
154
193
|
Base styles, pseudo-states, and media rules live on the component:
|
|
@@ -173,7 +212,7 @@ class primary_button(button):
|
|
|
173
212
|
Pages collect only the rules used by their element tree. Python inheritance and
|
|
174
213
|
the CSS cascade cooperate instead of imitating one another.
|
|
175
214
|
|
|
176
|
-
## Classes and
|
|
215
|
+
## Classes, IDs, and names are selectors
|
|
177
216
|
|
|
178
217
|
Classes can be used directly anywhere a selector is expected:
|
|
179
218
|
|
|
@@ -196,6 +235,19 @@ assert id.unread_count is id.unread_count
|
|
|
196
235
|
As an HTML attribute, `id.unread_count` renders as `unread-count`. As a
|
|
197
236
|
selector, it renders as `#unread-count`.
|
|
198
237
|
|
|
238
|
+
Form names work the same way while preserving Python underscores:
|
|
239
|
+
|
|
240
|
+
~~~python
|
|
241
|
+
from hyperclass import name
|
|
242
|
+
|
|
243
|
+
input(name=name.search_query)
|
|
244
|
+
request.form[name.search_query]
|
|
245
|
+
hx.get(search, include=name.search_query, target=id.results)
|
|
246
|
+
~~~
|
|
247
|
+
|
|
248
|
+
As an attribute, `name.search_query` renders as `search_query`. As a selector,
|
|
249
|
+
it renders as `[name="search_query"]`. Repeated access returns the same object.
|
|
250
|
+
|
|
199
251
|
## Routes are references, not strings
|
|
200
252
|
|
|
201
253
|
Application subclasses collect decorated method routes:
|
|
@@ -256,6 +308,7 @@ return a more specific `Response`.
|
|
|
256
308
|
|
|
257
309
|
The underlying values remain available as `request.form`, `request.query`,
|
|
258
310
|
`.get(...)`, `.getlist(...)`, and `.int(...)` when explicit parsing is clearer.
|
|
311
|
+
Those accessors accept first-class `name.*` objects as well as strings.
|
|
259
312
|
|
|
260
313
|
## WSGI and htmx 4
|
|
261
314
|
|
|
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
|
|
7
7
|
from html import escape
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
|
-
from .css import
|
|
10
|
+
from .css import PSEUDO_STATES, Media, Style
|
|
11
11
|
|
|
12
12
|
HTMX_SRC = "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0"
|
|
13
13
|
HTMX_INTEGRITY = (
|
|
@@ -51,6 +51,38 @@ class IdNamespace:
|
|
|
51
51
|
id = IdNamespace()
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Name:
|
|
56
|
+
"""A first-class form name which is also usable as a selector."""
|
|
57
|
+
|
|
58
|
+
name: str
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def selector(self) -> str:
|
|
62
|
+
return f'[name="{self.name}"]'
|
|
63
|
+
|
|
64
|
+
def __str__(self) -> str:
|
|
65
|
+
return self.name
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class NameNamespace:
|
|
69
|
+
def __init__(self) -> None:
|
|
70
|
+
self._values: dict[str, Name] = {}
|
|
71
|
+
|
|
72
|
+
def __getattr__(self, value: str) -> Name:
|
|
73
|
+
if value.startswith("_"):
|
|
74
|
+
raise AttributeError(value)
|
|
75
|
+
try:
|
|
76
|
+
return self._values[value]
|
|
77
|
+
except KeyError:
|
|
78
|
+
field_name = Name(value)
|
|
79
|
+
self._values[value] = field_name
|
|
80
|
+
return field_name
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
name = NameNamespace()
|
|
84
|
+
|
|
85
|
+
|
|
54
86
|
class ElementMeta(type):
|
|
55
87
|
@property
|
|
56
88
|
def selector(cls) -> str:
|
|
@@ -93,12 +125,14 @@ def semantic_classes(value: type) -> tuple[type, ...]:
|
|
|
93
125
|
def selector(value: Any) -> str:
|
|
94
126
|
"""Turn a selector string, element class, or element instance into CSS."""
|
|
95
127
|
|
|
96
|
-
if isinstance(value, Id):
|
|
128
|
+
if isinstance(value, (Id, Name)):
|
|
97
129
|
return value.selector
|
|
98
130
|
if isinstance(value, str):
|
|
99
131
|
return value
|
|
100
132
|
if isinstance(value, element):
|
|
101
|
-
|
|
133
|
+
attributes = _class_attributes(type(value))
|
|
134
|
+
attributes.update(getattr(value, "_attrs", {}))
|
|
135
|
+
identity = attributes.get("id")
|
|
102
136
|
if identity:
|
|
103
137
|
return f"#{identity}"
|
|
104
138
|
return selector(type(value))
|
|
@@ -183,6 +217,27 @@ def _attribute_value(value: Any) -> str:
|
|
|
183
217
|
return str(value)
|
|
184
218
|
|
|
185
219
|
|
|
220
|
+
def _attribute(target: dict[str, Any], name: str, value: Any) -> None:
|
|
221
|
+
if name == "hx" and isinstance(value, Mapping):
|
|
222
|
+
target.update(value)
|
|
223
|
+
else:
|
|
224
|
+
target[_attribute_name(name)] = value
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _class_attributes(value: type) -> dict[str, Any]:
|
|
228
|
+
attributes: dict[str, Any] = {}
|
|
229
|
+
for semantic_class in semantic_classes(value):
|
|
230
|
+
for name, default in semantic_class.__dict__.items():
|
|
231
|
+
if name.startswith("_") or isinstance(default, (Style, Media, type)):
|
|
232
|
+
continue
|
|
233
|
+
if isinstance(default, (classmethod, staticmethod, property)):
|
|
234
|
+
continue
|
|
235
|
+
if callable(default):
|
|
236
|
+
continue
|
|
237
|
+
_attribute(attributes, name, default)
|
|
238
|
+
return attributes
|
|
239
|
+
|
|
240
|
+
|
|
186
241
|
class element(metaclass=ElementMeta):
|
|
187
242
|
"""Base class for all rendered HTML elements."""
|
|
188
243
|
|
|
@@ -190,10 +245,7 @@ class element(metaclass=ElementMeta):
|
|
|
190
245
|
self._children = children
|
|
191
246
|
self._attrs: dict[str, Any] = {}
|
|
192
247
|
for name, value in attributes.items():
|
|
193
|
-
|
|
194
|
-
self._attrs.update(value)
|
|
195
|
-
else:
|
|
196
|
-
self._attrs[_attribute_name(name)] = value
|
|
248
|
+
_attribute(self._attrs, name, value)
|
|
197
249
|
|
|
198
250
|
@property
|
|
199
251
|
def selector(self) -> str:
|
|
@@ -256,7 +308,8 @@ partial = ElementMeta(
|
|
|
256
308
|
|
|
257
309
|
|
|
258
310
|
def _render_attributes(node: element, classes: tuple[type, ...]) -> str:
|
|
259
|
-
attributes =
|
|
311
|
+
attributes = _class_attributes(type(node))
|
|
312
|
+
attributes.update(getattr(node, "_attrs", {}))
|
|
260
313
|
generated = [class_name(value) for value in classes]
|
|
261
314
|
explicit = attributes.pop("class", attributes.pop("class-", None))
|
|
262
315
|
if explicit:
|
|
@@ -365,12 +418,15 @@ __all__ = [
|
|
|
365
418
|
"Id",
|
|
366
419
|
"IdNamespace",
|
|
367
420
|
"Markup",
|
|
421
|
+
"Name",
|
|
422
|
+
"NameNamespace",
|
|
368
423
|
"Page",
|
|
369
424
|
"class_name",
|
|
370
425
|
"element",
|
|
371
426
|
"fragment",
|
|
372
427
|
"id",
|
|
373
428
|
"markup",
|
|
429
|
+
"name",
|
|
374
430
|
"page",
|
|
375
431
|
"partial",
|
|
376
432
|
"render",
|
|
@@ -66,7 +66,7 @@ class Htmx:
|
|
|
66
66
|
for name, value in options.items():
|
|
67
67
|
if value is None:
|
|
68
68
|
continue
|
|
69
|
-
if name in {"target", "select", "select_oob", "sync"}:
|
|
69
|
+
if name in {"include", "target", "select", "select_oob", "sync"}:
|
|
70
70
|
value = selector(value) if not isinstance(value, Target) else value
|
|
71
71
|
values[_option_name(name)] = value
|
|
72
72
|
return Attributes(values)
|
|
@@ -230,8 +230,8 @@ class Values(Mapping[str, str]):
|
|
|
230
230
|
def __init__(self, values: Mapping[str, list[str]] | None = None):
|
|
231
231
|
self._values = dict(values or {})
|
|
232
232
|
|
|
233
|
-
def __getitem__(self, key:
|
|
234
|
-
return self._values[key][-1]
|
|
233
|
+
def __getitem__(self, key: Any) -> str:
|
|
234
|
+
return self._values[str(key)][-1]
|
|
235
235
|
|
|
236
236
|
def __iter__(self) -> Iterator[str]:
|
|
237
237
|
return iter(self._values)
|
|
@@ -239,14 +239,14 @@ class Values(Mapping[str, str]):
|
|
|
239
239
|
def __len__(self) -> int:
|
|
240
240
|
return len(self._values)
|
|
241
241
|
|
|
242
|
-
def get(self, key:
|
|
243
|
-
values = self._values.get(key)
|
|
242
|
+
def get(self, key: Any, default: Any = None) -> Any:
|
|
243
|
+
values = self._values.get(str(key))
|
|
244
244
|
return values[-1] if values else default
|
|
245
245
|
|
|
246
|
-
def getlist(self, key:
|
|
247
|
-
return list(self._values.get(key, ()))
|
|
246
|
+
def getlist(self, key: Any) -> list[str]:
|
|
247
|
+
return list(self._values.get(str(key), ()))
|
|
248
248
|
|
|
249
|
-
def int(self, key:
|
|
249
|
+
def int(self, key: Any, default: int | None = None) -> int:
|
|
250
250
|
value = self.get(key)
|
|
251
251
|
if value is None:
|
|
252
252
|
if default is not None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hyperclass
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.4
|
|
4
4
|
Summary: Build interactive web applications as Python class hierarchies.
|
|
5
5
|
Author: Grant Jenks
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -50,7 +50,7 @@ from dataclasses import dataclass
|
|
|
50
50
|
|
|
51
51
|
from hyperclass import (
|
|
52
52
|
App, button, css, div, form, get, grid, hx, input, outer_morph,
|
|
53
|
-
post, rem,
|
|
53
|
+
name, post, rem,
|
|
54
54
|
)
|
|
55
55
|
|
|
56
56
|
|
|
@@ -64,11 +64,17 @@ class card(div):
|
|
|
64
64
|
)
|
|
65
65
|
|
|
66
66
|
|
|
67
|
+
class guest_name(input):
|
|
68
|
+
name = name.name
|
|
69
|
+
placeholder = "Your name"
|
|
70
|
+
required = True
|
|
71
|
+
|
|
72
|
+
|
|
67
73
|
class guest_form(form):
|
|
68
74
|
style = css(display=grid, gap=.75 * rem)
|
|
69
75
|
|
|
70
76
|
def content(self):
|
|
71
|
-
yield
|
|
77
|
+
yield guest_name()
|
|
72
78
|
yield button("Say hello", type="submit")
|
|
73
79
|
|
|
74
80
|
|
|
@@ -179,6 +185,39 @@ class greeting(card):
|
|
|
179
185
|
Text and attribute values are escaped by default. `markup(...)` is the explicit
|
|
180
186
|
escape hatch for trusted HTML.
|
|
181
187
|
|
|
188
|
+
## HTML attributes inherit too
|
|
189
|
+
|
|
190
|
+
Non-private class values become default HTML attributes:
|
|
191
|
+
|
|
192
|
+
~~~python
|
|
193
|
+
from hyperclass import a, input, name
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class external_link(a):
|
|
197
|
+
target = "_blank"
|
|
198
|
+
rel = "noreferrer"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class url_field(input):
|
|
202
|
+
type = "url"
|
|
203
|
+
name = name.url
|
|
204
|
+
required = True
|
|
205
|
+
autocomplete = "url"
|
|
206
|
+
~~~
|
|
207
|
+
|
|
208
|
+
The defaults follow the same base-to-derived order as styles. Subclasses and
|
|
209
|
+
multiple-inheritance mixins can override them. Attributes passed to an instance
|
|
210
|
+
win last:
|
|
211
|
+
|
|
212
|
+
~~~python
|
|
213
|
+
external_link("Same tab", href="/", target="_self", rel=None)
|
|
214
|
+
~~~
|
|
215
|
+
|
|
216
|
+
`None` and `False` suppress an inherited attribute. Underscores in Python names
|
|
217
|
+
become hyphens, so `aria_label` renders as `aria-label`. Boolean `True` renders
|
|
218
|
+
as a valueless HTML attribute. An `hx = hx.get(...)` class default expands into
|
|
219
|
+
the corresponding htmx attributes.
|
|
220
|
+
|
|
182
221
|
## CSS is Python too
|
|
183
222
|
|
|
184
223
|
Base styles, pseudo-states, and media rules live on the component:
|
|
@@ -203,7 +242,7 @@ class primary_button(button):
|
|
|
203
242
|
Pages collect only the rules used by their element tree. Python inheritance and
|
|
204
243
|
the CSS cascade cooperate instead of imitating one another.
|
|
205
244
|
|
|
206
|
-
## Classes and
|
|
245
|
+
## Classes, IDs, and names are selectors
|
|
207
246
|
|
|
208
247
|
Classes can be used directly anywhere a selector is expected:
|
|
209
248
|
|
|
@@ -226,6 +265,19 @@ assert id.unread_count is id.unread_count
|
|
|
226
265
|
As an HTML attribute, `id.unread_count` renders as `unread-count`. As a
|
|
227
266
|
selector, it renders as `#unread-count`.
|
|
228
267
|
|
|
268
|
+
Form names work the same way while preserving Python underscores:
|
|
269
|
+
|
|
270
|
+
~~~python
|
|
271
|
+
from hyperclass import name
|
|
272
|
+
|
|
273
|
+
input(name=name.search_query)
|
|
274
|
+
request.form[name.search_query]
|
|
275
|
+
hx.get(search, include=name.search_query, target=id.results)
|
|
276
|
+
~~~
|
|
277
|
+
|
|
278
|
+
As an attribute, `name.search_query` renders as `search_query`. As a selector,
|
|
279
|
+
it renders as `[name="search_query"]`. Repeated access returns the same object.
|
|
280
|
+
|
|
229
281
|
## Routes are references, not strings
|
|
230
282
|
|
|
231
283
|
Application subclasses collect decorated method routes:
|
|
@@ -286,6 +338,7 @@ return a more specific `Response`.
|
|
|
286
338
|
|
|
287
339
|
The underlying values remain available as `request.form`, `request.query`,
|
|
288
340
|
`.get(...)`, `.getlist(...)`, and `.int(...)` when explicit parsing is clearer.
|
|
341
|
+
Those accessors accept first-class `name.*` objects as well as strings.
|
|
289
342
|
|
|
290
343
|
## WSGI and htmx 4
|
|
291
344
|
|
|
@@ -55,6 +55,11 @@ def test_bookmark_lifecycle(tmp_path):
|
|
|
55
55
|
assert "<!doctype html>" not in payload
|
|
56
56
|
assert "Read this" in payload
|
|
57
57
|
assert "1 unread bookmark" in payload
|
|
58
|
+
assert (
|
|
59
|
+
'<input class="text-field url-field" type="url" name="url" required'
|
|
60
|
+
in payload
|
|
61
|
+
)
|
|
62
|
+
assert 'target="_blank" rel="noreferrer"' in payload
|
|
58
63
|
assert 'hx-patch="/bookmarks/1"' in payload
|
|
59
64
|
bookmark = app.store.list()[0]
|
|
60
65
|
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
from hyperclass import (
|
|
2
2
|
Page,
|
|
3
|
+
a,
|
|
4
|
+
button,
|
|
3
5
|
css,
|
|
4
6
|
div,
|
|
5
7
|
fragment,
|
|
6
8
|
grid,
|
|
7
9
|
id,
|
|
10
|
+
input,
|
|
8
11
|
media,
|
|
12
|
+
name,
|
|
9
13
|
orange,
|
|
10
14
|
outer_morph,
|
|
11
15
|
partial,
|
|
@@ -107,3 +111,51 @@ def test_page_collects_state_and_media_styles_from_classes():
|
|
|
107
111
|
"@media (max-width:40rem){.interactive-card{grid-template-columns:1fr}}"
|
|
108
112
|
in document
|
|
109
113
|
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_html_attributes_are_inherited_and_can_be_overridden():
|
|
117
|
+
class external_link(a):
|
|
118
|
+
target = "_blank"
|
|
119
|
+
rel = "noreferrer"
|
|
120
|
+
|
|
121
|
+
assert render(external_link("Python", href="https://python.org")) == (
|
|
122
|
+
'<a class="external-link" target="_blank" rel="noreferrer" '
|
|
123
|
+
'href="https://python.org">Python</a>'
|
|
124
|
+
)
|
|
125
|
+
assert render(external_link("Here", href="/", target="_self", rel=None)) == (
|
|
126
|
+
'<a class="external-link" target="_self" href="/">Here</a>'
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_multiple_inheritance_composes_attribute_defaults():
|
|
131
|
+
class disabled:
|
|
132
|
+
disabled = True
|
|
133
|
+
aria_disabled = "true"
|
|
134
|
+
|
|
135
|
+
class caution:
|
|
136
|
+
title = "Careful"
|
|
137
|
+
|
|
138
|
+
class cautious_button(button, disabled, caution):
|
|
139
|
+
title = "Really careful"
|
|
140
|
+
|
|
141
|
+
assert render(cautious_button("Continue")) == (
|
|
142
|
+
'<button class="disabled caution cautious-button" disabled '
|
|
143
|
+
'aria-disabled="true" title="Really careful">Continue</button>'
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_names_are_lazy_interned_attributes_and_selectors():
|
|
148
|
+
assert name.search_query is name.search_query
|
|
149
|
+
assert str(name.search_query) == "search_query"
|
|
150
|
+
assert name.search_query.selector == '[name="search_query"]'
|
|
151
|
+
assert selector(name.search_query) == '[name="search_query"]'
|
|
152
|
+
assert render(input(name=name.search_query)) == (
|
|
153
|
+
'<input name="search_query">'
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_class_default_id_controls_instance_selector():
|
|
158
|
+
class results(div):
|
|
159
|
+
id = id.search_results
|
|
160
|
+
|
|
161
|
+
assert results().selector == "#search-results"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from hyperclass import closest, div, hx, outer_morph, render
|
|
1
|
+
from hyperclass import closest, div, hx, id, name, outer_morph, render
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
class counter(div):
|
|
@@ -24,3 +24,19 @@ def test_plain_class_target_becomes_a_selector():
|
|
|
24
24
|
assert render(div(hx=hx.get("/counter", target=counter))) == (
|
|
25
25
|
'<div hx-get="/counter" hx-target=".counter"></div>'
|
|
26
26
|
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_class_attributes_can_declare_htmx_and_name_selectors():
|
|
30
|
+
class live_search(div):
|
|
31
|
+
hx = hx.get(
|
|
32
|
+
"/search",
|
|
33
|
+
include=name.query,
|
|
34
|
+
target=id.results,
|
|
35
|
+
swap=outer_morph,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
assert render(live_search()) == (
|
|
39
|
+
'<div class="live-search" hx-get="/search" '
|
|
40
|
+
'hx-include="[name="query"]" hx-target="#results" '
|
|
41
|
+
'hx-swap="outerMorph"></div>'
|
|
42
|
+
)
|
|
@@ -5,6 +5,7 @@ from wsgiref.util import setup_testing_defaults
|
|
|
5
5
|
|
|
6
6
|
from hyperclass import (
|
|
7
7
|
App,
|
|
8
|
+
Values,
|
|
8
9
|
button,
|
|
9
10
|
closest,
|
|
10
11
|
div,
|
|
@@ -13,6 +14,7 @@ from hyperclass import (
|
|
|
13
14
|
hidden,
|
|
14
15
|
hx,
|
|
15
16
|
input,
|
|
17
|
+
name,
|
|
16
18
|
outer_morph,
|
|
17
19
|
output,
|
|
18
20
|
patch,
|
|
@@ -266,3 +268,11 @@ def test_dataclass_binding_uses_defaults_and_reports_bad_values():
|
|
|
266
268
|
)
|
|
267
269
|
assert captured["status"] == "400 Bad Request"
|
|
268
270
|
assert payload == "invalid form value for priority: 'high'"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_values_accept_first_class_form_names():
|
|
274
|
+
values = Values({"search_query": ["python", "wsgi"], "page": ["3"]})
|
|
275
|
+
assert values[name.search_query] == "wsgi"
|
|
276
|
+
assert values.get(name.search_query) == "wsgi"
|
|
277
|
+
assert values.getlist(name.search_query) == ["python", "wsgi"]
|
|
278
|
+
assert values.int(name.page) == 3
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|