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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -1
  3. data/README.md +493 -1
  4. data/app/assets/javascripts/unmagic/components/autogrow.js +74 -0
  5. data/app/assets/javascripts/unmagic/components/clipboard.js +63 -0
  6. data/app/assets/javascripts/unmagic/components/confirm.js +102 -0
  7. data/app/assets/javascripts/unmagic/components/dialog.js +53 -0
  8. data/app/assets/javascripts/unmagic/components/menu.js +139 -0
  9. data/app/assets/javascripts/unmagic/components/modal.js +182 -0
  10. data/app/assets/javascripts/unmagic/components/tabs.js +94 -0
  11. data/app/assets/javascripts/unmagic/components/time.js +169 -0
  12. data/app/assets/javascripts/unmagic/components/toasts.js +171 -0
  13. data/app/assets/javascripts/unmagic/components/tooltip.js +133 -0
  14. data/app/assets/javascripts/unmagic/components/uuid_input.js +70 -0
  15. data/app/assets/javascripts/unmagic/components.js +19 -0
  16. data/app/assets/stylesheets/unmagic/components.css +927 -0
  17. data/config/importmap.rb +5 -1
  18. data/lib/unmagic/components/action_view_helpers.rb +373 -0
  19. data/lib/unmagic/components/autogrow.rb +13 -0
  20. data/lib/unmagic/components/badge.rb +21 -0
  21. data/lib/unmagic/components/button.rb +28 -0
  22. data/lib/unmagic/components/callout.rb +60 -0
  23. data/lib/unmagic/components/card.rb +67 -0
  24. data/lib/unmagic/components/configuration.rb +19 -1
  25. data/lib/unmagic/components/confirm_template.rb +46 -0
  26. data/lib/unmagic/components/copy_button.rb +50 -0
  27. data/lib/unmagic/components/detail_list.rb +13 -3
  28. data/lib/unmagic/components/dialog.rb +79 -0
  29. data/lib/unmagic/components/dialog_responder.rb +46 -0
  30. data/lib/unmagic/components/engine.rb +13 -4
  31. data/lib/unmagic/components/form_builder.rb +24 -0
  32. data/lib/unmagic/components/icons.rb +40 -0
  33. data/lib/unmagic/components/local_time.rb +64 -0
  34. data/lib/unmagic/components/menu.rb +82 -0
  35. data/lib/unmagic/components/modal.rb +75 -0
  36. data/lib/unmagic/components/page_header.rb +96 -0
  37. data/lib/unmagic/components/skeleton.rb +97 -0
  38. data/lib/unmagic/components/tabs.rb +95 -0
  39. data/lib/unmagic/components/toast.rb +54 -0
  40. data/lib/unmagic/components/toasts.rb +46 -0
  41. data/lib/unmagic/components/tooltip.rb +32 -0
  42. data/lib/unmagic/components/turbo_stream_actions.rb +19 -0
  43. data/lib/unmagic/components/uuid_input.rb +24 -0
  44. data/lib/unmagic/components/version.rb +1 -1
  45. data/lib/unmagic/components.rb +21 -0
  46. metadata +42 -7
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2e66d8f2c875d1c138f1a7921f52e7ec3e77a44b834eb6026025d40dec4ab9ab
4
- data.tar.gz: c291912ef44869d6216760661a32b2046fa8820e718acffcd5a6d2cf7fbecc2a
3
+ metadata.gz: 73a26932047bf5f5019925414115576c98d199f51e434ebae9e614423ba67022
4
+ data.tar.gz: 93be93b5f8b3c9c42c8224a0ec229635a270a89f5319477e0b99970cb4ab4e8c
5
5
  SHA512:
6
- metadata.gz: fe94b2574228893a83a514cdd07f6608e2541710494e10775926e784c6dced8c83d9e3f6f47f0ad4a51845e26d1c2255fadbc3c120955b8980829b2467c645ca
7
- data.tar.gz: fe2a0c8fe420bf563d69d7ce8fc0cde4001decf52d141c57e91407b6666ad40b15862f846315232511400fbadddbdde88e3aa512029d8f77cc1834635ac2f3b4
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.1.0...HEAD
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