atomic_view 0.3.0 → 0.4.1
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/lib/atomic_view/components/lazy_pagination_component.html.erb +8 -0
- data/lib/atomic_view/components/lazy_pagination_component.rb +43 -0
- data/lib/atomic_view/components/table_component/cell_component.html.erb +6 -0
- data/lib/atomic_view/components/table_component/cell_component.rb +66 -0
- data/lib/atomic_view/components/table_component/column_component.rb +54 -0
- data/lib/atomic_view/components/table_component/row_component.html.erb +3 -0
- data/lib/atomic_view/components/table_component/row_component.rb +55 -0
- data/lib/atomic_view/components/table_component.rb +65 -6
- data/lib/atomic_view/version.rb +1 -1
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f37c405116dc18595f21f9f7b3a45223edef67c921c991a7c1523a93b594cf39
|
|
4
|
+
data.tar.gz: 3ce7216ee4b6327f57dec679651d507ba799023f4b2eb3b865ef61bfa3781c1a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a550e6749f5c77da2336b475b69d59d24d09be6e5dfb321bc9c2e73af3968d1b308e0d26962e728f8f0c5dfcd4a341e0d6e63a753ec5986943deba90db4223c7
|
|
7
|
+
data.tar.gz: 05477662ea7b03cd022ca121c0c1b30967cdf99feae9166209aabf64f531ef1902b3a5c746e76dca92cc107e2a7caa05705a36f91f2fd9bdb3e941270eb49e07
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<% if next_page? %>
|
|
2
|
+
<%= tag.turbo_frame(id: id, loading: :lazy, src: next_page_path, class: "flex items-center justify-center gap-2 py-3") do %>
|
|
3
|
+
<span class="size-3.5 animate-spin rounded-full border-2 border-border border-t-foreground motion-reduce:animate-none" aria-hidden="true"></span>
|
|
4
|
+
<span class="sr-only">Loading more…</span>
|
|
5
|
+
<% end %>
|
|
6
|
+
<% else %>
|
|
7
|
+
<%= tag.turbo_frame(id: id) %>
|
|
8
|
+
<% end %>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
# LazyPagination
|
|
6
|
+
#
|
|
7
|
+
# The infinite-scroll counterpart to PaginationComponent's page-number
|
|
8
|
+
# links -- use PaginationComponent for numbered pages, this one for a
|
|
9
|
+
# list that loads its next page automatically as the user scrolls.
|
|
10
|
+
# Stays equally pagination-gem agnostic: the caller resolves both
|
|
11
|
+
# `next_page` (any presence-checkable value, e.g. a Pagy `next` page
|
|
12
|
+
# number) and `next_page_path` (the URL that lazily loaded page comes
|
|
13
|
+
# from) themselves.
|
|
14
|
+
#
|
|
15
|
+
# render(AtomicView::Components::LazyPaginationComponent.new(
|
|
16
|
+
# next_page: @pagy.next,
|
|
17
|
+
# next_page_path: @pagy.next && bookings_path(page: @pagy.next)
|
|
18
|
+
# ))
|
|
19
|
+
#
|
|
20
|
+
# Renders a `<turbo-frame loading="lazy" src="...">` (the same
|
|
21
|
+
# `tag.turbo_frame` technique GanttComponent's row/date pagination
|
|
22
|
+
# uses -- see that class's docs -- rather than a hard dependency on
|
|
23
|
+
# turbo-rails' view helpers) wrapping a spinner while `next_page` is
|
|
24
|
+
# present. Once there's no next page, it renders an empty
|
|
25
|
+
# `<turbo-frame>` with the same `id` instead of nothing at all, so a
|
|
26
|
+
# later `turbo_stream.replace(id, ...)` response from the last real
|
|
27
|
+
# page's fetch always has a target to replace.
|
|
28
|
+
class LazyPaginationComponent < AtomicView::Component
|
|
29
|
+
attr_reader :next_page, :next_page_path, :id
|
|
30
|
+
|
|
31
|
+
def initialize(next_page:, next_page_path: nil, id: :pagination)
|
|
32
|
+
super()
|
|
33
|
+
@next_page = next_page
|
|
34
|
+
@next_page_path = next_page_path
|
|
35
|
+
@id = id
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def next_page?
|
|
39
|
+
next_page.present?
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<%= tag.td(**html_options, class: html_class) do %>
|
|
2
|
+
<% if linked? %>
|
|
3
|
+
<%= link_to "", path, class: "absolute inset-0", tabindex: (first ? 0 : -1), "aria-hidden": (first ? nil : "true"), "aria-label": (first ? label : nil), data: {row_link: true} %>
|
|
4
|
+
<% end %>
|
|
5
|
+
<%= tag.span(content, class: content_class) %>
|
|
6
|
+
<% end %>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
class TableComponent
|
|
6
|
+
# TableComponent::Cell
|
|
7
|
+
#
|
|
8
|
+
# One <td> within a RowComponent -- see that class's docs for the
|
|
9
|
+
# standalone-render requirement and the per-cell stretched-link
|
|
10
|
+
# design. `first` and `clickable` are set by RowComponent's `cells`
|
|
11
|
+
# slot from the row itself, not passed by the caller.
|
|
12
|
+
#
|
|
13
|
+
# Visible content renders as a sibling of the (empty, transparent)
|
|
14
|
+
# stretched `<a>`, wrapped in a `pointer-events-none` span so plain
|
|
15
|
+
# content (the common case -- a name, a badge) doesn't sit in the
|
|
16
|
+
# way of clicks meant for the row-wide overlay underneath it. Pass
|
|
17
|
+
# `interactive: true` on a cell that renders a real, more specific
|
|
18
|
+
# link/button of its own (e.g. an "Edit" action) to flip that span
|
|
19
|
+
# back to `pointer-events-auto`, so that cell's own content takes
|
|
20
|
+
# the click instead of falling through to the row link.
|
|
21
|
+
class CellComponent < AtomicView::Component
|
|
22
|
+
attr_reader :path, :label, :first, :align, :clickable, :interactive
|
|
23
|
+
|
|
24
|
+
def initialize(path:, label:, first:, align: :left, clickable: true, interactive: false, **options)
|
|
25
|
+
super()
|
|
26
|
+
@path = path
|
|
27
|
+
@label = label
|
|
28
|
+
@first = first
|
|
29
|
+
@align = align
|
|
30
|
+
@clickable = clickable
|
|
31
|
+
@interactive = interactive
|
|
32
|
+
@options = options
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def linked?
|
|
36
|
+
clickable && path.present?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def html_class
|
|
40
|
+
class_names(base_classes, align_classes, @options[:class])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def html_options
|
|
44
|
+
@options.except(:class)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def content_class
|
|
48
|
+
interactive ? "relative pointer-events-auto" : "relative pointer-events-none"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def base_classes
|
|
54
|
+
"relative px-2.5 py-2.5 border-b border-border/5"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def align_classes
|
|
58
|
+
case align
|
|
59
|
+
when :center then "text-center font-semibold"
|
|
60
|
+
when :right then "text-right"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
class TableComponent
|
|
6
|
+
# TableComponent::Column
|
|
7
|
+
#
|
|
8
|
+
# One <th> in the header row TableComponent auto-renders once at
|
|
9
|
+
# least one `with_column` is given -- see TableComponent's docs.
|
|
10
|
+
#
|
|
11
|
+
# `label` defaults to `model.human_attribute_name(attribute)`
|
|
12
|
+
# (mirroring Rails' own form label defaulting) when a `model:` is
|
|
13
|
+
# given -- TableComponent passes its own `model:` through to every
|
|
14
|
+
# column automatically, so callers only need `attribute:`. Pass
|
|
15
|
+
# `label:` directly instead for a column with no backing
|
|
16
|
+
# attribute (e.g. a blank header over a row of "Edit" links).
|
|
17
|
+
class ColumnComponent < AtomicView::Component
|
|
18
|
+
attr_reader :model, :attribute, :align, :width
|
|
19
|
+
|
|
20
|
+
def initialize(model: nil, attribute: nil, label: nil, align: :left, width: nil)
|
|
21
|
+
super()
|
|
22
|
+
raise ArgumentError, "label is required when model is not given" if model.nil? && label.nil?
|
|
23
|
+
|
|
24
|
+
@model = model
|
|
25
|
+
@attribute = attribute
|
|
26
|
+
@label = label
|
|
27
|
+
@align = align
|
|
28
|
+
@width = width
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def label
|
|
32
|
+
@label || model.human_attribute_name(attribute)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def call
|
|
36
|
+
tag.th(label, scope: "col", class: header_class)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def header_class
|
|
42
|
+
class_names("p-2 font-medium", align_class, width)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def align_class
|
|
46
|
+
case align
|
|
47
|
+
when :center then "text-center"
|
|
48
|
+
when :right then "text-right"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
class TableComponent
|
|
6
|
+
# TableComponent::Row
|
|
7
|
+
#
|
|
8
|
+
# A single <tr>, renderable standalone -- no parent TableComponent
|
|
9
|
+
# instance required. That matters because a turbo_stream
|
|
10
|
+
# `append`/`update` response renders exactly one row outside of any
|
|
11
|
+
# table context, e.g.:
|
|
12
|
+
#
|
|
13
|
+
# turbo_stream.append(:bookings_body, AtomicView::Components::TableComponent::RowComponent.new(
|
|
14
|
+
# record: @booking, label: @booking.camper_name, path: booking_path(@booking)
|
|
15
|
+
# ) { |row| row.with_cell { @booking.camper_name } })
|
|
16
|
+
#
|
|
17
|
+
# `path` defaults to `record` itself so `path_to(record)`-style route
|
|
18
|
+
# helpers resolve it via `to_model`/`to_param` the same way `link_to`
|
|
19
|
+
# would; pass `path:` explicitly when the row's destination isn't
|
|
20
|
+
# just `record`'s own show page.
|
|
21
|
+
#
|
|
22
|
+
# Each `with_cell` becomes its own stretched link to `path` -- a
|
|
23
|
+
# single `<a>` can't span multiple `<td>` siblings the way it could
|
|
24
|
+
# wrap a `<div>` row -- but only the first cell's link stays in the
|
|
25
|
+
# tab order (`tabindex="0"`; the rest are `tabindex="-1"` and
|
|
26
|
+
# `aria-hidden`), so a keyboard/screen-reader user hits one focus
|
|
27
|
+
# stop per row rather than one per cell. `label` is that first
|
|
28
|
+
# link's accessible name. Pass `clickable: false` for a row that
|
|
29
|
+
# shouldn't navigate anywhere -- cells then render as plain,
|
|
30
|
+
# unlinked content.
|
|
31
|
+
class RowComponent < AtomicView::Component
|
|
32
|
+
renders_many :cells, ->(**kwargs) { CellComponent.new(path: path, label: label, first: cells.empty?, clickable: clickable, **kwargs) }
|
|
33
|
+
|
|
34
|
+
attr_reader :record, :label, :path, :clickable
|
|
35
|
+
|
|
36
|
+
def initialize(record:, label:, path: nil, clickable: true, **options)
|
|
37
|
+
super()
|
|
38
|
+
@record = record
|
|
39
|
+
@label = label
|
|
40
|
+
@path = path || record
|
|
41
|
+
@clickable = clickable
|
|
42
|
+
@options = options
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def html_class
|
|
46
|
+
class_names(clickable ? "hover:bg-offset" : nil, @options[:class])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def html_options
|
|
50
|
+
@options.except(:class)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -4,10 +4,10 @@ module AtomicView
|
|
|
4
4
|
module Components
|
|
5
5
|
# Table
|
|
6
6
|
#
|
|
7
|
-
# A thin styling wrapper around <table>.
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
7
|
+
# A thin styling wrapper around <table>. By default this owns no
|
|
8
|
+
# row/column concept -- the consuming view builds its own
|
|
9
|
+
# <thead>/<tbody> markup and calls the yielded component's #head_class
|
|
10
|
+
# / #row_class helpers to pick up the shared styling:
|
|
11
11
|
#
|
|
12
12
|
# <%= render AtomicView::Components::TableComponent.new do |table| %>
|
|
13
13
|
# <thead>
|
|
@@ -21,17 +21,63 @@ module AtomicView
|
|
|
21
21
|
# </tr>
|
|
22
22
|
# </tbody>
|
|
23
23
|
# <% end %>
|
|
24
|
+
#
|
|
25
|
+
# `with_column` is an opt-in alternative to hand-building the <thead>:
|
|
26
|
+
# give it at least one column and TableComponent renders the header row
|
|
27
|
+
# itself, from `<th>`s built by TableComponent::ColumnComponent -- and
|
|
28
|
+
# wraps the block's return value in a <tbody> for you too, so the block
|
|
29
|
+
# only has to render rows (typically TableComponent::RowComponent),
|
|
30
|
+
# same as it would for a turbo_stream append/update elsewhere:
|
|
31
|
+
#
|
|
32
|
+
# <%= render AtomicView::Components::TableComponent.new(model: Booking) do |table| %>
|
|
33
|
+
# <% table.with_column(attribute: :camper_name) %>
|
|
34
|
+
# <% table.with_column(attribute: :status, align: :center) %>
|
|
35
|
+
# <% table.with_column(label: "") %>
|
|
36
|
+
#
|
|
37
|
+
# <% @bookings.each do |booking| %>
|
|
38
|
+
# <%= render(AtomicView::Components::TableComponent::RowComponent.new(record: booking, label: booking.camper_name, path: booking_path(booking))) do |row| %>
|
|
39
|
+
# <% row.with_cell { booking.camper_name } %>
|
|
40
|
+
# <% row.with_cell(align: :center) { render(BadgeComponent.new) { booking.status } } %>
|
|
41
|
+
# <% row.with_cell(interactive: true) { render(LinkComponent.new(edit_booking_path(booking), variant: :outline)) { "Edit" } } %>
|
|
42
|
+
# <% end %>
|
|
43
|
+
# <% end %>
|
|
44
|
+
# <% end %>
|
|
45
|
+
#
|
|
46
|
+
# The <tbody>'s `id` defaults to `model.model_name.plural` (e.g.
|
|
47
|
+
# "bookings") so a turbo_stream response can target it directly with
|
|
48
|
+
# no extra bookkeeping -- pass `body_id:` to override it (a nested
|
|
49
|
+
# resource sharing a differently-named frame, say).
|
|
50
|
+
#
|
|
51
|
+
# A table with no columns given renders exactly as before -- `with_column`
|
|
52
|
+
# is opt-in, not a second required API, and in that raw mode you still
|
|
53
|
+
# own the whole `<thead>`/`<tbody>` yourself. `model:` is passed through
|
|
54
|
+
# to each column so `with_column(attribute: :camper_name)` can default
|
|
55
|
+
# its label via `model.human_attribute_name(:camper_name)`; a column
|
|
56
|
+
# with no backing attribute (like the blank "" header above, for an
|
|
57
|
+
# actions column) just passes `label:` directly instead.
|
|
24
58
|
class TableComponent < AtomicView::Component
|
|
25
59
|
HEAD_ROW_CLASSES = "text-left text-xs font-semibold uppercase tracking-wide text-muted-foreground border-b border-border"
|
|
26
60
|
BODY_ROW_CLASSES = "border-b border-border hover:bg-offset"
|
|
27
61
|
|
|
28
|
-
|
|
62
|
+
renders_many :columns, ->(**kwargs) { ColumnComponent.new(model: model, **kwargs) }
|
|
63
|
+
|
|
64
|
+
attr_reader :model
|
|
65
|
+
|
|
66
|
+
def initialize(model: nil, body_id: nil, **options)
|
|
29
67
|
super()
|
|
68
|
+
@model = model
|
|
69
|
+
@body_id = body_id
|
|
30
70
|
@options = options
|
|
31
71
|
end
|
|
32
72
|
|
|
33
73
|
def call
|
|
34
|
-
tag.table(**@options.except(:class), class: class_names(base_classes, @options[:class]))
|
|
74
|
+
tag.table(**@options.except(:class), class: class_names(base_classes, @options[:class])) do
|
|
75
|
+
if columns?
|
|
76
|
+
safe_join([columns_header, tag.tbody(content, id: body_id)])
|
|
77
|
+
else
|
|
78
|
+
content
|
|
79
|
+
end
|
|
80
|
+
end
|
|
35
81
|
end
|
|
36
82
|
|
|
37
83
|
def head_class(**options)
|
|
@@ -44,6 +90,19 @@ module AtomicView
|
|
|
44
90
|
|
|
45
91
|
private
|
|
46
92
|
|
|
93
|
+
def columns_header
|
|
94
|
+
return unless columns?
|
|
95
|
+
tag.thead { tag.tr(class: head_class) { safe_join(columns) } }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def columns?
|
|
99
|
+
columns.any?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def body_id
|
|
103
|
+
@body_id || model&.model_name&.plural
|
|
104
|
+
end
|
|
105
|
+
|
|
47
106
|
def base_classes
|
|
48
107
|
"w-full text-sm"
|
|
49
108
|
end
|
data/lib/atomic_view/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: atomic_view
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Joel Warrington
|
|
@@ -219,6 +219,8 @@ files:
|
|
|
219
219
|
- lib/atomic_view/components/index_page_component.rb
|
|
220
220
|
- lib/atomic_view/components/kbd_component.rb
|
|
221
221
|
- lib/atomic_view/components/label_component.rb
|
|
222
|
+
- lib/atomic_view/components/lazy_pagination_component.html.erb
|
|
223
|
+
- lib/atomic_view/components/lazy_pagination_component.rb
|
|
222
224
|
- lib/atomic_view/components/link_component.html.erb
|
|
223
225
|
- lib/atomic_view/components/link_component.rb
|
|
224
226
|
- lib/atomic_view/components/modal_component.html.erb
|
|
@@ -245,6 +247,11 @@ files:
|
|
|
245
247
|
- lib/atomic_view/components/show_page_component.rb
|
|
246
248
|
- lib/atomic_view/components/submit_component.rb
|
|
247
249
|
- lib/atomic_view/components/table_component.rb
|
|
250
|
+
- lib/atomic_view/components/table_component/cell_component.html.erb
|
|
251
|
+
- lib/atomic_view/components/table_component/cell_component.rb
|
|
252
|
+
- lib/atomic_view/components/table_component/column_component.rb
|
|
253
|
+
- lib/atomic_view/components/table_component/row_component.html.erb
|
|
254
|
+
- lib/atomic_view/components/table_component/row_component.rb
|
|
248
255
|
- lib/atomic_view/components/tabs_component.html.erb
|
|
249
256
|
- lib/atomic_view/components/tabs_component.rb
|
|
250
257
|
- lib/atomic_view/components/telephone_field_component.rb
|