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
data/docs/dsl.md ADDED
@@ -0,0 +1,329 @@
1
+ # The Weft DSL
2
+
3
+ A Weft component describes its interactive behavior in two layers. **Class-body declarations** — the verbs — state what the component does: it refreshes on a timer, it performs an action, it recovers from an error. **Element kwargs**, used inside `build`, wire individual elements to those behaviors: this button performs the `:cancel` action, this div loads a tooltip on hover. Both layers compile down to auto-generated routes and htmx attributes; you write neither by hand. (The HTML itself — the `build` method and everything inside it — is [Arbre](arbre.md), documented separately.)
4
+
5
+ ```ruby
6
+ class DeliveryStatus < Weft::Component
7
+ attribute :delivery_id # wire state
8
+
9
+ refreshes every: 5.seconds # verb: live updates
10
+
11
+ performs :cancel do |attrs| # verb: user-initiated action
12
+ CancelDelivery.call(Delivery.find(attrs.delivery_id))
13
+ end
14
+
15
+ def build(attributes = {})
16
+ super
17
+ delivery = Delivery.find(attrs.delivery_id)
18
+ span "Arriving #{delivery.eta.humanize}"
19
+ button "Cancel", action: :cancel # element kwarg: wires to the verb
20
+ end
21
+ end
22
+ ```
23
+
24
+ **In this document:**
25
+
26
+ - [Attributes](#attributes)
27
+ - [Verbs](#verbs)
28
+ - [`refreshes` — the client re-fetches](#refreshes--the-client-re-fetches)
29
+ - [`pushes` — the server sends updates](#pushes--the-server-sends-updates)
30
+ - [`performs` — user-initiated actions](#performs--user-initiated-actions) and [the callable contract](#the-callable-contract)
31
+ - [`transfers` — actions that render something else](#transfers--actions-that-render-something-else)
32
+ - [`dismisses` — remove from the DOM](#dismisses--remove-from-the-dom)
33
+ - [`triggers` — announce to the rest of the page](#triggers--announce-to-the-rest-of-the-page)
34
+ - [`includes` — companions in the same response](#includes--companions-in-the-same-response)
35
+ - [`recovers` — declare error behavior](#recovers--declare-error-behavior)
36
+ - [Other class-body declarations](#other-class-body-declarations)
37
+ - [Element kwargs](#element-kwargs): [`action:`](#action), [`navigate:`](#navigate), [`loads:`](#loads), [`trigger:`](#trigger), [`push_url:`](#push_url) — plus the [swap](#swap-values), [trigger](#trigger-values), and [target](#targets) value tables
38
+ - [Shorthands](#shorthands)
39
+
40
+ ## Attributes
41
+
42
+ ```ruby
43
+ attribute :status, default: "active"
44
+ attribute :page, default: 1
45
+ ```
46
+
47
+ Attributes are a component's *wire state* — the values that identify what this particular instance shows, small enough to travel in a URL. When the component renders inside a page, attributes come from the rendering call (`orders_panel(status: "shipped")`); when it renders over the wire — a refresh, an action, an SSE push — they come from request parameters. Either way, `build` and action callables see the same resolved values.
48
+
49
+ Wire values arrive as strings, so Weft coerces them based on each attribute's default: an `Integer` default coerces with `to_i`, a `Float` with `to_f`, and a `true`/`false` default maps `"true"` and `"1"` to `true` (anything else to `false`). Attributes with other defaults (strings, `nil`) pass through untouched. A `type:` kwarg is accepted on `attribute` but reserved for future use — today, the default *is* the type declaration.
50
+
51
+ Inside the component, `attrs` returns the resolved values with method-style access:
52
+
53
+ ```ruby
54
+ attrs.status # => "shipped"
55
+ attrs.page # => 2 (an Integer — coerced)
56
+ attrs[:status] # explicit hash-style access
57
+ attrs.to_h # the underlying hash
58
+ ```
59
+
60
+ Declared attribute names always win over hash methods — if you declare `attribute :count`, `attrs.count` is your value, not `Hash#count`. For anything not declared, the hash API is available directly on `attrs`.
61
+
62
+ Declaring attributes has a routing consequence: a component with attributes (or any verb below) is considered independently addressable and gets its own route. See [Routing](routing.md).
63
+
64
+ ## Verbs
65
+
66
+ ### `refreshes` — the client re-fetches
67
+
68
+ ```ruby
69
+ refreshes every: 10.seconds # poll on a timer
70
+ refreshes every: 0.6 # sub-second polling ("every 600ms")
71
+ refreshes on: "order-updated" # re-fetch when an event fires
72
+ refreshes every: 30, on: "saved" # both
73
+ ```
74
+
75
+ The component's wrapper element gets the htmx wiring to GET its own route and replace itself with the response (`outerHTML` swap). With `every:`, that happens on a timer. With `on:`, it happens whenever the named event fires — typically emitted by some other component's `triggers` declaration, arriving as an `HX-Trigger` response header and listened for at the body level, so any component on the page can react to any other's events.
76
+
77
+ Multiple `refreshes` calls accumulate into a single trigger list. Because the wiring is declared on the class, it's present both in the initial page render *and* in every refreshed fragment — the component keeps refreshing forever, with nothing duplicated by hand.
78
+
79
+ Intervals count in seconds — an integer, a float, or an ActiveSupport duration. Whole seconds render as htmx's `every 5s`; fractional values render in millisecond syntax (`every 600ms`). One millisecond is the floor: anything smaller is rounded up to `1ms`, with a warning through `Weft.logger`.
80
+
81
+ ### `pushes` — the server sends updates
82
+
83
+ ```ruby
84
+ pushes every: 5.seconds
85
+ ```
86
+
87
+ Where `refreshes` polls, `pushes` streams: the Router auto-generates an SSE endpoint for the component (at `<component path>/_stream` — see [Routing](routing.md)), and the component renders with the htmx SSE attributes to connect to it. On the declared interval — seconds, fractional or whole, with the same 1ms floor as `refreshes` — the server re-renders the component and pushes the result down the open connection.
88
+
89
+ A new subscriber receives an immediate snapshot frame, then the regular cadence. Pushed frames swap into the component's *interior* (`innerHTML`) — the wrapper element holds the SSE connection, so it must persist across updates.
90
+
91
+ Pages include the htmx SSE extension script automatically when any component declares `pushes` (the [`include_sse_ext`](configuration.md#include_sse_ext) setting).
92
+
93
+ ### `performs` — user-initiated actions
94
+
95
+ ```ruby
96
+ performs :advance do |attrs|
97
+ order = Oms::Order.find(attrs.order_id)
98
+ Oms::AdvanceOrder.call(order)
99
+ end
100
+ ```
101
+
102
+ Declares an action: the Router generates a route for it, and elements wire to it with the `action:` kwarg (below). When the request arrives, the callable runs, then the component re-renders and the response replaces it in the page.
103
+
104
+ The full signature:
105
+
106
+ ```ruby
107
+ performs :name, method: :post, swap: :outer_html, target: nil do |attrs| ... end
108
+ ```
109
+
110
+ - **`method:`** — the HTTP method (default `:post`). A *named* action routes at `<component path>/<name>`; a *nameless* one (`performs method: :delete do ... end`) routes at the component's own path, distinguished by method. A nameless GET action is special: it intercepts the component's own render route, running the callable before every over-the-wire render.
111
+ - **`swap:`** — how the response lands in the DOM (default `:outer_html`, replacing the component). See the [swap table](#swap-values).
112
+ - **`target:`** — a CSS selector for where the response lands (default: the component itself, by DOM id).
113
+
114
+ ### The callable contract
115
+
116
+ Action callables receive one argument — the component's resolved `attrs` — and their return value directs what happens next:
117
+
118
+ - **`nil`** (or any ignored value): re-render with the original attrs. The common case — the callable did its side effect; the fresh render reflects it.
119
+ - **a `Hash`**: merged into the attrs (returned keys win), and the merged set drives the re-render. Use this to change state on the way through: `performs :filter do |attrs| { page: 1 } end`.
120
+ - **a `Weft::Redirect`**: navigate away instead of re-rendering. Build one with `Weft.redirect`:
121
+
122
+ ```ruby
123
+ performs :create do |attrs|
124
+ order = Oms::CreateOrder.call(attrs.to_h)
125
+ Weft.redirect(OrderDetailPage, order_id: order.id)
126
+ end
127
+ ```
128
+
129
+ `Weft.redirect` takes a `Weft::Page` subclass plus attrs (interpolated into the page's path pattern), or a plain URL string. The Router handles transport: htmx requests get an `HX-Redirect` header, traditional form submissions get a 302.
130
+
131
+ If the callable raises, the error walks the component's recovery chain — see [Error handling](error-handling.md).
132
+
133
+ ### `transfers` — actions that render something else
134
+
135
+ ```ruby
136
+ transfers :edit, to: EditableOrderHeader do |attrs|
137
+ { mode: "full" }
138
+ end
139
+ ```
140
+
141
+ Identical to `performs` in signature and contract, except the response renders the `to:` component instead of the declaring one — for actions whose natural result is a different piece of UI (a read-only header becoming an edit form). The merged attrs feed the target component. The target only needs to *render*; it does not need its own route (see [routability vs. render targets](routing.md#routable-vs-render-target)).
142
+
143
+ ### `dismisses` — remove from the DOM
144
+
145
+ ```ruby
146
+ dismisses :close # no side effects
147
+ dismisses :archive do |attrs| # with side effects
148
+ Item.find(attrs.item_id).archive!
149
+ end
150
+ ```
151
+
152
+ Sugar for `performs` with `method: :delete, swap: :delete`: on success, the component is removed from the page entirely. The callable, if given, runs for side effects. If it raises, Weft overrides the destructive swap (via `HX-Reswap`) so the error rendering appears where the component was, rather than the element silently vanishing.
153
+
154
+ ### `triggers` — announce to the rest of the page
155
+
156
+ ```ruby
157
+ triggers "delivery-completed"
158
+ ```
159
+
160
+ Every action response from this component carries the named event in its `HX-Trigger` header. Other components subscribe with `refreshes on: "delivery-completed"` — a decoupled way to say "when this changes, those refresh," without the components knowing about each other. Multiple `triggers` declarations accumulate.
161
+
162
+ ### `includes` — companions in the same response
163
+
164
+ ```ruby
165
+ includes Oms::OrderHeader # alongside every response
166
+ includes Oms::OrderHeader, on: :advance # only for the :advance action
167
+ includes Oms::OrderHeader do |attrs| # with explicit attr mapping
168
+ { order_id: attrs.order_id, compact: true }
169
+ end
170
+ ```
171
+
172
+ Sometimes one interaction changes two things: completing a shipment updates the shipment card *and* the order header above it. `includes` declares that relationship — whenever this component responds to an action or pushes an SSE frame, the included component renders too, marked out-of-band (`hx-swap-oob`) so htmx routes it to its own DOM slot by id.
173
+
174
+ Without a block, the included component resolves its attributes from the same request parameters. With a block, the block receives the primary component's resolved attrs and returns the wire attrs for the included one. With `on:`, the inclusion applies only to that named action (and not to SSE pushes; unfiltered inclusions apply to both).
175
+
176
+ ### `recovers` — declare error behavior
177
+
178
+ ```ruby
179
+ recovers from: Weft::Unprocessable do |attrs, error|
180
+ { error_message: error.message }
181
+ end
182
+ recovers from: Weft::Unauthorized, with: LoginPage
183
+ ```
184
+
185
+ Declares how this component or page responds when a render or action raises. `from:` matches by exception class, HTTP status code, status range, or an array of those; `with:` names what renders instead. The gem ships default recoveries, so this is opt-in refinement. The complete model — matching, chain order, auto-injected attributes — is in [Error handling](error-handling.md).
186
+
187
+ ### Other class-body declarations
188
+
189
+ **`adds_children_to :@ivar`** — generates the standard container pattern: children added from a caller's block go into the named element rather than the wrapper, while the component's own structural elements (built during `build`) land normally.
190
+
191
+ ```ruby
192
+ class Card < Weft::Component
193
+ adds_children_to :@body
194
+
195
+ def build(attributes = {})
196
+ super
197
+ h2 "Header" # structural — goes to the wrapper
198
+ @body = div(class: "card-body") # caller's block content goes here
199
+ end
200
+ end
201
+ ```
202
+
203
+ The leading `@` in the symbol is required, as a reminder that *you* must assign that instance variable somewhere in `build` — if `build` finishes without assigning it and a child then arrives, Weft raises a pointed error rather than silently misplacing content. The underlying mechanics (and when to hand-roll instead) are in [Arbre: the HTML layer](arbre.md#receiving-caller-content).
204
+
205
+ **`abstract!` / `routable!`** — override the class's routing eligibility in either direction. Covered in [Routing](routing.md#abstract-and-routable).
206
+
207
+ ## Element kwargs
208
+
209
+ Inside `build` (and inside blocks nested under it), any element accepts Weft kwargs alongside its normal HTML attributes. Weft intercepts them at render time and expands them into htmx wiring.
210
+
211
+ ### `action:`
212
+
213
+ ```ruby
214
+ button "Advance", action: :advance, class: "btn btn-primary"
215
+ ```
216
+
217
+ Wires the element to a declared `performs`/`transfers` action on the nearest enclosing component that declares it. Expands to the full htmx set: the request (`hx-post` etc. to the action's route), the target (the component's own element, unless the action declared `target:`), the swap, and the component's current attrs as the payload (`hx-vals`).
218
+
219
+ On a `form` element, `action:` additionally emits plain HTML `action` and `method` attributes, so the form still submits without JavaScript — and the field values themselves become the payload:
220
+
221
+ ```ruby
222
+ form(action: :create) do
223
+ input(type: "text", name: "customer_name")
224
+ input(type: "submit", value: "Create")
225
+ end
226
+ ```
227
+
228
+ ### `navigate:`
229
+
230
+ ```ruby
231
+ button "Next", navigate: { page: attrs.page + 1 }
232
+ ```
233
+
234
+ Re-fetches the enclosing component with some of its attrs changed — a GET to the component's own route with the overridden values, replacing the component. This is the idiom for filters, sorting, and pagination: same component, different wire state. Pass `nil` to drop an attr from the URL. Pairs naturally with `push_url:` when the new state should be reflected in the address bar.
235
+
236
+ ### `loads:`
237
+
238
+ ```ruby
239
+ button "Show manifest", loads: Logistics::ShipmentManifest,
240
+ with: { shipment_id: shipment.id },
241
+ swap: :fill, target: "#detail-pane"
242
+ ```
243
+
244
+ Loads a *different* component into a chosen DOM location on click (or whatever `trigger:` you add). `swap:` and `target:` are required — `loads:` is the fully-explicit primitive underneath the [shorthands](#shorthands), which exist to fill those in for common patterns. `with:` supplies the target component's wire attrs; omitted, it defaults to the enclosing component's current attrs.
245
+
246
+ ### `trigger:`
247
+
248
+ ```ruby
249
+ div(loads: Preview, with: { id: id }, swap: :fill, target: :self,
250
+ trigger: :visible)
251
+ ```
252
+
253
+ Sets when the element's request fires. Accepts the semantic symbols in the [trigger table](#trigger-values) or any raw [htmx trigger string](https://htmx.org/attributes/hx-trigger/) for full control (`"mouseenter once from:closest .card"`). Works standalone or alongside `action:` / `navigate:` / `loads:` / a shorthand.
254
+
255
+ ### `push_url:`
256
+
257
+ ```ruby
258
+ button label, action: :filter, push_url: "/orders?status=#{status}"
259
+ ```
260
+
261
+ Pushes a URL into the browser's address bar when the request completes, keeping the location shareable and the back button meaningful. Pass the URL string, or `true` to push the request's own URL.
262
+
263
+ ### Swap values
264
+
265
+ Weft accepts semantic swap names (preferred), the htmx-native names as symbols, or any raw string:
266
+
267
+ | Semantic | htmx equivalent | Effect |
268
+ | --- | --- | --- |
269
+ | `:replace` | `outerHTML` | Replace the target element entirely |
270
+ | `:fill` | `innerHTML` | Replace the target's contents |
271
+ | `:before` | `beforebegin` | Insert before the target |
272
+ | `:prepend` | `afterbegin` | Insert at the start of the target |
273
+ | `:append` | `beforeend` | Insert at the end of the target |
274
+ | `:after` | `afterend` | Insert after the target |
275
+ | `:remove` | `delete` | Remove the target |
276
+ | `:none` | `none` | Don't swap anything |
277
+
278
+ ### Trigger values
279
+
280
+ | Semantic | htmx equivalent | Fires… |
281
+ | --- | --- | --- |
282
+ | `:click` | `click` | on click |
283
+ | `:hover` | `mouseenter once` | on first hover |
284
+ | `:visible` | `revealed` | when scrolled into view |
285
+ | `:input` | `input changed delay:300ms` | as the user types, debounced |
286
+
287
+ ### Targets
288
+
289
+ Wherever a `target:` is accepted: `:self` targets the element itself, a string is a CSS selector passed through to htmx (including forms like `"closest tr"`), and an Arbre element reference targets that element by its id. In verb declarations (`performs`/`transfers`), only the selector-string form applies — `:self` and element references describe elements, which don't exist yet at class-declaration time.
290
+
291
+ ## Shorthands
292
+
293
+ Shorthands are named presets over the `loads:` machinery — one kwarg that says what the interaction *is*, with the trigger and swap details baked in:
294
+
295
+ ```ruby
296
+ button "▸", inline_expand: Oms::OrderInlineDetail,
297
+ with: { order_id: order.id },
298
+ target: "closest tr"
299
+ ```
300
+
301
+ The kwarg's value is the component class to load (`with:` supplies its attrs, same as `loads:`). The gem ships these presets:
302
+
303
+ | Shorthand | Trigger | Swap | Target | Example |
304
+ | --- | --- | --- | --- | --- |
305
+ | `tooltip:` | `:hover` | `:fill` | supply `target:` | [Tooltip](examples/tooltip.md) |
306
+ | `inline_expand:` | `:click` | `:after` | supply `target:` | [Inline Expansion](examples/inline-expansion.md) |
307
+ | `lazy:` | `:visible` | `:fill` | `:self` | [Lazy Loading](examples/lazy-loading.md) |
308
+ | `modal:` | `:click` | `:fill` | supply `target:` | [Modal Dialog](examples/modal-dialog.md) |
309
+ | `load_more:` | `:click` | `:replace` | `:self` | [Click to Load](examples/click-to-load.md) |
310
+ | `infinite_scroll:` | `:visible` | `:after` | supply `target:` | [Infinite Scroll](examples/infinite-scroll.md) |
311
+ | `live_search:` | `:input` | `:fill` | supply `target:` | [Active Search](examples/active-search.md) |
312
+ | `tabs:` | `:click` | `:fill` | supply `target:` | [Tabs](examples/tabs.md) |
313
+ | `retry:` | `:click` | `:replace` | `closest .weft-error` | — |
314
+
315
+ Where the table says "supply `target:`", the preset has no universally-right answer for where the content lands, so the call site provides it (omitting it raises immediately, with a message saying so). Explicit `swap:` and `target:` kwargs always override the preset.
316
+
317
+ `retry:` is the odd one out: its value is a **URL string** rather than a component class — the failing component's own GET URL, as injected into error components via the `:retry_url` recovery attribute (see [Error handling](error-handling.md)). Its baked-in target replaces the enclosing `.weft-error` box with the freshly-rendered component:
318
+
319
+ ```ruby
320
+ button "Retry", retry: attrs.retry_url
321
+ ```
322
+
323
+ ### Registering your own
324
+
325
+ ```ruby
326
+ Weft.register_shorthand :paginate, trigger: :click, swap: :replace
327
+ ```
328
+
329
+ A registration names the preset and provides any of `trigger:`, `swap:`, and `target:`. From then on, `paginate:` works as an element kwarg everywhere — same machinery, your vocabulary. Naming interactions after their intent keeps call sites readable: `button "Next", paginate: OrdersPanel, with: { page: 2 }` says more than the four htmx attributes it expands to.
@@ -0,0 +1,136 @@
1
+ # Error handling
2
+
3
+ In a component-oriented UI, an error is part of the interface. When one component's render or action raises, the right outcome is usually a visible error state *in that component's place* — not a dead button, not a blank region, and certainly not a whole-page crash. Weft's error handling is built around that idea: exceptions map to renderable fallbacks through a declarative chain, with sensible defaults at every level, so an unhandled error always lands somewhere visible.
4
+
5
+ Two layers cooperate to make this work. On the server, the Router catches errors and renders a recovery target instead. On the client, `Weft::Page` configures htmx to swap error responses into the page (by default htmx discards them) — which is why a failing component shows its error box right where the component was.
6
+
7
+ **In this document:**
8
+
9
+ - [The error classes](#the-error-classes)
10
+ - [The `recovers` chain](#the-recovers-chain) — matching, targets, blocks, and [the built-in edges](#the-built-in-edges)
11
+ - [What happens when something raises](#what-happens-when-something-raises)
12
+ - [Auto-injected recovery attributes](#auto-injected-recovery-attributes)
13
+ - [Presentation settings](#presentation-settings)
14
+
15
+ ## The error classes
16
+
17
+ Weft ships a small semantic hierarchy rooted at `Weft::Error`:
18
+
19
+ | Class | Status | Meaning |
20
+ | --- | --- | --- |
21
+ | `Weft::Error` | — | Abstract root. Never raised directly; `rescue Weft::Error` catches the whole family. |
22
+ | `Weft::HTTPError` | — | Abstract intermediate for errors that carry an HTTP status. |
23
+ | `Weft::NotFound` | 404 | The thing addressed doesn't exist. |
24
+ | `Weft::Unauthorized` | 401 | Authentication required. |
25
+ | `Weft::Forbidden` | 403 | Authenticated, but not allowed. |
26
+ | `Weft::Unprocessable` | 422 | The request was understood but can't be acted on — validation failures, mostly. |
27
+ | `Weft::InternalError` | 500 | An explicit "we broke" signal. |
28
+
29
+ Raise these from your `build` methods and action callables to communicate outcomes with the right status semantics: `raise Weft::NotFound` when a record lookup comes up empty, `raise Weft::Unprocessable` when validation fails. Errors that aren't `Weft::HTTPError`s — an unrescued `ActiveRecord::RecordNotFound`, a `NoMethodError` — are treated as status 500.
30
+
31
+ A separate branch of the family reports *your* mistakes to you, raised at definition or configuration time rather than during request handling: `Weft::InvalidConfiguration` (a bad value inside `Weft.configure`), `Weft::InvalidDefinition` (a bad class-body declaration, including route collisions), and `Weft::InvalidUsage` (a bad call at render time). These are meant to fail loudly during development, not to be recovery targets.
32
+
33
+ ## The `recovers` chain
34
+
35
+ Components and pages declare how they handle errors with `recovers`:
36
+
37
+ ```ruby
38
+ class OrderEditor < Weft::Component
39
+ recovers from: Weft::Unprocessable do |attrs, error|
40
+ { error_message: error.message }
41
+ end
42
+ recovers from: Weft::Unauthorized, with: LoginPage
43
+ end
44
+ ```
45
+
46
+ Each declaration is an edge: *when this kind of error escapes me, render that instead.* The pieces:
47
+
48
+ **`from:`** decides whether an edge matches a given exception. It accepts:
49
+
50
+
51
+ - a **Class** — matches that exception class and its subclasses (`from: Weft::HTTPError` catches the whole status-bearing family);
52
+ - an **Integer** — matches by HTTP status (`from: 404`);
53
+ - a **Range** — matches statuses in the range (`from: 500..599`);
54
+ - an **Array** of any of the above — matches if any element does.
55
+
56
+ **`with:`** names the recovery target — what renders in place of the failure. It accepts a component or page class, or a symbol naming a [configuration knob](configuration.md#the-four-fallback-targets) (`with: :error_component`), resolved at error-handling time so reconfiguration propagates. Omitted, it defaults to the declaring class itself — "on this error, re-render me" — which pairs naturally with a block that adjusts attrs.
57
+
58
+ **The block**, if given, receives `(attrs, error)` — the same resolved attrs an action callable sees, plus the exception — and returns a hash merged into the attrs the recovery target renders with (returned keys win). It's for *carrying information onto the error rendering*, like the validation messages above; it never returns HTML.
59
+
60
+ Edges are consulted in a defined order: a class's own declarations first (in declaration order), then its ancestors' — so subclass declarations beat inherited ones, and within a class, first match wins. Put more-specific edges before catch-alls.
61
+
62
+ ### The built-in edges
63
+
64
+ `Weft::Component` and `Weft::Page` each ship two edges, which is why error handling works before you've declared anything:
65
+
66
+ ```ruby
67
+ # on Weft::Component
68
+ recovers from: Weft::NotFound, with: :not_found_component
69
+ recovers from: StandardError, with: :error_component
70
+
71
+ # on Weft::Page
72
+ recovers from: Weft::NotFound, with: :not_found_page
73
+ recovers from: StandardError, with: :error_page
74
+ ```
75
+
76
+ The symbols resolve through `Weft.configuration`, so [reassigning those knobs](configuration.md#the-four-fallback-targets) rebrands the defaults app-wide. Because these live on the base classes, any edge you declare on your own class takes precedence.
77
+
78
+ ## What happens when something raises
79
+
80
+ **In component context** — a fragment render, an action, an SSE frame — the Router walks the failing component's chain and renders the matched target as a fragment, with the response status taken from the exception (`Weft::HTTPError#status`, else 500). On the client, the fragment swaps in where the component's response would have gone, so the error appears exactly where the problem is. If the matched target is a *page* class, the recovery becomes a redirect to that page instead (`HX-Redirect` for htmx requests, 302 otherwise) — the `with: LoginPage` pattern above.
81
+
82
+ One wrinkle worth knowing: for actions with a destructive swap (`dismisses`, or any `performs` with `swap: :delete`), a successful response removes the element — which would make an error invisible. Weft overrides the swap on error responses (via `HX-Reswap`) so the error rendering replaces the component instead of vanishing with it.
83
+
84
+ **In page context** — a full-document render, or a request no route matched — the Router walks the page's chain (for routing misses, the base `Weft::Page` chain, which lands on the not-found page). A traditional request gets the recovery page as a complete document; an htmx request gets just the page's body content, since the document shell is already on the client.
85
+
86
+ **If the recovery itself raises** — a bug in your error component, say — Weft stops walking and emits a minimal hardcoded error rendering, logging the recovery failure and surfacing the *original* error. There is always a floor; error handling never recurses into itself.
87
+
88
+ Errors during SSE pushes don't kill the stream: the frame is skipped, the error logged, and pushing resumes on the next interval.
89
+
90
+ ## Auto-injected recovery attributes
91
+
92
+ A recovery target usually wants context: what failed, where, with what status. The Router offers five values, injected **schema-gated**: each is passed only if the target *declares an attribute of that name*. Declaring the attribute is the opt-in; anything not declared is never injected, so nothing leaks into renders (or URLs) uninvited.
93
+
94
+ | Attribute | Value |
95
+ | --- | --- |
96
+ | `:exception` | The exception object itself. |
97
+ | `:request_path` | The path of the failing request. |
98
+ | `:status_code` | The resolved HTTP status (the exception's, or 500). |
99
+ | `:component_id` | The failing component's DOM id. |
100
+ | `:retry_url` | A GET URL that re-renders the failing component with its current attrs. |
101
+
102
+ So a custom error component opts in by declaration:
103
+
104
+ ```ruby
105
+ class MyApp::ErrorComponent < Weft::Component
106
+ abstract!
107
+
108
+ attribute :exception
109
+ attribute :retry_url
110
+
111
+ def build(attributes = {})
112
+ super
113
+ add_class "weft-error"
114
+ div { text_node "Something went wrong." }
115
+ div @attrs.exception.message if Weft.configuration.verbose_error_pages
116
+ button "Retry", retry: @attrs.retry_url if @attrs.retry_url
117
+ end
118
+ end
119
+ ```
120
+
121
+ Notes on the individual values:
122
+
123
+ - **These five names are reserved** on any class used as a recovery target. Declaring an attribute with one of these names *means* "inject the recovery value here" — so don't reuse them for your own data on error components, or on any component/page reachable through a `recovers` edge.
124
+ - **`:component_id`** preserves DOM identity: render your error wrapper with it as the element id (the gem's defaults do) and the error lands under the failing component's original id — so multiple simultaneous failures each swap into their own slot rather than colliding.
125
+ - **`:retry_url`** feeds the [`retry:` shorthand](dsl.md#shorthands): one button attribute, and the user can re-request the failed component in place. For a failed *action*, the URL renders the underlying component's view — a fresh look, not a replay of the failed action.
126
+ - When a recovery resolves to a **redirect** (page target from component context), only `:request_path` and `:status_code` travel — the others have no meaning in a URL.
127
+ - Keep the `weft-error` CSS class on custom error components: it's the DOM marker the `retry:` shorthand targets, and a useful styling hook besides.
128
+
129
+ ## Presentation settings
130
+
131
+ Two configuration settings shape how the built-in fallbacks present; both are covered in detail in [Configuration](configuration.md#error-handling):
132
+
133
+ - [`verbose_error_pages`](configuration.md#verbose_error_pages) — whether the gem defaults show exception class/message and the failing path (turn off in production).
134
+ - [`htmx_errors`](configuration.md#htmx_errors) — whether htmx-request errors falling through to the gem defaults render in place (`:fragment`) or navigate to the error page (`:redirect`). Your own `recovers` edges are never affected, and 404s always render in place.
135
+
136
+ > **v0.1 limitation:** custom `recovers from: Weft::NotFound` declarations are not yet reliably honored — the gem-default not-found rendering can take over the response. To customize not-found presentation in v0.1, assign the [`not_found_page` / `not_found_component` knobs](configuration.md#the-four-fallback-targets), which are fully supported. First-class custom `NotFound` recoveries land in v0.2.
@@ -0,0 +1,70 @@
1
+ # Examples
2
+
3
+ If you've ever thought *"this button should be right here, and pressing it should just do the thing"* — that's the instinct Weft is built around. Each example on these pages starts from something a user should be able to do, and shows the component that does it: the markup, the behavior, and the server-side logic in one place, because in Weft they *are* one place.
4
+
5
+ Every example is complete and self-contained — a small data stub stands in for your real data layer, and the code shown is the code that ran. The "On the wire" sections quote real captured requests and responses, so you can see exactly what travels.
6
+
7
+ ## The catalog
8
+
9
+ | Example | What it shows |
10
+ | --- | --- |
11
+ | [Click to Edit](click-to-edit.md) | Swap a read-only view for an edit form in place — `loads:` + `transfers` |
12
+ | [Edit Row](edit-row.md) | The same pattern per table row |
13
+ | [Delete Row](delete-row.md) | Remove a row with a confirmation — `dismisses` |
14
+ | [Bulk Update](bulk-update.md) | One form updating many rows — `performs` + array params |
15
+ | [Inline Validation](inline-validation.md) | Per-field validation as the user types — `performs` + `recovers` |
16
+ | [File Upload](file-upload.md) | Multipart upload through a component action |
17
+ | [Reset User Input](reset-user-input.md) | Clearing a form after submit — free in Weft |
18
+ | [Click to Load](click-to-load.md) | Load the next page of rows on demand — `load_more:` |
19
+ | [Lazy Loading](lazy-loading.md) | Defer expensive content until it's visible — `lazy:` |
20
+ | [Infinite Scroll](infinite-scroll.md) | Rows that keep coming as you scroll — `infinite_scroll:` |
21
+ | [Inline Expansion](inline-expansion.md) | Expand a row's detail in place — `inline_expand:` |
22
+ | [Active Search](active-search.md) | Search-as-you-type — `live_search:` |
23
+ | [Value Select](value-select.md) | Cascading selects — one select repopulating another |
24
+ | [Tabs](tabs.md) | Server-driven tab panes — `tabs:` |
25
+ | [Tooltip](tooltip.md) | Lazy-loaded hover detail — `tooltip:` |
26
+ | [Modal Dialog](modal-dialog.md) | Open a modal, close it, no JavaScript — `modal:` + `dismisses` |
27
+ | [Browser Dialogs](browser-dialogs.md) | Native confirm/prompt guards on actions |
28
+ | [Keyboard Shortcuts](keyboard-shortcuts.md) | Key-driven actions via `trigger:` |
29
+ | [Progress Bar](progress-bar.md) | A job-runner progress bar — `refreshes every:` |
30
+ | [Live Ticker](live-ticker.md) | Server-pushed updates over SSE — `pushes every:` |
31
+ | [Updating Other Content](updating-other-content.md) | One action updating several regions — `includes` + `triggers` |
32
+
33
+ ## Coming from htmx?
34
+
35
+ This catalog deliberately covers the ground of [htmx's examples](https://htmx.org/examples/) — if you know a pattern from there, its Weft answer is here. Three of the pages above (Tooltip, Inline Expansion, Live Ticker) have no htmx counterpart; everything in htmx's catalog maps as follows:
36
+
37
+ | htmx example | Weft's answer |
38
+ | --- | --- |
39
+ | Click To Edit | [Click to Edit](click-to-edit.md) |
40
+ | Bulk Update | [Bulk Update](bulk-update.md) |
41
+ | Click To Load | [Click to Load](click-to-load.md) |
42
+ | Delete Row | [Delete Row](delete-row.md) |
43
+ | Edit Row | [Edit Row](edit-row.md) |
44
+ | Lazy Loading | [Lazy Loading](lazy-loading.md) |
45
+ | Inline Validation | [Inline Validation](inline-validation.md) |
46
+ | Infinite Scroll | [Infinite Scroll](infinite-scroll.md) |
47
+ | Active Search | [Active Search](active-search.md) |
48
+ | Progress Bar | [Progress Bar](progress-bar.md) |
49
+ | Value Select | [Value Select](value-select.md) |
50
+ | File Upload | [File Upload](file-upload.md) — the upload itself; the JS-driven progress meter is out of scope |
51
+ | Preserving File Inputs | not ported — a browser constraint htmx works around with custom JS; re-select files after a failed submit |
52
+ | Reset User Input | [Reset User Input](reset-user-input.md) |
53
+ | Dialogs — Browser | [Browser Dialogs](browser-dialogs.md) |
54
+ | Dialogs — UIKit | see [Modal Dialog](modal-dialog.md); CSS-framework integrations are future work |
55
+ | Dialogs — Bootstrap | same |
56
+ | Dialogs — Custom | [Modal Dialog](modal-dialog.md) — and Weft's needs no hyperscript |
57
+ | Tabs (HATEOAS) | [Tabs](tabs.md) |
58
+ | Tabs (JavaScript) | [Tabs](tabs.md) — the server-driven variant *is* the Weft way |
59
+ | Keyboard Shortcuts | [Keyboard Shortcuts](keyboard-shortcuts.md) |
60
+ | Sortable (drag & drop) | not ported — requires Sortable.js; client-side JS integration is outside this catalog's scope |
61
+ | Updating Other Content | [Updating Other Content](updating-other-content.md) — declarative, where htmx offers four manual options |
62
+ | Confirm (custom dialog) | not ported — requires sweetalert2; the no-JS answer is [Browser Dialogs](browser-dialogs.md) |
63
+ | Async Authentication | not ported — client-side token handling, outside this catalog's scope |
64
+ | Web Components | not ported — shadow-DOM integration, outside this catalog's scope |
65
+ | Animations | not ported yet — htmx's swap/settle CSS classes work unchanged under Weft |
66
+ | moveBefore() | not ported — experimental browser API |
67
+
68
+ ## Where these fit
69
+
70
+ The examples show *patterns*; the mechanics behind them live in the reference docs — [the DSL](../dsl.md) for every verb and kwarg, [Routing](../routing.md) for how components get their URLs, [Error handling](../error-handling.md) for the recovery machinery, and [Arbre](../arbre.md) for the HTML layer itself. New to Weft entirely? Start with [the tutorial](../tutorial.md).