phlexi-menu 0.1.0 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +151 -126
- data/changes.patch +393 -0
- data/export.json +7 -7
- data/gemfiles/default.gemfile.lock +1 -1
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/lib/phlexi/menu/badge.rb +32 -0
- data/lib/phlexi/menu/builder.rb +0 -21
- data/lib/phlexi/menu/component.rb +77 -45
- data/lib/phlexi/menu/item.rb +79 -32
- data/lib/phlexi/menu/theme.rb +2 -0
- data/lib/phlexi/menu/version.rb +1 -1
- metadata +4 -2
data/changes.patch
ADDED
@@ -0,0 +1,393 @@
|
|
1
|
+
diff --git a/lib/phlexi/menu/component.rb b/lib/phlexi/menu/component.rb
|
2
|
+
index 7f35b74..b9ecc5e 100644
|
3
|
+
--- a/lib/phlexi/menu/component.rb
|
4
|
+
+++ b/lib/phlexi/menu/component.rb
|
5
|
+
@@ -31,7 +31,10 @@ module Phlexi
|
6
|
+
# @param menu [Phlexi::Menu::Builder] The menu structure to render
|
7
|
+
# @param max_depth [Integer] Maximum nesting depth for menu items
|
8
|
+
# @param options [Hash] Additional options passed to rendering methods
|
9
|
+
+ # @raise [ArgumentError] If menu is nil
|
10
|
+
def initialize(menu, max_depth: default_max_depth, **options)
|
11
|
+
+ raise ArgumentError, "Menu cannot be nil" if menu.nil?
|
12
|
+
+
|
13
|
+
@menu = menu
|
14
|
+
@max_depth = max_depth
|
15
|
+
@options = options
|
16
|
+
@@ -39,9 +42,7 @@ module Phlexi
|
17
|
+
end
|
18
|
+
|
19
|
+
def view_template
|
20
|
+
- nav(class: themed(:nav)) do
|
21
|
+
- render_items(@menu.items)
|
22
|
+
- end
|
23
|
+
+ nav(class: themed(:nav)) { render_items(@menu.items) }
|
24
|
+
end
|
25
|
+
|
26
|
+
protected
|
27
|
+
@@ -50,14 +51,12 @@ module Phlexi
|
28
|
+
#
|
29
|
+
# @param items [Array<Phlexi::Menu::Item>] The items to render
|
30
|
+
# @param depth [Integer] Current nesting depth
|
31
|
+
+ # @return [void]
|
32
|
+
def render_items(items, depth = 0)
|
33
|
+
- return if depth >= @max_depth
|
34
|
+
- return if items.empty?
|
35
|
+
+ return if depth >= @max_depth || items.empty?
|
36
|
+
|
37
|
+
ul(class: themed(:items_container, depth)) do
|
38
|
+
- items.each do |item|
|
39
|
+
- render_item_wrapper(item, depth)
|
40
|
+
- end
|
41
|
+
+ items.each { |item| render_item_wrapper(item, depth) }
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
@@ -65,21 +64,41 @@ module Phlexi
|
46
|
+
#
|
47
|
+
# @param item [Phlexi::Menu::Item] The item to wrap
|
48
|
+
# @param depth [Integer] Current nesting depth
|
49
|
+
+ # @return [void]
|
50
|
+
def render_item_wrapper(item, depth)
|
51
|
+
- li(class: tokens(
|
52
|
+
+ li(class: compute_item_wrapper_classes(item, depth)) do
|
53
|
+
+ render_item_content(item, depth)
|
54
|
+
+ render_nested_items(item, depth)
|
55
|
+
+ end
|
56
|
+
+ end
|
57
|
+
+
|
58
|
+
+ # Computes CSS classes for item wrapper
|
59
|
+
+ #
|
60
|
+
+ # @param item [Phlexi::Menu::Item] The menu item
|
61
|
+
+ # @param depth [Integer] Current nesting depth
|
62
|
+
+ # @return [String] Space-separated CSS classes
|
63
|
+
+ def compute_item_wrapper_classes(item, depth)
|
64
|
+
+ tokens(
|
65
|
+
themed(:item_wrapper, depth),
|
66
|
+
item_parent_class(item, depth),
|
67
|
+
active?(item) ? themed(:active, depth) : nil
|
68
|
+
- )) do
|
69
|
+
- render_item_content(item, depth)
|
70
|
+
- render_items(item.items, depth + 1) if nested?(item, depth)
|
71
|
+
- end
|
72
|
+
+ )
|
73
|
+
+ end
|
74
|
+
+
|
75
|
+
+ # Renders nested items if present and within depth limit
|
76
|
+
+ #
|
77
|
+
+ # @param item [Phlexi::Menu::Item] The parent menu item
|
78
|
+
+ # @param depth [Integer] Current nesting depth
|
79
|
+
+ # @return [void]
|
80
|
+
+ def render_nested_items(item, depth)
|
81
|
+
+ render_items(item.items, depth + 1) if nested?(item, depth)
|
82
|
+
end
|
83
|
+
|
84
|
+
# Renders the content of a menu item, choosing between link and span.
|
85
|
+
#
|
86
|
+
# @param item [Phlexi::Menu::Item] The item to render content for
|
87
|
+
# @param depth [Integer] Current nesting depth
|
88
|
+
+ # @return [void]
|
89
|
+
def render_item_content(item, depth)
|
90
|
+
if item.url
|
91
|
+
render_item_link(item, depth)
|
92
|
+
@@ -92,13 +111,11 @@ module Phlexi
|
93
|
+
#
|
94
|
+
# @param item [Phlexi::Menu::Item] The item to render as a link
|
95
|
+
# @param depth [Integer] Current nesting depth
|
96
|
+
+ # @return [void]
|
97
|
+
def render_item_link(item, depth)
|
98
|
+
a(
|
99
|
+
href: item.url,
|
100
|
+
- class: tokens(
|
101
|
+
- themed(:item_link, depth),
|
102
|
+
- active_class(item, depth)
|
103
|
+
- )
|
104
|
+
+ class: tokens(themed(:item_link, depth), active_class(item, depth))
|
105
|
+
) do
|
106
|
+
render_item_interior(item, depth)
|
107
|
+
end
|
108
|
+
@@ -108,6 +125,7 @@ module Phlexi
|
109
|
+
#
|
110
|
+
# @param item [Phlexi::Menu::Item] The item to render as a span
|
111
|
+
# @param depth [Integer] Current nesting depth
|
112
|
+
+ # @return [void]
|
113
|
+
def render_item_span(item, depth)
|
114
|
+
span(class: themed(:item_span, depth)) do
|
115
|
+
render_item_interior(item, depth)
|
116
|
+
@@ -118,47 +136,69 @@ module Phlexi
|
117
|
+
#
|
118
|
+
# @param item [Phlexi::Menu::Item] The item to render interior content for
|
119
|
+
# @param depth [Integer] Current nesting depth
|
120
|
+
+ # @return [void]
|
121
|
+
def render_item_interior(item, depth)
|
122
|
+
- render_leading_badge(item.leading_badge, depth) if item.leading_badge
|
123
|
+
+ render_leading_badge(item, depth) if item.leading_badge
|
124
|
+
render_icon(item.icon, depth) if item.icon
|
125
|
+
render_label(item.label, depth)
|
126
|
+
- render_trailing_badge(item.trailing_badge, depth) if item.trailing_badge
|
127
|
+
+ render_trailing_badge(item, depth) if item.trailing_badge
|
128
|
+
end
|
129
|
+
|
130
|
+
# Renders the item's label.
|
131
|
+
#
|
132
|
+
# @param label [String, Component] The label to render
|
133
|
+
# @param depth [Integer] Current nesting depth
|
134
|
+
+ # @return [void]
|
135
|
+
def render_label(label, depth)
|
136
|
+
- phlexi_render(label) {
|
137
|
+
+ phlexi_render(label) do
|
138
|
+
span(class: themed(:item_label, depth)) { label }
|
139
|
+
- }
|
140
|
+
+ end
|
141
|
+
+ end
|
142
|
+
+
|
143
|
+
+ # Renders the leading badge if present
|
144
|
+
+ #
|
145
|
+
+ # @param item [Phlexi::Menu::Item] The menu item
|
146
|
+
+ # @param depth [Integer] Current nesting depth
|
147
|
+
+ # @return [void]
|
148
|
+
+ def render_leading_badge(item, depth)
|
149
|
+
+ return unless item.leading_badge
|
150
|
+
+
|
151
|
+
+ div(class: themed(:leading_badge_wrapper, depth)) do
|
152
|
+
+ render_badge(item.leading_badge, item.leading_badge_options, :leading_badge, depth)
|
153
|
+
+ end
|
154
|
+
end
|
155
|
+
|
156
|
+
- # Renders the item's leading badge.
|
157
|
+
+ # Renders the trailing badge if present
|
158
|
+
#
|
159
|
+
- # @param badge [String, Component] The leading badge to render
|
160
|
+
+ # @param item [Phlexi::Menu::Item] The menu item
|
161
|
+
# @param depth [Integer] Current nesting depth
|
162
|
+
- def render_leading_badge(badge, depth)
|
163
|
+
- phlexi_render(badge) {
|
164
|
+
- span(class: themed(:leading_badge, depth)) { badge }
|
165
|
+
- }
|
166
|
+
+ # @return [void]
|
167
|
+
+ def render_trailing_badge(item, depth)
|
168
|
+
+ return unless item.trailing_badge
|
169
|
+
+
|
170
|
+
+ div(class: themed(:trailing_badge_wrapper, depth)) do
|
171
|
+
+ render_badge(item.trailing_badge, item.trailing_badge_options, :trailing_badge, depth)
|
172
|
+
+ end
|
173
|
+
end
|
174
|
+
|
175
|
+
- # Renders the item's trailing badge.
|
176
|
+
+ # Renders a badge with given options
|
177
|
+
#
|
178
|
+
- # @param badge [String, Component] The trailing badge to render
|
179
|
+
+ # @param badge [Object] The badge content
|
180
|
+
+ # @param options [Hash] Badge rendering options
|
181
|
+
+ # @param type [Symbol] Badge type (leading or trailing)
|
182
|
+
# @param depth [Integer] Current nesting depth
|
183
|
+
- def render_trailing_badge(badge, depth)
|
184
|
+
- phlexi_render(badge) {
|
185
|
+
- span(class: themed(:trailing_badge, depth)) { badge }
|
186
|
+
- }
|
187
|
+
+ # @return [void]
|
188
|
+
+ def render_badge(badge, options, type, depth)
|
189
|
+
+ phlexi_render(badge) do
|
190
|
+
+ render BadgeComponent.new(badge, **options)
|
191
|
+
+ end
|
192
|
+
end
|
193
|
+
|
194
|
+
# Renders the item's icon.
|
195
|
+
#
|
196
|
+
# @param icon [Class] The icon component class to render
|
197
|
+
# @param depth [Integer] Current nesting depth
|
198
|
+
+ # @return [void]
|
199
|
+
def render_icon(icon, depth)
|
200
|
+
return unless icon
|
201
|
+
|
202
|
+
@@ -222,6 +262,7 @@ module Phlexi
|
203
|
+
# @param arg [Object] The value to render
|
204
|
+
# @yield The default rendering block
|
205
|
+
# @raise [ArgumentError] If no block is provided
|
206
|
+
+ # @return [void]
|
207
|
+
def phlexi_render(arg, &)
|
208
|
+
return unless arg
|
209
|
+
raise ArgumentError, "phlexi_render requires a default render block" unless block_given?
|
210
|
+
@@ -235,6 +276,7 @@ module Phlexi
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
+ # @return [Integer] The default maximum depth for the menu
|
215
|
+
def default_max_depth = self.class::DEFAULT_MAX_DEPTH
|
216
|
+
end
|
217
|
+
end
|
218
|
+
diff --git a/lib/phlexi/menu/item.rb b/lib/phlexi/menu/item.rb
|
219
|
+
index 4d128d6..b8b5614 100644
|
220
|
+
--- a/lib/phlexi/menu/item.rb
|
221
|
+
+++ b/lib/phlexi/menu/item.rb
|
222
|
+
@@ -20,6 +20,11 @@ module Phlexi
|
223
|
+
# admin.item "Users", url: "/admin/users"
|
224
|
+
# admin.item "Settings", url: "/admin/settings"
|
225
|
+
# end
|
226
|
+
+ #
|
227
|
+
+ # @example Custom active state logic
|
228
|
+
+ # Item.new("Dashboard", url: "/dashboard", active: -> (context) {
|
229
|
+
+ # context.controller.controller_name == "dashboards"
|
230
|
+
+ # })
|
231
|
+
class Item
|
232
|
+
# @return [String] The display text for the menu item
|
233
|
+
attr_reader :label
|
234
|
+
@@ -33,9 +38,15 @@ module Phlexi
|
235
|
+
# @return [String, Component, nil] The badge displayed before the label
|
236
|
+
attr_reader :leading_badge
|
237
|
+
|
238
|
+
+ # @return [Hash] Options for the leading badge
|
239
|
+
+ attr_reader :leading_badge_options
|
240
|
+
+
|
241
|
+
# @return [String, Component, nil] The badge displayed after the label
|
242
|
+
attr_reader :trailing_badge
|
243
|
+
|
244
|
+
+ # @return [Hash] Options for the trailing badge
|
245
|
+
+ attr_reader :trailing_badge_options
|
246
|
+
+
|
247
|
+
# @return [Array<Item>] Collection of nested menu items
|
248
|
+
attr_reader :items
|
249
|
+
|
250
|
+
@@ -55,14 +66,12 @@ module Phlexi
|
251
|
+
# @raise [ArgumentError] If the label is nil or empty
|
252
|
+
def initialize(label, url: nil, icon: nil, leading_badge: nil, trailing_badge: nil, **options, &)
|
253
|
+
raise ArgumentError, "Label cannot be nil" unless label
|
254
|
+
-
|
255
|
+
@label = label
|
256
|
+
@url = url
|
257
|
+
@icon = icon
|
258
|
+
- @leading_badge = leading_badge
|
259
|
+
- @trailing_badge = trailing_badge
|
260
|
+
- @options = options
|
261
|
+
@items = []
|
262
|
+
+ @options = options
|
263
|
+
+ setup_badges(leading_badge, trailing_badge, options)
|
264
|
+
|
265
|
+
yield self if block_given?
|
266
|
+
end
|
267
|
+
@@ -70,16 +79,44 @@ module Phlexi
|
268
|
+
# Creates and adds a nested menu item.
|
269
|
+
#
|
270
|
+
# @param label [String] The display text for the nested item
|
271
|
+
- # @param ** [Hash] Additional options passed to the Item constructor
|
272
|
+
+ # @param args [Hash] Additional options passed to the Item constructor
|
273
|
+
# @yield [item] Optional block for adding further nested items
|
274
|
+
# @yieldparam item [Item] The newly created nested item
|
275
|
+
# @return [Item] The created nested item
|
276
|
+
- def item(label, **, &)
|
277
|
+
- new_item = self.class.new(label, **, &)
|
278
|
+
+ def item(label, **args, &)
|
279
|
+
+ new_item = self.class.new(label, **args, &)
|
280
|
+
@items << new_item
|
281
|
+
new_item
|
282
|
+
end
|
283
|
+
|
284
|
+
+ # Add a leading badge to the menu item
|
285
|
+
+ #
|
286
|
+
+ # @param badge [String, Component] The badge content
|
287
|
+
+ # @param opts [Hash] Additional options for the badge
|
288
|
+
+ # @return [self] Returns self for method chaining
|
289
|
+
+ # @raise [ArgumentError] If badge is nil
|
290
|
+
+ def with_leading_badge(badge, **opts)
|
291
|
+
+ raise ArgumentError, "Badge cannot be nil" if badge.nil?
|
292
|
+
+
|
293
|
+
+ @leading_badge = badge
|
294
|
+
+ @leading_badge_options = opts
|
295
|
+
+ self
|
296
|
+
+ end
|
297
|
+
+
|
298
|
+
+ # Add a trailing badge to the menu item
|
299
|
+
+ #
|
300
|
+
+ # @param badge [String, Component] The badge content
|
301
|
+
+ # @param opts [Hash] Additional options for the badge
|
302
|
+
+ # @return [self] Returns self for method chaining
|
303
|
+
+ # @raise [ArgumentError] If badge is nil
|
304
|
+
+ def with_trailing_badge(badge, **opts)
|
305
|
+
+ raise ArgumentError, "Badge cannot be nil" if badge.nil?
|
306
|
+
+
|
307
|
+
+ @trailing_badge = badge
|
308
|
+
+ @trailing_badge_options = opts
|
309
|
+
+ self
|
310
|
+
+ end
|
311
|
+
+
|
312
|
+
# Determines if this menu item should be shown as active.
|
313
|
+
# Checks in the following order:
|
314
|
+
# 1. Custom active logic if provided in options
|
315
|
+
@@ -89,23 +126,54 @@ module Phlexi
|
316
|
+
# @param context [Object] The context object (typically a controller) for active state checking
|
317
|
+
# @return [Boolean] true if the item should be shown as active, false otherwise
|
318
|
+
def active?(context)
|
319
|
+
- # First check custom active logic if provided
|
320
|
+
- return @options[:active].call(context) if @options[:active].respond_to?(:call)
|
321
|
+
-
|
322
|
+
- # Then check if this item's URL matches current page
|
323
|
+
- if context.respond_to?(:helpers) && @url
|
324
|
+
- return true if context.helpers.current_page?(@url)
|
325
|
+
- end
|
326
|
+
-
|
327
|
+
- # Finally check if any child items are active
|
328
|
+
- @items.any? { |item| item.active?(context) }
|
329
|
+
+ check_custom_active_state(context) ||
|
330
|
+
+ check_current_page_match(context) ||
|
331
|
+
+ check_nested_items_active(context)
|
332
|
+
end
|
333
|
+
|
334
|
+
# Returns a string representation of the menu item.
|
335
|
+
#
|
336
|
+
# @return [String] A human-readable representation of the menu item
|
337
|
+
def inspect
|
338
|
+
- "#<#{self.class} label=#{@label} url=#{@url} items=#{@items.map(&:inspect)}>"
|
339
|
+
+ "#<#{self.class} label=#{@label.inspect} url=#{@url.inspect} items=#{@items.inspect}>"
|
340
|
+
+ end
|
341
|
+
+
|
342
|
+
+ private
|
343
|
+
+
|
344
|
+
+ # Sets up the badge attributes
|
345
|
+
+ #
|
346
|
+
+ # @param leading_badge [String, Component, nil] The leading badge
|
347
|
+
+ # @param trailing_badge [String, Component, nil] The trailing badge
|
348
|
+
+ # @param options [Hash] Options containing badge configurations
|
349
|
+
+ def setup_badges(leading_badge, trailing_badge, options)
|
350
|
+
+ @leading_badge = leading_badge
|
351
|
+
+ @leading_badge_options = options.delete(:leading_badge_options) || {}
|
352
|
+
+ @trailing_badge = trailing_badge
|
353
|
+
+ @trailing_badge_options = options.delete(:trailing_badge_options) || {}
|
354
|
+
+ end
|
355
|
+
+
|
356
|
+
+ # Checks if there's custom active state logic
|
357
|
+
+ #
|
358
|
+
+ # @param context [Object] The context for active state checking
|
359
|
+
+ # @return [Boolean] Result of custom active check
|
360
|
+
+ def check_custom_active_state(context)
|
361
|
+
+ @options[:active].respond_to?(:call) && @options[:active].call(context)
|
362
|
+
+ end
|
363
|
+
+
|
364
|
+
+ # Checks if the current page matches the item's URL
|
365
|
+
+ #
|
366
|
+
+ # @param context [Object] The context for URL matching
|
367
|
+
+ # @return [Boolean] Whether the current page matches
|
368
|
+
+ def check_current_page_match(context)
|
369
|
+
+ context.respond_to?(:helpers) && @url && context.helpers.current_page?(@url)
|
370
|
+
+ end
|
371
|
+
+
|
372
|
+
+ # Checks if any nested items are active
|
373
|
+
+ #
|
374
|
+
+ # @param context [Object] The context for checking nested items
|
375
|
+
+ # @return [Boolean] Whether any nested items are active
|
376
|
+
+ def check_nested_items_active(context)
|
377
|
+
+ @items.any? { |item| item.active?(context) }
|
378
|
+
end
|
379
|
+
end
|
380
|
+
end
|
381
|
+
diff --git a/lib/phlexi/menu/theme.rb b/lib/phlexi/menu/theme.rb
|
382
|
+
index 076b137..415498b 100644
|
383
|
+
--- a/lib/phlexi/menu/theme.rb
|
384
|
+
+++ b/lib/phlexi/menu/theme.rb
|
385
|
+
@@ -25,6 +25,8 @@ module Phlexi
|
386
|
+
hover: nil, # Hover state
|
387
|
+
|
388
|
+
# Badge elements
|
389
|
+
+ leading_badge_wrapper: nil, # Wrapper for leading badge
|
390
|
+
+ trailing_badge_wrapper: nil, # Wrapper for trailing badge
|
391
|
+
leading_badge: nil, # Badge before label
|
392
|
+
trailing_badge: nil, # Badge after label
|
393
|
+
|
data/export.json
CHANGED
@@ -5,7 +5,7 @@
|
|
5
5
|
},
|
6
6
|
{
|
7
7
|
"path": "/Users/stefan/Documents/plutonium/phlexi-menu/README.md",
|
8
|
-
"contents": "# Phlexi::Menu\n\nPhlexi::Menu is a flexible and powerful menu builder for Ruby applications. It provides an elegant way to create hierarchical menus with support for icons, badges, and active state detection.\n\n[![Ruby](https://github.com/radioactive-labs/phlexi-menu/actions/workflows/main.yml/badge.svg)](https://github.com/radioactive-labs/phlexi-menu/actions/workflows/main.yml)\n\n## Table of Contents\n\n- [Features](#features)\n- [Prerequisites](#prerequisites)\n- [Installation](#installation)\n- [Usage](#usage)\n - [Basic Usage](#basic-usage)\n - [Menu Items](#menu-items)\n - [Component Options](#component-options)\n - [Theming](#theming)\n - [Badge Components](#badge-components)\n - [Rails Integration](#rails-integration)\n- [Advanced Usage](#advanced-usage)\n - [Component Customization](#component-customization)\n - [Dynamic Menus](#dynamic-menus)\n- [Development](#development)\n- [Contributing](#contributing)\n- [License](#license)\n\n## Features\n\n- Hierarchical menu structure with controlled nesting depth\n- Support for icons and dual-badge system (leading and trailing badges)\n- Intelligent active state detection\n- Flexible theming system\n- Works seamlessly with Phlex components\n- Rails-compatible URL handling\n- Customizable rendering components\n\n## Prerequisites\n\n- Ruby >= 3.2.2\n- Rails (optional, but recommended)\n- Phlex (~> 1.11)\n\n## Installation\n\nAdd this line to your application's Gemfile:\n\n```ruby\ngem 'phlexi-menu'\n```\n\nAnd then execute:\n\n```bash\n$ bundle install\n```\n\n## Usage\n\n### Basic Usage\n\n```ruby\nclass MainMenu < Phlexi::Menu::Component\n class Theme < Theme\n def self.theme\n super.merge({\n nav: \"bg-white shadow\",\n items_container: \"space-y-1\",\n item_wrapper: \"relative\",\n item_link: \"flex items-center px-4 py-2 hover:bg-gray-50\",\n item_span: \"flex items-center px-4 py-2\",\n item_label: \"mx-3\",\n leading_badge: \"mr-2 px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-600\",\n trailing_badge: \"ml-auto px-2 py-0.5 text-xs rounded-full bg-red-100 text-red-600\",\n icon: \"h-5 w-5\",\n active: \"bg-blue-50 text-blue-600\"\n })\n end\n end\nend\n\n# Using the menu\nmenu = Phlexi::Menu::Builder.new do |m|\n m.item \"Dashboard\", \n url: \"/\", \n icon: DashboardIcon\n \n m.item \"Users\", \n url: \"/users\", \n leading_badge: \"Beta\",\n trailing_badge: \"23\" do |users|\n users.item \"All Users\", url: \"/users\"\n users.item \"Add User\", url: \"/users/new\"\n end\n \n m.item \"Settings\", \n url: \"/settings\", \n icon: SettingsIcon,\n leading_badge: CustomBadgeComponent.new\nend\n\n# In your view\nrender MainMenu.new(menu, max_depth: 2)\n```\n\n### Menu Items\n\nMenu items support several options:\n\n```ruby\nm.item \"Menu Item\",\n url: \"/path\", # URL for the menu item\n icon: IconComponent, # Icon component class\n leading_badge: \"Beta\", # Leading badge (status/type indicators)\n trailing_badge: \"99+\", # Trailing badge (counts/notifications)\n active: ->(context) { # Custom active state logic\n context.controller_name == \"products\"\n }\n```\n\n### Component Options\n\nThe menu component accepts these initialization options:\n\n```ruby\nMainMenu.new(\n menu, # The menu instance\n max_depth: 3, # Maximum nesting depth (default: 3)\n **options # Additional options passed to templates\n)\n```\n\n### Theming\n\n```ruby\nclass CustomMenu < Phlexi::Menu::Component\n class Theme < Theme\n def self.theme\n super.merge({\n nav: \"bg-white shadow rounded-lg\",\n items_container: \"space-y-1\",\n item_wrapper: \"relative\",\n item_link: \"flex items-center px-4 py-2 hover:bg-gray-50\",\n item_span: \"flex items-center px-4 py-2\",\n item_label: \"mx-3\",\n leading_badge: \"mr-2 px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-600\",\n trailing_badge: \"ml-auto px-2 py-0.5 text-xs rounded-full bg-red-100 text-red-600\",\n icon: \"h-5 w-5\",\n active: \"bg-blue-50 text-blue-600\"\n })\n end\n end\nend\n```\n\n### Badge Components\n\nBadges can be either strings or Phlex components:\n\n```ruby\nclass CustomBadgeComponent < ApplicationComponent\n def view_template\n div(class: \"flex items-center\") do\n span(class: \"h-2 w-2 rounded-full bg-blue-400\")\n span(class: \"ml-2\") { \"New\" }\n end\n end\nend\n\n# Usage\nm.item \"Products\", leading_badge: CustomBadgeComponent.new\n```\n\n### Rails Integration\n\nIn your controller:\n\n```ruby\nclass ApplicationController < ActionController::Base\n def navigation\n @navigation ||= Phlexi::Menu::Builder.new do |m|\n m.item \"Home\", \n url: root_path, \n icon: HomeIcon\n \n if user_signed_in?\n m.item \"Account\", \n url: account_path,\n trailing_badge: notifications_count do |account|\n account.item \"Profile\", url: profile_path\n account.item \"Settings\", url: settings_path\n account.item \"Logout\", url: logout_path\n end\n end\n\n if current_user&.admin?\n m.item \"Admin\", \n url: admin_path, \n leading_badge: \"Admin\"\n end\n end\n end\n helper_method :navigation\nend\n```\n\nNote: The menu component uses Rails' `current_page?` helper for default active state detection. If you're not using Rails or want custom active state logic, provide an `active` callable to your menu items:\n\n```ruby\nm.item \"Custom Active\", url: \"/path\", active: ->(context) {\n # Your custom active state logic here\n context.request.path.start_with?(\"/path\")\n}\n```\n\n## Advanced Usage\n\n### Component Customization\n\nYou can customize specific rendering steps:\n\n```ruby\nclass CustomMenu < Phlexi::Menu::Component\n # Override just what you need\n def render_item_interior(item)\n div(class: \"flex items-center gap-2\") do\n render_leading_badge(item.leading_badge) if item.leading_badge\n render_icon(item.icon) if item.icon\n span(class: themed(:item_label)) { item.label.upcase }\n render_trailing_badge(item.trailing_badge) if item.trailing_badge\n end\n end\n\n def render_leading_badge(badge)\n div(class: tokens(themed(:leading_badge), \"flex items-center\")) do\n span { \"●\" }\n span(class: \"ml-1\") { badge }\n end\n end\nend\n```\n\nThe component provides these customization points:\n- `render_items`: Handles collection of items and nesting\n- `render_item_wrapper`: Wraps individual items\n- `render_item_content`: Chooses between link and span rendering\n- `render_item_interior`: Handles the item's internal layout\n- `render_leading_badge`: Renders the leading badge\n- `render_trailing_badge`: Renders the trailing badge\n- `render_icon`: Renders the icon component\n\n### Dynamic Menus\n\nExample of building menus based on user permissions:\n\n```ruby\nPhlexi::Menu::Builder.new do |m|\n # Basic items\n m.item \"Home\", url: root_path\n \n # Authorization-based items\n if current_user.can?(:manage, :products)\n m.item \"Products\", url: products_path do |products|\n products.item \"All Products\", url: products_path\n products.item \"Categories\", url: categories_path if current_user.can?(:manage, :categories)\n products.item \"New Product\", url: new_product_path\n end\n end\n \n # Dynamic items from database\n current_user.organizations.each do |org|\n m.item org.name, url: organization_path(org), icon: OrgIcon\n end\nend\n```\n\n## Development\n\nAfter checking out the repo:\n\n1. Run `bin/setup` to install dependencies\n2. Run `bin/appraise install` to install appraisal gemfiles \n3. Run `bin/appraise rake test` to run the tests against all supported versions\n4. You can also run `bin/console` for an interactive prompt\n\nFor development against a single version, you can just use `bundle exec rake test`.\n\n## Contributing\n\nBug reports and pull requests are welcome on GitHub at https://github.com/radioactive-labs/phlexi-menu.\n\n1. Fork it\n2. Create your feature branch (`git checkout -b my-new-feature`)\n3. Commit your changes (`git commit -am 'Add some feature'`)\n4. Push to the branch (`git push origin my-new-feature`)\n5. Create new Pull Request\n\n## License\n\nThe gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT)."
|
8
|
+
"contents": "# Phlexi::Menu\n\nPhlexi::Menu is a flexible and powerful menu builder for Ruby applications. It provides an elegant way to create hierarchical menus with support for icons, badges, and active state detection.\n\n[![Ruby](https://github.com/radioactive-labs/phlexi-menu/actions/workflows/main.yml/badge.svg)](https://github.com/radioactive-labs/phlexi-menu/actions/workflows/main.yml)\n\n## Table of Contents\n\n- [Features](#features)\n- [Prerequisites](#prerequisites)\n- [Installation](#installation)\n- [Usage](#usage)\n - [Basic Usage](#basic-usage)\n - [Menu Items](#menu-items)\n - [Component Options](#component-options)\n - [Nesting and Depth Limits](#nesting-and-depth-limits)\n - [Theming](#theming)\n - [Static Theming](#static-theming)\n - [Depth-Aware Theming](#depth-aware-theming)\n - [Badge Components](#badge-components)\n - [Rails Integration](#rails-integration)\n- [Advanced Usage](#advanced-usage)\n - [Component Customization](#component-customization)\n - [Dynamic Menus](#dynamic-menus)\n- [Development](#development)\n- [Contributing](#contributing)\n- [License](#license)\n\n## Features\n\n- Hierarchical menu structure with intelligent depth control\n- Support for icons and dual-badge system (leading and trailing badges)\n- Intelligent active state detection\n- Flexible theming system with depth awareness\n- Smart nesting behavior based on depth limits\n- Works seamlessly with Phlex components\n- Rails-compatible URL handling\n- Customizable rendering components\n\n## Prerequisites\n\n- Ruby >= 3.2.2\n- Rails (optional, but recommended)\n- Phlex (~> 1.11)\n\n## Installation\n\nAdd this line to your application's Gemfile:\n\n```ruby\ngem 'phlexi-menu'\n```\n\nAnd then execute:\n\n```bash\n$ bundle install\n```\n\n## Usage\n\n### Basic Usage\n\n```ruby\nclass MainMenu < Phlexi::Menu::Component\n class Theme < Theme\n def self.theme\n super.merge({\n nav: \"bg-white shadow\",\n items_container: \"space-y-1\",\n item_wrapper: ->(depth) { \"relative pl-#{depth * 4}\" },\n item_link: \"flex items-center px-4 py-2 hover:bg-gray-50\",\n item_span: \"flex items-center px-4 py-2\",\n item_label: ->(depth) { \"mx-3 text-gray-#{600 + (depth * 100)}\" },\n leading_badge: \"mr-2 px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-600\",\n trailing_badge: \"ml-auto px-2 py-0.5 text-xs rounded-full bg-red-100 text-red-600\",\n icon: \"h-5 w-5\",\n active: \"bg-blue-50 text-blue-600\",\n item_parent: \"has-children\"\n })\n end\n end\nend\n\n# Using the menu\nmenu = Phlexi::Menu::Builder.new do |m|\n m.item \"Dashboard\", \n url: \"/\", \n icon: DashboardIcon\n \n m.item \"Users\", \n url: \"/users\", \n leading_badge: \"Beta\",\n trailing_badge: \"23\" do |users|\n users.item \"All Users\", url: \"/users\"\n users.item \"Add User\", url: \"/users/new\"\n end\n\n m.item \"Settings\", \n url: \"/settings\", \n icon: SettingsIcon,\n leading_badge: CustomBadgeComponent.new\nend\n\n# In your view\nrender MainMenu.new(menu, max_depth: 2)\n```\n\n### Menu Items\n\nMenu items support several options:\n\n```ruby\nm.item \"Menu Item\",\n url: \"/path\", # URL for the menu item\n icon: IconComponent, # Icon component class\n leading_badge: \"Beta\", # Leading badge (status/type indicators)\n trailing_badge: \"99+\", # Trailing badge (counts/notifications)\n active: ->(context) { # Custom active state logic\n context.controller_name == \"products\"\n }\n```\n\n### Component Options\n\nThe menu component accepts these initialization options:\n\n```ruby\nMainMenu.new(\n menu, # The menu instance\n max_depth: 3, # Maximum nesting depth (default: 3)\n **options # Additional options passed to templates\n)\n```\n\n### Nesting and Depth Limits\n\nPhlexi::Menu intelligently handles menu nesting based on the specified maximum depth:\n\n```ruby\n# Create a deeply nested menu structure\nmenu = Phlexi::Menu::Builder.new do |m|\n m.item \"Level 0\" do |l0| # Will be nested (depth 0)\n l0.item \"Level 1\" do |l1| # Will be nested if max_depth > 2\n l1.item \"Level 2\" # Will be nested if max_depth > 3\n l1.item \"Level 3\" # Won't be nested if max_depth <= 3\n end\n end\n end\nend\n\n# Render with depth limit\nmenu_component = MainMenu.new(menu, max_depth: 2)\n```\n\nKey behaviors:\n- Items are only treated as nested if their children can be rendered within the depth limit\n- Parent styling classes (item_parent theme) are only applied to items whose children will be shown\n- Nesting structure automatically adjusts based on the max_depth setting\n- Depth-aware theme values receive the actual rendered depth of each item\n\nExample with max_depth of 2:\n```ruby\nmenu = Phlexi::Menu::Builder.new do |m|\n m.item \"Products\" do |products| # depth 0, gets parent styling\n products.item \"Categories\" do |cats| # depth 1, gets parent styling\n cats.item \"Electronics\" # depth 2, no parent styling\n cats.item \"Books\" do |books| # depth 2, no parent styling\n books.item \"Fiction\" # not rendered (depth 3)\n end\n end\n end\nend\n```\n\n### Theming\n\nPhlexi::Menu provides two approaches to theming: static and depth-aware.\n\n#### Static Theming\n\nBasic theme configuration with fixed classes:\n\n```ruby\nclass CustomMenu < Phlexi::Menu::Component\n class Theme < Theme\n def self.theme\n super.merge({\n nav: \"bg-white shadow rounded-lg\",\n items_container: \"space-y-1\",\n item_wrapper: \"relative\",\n item_link: \"flex items-center px-4 py-2 hover:bg-gray-50\",\n item_span: \"flex items-center px-4 py-2\",\n item_label: \"mx-3\",\n leading_badge: \"mr-2 px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-600\",\n trailing_badge: \"ml-auto px-2 py-0.5 text-xs rounded-full bg-red-100 text-red-600\",\n icon: \"h-5 w-5\",\n active: \"bg-blue-50 text-blue-600\"\n })\n end\n end\nend\n```\n\n#### Depth-Aware Theming\n\nAdvanced theme configuration with depth-sensitive classes:\n\n```ruby\nclass DepthAwareMenu < Phlexi::Menu::Component\n class Theme < Theme\n def self.theme\n super.merge({\n # Static classes\n nav: \"bg-white shadow\",\n \n # Progressive indentation\n item_wrapper: ->(depth) { \"relative pl-#{depth * 4}\" },\n \n # Gradually fading text\n item_label: ->(depth) { \"mx-3 text-gray-#{600 + (depth * 100)}\" },\n \n # Different icon styles per level\n icon: ->(depth) {\n base = \"h-5 w-5\"\n color = depth.zero? ? \"text-primary\" : \"text-gray-400\"\n [base, color]\n },\n \n # Smaller text at deeper levels\n item_link: ->(depth) {\n size = depth.zero? ? \"text-base\" : \"text-sm\"\n [\"flex items-center px-4 py-2 hover:bg-gray-50\", size]\n }\n })\n end\n end\nend\n```\n\nTheme values can be either:\n- Static strings for consistent styling\n- Arrays of classes that will be joined\n- Callables (procs/lambdas) that receive the current depth and return strings or arrays\n\n### Advanced Usage\n\n#### Component Customization\n\nYou can customize the nesting behavior by overriding the nested? method:\n\n```ruby\nclass CustomMenu < Phlexi::Menu::Component\n protected\n\n def nested?(item, depth)\n # Custom logic for when to treat items as nested\n return false if depth >= @max_depth - 1 # Reserve last level\n return false if item.items.empty? # No empty parents\n \n # Allow nesting only for items with certain attributes\n item.options[:allow_nesting]\n end\nend\n```\n\n\n\n### Badge Components\n\nBadges can be either strings or Phlex components:\n\n```ruby\nclass CustomBadgeComponent < ApplicationComponent\n def view_template\n div(class: \"flex items-center\") do\n span(class: \"h-2 w-2 rounded-full bg-blue-400\")\n span(class: \"ml-2\") { \"New\" }\n end\n end\nend\n\n# Usage\nm.item \"Products\", leading_badge: CustomBadgeComponent.new\n```\n\n### Rails Integration\n\nIn your controller:\n\n```ruby\nclass ApplicationController < ActionController::Base\n def navigation\n @navigation ||= Phlexi::Menu::Builder.new do |m|\n m.item \"Home\", \n url: root_path, \n icon: HomeIcon\n \n if user_signed_in?\n m.item \"Account\", \n url: account_path,\n trailing_badge: notifications_count do |account|\n account.item \"Profile\", url: profile_path\n account.item \"Settings\", url: settings_path\n account.item \"Logout\", url: logout_path\n end\n end\n\n if current_user&.admin?\n m.item \"Admin\", \n url: admin_path, \n leading_badge: \"Admin\"\n end\n end\n end\n helper_method :navigation\nend\n```\n\nNote: The menu component uses Rails' `current_page?` helper for default active state detection. If you're not using Rails or want custom active state logic, provide an `active` callable to your menu items:\n\n```ruby\nm.item \"Custom Active\", url: \"/path\", active: ->(context) {\n # Your custom active state logic here\n context.request.path.start_with?(\"/path\")\n}\n```\n\n## Advanced Usage\n\n### Component Customization\n\nYou can customize specific rendering steps:\n\n```ruby\nclass CustomMenu < Phlexi::Menu::Component\n # Override just what you need\n def render_item_interior(item)\n div(class: \"flex items-center gap-2\") do\n render_leading_badge(item.leading_badge) if item.leading_badge\n render_icon(item.icon) if item.icon\n span(class: themed(:item_label)) { item.label.upcase }\n render_trailing_badge(item.trailing_badge) if item.trailing_badge\n end\n end\n\n def render_leading_badge(badge)\n div(class: tokens(themed(:leading_badge), \"flex items-center\")) do\n span { \"●\" }\n span(class: \"ml-1\") { badge }\n end\n end\nend\n```\n\nThe component provides these customization points:\n- `render_items`: Handles collection of items and nesting\n- `render_item_wrapper`: Wraps individual items\n- `render_item_content`: Chooses between link and span rendering\n- `render_item_interior`: Handles the item's internal layout\n- `render_leading_badge`: Renders the leading badge\n- `render_trailing_badge`: Renders the trailing badge\n- `render_icon`: Renders the icon component\n\n### Dynamic Menus\n\nExample of building menus based on user permissions:\n\n```ruby\nPhlexi::Menu::Builder.new do |m|\n # Basic items\n m.item \"Home\", url: root_path\n \n # Authorization-based items\n if current_user.can?(:manage, :products)\n m.item \"Products\", url: products_path do |products|\n products.item \"All Products\", url: products_path\n products.item \"Categories\", url: categories_path if current_user.can?(:manage, :categories)\n products.item \"New Product\", url: new_product_path\n end\n end\n \n # Dynamic items from database\n current_user.organizations.each do |org|\n m.item org.name, url: organization_path(org), icon: OrgIcon\n end\nend\n```\n\n## Development\n\nAfter checking out the repo:\n\n1. Run `bin/setup` to install dependencies\n2. Run `bin/appraise install` to install appraisal gemfiles \n3. Run `bin/appraise rake test` to run the tests against all supported versions\n4. You can also run `bin/console` for an interactive prompt\n\nFor development against a single version, you can just use `bundle exec rake test`.\n\n## Contributing\n\nBug reports and pull requests are welcome on GitHub at https://github.com/radioactive-labs/phlexi-menu.\n\n1. Fork it\n2. Create your feature branch (`git checkout -b my-new-feature`)\n3. Commit your changes (`git commit -am 'Add some feature'`)\n4. Push to the branch (`git push origin my-new-feature`)\n5. Create new Pull Request\n\n## License\n\nThe gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).\n"
|
9
9
|
},
|
10
10
|
{
|
11
11
|
"path": "/Users/stefan/Documents/plutonium/phlexi-menu/export.rb",
|
@@ -13,23 +13,23 @@
|
|
13
13
|
},
|
14
14
|
{
|
15
15
|
"path": "/Users/stefan/Documents/plutonium/phlexi-menu/lib/phlexi/menu/builder.rb",
|
16
|
-
"contents": "# frozen_string_literal: true\n\nmodule Phlexi\n module Menu\n # Builder class for constructing hierarchical menu structures.\n # Provides a DSL for creating nested menu items with support for labels,\n # URLs, icons, and badges.\n #\n # @example Basic usage\n # menu = Phlexi::Menu::Builder.new do |m|\n # m.item \"Home\", url: \"/\"\n # m.item \"Products\", url: \"/products\" do |products|\n # products.item \"All Products\", url: \"/products\"\n # products.item \"Add Product\", url: \"/products/new\"\n # end\n # end\n class Builder\n # @return [Array<Phlexi::Menu::Item>] The collection of top-level menu items\n attr_reader :items\n\n # Nested Item class that inherits from Phlexi::Menu::Item\n class Item < Phlexi::Menu::Item; end\n\n # Initializes a new menu builder.\n #\n # @yield [builder] Passes the builder instance to the block for menu construction\n # @yieldparam builder [Phlexi::Menu::Builder] The builder instance\n def initialize(&)\n @items = []\n\n yield self if block_given?\n end\n\n # Creates and adds a new menu item to the current menu level.\n #\n # @param label [String] The display text for the menu item\n # @param ** [Hash] Additional options passed to the Item constructor\n # @yield [item] Optional block for adding nested menu items\n # @yieldparam item [Phlexi::Menu::Item] The newly created menu item\n # @return [Phlexi::Menu::Item] The created menu item\n # @raise [ArgumentError] If the label is nil\n def item(label, **, &)\n raise ArgumentError, \"Label cannot be nil\" unless label\n\n new_item = self.class::Item.new(label, **, &)\n @items << new_item\n new_item\n end\n\n #
|
16
|
+
"contents": "# frozen_string_literal: true\n\nmodule Phlexi\n module Menu\n # Builder class for constructing hierarchical menu structures.\n # Provides a DSL for creating nested menu items with support for labels,\n # URLs, icons, and badges.\n #\n # @example Basic usage\n # menu = Phlexi::Menu::Builder.new do |m|\n # m.item \"Home\", url: \"/\"\n # m.item \"Products\", url: \"/products\" do |products|\n # products.item \"All Products\", url: \"/products\"\n # products.item \"Add Product\", url: \"/products/new\"\n # end\n # end\n class Builder\n # @return [Array<Phlexi::Menu::Item>] The collection of top-level menu items\n attr_reader :items\n\n # Nested Item class that inherits from Phlexi::Menu::Item\n class Item < Phlexi::Menu::Item; end\n\n # Initializes a new menu builder.\n #\n # @yield [builder] Passes the builder instance to the block for menu construction\n # @yieldparam builder [Phlexi::Menu::Builder] The builder instance\n def initialize(&)\n @items = []\n\n yield self if block_given?\n end\n\n # Creates and adds a new menu item to the current menu level.\n #\n # @param label [String] The display text for the menu item\n # @param ** [Hash] Additional options passed to the Item constructor\n # @yield [item] Optional block for adding nested menu items\n # @yieldparam item [Phlexi::Menu::Item] The newly created menu item\n # @return [Phlexi::Menu::Item] The created menu item\n # @raise [ArgumentError] If the label is nil\n def item(label, **, &)\n raise ArgumentError, \"Label cannot be nil\" unless label\n\n new_item = self.class::Item.new(label, **, &)\n @items << new_item\n new_item\n end\n\n # Returns a string representation of the menu structure.\n #\n # @return [String] A human-readable representation of the menu\n def inspect\n \"#<#{self.class} items=#{@items.map(&:inspect)}>\"\n end\n end\n end\nend\n"
|
17
17
|
},
|
18
18
|
{
|
19
19
|
"path": "/Users/stefan/Documents/plutonium/phlexi-menu/lib/phlexi/menu/component.rb",
|
20
|
-
"contents": "# frozen_string_literal: true\n\nrequire \"phlex\"\n\nmodule Phlexi\n module Menu\n # Base menu component that other menu renderers can inherit from.\n # Provides the core rendering logic for hierarchical menus with support\n # for theming, icons, badges, and active state detection.\n #\n # @example Basic usage\n # class MyMenu < Phlexi::Menu::Component\n # class Theme < Theme\n # def self.theme\n # super.merge({\n # nav: \"bg-white shadow\",\n # item_label: \"text-gray
|
20
|
+
"contents": "# frozen_string_literal: true\n\nrequire \"phlex\"\n\nmodule Phlexi\n module Menu\n # Base menu component that other menu renderers can inherit from.\n # Provides the core rendering logic for hierarchical menus with support\n # for theming, icons, badges, and active state detection.\n #\n # @example Basic usage\n # class MyMenu < Phlexi::Menu::Component\n # class Theme < Theme\n # def self.theme\n # super.merge({\n # nav: \"bg-white shadow\",\n # item_label: ->(depth) { \"text-gray-#{600 + (depth * 100)}\" }\n # })\n # end\n # end\n # end\n class Component < COMPONENT_BASE\n # Theme class for customizing menu appearance\n class Theme < Phlexi::Menu::Theme; end\n\n # @return [Integer] The default maximum nesting depth for menu items\n DEFAULT_MAX_DEPTH = 3\n\n # Initializes a new menu component.\n #\n # @param menu [Phlexi::Menu::Builder] The menu structure to render\n # @param max_depth [Integer] Maximum nesting depth for menu items\n # @param options [Hash] Additional options passed to rendering methods\n def initialize(menu, max_depth: default_max_depth, **options)\n @menu = menu\n @max_depth = max_depth\n @options = options\n super()\n end\n\n def view_template\n nav(class: themed(:nav)) do\n render_items(@menu.items)\n end\n end\n\n protected\n\n # Renders a collection of menu items with nesting support.\n #\n # @param items [Array<Phlexi::Menu::Item>] The items to render\n # @param depth [Integer] Current nesting depth\n def render_items(items, depth = 0)\n return if depth >= @max_depth\n return if items.empty?\n\n ul(class: themed(:items_container, depth)) do\n items.each do |item|\n render_item_wrapper(item, depth)\n end\n end\n end\n\n # Renders the wrapper element for a menu item.\n #\n # @param item [Phlexi::Menu::Item] The item to wrap\n # @param depth [Integer] Current nesting depth\n def render_item_wrapper(item, depth)\n li(class: tokens(\n themed(:item_wrapper, depth),\n item_parent_class(item, depth),\n active?(item) ? themed(:active, depth) : nil\n )) do\n render_item_content(item, depth)\n render_items(item.items, depth + 1) if nested?(item, depth)\n end\n end\n\n # Renders the content of a menu item, choosing between link and span.\n #\n # @param item [Phlexi::Menu::Item] The item to render content for\n # @param depth [Integer] Current nesting depth\n def render_item_content(item, depth)\n if item.url\n render_item_link(item, depth)\n else\n render_item_span(item, depth)\n end\n end\n\n # Renders a menu item as a link.\n #\n # @param item [Phlexi::Menu::Item] The item to render as a link\n # @param depth [Integer] Current nesting depth\n def render_item_link(item, depth)\n a(\n href: item.url,\n class: tokens(\n themed(:item_link, depth),\n active_class(item, depth)\n )\n ) do\n render_item_interior(item, depth)\n end\n end\n\n # Renders a menu item as a span (for non-linking items).\n #\n # @param item [Phlexi::Menu::Item] The item to render as a span\n # @param depth [Integer] Current nesting depth\n def render_item_span(item, depth)\n span(class: themed(:item_span, depth)) do\n render_item_interior(item, depth)\n end\n end\n\n # Renders the interior content of a menu item (badges, icon, label).\n #\n # @param item [Phlexi::Menu::Item] The item to render interior content for\n # @param depth [Integer] Current nesting depth\n def render_item_interior(item, depth)\n render_leading_badge(item.leading_badge, depth) if item.leading_badge\n render_icon(item.icon, depth) if item.icon\n render_label(item.label, depth)\n render_trailing_badge(item.trailing_badge, depth) if item.trailing_badge\n end\n\n # Renders the item's label.\n #\n # @param label [String, Component] The label to render\n # @param depth [Integer] Current nesting depth\n def render_label(label, depth)\n phlexi_render(label) {\n span(class: themed(:item_label, depth)) { label }\n }\n end\n\n # Renders the item's leading badge.\n #\n # @param badge [String, Component] The leading badge to render\n # @param depth [Integer] Current nesting depth\n def render_leading_badge(badge, depth)\n phlexi_render(badge) {\n span(class: themed(:leading_badge, depth)) { badge }\n }\n end\n\n # Renders the item's trailing badge.\n #\n # @param badge [String, Component] The trailing badge to render\n # @param depth [Integer] Current nesting depth\n def render_trailing_badge(badge, depth)\n phlexi_render(badge) {\n span(class: themed(:trailing_badge, depth)) { badge }\n }\n end\n\n # Renders the item's icon.\n #\n # @param icon [Class] The icon component class to render\n # @param depth [Integer] Current nesting depth\n def render_icon(icon, depth)\n return unless icon\n\n div(class: themed(:icon_wrapper, depth)) do\n render icon.new(class: themed(:icon, depth))\n end\n end\n\n # Determines the active state class for an item.\n #\n # @param item [Phlexi::Menu::Item] The item to check active state for\n # @param depth [Integer] Current nesting depth\n # @return [String, nil] The active class name or nil\n def active_class(item, depth)\n active?(item) ? themed(:active, depth) : nil\n end\n\n # Helper method to check if an item is active\n #\n # @param item [Phlexi::Menu::Item] The item to check\n # @return [Boolean] Whether the item is active\n def active?(item)\n item.active?(self)\n end\n\n # Determines if an item should be treated as nested based on its contents\n # and the current depth relative to the maximum allowed depth.\n #\n # @param item [Phlexi::Menu::Item] The item to check\n # @param depth [Integer] Current nesting depth\n # @return [Boolean] Whether the item should be treated as nested\n def nested?(item, depth)\n has_children = item.items.any?\n within_depth = (depth + 1) < @max_depth\n has_children && within_depth\n end\n\n # Determines the parent state class for an item.\n #\n # @param item [Phlexi::Menu::Item] The item to check parent state for\n # @param depth [Integer] Current nesting depth\n # @return [String, nil] The parent class name or nil\n def item_parent_class(item, depth)\n nested?(item, depth) ? themed(:item_parent, depth) : nil\n end\n\n # Resolves a theme component to its CSS classes.\n #\n # @param component [Symbol] The theme component to resolve\n # @param depth [Integer] Current nesting depth\n # @return [String, nil] The resolved CSS classes or nil\n def themed(component, depth = 0)\n theme = self.class::Theme.instance.resolve_theme(component)\n return nil if theme.nil?\n return theme unless theme.respond_to?(:call)\n theme.call(depth)\n end\n\n # Renders either a component or simple value with fallback.\n #\n # @param arg [Object] The value to render\n # @yield The default rendering block\n # @raise [ArgumentError] If no block is provided\n def phlexi_render(arg, &)\n return unless arg\n raise ArgumentError, \"phlexi_render requires a default render block\" unless block_given?\n\n if arg.class < Phlex::SGML || arg.respond_to?(:render_in)\n render arg\n elsif arg.respond_to?(:to_proc)\n instance_exec(&arg)\n else\n yield\n end\n end\n\n def default_max_depth = self.class::DEFAULT_MAX_DEPTH\n end\n end\nend\n"
|
21
21
|
},
|
22
22
|
{
|
23
23
|
"path": "/Users/stefan/Documents/plutonium/phlexi-menu/lib/phlexi/menu/item.rb",
|
24
|
-
"contents": "# frozen_string_literal: true\n\nmodule Phlexi\n module Menu\n # Represents a single menu item in the navigation hierarchy.\n # Each item can have a label, URL, icon, badges, and nested child items.\n #\n # @example Basic menu item\n # item = Item.new(\"Home\", url: \"/\")\n #\n # @example Menu item with badges and icon\n # item = Item.new(\"Products\",\n # url: \"/products\",\n # icon: ProductIcon,\n # leading_badge: \"New\",\n # trailing_badge: \"5\")\n #\n # @example Nested menu items\n # item = Item.new(\"Admin\") do |admin|\n # admin.item \"Users\", url: \"/admin/users\"\n # admin.item \"Settings\", url: \"/admin/settings\"\n # end\n class Item\n # @return [String] The display text for the menu item\n attr_reader :label\n\n # @return [String, nil] The URL the menu item links to\n attr_reader :url\n\n # @return [Class, nil] The icon component class to be rendered\n attr_reader :icon\n\n # @return [String, Component, nil] The badge displayed before the label\n attr_reader :leading_badge\n\n # @return [String, Component, nil] The badge displayed after the label\n attr_reader :trailing_badge\n\n # @return [Array<Item>] Collection of nested menu items\n attr_reader :items\n\n # @return [Hash] Additional options for customizing the menu item\n attr_reader :options\n\n # Initializes a new menu item.\n #\n # @param label [String] The display text for the menu item\n # @param url [String, nil] The URL the menu item links to\n # @param icon [Class, nil] The icon component class\n # @param leading_badge [String, Component, nil] Badge displayed before the label\n # @param trailing_badge [String, Component, nil] Badge displayed after the label\n # @param options [Hash] Additional options (e.g., :active for custom active state logic)\n # @yield [item] Optional block for adding nested items\n # @yieldparam item [Item] The newly created menu item\n # @raise [ArgumentError] If the label is nil or empty\n def initialize(label, url: nil, icon: nil, leading_badge: nil, trailing_badge: nil, **options, &)\n raise ArgumentError, \"Label cannot be nil\" unless label\n\n @label = label\n @url = url\n @icon = icon\n @leading_badge = leading_badge\n @trailing_badge = trailing_badge\n @options = options\n @items = []\n\n yield self if block_given?\n end\n\n # Creates and adds a nested menu item.\n #\n # @param label [String] The display text for the nested item\n # @param ** [Hash] Additional options passed to the Item constructor\n # @yield [item] Optional block for adding further nested items\n # @yieldparam item [Item] The newly created nested item\n # @return [Item] The created nested item\n def item(label, **, &)\n new_item = self.class.new(label, **, &)\n @items << new_item\n new_item\n end\n\n # Determines if this menu item should be shown as active.\n # Checks in the following order:\n # 1. Custom active logic if provided in options\n # 2. URL match with current page\n # 3. Active state of any child items\n #\n # @param context [Object] The context object (typically a controller) for active state checking\n # @return [Boolean] true if the item should be shown as active, false otherwise\n def active?(context)\n # First check custom active logic if provided\n return @options[:active].call(context) if @options[:active].respond_to?(:call)\n\n # Then check if this item's URL matches current page\n if context.respond_to?(:helpers) && @url\n return true if context.helpers.current_page?(@url)\n end\n\n # Finally check if any child items are active\n @items.any? { |item| item.active?(context) }\n end\n\n #
|
24
|
+
"contents": "# frozen_string_literal: true\n\nmodule Phlexi\n module Menu\n # Represents a single menu item in the navigation hierarchy.\n # Each item can have a label, URL, icon, badges, and nested child items.\n #\n # @example Basic menu item\n # item = Item.new(\"Home\", url: \"/\")\n #\n # @example Menu item with badges and icon\n # item = Item.new(\"Products\",\n # url: \"/products\",\n # icon: ProductIcon,\n # leading_badge: \"New\",\n # trailing_badge: \"5\")\n #\n # @example Nested menu items\n # item = Item.new(\"Admin\") do |admin|\n # admin.item \"Users\", url: \"/admin/users\"\n # admin.item \"Settings\", url: \"/admin/settings\"\n # end\n class Item\n # @return [String] The display text for the menu item\n attr_reader :label\n\n # @return [String, nil] The URL the menu item links to\n attr_reader :url\n\n # @return [Class, nil] The icon component class to be rendered\n attr_reader :icon\n\n # @return [String, Component, nil] The badge displayed before the label\n attr_reader :leading_badge\n\n # @return [String, Component, nil] The badge displayed after the label\n attr_reader :trailing_badge\n\n # @return [Array<Item>] Collection of nested menu items\n attr_reader :items\n\n # @return [Hash] Additional options for customizing the menu item\n attr_reader :options\n\n # Initializes a new menu item.\n #\n # @param label [String] The display text for the menu item\n # @param url [String, nil] The URL the menu item links to\n # @param icon [Class, nil] The icon component class\n # @param leading_badge [String, Component, nil] Badge displayed before the label\n # @param trailing_badge [String, Component, nil] Badge displayed after the label\n # @param options [Hash] Additional options (e.g., :active for custom active state logic)\n # @yield [item] Optional block for adding nested items\n # @yieldparam item [Item] The newly created menu item\n # @raise [ArgumentError] If the label is nil or empty\n def initialize(label, url: nil, icon: nil, leading_badge: nil, trailing_badge: nil, **options, &)\n raise ArgumentError, \"Label cannot be nil\" unless label\n\n @label = label\n @url = url\n @icon = icon\n @leading_badge = leading_badge\n @trailing_badge = trailing_badge\n @options = options\n @items = []\n\n yield self if block_given?\n end\n\n # Creates and adds a nested menu item.\n #\n # @param label [String] The display text for the nested item\n # @param ** [Hash] Additional options passed to the Item constructor\n # @yield [item] Optional block for adding further nested items\n # @yieldparam item [Item] The newly created nested item\n # @return [Item] The created nested item\n def item(label, **, &)\n new_item = self.class.new(label, **, &)\n @items << new_item\n new_item\n end\n\n # Determines if this menu item should be shown as active.\n # Checks in the following order:\n # 1. Custom active logic if provided in options\n # 2. URL match with current page\n # 3. Active state of any child items\n #\n # @param context [Object] The context object (typically a controller) for active state checking\n # @return [Boolean] true if the item should be shown as active, false otherwise\n def active?(context)\n # First check custom active logic if provided\n return @options[:active].call(context) if @options[:active].respond_to?(:call)\n\n # Then check if this item's URL matches current page\n if context.respond_to?(:helpers) && @url\n return true if context.helpers.current_page?(@url)\n end\n\n # Finally check if any child items are active\n @items.any? { |item| item.active?(context) }\n end\n\n # Returns a string representation of the menu item.\n #\n # @return [String] A human-readable representation of the menu item\n def inspect\n \"#<#{self.class} label=#{@label} url=#{@url} items=#{@items.map(&:inspect)}>\"\n end\n end\n end\nend\n"
|
25
25
|
},
|
26
26
|
{
|
27
27
|
"path": "/Users/stefan/Documents/plutonium/phlexi-menu/lib/phlexi/menu/theme.rb",
|
28
|
-
"contents": "require \"phlexi-field\"\n\nmodule Phlexi\n module Menu\n class Theme < Phlexi::Field::Theme\n def self.theme\n @theme ||= {\n nav: nil
|
28
|
+
"contents": "require \"phlexi-field\"\n\nmodule Phlexi\n module Menu\n class Theme < Phlexi::Field::Theme\n # Defines the default theme structure with nil values\n # Can be overridden in subclasses to provide custom styling\n #\n # @return [Hash] Default theme structure with nil values\n def self.theme\n @theme ||= {\n # Container elements\n nav: nil, # Navigation wrapper\n items_container: nil, # <ul> list container\n\n # Item structure elements\n item_wrapper: nil, # <li> item wrapper\n item_parent: nil, # Additional class for items with visible children\n item_link: nil, # <a> for clickable items\n item_span: nil, # <span> for non-clickable items\n item_label: nil, # Label text wrapper\n\n # Interactive states\n active: nil, # Active/selected state\n hover: nil, # Hover state\n\n # Badge elements\n leading_badge: nil, # Badge before label\n trailing_badge: nil, # Badge after label\n\n # Icon elements\n icon: nil, # Icon styling\n icon_wrapper: nil # Icon container\n }.freeze\n end\n end\n end\nend\n"
|
29
29
|
},
|
30
30
|
{
|
31
31
|
"path": "/Users/stefan/Documents/plutonium/phlexi-menu/lib/phlexi/menu/version.rb",
|
32
|
-
"contents": "# frozen_string_literal: true\n\nmodule Phlexi\n module Menu\n VERSION = \"0.0
|
32
|
+
"contents": "# frozen_string_literal: true\n\nmodule Phlexi\n module Menu\n VERSION = \"0.1.0\"\n end\nend\n"
|
33
33
|
},
|
34
34
|
{
|
35
35
|
"path": "/Users/stefan/Documents/plutonium/phlexi-menu/lib/phlexi/menu.rb",
|
@@ -73,7 +73,7 @@
|
|
73
73
|
},
|
74
74
|
{
|
75
75
|
"path": "/Users/stefan/Documents/plutonium/phlexi-menu/test/phlexi/menu_test.rb",
|
76
|
-
"contents": "# frozen_string_literal: true\n\nrequire \"test_helper\"\n\nmodule Phlexi\n class MenuTest < Minitest::Test\n include Capybara::DSL\n include Phlex::Testing::Capybara::ViewHelper\n\n class TestIcon < Phlex::HTML\n def initialize(**attributes)\n @attributes = attributes\n super()\n end\n\n def view_template\n div(**@attributes) { \"Test Icon\" }\n end\n end\n\n class TestComponent < Phlex::HTML\n def initialize(**attributes)\n @attributes = attributes\n super()\n end\n\n def view_template\n div(**@attributes) { \"Test Component\" }\n end\n end\n\n class CustomThemeMenu < Phlexi::Menu::Component\n class Theme < Theme\n def self.theme\n super.merge({\n nav: \"custom-nav\",\n item_label: \"custom-label\"\n })\n end\n end\n end\n\n class TestMenu < Phlexi::Menu::Component\n class Theme < Theme\n def self.theme\n super.merge({\n nav: \"test-nav\",\n items_container: \"test-items\",\n item_wrapper: \"test-item\",\n item_parent: \"test-parent\",\n item_link: \"test-link\",\n item_span: \"test-span\",\n item_label: \"test-label\",\n leading_badge: \"test-leading-badge\",\n trailing_badge: \"test-trailing-badge\",\n icon: \"test-icon\",\n active: \"test-active\"\n })\n end\n end\n end\n\n class MockContext\n class MockHelpers\n def initialize(current_page_path)\n @current_page_path = current_page_path\n end\n\n def current_page?(path)\n path == @current_page_path\n end\n end\n\n class MockRequest\n attr_reader :path\n\n def initialize(path)\n @path = path\n end\n end\n\n attr_reader :request_path, :current_page_path\n\n def initialize(request_path: \"/\", current_page_path: \"/\")\n @request_path = request_path\n @current_page_path = current_page_path\n end\n\n def request\n @request ||= MockRequest.new(@request_path)\n end\n\n def helpers\n @helpers ||= MockHelpers.new(@current_page_path)\n end\n end\n\n def setup\n @menu = Phlexi::Menu::Builder.new do |m|\n m.item \"Home\",\n url: \"/\",\n icon: TestIcon,\n leading_badge: \"New\",\n trailing_badge: \"2\"\n\n m.item \"Products\",\n url: \"/products\" do |products|\n products.item \"All Products\",\n url: \"/products\",\n leading_badge: TestComponent.new\n products.item \"Add Product\",\n url: \"/products/new\"\n end\n\n m.item \"Settings\",\n url: \"/settings\",\n active: ->(context) { context.respond_to?(:request) && context.request.path.start_with?(\"/settings\") }\n end\n end\n\n def test_menu_structure\n assert_equal 3, @menu.items.length\n\n # Test first level items\n home = @menu.items[0]\n assert_equal \"Home\", home.label\n assert_equal \"/\", home.url\n assert_equal TestIcon, home.icon\n assert_equal \"New\", home.leading_badge\n assert_equal \"2\", home.trailing_badge\n assert_empty home.items\n\n # Test nested items\n products = @menu.items[1]\n assert_equal \"Products\", products.label\n assert_equal \"/products\", products.url\n assert_equal 2, products.items.length\n\n # Test nested item properties\n all_products = products.items[0]\n assert_equal \"All Products\", all_products.label\n assert_equal \"/products\", all_products.url\n # Compare the class of the component instance instead of direct class comparison\n assert_instance_of TestComponent, all_products.leading_badge\n end\n\n def test_menu_rendering\n render TestMenu.new(@menu)\n\n # <nav class=\"test-nav\"><ul class=\"test-items\"><li class=\"test-item\"><a href=\"/\" class=\"test-link\"><span class=\"test-leading-badge\">New</span><div><div class=\"test-icon\">Test Icon</div></div><span class=\"test-label\">Home</span><span class=\"test-trailing-badge\">2</span></a></li><li class=\"test-item test-parent\"><a href=\"/products\" class=\"test-link\"><span class=\"test-label\">Products</span></a><ul class=\"test-items\"><li class=\"test-item\"><a href=\"/products\" class=\"test-link\"><div>Test Component</div><span class=\"test-label\">All Products</span></a></li><li class=\"test-item\"><a href=\"/products/new\" class=\"test-link\"><span class=\"test-label\">Add Product</span></a></li></ul></li><li class=\"test-item\"><a href=\"/settings\" class=\"test-link\"><span class=\"test-label\">Settings</span></a></li></ul></nav>\n\n # Test basic structure\n assert has_css?(\".test-nav\")\n assert has_css?(\".test-items\")\n\n # Test top-level items count\n assert_equal 3, all(\".test-nav > .test-items > .test-item\", minimum: 0).count\n\n # Test Home item structure and content\n assert has_css?(\".test-nav > .test-items > .test-item:first-child .test-link[href='/']\")\n assert has_css?(\".test-nav > .test-items > .test-item:first-child .test-leading-badge\", text: \"New\")\n assert has_css?(\".test-nav > .test-items > .test-item:first-child .test-icon\", text: \"Test Icon\")\n assert has_css?(\".test-nav > .test-items > .test-item:first-child .test-label\", text: \"Home\")\n assert has_css?(\".test-nav > .test-items > .test-item:first-child .test-trailing-badge\", text: \"2\")\n\n # Test Products item and its nested structure\n products_item = \".test-nav > .test-items > .test-item:nth-child(2)\"\n assert has_css?(\"#{products_item} .test-link[href='/products']\")\n assert has_css?(\"#{products_item} .test-label\", text: \"Products\")\n assert has_css?(\"#{products_item}.test-parent\")\n\n # Test nested items under Products\n assert_equal 2, all(\"#{products_item} > .test-items > .test-item\", minimum: 0).count\n\n # Test All Products item\n all_products = \"#{products_item} > .test-items > .test-item:first-child\"\n assert has_css?(\"#{all_products} .test-link[href='/products']\")\n # Changed to look for the actual rendered component output\n assert has_css?(\"#{all_products} .test-link div\", text: \"Test Component\")\n assert has_css?(\"#{all_products} .test-label\", text: \"All Products\")\n\n # Test Add Product item\n add_product = \"#{products_item} > .test-items > .test-item:last-child\"\n assert has_css?(\"#{add_product} .test-link[href='/products/new']\")\n assert has_css?(\"#{add_product} .test-label\", text: \"Add Product\")\n\n # Test Settings item\n settings_item = \".test-nav > .test-items > .test-item:last-child\"\n assert has_css?(\"#{settings_item} .test-link[href='/settings']\")\n assert has_css?(\"#{settings_item} .test-label\", text: \"Settings\")\n end\n\n def test_active_state_detection\n # Test direct URL match\n mock_context = MockContext.new(\n request_path: \"/\",\n current_page_path: \"/\"\n )\n assert @menu.items[0].active?(mock_context), \"Home item should be active when current page matches\"\n\n # Test custom active logic\n mock_context = MockContext.new(\n request_path: \"/settings/profile\",\n current_page_path: \"/other\"\n )\n assert @menu.items[2].active?(mock_context), \"Settings should be active when path starts with /settings\"\n\n # Test parent active state through child URL match\n mock_context = MockContext.new(\n request_path: \"/other\",\n current_page_path: \"/products/new\"\n )\n assert @menu.items[1].active?(mock_context), \"Products menu should be active when a child URL matches\"\n\n # Test direct child URL match\n mock_context = MockContext.new(\n request_path: \"/products\",\n current_page_path: \"/products\"\n )\n assert @menu.items[1].items[0].active?(mock_context), \"Child item should be active when its URL matches\"\n\n # Test parent isn't active when URLs don't match\n mock_context = MockContext.new(\n request_path: \"/other\",\n current_page_path: \"/other\"\n )\n refute @menu.items[1].active?(mock_context), \"Products menu should not be active when no URLs match\"\n end\n\n def test_max_depth_rendering\n deep_menu = Phlexi::Menu::Builder.new do |m|\n m.item \"Level 1\" do |l1|\n l1.item \"Level 2\" do |l2|\n l2.item \"Level 3\" do |l3|\n l3.item \"Level 4\"\n end\n end\n end\n end\n\n # Test default max depth (3)\n render TestMenu.new(deep_menu)\n\n assert has_css?(\".test-label\", text: \"Level 1\")\n assert has_css?(\".test-label\", text: \"Level 2\")\n assert has_css?(\".test-label\", text: \"Level 3\")\n refute has_css?(\".test-label\", text: \"Level 4\")\n\n # Test custom max depth\n render TestMenu.new(deep_menu, max_depth: 2)\n\n assert has_css?(\".test-label\", text: \"Level 1\")\n assert has_css?(\".test-label\", text: \"Level 2\")\n refute has_css?(\".test-label\", text: \"Level 3\")\n end\n\n def test_component_rendering\n menu = Phlexi::Menu::Builder.new do |m|\n m.item \"Item\",\n leading_badge: TestComponent.new,\n trailing_badge: TestComponent.new\n end\n\n render TestMenu.new(menu)\n\n # <nav class=\"test-nav\">\n # <ul class=\"test-items\">\n # <li class=\"test-item\">\n # <span class=\"test-span\">\n # <div>Test Component</div>\n # <span class=\"test-label\">Item</span>\n # <div>Test Component</div>\n # </span>\n # </li>\n # </ul>\n # </nav>\n\n # Check the number of TestComponent instances\n assert_equal 2, all(\"div\", text: \"Test Component\", minimum: 0).count\n\n # Check the label exists with correct text\n assert has_css?(\".test-label\", text: \"Item\")\n end\n\n def test_theme_customization\n render CustomThemeMenu.new(@menu)\n\n # Test basic theme customization\n assert has_css?(\".custom-nav\")\n\n # Test specific label presence\n assert has_css?(\".custom-label\", text: \"Home\")\n assert has_css?(\".custom-label\", text: \"Products\")\n assert has_css?(\".custom-label\", text: \"Settings\")\n\n # Test label count\n assert_equal 5, all(\".custom-label\", minimum: 0).count\n end\n end\nend\n"
|
76
|
+
"contents": "# frozen_string_literal: true\n\nrequire \"test_helper\"\n\nmodule Phlexi\n class MenuTest < Minitest::Test\n include Capybara::DSL\n include Phlex::Testing::Capybara::ViewHelper\n\n class TestIcon < Phlex::HTML\n def initialize(**attributes)\n @attributes = attributes\n super()\n end\n\n def view_template\n div(**@attributes) { \"Test Icon\" }\n end\n end\n\n class TestComponent < Phlex::HTML\n def initialize(**attributes)\n @attributes = attributes\n super()\n end\n\n def view_template\n div(**@attributes) { \"Test Component\" }\n end\n end\n\n class CustomThemeMenu < Phlexi::Menu::Component\n class Theme < Theme\n def self.theme\n super.merge({\n nav: \"custom-nav\",\n item_label: \"custom-label\"\n })\n end\n end\n end\n\n class DepthAwareMenu < Phlexi::Menu::Component\n class Theme < Theme\n def self.theme\n super.merge({\n nav: \"depth-nav\",\n items_container: \"depth-items\",\n item_wrapper: ->(depth) { [\"depth-item\", \"depth-#{depth}\"] },\n item_link: ->(depth) { [\"depth-link\", depth.zero? ? \"root\" : \"nested\"] },\n item_label: ->(depth) { [\"depth-label\", \"level-#{depth}\"] },\n icon: ->(depth) { [\"depth-icon\", depth.zero? ? \"primary\" : \"secondary\"] },\n leading_badge: ->(depth) { [\"depth-leading-badge\", \"indent-#{depth}\"] },\n trailing_badge: ->(depth) { [\"depth-trailing-badge\", \"offset-#{depth}\"] },\n active: ->(depth) { [\"depth-active\", \"highlight-#{depth}\"] }\n })\n end\n end\n end\n\n # Define a menu component with various types of callable theme values\n class CallableThemeMenu < Phlexi::Menu::Component\n class Theme < Theme\n def self.theme\n super.merge({\n # Lambda returning string\n item_label: ->(depth) { \"depth-#{depth}-label\" },\n\n # Lambda returning array\n item_wrapper: ->(depth) { [\"wrapper\", \"level-#{depth}\"] },\n\n # Lambda with conditional logic\n item_link: ->(depth) {\n depth.zero? ? \"root-link\" : [\"nested-link\", \"indent-#{depth}\"]\n },\n\n # Static string (non-callable)\n nav: \"static-nav\"\n })\n end\n end\n end\n\n class TestMenu < Phlexi::Menu::Component\n class Theme < Theme\n def self.theme\n super.merge({\n nav: \"test-nav\",\n items_container: \"test-items\",\n item_wrapper: \"test-item\",\n item_parent: \"test-parent\",\n item_link: \"test-link\",\n item_span: \"test-span\",\n item_label: \"test-label\",\n leading_badge: \"test-leading-badge\",\n trailing_badge: \"test-trailing-badge\",\n icon: \"test-icon\",\n icon_wrapper: \"test-icon-wrapper\",\n active: \"test-active\",\n hover: \"test-hover\"\n })\n end\n end\n end\n\n class MockContext\n class MockHelpers\n def initialize(current_page_path)\n @current_page_path = current_page_path\n end\n\n def current_page?(path)\n path == @current_page_path\n end\n end\n\n class MockRequest\n attr_reader :path\n\n def initialize(path)\n @path = path\n end\n end\n\n attr_reader :request_path, :current_page_path\n\n def initialize(request_path: \"/\", current_page_path: \"/\")\n @request_path = request_path\n @current_page_path = current_page_path\n end\n\n def request\n @request ||= MockRequest.new(@request_path)\n end\n\n def helpers\n @helpers ||= MockHelpers.new(@current_page_path)\n end\n end\n\n def setup\n @menu = Phlexi::Menu::Builder.new do |m|\n m.item \"Home\",\n url: \"/\",\n icon: TestIcon,\n leading_badge: \"New\",\n trailing_badge: \"2\"\n\n m.item \"Products\",\n url: \"/products\" do |products|\n products.item \"All Products\",\n url: \"/products\",\n leading_badge: TestComponent.new\n products.item \"Add Product\",\n url: \"/products/new\"\n end\n\n m.item \"Settings\",\n url: \"/settings\",\n active: ->(context) { context.respond_to?(:request) && context.request.path.start_with?(\"/settings\") }\n end\n end\n\n def test_menu_structure\n assert_equal 3, @menu.items.length\n\n # Test first level items\n home = @menu.items[0]\n assert_equal \"Home\", home.label\n assert_equal \"/\", home.url\n assert_equal TestIcon, home.icon\n assert_equal \"New\", home.leading_badge\n assert_equal \"2\", home.trailing_badge\n assert_empty home.items\n\n # Test nested items\n products = @menu.items[1]\n assert_equal \"Products\", products.label\n assert_equal \"/products\", products.url\n assert_equal 2, products.items.length\n\n # Test nested item properties\n all_products = products.items[0]\n assert_equal \"All Products\", all_products.label\n assert_equal \"/products\", all_products.url\n # Compare the class of the component instance instead of direct class comparison\n assert_instance_of TestComponent, all_products.leading_badge\n end\n\n def test_menu_rendering\n render TestMenu.new(@menu)\n\n # <nav class=\"test-nav\"><ul class=\"test-items\"><li class=\"test-item\"><a href=\"/\" class=\"test-link\"><span class=\"test-leading-badge\">New</span><div><div class=\"test-icon\">Test Icon</div></div><span class=\"test-label\">Home</span><span class=\"test-trailing-badge\">2</span></a></li><li class=\"test-item test-parent\"><a href=\"/products\" class=\"test-link\"><span class=\"test-label\">Products</span></a><ul class=\"test-items\"><li class=\"test-item\"><a href=\"/products\" class=\"test-link\"><div>Test Component</div><span class=\"test-label\">All Products</span></a></li><li class=\"test-item\"><a href=\"/products/new\" class=\"test-link\"><span class=\"test-label\">Add Product</span></a></li></ul></li><li class=\"test-item\"><a href=\"/settings\" class=\"test-link\"><span class=\"test-label\">Settings</span></a></li></ul></nav>\n\n # Test basic structure\n assert has_css?(\".test-nav\")\n assert has_css?(\".test-items\")\n\n # Test top-level items count\n assert_equal 3, all(\".test-nav > .test-items > .test-item\", minimum: 0).count\n\n # Test Home item structure and content\n assert has_css?(\".test-nav > .test-items > .test-item:first-child .test-link[href='/']\")\n assert has_css?(\".test-nav > .test-items > .test-item:first-child .test-leading-badge\", text: \"New\")\n assert has_css?(\".test-nav > .test-items > .test-item:first-child .test-icon\", text: \"Test Icon\")\n assert has_css?(\".test-nav > .test-items > .test-item:first-child .test-label\", text: \"Home\")\n assert has_css?(\".test-nav > .test-items > .test-item:first-child .test-trailing-badge\", text: \"2\")\n\n # Test Products item and its nested structure\n products_item = \".test-nav > .test-items > .test-item:nth-child(2)\"\n assert has_css?(\"#{products_item} .test-link[href='/products']\")\n assert has_css?(\"#{products_item} .test-label\", text: \"Products\")\n assert has_css?(\"#{products_item}.test-parent\")\n\n # Test nested items under Products\n assert_equal 2, all(\"#{products_item} > .test-items > .test-item\", minimum: 0).count\n\n # Test All Products item\n all_products = \"#{products_item} > .test-items > .test-item:first-child\"\n assert has_css?(\"#{all_products} .test-link[href='/products']\")\n # Changed to look for the actual rendered component output\n assert has_css?(\"#{all_products} .test-link div\", text: \"Test Component\")\n assert has_css?(\"#{all_products} .test-label\", text: \"All Products\")\n\n # Test Add Product item\n add_product = \"#{products_item} > .test-items > .test-item:last-child\"\n assert has_css?(\"#{add_product} .test-link[href='/products/new']\")\n assert has_css?(\"#{add_product} .test-label\", text: \"Add Product\")\n\n # Test Settings item\n settings_item = \".test-nav > .test-items > .test-item:last-child\"\n assert has_css?(\"#{settings_item} .test-link[href='/settings']\")\n assert has_css?(\"#{settings_item} .test-label\", text: \"Settings\")\n end\n\n def test_active_state_detection\n # Test direct URL match\n mock_context = MockContext.new(\n request_path: \"/\",\n current_page_path: \"/\"\n )\n assert @menu.items[0].active?(mock_context), \"Home item should be active when current page matches\"\n\n # Test custom active logic\n mock_context = MockContext.new(\n request_path: \"/settings/profile\",\n current_page_path: \"/other\"\n )\n assert @menu.items[2].active?(mock_context), \"Settings should be active when path starts with /settings\"\n\n # Test parent active state through child URL match\n mock_context = MockContext.new(\n request_path: \"/other\",\n current_page_path: \"/products/new\"\n )\n assert @menu.items[1].active?(mock_context), \"Products menu should be active when a child URL matches\"\n\n # Test direct child URL match\n mock_context = MockContext.new(\n request_path: \"/products\",\n current_page_path: \"/products\"\n )\n assert @menu.items[1].items[0].active?(mock_context), \"Child item should be active when its URL matches\"\n\n # Test parent isn't active when URLs don't match\n mock_context = MockContext.new(\n request_path: \"/other\",\n current_page_path: \"/other\"\n )\n refute @menu.items[1].active?(mock_context), \"Products menu should not be active when no URLs match\"\n end\n\n def test_max_depth_rendering\n deep_menu = Phlexi::Menu::Builder.new do |m|\n m.item \"Level 1\" do |l1|\n l1.item \"Level 2\" do |l2|\n l2.item \"Level 3\" do |l3|\n l3.item \"Level 4\"\n end\n end\n end\n end\n\n # Test default max depth (3)\n render TestMenu.new(deep_menu)\n\n # Check rendered items\n assert has_css?(\".test-label\", text: \"Level 1\")\n assert has_css?(\".test-label\", text: \"Level 2\")\n assert has_css?(\".test-label\", text: \"Level 3\")\n refute has_css?(\".test-label\", text: \"Level 4\")\n\n # Check parent classes\n assert has_css?(\".test-item:first-child.test-parent\") # Level 1 should have parent class\n assert has_css?(\".test-item .test-item:first-child.test-parent\") # Level 2 should have parent class\n refute has_css?(\".test-item .test-item .test-item.test-parent\") # Level 3 shouldn't have parent class\n\n # Test custom max depth\n render TestMenu.new(deep_menu, max_depth: 2)\n\n # Check rendered items\n assert has_css?(\".test-label\", text: \"Level 1\")\n assert has_css?(\".test-label\", text: \"Level 2\")\n refute has_css?(\".test-label\", text: \"Level 3\")\n\n # Check parent classes with custom depth\n assert has_css?(\".test-item:first-child.test-parent\") # Level 1 should have parent class\n refute has_css?(\".test-item .test-item.test-parent\") # Level 2 shouldn't have parent class\n end\n\n def test_depth_limited_nesting\n menu = Phlexi::Menu::Builder.new do |m|\n m.item \"Root\" do |root|\n root.item \"Child\" do |child|\n child.item \"Grandchild\" do |grand|\n grand.item \"Great-grandchild\"\n end\n end\n end\n end\n\n # Render with max_depth of 2\n render TestMenu.new(menu, max_depth: 2)\n\n # Check items rendered\n assert has_css?(\".test-label\", text: \"Root\")\n assert has_css?(\".test-label\", text: \"Child\")\n refute has_css?(\".test-label\", text: \"Grandchild\")\n refute has_css?(\".test-label\", text: \"Great-grandchild\")\n\n # Check parent classes based on renderable children\n assert has_css?(\".test-item:first-child.test-parent\") # Root should have parent class\n refute has_css?(\".test-item .test-item.test-parent\") # Child shouldn't have parent class\n end\n\n def test_nested_state_with_depth_limit\n menu = Phlexi::Menu::Builder.new do |m|\n m.item \"A\" do |a| # depth 0\n a.item \"B\" do |b| # depth 1\n b.item \"C\" # depth 2\n end\n end\n end\n\n # Test parent classes at each max_depth\n {\n 1 => {\n root: false, # A can't be nested because depth 1 is max\n child: false # B won't be rendered at all\n },\n 2 => {\n root: true, # A can be nested because B will be rendered\n child: false # B can't be nested because depth 2 is max\n },\n 3 => {\n root: true, # A can be nested because B will be rendered\n child: true # B can be nested because C will be rendered\n }\n }.each do |max_depth, expected|\n component = TestMenu.new(menu, max_depth: max_depth)\n render component\n\n # Check root level (A)\n if expected[:root]\n assert has_css?(\"li.test-item.test-parent\", text: \"A\"),\n \"At max_depth #{max_depth}, root should have parent class\"\n else\n refute has_css?(\"li.test-item.test-parent\", text: \"A\"),\n \"At max_depth #{max_depth}, root should not have parent class\"\n end\n\n # Only check child level (B) if it should be rendered\n if max_depth > 1\n if expected[:child]\n assert has_css?(\"li.test-item.test-parent li.test-item.test-parent\", text: \"B\"),\n \"At max_depth #{max_depth}, child should have parent class\"\n else\n refute has_css?(\"li.test-item.test-parent li.test-item.test-parent\", text: \"B\"),\n \"At max_depth #{max_depth}, child should not have parent class\"\n end\n end\n end\n end\n\n def test_component_rendering\n menu = Phlexi::Menu::Builder.new do |m|\n m.item \"Item\",\n leading_badge: TestComponent.new,\n trailing_badge: TestComponent.new\n end\n\n render TestMenu.new(menu)\n\n # <nav class=\"test-nav\">\n # <ul class=\"test-items\">\n # <li class=\"test-item\">\n # <span class=\"test-span\">\n # <div>Test Component</div>\n # <span class=\"test-label\">Item</span>\n # <div>Test Component</div>\n # </span>\n # </li>\n # </ul>\n # </nav>\n\n # Check the number of TestComponent instances\n assert_equal 2, all(\"div\", text: \"Test Component\", minimum: 0).count\n\n # Check the label exists with correct text\n assert has_css?(\".test-label\", text: \"Item\")\n end\n\n def test_theme_customization\n render CustomThemeMenu.new(@menu)\n\n # Test basic theme customization\n assert has_css?(\".custom-nav\")\n\n # Test specific label presence\n assert has_css?(\".custom-label\", text: \"Home\")\n assert has_css?(\".custom-label\", text: \"Products\")\n assert has_css?(\".custom-label\", text: \"Settings\")\n\n # Test label count\n assert_equal 5, all(\".custom-label\", minimum: 0).count\n end\n\n def test_depth_aware_theming\n # Create a deeply nested menu for testing\n deep_menu = Phlexi::Menu::Builder.new do |m|\n m.item \"Root\",\n url: \"/\",\n icon: TestIcon,\n leading_badge: \"New\",\n trailing_badge: \"1\" do |root|\n root.item \"Level 1\", url: \"/level1\" do |l1|\n l1.item \"Level 2\",\n url: \"/level2\",\n icon: TestIcon,\n leading_badge: \"Beta\",\n trailing_badge: \"2\"\n end\n end\n end\n\n render DepthAwareMenu.new(deep_menu)\n\n # Test root level (depth 0) classes\n root = \".depth-nav > .depth-items > li:first-child\"\n assert has_css?(\"#{root}.depth-item.depth-0\")\n assert has_css?(\"#{root} a.depth-link.root\")\n assert has_css?(\"#{root} span.depth-label.level-0\", text: \"Root\")\n assert has_css?(\"#{root} div.depth-icon.primary\")\n assert has_css?(\"#{root} span.depth-leading-badge.indent-0\", text: \"New\")\n assert has_css?(\"#{root} span.depth-trailing-badge.offset-0\", text: \"1\")\n\n # Test level 1 classes\n level1 = \"#{root} > .depth-items > li:first-child\"\n assert has_css?(\"#{level1}.depth-item.depth-1\")\n assert has_css?(\"#{level1} a.depth-link.nested\")\n assert has_css?(\"#{level1} span.depth-label.level-1\", text: \"Level 1\")\n\n # Test level 2 classes\n level2 = \"#{level1} > .depth-items > li:first-child\"\n assert has_css?(\"#{level2}.depth-item.depth-2\")\n assert has_css?(\"#{level2} a.depth-link.nested\")\n assert has_css?(\"#{level2} span.depth-label.level-2\", text: \"Level 2\")\n assert has_css?(\"#{level2} div.depth-icon.secondary\")\n assert has_css?(\"#{level2} span.depth-leading-badge.indent-2\", text: \"Beta\")\n assert has_css?(\"#{level2} span.depth-trailing-badge.offset-2\", text: \"2\")\n end\n\n def test_depth_aware_active_state\n menu = Phlexi::Menu::Builder.new do |m|\n m.item \"Root\", url: \"/\" do |root|\n root.item \"Child\", url: \"/child\" do |child|\n child.item \"Grandchild\", url: \"/child/grand\"\n end\n end\n end\n\n # Mock context that considers \"/child/grand\" as current page\n mock_context = MockContext.new(\n request_path: \"/child/grand\",\n current_page_path: \"/child/grand\"\n )\n\n # Create a component instance with mock context\n component = DepthAwareMenu.new(menu)\n\n # Add helper methods to allow active state checking\n component.define_singleton_method(:helpers) { mock_context.helpers }\n component.define_singleton_method(:request) { mock_context.request }\n\n # Render the component\n render component\n\n # Render with depth-aware theme\n menu_component = DepthAwareMenu.new(menu)\n menu_component.define_singleton_method(:helpers) { mock_context.helpers }\n render menu_component\n\n # Test active classes at each depth\n assert has_css?(\".depth-item.depth-0 .depth-active.highlight-0\") # Root\n assert has_css?(\".depth-item.depth-1 .depth-active.highlight-1\") # Child\n assert has_css?(\".depth-item.depth-2 .depth-active.highlight-2\") # Grandchild\n end\n\n def test_callable_theme_values\n menu = Phlexi::Menu::Builder.new do |m|\n m.item \"Test\", url: \"/\" do |root|\n root.item \"Nested\", url: \"/nested\"\n end\n end\n\n render CallableThemeMenu.new(menu)\n\n # Test static theme value\n assert has_css?(\".static-nav\")\n\n # Test string-returning lambda\n assert has_css?(\".depth-0-label\", text: \"Test\")\n assert has_css?(\".depth-1-label\", text: \"Nested\")\n\n # Test array-returning lambda\n assert has_css?(\".wrapper.level-0\")\n assert has_css?(\".wrapper.level-1\")\n\n # Test conditional lambda\n assert has_css?(\".root-link\")\n assert has_css?(\".nested-link.indent-1\")\n end\n end\nend\n"
|
77
77
|
},
|
78
78
|
{
|
79
79
|
"path": "/Users/stefan/Documents/plutonium/phlexi-menu/test/test_helper.rb",
|