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
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ # A button that copies text to the clipboard. See ActionViewHelpers#copy_button.
6
+ class CopyButton
7
+ def initialize(view, text:, from:, label:, **options)
8
+ raise ArgumentError, "copy_button needs the text to copy, or from: an element id" if text.nil? && from.nil?
9
+
10
+ @view = view
11
+ @text = text
12
+ @from = from
13
+ @label = label || I18n.t("unmagic.components.clipboard.copy", default: "Copy")
14
+ @options = options
15
+ end
16
+
17
+ # The live region says "Copied" for a screen reader, since the icon swap is
18
+ # only seen.
19
+ def render(content)
20
+ copied = I18n.t("unmagic.components.clipboard.copied", default: "Copied")
21
+
22
+ view.content_tag("unmagic-clipboard", value: @text, for: @from, class: "UnmagicClipboard",
23
+ data: { copied_label: copied }) do
24
+ safe_join [ button(content), tag.span(class: "UnmagicVisuallyHidden", "aria-live": "polite") ]
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :view
31
+
32
+ delegate :tag, :safe_join, to: :view, private: true
33
+
34
+ def button(content)
35
+ if content
36
+ tag.button(content, type: "button", **@options,
37
+ class: view.class_names(Button.classes, "UnmagicClipboard__button", @options[:class]))
38
+ else
39
+ tag.button(type: "button", **@options, "aria-label": @label, title: @label,
40
+ class: view.class_names(Button.classes(:icon), "UnmagicClipboard__button", @options[:class])) do
41
+ safe_join [
42
+ Icons.svg(view, :copy, class: "UnmagicClipboard__idle"),
43
+ Icons.svg(view, :check, class: "UnmagicClipboard__done")
44
+ ]
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -7,13 +7,17 @@ module Unmagic
7
7
  class DetailList
8
8
  VARIANTS = %i[inline stacked].freeze
9
9
 
10
- def initialize(view, variant:, **options)
10
+ # Values vary in length, so a skeleton's bars do too.
11
+ SKELETON_WIDTHS = %w[55% 40% 70% 35% 60%].freeze
12
+
13
+ def initialize(view, variant:, skeleton: false, **options)
11
14
  unless VARIANTS.include?(variant)
12
15
  raise ArgumentError, "unknown detail_list variant #{variant.inspect} (expected one of #{VARIANTS.inspect})"
13
16
  end
14
17
 
15
18
  @view = view
16
19
  @variant = variant
20
+ @skeleton = skeleton
17
21
  @classes = options[:class]
18
22
  @items = []
19
23
  end
@@ -30,9 +34,12 @@ module Unmagic
30
34
  @classes,
31
35
  )
32
36
 
33
- tag.dl class: classes do
37
+ list = tag.dl class: classes do
34
38
  safe_join @items.map { |item| stacked? ? stacked_item(item) : inline_item(item) }
35
39
  end
40
+
41
+ # A <dl> may only hold its items, so the loading label goes around it.
42
+ @skeleton ? Skeleton.group(view) { list } : list
36
43
  end
37
44
 
38
45
  private
@@ -53,8 +60,11 @@ module Unmagic
53
60
  end
54
61
  end
55
62
 
63
+ # A skeleton keeps the real labels and stands a bar in for each value.
56
64
  def value(item)
57
- if item.block
65
+ if @skeleton
66
+ Skeleton.new(view).text(width: SKELETON_WIDTHS[@items.index(item) % SKELETON_WIDTHS.size])
67
+ elsif item.block
58
68
  view.capture(&item.block).presence || "—"
59
69
  else
60
70
  item.value
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Unmagic
6
+ module Components
7
+ # The panel chrome inside a dialog: a titled header with a close button, the
8
+ # body, and an optional footer of actions. Shared by the frame modal (`dialog`),
9
+ # the same-page dialog (`dialog_tag`), and the modal's own loading and error
10
+ # states, so every dialog is framed identically. See ActionViewHelpers#dialog.
11
+ class Dialog
12
+ SIZES = %i[default wide].freeze
13
+
14
+ attr_reader :title_id
15
+
16
+ def titled? = @title.present?
17
+
18
+ def initialize(view, title: nil, size: :default, close: true, **options)
19
+ unless SIZES.include?(size)
20
+ raise ArgumentError, "unknown dialog size #{size.inspect} (expected one of #{SIZES.inspect})"
21
+ end
22
+
23
+ @view = view
24
+ @title = title
25
+ @size = size
26
+ @close = close
27
+ @options = options
28
+ @title_id = "unmagic_dialog_#{SecureRandom.hex(4)}_title"
29
+ @footer = nil
30
+ end
31
+
32
+ # The bottom row of actions — usually the form's submit.
33
+ def footer(content = nil, &block)
34
+ @footer = block ? view.capture(&block) : content
35
+ nil
36
+ end
37
+
38
+ def render(body)
39
+ classes = view.class_names("UnmagicDialog", { "UnmagicDialog--wide" => @size == :wide }, @options[:class])
40
+
41
+ tag.div(**@options, class: classes) do
42
+ safe_join [
43
+ header,
44
+ tag.div(body, class: "UnmagicDialog__body"),
45
+ (tag.div(@footer, class: "UnmagicDialog__footer") if @footer.present?)
46
+ ].compact
47
+ end
48
+ end
49
+
50
+ # The close button on its own, for chrome built by hand.
51
+ def self.close_button(view)
52
+ label = I18n.t("unmagic.components.dialog.close", default: "Close")
53
+
54
+ view.tag.button(
55
+ Icons.svg(view, :x),
56
+ type: "button", class: "#{Button.classes(:icon)} UnmagicDialog__close",
57
+ "aria-label": label, data: { unmagic_dialog_close: "" }
58
+ )
59
+ end
60
+
61
+ private
62
+
63
+ attr_reader :view
64
+
65
+ delegate :tag, :safe_join, to: :view, private: true
66
+
67
+ def header
68
+ return if @title.blank? && !@close
69
+
70
+ tag.header class: "UnmagicDialog__header" do
71
+ safe_join [
72
+ (tag.h2(@title, id: title_id, class: "UnmagicDialog__title") if @title.present?),
73
+ (self.class.close_button(view) if @close)
74
+ ].compact
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Unmagic
6
+ module Components
7
+ # The success response for a create or update whose form opened in the frame
8
+ # modal. A turbo_stream.refresh morphs the page in place — updating the list the
9
+ # dialog was launched from — and the modal closes in that same render, so the
10
+ # page repaints once, straight to the new state. A visit without Turbo falls back
11
+ # to a plain redirect.
12
+ #
13
+ # class LabelsController < ApplicationController
14
+ # include Unmagic::Components::DialogResponder
15
+ #
16
+ # def update
17
+ # if @label.update(label_params)
18
+ # refresh_or_redirect labels_path, notice: "Label saved."
19
+ # else
20
+ # render :edit, status: :unprocessable_content
21
+ # end
22
+ # end
23
+ # end
24
+ #
25
+ # The request_id is dropped so the refresh isn't suppressed on the very tab that
26
+ # submitted it — Turbo's guard against echoing a tab's own broadcasts keys off it.
27
+ #
28
+ # A plain redirect_to works from a modal form too: the modal visits the page it
29
+ # lands on and closes. The refresh is the smoother of the two, keeping scroll
30
+ # position and any state the page holds.
31
+ module DialogResponder
32
+ extend ActiveSupport::Concern
33
+
34
+ private
35
+
36
+ def refresh_or_redirect(url, **flash_options)
37
+ flash_options.each { |type, message| flash[type] = message }
38
+
39
+ respond_to do |format|
40
+ format.turbo_stream { render turbo_stream: turbo_stream.refresh(request_id: nil) }
41
+ format.html { redirect_to url }
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -13,6 +13,14 @@ module Unmagic
13
13
  end
14
14
  end
15
15
 
16
+ # turbo_stream.toast. turbo-rails runs this hook when its tag builder loads, so
17
+ # an app without Turbo never sees it.
18
+ initializer "unmagic_components.turbo_streams" do
19
+ ActiveSupport.on_load(:turbo_streams_tag_builder) do
20
+ include Unmagic::Components::TurboStreamActions
21
+ end
22
+ end
23
+
16
24
  # The components' stylesheet is a plain CSS file, deliberately not part of any
17
25
  # Tailwind build: Tailwind only generates classes it can see, and it does not
18
26
  # scan installed gems. Serving it through the asset pipeline keeps the gem's
@@ -24,10 +32,11 @@ module Unmagic
24
32
  app.config.assets.paths << Engine.root.join("app/assets/javascripts")
25
33
  end
26
34
 
27
- # The one piece of JavaScript here: the `upsert` Turbo Stream action a live
28
- # table's broadcasts use. Pinned rather than served so the host imports it by
29
- # name; importmap-rails is optional, and an app without it simply never sees
30
- # the pin (and cannot broadcast into a table).
35
+ # The components' JavaScript: custom elements and the `upsert` Turbo Stream
36
+ # action. Pinned rather than served so the host imports each by name
37
+ # ("unmagic/components", or "unmagic/components/modal"). importmap-rails is
38
+ # optional; an app without it never sees the pins and wires the files up its
39
+ # own way.
31
40
  initializer "unmagic_components.importmap", before: "importmap" do |app|
32
41
  next unless app.config.respond_to?(:importmap)
33
42
 
@@ -133,6 +133,30 @@ module Unmagic
133
133
  @template.content_tag(:button, options) { block ? @template.capture(&block) : value }
134
134
  end
135
135
 
136
+ # A textarea that grows with what's typed, from its rows up to its CSS
137
+ # max-height, then scrolls. Works as a field's control too:
138
+ #
139
+ # <%= form.field :body, "Message", as: :autogrow_text_area, rows: 2 %>
140
+ #
141
+ # Needs import "unmagic/components/autogrow".
142
+ def autogrow_text_area(method, options = {})
143
+ Components::Autogrow.wrap(@template, text_area(method, options))
144
+ end
145
+
146
+ # A hidden field holding a fresh, time-ordered UUIDv7, so the form submits an
147
+ # id the client already knows: to match an optimistically rendered element to
148
+ # the record the server creates under the same id.
149
+ #
150
+ # <%= form.uuid_field :id %>
151
+ #
152
+ # A new id is minted when the element upgrades and every time the form is
153
+ # reset, so a form that clears itself after each submit sends a fresh one.
154
+ # Without the script the server's own id is sent. Needs import
155
+ # "unmagic/components/uuid_input".
156
+ def uuid_field(method, options = {})
157
+ Components::UuidInput.new(@template, field_name(method), **options).render
158
+ end
159
+
136
160
  # The value to show for a field, whether the object is a model or something
137
161
  # hash-ish (a JSON Schema instance, a params object). Reads what the user
138
162
  # actually typed when the object tracks that, so a rejected cast still shows
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ # The handful of glyphs the components draw for themselves — a close button, a
6
+ # toast's tone, a copy button's confirmation. Inline SVG so the gem depends on no
7
+ # icon library and no host helper. Paths are from Lucide (ISC licence).
8
+ module Icons
9
+ PATHS = {
10
+ x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
11
+ check: '<path d="M20 6 9 17l-5-5"/>',
12
+ circle_check: '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
13
+ circle_x: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>',
14
+ triangle_alert: '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/>' \
15
+ '<path d="M12 9v4"/><path d="M12 17h.01"/>',
16
+ info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
17
+ copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>' \
18
+ '<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
19
+ rotate: '<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/>',
20
+ ellipsis_vertical: '<circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/>',
21
+ arrow_left: '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
22
+ chevron_down: '<path d="m6 9 6 6 6-6"/>'
23
+ }.freeze
24
+
25
+ # The glyph a tone leads with, where it has one.
26
+ TONE_ICONS = { good: :circle_check, warn: :triangle_alert, bad: :circle_x, info: :info }.freeze
27
+
28
+ def self.svg(view, name, **options)
29
+ paths = PATHS.fetch(name) { raise ArgumentError, "unknown icon #{name.inspect}" }
30
+
31
+ view.tag.svg(
32
+ paths.html_safe, # rubocop:disable Rails/OutputSafety -- the constant markup above, never input
33
+ xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor",
34
+ "stroke-width": 2, "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true",
35
+ **options, class: view.class_names("UnmagicIcon", options[:class])
36
+ )
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ # A timestamp the browser rewrites in the viewer's own locale and time zone.
6
+ # The server renders a readable fallback, in Time.zone, for the moment before
7
+ # the element upgrades. See ActionViewHelpers#local_time_tag.
8
+ class LocalTime
9
+ FORMATS = %i[short medium long full date time relative].freeze
10
+
11
+ # The server fallback for each absolute format, in I18n's time formats.
12
+ I18N_FORMATS = { short: :short, medium: :default, long: :long, full: :long }.freeze
13
+
14
+ def initialize(view, time, format:, compact:, **options)
15
+ unless FORMATS.include?(format)
16
+ raise ArgumentError, "unknown local_time_tag format #{format.inspect} (expected one of #{FORMATS.inspect})"
17
+ end
18
+
19
+ @view = view
20
+ @time = time.in_time_zone
21
+ @format = format
22
+ @compact = compact
23
+ @options = options
24
+ end
25
+
26
+ # The attributes live on the element, where a morph that changes them is
27
+ # seen; the inner <time> keeps the markup meaningful without the script.
28
+ def render
29
+ datetime = @time.utc.iso8601
30
+
31
+ view.content_tag("unmagic-time", datetime: datetime, format: @format, compact: ("" if @compact), **@options) do
32
+ tag.time(fallback, datetime: datetime, title: (I18n.l(@time, format: :long) if relative?))
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :view
39
+
40
+ delegate :tag, to: :view, private: true
41
+
42
+ def relative? = @format == :relative
43
+
44
+ def fallback
45
+ case @format
46
+ when :relative then relative_phrase
47
+ when :date then I18n.l(@time.to_date, format: :long)
48
+ when :time then I18n.l(@time, format: "%H:%M")
49
+ else I18n.l(@time, format: I18N_FORMATS.fetch(@format))
50
+ end
51
+ end
52
+
53
+ def relative_phrase
54
+ distance = view.distance_of_time_in_words(Time.current, @time)
55
+
56
+ if @time.past?
57
+ I18n.t("unmagic.components.time.past", distance: distance, default: "%{distance} ago")
58
+ else
59
+ I18n.t("unmagic.components.time.future", distance: distance, default: "in %{distance}")
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ # A dropdown of actions behind a trigger, built on <details>. See
6
+ # ActionViewHelpers#menu.
7
+ class Menu
8
+ ALIGNMENTS = %i[start end].freeze
9
+ TONES = %i[default danger].freeze
10
+
11
+ def initialize(view, label:, align:, **options)
12
+ unless ALIGNMENTS.include?(align)
13
+ raise ArgumentError, "unknown menu align #{align.inspect} (expected one of #{ALIGNMENTS.inspect})"
14
+ end
15
+
16
+ @view = view
17
+ @label = label
18
+ @align = align
19
+ @options = options
20
+ @items = []
21
+ end
22
+
23
+ # A link item. Takes link_to's arguments, block form included.
24
+ def link(name = nil, url = nil, tone: :default, **options, &block)
25
+ name, url = view.capture(&block), name if block
26
+ @items << view.link_to(name, url, **options, role: "menuitem", class: item_classes(tone, options[:class]))
27
+ nil
28
+ end
29
+
30
+ # A button_to item, for an action that isn't a GET. Takes button_to's
31
+ # arguments, block form included, so form: { data: { turbo_confirm: } } works.
32
+ def button(name = nil, url = nil, tone: :default, **options, &block)
33
+ name, url = view.capture(&block), name if block
34
+ @items << view.button_to(url, **options, role: "menuitem", class: item_classes(tone, options[:class]),
35
+ form_class: "UnmagicMenu__form") { name }
36
+ nil
37
+ end
38
+
39
+ def divider
40
+ @items << tag.hr(class: "UnmagicMenu__divider", role: "separator")
41
+ nil
42
+ end
43
+
44
+ def render
45
+ view.content_tag("unmagic-menu", **@options, class: view.class_names("UnmagicMenu", @options[:class])) do
46
+ tag.details class: "UnmagicMenu__details" do
47
+ safe_join [
48
+ trigger,
49
+ tag.div(safe_join(@items), class: "UnmagicMenu__panel UnmagicMenu__panel--#{@align}", role: "menu")
50
+ ]
51
+ end
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ attr_reader :view
58
+
59
+ delegate :tag, :safe_join, to: :view, private: true
60
+
61
+ def trigger
62
+ if @label
63
+ tag.summary(class: "#{Button.classes} UnmagicMenu__trigger", "aria-haspopup": "menu") do
64
+ safe_join [ @label, Icons.svg(view, :chevron_down) ]
65
+ end
66
+ else
67
+ label = I18n.t("unmagic.components.menu.label", default: "More actions")
68
+ tag.summary(Icons.svg(view, :ellipsis_vertical), class: "#{Button.classes(:icon)} UnmagicMenu__trigger",
69
+ "aria-haspopup": "menu", "aria-label": label, title: label)
70
+ end
71
+ end
72
+
73
+ def item_classes(tone, extra)
74
+ unless TONES.include?(tone)
75
+ raise ArgumentError, "unknown menu item tone #{tone.inspect} (expected one of #{TONES.inspect})"
76
+ end
77
+
78
+ view.class_names("UnmagicMenu__item", { "UnmagicMenu__item--danger" => tone == :danger }, extra)
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ # The one shared modal a layout mounts: a native <dialog> holding the turbo frame
6
+ # that modal links load into, plus inert templates for the loading and error
7
+ # states the <unmagic-modal> element swaps in. See ActionViewHelpers#modal_frame.
8
+ class Modal
9
+ def initialize(view, id:)
10
+ @view = view
11
+ @id = id
12
+ end
13
+
14
+ # overflow stays visible (see the CSS) so a dropdown inside a dialog form isn't
15
+ # clipped; a dialog keeps its content short enough to fit instead.
16
+ def render
17
+ view.content_tag("unmagic-modal") do
18
+ tag.dialog class: "UnmagicDialogBox", data: { unmagic_dialog: "" } do
19
+ safe_join [
20
+ view.turbo_frame_tag(@id),
21
+ tag.template(skeleton, data: { unmagic_modal_skeleton: "" }),
22
+ tag.template(error, data: { unmagic_modal_error: "" })
23
+ ]
24
+ end
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :view
31
+
32
+ delegate :tag, :safe_join, to: :view, private: true
33
+
34
+ # Framed like a real dialog, so the panel is sized before the form arrives and
35
+ # nothing jumps when it does.
36
+ def skeleton
37
+ tag.div class: "UnmagicDialog", role: "status" do
38
+ safe_join [
39
+ tag.span(I18n.t("unmagic.components.modal.loading", default: "Loading…"), class: "UnmagicVisuallyHidden"),
40
+ tag.header(bar("title"), class: "UnmagicDialog__header"),
41
+ tag.div(class: "UnmagicDialog__body") do
42
+ safe_join Array.new(3) { tag.div(safe_join([ bar("label"), bar("input") ]), class: "UnmagicDialog__skeleton-field") }
43
+ end,
44
+ tag.div(bar("button"), class: "UnmagicDialog__footer")
45
+ ]
46
+ end
47
+ end
48
+
49
+ def bar(kind)
50
+ tag.div class: "UnmagicSkeleton UnmagicDialog__skeleton-#{kind}"
51
+ end
52
+
53
+ def error
54
+ panel = Dialog.new(view, title: I18n.t("unmagic.components.modal.error_title", default: "Couldn’t load"),
55
+ role: "alert")
56
+
57
+ panel.footer do
58
+ tag.button type: "button", class: Button.classes(:primary), data: { unmagic_modal_retry: "" } do
59
+ safe_join [ Icons.svg(view, :rotate), I18n.t("unmagic.components.modal.retry", default: "Try again") ]
60
+ end
61
+ end
62
+
63
+ panel.render(
64
+ tag.div(class: "UnmagicDialog__error") do
65
+ safe_join [
66
+ Icons.svg(view, :circle_x),
67
+ tag.p(I18n.t("unmagic.components.modal.error_message",
68
+ default: "Something went wrong loading this. Check your connection and try again."))
69
+ ]
70
+ end
71
+ )
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ # The top of a page: an optional back link, the title with badges beside it,
6
+ # a description, and the page's actions on the right. See
7
+ # ActionViewHelpers#page_header.
8
+ class PageHeader
9
+ def initialize(view, title: nil, description: nil, back: nil, skeleton: false, **options)
10
+ @view = view
11
+ @title = title
12
+ @description = description
13
+ @back = back
14
+ @skeleton = skeleton
15
+ @options = options
16
+ @leading = nil
17
+ @badges = []
18
+ end
19
+
20
+ # Title markup richer than a string — a <code>, a link. Overrides title:.
21
+ def title(content = nil, &block)
22
+ @title = block ? view.capture(&block) : content
23
+ nil
24
+ end
25
+
26
+ # A description with markup in it. Overrides description:.
27
+ def description(content = nil, &block)
28
+ @description = block ? view.capture(&block) : content
29
+ nil
30
+ end
31
+
32
+ # Something before the title, such as an avatar.
33
+ def leading(content = nil, &block)
34
+ @leading = block ? view.capture(&block) : content
35
+ nil
36
+ end
37
+
38
+ # A badge beside the title. Call it once per badge.
39
+ def badge(content = nil, tone: :neutral, &block)
40
+ @badges << tag.span(block ? view.capture(&block) : content, class: Badge.classes(tone))
41
+ nil
42
+ end
43
+
44
+ # As a skeleton, anything given still renders for real, and the rest stands in
45
+ # as shapes: a title bar, a description line (unless description: false) and
46
+ # a button.
47
+ def render(actions)
48
+ actions = Skeleton.new(view).button if @skeleton && actions.blank?
49
+
50
+ tag.header(**@options, role: ("status" if @skeleton),
51
+ class: view.class_names("UnmagicPageHeader", @options[:class])) do
52
+ safe_join [
53
+ (Skeleton.hidden_label(view) if @skeleton),
54
+ back_link,
55
+ tag.div(class: "UnmagicPageHeader__row") do
56
+ safe_join [
57
+ tag.div(safe_join([ heading, description_tag ].compact), class: "UnmagicPageHeader__main"),
58
+ (tag.div(actions, class: "UnmagicPageHeader__actions") if actions.present?)
59
+ ].compact
60
+ end
61
+ ].compact
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ attr_reader :view
68
+
69
+ delegate :tag, :safe_join, to: :view, private: true
70
+
71
+ def back_link
72
+ return unless @back
73
+
74
+ text, path = @back.values_at(:text, :path)
75
+ view.link_to path, class: "UnmagicPageHeader__back" do
76
+ safe_join [ Icons.svg(view, :arrow_left), text ]
77
+ end
78
+ end
79
+
80
+ def heading
81
+ tag.div class: "UnmagicPageHeader__heading" do
82
+ title = @title.presence || (Skeleton.new(view).text(width: "14rem") if @skeleton)
83
+ safe_join [ @leading, tag.h1(title, class: "UnmagicPageHeader__title"), *@badges ].compact
84
+ end
85
+ end
86
+
87
+ def description_tag
88
+ if @description.present?
89
+ tag.div(@description, class: "UnmagicPageHeader__description")
90
+ elsif @skeleton && @description != false
91
+ tag.div(Skeleton.new(view).text(width: "32rem"), class: "UnmagicPageHeader__description")
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end