phlexi-menu 0.0.1 → 0.0.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/export.json +8 -8
- data/export.rb +1 -1
- data/lib/phlexi/menu/builder.rb +56 -0
- data/lib/phlexi/menu/component.rb +78 -9
- data/lib/phlexi/menu/item.rb +94 -1
- data/lib/phlexi/menu/version.rb +1 -1
- data/lib/phlexi/menu.rb +0 -8
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7cc4d5883a4c76ae7455de7f38c33639dccee507b1406549a00e1f211906020e
|
4
|
+
data.tar.gz: 23332831a113a1dc53515afe6449861cb8282b2f1ecd2f4dfde1eaf427cb0d52
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ab3e7aad92d888a87599f33fcffa5e52c413e8b7bffaba4852e4d07e2fdd65e1b3dbf3f031542f95995a78fbac3fd29cf4609db7c332581af23249f7b07f84c2
|
7
|
+
data.tar.gz: bd393a7fa7bdc00f2c20ee7227bbde090584d87f2c310cdf07d77c1cf4607871142860223f1ca267bfeedfb40bb724b3d88f04df89ef3f4eac6c80a62c59cf2a
|
data/README.md
CHANGED
@@ -94,7 +94,7 @@ menu = Phlexi::Menu::Builder.new do |m|
|
|
94
94
|
m.item "Settings",
|
95
95
|
url: "/settings",
|
96
96
|
icon: SettingsIcon,
|
97
|
-
leading_badge: CustomBadgeComponent
|
97
|
+
leading_badge: CustomBadgeComponent.new
|
98
98
|
end
|
99
99
|
|
100
100
|
# In your view
|
@@ -166,7 +166,7 @@ class CustomBadgeComponent < ApplicationComponent
|
|
166
166
|
end
|
167
167
|
|
168
168
|
# Usage
|
169
|
-
m.item "Products", leading_badge: CustomBadgeComponent
|
169
|
+
m.item "Products", leading_badge: CustomBadgeComponent.new
|
170
170
|
```
|
171
171
|
|
172
172
|
### Rails Integration
|
data/export.json
CHANGED
@@ -5,23 +5,23 @@
|
|
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\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,
|
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)."
|
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 class Builder\n attr_reader :items\n\n class Item < Phlexi::Menu::Item; end\n\n def initialize(&)\n @items = []\n\n yield self if block_given?\n end\n\n def item(label, **, &)\n new_item = self.class::Item.new(label, **, &)\n @items << new_item\n new_item\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 # 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\nmodule Phlexi\n module Menu\n # Base menu component that other menu renderers can inherit from\n class Component < COMPONENT_BASE\n class Theme < Phlexi::Menu::Theme; end\n\n DEFAULT_MAX_DEPTH = 3\n\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 class Item\n attr_reader :label, :url, :icon, :leading_badge, :trailing_badge
|
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",
|
@@ -29,11 +29,11 @@
|
|
29
29
|
},
|
30
30
|
{
|
31
31
|
"path": "/Users/stefan/Documents/plutonium/phlexi-menu/lib/phlexi/menu/version.rb",
|
32
|
-
"contents": "# frozen_string_literal: true\n\nmodule Phlexi\n module Menu\n VERSION = \"0.0.
|
32
|
+
"contents": "# frozen_string_literal: true\n\nmodule Phlexi\n module Menu\n VERSION = \"0.0.2\"\n end\nend\n"
|
33
33
|
},
|
34
34
|
{
|
35
35
|
"path": "/Users/stefan/Documents/plutonium/phlexi-menu/lib/phlexi/menu.rb",
|
36
|
-
"contents": "# frozen_string_literal: true\n\nrequire \"zeitwerk\"\nrequire \"phlex\"\nrequire \"active_support/core_ext/object/blank\"\n\nmodule Phlexi\n NIL_VALUE = :__i_phlexi_i__\n\n module Menu\n Loader = Zeitwerk::Loader.new.tap do |loader|\n loader.tag = File.basename(__FILE__, \".rb\")\n loader.ignore(\"#{__dir__}/menu/version.rb\")\n loader.inflector.inflect(\n \"phlexi-menu\" => \"Phlexi\",\n \"phlexi\" => \"Phlexi\"\n )\n loader.push_dir(File.expand_path(\"..\", __dir__))\n loader.setup\n end\n\n COMPONENT_BASE = (defined?(::ApplicationComponent) ? ::ApplicationComponent : Phlex::HTML)\n\n class Error < StandardError; end\n
|
36
|
+
"contents": "# frozen_string_literal: true\n\nrequire \"zeitwerk\"\nrequire \"phlex\"\nrequire \"active_support/core_ext/object/blank\"\n\nmodule Phlexi\n NIL_VALUE = :__i_phlexi_i__\n\n module Menu\n Loader = Zeitwerk::Loader.new.tap do |loader|\n loader.tag = File.basename(__FILE__, \".rb\")\n loader.ignore(\"#{__dir__}/menu/version.rb\")\n loader.inflector.inflect(\n \"phlexi-menu\" => \"Phlexi\",\n \"phlexi\" => \"Phlexi\"\n )\n loader.push_dir(File.expand_path(\"..\", __dir__))\n loader.setup\n end\n\n COMPONENT_BASE = (defined?(::ApplicationComponent) ? ::ApplicationComponent : Phlex::HTML)\n\n class Error < StandardError; end\n end\nend\n"
|
37
37
|
},
|
38
38
|
{
|
39
39
|
"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\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 assert_equal TestComponent, all_products.leading_badge\n end\n\n def test_menu_rendering\n render TestMenu.new(@menu)\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 assert has_css?(\"#{all_products} .test-leading-badge\", text: \"Phlexi::MenuTest::TestComponent\")\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 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"
|
77
77
|
},
|
78
78
|
{
|
79
79
|
"path": "/Users/stefan/Documents/plutonium/phlexi-menu/test/test_helper.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
@@ -2,22 +2,78 @@
|
|
2
2
|
|
3
3
|
module Phlexi
|
4
4
|
module Menu
|
5
|
+
# Builder class for constructing hierarchical menu structures.
|
6
|
+
# Provides a DSL for creating nested menu items with support for labels,
|
7
|
+
# URLs, icons, and badges.
|
8
|
+
#
|
9
|
+
# @example Basic usage
|
10
|
+
# menu = Phlexi::Menu::Builder.new do |m|
|
11
|
+
# m.item "Home", url: "/"
|
12
|
+
# m.item "Products", url: "/products" do |products|
|
13
|
+
# products.item "All Products", url: "/products"
|
14
|
+
# products.item "Add Product", url: "/products/new"
|
15
|
+
# end
|
16
|
+
# end
|
5
17
|
class Builder
|
18
|
+
# @return [Array<Phlexi::Menu::Item>] The collection of top-level menu items
|
6
19
|
attr_reader :items
|
7
20
|
|
21
|
+
# Nested Item class that inherits from Phlexi::Menu::Item
|
8
22
|
class Item < Phlexi::Menu::Item; end
|
9
23
|
|
24
|
+
# Initializes a new menu builder.
|
25
|
+
#
|
26
|
+
# @yield [builder] Passes the builder instance to the block for menu construction
|
27
|
+
# @yieldparam builder [Phlexi::Menu::Builder] The builder instance
|
10
28
|
def initialize(&)
|
11
29
|
@items = []
|
12
30
|
|
13
31
|
yield self if block_given?
|
14
32
|
end
|
15
33
|
|
34
|
+
# Creates and adds a new menu item to the current menu level.
|
35
|
+
#
|
36
|
+
# @param label [String] The display text for the menu item
|
37
|
+
# @param ** [Hash] Additional options passed to the Item constructor
|
38
|
+
# @yield [item] Optional block for adding nested menu items
|
39
|
+
# @yieldparam item [Phlexi::Menu::Item] The newly created menu item
|
40
|
+
# @return [Phlexi::Menu::Item] The created menu item
|
41
|
+
# @raise [ArgumentError] If the label is nil
|
16
42
|
def item(label, **, &)
|
43
|
+
raise ArgumentError, "Label cannot be nil" unless label
|
44
|
+
|
17
45
|
new_item = self.class::Item.new(label, **, &)
|
18
46
|
@items << new_item
|
19
47
|
new_item
|
20
48
|
end
|
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
|
+
# Returns a string representation of the menu structure.
|
72
|
+
#
|
73
|
+
# @return [String] A human-readable representation of the menu
|
74
|
+
def inspect
|
75
|
+
"#<#{self.class} items=#{@items.map(&:inspect)}>"
|
76
|
+
end
|
21
77
|
end
|
22
78
|
end
|
23
79
|
end
|
@@ -4,19 +4,43 @@ require "phlex"
|
|
4
4
|
|
5
5
|
module Phlexi
|
6
6
|
module Menu
|
7
|
-
# Base menu component that other menu renderers can inherit from
|
7
|
+
# Base menu component that other menu renderers can inherit from.
|
8
|
+
# Provides the core rendering logic for hierarchical menus with support
|
9
|
+
# for theming, icons, badges, and active state detection.
|
10
|
+
#
|
11
|
+
# @example Basic usage
|
12
|
+
# class MyMenu < Phlexi::Menu::Component
|
13
|
+
# class Theme < Theme
|
14
|
+
# def self.theme
|
15
|
+
# super.merge({
|
16
|
+
# nav: "bg-white shadow",
|
17
|
+
# item_label: "text-gray-600"
|
18
|
+
# })
|
19
|
+
# end
|
20
|
+
# end
|
21
|
+
# end
|
8
22
|
class Component < COMPONENT_BASE
|
23
|
+
# Theme class for customizing menu appearance
|
9
24
|
class Theme < Phlexi::Menu::Theme; end
|
10
25
|
|
26
|
+
# @return [Integer] The default maximum nesting depth for menu items
|
11
27
|
DEFAULT_MAX_DEPTH = 3
|
12
28
|
|
13
|
-
|
29
|
+
# Initializes a new menu component.
|
30
|
+
#
|
31
|
+
# @param menu [Phlexi::Menu::Builder] The menu structure to render
|
32
|
+
# @param max_depth [Integer] Maximum nesting depth for menu items
|
33
|
+
# @param options [Hash] Additional options passed to rendering methods
|
34
|
+
def initialize(menu, max_depth: default_max_depth, **options)
|
14
35
|
@menu = menu
|
15
36
|
@max_depth = max_depth
|
16
37
|
@options = options
|
17
38
|
super()
|
18
39
|
end
|
19
40
|
|
41
|
+
# Renders the menu structure as HTML.
|
42
|
+
#
|
43
|
+
# @return [String] The rendered HTML
|
20
44
|
def view_template
|
21
45
|
nav(class: themed(:nav)) do
|
22
46
|
render_items(@menu.items)
|
@@ -25,7 +49,10 @@ module Phlexi
|
|
25
49
|
|
26
50
|
protected
|
27
51
|
|
28
|
-
#
|
52
|
+
# Renders a collection of menu items with nesting support.
|
53
|
+
#
|
54
|
+
# @param items [Array<Phlexi::Menu::Item>] The items to render
|
55
|
+
# @param depth [Integer] Current nesting depth
|
29
56
|
def render_items(items, depth = 0)
|
30
57
|
return if depth >= @max_depth
|
31
58
|
return if items.empty?
|
@@ -37,6 +64,10 @@ module Phlexi
|
|
37
64
|
end
|
38
65
|
end
|
39
66
|
|
67
|
+
# Renders the wrapper element for a menu item.
|
68
|
+
#
|
69
|
+
# @param item [Phlexi::Menu::Item] The item to wrap
|
70
|
+
# @param depth [Integer] Current nesting depth
|
40
71
|
def render_item_wrapper(item, depth)
|
41
72
|
li(class: tokens(
|
42
73
|
themed(:item_wrapper),
|
@@ -48,6 +79,9 @@ module Phlexi
|
|
48
79
|
end
|
49
80
|
end
|
50
81
|
|
82
|
+
# Renders the content of a menu item, choosing between link and span.
|
83
|
+
#
|
84
|
+
# @param item [Phlexi::Menu::Item] The item to render content for
|
51
85
|
def render_item_content(item)
|
52
86
|
if item.url
|
53
87
|
render_item_link(item)
|
@@ -56,18 +90,27 @@ module Phlexi
|
|
56
90
|
end
|
57
91
|
end
|
58
92
|
|
93
|
+
# Renders a menu item as a link.
|
94
|
+
#
|
95
|
+
# @param item [Phlexi::Menu::Item] The item to render as a link
|
59
96
|
def render_item_link(item)
|
60
97
|
a(href: item.url, class: themed(:item_link)) do
|
61
98
|
render_item_interior(item)
|
62
99
|
end
|
63
100
|
end
|
64
101
|
|
102
|
+
# Renders a menu item as a span (for non-linking items).
|
103
|
+
#
|
104
|
+
# @param item [Phlexi::Menu::Item] The item to render as a span
|
65
105
|
def render_item_span(item)
|
66
106
|
span(class: themed(:item_span)) do
|
67
107
|
render_item_interior(item)
|
68
108
|
end
|
69
109
|
end
|
70
110
|
|
111
|
+
# Renders the interior content of a menu item (badges, icon, label).
|
112
|
+
#
|
113
|
+
# @param item [Phlexi::Menu::Item] The item to render interior content for
|
71
114
|
def render_item_interior(item)
|
72
115
|
render_leading_badge(item.leading_badge) if item.leading_badge
|
73
116
|
render_icon(item.icon) if item.icon
|
@@ -75,24 +118,36 @@ module Phlexi
|
|
75
118
|
render_trailing_badge(item.trailing_badge) if item.trailing_badge
|
76
119
|
end
|
77
120
|
|
121
|
+
# Renders the item's label.
|
122
|
+
#
|
123
|
+
# @param label [String, Component] The label to render
|
78
124
|
def render_label(label)
|
79
125
|
phlexi_render(label) {
|
80
126
|
span(class: themed(:item_label)) { label }
|
81
127
|
}
|
82
128
|
end
|
83
129
|
|
130
|
+
# Renders the item's leading badge.
|
131
|
+
#
|
132
|
+
# @param badge [String, Component] The leading badge to render
|
84
133
|
def render_leading_badge(badge)
|
85
134
|
phlexi_render(badge) {
|
86
135
|
span(class: themed(:leading_badge)) { badge }
|
87
136
|
}
|
88
137
|
end
|
89
138
|
|
139
|
+
# Renders the item's trailing badge.
|
140
|
+
#
|
141
|
+
# @param badge [String, Component] The trailing badge to render
|
90
142
|
def render_trailing_badge(badge)
|
91
143
|
phlexi_render(badge) {
|
92
144
|
span(class: themed(:trailing_badge)) { badge }
|
93
145
|
}
|
94
146
|
end
|
95
147
|
|
148
|
+
# Renders the item's icon.
|
149
|
+
#
|
150
|
+
# @param icon [Class] The icon component class to render
|
96
151
|
def render_icon(icon)
|
97
152
|
return unless icon
|
98
153
|
|
@@ -101,35 +156,49 @@ module Phlexi
|
|
101
156
|
end
|
102
157
|
end
|
103
158
|
|
159
|
+
# Determines the active state class for an item.
|
160
|
+
#
|
161
|
+
# @param item [Phlexi::Menu::Item] The item to check active state for
|
162
|
+
# @return [String, nil] The active class name or nil
|
104
163
|
def active_class(item)
|
105
|
-
item.active?(
|
164
|
+
item.active?(self) ? themed(:active) : nil
|
106
165
|
end
|
107
166
|
|
167
|
+
# Determines the parent state class for an item.
|
168
|
+
#
|
169
|
+
# @param item [Phlexi::Menu::Item] The item to check parent state for
|
170
|
+
# @return [String, nil] The parent class name or nil
|
108
171
|
def item_parent_class(item)
|
109
172
|
item.items.any? ? themed(:item_parent) : nil
|
110
173
|
end
|
111
174
|
|
175
|
+
# Resolves a theme component to its CSS classes.
|
176
|
+
#
|
177
|
+
# @param component [Symbol] The theme component to resolve
|
178
|
+
# @return [String, nil] The resolved CSS classes or nil
|
112
179
|
def themed(component)
|
113
180
|
self.class::Theme.instance.resolve_theme(component)
|
114
181
|
end
|
115
182
|
|
183
|
+
# Renders either a component or simple value with fallback.
|
184
|
+
#
|
185
|
+
# @param arg [Object] The value to render
|
186
|
+
# @yield The default rendering block
|
187
|
+
# @raise [ArgumentError] If no block is provided
|
116
188
|
def phlexi_render(arg, &)
|
117
189
|
return unless arg
|
118
190
|
raise ArgumentError, "phlexi_render requires a default render block" unless block_given?
|
119
191
|
|
120
|
-
# Handle Phlex components or Rails Renderables
|
121
|
-
# if arg.is_a?(Class) && (arg < Phlex::SGML || arg.respond_to?(:render_in))
|
122
|
-
# render arg.new
|
123
|
-
# els
|
124
192
|
if arg.class < Phlex::SGML || arg.respond_to?(:render_in)
|
125
193
|
render arg
|
126
|
-
# Handle procs
|
127
194
|
elsif arg.respond_to?(:to_proc)
|
128
195
|
instance_exec(&arg)
|
129
196
|
else
|
130
197
|
yield
|
131
198
|
end
|
132
199
|
end
|
200
|
+
|
201
|
+
def default_max_depth = self.class::DEFAULT_MAX_DEPTH
|
133
202
|
end
|
134
203
|
end
|
135
204
|
end
|
data/lib/phlexi/menu/item.rb
CHANGED
@@ -2,10 +2,60 @@
|
|
2
2
|
|
3
3
|
module Phlexi
|
4
4
|
module Menu
|
5
|
+
# Represents a single menu item in the navigation hierarchy.
|
6
|
+
# Each item can have a label, URL, icon, badges, and nested child items.
|
7
|
+
#
|
8
|
+
# @example Basic menu item
|
9
|
+
# item = Item.new("Home", url: "/")
|
10
|
+
#
|
11
|
+
# @example Menu item with badges and icon
|
12
|
+
# item = Item.new("Products",
|
13
|
+
# url: "/products",
|
14
|
+
# icon: ProductIcon,
|
15
|
+
# leading_badge: "New",
|
16
|
+
# trailing_badge: "5")
|
17
|
+
#
|
18
|
+
# @example Nested menu items
|
19
|
+
# item = Item.new("Admin") do |admin|
|
20
|
+
# admin.item "Users", url: "/admin/users"
|
21
|
+
# admin.item "Settings", url: "/admin/settings"
|
22
|
+
# end
|
5
23
|
class Item
|
6
|
-
|
24
|
+
# @return [String] The display text for the menu item
|
25
|
+
attr_reader :label
|
7
26
|
|
27
|
+
# @return [String, nil] The URL the menu item links to
|
28
|
+
attr_reader :url
|
29
|
+
|
30
|
+
# @return [Class, nil] The icon component class to be rendered
|
31
|
+
attr_reader :icon
|
32
|
+
|
33
|
+
# @return [String, Component, nil] The badge displayed before the label
|
34
|
+
attr_reader :leading_badge
|
35
|
+
|
36
|
+
# @return [String, Component, nil] The badge displayed after the label
|
37
|
+
attr_reader :trailing_badge
|
38
|
+
|
39
|
+
# @return [Array<Item>] Collection of nested menu items
|
40
|
+
attr_reader :items
|
41
|
+
|
42
|
+
# @return [Hash] Additional options for customizing the menu item
|
43
|
+
attr_reader :options
|
44
|
+
|
45
|
+
# Initializes a new menu item.
|
46
|
+
#
|
47
|
+
# @param label [String] The display text for the menu item
|
48
|
+
# @param url [String, nil] The URL the menu item links to
|
49
|
+
# @param icon [Class, nil] The icon component class
|
50
|
+
# @param leading_badge [String, Component, nil] Badge displayed before the label
|
51
|
+
# @param trailing_badge [String, Component, nil] Badge displayed after the label
|
52
|
+
# @param options [Hash] Additional options (e.g., :active for custom active state logic)
|
53
|
+
# @yield [item] Optional block for adding nested items
|
54
|
+
# @yieldparam item [Item] The newly created menu item
|
55
|
+
# @raise [ArgumentError] If the label is nil or empty
|
8
56
|
def initialize(label, url: nil, icon: nil, leading_badge: nil, trailing_badge: nil, **options, &)
|
57
|
+
raise ArgumentError, "Label cannot be nil" unless label
|
58
|
+
|
9
59
|
@label = label
|
10
60
|
@url = url
|
11
61
|
@icon = icon
|
@@ -17,12 +67,27 @@ module Phlexi
|
|
17
67
|
yield self if block_given?
|
18
68
|
end
|
19
69
|
|
70
|
+
# Creates and adds a nested menu item.
|
71
|
+
#
|
72
|
+
# @param label [String] The display text for the nested item
|
73
|
+
# @param ** [Hash] Additional options passed to the Item constructor
|
74
|
+
# @yield [item] Optional block for adding further nested items
|
75
|
+
# @yieldparam item [Item] The newly created nested item
|
76
|
+
# @return [Item] The created nested item
|
20
77
|
def item(label, **, &)
|
21
78
|
new_item = self.class.new(label, **, &)
|
22
79
|
@items << new_item
|
23
80
|
new_item
|
24
81
|
end
|
25
82
|
|
83
|
+
# Determines if this menu item should be shown as active.
|
84
|
+
# Checks in the following order:
|
85
|
+
# 1. Custom active logic if provided in options
|
86
|
+
# 2. URL match with current page
|
87
|
+
# 3. Active state of any child items
|
88
|
+
#
|
89
|
+
# @param context [Object] The context object (typically a controller) for active state checking
|
90
|
+
# @return [Boolean] true if the item should be shown as active, false otherwise
|
26
91
|
def active?(context)
|
27
92
|
# First check custom active logic if provided
|
28
93
|
return @options[:active].call(context) if @options[:active].respond_to?(:call)
|
@@ -35,6 +100,34 @@ module Phlexi
|
|
35
100
|
# Finally check if any child items are active
|
36
101
|
@items.any? { |item| item.active?(context) }
|
37
102
|
end
|
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
|
+
|
118
|
+
# Checks if this menu item has any nested items.
|
119
|
+
#
|
120
|
+
# @return [Boolean] true if the item has nested items, false otherwise
|
121
|
+
def nested?
|
122
|
+
!empty?
|
123
|
+
end
|
124
|
+
|
125
|
+
# Returns a string representation of the menu item.
|
126
|
+
#
|
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)}>"
|
130
|
+
end
|
38
131
|
end
|
39
132
|
end
|
40
133
|
end
|
data/lib/phlexi/menu/version.rb
CHANGED
data/lib/phlexi/menu.rb
CHANGED
@@ -22,13 +22,5 @@ module Phlexi
|
|
22
22
|
COMPONENT_BASE = (defined?(::ApplicationComponent) ? ::ApplicationComponent : Phlex::HTML)
|
23
23
|
|
24
24
|
class Error < StandardError; end
|
25
|
-
|
26
|
-
def self.object_primary_key(object)
|
27
|
-
if object.class.respond_to?(:primary_key)
|
28
|
-
object.send(object.class.primary_key.to_sym)
|
29
|
-
elsif object.respond_to?(:id)
|
30
|
-
object.id
|
31
|
-
end
|
32
|
-
end
|
33
25
|
end
|
34
26
|
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.
|
4
|
+
version: 0.0.3
|
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
|
+
date: 2024-12-11 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: phlex
|