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.
Files changed (66) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +37 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +150 -0
  5. data/docs/app-patterns.md +268 -0
  6. data/docs/arbre.md +298 -0
  7. data/docs/configuration.md +219 -0
  8. data/docs/dsl.md +329 -0
  9. data/docs/error-handling.md +136 -0
  10. data/docs/examples/README.md +70 -0
  11. data/docs/examples/active-search.md +125 -0
  12. data/docs/examples/browser-dialogs.md +71 -0
  13. data/docs/examples/bulk-update.md +122 -0
  14. data/docs/examples/click-to-edit.md +113 -0
  15. data/docs/examples/click-to-load.md +77 -0
  16. data/docs/examples/delete-row.md +101 -0
  17. data/docs/examples/edit-row.md +146 -0
  18. data/docs/examples/file-upload.md +96 -0
  19. data/docs/examples/infinite-scroll.md +98 -0
  20. data/docs/examples/inline-expansion.md +98 -0
  21. data/docs/examples/inline-validation.md +101 -0
  22. data/docs/examples/keyboard-shortcuts.md +71 -0
  23. data/docs/examples/lazy-loading.md +96 -0
  24. data/docs/examples/live-ticker.md +91 -0
  25. data/docs/examples/modal-dialog.md +88 -0
  26. data/docs/examples/progress-bar.md +138 -0
  27. data/docs/examples/reset-user-input.md +101 -0
  28. data/docs/examples/tabs.md +102 -0
  29. data/docs/examples/tooltip.md +88 -0
  30. data/docs/examples/updating-other-content.md +169 -0
  31. data/docs/examples/value-select.md +109 -0
  32. data/docs/routing.md +131 -0
  33. data/docs/tutorial.md +372 -0
  34. data/lib/weft/action.rb +83 -0
  35. data/lib/weft/attributes.rb +65 -0
  36. data/lib/weft/component.rb +176 -0
  37. data/lib/weft/configuration.rb +177 -0
  38. data/lib/weft/context/interception.rb +22 -0
  39. data/lib/weft/context.rb +184 -0
  40. data/lib/weft/defaults/error_component.rb +63 -0
  41. data/lib/weft/defaults/error_page.rb +27 -0
  42. data/lib/weft/defaults/not_found_component.rb +44 -0
  43. data/lib/weft/defaults/not_found_page.rb +25 -0
  44. data/lib/weft/defaults.rb +9 -0
  45. data/lib/weft/dsl/actions.rb +72 -0
  46. data/lib/weft/dsl/attributes.rb +43 -0
  47. data/lib/weft/dsl/containers.rb +77 -0
  48. data/lib/weft/dsl/inclusions.rb +49 -0
  49. data/lib/weft/dsl/recoveries.rb +89 -0
  50. data/lib/weft/dsl/triggers.rb +40 -0
  51. data/lib/weft/dsl/updates.rb +86 -0
  52. data/lib/weft/error.rb +64 -0
  53. data/lib/weft/page.rb +371 -0
  54. data/lib/weft/redirect.rb +45 -0
  55. data/lib/weft/registry/eligibility.rb +58 -0
  56. data/lib/weft/registry.rb +202 -0
  57. data/lib/weft/resolver.rb +33 -0
  58. data/lib/weft/router/actions.rb +77 -0
  59. data/lib/weft/router/errors.rb +246 -0
  60. data/lib/weft/router/oob_includes.rb +34 -0
  61. data/lib/weft/router/streaming.rb +63 -0
  62. data/lib/weft/router.rb +191 -0
  63. data/lib/weft/shorthands.rb +57 -0
  64. data/lib/weft/version.rb +5 -0
  65. data/lib/weft.rb +124 -0
  66. metadata +169 -0
@@ -0,0 +1,125 @@
1
+ # Active Search
2
+
3
+ A search box that filters a result list as the user types. Each (debounced) keystroke asks the server for matches, and the results region is refilled with the answer — search-as-you-type with all the logic living server-side.
4
+
5
+ This is Weft's take on [htmx's active-search example](https://htmx.org/examples/active-search/), searching the same kind of small contact directory by name or email. One preset detail differs and is worth stating precisely: Weft's `live_search:` shorthand fires on `input changed delay:300ms` — the `input` event, debounced by 300 milliseconds — where htmx's published example uses `keyup changed delay:500ms`. If you want htmx's exact timing (or any other), the `trigger:` kwarg overrides the preset, as shown below.
6
+
7
+ ## The components
8
+
9
+ ```ruby
10
+ PEOPLE = [
11
+ { name: "Venus Grimes", email: "vgrimes@example.org" },
12
+ { name: "Kandy Kane", email: "kandy@example.org" },
13
+ { name: "Antonia Benitez", email: "abenitez@example.org" },
14
+ { name: "Dewayne Sharp", email: "dsharp@example.org" },
15
+ { name: "Nathan Grimes", email: "ngrimes@example.org" },
16
+ { name: "Mia Chandler", email: "mchandler@example.org" },
17
+ { name: "Leland Ortega", email: "lortega@example.org" },
18
+ { name: "Saanvi Rao", email: "srao@example.org" }
19
+ ].freeze
20
+
21
+ class ContactResults < Weft::Component
22
+ builder_method :contact_results
23
+
24
+ attribute :q, default: ""
25
+
26
+ def build(attributes = {})
27
+ super
28
+ matches = search(attrs.q)
29
+ if matches.empty?
30
+ para "No one matches “#{attrs.q}”."
31
+ else
32
+ table do
33
+ thead { tr { th "Name"; th "Email" } }
34
+ tbody do
35
+ matches.each { |person| tr { td person[:name]; td person[:email] } }
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def search(query)
44
+ q = query.strip.downcase
45
+ PEOPLE.select { |person| "#{person[:name]} #{person[:email]}".downcase.include?(q) }
46
+ end
47
+ end
48
+
49
+ class ContactSearch < Weft::Component
50
+ builder_method :contact_search
51
+
52
+ def build(attributes = {})
53
+ super
54
+ h3 "Search the directory"
55
+ input type: "search", name: "q", placeholder: "Begin typing to search...",
56
+ live_search: ContactResults, target: "#search-results"
57
+ div id: "search-results" do
58
+ contact_results(q: "")
59
+ end
60
+ end
61
+ end
62
+ ```
63
+
64
+ (The `PEOPLE` array stands in for your data layer — in a real app, `search` becomes a scoped query.)
65
+
66
+ ## How it works
67
+
68
+ **The input's `name` is the search parameter.** [`live_search:`](../dsl.md#shorthands) presets trigger `:input` and swap `:fill`; the call site supplies the target. Notice that the generated URL below carries no query string — htmx includes the triggering input's own `name`/value with the request, which is how the typed text travels. The two halves must agree: the input says `name: "q"`, and `ContactResults` declares `attribute :q` to receive it. An empty default keeps the blank-box case (matching everyone) working.
69
+
70
+ **Debounced by the preset, adjustable at the call site.** The `:input` semantic trigger expands to `input changed delay:300ms`: fire on input events, only when the value actually changed, at most once per 300ms lull. To reproduce htmx's example exactly, override it in place — the preset's request, swap, and target are all kept:
71
+
72
+ ```ruby
73
+ input type: "search", name: "q",
74
+ live_search: ContactResults, target: "#search-results",
75
+ trigger: "keyup changed delay:500ms"
76
+ ```
77
+
78
+ **Results refill a stable container.** The `:fill` swap replaces the *contents* of `#search-results`, so the container div persists across searches while a fresh `ContactResults` lands inside it each time. Pre-rendering `contact_results(q: "")` in the container means the page starts with the full directory rather than an empty pane — the initial state and every subsequent state are the same component.
79
+
80
+ **The whole result set re-renders per search.** No row diffing, no client-side state: each request returns the complete table (or the "no matches" paragraph) for that query. At search-box scale this is the simple, correct trade.
81
+
82
+ ## On the wire
83
+
84
+ The initial render — the wired input, and the container holding the unfiltered component:
85
+
86
+ ```html
87
+ <input type="search" name="q" placeholder="Begin typing to search..."
88
+ hx-get="/_components/contact_results" hx-swap="innerHTML"
89
+ hx-target="#search-results" hx-trigger="input changed delay:300ms"/>
90
+ <div id="search-results">
91
+ <div id="contact-results-">
92
+ <table>…all eight people…</table>
93
+ </div>
94
+ </div>
95
+ ```
96
+
97
+ (That `id="contact-results-"` is the component's DOM id — dasherized class name plus first attribute value, which here is the empty string.)
98
+
99
+ Typing "grimes" settles into `GET /_components/contact_results?q=grimes`:
100
+
101
+ ```html
102
+ <div id="contact-results-grimes">
103
+ <table>
104
+ <thead><tr><th>Name</th><th>Email</th></tr></thead>
105
+ <tbody>
106
+ <tr><td>Venus Grimes</td><td>vgrimes@example.org</td></tr>
107
+ <tr><td>Nathan Grimes</td><td>ngrimes@example.org</td></tr>
108
+ </tbody>
109
+ </table>
110
+ </div>
111
+ ```
112
+
113
+ And a query with no matches (`GET /_components/contact_results?q=zz`) returns the friendly empty state:
114
+
115
+ ```html
116
+ <div id="contact-results-zz">
117
+ <p>No one matches “zz”.</p>
118
+ </div>
119
+ ```
120
+
121
+ ## Related
122
+
123
+ - [Tabs](tabs.md) — the same fill-a-stable-container shape, driven by clicks instead of keystrokes.
124
+ - The [shorthands table](../dsl.md#shorthands) and [trigger values](../dsl.md#trigger-values) in the DSL reference.
125
+ - The [tutorial](../tutorial.md) covers the pairing of form field names with declared attributes in depth.
@@ -0,0 +1,71 @@
1
+ # Browser Dialogs
2
+
3
+ A destructive button guarded by the browser's native `confirm()` dialog — the user gets a chance to back out before the request is ever made, with no dialog component to build and no CSS to write.
4
+
5
+ This is Weft's take on [htmx's dialogs example](https://htmx.org/examples/dialogs/). The honest point of this page is that Weft has no kwarg for `hx-confirm` — and doesn't need one: raw htmx attributes pass through to the element untouched, side by side with whatever the Weft kwargs expand to.
6
+
7
+ ## The components
8
+
9
+ ```ruby
10
+ ACCOUNT = { status: "active" }
11
+
12
+ class AccountPanel < Weft::Component
13
+ builder_method :account_panel
14
+
15
+ performs :deactivate do
16
+ ACCOUNT[:status] = "deactivated"
17
+ nil
18
+ end
19
+
20
+ def build(attributes = {})
21
+ super
22
+ para "Your account is #{ACCOUNT[:status]}."
23
+ if ACCOUNT[:status] == "active"
24
+ button "Deactivate my account", action: :deactivate,
25
+ "hx-confirm" => "Deactivate your account? You can sign back in to reactivate."
26
+ end
27
+ end
28
+ end
29
+ ```
30
+
31
+ (The `ACCOUNT` hash stands in for your data layer, and the current user for whoever your app has signed in.)
32
+
33
+ ## How it works
34
+
35
+ **Raw htmx attributes ride along.** Weft intercepts only its own kwargs — `action:`, `trigger:`, and friends. Everything else on an element, string-keyed htmx attributes included, renders as a plain HTML attribute. So `"hx-confirm" => "..."` lands verbatim next to the wiring that [`action:`](../dsl.md#action) expanded, and htmx picks it up like any hand-written page. This is the general escape hatch: whenever htmx has a feature Weft has no vocabulary for, write the attribute yourself.
36
+
37
+ **The guard lives in the browser, not on the server.** `hx-confirm` gates the *request*: htmx shows the native dialog and only issues the POST if the user accepts. The endpoint itself is unchanged — a request made outside htmx skips the question entirely. Treat it as protection against misclicks, never as access control; anything truly destructive still needs authorization server-side.
38
+
39
+ **The action is ordinary Weft.** `performs :deactivate` runs the write and re-renders the component, which now shows the deactivated state — the standard action contract, unaware that a dialog ever happened.
40
+
41
+ **`hx-prompt` doesn't carry over.** htmx's companion attribute asks for a line of text and sends the answer as an `HX-Prompt` *request header* — but a Weft action callable receives only the component's resolved attributes, which come from request parameters, so the prompted value never reaches your code. When an action needs user input, give the component a real input: a form field paired with a declared attribute, as in [Click to Edit](click-to-edit.md).
42
+
43
+ ## On the wire
44
+
45
+ The initial render — the confirm attribute sits verbatim beside the expanded action wiring:
46
+
47
+ ```html
48
+ <div id="account-panel">
49
+ <p>Your account is active.</p>
50
+ <button hx-confirm="Deactivate your account? You can sign back in to reactivate."
51
+ hx-post="/_components/account_panel/deactivate"
52
+ hx-target="#account-panel" hx-swap="outerHTML"
53
+ hx-vals="{}">Deactivate my account</button>
54
+ </div>
55
+ ```
56
+
57
+ Accepting the dialog issues `POST /_components/account_panel/deactivate`, and the re-rendered panel replaces the old one:
58
+
59
+ ```html
60
+ <div id="account-panel">
61
+ <p>Your account is deactivated.</p>
62
+ </div>
63
+ ```
64
+
65
+ The same POST sent from outside the browser — no htmx, no dialog — is accepted just the same, which is exactly why the confirm is a courtesy and the authorization is your job.
66
+
67
+ ## Related
68
+
69
+ - [Modal Dialog](modal-dialog.md) — when you want a dialog you own instead of the browser's.
70
+ - [Click to Edit](click-to-edit.md) — form fields paired with attributes: the Weft answer to "prompt the user for a value."
71
+ - [`action:`](../dsl.md#action) and [`performs`](../dsl.md#performs--user-initiated-actions) in the DSL reference.
@@ -0,0 +1,122 @@
1
+ # Bulk Update
2
+
3
+ A table of team members, each row with an Activate checkbox; one submit applies every change at once, and a status line reports how many members were activated and deactivated.
4
+
5
+ This is Weft's take on [htmx's bulk-update example](https://htmx.org/examples/bulk-update/), with one structural difference. Their form posts the checkboxes and swaps a toast message into a separate slot, leaving the table as the user left it; in Weft the whole roster is one component, so the submit re-renders it from server state — the checkboxes come back showing what was actually saved, with the status line beneath them.
6
+
7
+ ## The components
8
+
9
+ ```ruby
10
+ TEAM_MEMBERS = {
11
+ "1" => { name: "Joe Smith", email: "joe@smith.org", active: true },
12
+ "2" => { name: "Angie MacDowell", email: "angie@macdowell.org", active: true },
13
+ "3" => { name: "Fuqua Tarkenton", email: "fuqua@tarkenton.org", active: true },
14
+ "4" => { name: "Kim Yee", email: "kim@yee.org", active: false }
15
+ }.freeze
16
+
17
+ class MemberRoster < Weft::Component
18
+ builder_method :member_roster
19
+
20
+ attribute :active_ids, default: []
21
+ attribute :status
22
+
23
+ performs :update, target: "#member-roster" do |attrs|
24
+ checked = attrs.active_ids
25
+ activated = deactivated = 0
26
+ TEAM_MEMBERS.each do |id, member|
27
+ active = checked.include?(id)
28
+ activated += 1 if active && !member[:active]
29
+ deactivated += 1 if !active && member[:active]
30
+ member[:active] = active
31
+ end
32
+ { status: "Activated #{activated} and deactivated #{deactivated} members." }
33
+ end
34
+
35
+ def build(attributes = {})
36
+ super
37
+ set_attribute :id, "member-roster"
38
+ form(action: :update) do
39
+ table do
40
+ thead { tr { th "Name"; th "Email"; th "Active" } }
41
+ tbody do
42
+ TEAM_MEMBERS.each do |id, member|
43
+ tr do
44
+ td member[:name]
45
+ td member[:email]
46
+ td do
47
+ if member[:active]
48
+ input type: "checkbox", name: "active_ids[]", value: id, checked: "checked"
49
+ else
50
+ input type: "checkbox", name: "active_ids[]", value: id
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ input type: "submit", value: "Bulk Update"
58
+ end
59
+ para attrs.status if attrs.status
60
+ end
61
+ end
62
+ ```
63
+
64
+ (The `TEAM_MEMBERS` hash stands in for your data layer — swap in ActiveRecord or whatever your app uses.)
65
+
66
+ ## How it works
67
+
68
+ **Bracket naming turns the checkboxes into one array.** Every checkbox shares the name `active_ids[]`, and Rack's parameter parsing folds those into a single array under the bracket-less key — so the component's declared `active_ids` attribute receives `["1", "2", "4"]`, and the callable reads it as `attrs.active_ids`. The values arrive as strings, which is why the data stub's keys are strings too. Checkboxes the user leaves unchecked simply aren't in the submission; that's the whole trick of the pattern.
69
+
70
+ **The `default: []` is load-bearing.** When *no* boxes are checked, the browser sends no `active_ids` parameter at all, and the attribute falls back to its default. An empty array makes that case mean "deactivate everyone" — and guarantees the callable always has a real array to call `include?` on, rather than `nil`.
71
+
72
+ **The callable diffs, then reports through its return value.** It compares each member's stored state against the submitted array, counts the flips, and writes the new state. Returning a hash merges it into the attrs for the re-render (see [the callable contract](../dsl.md#the-callable-contract)), so `{ status: "Activated 1 and deactivated 1 members." }` is how the count reaches the status line — `attrs.status` is `nil` on a fresh render and the paragraph only appears after an update.
73
+
74
+ **An array can't anchor a DOM id.** Weft derives a component's DOM id from its first declared attribute — perfect when that's a record id, unusable when it's an array (`id="member-roster-[]"` is not a selector htmx can target). So this component pins its own identity: `set_attribute :id, "member-roster"` fixes the wrapper's id inside `build`, and `performs :update, target: "#member-roster"` points the action's swap at that same anchor. Both live server-side, so every re-rendered fragment carries the same stable wiring.
75
+
76
+ **The checkboxes tell the truth after the write.** `build` renders each checkbox from the data store, not from the submitted attrs — the response reflects what was actually saved. And since `form(action: :update)` also emits plain `action`/`method` attributes, the whole thing degrades to a normal POST without JavaScript.
77
+
78
+ ## On the wire
79
+
80
+ The initial render (or `GET /_components/member_roster`) — the form wired to the action, one row per member:
81
+
82
+ ```html
83
+ <div id="member-roster">
84
+ <form hx-post="/_components/member_roster/update" hx-target="#member-roster"
85
+ hx-swap="outerHTML" action="/_components/member_roster/update" method="post">
86
+ <table>
87
+ <thead>...</thead>
88
+ <tbody>
89
+ <tr>
90
+ <td>Joe Smith</td>
91
+ <td>joe@smith.org</td>
92
+ <td><input type="checkbox" name="active_ids[]" value="1" checked="checked"/></td>
93
+ </tr>
94
+ ...
95
+ <tr>
96
+ <td>Kim Yee</td>
97
+ <td>kim@yee.org</td>
98
+ <td><input type="checkbox" name="active_ids[]" value="4"/></td>
99
+ </tr>
100
+ </tbody>
101
+ </table>
102
+ <input type="submit" value="Bulk Update"/>
103
+ </form>
104
+ </div>
105
+ ```
106
+
107
+ Unchecking Fuqua, checking Kim, and submitting sends `POST /_components/member_roster/update` with the body `active_ids[]=1&active_ids[]=2&active_ids[]=4`. The response is the same component, re-rendered from the updated store — Fuqua's box now unchecked, Kim's checked — ending with:
108
+
109
+ ```html
110
+ <input type="submit" value="Bulk Update"/>
111
+ </form>
112
+ <p>Activated 1 and deactivated 1 members.</p>
113
+ </div>
114
+ ```
115
+
116
+ Submitting with every box unchecked sends an empty body; the default kicks in and the response reports `Activated 0 and deactivated 3 members.`
117
+
118
+ ## Related
119
+
120
+ - [Click to Edit](click-to-edit.md) — the basics of pairing form fields with declared attributes.
121
+ - [Delete Row](delete-row.md) and [Edit Row](edit-row.md) — acting on table rows one at a time instead of all at once.
122
+ - [`performs`](../dsl.md#performs--user-initiated-actions) and [the callable contract](../dsl.md#the-callable-contract) in the DSL reference.
@@ -0,0 +1,113 @@
1
+ # Click to Edit
2
+
3
+ A read-only view of a record with an Edit button. Clicking swaps an editable form into its place; saving — or canceling — swaps the read-only view back. No page navigation, and no JavaScript beyond what Weft already ships.
4
+
5
+ This is Weft's take on [htmx's click-to-edit example](https://htmx.org/examples/click-to-edit/), and the shape is the same: the UI moves between two states, and each state is a server-rendered fragment. In Weft, each state is simply a component.
6
+
7
+ ## The components
8
+
9
+ ```ruby
10
+ CONTACTS = {
11
+ "1" => { first_name: "Joe", last_name: "Blow", email: "joe@blow.com" }
12
+ }.freeze
13
+
14
+ class ContactCard < Weft::Component
15
+ builder_method :contact_card
16
+
17
+ attribute :contact_id
18
+
19
+ def build(attributes = {})
20
+ super
21
+ contact = CONTACTS.fetch(attrs.contact_id)
22
+ div { strong "First Name: "; text_node contact[:first_name] }
23
+ div { strong "Last Name: "; text_node contact[:last_name] }
24
+ div { strong "Email: "; text_node contact[:email] }
25
+ button "Click To Edit",
26
+ loads: ContactEditor, with: { contact_id: attrs.contact_id },
27
+ swap: :replace, target: self
28
+ end
29
+ end
30
+
31
+ class ContactEditor < Weft::Component
32
+ builder_method :contact_editor
33
+
34
+ attribute :contact_id
35
+ attribute :first_name
36
+ attribute :last_name
37
+ attribute :email
38
+
39
+ transfers :save, to: ContactCard do |attrs|
40
+ CONTACTS.fetch(attrs.contact_id).merge!(
41
+ first_name: attrs.first_name, last_name: attrs.last_name, email: attrs.email
42
+ )
43
+ nil
44
+ end
45
+
46
+ def build(attributes = {})
47
+ super
48
+ contact = CONTACTS.fetch(attrs.contact_id)
49
+ form(action: :save) do
50
+ input(type: "hidden", name: "contact_id", value: attrs.contact_id)
51
+ div do
52
+ label("First Name ", for: "first_name")
53
+ input(type: "text", name: "first_name", id: "first_name", value: contact[:first_name])
54
+ end
55
+ div do
56
+ label("Last Name ", for: "last_name")
57
+ input(type: "text", name: "last_name", id: "last_name", value: contact[:last_name])
58
+ end
59
+ div do
60
+ label("Email ", for: "email")
61
+ input(type: "text", name: "email", id: "email", value: contact[:email])
62
+ end
63
+ input(type: "submit", value: "Submit")
64
+ button "Cancel", type: "button",
65
+ loads: ContactCard, with: { contact_id: attrs.contact_id },
66
+ swap: :replace, target: self
67
+ end
68
+ end
69
+ end
70
+ ```
71
+
72
+ (The `CONTACTS` hash stands in for your data layer — swap in ActiveRecord or whatever your app uses.)
73
+
74
+ ## How it works
75
+
76
+ **Reads and writes get different verbs.** Opening the editor changes nothing on the server, so the Edit button is a [`loads:`](../dsl.md#loads) — a plain GET that fetches the editor component and swaps it over the card (`swap: :replace`). Cancel is the same thing pointed back at the card. Saving *does* change something, so it's a [`transfers`](../dsl.md#transfers--actions-that-render-something-else): a POST that runs the write, then renders the card — the natural "what you see after saving" — in the editor's place.
77
+
78
+ **`target: self` pins the swap to the component.** Inside `build`, `self` is the component instance, and a component reference as a `target:` resolves to its DOM id. Each fragment replaces the whole card/editor element, wherever it sits in the page.
79
+
80
+ **The two components reference each other — without a cycle.** `transfers :save, to: ContactCard` runs in the class body, so `ContactCard` must already be defined; but `loads: ContactEditor` isn't evaluated until render. Defining the display component first therefore breaks the loop with no forward-declaration tricks. This ordering trick generalizes to any two-state component pair.
81
+
82
+ **Form fields pair with declared attributes.** The editor declares `first_name`, `last_name`, and `email` so its fields reach the save callable as `attrs.first_name` and friends — and `contact_id` rides along as a hidden input, because it's part of the component's identity rather than something the user edits. (This pairing is covered in depth in [the tutorial](../tutorial.md#7-taking-rsvps).)
83
+
84
+ **It still works without JavaScript.** `form(action: :save)` emits plain `action`/`method` attributes alongside the htmx wiring, so the save degrades to a normal POST. Note `type: "button"` on Cancel — inside a form, a bare `<button>` is a submit button.
85
+
86
+ ## On the wire
87
+
88
+ The initial render (or `GET /_components/contact_card?contact_id=1`):
89
+
90
+ ```html
91
+ <div id="contact-card-1">
92
+ <div><strong>First Name: </strong>Joe</div>
93
+ <div><strong>Last Name: </strong>Blow</div>
94
+ <div><strong>Email: </strong>joe@blow.com</div>
95
+ <button hx-get="/_components/contact_editor?contact_id=1"
96
+ hx-swap="outerHTML" hx-target="#contact-card-1">Click To Edit</button>
97
+ </div>
98
+ ```
99
+
100
+ Clicking Edit fetches the editor; its form is wired to the save action, with the non-JS fallback visible:
101
+
102
+ ```html
103
+ <form hx-post="/_components/contact_editor/save" hx-target="#contact-editor-1"
104
+ hx-swap="outerHTML" action="/_components/contact_editor/save" method="post">
105
+ ```
106
+
107
+ Submitting `POST /_components/contact_editor/save` with the edited fields returns the updated card — `<div id="contact-card-1">…Joseph…</div>` — which replaces the editor. The next fetch of the card confirms the write stuck.
108
+
109
+ ## Related
110
+
111
+ - [Edit Row](edit-row.md) — this same pattern applied per-row in a table.
112
+ - [`loads:`](../dsl.md#loads) and [`transfers`](../dsl.md#transfers--actions-that-render-something-else) in the DSL reference.
113
+ - htmx's original uses a RESTful `PUT`; if you prefer that, `transfers :save, to: ContactCard, method: :put` does exactly what you'd hope.
@@ -0,0 +1,77 @@
1
+ # Click to Load
2
+
3
+ A list that shows its first page up front, with a "load more" button at the bottom. Clicking the button replaces it with the next page of results — which brings its own button, until the data runs out. The page never reloads, and nothing already on screen is disturbed.
4
+
5
+ This is Weft's take on [htmx's click-to-load example](https://htmx.org/examples/click-to-load/), with the same cast of agents. One structural difference is worth knowing up front: htmx's version returns a loose fragment of sibling `<tr>`s to splice into a table, but a Weft component always renders exactly one wrapper element. So the Weft shape is a *chunk* — one element holding a page of results plus the next button — and each chunk lands where the previous button was. (For tables specifically, see [Infinite Scroll](infinite-scroll.md), whose append-style swap plays naturally with `<tbody>` chunks.)
6
+
7
+ ## The components
8
+
9
+ ```ruby
10
+ AGENTS = (1..24).map { |n| { name: "Agent Smith", email: "void#{n}@null.org", number: n } }.freeze
11
+
12
+ class AgentRows < Weft::Component
13
+ builder_method :agent_rows
14
+
15
+ PER_PAGE = 6
16
+
17
+ attribute :page, default: 1
18
+
19
+ def build(attributes = {})
20
+ super
21
+ batch = AGENTS[(attrs.page - 1) * PER_PAGE, PER_PAGE]
22
+ batch.each do |agent|
23
+ div { strong agent[:name]; text_node " — #{agent[:email]} (##{agent[:number]})" }
24
+ end
25
+ if attrs.page * PER_PAGE < AGENTS.size
26
+ button "Load More Agents...", load_more: AgentRows, with: { page: attrs.page + 1 }
27
+ end
28
+ end
29
+ end
30
+ ```
31
+
32
+ (The `AGENTS` array stands in for your data layer — swap in ActiveRecord or whatever your app uses.)
33
+
34
+ Render `agent_rows(page: 1)` wherever the list lives, and the pattern takes care of itself from there.
35
+
36
+ ## How it works
37
+
38
+ **One shorthand, no ceremony.** [`load_more:`](../dsl.md#shorthands) is a preset over the [`loads:`](../dsl.md#loads) machinery: trigger `:click`, swap `:replace`, target `:self`. In plain terms — when this button is clicked, fetch the named component and put it where the button was. The call site supplies only what varies: which component to load (`AgentRows`, the component's own class) and its wire attrs (`with: { page: attrs.page + 1 }`).
39
+
40
+ **The component is a chunk, not the whole list.** Each `AgentRows` instance renders one page of agents and, when more remain, the button that fetches the next chunk *in its own place*. Clicking never touches the agents already on screen; the button alone is replaced, and the new chunk arrives with its own button. The recursion bottoms out naturally — the `if` guard means the final chunk simply renders no button.
41
+
42
+ **Attributes make the chunk addressable.** Declaring `attribute :page, default: 1` gives the component a route ([Routing](../routing.md)) and coerces the wire value: `page=2` arrives as the string `"2"` and reaches `attrs.page` as the Integer `2`, because the default is an Integer.
43
+
44
+ **A component can load itself.** `load_more: AgentRows` inside `AgentRows`'s own `build` is unremarkable — the class reference is evaluated at render time, so self-reference needs no tricks.
45
+
46
+ ## On the wire
47
+
48
+ The initial render (or `GET /_components/agent_rows?page=1`):
49
+
50
+ ```html
51
+ <div id="agent-rows-1">
52
+ <div><strong>Agent Smith</strong> — void1@null.org (#1)</div>
53
+ <!-- … five more agents … -->
54
+ <button hx-get="/_components/agent_rows?page=2" hx-swap="outerHTML"
55
+ hx-target="this" hx-trigger="click">Load More Agents...</button>
56
+ </div>
57
+ ```
58
+
59
+ Every htmx attribute there came from the one `load_more:` kwarg. Clicking issues `GET /_components/agent_rows?page=2`, and the response replaces the button:
60
+
61
+ ```html
62
+ <div id="agent-rows-2">
63
+ <div><strong>Agent Smith</strong> — void7@null.org (#7)</div>
64
+ <!-- … -->
65
+ <button hx-get="/_components/agent_rows?page=3" hx-swap="outerHTML"
66
+ hx-target="this" hx-trigger="click">Load More Agents...</button>
67
+ </div>
68
+ ```
69
+
70
+ The last page (`GET /_components/agent_rows?page=4`) renders its agents and no button — the interaction retires itself.
71
+
72
+ ## Related
73
+
74
+ - [Infinite Scroll](infinite-scroll.md) — the same next-page mechanic, triggered by scrolling instead of a click, in a real table.
75
+ - [Lazy Loading](lazy-loading.md) — deferring one expensive section rather than paginating many.
76
+ - The [shorthands table](../dsl.md#shorthands) in the DSL reference, including how to register your own preset.
77
+ - For pagination that *replaces* the current page instead of accumulating, [`navigate:`](../dsl.md#navigate) is the better verb.
@@ -0,0 +1,101 @@
1
+ # Delete Row
2
+
3
+ A table of contacts, each row with a Delete button. Clicking it asks the browser's native "Are you sure?", deletes the record on the server, and removes the row from the table.
4
+
5
+ This is Weft's take on [htmx's delete-row example](https://htmx.org/examples/delete-row/). Their version hoists the htmx attributes onto the `<tbody>` and lets every row inherit them; a Weft row is a component that carries its own wiring, generated from its own declaration. One cosmetic difference: htmx's original fades the row out over a second before removing it — that's a CSS transition plus a swap delay, and their approach carries over unchanged if you want it. This version keeps zero CSS and removes the row immediately.
6
+
7
+ ## The components
8
+
9
+ ```ruby
10
+ CONTACT_BOOK = {
11
+ "1" => { name: "Angie MacDowell", email: "angie@macdowell.org", status: "Active" },
12
+ "2" => { name: "Fuqua Tarkenton", email: "fuqua@tarkenton.org", status: "Active" },
13
+ "3" => { name: "Kim Yee", email: "kim@yee.org", status: "Inactive" }
14
+ }
15
+
16
+ class ContactRow < Weft::Component
17
+ builder_method :contact_row
18
+
19
+ attribute :contact_id
20
+
21
+ dismisses :destroy do |attrs|
22
+ CONTACT_BOOK.delete(attrs.contact_id)
23
+ nil
24
+ end
25
+
26
+ def tag_name
27
+ "tr"
28
+ end
29
+
30
+ def build(attributes = {})
31
+ super
32
+ contact = CONTACT_BOOK[attrs.contact_id]
33
+ return unless contact
34
+
35
+ td contact[:name]
36
+ td contact[:email]
37
+ td contact[:status]
38
+ td do
39
+ button "Delete", action: :destroy, "hx-confirm" => "Are you sure?"
40
+ end
41
+ end
42
+ end
43
+
44
+ class ContactsTable < Weft::Component
45
+ builder_method :contacts_table
46
+
47
+ def build(attributes = {})
48
+ super
49
+ table do
50
+ thead { tr { th "Name"; th "Email"; th "Status"; th "" } }
51
+ tbody do
52
+ CONTACT_BOOK.each_key { |id| contact_row(contact_id: id) }
53
+ end
54
+ end
55
+ end
56
+ end
57
+ ```
58
+
59
+ (The `CONTACT_BOOK` hash stands in for your data layer — swap in ActiveRecord or whatever your app uses.)
60
+
61
+ ## How it works
62
+
63
+ **The row is the component.** Overriding `tag_name` makes the wrapper a `<tr>`, so each contact renders as a real table row with its own DOM id. The identifying attribute is declared first because that's where the id comes from — `contact_id` of `"1"` yields `id="contact-row-1"`, which is exactly what the delete needs to target.
64
+
65
+ **`dismisses` is the delete-shaped verb.** It's sugar for a `performs` with `method: :delete, swap: :delete` (see [`dismisses`](../dsl.md#dismisses--remove-from-the-dom)): the button wired with `action: :destroy` issues a `DELETE` to the action's route, the callable removes the record, and on success htmx deletes the target element — the row — from the DOM. The row's identity travels automatically: an action button carries the component's attrs as `hx-vals`. Note there's no non-JavaScript fallback here — plain HTML has no DELETE — which is the nature of the pattern rather than a Weft limitation.
66
+
67
+ **The confirmation is one raw attribute.** Kwargs Weft doesn't recognize pass straight through to the element, so `"hx-confirm" => "Are you sure?"` lands on the button as-is and htmx shows the browser's native confirm dialog before sending anything. No request fires on Cancel. For the fuller confirm-and-prompt story, see [Browser Dialogs](browser-dialogs.md).
68
+
69
+ **The dismissal response is rendered, then thrown away.** htmx ignores the response body on a delete swap, but Weft still renders the component once after the callable runs — so `build` must survive the record being gone, hence the `return unless contact` guard (an empty `<tr>` nobody will see). The trailing `nil` in the callable matters for the same reason: `Hash#delete` returns the deleted record, and a hash returned from a callable merges into the attrs for that final render. If the callable *raises*, Weft overrides the destructive swap (via `HX-Reswap`) so the error rendering appears where the row was, instead of the row silently vanishing.
70
+
71
+ ## On the wire
72
+
73
+ Each row arrives fully wired — `GET /_components/contact_row?contact_id=1` returns the same fragment the table renders inline:
74
+
75
+ ```html
76
+ <tr id="contact-row-1">
77
+ <td>Angie MacDowell</td>
78
+ <td>angie@macdowell.org</td>
79
+ <td>Active</td>
80
+ <td>
81
+ <button hx-confirm="Are you sure?" hx-delete="/_components/contact_row/destroy"
82
+ hx-target="#contact-row-1" hx-swap="delete"
83
+ hx-vals="{&quot;contact_id&quot;:&quot;1&quot;}">Delete</button>
84
+ </td>
85
+ </tr>
86
+ ```
87
+
88
+ Confirming the dialog sends `DELETE /_components/contact_row/destroy?contact_id=1` (htmx 2 puts DELETE parameters in the query string). The server deletes the record and responds `200` with the guarded, now-empty render — which htmx discards while removing the row:
89
+
90
+ ```html
91
+ <tr id="contact-row-1"></tr>
92
+ ```
93
+
94
+ Fetching the table again shows two rows; fetching the deleted row's own URL returns the same empty `<tr>`, confirming the record is gone.
95
+
96
+ ## Related
97
+
98
+ - [Browser Dialogs](browser-dialogs.md) — the confirm/prompt story in full.
99
+ - [Edit Row](edit-row.md) — rows that switch into an editable state instead of disappearing.
100
+ - [Inline Expansion](inline-expansion.md) — another `tag_name "tr"` component, inserted rather than removed.
101
+ - [`dismisses`](../dsl.md#dismisses--remove-from-the-dom) in the DSL reference.