phlexi-menu 0.0.3 → 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.
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 # Checks if the menu has any items.\n #\n # @return [Boolean] true if the menu has no items, false otherwise\n def empty?\n @items.empty?\n end\n\n # Returns the number of top-level items in the menu.\n #\n # @return [Integer] The count of top-level menu items\n def size\n @items.size\n end\n\n # Checks if this menu item has any nested items.\n #\n # @return [Boolean] true if the item has nested items, false otherwise\n def nested?\n !empty?\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"
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-600\"\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 # Renders the menu structure as HTML.\n #\n # @return [String] The rendered HTML\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)) 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),\n active_class(item),\n item_parent_class(item)\n )) do\n render_item_content(item)\n render_items(item.items, depth + 1) if item.items.any?\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 def render_item_content(item)\n if item.url\n render_item_link(item)\n else\n render_item_span(item)\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 def render_item_link(item)\n a(href: item.url, class: themed(:item_link)) do\n render_item_interior(item)\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 def render_item_span(item)\n span(class: themed(:item_span)) do\n render_item_interior(item)\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 def render_item_interior(item)\n render_leading_badge(item.leading_badge) if item.leading_badge\n render_icon(item.icon) if item.icon\n render_label(item.label)\n render_trailing_badge(item.trailing_badge) if item.trailing_badge\n end\n\n # Renders the item's label.\n #\n # @param label [String, Component] The label to render\n def render_label(label)\n phlexi_render(label) {\n span(class: themed(:item_label)) { label }\n }\n end\n\n # Renders the item's leading badge.\n #\n # @param badge [String, Component] The leading badge to render\n def render_leading_badge(badge)\n phlexi_render(badge) {\n span(class: themed(:leading_badge)) { badge }\n }\n end\n\n # Renders the item's trailing badge.\n #\n # @param badge [String, Component] The trailing badge to render\n def render_trailing_badge(badge)\n phlexi_render(badge) {\n span(class: themed(:trailing_badge)) { badge }\n }\n end\n\n # Renders the item's icon.\n #\n # @param icon [Class] The icon component class to render\n def render_icon(icon)\n return unless icon\n\n div(class: themed(:icon_wrapper)) do\n render icon.new(class: themed(:icon))\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 # @return [String, nil] The active class name or nil\n def active_class(item)\n item.active?(self) ? themed(:active) : nil\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 # @return [String, nil] The parent class name or nil\n def item_parent_class(item)\n item.items.any? ? themed(:item_parent) : nil\n end\n\n # Resolves a theme component to its CSS classes.\n #\n # @param component [Symbol] The theme component to resolve\n # @return [String, nil] The resolved CSS classes or nil\n def themed(component)\n self.class::Theme.instance.resolve_theme(component)\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"
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 # Checks if the menu has any items.\n #\n # @return [Boolean] true if the menu has no items, false otherwise\n def empty?\n @items.empty?\n end\n\n # Returns the number of top-level items in the menu.\n #\n # @return [Integer] The count of top-level menu items\n def size\n @items.size\n end\n\n # Checks if this menu item has any nested items.\n #\n # @return [Boolean] true if the item has nested items, false otherwise\n def nested?\n !empty?\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"
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,\n items_container: nil,\n item_wrapper: nil,\n item_parent: nil,\n item_link: nil,\n item_span: nil,\n item_label: nil,\n leading_badge: nil,\n trailing_badge: nil,\n icon: nil,\n active: nil\n }.freeze\n end\n end\n end\nend\n"
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.2\"\n end\nend\n"
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",
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- phlexi-menu (0.0.1)
4
+ phlexi-menu (0.2.0)
5
5
  phlex (~> 1.11)
6
6
  phlexi-field
7
7
  zeitwerk