phlexi-menu 0.1.0 → 0.3.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.
@@ -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,7 +33,10 @@ 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
@@ -39,9 +44,7 @@ module Phlexi
39
44
  end
40
45
 
41
46
  def view_template
42
- nav(class: themed(:nav)) do
43
- render_items(@menu.items)
44
- end
47
+ nav(class: themed(:nav)) { render_items(@menu.items) }
45
48
  end
46
49
 
47
50
  protected
@@ -50,14 +53,12 @@ module Phlexi
50
53
  #
51
54
  # @param items [Array<Phlexi::Menu::Item>] The items to render
52
55
  # @param depth [Integer] Current nesting depth
56
+ # @return [void]
53
57
  def render_items(items, depth = 0)
54
- return if depth >= @max_depth
55
- return if items.empty?
58
+ return if depth >= @max_depth || items.empty?
56
59
 
57
60
  ul(class: themed(:items_container, depth)) do
58
- items.each do |item|
59
- render_item_wrapper(item, depth)
60
- end
61
+ items.each { |item| render_item_wrapper(item, depth) }
61
62
  end
62
63
  end
63
64
 
@@ -65,35 +66,41 @@ module Phlexi
65
66
  #
66
67
  # @param item [Phlexi::Menu::Item] The item to wrap
67
68
  # @param depth [Integer] Current nesting depth
69
+ # @return [void]
68
70
  def render_item_wrapper(item, depth)
69
- li(class: tokens(
70
- themed(:item_wrapper, depth),
71
- item_parent_class(item, depth),
72
- active?(item) ? themed(:active, depth) : nil
73
- )) do
71
+ li(class: compute_item_wrapper_classes(item, depth)) do
74
72
  render_item_content(item, depth)
75
- render_items(item.items, depth + 1) if nested?(item, depth)
73
+ render_nested_items(item, depth)
76
74
  end
77
75
  end
78
76
 
79
- def nested?(item, depth)
80
- has_children = item.items.any?
81
- within_depth = (depth + 1) < @max_depth
82
- has_children && within_depth
83
- end
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
+ wrapper = themed(:item_wrapper, depth)
84
+ parent = item_parent_class(item, depth)
85
+ active = active?(item) ? themed(:active, depth) : nil
84
86
 
85
- def item_parent_class(item, depth)
86
- nested?(item, depth) ? themed(:item_parent, depth) : nil
87
+ [wrapper, parent, active].compact.join(" ")
87
88
  end
88
89
 
89
- def active?(item)
90
- item.active?(self)
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)
91
97
  end
92
98
 
93
99
  # Renders the content of a menu item, choosing between link and span.
94
100
  #
95
101
  # @param item [Phlexi::Menu::Item] The item to render content for
96
102
  # @param depth [Integer] Current nesting depth
103
+ # @return [void]
97
104
  def render_item_content(item, depth)
98
105
  if item.url
99
106
  render_item_link(item, depth)
@@ -106,14 +113,13 @@ module Phlexi
106
113
  #
107
114
  # @param item [Phlexi::Menu::Item] The item to render as a link
108
115
  # @param depth [Integer] Current nesting depth
116
+ # @return [void]
109
117
  def render_item_link(item, depth)
110
- a(
111
- href: item.url,
112
- class: tokens(
113
- themed(:item_link, depth),
114
- active?(item) ? themed(:active, depth) : nil
115
- )
116
- ) do
118
+ link_class = themed(:item_link, depth)
119
+ active = active_class(item, depth)
120
+ classes = active ? "#{link_class} #{active}" : link_class
121
+
122
+ a(href: item.url, class: classes) do
117
123
  render_item_interior(item, depth)
118
124
  end
119
125
  end
@@ -122,6 +128,7 @@ module Phlexi
122
128
  #
123
129
  # @param item [Phlexi::Menu::Item] The item to render as a span
124
130
  # @param depth [Integer] Current nesting depth
131
+ # @return [void]
125
132
  def render_item_span(item, depth)
126
133
  span(class: themed(:item_span, depth)) do
127
134
  render_item_interior(item, depth)
@@ -132,47 +139,69 @@ module Phlexi
132
139
  #
133
140
  # @param item [Phlexi::Menu::Item] The item to render interior content for
134
141
  # @param depth [Integer] Current nesting depth
142
+ # @return [void]
135
143
  def render_item_interior(item, depth)
136
- render_leading_badge(item.leading_badge, depth) if item.leading_badge
144
+ render_leading_badge(item, depth) if item.leading_badge
137
145
  render_icon(item.icon, depth) if item.icon
138
146
  render_label(item.label, depth)
139
- render_trailing_badge(item.trailing_badge, depth) if item.trailing_badge
147
+ render_trailing_badge(item, depth) if item.trailing_badge
140
148
  end
141
149
 
142
150
  # Renders the item's label.
143
151
  #
144
152
  # @param label [String, Component] The label to render
145
153
  # @param depth [Integer] Current nesting depth
154
+ # @return [void]
146
155
  def render_label(label, depth)
147
- phlexi_render(label) {
156
+ phlexi_render(label) do
148
157
  span(class: themed(:item_label, depth)) { label }
149
- }
158
+ end
150
159
  end
151
160
 
152
- # Renders the item's leading badge.
161
+ # Renders the leading badge if present
153
162
  #
154
- # @param badge [String, Component] The leading badge to render
163
+ # @param item [Phlexi::Menu::Item] The menu item
155
164
  # @param depth [Integer] Current nesting depth
156
- def render_leading_badge(badge, depth)
157
- phlexi_render(badge) {
158
- span(class: themed(:leading_badge, depth)) { badge }
159
- }
165
+ # @return [void]
166
+ def render_leading_badge(item, depth)
167
+ return unless item.leading_badge
168
+
169
+ div(class: themed(:leading_badge_wrapper, depth)) do
170
+ render_badge(item.leading_badge, item.leading_badge_options, :leading_badge, depth)
171
+ end
160
172
  end
161
173
 
162
- # Renders the item's trailing badge.
174
+ # Renders the trailing badge if present
163
175
  #
164
- # @param badge [String, Component] The trailing badge to render
176
+ # @param item [Phlexi::Menu::Item] The menu item
165
177
  # @param depth [Integer] Current nesting depth
166
- def render_trailing_badge(badge, depth)
167
- phlexi_render(badge) {
168
- span(class: themed(:trailing_badge, depth)) { badge }
169
- }
178
+ # @return [void]
179
+ def render_trailing_badge(item, depth)
180
+ return unless item.trailing_badge
181
+
182
+ div(class: themed(:trailing_badge_wrapper, depth)) do
183
+ render_badge(item.trailing_badge, item.trailing_badge_options, :trailing_badge, depth)
184
+ end
185
+ end
186
+
187
+ # Renders a badge with given options
188
+ #
189
+ # @param badge [Object] The badge content
190
+ # @param options [Hash] Badge rendering options
191
+ # @param type [Symbol] Badge type (leading or trailing)
192
+ # @param depth [Integer] Current nesting depth
193
+ # @return [void]
194
+ def render_badge(badge, options, type, depth)
195
+ phlexi_render(badge) do
196
+ render self.class::Badge.new(badge, **options)
197
+ end
170
198
  end
171
199
 
172
200
  # Renders the item's icon.
173
201
  #
174
202
  # @param icon [Class] The icon component class to render
175
203
  # @param depth [Integer] Current nesting depth
204
+ # @return [void]
176
205
  def render_icon(icon, depth)
177
206
  return unless icon
178
207
 
@@ -205,7 +234,9 @@ module Phlexi
205
234
  # @param depth [Integer] Current nesting depth
206
235
  # @return [Boolean] Whether the item should be treated as nested
207
236
  def nested?(item, depth)
208
- item.nested? && (depth + 1) < @max_depth
237
+ has_children = item.items.any?
238
+ within_depth = (depth + 1) < @max_depth
239
+ has_children && within_depth
209
240
  end
210
241
 
211
242
  # Determines the parent state class for an item.
@@ -221,25 +252,26 @@ module Phlexi
221
252
  #
222
253
  # @param component [Symbol] The theme component to resolve
223
254
  # @param depth [Integer] Current nesting depth
224
- # @return [String, nil] The resolved CSS classes or nil
255
+ # @return [String] The resolved CSS classes
225
256
  def themed(component, depth = 0)
226
257
  theme = self.class::Theme.instance.resolve_theme(component)
227
- return nil if theme.nil?
228
- return theme unless theme.respond_to?(:call)
229
- theme.call(depth)
258
+ theme.is_a?(Proc) ? theme.call(depth) : theme
230
259
  end
231
260
 
232
- # Renders either a component or simple value with fallback.
261
+ # Helper method to render content with proper handling of different types
233
262
  #
234
- # @param arg [Object] The value to render
235
- # @yield The default rendering block
263
+ # @param arg [Object] The content to render
264
+ # @yield Block to render if arg is nil
236
265
  # @raise [ArgumentError] If no block is provided
266
+ # @return [void]
237
267
  def phlexi_render(arg, &)
238
268
  return unless arg
239
269
  raise ArgumentError, "phlexi_render requires a default render block" unless block_given?
240
270
 
271
+ # Handle Phlex components or Rails Renderables
241
272
  if arg.class < Phlex::SGML || arg.respond_to?(:render_in)
242
273
  render arg
274
+ # Handle procs
243
275
  elsif arg.respond_to?(:to_proc)
244
276
  instance_exec(&arg)
245
277
  else
@@ -247,6 +279,7 @@ module Phlexi
247
279
  end
248
280
  end
249
281
 
282
+ # @return [Integer] The default maximum depth for the menu
250
283
  def default_max_depth = self.class::DEFAULT_MAX_DEPTH
251
284
  end
252
285
  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
@@ -25,6 +25,8 @@ module Phlexi
25
25
  hover: nil, # Hover state
26
26
 
27
27
  # Badge elements
28
+ leading_badge_wrapper: nil, # Wrapper for leading badge
29
+ trailing_badge_wrapper: nil, # Wrapper for trailing badge
28
30
  leading_badge: nil, # Badge before label
29
31
  trailing_badge: nil, # Badge after label
30
32
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlexi
4
4
  module Menu
5
- VERSION = "0.1.0"
5
+ VERSION = "0.3.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.1.0
4
+ version: 0.3.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: 2025-05-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: phlex
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '1.11'
19
+ version: '2.0'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '1.11'
26
+ version: '2.0'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: zeitwerk
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -42,16 +42,16 @@ dependencies:
42
42
  name: phlexi-field
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
- - - ">="
45
+ - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: '0'
47
+ version: 0.1.0
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
- - - ">="
52
+ - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: '0'
54
+ version: 0.1.0
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: rake
57
57
  requirement: !ruby/object:Gem::Requirement
@@ -180,15 +180,15 @@ files:
180
180
  - LICENSE.txt
181
181
  - README.md
182
182
  - Rakefile
183
+ - changes.patch
183
184
  - config.ru
184
- - export.json
185
- - export.rb
186
185
  - gemfiles/default.gemfile
187
186
  - gemfiles/default.gemfile.lock
188
187
  - gemfiles/rails_7.gemfile
189
188
  - gemfiles/rails_7.gemfile.lock
190
189
  - lib/phlexi-menu.rb
191
190
  - lib/phlexi/menu.rb
191
+ - lib/phlexi/menu/badge.rb
192
192
  - lib/phlexi/menu/builder.rb
193
193
  - lib/phlexi/menu/component.rb
194
194
  - lib/phlexi/menu/item.rb