unmagic-components 0.1.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 +7 -0
- data/CHANGELOG.md +53 -0
- data/LICENSE +21 -0
- data/README.md +337 -0
- data/app/assets/javascripts/unmagic/components/upsert.js +45 -0
- data/app/assets/stylesheets/unmagic/components.css +382 -0
- data/config/importmap.rb +3 -0
- data/lib/unmagic/components/action_view_helpers.rb +168 -0
- data/lib/unmagic/components/configuration.rb +36 -0
- data/lib/unmagic/components/detail_list/item.rb +31 -0
- data/lib/unmagic/components/detail_list.rb +65 -0
- data/lib/unmagic/components/engine.rb +39 -0
- data/lib/unmagic/components/form_builder.rb +205 -0
- data/lib/unmagic/components/renderers/empty_state.rb +22 -0
- data/lib/unmagic/components/renderers/pagination.rb +62 -0
- data/lib/unmagic/components/table/column.rb +46 -0
- data/lib/unmagic/components/table.rb +254 -0
- data/lib/unmagic/components/table_tag.rb +93 -0
- data/lib/unmagic/components/version.rb +7 -0
- data/lib/unmagic/components.rb +43 -0
- data/lib/unmagic-components.rb +3 -0
- metadata +137 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "action_view_helpers"
|
|
4
|
+
|
|
5
|
+
module Unmagic
|
|
6
|
+
module Components
|
|
7
|
+
class Engine < ::Rails::Engine
|
|
8
|
+
isolate_namespace Unmagic::Components
|
|
9
|
+
|
|
10
|
+
initializer "unmagic_components.helpers" do
|
|
11
|
+
ActiveSupport.on_load(:action_view) do
|
|
12
|
+
include Unmagic::Components::ActionViewHelpers
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The components' stylesheet is a plain CSS file, deliberately not part of any
|
|
17
|
+
# Tailwind build: Tailwind only generates classes it can see, and it does not
|
|
18
|
+
# scan installed gems. Serving it through the asset pipeline keeps the gem's
|
|
19
|
+
# look self-contained and themeable through --unmagic-* custom properties.
|
|
20
|
+
initializer "unmagic_components.assets" do |app|
|
|
21
|
+
next unless app.config.respond_to?(:assets)
|
|
22
|
+
|
|
23
|
+
app.config.assets.paths << Engine.root.join("app/assets/stylesheets")
|
|
24
|
+
app.config.assets.paths << Engine.root.join("app/assets/javascripts")
|
|
25
|
+
end
|
|
26
|
+
|
|
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).
|
|
31
|
+
initializer "unmagic_components.importmap", before: "importmap" do |app|
|
|
32
|
+
next unless app.config.respond_to?(:importmap)
|
|
33
|
+
|
|
34
|
+
app.config.importmap.paths << Engine.root.join("config/importmap.rb")
|
|
35
|
+
app.config.importmap.cache_sweepers << Engine.root.join("app/assets/javascripts")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "action_view"
|
|
4
|
+
require "action_view/helpers"
|
|
5
|
+
|
|
6
|
+
module Unmagic
|
|
7
|
+
module Components
|
|
8
|
+
# The chrome around a form control: the wrapper, the label and its required
|
|
9
|
+
# marker, the hint, and the error line — the part every app writes the same way
|
|
10
|
+
# and then repeats in every view.
|
|
11
|
+
#
|
|
12
|
+
# <%= form_with model: @label, builder: Unmagic::Components::FormBuilder do |form| %>
|
|
13
|
+
# <%= form.errors_summary %>
|
|
14
|
+
# <%= form.field :name, "Name", required: true, hint: "Shown in the sidebar." %>
|
|
15
|
+
# <%= form.submit "Add label" %>
|
|
16
|
+
# <% end %>
|
|
17
|
+
#
|
|
18
|
+
# What the control itself looks like is deliberately not decided here. Apps
|
|
19
|
+
# style inputs in incompatible ways — a class on every input, or a bare-element
|
|
20
|
+
# rule — and a component library that picked one would be wrong in the other. So
|
|
21
|
+
# the builder emits structure and leaves the control's own appearance alone.
|
|
22
|
+
class FormBuilder < ::ActionView::Helpers::FormBuilder
|
|
23
|
+
# Verbs the plain "drop a trailing -e, add -ing" rule gets wrong (consonant
|
|
24
|
+
# doubling). Everything else the rule handles: Save -> Saving, Create ->
|
|
25
|
+
# Creating, Add -> Adding, Continue -> Continuing.
|
|
26
|
+
SUBMIT_GERUNDS = {
|
|
27
|
+
"submit" => "Submitting",
|
|
28
|
+
"set" => "Setting",
|
|
29
|
+
"get" => "Getting",
|
|
30
|
+
"log" => "Logging",
|
|
31
|
+
"run" => "Running"
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
# Label + control + hint + error, wrapped consistently. Pass a block to supply
|
|
35
|
+
# a control the builder doesn't know how to make (a select, a file picker, two
|
|
36
|
+
# inputs side by side); otherwise it is built from `as:`.
|
|
37
|
+
#
|
|
38
|
+
# <%= form.field :email, "Email", required: true, as: :email_field %>
|
|
39
|
+
# <%= form.field :role, "Role" do %>
|
|
40
|
+
# <%= form.select :role, Role.all %>
|
|
41
|
+
# <% end %>
|
|
42
|
+
def field(method, label_text = nil, required: false, hint: nil, as: :text_field, **options, &block)
|
|
43
|
+
options = options.merge(required: true) if required && !block
|
|
44
|
+
|
|
45
|
+
errors = errors_for(method)
|
|
46
|
+
options = options.merge("aria-invalid" => "true") if errors.any? && !block
|
|
47
|
+
|
|
48
|
+
control = block ? @template.capture(&block) : public_send(as, method, options)
|
|
49
|
+
|
|
50
|
+
@template.content_tag(:div, class: field_classes, data: { field: "" }) do
|
|
51
|
+
@template.safe_join [
|
|
52
|
+
(field_label(method, label_text, required) if label_text),
|
|
53
|
+
control,
|
|
54
|
+
(@template.content_tag(:p, hint, class: "UnmagicHint") if hint),
|
|
55
|
+
(@template.content_tag(:p, errors.to_sentence, class: "UnmagicError") if errors.any?)
|
|
56
|
+
].compact
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def label(method, text = nil, options = {}, &block)
|
|
61
|
+
options = options.dup
|
|
62
|
+
options[:class] = @template.class_names("UnmagicLabel", options[:class])
|
|
63
|
+
super
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Lay the contained fields out in a row — a first-name / last-name pair above
|
|
67
|
+
# other stacked fields. With inline: true they render compact and auto-width,
|
|
68
|
+
# for a filter toolbar. Groups can't nest.
|
|
69
|
+
def group(inline: false, **options, &block)
|
|
70
|
+
@group = inline ? :inline : :row
|
|
71
|
+
content = @template.capture(&block)
|
|
72
|
+
@group = nil
|
|
73
|
+
|
|
74
|
+
classes = @template.class_names(inline ? "UnmagicFieldGroup--inline" : "UnmagicFieldGroup", options[:class])
|
|
75
|
+
@template.content_tag(:div, content, class: classes)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The record's whole-object errors, read as a sentence. Attribute errors show
|
|
79
|
+
# under their own field; these have nowhere else to go.
|
|
80
|
+
def errors_summary
|
|
81
|
+
errors = errors_for(:base)
|
|
82
|
+
return if errors.none?
|
|
83
|
+
|
|
84
|
+
@template.content_tag(:div, errors.to_sentence, class: "UnmagicFormErrors", role: "alert")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# A checkbox with its label beside it, and an optional hint under that.
|
|
88
|
+
def check_box_field(method, label_text, hint: nil, **options)
|
|
89
|
+
@template.content_tag(:label, class: "UnmagicCheckField") do
|
|
90
|
+
text = [ @template.content_tag(:span, label_text, class: "UnmagicCheckField__label") ]
|
|
91
|
+
text << @template.content_tag(:span, hint, class: "UnmagicHint") if hint
|
|
92
|
+
|
|
93
|
+
@template.safe_join [
|
|
94
|
+
check_box(method, options),
|
|
95
|
+
@template.content_tag(:span, @template.safe_join(text), class: "UnmagicCheckField__text")
|
|
96
|
+
]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# A vertical list of checkboxes from a collection — Rails' collection_check_boxes
|
|
101
|
+
# with the labelling and spacing baked in. inline: true flows them in a row.
|
|
102
|
+
def check_box_collection(method, collection, value_method, text_method, inline: false, **options)
|
|
103
|
+
body = collection_check_boxes(method, collection, value_method, text_method) do |check_box|
|
|
104
|
+
@template.content_tag(:label, class: "UnmagicCheckField") do
|
|
105
|
+
@template.safe_join [
|
|
106
|
+
check_box.check_box(options),
|
|
107
|
+
@template.content_tag(:span, check_box.text, class: "UnmagicCheckField__label")
|
|
108
|
+
]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
@template.content_tag(:div, body, class: inline ? "UnmagicCheckList--inline" : "UnmagicCheckList")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A submit button that says what it is doing while it does it: Turbo swaps the
|
|
116
|
+
# label for the conjugated verb ("Save" -> "Saving…") for the length of the
|
|
117
|
+
# submit. Pass submitting: false to leave the label alone, or a string to
|
|
118
|
+
# choose it.
|
|
119
|
+
def submit(value = nil, options = {}, &block)
|
|
120
|
+
options = options.dup
|
|
121
|
+
variant = options.delete(:variant) || :primary
|
|
122
|
+
submitting = options.delete(:submitting)
|
|
123
|
+
value ||= "Save"
|
|
124
|
+
|
|
125
|
+
options[:type] = "submit"
|
|
126
|
+
options[:class] = @template.class_names(
|
|
127
|
+
Components.configuration.submit_class.call(@template, variant), options[:class]
|
|
128
|
+
)
|
|
129
|
+
options[:data] = with_submitting_text(options[:data], value, submitting)
|
|
130
|
+
|
|
131
|
+
# A block is the caller's own button content — an icon beside the label —
|
|
132
|
+
# while `value` stays the label the submitting text is conjugated from.
|
|
133
|
+
@template.content_tag(:button, options) { block ? @template.capture(&block) : value }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The value to show for a field, whether the object is a model or something
|
|
137
|
+
# hash-ish (a JSON Schema instance, a params object). Reads what the user
|
|
138
|
+
# actually typed when the object tracks that, so a rejected cast still shows
|
|
139
|
+
# their input rather than nil.
|
|
140
|
+
def form_value_for(method)
|
|
141
|
+
if hash_value_object?
|
|
142
|
+
@object.key?(method.to_s) ? @object[method.to_s] : @object[method.to_sym]
|
|
143
|
+
elsif @object.respond_to?("#{method}_before_type_cast") && form_value_came_from_user?(method)
|
|
144
|
+
@object.public_send("#{method}_before_type_cast")
|
|
145
|
+
elsif @object.respond_to?(method)
|
|
146
|
+
@object.public_send(method)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def field_classes
|
|
153
|
+
case @group
|
|
154
|
+
when :row then "UnmagicField UnmagicField--in-row"
|
|
155
|
+
when :inline then "UnmagicField UnmagicField--inline"
|
|
156
|
+
else "UnmagicField"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def field_label(method, text, required)
|
|
161
|
+
return label(method, text) unless required
|
|
162
|
+
|
|
163
|
+
label(method) do
|
|
164
|
+
@template.safe_join [ text, " ", @template.content_tag(:span, "*", class: "UnmagicLabel__required") ]
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def errors_for(method)
|
|
169
|
+
return [] unless @object.respond_to?(:errors)
|
|
170
|
+
|
|
171
|
+
@object.errors[method]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def hash_value_object?
|
|
175
|
+
@object.respond_to?(:key?) && @object.respond_to?(:[]) && @object.respond_to?(:to_h)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def form_value_came_from_user?(method)
|
|
179
|
+
came_from_user = "#{method}_came_from_user?"
|
|
180
|
+
!@object.respond_to?(came_from_user) || @object.public_send(came_from_user)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# data-turbo-submits-with, unless the caller opted out or set their own.
|
|
184
|
+
def with_submitting_text(data, value, submitting)
|
|
185
|
+
return data if submitting == false
|
|
186
|
+
|
|
187
|
+
text = submitting.is_a?(String) ? submitting : submitting_text(value)
|
|
188
|
+
return data unless text
|
|
189
|
+
|
|
190
|
+
data = (data || {}).dup
|
|
191
|
+
data[:turbo_submits_with] = text unless data.key?(:turbo_submits_with)
|
|
192
|
+
data
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# "Save" -> "Saving…", "Add label" -> "Adding label…".
|
|
196
|
+
def submitting_text(value)
|
|
197
|
+
verb, *rest = value.to_s.split
|
|
198
|
+
return unless verb
|
|
199
|
+
|
|
200
|
+
gerund = SUBMIT_GERUNDS[verb.downcase] || "#{verb.sub(/e\z/i, "")}ing"
|
|
201
|
+
"#{[ gerund, *rest ].join(" ")}…"
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
module Components
|
|
5
|
+
module Renderers
|
|
6
|
+
# The blank slate a table falls back to when its collection is empty.
|
|
7
|
+
module EmptyState
|
|
8
|
+
class << self
|
|
9
|
+
def default
|
|
10
|
+
method(:render).to_proc
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Extra options from `table.empty` are for a host's own renderer; the
|
|
14
|
+
# built-in one takes only a class.
|
|
15
|
+
def render(view, content, **options)
|
|
16
|
+
view.tag.div(content, class: view.class_names("UnmagicEmptyState", options[:class]))
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
module Components
|
|
5
|
+
module Renderers
|
|
6
|
+
# A prev/next pager over a Pagy object. Pagy is an optional dependency: the
|
|
7
|
+
# default resolver looks for the controller's @pagy and the renderer bails
|
|
8
|
+
# out unless the object it gets speaks the parts of Pagy's API it needs, so
|
|
9
|
+
# an app without Pagy simply never renders a pager.
|
|
10
|
+
module Pagination
|
|
11
|
+
class << self
|
|
12
|
+
def default
|
|
13
|
+
->(view, pagy:, turbo_frame: nil) { render(view, pagy: pagy, turbo_frame: turbo_frame) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def default_pagy_for
|
|
17
|
+
method(:resolve).to_proc
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# A count-aware collection (search results reporting #found) can build its
|
|
21
|
+
# own pagy, which forces a lazy collection only on the render that shows
|
|
22
|
+
# it. Anything else falls back to whatever the action assigned.
|
|
23
|
+
def resolve(view, collection)
|
|
24
|
+
if defined?(::Pagy) && collection.respond_to?(:found) && view.respond_to?(:pagy, true)
|
|
25
|
+
view.send(:pagy, :offset, collection, count: collection.found, limit: collection.per_page).first
|
|
26
|
+
else
|
|
27
|
+
view.instance_variable_get(:@pagy)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def render(view, pagy:, turbo_frame: nil)
|
|
32
|
+
return unless pageable?(pagy)
|
|
33
|
+
return if pagy.previous.nil? && pagy.next.nil?
|
|
34
|
+
|
|
35
|
+
data = ({ turbo_frame: turbo_frame, turbo_action: "advance" } if turbo_frame)
|
|
36
|
+
|
|
37
|
+
view.tag.nav(class: "UnmagicPagination", "aria-label": "Pagination") do
|
|
38
|
+
view.safe_join [
|
|
39
|
+
link(view, pagy, :previous, "Previous", data),
|
|
40
|
+
link(view, pagy, :next, "Next", data)
|
|
41
|
+
]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def pageable?(pagy)
|
|
48
|
+
pagy.respond_to?(:previous) && pagy.respond_to?(:next) && pagy.respond_to?(:page_url)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def link(view, pagy, direction, label, data)
|
|
52
|
+
if pagy.public_send(direction)
|
|
53
|
+
view.link_to(label, pagy.page_url(direction), class: "UnmagicPagination__link", data: data)
|
|
54
|
+
else
|
|
55
|
+
view.tag.span(label, class: "UnmagicPagination__link", "aria-disabled": "true")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
module Components
|
|
5
|
+
class Table
|
|
6
|
+
class Column
|
|
7
|
+
attr_reader :title, :attribute, :sort, :direction, :block, :width
|
|
8
|
+
|
|
9
|
+
def initialize(title:, attribute:, block:, sort: nil, direction: :asc, align: nil,
|
|
10
|
+
numeric: false, width: nil, **options)
|
|
11
|
+
@title = title
|
|
12
|
+
@attribute = attribute
|
|
13
|
+
@block = block
|
|
14
|
+
@sort = sort
|
|
15
|
+
@direction = direction.to_sym
|
|
16
|
+
@align = align
|
|
17
|
+
@numeric = numeric
|
|
18
|
+
@width = width
|
|
19
|
+
@classes = options[:class]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def right_aligned? = @align == :right || @numeric
|
|
23
|
+
|
|
24
|
+
def centered? = @align == :center
|
|
25
|
+
|
|
26
|
+
def header_classes
|
|
27
|
+
alignment
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def cell_classes
|
|
31
|
+
[ alignment, ("is-numeric" if @numeric), @classes ].compact.presence&.join(" ")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def alignment
|
|
37
|
+
if right_aligned?
|
|
38
|
+
"is-right"
|
|
39
|
+
elsif centered?
|
|
40
|
+
"is-center"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
module Components
|
|
5
|
+
# Collects the column definitions a `table_for` block declares, then renders
|
|
6
|
+
# them as a TableTag. See ActionViewHelpers#table_for for the public API.
|
|
7
|
+
class Table
|
|
8
|
+
SKELETON_ROWS = 8
|
|
9
|
+
|
|
10
|
+
# A pinned column sizes its bar as a fraction of itself; a content-sized one
|
|
11
|
+
# has no width to take a fraction of, so it falls back to a fixed bar.
|
|
12
|
+
SKELETON_WIDTHS = %w[8rem 5rem 7rem 4rem 6rem].freeze
|
|
13
|
+
SKELETON_FRACTIONS = %w[75% 50% 66% 40% 80%].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(view, collection, headers: true, sorted_by: nil, sort_direction: nil,
|
|
16
|
+
sort_url: nil, row_class: nil, rows_id: nil, row_id: nil, **attributes)
|
|
17
|
+
@view = view
|
|
18
|
+
@collection = collection
|
|
19
|
+
@headers = headers
|
|
20
|
+
@attributes = attributes
|
|
21
|
+
@rows_id = rows_id
|
|
22
|
+
@row_id = row_id
|
|
23
|
+
@sort_url = sort_url
|
|
24
|
+
@row_class = row_class
|
|
25
|
+
@sorted_by = (sorted_by || view.params[:sort]).presence&.to_s
|
|
26
|
+
@sort_direction = (sort_direction || view.params[:direction]).presence&.to_sym
|
|
27
|
+
@columns = []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def column(title = nil, attribute = nil, **options, &block)
|
|
31
|
+
@columns << Column.new(title: title, attribute: attribute, block: block, **options)
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A full-width companion row rendered under a record's own. The block runs
|
|
36
|
+
# per record inside one colspan cell; rendering nothing skips the row, so
|
|
37
|
+
# other records stay single-line. The record row drops its bottom border so
|
|
38
|
+
# the pair reads as one row.
|
|
39
|
+
def details(&block)
|
|
40
|
+
@details_block = block
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The blank slate for a genuinely empty dataset (no search/filter applied) —
|
|
45
|
+
# typically a prompt to create the first record.
|
|
46
|
+
# Any extra options are handed to the configured empty_state seam, so an
|
|
47
|
+
# app whose blank slate takes more than text (an icon, say) can ask for it
|
|
48
|
+
# per table.
|
|
49
|
+
def empty(text = nil, **options, &block)
|
|
50
|
+
@empty_text = text
|
|
51
|
+
@empty_block = block
|
|
52
|
+
@empty_options = options
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The blank slate shown when a search/filter matched nothing, so a
|
|
57
|
+
# filtered-to-zero table doesn't read as "create your first one". Only
|
|
58
|
+
# reached when the collection reports filtered?.
|
|
59
|
+
def no_results(text = nil, **options, &block)
|
|
60
|
+
@no_results_text = text
|
|
61
|
+
@no_results_block = block
|
|
62
|
+
@no_results_options = options
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def render(pagy: nil, turbo_frame: nil)
|
|
67
|
+
@turbo_frame = turbo_frame
|
|
68
|
+
|
|
69
|
+
if @collection.blank?
|
|
70
|
+
empty_slate
|
|
71
|
+
else
|
|
72
|
+
safe_join [ table, pagination(pagy) ].compact
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The same header cells and the same column widths the loaded table renders —
|
|
77
|
+
# only the body is stand-in bars — so the frame swaps rows in without
|
|
78
|
+
# shifting the columns.
|
|
79
|
+
def skeleton
|
|
80
|
+
view.table_tag (skeleton_header_cells if @headers), skeleton_rows,
|
|
81
|
+
**table_attributes, widths: widths, caption: "Loading…", role: "status", "aria-busy": "true"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The <tr> this table would render for one record, on its own — what a
|
|
85
|
+
# broadcast upserts into the tbody. The companion details row is left out:
|
|
86
|
+
# a stream action carries one element, and the pair is a page-render concern.
|
|
87
|
+
def row(record)
|
|
88
|
+
TableTag.new(view, nil, []).render_row(row_hash(record))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
attr_reader :view
|
|
94
|
+
|
|
95
|
+
delegate :tag, :safe_join, :link_to, :class_names, to: :view, private: true
|
|
96
|
+
|
|
97
|
+
def table
|
|
98
|
+
view.table_tag((header_cells if @headers), row_data, widths: widths, rows_id: @rows_id, **table_attributes)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Anything the caller put on table_for beyond the builder's own options rides
|
|
102
|
+
# on the <table>, so a view can space, identify or annotate it without
|
|
103
|
+
# wrapping it in a div.
|
|
104
|
+
def table_attributes = @attributes
|
|
105
|
+
|
|
106
|
+
# A record collection's columns/rows recast as the plain cells table_tag
|
|
107
|
+
# renders: the header carries its sort link and aria-sort, each row its dom
|
|
108
|
+
# id and row_class.
|
|
109
|
+
def header_cells
|
|
110
|
+
@columns.map do |column|
|
|
111
|
+
{ content: header_label(column), class: column.header_classes, "aria-sort": aria_sort(column) }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def widths = @columns.map(&:width)
|
|
116
|
+
|
|
117
|
+
def row_data
|
|
118
|
+
@collection.flat_map do |record|
|
|
119
|
+
details = details_content(record)
|
|
120
|
+
row = row_hash(record, has_details: details.present?)
|
|
121
|
+
details.present? ? [ row, details_row(record, details) ] : [ row ]
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def row_hash(record, has_details: false)
|
|
126
|
+
{
|
|
127
|
+
cells: @columns.map { |column| { content: cell_content(column, record), class: column.cell_classes } },
|
|
128
|
+
id: row_id(record),
|
|
129
|
+
class: class_names(@row_class&.call(record), "has-details" => has_details).presence
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def details_content(record)
|
|
134
|
+
view.capture(record, &@details_block) if @details_block
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def details_row(record, content)
|
|
138
|
+
{
|
|
139
|
+
cells: [ { content: content, colspan: @columns.size } ],
|
|
140
|
+
class: class_names("UnmagicTable__details", @row_class&.call(record))
|
|
141
|
+
}
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def header_label(column)
|
|
145
|
+
column.sort ? sort_link(column) : column.title
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def aria_sort(column)
|
|
149
|
+
if column.sort && sorted_by?(column)
|
|
150
|
+
sorted_direction(column) == :asc ? "ascending" : "descending"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def sort_link(column)
|
|
155
|
+
next_direction = sorted_by?(column) ? opposite(sorted_direction(column)) : column.direction
|
|
156
|
+
|
|
157
|
+
link_to sort_url(column.sort, next_direction), class: "UnmagicTable__sort", data: frame_data do
|
|
158
|
+
safe_join [ column.title, sort_arrow(column) ].compact, " "
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def sort_arrow(column)
|
|
163
|
+
tag.span(sorted_direction(column) == :asc ? "↑" : "↓", "aria-hidden": "true") if sorted_by?(column)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def sorted_by?(column) = @sorted_by == column.sort.to_s
|
|
167
|
+
|
|
168
|
+
def sorted_direction(column)
|
|
169
|
+
if sorted_by?(column)
|
|
170
|
+
@sort_direction || column.direction
|
|
171
|
+
else
|
|
172
|
+
column.direction
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def opposite(direction) = direction == :asc ? :desc : :asc
|
|
177
|
+
|
|
178
|
+
def sort_url(key, direction)
|
|
179
|
+
if @sort_url
|
|
180
|
+
@sort_url.call(key, direction)
|
|
181
|
+
else
|
|
182
|
+
query = view.request.query_parameters.except("page").merge("sort" => key, "direction" => direction)
|
|
183
|
+
"#{view.request.path}?#{query.to_query}"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# dom_id by default. A live table over a mixed collection needs to say
|
|
188
|
+
# otherwise: dom_id names the record's own class, so an STI table's ids carry
|
|
189
|
+
# different prefixes and sort by type rather than by id — which is the order
|
|
190
|
+
# the upsert action inserts on.
|
|
191
|
+
def row_id(record)
|
|
192
|
+
if @row_id
|
|
193
|
+
@row_id.call(record)
|
|
194
|
+
elsif record.respond_to?(:to_key)
|
|
195
|
+
view.dom_id(record)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def cell_content(column, record)
|
|
200
|
+
if column.block
|
|
201
|
+
view.capture(record, &column.block)
|
|
202
|
+
elsif column.attribute
|
|
203
|
+
record.public_send(column.attribute)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def empty_slate
|
|
208
|
+
content, options = filtered? ? no_results_slate : empty_dataset_slate
|
|
209
|
+
Components.configuration.empty_state.call(view, content, **options)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# A search result set knows whether a query/filter was applied; a plain
|
|
213
|
+
# relation or array doesn't, so it's treated as an unfiltered dataset.
|
|
214
|
+
def filtered? = @collection.respond_to?(:filtered?) && @collection.filtered?
|
|
215
|
+
|
|
216
|
+
def empty_dataset_slate
|
|
217
|
+
content = @empty_block ? view.capture(&@empty_block) : (@empty_text || "Nothing here yet.")
|
|
218
|
+
[ content, @empty_options || {} ]
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def no_results_slate
|
|
222
|
+
content = @no_results_block ? view.capture(&@no_results_block) : (@no_results_text || "No matching results.")
|
|
223
|
+
[ content, @no_results_options || {} ]
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def pagination(pagy)
|
|
227
|
+
return unless pagy
|
|
228
|
+
|
|
229
|
+
Components.configuration.pagination.call(view, pagy: pagy, turbo_frame: @turbo_frame)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def frame_data
|
|
233
|
+
{ turbo_frame: @turbo_frame, turbo_action: "advance" } if @turbo_frame
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def skeleton_header_cells
|
|
237
|
+
@columns.map { |column| { content: column.title, class: column.header_classes } }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def skeleton_rows
|
|
241
|
+
Array.new(SKELETON_ROWS) do |index|
|
|
242
|
+
@columns.map.with_index { |column, column_index| skeleton_bar(column, index + column_index) }
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def skeleton_bar(column, seed)
|
|
247
|
+
scale = column.width.present? ? SKELETON_FRACTIONS : SKELETON_WIDTHS
|
|
248
|
+
classes = class_names("UnmagicSkeleton", "is-right" => column.right_aligned?)
|
|
249
|
+
|
|
250
|
+
tag.div class: classes, style: "width: #{scale[seed % scale.size]}"
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|