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,96 @@
|
|
|
1
|
+
# Lazy Loading
|
|
2
|
+
|
|
3
|
+
A placeholder that fills itself with real content the moment it scrolls into view. Expensive sections — big queries, slow aggregations — stay out of the initial render, and the page arrives fast.
|
|
4
|
+
|
|
5
|
+
This is Weft's take on [htmx's lazy-load example](https://htmx.org/examples/lazy-load/), where a graph is fetched only when revealed. The shape is identical: a lightweight placeholder carries the wiring, and the server renders the heavy part on demand. In Weft, the heavy part is simply a component.
|
|
6
|
+
|
|
7
|
+
## The components
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
QUARTERLY_REVENUE = {
|
|
11
|
+
2025 => { "Q1" => "$418,200", "Q2" => "$473,900", "Q3" => "$391,400", "Q4" => "$512,750" }
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
class RevenueTable < Weft::Component
|
|
15
|
+
builder_method :revenue_table
|
|
16
|
+
|
|
17
|
+
attribute :year, default: 2025
|
|
18
|
+
|
|
19
|
+
def build(attributes = {})
|
|
20
|
+
super
|
|
21
|
+
table do
|
|
22
|
+
thead { tr { th "Quarter"; th "Revenue" } }
|
|
23
|
+
tbody do
|
|
24
|
+
QUARTERLY_REVENUE.fetch(attrs.year).each do |quarter, amount|
|
|
25
|
+
tr { td quarter; td amount }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class AnnualReport < Weft::Component
|
|
33
|
+
builder_method :annual_report
|
|
34
|
+
|
|
35
|
+
attribute :year, default: 2025
|
|
36
|
+
|
|
37
|
+
def build(attributes = {})
|
|
38
|
+
super
|
|
39
|
+
h2 "#{attrs.year} annual report"
|
|
40
|
+
para "Commentary, highlights, and everything else the reader scrolls through " \
|
|
41
|
+
"before the numbers. The revenue table below is expensive to produce, " \
|
|
42
|
+
"so it loads only when it comes into view."
|
|
43
|
+
div lazy: RevenueTable, with: { year: attrs.year } do
|
|
44
|
+
para "Loading revenue…"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
(The `QUARTERLY_REVENUE` hash stands in for your data layer — in real life this is the query you'd rather not run on every page view.)
|
|
51
|
+
|
|
52
|
+
## How it works
|
|
53
|
+
|
|
54
|
+
**The placeholder is an ordinary element with one extra kwarg.** [`lazy:`](../dsl.md#shorthands) 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 attrs.
|
|
55
|
+
|
|
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
|
+
|
|
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
|
+
|
|
60
|
+
**`with:` passes the wire state along.** The placeholder hands its own `year` down to the loaded component (`with: { year: attrs.year }`), and the value travels in the URL — visible below. If you omit `with:`, the enclosing component's attrs are passed by default.
|
|
61
|
+
|
|
62
|
+
## On the wire
|
|
63
|
+
|
|
64
|
+
The initial render (or `GET /_components/annual_report?year=2025`) contains the placeholder, wired and waiting:
|
|
65
|
+
|
|
66
|
+
```html
|
|
67
|
+
<div id="annual-report-2025">
|
|
68
|
+
<h2>2025 annual report</h2>
|
|
69
|
+
<p>Commentary, highlights, and everything else the reader scrolls through …</p>
|
|
70
|
+
<div hx-get="/_components/revenue_table?year=2025" hx-swap="innerHTML"
|
|
71
|
+
hx-target="this" hx-trigger="revealed">
|
|
72
|
+
<p>Loading revenue…</p>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Scrolling it into view issues `GET /_components/revenue_table?year=2025`, and the response fills the placeholder:
|
|
78
|
+
|
|
79
|
+
```html
|
|
80
|
+
<div id="revenue-table-2025">
|
|
81
|
+
<table>
|
|
82
|
+
<thead><tr><th>Quarter</th><th>Revenue</th></tr></thead>
|
|
83
|
+
<tbody>
|
|
84
|
+
<tr><td>Q1</td><td>$418,200</td></tr>
|
|
85
|
+
<tr><td>Q2</td><td>$473,900</td></tr>
|
|
86
|
+
<!-- … -->
|
|
87
|
+
</tbody>
|
|
88
|
+
</table>
|
|
89
|
+
</div>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Related
|
|
93
|
+
|
|
94
|
+
- [Infinite Scroll](infinite-scroll.md) — the same `:visible` trigger, used repeatedly to grow a table page by page.
|
|
95
|
+
- [Click to Load](click-to-load.md) — deferred loading where the user asks for more, instead of scrolling to it.
|
|
96
|
+
- The [shorthands table](../dsl.md#shorthands) and the [trigger values](../dsl.md#trigger-values) in the DSL reference.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Live Ticker
|
|
2
|
+
|
|
3
|
+
A panel of live numbers — a server clock, a drifting gauge — that updates itself every couple of seconds without being asked. The server holds a connection open and pushes fresh renderings down it; the client never polls.
|
|
4
|
+
|
|
5
|
+
This one has no direct counterpart in [htmx's examples catalog](https://htmx.org/examples/), which doesn't carry a server-sent-events demo — htmx covers SSE through its [SSE extension](https://htmx.org/extensions/sse/) rather than a catalog page. Weft builds on that same extension, and wraps the whole arrangement — the stream endpoint, the connection attributes, the event framing, even loading the extension script — into one declaration: [`pushes every: 2`](../dsl.md#pushes--the-server-sends-updates).
|
|
6
|
+
|
|
7
|
+
## The components
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# A drifting gauge: a small random walk that stands in for any live number
|
|
11
|
+
# your app can read on demand — active sessions, queue depth, a sensor.
|
|
12
|
+
module VisitorGauge
|
|
13
|
+
class << self
|
|
14
|
+
def reading
|
|
15
|
+
@reading = (@reading || 142) + rand(-9..9)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class LiveTicker < Weft::Component
|
|
21
|
+
builder_method :live_ticker
|
|
22
|
+
|
|
23
|
+
pushes every: 2
|
|
24
|
+
|
|
25
|
+
def build(attributes = {})
|
|
26
|
+
super
|
|
27
|
+
para "Server time: #{Time.now.strftime('%H:%M:%S')}"
|
|
28
|
+
para "Visitors right now: #{VisitorGauge.reading}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## How it works
|
|
34
|
+
|
|
35
|
+
**One declaration, both ends of the wire.** `pushes every: 2` does two things at once. Server-side, the Router auto-generates an SSE endpoint for the component at its own path plus a stream suffix — `/_components/live_ticker/_stream` — where it re-renders the component every 2 seconds and pushes the result down the open connection. Client-side, the component renders with the htmx SSE attributes (`hx-ext="sse"`, `sse-connect`, `sse-swap`) already on its wrapper, so the connection opens as soon as the component lands in a page. (The `_stream` suffix is configurable — see [`stream_suffix`](../configuration.md#stream_suffix).)
|
|
36
|
+
|
|
37
|
+
**Pushed frames fill the component's interior.** Unlike a [`refreshes`](../dsl.md#refreshes--the-client-re-fetches) poll, which replaces the whole component, a pushed frame swaps `innerHTML`: the wrapper element *holds the SSE connection*, so it must persist while its contents are replaced underneath it. That's also why each frame's payload is the component's children only, with no wrapper div.
|
|
38
|
+
|
|
39
|
+
**Events are named after the component.** Each frame arrives as `event: live-ticker` — the component's DOM id — and the wrapper's `sse-swap="live-ticker"` subscribes to exactly that name. The two are generated from the same value, so they always agree.
|
|
40
|
+
|
|
41
|
+
**New subscribers get an immediate snapshot.** The first frame is pushed the moment the connection opens, then the 2-second cadence begins. A page that renders the component and connects a beat later doesn't sit stale for one interval — and a dropped connection that reconnects catches up instantly.
|
|
42
|
+
|
|
43
|
+
**The extension script ships itself.** Pages include the htmx SSE extension automatically when any registered component declares `pushes` — the [`include_sse_ext`](../configuration.md#include_sse_ext) setting, whose `:auto` default means apps without SSE components never ship the extra script and apps with them never have to remember it.
|
|
44
|
+
|
|
45
|
+
## On the wire
|
|
46
|
+
|
|
47
|
+
The initial render (or `GET /_components/live_ticker`) — the wrapper carries the connection wiring, and usable content is already inside:
|
|
48
|
+
|
|
49
|
+
```html
|
|
50
|
+
<div id="live-ticker" hx-ext="sse" sse-connect="/_components/live_ticker/_stream"
|
|
51
|
+
sse-swap="live-ticker" hx-swap="innerHTML">
|
|
52
|
+
<p>Server time: 15:39:53</p>
|
|
53
|
+
<p>Visitors right now: 146</p>
|
|
54
|
+
</div>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The page it sits in has the extension script in its `<head>`, included automatically alongside htmx itself:
|
|
58
|
+
|
|
59
|
+
```html
|
|
60
|
+
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-…" crossorigin="anonymous"></script>
|
|
61
|
+
<script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Connecting to the stream — here with `curl -N`, held open for seven seconds — answers with SSE headers and then a frame every 2 seconds, the first one immediately:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
HTTP/1.1 200 OK
|
|
68
|
+
content-type: text/event-stream;charset=utf-8
|
|
69
|
+
cache-control: no-cache
|
|
70
|
+
transfer-encoding: chunked
|
|
71
|
+
|
|
72
|
+
event: live-ticker
|
|
73
|
+
data: <p>Server time: 15:39:53</p>
|
|
74
|
+
data: <p>Visitors right now: 144</p>
|
|
75
|
+
|
|
76
|
+
event: live-ticker
|
|
77
|
+
data: <p>Server time: 15:39:55</p>
|
|
78
|
+
data: <p>Visitors right now: 151</p>
|
|
79
|
+
|
|
80
|
+
event: live-ticker
|
|
81
|
+
data: <p>Server time: 15:39:57</p>
|
|
82
|
+
data: <p>Visitors right now: 159</p>
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Each multi-line rendering becomes consecutive `data:` lines in one event, per the SSE format; htmx reassembles them and swaps the result into the wrapper.
|
|
86
|
+
|
|
87
|
+
## Related
|
|
88
|
+
|
|
89
|
+
- [Progress Bar](progress-bar.md) — the polling counterpart: the client re-fetches on a timer instead of holding a connection.
|
|
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 attributes streams too — its wire state rides the `sse-connect` URL as query parameters, so each subscriber gets frames for *its* instance.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Modal Dialog
|
|
2
|
+
|
|
3
|
+
A button opens a dialog floating over a dimmed page; pressing Close — or clicking the dimmed backdrop — makes it go away. The dialog is a component fetched on demand, and closing it is simply removing it from the DOM.
|
|
4
|
+
|
|
5
|
+
This is Weft's take on [htmx's custom modal example](https://htmx.org/examples/modal-custom/). htmx's version reaches for hyperscript to wire up the close behavior; in Weft the close is a [`dismisses`](../dsl.md#dismisses--remove-from-the-dom) action, so there's no JavaScript beyond what Weft already ships. (htmx's catalog also carries UIkit and Bootstrap modal variants — integrating with a CSS framework's modal machinery is out of scope here.)
|
|
6
|
+
|
|
7
|
+
## The components
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
class TourDialog < Weft::Component
|
|
11
|
+
builder_method :tour_dialog
|
|
12
|
+
|
|
13
|
+
dismisses :close
|
|
14
|
+
|
|
15
|
+
def build(attributes = {})
|
|
16
|
+
super
|
|
17
|
+
# Underlay and panel are siblings, so clicks on the panel never reach
|
|
18
|
+
# the underlay. Inline styles are the minimum to dim and center —
|
|
19
|
+
# real styling belongs in your stylesheet.
|
|
20
|
+
div action: :close,
|
|
21
|
+
style: "position: fixed; inset: 0; background: rgba(0, 0, 0, 0.4);"
|
|
22
|
+
div style: "position: fixed; top: 20%; left: 50%; transform: translateX(-50%); " \
|
|
23
|
+
"background: white; padding: 1.5rem 2rem; border-radius: 6px;" do
|
|
24
|
+
h3 "Book a tour"
|
|
25
|
+
para "Our next open house is Saturday at noon. Come see the workshop, " \
|
|
26
|
+
"meet the team, and try the tools yourself."
|
|
27
|
+
button "Close", action: :close
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class TourPromo < Weft::Component
|
|
33
|
+
builder_method :tour_promo
|
|
34
|
+
|
|
35
|
+
def build(attributes = {})
|
|
36
|
+
super
|
|
37
|
+
button "Book a tour", modal: TourDialog, target: "#modal-slot"
|
|
38
|
+
div id: "modal-slot"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## How it works
|
|
44
|
+
|
|
45
|
+
**Opening is a load into a stable slot.** [`modal:`](../dsl.md#shorthands) 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
|
+
|
|
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 |attrs| ... end`) if closing should also do something server-side, like recording that the tour offer was seen.
|
|
48
|
+
|
|
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
|
+
|
|
51
|
+
**Closing is a round trip.** htmx's custom modal closes instantly, client-side; Weft's close asks the server first. That's the trade for writing no close-handling JavaScript: a request on the wire per dismissal, in exchange for one declaration and a place to hang side effects. For a modal on a reasonable connection the difference isn't perceptible, but it's worth knowing which one you're getting.
|
|
52
|
+
|
|
53
|
+
## On the wire
|
|
54
|
+
|
|
55
|
+
The initial render — a wired trigger button and an empty slot:
|
|
56
|
+
|
|
57
|
+
```html
|
|
58
|
+
<div id="tour-promo">
|
|
59
|
+
<button hx-get="/_components/tour_dialog" hx-swap="innerHTML"
|
|
60
|
+
hx-target="#modal-slot" hx-trigger="click">Book a tour</button>
|
|
61
|
+
<div id="modal-slot"></div>
|
|
62
|
+
</div>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Clicking the button issues `GET /_components/tour_dialog`, and the dialog fills the slot. Underlay and Close button carry identical dismissal wiring:
|
|
66
|
+
|
|
67
|
+
```html
|
|
68
|
+
<div id="tour-dialog">
|
|
69
|
+
<div style="position: fixed; inset: 0; background: rgba(0, 0, 0, 0.4);"
|
|
70
|
+
hx-delete="/_components/tour_dialog/close" hx-target="#tour-dialog"
|
|
71
|
+
hx-swap="delete" hx-vals="{}"></div>
|
|
72
|
+
<div style="position: fixed; top: 20%; left: 50%; transform: translateX(-50%); background: white; padding: 1.5rem 2rem; border-radius: 6px;">
|
|
73
|
+
<h3>Book a tour</h3>
|
|
74
|
+
<p>Our next open house is Saturday at noon. Come see the workshop,
|
|
75
|
+
meet the team, and try the tools yourself.</p>
|
|
76
|
+
<button hx-delete="/_components/tour_dialog/close" hx-target="#tour-dialog"
|
|
77
|
+
hx-swap="delete" hx-vals="{}">Close</button>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Clicking either one issues `DELETE /_components/tour_dialog/close`. The response is a `200` whose body htmx discards — a `delete` swap removes the target element no matter what came back — leaving `#modal-slot` empty again.
|
|
83
|
+
|
|
84
|
+
## Related
|
|
85
|
+
|
|
86
|
+
- [Browser Dialogs](browser-dialogs.md) — when a native `confirm()` box is dialog enough.
|
|
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 [shorthands table](../dsl.md#shorthands) in the DSL reference.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# Progress Bar
|
|
2
|
+
|
|
3
|
+
A Start button kicks off a background job, a progress bar advances as the server reports the work getting done, and a Restart button appears when it finishes — the whole lifecycle driven by one polling component.
|
|
4
|
+
|
|
5
|
+
This is Weft's take on [htmx's progress-bar example](https://htmx.org/examples/progress-bar/), with the same ARIA-annotated bar and the same smooth CSS-transition movement. htmx builds it from three hand-wired pieces — a start button, a polling wrapper, and an `HX-Trigger`-based handoff to stop the polling. In Weft it's one component with two declarations: `performs :start` for the kickoff, `refreshes every: 1` for the watching.
|
|
6
|
+
|
|
7
|
+
## The components
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# A fake background job: each status check advances the work by 20 points.
|
|
11
|
+
# Stands in for reading real progress from your job layer — a Sidekiq
|
|
12
|
+
# status hash, an ActiveJob progress record, whatever your app uses.
|
|
13
|
+
module FakeJob
|
|
14
|
+
class << self
|
|
15
|
+
def start!
|
|
16
|
+
@state = "running"
|
|
17
|
+
@progress = 0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Report the current status, then advance the fake work so the next
|
|
21
|
+
# check sees more of it done.
|
|
22
|
+
def check
|
|
23
|
+
snapshot = { state: state, progress: progress }
|
|
24
|
+
advance if state == "running"
|
|
25
|
+
snapshot
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def state = @state ||= "idle"
|
|
29
|
+
def progress = @progress ||= 0
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def advance
|
|
34
|
+
@progress += 20
|
|
35
|
+
@state = "complete" if @progress >= 100
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class JobMonitor < Weft::Component
|
|
41
|
+
builder_method :job_monitor
|
|
42
|
+
|
|
43
|
+
refreshes every: 1
|
|
44
|
+
|
|
45
|
+
performs :start do |_attrs|
|
|
46
|
+
FakeJob.start!
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def build(attributes = {})
|
|
51
|
+
super
|
|
52
|
+
job = FakeJob.check
|
|
53
|
+
case job[:state]
|
|
54
|
+
when "idle"
|
|
55
|
+
button "Start Job", action: :start
|
|
56
|
+
when "running"
|
|
57
|
+
h3 "Running", id: "pblabel"
|
|
58
|
+
progress_bar(job[:progress])
|
|
59
|
+
else
|
|
60
|
+
h3 "Complete", id: "pblabel"
|
|
61
|
+
progress_bar(100)
|
|
62
|
+
button "Restart Job", action: :start
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def progress_bar(percent)
|
|
69
|
+
div class: "progress", role: "progressbar", "aria-valuemin": "0",
|
|
70
|
+
"aria-valuemax": "100", "aria-valuenow": percent.to_s,
|
|
71
|
+
"aria-labelledby": "pblabel",
|
|
72
|
+
style: "max-width:20rem; background:#e5e7eb; border-radius:4px" do
|
|
73
|
+
div class: "progress-bar",
|
|
74
|
+
style: "width:#{percent}%; height:1.25rem; background:#2563eb; " \
|
|
75
|
+
"border-radius:4px; transition:width 1s linear"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## How it works
|
|
82
|
+
|
|
83
|
+
**One component renders the whole lifecycle.** `build` checks the job and renders whichever state it finds: a Start button, the advancing bar, or the finished bar with its Restart affordance. Because [`refreshes every: 1`](../dsl.md#refreshes--the-client-re-fetches) puts the polling wiring on the component's wrapper element, every poll replaces the component wholesale (`outerHTML`) — so the UI moves between states with no extra plumbing. When the server flips the job to complete, the next poll simply renders the completed state.
|
|
84
|
+
|
|
85
|
+
**Starting is an ordinary action.** The Start and Restart buttons both wire to [`performs :start`](../dsl.md#performs--user-initiated-actions), whose callable kicks the fake job and ends with `nil` — the re-render after the action shows the freshly started bar at 0%. htmx's version needs the start response to *establish* the polling wrapper; in Weft the wiring is declared on the class, so it's already present in every rendering, initial page included.
|
|
86
|
+
|
|
87
|
+
**The bar is just a styled div, meaningfully annotated.** The outer div carries `role="progressbar"` with `aria-valuenow`/`-valuemin`/`-valuemax` (mirroring htmx's markup), and the inner div's inline `width` is the percentage. The `transition: width 1s linear` matches the 1-second poll cadence, so the bar glides continuously instead of jumping — the same trick as htmx's "smooth" variant.
|
|
88
|
+
|
|
89
|
+
**Progress advances on each check.** `FakeJob.check` reports the current status and then moves the fake work forward, so five polls walk the bar 0 → 20 → 40 → 60 → 80 → done. In your app, `check` becomes a read of real job progress and the cadence stops being lockstep — the component doesn't care either way.
|
|
90
|
+
|
|
91
|
+
**One honest limitation:** the refresh wiring is class-level and unconditional in this release, so the completed (and idle) states keep polling once per second even though nothing will change until someone clicks Restart. htmx's original stops polling at 100% via an `HX-Trigger` handoff; Weft currently has no per-render way to switch the timer off.
|
|
92
|
+
|
|
93
|
+
## On the wire
|
|
94
|
+
|
|
95
|
+
The initial render (or `GET /_components/job_monitor`) — note the polling wiring already on the wrapper:
|
|
96
|
+
|
|
97
|
+
```html
|
|
98
|
+
<div id="job-monitor" hx-get="/_components/job_monitor" hx-trigger="every 1s" hx-swap="outerHTML">
|
|
99
|
+
<button hx-post="/_components/job_monitor/start" hx-target="#job-monitor"
|
|
100
|
+
hx-swap="outerHTML" hx-vals="{}">Start Job</button>
|
|
101
|
+
</div>
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Clicking Start issues `POST /_components/job_monitor/start`, and the response is the running state at 0%:
|
|
105
|
+
|
|
106
|
+
```html
|
|
107
|
+
<div id="job-monitor" hx-get="/_components/job_monitor" hx-trigger="every 1s" hx-swap="outerHTML">
|
|
108
|
+
<h3 id="pblabel">Running</h3>
|
|
109
|
+
<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100"
|
|
110
|
+
aria-valuenow="0" aria-labelledby="pblabel"
|
|
111
|
+
style="max-width:20rem; background:#e5e7eb; border-radius:4px">
|
|
112
|
+
<div class="progress-bar" style="width:0%; height:1.25rem; background:#2563eb; border-radius:4px; transition:width 1s linear"></div>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Each poll — `GET /_components/job_monitor`, once per second — returns the same shape further along (`aria-valuenow="20"`, `width:20%`, then 40, 60, 80…). The fifth poll finds the job complete:
|
|
118
|
+
|
|
119
|
+
```html
|
|
120
|
+
<div id="job-monitor" hx-get="/_components/job_monitor" hx-trigger="every 1s" hx-swap="outerHTML">
|
|
121
|
+
<h3 id="pblabel">Complete</h3>
|
|
122
|
+
<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100"
|
|
123
|
+
aria-valuenow="100" aria-labelledby="pblabel"
|
|
124
|
+
style="max-width:20rem; background:#e5e7eb; border-radius:4px">
|
|
125
|
+
<div class="progress-bar" style="width:100%; height:1.25rem; background:#2563eb; border-radius:4px; transition:width 1s linear"></div>
|
|
126
|
+
</div>
|
|
127
|
+
<button hx-post="/_components/job_monitor/start" hx-target="#job-monitor"
|
|
128
|
+
hx-swap="outerHTML" hx-vals="{}">Restart Job</button>
|
|
129
|
+
</div>
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Restart POSTs the same `:start` action and the cycle begins again. (The completed fragment still carries `hx-trigger="every 1s"` — the limitation noted above, visible on the wire.)
|
|
133
|
+
|
|
134
|
+
## Related
|
|
135
|
+
|
|
136
|
+
- [Live Ticker](live-ticker.md) — when the server should *push* updates instead of being polled.
|
|
137
|
+
- [`refreshes`](../dsl.md#refreshes--the-client-re-fetches) and [`performs`](../dsl.md#performs--user-initiated-actions) in the DSL reference.
|
|
138
|
+
- htmx's original polls every 600ms — `refreshes every: 0.6` expresses exactly that. This example keeps a 1-second cadence and matches the bar's `transition` duration to it.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Reset User Input
|
|
2
|
+
|
|
3
|
+
An add-a-comment form beneath a comment list: submit, and the new comment appears in the list while the form comes back empty, ready for the next thought. Nothing *resets* the form — a new one arrives.
|
|
4
|
+
|
|
5
|
+
This is Weft's take on [htmx's reset-user-input example](https://htmx.org/examples/reset-user-input/), though "take on" oversells it. htmx needs `hx-on::after-request="this.reset()"` because the form that posted is still sitting in the DOM afterwards, holding the user's text. A Weft action re-renders the whole component from server state, so the swapped-in form never had text in it to begin with. There's nothing to do — this page is about why.
|
|
6
|
+
|
|
7
|
+
## The components
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
GUEST_COMMENTS = [
|
|
11
|
+
{ author: "Rosa", body: "Lovely event — count me in for next year." }
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
class CommentSection < Weft::Component
|
|
15
|
+
builder_method :comment_section
|
|
16
|
+
|
|
17
|
+
attribute :author
|
|
18
|
+
attribute :body
|
|
19
|
+
|
|
20
|
+
performs :post do |attrs|
|
|
21
|
+
author = attrs.author.to_s.strip
|
|
22
|
+
body = attrs.body.to_s.strip
|
|
23
|
+
GUEST_COMMENTS << { author: author, body: body } unless author.empty? || body.empty?
|
|
24
|
+
{ author: nil, body: nil }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def build(attributes = {})
|
|
28
|
+
super
|
|
29
|
+
ul do
|
|
30
|
+
GUEST_COMMENTS.each do |comment|
|
|
31
|
+
li { strong "#{comment[:author]}: "; text_node comment[:body] }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
form(action: :post) do
|
|
35
|
+
div do
|
|
36
|
+
label "Name ", for: "author"
|
|
37
|
+
input type: "text", name: "author", id: "author"
|
|
38
|
+
end
|
|
39
|
+
div do
|
|
40
|
+
label "Comment ", for: "body"
|
|
41
|
+
input type: "text", name: "body", id: "body"
|
|
42
|
+
end
|
|
43
|
+
input type: "submit", value: "Add Comment"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
(The `GUEST_COMMENTS` array stands in for your data layer — swap in ActiveRecord or whatever your app uses.)
|
|
50
|
+
|
|
51
|
+
## How it works
|
|
52
|
+
|
|
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
|
+
|
|
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 attrs for the re-render ([the callable contract](../dsl.md#the-callable-contract)), and Weft derives a component's DOM id from its first declared attribute. 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
|
+
|
|
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
|
+
|
|
59
|
+
**No JavaScript, no reset handler.** `form(action: :post)` also emits plain `action`/`method` attributes, so without htmx the same POST works as a full-page form submit — and a freshly rendered page has empty fields for exactly the same reason the fragment does.
|
|
60
|
+
|
|
61
|
+
## On the wire
|
|
62
|
+
|
|
63
|
+
The initial render (or `GET /_components/comment_section`) — one comment in the list, the form's inputs carrying no `value` attributes:
|
|
64
|
+
|
|
65
|
+
```html
|
|
66
|
+
<div id="comment-section">
|
|
67
|
+
<ul>
|
|
68
|
+
<li><strong>Rosa: </strong>Lovely event — count me in for next year.</li>
|
|
69
|
+
</ul>
|
|
70
|
+
<form hx-post="/_components/comment_section/post" hx-target="#comment-section"
|
|
71
|
+
hx-swap="outerHTML" action="/_components/comment_section/post" method="post">
|
|
72
|
+
<div>
|
|
73
|
+
<label for="author">Name </label>
|
|
74
|
+
<input type="text" name="author" id="author"/>
|
|
75
|
+
</div>
|
|
76
|
+
<div>
|
|
77
|
+
<label for="body">Comment </label>
|
|
78
|
+
<input type="text" name="body" id="body"/>
|
|
79
|
+
</div>
|
|
80
|
+
<input type="submit" value="Add Comment"/>
|
|
81
|
+
</form>
|
|
82
|
+
</div>
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Typing a comment and submitting posts `author=Elena&body=See+you+there%21`, and the `200 OK` response is the same component with Elena's comment in the list — and the same valueless inputs as the initial render:
|
|
86
|
+
|
|
87
|
+
```html
|
|
88
|
+
<ul>
|
|
89
|
+
<li><strong>Rosa: </strong>Lovely event — count me in for next year.</li>
|
|
90
|
+
<li><strong>Elena: </strong>See you there!</li>
|
|
91
|
+
</ul>
|
|
92
|
+
<form ...>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
In a real browser the captured round trip reads the same: the POST body carries the typed values (`author=Browser%20Bot&body=Submitted%20by%20a%20real%20browser.`), and the post-swap DOM shows the new comment *and* empty inputs — the elements that held the typed text are gone, replaced by the response.
|
|
96
|
+
|
|
97
|
+
## Related
|
|
98
|
+
|
|
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 attributes.
|
|
101
|
+
- [`performs`](../dsl.md#performs--user-initiated-actions) and [the callable contract](../dsl.md#the-callable-contract) in the DSL reference.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Tabs
|
|
2
|
+
|
|
3
|
+
A row of tab buttons over a content panel. Clicking a tab fetches that tab's content from the server and swaps it into the panel — each tab is a component, and the server is the single source of truth for what's in it.
|
|
4
|
+
|
|
5
|
+
This is Weft's take on [htmx's server-driven tabs example](https://htmx.org/examples/tabs-hateoas/). htmx's catalog also carries a second, JavaScript-flavored tabs variant for keeping tab state purely client-side; in Weft that fork in the road doesn't arise — components are server-rendered by design, so *this* pattern is the answer to both.
|
|
6
|
+
|
|
7
|
+
## The components
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
class DescriptionTab < Weft::Component
|
|
11
|
+
builder_method :description_tab
|
|
12
|
+
routable!
|
|
13
|
+
|
|
14
|
+
def build(attributes = {})
|
|
15
|
+
super
|
|
16
|
+
para "The Fairweather field kit packs a tarp, a stove, and a first-aid pouch " \
|
|
17
|
+
"into a single roll-top bag. Everything clips to the outside webbing."
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class ShippingTab < Weft::Component
|
|
22
|
+
builder_method :shipping_tab
|
|
23
|
+
routable!
|
|
24
|
+
|
|
25
|
+
def build(attributes = {})
|
|
26
|
+
super
|
|
27
|
+
para "Orders placed before noon ship the same day. Standard delivery takes " \
|
|
28
|
+
"three to five business days; expedited arrives in two."
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class ReturnsTab < Weft::Component
|
|
33
|
+
builder_method :returns_tab
|
|
34
|
+
routable!
|
|
35
|
+
|
|
36
|
+
def build(attributes = {})
|
|
37
|
+
super
|
|
38
|
+
para "Unused kits can be returned within thirty days for a full refund. " \
|
|
39
|
+
"Field-tested kits can be exchanged for store credit."
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class ProductTabs < Weft::Component
|
|
44
|
+
builder_method :product_tabs
|
|
45
|
+
|
|
46
|
+
def build(attributes = {})
|
|
47
|
+
super
|
|
48
|
+
nav do
|
|
49
|
+
button "Description", tabs: DescriptionTab, target: "#product-tab-panel"
|
|
50
|
+
button "Shipping", tabs: ShippingTab, target: "#product-tab-panel"
|
|
51
|
+
button "Returns", tabs: ReturnsTab, target: "#product-tab-panel"
|
|
52
|
+
end
|
|
53
|
+
div id: "product-tab-panel" do
|
|
54
|
+
description_tab
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## How it works
|
|
61
|
+
|
|
62
|
+
**Every button aims at the same panel.** [`tabs:`](../dsl.md#shorthands) 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
|
+
|
|
64
|
+
**`routable!` makes contentful-but-stateless tabs fetchable.** A component normally earns its route by declaring attributes 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
|
+
|
|
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 — `attribute :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
|
+
|
|
68
|
+
## On the wire
|
|
69
|
+
|
|
70
|
+
The initial render — three wired buttons, and the panel holding the default tab:
|
|
71
|
+
|
|
72
|
+
```html
|
|
73
|
+
<div id="product-tabs">
|
|
74
|
+
<nav>
|
|
75
|
+
<button hx-get="/_components/description_tab" hx-swap="innerHTML"
|
|
76
|
+
hx-target="#product-tab-panel" hx-trigger="click">Description</button>
|
|
77
|
+
<button hx-get="/_components/shipping_tab" hx-swap="innerHTML"
|
|
78
|
+
hx-target="#product-tab-panel" hx-trigger="click">Shipping</button>
|
|
79
|
+
<button hx-get="/_components/returns_tab" hx-swap="innerHTML"
|
|
80
|
+
hx-target="#product-tab-panel" hx-trigger="click">Returns</button>
|
|
81
|
+
</nav>
|
|
82
|
+
<div id="product-tab-panel">
|
|
83
|
+
<div id="description-tab">
|
|
84
|
+
<p>The Fairweather field kit packs a tarp, a stove, and a first-aid pouch into a single roll-top bag. Everything clips to the outside webbing.</p>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Clicking Shipping issues `GET /_components/shipping_tab`, and the response fills the panel:
|
|
91
|
+
|
|
92
|
+
```html
|
|
93
|
+
<div id="shipping-tab">
|
|
94
|
+
<p>Orders placed before noon ship the same day. Standard delivery takes three to five business days; expedited arrives in two.</p>
|
|
95
|
+
</div>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Related
|
|
99
|
+
|
|
100
|
+
- [Active Search](active-search.md) — the same fill-a-stable-container shape, driven by typing instead of clicks.
|
|
101
|
+
- [`abstract!` and `routable!`](../routing.md#abstract-and-routable) in the routing reference.
|
|
102
|
+
- The [shorthands table](../dsl.md#shorthands) in the DSL reference.
|