phlexi-menu 0.0.2 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +122 -7
- data/export.json +4 -4
- data/export.rb +1 -1
- data/gemfiles/default.gemfile.lock +1 -1
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/lib/phlexi/menu/builder.rb +8 -1
- data/lib/phlexi/menu/component.rb +91 -40
- data/lib/phlexi/menu/item.rb +16 -2
- data/lib/phlexi/menu/theme.rb +26 -11
- data/lib/phlexi/menu/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 9bb5cb841e6404ef962a4003718f3e2f9827dd165f417dd2a1ef9f2b5d0e07b2
|
4
|
+
data.tar.gz: 422533e0556c9e51f5c49eb5225ecb0d0bbb4c21af00b9ac56afcf4f723b7687
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2acde964c44d59ba554b024e83143d784214faf41d74cb823152a86c98dd88863d659910e3bb21f5cd2f9e2c4c7445dc2344c8260c9977d1af9cc50f4929246f
|
7
|
+
data.tar.gz: 9e651553370ad7a00ff40c0243b36729054208531eae757b945220423bc27075df0304e47f591f1c66b68a91265ed8567aa70b83653b3d69a48033fbe99392a3
|
data/README.md
CHANGED
@@ -13,7 +13,10 @@ Phlexi::Menu is a flexible and powerful menu builder for Ruby applications. It p
|
|
13
13
|
- [Basic Usage](#basic-usage)
|
14
14
|
- [Menu Items](#menu-items)
|
15
15
|
- [Component Options](#component-options)
|
16
|
+
- [Nesting and Depth Limits](#nesting-and-depth-limits)
|
16
17
|
- [Theming](#theming)
|
18
|
+
- [Static Theming](#static-theming)
|
19
|
+
- [Depth-Aware Theming](#depth-aware-theming)
|
17
20
|
- [Badge Components](#badge-components)
|
18
21
|
- [Rails Integration](#rails-integration)
|
19
22
|
- [Advanced Usage](#advanced-usage)
|
@@ -25,10 +28,11 @@ Phlexi::Menu is a flexible and powerful menu builder for Ruby applications. It p
|
|
25
28
|
|
26
29
|
## Features
|
27
30
|
|
28
|
-
- Hierarchical menu structure with
|
31
|
+
- Hierarchical menu structure with intelligent depth control
|
29
32
|
- Support for icons and dual-badge system (leading and trailing badges)
|
30
33
|
- Intelligent active state detection
|
31
|
-
- Flexible theming system
|
34
|
+
- Flexible theming system with depth awareness
|
35
|
+
- Smart nesting behavior based on depth limits
|
32
36
|
- Works seamlessly with Phlex components
|
33
37
|
- Rails-compatible URL handling
|
34
38
|
- Customizable rendering components
|
@@ -64,14 +68,15 @@ class MainMenu < Phlexi::Menu::Component
|
|
64
68
|
super.merge({
|
65
69
|
nav: "bg-white shadow",
|
66
70
|
items_container: "space-y-1",
|
67
|
-
item_wrapper: "relative",
|
71
|
+
item_wrapper: ->(depth) { "relative pl-#{depth * 4}" },
|
68
72
|
item_link: "flex items-center px-4 py-2 hover:bg-gray-50",
|
69
73
|
item_span: "flex items-center px-4 py-2",
|
70
|
-
item_label: "mx-3",
|
74
|
+
item_label: ->(depth) { "mx-3 text-gray-#{600 + (depth * 100)}" },
|
71
75
|
leading_badge: "mr-2 px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-600",
|
72
76
|
trailing_badge: "ml-auto px-2 py-0.5 text-xs rounded-full bg-red-100 text-red-600",
|
73
77
|
icon: "h-5 w-5",
|
74
|
-
active: "bg-blue-50 text-blue-600"
|
78
|
+
active: "bg-blue-50 text-blue-600",
|
79
|
+
item_parent: "has-children"
|
75
80
|
})
|
76
81
|
end
|
77
82
|
end
|
@@ -90,7 +95,7 @@ menu = Phlexi::Menu::Builder.new do |m|
|
|
90
95
|
users.item "All Users", url: "/users"
|
91
96
|
users.item "Add User", url: "/users/new"
|
92
97
|
end
|
93
|
-
|
98
|
+
|
94
99
|
m.item "Settings",
|
95
100
|
url: "/settings",
|
96
101
|
icon: SettingsIcon,
|
@@ -128,8 +133,54 @@ MainMenu.new(
|
|
128
133
|
)
|
129
134
|
```
|
130
135
|
|
136
|
+
### Nesting and Depth Limits
|
137
|
+
|
138
|
+
Phlexi::Menu intelligently handles menu nesting based on the specified maximum depth:
|
139
|
+
|
140
|
+
```ruby
|
141
|
+
# Create a deeply nested menu structure
|
142
|
+
menu = Phlexi::Menu::Builder.new do |m|
|
143
|
+
m.item "Level 0" do |l0| # Will be nested (depth 0)
|
144
|
+
l0.item "Level 1" do |l1| # Will be nested if max_depth > 2
|
145
|
+
l1.item "Level 2" # Will be nested if max_depth > 3
|
146
|
+
l1.item "Level 3" # Won't be nested if max_depth <= 3
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
# Render with depth limit
|
153
|
+
menu_component = MainMenu.new(menu, max_depth: 2)
|
154
|
+
```
|
155
|
+
|
156
|
+
Key behaviors:
|
157
|
+
- Items are only treated as nested if their children can be rendered within the depth limit
|
158
|
+
- Parent styling classes (item_parent theme) are only applied to items whose children will be shown
|
159
|
+
- Nesting structure automatically adjusts based on the max_depth setting
|
160
|
+
- Depth-aware theme values receive the actual rendered depth of each item
|
161
|
+
|
162
|
+
Example with max_depth of 2:
|
163
|
+
```ruby
|
164
|
+
menu = Phlexi::Menu::Builder.new do |m|
|
165
|
+
m.item "Products" do |products| # depth 0, gets parent styling
|
166
|
+
products.item "Categories" do |cats| # depth 1, gets parent styling
|
167
|
+
cats.item "Electronics" # depth 2, no parent styling
|
168
|
+
cats.item "Books" do |books| # depth 2, no parent styling
|
169
|
+
books.item "Fiction" # not rendered (depth 3)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
```
|
175
|
+
|
131
176
|
### Theming
|
132
177
|
|
178
|
+
Phlexi::Menu provides two approaches to theming: static and depth-aware.
|
179
|
+
|
180
|
+
#### Static Theming
|
181
|
+
|
182
|
+
Basic theme configuration with fixed classes:
|
183
|
+
|
133
184
|
```ruby
|
134
185
|
class CustomMenu < Phlexi::Menu::Component
|
135
186
|
class Theme < Theme
|
@@ -151,6 +202,70 @@ class CustomMenu < Phlexi::Menu::Component
|
|
151
202
|
end
|
152
203
|
```
|
153
204
|
|
205
|
+
#### Depth-Aware Theming
|
206
|
+
|
207
|
+
Advanced theme configuration with depth-sensitive classes:
|
208
|
+
|
209
|
+
```ruby
|
210
|
+
class DepthAwareMenu < Phlexi::Menu::Component
|
211
|
+
class Theme < Theme
|
212
|
+
def self.theme
|
213
|
+
super.merge({
|
214
|
+
# Static classes
|
215
|
+
nav: "bg-white shadow",
|
216
|
+
|
217
|
+
# Progressive indentation
|
218
|
+
item_wrapper: ->(depth) { "relative pl-#{depth * 4}" },
|
219
|
+
|
220
|
+
# Gradually fading text
|
221
|
+
item_label: ->(depth) { "mx-3 text-gray-#{600 + (depth * 100)}" },
|
222
|
+
|
223
|
+
# Different icon styles per level
|
224
|
+
icon: ->(depth) {
|
225
|
+
base = "h-5 w-5"
|
226
|
+
color = depth.zero? ? "text-primary" : "text-gray-400"
|
227
|
+
[base, color]
|
228
|
+
},
|
229
|
+
|
230
|
+
# Smaller text at deeper levels
|
231
|
+
item_link: ->(depth) {
|
232
|
+
size = depth.zero? ? "text-base" : "text-sm"
|
233
|
+
["flex items-center px-4 py-2 hover:bg-gray-50", size]
|
234
|
+
}
|
235
|
+
})
|
236
|
+
end
|
237
|
+
end
|
238
|
+
end
|
239
|
+
```
|
240
|
+
|
241
|
+
Theme values can be either:
|
242
|
+
- Static strings for consistent styling
|
243
|
+
- Arrays of classes that will be joined
|
244
|
+
- Callables (procs/lambdas) that receive the current depth and return strings or arrays
|
245
|
+
|
246
|
+
### Advanced Usage
|
247
|
+
|
248
|
+
#### Component Customization
|
249
|
+
|
250
|
+
You can customize the nesting behavior by overriding the nested? method:
|
251
|
+
|
252
|
+
```ruby
|
253
|
+
class CustomMenu < Phlexi::Menu::Component
|
254
|
+
protected
|
255
|
+
|
256
|
+
def nested?(item, depth)
|
257
|
+
# Custom logic for when to treat items as nested
|
258
|
+
return false if depth >= @max_depth - 1 # Reserve last level
|
259
|
+
return false if item.items.empty? # No empty parents
|
260
|
+
|
261
|
+
# Allow nesting only for items with certain attributes
|
262
|
+
item.options[:allow_nesting]
|
263
|
+
end
|
264
|
+
end
|
265
|
+
```
|
266
|
+
|
267
|
+
|
268
|
+
|
154
269
|
### Badge Components
|
155
270
|
|
156
271
|
Badges can be either strings or Phlex components:
|
@@ -295,4 +410,4 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/radioa
|
|
295
410
|
|
296
411
|
## License
|
297
412
|
|
298
|
-
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
413
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/export.json
CHANGED
@@ -9,19 +9,19 @@
|
|
9
9
|
},
|
10
10
|
{
|
11
11
|
"path": "/Users/stefan/Documents/plutonium/phlexi-menu/export.rb",
|
12
|
-
"contents": "require \"json\"\nrequire \"find\"\n\ndef export_files_to_json(directory, extensions, output_file, exceptions = [])\n # Convert extensions to lowercase for case-insensitive matching\n extensions = extensions.map(&:downcase)\n\n # Array to store file data\n files_data = []\n\n # Find all files in directory and subdirectories\n Find.find(directory) do |path|\n # Skip if not a file\n next unless File.file?(path)\n next if exceptions.any? { |exception| path.include?(exception) }\n\n # Check if file extension matches any in our list\n ext = File.extname(path).downcase[1
|
12
|
+
"contents": "require \"json\"\nrequire \"find\"\n\ndef export_files_to_json(directory, extensions, output_file, exceptions = [])\n # Convert extensions to lowercase for case-insensitive matching\n extensions = extensions.map(&:downcase)\n\n # Array to store file data\n files_data = []\n\n # Find all files in directory and subdirectories\n Find.find(directory) do |path|\n # Skip if not a file\n next unless File.file?(path)\n next if exceptions.any? { |exception| path.include?(exception) }\n\n # Check if file extension matches any in our list\n ext = File.extname(path).downcase[1..] # Remove the leading dot\n next unless extensions.include?(ext)\n\n puts path\n\n begin\n # Read file contents\n contents = File.read(path)\n\n # Add to our array\n files_data << {\n \"path\" => path,\n \"contents\" => contents\n }\n rescue => e\n puts \"Error reading file #{path}: #{e.message}\"\n end\n end\n\n # Write to JSON file\n File.write(output_file, JSON.pretty_generate(files_data))\n\n puts \"Successfully exported #{files_data.length} files to #{output_file}\"\nend\n\n# Example usage (uncomment and modify as needed):\ndirectory = \"/Users/stefan/Documents/plutonium/phlexi-menu\"\nexceptions = [\"/.github/\", \"/.vscode/\", \"gemfiles\", \"pkg\", \"node_modules\"]\nextensions = [\"rb\", \"md\", \"yml\", \"yaml\", \"gemspec\"]\noutput_file = \"export.json\"\nexport_files_to_json(directory, extensions, output_file, exceptions)\n"
|
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 # 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(&:
|
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"
|
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:
|
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"
|
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 this menu item has any nested items.\n #\n # @return [Boolean] true if the item has nested items, false otherwise\n def nested?\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 # 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"
|
25
25
|
},
|
26
26
|
{
|
27
27
|
"path": "/Users/stefan/Documents/plutonium/phlexi-menu/lib/phlexi/menu/theme.rb",
|
data/export.rb
CHANGED
@@ -15,7 +15,7 @@ def export_files_to_json(directory, extensions, output_file, exceptions = [])
|
|
15
15
|
next if exceptions.any? { |exception| path.include?(exception) }
|
16
16
|
|
17
17
|
# Check if file extension matches any in our list
|
18
|
-
ext = File.extname(path).downcase[1
|
18
|
+
ext = File.extname(path).downcase[1..] # Remove the leading dot
|
19
19
|
next unless extensions.include?(ext)
|
20
20
|
|
21
21
|
puts path
|
data/lib/phlexi/menu/builder.rb
CHANGED
@@ -61,11 +61,18 @@ module Phlexi
|
|
61
61
|
@items.size
|
62
62
|
end
|
63
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
|
+
|
64
71
|
# Returns a string representation of the menu structure.
|
65
72
|
#
|
66
73
|
# @return [String] A human-readable representation of the menu
|
67
74
|
def inspect
|
68
|
-
"#<#{self.class} items=#{@items.map(&:
|
75
|
+
"#<#{self.class} items=#{@items.map(&:inspect)}>"
|
69
76
|
end
|
70
77
|
end
|
71
78
|
end
|
@@ -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
|
17
|
+
# item_label: ->(depth) { "text-gray-#{600 + (depth * 100)}" }
|
18
18
|
# })
|
19
19
|
# end
|
20
20
|
# end
|
@@ -31,16 +31,13 @@ module Phlexi
|
|
31
31
|
# @param menu [Phlexi::Menu::Builder] The menu structure to render
|
32
32
|
# @param max_depth [Integer] Maximum nesting depth for menu items
|
33
33
|
# @param options [Hash] Additional options passed to rendering methods
|
34
|
-
def initialize(menu, max_depth:
|
34
|
+
def initialize(menu, max_depth: default_max_depth, **options)
|
35
35
|
@menu = menu
|
36
36
|
@max_depth = max_depth
|
37
37
|
@options = options
|
38
38
|
super()
|
39
39
|
end
|
40
40
|
|
41
|
-
# Renders the menu structure as HTML.
|
42
|
-
#
|
43
|
-
# @return [String] The rendered HTML
|
44
41
|
def view_template
|
45
42
|
nav(class: themed(:nav)) do
|
46
43
|
render_items(@menu.items)
|
@@ -57,7 +54,7 @@ module Phlexi
|
|
57
54
|
return if depth >= @max_depth
|
58
55
|
return if items.empty?
|
59
56
|
|
60
|
-
ul(class: themed(:items_container)) do
|
57
|
+
ul(class: themed(:items_container, depth)) do
|
61
58
|
items.each do |item|
|
62
59
|
render_item_wrapper(item, depth)
|
63
60
|
end
|
@@ -70,114 +67,166 @@ module Phlexi
|
|
70
67
|
# @param depth [Integer] Current nesting depth
|
71
68
|
def render_item_wrapper(item, depth)
|
72
69
|
li(class: tokens(
|
73
|
-
themed(:item_wrapper),
|
74
|
-
|
75
|
-
|
70
|
+
themed(:item_wrapper, depth),
|
71
|
+
item_parent_class(item, depth),
|
72
|
+
active?(item) ? themed(:active, depth) : nil
|
76
73
|
)) do
|
77
|
-
render_item_content(item)
|
78
|
-
render_items(item.items, depth + 1) if item
|
74
|
+
render_item_content(item, depth)
|
75
|
+
render_items(item.items, depth + 1) if nested?(item, depth)
|
79
76
|
end
|
80
77
|
end
|
81
78
|
|
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
|
84
|
+
|
85
|
+
def item_parent_class(item, depth)
|
86
|
+
nested?(item, depth) ? themed(:item_parent, depth) : nil
|
87
|
+
end
|
88
|
+
|
89
|
+
def active?(item)
|
90
|
+
item.active?(self)
|
91
|
+
end
|
92
|
+
|
82
93
|
# Renders the content of a menu item, choosing between link and span.
|
83
94
|
#
|
84
95
|
# @param item [Phlexi::Menu::Item] The item to render content for
|
85
|
-
|
96
|
+
# @param depth [Integer] Current nesting depth
|
97
|
+
def render_item_content(item, depth)
|
86
98
|
if item.url
|
87
|
-
render_item_link(item)
|
99
|
+
render_item_link(item, depth)
|
88
100
|
else
|
89
|
-
render_item_span(item)
|
101
|
+
render_item_span(item, depth)
|
90
102
|
end
|
91
103
|
end
|
92
104
|
|
93
105
|
# Renders a menu item as a link.
|
94
106
|
#
|
95
107
|
# @param item [Phlexi::Menu::Item] The item to render as a link
|
96
|
-
|
97
|
-
|
98
|
-
|
108
|
+
# @param depth [Integer] Current nesting depth
|
109
|
+
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
|
117
|
+
render_item_interior(item, depth)
|
99
118
|
end
|
100
119
|
end
|
101
120
|
|
102
121
|
# Renders a menu item as a span (for non-linking items).
|
103
122
|
#
|
104
123
|
# @param item [Phlexi::Menu::Item] The item to render as a span
|
105
|
-
|
106
|
-
|
107
|
-
|
124
|
+
# @param depth [Integer] Current nesting depth
|
125
|
+
def render_item_span(item, depth)
|
126
|
+
span(class: themed(:item_span, depth)) do
|
127
|
+
render_item_interior(item, depth)
|
108
128
|
end
|
109
129
|
end
|
110
130
|
|
111
131
|
# Renders the interior content of a menu item (badges, icon, label).
|
112
132
|
#
|
113
133
|
# @param item [Phlexi::Menu::Item] The item to render interior content for
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
134
|
+
# @param depth [Integer] Current nesting depth
|
135
|
+
def render_item_interior(item, depth)
|
136
|
+
render_leading_badge(item.leading_badge, depth) if item.leading_badge
|
137
|
+
render_icon(item.icon, depth) if item.icon
|
138
|
+
render_label(item.label, depth)
|
139
|
+
render_trailing_badge(item.trailing_badge, depth) if item.trailing_badge
|
119
140
|
end
|
120
141
|
|
121
142
|
# Renders the item's label.
|
122
143
|
#
|
123
144
|
# @param label [String, Component] The label to render
|
124
|
-
|
145
|
+
# @param depth [Integer] Current nesting depth
|
146
|
+
def render_label(label, depth)
|
125
147
|
phlexi_render(label) {
|
126
|
-
span(class: themed(:item_label)) { label }
|
148
|
+
span(class: themed(:item_label, depth)) { label }
|
127
149
|
}
|
128
150
|
end
|
129
151
|
|
130
152
|
# Renders the item's leading badge.
|
131
153
|
#
|
132
154
|
# @param badge [String, Component] The leading badge to render
|
133
|
-
|
155
|
+
# @param depth [Integer] Current nesting depth
|
156
|
+
def render_leading_badge(badge, depth)
|
134
157
|
phlexi_render(badge) {
|
135
|
-
span(class: themed(:leading_badge)) { badge }
|
158
|
+
span(class: themed(:leading_badge, depth)) { badge }
|
136
159
|
}
|
137
160
|
end
|
138
161
|
|
139
162
|
# Renders the item's trailing badge.
|
140
163
|
#
|
141
164
|
# @param badge [String, Component] The trailing badge to render
|
142
|
-
|
165
|
+
# @param depth [Integer] Current nesting depth
|
166
|
+
def render_trailing_badge(badge, depth)
|
143
167
|
phlexi_render(badge) {
|
144
|
-
span(class: themed(:trailing_badge)) { badge }
|
168
|
+
span(class: themed(:trailing_badge, depth)) { badge }
|
145
169
|
}
|
146
170
|
end
|
147
171
|
|
148
172
|
# Renders the item's icon.
|
149
173
|
#
|
150
174
|
# @param icon [Class] The icon component class to render
|
151
|
-
|
175
|
+
# @param depth [Integer] Current nesting depth
|
176
|
+
def render_icon(icon, depth)
|
152
177
|
return unless icon
|
153
178
|
|
154
|
-
div(class: themed(:icon_wrapper)) do
|
155
|
-
render icon.new(class: themed(:icon))
|
179
|
+
div(class: themed(:icon_wrapper, depth)) do
|
180
|
+
render icon.new(class: themed(:icon, depth))
|
156
181
|
end
|
157
182
|
end
|
158
183
|
|
159
184
|
# Determines the active state class for an item.
|
160
185
|
#
|
161
186
|
# @param item [Phlexi::Menu::Item] The item to check active state for
|
187
|
+
# @param depth [Integer] Current nesting depth
|
162
188
|
# @return [String, nil] The active class name or nil
|
163
|
-
def active_class(item)
|
164
|
-
|
189
|
+
def active_class(item, depth)
|
190
|
+
active?(item) ? themed(:active, depth) : nil
|
191
|
+
end
|
192
|
+
|
193
|
+
# Helper method to check if an item is active
|
194
|
+
#
|
195
|
+
# @param item [Phlexi::Menu::Item] The item to check
|
196
|
+
# @return [Boolean] Whether the item is active
|
197
|
+
def active?(item)
|
198
|
+
item.active?(self)
|
199
|
+
end
|
200
|
+
|
201
|
+
# Determines if an item should be treated as nested based on its contents
|
202
|
+
# and the current depth relative to the maximum allowed depth.
|
203
|
+
#
|
204
|
+
# @param item [Phlexi::Menu::Item] The item to check
|
205
|
+
# @param depth [Integer] Current nesting depth
|
206
|
+
# @return [Boolean] Whether the item should be treated as nested
|
207
|
+
def nested?(item, depth)
|
208
|
+
item.nested? && (depth + 1) < @max_depth
|
165
209
|
end
|
166
210
|
|
167
211
|
# Determines the parent state class for an item.
|
168
212
|
#
|
169
213
|
# @param item [Phlexi::Menu::Item] The item to check parent state for
|
214
|
+
# @param depth [Integer] Current nesting depth
|
170
215
|
# @return [String, nil] The parent class name or nil
|
171
|
-
def item_parent_class(item)
|
172
|
-
item
|
216
|
+
def item_parent_class(item, depth)
|
217
|
+
nested?(item, depth) ? themed(:item_parent, depth) : nil
|
173
218
|
end
|
174
219
|
|
175
220
|
# Resolves a theme component to its CSS classes.
|
176
221
|
#
|
177
222
|
# @param component [Symbol] The theme component to resolve
|
223
|
+
# @param depth [Integer] Current nesting depth
|
178
224
|
# @return [String, nil] The resolved CSS classes or nil
|
179
|
-
def themed(component)
|
180
|
-
self.class::Theme.instance.resolve_theme(component)
|
225
|
+
def themed(component, depth = 0)
|
226
|
+
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)
|
181
230
|
end
|
182
231
|
|
183
232
|
# Renders either a component or simple value with fallback.
|
@@ -197,6 +246,8 @@ module Phlexi
|
|
197
246
|
yield
|
198
247
|
end
|
199
248
|
end
|
249
|
+
|
250
|
+
def default_max_depth = self.class::DEFAULT_MAX_DEPTH
|
200
251
|
end
|
201
252
|
end
|
202
253
|
end
|
data/lib/phlexi/menu/item.rb
CHANGED
@@ -101,18 +101,32 @@ module Phlexi
|
|
101
101
|
@items.any? { |item| item.active?(context) }
|
102
102
|
end
|
103
103
|
|
104
|
+
# Checks if the menu has any items.
|
105
|
+
#
|
106
|
+
# @return [Boolean] true if the menu has no items, false otherwise
|
107
|
+
def empty?
|
108
|
+
@items.empty?
|
109
|
+
end
|
110
|
+
|
111
|
+
# Returns the number of top-level items in the menu.
|
112
|
+
#
|
113
|
+
# @return [Integer] The count of top-level menu items
|
114
|
+
def size
|
115
|
+
@items.size
|
116
|
+
end
|
117
|
+
|
104
118
|
# Checks if this menu item has any nested items.
|
105
119
|
#
|
106
120
|
# @return [Boolean] true if the item has nested items, false otherwise
|
107
121
|
def nested?
|
108
|
-
|
122
|
+
!empty?
|
109
123
|
end
|
110
124
|
|
111
125
|
# Returns a string representation of the menu item.
|
112
126
|
#
|
113
127
|
# @return [String] A human-readable representation of the menu item
|
114
128
|
def inspect
|
115
|
-
"#<#{self.class} label=#{@label} url=#{@url} items=#{@items.map(&:
|
129
|
+
"#<#{self.class} label=#{@label} url=#{@url} items=#{@items.map(&:inspect)}>"
|
116
130
|
end
|
117
131
|
end
|
118
132
|
end
|
data/lib/phlexi/menu/theme.rb
CHANGED
@@ -3,19 +3,34 @@ 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
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
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: nil, # Badge before label
|
29
|
+
trailing_badge: nil, # Badge after label
|
30
|
+
|
31
|
+
# Icon elements
|
32
|
+
icon: nil, # Icon styling
|
33
|
+
icon_wrapper: nil # Icon container
|
19
34
|
}.freeze
|
20
35
|
end
|
21
36
|
end
|
data/lib/phlexi/menu/version.rb
CHANGED