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.
- checksums.yaml +4 -4
- data/README.md +410 -13
- data/app/assets/images/maquina.svg +1 -0
- data/app/assets/stylesheets/alert.css +143 -0
- data/app/assets/stylesheets/badge.css +145 -0
- data/app/assets/stylesheets/breadcrumbs.css +163 -0
- data/app/assets/stylesheets/card.css +128 -0
- data/app/assets/stylesheets/dropdown_menu.css +248 -0
- data/app/assets/stylesheets/empty.css +133 -0
- data/app/assets/stylesheets/form.css +617 -0
- data/app/assets/stylesheets/header.css +61 -0
- data/app/assets/stylesheets/maquina_components.css +178 -0
- data/app/assets/stylesheets/pagination.css +154 -0
- data/app/assets/stylesheets/sidebar.css +477 -0
- data/app/assets/stylesheets/table.css +205 -0
- data/app/assets/stylesheets/toggle_group.css +151 -0
- data/app/assets/tailwind/maquina_components_engine/engine.css +16 -0
- data/app/helpers/maquina_components/breadcrumbs_helper.rb +118 -0
- data/app/helpers/maquina_components/dropdown_menu_helper.rb +249 -0
- data/app/helpers/maquina_components/empty_helper.rb +102 -0
- data/app/helpers/maquina_components/icons_helper.rb +161 -0
- data/app/helpers/maquina_components/pagination_helper.rb +153 -0
- data/app/helpers/maquina_components/sidebar_helper.rb +63 -0
- data/app/helpers/maquina_components/table_helper.rb +144 -0
- data/app/helpers/maquina_components/toggle_group_helper.rb +172 -0
- data/app/javascript/controllers/breadcrumb_controller.js +71 -0
- data/app/javascript/controllers/dropdown_menu_controller.js +203 -0
- data/app/javascript/controllers/menu_button_controller.js +59 -0
- data/app/javascript/controllers/sidebar_controller.js +316 -0
- data/app/javascript/controllers/sidebar_trigger_controller.js +32 -0
- data/app/javascript/controllers/toggle_group_controller.js +178 -0
- data/app/views/components/_alert.html.erb +12 -0
- data/app/views/components/_badge.html.erb +10 -0
- data/app/views/components/_breadcrumbs.html.erb +16 -0
- data/app/views/components/_card.html.erb +6 -0
- data/app/views/components/_dropdown.html.erb +25 -0
- data/app/views/components/_dropdown_menu.html.erb +9 -0
- data/app/views/components/_empty.html.erb +10 -0
- data/app/views/components/_header.html.erb +8 -0
- data/app/views/components/_menu_button.html.erb +44 -0
- data/app/views/components/_pagination.html.erb +13 -0
- data/app/views/components/_separator.html.erb +11 -0
- data/app/views/components/_sidebar.html.erb +40 -0
- data/app/views/components/_simple_table.html.erb +49 -0
- data/app/views/components/_table.html.erb +21 -0
- data/app/views/components/_toggle_group.html.erb +24 -0
- data/app/views/components/alert/_description.html.erb +6 -0
- data/app/views/components/alert/_title.html.erb +6 -0
- data/app/views/components/breadcrumbs/_ellipsis.html.erb +9 -0
- data/app/views/components/breadcrumbs/_item.html.erb +8 -0
- data/app/views/components/breadcrumbs/_link.html.erb +8 -0
- data/app/views/components/breadcrumbs/_list.html.erb +8 -0
- data/app/views/components/breadcrumbs/_page.html.erb +8 -0
- data/app/views/components/breadcrumbs/_separator.html.erb +17 -0
- data/app/views/components/card/_action.html.erb +6 -0
- data/app/views/components/card/_content.html.erb +9 -0
- data/app/views/components/card/_description.html.erb +6 -0
- data/app/views/components/card/_footer.html.erb +17 -0
- data/app/views/components/card/_header.html.erb +9 -0
- data/app/views/components/card/_title.html.erb +9 -0
- data/app/views/components/dropdown_menu/_content.html.erb +20 -0
- data/app/views/components/dropdown_menu/_group.html.erb +12 -0
- data/app/views/components/dropdown_menu/_item.html.erb +29 -0
- data/app/views/components/dropdown_menu/_label.html.erb +13 -0
- data/app/views/components/dropdown_menu/_separator.html.erb +11 -0
- data/app/views/components/dropdown_menu/_shortcut.html.erb +12 -0
- data/app/views/components/dropdown_menu/_trigger.html.erb +24 -0
- data/app/views/components/empty/_content.html.erb +8 -0
- data/app/views/components/empty/_description.html.erb +12 -0
- data/app/views/components/empty/_header.html.erb +8 -0
- data/app/views/components/empty/_media.html.erb +13 -0
- data/app/views/components/empty/_title.html.erb +12 -0
- data/app/views/components/pagination/_content.html.erb +8 -0
- data/app/views/components/pagination/_ellipsis.html.erb +28 -0
- data/app/views/components/pagination/_item.html.erb +8 -0
- data/app/views/components/pagination/_link.html.erb +23 -0
- data/app/views/components/pagination/_next.html.erb +57 -0
- data/app/views/components/pagination/_previous.html.erb +57 -0
- data/app/views/components/sidebar/_content.html.erb +8 -0
- data/app/views/components/sidebar/_footer.html.erb +8 -0
- data/app/views/components/sidebar/_group.html.erb +12 -0
- data/app/views/components/sidebar/_header.html.erb +8 -0
- data/app/views/components/sidebar/_inset.html.erb +8 -0
- data/app/views/components/sidebar/_menu.html.erb +8 -0
- data/app/views/components/sidebar/_menu_button.html.erb +14 -0
- data/app/views/components/sidebar/_menu_item.html.erb +7 -0
- data/app/views/components/sidebar/_menu_link.html.erb +32 -0
- data/app/views/components/sidebar/_provider.html.erb +16 -0
- data/app/views/components/sidebar/_trigger.html.erb +12 -0
- data/app/views/components/stats/_stats_card.html.erb +100 -0
- data/app/views/components/stats/_stats_grid.html.erb +38 -0
- data/app/views/components/table/_body.html.erb +5 -0
- data/app/views/components/table/_caption.html.erb +5 -0
- data/app/views/components/table/_cell.html.erb +5 -0
- data/app/views/components/table/_footer.html.erb +5 -0
- data/app/views/components/table/_head.html.erb +8 -0
- data/app/views/components/table/_header.html.erb +8 -0
- data/app/views/components/table/_row.html.erb +8 -0
- data/app/views/components/toggle_group/_item.html.erb +19 -0
- data/config/importmap.rb +1 -0
- data/lib/generators/maquina_components/install/USAGE +39 -0
- data/lib/generators/maquina_components/install/install_generator.rb +123 -0
- data/lib/generators/maquina_components/install/templates/maquina_components_helper.rb.tt +68 -0
- data/lib/generators/maquina_components/install/templates/theme.css.tt +179 -0
- data/lib/maquina_components/engine.rb +10 -0
- data/lib/maquina_components/version.rb +1 -1
- 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
|