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
data/config/importmap.rb CHANGED
@@ -1,3 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- pin "unmagic/components/upsert", to: "unmagic/components/upsert.js", preload: true
3
+ # Every component module pinned by name: "unmagic/components" imports them all, and
4
+ # "unmagic/components/<name>" imports one.
5
+ pin "unmagic/components", to: "unmagic/components.js", preload: true
6
+ pin_all_from File.expand_path("../app/assets/javascripts/unmagic/components", __dir__),
7
+ under: "unmagic/components", preload: true
@@ -154,8 +154,381 @@ module Unmagic
154
154
  builder.render
155
155
  end
156
156
 
157
+ # The class string for a button, so the look composes with link_to, button_to
158
+ # and form.submit alike.
159
+ #
160
+ # <%= link_to "New label", new_label_path, class: button_classes(:primary) %>
161
+ #
162
+ # variant: :default, :primary, :ghost, :danger or :icon. size: :small or :large.
163
+ def button_classes(variant = :default, size: nil)
164
+ Components::Button.classes(variant, size: size)
165
+ end
166
+
167
+ # Mounts the shared modal: a <dialog> around the turbo frame modal links load
168
+ # into. Render it once, in the layout.
169
+ #
170
+ # <%= modal_frame %>
171
+ #
172
+ # The dialog opens the moment the frame starts fetching, showing a skeleton
173
+ # until the response lands, and swaps in an error panel with a retry if the
174
+ # load fails. It closes on Escape, a backdrop click, or the panel's close
175
+ # button, and clears the frame as it does. Needs Turbo and the <unmagic-modal>
176
+ # element (import "unmagic/components/modal").
177
+ def modal_frame(id: Components.configuration.modal_frame_id)
178
+ Components::Modal.new(self, id: id).render
179
+ end
180
+
181
+ # link_to, aimed at the shared modal. Takes link_to's arguments.
182
+ #
183
+ # <%= modal_link_to "Edit", edit_label_path(label), class: button_classes %>
184
+ def modal_link_to(name = nil, options = nil, html_options = nil, &block)
185
+ if block
186
+ link_to(name, modal_link_options(options), &block)
187
+ else
188
+ link_to(name, options, modal_link_options(html_options))
189
+ end
190
+ end
191
+
192
+ # A dialog's panel: a titled header with a close button, the body, and an
193
+ # optional footer. This is what a modal link's response renders.
194
+ #
195
+ # <%= dialog title: "Edit label", form: { model: @label } do |dialog, form| %>
196
+ # <%= form.field :name, "Name" %>
197
+ # <% dialog.footer { form.submit } %>
198
+ # <% end %>
199
+ #
200
+ # form: takes form_with's options and builds the form around the whole panel,
201
+ # so a submit in the footer is inside it. Build the form here rather than
202
+ # around the dialog: on a request aimed at the modal frame the dialog wraps
203
+ # itself in that frame, and Turbo keeps only what is inside the frame. That is
204
+ # also why the action can render the same template whether it is opened in the
205
+ # modal or visited directly. Render it with status: :unprocessable_content on a
206
+ # failed save and the dialog stays open showing the errors.
207
+ #
208
+ # Without form: the block is yielded just the panel builder. size: :wide for a
209
+ # larger panel; close: false drops the close button; any other option rides on
210
+ # the panel.
211
+ def dialog(title: nil, form: nil, **options, &block)
212
+ builder = Components::Dialog.new(self, title: title, **options)
213
+ panel =
214
+ if form
215
+ form_with(**form) { |form_builder| builder.render(capture(builder, form_builder, &block)) }
216
+ else
217
+ builder.render(capture(builder, &block))
218
+ end
219
+
220
+ frame_id = Components.configuration.modal_frame_id
221
+ if respond_to?(:turbo_frame_request_id) && turbo_frame_request_id == frame_id
222
+ turbo_frame_tag(frame_id) { panel }
223
+ else
224
+ panel
225
+ end
226
+ end
227
+
228
+ # A dialog already on the page, opened without a request.
229
+ #
230
+ # <%= dialog_button "What's this?", dialog: "scopes_help", class: button_classes(:ghost) %>
231
+ #
232
+ # <%= dialog_tag "scopes_help", title: "Scopes" do %>
233
+ # <p>Scopes limit what a token can do.</p>
234
+ # <% end %>
235
+ #
236
+ # The block is yielded the panel builder, for a footer. Extra options ride on
237
+ # the <dialog>. Needs import "unmagic/components/dialog".
238
+ def dialog_tag(id, title: nil, size: :default, close: true, **options, &block)
239
+ builder = Components::Dialog.new(self, title: title, size: size, close: close)
240
+ panel = builder.render(capture(builder, &block))
241
+
242
+ options[:class] = class_names("UnmagicDialogBox", options[:class])
243
+ options[:"aria-labelledby"] ||= builder.title_id if builder.titled?
244
+ options[:data] = (options[:data] || {}).merge(unmagic_dialog: "")
245
+
246
+ tag.dialog(panel, id: id, **options)
247
+ end
248
+
249
+ # A button that opens the dialog_tag with the given id.
250
+ def dialog_button(content = nil, dialog:, **options, &block)
251
+ options[:data] = (options[:data] || {}).merge(unmagic_dialog_open: dialog)
252
+
253
+ tag.button(
254
+ block ? capture(&block) : content,
255
+ type: "button", "aria-haspopup": "dialog", "aria-controls": dialog, **options
256
+ )
257
+ end
258
+
259
+ # A small pill of text: a status, a count, a label.
260
+ #
261
+ # <%= badge "Draft" %>
262
+ # <%= badge "Overdue", tone: :bad %>
263
+ #
264
+ # tone: :neutral (default), :good, :warn, :bad, :info or :accent. Other
265
+ # options go on the <span>.
266
+ def badge(content = nil, tone: :neutral, **options, &block)
267
+ options[:class] = class_names(Components::Badge.classes(tone), options[:class])
268
+ tag.span(block ? capture(&block) : content, **options)
269
+ end
270
+
271
+ # A bordered surface for a section of a page.
272
+ #
273
+ # <%= card title: "Members" do |card| %>
274
+ # <% card.actions { link_to "Invite", new_invitation_path, class: button_classes(:small) } %>
275
+ # <%= table_for @members do |table| %>...<% end %>
276
+ # <% card.footer { "3 of 5 seats used" } %>
277
+ # <% end %>
278
+ #
279
+ # flush: true drops the body's padding, for a table or list that runs edge to
280
+ # edge. href: makes the whole card one link, for a row that opens a record;
281
+ # nothing inside it should then be a link or button of its own. Other options
282
+ # go on the card.
283
+ def card(title: nil, href: nil, flush: false, **options, &block)
284
+ builder = Components::Card.new(self, title: title, href: href, flush: flush, **options)
285
+ builder.render(block ? capture(builder, &block) : nil)
286
+ end
287
+
288
+ # Blocks out an interface while it loads. The block is yielded a builder whose
289
+ # shapes you arrange with your own markup, the way form_for yields f:
290
+ #
291
+ # <%= skeleton label: "Loading candidate" do |s| %>
292
+ # <div class="flex items-center gap-3">
293
+ # <%= s.circle size: "3rem" %>
294
+ # <div class="flex-1"><%= s.text width: "60%" %><%= s.text width: "40%" %></div>
295
+ # </div>
296
+ # <%= s.text lines: 2 %>
297
+ # <% end %>
298
+ #
299
+ # s.text is a line sized by the font around it (lines: for a paragraph);
300
+ # s.circle an avatar (size:); s.block an image or chart (height:, width:);
301
+ # s.button a button_classes button (size: :small / :large). Each takes class:
302
+ # and style: too. The shapes are hidden from screen readers, which hear label:
303
+ # ("Loading…") once instead. Other options go on the wrapper.
304
+ #
305
+ # The same shapes stand alone as skeleton_text, skeleton_circle, skeleton_block
306
+ # and skeleton_button. detail_list, page_header and card take skeleton: true
307
+ # to render a skeleton version of themselves.
308
+ def skeleton(label: nil, **options, &block)
309
+ builder = Components::Skeleton.new(self)
310
+ Components::Skeleton.group(self, label: label, **options) { capture(builder, &block) }
311
+ end
312
+
313
+ # A line of text (or lines: of them) outside a skeleton block.
314
+ def skeleton_text(width: nil, lines: 1, **options)
315
+ Components::Skeleton.new(self).text(width: width, lines: lines, **options)
316
+ end
317
+
318
+ # An avatar-sized circle outside a skeleton block.
319
+ def skeleton_circle(size: "2.5rem", **options)
320
+ Components::Skeleton.new(self).circle(size: size, **options)
321
+ end
322
+
323
+ # A rectangle outside a skeleton block.
324
+ def skeleton_block(height: "8rem", width: nil, **options)
325
+ Components::Skeleton.new(self).block(height: height, width: width, **options)
326
+ end
327
+
328
+ # A button-sized shape outside a skeleton block.
329
+ def skeleton_button(size: nil, width: nil, **options)
330
+ Components::Skeleton.new(self).button(size: size, width: width, **options)
331
+ end
332
+
333
+ # The top of a page. The block's output becomes the actions on the right.
334
+ #
335
+ # <%= page_header title: @label.name, description: "Applied to 12 issues.",
336
+ # back: { text: "Labels", path: labels_path } do |header| %>
337
+ # <% header.badge "Archived", tone: :warn if @label.archived? %>
338
+ # <%= modal_link_to "Edit", edit_label_path(@label), class: button_classes %>
339
+ # <% end %>
340
+ #
341
+ # The builder also takes title and description blocks for markup, and leading
342
+ # for something before the title, such as an avatar. Other options go on the
343
+ # <header>.
344
+ def page_header(title: nil, description: nil, back: nil, **options, &block)
345
+ builder = Components::PageHeader.new(self, title: title, description: description, back: back, **options)
346
+ builder.render(block ? capture(builder, &block) : nil)
347
+ end
348
+
349
+ # A tinted note stating the state of something in place: a health check, a
350
+ # warning above a form.
351
+ #
352
+ # <%= callout "DNS isn't verified", tone: :warn, badge: "Pending" do %>
353
+ # Add the TXT record below, then check again.
354
+ # <% end %>
355
+ #
356
+ # tone: :neutral (default), :good, :warn, :bad or :info, each with its own
357
+ # icon (neutral has none); icon: false drops it. Other options go on the
358
+ # callout.
359
+ def callout(title = nil, tone: :neutral, badge: nil, icon: true, **options, &block)
360
+ Components::Callout.new(self, title: title, tone: tone, badge: badge, icon: icon, **options)
361
+ .render(block ? capture(&block) : nil)
362
+ end
363
+
364
+ # The dashed blank slate a list or table shows when there is nothing in it.
365
+ # Renders through the same empty_state seam as a table's, so an app that
366
+ # replaces one gets both.
367
+ #
368
+ # <%= empty_state "No invitations yet." %>
369
+ def empty_state(content = nil, **options, &block)
370
+ Components.configuration.empty_state.call(self, block ? capture(&block) : content, **options)
371
+ end
372
+
373
+ # A timestamp shown in the viewer's own locale and time zone.
374
+ #
375
+ # <%= local_time_tag comment.created_at, format: :relative %> "3 hours ago"
376
+ # <%= local_time_tag invoice.due_at, format: :date %> "16 Sept 2026"
377
+ # <%= local_time_tag event.starts_at %> "16 Sept 2026, 4:33 pm"
378
+ #
379
+ # format: :short, :medium (default), :long or :full for a date and time;
380
+ # :date or :time for one of them; :relative for a distance that keeps itself
381
+ # current ("just now", "5 minutes ago", "yesterday"), with the full time in its
382
+ # title. compact: true shortens a relative time to "5m" / "3h". Past a week a
383
+ # relative time settles into a date.
384
+ #
385
+ # The browser does the formatting with Intl, so there is nothing to
386
+ # translate. Until the element upgrades, the server's own rendering in
387
+ # Time.zone shows instead. A blank time renders an em dash. Needs import
388
+ # "unmagic/components/time".
389
+ def local_time_tag(time, format: :medium, compact: false, **options)
390
+ return "—" if time.blank?
391
+
392
+ Components::LocalTime.new(self, time, format: format, compact: compact, **options).render
393
+ end
394
+
395
+ # Explains something on hover or focus.
396
+ #
397
+ # <%= tooltip "canonical", text: "The URL search engines treat as the original." %>
398
+ #
399
+ # <%= tooltip text: "Copy the key" do %>
400
+ # <%= copy_button @key.secret %>
401
+ # <% end %>
402
+ #
403
+ # Plain text content is styled as a term — a dashed underline and a help
404
+ # cursor — and made focusable; block content (a button, an icon) is left as it
405
+ # is. term: overrides that guess. placement: :top (default) or :bottom, and it
406
+ # flips when there's no room. The hint renders in the browser's top layer, so
407
+ # nothing clips it. Needs import "unmagic/components/tooltip".
408
+ def tooltip(content = nil, text:, placement: :top, term: nil, **options, &block)
409
+ term = block.nil? if term.nil?
410
+ Components::Tooltip.new(self, text: text, placement: placement, term: term, **options)
411
+ .render(block ? capture(&block) : content)
412
+ end
413
+
414
+ # A dropdown of actions.
415
+ #
416
+ # <%= menu do |menu| %>
417
+ # <% menu.link "Edit", edit_job_path(@job) %>
418
+ # <% menu.divider %>
419
+ # <% menu.button "Delete", job_path(@job), method: :delete, tone: :danger,
420
+ # form: { data: { turbo_confirm: "Delete this job?" } } %>
421
+ # <% end %>
422
+ #
423
+ # With no label the trigger is a ⋮ icon button labelled "More actions"; pass
424
+ # one for a text button with a chevron. align: :end (default) lines the panel
425
+ # up with the trigger's right edge, :start with its left. link and button take
426
+ # link_to's and button_to's arguments, plus tone: :danger.
427
+ #
428
+ # It closes on an outside click, Escape, choosing an item, or a Turbo
429
+ # navigation. Arrow keys, Home and End move between items, and opening it from
430
+ # the keyboard focuses the first. Other options go on the element. Needs import
431
+ # "unmagic/components/menu".
432
+ def menu(label = nil, align: :end, **options, &block)
433
+ builder = Components::Menu.new(self, label: label, align: align, **options)
434
+ capture(builder, &block)
435
+ builder.render
436
+ end
437
+
438
+ # A row of tabs.
439
+ #
440
+ # <%= tabs id: "response" do |tabs| %>
441
+ # <% tabs.tab "Body" %>
442
+ # <% tabs.tab "Headers" %>
443
+ # <% tabs.tab "Preview", disabled: "HTML only" %>
444
+ # <% tabs.panel do %>...<% end %>
445
+ # <% tabs.panel do %>...<% end %>
446
+ # <% end %>
447
+ #
448
+ # Panels pair with the enabled tabs in order; a tab with disabled: shows its
449
+ # reason and takes no panel. active: true picks the first tab shown. Arrow
450
+ # keys, Home and End move between tabs. With an id:, the chosen tab is
451
+ # remembered for the tab's session and survives a morph refresh.
452
+ #
453
+ # Tabs with href: are links to separate pages instead, rendered on the server
454
+ # with no script; mark the current one active: true.
455
+ #
456
+ # <%= tabs do |tabs| %>
457
+ # <% tabs.tab "All", href: invitations_path, active: @status.nil? %>
458
+ # <% tabs.tab "Replied", href: invitations_path(status: "replied"), active: @status == "replied" %>
459
+ # <% end %>
460
+ #
461
+ # Needs import "unmagic/components/tabs" for panels.
462
+ def tabs(id: nil, **options, &block)
463
+ builder = Components::Tabs.new(self, id: id, **options)
464
+ capture(builder, &block)
465
+ builder.render
466
+ end
467
+
468
+ # A button that copies text to the clipboard, showing a check for a moment
469
+ # once it has.
470
+ #
471
+ # <%= copy_button @key.secret %>
472
+ # <%= copy_button from: "install_command" do %>Copy command<% end %>
473
+ #
474
+ # from: copies the value of the input, or the text of the element, with that
475
+ # id when clicked, so the text isn't duplicated into an attribute. Without a
476
+ # block it's an icon button labelled label: ("Copy"). Other options go on the
477
+ # <button>. It fires unmagic-clipboard:copy, or unmagic-clipboard:error when
478
+ # the browser refuses. Needs import "unmagic/components/clipboard".
479
+ def copy_button(text = nil, from: nil, label: nil, **options, &block)
480
+ Components::CopyButton.new(self, text: text, from: from, label: label, **options)
481
+ .render(block ? capture(&block) : nil)
482
+ end
483
+
484
+ # text_area_tag, growing with its content. See FormBuilder#autogrow_text_area.
485
+ def autogrow_text_area_tag(name, content = nil, **options)
486
+ Components::Autogrow.wrap(self, text_area_tag(name, content, options))
487
+ end
488
+
489
+ # A hidden field holding a fresh UUIDv7, outside a form builder. See
490
+ # FormBuilder#uuid_field.
491
+ #
492
+ # <%= uuid_input_tag "message[id]" %>
493
+ def uuid_input_tag(name, **options)
494
+ Components::UuidInput.new(self, name, **options).render
495
+ end
496
+
497
+ # Turns the request's flashes into toasts. Render it once, in the layout.
498
+ #
499
+ # <%= flash_toasts %>
500
+ #
501
+ # Set flash[:notice] or flash[:alert] as usual and it pops up, dismisses
502
+ # itself after duration: milliseconds, and survives a morph refresh while it
503
+ # is on screen. Hovering or focusing a toast holds it open. The tone comes
504
+ # from config.flash_tones (notice is :good, alert :bad).
505
+ #
506
+ # Pass the flashes to show when some aren't meant for the user:
507
+ #
508
+ # <%= flash_toasts flash.to_hash.except("copy_link") %>
509
+ #
510
+ # Pop one from a stream with turbo_stream.toast. Needs import
511
+ # "unmagic/components/toasts".
512
+ def flash_toasts(flashes = flash, duration: 5000)
513
+ Components::Toasts.new(self, flashes, duration: duration).render
514
+ end
515
+
516
+ # Server-rendered words for the confirm dialog that replaces window.confirm for
517
+ # data-turbo-confirm. Render it once in the layout when the defaults ("Are you
518
+ # sure?", "Cancel", "Confirm") need translating; without it the dialog uses
519
+ # them in English. Needs import "unmagic/components/confirm".
520
+ def confirm_dialog_template
521
+ Components::ConfirmTemplate.new(self).render
522
+ end
523
+
157
524
  private
158
525
 
526
+ def modal_link_options(html_options)
527
+ html_options = (html_options || {}).dup
528
+ html_options[:data] = { turbo_frame: Components.configuration.modal_frame_id }.merge(html_options[:data] || {})
529
+ html_options
530
+ end
531
+
159
532
  def table_pagy_for(collection, paginate)
160
533
  case paginate
161
534
  when true then Components.configuration.pagy_for.call(self, collection)
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ # Wraps a textarea in the <unmagic-autogrow> element that grows it with its
6
+ # content. See FormBuilder#autogrow_text_area.
7
+ module Autogrow
8
+ def self.wrap(view, textarea)
9
+ view.content_tag("unmagic-autogrow", textarea, class: "UnmagicAutogrow")
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ # The class string for a badge. See ActionViewHelpers#badge.
6
+ #
7
+ # The look lives in CSS rather than in the helper so markup that can only carry
8
+ # a class name — a tooltip trigger, generated HTML — can wear it too.
9
+ module Badge
10
+ TONES = %i[neutral good warn bad info accent].freeze
11
+
12
+ def self.classes(tone = :neutral)
13
+ unless TONES.include?(tone)
14
+ raise ArgumentError, "unknown badge tone #{tone.inspect} (expected one of #{TONES.inspect})"
15
+ end
16
+
17
+ tone == :neutral ? "UnmagicBadge" : "UnmagicBadge UnmagicBadge--#{tone}"
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ # The class string for a button, so the look composes with link_to, button_to
6
+ # and form.submit alike. See ActionViewHelpers#button_classes.
7
+ #
8
+ # The components call this directly rather than through the view helper: a
9
+ # host with a button_classes helper of its own shadows the gem's, and its
10
+ # signature is nobody's business here.
11
+ module Button
12
+ VARIANTS = %i[default primary ghost danger icon].freeze
13
+ SIZES = %i[small large].freeze
14
+
15
+ def self.classes(variant = :default, size: nil)
16
+ unless VARIANTS.include?(variant)
17
+ raise ArgumentError, "unknown button variant #{variant.inspect} (expected one of #{VARIANTS.inspect})"
18
+ end
19
+ unless size.nil? || SIZES.include?(size)
20
+ raise ArgumentError, "unknown button size #{size.inspect} (expected one of #{SIZES.inspect})"
21
+ end
22
+
23
+ [ "UnmagicButton", ("UnmagicButton--#{variant}" unless variant == :default), ("UnmagicButton--#{size}" if size) ]
24
+ .compact.join(" ")
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ # A tinted callout stating the state of something in place: a leading icon
6
+ # beside the message, optionally under a title with a badge. See
7
+ # ActionViewHelpers#callout.
8
+ class Callout
9
+ TONES = %i[neutral good warn bad info].freeze
10
+
11
+ def initialize(view, title: nil, tone: :neutral, badge: nil, icon: true, **options)
12
+ unless TONES.include?(tone)
13
+ raise ArgumentError, "unknown callout tone #{tone.inspect} (expected one of #{TONES.inspect})"
14
+ end
15
+
16
+ @view = view
17
+ @title = title
18
+ @tone = tone
19
+ @badge = badge
20
+ @icon = icon
21
+ @options = options
22
+ end
23
+
24
+ def render(body)
25
+ classes = view.class_names("UnmagicCallout", "UnmagicCallout--#{@tone}", @options[:class])
26
+
27
+ tag.div(**@options, class: classes) do
28
+ safe_join [
29
+ icon,
30
+ tag.div(class: "UnmagicCallout__content") do
31
+ safe_join [ heading, tag.div(body, class: "UnmagicCallout__body") ].compact
32
+ end
33
+ ].compact
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :view
40
+
41
+ delegate :tag, :safe_join, to: :view, private: true
42
+
43
+ def icon
44
+ name = Icons::TONE_ICONS[@tone]
45
+ Icons.svg(view, name, class: "UnmagicCallout__icon") if @icon && name
46
+ end
47
+
48
+ def heading
49
+ return if @title.blank? && @badge.blank?
50
+
51
+ tag.div class: "UnmagicCallout__heading" do
52
+ safe_join [
53
+ (tag.p(@title, class: "UnmagicCallout__title") if @title.present?),
54
+ (tag.span(@badge, class: Badge.classes(@tone)) if @badge.present?)
55
+ ].compact
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ # A bordered surface with an optional titled header, a body, and a footer of
6
+ # actions. See ActionViewHelpers#card.
7
+ class Card
8
+ def initialize(view, title: nil, href: nil, flush: false, skeleton: false, **options)
9
+ @view = view
10
+ @title = title
11
+ @href = href
12
+ @flush = flush
13
+ @skeleton = skeleton
14
+ @options = options
15
+ @actions = nil
16
+ @footer = nil
17
+ end
18
+
19
+ # Controls beside the title, on the right of the header.
20
+ def actions(content = nil, &block)
21
+ @actions = block ? view.capture(&block) : content
22
+ nil
23
+ end
24
+
25
+ # A tinted row along the bottom.
26
+ def footer(content = nil, &block)
27
+ @footer = block ? view.capture(&block) : content
28
+ nil
29
+ end
30
+
31
+ # As a skeleton the card keeps its title and chrome, and a body with nothing in
32
+ # it becomes three lines of text; pass a block to block the body out yourself.
33
+ # The loading label sits on the card itself rather than on a wrapper, so a
34
+ # skeleton card still stretches to its row in a grid.
35
+ def render(body)
36
+ classes = view.class_names("UnmagicCard", { "UnmagicCard--link" => @href }, @options[:class])
37
+ body = Skeleton.new(view).text(lines: 3) if @skeleton && body.blank?
38
+
39
+ view.content_tag(@href ? :a : :section, **@options, href: @href, role: ("status" if @skeleton), class: classes) do
40
+ safe_join [
41
+ (Skeleton.hidden_label(view) if @skeleton),
42
+ header,
43
+ tag.div(body, class: view.class_names("UnmagicCard__body", { "UnmagicCard__body--flush" => @flush })),
44
+ (tag.div(@footer, class: "UnmagicCard__footer") if @footer.present?)
45
+ ].compact
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ attr_reader :view
52
+
53
+ delegate :tag, :safe_join, to: :view, private: true
54
+
55
+ def header
56
+ return if @title.blank? && @actions.blank?
57
+
58
+ tag.header class: "UnmagicCard__header" do
59
+ safe_join [
60
+ (tag.h2(@title, class: "UnmagicCard__title") if @title.present?),
61
+ (tag.div(@actions, class: "UnmagicCard__actions") if @actions.present?)
62
+ ].compact
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -7,7 +7,25 @@ module Unmagic
7
7
  # library. An app that already owns these concerns points them at its own
8
8
  # versions in an initializer.
9
9
  class Configuration
10
- attr_writer :empty_state, :pagination, :pagy_for, :submit_class
10
+ attr_writer :empty_state, :pagination, :pagy_for, :submit_class, :modal_frame_id, :flash_tones
11
+
12
+ # The tone each flash type's toast wears, keyed by the flash type as a string.
13
+ # A type that isn't listed is :info.
14
+ def flash_tones
15
+ @flash_tones ||= {
16
+ "notice" => :good, "success" => :good,
17
+ "alert" => :bad, "error" => :bad,
18
+ "warning" => :warn,
19
+ "info" => :info
20
+ }
21
+ end
22
+
23
+ # The id of the turbo frame the shared modal loads into — what modal_link_to
24
+ # targets, and what a `dialog` checks to know it is being rendered into the
25
+ # modal.
26
+ def modal_frame_id
27
+ @modal_frame_id ||= "modal"
28
+ end
11
29
 
12
30
  # Renders a table's blank slate. Called with (view, content), where content
13
31
  # is already-captured markup or a plain string.
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ # The markup the confirm element clones for each data-turbo-confirm prompt,
6
+ # rendered on the server so its words go through I18n. Without it on the page the
7
+ # element falls back to the same markup in English. See
8
+ # ActionViewHelpers#confirm_dialog_template.
9
+ class ConfirmTemplate
10
+ def initialize(view)
11
+ @view = view
12
+ end
13
+
14
+ def render
15
+ tag.template data: { unmagic_confirm: "" } do
16
+ tag.dialog class: "UnmagicDialogBox", role: "alertdialog", data: { unmagic_dialog: "" } do
17
+ tag.form method: "dialog", class: "UnmagicDialog" do
18
+ safe_join [
19
+ tag.header(class: "UnmagicDialog__header") do
20
+ tag.h2(t(:title, "Are you sure?"), class: "UnmagicDialog__title", data: { unmagic_confirm_title: "" })
21
+ end,
22
+ tag.div(class: "UnmagicDialog__body") { tag.p(data: { unmagic_confirm_message: "" }) },
23
+ tag.div(class: "UnmagicDialog__footer") do
24
+ safe_join [
25
+ tag.button(t(:cancel, "Cancel"), value: "cancel", class: Button.classes,
26
+ data: { unmagic_confirm_cancel: "" }),
27
+ tag.button(t(:accept, "Confirm"), value: "confirm", class: Button.classes(:primary),
28
+ data: { unmagic_confirm_accept: "" })
29
+ ]
30
+ end
31
+ ]
32
+ end
33
+ end
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :view
40
+
41
+ delegate :tag, :safe_join, to: :view, private: true
42
+
43
+ def t(key, default) = I18n.t("unmagic.components.confirm.#{key}", default: default)
44
+ end
45
+ end
46
+ end