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