phlexi-menu 0.0.3 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "phlex"
4
+
5
+ module Phlexi
6
+ module Menu
7
+ # A component for rendering badge elements in menus
8
+ #
9
+ # @example Basic usage
10
+ # Badge.new("New!", class: "badge-primary")
11
+ #
12
+ # @example With custom styling
13
+ # Badge.new("2", class: "badge-notification")
14
+ #
15
+ class Badge < COMPONENT_BASE
16
+ # Initialize a new badge component
17
+ #
18
+ # @param content [String] The text content to display in the badge
19
+ # @param options [Hash] Additional HTML attributes for the badge element
20
+ # @option options [String] :class CSS classes to apply to the badge
21
+ def initialize(content, **options)
22
+ @content = content
23
+ @options = options
24
+ super()
25
+ end
26
+
27
+ def view_template
28
+ span(class: @options[:class]) { @content }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -47,27 +47,6 @@ module Phlexi
47
47
  new_item
48
48
  end
49
49
 
50
- # Checks if the menu has any items.
51
- #
52
- # @return [Boolean] true if the menu has no items, false otherwise
53
- def empty?
54
- @items.empty?
55
- end
56
-
57
- # Returns the number of top-level items in the menu.
58
- #
59
- # @return [Integer] The count of top-level menu items
60
- def size
61
- @items.size
62
- end
63
-
64
- # Checks if this menu item has any nested items.
65
- #
66
- # @return [Boolean] true if the item has nested items, false otherwise
67
- def nested?
68
- !empty?
69
- end
70
-
71
50
  # Returns a string representation of the menu structure.
72
51
  #
73
52
  # @return [String] A human-readable representation of the menu
@@ -14,7 +14,7 @@ module Phlexi
14
14
  # def self.theme
15
15
  # super.merge({
16
16
  # nav: "bg-white shadow",
17
- # item_label: "text-gray-600"
17
+ # item_label: ->(depth) { "text-gray-#{600 + (depth * 100)}" }
18
18
  # })
19
19
  # end
20
20
  # end
@@ -23,6 +23,8 @@ module Phlexi
23
23
  # Theme class for customizing menu appearance
24
24
  class Theme < Phlexi::Menu::Theme; end
25
25
 
26
+ class Badge < Phlexi::Menu::Badge; end
27
+
26
28
  # @return [Integer] The default maximum nesting depth for menu items
27
29
  DEFAULT_MAX_DEPTH = 3
28
30
 
@@ -31,20 +33,18 @@ module Phlexi
31
33
  # @param menu [Phlexi::Menu::Builder] The menu structure to render
32
34
  # @param max_depth [Integer] Maximum nesting depth for menu items
33
35
  # @param options [Hash] Additional options passed to rendering methods
36
+ # @raise [ArgumentError] If menu is nil
34
37
  def initialize(menu, max_depth: default_max_depth, **options)
38
+ raise ArgumentError, "Menu cannot be nil" if menu.nil?
39
+
35
40
  @menu = menu
36
41
  @max_depth = max_depth
37
42
  @options = options
38
43
  super()
39
44
  end
40
45
 
41
- # Renders the menu structure as HTML.
42
- #
43
- # @return [String] The rendered HTML
44
46
  def view_template
45
- nav(class: themed(:nav)) do
46
- render_items(@menu.items)
47
- end
47
+ nav(class: themed(:nav)) { render_items(@menu.items) }
48
48
  end
49
49
 
50
50
  protected
@@ -53,14 +53,12 @@ module Phlexi
53
53
  #
54
54
  # @param items [Array<Phlexi::Menu::Item>] The items to render
55
55
  # @param depth [Integer] Current nesting depth
56
+ # @return [void]
56
57
  def render_items(items, depth = 0)
57
- return if depth >= @max_depth
58
- return if items.empty?
58
+ return if depth >= @max_depth || items.empty?
59
59
 
60
- ul(class: themed(:items_container)) do
61
- items.each do |item|
62
- render_item_wrapper(item, depth)
63
- end
60
+ ul(class: themed(:items_container, depth)) do
61
+ items.each { |item| render_item_wrapper(item, depth) }
64
62
  end
65
63
  end
66
64
 
@@ -68,116 +66,197 @@ module Phlexi
68
66
  #
69
67
  # @param item [Phlexi::Menu::Item] The item to wrap
70
68
  # @param depth [Integer] Current nesting depth
69
+ # @return [void]
71
70
  def render_item_wrapper(item, depth)
72
- li(class: tokens(
73
- themed(:item_wrapper),
74
- active_class(item),
75
- item_parent_class(item)
76
- )) do
77
- render_item_content(item)
78
- render_items(item.items, depth + 1) if item.items.any?
71
+ li(class: compute_item_wrapper_classes(item, depth)) do
72
+ render_item_content(item, depth)
73
+ render_nested_items(item, depth)
79
74
  end
80
75
  end
81
76
 
77
+ # Computes CSS classes for item wrapper
78
+ #
79
+ # @param item [Phlexi::Menu::Item] The menu item
80
+ # @param depth [Integer] Current nesting depth
81
+ # @return [String] Space-separated CSS classes
82
+ def compute_item_wrapper_classes(item, depth)
83
+ tokens(
84
+ themed(:item_wrapper, depth),
85
+ item_parent_class(item, depth),
86
+ active?(item) ? themed(:active, depth) : nil
87
+ )
88
+ end
89
+
90
+ # Renders nested items if present and within depth limit
91
+ #
92
+ # @param item [Phlexi::Menu::Item] The parent menu item
93
+ # @param depth [Integer] Current nesting depth
94
+ # @return [void]
95
+ def render_nested_items(item, depth)
96
+ render_items(item.items, depth + 1) if nested?(item, depth)
97
+ end
98
+
82
99
  # Renders the content of a menu item, choosing between link and span.
83
100
  #
84
101
  # @param item [Phlexi::Menu::Item] The item to render content for
85
- def render_item_content(item)
102
+ # @param depth [Integer] Current nesting depth
103
+ # @return [void]
104
+ def render_item_content(item, depth)
86
105
  if item.url
87
- render_item_link(item)
106
+ render_item_link(item, depth)
88
107
  else
89
- render_item_span(item)
108
+ render_item_span(item, depth)
90
109
  end
91
110
  end
92
111
 
93
112
  # Renders a menu item as a link.
94
113
  #
95
114
  # @param item [Phlexi::Menu::Item] The item to render as a link
96
- def render_item_link(item)
97
- a(href: item.url, class: themed(:item_link)) do
98
- render_item_interior(item)
115
+ # @param depth [Integer] Current nesting depth
116
+ # @return [void]
117
+ def render_item_link(item, depth)
118
+ a(
119
+ href: item.url,
120
+ class: tokens(themed(:item_link, depth), active_class(item, depth))
121
+ ) do
122
+ render_item_interior(item, depth)
99
123
  end
100
124
  end
101
125
 
102
126
  # Renders a menu item as a span (for non-linking items).
103
127
  #
104
128
  # @param item [Phlexi::Menu::Item] The item to render as a span
105
- def render_item_span(item)
106
- span(class: themed(:item_span)) do
107
- render_item_interior(item)
129
+ # @param depth [Integer] Current nesting depth
130
+ # @return [void]
131
+ def render_item_span(item, depth)
132
+ span(class: themed(:item_span, depth)) do
133
+ render_item_interior(item, depth)
108
134
  end
109
135
  end
110
136
 
111
137
  # Renders the interior content of a menu item (badges, icon, label).
112
138
  #
113
139
  # @param item [Phlexi::Menu::Item] The item to render interior content for
114
- def render_item_interior(item)
115
- render_leading_badge(item.leading_badge) if item.leading_badge
116
- render_icon(item.icon) if item.icon
117
- render_label(item.label)
118
- render_trailing_badge(item.trailing_badge) if item.trailing_badge
140
+ # @param depth [Integer] Current nesting depth
141
+ # @return [void]
142
+ def render_item_interior(item, depth)
143
+ render_leading_badge(item, depth) if item.leading_badge
144
+ render_icon(item.icon, depth) if item.icon
145
+ render_label(item.label, depth)
146
+ render_trailing_badge(item, depth) if item.trailing_badge
119
147
  end
120
148
 
121
149
  # Renders the item's label.
122
150
  #
123
151
  # @param label [String, Component] The label to render
124
- def render_label(label)
125
- phlexi_render(label) {
126
- span(class: themed(:item_label)) { label }
127
- }
152
+ # @param depth [Integer] Current nesting depth
153
+ # @return [void]
154
+ def render_label(label, depth)
155
+ phlexi_render(label) do
156
+ span(class: themed(:item_label, depth)) { label }
157
+ end
158
+ end
159
+
160
+ # Renders the leading badge if present
161
+ #
162
+ # @param item [Phlexi::Menu::Item] The menu item
163
+ # @param depth [Integer] Current nesting depth
164
+ # @return [void]
165
+ def render_leading_badge(item, depth)
166
+ return unless item.leading_badge
167
+
168
+ div(class: themed(:leading_badge_wrapper, depth)) do
169
+ render_badge(item.leading_badge, item.leading_badge_options, :leading_badge, depth)
170
+ end
128
171
  end
129
172
 
130
- # Renders the item's leading badge.
173
+ # Renders the trailing badge if present
131
174
  #
132
- # @param badge [String, Component] The leading badge to render
133
- def render_leading_badge(badge)
134
- phlexi_render(badge) {
135
- span(class: themed(:leading_badge)) { badge }
136
- }
175
+ # @param item [Phlexi::Menu::Item] The menu item
176
+ # @param depth [Integer] Current nesting depth
177
+ # @return [void]
178
+ def render_trailing_badge(item, depth)
179
+ return unless item.trailing_badge
180
+
181
+ div(class: themed(:trailing_badge_wrapper, depth)) do
182
+ render_badge(item.trailing_badge, item.trailing_badge_options, :trailing_badge, depth)
183
+ end
137
184
  end
138
185
 
139
- # Renders the item's trailing badge.
186
+ # Renders a badge with given options
140
187
  #
141
- # @param badge [String, Component] The trailing badge to render
142
- def render_trailing_badge(badge)
143
- phlexi_render(badge) {
144
- span(class: themed(:trailing_badge)) { badge }
145
- }
188
+ # @param badge [Object] The badge content
189
+ # @param options [Hash] Badge rendering options
190
+ # @param type [Symbol] Badge type (leading or trailing)
191
+ # @param depth [Integer] Current nesting depth
192
+ # @return [void]
193
+ def render_badge(badge, options, type, depth)
194
+ phlexi_render(badge) do
195
+ render self.class::Badge.new(badge, **options)
196
+ end
146
197
  end
147
198
 
148
199
  # Renders the item's icon.
149
200
  #
150
201
  # @param icon [Class] The icon component class to render
151
- def render_icon(icon)
202
+ # @param depth [Integer] Current nesting depth
203
+ # @return [void]
204
+ def render_icon(icon, depth)
152
205
  return unless icon
153
206
 
154
- div(class: themed(:icon_wrapper)) do
155
- render icon.new(class: themed(:icon))
207
+ div(class: themed(:icon_wrapper, depth)) do
208
+ render icon.new(class: themed(:icon, depth))
156
209
  end
157
210
  end
158
211
 
159
212
  # Determines the active state class for an item.
160
213
  #
161
214
  # @param item [Phlexi::Menu::Item] The item to check active state for
215
+ # @param depth [Integer] Current nesting depth
162
216
  # @return [String, nil] The active class name or nil
163
- def active_class(item)
164
- item.active?(self) ? themed(:active) : nil
217
+ def active_class(item, depth)
218
+ active?(item) ? themed(:active, depth) : nil
219
+ end
220
+
221
+ # Helper method to check if an item is active
222
+ #
223
+ # @param item [Phlexi::Menu::Item] The item to check
224
+ # @return [Boolean] Whether the item is active
225
+ def active?(item)
226
+ item.active?(self)
227
+ end
228
+
229
+ # Determines if an item should be treated as nested based on its contents
230
+ # and the current depth relative to the maximum allowed depth.
231
+ #
232
+ # @param item [Phlexi::Menu::Item] The item to check
233
+ # @param depth [Integer] Current nesting depth
234
+ # @return [Boolean] Whether the item should be treated as nested
235
+ def nested?(item, depth)
236
+ has_children = item.items.any?
237
+ within_depth = (depth + 1) < @max_depth
238
+ has_children && within_depth
165
239
  end
166
240
 
167
241
  # Determines the parent state class for an item.
168
242
  #
169
243
  # @param item [Phlexi::Menu::Item] The item to check parent state for
244
+ # @param depth [Integer] Current nesting depth
170
245
  # @return [String, nil] The parent class name or nil
171
- def item_parent_class(item)
172
- item.items.any? ? themed(:item_parent) : nil
246
+ def item_parent_class(item, depth)
247
+ nested?(item, depth) ? themed(:item_parent, depth) : nil
173
248
  end
174
249
 
175
250
  # Resolves a theme component to its CSS classes.
176
251
  #
177
252
  # @param component [Symbol] The theme component to resolve
253
+ # @param depth [Integer] Current nesting depth
178
254
  # @return [String, nil] The resolved CSS classes or nil
179
- def themed(component)
180
- self.class::Theme.instance.resolve_theme(component)
255
+ def themed(component, depth = 0)
256
+ theme = self.class::Theme.instance.resolve_theme(component)
257
+ return nil if theme.nil?
258
+ return theme unless theme.respond_to?(:call)
259
+ theme.call(depth)
181
260
  end
182
261
 
183
262
  # Renders either a component or simple value with fallback.
@@ -185,6 +264,7 @@ module Phlexi
185
264
  # @param arg [Object] The value to render
186
265
  # @yield The default rendering block
187
266
  # @raise [ArgumentError] If no block is provided
267
+ # @return [void]
188
268
  def phlexi_render(arg, &)
189
269
  return unless arg
190
270
  raise ArgumentError, "phlexi_render requires a default render block" unless block_given?
@@ -198,6 +278,7 @@ module Phlexi
198
278
  end
199
279
  end
200
280
 
281
+ # @return [Integer] The default maximum depth for the menu
201
282
  def default_max_depth = self.class::DEFAULT_MAX_DEPTH
202
283
  end
203
284
  end
@@ -20,6 +20,11 @@ module Phlexi
20
20
  # admin.item "Users", url: "/admin/users"
21
21
  # admin.item "Settings", url: "/admin/settings"
22
22
  # end
23
+ #
24
+ # @example Custom active state logic
25
+ # Item.new("Dashboard", url: "/dashboard", active: -> (context) {
26
+ # context.controller.controller_name == "dashboards"
27
+ # })
23
28
  class Item
24
29
  # @return [String] The display text for the menu item
25
30
  attr_reader :label
@@ -33,9 +38,15 @@ module Phlexi
33
38
  # @return [String, Component, nil] The badge displayed before the label
34
39
  attr_reader :leading_badge
35
40
 
41
+ # @return [Hash] Options for the leading badge
42
+ attr_reader :leading_badge_options
43
+
36
44
  # @return [String, Component, nil] The badge displayed after the label
37
45
  attr_reader :trailing_badge
38
46
 
47
+ # @return [Hash] Options for the trailing badge
48
+ attr_reader :trailing_badge_options
49
+
39
50
  # @return [Array<Item>] Collection of nested menu items
40
51
  attr_reader :items
41
52
 
@@ -55,14 +66,12 @@ module Phlexi
55
66
  # @raise [ArgumentError] If the label is nil or empty
56
67
  def initialize(label, url: nil, icon: nil, leading_badge: nil, trailing_badge: nil, **options, &)
57
68
  raise ArgumentError, "Label cannot be nil" unless label
58
-
59
69
  @label = label
60
70
  @url = url
61
71
  @icon = icon
62
- @leading_badge = leading_badge
63
- @trailing_badge = trailing_badge
64
- @options = options
65
72
  @items = []
73
+ @options = options
74
+ setup_badges(leading_badge, trailing_badge, options)
66
75
 
67
76
  yield self if block_given?
68
77
  end
@@ -70,16 +79,44 @@ module Phlexi
70
79
  # Creates and adds a nested menu item.
71
80
  #
72
81
  # @param label [String] The display text for the nested item
73
- # @param ** [Hash] Additional options passed to the Item constructor
82
+ # @param args [Hash] Additional options passed to the Item constructor
74
83
  # @yield [item] Optional block for adding further nested items
75
84
  # @yieldparam item [Item] The newly created nested item
76
85
  # @return [Item] The created nested item
77
- def item(label, **, &)
78
- new_item = self.class.new(label, **, &)
86
+ def item(label, **args, &)
87
+ new_item = self.class.new(label, **args, &)
79
88
  @items << new_item
80
89
  new_item
81
90
  end
82
91
 
92
+ # Add a leading badge to the menu item
93
+ #
94
+ # @param badge [String, Component] The badge content
95
+ # @param opts [Hash] Additional options for the badge
96
+ # @return [self] Returns self for method chaining
97
+ # @raise [ArgumentError] If badge is nil
98
+ def with_leading_badge(badge, **opts)
99
+ raise ArgumentError, "Badge cannot be nil" if badge.nil?
100
+
101
+ @leading_badge = badge
102
+ @leading_badge_options = opts.freeze
103
+ self
104
+ end
105
+
106
+ # Add a trailing badge to the menu item
107
+ #
108
+ # @param badge [String, Component] The badge content
109
+ # @param opts [Hash] Additional options for the badge
110
+ # @return [self] Returns self for method chaining
111
+ # @raise [ArgumentError] If badge is nil
112
+ def with_trailing_badge(badge, **opts)
113
+ raise ArgumentError, "Badge cannot be nil" if badge.nil?
114
+
115
+ @trailing_badge = badge
116
+ @trailing_badge_options = opts.freeze
117
+ self
118
+ end
119
+
83
120
  # Determines if this menu item should be shown as active.
84
121
  # Checks in the following order:
85
122
  # 1. Custom active logic if provided in options
@@ -89,44 +126,54 @@ module Phlexi
89
126
  # @param context [Object] The context object (typically a controller) for active state checking
90
127
  # @return [Boolean] true if the item should be shown as active, false otherwise
91
128
  def active?(context)
92
- # First check custom active logic if provided
93
- return @options[:active].call(context) if @options[:active].respond_to?(:call)
94
-
95
- # Then check if this item's URL matches current page
96
- if context.respond_to?(:helpers) && @url
97
- return true if context.helpers.current_page?(@url)
98
- end
129
+ check_custom_active_state(context) ||
130
+ check_current_page_match(context) ||
131
+ check_nested_items_active(context)
132
+ end
99
133
 
100
- # Finally check if any child items are active
101
- @items.any? { |item| item.active?(context) }
134
+ # Returns a string representation of the menu item.
135
+ #
136
+ # @return [String] A human-readable representation of the menu item
137
+ def inspect
138
+ "#<#{self.class} label=#{@label.inspect} url=#{@url.inspect} items=#{@items.inspect}>"
102
139
  end
103
140
 
104
- # Checks if the menu has any items.
141
+ private
142
+
143
+ # Sets up the badge attributes
105
144
  #
106
- # @return [Boolean] true if the menu has no items, false otherwise
107
- def empty?
108
- @items.empty?
145
+ # @param leading_badge [String, Component, nil] The leading badge
146
+ # @param trailing_badge [String, Component, nil] The trailing badge
147
+ # @param options [Hash] Options containing badge configurations
148
+ def setup_badges(leading_badge, trailing_badge, options)
149
+ @leading_badge = leading_badge
150
+ @leading_badge_options = (options.delete(:leading_badge_options) || {}).freeze
151
+ @trailing_badge = trailing_badge
152
+ @trailing_badge_options = (options.delete(:trailing_badge_options) || {}).freeze
109
153
  end
110
154
 
111
- # Returns the number of top-level items in the menu.
155
+ # Checks if there's custom active state logic
112
156
  #
113
- # @return [Integer] The count of top-level menu items
114
- def size
115
- @items.size
157
+ # @param context [Object] The context for active state checking
158
+ # @return [Boolean] Result of custom active check
159
+ def check_custom_active_state(context)
160
+ @options[:active].respond_to?(:call) && @options[:active].call(context)
116
161
  end
117
162
 
118
- # Checks if this menu item has any nested items.
163
+ # Checks if the current page matches the item's URL
119
164
  #
120
- # @return [Boolean] true if the item has nested items, false otherwise
121
- def nested?
122
- !empty?
165
+ # @param context [Object] The context for URL matching
166
+ # @return [Boolean] Whether the current page matches
167
+ def check_current_page_match(context)
168
+ context.respond_to?(:helpers) && @url && context.helpers.current_page?(@url)
123
169
  end
124
170
 
125
- # Returns a string representation of the menu item.
171
+ # Checks if any nested items are active
126
172
  #
127
- # @return [String] A human-readable representation of the menu item
128
- def inspect
129
- "#<#{self.class} label=#{@label} url=#{@url} items=#{@items.map(&:inspect)}>"
173
+ # @param context [Object] The context for checking nested items
174
+ # @return [Boolean] Whether any nested items are active
175
+ def check_nested_items_active(context)
176
+ @items.any? { |item| item.active?(context) }
130
177
  end
131
178
  end
132
179
  end
@@ -3,19 +3,36 @@ require "phlexi-field"
3
3
  module Phlexi
4
4
  module Menu
5
5
  class Theme < Phlexi::Field::Theme
6
+ # Defines the default theme structure with nil values
7
+ # Can be overridden in subclasses to provide custom styling
8
+ #
9
+ # @return [Hash] Default theme structure with nil values
6
10
  def self.theme
7
11
  @theme ||= {
8
- nav: nil,
9
- items_container: nil,
10
- item_wrapper: nil,
11
- item_parent: nil,
12
- item_link: nil,
13
- item_span: nil,
14
- item_label: nil,
15
- leading_badge: nil,
16
- trailing_badge: nil,
17
- icon: nil,
18
- active: nil
12
+ # Container elements
13
+ nav: nil, # Navigation wrapper
14
+ items_container: nil, # <ul> list container
15
+
16
+ # Item structure elements
17
+ item_wrapper: nil, # <li> item wrapper
18
+ item_parent: nil, # Additional class for items with visible children
19
+ item_link: nil, # <a> for clickable items
20
+ item_span: nil, # <span> for non-clickable items
21
+ item_label: nil, # Label text wrapper
22
+
23
+ # Interactive states
24
+ active: nil, # Active/selected state
25
+ hover: nil, # Hover state
26
+
27
+ # Badge elements
28
+ leading_badge_wrapper: nil, # Wrapper for leading badge
29
+ trailing_badge_wrapper: nil, # Wrapper for trailing badge
30
+ leading_badge: nil, # Badge before label
31
+ trailing_badge: nil, # Badge after label
32
+
33
+ # Icon elements
34
+ icon: nil, # Icon styling
35
+ icon_wrapper: nil # Icon container
19
36
  }.freeze
20
37
  end
21
38
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlexi
4
4
  module Menu
5
- VERSION = "0.0.3"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlexi-menu
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.3
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Froelich
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-12-11 00:00:00.000000000 Z
11
+ date: 2024-12-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: phlex
@@ -180,6 +180,7 @@ files:
180
180
  - LICENSE.txt
181
181
  - README.md
182
182
  - Rakefile
183
+ - changes.patch
183
184
  - config.ru
184
185
  - export.json
185
186
  - export.rb
@@ -189,6 +190,7 @@ files:
189
190
  - gemfiles/rails_7.gemfile.lock
190
191
  - lib/phlexi-menu.rb
191
192
  - lib/phlexi/menu.rb
193
+ - lib/phlexi/menu/badge.rb
192
194
  - lib/phlexi/menu/builder.rb
193
195
  - lib/phlexi/menu/component.rb
194
196
  - lib/phlexi/menu/item.rb