phlexi-menu 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c226e208c70d349bcf13eda150e4762c8ec190f4c406307586d8951fa594517b
4
- data.tar.gz: 8247e9a50e5ba3aa18b23181716e4efdde83c5609ced908fd3a101a820b04f89
3
+ metadata.gz: 40109076a44e4310e8cbc068e809dedfca7b9afc6227b0c7ee96acc5cf72a4c0
4
+ data.tar.gz: e66e40f81ee54a7f785b382679d698ef3faee3262636bed0f37912e7d9522af0
5
5
  SHA512:
6
- metadata.gz: 89dc1111d457ceb9cf21e7163f03a404813489422e367297763f0c0900700b2121810c0bfeb2a2e63458705b649aee0653a9bced8211338e8482a9b86137a194
7
- data.tar.gz: 72379ee9c33bdb2fe05d72b53ed1fb32e07dd90c55935c6f3bf91f7d3838cb4e574a55e6d7f875ac05708ee6619936331295695a41a508ce0551c799ad12913b
6
+ metadata.gz: 19bab094ccaac69456aa7f008bc2b17322b0ffbfc180378103373fb1f07b05d712e7992bd452618c3a283ad04dc89beddd31ed9f2b6839180315bde73c21493c
7
+ data.tar.gz: 0bd4f5d8d65861f622561a8d6cbe01f62a0d17b4866440e3bfda52be935c28d90a07d111e53333318ad1836e2b16f10bf73261be74015cdcfd32d5ff5b4a1c7f
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,7 +5,7 @@
5
5
  },
6
6
  {
7
7
  "path": "/Users/stefan/Documents/plutonium/phlexi-menu/README.md",
8
- "contents": "# Phlexi::Menu\n\nPhlexi::Menu is a flexible and powerful menu builder for Ruby applications. It provides an elegant way to create hierarchical menus with support for icons, badges, and active state detection.\n\n[![Ruby](https://github.com/radioactive-labs/phlexi-menu/actions/workflows/main.yml/badge.svg)](https://github.com/radioactive-labs/phlexi-menu/actions/workflows/main.yml)\n\n## Table of Contents\n\n- [Features](#features)\n- [Prerequisites](#prerequisites)\n- [Installation](#installation)\n- [Usage](#usage)\n - [Basic Usage](#basic-usage)\n - [Menu Items](#menu-items)\n - [Component Options](#component-options)\n - [Theming](#theming)\n - [Badge Components](#badge-components)\n - [Rails Integration](#rails-integration)\n- [Advanced Usage](#advanced-usage)\n - [Component Customization](#component-customization)\n - [Dynamic Menus](#dynamic-menus)\n- [Development](#development)\n- [Contributing](#contributing)\n- [License](#license)\n\n## Features\n\n- Hierarchical menu structure with controlled nesting depth\n- Support for icons and dual-badge system (leading and trailing badges)\n- Intelligent active state detection\n- Flexible theming system\n- Works seamlessly with Phlex components\n- Rails-compatible URL handling\n- Customizable rendering components\n\n## Prerequisites\n\n- Ruby >= 3.2.2\n- Rails (optional, but recommended)\n- Phlex (~> 1.11)\n\n## Installation\n\nAdd this line to your application's Gemfile:\n\n```ruby\ngem 'phlexi-menu'\n```\n\nAnd then execute:\n\n```bash\n$ bundle install\n```\n\n## Usage\n\n### Basic Usage\n\n```ruby\nclass MainMenu < Phlexi::Menu::Component\n class Theme < Theme\n def self.theme\n super.merge({\n nav: \"bg-white shadow\",\n items_container: \"space-y-1\",\n item_wrapper: \"relative\",\n item_link: \"flex items-center px-4 py-2 hover:bg-gray-50\",\n item_span: \"flex items-center px-4 py-2\",\n item_label: \"mx-3\",\n leading_badge: \"mr-2 px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-600\",\n trailing_badge: \"ml-auto px-2 py-0.5 text-xs rounded-full bg-red-100 text-red-600\",\n icon: \"h-5 w-5\",\n active: \"bg-blue-50 text-blue-600\"\n })\n end\n end\nend\n\n# Using the menu\nmenu = Phlexi::Menu::Builder.new do |m|\n m.item \"Dashboard\", \n url: \"/\", \n icon: DashboardIcon\n \n m.item \"Users\", \n url: \"/users\", \n leading_badge: \"Beta\",\n trailing_badge: \"23\" do |users|\n users.item \"All Users\", url: \"/users\"\n users.item \"Add User\", url: \"/users/new\"\n end\n \n m.item \"Settings\", \n url: \"/settings\", \n icon: SettingsIcon,\n leading_badge: CustomBadgeComponent\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\n```\n\n### Rails Integration\n\nIn your controller:\n\n```ruby\nclass ApplicationController < ActionController::Base\n def navigation\n @navigation ||= Phlexi::Menu::Builder.new do |m|\n m.item \"Home\", \n url: root_path, \n icon: HomeIcon\n \n if user_signed_in?\n m.item \"Account\", \n url: account_path,\n trailing_badge: notifications_count do |account|\n account.item \"Profile\", url: profile_path\n account.item \"Settings\", url: settings_path\n account.item \"Logout\", url: logout_path\n end\n end\n\n if current_user&.admin?\n m.item \"Admin\", \n url: admin_path, \n leading_badge: \"Admin\"\n end\n end\n end\n helper_method :navigation\nend\n```\n\nNote: The menu component uses Rails' `current_page?` helper for default active state detection. If you're not using Rails or want custom active state logic, provide an `active` callable to your menu items:\n\n```ruby\nm.item \"Custom Active\", url: \"/path\", active: ->(context) {\n # Your custom active state logic here\n context.request.path.start_with?(\"/path\")\n}\n```\n\n## Advanced Usage\n\n### Component Customization\n\nYou can customize specific rendering steps:\n\n```ruby\nclass CustomMenu < Phlexi::Menu::Component\n # Override just what you need\n def render_item_interior(item)\n div(class: \"flex items-center gap-2\") do\n render_leading_badge(item.leading_badge) if item.leading_badge\n render_icon(item.icon) if item.icon\n span(class: themed(:item_label)) { item.label.upcase }\n render_trailing_badge(item.trailing_badge) if item.trailing_badge\n end\n end\n\n def render_leading_badge(badge)\n div(class: tokens(themed(:leading_badge), \"flex items-center\")) do\n span { \"●\" }\n span(class: \"ml-1\") { badge }\n end\n end\nend\n```\n\nThe component provides these customization points:\n- `render_items`: Handles collection of items and nesting\n- `render_item_wrapper`: Wraps individual items\n- `render_item_content`: Chooses between link and span rendering\n- `render_item_interior`: Handles the item's internal layout\n- `render_leading_badge`: Renders the leading badge\n- `render_trailing_badge`: Renders the trailing badge\n- `render_icon`: Renders the icon component\n\n### Dynamic Menus\n\nExample of building menus based on user permissions:\n\n```ruby\nPhlexi::Menu::Builder.new do |m|\n # Basic items\n m.item \"Home\", url: root_path\n \n # Authorization-based items\n if current_user.can?(:manage, :products)\n m.item \"Products\", url: products_path do |products|\n products.item \"All Products\", url: products_path\n products.item \"Categories\", url: categories_path if current_user.can?(:manage, :categories)\n products.item \"New Product\", url: new_product_path\n end\n end\n \n # Dynamic items from database\n current_user.organizations.each do |org|\n m.item org.name, url: organization_path(org), icon: OrgIcon\n end\nend\n```\n\n## Development\n\nAfter checking out the repo:\n\n1. Run `bin/setup` to install dependencies\n2. Run `bin/appraise install` to install appraisal gemfiles \n3. Run `bin/appraise rake test` to run the tests against all supported versions\n4. You can also run `bin/console` for an interactive prompt\n\nFor development against a single version, you can just use `bundle exec rake test`.\n\n## Contributing\n\nBug reports and pull requests are welcome on GitHub at https://github.com/radioactive-labs/phlexi-menu.\n\n1. Fork it\n2. Create your feature branch (`git checkout -b my-new-feature`)\n3. Commit your changes (`git commit -am 'Add some feature'`)\n4. Push to the branch (`git push origin my-new-feature`)\n5. Create new Pull Request\n\n## License\n\nThe gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT)."
8
+ "contents": "# Phlexi::Menu\n\nPhlexi::Menu is a flexible and powerful menu builder for Ruby applications. It provides an elegant way to create hierarchical menus with support for icons, badges, and active state detection.\n\n[![Ruby](https://github.com/radioactive-labs/phlexi-menu/actions/workflows/main.yml/badge.svg)](https://github.com/radioactive-labs/phlexi-menu/actions/workflows/main.yml)\n\n## Table of Contents\n\n- [Features](#features)\n- [Prerequisites](#prerequisites)\n- [Installation](#installation)\n- [Usage](#usage)\n - [Basic Usage](#basic-usage)\n - [Menu Items](#menu-items)\n - [Component Options](#component-options)\n - [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",
@@ -13,15 +13,15 @@
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 # 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(&:label)}>\"\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: DEFAULT_MAX_DEPTH, **options)\n @menu = menu\n @max_depth = max_depth\n @options = options\n super()\n end\n\n def view_template\n nav(class: themed(:nav)) do\n render_items(@menu.items)\n end\n end\n\n protected\n\n # Base implementation handles nesting and delegates individual item rendering\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 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 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 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 def render_item_span(item)\n span(class: themed(:item_span)) do\n render_item_interior(item)\n end\n end\n\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 def render_label(label)\n phlexi_render(label) {\n span(class: themed(:item_label)) { label }\n }\n end\n\n def render_leading_badge(badge)\n phlexi_render(badge) {\n span(class: themed(:leading_badge)) { badge.to_s }\n }\n end\n\n def render_trailing_badge(badge)\n phlexi_render(badge) {\n span(class: themed(:trailing_badge)) { badge.to_s }\n }\n end\n\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 def active_class(item)\n item.active?(context) ? themed(:active) : nil\n end\n\n def item_parent_class(item)\n item.items.any? ? themed(:item_parent) : nil\n end\n\n def themed(component)\n self.class::Theme.instance.resolve_theme(component)\n end\n\n def phlexi_render(arg, &)\n return unless arg\n raise ArgumentError, \"phlexi_render requires a default render block\" unless block_given?\n\n # Handle Phlex components or Rails Renderables\n if arg.class < Phlex::SGML || arg.respond_to?(:render_in)\n render arg\n # Handle procs\n elsif arg.respond_to?(:to_proc)\n instance_exec(&arg)\n else\n yield\n end\n end\n end\n end\nend\n"
20
+ "contents": "# frozen_string_literal: true\n\nrequire \"phlex\"\n\nmodule Phlexi\n module Menu\n # Base menu component that other menu renderers can inherit from.\n # Provides the core rendering logic for hierarchical menus with support\n # for theming, icons, badges, and active state detection.\n #\n # @example Basic usage\n # class MyMenu < Phlexi::Menu::Component\n # class Theme < Theme\n # def self.theme\n # super.merge({\n # nav: \"bg-white shadow\",\n # item_label: \"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?(context) ? 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 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, :items, :options\n\n def initialize(label, url: nil, icon: nil, leading_badge: nil, trailing_badge: nil, **options, &)\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 def item(label, **, &)\n new_item = self.class.new(label, **, &)\n @items << new_item\n new_item\n end\n\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 end\n end\nend\n"
24
+ "contents": "# frozen_string_literal: true\n\nmodule Phlexi\n module Menu\n # Represents a single menu item in the navigation hierarchy.\n # Each item can have a label, URL, icon, badges, and nested child items.\n #\n # @example Basic menu item\n # item = Item.new(\"Home\", url: \"/\")\n #\n # @example Menu item with badges and icon\n # item = Item.new(\"Products\",\n # url: \"/products\",\n # icon: ProductIcon,\n # leading_badge: \"New\",\n # trailing_badge: \"5\")\n #\n # @example Nested menu items\n # item = Item.new(\"Admin\") do |admin|\n # admin.item \"Users\", url: \"/admin/users\"\n # admin.item \"Settings\", url: \"/admin/settings\"\n # end\n class Item\n # @return [String] The display text for the menu item\n attr_reader :label\n\n # @return [String, nil] The URL the menu item links to\n attr_reader :url\n\n # @return [Class, nil] The icon component class to be rendered\n attr_reader :icon\n\n # @return [String, Component, nil] The badge displayed before the label\n attr_reader :leading_badge\n\n # @return [String, Component, nil] The badge displayed after the label\n attr_reader :trailing_badge\n\n # @return [Array<Item>] Collection of nested menu items\n attr_reader :items\n\n # @return [Hash] Additional options for customizing the menu item\n attr_reader :options\n\n # Initializes a new menu item.\n #\n # @param label [String] The display text for the menu item\n # @param url [String, nil] The URL the menu item links to\n # @param icon [Class, nil] The icon component class\n # @param leading_badge [String, Component, nil] Badge displayed before the label\n # @param trailing_badge [String, Component, nil] Badge displayed after the label\n # @param options [Hash] Additional options (e.g., :active for custom active state logic)\n # @yield [item] Optional block for adding nested items\n # @yieldparam item [Item] The newly created menu item\n # @raise [ArgumentError] If the label is nil or empty\n def initialize(label, url: nil, icon: nil, leading_badge: nil, trailing_badge: nil, **options, &)\n raise ArgumentError, \"Label cannot be nil\" unless label\n\n @label = label\n @url = url\n @icon = icon\n @leading_badge = leading_badge\n @trailing_badge = trailing_badge\n @options = options\n @items = []\n\n yield self if block_given?\n end\n\n # Creates and adds a nested menu item.\n #\n # @param label [String] The display text for the nested item\n # @param ** [Hash] Additional options passed to the Item constructor\n # @yield [item] Optional block for adding further nested items\n # @yieldparam item [Item] The newly created nested item\n # @return [Item] The created nested item\n def item(label, **, &)\n new_item = self.class.new(label, **, &)\n @items << new_item\n new_item\n end\n\n # Determines if this menu item should be shown as active.\n # Checks in the following order:\n # 1. Custom active logic if provided in options\n # 2. URL match with current page\n # 3. Active state of any child items\n #\n # @param context [Object] The context object (typically a controller) for active state checking\n # @return [Boolean] true if the item should be shown as active, false otherwise\n def active?(context)\n # First check custom active logic if provided\n return @options[:active].call(context) if @options[:active].respond_to?(:call)\n\n # Then check if this item's URL matches current page\n if context.respond_to?(:helpers) && @url\n return true if context.helpers.current_page?(@url)\n end\n\n # Finally check if any child items are active\n @items.any? { |item| item.active?(context) }\n end\n\n # 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 !@items.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(&:label)}>\"\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.1\"\n end\nend\n"
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\n def self.object_primary_key(object)\n if object.class.respond_to?(:primary_key)\n object.send(object.class.primary_key.to_sym)\n elsif object.respond_to?(:id)\n object.id\n end\n end\n end\nend\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",
@@ -2,22 +2,71 @@
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
+ # Returns a string representation of the menu structure.
65
+ #
66
+ # @return [String] A human-readable representation of the menu
67
+ def inspect
68
+ "#<#{self.class} items=#{@items.map(&:label)}>"
69
+ end
21
70
  end
22
71
  end
23
72
  end
@@ -4,12 +4,33 @@ 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
 
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
13
34
  def initialize(menu, max_depth: DEFAULT_MAX_DEPTH, **options)
14
35
  @menu = menu
15
36
  @max_depth = max_depth
@@ -17,6 +38,9 @@ module Phlexi
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
- # Base implementation handles nesting and delegates individual item rendering
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,29 +156,41 @@ 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
164
  item.active?(context) ? 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
@@ -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
- attr_reader :label, :url, :icon, :leading_badge, :trailing_badge, :items, :options
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,20 @@ 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 this menu item has any nested items.
105
+ #
106
+ # @return [Boolean] true if the item has nested items, false otherwise
107
+ def nested?
108
+ !@items.empty?
109
+ end
110
+
111
+ # Returns a string representation of the menu item.
112
+ #
113
+ # @return [String] A human-readable representation of the menu item
114
+ def inspect
115
+ "#<#{self.class} label=#{@label} url=#{@url} items=#{@items.map(&:label)}>"
116
+ end
38
117
  end
39
118
  end
40
119
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlexi
4
4
  module Menu
5
- VERSION = "0.0.1"
5
+ VERSION = "0.0.2"
6
6
  end
7
7
  end
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.1
4
+ version: 0.0.2
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-10 00:00:00.000000000 Z
11
+ date: 2024-12-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: phlex