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,146 @@
|
|
|
1
|
+
# Edit Row
|
|
2
|
+
|
|
3
|
+
A table where each row can switch into an editable state: Edit swaps the row for a row of inputs, Save writes the changes and swaps the display row back, Cancel backs out without saving.
|
|
4
|
+
|
|
5
|
+
This is Weft's take on [htmx's edit-row example](https://htmx.org/examples/edit-row/) — the [click-to-edit](click-to-edit.md) pattern applied per row. Two honest differences: their version uses hyperscript to enforce editing one row at a time, while here each row is its own independent component pair — several rows can be in edit mode at once, and one-at-a-time is app policy this example doesn't impose. And where their editor gathers its inputs with `hx-include="closest tr"`, this version leans on plain HTML instead.
|
|
6
|
+
|
|
7
|
+
## The components
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
PEOPLE = {
|
|
11
|
+
"1" => { name: "Joe Smith", email: "joe@smith.org" },
|
|
12
|
+
"2" => { name: "Angie MacDowell", email: "angie@macdowell.org" },
|
|
13
|
+
"3" => { name: "Fuqua Tarkenton", email: "fuqua@tarkenton.org" }
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
class PersonRow < Weft::Component
|
|
17
|
+
builder_method :person_row
|
|
18
|
+
|
|
19
|
+
attribute :person_id
|
|
20
|
+
|
|
21
|
+
def tag_name
|
|
22
|
+
"tr"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def build(attributes = {})
|
|
26
|
+
super
|
|
27
|
+
person = PEOPLE.fetch(attrs.person_id)
|
|
28
|
+
td person[:name]
|
|
29
|
+
td person[:email]
|
|
30
|
+
td do
|
|
31
|
+
button "Edit", loads: PersonRowEditor, with: { person_id: attrs.person_id },
|
|
32
|
+
swap: :replace, target: self
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class PersonRowEditor < Weft::Component
|
|
38
|
+
builder_method :person_row_editor
|
|
39
|
+
|
|
40
|
+
attribute :person_id
|
|
41
|
+
attribute :name
|
|
42
|
+
attribute :email
|
|
43
|
+
|
|
44
|
+
transfers :save, to: PersonRow do |attrs|
|
|
45
|
+
PEOPLE.fetch(attrs.person_id).merge!(name: attrs.name, email: attrs.email)
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def tag_name
|
|
50
|
+
"tr"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def build(attributes = {})
|
|
54
|
+
super
|
|
55
|
+
person = PEOPLE.fetch(attrs.person_id)
|
|
56
|
+
save_form = "save-person-#{attrs.person_id}"
|
|
57
|
+
td { input type: "text", name: "name", value: person[:name], form: save_form }
|
|
58
|
+
td { input type: "text", name: "email", value: person[:email], form: save_form }
|
|
59
|
+
td do
|
|
60
|
+
form(action: :save, id: save_form) do
|
|
61
|
+
input type: "hidden", name: "person_id", value: attrs.person_id
|
|
62
|
+
input type: "submit", value: "Save"
|
|
63
|
+
button "Cancel", type: "button",
|
|
64
|
+
loads: PersonRow, with: { person_id: attrs.person_id },
|
|
65
|
+
swap: :replace, target: self
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
class PeopleTable < Weft::Component
|
|
72
|
+
builder_method :people_table
|
|
73
|
+
|
|
74
|
+
def build(attributes = {})
|
|
75
|
+
super
|
|
76
|
+
table do
|
|
77
|
+
thead { tr { th "Name"; th "Email"; th "" } }
|
|
78
|
+
tbody do
|
|
79
|
+
PEOPLE.each_key { |id| person_row(person_id: id) }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
(The `PEOPLE` hash stands in for your data layer — swap in ActiveRecord or whatever your app uses.)
|
|
87
|
+
|
|
88
|
+
## How it works
|
|
89
|
+
|
|
90
|
+
**It's click-to-edit, once per row.** Both components render as `<tr>` (the `tag_name` override), with the identifying attribute 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.
|
|
91
|
+
|
|
92
|
+
**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
|
+
|
|
94
|
+
**Rows edit independently.** Each row is a self-contained component pair with its own ids (`person-row-2`, `person-row-editor-2`, `save-person-2`), so opening one editor doesn't disturb another. If your app wants only one row editable at a time, that's a policy to enforce on top of this pattern, not something the components themselves impose.
|
|
95
|
+
|
|
96
|
+
## On the wire
|
|
97
|
+
|
|
98
|
+
Each display row arrives wired (`GET /_components/person_row?person_id=1` returns the same fragment the table renders):
|
|
99
|
+
|
|
100
|
+
```html
|
|
101
|
+
<tr id="person-row-1">
|
|
102
|
+
<td>Joe Smith</td>
|
|
103
|
+
<td>joe@smith.org</td>
|
|
104
|
+
<td>
|
|
105
|
+
<button hx-get="/_components/person_row_editor?person_id=1"
|
|
106
|
+
hx-swap="outerHTML" hx-target="#person-row-1">Edit</button>
|
|
107
|
+
</td>
|
|
108
|
+
</tr>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Clicking Edit fetches the editor row — note the `form` attributes tying the scattered inputs to the form in the last cell, and the non-JS fallback on the form itself:
|
|
112
|
+
|
|
113
|
+
```html
|
|
114
|
+
<tr id="person-row-editor-1">
|
|
115
|
+
<td><input type="text" name="name" value="Joe Smith" form="save-person-1"/></td>
|
|
116
|
+
<td><input type="text" name="email" value="joe@smith.org" form="save-person-1"/></td>
|
|
117
|
+
<td>
|
|
118
|
+
<form id="save-person-1" hx-post="/_components/person_row_editor/save"
|
|
119
|
+
hx-target="#person-row-editor-1" hx-swap="outerHTML"
|
|
120
|
+
action="/_components/person_row_editor/save" method="post">
|
|
121
|
+
<input type="hidden" name="person_id" value="1"/>
|
|
122
|
+
<input type="submit" value="Save"/>
|
|
123
|
+
<button type="button" hx-get="/_components/person_row?person_id=1"
|
|
124
|
+
hx-swap="outerHTML" hx-target="#person-row-editor-1">Cancel</button>
|
|
125
|
+
</form>
|
|
126
|
+
</td>
|
|
127
|
+
</tr>
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Submitting `POST /_components/person_row_editor/save` with the edited fields returns the updated display row, which replaces the editor:
|
|
131
|
+
|
|
132
|
+
```html
|
|
133
|
+
<tr id="person-row-1">
|
|
134
|
+
<td>Joe B. Smith</td>
|
|
135
|
+
<td>joe.smith@example.com</td>
|
|
136
|
+
...
|
|
137
|
+
</tr>
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Only the attributes `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
|
+
|
|
142
|
+
## Related
|
|
143
|
+
|
|
144
|
+
- [Click to Edit](click-to-edit.md) — the same two-state pattern on a standalone card, where a form can simply wrap its fields.
|
|
145
|
+
- [Delete Row](delete-row.md) — rows that leave the table instead of switching state.
|
|
146
|
+
- [`loads:`](../dsl.md#loads) and [`transfers`](../dsl.md#transfers--actions-that-render-something-else) in the DSL reference.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# File Upload
|
|
2
|
+
|
|
3
|
+
A file input, an Upload button, and a list of everything uploaded so far. The file travels through a component action like any other form submission, and the component re-renders with the list grown by one.
|
|
4
|
+
|
|
5
|
+
This is Weft's take on [htmx's file-upload example](https://htmx.org/examples/file-upload/), covering the upload itself; their JavaScript-driven progress meter is out of scope here — that part is hand-written JS even in htmx's own catalog.
|
|
6
|
+
|
|
7
|
+
## The components
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
UPLOADED_REPORTS = []
|
|
11
|
+
|
|
12
|
+
class ReportUploader < Weft::Component
|
|
13
|
+
builder_method :report_uploader
|
|
14
|
+
|
|
15
|
+
attribute :document
|
|
16
|
+
|
|
17
|
+
performs :upload do |attrs|
|
|
18
|
+
file = attrs.document
|
|
19
|
+
if file.is_a?(Hash) && file[:tempfile]
|
|
20
|
+
UPLOADED_REPORTS << { name: file[:filename], size: file[:tempfile].size }
|
|
21
|
+
end
|
|
22
|
+
{ document: nil }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def build(attributes = {})
|
|
26
|
+
super
|
|
27
|
+
form(action: :upload, enctype: "multipart/form-data") do
|
|
28
|
+
input type: "file", name: "document"
|
|
29
|
+
input type: "submit", value: "Upload"
|
|
30
|
+
end
|
|
31
|
+
if UPLOADED_REPORTS.any?
|
|
32
|
+
h3 "Uploaded"
|
|
33
|
+
ul do
|
|
34
|
+
UPLOADED_REPORTS.each { |doc| li "#{doc[:name]} (#{doc[:size]} bytes)" }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
(The `UPLOADED_REPORTS` array stands in for wherever your app actually puts files — storage service, attachment library, what have you.)
|
|
42
|
+
|
|
43
|
+
## How it works
|
|
44
|
+
|
|
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
|
+
|
|
47
|
+
**The file arrives through a declared attribute.** The `document` attribute 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
|
+
|
|
49
|
+
**Returning `{ document: nil }` is load-bearing.** A callable's returned hash merges into the attrs 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 attribute, 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
|
+
|
|
51
|
+
## On the wire
|
|
52
|
+
|
|
53
|
+
The initial render (or `GET /_components/report_uploader`) — the enctype sits as a plain HTML attribute beside the htmx wiring and the no-JS fallback:
|
|
54
|
+
|
|
55
|
+
```html
|
|
56
|
+
<div id="report-uploader">
|
|
57
|
+
<form enctype="multipart/form-data" hx-post="/_components/report_uploader/upload"
|
|
58
|
+
hx-target="#report-uploader" hx-swap="outerHTML"
|
|
59
|
+
action="/_components/report_uploader/upload" method="post">
|
|
60
|
+
<input type="file" name="document"/>
|
|
61
|
+
<input type="submit" value="Upload"/>
|
|
62
|
+
</form>
|
|
63
|
+
</div>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Choosing a file and pressing Upload sends a genuine multipart body — this one captured from htmx in a browser:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
POST /_components/report_uploader/upload
|
|
70
|
+
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarySSp7J2ErxcZUwkP2
|
|
71
|
+
|
|
72
|
+
------WebKitFormBoundarySSp7J2ErxcZUwkP2
|
|
73
|
+
Content-Disposition: form-data; name="document"; filename="browser-note.txt"
|
|
74
|
+
Content-Type: text/plain
|
|
75
|
+
|
|
76
|
+
hello from a real browser
|
|
77
|
+
------WebKitFormBoundarySSp7J2ErxcZUwkP2--
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The response is the component re-rendered, upload duly listed:
|
|
81
|
+
|
|
82
|
+
```html
|
|
83
|
+
<h3>Uploaded</h3>
|
|
84
|
+
<ul>
|
|
85
|
+
<li>browser-note.txt (25 bytes)</li>
|
|
86
|
+
</ul>
|
|
87
|
+
</div>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The endpoint doesn't care where the multipart came from — a no-JS browser submit or `curl -F "document=@q3-report.txt" …` lands the same way. And the cautionary baseline: the identical browser submit *without* the enctype attribute arrives as `document=%5Bobject%20File%5D`, and nothing is stored.
|
|
91
|
+
|
|
92
|
+
## Related
|
|
93
|
+
|
|
94
|
+
- [Reset User Input](reset-user-input.md) — the same clear-it-in-the-return move, there to keep text fields empty.
|
|
95
|
+
- [Inline Validation](inline-validation.md) — rejecting a bad submission with a `422` instead of quietly ignoring it.
|
|
96
|
+
- [`performs`](../dsl.md#performs--user-initiated-actions) and [the callable contract](../dsl.md#the-callable-contract) in the DSL reference.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Infinite Scroll
|
|
2
|
+
|
|
3
|
+
A table that grows as you read it: when the last visible row scrolls into view, the next page of rows is fetched and appended after it — no button, no page numbers, no end-of-page cliff until the data genuinely runs out.
|
|
4
|
+
|
|
5
|
+
This is Weft's take on [htmx's infinite-scroll example](https://htmx.org/examples/infinite-scroll/), and the mechanism is the same: the final row of each batch carries a `revealed` trigger, and each response ends with the next trigger row. Where htmx's server returns a loose fragment of `<tr>`s, a Weft component renders one wrapper element — so here each batch is a `<tbody>`, appended as a sibling of the last. HTML happily allows a table multiple `<tbody>` elements, which makes it the natural chunk unit for growing tables.
|
|
6
|
+
|
|
7
|
+
## The components
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
DIRECTORY = (1..42).map { |n| { name: "Contact #{n}", email: "contact#{n}@example.org" } }.freeze
|
|
11
|
+
|
|
12
|
+
class ContactRows < Weft::Component
|
|
13
|
+
builder_method :contact_rows
|
|
14
|
+
|
|
15
|
+
PER_PAGE = 10
|
|
16
|
+
|
|
17
|
+
attribute :page, default: 1
|
|
18
|
+
|
|
19
|
+
def tag_name
|
|
20
|
+
"tbody"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def build(attributes = {})
|
|
24
|
+
super
|
|
25
|
+
rows = DIRECTORY[(attrs.page - 1) * PER_PAGE, PER_PAGE]
|
|
26
|
+
rows.each_with_index do |contact, index|
|
|
27
|
+
if index == rows.size - 1 && more_pages?
|
|
28
|
+
tr(infinite_scroll: ContactRows, with: { page: attrs.page + 1 }, target: "closest tbody") do
|
|
29
|
+
td contact[:name]; td contact[:email]
|
|
30
|
+
end
|
|
31
|
+
else
|
|
32
|
+
tr { td contact[:name]; td contact[:email] }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def more_pages?
|
|
40
|
+
attrs.page * PER_PAGE < DIRECTORY.size
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class ContactDirectory < Weft::Component
|
|
45
|
+
builder_method :contact_directory
|
|
46
|
+
|
|
47
|
+
def build(attributes = {})
|
|
48
|
+
super
|
|
49
|
+
h3 "Contact directory"
|
|
50
|
+
table do
|
|
51
|
+
thead { tr { th "Name"; th "Email" } }
|
|
52
|
+
contact_rows(page: 1)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
(The `DIRECTORY` array stands in for your data layer — swap in ActiveRecord or whatever your app uses.)
|
|
59
|
+
|
|
60
|
+
## How it works
|
|
61
|
+
|
|
62
|
+
**The last row is the sentinel.** [`infinite_scroll:`](../dsl.md#shorthands) 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
|
+
|
|
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
|
+
|
|
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
|
+
|
|
68
|
+
**`page` travels as wire state.** `attribute :page, default: 1` makes each batch independently addressable (`/_components/contact_rows?page=3`) and coerces the URL string to an Integer, since the default is one. The `ContactDirectory` wrapper, by contrast, declares nothing — it's plain composition and renders only as part of a page.
|
|
69
|
+
|
|
70
|
+
## On the wire
|
|
71
|
+
|
|
72
|
+
The initial render (or `GET /_components/contact_rows?page=1`) — nine plain rows, then the sentinel:
|
|
73
|
+
|
|
74
|
+
```html
|
|
75
|
+
<tbody id="contact-rows-1">
|
|
76
|
+
<tr><td>Contact 1</td><td>contact1@example.org</td></tr>
|
|
77
|
+
<!-- … -->
|
|
78
|
+
<tr hx-get="/_components/contact_rows?page=2" hx-swap="afterend"
|
|
79
|
+
hx-target="closest tbody" hx-trigger="revealed">
|
|
80
|
+
<td>Contact 10</td><td>contact10@example.org</td>
|
|
81
|
+
</tr>
|
|
82
|
+
</tbody>
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Scrolling that row into view issues `GET /_components/contact_rows?page=2`, whose response is the next `<tbody>` — appended after the first, ending in its own sentinel pointing at `page=3`. The last batch (`GET /_components/contact_rows?page=5`) contains only plain rows:
|
|
86
|
+
|
|
87
|
+
```html
|
|
88
|
+
<tbody id="contact-rows-5">
|
|
89
|
+
<tr><td>Contact 41</td><td>contact41@example.org</td></tr>
|
|
90
|
+
<tr><td>Contact 42</td><td>contact42@example.org</td></tr>
|
|
91
|
+
</tbody>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Related
|
|
95
|
+
|
|
96
|
+
- [Click to Load](click-to-load.md) — the same batch-by-batch growth, but on the reader's explicit request.
|
|
97
|
+
- [Lazy Loading](lazy-loading.md) — the `:visible` trigger deferring a single section instead of paginating many.
|
|
98
|
+
- The [shorthands table](../dsl.md#shorthands) and [targets](../dsl.md#targets) in the DSL reference.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Inline Expansion
|
|
2
|
+
|
|
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
|
+
|
|
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 shorthands. It's the pattern for master-detail tables where the detail is cheap to want and expensive to preload for every row.
|
|
6
|
+
|
|
7
|
+
## The components
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
ORDERS = {
|
|
11
|
+
"1001" => { customer: "Ada Lovelace", total: "$120.00", items: ["Brass gears (x12)", "Punch cards (x100)"] },
|
|
12
|
+
"1002" => { customer: "Grace Hopper", total: "$45.50", items: ["Nanosecond wire (x3)"] },
|
|
13
|
+
"1003" => { customer: "Katherine Johnson", total: "$310.25", items: ["Slide rule", "Graph paper (x20)"] }
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
class OrderItemsRow < Weft::Component
|
|
17
|
+
builder_method :order_items_row
|
|
18
|
+
|
|
19
|
+
attribute :order_id
|
|
20
|
+
|
|
21
|
+
def tag_name
|
|
22
|
+
"tr"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def build(attributes = {})
|
|
26
|
+
super
|
|
27
|
+
order = ORDERS.fetch(attrs.order_id)
|
|
28
|
+
td colspan: 4 do
|
|
29
|
+
strong "Items: "
|
|
30
|
+
text_node order[:items].join(", ")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class OrdersTable < Weft::Component
|
|
36
|
+
builder_method :orders_table
|
|
37
|
+
|
|
38
|
+
def build(attributes = {})
|
|
39
|
+
super
|
|
40
|
+
table do
|
|
41
|
+
thead { tr { th ""; th "Order"; th "Customer"; th "Total" } }
|
|
42
|
+
tbody do
|
|
43
|
+
ORDERS.each do |id, order|
|
|
44
|
+
tr do
|
|
45
|
+
td do
|
|
46
|
+
button "▸", inline_expand: OrderItemsRow, with: { order_id: id },
|
|
47
|
+
target: "closest tr", trigger: "click once"
|
|
48
|
+
end
|
|
49
|
+
td id
|
|
50
|
+
td order[:customer]
|
|
51
|
+
td order[:total]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
(The `ORDERS` hash stands in for your data layer — swap in ActiveRecord or whatever your app uses.)
|
|
61
|
+
|
|
62
|
+
## How it works
|
|
63
|
+
|
|
64
|
+
**The detail arrives *after* the trigger's row.** [`inline_expand:`](../dsl.md#shorthands) presets trigger `:click` 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
|
+
|
|
66
|
+
**`trigger: "click once"` guards against double insertion.** The preset's `:click` fires on *every* click — left alone, a second click would insert a second copy of the detail row. The `trigger:` kwarg overrides the preset with htmx's raw trigger grammar, and `once` caps the interaction at a single firing. (There's currently no semantic symbol for "click once" the way `:hover` bakes in `mouseenter once`, so the raw string is the honest spelling.)
|
|
67
|
+
|
|
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
|
+
|
|
70
|
+
## On the wire
|
|
71
|
+
|
|
72
|
+
The initial render — each order row carries its wired disclosure button:
|
|
73
|
+
|
|
74
|
+
```html
|
|
75
|
+
<tr>
|
|
76
|
+
<td>
|
|
77
|
+
<button hx-get="/_components/order_items_row?order_id=1001" hx-swap="afterend"
|
|
78
|
+
hx-target="closest tr" hx-trigger="click once">▸</button>
|
|
79
|
+
</td>
|
|
80
|
+
<td>1001</td>
|
|
81
|
+
<td>Ada Lovelace</td>
|
|
82
|
+
<td>$120.00</td>
|
|
83
|
+
</tr>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Clicking issues `GET /_components/order_items_row?order_id=1001`, and the response slots in beneath the row:
|
|
87
|
+
|
|
88
|
+
```html
|
|
89
|
+
<tr id="order-items-row-1001">
|
|
90
|
+
<td colspan="4"><strong>Items: </strong>Brass gears (x12), Punch cards (x100)</td>
|
|
91
|
+
</tr>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Related
|
|
95
|
+
|
|
96
|
+
- [Tooltip](tooltip.md) — the other Weft-native shorthand: hover-driven detail loaded into a bubble instead of a row.
|
|
97
|
+
- [Click to Edit](click-to-edit.md) — two-state rows that swap in place rather than expanding.
|
|
98
|
+
- The [shorthands table](../dsl.md#shorthands) and [trigger values](../dsl.md#trigger-values) in the DSL reference.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Inline Validation
|
|
2
|
+
|
|
3
|
+
A signup form's email field that checks itself: leave the field, and a moment later there's either a green all-clear or a specific complaint — straight from the server, before the user gets anywhere near the submit button.
|
|
4
|
+
|
|
5
|
+
This is Weft's take on [htmx's inline-validation example](https://htmx.org/examples/inline-validation/), and the shape is the same: the field posts its value when it changes, the server decides, and the field's corner of the page re-renders with the verdict. In Weft the field is a small component that owns its whole validation story — the check, the error state, and the success state live next to the markup they decorate.
|
|
6
|
+
|
|
7
|
+
## The components
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
TAKEN_EMAILS = ["taken@example.com"].freeze
|
|
11
|
+
|
|
12
|
+
class SignupEmailField < Weft::Component
|
|
13
|
+
builder_method :signup_email_field
|
|
14
|
+
|
|
15
|
+
attribute :email
|
|
16
|
+
attribute :error_message
|
|
17
|
+
|
|
18
|
+
performs :validate, target: "#signup-email-field" do |attrs|
|
|
19
|
+
email = attrs.email.to_s.strip
|
|
20
|
+
unless email.match?(URI::MailTo::EMAIL_REGEXP)
|
|
21
|
+
raise Weft::Unprocessable, "That doesn't look like an email address."
|
|
22
|
+
end
|
|
23
|
+
raise Weft::Unprocessable, "#{email} is already registered." if TAKEN_EMAILS.include?(email)
|
|
24
|
+
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
recovers from: Weft::Unprocessable do |_attrs, error|
|
|
29
|
+
{ error_message: error.message }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def build(attributes = {})
|
|
33
|
+
super
|
|
34
|
+
set_attribute :id, "signup-email-field"
|
|
35
|
+
form(action: :validate, trigger: "change") do
|
|
36
|
+
label "Email Address ", for: "email"
|
|
37
|
+
input type: "email", name: "email", id: "email", value: attrs.email
|
|
38
|
+
end
|
|
39
|
+
if attrs.error_message
|
|
40
|
+
para attrs.error_message, style: "color:#b91c1c"
|
|
41
|
+
elsif attrs.email
|
|
42
|
+
para "#{attrs.email} looks good.", style: "color:#15803d"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
(The `TAKEN_EMAILS` list stands in for the uniqueness check your real data layer would run.)
|
|
49
|
+
|
|
50
|
+
## How it works
|
|
51
|
+
|
|
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 `attrs.email`, exactly as it would on a full submit.
|
|
53
|
+
|
|
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 attributes 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
|
+
|
|
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 attrs 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
|
+
|
|
58
|
+
**A value that changes can't anchor the DOM id.** Weft derives a component's DOM id from its first declared attribute — 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
|
+
|
|
60
|
+
**The field echoes what the user typed.** The swap replaces the whole component, input included, so `value: attrs.email` writes the submitted text back into the fresh input — without it, every complaint would also blank the field.
|
|
61
|
+
|
|
62
|
+
## On the wire
|
|
63
|
+
|
|
64
|
+
The initial render (or `GET /_components/signup_email_field`) — one field, its form listening for `change`:
|
|
65
|
+
|
|
66
|
+
```html
|
|
67
|
+
<div id="signup-email-field">
|
|
68
|
+
<form hx-post="/_components/signup_email_field/validate" hx-target="#signup-email-field"
|
|
69
|
+
hx-swap="outerHTML" action="/_components/signup_email_field/validate" method="post"
|
|
70
|
+
hx-trigger="change">
|
|
71
|
+
<label for="email">Email Address </label>
|
|
72
|
+
<input type="email" name="email" id="email"/>
|
|
73
|
+
</form>
|
|
74
|
+
</div>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Typing `not-an-email` and tabbing away posts `email=not-an-email`, and the answer is `422 Unprocessable Content` — the component re-rendered mid-complaint, the typed text still in the field:
|
|
78
|
+
|
|
79
|
+
```html
|
|
80
|
+
<div id="signup-email-field">
|
|
81
|
+
<form ...>
|
|
82
|
+
<label for="email">Email Address </label>
|
|
83
|
+
<input type="email" name="email" id="email" value="not-an-email"/>
|
|
84
|
+
</form>
|
|
85
|
+
<p style="color:#b91c1c">That doesn't look like an email address.</p>
|
|
86
|
+
</div>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`taken@example.com` earns the other complaint the same way (`422`, "taken@example.com is already registered."), and `maria@example.com` comes back `200 OK` wearing the success line:
|
|
90
|
+
|
|
91
|
+
```html
|
|
92
|
+
<p style="color:#15803d">maria@example.com looks good.</p>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
And the part that makes it *inline*: a browser's `change` event on the input bubbles to the form and posts exactly what was typed — the captured request body is `email=typed.by.browser%40example.com`, no submit button involved.
|
|
96
|
+
|
|
97
|
+
## Related
|
|
98
|
+
|
|
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 attributes.
|
|
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.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Keyboard Shortcuts
|
|
2
|
+
|
|
3
|
+
A component action fired by a keystroke from anywhere on the page — the same request a click would make, bound to a key combination instead. No new machinery: a keyboard shortcut is just another trigger.
|
|
4
|
+
|
|
5
|
+
This is Weft's take on [htmx's keyboard shortcuts example](https://htmx.org/examples/keyboard-shortcuts/), and the shape carries over directly: htmx's `hx-trigger` grammar already knows about key events, filters, and listening beyond the element itself, and Weft's [`trigger:`](../dsl.md#trigger) kwarg accepts that grammar in full.
|
|
6
|
+
|
|
7
|
+
## The components
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
INBOX_NOTE = { archived: false }
|
|
11
|
+
|
|
12
|
+
class InboxNote < Weft::Component
|
|
13
|
+
builder_method :inbox_note
|
|
14
|
+
|
|
15
|
+
performs :archive do
|
|
16
|
+
INBOX_NOTE[:archived] = true
|
|
17
|
+
nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def build(attributes = {})
|
|
21
|
+
super
|
|
22
|
+
if INBOX_NOTE[:archived]
|
|
23
|
+
para "Archived. Nothing left to do here."
|
|
24
|
+
else
|
|
25
|
+
para %("Lunch on Thursday?" — from Sam)
|
|
26
|
+
button "Archive (Alt+Shift+A)", action: :archive,
|
|
27
|
+
trigger: "click, keyup[altKey&&shiftKey&&key=='A'] from:body"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
(The `INBOX_NOTE` hash stands in for your data layer.)
|
|
34
|
+
|
|
35
|
+
## How it works
|
|
36
|
+
|
|
37
|
+
**`trigger:` accepts the full htmx grammar.** The semantic symbols in the [trigger table](../dsl.md#trigger-values) cover the common cases, but any string passes through to `hx-trigger` untouched — and that string can use everything [htmx's trigger syntax](https://htmx.org/attributes/hx-trigger/) offers. This one uses three pieces at once: *comma-separated triggers* (either one fires the request), an *event filter* in brackets (a JavaScript expression tested against the event), and a *`from:` modifier* (listen on `body`, not just the element).
|
|
38
|
+
|
|
39
|
+
**Two triggers, one action.** [`action:`](../dsl.md#action) supplies what the request *is* — the POST to `:archive`, the target, the swap. `trigger:` only changes *when* it fires, replacing the button's default click-only trigger with click-or-keystroke. The button stays a button; the shortcut is an alternative route to the identical request.
|
|
40
|
+
|
|
41
|
+
**`from:body` is what makes it a shortcut.** Without it, the keyup would only fire while the button itself had focus — which is no shortcut at all. Listening on `body` catches the key anywhere on the page. The filter then decides *which* keys: requiring `altKey && shiftKey` keeps the action from firing while someone merely types the letter into a text field. Bare-key bindings — `keyup[key=='/'] from:body` to jump to a search box, say — use the same grammar; just remember that unmodified letters and typing collide.
|
|
42
|
+
|
|
43
|
+
## On the wire
|
|
44
|
+
|
|
45
|
+
The initial render — one button carrying both the action wiring and the composite trigger:
|
|
46
|
+
|
|
47
|
+
```html
|
|
48
|
+
<div id="inbox-note">
|
|
49
|
+
<p>"Lunch on Thursday?" — from Sam</p>
|
|
50
|
+
<button hx-post="/_components/inbox_note/archive" hx-target="#inbox-note"
|
|
51
|
+
hx-swap="outerHTML" hx-vals="{}"
|
|
52
|
+
hx-trigger="click, keyup[altKey&&shiftKey&&key=='A'] from:body">
|
|
53
|
+
Archive (Alt+Shift+A)</button>
|
|
54
|
+
</div>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The `&&` and `'` are ordinary HTML attribute escaping — the browser decodes them before htmx reads the attribute, so htmx sees exactly the string you wrote: `click, keyup[altKey&&shiftKey&&key=='A'] from:body`.
|
|
58
|
+
|
|
59
|
+
Clicking the button — or pressing Alt+Shift+A anywhere on the page — issues `POST /_components/inbox_note/archive`, and the re-rendered component replaces the note:
|
|
60
|
+
|
|
61
|
+
```html
|
|
62
|
+
<div id="inbox-note">
|
|
63
|
+
<p>Archived. Nothing left to do here.</p>
|
|
64
|
+
</div>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Related
|
|
68
|
+
|
|
69
|
+
- The [trigger values table](../dsl.md#trigger-values) — the semantic symbols this page's raw string bypasses.
|
|
70
|
+
- [Active Search](active-search.md) — another interaction defined almost entirely by its trigger.
|
|
71
|
+
- [Modal Dialog](modal-dialog.md) — where the same grammar could bind Escape to a dismissal.
|