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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 35571b6bf96da5419dcc5d10709fb6e391bc3d47991b0914934099003d4e2873
4
- data.tar.gz: '09145bc057a978d84c5d21c98fe3c86b5e3db12c52d3726480eb79025a9c0d93'
3
+ metadata.gz: f37c405116dc18595f21f9f7b3a45223edef67c921c991a7c1523a93b594cf39
4
+ data.tar.gz: 3ce7216ee4b6327f57dec679651d507ba799023f4b2eb3b865ef61bfa3781c1a
5
5
  SHA512:
6
- metadata.gz: 26248796be4512d71197dae22674290aeab4c8b1b13dbaa4bc220e4c779858369f11e82ed9c645da13f101e8c9da130c8529694543426ff6c84bff0a73677072
7
- data.tar.gz: 7220d17e20fca18bff3896b2a31e65846b499a2a453e315af1625c9d181bb118f63659e276bfa0cc3e20766be564525d1d02dec1c12c50d8310d51037c1f8202
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,3 @@
1
+ <%= tag.tr(**html_options, class: html_class) do %>
2
+ <%= safe_join(cells) %>
3
+ <% 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>. Consistent with the rest of the
8
- # gem, this does not own row/column iteration -- the consuming view
9
- # builds its own <thead>/<tbody> markup and calls the yielded component's
10
- # #head_class / #row_class helpers to pick up the shared styling.
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
- def initialize(**options)
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])) { content }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AtomicView
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.1"
5
5
  end
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.3.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