weft 0.1.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +37 -0
- data/LICENSE.txt +21 -0
- data/README.md +150 -0
- data/docs/app-patterns.md +268 -0
- data/docs/arbre.md +298 -0
- data/docs/configuration.md +219 -0
- data/docs/dsl.md +329 -0
- data/docs/error-handling.md +136 -0
- data/docs/examples/README.md +70 -0
- data/docs/examples/active-search.md +125 -0
- data/docs/examples/browser-dialogs.md +71 -0
- data/docs/examples/bulk-update.md +122 -0
- data/docs/examples/click-to-edit.md +113 -0
- data/docs/examples/click-to-load.md +77 -0
- data/docs/examples/delete-row.md +101 -0
- data/docs/examples/edit-row.md +146 -0
- data/docs/examples/file-upload.md +96 -0
- data/docs/examples/infinite-scroll.md +98 -0
- data/docs/examples/inline-expansion.md +98 -0
- data/docs/examples/inline-validation.md +101 -0
- data/docs/examples/keyboard-shortcuts.md +71 -0
- data/docs/examples/lazy-loading.md +96 -0
- data/docs/examples/live-ticker.md +91 -0
- data/docs/examples/modal-dialog.md +88 -0
- data/docs/examples/progress-bar.md +138 -0
- data/docs/examples/reset-user-input.md +101 -0
- data/docs/examples/tabs.md +102 -0
- data/docs/examples/tooltip.md +88 -0
- data/docs/examples/updating-other-content.md +169 -0
- data/docs/examples/value-select.md +109 -0
- data/docs/routing.md +131 -0
- data/docs/tutorial.md +372 -0
- data/lib/weft/action.rb +83 -0
- data/lib/weft/attributes.rb +65 -0
- data/lib/weft/component.rb +176 -0
- data/lib/weft/configuration.rb +177 -0
- data/lib/weft/context/interception.rb +22 -0
- data/lib/weft/context.rb +184 -0
- data/lib/weft/defaults/error_component.rb +63 -0
- data/lib/weft/defaults/error_page.rb +27 -0
- data/lib/weft/defaults/not_found_component.rb +44 -0
- data/lib/weft/defaults/not_found_page.rb +25 -0
- data/lib/weft/defaults.rb +9 -0
- data/lib/weft/dsl/actions.rb +72 -0
- data/lib/weft/dsl/attributes.rb +43 -0
- data/lib/weft/dsl/containers.rb +77 -0
- data/lib/weft/dsl/inclusions.rb +49 -0
- data/lib/weft/dsl/recoveries.rb +89 -0
- data/lib/weft/dsl/triggers.rb +40 -0
- data/lib/weft/dsl/updates.rb +86 -0
- data/lib/weft/error.rb +64 -0
- data/lib/weft/page.rb +371 -0
- data/lib/weft/redirect.rb +45 -0
- data/lib/weft/registry/eligibility.rb +58 -0
- data/lib/weft/registry.rb +202 -0
- data/lib/weft/resolver.rb +33 -0
- data/lib/weft/router/actions.rb +77 -0
- data/lib/weft/router/errors.rb +246 -0
- data/lib/weft/router/oob_includes.rb +34 -0
- data/lib/weft/router/streaming.rb +63 -0
- data/lib/weft/router.rb +191 -0
- data/lib/weft/shorthands.rb +57 -0
- data/lib/weft/version.rb +5 -0
- data/lib/weft.rb +124 -0
- metadata +169 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Tooltip
|
|
2
|
+
|
|
3
|
+
Hovering over an element fetches a small piece of server-rendered detail — a profile card, a definition, a status readout — and places it in a bubble beside the trigger. The detail is rendered fresh from the server on first hover, then stays put.
|
|
4
|
+
|
|
5
|
+
There's no counterpart for this in the htmx examples catalog; `tooltip:` is Weft-native sugar over the same [`loads:`](../dsl.md#loads) machinery as the other shorthands. It earns its keep when the tooltip content is worth a server round-trip — live data, per-record queries — rather than static text a `title=` attribute could carry.
|
|
6
|
+
|
|
7
|
+
## The components
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
TEAM = {
|
|
11
|
+
"priya" => { role: "Backend", timezone: "UTC+5:30", focus: "payments migration" },
|
|
12
|
+
"marco" => { role: "Design", timezone: "UTC+1", focus: "checkout redesign" },
|
|
13
|
+
"june" => { role: "Support", timezone: "UTC−8", focus: "triage rotation" }
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
class MemberPeek < Weft::Component
|
|
17
|
+
builder_method :member_peek
|
|
18
|
+
|
|
19
|
+
attribute :handle
|
|
20
|
+
|
|
21
|
+
def build(attributes = {})
|
|
22
|
+
super
|
|
23
|
+
member = TEAM.fetch(attrs.handle)
|
|
24
|
+
strong attrs.handle.capitalize
|
|
25
|
+
para "#{member[:role]} — #{member[:timezone]}"
|
|
26
|
+
para "Focus: #{member[:focus]}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class TeamRoster < Weft::Component
|
|
31
|
+
builder_method :team_roster
|
|
32
|
+
|
|
33
|
+
def build(attributes = {})
|
|
34
|
+
super
|
|
35
|
+
h3 "On the project"
|
|
36
|
+
ul do
|
|
37
|
+
TEAM.each_key do |handle|
|
|
38
|
+
li style: "position: relative" do
|
|
39
|
+
span handle.capitalize, class: "peek-trigger",
|
|
40
|
+
tooltip: MemberPeek, with: { handle: handle }, target: "#peek-#{handle}"
|
|
41
|
+
div id: "peek-#{handle}", class: "peek-bubble",
|
|
42
|
+
style: "position: absolute; left: 8rem; top: 0;"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
(The `TEAM` hash stands in for your data layer; the inline styles are the minimum to float each bubble beside its row — real styling belongs in your stylesheet.)
|
|
51
|
+
|
|
52
|
+
## How it works
|
|
53
|
+
|
|
54
|
+
**Hover fetches; the bubble receives.** [`tooltip:`](../dsl.md#shorthands) presets trigger `:hover` and swap `:fill`; the call site says where the content lands. Each name targets its own empty bubble div (`target: "#peek-#{handle}"`), so every row has an independent tooltip slot. The bubbles start empty and cost nothing until hovered.
|
|
55
|
+
|
|
56
|
+
**`:hover` means *first* hover.** The semantic trigger expands to htmx's `mouseenter once` — the fetch happens the first time the pointer enters, and never again. Be clear about what that buys and what it doesn't: Weft delivers the *content*, once, lazily. It does not show and hide the bubble as the pointer comes and goes — that's presentation, and it belongs to CSS (a `.peek-bubble` hidden until `li:hover`, for instance). After the first hover, showing the tooltip again is free, because the content is already in the page.
|
|
57
|
+
|
|
58
|
+
**Per-row wire attrs, one component.** Every trigger loads the same `MemberPeek` class with a different `with: { handle: ... }` — the component is written once and addressed per record, which is the same shape as every list-plus-detail pattern in this catalog.
|
|
59
|
+
|
|
60
|
+
## On the wire
|
|
61
|
+
|
|
62
|
+
The initial render — each name wired, each bubble empty:
|
|
63
|
+
|
|
64
|
+
```html
|
|
65
|
+
<li style="position: relative">
|
|
66
|
+
<span class="peek-trigger" hx-get="/_components/member_peek?handle=priya"
|
|
67
|
+
hx-swap="innerHTML" hx-target="#peek-priya"
|
|
68
|
+
hx-trigger="mouseenter once">Priya</span>
|
|
69
|
+
<div id="peek-priya" class="peek-bubble"
|
|
70
|
+
style="position: absolute; left: 8rem; top: 0;"></div>
|
|
71
|
+
</li>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The first hover over Priya issues `GET /_components/member_peek?handle=priya`, and the bubble fills:
|
|
75
|
+
|
|
76
|
+
```html
|
|
77
|
+
<div id="member-peek-priya">
|
|
78
|
+
<strong>Priya</strong>
|
|
79
|
+
<p>Backend — UTC+5:30</p>
|
|
80
|
+
<p>Focus: payments migration</p>
|
|
81
|
+
</div>
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Related
|
|
85
|
+
|
|
86
|
+
- [Inline Expansion](inline-expansion.md) — the other Weft-native shorthand: click-driven detail that lands *after* its trigger instead of in a bubble.
|
|
87
|
+
- [Lazy Loading](lazy-loading.md) — the same load-once deferral, triggered by visibility instead of the pointer.
|
|
88
|
+
- The [shorthands table](../dsl.md#shorthands) in the DSL reference.
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# Updating Other Content
|
|
2
|
+
|
|
3
|
+
A form adds a contact, and a contacts table elsewhere on the page — outside the form, not enclosing it, not enclosed by it — updates in the same interaction. One submit, two regions refreshed.
|
|
4
|
+
|
|
5
|
+
This is Weft's take on [htmx's update-other-content example](https://htmx.org/examples/update-other-content/), which uses the same scenario: a table of contacts with an add-contact form below it. htmx's page is really an essay weighing four manual solutions — expanding the target, out-of-band swaps, event triggers, and a path-dependencies extension — each with its own wiring to write and trade-offs to hold in your head. Weft collapses the choice to declarations: `includes ContactsTable` on the form covers the common case in one line, and a `triggers`/`refreshes on:` pair covers the decoupled case in two. [The full four-way mapping is below.](#htmxs-four-solutions-mapped)
|
|
6
|
+
|
|
7
|
+
## The components
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
ADDRESS_BOOK = [
|
|
11
|
+
{ name: "Joe Smith", email: "joe@smith.org" }
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
class ContactsTable < Weft::Component
|
|
15
|
+
builder_method :contacts_table
|
|
16
|
+
|
|
17
|
+
def build(attributes = {})
|
|
18
|
+
super
|
|
19
|
+
table do
|
|
20
|
+
thead { tr { th "Name"; th "Email" } }
|
|
21
|
+
tbody do
|
|
22
|
+
ADDRESS_BOOK.each { |contact| tr { td contact[:name]; td contact[:email] } }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class NewContactForm < Weft::Component
|
|
29
|
+
builder_method :new_contact_form
|
|
30
|
+
|
|
31
|
+
attribute :name
|
|
32
|
+
attribute :email
|
|
33
|
+
|
|
34
|
+
includes ContactsTable
|
|
35
|
+
|
|
36
|
+
performs :add do |attrs|
|
|
37
|
+
ADDRESS_BOOK << { name: attrs.name, email: attrs.email }
|
|
38
|
+
{ name: nil, email: nil }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def build(attributes = {})
|
|
42
|
+
super
|
|
43
|
+
h3 "Add a contact"
|
|
44
|
+
form(action: :add) do
|
|
45
|
+
label("Name ", for: "name")
|
|
46
|
+
input(type: "text", name: "name", id: "name")
|
|
47
|
+
label(" Email ", for: "email")
|
|
48
|
+
input(type: "email", name: "email", id: "email")
|
|
49
|
+
input(type: "submit", value: "Add Contact")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
And the page that places them side by side:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
class ContactsPage < Weft::Page
|
|
59
|
+
def build(attributes = {})
|
|
60
|
+
attributes[:title] = "Contacts"
|
|
61
|
+
super
|
|
62
|
+
h1 "Contacts"
|
|
63
|
+
contacts_table
|
|
64
|
+
new_contact_form
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
(`ADDRESS_BOOK` stands in for your data layer, as usual.)
|
|
70
|
+
|
|
71
|
+
## How it works
|
|
72
|
+
|
|
73
|
+
**`includes` declares the relationship once, in the class body.** [`includes ContactsTable`](../dsl.md#includes--companions-in-the-same-response) means: whenever this form responds to an action, render the table too, marked out-of-band. htmx receives one response containing two fragments — the re-rendered form swaps into the form's place as usual, and the table fragment, carrying `hx-swap-oob="true"`, is routed to its own DOM slot by id (`#contacts-table`). One request, one response, two regions updated. This is htmx's out-of-band solution with the response construction, the OOB attribute, and the id bookkeeping all handled for you.
|
|
74
|
+
|
|
75
|
+
**The included component needs no route.** In this variant `ContactsTable` declares no attributes and no verbs, so it isn't independently addressable — `GET /_components/contacts_table` answers 404 — and that's fine: it renders inside the page and travels inside the form's responses. Companions only need to *render* (see [routing](../routing.md#routable-vs-render-target)).
|
|
76
|
+
|
|
77
|
+
**The callable resets the form.** An action callable's return value directs the re-render ([the callable contract](../dsl.md#the-callable-contract)): returning a hash merges it into the attrs. Returning `{ name: nil, email: nil }` clears the just-submitted values, so the form comes back empty after each add — htmx's reset-the-form problem solved server-side, with no `hx-on` handler. (It also keeps the component's derived DOM id, which is built from the first attribute's value, stable across renders.)
|
|
78
|
+
|
|
79
|
+
**Form fields pair with declared attributes.** The form declares `name` and `email` so the submitted fields reach the callable as `attrs.name` and `attrs.email` — the same pairing as every Weft form (covered in depth in [the tutorial](../tutorial.md#7-taking-rsvps)). And since `form(action: :add)` also emits plain `action`/`method` attributes, the add still works without JavaScript; only the tableside update needs htmx.
|
|
80
|
+
|
|
81
|
+
## The decoupled variant
|
|
82
|
+
|
|
83
|
+
`includes` is directional: the form knows the table exists. Sometimes it shouldn't — the reacting components may be many, elsewhere, or someone else's. Then the form *announces* and interested components *listen*:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
class ContactsTable < Weft::Component
|
|
87
|
+
# …exactly as before, plus one declaration:
|
|
88
|
+
refreshes on: "contact-added"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
class NewContactForm < Weft::Component
|
|
92
|
+
# …exactly as before, but in place of `includes ContactsTable`:
|
|
93
|
+
triggers "contact-added"
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
[`triggers`](../dsl.md#triggers--announce-to-the-rest-of-the-page) stamps every action response from the form with an `HX-Trigger: contact-added` header; htmx fires that as an event on the page body. [`refreshes on:`](../dsl.md#refreshes--the-client-re-fetches) wires the table's wrapper to listen for it (`from:body`) and re-fetch its own route. The two components never mention each other — any number of components can subscribe to `"contact-added"` without the form changing at all. The trade: each listener re-fetches itself, so a submit costs one extra GET per listener (and the table must now be routable, which its `refreshes` declaration itself ensures).
|
|
98
|
+
|
|
99
|
+
**Choosing between them:** reach for `includes` when the form naturally knows what it changes — everything arrives in the same response, zero extra requests. Reach for `triggers` when the reactions should be open-ended or the components shouldn't know about each other.
|
|
100
|
+
|
|
101
|
+
## htmx's four solutions, mapped
|
|
102
|
+
|
|
103
|
+
| htmx's solution | In Weft |
|
|
104
|
+
| --- | --- |
|
|
105
|
+
| 1. Expand the target | Still available — wrap both regions in one component and re-render it whole — but rarely needed once the next two are declarations. |
|
|
106
|
+
| 2. Out-of-band responses | `includes ContactsTable` on the form. |
|
|
107
|
+
| 3. Triggering events | `triggers "contact-added"` on the form, `refreshes on: "contact-added"` on the table. |
|
|
108
|
+
| 4. Path dependencies (extension) | Not needed — solutions 2 and 3 as declarations cover both coupling directions without an extension. |
|
|
109
|
+
|
|
110
|
+
## On the wire
|
|
111
|
+
|
|
112
|
+
The initial page render — two independent regions, and in this variant the table wrapper carries no wiring at all:
|
|
113
|
+
|
|
114
|
+
```html
|
|
115
|
+
<div id="contacts-table">
|
|
116
|
+
<table>…Joe Smith…</table>
|
|
117
|
+
</div>
|
|
118
|
+
<div id="new-contact-form">
|
|
119
|
+
<h3>Add a contact</h3>
|
|
120
|
+
<form hx-post="/_components/new_contact_form/add" hx-target="#new-contact-form"
|
|
121
|
+
hx-swap="outerHTML" action="/_components/new_contact_form/add" method="post">
|
|
122
|
+
…
|
|
123
|
+
</form>
|
|
124
|
+
</div>
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Submitting `POST /_components/new_contact_form/add` with `name=Angie MacDowell&email=angie@macdowell.org` returns one response holding both fragments — the emptied form, then the table marked out-of-band, new row included:
|
|
128
|
+
|
|
129
|
+
```html
|
|
130
|
+
<div id="new-contact-form">
|
|
131
|
+
<h3>Add a contact</h3>
|
|
132
|
+
<form hx-post="/_components/new_contact_form/add" hx-target="#new-contact-form"
|
|
133
|
+
hx-swap="outerHTML" action="/_components/new_contact_form/add" method="post">
|
|
134
|
+
…fresh, empty fields…
|
|
135
|
+
</form>
|
|
136
|
+
</div>
|
|
137
|
+
<div id="contacts-table" hx-swap-oob="true">
|
|
138
|
+
<table>
|
|
139
|
+
<thead><tr><th>Name</th><th>Email</th></tr></thead>
|
|
140
|
+
<tbody>
|
|
141
|
+
<tr><td>Joe Smith</td><td>joe@smith.org</td></tr>
|
|
142
|
+
<tr><td>Angie MacDowell</td><td>angie@macdowell.org</td></tr>
|
|
143
|
+
</tbody>
|
|
144
|
+
</table>
|
|
145
|
+
</div>
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
In the decoupled variant, the same submit instead answers with the form alone plus the event header:
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
HTTP/1.1 200 OK
|
|
152
|
+
content-type: text/html;charset=utf-8
|
|
153
|
+
hx-trigger: contact-added
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
…and the table, whose wrapper rendered as
|
|
157
|
+
|
|
158
|
+
```html
|
|
159
|
+
<div id="contacts-table" hx-get="/_components/contacts_table"
|
|
160
|
+
hx-trigger="contact-added from:body" hx-swap="outerHTML">
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
hears the event and issues `GET /_components/contacts_table`, which returns the fresh table with both rows.
|
|
164
|
+
|
|
165
|
+
## Related
|
|
166
|
+
|
|
167
|
+
- [Click to Edit](click-to-edit.md) — the form-fields-pair-with-attributes pattern this example builds on.
|
|
168
|
+
- [`includes`](../dsl.md#includes--companions-in-the-same-response), [`triggers`](../dsl.md#triggers--announce-to-the-rest-of-the-page), and [`refreshes`](../dsl.md#refreshes--the-client-re-fetches) in the DSL reference.
|
|
169
|
+
- `includes` accepts `on: :action_name` to scope a companion to one action, and a block to map the primary component's attrs onto the companion's — see [the DSL reference](../dsl.md#includes--companions-in-the-same-response).
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Value Select
|
|
2
|
+
|
|
3
|
+
Two selects, where the first drives the second: pick a car make, and the model select repopulates with that make's models — no page reload, no hand-written JavaScript.
|
|
4
|
+
|
|
5
|
+
This is Weft's take on [htmx's cascading-selects example](https://htmx.org/examples/value-select/), with one structural difference. Their server returns bare `<option>` tags that swap into the model select's interior; a Weft component brings its own wrapper element, so here the model select *is* the component — changing the make fetches a fresh `<select>` and swaps it into a stable slot.
|
|
6
|
+
|
|
7
|
+
## The components
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
CAR_MODELS = {
|
|
11
|
+
"audi" => %w[A1 A4 A6],
|
|
12
|
+
"toyota" => %w[Landcruiser Tacoma Yaris],
|
|
13
|
+
"bmw" => %w[325i 325ix X5]
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
class ModelSelect < Weft::Component
|
|
17
|
+
builder_method :model_select
|
|
18
|
+
|
|
19
|
+
attribute :make, default: "audi"
|
|
20
|
+
|
|
21
|
+
def build(attributes = {})
|
|
22
|
+
super
|
|
23
|
+
set_attribute :name, "model"
|
|
24
|
+
CAR_MODELS.fetch(attrs.make).each do |model|
|
|
25
|
+
option model, value: model.downcase
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def tag_name
|
|
30
|
+
"select"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The make select needs no component of its own — it's plain markup in the page, with the Weft wiring as kwargs:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
class CarsPage < Weft::Page
|
|
39
|
+
def build(attributes = {})
|
|
40
|
+
super
|
|
41
|
+
div do
|
|
42
|
+
label "Make ", for: "make"
|
|
43
|
+
select name: "make", id: "make",
|
|
44
|
+
loads: ModelSelect, trigger: "change", swap: :fill, target: "#models" do
|
|
45
|
+
option "Audi", value: "audi"
|
|
46
|
+
option "Toyota", value: "toyota"
|
|
47
|
+
option "BMW", value: "bmw"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
div do
|
|
51
|
+
label "Model "
|
|
52
|
+
span id: "models" do
|
|
53
|
+
model_select(make: "audi")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
(The `CAR_MODELS` hash stands in for your data layer, as usual.)
|
|
61
|
+
|
|
62
|
+
## How it works
|
|
63
|
+
|
|
64
|
+
**The select's own value is the parameter.** The [`loads:`](../dsl.md#loads) kwarg generates a GET to `ModelSelect`'s route with *no* query string — and htmx completes it at request time: per [its parameter rules](https://htmx.org/docs/#parameters), the element that causes a request includes its own `name=value`. Changing the select to Toyota therefore sends `GET /_components/model_select?make=toyota`, and the component's declared `make` attribute picks the parameter up on the server (see [Attributes](../dsl.md#attributes)). That inclusion is htmx client-side behavior — you won't see it in any rendered attribute — so if the models ever fail to repopulate, the first thing to check is that the triggering select still has its `name`.
|
|
65
|
+
|
|
66
|
+
**Keep the URL clean of `with:`.** Baking `with: { make: ... }` into the URL would freeze the value at render time, fighting the live selection htmx appends. In page markup, simply omitting `with:` does the right thing. Inside a *component's* `build`, though, an omitted `with:` defaults to that component's current attrs — pass an explicit `with: {}` there to keep the live value as the only parameter.
|
|
67
|
+
|
|
68
|
+
**The component is the `<select>` itself.** Overriding `tag_name` (an [Arbre-layer move](../arbre.md#inside-build-the-component-contract)) makes the wrapper element a `<select>` rather than the default `<div>`, so the fetched fragment drops into the form as a real form control. Note that its `name` attribute is set inside `build` rather than at the call site: a fragment fetched over the wire is rebuilt from its declared attributes alone, so any wrapper attribute the pattern depends on belongs in `build`. And its DOM id derives from the `make` value, changing with every swap — which is why the make select targets the stable `#models` slot with `swap: :fill` instead of chasing the select by id.
|
|
69
|
+
|
|
70
|
+
**`trigger: "change"` is spelled out for clarity.** It's also htmx's default trigger for a `<select>`, which is why the original htmx example omits it; keeping it explicit costs one kwarg and makes the interaction readable at the call site.
|
|
71
|
+
|
|
72
|
+
## On the wire
|
|
73
|
+
|
|
74
|
+
The initial render of the two selects:
|
|
75
|
+
|
|
76
|
+
```html
|
|
77
|
+
<select name="make" id="make" hx-get="/_components/model_select"
|
|
78
|
+
hx-swap="innerHTML" hx-target="#models" hx-trigger="change">
|
|
79
|
+
<option value="audi">Audi</option>
|
|
80
|
+
<option value="toyota">Toyota</option>
|
|
81
|
+
<option value="bmw">BMW</option>
|
|
82
|
+
</select>
|
|
83
|
+
...
|
|
84
|
+
<span id="models">
|
|
85
|
+
<select id="model-select-audi" name="model">
|
|
86
|
+
<option value="a1">A1</option>
|
|
87
|
+
<option value="a4">A4</option>
|
|
88
|
+
<option value="a6">A6</option>
|
|
89
|
+
</select>
|
|
90
|
+
</span>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Choosing Toyota sends `GET /_components/model_select?make=toyota`, which returns the fresh select:
|
|
94
|
+
|
|
95
|
+
```html
|
|
96
|
+
<select id="model-select-toyota" name="model">
|
|
97
|
+
<option value="landcruiser">Landcruiser</option>
|
|
98
|
+
<option value="tacoma">Tacoma</option>
|
|
99
|
+
<option value="yaris">Yaris</option>
|
|
100
|
+
</select>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
— and htmx fills `#models` with it. With no `make` parameter at all, the attribute's default renders the Audi list; parameters the component doesn't declare are simply ignored.
|
|
104
|
+
|
|
105
|
+
## Related
|
|
106
|
+
|
|
107
|
+
- [Click to Edit](click-to-edit.md) — the same `loads:` machinery swapping whole UI states instead of one control.
|
|
108
|
+
- [`loads:`](../dsl.md#loads) and [`trigger:`](../dsl.md#trigger) in the DSL reference; [Attributes](../dsl.md#attributes) for how parameters become `attrs`.
|
|
109
|
+
- The [`live_search:` shorthand](../dsl.md#shorthands) is this same fetch-into-a-slot pattern, triggered by typing instead of selecting.
|
data/docs/routing.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Routing
|
|
2
|
+
|
|
3
|
+
Weft has no routes file. Defining a `Weft::Component` or `Weft::Page` subclass registers it, and `Weft::Router` serves it: components as HTML fragments at derived paths, pages as full documents at their declared patterns, actions and SSE streams at paths derived from their components. This document is the reference for how those routes come to be — and how to control the parts you sometimes need to.
|
|
4
|
+
|
|
5
|
+
**In this document:**
|
|
6
|
+
|
|
7
|
+
- [Mounting the Router](#mounting-the-router)
|
|
8
|
+
- [Component routes](#component-routes), including [action routes](#action-routes) and [stream endpoints](#stream-endpoints)
|
|
9
|
+
- [Page routes](#page-routes)
|
|
10
|
+
- [What routes — and what doesn't](#what-routes--and-what-doesnt), including [`abstract!` and `routable!`](#abstract-and-routable) and [routable vs. render target](#routable-vs-render-target)
|
|
11
|
+
- [Collision detection](#collision-detection)
|
|
12
|
+
- [Code reloading](#code-reloading)
|
|
13
|
+
|
|
14
|
+
## Mounting the Router
|
|
15
|
+
|
|
16
|
+
`Weft::Router` is Rack middleware. It serves the requests it recognizes and passes the rest through:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
# config.ru — alongside an existing app
|
|
20
|
+
use Weft::Router
|
|
21
|
+
run MyApp
|
|
22
|
+
|
|
23
|
+
# config.ru — standalone, Weft is the whole app
|
|
24
|
+
run Weft::Router
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
In standalone mode there is no downstream app, so unmatched requests render the configured not-found page instead (see [Error handling](error-handling.md)).
|
|
28
|
+
|
|
29
|
+
One prerequisite worth knowing: registration happens when a class is *defined* (via Ruby's `inherited` hook), so your component and page files must be loaded before the first request. If you use an autoloader, eager-load these directories at boot.
|
|
30
|
+
|
|
31
|
+
## Component routes
|
|
32
|
+
|
|
33
|
+
Every routable component is addressable at a GET route that renders it as an HTML fragment — the mechanism behind `refreshes`, `navigate:`, `loads:`, and the shorthands, and equally usable directly (`curl` it; you'll get the component's HTML).
|
|
34
|
+
|
|
35
|
+
The path derives from the class name: strip a trailing `Component` if present, snake-case what's left, prefix `/_components/`. Namespaces become path segments.
|
|
36
|
+
|
|
37
|
+
| Class | Route |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `OrdersPanel` | `/_components/orders_panel` |
|
|
40
|
+
| `OrdersPanelComponent` | `/_components/orders_panel` |
|
|
41
|
+
| `Oms::OrderHeader` | `/_components/oms/order_header` |
|
|
42
|
+
|
|
43
|
+
The suffix-stripping means `OrdersPanel` and `OrdersPanelComponent` are the same route — pick whichever naming style your app prefers, consistently. The `/_components/` prefix keeps the fragment namespace visibly separate from your page URLs; the leading underscore marks it as infrastructure.
|
|
44
|
+
|
|
45
|
+
Attributes arrive as query parameters (`/_components/orders_panel?status=shipped&page=2`) and resolve through the component's declared schema — undeclared parameters are ignored, and declared ones are type-coerced from their defaults (see [Attributes](dsl.md#attributes)).
|
|
46
|
+
|
|
47
|
+
To change the path for one class, set `component_path` on it — a string, or a proc receiving the class:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
class OrdersPanel < Weft::Component
|
|
51
|
+
self.component_path = "/api/panels/orders"
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The setting is inherited by subclasses (handy on an abstract base for a whole family). To change the convention app-wide instead, replace the [`component_path` configuration proc](configuration.md#component_path).
|
|
56
|
+
|
|
57
|
+
### Action routes
|
|
58
|
+
|
|
59
|
+
Actions declared with `performs`/`transfers`/`dismisses` route under their component's path:
|
|
60
|
+
|
|
61
|
+
- **Named actions** get a subpath: `performs :advance` on `Oms::OrderHeader` routes at `POST /_components/oms/order_header/advance`.
|
|
62
|
+
- **Nameless actions** route at the component's own path, distinguished by HTTP method: `performs(method: :delete)` answers `DELETE /_components/oms/order_header`. A nameless GET action intercepts the component's render route itself.
|
|
63
|
+
|
|
64
|
+
The HTTP method comes from the declaration's `method:` kwarg (default `:post`); the Router answers GET, POST, PUT, DELETE, and PATCH.
|
|
65
|
+
|
|
66
|
+
### Stream endpoints
|
|
67
|
+
|
|
68
|
+
A component declaring `pushes` also gets an SSE endpoint at its path plus the stream suffix — by default, `/_components/order_feed/_stream`. The component's rendered `sse-connect` URL and the Router's stream handling both derive from the same [`stream_suffix` setting](configuration.md#stream_suffix), so they can't drift apart. The suffix's leading underscore keeps stream endpoints from ever colliding with a nested component path, since path segments derived from Ruby class names can't begin with an underscore.
|
|
69
|
+
|
|
70
|
+
## Page routes
|
|
71
|
+
|
|
72
|
+
Pages route as full HTML documents at people-facing URLs — no prefix, no derivation from fragments. A page declares its pattern with `page_path`, Sinatra-style, with `:param` segments mapping to attributes:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
class OrderDetailPage < Weft::Page
|
|
76
|
+
self.page_path = "/orders/:order_id"
|
|
77
|
+
attribute :order_id
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
A request for `/orders/42` renders the page with `attrs.order_id == "42"`. Path parameters merge with query and body parameters (path wins on conflicts), and the combined set resolves through the page's attribute schema like any other wire state.
|
|
82
|
+
|
|
83
|
+
The pattern is bidirectional — it also builds URLs. `Weft.redirect(OrderDetailPage, order_id: 42)` interpolates the attrs into the pattern, and `OrderDetailPage.redirect_url(order_id: 42, highlight: "items")` additionally turns declared-but-not-in-path attrs into a query string (undeclared keys are discarded, never leaked into URLs).
|
|
84
|
+
|
|
85
|
+
Pages without an explicit `page_path` infer one from the class name: demodulized, snake-cased, with a trailing `Page` stripped if present — `DashboardPage` and `Dashboard` both route at `/dashboard`. Two edges of the inference to know:
|
|
86
|
+
|
|
87
|
+
- A page with **attributes** must declare `page_path` explicitly — a parameterized pattern can't be guessed from a name, so Weft raises with the pattern it suggests rather than inventing one.
|
|
88
|
+
- A page named such that nothing usable remains after stripping (`Admin::Page`) also raises, with the remediation options spelled out.
|
|
89
|
+
|
|
90
|
+
## What routes — and what doesn't
|
|
91
|
+
|
|
92
|
+
Registration and routability are separate ideas. *Every* `Weft::Component` and `Weft::Page` subclass registers; whether it gets a route is inferred from what it declares:
|
|
93
|
+
|
|
94
|
+
- A **component** is routable when it declares interactive behavior: any attribute, action, `refreshes`, or `pushes`. A purely presentational component — just a `build` method — registers but is never served; there's nothing to address it *for*.
|
|
95
|
+
- A **page** is routable when it has a usable path: an explicit `page_path`, or a name the default can be derived from (and no attributes, per the edge above).
|
|
96
|
+
|
|
97
|
+
### `abstract!` and `routable!`
|
|
98
|
+
|
|
99
|
+
When the inference gets it wrong, override it — per class, in either direction:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
class ApplicationPage < Weft::Page
|
|
103
|
+
abstract! # shared assets and recoveries; not itself a destination
|
|
104
|
+
end
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`abstract!` marks a class non-routable no matter what it declares; `routable!` forces the opposite. The override applies only to the class that declares it — subclasses of an abstract base infer (or declare) their own routability, so the common pattern of an abstract `ApplicationPage`/`ApplicationComponent` with concrete routable children just works.
|
|
108
|
+
|
|
109
|
+
### Routable vs. render target
|
|
110
|
+
|
|
111
|
+
"Routable" means *addressable at its own GET URL* — and that is orthogonal to being a **render target**. Verbs with transfer semantics (`transfers to:`, `recovers with:`) render their target on the server, inside an in-flight response; the target class needs attributes to render with, but no route of its own. Weft's own default error components work exactly this way: they're `abstract!`, unreachable by URL, and rendered constantly.
|
|
112
|
+
|
|
113
|
+
Navigation-semantic wiring (`refreshes`, `navigate:`, `loads:`, shorthands) *does* need its target addressable — and here the inference has a gap to watch. Declaring `refreshes` makes a component routable, but being the *target* of another component's `loads:` or shorthand kwarg confers nothing: a target with no attributes and no verbs of its own quietly stays off the route table, and the element wired to load it gets a not-found response at interaction time. Most real targets declare attributes and route on their own; for a purely presentational one, declare `routable!` explicitly. Where you'll reach for `abstract!` is the opposite case: a transfer target that declares attributes (so it can render) but should never be an endpoint — declare it abstract and it carries attributes for rendering while staying off the route table.
|
|
114
|
+
|
|
115
|
+
## Collision detection
|
|
116
|
+
|
|
117
|
+
Distinct classes can resolve to the same route — two same-named classes in different namespaces under a custom `component_path` proc, a page pattern matching a component path, a class named such that suffix-stripping collides with a sibling. Rather than letting one silently shadow the other, Weft validates the whole route table on the first request: every routable component's path, each component's reserved stream endpoint, and every routable page pattern. Any duplicate raises `Weft::InvalidDefinition` naming both parties:
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
Route collision on "/_components/orders_panel": component Oms::OrdersPanel and
|
|
121
|
+
component Admin::OrdersPanel resolve to the same route. Rename one class, set an
|
|
122
|
+
explicit component_path/page_path, or mark one abstract! if it should not route.
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The same validation rejects malformed paths (anything that isn't a string beginning with `/`) from a misbehaving custom proc. Validation runs once and is memoized; registering a new class re-arms it. Non-routable classes occupy no route and can never collide.
|
|
126
|
+
|
|
127
|
+
## Code reloading
|
|
128
|
+
|
|
129
|
+
Development-mode reloaders redefine constants, which would strand the *old* class object in Weft's registry — and a stale twin at the same path would read as a route collision. Weft prunes superseded registrations automatically: at route-resolution time it drops any registered class whose name no longer resolves to that same class object. The sweep is memoized per registry generation, so production pays it once, ever.
|
|
130
|
+
|
|
131
|
+
This works with any reloading setup — [`auto_reload`](configuration.md#auto_reload), or your own Zeitwerk `reload` hook. `Weft.registry.clear` is the explicit full-reset primitive if your integration wants to rebuild registration from scratch.
|