unmagic-components 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +73 -1
- data/README.md +493 -1
- data/app/assets/javascripts/unmagic/components/autogrow.js +74 -0
- data/app/assets/javascripts/unmagic/components/clipboard.js +63 -0
- data/app/assets/javascripts/unmagic/components/confirm.js +102 -0
- data/app/assets/javascripts/unmagic/components/dialog.js +53 -0
- data/app/assets/javascripts/unmagic/components/menu.js +139 -0
- data/app/assets/javascripts/unmagic/components/modal.js +182 -0
- data/app/assets/javascripts/unmagic/components/tabs.js +94 -0
- data/app/assets/javascripts/unmagic/components/time.js +169 -0
- data/app/assets/javascripts/unmagic/components/toasts.js +171 -0
- data/app/assets/javascripts/unmagic/components/tooltip.js +133 -0
- data/app/assets/javascripts/unmagic/components/uuid_input.js +70 -0
- data/app/assets/javascripts/unmagic/components.js +19 -0
- data/app/assets/stylesheets/unmagic/components.css +927 -0
- data/config/importmap.rb +5 -1
- data/lib/unmagic/components/action_view_helpers.rb +373 -0
- data/lib/unmagic/components/autogrow.rb +13 -0
- data/lib/unmagic/components/badge.rb +21 -0
- data/lib/unmagic/components/button.rb +28 -0
- data/lib/unmagic/components/callout.rb +60 -0
- data/lib/unmagic/components/card.rb +67 -0
- data/lib/unmagic/components/configuration.rb +19 -1
- data/lib/unmagic/components/confirm_template.rb +46 -0
- data/lib/unmagic/components/copy_button.rb +50 -0
- data/lib/unmagic/components/detail_list.rb +13 -3
- data/lib/unmagic/components/dialog.rb +79 -0
- data/lib/unmagic/components/dialog_responder.rb +46 -0
- data/lib/unmagic/components/engine.rb +13 -4
- data/lib/unmagic/components/form_builder.rb +24 -0
- data/lib/unmagic/components/icons.rb +40 -0
- data/lib/unmagic/components/local_time.rb +64 -0
- data/lib/unmagic/components/menu.rb +82 -0
- data/lib/unmagic/components/modal.rb +75 -0
- data/lib/unmagic/components/page_header.rb +96 -0
- data/lib/unmagic/components/skeleton.rb +97 -0
- data/lib/unmagic/components/tabs.rb +95 -0
- data/lib/unmagic/components/toast.rb +54 -0
- data/lib/unmagic/components/toasts.rb +46 -0
- data/lib/unmagic/components/tooltip.rb +32 -0
- data/lib/unmagic/components/turbo_stream_actions.rb +19 -0
- data/lib/unmagic/components/uuid_input.rb +24 -0
- data/lib/unmagic/components/version.rb +1 -1
- data/lib/unmagic/components.rb +21 -0
- metadata +42 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 73a26932047bf5f5019925414115576c98d199f51e434ebae9e614423ba67022
|
|
4
|
+
data.tar.gz: 93be93b5f8b3c9c42c8224a0ec229635a270a89f5319477e0b99970cb4ab4e8c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1269dce8cd8dac1c5d3d8e9f0ac2437bf4b2bd25e2511d5dc8aee4010157dfb9156be984fc2976029995dc704d3bb4ac8b880ea2683f51571f9bc88edf3cac8d
|
|
7
|
+
data.tar.gz: 4a6e77ce020686fecbbe0c09247ab61769128b263ff47cea7df9549b3b360670e068400743bfae9a69d467250479f0768e57d57581d651927c686800ccefd868
|
data/CHANGELOG.md
CHANGED
|
@@ -7,8 +7,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-09-16
|
|
11
|
+
|
|
10
12
|
### Added
|
|
11
13
|
|
|
14
|
+
- Dialogs, each a native `<dialog>` sharing one panel (a titled header with a
|
|
15
|
+
close button, the body, and an optional footer):
|
|
16
|
+
- **The shared modal.** Mount it with `modal_frame`, point links at it with
|
|
17
|
+
`modal_link_to`, and render a `dialog` from the action. `form:` builds the
|
|
18
|
+
form around the whole panel. The `<unmagic-modal>` element opens on the
|
|
19
|
+
frame's request with a skeleton, and shows an error panel with a retry when
|
|
20
|
+
the load fails. It closes in the same render as a `turbo_stream.refresh`, so
|
|
21
|
+
the page repaints once. `Unmagic::Components::DialogResponder` provides the
|
|
22
|
+
`refresh_or_redirect` that sends that refresh, and `config.modal_frame_id`
|
|
23
|
+
renames the frame.
|
|
24
|
+
- **Dialogs already on the page:** `dialog_tag` and `dialog_button`.
|
|
25
|
+
- **A confirm dialog in place of `window.confirm`** for `data-turbo-confirm`
|
|
26
|
+
(`import "unmagic/components/confirm"`). `data-turbo-confirm-title`,
|
|
27
|
+
`-accept` and `-variant="danger"` customise it, and `confirm_dialog_template`
|
|
28
|
+
carries its words through I18n.
|
|
29
|
+
- Page building blocks with no JavaScript:
|
|
30
|
+
- `page_header` shows a back link, a title with badges, a description and
|
|
31
|
+
actions.
|
|
32
|
+
- `card` has a header with actions and a tinted footer. `flush:` suits a table
|
|
33
|
+
that runs edge to edge, and `href:` makes the whole card one link.
|
|
34
|
+
- `badge` comes in neutral, good, warn, bad, info and accent tones.
|
|
35
|
+
- `callout` comes in neutral, good, warn, bad and info tones, each with an icon
|
|
36
|
+
and an optional title and badge.
|
|
37
|
+
- `empty_state` is now a helper you can call directly, still rendered through
|
|
38
|
+
the `empty_state` setting. A new `--unmagic-surface-3` token colours neutral
|
|
39
|
+
badges.
|
|
40
|
+
- `local_time_tag` and `<unmagic-time>`. A timestamp the browser formats with
|
|
41
|
+
`Intl`, in the viewer's locale and time zone. The formats are short, medium,
|
|
42
|
+
long, full, date, time and relative. Relative times keep themselves current on
|
|
43
|
+
one shared, minute-aligned timer, and `compact: true` shortens them to "5m".
|
|
44
|
+
- `tooltip` and `<unmagic-tooltip>`. A hint on hover or focus, drawn in the top
|
|
45
|
+
layer so nothing clips it. It flips to the other side when there isn't room,
|
|
46
|
+
stays on screen, and closes on Escape. Plain text is styled as a term. New
|
|
47
|
+
`--unmagic-tooltip` and `--unmagic-on-tooltip` tokens colour it.
|
|
48
|
+
- `menu` and `<unmagic-menu>`. A dropdown built on `<details>` with `link`,
|
|
49
|
+
`button` and `divider` items. It closes on an outside click, Escape, choosing an
|
|
50
|
+
item, or a Turbo navigation, and supports keyboard navigation.
|
|
51
|
+
- `tabs` and `<unmagic-tabs>`. Panels switched in the page using the ARIA tab
|
|
52
|
+
pattern with arrow-key navigation, including disabled tabs that show a reason.
|
|
53
|
+
With an `id:` the choice is remembered. With `href:`, tabs become a link bar
|
|
54
|
+
rendered on the server. A new `--unmagic-raised` token colours the selected tab.
|
|
55
|
+
- `copy_button` and `<unmagic-clipboard>`. Copies text, or the contents of an
|
|
56
|
+
element given by `from:`, then briefly shows a check and announces it. It fires
|
|
57
|
+
`unmagic-clipboard:copy` and `unmagic-clipboard:error`.
|
|
58
|
+
- `FormBuilder#autogrow_text_area` and `autogrow_text_area_tag`. A textarea that
|
|
59
|
+
grows from its rows to its CSS max-height, re-measuring as you type, on reset
|
|
60
|
+
and when its width changes.
|
|
61
|
+
- `FormBuilder#uuid_field` and `uuid_input_tag`. A hidden field holding a UUIDv7,
|
|
62
|
+
generated on the server and again in the browser when the page loads and each
|
|
63
|
+
time the form resets.
|
|
64
|
+
- Skeletons for blocking out an interface while it loads. `skeleton do |s|` yields
|
|
65
|
+
`s.text`, `s.circle`, `s.block` and `s.button`, arranged with your own markup
|
|
66
|
+
and announced once as "Loading…". `skeleton_text`, `skeleton_circle`,
|
|
67
|
+
`skeleton_block` and `skeleton_button` are the same shapes outside a block.
|
|
68
|
+
Shapes take their size from what they replace: a text line fills one line of
|
|
69
|
+
its font, and a button shape is a real button's height. `detail_list`,
|
|
70
|
+
`page_header` and `card` take `skeleton: true` to render a skeleton version of
|
|
71
|
+
themselves.
|
|
72
|
+
- Toasts. `flash_toasts` turns the request's flashes into toasts that dismiss
|
|
73
|
+
themselves, pause while hovered or focused, and survive Drive visits and morph
|
|
74
|
+
refreshes. `turbo_stream.toast` shows one from a stream response.
|
|
75
|
+
`config.flash_tones` maps flash types to the good, warn, bad and info tones,
|
|
76
|
+
which take their colours from the new `--unmagic-good*` and `--unmagic-warn*`
|
|
77
|
+
tokens.
|
|
78
|
+
- `button_classes`, with `:primary`, `:ghost`, `:danger` and `:icon` variants and
|
|
79
|
+
`:small` and `:large` sizes.
|
|
80
|
+
- `import "unmagic/components"` imports every component. The engine pins each one
|
|
81
|
+
as `unmagic/components/<name>`.
|
|
82
|
+
- `--unmagic-focus` and `--unmagic-backdrop` theme tokens.
|
|
12
83
|
- `table_for` passes any option it doesn't recognise to the `<table>` element, so a
|
|
13
84
|
view can set `class:`, `data:` or ARIA attributes without a wrapper. `id:` still
|
|
14
85
|
names the deferred turbo frame.
|
|
@@ -49,5 +120,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
49
120
|
- Configurable empty-state, pagination and Pagy seams so the gem depends on neither
|
|
50
121
|
Pagy nor any host helper.
|
|
51
122
|
|
|
52
|
-
[Unreleased]: https://github.com/unreasonable-magic/unmagic-components/compare/v0.
|
|
123
|
+
[Unreleased]: https://github.com/unreasonable-magic/unmagic-components/compare/v0.2.0...HEAD
|
|
124
|
+
[0.2.0]: https://github.com/unreasonable-magic/unmagic-components/compare/v0.1.0...v0.2.0
|
|
53
125
|
[0.1.0]: https://github.com/unreasonable-magic/unmagic-components/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -32,6 +32,10 @@ the spirit of `form_for`: describe the columns, get the chrome.
|
|
|
32
32
|
- **Companion detail rows** under any record, skipped per row when empty.
|
|
33
33
|
- **Detail lists** in inline and stacked layouts, with blanks rendered as an em
|
|
34
34
|
dash so call sites don't each need `.presence || "—"`.
|
|
35
|
+
- **Dialogs** as native `<dialog>`s: a shared modal that loads its content from
|
|
36
|
+
the server with a skeleton, an error panel with retry, and a close that lands
|
|
37
|
+
in the same render as the page's refresh; same-page dialogs; and a confirm
|
|
38
|
+
dialog in place of `window.confirm`.
|
|
35
39
|
- **No hard dependency** on a pagination library or on any helper of yours.
|
|
36
40
|
|
|
37
41
|
## Installation
|
|
@@ -47,7 +51,9 @@ Add the stylesheet to your layout:
|
|
|
47
51
|
```
|
|
48
52
|
|
|
49
53
|
The engine mixes the helpers into ActionView automatically — no initializer
|
|
50
|
-
needed to get started.
|
|
54
|
+
needed to get started. The interactive components need their JavaScript too; with
|
|
55
|
+
importmap-rails the engine pins it for you, so add `import "unmagic/components"`
|
|
56
|
+
to your application.js (see [Dialogs](#dialogs)).
|
|
51
57
|
|
|
52
58
|
## Theming
|
|
53
59
|
|
|
@@ -59,6 +65,8 @@ To match your own design, set the `--unmagic-*` knobs wherever your theme lives:
|
|
|
59
65
|
:root {
|
|
60
66
|
--unmagic-surface: var(--surface);
|
|
61
67
|
--unmagic-surface-2: var(--surface-2);
|
|
68
|
+
--unmagic-surface-3: var(--surface-3); /* neutral badges, tab tracks */
|
|
69
|
+
--unmagic-raised: var(--surface); /* the selected tab */
|
|
62
70
|
--unmagic-hover: var(--hover);
|
|
63
71
|
--unmagic-border: var(--border);
|
|
64
72
|
--unmagic-border-strong: var(--border-strong);
|
|
@@ -66,9 +74,27 @@ To match your own design, set the `--unmagic-*` knobs wherever your theme lives:
|
|
|
66
74
|
--unmagic-text-2: var(--text-2);
|
|
67
75
|
--unmagic-text-3: var(--text-3);
|
|
68
76
|
--unmagic-skeleton: var(--surface-3);
|
|
77
|
+
--unmagic-accent: var(--primary); /* primary buttons */
|
|
78
|
+
--unmagic-on-accent: var(--on-primary);
|
|
79
|
+
--unmagic-bad: var(--danger); /* errors, danger buttons */
|
|
80
|
+
--unmagic-bad-surface: var(--danger-surface);
|
|
81
|
+
--unmagic-bad-border: var(--danger-border);
|
|
82
|
+
--unmagic-good: var(--success); /* good toasts */
|
|
83
|
+
--unmagic-good-surface: var(--success-surface);
|
|
84
|
+
--unmagic-good-border: var(--success-border);
|
|
85
|
+
--unmagic-warn: var(--warning); /* warn toasts */
|
|
86
|
+
--unmagic-warn-surface: var(--warning-surface);
|
|
87
|
+
--unmagic-warn-border: var(--warning-border);
|
|
88
|
+
--unmagic-tooltip: var(--inverse); /* tooltips */
|
|
89
|
+
--unmagic-on-tooltip: var(--on-inverse);
|
|
90
|
+
--unmagic-focus: var(--focus-ring);
|
|
91
|
+
--unmagic-backdrop: rgb(0 0 0 / 0.5); /* behind dialogs */
|
|
69
92
|
}
|
|
70
93
|
```
|
|
71
94
|
|
|
95
|
+
In a dark theme, set `--unmagic-accent` too. Its fallback is near-black, which
|
|
96
|
+
disappears against a dark dialog.
|
|
97
|
+
|
|
72
98
|
Dark mode needs nothing extra. If your own tokens already flip, these flip with
|
|
73
99
|
them — the gem ships no dark variant and makes no assumption about how you
|
|
74
100
|
select a theme.
|
|
@@ -231,6 +257,17 @@ Set it as the default with `config.action_view.default_form_builder`, or pass
|
|
|
231
257
|
your own button content, e.g. an icon.
|
|
232
258
|
- `form_value_for` — the value to show, whether the object is a model or something
|
|
233
259
|
hash-ish, preferring what the user actually typed.
|
|
260
|
+
- `autogrow_text_area` — a textarea that grows as you type, from the height its
|
|
261
|
+
`rows` give it up to its CSS `max-height`, and then scrolls. Use it as a
|
|
262
|
+
field's control with `form.field :body, "Message", as: :autogrow_text_area`.
|
|
263
|
+
Outside a form builder, use `autogrow_text_area_tag`. Needs
|
|
264
|
+
`import "unmagic/components/autogrow"`.
|
|
265
|
+
- `uuid_field` — a hidden field holding a fresh UUIDv7. Use it when the form
|
|
266
|
+
should submit an id the client already knows, such as the id of an element
|
|
267
|
+
rendered before the server replies. A new id is generated when the page loads
|
|
268
|
+
and every time the form resets; without JavaScript the server's own id is sent.
|
|
269
|
+
Outside a form builder, use `uuid_input_tag`. Needs
|
|
270
|
+
`import "unmagic/components/uuid_input"`.
|
|
234
271
|
|
|
235
272
|
**What the control looks like is not decided here.** Apps style inputs in
|
|
236
273
|
incompatible ways — a class on every input, or a bare-element rule — and a
|
|
@@ -319,6 +356,461 @@ by id. Give every row one prefix and the time-ordered id decides.
|
|
|
319
356
|
The companion `details` row is not broadcast: a stream action carries one element,
|
|
320
357
|
and the pair is a page-render concern.
|
|
321
358
|
|
|
359
|
+
## Dialogs
|
|
360
|
+
|
|
361
|
+
There are three kinds, all built on the same panel: a modal whose content loads
|
|
362
|
+
from the server, a dialog that's already on the page, and a confirm that replaces
|
|
363
|
+
`window.confirm`. Each is a native `<dialog>`, so the browser handles trapping
|
|
364
|
+
focus, closing on Escape, and returning focus when it closes.
|
|
365
|
+
|
|
366
|
+
Import the behaviour once:
|
|
367
|
+
|
|
368
|
+
```js
|
|
369
|
+
// app/javascript/application.js
|
|
370
|
+
import "unmagic/components" // every component, including the confirm dialog
|
|
371
|
+
|
|
372
|
+
// ...or only the ones you want
|
|
373
|
+
import "unmagic/components/modal"
|
|
374
|
+
import "unmagic/components/confirm"
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### The shared modal
|
|
378
|
+
|
|
379
|
+
Mount it once, in the layout:
|
|
380
|
+
|
|
381
|
+
```erb
|
|
382
|
+
<%= modal_frame %>
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
Point a link at any action with `modal_link_to`. It takes `link_to`'s arguments:
|
|
386
|
+
|
|
387
|
+
```erb
|
|
388
|
+
<%= modal_link_to "Edit", edit_label_path(@label), class: button_classes %>
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
The action renders a `dialog`. It's the same template whether the page opens in
|
|
392
|
+
the modal or is visited directly:
|
|
393
|
+
|
|
394
|
+
```erb
|
|
395
|
+
<%# labels/edit.html.erb %>
|
|
396
|
+
<%= dialog title: "Edit label", form: { model: @label } do |dialog, form| %>
|
|
397
|
+
<%= form.field :name, "Name", required: true %>
|
|
398
|
+
|
|
399
|
+
<% dialog.footer do %>
|
|
400
|
+
<button type="button" class="<%= button_classes %>" data-unmagic-dialog-close>Cancel</button>
|
|
401
|
+
<%= form.submit "Save" %>
|
|
402
|
+
<% end %>
|
|
403
|
+
<% end %>
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
```ruby
|
|
407
|
+
class LabelsController < ApplicationController
|
|
408
|
+
include Unmagic::Components::DialogResponder
|
|
409
|
+
|
|
410
|
+
def update
|
|
411
|
+
if @label.update(label_params)
|
|
412
|
+
refresh_or_redirect labels_path, notice: "Label saved."
|
|
413
|
+
else
|
|
414
|
+
render :edit, status: :unprocessable_content
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
What that gets you:
|
|
421
|
+
|
|
422
|
+
- **Opening:** the dialog opens as soon as the request starts and shows a skeleton
|
|
423
|
+
until the response arrives. Turbo's hover prefetch doesn't open it.
|
|
424
|
+
- **Failed loads:** a network error, an error status or an empty `head :forbidden`
|
|
425
|
+
all show an error panel with a Try again button, rather than Turbo's
|
|
426
|
+
"Content missing".
|
|
427
|
+
- **Failed saves:** a `:unprocessable_content` render replaces the form inside the
|
|
428
|
+
dialog, errors and all.
|
|
429
|
+
- **Successful saves:**
|
|
430
|
+
- `refresh_or_redirect`: the dialog stays up, with its submit button still
|
|
431
|
+
saying "Saving…". It closes in the same render as the morph refresh, so the
|
|
432
|
+
page repaints once, straight to its new state.
|
|
433
|
+
- A stream response that doesn't refresh: the dialog closes as soon as it's read.
|
|
434
|
+
- A plain `redirect_to`: the modal visits that page and closes.
|
|
435
|
+
- **Multi-step forms:** a response that renders back into the frame, such as the
|
|
436
|
+
next step of a wizard, keeps the dialog open.
|
|
437
|
+
- **Closing:** Escape, a click on the backdrop, or any
|
|
438
|
+
`[data-unmagic-dialog-close]`. A drag that starts in an input and ends over the
|
|
439
|
+
backdrop doesn't count, so selecting text never throws the form away.
|
|
440
|
+
|
|
441
|
+
Build the form with `form:` rather than wrapping the dialog in `form_with`. On a
|
|
442
|
+
request aimed at the modal the dialog wraps itself in the modal's turbo frame, and
|
|
443
|
+
Turbo keeps only what is inside that frame. A form outside the dialog would be
|
|
444
|
+
dropped.
|
|
445
|
+
|
|
446
|
+
The dialog leaves overflow visible, so a dropdown inside a form isn't clipped. The
|
|
447
|
+
catch is that a dialog's content has to be short enough to fit the screen.
|
|
448
|
+
`config.modal_frame_id` (default `"modal"`) renames the frame.
|
|
449
|
+
|
|
450
|
+
### A dialog already on the page
|
|
451
|
+
|
|
452
|
+
```erb
|
|
453
|
+
<%= dialog_button "What's a scope?", dialog: "scopes_help", class: button_classes(:ghost) %>
|
|
454
|
+
|
|
455
|
+
<%= dialog_tag "scopes_help", title: "Scopes" do %>
|
|
456
|
+
<p>A scope limits what a token can do.</p>
|
|
457
|
+
<% end %>
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
The block is yielded the panel, for a `footer`. Extra options go on the
|
|
461
|
+
`<dialog>`.
|
|
462
|
+
|
|
463
|
+
### Confirm
|
|
464
|
+
|
|
465
|
+
Importing `unmagic/components/confirm` replaces the browser's confirm box for
|
|
466
|
+
`data-turbo-confirm` with a dialog in the same chrome:
|
|
467
|
+
|
|
468
|
+
```erb
|
|
469
|
+
<%= button_to "Delete", label_path(@label), method: :delete, class: button_classes(:danger),
|
|
470
|
+
form: { data: { turbo_confirm: "Delete this label?",
|
|
471
|
+
turbo_confirm_accept: "Delete",
|
|
472
|
+
turbo_confirm_variant: "danger" } } %>
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
- `data-turbo-confirm-title` sets the heading.
|
|
476
|
+
- `data-turbo-confirm-accept` sets the confirming button's label.
|
|
477
|
+
- `data-turbo-confirm-variant="danger"` makes that button red and focuses Cancel
|
|
478
|
+
instead, so a destructive action is never one Enter away.
|
|
479
|
+
|
|
480
|
+
These attributes are read from the submitter, then the form. A link with
|
|
481
|
+
`data-turbo-method` doesn't pass them on: Turbo builds a form for it and copies
|
|
482
|
+
only `data-turbo-confirm` onto it. Use `button_to` when you need them.
|
|
483
|
+
|
|
484
|
+
### Buttons
|
|
485
|
+
|
|
486
|
+
`button_classes(variant = :default, size: nil)` returns the class string, so the
|
|
487
|
+
look works with `link_to`, `button_to` and `form.submit` alike. The variants are
|
|
488
|
+
`:default`, `:primary`, `:ghost`, `:danger` and `:icon`; the sizes are `:small`
|
|
489
|
+
and `:large`.
|
|
490
|
+
|
|
491
|
+
### Translations
|
|
492
|
+
|
|
493
|
+
The words in these components go through I18n, with English defaults. The confirm
|
|
494
|
+
dialog is built in the browser, so render `<%= confirm_dialog_template %>` once in
|
|
495
|
+
your layout to translate it.
|
|
496
|
+
|
|
497
|
+
| Key | Default |
|
|
498
|
+
|---|---|
|
|
499
|
+
| `unmagic.components.dialog.close` | Close |
|
|
500
|
+
| `unmagic.components.modal.loading` | Loading… |
|
|
501
|
+
| `unmagic.components.modal.error_title` | Couldn’t load |
|
|
502
|
+
| `unmagic.components.modal.error_message` | Something went wrong loading this. Check your connection and try again. |
|
|
503
|
+
| `unmagic.components.modal.retry` | Try again |
|
|
504
|
+
| `unmagic.components.confirm.title` | Are you sure? |
|
|
505
|
+
| `unmagic.components.confirm.accept` | Confirm |
|
|
506
|
+
| `unmagic.components.confirm.cancel` | Cancel |
|
|
507
|
+
|
|
508
|
+
## Page building blocks
|
|
509
|
+
|
|
510
|
+
Plain Ruby and CSS, with no JavaScript.
|
|
511
|
+
|
|
512
|
+
### `page_header(title:, description:, back:, **options, &block)`
|
|
513
|
+
|
|
514
|
+
```erb
|
|
515
|
+
<%= page_header title: @label.name, description: "Applied to 12 issues.",
|
|
516
|
+
back: { text: "Labels", path: labels_path } do |header| %>
|
|
517
|
+
<% header.badge "Archived", tone: :warn if @label.archived? %>
|
|
518
|
+
<%= modal_link_to "Edit", edit_label_path(@label), class: button_classes %>
|
|
519
|
+
<% end %>
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
The block's output becomes the actions on the right. Its builder also takes
|
|
523
|
+
`title { }` and `description { }` for markup, and `leading { }` for something
|
|
524
|
+
before the title, such as an avatar. The actions drop below the title when there
|
|
525
|
+
isn't room beside it.
|
|
526
|
+
|
|
527
|
+
### `card(title:, href:, flush:, **options, &block)`
|
|
528
|
+
|
|
529
|
+
```erb
|
|
530
|
+
<%= card title: "Members" do |card| %>
|
|
531
|
+
<% card.actions { link_to "Invite", new_invitation_path, class: button_classes(size: :small) } %>
|
|
532
|
+
Ada, Grace and Katherine can see everything.
|
|
533
|
+
<% card.footer { "3 of 5 seats used" } %>
|
|
534
|
+
<% end %>
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
- `flush: true` removes the body's padding. A `table_tag` or `table_for` inside a
|
|
538
|
+
flush card uses the card's border instead of drawing its own.
|
|
539
|
+
- `href:` makes the whole card one link, for a row that opens a record. Don't put
|
|
540
|
+
other links or buttons inside it.
|
|
541
|
+
- A card doesn't clip its content, so a dropdown inside one isn't cut off.
|
|
542
|
+
|
|
543
|
+
### `badge(content, tone: :neutral, **options)`
|
|
544
|
+
|
|
545
|
+
```erb
|
|
546
|
+
<%= badge "Draft" %>
|
|
547
|
+
<%= badge "Overdue", tone: :bad %>
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
Tones are `:neutral`, `:good`, `:warn`, `:bad`, `:info` (outlined) and `:accent`.
|
|
551
|
+
|
|
552
|
+
### `callout(title = nil, tone: :neutral, badge: nil, icon: true, **options, &block)`
|
|
553
|
+
|
|
554
|
+
```erb
|
|
555
|
+
<%= callout "DNS isn't verified", tone: :warn, badge: "Pending" do %>
|
|
556
|
+
Add the TXT record below, then check again.
|
|
557
|
+
<% end %>
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
This is for the state of something in place, such as a health check or a warning
|
|
561
|
+
above a form. Each tone except `:neutral` has an icon, and `icon: false` removes
|
|
562
|
+
it. A badge takes the callout's tone.
|
|
563
|
+
|
|
564
|
+
### `empty_state(content = nil, **options, &block)`
|
|
565
|
+
|
|
566
|
+
```erb
|
|
567
|
+
<%= empty_state "No invitations yet." %>
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
This is the same blank slate tables use. It renders through the `empty_state`
|
|
571
|
+
setting, so if your app replaces it, both change.
|
|
572
|
+
|
|
573
|
+
## Times and tooltips
|
|
574
|
+
|
|
575
|
+
### `local_time_tag(time, format: :medium, compact: false, **options)`
|
|
576
|
+
|
|
577
|
+
```erb
|
|
578
|
+
<%= local_time_tag comment.created_at, format: :relative %> <%# "3 hours ago" %>
|
|
579
|
+
<%= local_time_tag invoice.due_at, format: :date %> <%# "16 Sept 2026" %>
|
|
580
|
+
<%= local_time_tag event.starts_at %> <%# "16 Sept 2026, 4:33 pm" %>
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
The browser formats the time with `Intl`, in the viewer's own locale and time
|
|
584
|
+
zone, so there's nothing to translate and no time zone to look up for the user.
|
|
585
|
+
Until the script runs, the server's own rendering (in `Time.zone`) shows instead.
|
|
586
|
+
|
|
587
|
+
| `format:` | Shows |
|
|
588
|
+
|---|---|
|
|
589
|
+
| `:short`, `:medium`, `:long`, `:full` | A date and time, in increasing detail |
|
|
590
|
+
| `:date`, `:time` | Just one of them |
|
|
591
|
+
| `:relative` | "now", "5 minutes ago", "yesterday", "in 3 days" |
|
|
592
|
+
|
|
593
|
+
- **Relative times stay current.** Every relative time on the page updates on one
|
|
594
|
+
shared timer, once a minute, and the full time shows on hover.
|
|
595
|
+
- **Days follow the calendar.** A relative time counts days by midnight, so
|
|
596
|
+
something from 11pm reads "yesterday" at 2am.
|
|
597
|
+
- **Old dates settle.** Past a week a relative time becomes a plain date and stops
|
|
598
|
+
updating.
|
|
599
|
+
- **Compact form:** `compact: true` shortens a relative time to "5m" or "3h".
|
|
600
|
+
- **Blank values:** a `nil` time renders an em dash.
|
|
601
|
+
|
|
602
|
+
### `tooltip(content = nil, text:, placement: :top, term: nil, **options, &block)`
|
|
603
|
+
|
|
604
|
+
```erb
|
|
605
|
+
Every page declares a <%= tooltip "canonical URL", text: "The address search engines treat as the original." %>.
|
|
606
|
+
|
|
607
|
+
<%= tooltip text: "Copy the key" do %>
|
|
608
|
+
<%= copy_button @key.secret %>
|
|
609
|
+
<% end %>
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
- **Hover and focus:** the hint appears on hover after a short delay, or straight
|
|
613
|
+
away on focus. Escape dismisses it.
|
|
614
|
+
- **Never clipped:** it's drawn in the browser's top layer, so a container's
|
|
615
|
+
overflow can't cut it off. It flips to the other side when there isn't room and
|
|
616
|
+
stays inside the screen.
|
|
617
|
+
- **Terms:** plain text content gets a dashed underline and a help cursor, and
|
|
618
|
+
becomes focusable. Block content, such as a button or an icon, is left alone
|
|
619
|
+
and the hint describes its focusable element. `term:` overrides that choice.
|
|
620
|
+
- **Placement:** `:top` or `:bottom`.
|
|
621
|
+
|
|
622
|
+
Needs `import "unmagic/components/time"` and `"unmagic/components/tooltip"`, or
|
|
623
|
+
`import "unmagic/components"`.
|
|
624
|
+
|
|
625
|
+
## Menus, tabs and copy buttons
|
|
626
|
+
|
|
627
|
+
### `menu(label = nil, align: :end, **options, &block)`
|
|
628
|
+
|
|
629
|
+
```erb
|
|
630
|
+
<%= menu do |menu| %>
|
|
631
|
+
<% menu.link "Edit", edit_job_path(@job) %>
|
|
632
|
+
<% menu.divider %>
|
|
633
|
+
<% menu.button "Delete", job_path(@job), method: :delete, tone: :danger,
|
|
634
|
+
form: { data: { turbo_confirm: "Delete this job?" } } %>
|
|
635
|
+
<% end %>
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
- **Built on `<details>`:** the menu opens even before its script loads.
|
|
639
|
+
- **Closing:** an outside click, Escape, choosing an item, or navigating away with
|
|
640
|
+
Turbo all close it.
|
|
641
|
+
- **Keyboard:** the arrow keys, Home and End move between items. Opening the menu
|
|
642
|
+
with the keyboard focuses the first item.
|
|
643
|
+
- **Trigger:** with no label it's a ⋮ icon button labelled "More actions"
|
|
644
|
+
(`unmagic.components.menu.label`). Pass a label for a text button with a chevron.
|
|
645
|
+
- **Items:** `link` and `button` take `link_to`'s and `button_to`'s arguments, plus
|
|
646
|
+
`tone: :danger`.
|
|
647
|
+
- **Alignment:** `align: :start` lines the panel up with the trigger's left edge
|
|
648
|
+
instead of its right.
|
|
649
|
+
|
|
650
|
+
### `tabs(id: nil, **options, &block)`
|
|
651
|
+
|
|
652
|
+
```erb
|
|
653
|
+
<%= tabs id: "response" do |tabs| %>
|
|
654
|
+
<% tabs.tab "Body" %>
|
|
655
|
+
<% tabs.tab "Headers" %>
|
|
656
|
+
<% tabs.tab "Preview", disabled: "HTML only" %>
|
|
657
|
+
<% tabs.panel do %>...<% end %>
|
|
658
|
+
<% tabs.panel do %>...<% end %>
|
|
659
|
+
<% end %>
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
- **Accessible markup:** the server renders the full tab pattern with its ARIA
|
|
663
|
+
roles, so only the selected tab is in the tab order. The arrow keys, Home and
|
|
664
|
+
End switch tabs.
|
|
665
|
+
- **Panels:** each panel pairs with an enabled tab, in order. A tab with
|
|
666
|
+
`disabled:` shows its reason and takes no panel, and `active: true` picks the
|
|
667
|
+
tab shown first.
|
|
668
|
+
- **Remembering the choice:** give the tabs an `id:` and the chosen tab is kept
|
|
669
|
+
for that page until the browser tab closes, even across a morph refresh.
|
|
670
|
+
Each change fires `unmagic-tabs:change`.
|
|
671
|
+
|
|
672
|
+
Give each tab an `href:` instead of panels and you get a bar of links to separate
|
|
673
|
+
pages. It's rendered on the server with no script, and `active: true` marks the
|
|
674
|
+
current page:
|
|
675
|
+
|
|
676
|
+
```erb
|
|
677
|
+
<%= tabs do |tabs| %>
|
|
678
|
+
<% tabs.tab "All", href: invitations_path, active: @status.nil? %>
|
|
679
|
+
<% tabs.tab "Replied", href: invitations_path(status: "replied"), active: @status == "replied" %>
|
|
680
|
+
<% end %>
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
### `copy_button(text = nil, from: nil, label: nil, **options, &block)`
|
|
684
|
+
|
|
685
|
+
```erb
|
|
686
|
+
<%= copy_button @key.secret %>
|
|
687
|
+
|
|
688
|
+
<code id="install_command">bundle add unmagic-components</code>
|
|
689
|
+
<%= copy_button from: "install_command" do %>Copy command<% end %>
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
- **Feedback:** the copy icon briefly turns into a check, and screen readers hear
|
|
693
|
+
"Copied".
|
|
694
|
+
- **Copying from the page:** `from:` copies the value of an input, or the text of
|
|
695
|
+
any element, with that id at the moment of the click. The text doesn't need to
|
|
696
|
+
be repeated in an attribute.
|
|
697
|
+
- **Labels and options:** without a block it's an icon button labelled `label:`
|
|
698
|
+
("Copy"). Other options go on the `<button>`.
|
|
699
|
+
- **Events:** it fires `unmagic-clipboard:copy`, or `unmagic-clipboard:error` when
|
|
700
|
+
the browser refuses the write, so you can show a toast.
|
|
701
|
+
|
|
702
|
+
Needs `import "unmagic/components/menu"`, `"unmagic/components/tabs"` and
|
|
703
|
+
`"unmagic/components/clipboard"`, or `import "unmagic/components"`.
|
|
704
|
+
|
|
705
|
+
## Skeletons
|
|
706
|
+
|
|
707
|
+
Skeletons block out an interface while it loads. `skeleton` yields a builder,
|
|
708
|
+
like `form_for` does. You arrange its shapes with your own markup:
|
|
709
|
+
|
|
710
|
+
```erb
|
|
711
|
+
<%= skeleton label: "Loading candidate" do |s| %>
|
|
712
|
+
<div class="flex items-center gap-3">
|
|
713
|
+
<%= s.circle size: "3rem" %>
|
|
714
|
+
<div class="flex-1">
|
|
715
|
+
<%= s.text width: "60%" %>
|
|
716
|
+
<%= s.text width: "40%" %>
|
|
717
|
+
</div>
|
|
718
|
+
</div>
|
|
719
|
+
<%= s.text lines: 2 %>
|
|
720
|
+
<% end %>
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
| Shape | Stands in for |
|
|
724
|
+
|---|---|
|
|
725
|
+
| `s.text(width:, lines:)` | A line of text in the surrounding font. `lines:` makes a paragraph with a shorter last line. |
|
|
726
|
+
| `s.circle(size:)` | An avatar or round icon (default `2.5rem`). |
|
|
727
|
+
| `s.block(height:, width:)` | An image, chart or map (default `8rem` tall, full width). |
|
|
728
|
+
| `s.button(size:, width:)` | A `button_classes` button, `size: :small` or `:large`. |
|
|
729
|
+
|
|
730
|
+
- **Nothing moves when content arrives.** Each shape is sized from what it
|
|
731
|
+
replaces: a text line fills exactly one line of the font it sits in, so a line
|
|
732
|
+
inside an `<h1>` is heading-sized, and a button shape matches a real button's
|
|
733
|
+
height.
|
|
734
|
+
- **Styling:** every shape also takes `class:` and `style:`.
|
|
735
|
+
- **Screen readers:** the shapes are hidden from them. They hear `label:`
|
|
736
|
+
("Loading…", `unmagic.components.skeleton.loading`) once for the whole group.
|
|
737
|
+
|
|
738
|
+
Outside a block, the same shapes are `skeleton_text`, `skeleton_circle`,
|
|
739
|
+
`skeleton_block` and `skeleton_button`.
|
|
740
|
+
|
|
741
|
+
Some components render a skeleton version of themselves, so it matches the real
|
|
742
|
+
one:
|
|
743
|
+
|
|
744
|
+
```erb
|
|
745
|
+
<%= detail_list skeleton: true do |list| %>
|
|
746
|
+
<% list.item "Created" %>
|
|
747
|
+
<% list.item "Salary" %>
|
|
748
|
+
<% end %>
|
|
749
|
+
|
|
750
|
+
<%= page_header skeleton: true %>
|
|
751
|
+
<%= card title: "Members", skeleton: true %>
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
Anything you pass still renders for real, and the rest becomes shapes:
|
|
755
|
+
|
|
756
|
+
- **`detail_list`** keeps its labels and shows a bar for each value.
|
|
757
|
+
- **`page_header`** shows a title bar, a description line and a button.
|
|
758
|
+
`description: false` leaves out the line.
|
|
759
|
+
- **`card`** keeps its title. An empty body becomes three lines, or you can pass
|
|
760
|
+
a block to block out the body yourself.
|
|
761
|
+
|
|
762
|
+
A skeleton pairs well with a lazy Turbo Frame:
|
|
763
|
+
|
|
764
|
+
```erb
|
|
765
|
+
<%= turbo_frame_tag "stats", src: stats_path, loading: :lazy do %>
|
|
766
|
+
<%= detail_list skeleton: true do |list| %>...<% end %>
|
|
767
|
+
<% end %>
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
## Toasts
|
|
771
|
+
|
|
772
|
+
Mount them once, in the layout:
|
|
773
|
+
|
|
774
|
+
```erb
|
|
775
|
+
<%= flash_toasts %>
|
|
776
|
+
```
|
|
777
|
+
|
|
778
|
+
Then set a flash as usual:
|
|
779
|
+
|
|
780
|
+
```ruby
|
|
781
|
+
redirect_to labels_path, notice: "Label saved."
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
The toast shows in the top-right corner and dismisses itself after five seconds
|
|
785
|
+
(`duration:`, in milliseconds). It also has a dismiss button.
|
|
786
|
+
|
|
787
|
+
- **Holding it open:** hovering or focusing a toast pauses its countdown. Letting
|
|
788
|
+
go resumes it, with at least a second left.
|
|
789
|
+
- **Staying put:** the stack is `data-turbo-permanent`, so a toast on screen
|
|
790
|
+
survives a Drive visit or a morph refresh. A flash set before
|
|
791
|
+
`refresh_or_redirect` arrives with the refresh.
|
|
792
|
+
- **Tone:** set by `config.flash_tones`. `notice` and `success` are good, `alert`
|
|
793
|
+
and `error` are bad, `warning` is warn, and anything else is info. A bad toast
|
|
794
|
+
interrupts screen readers with `role="alert"`; the rest are announced politely.
|
|
795
|
+
- **Filtering:** pass the flashes to show when some aren't meant for the user:
|
|
796
|
+
`flash_toasts flash.to_hash.except("copy_link")`.
|
|
797
|
+
|
|
798
|
+
To show a toast from a stream response, where there's no redirect to carry a
|
|
799
|
+
flash:
|
|
800
|
+
|
|
801
|
+
```ruby
|
|
802
|
+
render turbo_stream: turbo_stream.toast("Invitation sent.")
|
|
803
|
+
render turbo_stream: turbo_stream.toast("Couldn't reach Slack.", tone: :bad)
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
The stack sits in the browser's top layer, so a toast shows above an open dialog.
|
|
807
|
+
It can't be hovered or dismissed until that dialog closes, because a modal dialog
|
|
808
|
+
makes the rest of the page inert, but it still times out on its own.
|
|
809
|
+
|
|
810
|
+
Needs `import "unmagic/components/toasts"`, which `import "unmagic/components"`
|
|
811
|
+
includes. The dismiss button's label is `unmagic.components.toast.dismiss`
|
|
812
|
+
("Dismiss").
|
|
813
|
+
|
|
322
814
|
## Development
|
|
323
815
|
|
|
324
816
|
```sh
|