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,151 @@
1
+ /* ===== Toggle Group Component Styles ===== */
2
+ /*
3
+ * A group of two-state buttons that can be toggled on or off.
4
+ * Uses data attributes for styling to avoid inline utility classes.
5
+ * Fully compatible with dark mode via CSS variables.
6
+ *
7
+ * Structure:
8
+ * - toggle-group (root container)
9
+ * - item (toggle button)
10
+ */
11
+
12
+ /* ===== Root Container ===== */
13
+ [data-component="toggle-group"] {
14
+ display: inline-flex;
15
+ align-items: center;
16
+ @apply gap-1 rounded-md;
17
+ }
18
+
19
+ /* Group disabled state */
20
+ [data-component="toggle-group"][aria-disabled="true"] {
21
+ @apply opacity-50 pointer-events-none;
22
+ }
23
+
24
+ /* ===== Toggle Item Base ===== */
25
+ [data-toggle-group-part="item"] {
26
+ display: inline-flex;
27
+ align-items: center;
28
+ justify-content: center;
29
+ @apply gap-2 rounded-md text-sm font-medium;
30
+ @apply transition-colors duration-150;
31
+
32
+ /* Default size */
33
+ @apply h-9 px-3;
34
+
35
+ /* Remove default button styles */
36
+ border: none;
37
+ cursor: pointer;
38
+
39
+ /* Default variant colors */
40
+ background-color: transparent;
41
+ color: var(--muted-foreground);
42
+ }
43
+
44
+ /* ===== Size Variants ===== */
45
+
46
+ /* Small */
47
+ [data-component="toggle-group"][data-size="sm"] [data-toggle-group-part="item"] {
48
+ @apply h-8 px-2.5 text-xs;
49
+ }
50
+
51
+ /* Large */
52
+ [data-component="toggle-group"][data-size="lg"] [data-toggle-group-part="item"] {
53
+ @apply h-10 px-4;
54
+ }
55
+
56
+ /* ===== Visual Variants ===== */
57
+
58
+ /* Default variant */
59
+ [data-component="toggle-group"][data-variant="default"] [data-toggle-group-part="item"] {
60
+ background-color: transparent;
61
+ }
62
+
63
+ [data-component="toggle-group"][data-variant="default"] [data-toggle-group-part="item"]:hover:not(:disabled) {
64
+ background-color: var(--muted);
65
+ color: var(--muted-foreground);
66
+ }
67
+
68
+ [data-component="toggle-group"][data-variant="default"] [data-toggle-group-part="item"][data-state="on"] {
69
+ background-color: var(--accent);
70
+ color: var(--accent-foreground);
71
+ }
72
+
73
+ /* Outline variant */
74
+ [data-component="toggle-group"][data-variant="outline"] {
75
+ background-color: transparent;
76
+ border: 1px solid var(--border);
77
+ @apply gap-0;
78
+ }
79
+
80
+ [data-component="toggle-group"][data-variant="outline"] [data-toggle-group-part="item"] {
81
+ @apply rounded-none;
82
+ border-right: 1px solid var(--border);
83
+ }
84
+
85
+ [data-component="toggle-group"][data-variant="outline"] [data-toggle-group-part="item"]:first-child {
86
+ @apply rounded-l-md;
87
+ }
88
+
89
+ [data-component="toggle-group"][data-variant="outline"] [data-toggle-group-part="item"]:last-child {
90
+ @apply rounded-r-md;
91
+ border-right: none;
92
+ }
93
+
94
+ [data-component="toggle-group"][data-variant="outline"] [data-toggle-group-part="item"]:hover:not(:disabled) {
95
+ background-color: var(--muted);
96
+ color: var(--muted-foreground);
97
+ }
98
+
99
+ [data-component="toggle-group"][data-variant="outline"] [data-toggle-group-part="item"][data-state="on"] {
100
+ background-color: var(--accent);
101
+ color: var(--accent-foreground);
102
+ }
103
+
104
+ /* ===== Interactive States ===== */
105
+
106
+ /* Hover */
107
+ [data-toggle-group-part="item"]:hover:not(:disabled) {
108
+ background-color: var(--muted);
109
+ color: var(--muted-foreground);
110
+ }
111
+
112
+ /* Pressed (on) state */
113
+ [data-toggle-group-part="item"][data-state="on"] {
114
+ background-color: var(--accent);
115
+ color: var(--accent-foreground);
116
+ }
117
+
118
+ /* Focus visible */
119
+ [data-toggle-group-part="item"]:focus-visible {
120
+ @apply outline-none;
121
+ box-shadow: 0 0 0 2px var(--background),
122
+ 0 0 0 4px var(--ring);
123
+ z-index: 1;
124
+ position: relative;
125
+ }
126
+
127
+ /* Disabled */
128
+ [data-toggle-group-part="item"]:disabled {
129
+ @apply opacity-50 cursor-not-allowed pointer-events-none;
130
+ }
131
+
132
+ /* ===== Icon Support ===== */
133
+ [data-toggle-group-part="item"] svg {
134
+ @apply size-4 shrink-0 pointer-events-none;
135
+ }
136
+
137
+ /* Icon-only size adjustments */
138
+ [data-component="toggle-group"][data-size="sm"] [data-toggle-group-part="item"] svg {
139
+ @apply size-3.5;
140
+ }
141
+
142
+ [data-component="toggle-group"][data-size="lg"] [data-toggle-group-part="item"] svg {
143
+ @apply size-5;
144
+ }
145
+
146
+ /* ===== Dark Mode ===== */
147
+ /*
148
+ * Dark mode is handled automatically through CSS variables.
149
+ * The theme variables change based on the .dark class on html/body.
150
+ * No additional dark mode styles needed here.
151
+ */
@@ -0,0 +1,16 @@
1
+ @source "../../../views/";
2
+
3
+ @layer components {
4
+ @import "../../stylesheets/alert.css";
5
+ @import "../../stylesheets/badge.css";
6
+ @import "../../stylesheets/breadcrumbs.css";
7
+ @import "../../stylesheets/card.css";
8
+ @import "../../stylesheets/dropdown_menu.css";
9
+ @import "../../stylesheets/empty.css";
10
+ @import "../../stylesheets/form.css";
11
+ @import "../../stylesheets/header.css";
12
+ @import "../../stylesheets/pagination.css";
13
+ @import "../../stylesheets/sidebar.css";
14
+ @import "../../stylesheets/table.css";
15
+ @import "../../stylesheets/toggle_group.css";
16
+ }
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaquinaComponents
4
+ # Breadcrumbs Helper
5
+ #
6
+ # Provides helper methods for rendering breadcrumb navigation.
7
+ # Supports both simple hash-based API and composable partials.
8
+ #
9
+ module BreadcrumbsHelper
10
+ # Render breadcrumbs from a hash of links
11
+ #
12
+ # @param links [Hash] Hash of text => path pairs
13
+ # @param current_page [String, nil] Text for current page (no link)
14
+ # @param css_classes [String] Additional CSS classes for nav element
15
+ # @return [String] HTML string
16
+ #
17
+ # @example Basic usage
18
+ # breadcrumbs({"Home" => root_path, "Users" => users_path}, "John Doe")
19
+ #
20
+ # @example Without current page
21
+ # breadcrumbs({"Home" => root_path, "Users" => users_path})
22
+ #
23
+ def breadcrumbs(links = {}, current_page = nil, css_classes: "")
24
+ render "components/breadcrumbs", css_classes: css_classes do
25
+ render "components/breadcrumbs/list" do
26
+ build_breadcrumb_items(links, current_page, responsive: false)
27
+ end
28
+ end
29
+ end
30
+
31
+ # Render responsive breadcrumbs that auto-collapse on overflow
32
+ #
33
+ # Uses Stimulus controller to hide middle items when space is limited,
34
+ # showing an ellipsis element instead.
35
+ #
36
+ # @param links [Hash] Hash of text => path pairs
37
+ # @param current_page [String, nil] Text for current page (no link)
38
+ # @param css_classes [String] Additional CSS classes for nav element
39
+ # @return [String] HTML string
40
+ #
41
+ # @example
42
+ # responsive_breadcrumbs(
43
+ # {"Home" => root_path, "Docs" => docs_path, "Components" => components_path},
44
+ # "Button"
45
+ # )
46
+ #
47
+ def responsive_breadcrumbs(links = {}, current_page = nil, css_classes: "")
48
+ render "components/breadcrumbs", css_classes: css_classes, responsive: true do
49
+ render "components/breadcrumbs/list" do
50
+ build_breadcrumb_items(links, current_page, responsive: true)
51
+ end
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ # Build breadcrumb items from links hash
58
+ #
59
+ # @param links [Hash] Hash of text => path pairs
60
+ # @param current_page [String, nil] Text for current page
61
+ # @param responsive [Boolean] Whether to include Stimulus targets
62
+ # @return [String] Safe-joined HTML string
63
+ #
64
+ def build_breadcrumb_items(links, current_page, responsive: false)
65
+ items = []
66
+ link_array = links.to_a
67
+
68
+ link_array.each_with_index do |(text, path), index|
69
+ # Determine if this is a collapsible middle item (not first or last)
70
+ is_middle = responsive && index > 0 && (index < link_array.size - 1 || current_page.present?)
71
+ item_data = is_middle ? {breadcrumb_target: "item"} : {}
72
+
73
+ items << capture do
74
+ render "components/breadcrumbs/item", data: item_data do
75
+ render "components/breadcrumbs/link", href: path do
76
+ text
77
+ end
78
+ end
79
+ end
80
+
81
+ # Add separator after each link
82
+ items << capture do
83
+ render "components/breadcrumbs/separator"
84
+ end
85
+
86
+ # Insert ellipsis after first item for responsive mode
87
+ if responsive && index == 0 && (link_array.size > 2 || (link_array.size > 1 && current_page.present?))
88
+ items << capture do
89
+ render "components/breadcrumbs/item", css_classes: "hidden", data: {breadcrumb_target: "ellipsis"} do
90
+ render "components/breadcrumbs/ellipsis"
91
+ end
92
+ end
93
+
94
+ items << capture do
95
+ render "components/breadcrumbs/separator", css_classes: "hidden", data: {breadcrumb_target: "ellipsisSeparator"}
96
+ end
97
+ end
98
+ end
99
+
100
+ # Add current page if provided
101
+ if current_page.present?
102
+ # Remove last separator since current page follows
103
+ items << capture do
104
+ render "components/breadcrumbs/item" do
105
+ render "components/breadcrumbs/page" do
106
+ current_page
107
+ end
108
+ end
109
+ end
110
+ else
111
+ # Remove trailing separator if no current page
112
+ items.pop
113
+ end
114
+
115
+ safe_join(items)
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaquinaComponents
4
+ # DropdownMenu Helper
5
+ #
6
+ # Provides a builder pattern for creating dropdown menus with a clean API.
7
+ #
8
+ # @example Basic usage
9
+ # <%= dropdown_menu do |menu| %>
10
+ # <% menu.trigger { "Options" } %>
11
+ # <% menu.content do %>
12
+ # <% menu.item "Profile", href: profile_path %>
13
+ # <% menu.item "Settings", href: settings_path %>
14
+ # <% end %>
15
+ # <% end %>
16
+ #
17
+ # @example With icons and shortcuts
18
+ # <%= dropdown_menu do |menu| %>
19
+ # <% menu.trigger variant: :ghost, size: :icon do %>
20
+ # <%= icon_for :more_horizontal %>
21
+ # <% end %>
22
+ # <% menu.content align: :end, width: :md do %>
23
+ # <% menu.label "Actions" %>
24
+ # <% menu.item "Edit", href: edit_path, icon: :pencil do |item| %>
25
+ # <% item.shortcut "⌘E" %>
26
+ # <% end %>
27
+ # <% menu.separator %>
28
+ # <% menu.item "Delete", href: delete_path, method: :delete, variant: :destructive, icon: :trash %>
29
+ # <% end %>
30
+ # <% end %>
31
+ #
32
+ # @example Simple data-driven menu
33
+ # <%= dropdown_menu_simple "Actions", items: [
34
+ # { label: "Edit", href: edit_path, icon: :pencil },
35
+ # { label: "Delete", href: delete_path, method: :delete, destructive: true }
36
+ # ] %>
37
+ #
38
+ module DropdownMenuHelper
39
+ # Renders a dropdown menu using the builder pattern
40
+ #
41
+ # @param css_classes [String] Additional CSS classes for the root element
42
+ # @param html_options [Hash] Additional HTML attributes
43
+ # @yield [DropdownMenuBuilder] Builder instance for constructing the menu
44
+ # @return [String] Rendered HTML
45
+ def dropdown_menu(css_classes: "", **html_options, &block)
46
+ builder = DropdownMenuBuilder.new(self)
47
+ capture(builder, &block)
48
+
49
+ render "components/dropdown_menu", css_classes: css_classes, **html_options do
50
+ builder.to_html
51
+ end
52
+ end
53
+
54
+ # Renders a simple dropdown menu from data
55
+ #
56
+ # @param trigger_text [String] Text for the trigger button
57
+ # @param items [Array<Hash>] Array of item configurations
58
+ # @param trigger_options [Hash] Options for the trigger button
59
+ # @param content_options [Hash] Options for the content container
60
+ # @return [String] Rendered HTML
61
+ def dropdown_menu_simple(trigger_text, items:, trigger_options: {}, content_options: {})
62
+ dropdown_menu do |menu|
63
+ menu.trigger(**trigger_options) { trigger_text }
64
+
65
+ menu.content(**content_options) do
66
+ items.each do |item|
67
+ if item[:separator]
68
+ menu.separator
69
+ elsif item[:label] && item[:href].nil? && item[:action].nil?
70
+ menu.label item[:label]
71
+ else
72
+ variant = item[:destructive] ? :destructive : :default
73
+ menu.item(
74
+ item[:label],
75
+ href: item[:href],
76
+ method: item[:method],
77
+ icon: item[:icon],
78
+ variant: variant,
79
+ disabled: item[:disabled]
80
+ )
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+
87
+ # Builder class for constructing dropdown menus
88
+ class DropdownMenuBuilder
89
+ def initialize(view_context)
90
+ @view = view_context
91
+ @trigger_content = nil
92
+ @content_block = nil
93
+ end
94
+
95
+ # Defines the trigger button
96
+ #
97
+ # @param variant [Symbol] Button variant
98
+ # @param size [Symbol] Button size
99
+ # @param as_child [Boolean] Whether to use custom trigger markup
100
+ # @param options [Hash] Additional options
101
+ # @yield Block for trigger content
102
+ def trigger(variant: :outline, size: :default, as_child: false, **options, &block)
103
+ @trigger_options = {variant: variant, size: size, as_child: as_child, **options}
104
+ @trigger_content = @view.capture(&block)
105
+ end
106
+
107
+ # Defines the menu content
108
+ #
109
+ # @param align [Symbol] Horizontal alignment
110
+ # @param side [Symbol] Which side to open
111
+ # @param width [Symbol] Width preset
112
+ # @param options [Hash] Additional options
113
+ # @yield Block containing menu items
114
+ def content(align: :start, side: :bottom, width: :default, **options, &block)
115
+ @content_options = {align: align, side: side, width: width, **options}
116
+ @content_builder = DropdownMenuContentBuilder.new(@view)
117
+ @view.capture(@content_builder, &block)
118
+ end
119
+
120
+ # Generates the final HTML
121
+ def to_html
122
+ parts = []
123
+
124
+ if @trigger_content
125
+ parts << @view.render(
126
+ "components/dropdown_menu/trigger",
127
+ **@trigger_options
128
+ ) { @trigger_content }
129
+ end
130
+
131
+ if @content_builder
132
+ parts << @view.render(
133
+ "components/dropdown_menu/content",
134
+ **@content_options
135
+ ) { @content_builder.to_html }
136
+ end
137
+
138
+ @view.safe_join(parts)
139
+ end
140
+ end
141
+
142
+ # Builder for dropdown menu content
143
+ class DropdownMenuContentBuilder
144
+ def initialize(view_context)
145
+ @view = view_context
146
+ @parts = []
147
+ end
148
+
149
+ # Adds a menu item
150
+ #
151
+ # @param label [String, nil] Item label (alternative to block)
152
+ # @param href [String, nil] URL for the item
153
+ # @param method [Symbol, nil] HTTP method
154
+ # @param icon [Symbol, nil] Icon name
155
+ # @param variant [Symbol] Visual variant
156
+ # @param disabled [Boolean] Whether disabled
157
+ # @param options [Hash] Additional options
158
+ # @yield Optional block for custom content or item builder
159
+ def item(label = nil, href: nil, method: nil, icon: nil, variant: :default, disabled: false, **options, &block)
160
+ item_builder = DropdownMenuItemBuilder.new(@view)
161
+
162
+ content = if block
163
+ @view.capture(item_builder, &block)
164
+ elsif label
165
+ build_item_content(label, icon)
166
+ end
167
+
168
+ # Append shortcut if defined via builder
169
+ content = @view.safe_join([content, item_builder.shortcut_html].compact) if item_builder.shortcut_html
170
+
171
+ @parts << @view.render(
172
+ "components/dropdown_menu/item",
173
+ href: href,
174
+ method: method,
175
+ variant: variant,
176
+ disabled: disabled,
177
+ **options
178
+ ) { content }
179
+ end
180
+
181
+ # Adds a label/heading
182
+ #
183
+ # @param text [String, nil] Label text
184
+ # @param inset [Boolean] Whether to indent
185
+ # @param options [Hash] Additional options
186
+ # @yield Optional block for custom content
187
+ def label(text = nil, inset: false, **options, &block)
188
+ @parts << @view.render(
189
+ "components/dropdown_menu/label",
190
+ text: text,
191
+ inset: inset,
192
+ **options,
193
+ &block
194
+ )
195
+ end
196
+
197
+ # Adds a separator
198
+ #
199
+ # @param options [Hash] Additional options
200
+ def separator(**options)
201
+ @parts << @view.render("components/dropdown_menu/separator", **options)
202
+ end
203
+
204
+ # Adds a group
205
+ #
206
+ # @param options [Hash] Additional options
207
+ # @yield Block containing group items
208
+ def group(**options, &block)
209
+ group_builder = DropdownMenuContentBuilder.new(@view)
210
+ @view.capture(group_builder, &block)
211
+
212
+ @parts << @view.render("components/dropdown_menu/group", **options) do
213
+ group_builder.to_html
214
+ end
215
+ end
216
+
217
+ # Generates the final HTML
218
+ def to_html
219
+ @view.safe_join(@parts)
220
+ end
221
+
222
+ private
223
+
224
+ def build_item_content(label, icon)
225
+ parts = []
226
+ parts << @view.icon_for(icon) if icon && @view.respond_to?(:icon_for)
227
+ parts << label
228
+ @view.safe_join(parts)
229
+ end
230
+ end
231
+
232
+ # Builder for individual menu items (supports shortcuts)
233
+ class DropdownMenuItemBuilder
234
+ attr_reader :shortcut_html
235
+
236
+ def initialize(view_context)
237
+ @view = view_context
238
+ @shortcut_html = nil
239
+ end
240
+
241
+ # Adds a keyboard shortcut
242
+ #
243
+ # @param text [String] Shortcut text
244
+ def shortcut(text)
245
+ @shortcut_html = @view.render("components/dropdown_menu/shortcut", text: text)
246
+ end
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaquinaComponents
4
+ # Empty Helper
5
+ #
6
+ # Provides convenient methods for creating empty state components.
7
+ #
8
+ # @example Simple empty state
9
+ # <%= empty_state title: "No projects", description: "Get started by creating one.", icon: :folder_open %>
10
+ #
11
+ # @example With action button
12
+ # <%= empty_state title: "No projects", icon: :folder_open do %>
13
+ # <%= link_to "Create Project", new_project_path, data: { component: "button", variant: "primary" } %>
14
+ # <% end %>
15
+ #
16
+ # @example Full control with partials
17
+ # <%= render "components/empty", variant: :outline do %>
18
+ # <%= render "components/empty/header" do %>
19
+ # <%= render "components/empty/media", icon: :search %>
20
+ # <%= render "components/empty/title", text: "No results" %>
21
+ # <% end %>
22
+ # <% end %>
23
+ #
24
+ module EmptyHelper
25
+ # Renders an empty state component with a simple API
26
+ #
27
+ # @param title [String] The title text
28
+ # @param description [String, nil] Optional description text
29
+ # @param icon [Symbol, nil] Icon name (uses icon_for helper)
30
+ # @param variant [Symbol] Visual style (:default, :outline)
31
+ # @param size [Symbol] Size variant (:default, :compact)
32
+ # @param css_classes [String] Additional CSS classes
33
+ # @param html_options [Hash] Additional HTML attributes
34
+ # @yield Optional block for action content (buttons, links)
35
+ # @return [String] Rendered HTML
36
+ def empty_state(title:, description: nil, icon: nil, variant: :default, size: :default, css_classes: "", **html_options, &block)
37
+ render "components/empty", variant: variant, size: size, css_classes: css_classes, **html_options do
38
+ parts = []
39
+
40
+ # Build header
41
+ header_content = []
42
+ header_content << render("components/empty/media", icon: icon) if icon
43
+ header_content << render("components/empty/title", text: title)
44
+ header_content << render("components/empty/description", text: description) if description
45
+
46
+ parts << render("components/empty/header") { safe_join(header_content) }
47
+
48
+ # Add content/actions if block given
49
+ if block
50
+ parts << render("components/empty/content") { capture(&block) }
51
+ end
52
+
53
+ safe_join(parts)
54
+ end
55
+ end
56
+
57
+ # Renders an empty state for search results
58
+ #
59
+ # @param query [String, nil] The search query (for display)
60
+ # @param reset_path [String, nil] Path to reset/clear search
61
+ # @param size [Symbol] Size variant
62
+ # @return [String] Rendered HTML
63
+ def empty_search_state(query: nil, reset_path: nil, size: :default)
64
+ description = if query.present?
65
+ "No results found for \"#{query}\". Try a different search term."
66
+ else
67
+ "No results found. Try adjusting your search."
68
+ end
69
+
70
+ empty_state(
71
+ title: "No results",
72
+ description: description,
73
+ icon: :search,
74
+ size: size
75
+ ) do
76
+ if reset_path
77
+ link_to "Clear search", reset_path, data: {component: "button", variant: "outline", size: "sm"}
78
+ end
79
+ end
80
+ end
81
+
82
+ # Renders an empty state for lists/tables
83
+ #
84
+ # @param resource_name [String] Name of the resource (e.g., "projects", "users")
85
+ # @param new_path [String, nil] Path to create new resource
86
+ # @param icon [Symbol] Icon to display
87
+ # @param size [Symbol] Size variant
88
+ # @return [String] Rendered HTML
89
+ def empty_list_state(resource_name:, new_path: nil, icon: :folder_open, size: :default)
90
+ empty_state(
91
+ title: "No #{resource_name} yet",
92
+ description: "Get started by creating your first #{resource_name.singularize}.",
93
+ icon: icon,
94
+ size: size
95
+ ) do
96
+ if new_path
97
+ link_to "Create #{resource_name.singularize.titleize}", new_path, data: {component: "button", variant: "primary"}
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end