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,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.