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
|
@@ -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
|
-
|
|
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
|
|
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
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
# the
|
|
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
|