weft 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +134 -28
- data/README.md +46 -23
- data/docs/app-patterns.md +8 -7
- data/docs/arbre.md +49 -18
- data/docs/configuration.md +42 -35
- data/docs/dsl.md +356 -105
- data/docs/error-handling.md +64 -24
- data/docs/examples/active-search.md +10 -10
- data/docs/examples/browser-dialogs.md +10 -10
- data/docs/examples/bulk-update.md +11 -11
- data/docs/examples/click-to-edit.md +14 -14
- data/docs/examples/click-to-load.md +8 -8
- data/docs/examples/delete-row.md +17 -19
- data/docs/examples/edit-row.md +17 -14
- data/docs/examples/file-upload.md +5 -5
- data/docs/examples/infinite-scroll.md +8 -8
- data/docs/examples/inline-expansion.md +8 -8
- data/docs/examples/inline-validation.md +17 -17
- data/docs/examples/lazy-loading.md +8 -8
- data/docs/examples/live-ticker.md +1 -1
- data/docs/examples/modal-dialog.md +3 -3
- data/docs/examples/progress-bar.md +1 -1
- data/docs/examples/reset-user-input.md +7 -7
- data/docs/examples/tabs.md +4 -4
- data/docs/examples/tooltip.md +8 -8
- data/docs/examples/updating-other-content.md +9 -9
- data/docs/examples/value-select.md +11 -11
- data/docs/params.md +112 -0
- data/docs/routing.md +13 -13
- data/docs/tutorial.md +46 -48
- data/lib/weft/action.rb +4 -2
- data/lib/weft/autoloading.rb +69 -0
- data/lib/weft/component.rb +97 -31
- data/lib/weft/configuration.rb +37 -5
- data/lib/weft/context/expansion.rb +184 -0
- data/lib/weft/context/interception.rb +22 -2
- data/lib/weft/context/modifiers.rb +78 -0
- data/lib/weft/context/traversal.rb +80 -0
- data/lib/weft/context/wiring.rb +85 -0
- data/lib/weft/context.rb +70 -164
- data/lib/weft/defaults/error_component.rb +57 -21
- data/lib/weft/defaults/error_page.rb +12 -10
- data/lib/weft/defaults/not_found_component.rb +14 -12
- data/lib/weft/defaults/not_found_page.rb +9 -8
- data/lib/weft/dsl/actions.rb +9 -9
- data/lib/weft/dsl/inclusions.rb +48 -11
- data/lib/weft/dsl/params.rb +265 -0
- data/lib/weft/dsl/recoveries.rb +36 -6
- data/lib/weft/dsl/sandbox.rb +26 -0
- data/lib/weft/dsl/triggers.rb +28 -8
- data/lib/weft/dsl/updates.rb +31 -8
- data/lib/weft/error.rb +12 -1
- data/lib/weft/page/assets.rb +222 -0
- data/lib/weft/page/head.rb +87 -0
- data/lib/weft/page.rb +55 -239
- data/lib/weft/params/assembly.rb +170 -0
- data/lib/weft/params.rb +138 -0
- data/lib/weft/presets.rb +96 -0
- data/lib/weft/redirect.rb +7 -7
- data/lib/weft/registry/eligibility.rb +5 -19
- data/lib/weft/registry.rb +58 -18
- data/lib/weft/resolver.rb +48 -20
- data/lib/weft/router/actions.rb +106 -22
- data/lib/weft/router/errors.rb +223 -83
- data/lib/weft/router/oob_includes.rb +202 -17
- data/lib/weft/router/streaming.rb +86 -20
- data/lib/weft/router.rb +33 -25
- data/lib/weft/version.rb +1 -1
- data/lib/weft.rb +37 -24
- metadata +32 -8
- data/lib/weft/attributes.rb +0 -65
- data/lib/weft/dsl/attributes.rb +0 -43
- data/lib/weft/shorthands.rb +0 -57
data/docs/examples/edit-row.md
CHANGED
|
@@ -16,7 +16,8 @@ PEOPLE = {
|
|
|
16
16
|
class PersonRow < Weft::Component
|
|
17
17
|
builder_method :person_row
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
param :person_id
|
|
20
|
+
receives :person_id
|
|
20
21
|
|
|
21
22
|
def tag_name
|
|
22
23
|
"tr"
|
|
@@ -24,11 +25,11 @@ class PersonRow < Weft::Component
|
|
|
24
25
|
|
|
25
26
|
def build(attributes = {})
|
|
26
27
|
super
|
|
27
|
-
person = PEOPLE.fetch(
|
|
28
|
+
person = PEOPLE.fetch(params.person_id)
|
|
28
29
|
td person[:name]
|
|
29
30
|
td person[:email]
|
|
30
31
|
td do
|
|
31
|
-
button "Edit", loads: PersonRowEditor, with: { person_id:
|
|
32
|
+
button "Edit", loads: PersonRowEditor, with: { person_id: params.person_id },
|
|
32
33
|
swap: :replace, target: self
|
|
33
34
|
end
|
|
34
35
|
end
|
|
@@ -37,12 +38,12 @@ end
|
|
|
37
38
|
class PersonRowEditor < Weft::Component
|
|
38
39
|
builder_method :person_row_editor
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
param :person_id
|
|
42
|
+
param :name
|
|
43
|
+
param :email
|
|
43
44
|
|
|
44
|
-
transfers :save, to: PersonRow do |
|
|
45
|
-
PEOPLE.fetch(
|
|
45
|
+
transfers :save, to: PersonRow do |params|
|
|
46
|
+
PEOPLE.fetch(params.person_id).merge!(name: params.name, email: params.email)
|
|
46
47
|
nil
|
|
47
48
|
end
|
|
48
49
|
|
|
@@ -52,16 +53,16 @@ class PersonRowEditor < Weft::Component
|
|
|
52
53
|
|
|
53
54
|
def build(attributes = {})
|
|
54
55
|
super
|
|
55
|
-
person = PEOPLE.fetch(
|
|
56
|
-
save_form = "save-person-#{
|
|
56
|
+
person = PEOPLE.fetch(params.person_id)
|
|
57
|
+
save_form = "save-person-#{params.person_id}"
|
|
57
58
|
td { input type: "text", name: "name", value: person[:name], form: save_form }
|
|
58
59
|
td { input type: "text", name: "email", value: person[:email], form: save_form }
|
|
59
60
|
td do
|
|
60
61
|
form(action: :save, id: save_form) do
|
|
61
|
-
input type: "hidden", name: "person_id", value:
|
|
62
|
+
input type: "hidden", name: "person_id", value: params.person_id
|
|
62
63
|
input type: "submit", value: "Save"
|
|
63
64
|
button "Cancel", type: "button",
|
|
64
|
-
loads: PersonRow, with: { person_id:
|
|
65
|
+
loads: PersonRow, with: { person_id: params.person_id },
|
|
65
66
|
swap: :replace, target: self
|
|
66
67
|
end
|
|
67
68
|
end
|
|
@@ -87,7 +88,9 @@ end
|
|
|
87
88
|
|
|
88
89
|
## How it works
|
|
89
90
|
|
|
90
|
-
**It's click-to-edit, once per row.** Both components render as `<tr>` (the `tag_name` override), with the identifying
|
|
91
|
+
**It's click-to-edit, once per row.** Both components render as `<tr>` (the `tag_name` override), with the identifying param declared first so each carries a usable DOM id. Entering edit mode changes nothing on the server, so Edit is a [`loads:`](../dsl.md#loads) — a GET that fetches the editor row and replaces the display row (`swap: :replace, target: self`). Saving is a [`transfers`](../dsl.md#transfers--actions-that-render-something-else): the write runs, then the *display* row renders in the editor's place. Cancel is the Edit button's mirror image, pointed back at `PersonRow`. As in click-to-edit, defining the display component first lets `transfers :save, to: PersonRow` resolve in the editor's class body, while `loads: PersonRowEditor` waits until render.
|
|
92
|
+
|
|
93
|
+
**Where `person_id` comes from differs by component.** The table hands each display row its `person_id` (`person_row(person_id: id)`) — a [`receives`](../dsl.md#receives--caller-hand-offs) hand-off, since per-row values can't inherit down the render tree — and `PersonRow` declares it as a `param` too, so the same id serializes into the row's own route and DOM id. The editor is different: it's always *fetched* by URL (Edit and Cancel both `loads:` it with `with: { person_id: … }`), so it reads `person_id` straight from the wire — `param` alone, no hand-off.
|
|
91
94
|
|
|
92
95
|
**A form can't wrap table cells — so the cells point at the form.** HTML won't allow a `<form>` to span `<td>`s inside a row, which is the structural puzzle of this pattern. htmx's original solves it with `hx-include="closest tr"`; here plain HTML does the same job: the form lives in the last cell, and the name and email inputs associate with it from their own cells via the standard [`form` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#form). Form-associated elements are part of the form's submission set, so both htmx's payload *and* the no-JavaScript fallback submission include all three fields — nothing about the association needs scripting.
|
|
93
96
|
|
|
@@ -137,7 +140,7 @@ Submitting `POST /_components/person_row_editor/save` with the edited fields ret
|
|
|
137
140
|
</tr>
|
|
138
141
|
```
|
|
139
142
|
|
|
140
|
-
Only the
|
|
143
|
+
Only the params `PersonRow` itself declares travel into that render — the editor's `name` and `email` were consumed by the save and play no part in the row's element.
|
|
141
144
|
|
|
142
145
|
## Related
|
|
143
146
|
|
|
@@ -12,10 +12,10 @@ UPLOADED_REPORTS = []
|
|
|
12
12
|
class ReportUploader < Weft::Component
|
|
13
13
|
builder_method :report_uploader
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
param :document
|
|
16
16
|
|
|
17
|
-
performs :upload do |
|
|
18
|
-
file =
|
|
17
|
+
performs :upload do |params|
|
|
18
|
+
file = params.document
|
|
19
19
|
if file.is_a?(Hash) && file[:tempfile]
|
|
20
20
|
UPLOADED_REPORTS << { name: file[:filename], size: file[:tempfile].size }
|
|
21
21
|
end
|
|
@@ -44,9 +44,9 @@ end
|
|
|
44
44
|
|
|
45
45
|
**One HTML attribute makes it multipart.** `enctype: "multipart/form-data"` isn't Weft vocabulary — it passes through to the `<form>` untouched, and it does double duty there: htmx honors a form's native enctype when it builds the request, and the no-JS fallback submit needs the same attribute anyway. Leave it off and the request still fires, but as an ordinary urlencoded POST whose file field has collapsed to the string `"[object File]"` — nothing a server can use. (htmx also has an `hx-encoding` attribute; it's only needed to force multipart from something other than a form.)
|
|
46
46
|
|
|
47
|
-
**The file arrives through a declared
|
|
47
|
+
**The file arrives through a declared param.** The `document` param receives whatever the server's multipart parsing produces — under Sinatra, a hash carrying `:filename`, `:type`, and a `:tempfile` ready to read. The callable checks for that shape before storing, which quietly covers the other case too: submitting with no file chosen sends `document=` (an empty string), the `is_a?(Hash)` guard skips it, and the re-render is a no-op.
|
|
48
48
|
|
|
49
|
-
**Returning `{ document: nil }` is load-bearing.** A callable's returned hash merges into the
|
|
49
|
+
**Returning `{ document: nil }` is load-bearing.** A callable's returned hash merges into the params for the re-render ([the callable contract](../dsl.md#the-callable-contract)) — and this one uses that to *clear* the file param rather than add anything. Weft derives a component's DOM id from its first declared param, and a tempfile-toting multipart hash in that slot would smear itself across the wrapper's id and every piece of htmx wiring derived from it. Cleared, the component comes back as plain `#report-uploader`: same id, same wiring, fresh empty file input.
|
|
50
50
|
|
|
51
51
|
## On the wire
|
|
52
52
|
|
|
@@ -14,7 +14,7 @@ class ContactRows < Weft::Component
|
|
|
14
14
|
|
|
15
15
|
PER_PAGE = 10
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
param :page, default: 1, type: :integer
|
|
18
18
|
|
|
19
19
|
def tag_name
|
|
20
20
|
"tbody"
|
|
@@ -22,10 +22,10 @@ class ContactRows < Weft::Component
|
|
|
22
22
|
|
|
23
23
|
def build(attributes = {})
|
|
24
24
|
super
|
|
25
|
-
rows = DIRECTORY[(
|
|
25
|
+
rows = DIRECTORY[(params.page - 1) * PER_PAGE, PER_PAGE]
|
|
26
26
|
rows.each_with_index do |contact, index|
|
|
27
27
|
if index == rows.size - 1 && more_pages?
|
|
28
|
-
tr(infinite_scroll: ContactRows, with: { page:
|
|
28
|
+
tr(infinite_scroll: ContactRows, with: { page: params.page + 1 }, target: "closest tbody") do
|
|
29
29
|
td contact[:name]; td contact[:email]
|
|
30
30
|
end
|
|
31
31
|
else
|
|
@@ -37,7 +37,7 @@ class ContactRows < Weft::Component
|
|
|
37
37
|
private
|
|
38
38
|
|
|
39
39
|
def more_pages?
|
|
40
|
-
|
|
40
|
+
params.page * PER_PAGE < DIRECTORY.size
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
43
|
|
|
@@ -49,7 +49,7 @@ class ContactDirectory < Weft::Component
|
|
|
49
49
|
h3 "Contact directory"
|
|
50
50
|
table do
|
|
51
51
|
thead { tr { th "Name"; th "Email" } }
|
|
52
|
-
contact_rows
|
|
52
|
+
contact_rows
|
|
53
53
|
end
|
|
54
54
|
end
|
|
55
55
|
end
|
|
@@ -59,13 +59,13 @@ end
|
|
|
59
59
|
|
|
60
60
|
## How it works
|
|
61
61
|
|
|
62
|
-
**The last row is the sentinel.** [`infinite_scroll:`](../dsl.md#
|
|
62
|
+
**The last row is the sentinel.** [`infinite_scroll:`](../dsl.md#presets) presets trigger `:visible` and swap `:after`; the call site adds the target. Placed on the final `tr` of a batch, it means: when this row scrolls into view, fetch the next batch and insert it after the closest `tbody`. The trigger expands to htmx's `revealed`, which fires once per element — each sentinel row does its job exactly one time.
|
|
63
63
|
|
|
64
64
|
**`target: "closest tbody"` keeps the table valid.** The fetched component is a whole `<tbody>` (that's the `tag_name` override), and swapping it `afterend` of the *row* would nest table sections illegally. Aiming the swap at the closest `tbody` instead makes each batch a sibling section — the shape HTML already sanctions for row grouping. This is why the preset leaves the target to you: only the call site knows what the insertion point should be.
|
|
65
65
|
|
|
66
66
|
**The chain stops itself.** The sentinel wiring is only rendered while `more_pages?` holds. The final batch is just rows — nothing left to trigger, nothing fetched past the end of the data.
|
|
67
67
|
|
|
68
|
-
**`page` travels as wire state.** `
|
|
68
|
+
**`page` travels as wire state.** `param :page, default: 1, type: :integer` makes each batch independently addressable (`/_components/contact_rows?page=3`), coercing the URL string to an Integer on the way in. The `ContactDirectory` wrapper, by contrast, declares nothing — it's plain composition and renders only as part of a page.
|
|
69
69
|
|
|
70
70
|
## On the wire
|
|
71
71
|
|
|
@@ -95,4 +95,4 @@ Scrolling that row into view issues `GET /_components/contact_rows?page=2`, whos
|
|
|
95
95
|
|
|
96
96
|
- [Click to Load](click-to-load.md) — the same batch-by-batch growth, but on the reader's explicit request.
|
|
97
97
|
- [Lazy Loading](lazy-loading.md) — the `:visible` trigger deferring a single section instead of paginating many.
|
|
98
|
-
- The [
|
|
98
|
+
- The [presets table](../dsl.md#presets) and [targets](../dsl.md#targets) in the DSL reference.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A compact row with a disclosure control. Clicking it fetches the row's detail from the server and inserts it directly beneath — the list stays a list, and depth appears exactly where the reader asked for it.
|
|
4
4
|
|
|
5
|
-
There's no counterpart for this in the htmx examples catalog; `inline_expand:` is Weft-native sugar over the same [`loads:`](../dsl.md#loads) machinery as the other
|
|
5
|
+
There's no counterpart for this in the htmx examples catalog; `inline_expand:` is Weft-native sugar over the same [`loads:`](../dsl.md#loads) machinery as the other presets. It's the pattern for master-detail tables where the detail is cheap to want and expensive to preload for every row.
|
|
6
6
|
|
|
7
7
|
## The components
|
|
8
8
|
|
|
@@ -16,7 +16,7 @@ ORDERS = {
|
|
|
16
16
|
class OrderItemsRow < Weft::Component
|
|
17
17
|
builder_method :order_items_row
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
param :order_id
|
|
20
20
|
|
|
21
21
|
def tag_name
|
|
22
22
|
"tr"
|
|
@@ -24,7 +24,7 @@ class OrderItemsRow < Weft::Component
|
|
|
24
24
|
|
|
25
25
|
def build(attributes = {})
|
|
26
26
|
super
|
|
27
|
-
order = ORDERS.fetch(
|
|
27
|
+
order = ORDERS.fetch(params.order_id)
|
|
28
28
|
td colspan: 4 do
|
|
29
29
|
strong "Items: "
|
|
30
30
|
text_node order[:items].join(", ")
|
|
@@ -44,7 +44,7 @@ class OrdersTable < Weft::Component
|
|
|
44
44
|
tr do
|
|
45
45
|
td do
|
|
46
46
|
button "▸", inline_expand: OrderItemsRow, with: { order_id: id },
|
|
47
|
-
target: "closest tr"
|
|
47
|
+
target: "closest tr"
|
|
48
48
|
end
|
|
49
49
|
td id
|
|
50
50
|
td order[:customer]
|
|
@@ -61,9 +61,9 @@ end
|
|
|
61
61
|
|
|
62
62
|
## How it works
|
|
63
63
|
|
|
64
|
-
**The detail arrives *after* the trigger's row.** [`inline_expand:`](../dsl.md#
|
|
64
|
+
**The detail arrives *after* the trigger's row.** [`inline_expand:`](../dsl.md#presets) presets trigger `:click_once` and swap `:after` (htmx's `afterend`); the call site supplies the target. `target: "closest tr"` walks up from the button to its row, so the fetched component is inserted as the next sibling row — which is why `OrderItemsRow` overrides `tag_name` to render as a `<tr>`. A `colspan` spanning the table's columns lets the detail breathe across the full width.
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
**One click, one insertion.** The preset's `:click_once` bakes in htmx's `once` modifier, capping the interaction at a single firing — because the detail lands *after* the button's row rather than replacing anything, a repeat click would otherwise insert a second copy. If your detail row removes itself on close and the button should work again, override with `trigger: :click`.
|
|
67
67
|
|
|
68
68
|
**Expansion, not a toggle.** Once expanded, the row stays expanded — this preset opens, it doesn't close. When you need collapse, give the detail component the behavior: a [`dismisses`](../dsl.md#dismisses--remove-from-the-dom) verb and a "Hide" button inside `OrderItemsRow` remove it from the DOM server-consistently. (The one-shot button does remain spent after that; a fully re-armable open/close control is a two-state component pair, as in [Click to Edit](click-to-edit.md).)
|
|
69
69
|
|
|
@@ -93,6 +93,6 @@ Clicking issues `GET /_components/order_items_row?order_id=1001`, and the respon
|
|
|
93
93
|
|
|
94
94
|
## Related
|
|
95
95
|
|
|
96
|
-
- [Tooltip](tooltip.md) — the other Weft-native
|
|
96
|
+
- [Tooltip](tooltip.md) — the other Weft-native preset: hover-driven detail loaded into a bubble instead of a row.
|
|
97
97
|
- [Click to Edit](click-to-edit.md) — two-state rows that swap in place rather than expanding.
|
|
98
|
-
- The [
|
|
98
|
+
- The [presets table](../dsl.md#presets) and [trigger values](../dsl.md#trigger-values) in the DSL reference.
|
|
@@ -12,11 +12,11 @@ TAKEN_EMAILS = ["taken@example.com"].freeze
|
|
|
12
12
|
class SignupEmailField < Weft::Component
|
|
13
13
|
builder_method :signup_email_field
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
param :email
|
|
16
|
+
param :error_message
|
|
17
17
|
|
|
18
|
-
performs :validate, target: "#signup-email-field" do |
|
|
19
|
-
email =
|
|
18
|
+
performs :validate, target: "#signup-email-field" do |params|
|
|
19
|
+
email = params.email.to_s.strip
|
|
20
20
|
unless email.match?(URI::MailTo::EMAIL_REGEXP)
|
|
21
21
|
raise Weft::Unprocessable, "That doesn't look like an email address."
|
|
22
22
|
end
|
|
@@ -25,21 +25,21 @@ class SignupEmailField < Weft::Component
|
|
|
25
25
|
nil
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
recovers from: Weft::Unprocessable do |
|
|
28
|
+
recovers from: Weft::Unprocessable do |_params, error|
|
|
29
29
|
{ error_message: error.message }
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def build(attributes = {})
|
|
33
33
|
super
|
|
34
34
|
set_attribute :id, "signup-email-field"
|
|
35
|
-
form(action: :validate, trigger:
|
|
35
|
+
form(action: :validate, trigger: :change) do
|
|
36
36
|
label "Email Address ", for: "email"
|
|
37
|
-
input type: "email", name: "email", id: "email", value:
|
|
37
|
+
input type: "email", name: "email", id: "email", value: params.email
|
|
38
38
|
end
|
|
39
|
-
if
|
|
40
|
-
para
|
|
41
|
-
elsif
|
|
42
|
-
para "#{
|
|
39
|
+
if params.error_message
|
|
40
|
+
para params.error_message, style: "color:#b91c1c"
|
|
41
|
+
elsif params.email
|
|
42
|
+
para "#{params.email} looks good.", style: "color:#15803d"
|
|
43
43
|
end
|
|
44
44
|
end
|
|
45
45
|
end
|
|
@@ -49,15 +49,15 @@ end
|
|
|
49
49
|
|
|
50
50
|
## How it works
|
|
51
51
|
|
|
52
|
-
**The field is a component, and the form belongs to the field.** `form(action: :validate, trigger:
|
|
52
|
+
**The field is a component, and the form belongs to the field.** `form(action: :validate, trigger: :change)` wires the POST like any action form, but `trigger:` swaps the form's natural submit trigger for `change` events — which bubble up from the input, so the request fires the moment the user leaves the field. A form's fields are its payload: the typed email reaches the callable as `params.email`, exactly as it would on a full submit.
|
|
53
53
|
|
|
54
|
-
**Put the action on the form, not the input.** It's tempting to skip the form and hang `action: :validate, trigger:
|
|
54
|
+
**Put the action on the form, not the input.** It's tempting to skip the form and hang `action: :validate, trigger: :change` on the input itself. Don't: on a non-form element, `action:` carries the component's declared params along as `hx-vals`, and htmx gives those precedence over the triggering element's own value — so the request goes out with the *component's* stale idea of the email, never the fresh keystrokes. A one-field form is the honest wiring: what's in the field is what gets sent.
|
|
55
55
|
|
|
56
|
-
**Validation failures are still renders.** Bad input raises `Weft::Unprocessable`; the `recovers from:` block catches it and returns `{ error_message: error.message }`, which merges into the
|
|
56
|
+
**Validation failures are still renders.** Bad input raises `Weft::Unprocessable`; the `recovers from:` block catches it and returns `{ error_message: error.message }`, which merges into the params for the re-render. The response goes out as a semantic `422 Unprocessable Content` whose body is this same component wearing its error paragraph. Valid input sails through to the `nil` return and renders the success line at a plain `200`. (The machinery is [the `recovers` chain](../error-handling.md#the-recovers-chain); the merge is [the callable contract](../dsl.md#the-callable-contract).)
|
|
57
57
|
|
|
58
|
-
**A value that changes can't anchor the DOM id.** Weft derives a component's DOM id from its first declared
|
|
58
|
+
**A value that changes can't anchor the DOM id.** Weft derives a component's DOM id from its first declared param — here that's the email itself, which would give the wrapper a different id on every render. So the component pins its own slot: `set_attribute :id, "signup-email-field"` fixes the wrapper's id, and `performs :validate, target: "#signup-email-field"` points the swap at that same anchor. (The same stable-slot idiom as [Bulk Update](bulk-update.md), for the same reason.)
|
|
59
59
|
|
|
60
|
-
**The field echoes what the user typed.** The swap replaces the whole component, input included, so `value:
|
|
60
|
+
**The field echoes what the user typed.** The swap replaces the whole component, input included, so `value: params.email` writes the submitted text back into the fresh input — without it, every complaint would also blank the field.
|
|
61
61
|
|
|
62
62
|
## On the wire
|
|
63
63
|
|
|
@@ -97,5 +97,5 @@ And the part that makes it *inline*: a browser's `change` event on the input bub
|
|
|
97
97
|
## Related
|
|
98
98
|
|
|
99
99
|
- [Bulk Update](bulk-update.md) — the stable-slot idiom this page borrows, and reporting back through the returned hash.
|
|
100
|
-
- [Click to Edit](click-to-edit.md) — the basics of pairing form fields with declared
|
|
100
|
+
- [Click to Edit](click-to-edit.md) — the basics of pairing form fields with declared params.
|
|
101
101
|
- [`performs`](../dsl.md#performs--user-initiated-actions), [`recovers`](../dsl.md#recovers--declare-error-behavior), and [`trigger:`](../dsl.md#trigger) in the DSL reference; [Error handling](../error-handling.md#the-recovers-chain) for the full recovery story.
|
|
@@ -14,14 +14,14 @@ QUARTERLY_REVENUE = {
|
|
|
14
14
|
class RevenueTable < Weft::Component
|
|
15
15
|
builder_method :revenue_table
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
param :year, default: 2025, type: :integer
|
|
18
18
|
|
|
19
19
|
def build(attributes = {})
|
|
20
20
|
super
|
|
21
21
|
table do
|
|
22
22
|
thead { tr { th "Quarter"; th "Revenue" } }
|
|
23
23
|
tbody do
|
|
24
|
-
QUARTERLY_REVENUE.fetch(
|
|
24
|
+
QUARTERLY_REVENUE.fetch(params.year).each do |quarter, amount|
|
|
25
25
|
tr { td quarter; td amount }
|
|
26
26
|
end
|
|
27
27
|
end
|
|
@@ -32,15 +32,15 @@ end
|
|
|
32
32
|
class AnnualReport < Weft::Component
|
|
33
33
|
builder_method :annual_report
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
param :year, default: 2025, type: :integer
|
|
36
36
|
|
|
37
37
|
def build(attributes = {})
|
|
38
38
|
super
|
|
39
|
-
h2 "#{
|
|
39
|
+
h2 "#{params.year} annual report"
|
|
40
40
|
para "Commentary, highlights, and everything else the reader scrolls through " \
|
|
41
41
|
"before the numbers. The revenue table below is expensive to produce, " \
|
|
42
42
|
"so it loads only when it comes into view."
|
|
43
|
-
div lazy: RevenueTable, with: { year:
|
|
43
|
+
div lazy: RevenueTable, with: { year: params.year } do
|
|
44
44
|
para "Loading revenue…"
|
|
45
45
|
end
|
|
46
46
|
end
|
|
@@ -51,13 +51,13 @@ end
|
|
|
51
51
|
|
|
52
52
|
## How it works
|
|
53
53
|
|
|
54
|
-
**The placeholder is an ordinary element with one extra kwarg.** [`lazy:`](../dsl.md#
|
|
54
|
+
**The placeholder is an ordinary element with one extra kwarg.** [`lazy:`](../dsl.md#presets) is a preset over the [`loads:`](../dsl.md#loads) machinery: trigger `:visible`, swap `:fill`, target `:self`. When the div scrolls into view, fetch `RevenueTable` and swap it into the div's interior. Because trigger, swap, *and* target all have an obvious right answer here, the call site needs nothing beyond the component class and its params.
|
|
55
55
|
|
|
56
56
|
**The div's children are the loading state.** Because the swap is `:fill` (`innerHTML`), the placeholder element itself survives; only its contents — the "Loading revenue…" paragraph — are replaced by the fetched component. Whatever you put in the block is what users see until the content arrives.
|
|
57
57
|
|
|
58
58
|
**`:visible` means once.** The semantic trigger expands to htmx's `revealed`, which fires a single time when the element first enters the viewport. The wrapper keeps its wiring after the swap, but no re-fetch loop follows.
|
|
59
59
|
|
|
60
|
-
**`with:` passes the wire state along.** The placeholder hands its own `year` down to the loaded component (`with: { year:
|
|
60
|
+
**`with:` passes the wire state along.** The placeholder hands its own `year` down to the loaded component (`with: { year: params.year }`), and the value travels in the URL — visible below. If you omit `with:`, the enclosing component's params are passed by default.
|
|
61
61
|
|
|
62
62
|
## On the wire
|
|
63
63
|
|
|
@@ -93,4 +93,4 @@ Scrolling it into view issues `GET /_components/revenue_table?year=2025`, and th
|
|
|
93
93
|
|
|
94
94
|
- [Infinite Scroll](infinite-scroll.md) — the same `:visible` trigger, used repeatedly to grow a table page by page.
|
|
95
95
|
- [Click to Load](click-to-load.md) — deferred loading where the user asks for more, instead of scrolling to it.
|
|
96
|
-
- The [
|
|
96
|
+
- The [presets table](../dsl.md#presets) and the [trigger values](../dsl.md#trigger-values) in the DSL reference.
|
|
@@ -88,4 +88,4 @@ Each multi-line rendering becomes consecutive `data:` lines in one event, per th
|
|
|
88
88
|
|
|
89
89
|
- [Progress Bar](progress-bar.md) — the polling counterpart: the client re-fetches on a timer instead of holding a connection.
|
|
90
90
|
- [`pushes`](../dsl.md#pushes--the-server-sends-updates) in the DSL reference; [`stream_suffix`](../configuration.md#stream_suffix) and [`include_sse_ext`](../configuration.md#include_sse_ext) in configuration.
|
|
91
|
-
- A component with
|
|
91
|
+
- A component with params streams too — its wire state rides the `sse-connect` URL as query parameters, so each subscriber gets frames for *its* instance.
|
|
@@ -42,9 +42,9 @@ end
|
|
|
42
42
|
|
|
43
43
|
## How it works
|
|
44
44
|
|
|
45
|
-
**Opening is a load into a stable slot.** [`modal:`](../dsl.md#
|
|
45
|
+
**Opening is a load into a stable slot.** [`modal:`](../dsl.md#presets) presets trigger `:click` and swap `:fill`; the call site supplies the target. Clicking the button fetches `TourDialog` and fills the empty `#modal-slot` div with it. The slot is permanent page structure — it outlives any dialog placed in it, so the modal can be opened again after closing.
|
|
46
46
|
|
|
47
|
-
**Closing is a dismissal.** `dismisses :close` declares a DELETE action whose swap removes the component from the page entirely — and for a modal, removed from the DOM *is* closed. The dialog vanishes, the slot div stays, and the page underneath was never touched. Give the dismissal a block (`dismisses :close do |
|
|
47
|
+
**Closing is a dismissal.** `dismisses :close` declares a DELETE action whose swap removes the component from the page entirely — and for a modal, removed from the DOM *is* closed. The dialog vanishes, the slot div stays, and the page underneath was never touched. Give the dismissal a block (`dismisses :close do |params| ... end`) if closing should also do something server-side, like recording that the tour offer was seen.
|
|
48
48
|
|
|
49
49
|
**The underlay closes too — same action, different element.** [`action:`](../dsl.md#action) isn't just for buttons: on the underlay div it expands to exactly the same wiring as the Close button, and htmx's default trigger for a div is a click. Because the underlay and the content panel are *siblings* — the underlay covers the viewport, the panel floats above it — a click on the panel never bubbles to the underlay, so only clicks on the dimmed backdrop dismiss.
|
|
50
50
|
|
|
@@ -85,4 +85,4 @@ Clicking either one issues `DELETE /_components/tour_dialog/close`. The response
|
|
|
85
85
|
|
|
86
86
|
- [Browser Dialogs](browser-dialogs.md) — when a native `confirm()` box is dialog enough.
|
|
87
87
|
- [Keyboard Shortcuts](keyboard-shortcuts.md) — the [`trigger:`](../dsl.md#trigger) grammar that could add Escape-to-close to this dialog.
|
|
88
|
-
- [`dismisses`](../dsl.md#dismisses--remove-from-the-dom) and the [
|
|
88
|
+
- [`dismisses`](../dsl.md#dismisses--remove-from-the-dom) and the [presets table](../dsl.md#presets) in the DSL reference.
|
|
@@ -14,12 +14,12 @@ GUEST_COMMENTS = [
|
|
|
14
14
|
class CommentSection < Weft::Component
|
|
15
15
|
builder_method :comment_section
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
param :author
|
|
18
|
+
param :body
|
|
19
19
|
|
|
20
|
-
performs :post do |
|
|
21
|
-
author =
|
|
22
|
-
body =
|
|
20
|
+
performs :post do |params|
|
|
21
|
+
author = params.author.to_s.strip
|
|
22
|
+
body = params.body.to_s.strip
|
|
23
23
|
GUEST_COMMENTS << { author: author, body: body } unless author.empty? || body.empty?
|
|
24
24
|
{ author: nil, body: nil }
|
|
25
25
|
end
|
|
@@ -52,7 +52,7 @@ end
|
|
|
52
52
|
|
|
53
53
|
**The response replaces the form, typed text and all.** `build` renders the list from the store and the inputs with no `value:` at all — so every render of this component has empty fields. The action's `outerHTML` swap replaces the old elements wholesale: the inputs holding the typed text leave the DOM, and fresh, empty ones arrive in the same response that shows the new comment in the list. What htmx solves with an after-request reset handler, the render model here dissolves.
|
|
54
54
|
|
|
55
|
-
**Clearing the params in the return keeps the identity stable.** The callable ends with `{ author: nil, body: nil }` — a returned hash merges into the
|
|
55
|
+
**Clearing the params in the return keeps the identity stable.** The callable ends with `{ author: nil, body: nil }` — a returned hash merges into the params for the re-render ([the callable contract](../dsl.md#the-callable-contract)), and Weft derives a component's DOM id from its first declared param. Cleared, the wrapper comes back as the same `#comment-section` the htmx wiring points at; left populated, the submitted name would leak into the re-rendered component's identity.
|
|
56
56
|
|
|
57
57
|
**Blank submits are the callable's problem, and it handles them.** The browser happily posts `author=&body=`; the strip-and-check guard appends nothing, and the response is simply the current state re-rendered. Client-side `required` attributes would be a fine courtesy on top, but the server-side guard is the one that holds.
|
|
58
58
|
|
|
@@ -97,5 +97,5 @@ In a real browser the captured round trip reads the same: the POST body carries
|
|
|
97
97
|
## Related
|
|
98
98
|
|
|
99
99
|
- [File Upload](file-upload.md) — clearing a param in the return for a different reason.
|
|
100
|
-
- [Click to Edit](click-to-edit.md) — when you *do* want fields pre-filled, pair them with declared
|
|
100
|
+
- [Click to Edit](click-to-edit.md) — when you *do* want fields pre-filled, pair them with declared params.
|
|
101
101
|
- [`performs`](../dsl.md#performs--user-initiated-actions) and [the callable contract](../dsl.md#the-callable-contract) in the DSL reference.
|
data/docs/examples/tabs.md
CHANGED
|
@@ -59,11 +59,11 @@ end
|
|
|
59
59
|
|
|
60
60
|
## How it works
|
|
61
61
|
|
|
62
|
-
**Every button aims at the same panel.** [`tabs:`](../dsl.md#
|
|
62
|
+
**Every button aims at the same panel.** [`tabs:`](../dsl.md#presets) presets trigger `:click` and swap `:fill`; the call site supplies the shared target. Clicking a tab fetches its component and replaces the panel's contents. The default tab is simply pre-rendered into the panel — the initial state is the same component a click would fetch.
|
|
63
63
|
|
|
64
|
-
**`routable!` makes contentful-but-stateless tabs fetchable.** A component normally earns its route by declaring
|
|
64
|
+
**`routable!` makes contentful-but-stateless tabs fetchable.** A component normally earns its route by declaring params or verbs ([Routing](../routing.md)); these tabs declare neither, so without help they would render fine *and* be unreachable over the wire — the buttons would point at URLs that 404. `routable!` opts them in explicitly. (Tabs that take wire state — an `order_id`, say — get their routes the ordinary way and don't need it.)
|
|
65
65
|
|
|
66
|
-
**The selected tab isn't highlighted — yet.** Only the panel is swapped; the buttons themselves never re-render, so nothing moves a "selected" class around. htmx's example re-renders the whole tab bar with each click for exactly this reason. The Weft equivalent of that move is to give the wrapping component the state and re-render it whole — `
|
|
66
|
+
**The selected tab isn't highlighted — yet.** Only the panel is swapped; the buttons themselves never re-render, so nothing moves a "selected" class around. htmx's example re-renders the whole tab bar with each click for exactly this reason. The Weft equivalent of that move is to give the wrapping component the state and re-render it whole — `param :tab` plus [`navigate: { tab: "shipping" }`](../dsl.md#navigate) on each button replaces the entire `ProductTabs` component, selected styling included, at the cost of re-sending the bar with every switch. Start with the version above; reach for `navigate:` when the highlight matters.
|
|
67
67
|
|
|
68
68
|
## On the wire
|
|
69
69
|
|
|
@@ -99,4 +99,4 @@ Clicking Shipping issues `GET /_components/shipping_tab`, and the response fills
|
|
|
99
99
|
|
|
100
100
|
- [Active Search](active-search.md) — the same fill-a-stable-container shape, driven by typing instead of clicks.
|
|
101
101
|
- [`abstract!` and `routable!`](../routing.md#abstract-and-routable) in the routing reference.
|
|
102
|
-
- The [
|
|
102
|
+
- The [presets table](../dsl.md#presets) in the DSL reference.
|
data/docs/examples/tooltip.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
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
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
|
|
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 presets. 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
6
|
|
|
7
7
|
## The components
|
|
8
8
|
|
|
@@ -16,12 +16,12 @@ TEAM = {
|
|
|
16
16
|
class MemberPeek < Weft::Component
|
|
17
17
|
builder_method :member_peek
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
param :handle
|
|
20
20
|
|
|
21
21
|
def build(attributes = {})
|
|
22
22
|
super
|
|
23
|
-
member = TEAM.fetch(
|
|
24
|
-
strong
|
|
23
|
+
member = TEAM.fetch(params.handle)
|
|
24
|
+
strong params.handle.capitalize
|
|
25
25
|
para "#{member[:role]} — #{member[:timezone]}"
|
|
26
26
|
para "Focus: #{member[:focus]}"
|
|
27
27
|
end
|
|
@@ -51,11 +51,11 @@ end
|
|
|
51
51
|
|
|
52
52
|
## How it works
|
|
53
53
|
|
|
54
|
-
**Hover fetches; the bubble receives.** [`tooltip:`](../dsl.md#
|
|
54
|
+
**Hover fetches; the bubble receives.** [`tooltip:`](../dsl.md#presets) 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
55
|
|
|
56
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
57
|
|
|
58
|
-
**Per-row wire
|
|
58
|
+
**Per-row wire params, 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
59
|
|
|
60
60
|
## On the wire
|
|
61
61
|
|
|
@@ -83,6 +83,6 @@ The first hover over Priya issues `GET /_components/member_peek?handle=priya`, a
|
|
|
83
83
|
|
|
84
84
|
## Related
|
|
85
85
|
|
|
86
|
-
- [Inline Expansion](inline-expansion.md) — the other Weft-native
|
|
86
|
+
- [Inline Expansion](inline-expansion.md) — the other Weft-native preset: click-driven detail that lands *after* its trigger instead of in a bubble.
|
|
87
87
|
- [Lazy Loading](lazy-loading.md) — the same load-once deferral, triggered by visibility instead of the pointer.
|
|
88
|
-
- The [
|
|
88
|
+
- The [presets table](../dsl.md#presets) in the DSL reference.
|
|
@@ -28,13 +28,13 @@ end
|
|
|
28
28
|
class NewContactForm < Weft::Component
|
|
29
29
|
builder_method :new_contact_form
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
param :name
|
|
32
|
+
param :email
|
|
33
33
|
|
|
34
34
|
includes ContactsTable
|
|
35
35
|
|
|
36
|
-
performs :add do |
|
|
37
|
-
ADDRESS_BOOK << { name:
|
|
36
|
+
performs :add do |params|
|
|
37
|
+
ADDRESS_BOOK << { name: params.name, email: params.email }
|
|
38
38
|
{ name: nil, email: nil }
|
|
39
39
|
end
|
|
40
40
|
|
|
@@ -72,11 +72,11 @@ end
|
|
|
72
72
|
|
|
73
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
74
|
|
|
75
|
-
**The included component needs no route.** In this variant `ContactsTable` declares no
|
|
75
|
+
**The included component needs no route.** In this variant `ContactsTable` declares no params 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
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
|
|
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 params. 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 param's value, stable across renders.)
|
|
78
78
|
|
|
79
|
-
**Form fields pair with declared
|
|
79
|
+
**Form fields pair with declared params.** The form declares `name` and `email` so the submitted fields reach the callable as `params.name` and `params.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
80
|
|
|
81
81
|
## The decoupled variant
|
|
82
82
|
|
|
@@ -164,6 +164,6 @@ hears the event and issues `GET /_components/contacts_table`, which returns the
|
|
|
164
164
|
|
|
165
165
|
## Related
|
|
166
166
|
|
|
167
|
-
- [Click to Edit](click-to-edit.md) — the form-fields-pair-with-
|
|
167
|
+
- [Click to Edit](click-to-edit.md) — the form-fields-pair-with-params pattern this example builds on.
|
|
168
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
|
|
169
|
+
- `includes` accepts `on: :action_name` to scope a companion to one action, and a block to map the primary component's params onto the companion's — see [the DSL reference](../dsl.md#includes--companions-in-the-same-response).
|