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,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ # Placeholder shapes for blocking out an interface while it loads. The same
6
+ # object is what `skeleton do |s|` yields and what the standalone helpers
7
+ # (skeleton_text, skeleton_circle, …) call, so the two forms can't differ. See
8
+ # ActionViewHelpers#skeleton.
9
+ #
10
+ # Every shape is hidden from assistive technology; a group says "Loading…" once
11
+ # for all of them.
12
+ class Skeleton
13
+ BUTTON_SIZES = %i[small large].freeze
14
+
15
+ # The last line of a paragraph is shorter, so a block of text reads as one.
16
+ LAST_LINE_WIDTH = "60%"
17
+
18
+ # A status region around some shapes, announced once.
19
+ def self.group(view, label: nil, **options)
20
+ view.tag.div(**options, role: "status", class: view.class_names("UnmagicSkeletonGroup", options[:class])) do
21
+ view.safe_join [ hidden_label(view, label), yield ]
22
+ end
23
+ end
24
+
25
+ # The words a screen reader hears in place of the shapes.
26
+ def self.hidden_label(view, label = nil)
27
+ label ||= I18n.t("unmagic.components.skeleton.loading", default: "Loading…")
28
+ view.tag.span(label, class: "UnmagicVisuallyHidden")
29
+ end
30
+
31
+ def initialize(view)
32
+ @view = view
33
+ end
34
+
35
+ # A line of text, sized by the font it sits in: it takes exactly one line box,
36
+ # so the text that replaces it lands in the same space. lines: renders a
37
+ # paragraph instead, with a shorter last line; width: then applies to the whole.
38
+ # class: and style: go on the line, width: on its bar.
39
+ def text(width: nil, lines: 1, **options)
40
+ raise ArgumentError, "skeleton text needs at least one line (got #{lines.inspect})" unless lines.to_i >= 1
41
+
42
+ return line(width: width, **options) if lines == 1
43
+
44
+ lines = Array.new(lines) { |index| line(width: (LAST_LINE_WIDTH if index == lines - 1)) }
45
+ view.tag.span(view.safe_join(lines), **options, "aria-hidden": "true",
46
+ class: view.class_names("UnmagicSkeletonText", options[:class]), style: style(options[:style], width: width))
47
+ end
48
+
49
+ # An avatar or a round icon.
50
+ def circle(size: "2.5rem", **options)
51
+ shape("circle", width: size, height: size, **options)
52
+ end
53
+
54
+ # An image, a chart, a map: anything rectangular. Full width unless given one.
55
+ def block(height: "8rem", width: nil, **options)
56
+ shape("block", width: width, height: height, **options)
57
+ end
58
+
59
+ # The size of a button_classes button, so a row of actions keeps its height.
60
+ def button(size: nil, width: nil, **options)
61
+ unless size.nil? || BUTTON_SIZES.include?(size)
62
+ raise ArgumentError, "unknown skeleton button size #{size.inspect} (expected one of #{BUTTON_SIZES.inspect})"
63
+ end
64
+
65
+ shape("button", width: width, modifier: ("UnmagicSkeleton--#{size}" if size), **options)
66
+ end
67
+
68
+ private
69
+
70
+ attr_reader :view
71
+
72
+ # One line box of the surrounding font (1lh tall) with the bar centred in it.
73
+ # The bar is thinner than the text so it reads as a placeholder, and the line
74
+ # box, not margins around the bar, supplies the rest of the height: margins
75
+ # would collapse through their container and the skeleton would come out
76
+ # shorter than the text it stands in for.
77
+ def line(width: nil, **options)
78
+ view.tag.span(**options, "aria-hidden": "true",
79
+ class: view.class_names("UnmagicSkeletonLine", options[:class]), style: style(options[:style])) do
80
+ shape("text", width: width)
81
+ end
82
+ end
83
+
84
+ def shape(kind, width: nil, height: nil, modifier: nil, **options)
85
+ view.tag.span(**options, "aria-hidden": "true",
86
+ class: view.class_names("UnmagicSkeleton", "UnmagicSkeleton--#{kind}", modifier, options[:class]),
87
+ style: style(options[:style], width: width, height: height))
88
+ end
89
+
90
+ def style(extra, **dimensions)
91
+ rules = dimensions.compact.map { |property, value| "#{property}: #{value}" }
92
+ rules << extra if extra.present?
93
+ rules.join("; ").presence
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Unmagic
6
+ module Components
7
+ # A row of tabs: either panels switched in the page, or links to separate URLs.
8
+ # See ActionViewHelpers#tabs.
9
+ class Tabs
10
+ def initialize(view, id: nil, **options)
11
+ @view = view
12
+ @id = id
13
+ @base = id || "unmagic_tabs_#{SecureRandom.hex(4)}"
14
+ @options = options
15
+ @tabs = []
16
+ @panels = []
17
+ end
18
+
19
+ def tab(label, disabled: nil, href: nil, active: false)
20
+ @tabs << { label: label, disabled: disabled, href: href, active: active }
21
+ nil
22
+ end
23
+
24
+ def panel(&block)
25
+ @panels << view.capture(&block)
26
+ nil
27
+ end
28
+
29
+ def render
30
+ links = @tabs.any? { |tab| tab[:href] }
31
+ raise ArgumentError, "tabs can't mix href: tabs with panels" if links && @panels.any?
32
+
33
+ links ? links_bar : switcher
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :view
39
+
40
+ delegate :tag, :safe_join, to: :view, private: true
41
+
42
+ def enabled = @tabs.reject { |tab| tab[:disabled] }
43
+
44
+ # Panels line up with the enabled tabs in order; a disabled tab takes none.
45
+ def switcher
46
+ if enabled.size != @panels.size
47
+ raise ArgumentError, "tabs has #{enabled.size} enabled tabs but #{@panels.size} panels"
48
+ end
49
+
50
+ selected = enabled.index { |tab| tab[:active] } || 0
51
+ index = -1
52
+
53
+ list = @tabs.map do |tab|
54
+ next disabled_tab(tab) if tab[:disabled]
55
+
56
+ index += 1
57
+ tag.button tab[:label], type: "button", role: "tab", id: "#{@base}_tab_#{index}", class: "UnmagicTabs__tab",
58
+ "aria-controls": "#{@base}_panel_#{index}", "aria-selected": (index == selected).to_s,
59
+ tabindex: (index == selected ? 0 : -1)
60
+ end
61
+
62
+ panels = @panels.each_with_index.map do |content, i|
63
+ tag.div content, role: "tabpanel", id: "#{@base}_panel_#{i}", class: "UnmagicTabs__panel",
64
+ "aria-labelledby": "#{@base}_tab_#{i}", tabindex: 0, hidden: i != selected
65
+ end
66
+
67
+ view.content_tag("unmagic-tabs", **@options, id: @id, class: view.class_names("UnmagicTabs", @options[:class])) do
68
+ safe_join [ tag.div(safe_join(list), role: "tablist", class: "UnmagicTabs__list"), *panels ]
69
+ end
70
+ end
71
+
72
+ # Each tab its own URL, rendered on the server: navigation, not a widget, so it
73
+ # needs no script and marks the current page rather than a selected tab.
74
+ def links_bar
75
+ tag.nav(**@options, id: @id, class: view.class_names("UnmagicTabs", @options[:class])) do
76
+ tag.div class: "UnmagicTabs__list" do
77
+ safe_join(@tabs.map do |tab|
78
+ next disabled_tab(tab) if tab[:disabled]
79
+
80
+ view.link_to tab[:label], tab[:href], class: "UnmagicTabs__tab", "aria-current": ("page" if tab[:active])
81
+ end)
82
+ end
83
+ end
84
+ end
85
+
86
+ def disabled_tab(tab)
87
+ reason = tab[:disabled] unless tab[:disabled] == true
88
+
89
+ tag.span class: "UnmagicTabs__tab", "aria-disabled": "true" do
90
+ safe_join [ tab[:label], (tag.span("(#{reason})", class: "UnmagicTabs__reason") if reason) ].compact, " "
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ # One toast, and the inert <template> the <unmagic-toasts> element pops it from.
6
+ # A toast arrives as a template rather than as the toast itself so a morph or a
7
+ # stream can deliver it without it rendering in place; the element clones it
8
+ # into the stack. See ActionViewHelpers#flash_toasts.
9
+ class Toast
10
+ TONES = %i[good warn bad info].freeze
11
+
12
+ # The <unmagic-toasts> element's id, which a stream appends templates to.
13
+ TARGET = "unmagic_toasts"
14
+
15
+ def initialize(view, message, tone:)
16
+ unless TONES.include?(tone)
17
+ raise ArgumentError, "unknown toast tone #{tone.inspect} (expected one of #{TONES.inspect})"
18
+ end
19
+
20
+ @view = view
21
+ @message = message
22
+ @tone = tone
23
+ end
24
+
25
+ def template
26
+ tag.template(render, data: { unmagic_toast_template: "" })
27
+ end
28
+
29
+ # A bad toast interrupts (role="alert"); the rest are announced politely by
30
+ # the stack's own live region.
31
+ def render
32
+ tag.div class: "UnmagicToast UnmagicToast--#{@tone}", role: ("alert" if @tone == :bad),
33
+ data: { unmagic_toast: "" } do
34
+ safe_join [
35
+ Icons.svg(view, Icons::TONE_ICONS.fetch(@tone), class: "UnmagicToast__icon"),
36
+ tag.div(@message, class: "UnmagicToast__message"),
37
+ tag.button(
38
+ Icons.svg(view, :x),
39
+ type: "button", class: "UnmagicToast__dismiss",
40
+ "aria-label": I18n.t("unmagic.components.toast.dismiss", default: "Dismiss"),
41
+ data: { unmagic_toast_dismiss: "" }
42
+ )
43
+ ]
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :view
50
+
51
+ delegate :tag, :safe_join, to: :view, private: true
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ # The mount point for toasts: the <unmagic-toasts> element, its stack, and a
6
+ # template for each of the request's flashes. See ActionViewHelpers#flash_toasts.
7
+ class Toasts
8
+ def initialize(view, flashes, duration:)
9
+ @view = view
10
+ @flashes = flashes
11
+ @duration = duration
12
+ end
13
+
14
+ # The stack is data-turbo-permanent, so a toast on screen — and its dismiss
15
+ # timer — survives a Drive visit or a morph refresh. It is a manual popover
16
+ # so the element can lift it into the top layer, above an open dialog.
17
+ def render
18
+ view.content_tag("unmagic-toasts", id: Toast::TARGET, duration: @duration) do
19
+ safe_join [
20
+ tag.div(class: "UnmagicToasts__stack", id: "#{Toast::TARGET}_stack", popover: "manual",
21
+ "aria-live": "polite", data: { turbo_permanent: "" }),
22
+ *templates
23
+ ]
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :view
30
+
31
+ delegate :tag, :safe_join, to: :view, private: true
32
+
33
+ def templates
34
+ tones = Components.configuration.flash_tones
35
+
36
+ @flashes.each_with_object([]) do |(type, messages), templates|
37
+ tone = tones.fetch(type.to_s, :info)
38
+
39
+ Array(messages).each do |message|
40
+ templates << Toast.new(view, message, tone: tone).template if message.present?
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ # A hint shown on hover or focus. See ActionViewHelpers#tooltip.
6
+ class Tooltip
7
+ PLACEMENTS = %i[top bottom].freeze
8
+
9
+ def initialize(view, text:, placement:, term:, **options)
10
+ unless PLACEMENTS.include?(placement)
11
+ raise ArgumentError, "unknown tooltip placement #{placement.inspect} (expected one of #{PLACEMENTS.inspect})"
12
+ end
13
+
14
+ @view = view
15
+ @text = text
16
+ @placement = placement
17
+ @term = term
18
+ @options = options
19
+ end
20
+
21
+ def render(content)
22
+ classes = view.class_names("UnmagicTooltip", { "UnmagicTooltip--term" => @term }, @options[:class])
23
+
24
+ view.content_tag("unmagic-tooltip", content, **@options, text: @text, placement: @placement, class: classes)
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :view
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ # Mixed into turbo-rails' stream tag builder by the engine.
6
+ module TurboStreamActions
7
+ # Pops a toast from a stream response — for something that happened outside
8
+ # the flash cycle, where there is no redirect or render to carry a flash.
9
+ #
10
+ # render turbo_stream: turbo_stream.toast("Invitation sent.")
11
+ # render turbo_stream: turbo_stream.toast("Couldn't reach Slack.", tone: :bad)
12
+ #
13
+ # tone: :good (default), :warn, :bad or :info. Needs flash_toasts on the page.
14
+ def toast(message, tone: :good)
15
+ append Toast::TARGET, Toast.new(@view_context, message, tone: tone).template
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Unmagic
6
+ module Components
7
+ # A hidden field holding a client-known UUIDv7. See FormBuilder#uuid_field.
8
+ class UuidInput
9
+ def initialize(view, name, **options)
10
+ @view = view
11
+ @name = name
12
+ @options = options
13
+ end
14
+
15
+ # The server renders an id of its own so the form still submits one without
16
+ # the script; the element replaces it as it upgrades, and again on each reset.
17
+ def render
18
+ @view.content_tag("unmagic-uuid-input", name: @name, **@options) do
19
+ @view.hidden_field_tag(@name, SecureRandom.uuid_v7, id: nil, autocomplete: "off")
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Unmagic
4
4
  module Components
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
@@ -14,8 +14,29 @@ require_relative "components/table_tag"
14
14
  require_relative "components/table/column"
15
15
  require_relative "components/table"
16
16
  require_relative "components/form_builder"
17
+ require_relative "components/skeleton"
17
18
  require_relative "components/detail_list/item"
18
19
  require_relative "components/detail_list"
20
+ require_relative "components/icons"
21
+ require_relative "components/button"
22
+ require_relative "components/dialog"
23
+ require_relative "components/modal"
24
+ require_relative "components/confirm_template"
25
+ require_relative "components/dialog_responder"
26
+ require_relative "components/toast"
27
+ require_relative "components/toasts"
28
+ require_relative "components/turbo_stream_actions"
29
+ require_relative "components/badge"
30
+ require_relative "components/callout"
31
+ require_relative "components/card"
32
+ require_relative "components/page_header"
33
+ require_relative "components/local_time"
34
+ require_relative "components/tooltip"
35
+ require_relative "components/menu"
36
+ require_relative "components/tabs"
37
+ require_relative "components/copy_button"
38
+ require_relative "components/autogrow"
39
+ require_relative "components/uuid_input"
19
40
  require_relative "components/action_view_helpers"
20
41
  # ActionView pulls in a partial Rails namespace, so this checks for the constant it
21
42
  # actually needs rather than for Rails.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: unmagic-components
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keith Pitt
@@ -79,11 +79,12 @@ dependencies:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
81
  version: '13.0'
82
- description: Server-rendered view builders in the spirit of form_for. table_for for
83
- index tables sortable headers, deferred turbo-frame loading with a matching skeleton,
84
- empty states, pagination, and rows a Turbo Stream can keep up to date. detail_list
85
- for description lists, and a form builder for the chrome around a control. Plain
86
- Rails helpers, themed through CSS custom properties.
82
+ description: UI components for Rails views in the spirit of form_for. Builders for
83
+ index tables (sortable, deferred, kept live by Turbo Streams), detail lists, forms,
84
+ cards, page headers and loading skeletons; a Turbo Frame modal, a confirm dialog
85
+ and flash toasts; menus, tabs, tooltips, local times, copy buttons, autogrowing
86
+ textareas and UUID inputs as self-registering custom elements. Plain helpers and
87
+ CSS themed through custom properties, with no Tailwind or Stimulus required.
87
88
  email:
88
89
  - keith@unreasonable-magic.com
89
90
  executables: []
@@ -93,22 +94,55 @@ files:
93
94
  - CHANGELOG.md
94
95
  - LICENSE
95
96
  - README.md
97
+ - app/assets/javascripts/unmagic/components.js
98
+ - app/assets/javascripts/unmagic/components/autogrow.js
99
+ - app/assets/javascripts/unmagic/components/clipboard.js
100
+ - app/assets/javascripts/unmagic/components/confirm.js
101
+ - app/assets/javascripts/unmagic/components/dialog.js
102
+ - app/assets/javascripts/unmagic/components/menu.js
103
+ - app/assets/javascripts/unmagic/components/modal.js
104
+ - app/assets/javascripts/unmagic/components/tabs.js
105
+ - app/assets/javascripts/unmagic/components/time.js
106
+ - app/assets/javascripts/unmagic/components/toasts.js
107
+ - app/assets/javascripts/unmagic/components/tooltip.js
96
108
  - app/assets/javascripts/unmagic/components/upsert.js
109
+ - app/assets/javascripts/unmagic/components/uuid_input.js
97
110
  - app/assets/stylesheets/unmagic/components.css
98
111
  - config/importmap.rb
99
112
  - lib/unmagic-components.rb
100
113
  - lib/unmagic/components.rb
101
114
  - lib/unmagic/components/action_view_helpers.rb
115
+ - lib/unmagic/components/autogrow.rb
116
+ - lib/unmagic/components/badge.rb
117
+ - lib/unmagic/components/button.rb
118
+ - lib/unmagic/components/callout.rb
119
+ - lib/unmagic/components/card.rb
102
120
  - lib/unmagic/components/configuration.rb
121
+ - lib/unmagic/components/confirm_template.rb
122
+ - lib/unmagic/components/copy_button.rb
103
123
  - lib/unmagic/components/detail_list.rb
104
124
  - lib/unmagic/components/detail_list/item.rb
125
+ - lib/unmagic/components/dialog.rb
126
+ - lib/unmagic/components/dialog_responder.rb
105
127
  - lib/unmagic/components/engine.rb
106
128
  - lib/unmagic/components/form_builder.rb
129
+ - lib/unmagic/components/icons.rb
130
+ - lib/unmagic/components/local_time.rb
131
+ - lib/unmagic/components/menu.rb
132
+ - lib/unmagic/components/modal.rb
133
+ - lib/unmagic/components/page_header.rb
107
134
  - lib/unmagic/components/renderers/empty_state.rb
108
135
  - lib/unmagic/components/renderers/pagination.rb
136
+ - lib/unmagic/components/skeleton.rb
109
137
  - lib/unmagic/components/table.rb
110
138
  - lib/unmagic/components/table/column.rb
111
139
  - lib/unmagic/components/table_tag.rb
140
+ - lib/unmagic/components/tabs.rb
141
+ - lib/unmagic/components/toast.rb
142
+ - lib/unmagic/components/toasts.rb
143
+ - lib/unmagic/components/tooltip.rb
144
+ - lib/unmagic/components/turbo_stream_actions.rb
145
+ - lib/unmagic/components/uuid_input.rb
112
146
  - lib/unmagic/components/version.rb
113
147
  homepage: https://github.com/unreasonable-magic/unmagic-components
114
148
  licenses:
@@ -133,5 +167,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
133
167
  requirements: []
134
168
  rubygems_version: 4.0.10
135
169
  specification_version: 4
136
- summary: Declarative table, detail-list and form builders for Rails views
170
+ summary: 'Server-rendered UI components for Rails: tables, forms, dialogs, toasts
171
+ and more'
137
172
  test_files: []