better_ui 0.10.0 → 0.11.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: f946c8d53c3641506cd438e13df28f7cc23b3b341294ee790b8fb8ce6a3b5438
4
- data.tar.gz: 0fa3b052a2588162f89d752460be51e318ed971152e6a903f9cfb76bbd2ca336
3
+ metadata.gz: aa5d4adee48517dabb8bd4b65018f8f05a5f9414e8ccbe46e7c0a6f833b735ba
4
+ data.tar.gz: b39c4dc0a95c7cca09807d27c88d6eba91fe480857bdb7d61dba1d2deb045cec
5
5
  SHA512:
6
- metadata.gz: 421babdbde227a74c064a9508b1dc32b4f04a31e0a96d4e3b5677492a5ed1d8e4edab11ad9e8289bf82c04424b73933559fda60a5d030cf5d42fc89fc4eb5f93
7
- data.tar.gz: 708369a2f8160381dd63e3dd1eedb0524eaf74517369d9e317bb95139f315e6041ae3ce6bfcb536e4ce15b2ab9e31cc6c9cf324b90322b726329284be9332057
6
+ metadata.gz: ded2a724d33adb91e44c7551689ba1c492f6ccf353c603c7d388db48f009331b820159064d796d83a8095f52f03dd7fd9c863b8e27ddecffbfde281c39bc0203
7
+ data.tar.gz: c9f3fecbe32188a0bd8e5c23876a9638e960377337dcb42165c0cc0dc63762140eff6a98802c57a426d5586db1be71f006fa933951e898168226477f2206c6bc
@@ -21,10 +21,11 @@ module BetterUi
21
21
  SORT_DIRECTIONS = %i[asc desc].freeze
22
22
 
23
23
  attr_reader :key, :label, :align, :header_classes, :cell_classes, :formatter,
24
- :sortable, :sorted, :sort_direction
24
+ :sortable, :sorted, :sort_direction, :sort_url, :sort_html
25
25
 
26
26
  def initialize(key: nil, label: nil, align: :left, header_classes: nil, cell_classes: nil,
27
- sortable: false, sorted: false, sort_direction: :asc, &formatter)
27
+ sortable: false, sorted: false, sort_direction: :asc,
28
+ sort_url: nil, sort_html: {}, &formatter)
28
29
  @key = key
29
30
  @label = label
30
31
  @align = validate_align(align)
@@ -33,6 +34,8 @@ module BetterUi
33
34
  @sortable = sortable
34
35
  @sorted = sorted
35
36
  @sort_direction = validate_sort_direction(sort_direction)
37
+ @sort_url = sort_url
38
+ @sort_html = sort_html || {}
36
39
  @formatter = formatter
37
40
  end
38
41
 
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterUi
4
+ module Table
5
+ module Concerns
6
+ # Shared SVG sort icon helpers for table header components.
7
+ #
8
+ # Provides three icon methods used by both TableComponent (collection mode)
9
+ # and HeaderCellComponent (slot mode) to render sort direction indicators.
10
+ module SortIcons
11
+ extend ActiveSupport::Concern
12
+
13
+ private
14
+
15
+ # SVG chevron-up icon for ascending sort
16
+ def sort_icon_asc_svg
17
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="size-4">' \
18
+ '<path fill-rule="evenodd" d="M11.78 9.78a.75.75 0 0 1-1.06 0L8 7.06 5.28 9.78a.75.75 0 0 1-1.06-1.06l3.25-3.25a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06Z" clip-rule="evenodd" />' \
19
+ "</svg>"
20
+ end
21
+
22
+ # SVG chevron-down icon for descending sort
23
+ def sort_icon_desc_svg
24
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="size-4">' \
25
+ '<path fill-rule="evenodd" d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />' \
26
+ "</svg>"
27
+ end
28
+
29
+ # SVG chevron-up-down icon for unsorted state
30
+ def sort_icon_unsorted_svg
31
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="size-4">' \
32
+ '<path fill-rule="evenodd" d="M5.22 10.22a.75.75 0 0 1 1.06 0L8 11.94l1.72-1.72a.75.75 0 1 1 1.06 1.06l-2.25 2.25a.75.75 0 0 1-1.06 0l-2.25-2.25a.75.75 0 0 1 0-1.06ZM10.78 5.78a.75.75 0 0 1-1.06 0L8 4.06 6.28 5.78a.75.75 0 0 1-1.06-1.06l2.25-2.25a.75.75 0 0 1 1.06 0l2.25 2.25a.75.75 0 0 1 0 1.06Z" clip-rule="evenodd" />' \
33
+ "</svg>"
34
+ end
35
+
36
+ # Returns the appropriate sort icon SVG for the given sort state
37
+ def sort_icon_svg(sorted:, direction: :asc)
38
+ return sort_icon_unsorted_svg unless sorted
39
+
40
+ case direction
41
+ when :asc then sort_icon_asc_svg
42
+ when :desc then sort_icon_desc_svg
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -1,12 +1,21 @@
1
1
  <th class="<%= component_classes %>"<% if @scope %> scope="<%= @scope %>"<% end %> <%= tag.attributes(@options) %>>
2
- <% if @sortable %>
2
+ <% if sort_link? %>
3
+ <%= link_to @sort_url, **sort_link_html, class: "flex items-center gap-1 no-underline text-inherit" do %>
4
+ <% if @label.present? %>
5
+ <%= @label %>
6
+ <% else %>
7
+ <%= content %>
8
+ <% end %>
9
+ <span class="<%= sort_icon_classes %>"><%= raw sort_icon %></span>
10
+ <% end %>
11
+ <% elsif @sortable %>
3
12
  <span class="flex items-center gap-1">
4
13
  <% if @label.present? %>
5
14
  <%= @label %>
6
15
  <% else %>
7
16
  <%= content %>
8
17
  <% end %>
9
- <span class="<%= sort_icon_classes %>"><%= sort_icon %></span>
18
+ <span class="<%= sort_icon_classes %>"><%= raw sort_icon %></span>
10
19
  </span>
11
20
  <% else %>
12
21
  <% if @label.present? %>
@@ -13,6 +13,7 @@ module BetterUi
13
13
  # @example Header cell with block content
14
14
  # <%= render BetterUi::Table::HeaderCellComponent.new(align: :right) { "Actions" } %>
15
15
  class HeaderCellComponent < ApplicationComponent
16
+ include Concerns::SortIcons
16
17
  SIZES = {
17
18
  xs: { padding: "px-2 py-1.5", text: "text-xs" },
18
19
  sm: { padding: "px-3 py-2", text: "text-sm" },
@@ -26,6 +27,7 @@ module BetterUi
26
27
 
27
28
  def initialize(label: nil, align: :left, size: :md, style: :default, scope: :col,
28
29
  variant: :primary, sortable: false, sorted: false, sort_direction: :asc,
30
+ sort_url: nil, sort_html: {},
29
31
  container_classes: nil, **options)
30
32
  @label = label
31
33
  @align = validate_align(align)
@@ -36,6 +38,8 @@ module BetterUi
36
38
  @sortable = sortable
37
39
  @sorted = sorted
38
40
  @sort_direction = validate_sort_direction(sort_direction)
41
+ @sort_url = sort_url
42
+ @sort_html = sort_html || {}
39
43
  @container_classes = container_classes
40
44
  @options = options
41
45
  end
@@ -84,12 +88,8 @@ module BetterUi
84
88
 
85
89
  def sort_icon
86
90
  return nil unless @sortable
87
- return "↕" unless @sorted
88
91
 
89
- case @sort_direction
90
- when :asc then "↑"
91
- when :desc then "↓"
92
- end
92
+ sort_icon_svg(sorted: @sorted, direction: @sort_direction)
93
93
  end
94
94
 
95
95
  def sort_icon_classes
@@ -112,6 +112,14 @@ module BetterUi
112
112
  end
113
113
  end
114
114
 
115
+ def sort_link?
116
+ @sortable && @sort_url.present?
117
+ end
118
+
119
+ def sort_link_html
120
+ @sort_html
121
+ end
122
+
115
123
  def validate_align(align)
116
124
  unless ALIGNMENTS.include?(align)
117
125
  raise ArgumentError, "Invalid align: #{align}. Must be one of: #{ALIGNMENTS.join(', ')}"
@@ -16,10 +16,15 @@
16
16
  <tr>
17
17
  <% columns.each do |column| %>
18
18
  <th class="<%= collection_header_cell_classes(column) %>" scope="col">
19
- <% if column.sortable %>
19
+ <% if column.sortable && collection_sort_link?(column) %>
20
+ <%= link_to collection_sort_url(column), **collection_sort_link_html(column), class: "flex items-center gap-1 no-underline text-inherit" do %>
21
+ <%= column.display_label %>
22
+ <span class="<%= collection_sort_icon_classes(column) %>"><%= raw collection_sort_icon(column) %></span>
23
+ <% end %>
24
+ <% elsif column.sortable %>
20
25
  <span class="flex items-center gap-1">
21
26
  <%= column.display_label %>
22
- <span class="<%= collection_sort_icon_classes(column) %>"><%= collection_sort_icon(column) %></span>
27
+ <span class="<%= collection_sort_icon_classes(column) %>"><%= raw collection_sort_icon(column) %></span>
23
28
  </span>
24
29
  <% else %>
25
30
  <%= column.display_label %>
@@ -30,6 +30,7 @@ module BetterUi
30
30
  # <% t.with_column(key: :role, label: "Role") { |user| user.role.humanize } %>
31
31
  # <% end %>
32
32
  class TableComponent < ApplicationComponent
33
+ include Concerns::SortIcons
33
34
  SIZES = {
34
35
  xs: { th_padding: "px-2 py-1.5", td_padding: "px-2 py-2", text: "text-xs" },
35
36
  sm: { th_padding: "px-3 py-2", td_padding: "px-3 py-2.5", text: "text-sm" },
@@ -93,6 +94,10 @@ module BetterUi
93
94
  body_row_partial: nil,
94
95
  header_partial: nil,
95
96
  footer_partial: nil,
97
+ sort_column: nil,
98
+ sort_direction: nil,
99
+ sort_url: nil,
100
+ sort_html: {},
96
101
  container_classes: nil,
97
102
  table_classes: nil,
98
103
  header_classes: nil,
@@ -115,6 +120,10 @@ module BetterUi
115
120
  @body_row_partial = body_row_partial
116
121
  @header_partial = header_partial
117
122
  @footer_partial = footer_partial
123
+ @sort_column = sort_column&.to_sym
124
+ @table_sort_direction = sort_direction&.to_sym
125
+ @sort_url = sort_url
126
+ @sort_html = sort_html || {}
118
127
  @container_classes = container_classes
119
128
  @table_classes = table_classes
120
129
  @header_classes = header_classes
@@ -417,20 +426,41 @@ module BetterUi
417
426
  "cursor-pointer select-none"
418
427
  end
419
428
 
420
- # Collection mode: sort icon
429
+ # Collection mode: whether this column is currently sorted
430
+ # Table-level sort_column overrides column-level sorted
431
+ def effective_sorted?(column)
432
+ if @sort_column
433
+ column.key && column.key.to_sym == @sort_column
434
+ else
435
+ column.sorted
436
+ end
437
+ end
438
+
439
+ # Collection mode: effective sort direction for a column
440
+ # Table-level overrides column-level when the column is the sorted one
441
+ def effective_sort_direction(column)
442
+ if @sort_column && effective_sorted?(column) && @table_sort_direction
443
+ @table_sort_direction
444
+ else
445
+ column.sort_direction
446
+ end
447
+ end
448
+
449
+ # Collection mode: next sort direction (toggles asc↔desc)
450
+ def next_sort_direction(column)
451
+ effective_sorted?(column) && effective_sort_direction(column) == :asc ? :desc : :asc
452
+ end
453
+
454
+ # Collection mode: sort icon SVG
421
455
  def collection_sort_icon(column)
422
456
  return nil unless column.sortable
423
- return "↕" unless column.sorted
424
457
 
425
- case column.sort_direction
426
- when :asc then "↑"
427
- when :desc then "↓"
428
- end
458
+ sort_icon_svg(sorted: effective_sorted?(column), direction: effective_sort_direction(column))
429
459
  end
430
460
 
431
461
  # Collection mode: sort icon classes
432
462
  def collection_sort_icon_classes(column)
433
- return "text-grayscale-400" unless column.sorted
463
+ return "text-grayscale-400" unless effective_sorted?(column)
434
464
 
435
465
  case @variant
436
466
  when :primary then "text-primary-700"
@@ -445,6 +475,28 @@ module BetterUi
445
475
  end
446
476
  end
447
477
 
478
+ # Collection mode: whether a column should render a sort link
479
+ def collection_sort_link?(column)
480
+ return false unless column.sortable
481
+ column.sort_url.present? || @sort_url.present?
482
+ end
483
+
484
+ # Collection mode: resolved sort URL for a column
485
+ def collection_sort_url(column)
486
+ if column.sort_url.present?
487
+ column.sort_url
488
+ elsif @sort_url.present?
489
+ @sort_url.call(column.key, next_sort_direction(column))
490
+ end
491
+ end
492
+
493
+ # Collection mode: merged sort link HTML attributes
494
+ def collection_sort_link_html(column)
495
+ base = @sort_html.dup
496
+ override = column.sort_html
497
+ base.merge(override)
498
+ end
499
+
448
500
  # Partial helpers
449
501
  def body_row_partial?
450
502
  @body_row_partial.present?
@@ -1,3 +1,3 @@
1
1
  module BetterUi
2
- VERSION = "0.10.0"
2
+ VERSION = "0.11.0"
3
3
  end
@@ -1,9 +1,9 @@
1
1
  <%
2
2
  users = [
3
- OpenStruct.new(name: "Alice Johnson", email: "alice@example.com", role: "admin", active: true),
4
- OpenStruct.new(name: "Bob Smith", email: "bob@example.com", role: "editor", active: true),
5
- OpenStruct.new(name: "Charlie Brown", email: "charlie@example.com", role: "viewer", active: false),
6
- OpenStruct.new(name: "Diana Prince", email: "diana@example.com", role: "admin", active: true)
3
+ ::OpenStruct.new(name: "Alice Johnson", email: "alice@example.com", role: "admin", active: true),
4
+ ::OpenStruct.new(name: "Bob Smith", email: "bob@example.com", role: "editor", active: true),
5
+ ::OpenStruct.new(name: "Charlie Brown", email: "charlie@example.com", role: "viewer", active: false),
6
+ ::OpenStruct.new(name: "Diana Prince", email: "diana@example.com", role: "admin", active: true)
7
7
  ]
8
8
  %>
9
9
 
@@ -44,10 +44,10 @@
44
44
 
45
45
  <%
46
46
  orders = [
47
- OpenStruct.new(number: "ORD-001", customer: "Alice Johnson", status: "Shipped", total: "$299.00", pending: false),
48
- OpenStruct.new(number: "ORD-002", customer: "Bob Smith", status: "Pending Review", total: "$149.50", pending: true),
49
- OpenStruct.new(number: "ORD-003", customer: "Charlie Brown", status: "Delivered", total: "$75.25", pending: false),
50
- OpenStruct.new(number: "ORD-004", customer: "Diana Prince", status: "Pending Review", total: "$512.00", pending: true)
47
+ ::OpenStruct.new(number: "ORD-001", customer: "Alice Johnson", status: "Shipped", total: "$299.00", pending: false),
48
+ ::OpenStruct.new(number: "ORD-002", customer: "Bob Smith", status: "Pending Review", total: "$149.50", pending: true),
49
+ ::OpenStruct.new(number: "ORD-003", customer: "Charlie Brown", status: "Delivered", total: "$75.25", pending: false),
50
+ ::OpenStruct.new(number: "ORD-004", customer: "Diana Prince", status: "Pending Review", total: "$512.00", pending: true)
51
51
  ]
52
52
  %>
53
53
 
@@ -4,9 +4,9 @@
4
4
 
5
5
  <%
6
6
  users = [
7
- OpenStruct.new(id: 1, name: "Alice Johnson", email: "alice@example.com", role: "Admin"),
8
- OpenStruct.new(id: 2, name: "Bob Smith", email: "bob@example.com", role: "Editor"),
9
- OpenStruct.new(id: 3, name: "Charlie Brown", email: "charlie@example.com", role: "Viewer")
7
+ ::OpenStruct.new(id: 1, name: "Alice Johnson", email: "alice@example.com", role: "Admin"),
8
+ ::OpenStruct.new(id: 2, name: "Bob Smith", email: "bob@example.com", role: "Editor"),
9
+ ::OpenStruct.new(id: 3, name: "Charlie Brown", email: "charlie@example.com", role: "Viewer")
10
10
  ]
11
11
  %>
12
12
 
@@ -29,9 +29,9 @@
29
29
 
30
30
  <%
31
31
  users = [
32
- OpenStruct.new(name: "Alice Johnson", email: "alice@example.com", role: "Admin", joined: "2024-01-15"),
33
- OpenStruct.new(name: "Bob Smith", email: "bob@example.com", role: "Editor", joined: "2024-02-20"),
34
- OpenStruct.new(name: "Charlie Brown", email: "charlie@example.com", role: "Viewer", joined: "2024-03-10")
32
+ ::OpenStruct.new(name: "Alice Johnson", email: "alice@example.com", role: "Admin", joined: "2024-01-15"),
33
+ ::OpenStruct.new(name: "Bob Smith", email: "bob@example.com", role: "Editor", joined: "2024-02-20"),
34
+ ::OpenStruct.new(name: "Charlie Brown", email: "charlie@example.com", role: "Viewer", joined: "2024-03-10")
35
35
  ]
36
36
  %>
37
37
 
@@ -41,4 +41,46 @@
41
41
  <% t.with_column(key: :role, label: "Role") %>
42
42
  <% t.with_column(key: :joined, label: "Joined", sortable: true, sorted: true, sort_direction: :desc) %>
43
43
  <% end %>
44
+
45
+ <h2 class="text-xl font-bold mb-4 mt-8">Sortable with Links (Slot Mode)</h2>
46
+ <p class="text-sm text-grayscale-500 mb-4">Sort headers wrapped in clickable links with sort_url.</p>
47
+
48
+ <%= render BetterUi::Table::TableComponent.new(variant: :success) do |t| %>
49
+ <% t.with_header do |h| %>
50
+ <% h.with_cell(label: "Name", sortable: true, sorted: true, sort_direction: :asc,
51
+ sort_url: "?sort=name&direction=desc",
52
+ sort_html: { data: { turbo_frame: "_top" } }) %>
53
+ <% h.with_cell(label: "Email", sortable: true,
54
+ sort_url: "?sort=email&direction=asc") %>
55
+ <% h.with_cell(label: "Role") %>
56
+ <% end %>
57
+
58
+ <% [
59
+ ["Alice Johnson", "alice@example.com", "Admin"],
60
+ ["Bob Smith", "bob@example.com", "Editor"]
61
+ ].each do |row_data| %>
62
+ <% t.with_row do |r| %>
63
+ <% r.with_cell { row_data[0] } %>
64
+ <% r.with_cell { row_data[1] } %>
65
+ <% r.with_cell { row_data[2] } %>
66
+ <% end %>
67
+ <% end %>
68
+ <% end %>
69
+
70
+ <h2 class="text-xl font-bold mb-4 mt-8">Sortable with Links (Collection Mode + Table-level sort_url)</h2>
71
+ <p class="text-sm text-grayscale-500 mb-4">Table-level sort_url auto-generates links for all sortable columns, with sort_column deriving the sorted state.</p>
72
+
73
+ <%= render BetterUi::Table::TableComponent.new(
74
+ collection: users,
75
+ variant: :accent,
76
+ sort_column: :name,
77
+ sort_direction: :asc,
78
+ sort_url: ->(key, dir) { "?sort=#{key}&direction=#{dir}" },
79
+ sort_html: { data: { turbo_frame: "_top" } }
80
+ ) do |t| %>
81
+ <% t.with_column(key: :name, label: "Name", sortable: true) %>
82
+ <% t.with_column(key: :email, label: "Email", sortable: true) %>
83
+ <% t.with_column(key: :role, label: "Role") %>
84
+ <% t.with_column(key: :joined, label: "Joined", sortable: true) %>
85
+ <% end %>
44
86
  </div>
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ostruct"
4
+
3
5
  module BetterUi
4
6
  module Table
5
7
  class TableComponentPreview < ViewComponent::Preview
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: better_ui
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Umberto Peserico
@@ -158,6 +158,7 @@ files:
158
158
  - app/components/better_ui/table/cell_component.rb
159
159
  - app/components/better_ui/table/cell_component/cell_component.html.erb
160
160
  - app/components/better_ui/table/column_component.rb
161
+ - app/components/better_ui/table/concerns/sort_icons.rb
161
162
  - app/components/better_ui/table/header_cell_component.rb
162
163
  - app/components/better_ui/table/header_cell_component/header_cell_component.html.erb
163
164
  - app/components/better_ui/table/header_component.rb