unmagic-components 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2e66d8f2c875d1c138f1a7921f52e7ec3e77a44b834eb6026025d40dec4ab9ab
4
+ data.tar.gz: c291912ef44869d6216760661a32b2046fa8820e718acffcd5a6d2cf7fbecc2a
5
+ SHA512:
6
+ metadata.gz: fe94b2574228893a83a514cdd07f6608e2541710494e10775926e784c6dced8c83d9e3f6f47f0ad4a51845e26d1c2255fadbc3c120955b8980829b2467c645ca
7
+ data.tar.gz: fe2a0c8fe420bf563d69d7ce8fc0cde4001decf52d141c57e91407b6666ad40b15862f846315232511400fbadddbdde88e3aa512029d8f77cc1834635ac2f3b4
data/CHANGELOG.md ADDED
@@ -0,0 +1,53 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+
12
+ - `table_for` passes any option it doesn't recognise to the `<table>` element, so a
13
+ view can set `class:`, `data:` or ARIA attributes without a wrapper. `id:` still
14
+ names the deferred turbo frame.
15
+ - `table.empty` and `table.no_results` take extra options and hand them to the
16
+ configured `empty_state` seam, for an app whose blank slate needs more than a
17
+ message (an icon, say).
18
+ - Live tables. `columns:` moves a table's column definitions into a partial, and
19
+ `row_for` renders the single `<tr>` those columns produce for one record, so a
20
+ Turbo Stream broadcast and the page render cannot drift. `rows_id:` puts an id
21
+ on the `<tbody>` for a stream to target, and `row_id:` overrides the row's own
22
+ id — needed over an STI collection, where `dom_id` names each subclass and the
23
+ rows would sort by type rather than by id.
24
+ - `FormBuilder`, the chrome around a form control: the `field` wrapper with its
25
+ label, required marker, hint and error line; `group` for a row of fields;
26
+ `errors_summary`; `check_box_field` and `check_box_collection`; `form_value_for`;
27
+ and a `submit` that conjugates its own label while submitting ("Save" ->
28
+ "Saving…") through `data-turbo-submits-with`. It deliberately does not style the
29
+ control itself — apps disagree about whether inputs carry a class or are styled
30
+ bare — so it emits structure and leaves appearance to the host.
31
+ - The `upsert` Turbo Stream action (`import "unmagic/components/upsert"`), which
32
+ merges `append` and `replace`: a row already on the page is replaced in place,
33
+ and a new one is inserted at the position its id sorts to. `order="desc"` on the
34
+ stream tag flips that for a newest-first list.
35
+
36
+ ## [0.1.0] - 2026-09-10
37
+
38
+ ### Added
39
+
40
+ - `table_for`, a declarative index-table builder: columns with blocks or attribute
41
+ names, sortable headers with `aria-sort`, right/centre alignment, numeric columns,
42
+ `<colgroup>` width pinning, companion detail rows, and two flavours of empty state.
43
+ - Deferred tables — `table_for collection, defer: true` renders a skeleton inside a
44
+ Turbo Frame without touching the collection, then loads the real rows into it.
45
+ - `table_tag`, the underlying primitive, for static tables built from plain arrays.
46
+ - `detail_list`, a description-list builder with inline and stacked variants.
47
+ - Theming through `--unmagic-*` CSS custom properties, with every value falling back
48
+ to a Tailwind palette default so the components look right unconfigured.
49
+ - Configurable empty-state, pagination and Pagy seams so the gem depends on neither
50
+ Pagy nor any host helper.
51
+
52
+ [Unreleased]: https://github.com/unreasonable-magic/unmagic-components/compare/v0.1.0...HEAD
53
+ [0.1.0]: https://github.com/unreasonable-magic/unmagic-components/releases/tag/v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Keith Pitt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,337 @@
1
+ # Unmagic::Components
2
+
3
+ Declarative table and detail-list builders for server-rendered Rails views, in
4
+ the spirit of `form_for`: describe the columns, get the chrome.
5
+
6
+ ```erb
7
+ <%= table_for @users do |table| %>
8
+ <% table.empty "No team members yet." %>
9
+
10
+ <% table.column "Name", width: "38%" do |user| %>
11
+ <%= link_to user.name, user %>
12
+ <% end %>
13
+
14
+ <% table.column "Created", sort: :created_at, direction: :desc do |user| %>
15
+ <%= user.created_at.to_fs(:short) %>
16
+ <% end %>
17
+
18
+ <% table.column "Logins", :sign_in_count, numeric: true %>
19
+ <% end %>
20
+ ```
21
+
22
+ ## Features
23
+
24
+ - **Sortable headers** that toggle `?sort`/`?direction`, carry `aria-sort`, and
25
+ preserve the rest of the query string.
26
+ - **Deferred tables** — `defer: true` renders a skeleton inside a Turbo Frame
27
+ without touching the collection, then loads the real rows into it. The
28
+ skeleton keeps the loaded table's headers and column widths, so nothing shifts
29
+ when the data lands.
30
+ - **Two flavours of empty state**: a blank slate for an empty dataset, and a
31
+ separate one for a search that matched nothing, chosen automatically.
32
+ - **Companion detail rows** under any record, skipped per row when empty.
33
+ - **Detail lists** in inline and stacked layouts, with blanks rendered as an em
34
+ dash so call sites don't each need `.presence || "—"`.
35
+ - **No hard dependency** on a pagination library or on any helper of yours.
36
+
37
+ ## Installation
38
+
39
+ ```ruby
40
+ gem "unmagic-components"
41
+ ```
42
+
43
+ Add the stylesheet to your layout:
44
+
45
+ ```erb
46
+ <%= stylesheet_link_tag "unmagic/components" %>
47
+ ```
48
+
49
+ The engine mixes the helpers into ActionView automatically — no initializer
50
+ needed to get started.
51
+
52
+ ## Theming
53
+
54
+ The stylesheet is plain CSS driven by custom properties, and every value falls
55
+ back to a Tailwind palette default, so the components look right unconfigured.
56
+ To match your own design, set the `--unmagic-*` knobs wherever your theme lives:
57
+
58
+ ```css
59
+ :root {
60
+ --unmagic-surface: var(--surface);
61
+ --unmagic-surface-2: var(--surface-2);
62
+ --unmagic-hover: var(--hover);
63
+ --unmagic-border: var(--border);
64
+ --unmagic-border-strong: var(--border-strong);
65
+ --unmagic-text: var(--text);
66
+ --unmagic-text-2: var(--text-2);
67
+ --unmagic-text-3: var(--text-3);
68
+ --unmagic-skeleton: var(--surface-3);
69
+ }
70
+ ```
71
+
72
+ Dark mode needs nothing extra. If your own tokens already flip, these flip with
73
+ them — the gem ships no dark variant and makes no assumption about how you
74
+ select a theme.
75
+
76
+ The CSS is deliberately **not** part of any Tailwind build. Tailwind only
77
+ generates classes it can see and it does not scan installed gems, so a component
78
+ library that emitted utilities from Ruby would render unstyled in your app
79
+ unless you pointed `@source` at the gem's install path.
80
+
81
+ If you do want Tailwind utilities from these tokens, map them with `@theme
82
+ inline` — not plain `@theme`. A non-inline theme variable makes the utility emit
83
+ `var(--color-unmagic-surface)`, which resolves *where that variable is defined*,
84
+ so an override scoped to a subtree (a themed preview pane, a `.dark` region)
85
+ would be invisible to it:
86
+
87
+ ```css
88
+ @theme inline {
89
+ --color-unmagic-surface: var(--unmagic-surface);
90
+ }
91
+ ```
92
+
93
+ ## Configuration
94
+
95
+ Three seams, each with a working default. Point them at your own versions if you
96
+ already own these concerns:
97
+
98
+ ```ruby
99
+ # config/initializers/unmagic_components.rb
100
+ Unmagic::Components.configure do |config|
101
+ # The table's blank slate. Called with (view, content, **options), where the
102
+ # options are whatever `table.empty` was given beyond its text.
103
+ config.empty_state = ->(view, content, **options) { view.empty_state(content) }
104
+
105
+ # The table's pager. Called with (view, pagy:, turbo_frame:).
106
+ config.pagination = ->(view, pagy:, turbo_frame:) {
107
+ view.render "shared/pagination", pagy: pagy, turbo_frame: turbo_frame
108
+ }
109
+
110
+ # Resolves the pager object a table pages with. Called with (view, collection);
111
+ # return nil to suppress the pager. Nothing here is Pagy-specific — the
112
+ # renderer only needs something answering previous/next/page_url, so
113
+ # geared_pagination or your own object works just as well.
114
+ config.pagy_for = ->(view, collection) { view.table_pagy(collection) }
115
+ end
116
+ ```
117
+
118
+ ## Usage
119
+
120
+ ### `table_for(collection, **options, &block)`
121
+
122
+ | Option | Default | Meaning |
123
+ |---|---|---|
124
+ | `defer:` | `false` | Render a skeleton in a Turbo Frame first, then load the rows |
125
+ | `id:` | `"#{controller_name}_table"` | The frame id, when deferring |
126
+ | `paginate:` | `true` | `false` suppresses the pager; a pagy object is used directly |
127
+ | `headers:` | `true` | `false` omits the `<thead>` |
128
+ | `sorted_by:` | `params[:sort]` | The currently applied sort key |
129
+ | `sort_direction:` | `params[:direction]` | `:asc` or `:desc` |
130
+ | `sort_url:` | — | `->(key, direction) { url }`, when sorting rides on other params |
131
+ | `row_class:` | — | `->(record) { "…" }` for extra `<tr>` classes |
132
+
133
+ Any other option rides on the `<table>` itself — `class:`, `data:`, `aria-*` — so a
134
+ view can space or annotate the table without wrapping it in a div. `id:` is the
135
+ exception: it names the deferred turbo frame, not the table.
136
+
137
+ On the yielded builder:
138
+
139
+ - `column(title = nil, attribute = nil, **options, &block)` — content comes from
140
+ the block, else `record.public_send(attribute)`. Options: `sort:`,
141
+ `direction:`, `align:` (`:right`/`:center`), `numeric:` (right-aligns and uses
142
+ tabular figures), `width:`, `class:`.
143
+ - `details(&block)` — a full-width companion row per record; capturing nothing
144
+ skips it.
145
+ - `empty(text = nil, **options, &block)` — the blank slate for an empty dataset.
146
+ - `no_results(text = nil, **options, &block)` — shown instead when the collection
147
+ responds to `filtered?` with true.
148
+
149
+ Extra options on `empty`/`no_results` are handed to the configured `empty_state`
150
+ seam, so an app whose blank slate takes more than a message can ask for it per
151
+ table:
152
+
153
+ ```erb
154
+ <% table.empty "No labels yet.", icon: "tag" %>
155
+ ```
156
+
157
+ ```ruby
158
+ config.empty_state = ->(view, content, **options) do
159
+ view.render "shared/empty", icon: options.fetch(:icon, "table"), message: content
160
+ end
161
+ ```
162
+
163
+ `width:` takes a CSS length (`"40%"`, `"170px"`), which rides on a `<col>` as a
164
+ style, or any other string, which is used as a class name so a Tailwind app can
165
+ pass `"w-[40%]"`. Any width switches the table to a fixed layout. Give every
166
+ column of a deferred table a width, so the skeleton and the rows that replace it
167
+ lay out identically.
168
+
169
+ ### `detail_list(variant: :inline, **options, &block)`
170
+
171
+ ```erb
172
+ <%= detail_list variant: :stacked do |list| %>
173
+ <% list.item "Client ID", @application.uid, class: "font-mono" %>
174
+ <% list.item "Redirect URIs", span: :full do %>
175
+ <% @application.redirect_uris.each do |uri| %>
176
+ <div><%= uri %></div>
177
+ <% end %>
178
+ <% end %>
179
+ <% list.item "Registered", @application.created_at %>
180
+ <% end %>
181
+ ```
182
+
183
+ `:inline` lays labels beside values in a two-column grid; `:stacked` puts small
184
+ caps labels above values, in two columns once there is room. An item's `class:`
185
+ lands on its `<dd>`, and `span: :full` stretches a stacked item across both
186
+ columns.
187
+
188
+ ### `table_tag(headers, rows, **options)`
189
+
190
+ The primitive the table is built on, for a static table that wants the same look
191
+ without the record/sort/pagination machinery:
192
+
193
+ ```erb
194
+ <%= table_tag [ "Name", "Score" ], [ [ "Ann", 42 ], [ "Bob", 7 ] ], aligns: [ nil, :right ] %>
195
+ ```
196
+
197
+ A cell is a value, or a `{ content:, **attrs }` hash setting attributes on its
198
+ `th`/`td`. A row is an array of cells, or a `{ cells:, **attrs }` hash setting
199
+ attributes on its `<tr>`. `aligns:` and `widths:` are per-column, and `caption:`
200
+ adds a screen-reader-only caption.
201
+
202
+ ## Forms
203
+
204
+ `FormBuilder` is the chrome around a control — the wrapper, the label and its
205
+ required marker, the hint, and the error line — the part every app writes the same
206
+ way and then repeats in every view.
207
+
208
+ ```erb
209
+ <%= form_with model: @label, builder: Unmagic::Components::FormBuilder do |form| %>
210
+ <%= form.errors_summary %>
211
+ <%= form.field :name, "Name", required: true, hint: "A key is derived from it." %>
212
+ <%= form.field :colour, "Colour" do %>
213
+ <%= form.select :colour, Label::COLOURS %>
214
+ <% end %>
215
+ <%= form.submit "Add label" %>
216
+ <% end %>
217
+ ```
218
+
219
+ Set it as the default with `config.action_view.default_form_builder`, or pass
220
+ `builder:` per form.
221
+
222
+ - `field(method, label, required:, hint:, as:, &block)` — the control comes from
223
+ `as:` (any builder method), or from the block when you pass one. An invalid field
224
+ gets `aria-invalid="true"` and its errors underneath, read as a sentence.
225
+ - `group(inline: false)` — lay the contained fields out in a row.
226
+ - `errors_summary` — the record's whole-object (`:base`) errors.
227
+ - `check_box_field`, `check_box_collection` — a checkbox with its label beside it.
228
+ - `submit` — conjugates its label for the length of the submit ("Save" → "Saving…",
229
+ "Add label" → "Adding label…") via `data-turbo-submits-with`. Pass
230
+ `submitting: false` to leave it alone, or a string to choose it. A block supplies
231
+ your own button content, e.g. an icon.
232
+ - `form_value_for` — the value to show, whether the object is a model or something
233
+ hash-ish, preferring what the user actually typed.
234
+
235
+ **What the control looks like is not decided here.** Apps style inputs in
236
+ incompatible ways — a class on every input, or a bare-element rule — and a
237
+ component library that picked one would be wrong in the other. So the builder emits
238
+ structure and the gem's CSS styles only the label, hint and error. Point your own
239
+ input rules at `.UnmagicField` to give its controls your look:
240
+
241
+ ```css
242
+ .field,
243
+ .UnmagicField {
244
+ /* your existing input rules */
245
+ }
246
+ ```
247
+
248
+ The submit button's classes come from a seam, so it wears your own button:
249
+
250
+ ```ruby
251
+ config.submit_class = ->(_view, variant) { variant == :primary ? "btn btn-primary" : "btn" }
252
+ ```
253
+
254
+ ## Live tables
255
+
256
+ A table whose rows a Turbo Stream keeps up to date needs the column definitions in
257
+ a place both the page render and a single broadcast row can reach, so they move
258
+ into a partial that takes a `table` local and does nothing but declare them:
259
+
260
+ ```erb
261
+ <%# tasks/_columns.html.erb %>
262
+ <% table.column "Task", width: "30%" do |task| %>
263
+ <%= link_to task.kind, task %>
264
+ <% end %>
265
+ <% table.column "Status" do |task| %>
266
+ <span class="badge"><%= task.status %></span>
267
+ <% end %>
268
+ ```
269
+
270
+ The page renders the table with them, names the `<tbody>` so a stream can target
271
+ it, and gives the rows one id prefix:
272
+
273
+ ```erb
274
+ <%= turbo_stream_from "tasks" %>
275
+
276
+ <%= table_for @tasks, columns: "tasks/columns", rows_id: "task_rows",
277
+ row_id: ->(task) { "task_#{task.id}" } do |table| %>
278
+ <% table.empty "Nothing has run yet." %>
279
+ <% end %>
280
+ ```
281
+
282
+ A broadcast renders one row from the same partial:
283
+
284
+ ```erb
285
+ <%# tasks/_row.html.erb %>
286
+ <%= row_for task, columns: "tasks/columns", row_id: ->(task) { "task_#{task.id}" } %>
287
+ ```
288
+
289
+ ```ruby
290
+ class Task < ApplicationRecord
291
+ after_create_commit :broadcast_row
292
+ after_update_commit :broadcast_row
293
+
294
+ private
295
+ def broadcast_row
296
+ broadcast_action_to "tasks", action: :upsert, target: "task_rows",
297
+ attributes: { order: "desc" }, partial: "tasks/row", locals: { task: self }
298
+ end
299
+ end
300
+ ```
301
+
302
+ `upsert` is a Turbo Stream action this gem ships. Import it once:
303
+
304
+ ```js
305
+ // app/javascript/application.js
306
+ import "unmagic/components/upsert"
307
+ ```
308
+
309
+ It merges `append` and `replace`. If an element with the incoming id is already in
310
+ the document it is replaced in place, so a record that is both server-rendered and
311
+ broadcast never duplicates. Otherwise it is inserted at the position its id sorts
312
+ to, so out-of-order delivery still lands in order — which assumes time-ordered ids
313
+ (UUIDv7, ULID). `order="desc"` flips the comparison for a newest-first list.
314
+
315
+ **`row_id:` matters over an STI collection.** `dom_id` names the record's own
316
+ class, so subclasses get different prefixes and the rows sort by type rather than
317
+ by id. Give every row one prefix and the time-ordered id decides.
318
+
319
+ The companion `details` row is not broadcast: a stream action carries one element,
320
+ and the pair is a page-render concern.
321
+
322
+ ## Development
323
+
324
+ ```sh
325
+ bundle install
326
+ bundle exec rspec
327
+ bundle exec rubocop
328
+ ```
329
+
330
+ ## Contributing
331
+
332
+ Bug reports and pull requests are welcome at
333
+ https://github.com/unreasonable-magic/unmagic-components.
334
+
335
+ ## License
336
+
337
+ Available as open source under the terms of the [MIT License](LICENSE).
@@ -0,0 +1,45 @@
1
+ // A Turbo Stream action that merges `append` and `replace`:
2
+ //
3
+ // <turbo-stream action="upsert" target="CONTAINER_ID">
4
+ // <template><tr id="ELEMENT_ID">…</tr></template>
5
+ // </turbo-stream>
6
+ //
7
+ // The template holds one element with a stable id. If an element with that id is already in the
8
+ // document — server-rendered on page load, streamed in earlier, or an optimistic placeholder the
9
+ // server is now confirming — it is replaced in place, so a record that is both on the page and
10
+ // broadcast never duplicates. Otherwise it is inserted into the target container at the position
11
+ // its id sorts to, so out-of-order delivery still lands in id order. That ordering assumes
12
+ // time-ordered ids (UUIDv7, ULID); with random ids the position is stable but arbitrary.
13
+ //
14
+ // A list showing newest first says so, and the comparison flips:
15
+ //
16
+ // <turbo-stream action="upsert" target="task_rows" order="desc">
17
+ //
18
+ // broadcast_action_to "tasks", action: :upsert, target: "task_rows",
19
+ // attributes: { order: "desc" }, partial: "tasks/row", locals: { task: self }
20
+ import { Turbo } from "@hotwired/turbo-rails"
21
+
22
+ Turbo.StreamActions.upsert = function () {
23
+ const incoming = this.templateContent.firstElementChild
24
+ if (!incoming) return
25
+
26
+ const existing = incoming.id ? document.getElementById(incoming.id) : null
27
+ if (existing) {
28
+ existing.replaceWith(incoming)
29
+ return
30
+ }
31
+
32
+ const container = this.targetElements[0]
33
+ if (!container) return
34
+
35
+ const descending = this.getAttribute("order") === "desc"
36
+
37
+ // Without an id there is nothing to sort on, so it goes last.
38
+ const successor = incoming.id
39
+ ? Array.from(container.children).find(
40
+ (child) => child.id && (descending ? child.id < incoming.id : child.id > incoming.id),
41
+ )
42
+ : null
43
+
44
+ container.insertBefore(incoming, successor ?? null)
45
+ }