maquina-components 0.1.2 → 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.
Files changed (113) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +349 -138
  3. data/app/assets/images/maquina.svg +1 -0
  4. data/app/assets/stylesheets/alert.css +143 -0
  5. data/app/assets/stylesheets/badge.css +145 -0
  6. data/app/assets/stylesheets/breadcrumbs.css +163 -0
  7. data/app/assets/stylesheets/card.css +128 -0
  8. data/app/assets/stylesheets/dropdown_menu.css +248 -0
  9. data/app/assets/stylesheets/empty.css +133 -0
  10. data/app/assets/stylesheets/form.css +617 -0
  11. data/app/assets/stylesheets/header.css +61 -0
  12. data/app/assets/stylesheets/maquina_components.css +143 -64
  13. data/app/assets/stylesheets/pagination.css +154 -0
  14. data/app/assets/stylesheets/sidebar.css +477 -0
  15. data/app/assets/stylesheets/table.css +205 -0
  16. data/app/assets/stylesheets/toggle_group.css +151 -0
  17. data/app/assets/tailwind/maquina_components_engine/engine.css +16 -0
  18. data/app/helpers/maquina_components/breadcrumbs_helper.rb +118 -0
  19. data/app/helpers/maquina_components/dropdown_menu_helper.rb +249 -0
  20. data/app/helpers/maquina_components/empty_helper.rb +102 -0
  21. data/app/helpers/{components → maquina_components}/icons_helper.rb +40 -3
  22. data/app/helpers/maquina_components/pagination_helper.rb +153 -0
  23. data/app/helpers/maquina_components/sidebar_helper.rb +63 -0
  24. data/app/helpers/maquina_components/table_helper.rb +144 -0
  25. data/app/helpers/maquina_components/toggle_group_helper.rb +172 -0
  26. data/app/javascript/controllers/breadcrumb_controller.js +71 -0
  27. data/app/javascript/controllers/dropdown_menu_controller.js +203 -0
  28. data/app/javascript/controllers/menu_button_controller.js +59 -0
  29. data/app/javascript/controllers/sidebar_controller.js +316 -0
  30. data/app/javascript/controllers/sidebar_trigger_controller.js +32 -0
  31. data/app/javascript/controllers/toggle_group_controller.js +178 -0
  32. data/app/views/components/_alert.html.erb +11 -10
  33. data/app/views/components/_badge.html.erb +10 -0
  34. data/app/views/components/_breadcrumbs.html.erb +16 -0
  35. data/app/views/components/_card.html.erb +4 -8
  36. data/app/views/components/_dropdown.html.erb +25 -0
  37. data/app/views/components/_dropdown_menu.html.erb +9 -0
  38. data/app/views/components/_empty.html.erb +10 -0
  39. data/app/views/components/_header.html.erb +8 -0
  40. data/app/views/components/_menu_button.html.erb +44 -0
  41. data/app/views/components/_pagination.html.erb +12 -33
  42. data/app/views/components/_separator.html.erb +11 -0
  43. data/app/views/components/_sidebar.html.erb +30 -20
  44. data/app/views/components/_simple_table.html.erb +49 -0
  45. data/app/views/components/_table.html.erb +21 -0
  46. data/app/views/components/_toggle_group.html.erb +24 -0
  47. data/app/views/components/alert/_description.html.erb +6 -0
  48. data/app/views/components/alert/_title.html.erb +6 -0
  49. data/app/views/components/breadcrumbs/_ellipsis.html.erb +9 -0
  50. data/app/views/components/breadcrumbs/_item.html.erb +8 -0
  51. data/app/views/components/breadcrumbs/_link.html.erb +8 -0
  52. data/app/views/components/breadcrumbs/_list.html.erb +8 -0
  53. data/app/views/components/breadcrumbs/_page.html.erb +8 -0
  54. data/app/views/components/breadcrumbs/_separator.html.erb +17 -0
  55. data/app/views/components/card/_action.html.erb +6 -0
  56. data/app/views/components/card/_content.html.erb +9 -0
  57. data/app/views/components/card/_description.html.erb +6 -0
  58. data/app/views/components/card/_footer.html.erb +17 -0
  59. data/app/views/components/card/_header.html.erb +9 -0
  60. data/app/views/components/card/_title.html.erb +9 -0
  61. data/app/views/components/dropdown_menu/_content.html.erb +20 -0
  62. data/app/views/components/dropdown_menu/_group.html.erb +12 -0
  63. data/app/views/components/dropdown_menu/_item.html.erb +29 -0
  64. data/app/views/components/dropdown_menu/_label.html.erb +13 -0
  65. data/app/views/components/dropdown_menu/_separator.html.erb +11 -0
  66. data/app/views/components/dropdown_menu/_shortcut.html.erb +12 -0
  67. data/app/views/components/dropdown_menu/_trigger.html.erb +24 -0
  68. data/app/views/components/empty/_content.html.erb +8 -0
  69. data/app/views/components/empty/_description.html.erb +12 -0
  70. data/app/views/components/empty/_header.html.erb +8 -0
  71. data/app/views/components/empty/_media.html.erb +13 -0
  72. data/app/views/components/empty/_title.html.erb +12 -0
  73. data/app/views/components/pagination/_content.html.erb +8 -0
  74. data/app/views/components/pagination/_ellipsis.html.erb +28 -0
  75. data/app/views/components/pagination/_item.html.erb +8 -0
  76. data/app/views/components/pagination/_link.html.erb +23 -0
  77. data/app/views/components/pagination/_next.html.erb +57 -0
  78. data/app/views/components/pagination/_previous.html.erb +57 -0
  79. data/app/views/components/sidebar/_content.html.erb +8 -0
  80. data/app/views/components/sidebar/_footer.html.erb +8 -0
  81. data/app/views/components/sidebar/_group.html.erb +12 -0
  82. data/app/views/components/sidebar/_header.html.erb +8 -0
  83. data/app/views/components/sidebar/_inset.html.erb +8 -0
  84. data/app/views/components/sidebar/_menu.html.erb +8 -0
  85. data/app/views/components/sidebar/_menu_button.html.erb +14 -0
  86. data/app/views/components/sidebar/_menu_item.html.erb +7 -0
  87. data/app/views/components/sidebar/_menu_link.html.erb +32 -0
  88. data/app/views/components/sidebar/_provider.html.erb +16 -0
  89. data/app/views/components/sidebar/_trigger.html.erb +12 -0
  90. data/app/views/components/stats/_stats_card.html.erb +100 -0
  91. data/app/views/components/stats/_stats_grid.html.erb +38 -0
  92. data/app/views/components/table/_body.html.erb +5 -0
  93. data/app/views/components/table/_caption.html.erb +5 -0
  94. data/app/views/components/table/_cell.html.erb +5 -0
  95. data/app/views/components/table/_footer.html.erb +5 -0
  96. data/app/views/components/table/_head.html.erb +8 -0
  97. data/app/views/components/table/_header.html.erb +8 -0
  98. data/app/views/components/table/_row.html.erb +8 -0
  99. data/app/views/components/toggle_group/_item.html.erb +19 -0
  100. data/config/importmap.rb +1 -0
  101. data/lib/generators/maquina_components/install/USAGE +39 -0
  102. data/lib/generators/maquina_components/install/install_generator.rb +123 -0
  103. data/lib/generators/maquina_components/install/templates/maquina_components_helper.rb.tt +68 -0
  104. data/lib/generators/maquina_components/install/templates/theme.css.tt +179 -0
  105. data/lib/maquina_components/engine.rb +10 -0
  106. data/lib/maquina_components/version.rb +1 -1
  107. metadata +116 -12
  108. data/app/helpers/components/pagination_helper.rb +0 -15
  109. data/app/views/components/_card_content.html.erb +0 -5
  110. data/app/views/components/_card_header.html.erb +0 -8
  111. data/app/views/components/_sidebar_content.html.erb +0 -8
  112. data/app/views/components/_sidebar_group.html.erb +0 -42
  113. data/app/views/components/_sidebar_header.html.erb +0 -3
@@ -1,9 +1,9 @@
1
- module Components
1
+ module MaquinaComponents
2
2
  module IconsHelper
3
3
  def icon_for(name, options = {})
4
4
  return nil unless name
5
5
 
6
- svg = icon_svg_for(name.to_sym)
6
+ svg = icon_svg_for(name.to_sym) || main_icon_svg_for(name.to_sym)
7
7
  return nil unless svg
8
8
 
9
9
  css_classes = options[:class]
@@ -16,7 +16,8 @@ module Components
16
16
  svg.html_safe
17
17
  end
18
18
 
19
- private
19
+ def main_icon_svg_for(name)
20
+ end
20
21
 
21
22
  def icon_svg_for(name)
22
23
  case name
@@ -118,6 +119,42 @@ module Components
118
119
  <line x1="12" x2="12.01" y1="16" y2="16"/>
119
120
  </svg>
120
121
  SVG
122
+ when :logout
123
+ <<~SVG.freeze
124
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="">
125
+ <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
126
+ <polyline points="16 17 21 12 16 7"></polyline>
127
+ <line x1="21" x2="9" y1="12" y2="12"></line>
128
+ </svg>
129
+ SVG
130
+ when :chevron_up_down
131
+ <<~SVG.freeze
132
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="">
133
+ <path d="m7 15 5 5 5-5"></path>
134
+ <path d="m7 9 5-5 5 5"></path>
135
+ </svg>
136
+ SVG
137
+ when :chevron_right
138
+ <<~SVG
139
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="">
140
+ <path d="m9 18 6-6-6-6"/>
141
+ </svg>
142
+ SVG
143
+ when :left_panel
144
+ <<~SVG.freeze
145
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="">
146
+ <rect width="18" height="18" x="3" y="3" rx="2"></rect>
147
+ <path d="M9 3v18"></path>
148
+ </svg>
149
+ SVG
150
+ when :ellipsis
151
+ <<~SVG.freeze
152
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
153
+ <circle cx="12" cy="12" r="1"/>
154
+ <circle cx="19" cy="12" r="1"/>
155
+ <circle cx="5" cy="12" r="1"/>
156
+ </svg>
157
+ SVG
121
158
  end
122
159
  end
123
160
  end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaquinaComponents
4
+ # Pagination Helper
5
+ #
6
+ # Provides convenient methods for creating pagination components with Pagy integration.
7
+ #
8
+ # @example Using the helper with Pagy
9
+ # <%%= pagination_nav(@pagy, :users_path) %>
10
+ #
11
+ # @example With additional params
12
+ # <%%= pagination_nav(@pagy, :search_users_path, params: { q: params[:q] }) %>
13
+ #
14
+ # @example With Turbo options
15
+ # <%%= pagination_nav(@pagy, :users_path, turbo: { action: :replace, frame: "users" }) %>
16
+ #
17
+ # @example Using partials directly
18
+ # <%%= render "components/pagination" do %>
19
+ # <%%= render "components/pagination/content" do %>
20
+ # <%%= render "components/pagination/item" do %>
21
+ # <%%= render "components/pagination/previous", href: prev_path %>
22
+ # <%% end %>
23
+ # ...
24
+ # <%% end %>
25
+ # <%% end %>
26
+ #
27
+ module PaginationHelper
28
+ # Renders a complete pagination navigation from a Pagy object
29
+ #
30
+ # @param pagy [Pagy] The Pagy pagination object
31
+ # @param route_helper [Symbol] Route helper method name (e.g., :users_path)
32
+ # @param params [Hash] Additional params to pass to route helper
33
+ # @param turbo [Hash] Turbo-specific data attributes
34
+ # @param show_labels [Boolean] Whether to show Previous/Next text labels
35
+ # @param css_classes [String] Additional CSS classes for the nav
36
+ # @return [String] Rendered HTML
37
+ def pagination_nav(pagy, route_helper, params: {}, turbo: {action: :replace}, show_labels: true, css_classes: "", **html_options)
38
+ return if pagy.pages <= 1
39
+
40
+ render "components/pagination", css_classes: css_classes, **html_options do
41
+ render "components/pagination/content" do
42
+ safe_join([
43
+ pagination_previous_item(pagy, route_helper, params, turbo, show_labels),
44
+ pagination_page_items(pagy, route_helper, params, turbo),
45
+ pagination_next_item(pagy, route_helper, params, turbo, show_labels)
46
+ ])
47
+ end
48
+ end
49
+ end
50
+
51
+ # Simpler pagination with just Previous/Next (no page numbers)
52
+ #
53
+ # @param pagy [Pagy] The Pagy pagination object
54
+ # @param route_helper [Symbol] Route helper method name
55
+ # @param params [Hash] Additional params to pass to route helper
56
+ # @param turbo [Hash] Turbo-specific data attributes
57
+ # @return [String] Rendered HTML
58
+ def pagination_simple(pagy, route_helper, params: {}, turbo: {action: :replace}, css_classes: "", **html_options)
59
+ return if pagy.pages <= 1
60
+
61
+ render "components/pagination", css_classes: css_classes, **html_options do
62
+ render "components/pagination/content" do
63
+ safe_join([
64
+ pagination_previous_item(pagy, route_helper, params, turbo, true),
65
+ pagination_next_item(pagy, route_helper, params, turbo, true)
66
+ ])
67
+ end
68
+ end
69
+ end
70
+
71
+ # Build paginated path with page param
72
+ #
73
+ # @param route_helper [Symbol] Route helper method name
74
+ # @param pagy [Pagy] The Pagy pagination object
75
+ # @param page [Integer] Page number
76
+ # @param extra_params [Hash] Additional params
77
+ # @return [String] URL path
78
+ def paginated_path(route_helper, pagy, page, extra_params = {})
79
+ page_param = pagy.vars[:page_param] || Pagy::DEFAULT[:page_param]
80
+ query_params = request.query_parameters.except(page_param.to_s).merge(extra_params)
81
+ query_params[page_param] = page
82
+
83
+ send(route_helper, query_params)
84
+ end
85
+
86
+ private
87
+
88
+ def pagination_previous_item(pagy, route_helper, params, turbo, show_label)
89
+ render "components/pagination/item" do
90
+ if pagy.prev
91
+ render "components/pagination/previous",
92
+ href: paginated_path(route_helper, pagy, pagy.prev, params),
93
+ show_label: show_label,
94
+ data: turbo_data(turbo)
95
+ else
96
+ render "components/pagination/previous",
97
+ disabled: true,
98
+ show_label: show_label
99
+ end
100
+ end
101
+ end
102
+
103
+ def pagination_next_item(pagy, route_helper, params, turbo, show_label)
104
+ render "components/pagination/item" do
105
+ if pagy.next
106
+ render "components/pagination/next",
107
+ href: paginated_path(route_helper, pagy, pagy.next, params),
108
+ show_label: show_label,
109
+ data: turbo_data(turbo)
110
+ else
111
+ render "components/pagination/next",
112
+ disabled: true,
113
+ show_label: show_label
114
+ end
115
+ end
116
+ end
117
+
118
+ def pagination_page_items(pagy, route_helper, params, turbo)
119
+ pagy.series.map do |item|
120
+ render "components/pagination/item" do
121
+ case item
122
+ when Integer
123
+ render "components/pagination/link",
124
+ href: paginated_path(route_helper, pagy, item, params),
125
+ active: item == pagy.page,
126
+ data: turbo_data(turbo) do
127
+ item.to_s
128
+ end
129
+ when String
130
+ # Current page (string representation)
131
+ render "components/pagination/link",
132
+ href: paginated_path(route_helper, pagy, item.to_i, params),
133
+ active: true,
134
+ data: turbo_data(turbo) do
135
+ item
136
+ end
137
+ when :gap
138
+ render "components/pagination/ellipsis"
139
+ end
140
+ end
141
+ end
142
+ end
143
+
144
+ def turbo_data(turbo)
145
+ return {} if turbo.blank?
146
+
147
+ data = {}
148
+ data[:turbo_action] = turbo[:action] if turbo[:action]
149
+ data[:turbo_frame] = turbo[:frame] if turbo[:frame]
150
+ data
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,63 @@
1
+ module MaquinaComponents
2
+ module SidebarHelper
3
+ # Get sidebar state from cookie
4
+ #
5
+ # Reads the sidebar state cookie and returns a String.
6
+ # Use this to set the state value in the sidebar
7
+ # to ensure server-rendered state matches client state.
8
+ #
9
+ # @param cookie_name [String] The cookie name (default: "sidebar_state")
10
+ # @return [String] expanded if sidebar should be open, collapsed otherwise
11
+ #
12
+ # @example In layout
13
+ # <%= render "components/sidebar",
14
+ # state: sidebar_state do %>
15
+ # <!-- content -->
16
+ # <% end %>
17
+ #
18
+ # @example With custom cookie name
19
+ # <%= render "components/sidebar",
20
+ # state: sidebar_state("custom_sidebar_cookie") do %>
21
+ # <!-- content -->
22
+ # <% end %>
23
+ #
24
+ def sidebar_state(cookie_name = "sidebar_state")
25
+ # Read cookie value
26
+ cookie_value = cookies[cookie_name]
27
+
28
+ # Default to expanded when no cookie exists
29
+ return :expanded if cookie_value.nil?
30
+
31
+ # Return expanded if cookie says "true", otherwise collapsed
32
+ (cookie_value == "true") ? :expanded : :collapsed
33
+ end
34
+
35
+ # Check if sidebar is currently open
36
+ #
37
+ # @param cookie_name [String] The cookie name (default: "sidebar_state")
38
+ # @return [Boolean] true if sidebar is open
39
+ #
40
+ # @example
41
+ # <% if sidebar_open? %>
42
+ # <!-- Show sidebar-specific content -->
43
+ # <% end %>
44
+ #
45
+ def sidebar_open?(cookie_name = "sidebar_state")
46
+ sidebar_state(cookie_name) == :expanded
47
+ end
48
+
49
+ # Check if sidebar is currently closed
50
+ #
51
+ # @param cookie_name [String] The cookie name (default: "sidebar_state")
52
+ # @return [Boolean] true if sidebar is closed
53
+ #
54
+ # @example
55
+ # <% if sidebar_closed? %>
56
+ # <!-- Show expanded content when sidebar is closed -->
57
+ # <% end %>
58
+ #
59
+ def sidebar_closed?(cookie_name = "sidebar_state")
60
+ sidebar_state(cookie_name) == :collapsed
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaquinaComponents
4
+ # Table Component Helper
5
+ #
6
+ # Provides a simple helper for rendering basic tables from collections.
7
+ # For complex tables, use the partials directly for full control.
8
+ #
9
+ # @example Basic usage with collection
10
+ # <%= simple_table @invoices,
11
+ # columns: [
12
+ # { key: :number, label: "Invoice" },
13
+ # { key: :status, label: "Status" },
14
+ # { key: :amount, label: "Amount", align: :right }
15
+ # ] %>
16
+ #
17
+ # @example With caption and bordered variant
18
+ # <%= simple_table @users,
19
+ # columns: [
20
+ # { key: :name, label: "Name" },
21
+ # { key: :email, label: "Email" },
22
+ # { key: :role, label: "Role" }
23
+ # ],
24
+ # caption: "Active users",
25
+ # variant: :bordered %>
26
+ #
27
+ module TableHelper
28
+ # Render a simple table from a collection
29
+ #
30
+ # @param collection [Array, ActiveRecord::Relation] The collection to render
31
+ # @param columns [Array<Hash>] Column definitions with :key, :label, and optional :align
32
+ # @param caption [String, nil] Optional table caption
33
+ # @param variant [Symbol, nil] Container variant (:bordered)
34
+ # @param table_variant [Symbol, nil] Table variant (:striped)
35
+ # @param empty_message [String] Message to show when collection is empty
36
+ # @param row_id [Symbol, nil] Method to call for row ID (e.g., :id)
37
+ # @param html_options [Hash] Additional HTML options for the table
38
+ # @return [String] Rendered HTML
39
+ def simple_table(collection, columns:, caption: nil, variant: nil, table_variant: nil, empty_message: "No data available", row_id: nil, **html_options)
40
+ render partial: "components/simple_table", locals: {
41
+ collection: collection,
42
+ columns: columns,
43
+ caption: caption,
44
+ variant: variant,
45
+ table_variant: table_variant,
46
+ empty_message: empty_message,
47
+ row_id: row_id,
48
+ html_options: html_options
49
+ }
50
+ end
51
+
52
+ # Generate data attributes for table elements
53
+ # Useful when composing tables with other Rails helpers
54
+ #
55
+ # @example Using with content_tag
56
+ # <%= content_tag :table, **table_data_attrs do %>
57
+ # ...
58
+ # <% end %>
59
+ #
60
+ # @param variant [Symbol, nil] Table variant (:striped)
61
+ # @return [Hash] Data attributes hash
62
+ def table_data_attrs(variant: nil)
63
+ attrs = { data: { component: "table" } }
64
+ attrs[:data][:variant] = variant.to_s if variant
65
+ attrs
66
+ end
67
+
68
+ # Generate data attributes for table container
69
+ #
70
+ # @param variant [Symbol, nil] Container variant (:bordered)
71
+ # @return [Hash] Data attributes hash
72
+ def table_container_data_attrs(variant: nil)
73
+ attrs = { data: { table_part: "container" } }
74
+ attrs[:data][:variant] = variant.to_s if variant
75
+ attrs
76
+ end
77
+
78
+ # Generate data attributes for table row
79
+ #
80
+ # @param selected [Boolean] Whether the row is selected
81
+ # @return [Hash] Data attributes hash
82
+ def table_row_data_attrs(selected: false)
83
+ attrs = { data: { table_part: "row" } }
84
+ attrs[:data][:state] = "selected" if selected
85
+ attrs
86
+ end
87
+
88
+ # Generate data attributes for table header
89
+ #
90
+ # @param sticky [Boolean] Whether the header is sticky
91
+ # @return [Hash] Data attributes hash
92
+ def table_header_data_attrs(sticky: false)
93
+ attrs = { data: { table_part: "header" } }
94
+ attrs[:data][:sticky] = "true" if sticky
95
+ attrs
96
+ end
97
+
98
+ # Generate data attributes for table head cell
99
+ # @return [Hash] Data attributes hash
100
+ def table_head_data_attrs
101
+ { data: { table_part: "head" } }
102
+ end
103
+
104
+ # Generate data attributes for table cell
105
+ #
106
+ # @param empty [Boolean] Whether this is an empty state cell
107
+ # @return [Hash] Data attributes hash
108
+ def table_cell_data_attrs(empty: false)
109
+ attrs = { data: { table_part: "cell" } }
110
+ attrs[:data][:empty] = "true" if empty
111
+ attrs
112
+ end
113
+
114
+ # Generate data attributes for table body
115
+ # @return [Hash] Data attributes hash
116
+ def table_body_data_attrs
117
+ { data: { table_part: "body" } }
118
+ end
119
+
120
+ # Generate data attributes for table footer
121
+ # @return [Hash] Data attributes hash
122
+ def table_footer_data_attrs
123
+ { data: { table_part: "footer" } }
124
+ end
125
+
126
+ # Generate data attributes for table caption
127
+ # @return [Hash] Data attributes hash
128
+ def table_caption_data_attrs
129
+ { data: { table_part: "caption" } }
130
+ end
131
+
132
+ # Convert alignment symbol to CSS class
133
+ #
134
+ # @param align [Symbol, nil] Alignment (:left, :center, :right)
135
+ # @return [String, nil] CSS class name
136
+ def table_alignment_class(align)
137
+ case align&.to_sym
138
+ when :right then "text-right"
139
+ when :center then "text-center"
140
+ else nil
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaquinaComponents
4
+ # Toggle Group Helper
5
+ #
6
+ # Provides convenient methods for creating toggle group components.
7
+ #
8
+ # @example Using partials directly
9
+ # <%%= render "components/toggle_group", type: :single, variant: :outline do %>
10
+ # <%%= render "components/toggle_group/item", value: "bold", aria_label: "Toggle bold" do %>
11
+ # <%%= icon_for :bold %>
12
+ # <%% end %>
13
+ # <%% end %>
14
+ #
15
+ # @example Using the helper with builder
16
+ # <%%= toggle_group type: :multiple, variant: :outline do |group| %>
17
+ # <%% group.item value: "bold", icon: :bold, aria_label: "Toggle bold" %>
18
+ # <%% group.item value: "italic", icon: :italic, aria_label: "Toggle italic" %>
19
+ # <%% end %>
20
+ #
21
+ # @example Simple data-driven helper
22
+ # <%%= toggle_group_simple type: :single, items: [
23
+ # { value: "left", icon: :align_left, aria_label: "Align left" },
24
+ # { value: "center", icon: :align_center, aria_label: "Align center" },
25
+ # { value: "right", icon: :align_right, aria_label: "Align right" }
26
+ # ] %>
27
+ #
28
+ module ToggleGroupHelper
29
+ # Renders a toggle group with builder pattern
30
+ #
31
+ # @param type [Symbol] Selection mode (:single, :multiple)
32
+ # @param variant [Symbol] Visual style (:default, :outline)
33
+ # @param size [Symbol] Size variant (:default, :sm, :lg)
34
+ # @param value [String, Array, nil] Initially selected value(s)
35
+ # @param disabled [Boolean] Disable all items
36
+ # @param css_classes [String] Additional CSS classes
37
+ # @param html_options [Hash] Additional HTML attributes
38
+ # @yield [ToggleGroupBuilder] Builder for adding items
39
+ # @return [String] Rendered HTML
40
+ def toggle_group(type: :single, variant: :default, size: :default, value: nil, disabled: false, css_classes: "", **html_options, &block)
41
+ builder = ToggleGroupBuilder.new(self, type: type, variant: variant, size: size, value: value, disabled: disabled)
42
+
43
+ if block && block.arity == 1
44
+ capture { yield(builder) }
45
+ end
46
+
47
+ render "components/toggle_group",
48
+ type: type,
49
+ variant: variant,
50
+ size: size,
51
+ value: value,
52
+ disabled: disabled,
53
+ css_classes: css_classes,
54
+ **html_options do
55
+ if block && block.arity == 1
56
+ builder.to_html
57
+ elsif block
58
+ capture(&block)
59
+ end
60
+ end
61
+ end
62
+
63
+ # Renders a simple data-driven toggle group
64
+ #
65
+ # @param type [Symbol] Selection mode (:single, :multiple)
66
+ # @param items [Array<Hash>] Array of item configurations
67
+ # @param variant [Symbol] Visual style
68
+ # @param size [Symbol] Size variant
69
+ # @param value [String, Array, nil] Initially selected value(s)
70
+ # @return [String] Rendered HTML
71
+ def toggle_group_simple(items:, type: :single, variant: :default, size: :default, value: nil, disabled: false, css_classes: "", **html_options)
72
+ selected_values = normalize_value(value)
73
+
74
+ render "components/toggle_group",
75
+ type: type,
76
+ variant: variant,
77
+ size: size,
78
+ value: value,
79
+ disabled: disabled,
80
+ css_classes: css_classes,
81
+ **html_options do
82
+ safe_join(items.map do |item|
83
+ item_value = item[:value].to_s
84
+ is_pressed = selected_values.include?(item_value)
85
+
86
+ render "components/toggle_group/item",
87
+ value: item_value,
88
+ pressed: is_pressed,
89
+ disabled: item[:disabled] || disabled,
90
+ aria_label: item[:aria_label] do
91
+ parts = []
92
+ parts << icon_for(item[:icon]) if item[:icon] && respond_to?(:icon_for)
93
+ parts << item[:label] if item[:label]
94
+ safe_join(parts)
95
+ end
96
+ end)
97
+ end
98
+ end
99
+
100
+ # Builder class for toggle group
101
+ class ToggleGroupBuilder
102
+ def initialize(view_context, type:, variant:, size:, value:, disabled:)
103
+ @view = view_context
104
+ @type = type
105
+ @variant = variant
106
+ @size = size
107
+ @value = value
108
+ @disabled = disabled
109
+ @items = []
110
+ @selected_values = normalize_value(value)
111
+ end
112
+
113
+ # Add an item to the toggle group
114
+ def item(value:, label: nil, icon: nil, disabled: false, aria_label: nil, **options, &block)
115
+ is_pressed = @selected_values.include?(value.to_s)
116
+
117
+ @items << {
118
+ value: value,
119
+ label: label,
120
+ icon: icon,
121
+ disabled: disabled || @disabled,
122
+ aria_label: aria_label,
123
+ pressed: is_pressed,
124
+ options: options,
125
+ block: block
126
+ }
127
+ end
128
+
129
+ def to_html
130
+ @view.safe_join(@items.map { |item| render_item(item) })
131
+ end
132
+
133
+ private
134
+
135
+ def render_item(item)
136
+ @view.render "components/toggle_group/item",
137
+ value: item[:value],
138
+ pressed: item[:pressed],
139
+ disabled: item[:disabled],
140
+ aria_label: item[:aria_label],
141
+ **item[:options] do
142
+ if item[:block]
143
+ @view.capture(&item[:block])
144
+ else
145
+ parts = []
146
+ parts << @view.icon_for(item[:icon]) if item[:icon] && @view.respond_to?(:icon_for)
147
+ parts << item[:label] if item[:label]
148
+ @view.safe_join(parts)
149
+ end
150
+ end
151
+ end
152
+
153
+ def normalize_value(value)
154
+ case value
155
+ when Array then value.map(&:to_s)
156
+ when nil then []
157
+ else [value.to_s]
158
+ end
159
+ end
160
+ end
161
+
162
+ private
163
+
164
+ def normalize_value(value)
165
+ case value
166
+ when Array then value.map(&:to_s)
167
+ when nil then []
168
+ else [value.to_s]
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,71 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["item", "ellipsis", "ellipsisSeparator"]
5
+
6
+ connect() {
7
+ this.windowResizeHandler = this.handleResize.bind(this)
8
+ window.addEventListener('resize', this.windowResizeHandler)
9
+ this.handleResize()
10
+ }
11
+
12
+ disconnect() {
13
+ window.removeEventListener('resize', this.windowResizeHandler)
14
+ }
15
+
16
+ handleResize() {
17
+ // Get visible width of container
18
+ const containerWidth = this.element.clientWidth
19
+ const items = this.itemTargets
20
+ const ellipsis = this.hasEllipsisTarget ? this.ellipsisTarget : null
21
+ const ellipsisSeparator = this.hasEllipsisSeparatorTarget ? this.ellipsisSeparatorTarget : null
22
+
23
+ // Always show first and last items
24
+ if (items.length < 3 || !ellipsis) {
25
+ return; // Not enough items to collapse or no ellipsis element
26
+ }
27
+
28
+ // Reset visibility
29
+ if (ellipsis) ellipsis.classList.add('hidden')
30
+ if (ellipsisSeparator) ellipsisSeparator.classList.add('hidden')
31
+
32
+ items.forEach(item => {
33
+ item.classList.remove('hidden')
34
+ })
35
+
36
+ // Check if we need to collapse items
37
+ let totalWidth = 0
38
+ items.forEach(item => {
39
+ totalWidth += item.offsetWidth
40
+ })
41
+
42
+ if (totalWidth > containerWidth) {
43
+ // We need to collapse items - show ellipsis
44
+ if (ellipsis) ellipsis.classList.remove('hidden')
45
+ if (ellipsisSeparator) ellipsisSeparator.classList.remove('hidden')
46
+
47
+ // Start hiding middle items until we fit
48
+ for (let i = items.length - 2; i > 0; i--) {
49
+ if (i !== 0 && i !== items.length - 1) {
50
+ items[i].classList.add('hidden')
51
+
52
+ // Recalculate total width
53
+ totalWidth = 0
54
+
55
+ if (ellipsis) totalWidth += ellipsis.offsetWidth
56
+ if (ellipsisSeparator) totalWidth += ellipsisSeparator.offsetWidth
57
+
58
+ items.forEach(item => {
59
+ if (!item.classList.contains('hidden')) {
60
+ totalWidth += item.offsetWidth
61
+ }
62
+ })
63
+
64
+ if (totalWidth <= containerWidth) {
65
+ break
66
+ }
67
+ }
68
+ }
69
+ }
70
+ }
71
+ }