maquina-components 0.1.1 → 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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +410 -13
  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 +178 -0
  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/maquina_components/icons_helper.rb +161 -0
  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 +12 -0
  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 +6 -0
  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 +13 -0
  42. data/app/views/components/_separator.html.erb +11 -0
  43. data/app/views/components/_sidebar.html.erb +40 -0
  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 +121 -5
@@ -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
+ }
@@ -0,0 +1,203 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * DropdownMenu Controller
5
+ *
6
+ * Handles opening/closing dropdown menus with:
7
+ * - Click to toggle
8
+ * - Click outside to close
9
+ * - Escape key to close
10
+ * - Keyboard navigation within menu
11
+ * - Focus management
12
+ * - Animation states
13
+ */
14
+ export default class extends Controller {
15
+ static targets = ["trigger", "content", "chevron"]
16
+
17
+ static values = {
18
+ open: { type: Boolean, default: false }
19
+ }
20
+
21
+ connect() {
22
+ this.handleClickOutside = this.handleClickOutside.bind(this)
23
+ this.handleKeydown = this.handleKeydown.bind(this)
24
+
25
+ // Set initial state on root element
26
+ this.element.dataset.state = "closed"
27
+ }
28
+
29
+ disconnect() {
30
+ this.removeEventListeners()
31
+ }
32
+
33
+ toggle(event) {
34
+ event?.preventDefault()
35
+
36
+ if (this.openValue) {
37
+ this.close()
38
+ } else {
39
+ this.open()
40
+ }
41
+ }
42
+
43
+ open() {
44
+ if (this.openValue || !this.hasContentTarget) return
45
+
46
+ this.openValue = true
47
+ this.element.dataset.state = "open"
48
+ this.contentTarget.dataset.state = "open"
49
+ this.contentTarget.hidden = false
50
+
51
+ // Update trigger aria
52
+ if (this.hasTriggerTarget) {
53
+ this.triggerTarget.setAttribute("aria-expanded", "true")
54
+ }
55
+
56
+ // Add event listeners
57
+ this.addEventListeners()
58
+
59
+ // Focus first item after animation
60
+ requestAnimationFrame(() => {
61
+ this.focusFirstItem()
62
+ })
63
+ }
64
+
65
+ close() {
66
+ if (!this.openValue || !this.hasContentTarget) return
67
+
68
+ // Start closing animation
69
+ this.contentTarget.dataset.state = "closing"
70
+
71
+ // Wait for animation to complete
72
+ const animationDuration = 100 // matches CSS animation duration
73
+
74
+ setTimeout(() => {
75
+ this.openValue = false
76
+ this.element.dataset.state = "closed"
77
+ this.contentTarget.dataset.state = "closed"
78
+ this.contentTarget.hidden = true
79
+
80
+ // Update trigger aria
81
+ if (this.hasTriggerTarget) {
82
+ this.triggerTarget.setAttribute("aria-expanded", "false")
83
+ }
84
+
85
+ // Remove event listeners
86
+ this.removeEventListeners()
87
+
88
+ // Return focus to trigger
89
+ if (this.hasTriggerTarget) {
90
+ this.triggerTarget.focus()
91
+ }
92
+ }, animationDuration)
93
+ }
94
+
95
+ // Event Handlers
96
+
97
+ handleClickOutside(event) {
98
+ if (!this.openValue) return
99
+ if (this.element.contains(event.target)) return
100
+
101
+ this.close()
102
+ }
103
+
104
+ handleKeydown(event) {
105
+ if (!this.openValue) return
106
+
107
+ switch (event.key) {
108
+ case "Escape":
109
+ event.preventDefault()
110
+ this.close()
111
+ break
112
+
113
+ case "ArrowDown":
114
+ event.preventDefault()
115
+ this.focusNextItem()
116
+ break
117
+
118
+ case "ArrowUp":
119
+ event.preventDefault()
120
+ this.focusPreviousItem()
121
+ break
122
+
123
+ case "Home":
124
+ event.preventDefault()
125
+ this.focusFirstItem()
126
+ break
127
+
128
+ case "End":
129
+ event.preventDefault()
130
+ this.focusLastItem()
131
+ break
132
+
133
+ case "Tab":
134
+ // Close menu and let focus move naturally
135
+ this.close()
136
+ break
137
+ }
138
+ }
139
+
140
+ // Focus Management
141
+
142
+ get menuItems() {
143
+ if (!this.hasContentTarget) return []
144
+
145
+ return Array.from(
146
+ this.contentTarget.querySelectorAll('[data-dropdown-menu-part="item"]:not([disabled]):not([aria-disabled="true"])')
147
+ )
148
+ }
149
+
150
+ get focusedItemIndex() {
151
+ const items = this.menuItems
152
+ const focused = document.activeElement
153
+ return items.indexOf(focused)
154
+ }
155
+
156
+ focusFirstItem() {
157
+ const items = this.menuItems
158
+ if (items.length > 0) {
159
+ items[0].focus()
160
+ }
161
+ }
162
+
163
+ focusLastItem() {
164
+ const items = this.menuItems
165
+ if (items.length > 0) {
166
+ items[items.length - 1].focus()
167
+ }
168
+ }
169
+
170
+ focusNextItem() {
171
+ const items = this.menuItems
172
+ if (items.length === 0) return
173
+
174
+ const currentIndex = this.focusedItemIndex
175
+ const nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0
176
+ items[nextIndex].focus()
177
+ }
178
+
179
+ focusPreviousItem() {
180
+ const items = this.menuItems
181
+ if (items.length === 0) return
182
+
183
+ const currentIndex = this.focusedItemIndex
184
+ const prevIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1
185
+ items[prevIndex].focus()
186
+ }
187
+
188
+ // Event Listener Management
189
+
190
+ addEventListeners() {
191
+ // Delay adding click outside listener to prevent immediate close
192
+ setTimeout(() => {
193
+ document.addEventListener("click", this.handleClickOutside)
194
+ }, 0)
195
+
196
+ document.addEventListener("keydown", this.handleKeydown)
197
+ }
198
+
199
+ removeEventListeners() {
200
+ document.removeEventListener("click", this.handleClickOutside)
201
+ document.removeEventListener("keydown", this.handleKeydown)
202
+ }
203
+ }
@@ -0,0 +1,59 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["button", "content"]
5
+
6
+ connect() {
7
+ if (!this.hasContentTarget) {
8
+ return
9
+ }
10
+
11
+ this.clickOutside = this.clickOutside.bind(this)
12
+ this.isOpen = this.buttonTarget.dataset.state === "open"
13
+
14
+ if (this.isOpen) {
15
+ this.addClickOutsideListener()
16
+ }
17
+ }
18
+
19
+ disconnect() {
20
+ this.removeClickOutsideListener()
21
+ }
22
+
23
+ toggle() {
24
+ if (!this.hasContentTarget) {
25
+ return
26
+ }
27
+
28
+ this.contentTarget.classList.remove("hidden")
29
+
30
+ this.isOpen = !this.isOpen
31
+ this.buttonTarget.dataset.state = this.isOpen ? "open" : "closed"
32
+
33
+ if (this.isOpen) {
34
+ // Add a small delay before adding the click outside listener
35
+ setTimeout(() => {
36
+ this.addClickOutsideListener()
37
+ }, 100)
38
+ } else {
39
+ this.removeClickOutsideListener()
40
+ }
41
+ }
42
+
43
+ clickOutside(event) {
44
+ if (!this.isOpen) return
45
+ if (event.target === this.element) return
46
+
47
+ if (!this.contentTarget.contains(event.target)) {
48
+ this.toggle()
49
+ }
50
+ }
51
+
52
+ addClickOutsideListener() {
53
+ document.addEventListener('click', this.clickOutside)
54
+ }
55
+
56
+ removeClickOutsideListener() {
57
+ document.removeEventListener('click', this.clickOutside)
58
+ }
59
+ }