phlexi-menu 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 77a15ed27fda6bba3dafc46555f7f796b94a18982056fb3898d73393539a02c1
4
- data.tar.gz: d91ecb5053d3935209c2c68099b5615db195994da9f1ae9236bc5a9f993267e5
3
+ metadata.gz: 67db7c06ba7a591ed8c52c6bc53213c1d6b6b13062e95e2cd8a9604f28eb25d3
4
+ data.tar.gz: 7ee704c205e923d0c0ab6bce59231440d8b9beb1b96698a71d4643e74ba7c2a9
5
5
  SHA512:
6
- metadata.gz: bc8f8b414638e5f5960f763978f3cb6e3c59ec7e745e6def67cf53a7d77c715e6b2331f3dd42aa4631279d51f06844cb2ca331557671f5fcee2128c9fc2c3f7d
7
- data.tar.gz: 0d4c58a1ce2738b39ab32497a00688d0290293b844c43539418fa051339d9ba52439a87aeb59f8bcc112978e57a7f9d8cf7feb17880dcc509a0d4c455156b325
6
+ metadata.gz: 7b32f2be963081aeb878034e1f5451c3117d1ba705608ff95262a963768bfa574de550de68bb452ae7111bcf7b165127156955b72ecd07b48a1ac8cae75a9035
7
+ data.tar.gz: efeec0d06636e4899bc43f5b25c1336e0d87f0b40d0f34492d1505b2e09844fd4fa8b8bac805d637634bbfac11a38da891dce4cfdf4a7e16ef1461fe260880b6
data/Appraisals CHANGED
@@ -1,5 +1,5 @@
1
1
  appraise "default" do
2
- # gem "phlex", "~> 1.10"
2
+ gem "phlex", "~> 2.0"
3
3
  end
4
4
 
5
5
  appraise "rails-7" do
@@ -80,11 +80,11 @@ module Phlexi
80
80
  # @param depth [Integer] Current nesting depth
81
81
  # @return [String] Space-separated CSS classes
82
82
  def compute_item_wrapper_classes(item, depth)
83
- tokens(
84
- themed(:item_wrapper, depth),
85
- item_parent_class(item, depth),
86
- active?(item) ? themed(:active, depth) : nil
87
- )
83
+ wrapper = themed(:item_wrapper, depth)
84
+ parent = item_parent_class(item, depth)
85
+ active = active?(item) ? themed(:active, depth) : nil
86
+
87
+ [wrapper, parent, active].compact.join(" ")
88
88
  end
89
89
 
90
90
  # Renders nested items if present and within depth limit
@@ -115,10 +115,11 @@ module Phlexi
115
115
  # @param depth [Integer] Current nesting depth
116
116
  # @return [void]
117
117
  def render_item_link(item, depth)
118
- a(
119
- href: item.url,
120
- class: tokens(themed(:item_link, depth), active_class(item, depth))
121
- ) do
118
+ link_class = themed(:item_link, depth)
119
+ active = active_class(item, depth)
120
+ classes = active ? "#{link_class} #{active}" : link_class
121
+
122
+ a(href: item.url, class: classes) do
122
123
  render_item_interior(item, depth)
123
124
  end
124
125
  end
@@ -251,26 +252,26 @@ module Phlexi
251
252
  #
252
253
  # @param component [Symbol] The theme component to resolve
253
254
  # @param depth [Integer] Current nesting depth
254
- # @return [String, nil] The resolved CSS classes or nil
255
+ # @return [String] The resolved CSS classes
255
256
  def themed(component, depth = 0)
256
257
  theme = self.class::Theme.instance.resolve_theme(component)
257
- return nil if theme.nil?
258
- return theme unless theme.respond_to?(:call)
259
- theme.call(depth)
258
+ theme.is_a?(Proc) ? theme.call(depth) : theme
260
259
  end
261
260
 
262
- # Renders either a component or simple value with fallback.
261
+ # Helper method to render content with proper handling of different types
263
262
  #
264
- # @param arg [Object] The value to render
265
- # @yield The default rendering block
263
+ # @param arg [Object] The content to render
264
+ # @yield Block to render if arg is nil
266
265
  # @raise [ArgumentError] If no block is provided
267
266
  # @return [void]
268
267
  def phlexi_render(arg, &)
269
268
  return unless arg
270
269
  raise ArgumentError, "phlexi_render requires a default render block" unless block_given?
271
270
 
271
+ # Handle Phlex components or Rails Renderables
272
272
  if arg.class < Phlex::SGML || arg.respond_to?(:render_in)
273
273
  render arg
274
+ # Handle procs
274
275
  elsif arg.respond_to?(:to_proc)
275
276
  instance_exec(&arg)
276
277
  else
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlexi
4
4
  module Menu
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlexi-menu
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Froelich
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-12-12 00:00:00.000000000 Z
11
+ date: 2025-05-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: phlex
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '1.11'
19
+ version: '2.0'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '1.11'
26
+ version: '2.0'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: zeitwerk
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -42,16 +42,16 @@ dependencies:
42
42
  name: phlexi-field
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
- - - ">="
45
+ - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: '0'
47
+ version: 0.1.0
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
- - - ">="
52
+ - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: '0'
54
+ version: 0.1.0
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: rake
57
57
  requirement: !ruby/object:Gem::Requirement
@@ -182,8 +182,6 @@ files:
182
182
  - Rakefile
183
183
  - changes.patch
184
184
  - config.ru
185
- - export.json
186
- - export.rb
187
185
  - gemfiles/default.gemfile
188
186
  - gemfiles/default.gemfile.lock
189
187
  - gemfiles/rails_7.gemfile
data/export.json DELETED
@@ -1,82 +0,0 @@
1
- [
2
- {
3
- "path": "/Users/stefan/Documents/plutonium/phlexi-menu/CHANGELOG.md",
4
- "contents": "## [Unreleased]\n\n## [0.0.1] - 2024-12-10\n\n- Initial release\n"
5
- },
6
- {
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 - [Nesting and Depth Limits](#nesting-and-depth-limits)\n - [Theming](#theming)\n - [Static Theming](#static-theming)\n - [Depth-Aware Theming](#depth-aware-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 intelligent depth control\n- Support for icons and dual-badge system (leading and trailing badges)\n- Intelligent active state detection\n- Flexible theming system with depth awareness\n- Smart nesting behavior based on depth limits\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: ->(depth) { \"relative pl-#{depth * 4}\" },\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: ->(depth) { \"mx-3 text-gray-#{600 + (depth * 100)}\" },\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 item_parent: \"has-children\"\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### Nesting and Depth Limits\n\nPhlexi::Menu intelligently handles menu nesting based on the specified maximum depth:\n\n```ruby\n# Create a deeply nested menu structure\nmenu = Phlexi::Menu::Builder.new do |m|\n m.item \"Level 0\" do |l0| # Will be nested (depth 0)\n l0.item \"Level 1\" do |l1| # Will be nested if max_depth > 2\n l1.item \"Level 2\" # Will be nested if max_depth > 3\n l1.item \"Level 3\" # Won't be nested if max_depth <= 3\n end\n end\n end\nend\n\n# Render with depth limit\nmenu_component = MainMenu.new(menu, max_depth: 2)\n```\n\nKey behaviors:\n- Items are only treated as nested if their children can be rendered within the depth limit\n- Parent styling classes (item_parent theme) are only applied to items whose children will be shown\n- Nesting structure automatically adjusts based on the max_depth setting\n- Depth-aware theme values receive the actual rendered depth of each item\n\nExample with max_depth of 2:\n```ruby\nmenu = Phlexi::Menu::Builder.new do |m|\n m.item \"Products\" do |products| # depth 0, gets parent styling\n products.item \"Categories\" do |cats| # depth 1, gets parent styling\n cats.item \"Electronics\" # depth 2, no parent styling\n cats.item \"Books\" do |books| # depth 2, no parent styling\n books.item \"Fiction\" # not rendered (depth 3)\n end\n end\n end\nend\n```\n\n### Theming\n\nPhlexi::Menu provides two approaches to theming: static and depth-aware.\n\n#### Static Theming\n\nBasic theme configuration with fixed classes:\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#### Depth-Aware Theming\n\nAdvanced theme configuration with depth-sensitive classes:\n\n```ruby\nclass DepthAwareMenu < Phlexi::Menu::Component\n class Theme < Theme\n def self.theme\n super.merge({\n # Static classes\n nav: \"bg-white shadow\",\n \n # Progressive indentation\n item_wrapper: ->(depth) { \"relative pl-#{depth * 4}\" },\n \n # Gradually fading text\n item_label: ->(depth) { \"mx-3 text-gray-#{600 + (depth * 100)}\" },\n \n # Different icon styles per level\n icon: ->(depth) {\n base = \"h-5 w-5\"\n color = depth.zero? ? \"text-primary\" : \"text-gray-400\"\n [base, color]\n },\n \n # Smaller text at deeper levels\n item_link: ->(depth) {\n size = depth.zero? ? \"text-base\" : \"text-sm\"\n [\"flex items-center px-4 py-2 hover:bg-gray-50\", size]\n }\n })\n end\n end\nend\n```\n\nTheme values can be either:\n- Static strings for consistent styling\n- Arrays of classes that will be joined\n- Callables (procs/lambdas) that receive the current depth and return strings or arrays\n\n### Advanced Usage\n\n#### Component Customization\n\nYou can customize the nesting behavior by overriding the nested? method:\n\n```ruby\nclass CustomMenu < Phlexi::Menu::Component\n protected\n\n def nested?(item, depth)\n # Custom logic for when to treat items as nested\n return false if depth >= @max_depth - 1 # Reserve last level\n return false if item.items.empty? # No empty parents\n \n # Allow nesting only for items with certain attributes\n item.options[:allow_nesting]\n end\nend\n```\n\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).\n"
9
- },
10
- {
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..] # 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
- },
14
- {
15
- "path": "/Users/stefan/Documents/plutonium/phlexi-menu/lib/phlexi/menu/builder.rb",
16
- "contents": "# frozen_string_literal: true\n\nmodule Phlexi\n module Menu\n # Builder class for constructing hierarchical menu structures.\n # Provides a DSL for creating nested menu items with support for labels,\n # URLs, icons, and badges.\n #\n # @example Basic usage\n # menu = Phlexi::Menu::Builder.new do |m|\n # m.item \"Home\", url: \"/\"\n # m.item \"Products\", url: \"/products\" do |products|\n # products.item \"All Products\", url: \"/products\"\n # products.item \"Add Product\", url: \"/products/new\"\n # end\n # end\n class Builder\n # @return [Array<Phlexi::Menu::Item>] The collection of top-level menu items\n attr_reader :items\n\n # Nested Item class that inherits from Phlexi::Menu::Item\n class Item < Phlexi::Menu::Item; end\n\n # Initializes a new menu builder.\n #\n # @yield [builder] Passes the builder instance to the block for menu construction\n # @yieldparam builder [Phlexi::Menu::Builder] The builder instance\n def initialize(&)\n @items = []\n\n yield self if block_given?\n end\n\n # Creates and adds a new menu item to the current menu level.\n #\n # @param label [String] The display text for the menu item\n # @param ** [Hash] Additional options passed to the Item constructor\n # @yield [item] Optional block for adding nested menu items\n # @yieldparam item [Phlexi::Menu::Item] The newly created menu item\n # @return [Phlexi::Menu::Item] The created menu item\n # @raise [ArgumentError] If the label is nil\n def item(label, **, &)\n raise ArgumentError, \"Label cannot be nil\" unless label\n\n new_item = self.class::Item.new(label, **, &)\n @items << new_item\n new_item\n end\n\n # 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
- },
18
- {
19
- "path": "/Users/stefan/Documents/plutonium/phlexi-menu/lib/phlexi/menu/component.rb",
20
- "contents": "# frozen_string_literal: true\n\nrequire \"phlex\"\n\nmodule Phlexi\n module Menu\n # Base menu component that other menu renderers can inherit from.\n # Provides the core rendering logic for hierarchical menus with support\n # for theming, icons, badges, and active state detection.\n #\n # @example Basic usage\n # class MyMenu < Phlexi::Menu::Component\n # class Theme < Theme\n # def self.theme\n # super.merge({\n # nav: \"bg-white shadow\",\n # item_label: ->(depth) { \"text-gray-#{600 + (depth * 100)}\" }\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 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, depth)) 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, depth),\n item_parent_class(item, depth),\n active?(item) ? themed(:active, depth) : nil\n )) do\n render_item_content(item, depth)\n render_items(item.items, depth + 1) if nested?(item, depth)\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 # @param depth [Integer] Current nesting depth\n def render_item_content(item, depth)\n if item.url\n render_item_link(item, depth)\n else\n render_item_span(item, depth)\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 # @param depth [Integer] Current nesting depth\n def render_item_link(item, depth)\n a(\n href: item.url,\n class: tokens(\n themed(:item_link, depth),\n active_class(item, depth)\n )\n ) do\n render_item_interior(item, depth)\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 # @param depth [Integer] Current nesting depth\n def render_item_span(item, depth)\n span(class: themed(:item_span, depth)) do\n render_item_interior(item, depth)\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 # @param depth [Integer] Current nesting depth\n def render_item_interior(item, depth)\n render_leading_badge(item.leading_badge, depth) if item.leading_badge\n render_icon(item.icon, depth) if item.icon\n render_label(item.label, depth)\n render_trailing_badge(item.trailing_badge, depth) if item.trailing_badge\n end\n\n # Renders the item's label.\n #\n # @param label [String, Component] The label to render\n # @param depth [Integer] Current nesting depth\n def render_label(label, depth)\n phlexi_render(label) {\n span(class: themed(:item_label, depth)) { label }\n }\n end\n\n # Renders the item's leading badge.\n #\n # @param badge [String, Component] The leading badge to render\n # @param depth [Integer] Current nesting depth\n def render_leading_badge(badge, depth)\n phlexi_render(badge) {\n span(class: themed(:leading_badge, depth)) { badge }\n }\n end\n\n # Renders the item's trailing badge.\n #\n # @param badge [String, Component] The trailing badge to render\n # @param depth [Integer] Current nesting depth\n def render_trailing_badge(badge, depth)\n phlexi_render(badge) {\n span(class: themed(:trailing_badge, depth)) { badge }\n }\n end\n\n # Renders the item's icon.\n #\n # @param icon [Class] The icon component class to render\n # @param depth [Integer] Current nesting depth\n def render_icon(icon, depth)\n return unless icon\n\n div(class: themed(:icon_wrapper, depth)) do\n render icon.new(class: themed(:icon, depth))\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 # @param depth [Integer] Current nesting depth\n # @return [String, nil] The active class name or nil\n def active_class(item, depth)\n active?(item) ? themed(:active, depth) : nil\n end\n\n # Helper method to check if an item is active\n #\n # @param item [Phlexi::Menu::Item] The item to check\n # @return [Boolean] Whether the item is active\n def active?(item)\n item.active?(self)\n end\n\n # Determines if an item should be treated as nested based on its contents\n # and the current depth relative to the maximum allowed depth.\n #\n # @param item [Phlexi::Menu::Item] The item to check\n # @param depth [Integer] Current nesting depth\n # @return [Boolean] Whether the item should be treated as nested\n def nested?(item, depth)\n has_children = item.items.any?\n within_depth = (depth + 1) < @max_depth\n has_children && within_depth\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 # @param depth [Integer] Current nesting depth\n # @return [String, nil] The parent class name or nil\n def item_parent_class(item, depth)\n nested?(item, depth) ? themed(:item_parent, depth) : nil\n end\n\n # Resolves a theme component to its CSS classes.\n #\n # @param component [Symbol] The theme component to resolve\n # @param depth [Integer] Current nesting depth\n # @return [String, nil] The resolved CSS classes or nil\n def themed(component, depth = 0)\n theme = self.class::Theme.instance.resolve_theme(component)\n return nil if theme.nil?\n return theme unless theme.respond_to?(:call)\n theme.call(depth)\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
- },
22
- {
23
- "path": "/Users/stefan/Documents/plutonium/phlexi-menu/lib/phlexi/menu/item.rb",
24
- "contents": "# frozen_string_literal: true\n\nmodule Phlexi\n module Menu\n # Represents a single menu item in the navigation hierarchy.\n # Each item can have a label, URL, icon, badges, and nested child items.\n #\n # @example Basic menu item\n # item = Item.new(\"Home\", url: \"/\")\n #\n # @example Menu item with badges and icon\n # item = Item.new(\"Products\",\n # url: \"/products\",\n # icon: ProductIcon,\n # leading_badge: \"New\",\n # trailing_badge: \"5\")\n #\n # @example Nested menu items\n # item = Item.new(\"Admin\") do |admin|\n # admin.item \"Users\", url: \"/admin/users\"\n # admin.item \"Settings\", url: \"/admin/settings\"\n # end\n class Item\n # @return [String] The display text for the menu item\n attr_reader :label\n\n # @return [String, nil] The URL the menu item links to\n attr_reader :url\n\n # @return [Class, nil] The icon component class to be rendered\n attr_reader :icon\n\n # @return [String, Component, nil] The badge displayed before the label\n attr_reader :leading_badge\n\n # @return [String, Component, nil] The badge displayed after the label\n attr_reader :trailing_badge\n\n # @return [Array<Item>] Collection of nested menu items\n attr_reader :items\n\n # @return [Hash] Additional options for customizing the menu item\n attr_reader :options\n\n # Initializes a new menu item.\n #\n # @param label [String] The display text for the menu item\n # @param url [String, nil] The URL the menu item links to\n # @param icon [Class, nil] The icon component class\n # @param leading_badge [String, Component, nil] Badge displayed before the label\n # @param trailing_badge [String, Component, nil] Badge displayed after the label\n # @param options [Hash] Additional options (e.g., :active for custom active state logic)\n # @yield [item] Optional block for adding nested items\n # @yieldparam item [Item] The newly created menu item\n # @raise [ArgumentError] If the label is nil or empty\n def initialize(label, url: nil, icon: nil, leading_badge: nil, trailing_badge: nil, **options, &)\n raise ArgumentError, \"Label cannot be nil\" unless label\n\n @label = label\n @url = url\n @icon = icon\n @leading_badge = leading_badge\n @trailing_badge = trailing_badge\n @options = options\n @items = []\n\n yield self if block_given?\n end\n\n # Creates and adds a nested menu item.\n #\n # @param label [String] The display text for the nested item\n # @param ** [Hash] Additional options passed to the Item constructor\n # @yield [item] Optional block for adding further nested items\n # @yieldparam item [Item] The newly created nested item\n # @return [Item] The created nested item\n def item(label, **, &)\n new_item = self.class.new(label, **, &)\n @items << new_item\n new_item\n end\n\n # Determines if this menu item should be shown as active.\n # Checks in the following order:\n # 1. Custom active logic if provided in options\n # 2. URL match with current page\n # 3. Active state of any child items\n #\n # @param context [Object] The context object (typically a controller) for active state checking\n # @return [Boolean] true if the item should be shown as active, false otherwise\n def active?(context)\n # First check custom active logic if provided\n return @options[:active].call(context) if @options[:active].respond_to?(:call)\n\n # Then check if this item's URL matches current page\n if context.respond_to?(:helpers) && @url\n return true if context.helpers.current_page?(@url)\n end\n\n # Finally check if any child items are active\n @items.any? { |item| item.active?(context) }\n end\n\n # 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
- },
26
- {
27
- "path": "/Users/stefan/Documents/plutonium/phlexi-menu/lib/phlexi/menu/theme.rb",
28
- "contents": "require \"phlexi-field\"\n\nmodule Phlexi\n module Menu\n class Theme < Phlexi::Field::Theme\n # Defines the default theme structure with nil values\n # Can be overridden in subclasses to provide custom styling\n #\n # @return [Hash] Default theme structure with nil values\n def self.theme\n @theme ||= {\n # Container elements\n nav: nil, # Navigation wrapper\n items_container: nil, # <ul> list container\n\n # Item structure elements\n item_wrapper: nil, # <li> item wrapper\n item_parent: nil, # Additional class for items with visible children\n item_link: nil, # <a> for clickable items\n item_span: nil, # <span> for non-clickable items\n item_label: nil, # Label text wrapper\n\n # Interactive states\n active: nil, # Active/selected state\n hover: nil, # Hover state\n\n # Badge elements\n leading_badge: nil, # Badge before label\n trailing_badge: nil, # Badge after label\n\n # Icon elements\n icon: nil, # Icon styling\n icon_wrapper: nil # Icon container\n }.freeze\n end\n end\n end\nend\n"
29
- },
30
- {
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.1.0\"\n end\nend\n"
33
- },
34
- {
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 end\nend\n"
37
- },
38
- {
39
- "path": "/Users/stefan/Documents/plutonium/phlexi-menu/lib/phlexi-menu.rb",
40
- "contents": "# frozen_string_literal: true\n\nrequire_relative \"phlexi/menu/version\"\nrequire_relative \"phlexi/menu\"\n"
41
- },
42
- {
43
- "path": "/Users/stefan/Documents/plutonium/phlexi-menu/phlexi-menu.gemspec",
44
- "contents": "# frozen_string_literal: true\n\nrequire_relative \"lib/phlexi/menu/version\"\n\nGem::Specification.new do |spec|\n spec.name = \"phlexi-menu\"\n spec.version = Phlexi::Menu::VERSION\n spec.authors = [\"Stefan Froelich\"]\n spec.email = [\"sfroelich01@gmail.com\"]\n\n spec.summary = \"A flexible and powerful menu builder for Ruby applications\"\n spec.description = \"Phlexi::Menu is a flexible menu builder for Ruby applications that provides hierarchical menus, active state detection, icons, badges, and a powerful theming system. Built with Phlex components, it offers a modern approach to building navigation menus.\"\n spec.homepage = \"https://github.com/radioactive-labs/phlexi-menu\"\n spec.license = \"MIT\"\n spec.required_ruby_version = \">= 3.2.2\"\n\n spec.metadata[\"allowed_push_host\"] = \"https://rubygems.org\"\n\n spec.metadata[\"homepage_uri\"] = spec.homepage\n spec.metadata[\"source_code_uri\"] = spec.homepage\n spec.metadata[\"changelog_uri\"] = spec.homepage\n\n # Specify which files should be added to the gem when it is released.\n # The `git ls-files -z` loads the files in the RubyGem that have been added into git.\n gemspec = File.basename(__FILE__)\n spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls|\n ls.readlines(\"\\x0\", chomp: true).reject do |f|\n (f == gemspec) ||\n f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])\n end\n end\n spec.bindir = \"exe\"\n spec.executables = spec.files.grep(%r{\\Aexe/}) { |f| File.basename(f) }\n spec.require_paths = [\"lib\"]\n\n spec.add_dependency \"phlex\", \"~> 1.11\"\n spec.add_dependency \"zeitwerk\"\n spec.add_dependency \"phlexi-field\"\n\n spec.add_development_dependency \"rake\"\n spec.add_development_dependency \"minitest\"\n spec.add_development_dependency \"minitest-reporters\"\n spec.add_development_dependency \"standard\"\n # spec.add_development_dependency \"brakeman\"\n spec.add_development_dependency \"bundle-audit\"\n spec.add_development_dependency \"appraisal\"\n spec.add_development_dependency \"combustion\"\n spec.add_development_dependency \"phlex-testing-capybara\"\nend\n"
45
- },
46
- {
47
- "path": "/Users/stefan/Documents/plutonium/phlexi-menu/test/internal/app/controllers/users_controller.rb",
48
- "contents": "class UsersController < ActionController::Base\n def create\n render plain: \"OK\"\n end\nend\n"
49
- },
50
- {
51
- "path": "/Users/stefan/Documents/plutonium/phlexi-menu/test/internal/app/models/post.rb",
52
- "contents": "class Post < ActiveRecord::Base\n belongs_to :user\n\n validates :body, presence: true\nend\n"
53
- },
54
- {
55
- "path": "/Users/stefan/Documents/plutonium/phlexi-menu/test/internal/app/models/user.rb",
56
- "contents": "class User < ActiveRecord::Base\n has_many :posts\n\n validates :name, presence: true\nend\n"
57
- },
58
- {
59
- "path": "/Users/stefan/Documents/plutonium/phlexi-menu/test/internal/config/database.yml",
60
- "contents": "test:\n adapter: sqlite3\n database: db/combustion_test.sqlite\n"
61
- },
62
- {
63
- "path": "/Users/stefan/Documents/plutonium/phlexi-menu/test/internal/config/routes.rb",
64
- "contents": "# frozen_string_literal: true\n\nRails.application.routes.draw do\n # Add your own routes here, or remove this file if you don't have need for it.\n resources :users, only: [:create]\nend\n"
65
- },
66
- {
67
- "path": "/Users/stefan/Documents/plutonium/phlexi-menu/test/internal/config/storage.yml",
68
- "contents": "test:\n service: Disk\n root: tmp/storage\n"
69
- },
70
- {
71
- "path": "/Users/stefan/Documents/plutonium/phlexi-menu/test/internal/db/schema.rb",
72
- "contents": "# frozen_string_literal: true\n\nActiveRecord::Schema.define do\n create_table :users, force: true do |t|\n t.string :name\n t.string :email\n t.timestamps null: false\n end\n\n create_table :posts, force: true do |t|\n t.belongs_to :user\n t.string :body\n t.timestamps null: false\n end\nend\n"
73
- },
74
- {
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 DepthAwareMenu < Phlexi::Menu::Component\n class Theme < Theme\n def self.theme\n super.merge({\n nav: \"depth-nav\",\n items_container: \"depth-items\",\n item_wrapper: ->(depth) { [\"depth-item\", \"depth-#{depth}\"] },\n item_link: ->(depth) { [\"depth-link\", depth.zero? ? \"root\" : \"nested\"] },\n item_label: ->(depth) { [\"depth-label\", \"level-#{depth}\"] },\n icon: ->(depth) { [\"depth-icon\", depth.zero? ? \"primary\" : \"secondary\"] },\n leading_badge: ->(depth) { [\"depth-leading-badge\", \"indent-#{depth}\"] },\n trailing_badge: ->(depth) { [\"depth-trailing-badge\", \"offset-#{depth}\"] },\n active: ->(depth) { [\"depth-active\", \"highlight-#{depth}\"] }\n })\n end\n end\n end\n\n # Define a menu component with various types of callable theme values\n class CallableThemeMenu < Phlexi::Menu::Component\n class Theme < Theme\n def self.theme\n super.merge({\n # Lambda returning string\n item_label: ->(depth) { \"depth-#{depth}-label\" },\n\n # Lambda returning array\n item_wrapper: ->(depth) { [\"wrapper\", \"level-#{depth}\"] },\n\n # Lambda with conditional logic\n item_link: ->(depth) {\n depth.zero? ? \"root-link\" : [\"nested-link\", \"indent-#{depth}\"]\n },\n\n # Static string (non-callable)\n nav: \"static-nav\"\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 icon_wrapper: \"test-icon-wrapper\",\n active: \"test-active\",\n hover: \"test-hover\"\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 # Check rendered items\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 # Check parent classes\n assert has_css?(\".test-item:first-child.test-parent\") # Level 1 should have parent class\n assert has_css?(\".test-item .test-item:first-child.test-parent\") # Level 2 should have parent class\n refute has_css?(\".test-item .test-item .test-item.test-parent\") # Level 3 shouldn't have parent class\n\n # Test custom max depth\n render TestMenu.new(deep_menu, max_depth: 2)\n\n # Check rendered items\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\n # Check parent classes with custom depth\n assert has_css?(\".test-item:first-child.test-parent\") # Level 1 should have parent class\n refute has_css?(\".test-item .test-item.test-parent\") # Level 2 shouldn't have parent class\n end\n\n def test_depth_limited_nesting\n menu = Phlexi::Menu::Builder.new do |m|\n m.item \"Root\" do |root|\n root.item \"Child\" do |child|\n child.item \"Grandchild\" do |grand|\n grand.item \"Great-grandchild\"\n end\n end\n end\n end\n\n # Render with max_depth of 2\n render TestMenu.new(menu, max_depth: 2)\n\n # Check items rendered\n assert has_css?(\".test-label\", text: \"Root\")\n assert has_css?(\".test-label\", text: \"Child\")\n refute has_css?(\".test-label\", text: \"Grandchild\")\n refute has_css?(\".test-label\", text: \"Great-grandchild\")\n\n # Check parent classes based on renderable children\n assert has_css?(\".test-item:first-child.test-parent\") # Root should have parent class\n refute has_css?(\".test-item .test-item.test-parent\") # Child shouldn't have parent class\n end\n\n def test_nested_state_with_depth_limit\n menu = Phlexi::Menu::Builder.new do |m|\n m.item \"A\" do |a| # depth 0\n a.item \"B\" do |b| # depth 1\n b.item \"C\" # depth 2\n end\n end\n end\n\n # Test parent classes at each max_depth\n {\n 1 => {\n root: false, # A can't be nested because depth 1 is max\n child: false # B won't be rendered at all\n },\n 2 => {\n root: true, # A can be nested because B will be rendered\n child: false # B can't be nested because depth 2 is max\n },\n 3 => {\n root: true, # A can be nested because B will be rendered\n child: true # B can be nested because C will be rendered\n }\n }.each do |max_depth, expected|\n component = TestMenu.new(menu, max_depth: max_depth)\n render component\n\n # Check root level (A)\n if expected[:root]\n assert has_css?(\"li.test-item.test-parent\", text: \"A\"),\n \"At max_depth #{max_depth}, root should have parent class\"\n else\n refute has_css?(\"li.test-item.test-parent\", text: \"A\"),\n \"At max_depth #{max_depth}, root should not have parent class\"\n end\n\n # Only check child level (B) if it should be rendered\n if max_depth > 1\n if expected[:child]\n assert has_css?(\"li.test-item.test-parent li.test-item.test-parent\", text: \"B\"),\n \"At max_depth #{max_depth}, child should have parent class\"\n else\n refute has_css?(\"li.test-item.test-parent li.test-item.test-parent\", text: \"B\"),\n \"At max_depth #{max_depth}, child should not have parent class\"\n end\n end\n end\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\n def test_depth_aware_theming\n # Create a deeply nested menu for testing\n deep_menu = Phlexi::Menu::Builder.new do |m|\n m.item \"Root\",\n url: \"/\",\n icon: TestIcon,\n leading_badge: \"New\",\n trailing_badge: \"1\" do |root|\n root.item \"Level 1\", url: \"/level1\" do |l1|\n l1.item \"Level 2\",\n url: \"/level2\",\n icon: TestIcon,\n leading_badge: \"Beta\",\n trailing_badge: \"2\"\n end\n end\n end\n\n render DepthAwareMenu.new(deep_menu)\n\n # Test root level (depth 0) classes\n root = \".depth-nav > .depth-items > li:first-child\"\n assert has_css?(\"#{root}.depth-item.depth-0\")\n assert has_css?(\"#{root} a.depth-link.root\")\n assert has_css?(\"#{root} span.depth-label.level-0\", text: \"Root\")\n assert has_css?(\"#{root} div.depth-icon.primary\")\n assert has_css?(\"#{root} span.depth-leading-badge.indent-0\", text: \"New\")\n assert has_css?(\"#{root} span.depth-trailing-badge.offset-0\", text: \"1\")\n\n # Test level 1 classes\n level1 = \"#{root} > .depth-items > li:first-child\"\n assert has_css?(\"#{level1}.depth-item.depth-1\")\n assert has_css?(\"#{level1} a.depth-link.nested\")\n assert has_css?(\"#{level1} span.depth-label.level-1\", text: \"Level 1\")\n\n # Test level 2 classes\n level2 = \"#{level1} > .depth-items > li:first-child\"\n assert has_css?(\"#{level2}.depth-item.depth-2\")\n assert has_css?(\"#{level2} a.depth-link.nested\")\n assert has_css?(\"#{level2} span.depth-label.level-2\", text: \"Level 2\")\n assert has_css?(\"#{level2} div.depth-icon.secondary\")\n assert has_css?(\"#{level2} span.depth-leading-badge.indent-2\", text: \"Beta\")\n assert has_css?(\"#{level2} span.depth-trailing-badge.offset-2\", text: \"2\")\n end\n\n def test_depth_aware_active_state\n menu = Phlexi::Menu::Builder.new do |m|\n m.item \"Root\", url: \"/\" do |root|\n root.item \"Child\", url: \"/child\" do |child|\n child.item \"Grandchild\", url: \"/child/grand\"\n end\n end\n end\n\n # Mock context that considers \"/child/grand\" as current page\n mock_context = MockContext.new(\n request_path: \"/child/grand\",\n current_page_path: \"/child/grand\"\n )\n\n # Create a component instance with mock context\n component = DepthAwareMenu.new(menu)\n\n # Add helper methods to allow active state checking\n component.define_singleton_method(:helpers) { mock_context.helpers }\n component.define_singleton_method(:request) { mock_context.request }\n\n # Render the component\n render component\n\n # Render with depth-aware theme\n menu_component = DepthAwareMenu.new(menu)\n menu_component.define_singleton_method(:helpers) { mock_context.helpers }\n render menu_component\n\n # Test active classes at each depth\n assert has_css?(\".depth-item.depth-0 .depth-active.highlight-0\") # Root\n assert has_css?(\".depth-item.depth-1 .depth-active.highlight-1\") # Child\n assert has_css?(\".depth-item.depth-2 .depth-active.highlight-2\") # Grandchild\n end\n\n def test_callable_theme_values\n menu = Phlexi::Menu::Builder.new do |m|\n m.item \"Test\", url: \"/\" do |root|\n root.item \"Nested\", url: \"/nested\"\n end\n end\n\n render CallableThemeMenu.new(menu)\n\n # Test static theme value\n assert has_css?(\".static-nav\")\n\n # Test string-returning lambda\n assert has_css?(\".depth-0-label\", text: \"Test\")\n assert has_css?(\".depth-1-label\", text: \"Nested\")\n\n # Test array-returning lambda\n assert has_css?(\".wrapper.level-0\")\n assert has_css?(\".wrapper.level-1\")\n\n # Test conditional lambda\n assert has_css?(\".root-link\")\n assert has_css?(\".nested-link.indent-1\")\n end\n end\nend\n"
77
- },
78
- {
79
- "path": "/Users/stefan/Documents/plutonium/phlexi-menu/test/test_helper.rb",
80
- "contents": "require \"phlexi-menu\"\n\nrequire \"minitest/autorun\"\nrequire \"minitest/reporters\"\nMinitest::Reporters.use!\n\nrequire \"phlex/testing/capybara\"\nrequire \"capybara/minitest\"\n\ndef gem_present?(gem_name)\n Gem::Specification.find_all_by_name(gem_name).any?\nend\n\nreturn unless gem_present?(\"rails\")\n\nrequire \"combustion\"\nCombustion.path = \"test/internal\"\nCombustion.initialize! :active_record, :action_controller\n\nRails.application.config.action_dispatch.show_exceptions = :none\n"
81
- }
82
- ]
data/export.rb DELETED
@@ -1,48 +0,0 @@
1
- require "json"
2
- require "find"
3
-
4
- def export_files_to_json(directory, extensions, output_file, exceptions = [])
5
- # Convert extensions to lowercase for case-insensitive matching
6
- extensions = extensions.map(&:downcase)
7
-
8
- # Array to store file data
9
- files_data = []
10
-
11
- # Find all files in directory and subdirectories
12
- Find.find(directory) do |path|
13
- # Skip if not a file
14
- next unless File.file?(path)
15
- next if exceptions.any? { |exception| path.include?(exception) }
16
-
17
- # Check if file extension matches any in our list
18
- ext = File.extname(path).downcase[1..] # Remove the leading dot
19
- next unless extensions.include?(ext)
20
-
21
- puts path
22
-
23
- begin
24
- # Read file contents
25
- contents = File.read(path)
26
-
27
- # Add to our array
28
- files_data << {
29
- "path" => path,
30
- "contents" => contents
31
- }
32
- rescue => e
33
- puts "Error reading file #{path}: #{e.message}"
34
- end
35
- end
36
-
37
- # Write to JSON file
38
- File.write(output_file, JSON.pretty_generate(files_data))
39
-
40
- puts "Successfully exported #{files_data.length} files to #{output_file}"
41
- end
42
-
43
- # Example usage (uncomment and modify as needed):
44
- directory = "/Users/stefan/Documents/plutonium/phlexi-menu"
45
- exceptions = ["/.github/", "/.vscode/", "gemfiles", "pkg", "node_modules"]
46
- extensions = ["rb", "md", "yml", "yaml", "gemspec"]
47
- output_file = "export.json"
48
- export_files_to_json(directory, extensions, output_file, exceptions)