maquina-components 0.2.0 → 0.3.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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +77 -0
  3. data/app/assets/stylesheets/calendar.css +222 -0
  4. data/app/assets/stylesheets/combobox.css +218 -0
  5. data/app/assets/stylesheets/date_picker.css +172 -0
  6. data/app/assets/stylesheets/toast.css +433 -0
  7. data/app/assets/tailwind/maquina_components_engine/engine.css +16 -14
  8. data/app/helpers/maquina_components/calendar_helper.rb +196 -0
  9. data/app/helpers/maquina_components/combobox_helper.rb +300 -0
  10. data/app/helpers/maquina_components/icons_helper.rb +220 -0
  11. data/app/helpers/maquina_components/table_helper.rb +9 -10
  12. data/app/helpers/maquina_components/toast_helper.rb +115 -0
  13. data/app/javascript/controllers/calendar_controller.js +394 -0
  14. data/app/javascript/controllers/combobox_controller.js +325 -0
  15. data/app/javascript/controllers/date_picker_controller.js +261 -0
  16. data/app/javascript/controllers/toast_controller.js +115 -0
  17. data/app/javascript/controllers/toaster_controller.js +226 -0
  18. data/app/views/components/_calendar.html.erb +121 -0
  19. data/app/views/components/_combobox.html.erb +13 -0
  20. data/app/views/components/_date_picker.html.erb +102 -0
  21. data/app/views/components/_toast.html.erb +53 -0
  22. data/app/views/components/_toaster.html.erb +17 -0
  23. data/app/views/components/calendar/_header.html.erb +22 -0
  24. data/app/views/components/calendar/_week.html.erb +53 -0
  25. data/app/views/components/combobox/_content.html.erb +17 -0
  26. data/app/views/components/combobox/_empty.html.erb +9 -0
  27. data/app/views/components/combobox/_group.html.erb +8 -0
  28. data/app/views/components/combobox/_input.html.erb +18 -0
  29. data/app/views/components/combobox/_label.html.erb +8 -0
  30. data/app/views/components/combobox/_list.html.erb +8 -0
  31. data/app/views/components/combobox/_option.html.erb +24 -0
  32. data/app/views/components/combobox/_separator.html.erb +6 -0
  33. data/app/views/components/combobox/_trigger.html.erb +22 -0
  34. data/app/views/components/toast/_action.html.erb +14 -0
  35. data/app/views/components/toast/_description.html.erb +8 -0
  36. data/app/views/components/toast/_title.html.erb +8 -0
  37. data/lib/maquina_components/version.rb +1 -1
  38. metadata +33 -2
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaquinaComponents
4
+ # Calendar Helper
5
+ #
6
+ # Provides utility methods for working with calendar and date picker data.
7
+ #
8
+ # @example Generate month data
9
+ # calendar_month_data(Date.current, :sunday)
10
+ #
11
+ # @example Check if date is in range
12
+ # calendar_date_in_range?(date, start_date, end_date)
13
+ #
14
+ module CalendarHelper
15
+ # Generate calendar month data
16
+ #
17
+ # @param date [Date] Any date within the target month
18
+ # @param week_starts_on [Symbol] :sunday or :monday
19
+ # @return [Hash] Month metadata and weeks array
20
+ def calendar_month_data(date, week_starts_on = :sunday)
21
+ first_of_month = date.beginning_of_month
22
+ last_of_month = date.end_of_month
23
+
24
+ # Calculate start of calendar grid
25
+ week_start = (week_starts_on == :monday) ? 1 : 0
26
+ days_before = (first_of_month.wday - week_start) % 7
27
+ calendar_start = first_of_month - days_before.days
28
+
29
+ # Calculate end of calendar grid (6 weeks max)
30
+ total_days = days_before + last_of_month.day
31
+ weeks_needed = (total_days / 7.0).ceil
32
+ weeks_needed = [weeks_needed, 6].min
33
+ calendar_end = calendar_start + (weeks_needed * 7 - 1).days
34
+
35
+ # Build weeks array
36
+ weeks = (calendar_start..calendar_end).each_slice(7).to_a
37
+
38
+ {
39
+ month: date.month,
40
+ year: date.year,
41
+ first_of_month: first_of_month,
42
+ last_of_month: last_of_month,
43
+ weeks: weeks,
44
+ week_starts_on: week_starts_on
45
+ }
46
+ end
47
+
48
+ # Format month name with year
49
+ #
50
+ # @param date [Date] Any date within the target month
51
+ # @param format [Symbol] :long (%B %Y) or :short (%b %Y)
52
+ # @return [String] Formatted month name
53
+ def calendar_month_name(date, format = :long)
54
+ case format
55
+ when :short
56
+ I18n.l(date, format: "%b %Y")
57
+ else
58
+ I18n.l(date, format: "%B %Y")
59
+ end
60
+ end
61
+
62
+ # Check if a date falls within a range
63
+ #
64
+ # @param date [Date] The date to check
65
+ # @param start_date [Date, nil] Range start (inclusive)
66
+ # @param end_date [Date, nil] Range end (inclusive)
67
+ # @return [Boolean]
68
+ def calendar_date_in_range?(date, start_date, end_date)
69
+ return false unless start_date && end_date
70
+ date.between?(start_date, end_date)
71
+ end
72
+
73
+ # Generate data attributes hash for calendar
74
+ #
75
+ # @param mode [Symbol] :single or :range
76
+ # @param selected [Date, String, nil] Selected date
77
+ # @param selected_end [Date, String, nil] End date for range
78
+ # @param month [Integer, nil] Display month
79
+ # @param year [Integer, nil] Display year
80
+ # @return [Hash] Data attributes for use with content_tag
81
+ def calendar_data_attrs(mode: :single, selected: nil, selected_end: nil, month: nil, year: nil)
82
+ selected_str = case selected
83
+ when Date, Time, DateTime then selected.to_date.iso8601
84
+ when String then selected
85
+ end
86
+
87
+ selected_end_str = case selected_end
88
+ when Date, Time, DateTime then selected_end.to_date.iso8601
89
+ when String then selected_end
90
+ end
91
+
92
+ display_date = selected_str ? Date.parse(selected_str) : Date.current
93
+ display_month = month || display_date.month
94
+ display_year = year || display_date.year
95
+
96
+ {
97
+ data: {
98
+ controller: "calendar",
99
+ component: "calendar",
100
+ "calendar-mode-value": mode,
101
+ "calendar-month-value": display_month,
102
+ "calendar-year-value": display_year,
103
+ "calendar-selected-value": selected_str,
104
+ "calendar-selected-end-value": selected_end_str
105
+ }.compact
106
+ }
107
+ end
108
+
109
+ # Get weekday names based on week start
110
+ #
111
+ # @param week_starts_on [Symbol] :sunday or :monday
112
+ # @param format [Symbol] :short (Mo, Tu) or :narrow (M, T) or :long (Monday)
113
+ # @return [Array<String>]
114
+ def calendar_weekday_names(week_starts_on = :sunday, format = :short)
115
+ names = case format
116
+ when :narrow
117
+ %w[S M T W T F S]
118
+ when :long
119
+ I18n.t("date.day_names")
120
+ else
121
+ %w[Su Mo Tu We Th Fr Sa]
122
+ end
123
+
124
+ (week_starts_on == :monday) ? names.rotate(1) : names
125
+ end
126
+
127
+ # Generate data attributes hash for date picker
128
+ #
129
+ # @param mode [Symbol] :single or :range
130
+ # @param selected [Date, String, nil] Selected date
131
+ # @param selected_end [Date, String, nil] End date for range
132
+ # @return [Hash] Data attributes for use with content_tag
133
+ def date_picker_data_attrs(mode: :single, selected: nil, selected_end: nil)
134
+ selected_str = case selected
135
+ when Date, Time, DateTime then selected.to_date.iso8601
136
+ when String then selected
137
+ end
138
+
139
+ selected_end_str = case selected_end
140
+ when Date, Time, DateTime then selected_end.to_date.iso8601
141
+ when String then selected_end
142
+ end
143
+
144
+ {
145
+ data: {
146
+ controller: "date-picker",
147
+ component: "date-picker",
148
+ "date-picker-mode-value": mode,
149
+ "date-picker-selected-value": selected_str,
150
+ "date-picker-selected-end-value": selected_end_str
151
+ }.compact
152
+ }
153
+ end
154
+
155
+ # Format date for display in date picker
156
+ #
157
+ # @param date [Date, String, nil] Date to format
158
+ # @param format [Symbol] :short, :long, or :full
159
+ # @return [String, nil]
160
+ def date_picker_format(date, format = :long)
161
+ return nil unless date
162
+
163
+ date = Date.parse(date) if date.is_a?(String)
164
+
165
+ case format
166
+ when :short
167
+ I18n.l(date, format: :short)
168
+ when :full
169
+ I18n.l(date, format: :long)
170
+ else
171
+ I18n.l(date, format: :long)
172
+ end
173
+ rescue ArgumentError
174
+ nil
175
+ end
176
+
177
+ # Format date range for display
178
+ #
179
+ # @param start_date [Date, String, nil] Start date
180
+ # @param end_date [Date, String, nil] End date
181
+ # @param format [Symbol] :short or :long
182
+ # @return [String, nil]
183
+ def date_picker_format_range(start_date, end_date, format = :short)
184
+ start_str = date_picker_format(start_date, format)
185
+ end_str = date_picker_format(end_date, format)
186
+
187
+ return nil unless start_str
188
+
189
+ if end_str
190
+ "#{start_str} - #{end_str}"
191
+ else
192
+ "#{start_str} - ..."
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaquinaComponents
4
+ # Combobox Helper
5
+ #
6
+ # Provides a builder pattern for creating combobox components with a clean API.
7
+ #
8
+ # @example Basic usage
9
+ # <%= combobox placeholder: "Select framework..." do |cb| %>
10
+ # <% cb.trigger %>
11
+ # <% cb.content do %>
12
+ # <% cb.input placeholder: "Search..." %>
13
+ # <% cb.list do %>
14
+ # <% cb.option value: "nextjs" do %>Next.js<% end %>
15
+ # <% cb.option value: "remix" do %>Remix<% end %>
16
+ # <% end %>
17
+ # <% cb.empty %>
18
+ # <% end %>
19
+ # <% end %>
20
+ #
21
+ # @example Simple data-driven combobox
22
+ # <%= combobox_simple placeholder: "Select framework...",
23
+ # options: [
24
+ # { value: "nextjs", label: "Next.js" },
25
+ # { value: "remix", label: "Remix" }
26
+ # ] %>
27
+ #
28
+ # @example With groups
29
+ # <%= combobox placeholder: "Select..." do |cb| %>
30
+ # <% cb.trigger %>
31
+ # <% cb.content do %>
32
+ # <% cb.input %>
33
+ # <% cb.list do %>
34
+ # <% cb.group do %>
35
+ # <% cb.label "Frontend" %>
36
+ # <% cb.option value: "react" do %>React<% end %>
37
+ # <% cb.option value: "vue" do %>Vue<% end %>
38
+ # <% end %>
39
+ # <% cb.separator %>
40
+ # <% cb.group do %>
41
+ # <% cb.label "Backend" %>
42
+ # <% cb.option value: "rails" do %>Rails<% end %>
43
+ # <% end %>
44
+ # <% end %>
45
+ # <% cb.empty %>
46
+ # <% end %>
47
+ # <% end %>
48
+ #
49
+ module ComboboxHelper
50
+ # Renders a combobox using the builder pattern
51
+ #
52
+ # @param id [String, nil] Explicit ID for the combobox
53
+ # @param name [String, nil] Form field name
54
+ # @param value [String, nil] Currently selected value
55
+ # @param placeholder [String] Placeholder text
56
+ # @param css_classes [String] Additional CSS classes
57
+ # @param html_options [Hash] Additional HTML attributes
58
+ # @yield [ComboboxBuilder] Builder instance for constructing the combobox
59
+ # @return [String] Rendered HTML
60
+ def combobox(id: nil, name: nil, value: nil, placeholder: "Select...", css_classes: "", **html_options, &block)
61
+ builder = ComboboxBuilder.new(self, placeholder: placeholder)
62
+ combobox_id = id || "combobox-#{SecureRandom.hex(4)}"
63
+ builder.combobox_id = combobox_id
64
+
65
+ capture(builder, &block)
66
+
67
+ render "components/combobox",
68
+ id: combobox_id,
69
+ name: name,
70
+ value: value,
71
+ placeholder: placeholder,
72
+ css_classes: css_classes,
73
+ **html_options do |_id|
74
+ builder.to_html
75
+ end
76
+ end
77
+
78
+ # Renders a simple combobox from data
79
+ #
80
+ # @param options [Array<Hash>] Array of option configurations with :value and :label keys
81
+ # @param placeholder [String] Placeholder text
82
+ # @param search_placeholder [String] Search input placeholder
83
+ # @param empty_text [String] Text shown when no results
84
+ # @param value [String, nil] Currently selected value
85
+ # @param name [String, nil] Form field name
86
+ # @param trigger_options [Hash] Options for the trigger button
87
+ # @param content_options [Hash] Options for the content container
88
+ # @return [String] Rendered HTML
89
+ def combobox_simple(
90
+ options:,
91
+ placeholder: "Select...",
92
+ search_placeholder: "Search...",
93
+ empty_text: "No results found.",
94
+ value: nil,
95
+ name: nil,
96
+ trigger_options: {},
97
+ content_options: {}
98
+ )
99
+ combobox(placeholder: placeholder, value: value, name: name) do |cb|
100
+ cb.trigger(**trigger_options)
101
+
102
+ cb.content(**content_options) do
103
+ cb.input(placeholder: search_placeholder)
104
+ cb.list do
105
+ options.each do |opt|
106
+ selected = value.present? && opt[:value].to_s == value.to_s
107
+ cb.option(value: opt[:value], selected: selected, disabled: opt[:disabled]) do
108
+ opt[:label] || opt[:value]
109
+ end
110
+ end
111
+ end
112
+ cb.empty(text: empty_text)
113
+ end
114
+ end
115
+ end
116
+
117
+ # Builder class for constructing comboboxes
118
+ class ComboboxBuilder
119
+ attr_accessor :combobox_id
120
+
121
+ def initialize(view_context, placeholder:)
122
+ @view = view_context
123
+ @placeholder = placeholder
124
+ @parts = []
125
+ end
126
+
127
+ # Defines the trigger button
128
+ #
129
+ # @param variant [Symbol] Button variant
130
+ # @param size [Symbol] Button size
131
+ # @param options [Hash] Additional options
132
+ def trigger(variant: :outline, size: :default, **options)
133
+ @parts << @view.render(
134
+ "components/combobox/trigger",
135
+ for_id: combobox_id,
136
+ placeholder: @placeholder,
137
+ variant: variant,
138
+ size: size,
139
+ **options
140
+ )
141
+ end
142
+
143
+ # Defines the content popover
144
+ #
145
+ # @param align [Symbol] Horizontal alignment
146
+ # @param width [Symbol] Width preset
147
+ # @param options [Hash] Additional options
148
+ # @yield Block containing combobox content
149
+ def content(align: :start, width: :default, **options, &block)
150
+ content_builder = ComboboxContentBuilder.new(@view)
151
+ @view.capture(content_builder, &block)
152
+
153
+ @parts << @view.render(
154
+ "components/combobox/content",
155
+ id: combobox_id,
156
+ align: align,
157
+ width: width,
158
+ **options
159
+ ) { content_builder.to_html }
160
+ end
161
+
162
+ # Shortcut to add input directly (delegates to content builder)
163
+ def input(placeholder: "Search...", **options)
164
+ @current_content_builder&.input(placeholder: placeholder, **options)
165
+ end
166
+
167
+ # Shortcut to add list directly (delegates to content builder)
168
+ def list(**options, &block)
169
+ @current_content_builder&.list(**options, &block)
170
+ end
171
+
172
+ # Shortcut to add empty directly (delegates to content builder)
173
+ def empty(text: "No results found.", **options)
174
+ @current_content_builder&.empty(text: text, **options)
175
+ end
176
+
177
+ # Generates the final HTML
178
+ def to_html
179
+ @view.safe_join(@parts)
180
+ end
181
+ end
182
+
183
+ # Builder for combobox content
184
+ class ComboboxContentBuilder
185
+ def initialize(view_context)
186
+ @view = view_context
187
+ @parts = []
188
+ end
189
+
190
+ # Adds the search input
191
+ #
192
+ # @param placeholder [String] Input placeholder
193
+ # @param options [Hash] Additional options
194
+ def input(placeholder: "Search...", **options)
195
+ @parts << @view.render(
196
+ "components/combobox/input",
197
+ placeholder: placeholder,
198
+ **options
199
+ )
200
+ end
201
+
202
+ # Adds the options list container
203
+ #
204
+ # @param options [Hash] Additional options
205
+ # @yield Block containing options
206
+ def list(**options, &block)
207
+ list_builder = ComboboxListBuilder.new(@view)
208
+ @view.capture(list_builder, &block)
209
+
210
+ @parts << @view.render(
211
+ "components/combobox/list",
212
+ **options
213
+ ) { list_builder.to_html }
214
+ end
215
+
216
+ # Adds the empty state
217
+ #
218
+ # @param text [String] Empty state text
219
+ # @param options [Hash] Additional options
220
+ def empty(text: "No results found.", **options)
221
+ @parts << @view.render(
222
+ "components/combobox/empty",
223
+ text: text,
224
+ **options
225
+ )
226
+ end
227
+
228
+ # Generates the final HTML
229
+ def to_html
230
+ @view.safe_join(@parts)
231
+ end
232
+ end
233
+
234
+ # Builder for combobox list content
235
+ class ComboboxListBuilder
236
+ def initialize(view_context)
237
+ @view = view_context
238
+ @parts = []
239
+ end
240
+
241
+ # Adds an option
242
+ #
243
+ # @param value [String] Option value
244
+ # @param selected [Boolean] Whether selected
245
+ # @param disabled [Boolean] Whether disabled
246
+ # @param options [Hash] Additional options
247
+ # @yield Block for option content
248
+ def option(value:, selected: false, disabled: false, **options, &block)
249
+ content = @view.capture(&block) if block
250
+ @parts << @view.render(
251
+ "components/combobox/option",
252
+ value: value,
253
+ selected: selected,
254
+ disabled: disabled,
255
+ **options
256
+ ) { content }
257
+ end
258
+
259
+ # Adds a group
260
+ #
261
+ # @param options [Hash] Additional options
262
+ # @yield Block containing group items
263
+ def group(**options, &block)
264
+ group_builder = ComboboxListBuilder.new(@view)
265
+ @view.capture(group_builder, &block)
266
+
267
+ @parts << @view.render(
268
+ "components/combobox/group",
269
+ **options
270
+ ) { group_builder.to_html }
271
+ end
272
+
273
+ # Adds a label
274
+ #
275
+ # @param text [String, nil] Label text
276
+ # @param options [Hash] Additional options
277
+ # @yield Optional block for custom content
278
+ def label(text = nil, **options, &block)
279
+ @parts << @view.render(
280
+ "components/combobox/label",
281
+ text: text,
282
+ **options,
283
+ &block
284
+ )
285
+ end
286
+
287
+ # Adds a separator
288
+ #
289
+ # @param options [Hash] Additional options
290
+ def separator(**options)
291
+ @parts << @view.render("components/combobox/separator", **options)
292
+ end
293
+
294
+ # Generates the final HTML
295
+ def to_html
296
+ @view.safe_join(@parts)
297
+ end
298
+ end
299
+ end
300
+ end