atomic_view 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 35571b6bf96da5419dcc5d10709fb6e391bc3d47991b0914934099003d4e2873
4
- data.tar.gz: '09145bc057a978d84c5d21c98fe3c86b5e3db12c52d3726480eb79025a9c0d93'
3
+ metadata.gz: 75aad701c9a9b4598ba4a086f8a3c1c37faaac0bbf23f4ef2927b4d667364f1b
4
+ data.tar.gz: 1a63769c7c94188b829105777227267be43df6480e6b2f864a5c7be4e831a179
5
5
  SHA512:
6
- metadata.gz: 26248796be4512d71197dae22674290aeab4c8b1b13dbaa4bc220e4c779858369f11e82ed9c645da13f101e8c9da130c8529694543426ff6c84bff0a73677072
7
- data.tar.gz: 7220d17e20fca18bff3896b2a31e65846b499a2a453e315af1625c9d181bb118f63659e276bfa0cc3e20766be564525d1d02dec1c12c50d8310d51037c1f8202
6
+ metadata.gz: ba9732b3bf6ed19951e2c29ac2feafa7af299679135645bf28650808fd9ff6a6c9e9c3d994be9d23319b69a4679e4e7a8d67a73ae46603a963544a11430e0579
7
+ data.tar.gz: 10aff2eb5c66efb09918961c87cc708941cd30721c769cd3c2402082d09898098b32389cdfffe52519bbd3bedfa34bb8f2f265694cb0ed5226148abcfcbcd058
@@ -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: "relative") %>
6
+ <% end %>
@@ -0,0 +1,58 @@
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 normal sibling of the (empty,
14
+ # transparent) stretched `<a>`, wrapped in its own `position:
15
+ # relative` span so a real link nested inside a cell's content (e.g.
16
+ # a second, more specific action) still stays clickable above the
17
+ # row-wide overlay.
18
+ class CellComponent < AtomicView::Component
19
+ attr_reader :path, :label, :first, :align, :clickable
20
+
21
+ def initialize(path:, label:, first:, align: :left, clickable: true, **options)
22
+ super()
23
+ @path = path
24
+ @label = label
25
+ @first = first
26
+ @align = align
27
+ @clickable = clickable
28
+ @options = options
29
+ end
30
+
31
+ def linked?
32
+ clickable && path.present?
33
+ end
34
+
35
+ def html_class
36
+ class_names(base_classes, align_classes, @options[:class])
37
+ end
38
+
39
+ def html_options
40
+ @options.except(:class)
41
+ end
42
+
43
+ private
44
+
45
+ def base_classes
46
+ "relative px-2.5 py-2.5 border-b border-border/5"
47
+ end
48
+
49
+ def align_classes
50
+ case align
51
+ when :center then "text-center font-semibold"
52
+ when :right then "text-right"
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ 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,53 @@ 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 -- you
28
+ # still own `<tbody>` content (typically TableComponent::RowComponent
29
+ # rows) in the block, same as before:
30
+ #
31
+ # <%= render AtomicView::Components::TableComponent.new(model: Booking) do |table| %>
32
+ # <% table.with_column(attribute: :camper_name) %>
33
+ # <% table.with_column(attribute: :status, align: :center) %>
34
+ # <% table.with_column(label: "") %>
35
+ #
36
+ # <tbody>
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 { render(LinkComponent.new(edit_booking_path(booking), variant: :outline)) { "Edit" } } %>
42
+ # <% end %>
43
+ # <% end %>
44
+ # </tbody>
45
+ # <% end %>
46
+ #
47
+ # A table with no columns given renders exactly as before -- `with_column`
48
+ # is opt-in, not a second required API. `model:` is passed through to
49
+ # each column so `with_column(attribute: :camper_name)` can default its
50
+ # label via `model.human_attribute_name(:camper_name)`; a column with no
51
+ # backing attribute (like the blank "" header above, for an actions
52
+ # column) just passes `label:` directly instead.
24
53
  class TableComponent < AtomicView::Component
25
54
  HEAD_ROW_CLASSES = "text-left text-xs font-semibold uppercase tracking-wide text-muted-foreground border-b border-border"
26
55
  BODY_ROW_CLASSES = "border-b border-border hover:bg-offset"
27
56
 
28
- def initialize(**options)
57
+ renders_many :columns, ->(**kwargs) { ColumnComponent.new(model: model, **kwargs) }
58
+
59
+ attr_reader :model
60
+
61
+ def initialize(model: nil, **options)
29
62
  super()
63
+ @model = model
30
64
  @options = options
31
65
  end
32
66
 
33
67
  def call
34
- tag.table(**@options.except(:class), class: class_names(base_classes, @options[:class])) { content }
68
+ tag.table(**@options.except(:class), class: class_names(base_classes, @options[:class])) do
69
+ safe_join([columns_header, content].compact)
70
+ end
35
71
  end
36
72
 
37
73
  def head_class(**options)
@@ -44,6 +80,15 @@ module AtomicView
44
80
 
45
81
  private
46
82
 
83
+ def columns_header
84
+ return unless columns?
85
+ tag.thead { tag.tr(class: head_class) { safe_join(columns) } }
86
+ end
87
+
88
+ def columns?
89
+ columns.any?
90
+ end
91
+
47
92
  def base_classes
48
93
  "w-full text-sm"
49
94
  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.0"
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.0
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