├── .ruby-version
├── test
├── dummy
│ ├── log
│ │ └── .keep
│ ├── app
│ │ ├── assets
│ │ │ ├── builds
│ │ │ │ └── .keep
│ │ │ └── images
│ │ │ │ └── .keep
│ │ ├── models
│ │ │ ├── concerns
│ │ │ │ └── .keep
│ │ │ └── application_record.rb
│ │ ├── controllers
│ │ │ ├── concerns
│ │ │ │ └── .keep
│ │ │ ├── home_controller.rb
│ │ │ └── application_controller.rb
│ │ ├── views
│ │ │ ├── layouts
│ │ │ │ ├── mailer.text.erb
│ │ │ │ └── mailer.html.erb
│ │ │ ├── application
│ │ │ │ └── _sidebar.html.erb
│ │ │ └── pwa
│ │ │ │ ├── manifest.json.erb
│ │ │ │ └── service-worker.js
│ │ ├── mailers
│ │ │ └── application_mailer.rb
│ │ ├── jobs
│ │ │ └── application_job.rb
│ │ └── javascript
│ │ │ ├── application.js
│ │ │ └── controllers
│ │ │ ├── theme_color_controller.js
│ │ │ └── theme_controller.js
│ ├── vendor
│ │ └── javascript
│ │ │ └── .keep
│ ├── Procfile.dev
│ ├── public
│ │ ├── icon.png
│ │ ├── icon.svg
│ │ └── 404.html
│ ├── bin
│ │ ├── rake
│ │ ├── importmap
│ │ ├── rails
│ │ ├── dev
│ │ └── setup
│ ├── config
│ │ ├── environment.rb
│ │ ├── cable.yml
│ │ ├── boot.rb
│ │ ├── importmap.rb
│ │ ├── initializers
│ │ │ ├── filter_parameter_logging.rb
│ │ │ ├── inflections.rb
│ │ │ └── content_security_policy.rb
│ │ ├── routes.rb
│ │ ├── application.rb
│ │ ├── locales
│ │ │ └── en.yml
│ │ ├── database.yml
│ │ ├── storage.yml
│ │ ├── puma.rb
│ │ └── environments
│ │ │ ├── test.rb
│ │ │ ├── development.rb
│ │ │ └── production.rb
│ ├── config.ru
│ └── Rakefile
├── maquina_components_test.rb
└── test_helper.rb
├── Rakefile
├── .rubocop.yml
├── imgs
├── dark.png
└── light.png
├── lib
├── maquina_components
│ ├── version.rb
│ └── engine.rb
├── tasks
│ └── maquina_components_tasks.rake
├── maquina-components.rb
└── generators
│ └── maquina_components
│ └── install
│ ├── USAGE
│ ├── templates
│ ├── maquina_components_helper.rb.tt
│ └── theme.css.tt
│ └── install_generator.rb
├── config
└── importmap.rb
├── bin
├── test
├── rails
├── rubocop
└── standardrb
├── app
├── views
│ └── components
│ │ ├── _card.html.erb
│ │ ├── table
│ │ ├── _body.html.erb
│ │ ├── _footer.html.erb
│ │ ├── _cell.html.erb
│ │ ├── _caption.html.erb
│ │ ├── _header.html.erb
│ │ ├── _row.html.erb
│ │ └── _head.html.erb
│ │ ├── card
│ │ ├── _action.html.erb
│ │ ├── _description.html.erb
│ │ ├── _header.html.erb
│ │ ├── _content.html.erb
│ │ ├── _title.html.erb
│ │ └── _footer.html.erb
│ │ ├── sidebar
│ │ ├── _menu.html.erb
│ │ ├── _content.html.erb
│ │ ├── _footer.html.erb
│ │ ├── _header.html.erb
│ │ ├── _inset.html.erb
│ │ ├── _menu_item.html.erb
│ │ ├── _group.html.erb
│ │ ├── _menu_button.html.erb
│ │ ├── _trigger.html.erb
│ │ ├── _provider.html.erb
│ │ └── _menu_link.html.erb
│ │ ├── breadcrumbs
│ │ ├── _item.html.erb
│ │ ├── _list.html.erb
│ │ ├── _link.html.erb
│ │ ├── _page.html.erb
│ │ ├── _ellipsis.html.erb
│ │ └── _separator.html.erb
│ │ ├── alert
│ │ ├── _title.html.erb
│ │ └── _description.html.erb
│ │ ├── empty
│ │ ├── _content.html.erb
│ │ ├── _header.html.erb
│ │ ├── _title.html.erb
│ │ ├── _description.html.erb
│ │ └── _media.html.erb
│ │ ├── pagination
│ │ ├── _item.html.erb
│ │ ├── _content.html.erb
│ │ ├── _link.html.erb
│ │ ├── _ellipsis.html.erb
│ │ ├── _next.html.erb
│ │ └── _previous.html.erb
│ │ ├── _header.html.erb
│ │ ├── _dropdown_menu.html.erb
│ │ ├── _separator.html.erb
│ │ ├── _badge.html.erb
│ │ ├── _empty.html.erb
│ │ ├── dropdown_menu
│ │ ├── _group.html.erb
│ │ ├── _separator.html.erb
│ │ ├── _shortcut.html.erb
│ │ ├── _label.html.erb
│ │ ├── _content.html.erb
│ │ ├── _trigger.html.erb
│ │ └── _item.html.erb
│ │ ├── _pagination.html.erb
│ │ ├── _breadcrumbs.html.erb
│ │ ├── _alert.html.erb
│ │ ├── toggle_group
│ │ └── _item.html.erb
│ │ ├── _table.html.erb
│ │ ├── _toggle_group.html.erb
│ │ ├── _dropdown.html.erb
│ │ ├── stats
│ │ ├── _stats_grid.html.erb
│ │ └── _stats_card.html.erb
│ │ ├── _sidebar.html.erb
│ │ ├── _simple_table.html.erb
│ │ └── _menu_button.html.erb
├── assets
│ ├── tailwind
│ │ └── maquina_components_engine
│ │ │ └── engine.css
│ ├── stylesheets
│ │ ├── header.css
│ │ ├── empty.css
│ │ ├── card.css
│ │ ├── badge.css
│ │ ├── maquina_components.css
│ │ ├── pagination.css
│ │ ├── toggle_group.css
│ │ ├── alert.css
│ │ ├── breadcrumbs.css
│ │ └── table.css
│ └── images
│ │ └── maquina.svg
├── javascript
│ └── controllers
│ │ ├── sidebar_trigger_controller.js
│ │ ├── menu_button_controller.js
│ │ ├── breadcrumb_controller.js
│ │ ├── toggle_group_controller.js
│ │ └── dropdown_menu_controller.js
└── helpers
│ └── maquina_components
│ ├── sidebar_helper.rb
│ ├── empty_helper.rb
│ ├── breadcrumbs_helper.rb
│ ├── table_helper.rb
│ ├── pagination_helper.rb
│ └── toggle_group_helper.rb
├── .gitignore
├── .standard.yml
├── Gemfile
├── MIT-LICENSE
├── maquina-components.gemspec
├── .github
└── workflows
│ └── ci.yml
├── CHANGELOG.md
└── docs
└── header.md
/.ruby-version:
--------------------------------------------------------------------------------
1 | 3.4.7
2 |
--------------------------------------------------------------------------------
/test/dummy/log/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/dummy/app/assets/builds/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/dummy/app/assets/images/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/dummy/app/models/concerns/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/dummy/vendor/javascript/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/dummy/app/controllers/concerns/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/dummy/app/views/layouts/mailer.text.erb:
--------------------------------------------------------------------------------
1 | <%= yield %>
2 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require "bundler/setup"
2 |
3 | require "bundler/gem_tasks"
4 |
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | require: standard
2 |
3 | inherit_gem:
4 | standard: config/base.yml
5 |
--------------------------------------------------------------------------------
/test/dummy/Procfile.dev:
--------------------------------------------------------------------------------
1 | web: bin/rails server
2 | css: bin/rails tailwindcss:watch
3 |
--------------------------------------------------------------------------------
/imgs/dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maquina-app/maquina_components/HEAD/imgs/dark.png
--------------------------------------------------------------------------------
/imgs/light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maquina-app/maquina_components/HEAD/imgs/light.png
--------------------------------------------------------------------------------
/lib/maquina_components/version.rb:
--------------------------------------------------------------------------------
1 | module MaquinaComponents
2 | VERSION = "0.2.0"
3 | end
4 |
--------------------------------------------------------------------------------
/test/dummy/public/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maquina-app/maquina_components/HEAD/test/dummy/public/icon.png
--------------------------------------------------------------------------------
/config/importmap.rb:
--------------------------------------------------------------------------------
1 | pin_all_from MaquinaComponents::Engine.root.join("app/javascript/controllers"), under: "controllers"
2 |
--------------------------------------------------------------------------------
/test/dummy/app/controllers/home_controller.rb:
--------------------------------------------------------------------------------
1 | class HomeController < ApplicationController
2 | def index
3 | end
4 | end
5 |
--------------------------------------------------------------------------------
/test/dummy/bin/rake:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require_relative "../config/boot"
3 | require "rake"
4 | Rake.application.run
5 |
--------------------------------------------------------------------------------
/test/dummy/app/models/application_record.rb:
--------------------------------------------------------------------------------
1 | class ApplicationRecord < ActiveRecord::Base
2 | primary_abstract_class
3 | end
4 |
--------------------------------------------------------------------------------
/test/dummy/bin/importmap:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | require_relative "../config/application"
4 | require "importmap/commands"
5 |
--------------------------------------------------------------------------------
/bin/test:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | $: << File.expand_path("../test", __dir__)
3 |
4 | require "bundler/setup"
5 | require "rails/plugin/test"
6 |
--------------------------------------------------------------------------------
/lib/tasks/maquina_components_tasks.rake:
--------------------------------------------------------------------------------
1 | # desc "Explaining what the task does"
2 | # task :maquina_components do
3 | # # Task goes here
4 | # end
5 |
--------------------------------------------------------------------------------
/test/dummy/app/views/application/_sidebar.html.erb:
--------------------------------------------------------------------------------
1 | <%# This file is no longer used - sidebar content moved to layout %>
2 | <%# Kept for reference only %>
3 |
--------------------------------------------------------------------------------
/test/dummy/app/mailers/application_mailer.rb:
--------------------------------------------------------------------------------
1 | class ApplicationMailer < ActionMailer::Base
2 | default from: "from@example.com"
3 | layout "mailer"
4 | end
5 |
--------------------------------------------------------------------------------
/test/dummy/public/icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/test/dummy/bin/rails:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | APP_PATH = File.expand_path("../config/application", __dir__)
3 | require_relative "../config/boot"
4 | require "rails/commands"
5 |
--------------------------------------------------------------------------------
/lib/maquina-components.rb:
--------------------------------------------------------------------------------
1 | require "maquina_components/version"
2 | require "maquina_components/engine"
3 |
4 | module MaquinaComponents
5 | # Your code goes here...
6 | end
7 |
--------------------------------------------------------------------------------
/test/dummy/config/environment.rb:
--------------------------------------------------------------------------------
1 | # Load the Rails application.
2 | require_relative "application"
3 |
4 | # Initialize the Rails application.
5 | Rails.application.initialize!
6 |
--------------------------------------------------------------------------------
/bin/rails:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | APP_PATH = File.expand_path("../test/dummy/config/application", __dir__)
3 | require_relative "../test/dummy/config/boot"
4 | require "rails/commands"
5 |
--------------------------------------------------------------------------------
/test/dummy/config.ru:
--------------------------------------------------------------------------------
1 | # This file is used by Rack-based servers to start the application.
2 |
3 | require_relative "config/environment"
4 |
5 | run Rails.application
6 | Rails.application.load_server
7 |
--------------------------------------------------------------------------------
/test/maquina_components_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class MaquinaComponentsTest < ActiveSupport::TestCase
4 | test "it has a version number" do
5 | assert MaquinaComponents::VERSION
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/test/dummy/config/cable.yml:
--------------------------------------------------------------------------------
1 | development:
2 | adapter: async
3 |
4 | test:
5 | adapter: test
6 |
7 | production:
8 | adapter: redis
9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
10 | channel_prefix: dummy_production
11 |
--------------------------------------------------------------------------------
/test/dummy/Rakefile:
--------------------------------------------------------------------------------
1 | # Add your own tasks in files placed in lib/tasks ending in .rake,
2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
3 |
4 | require_relative "config/application"
5 |
6 | Rails.application.load_tasks
7 |
--------------------------------------------------------------------------------
/test/dummy/app/controllers/application_controller.rb:
--------------------------------------------------------------------------------
1 | class ApplicationController < ActionController::Base
2 | # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
3 | allow_browser versions: :modern
4 | end
5 |
--------------------------------------------------------------------------------
/test/dummy/config/boot.rb:
--------------------------------------------------------------------------------
1 | # Set up gems listed in the Gemfile.
2 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__)
3 |
4 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"])
5 | $LOAD_PATH.unshift File.expand_path("../../../lib", __dir__)
6 |
--------------------------------------------------------------------------------
/app/views/components/_card.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(component: :card) %>
3 |
4 | <%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
5 | <%= yield %>
6 | <% end %>
7 |
--------------------------------------------------------------------------------
/app/views/components/table/_body.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(table_part: :body) %>
3 | <%= content_tag :tbody, class: css_classes.presence, data: merged_data, **html_options do %>
4 | <%= yield %>
5 | <% end %>
6 |
--------------------------------------------------------------------------------
/app/views/components/card/_action.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(card_part: :action) %>
3 |
4 | <%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
5 | <%= yield %>
6 | <% end %>
7 |
--------------------------------------------------------------------------------
/app/views/components/table/_footer.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(table_part: :footer) %>
3 | <%= content_tag :tfoot, class: css_classes.presence, data: merged_data, **html_options do %>
4 | <%= yield %>
5 | <% end %>
6 |
--------------------------------------------------------------------------------
/bin/rubocop:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require "rubygems"
3 | require "bundler/setup"
4 |
5 | # explicit rubocop config increases performance slightly while avoiding config confusion.
6 | ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__))
7 |
8 | load Gem.bin_path("rubocop", "rubocop")
9 |
--------------------------------------------------------------------------------
/app/views/components/sidebar/_menu.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | sidebar_part: :menu
4 | ) %>
5 |
6 | <%= content_tag :ul, class: css_classes, data: merged_data, **html_options do %>
7 | <%= yield %>
8 | <% end %>
9 |
--------------------------------------------------------------------------------
/app/views/components/table/_cell.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(table_part: :cell) %>
3 | <%= content_tag :td, class: css_classes.presence, data: merged_data, **html_options do %>
4 | <%= yield if block_given? %>
5 | <% end %>
6 |
--------------------------------------------------------------------------------
/app/views/components/breadcrumbs/_item.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | breadcrumb_part: :item
4 | ) %>
5 |
6 | <%= content_tag :li, class: css_classes, data: merged_data, **html_options do %>
7 | <%= yield %>
8 | <% end %>
9 |
--------------------------------------------------------------------------------
/app/views/components/breadcrumbs/_list.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | breadcrumb_part: :list
4 | ) %>
5 |
6 | <%= content_tag :ol, class: css_classes, data: merged_data, **html_options do %>
7 | <%= yield %>
8 | <% end %>
9 |
--------------------------------------------------------------------------------
/app/views/components/sidebar/_content.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | sidebar_part: :content
4 | ) %>
5 |
6 | <%= content_tag :div, class: css_classes, data: merged_data, **html_options do %>
7 | <%= yield %>
8 | <% end %>
9 |
--------------------------------------------------------------------------------
/app/views/components/sidebar/_footer.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | sidebar_part: :footer
4 | ) %>
5 |
6 | <%= content_tag :div, class: css_classes, data: merged_data, **html_options do %>
7 | <%= yield %>
8 | <% end %>
9 |
--------------------------------------------------------------------------------
/app/views/components/sidebar/_header.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | sidebar_part: :header
4 | ) %>
5 |
6 | <%= content_tag :div, class: css_classes, data: merged_data, **html_options do %>
7 | <%= yield %>
8 | <% end %>
9 |
--------------------------------------------------------------------------------
/app/views/components/sidebar/_inset.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | sidebar_part: :inset
4 | ) %>
5 |
6 | <%= content_tag :main, class: css_classes, data: merged_data, **html_options do %>
7 | <%= yield %>
8 | <% end %>
9 |
--------------------------------------------------------------------------------
/app/views/components/alert/_title.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (text: nil, css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(alert_part: :title) %>
3 |
4 | <%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
5 | <%= text || yield %>
6 | <% end %>
7 |
--------------------------------------------------------------------------------
/app/views/components/breadcrumbs/_link.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (href:, css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | breadcrumb_part: :link
4 | ) %>
5 |
6 | <%= link_to href, class: css_classes, data: merged_data, **html_options do %>
7 | <%= yield %>
8 | <% end %>
9 |
--------------------------------------------------------------------------------
/app/views/components/table/_caption.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(table_part: :caption) %>
3 | <%= content_tag :caption, class: css_classes.presence, data: merged_data, **html_options do %>
4 | <%= yield if block_given? %>
5 | <% end %>
6 |
--------------------------------------------------------------------------------
/app/views/components/empty/_content.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | "empty-part": "content"
4 | ) %>
5 |
6 | <%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
7 | <%= yield %>
8 | <% end %>
9 |
--------------------------------------------------------------------------------
/app/views/components/empty/_header.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | "empty-part": "header"
4 | ) %>
5 |
6 | <%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
7 | <%= yield %>
8 | <% end %>
9 |
--------------------------------------------------------------------------------
/test/dummy/app/jobs/application_job.rb:
--------------------------------------------------------------------------------
1 | class ApplicationJob < ActiveJob::Base
2 | # Automatically retry jobs that encountered a deadlock
3 | # retry_on ActiveRecord::Deadlocked
4 |
5 | # Most jobs are safe to ignore if the underlying records are no longer available
6 | # discard_on ActiveJob::DeserializationError
7 | end
8 |
--------------------------------------------------------------------------------
/test/dummy/app/views/layouts/mailer.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
8 |
9 |
10 |
11 | <%= yield %>
12 |
13 |
14 |
--------------------------------------------------------------------------------
/app/views/components/card/_description.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (text: nil, css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(card_part: :description) %>
3 |
4 | <%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
5 | <%= text || yield %>
6 | <% end %>
7 |
--------------------------------------------------------------------------------
/app/views/components/pagination/_item.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | "pagination-part": "item"
4 | ) %>
5 |
6 | <%= content_tag :li, class: css_classes.presence, data: merged_data, **html_options do %>
7 | <%= yield %>
8 | <% end %>
9 |
--------------------------------------------------------------------------------
/app/views/components/sidebar/_menu_item.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", **html_options) %>
2 | <% merged_data =
3 | (html_options.delete(:data) || {}).merge(sidebar_part: "menu-item") %>
4 |
5 | <%= content_tag :li, class: "group/menu-item #{css_classes}", data: merged_data, **html_options do %>
6 | <%= yield %>
7 | <% end %>
8 |
--------------------------------------------------------------------------------
/app/views/components/alert/_description.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (text: nil, css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(alert_part: :description) %>
3 |
4 | <%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
5 | <%= text || yield %>
6 | <% end %>
7 |
--------------------------------------------------------------------------------
/app/views/components/pagination/_content.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | "pagination-part": "content"
4 | ) %>
5 |
6 | <%= content_tag :ul, class: css_classes.presence, data: merged_data, **html_options do %>
7 | <%= yield %>
8 | <% end %>
9 |
--------------------------------------------------------------------------------
/app/views/components/_header.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | component: :header
4 | ) %>
5 |
6 | <%= content_tag :header, class: css_classes.presence, data: merged_data, **html_options do %>
7 | <%= yield %>
8 | <% end %>
9 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.bundle/
2 | /doc/
3 | /log/*.log
4 | /pkg/
5 | /tmp/
6 | /test/dummy/db/*.sqlite3
7 | /test/dummy/db/*.sqlite3-*
8 | /test/dummy/log/*.log
9 | /test/dummy/storage/
10 | /test/dummy/tmp/
11 | /test/dummy/app/assets/builds/
12 |
13 | .DS_Store
14 |
15 | # Claude Code local files
16 | CLAUDE.md
17 | AGENTS.md
18 | .claude/
19 | /references/
20 |
--------------------------------------------------------------------------------
/app/views/components/table/_header.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", sticky: false, **html_options) %>
2 | <%
3 | merged_data = (html_options.delete(:data) || {}).merge(table_part: :header)
4 | merged_data[:sticky] = "true" if sticky
5 | %>
6 | <%= content_tag :thead, class: css_classes.presence, data: merged_data, **html_options do %>
7 | <%= yield %>
8 | <% end %>
9 |
--------------------------------------------------------------------------------
/app/views/components/table/_row.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", selected: false, **html_options) %>
2 | <%
3 | merged_data = (html_options.delete(:data) || {}).merge(table_part: :row)
4 | merged_data[:state] = :selected if selected
5 | %>
6 | <%= content_tag :tr, class: css_classes.presence, data: merged_data, **html_options do %>
7 | <%= yield %>
8 | <% end %>
9 |
--------------------------------------------------------------------------------
/app/views/components/_dropdown_menu.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | component: "dropdown-menu",
4 | controller: "dropdown-menu"
5 | ) %>
6 |
7 | <%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
8 | <%= yield %>
9 | <% end %>
10 |
--------------------------------------------------------------------------------
/app/views/components/breadcrumbs/_page.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | breadcrumb_part: :page
4 | ) %>
5 |
6 | <%= content_tag :span, role: "link", aria: {current: "page", disabled: true}, class: css_classes, data: merged_data, **html_options do %>
7 | <%= yield %>
8 | <% end %>
9 |
--------------------------------------------------------------------------------
/app/views/components/table/_head.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", scope: "col", **html_options) %>
2 | <%
3 | merged_data = (html_options.delete(:data) || {}).merge(table_part: :head)
4 | html_options[:scope] = scope if scope
5 | %>
6 | <%= content_tag :th, class: css_classes.presence, data: merged_data, **html_options do %>
7 | <%= yield if block_given? %>
8 | <% end %>
9 |
--------------------------------------------------------------------------------
/app/views/components/_separator.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (orientation: :horizontal) -%>
2 |
3 |
12 |
--------------------------------------------------------------------------------
/app/views/components/_badge.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (variant: :default, size: :md, css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | component: :badge,
4 | variant: variant,
5 | size: size
6 | ) %>
7 |
8 | <%= content_tag :span, class: css_classes, data: merged_data, **html_options do %>
9 | <%= yield %>
10 | <% end %>
11 |
--------------------------------------------------------------------------------
/app/views/components/card/_header.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (layout: :column, css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | card_part: :header,
4 | layout: (layout == :row ? :row : nil)
5 | ).compact %>
6 |
7 | <%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
8 | <%= yield %>
9 | <% end %>
10 |
--------------------------------------------------------------------------------
/test/dummy/config/importmap.rb:
--------------------------------------------------------------------------------
1 | # Pin npm packages by running ./bin/importmap
2 |
3 | pin "application"
4 |
5 | # Hotwire
6 | pin "@hotwired/turbo-rails", to: "turbo.min.js"
7 | pin "@hotwired/stimulus", to: "stimulus.min.js"
8 | pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
9 |
10 | # Application controllers
11 | pin_all_from "app/javascript/controllers", under: "controllers"
12 |
--------------------------------------------------------------------------------
/app/views/components/_empty.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (variant: :default, size: :default, css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | component: :empty,
4 | variant: variant,
5 | size: size
6 | ) %>
7 |
8 | <%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
9 | <%= yield %>
10 | <% end %>
11 |
--------------------------------------------------------------------------------
/app/views/components/dropdown_menu/_group.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | "dropdown-menu-part": "group"
4 | ) %>
5 |
6 | <%= content_tag :div,
7 | role: "group",
8 | class: css_classes.presence,
9 | data: merged_data,
10 | **html_options do %>
11 | <%= yield %>
12 | <% end %>
13 |
--------------------------------------------------------------------------------
/app/views/components/card/_content.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (spacing: :default, css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | card_part: :content,
4 | spacing: (spacing == :full ? :full : nil)
5 | ).compact %>
6 |
7 | <%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
8 | <%= yield %>
9 | <% end %>
10 |
--------------------------------------------------------------------------------
/app/views/components/card/_title.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (text: nil, size: :default, css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | card_part: :title,
4 | size: (size == :sm ? :sm : nil)
5 | ).compact %>
6 |
7 | <%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
8 | <%= text || yield %>
9 | <% end %>
10 |
--------------------------------------------------------------------------------
/app/views/components/breadcrumbs/_ellipsis.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | breadcrumb_part: :ellipsis
4 | ) %>
5 |
6 | <%= content_tag :span, role: "presentation", class: css_classes, data: merged_data, **html_options do %>
7 | <%= icon_for(:ellipsis) %>
8 | More
9 | <% end %>
10 |
--------------------------------------------------------------------------------
/app/views/components/dropdown_menu/_separator.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | "dropdown-menu-part": "separator"
4 | ) %>
5 |
6 | <%= content_tag :div, nil,
7 | role: "separator",
8 | aria: { orientation: "horizontal" },
9 | class: css_classes.presence,
10 | data: merged_data,
11 | **html_options %>
12 |
--------------------------------------------------------------------------------
/app/views/components/empty/_title.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (text: nil, css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | "empty-part": "title"
4 | ) %>
5 |
6 | <%= content_tag :h3, class: css_classes.presence, data: merged_data, **html_options do %>
7 | <% if text.present? %>
8 | <%= text %>
9 | <% else %>
10 | <%= yield %>
11 | <% end %>
12 | <% end %>
13 |
--------------------------------------------------------------------------------
/app/views/components/empty/_description.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (text: nil, css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | "empty-part": "description"
4 | ) %>
5 |
6 | <%= content_tag :p, class: css_classes.presence, data: merged_data, **html_options do %>
7 | <% if text.present? %>
8 | <%= text %>
9 | <% else %>
10 | <%= yield %>
11 | <% end %>
12 | <% end %>
13 |
--------------------------------------------------------------------------------
/app/views/components/dropdown_menu/_shortcut.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (text: nil, css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | "dropdown-menu-part": "shortcut"
4 | ) %>
5 |
6 | <%= content_tag :span, class: css_classes.presence, data: merged_data, **html_options do %>
7 | <% if text.present? %>
8 | <%= text %>
9 | <% else %>
10 | <%= yield %>
11 | <% end %>
12 | <% end %>
13 |
--------------------------------------------------------------------------------
/app/views/components/sidebar/_group.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (title: nil, css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | sidebar_part: :group
4 | ) %>
5 |
6 | <%= content_tag :div, class: css_classes, data: merged_data, **html_options do %>
7 | <% if title.present? %>
8 | <%= title %>
9 | <% end %>
10 |
11 | <%= yield %>
12 | <% end %>
13 |
--------------------------------------------------------------------------------
/test/dummy/bin/dev:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | if ! gem list foreman -i --silent; then
4 | echo "Installing foreman..."
5 | gem install foreman
6 | fi
7 |
8 | # Default to port 3000 if not specified
9 | export PORT="${PORT:-3000}"
10 |
11 | # Let the debug gem allow remote connections,
12 | # but avoid loading until `debugger` is called
13 | export RUBY_DEBUG_OPEN="true"
14 | export RUBY_DEBUG_LAZY="true"
15 |
16 | exec foreman start -f Procfile.dev "$@"
17 |
--------------------------------------------------------------------------------
/app/views/components/_pagination.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | component: :pagination
4 | ) %>
5 |
6 | <%= content_tag :nav,
7 | role: "navigation",
8 | "aria-label": html_options.delete(:"aria-label") || "Pagination",
9 | class: css_classes.presence,
10 | data: merged_data,
11 | **html_options do %>
12 | <%= yield %>
13 | <% end %>
14 |
--------------------------------------------------------------------------------
/.standard.yml:
--------------------------------------------------------------------------------
1 | fix: true # default: false
2 | parallel: true # default: false
3 | format: progress # default: Standard::Formatter
4 | ruby_version: 3.4.1 # default: RUBY_VERSION
5 | default_ignores: false # default: true
6 |
7 | ignore: # default: []
8 | - "config/environments/**/*"
9 | - "config/application.rb"
10 | - "config/boot.rb"
11 | - "config/puma.rb"
12 | - "db/schema.rb"
13 | - "test/dummy/**/*"
14 | - "tmp/**/*"
15 | - "vendor/bundle/**/*"
16 |
--------------------------------------------------------------------------------
/app/views/components/dropdown_menu/_label.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (text: nil, inset: false, css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | "dropdown-menu-part": "label",
4 | inset: inset
5 | ) %>
6 |
7 | <%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
8 | <% if text.present? %>
9 | <%= text %>
10 | <% else %>
11 | <%= yield %>
12 | <% end %>
13 | <% end %>
14 |
--------------------------------------------------------------------------------
/app/views/components/_breadcrumbs.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", responsive: false, **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | component: :breadcrumbs
4 | )
5 |
6 | if responsive
7 | merged_data[:controller] = "breadcrumb"
8 | end %>
9 |
10 |
14 | >
15 | <%= yield %>
16 |
17 |
--------------------------------------------------------------------------------
/app/views/components/_alert.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (variant: :default, icon: nil, css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | component: :alert,
4 | variant: variant,
5 | has_icon: icon.present? || nil
6 | ).compact %>
7 |
8 | <%= content_tag :div, role: :alert, class: css_classes.presence, data: merged_data, **html_options do %>
9 | <%= icon_for(icon) if icon.present? %>
10 |
11 | <%= yield %>
12 | <% end %>
13 |
--------------------------------------------------------------------------------
/app/views/components/empty/_media.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (icon: nil, variant: :icon, css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | "empty-part": "media",
4 | variant: variant
5 | ) %>
6 |
7 | <%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
8 | <% if icon.present? && respond_to?(:icon_for) %>
9 | <%= icon_for(icon) %>
10 | <% elsif block_given? %>
11 | <%= yield %>
12 | <% end %>
13 | <% end %>
14 |
--------------------------------------------------------------------------------
/app/views/components/breadcrumbs/_separator.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (icon: :chevron_right, css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | breadcrumb_part: :separator
4 | ) %>
5 |
6 |
11 | >
12 | <% if icon == :custom %>
13 | <%= yield %>
14 | <% else %>
15 | <%= icon_for(icon) %>
16 | <% end %>
17 |
18 |
--------------------------------------------------------------------------------
/test/dummy/app/views/pwa/manifest.json.erb:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Dummy",
3 | "icons": [
4 | {
5 | "src": "/icon.png",
6 | "type": "image/png",
7 | "sizes": "512x512"
8 | },
9 | {
10 | "src": "/icon.png",
11 | "type": "image/png",
12 | "sizes": "512x512",
13 | "purpose": "maskable"
14 | }
15 | ],
16 | "start_url": "/",
17 | "display": "standalone",
18 | "scope": "/",
19 | "description": "Dummy.",
20 | "theme_color": "red",
21 | "background_color": "red"
22 | }
23 |
--------------------------------------------------------------------------------
/app/views/components/sidebar/_menu_button.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (title:, url:, icon_name: nil, size: :default, active: false, css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | sidebar_part: "menu-button",
4 | size: size,
5 | active: active
6 | ) %>
7 |
8 | <%= link_to url, class: css_classes, data: merged_data, **html_options do %>
9 | <% if icon_name.present? %>
10 | <%= icon_for(icon_name) %>
11 | <% end %>
12 |
13 | <%= title %>
14 | <% end %>
15 |
--------------------------------------------------------------------------------
/test/dummy/config/initializers/filter_parameter_logging.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file.
4 | # Use this to limit dissemination of sensitive information.
5 | # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
6 | Rails.application.config.filter_parameters += [
7 | :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc
8 | ]
9 |
--------------------------------------------------------------------------------
/lib/maquina_components/engine.rb:
--------------------------------------------------------------------------------
1 | module MaquinaComponents
2 | class Engine < ::Rails::Engine
3 | initializer "maquina-components.importmap", before: "importmap" do |app|
4 | app.config.importmap.paths << root.join("config/importmap.rb")
5 | app.config.importmap.cache_sweepers << root.join("app/javascript")
6 | end
7 |
8 | initializer "maquin-components.assets" do |app|
9 | if app.config.respond_to?(:assets)
10 | app.config.assets.paths << Engine.root.join("app/javascript")
11 | end
12 | end
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 |
3 | # Specify your gem's dependencies in maquina_components.gemspec.
4 | gemspec
5 |
6 | gem "puma"
7 |
8 | gem "sqlite3"
9 |
10 | # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
11 | gem "standard", require: false
12 |
13 | # Start debugger with binding.b [https://github.com/ruby/debug]
14 | # gem "debug", ">= 1.0.0"
15 |
16 | gem "importmap-rails", "~> 2.1"
17 | gem "propshaft", "~> 1.1"
18 | gem "tailwindcss-rails", "~> 4.4"
19 |
20 | # Hotwire
21 | gem "turbo-rails"
22 | gem "stimulus-rails"
23 |
--------------------------------------------------------------------------------
/app/views/components/sidebar/_trigger.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (icon_name:, css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | sidebar_part: :trigger,
4 | controller: "sidebar-trigger",
5 | action: "click->sidebar-trigger#triggerClick",
6 | sidebar_trigger_sidebar_outlet: "[data-outlet='sidebar']"
7 | ) %>
8 |
9 | <%= button_tag type: :button, class: css_classes, data: merged_data, **html_options do %>
10 | <%= icon_for(icon_name) %>
11 | Toggle Sidebar
12 | <% end %>
13 |
--------------------------------------------------------------------------------
/app/views/components/card/_footer.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (align: :start, spacing: :default, css_classes: "", **html_options) %>
2 | <% align_value = case align.to_sym
3 | when :between then :between
4 | when :end then :end
5 | when :center then :center
6 | else nil
7 | end
8 |
9 | merged_data = (html_options.delete(:data) || {}).merge(
10 | card_part: :footer,
11 | align: align_value,
12 | spacing: (spacing == :full ? :full : nil)
13 | ).compact %>
14 |
15 | <%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
16 | <%= yield %>
17 | <% end %>
18 |
--------------------------------------------------------------------------------
/test/dummy/app/javascript/application.js:
--------------------------------------------------------------------------------
1 | // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
2 |
3 | import "@hotwired/turbo-rails"
4 | import { Application } from "@hotwired/stimulus"
5 | import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
6 |
7 | const application = Application.start()
8 |
9 | // Configure Stimulus development experience
10 | application.debug = false
11 | window.Stimulus = application
12 |
13 | // Eager load all controllers from the engine (defined in importmap via pin_all_from)
14 | eagerLoadControllersFrom("controllers", application)
15 |
--------------------------------------------------------------------------------
/app/views/components/dropdown_menu/_content.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (align: :start, side: :bottom, width: :default, css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | "dropdown-menu-part": "content",
4 | "dropdown-menu-target": "content",
5 | align: align,
6 | side: side,
7 | width: width,
8 | state: "closed"
9 | ) %>
10 |
11 | <%= content_tag :div,
12 | role: "menu",
13 | aria: { orientation: "vertical" },
14 | tabindex: "-1",
15 | class: css_classes.presence,
16 | data: merged_data,
17 | hidden: true,
18 | **html_options do %>
19 | <%= yield %>
20 | <% end %>
21 |
--------------------------------------------------------------------------------
/app/assets/tailwind/maquina_components_engine/engine.css:
--------------------------------------------------------------------------------
1 | @source "../../../views/";
2 |
3 | @layer components {
4 | @import "../../stylesheets/alert.css";
5 | @import "../../stylesheets/badge.css";
6 | @import "../../stylesheets/breadcrumbs.css";
7 | @import "../../stylesheets/card.css";
8 | @import "../../stylesheets/dropdown_menu.css";
9 | @import "../../stylesheets/empty.css";
10 | @import "../../stylesheets/form.css";
11 | @import "../../stylesheets/header.css";
12 | @import "../../stylesheets/pagination.css";
13 | @import "../../stylesheets/sidebar.css";
14 | @import "../../stylesheets/table.css";
15 | @import "../../stylesheets/toggle_group.css";
16 | }
17 |
--------------------------------------------------------------------------------
/test/test_helper.rb:
--------------------------------------------------------------------------------
1 | # Configure Rails Environment
2 | ENV["RAILS_ENV"] = "test"
3 |
4 | require_relative "../test/dummy/config/environment"
5 | ActiveRecord::Migrator.migrations_paths = [File.expand_path("../test/dummy/db/migrate", __dir__)]
6 | require "rails/test_help"
7 |
8 | # Load fixtures from the engine
9 | if ActiveSupport::TestCase.respond_to?(:fixture_paths=)
10 | ActiveSupport::TestCase.fixture_paths = [File.expand_path("fixtures", __dir__)]
11 | ActionDispatch::IntegrationTest.fixture_paths = ActiveSupport::TestCase.fixture_paths
12 | ActiveSupport::TestCase.file_fixture_path = File.expand_path("fixtures", __dir__) + "/files"
13 | ActiveSupport::TestCase.fixtures :all
14 | end
15 |
--------------------------------------------------------------------------------
/app/views/components/toggle_group/_item.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (value:, pressed: false, disabled: false, aria_label: nil, css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | "toggle-group-part": "item",
4 | "toggle-group-target": "item",
5 | value: value,
6 | state: pressed ? "on" : "off",
7 | action: "click->toggle-group#toggle keydown->toggle-group#handleKeydown"
8 | ) %>
9 |
10 | <%= content_tag :button,
11 | type: "button",
12 | class: css_classes.presence,
13 | data: merged_data,
14 | disabled: disabled,
15 | "aria-pressed": pressed,
16 | "aria-label": aria_label,
17 | **html_options do %>
18 | <%= yield %>
19 | <% end %>
20 |
--------------------------------------------------------------------------------
/app/views/components/pagination/_link.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (href:, active: false, disabled: false, css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | "pagination-part": "link",
4 | active: active
5 | ) %>
6 |
7 | <% if disabled %>
8 | <%= content_tag :span,
9 | class: css_classes.presence,
10 | data: merged_data,
11 | "aria-disabled": "true",
12 | **html_options do %>
13 | <%= yield %>
14 | <% end %>
15 | <% else %>
16 | <%= link_to href,
17 | class: css_classes.presence,
18 | data: merged_data,
19 | "aria-current": (active ? "page" : nil),
20 | **html_options do %>
21 | <%= yield %>
22 | <% end %>
23 | <% end %>
24 |
--------------------------------------------------------------------------------
/test/dummy/config/initializers/inflections.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Add new inflection rules using the following format. Inflections
4 | # are locale specific, and you may define rules for as many different
5 | # locales as you wish. All of these examples are active by default:
6 | # ActiveSupport::Inflector.inflections(:en) do |inflect|
7 | # inflect.plural /^(ox)$/i, "\\1en"
8 | # inflect.singular /^(ox)en/i, "\\1"
9 | # inflect.irregular "person", "people"
10 | # inflect.uncountable %w( fish sheep )
11 | # end
12 |
13 | # These inflection rules are supported but not enabled by default:
14 | # ActiveSupport::Inflector.inflections(:en) do |inflect|
15 | # inflect.acronym "RESTful"
16 | # end
17 |
--------------------------------------------------------------------------------
/test/dummy/config/routes.rb:
--------------------------------------------------------------------------------
1 | Rails.application.routes.draw do
2 | # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
3 |
4 | # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
5 | # Can be used by load balancers and uptime monitors to verify that the app is live.
6 | get "up" => "rails/health#show", :as => :rails_health_check
7 |
8 | # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb)
9 | # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
10 | # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker
11 |
12 | # Defines the root path route ("/")
13 | root "home#index"
14 | end
15 |
--------------------------------------------------------------------------------
/app/views/components/sidebar/_provider.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (default_open: true, variant: :inset, css_classes: "", cookie_name: "sidebar_state", keyboard_shortcut: "b", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | component: :sidebar,
4 | variant: variant,
5 | controller: "sidebar",
6 | outlet: "sidebar",
7 | sidebar_default_open_value: default_open,
8 | sidebar_open_value: default_open,
9 | sidebar_cookie_name_value: cookie_name,
10 | sidebar_keyboard_shortcut_value: keyboard_shortcut,
11 | action: "keydown.meta+#{keyboard_shortcut}@window->sidebar#toggleWithKeyboard keydown.ctrl+#{keyboard_shortcut}@window->sidebar#toggleWithKeyboard"
12 | ) %>
13 |
14 | <%= content_tag :div, class: css_classes, data: merged_data, **html_options do %>
15 | <%= yield %>
16 | <% end %>
17 |
--------------------------------------------------------------------------------
/app/views/components/dropdown_menu/_trigger.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (variant: :outline, size: :default, as_child: false, css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | "dropdown-menu-target": "trigger",
4 | action: "dropdown-menu#toggle"
5 | ) %>
6 |
7 | <% if as_child %>
8 | <%= yield %>
9 | <% else %>
10 | <% button_data = merged_data.merge(
11 | component: "button",
12 | variant: variant,
13 | size: size
14 | ) %>
15 | <%= content_tag :button,
16 | type: "button",
17 | class: css_classes.presence,
18 | data: button_data,
19 | aria: { haspopup: "menu", expanded: "false" },
20 | **html_options do %>
21 | <%= yield %>
22 | <%= icon_for :chevron_down, data: { "dropdown-menu-target": "chevron" } %>
23 | <% end %>
24 | <% end %>
25 |
--------------------------------------------------------------------------------
/app/views/components/pagination/_ellipsis.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", **html_options) %>
2 | <% sr_label = t("maquina_components.pagination.more_pages", default: "More pages") %>
3 |
4 |
9 | >
10 |
22 |
23 |
24 |
25 |
26 |
27 | <%= sr_label %>
28 |
29 |
--------------------------------------------------------------------------------
/bin/standardrb:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | #
5 | # This file was generated by Bundler.
6 | #
7 | # The application 'standardrb' is installed as part of a gem, and
8 | # this file is here to facilitate running it.
9 | #
10 |
11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
12 |
13 | bundle_binstub = File.expand_path("bundle", __dir__)
14 |
15 | if File.file?(bundle_binstub)
16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
17 | load(bundle_binstub)
18 | else
19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
21 | end
22 | end
23 |
24 | require "rubygems"
25 | require "bundler/setup"
26 |
27 | load Gem.bin_path("standard", "standardrb")
28 |
--------------------------------------------------------------------------------
/app/views/components/_table.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (css_classes: "", container: true, variant: nil, table_variant: nil, **html_options) %>
2 | <%
3 | # Table data attributes - merge user data with component defaults
4 | table_data = (html_options.delete(:data) || {}).merge(component: :table)
5 | table_data[:variant] = table_variant if table_variant
6 |
7 | # Container data attributes
8 | container_data = { table_part: :container }
9 | container_data[:variant] = variant if variant
10 | %>
11 | <% if container %>
12 | >
13 | <%= content_tag :table, class: css_classes.presence, data: table_data, **html_options do %>
14 | <%= yield %>
15 | <% end %>
16 |
17 | <% else %>
18 | <%= content_tag :table, class: css_classes.presence, data: table_data, **html_options do %>
19 | <%= yield %>
20 | <% end %>
21 | <% end %>
22 |
--------------------------------------------------------------------------------
/app/views/components/_toggle_group.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (type: :single, variant: :default, size: :default, value: nil, disabled: false, css_classes: "", **html_options) %>
2 | <% selected_values = case value
3 | when Array then value.map(&:to_s)
4 | when nil then []
5 | else [value.to_s]
6 | end
7 |
8 | merged_data = (html_options.delete(:data) || {}).merge(
9 | controller: "toggle-group",
10 | component: "toggle-group",
11 | variant: variant,
12 | size: size,
13 | "toggle-group-type-value": type,
14 | "toggle-group-selected-value": selected_values.to_json
15 | ) %>
16 |
17 | <%= content_tag :div,
18 | role: "group",
19 | class: css_classes.presence,
20 | data: merged_data,
21 | "aria-disabled": (disabled ? "true" : nil),
22 | **html_options do %>
23 | <%= yield %>
24 | <% end %>
25 |
--------------------------------------------------------------------------------
/app/javascript/controllers/sidebar_trigger_controller.js:
--------------------------------------------------------------------------------
1 | import { Controller } from "@hotwired/stimulus"
2 |
3 | /**
4 | * Sidebar Trigger Controller
5 | *
6 | * Triggers sidebar toggle via Stimulus outlets.
7 | * Can be placed anywhere on the page - finds sidebar via outlet selector.
8 | *
9 | * @example
10 | *
15 | * Toggle
16 | *
17 | */
18 | export default class extends Controller {
19 | static outlets = ["sidebar"]
20 |
21 | /**
22 | * Toggle sidebar when trigger is clicked
23 | * Works with multiple sidebars if multiple outlets are connected
24 | */
25 | triggerClick() {
26 | if (this.hasSidebarOutlet) {
27 | this.sidebarOutlets.forEach(outlet => {
28 | outlet.toggle()
29 | })
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/app/views/components/dropdown_menu/_item.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (href: nil, method: nil, variant: :default, disabled: false, css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | "dropdown-menu-part": "item",
4 | variant: variant
5 | ) %>
6 |
7 | <% merged_data["turbo-method"] = method if method.present? %>
8 |
9 | <% aria_attrs = {} %>
10 | <% aria_attrs[:disabled] = "true" if disabled %>
11 |
12 | <% tag_name = href.present? ? :a : :button %>
13 |
14 | <% tag_options = {
15 | role: "menuitem",
16 | tabindex: "-1",
17 | class: css_classes.presence,
18 | data: merged_data,
19 | aria: aria_attrs.presence,
20 | disabled: (disabled unless href.present?),
21 | **html_options
22 | } %>
23 |
24 | <% tag_options[:href] = href if href.present? %>
25 | <% tag_options[:type] = "button" if tag_name == :button %>
26 |
27 | <%= content_tag tag_name, **tag_options.compact do %>
28 | <%= yield %>
29 | <% end %>
30 |
--------------------------------------------------------------------------------
/test/dummy/app/views/pwa/service-worker.js:
--------------------------------------------------------------------------------
1 | // Add a service worker for processing Web Push notifications:
2 | //
3 | // self.addEventListener("push", async (event) => {
4 | // const { title, options } = await event.data.json()
5 | // event.waitUntil(self.registration.showNotification(title, options))
6 | // })
7 | //
8 | // self.addEventListener("notificationclick", function(event) {
9 | // event.notification.close()
10 | // event.waitUntil(
11 | // clients.matchAll({ type: "window" }).then((clientList) => {
12 | // for (let i = 0; i < clientList.length; i++) {
13 | // let client = clientList[i]
14 | // let clientPath = (new URL(client.url)).pathname
15 | //
16 | // if (clientPath == event.notification.data.path && "focus" in client) {
17 | // return client.focus()
18 | // }
19 | // }
20 | //
21 | // if (clients.openWindow) {
22 | // return clients.openWindow(event.notification.data.path)
23 | // }
24 | // })
25 | // )
26 | // })
27 |
--------------------------------------------------------------------------------
/app/views/components/_dropdown.html.erb:
--------------------------------------------------------------------------------
1 |
21 | <%= yield %>
22 |
23 |
26 |
--------------------------------------------------------------------------------
/test/dummy/config/application.rb:
--------------------------------------------------------------------------------
1 | require_relative "boot"
2 |
3 | require "rails/all"
4 |
5 | # Require the gems listed in Gemfile, including any gems
6 | # you've limited to :test, :development, or :production.
7 | Bundler.require(*Rails.groups)
8 |
9 | module Dummy
10 | class Application < Rails::Application
11 | config.load_defaults Rails::VERSION::STRING.to_f
12 |
13 | # Please, add to the `ignore` list any other `lib` subdirectories that do
14 | # not contain `.rb` files, or that should not be reloaded or eager loaded.
15 | # Common ones are `templates`, `generators`, or `middleware`, for example.
16 | config.autoload_lib(ignore: %w[assets tasks])
17 |
18 | # Configuration for the application, engines, and railties goes here.
19 | #
20 | # These settings can be overridden in specific environments using the files
21 | # in config/environments, which are processed later.
22 | #
23 | # config.time_zone = "Central Time (US & Canada)"
24 | # config.eager_load_paths << Rails.root.join("extras")
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/test/dummy/config/locales/en.yml:
--------------------------------------------------------------------------------
1 | # Files in the config/locales directory are used for internationalization and
2 | # are automatically loaded by Rails. If you want to use locales other than
3 | # English, add the necessary files in this directory.
4 | #
5 | # To use the locales, use `I18n.t`:
6 | #
7 | # I18n.t "hello"
8 | #
9 | # In views, this is aliased to just `t`:
10 | #
11 | # <%= t("hello") %>
12 | #
13 | # To use a different locale, set it with `I18n.locale`:
14 | #
15 | # I18n.locale = :es
16 | #
17 | # This would use the information in config/locales/es.yml.
18 | #
19 | # To learn more about the API, please read the Rails Internationalization guide
20 | # at https://guides.rubyonrails.org/i18n.html.
21 | #
22 | # Be aware that YAML interprets the following case-insensitive strings as
23 | # booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings
24 | # must be quoted to be interpreted as strings. For example:
25 | #
26 | # en:
27 | # "yes": yup
28 | # enabled: "ON"
29 |
30 | en:
31 | hello: "Hello world"
32 |
--------------------------------------------------------------------------------
/MIT-LICENSE:
--------------------------------------------------------------------------------
1 | Copyright Mario Alberto Chávez
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining
4 | a copy of this software and associated documentation files (the
5 | "Software"), to deal in the Software without restriction, including
6 | without limitation the rights to use, copy, modify, merge, publish,
7 | distribute, sublicense, and/or sell copies of the Software, and to
8 | permit persons to whom the Software is furnished to do so, subject to
9 | the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be
12 | included in all copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/test/dummy/config/database.yml:
--------------------------------------------------------------------------------
1 | # SQLite. Versions 3.8.0 and up are supported.
2 | # gem install sqlite3
3 | #
4 | # Ensure the SQLite 3 gem is defined in your Gemfile
5 | # gem "sqlite3"
6 | #
7 | default: &default
8 | adapter: sqlite3
9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
10 | timeout: 5000
11 |
12 | development:
13 | <<: *default
14 | database: storage/development.sqlite3
15 |
16 | # Warning: The database defined as "test" will be erased and
17 | # re-generated from your development database when you run "rake".
18 | # Do not set this db to the same as development or production.
19 | test:
20 | <<: *default
21 | database: storage/test.sqlite3
22 |
23 |
24 | # SQLite3 write its data on the local filesystem, as such it requires
25 | # persistent disks. If you are deploying to a managed service, you should
26 | # make sure it provides disk persistence, as many don't.
27 | #
28 | # Similarly, if you deploy your application as a Docker container, you must
29 | # ensure the database is located in a persisted volume.
30 | production:
31 | <<: *default
32 | # database: path/to/persistent/storage/production.sqlite3
33 |
--------------------------------------------------------------------------------
/test/dummy/bin/setup:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require "fileutils"
3 |
4 | APP_ROOT = File.expand_path("..", __dir__)
5 |
6 | def system!(*args)
7 | system(*args, exception: true)
8 | end
9 |
10 | FileUtils.chdir APP_ROOT do
11 | # This script is a way to set up or update your development environment automatically.
12 | # This script is idempotent, so that you can run it at any time and get an expectable outcome.
13 | # Add necessary setup steps to this file.
14 |
15 | puts "== Installing dependencies =="
16 | system("bundle check") || system!("bundle install")
17 |
18 | # puts "\n== Copying sample files =="
19 | # unless File.exist?("config/database.yml")
20 | # FileUtils.cp "config/database.yml.sample", "config/database.yml"
21 | # end
22 |
23 | puts "\n== Preparing database =="
24 | system! "bin/rails db:prepare"
25 |
26 | puts "\n== Removing old logs and tempfiles =="
27 | system! "bin/rails log:clear tmp:clear"
28 |
29 | unless ARGV.include?("--skip-server")
30 | puts "\n== Starting development server =="
31 | STDOUT.flush # flush the output before exec(2) so that it displays
32 | exec "bin/dev"
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/test/dummy/config/initializers/content_security_policy.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Define an application-wide content security policy.
4 | # See the Securing Rails Applications Guide for more information:
5 | # https://guides.rubyonrails.org/security.html#content-security-policy-header
6 |
7 | # Rails.application.configure do
8 | # config.content_security_policy do |policy|
9 | # policy.default_src :self, :https
10 | # policy.font_src :self, :https, :data
11 | # policy.img_src :self, :https, :data
12 | # policy.object_src :none
13 | # policy.script_src :self, :https
14 | # policy.style_src :self, :https
15 | # # Specify URI for violation reports
16 | # # policy.report_uri "/csp-violation-report-endpoint"
17 | # end
18 | #
19 | # # Generate session nonces for permitted importmap, inline scripts, and inline styles.
20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
21 | # config.content_security_policy_nonce_directives = %w(script-src style-src)
22 | #
23 | # # Report violations without enforcing the policy.
24 | # # config.content_security_policy_report_only = true
25 | # end
26 |
--------------------------------------------------------------------------------
/app/views/components/sidebar/_menu_link.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (url: "#", active: false, title: nil, subtitle: nil, icon: nil, text_icon: nil, icon_classes: "", css_classes: "", **html_options) %>
2 | <% merged_data = (html_options.delete(:data) || {}).merge(
3 | sidebar_part: "menu-link",
4 | active: active
5 | ) %>
6 |
7 | <%= link_to url, class: css_classes, data: merged_data, **html_options do %>
8 | <% if icon.present? || text_icon.present? %>
9 |
10 | <% if icon.present? %>
11 | <%= image_tag icon, class: icon_classes %>
12 | <% elsif text_icon.present? %>
13 | <%= text_icon %>
14 | <% end %>
15 |
16 | <% end %>
17 |
18 | <% if title.present? || subtitle.present? %>
19 |
23 | <% if title.present? %>
24 | <%= title %>
25 | <% end %>
26 |
27 | <% if subtitle.present? %>
28 |
29 | <% end %>
30 |
31 | <% end %>
32 | <% end %>
33 |
--------------------------------------------------------------------------------
/app/views/components/stats/_stats_grid.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (cards: [], columns: 3, container_class: "", action: nil, action_position: "end") -%>
2 |
3 | "
5 | >
6 | <% if action.present? && action_position == "start" %>
7 |
8 | <%= action %>
9 |
10 | <% end %>
11 |
12 |
"
20 | >
21 | <% cards.each do |card| %>
22 | <%= render "components/stats_card",
23 | title: card[:title],
24 | value: card[:value],
25 | icon: card[:icon],
26 | icon_class: card[:icon_class] || "",
27 | subtitle: card[:subtitle],
28 | value_class: card[:value_class] || "",
29 | container_class: card[:container_class] || "" %>
30 | <% end %>
31 |
32 |
33 | <% if action.present? && action_position == "end" %>
34 |
35 | <%= action %>
36 |
37 | <% end %>
38 |
39 |
--------------------------------------------------------------------------------
/test/dummy/config/storage.yml:
--------------------------------------------------------------------------------
1 | test:
2 | service: Disk
3 | root: <%= Rails.root.join("tmp/storage") %>
4 |
5 | local:
6 | service: Disk
7 | root: <%= Rails.root.join("storage") %>
8 |
9 | # Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
10 | # amazon:
11 | # service: S3
12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
14 | # region: us-east-1
15 | # bucket: your_own_bucket-<%= Rails.env %>
16 |
17 | # Remember not to checkin your GCS keyfile to a repository
18 | # google:
19 | # service: GCS
20 | # project: your_project
21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %>
22 | # bucket: your_own_bucket-<%= Rails.env %>
23 |
24 | # Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key)
25 | # microsoft:
26 | # service: AzureStorage
27 | # storage_account_name: your_account_name
28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %>
29 | # container: your_container_name-<%= Rails.env %>
30 |
31 | # mirror:
32 | # service: Mirror
33 | # primary: local
34 | # mirrors: [ amazon, google, microsoft ]
35 |
--------------------------------------------------------------------------------
/maquina-components.gemspec:
--------------------------------------------------------------------------------
1 | require_relative "lib/maquina_components/version"
2 |
3 | Gem::Specification.new do |spec|
4 | spec.name = "maquina-components"
5 | spec.version = MaquinaComponents::VERSION
6 | spec.authors = ["Mario Alberto Chávez"]
7 | spec.email = ["mario.chavez@gmail.com"]
8 | spec.homepage = "https://maquina.app"
9 | spec.summary = "ERB, TailwindCSS, and StimulusJS UI components based on Shadcn/UI."
10 | spec.description = "ERB, TailwindCSS, and StimulusJS UI components based on Shadcn/UI."
11 | spec.license = "MIT"
12 |
13 | # Prevent pushing this gem to RubyGems.org. To allow pushes either set the "allowed_push_host"
14 | # to allow pushing to a single host or delete this section to allow pushing to any host.
15 | # spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'"
16 |
17 | spec.metadata["homepage_uri"] = spec.homepage
18 | spec.metadata["source_code_uri"] = "https://github.com/maquina-app/maquina_components"
19 | spec.metadata["changelog_uri"] = "https://github.com/maquina-app/maquina_components"
20 |
21 | spec.files = Dir.chdir(File.expand_path(__dir__)) do
22 | Dir["{app,config,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md"]
23 | end
24 |
25 | spec.add_dependency "rails", ">= 7.2.0"
26 | spec.add_dependency "tailwindcss-rails", "~> 4.2"
27 | end
28 |
--------------------------------------------------------------------------------
/app/views/components/_sidebar.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (id: nil, state: :collapsed, collapsible: :offcanvas, variant: :inset, side: :left, css_classes: "", **html_options) %>
2 | <% random_id = id || "sidebar-#{SecureRandom.hex(6)}"
3 |
4 | merged_data = (html_options.delete(:data) || {}).merge(
5 | sidebar_part: :root,
6 | sidebar_target: "sidebar",
7 | state: state,
8 | variant: variant,
9 | collapsible: collapsible,
10 | side: side
11 | ) %>
12 |
13 |
41 |
--------------------------------------------------------------------------------
/app/javascript/controllers/menu_button_controller.js:
--------------------------------------------------------------------------------
1 | import { Controller } from "@hotwired/stimulus"
2 |
3 | export default class extends Controller {
4 | static targets = ["button", "content"]
5 |
6 | connect() {
7 | if (!this.hasContentTarget) {
8 | return
9 | }
10 |
11 | this.clickOutside = this.clickOutside.bind(this)
12 | this.isOpen = this.buttonTarget.dataset.state === "open"
13 |
14 | if (this.isOpen) {
15 | this.addClickOutsideListener()
16 | }
17 | }
18 |
19 | disconnect() {
20 | this.removeClickOutsideListener()
21 | }
22 |
23 | toggle() {
24 | if (!this.hasContentTarget) {
25 | return
26 | }
27 |
28 | this.contentTarget.classList.remove("hidden")
29 |
30 | this.isOpen = !this.isOpen
31 | this.buttonTarget.dataset.state = this.isOpen ? "open" : "closed"
32 |
33 | if (this.isOpen) {
34 | // Add a small delay before adding the click outside listener
35 | setTimeout(() => {
36 | this.addClickOutsideListener()
37 | }, 100)
38 | } else {
39 | this.removeClickOutsideListener()
40 | }
41 | }
42 |
43 | clickOutside(event) {
44 | if (!this.isOpen) return
45 | if (event.target === this.element) return
46 |
47 | if (!this.contentTarget.contains(event.target)) {
48 | this.toggle()
49 | }
50 | }
51 |
52 | addClickOutsideListener() {
53 | document.addEventListener('click', this.clickOutside)
54 | }
55 |
56 | removeClickOutsideListener() {
57 | document.removeEventListener('click', this.clickOutside)
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/lib/generators/maquina_components/install/USAGE:
--------------------------------------------------------------------------------
1 | Description:
2 | Install maquina_components into your Rails application.
3 |
4 | This generator will:
5 | - Add the engine CSS import to your Tailwind application.css
6 | - Add theme variables (shadcn/ui convention) for customization
7 | - Create a helper file for icon overrides and customizations
8 |
9 | Example:
10 | bin/rails generate maquina_components:install
11 |
12 | This will:
13 | insert @import "../builds/tailwind/maquina_components_engine.css";
14 | append Theme variables to app/assets/tailwind/application.css
15 | create app/helpers/maquina_components_helper.rb
16 |
17 | Options:
18 | --skip-theme Skip adding theme variables (if you already have them)
19 | --skip-helper Skip creating the helper file
20 |
21 | Prerequisites:
22 | - tailwindcss-rails gem installed
23 | - app/assets/tailwind/application.css exists
24 |
25 | After Installation:
26 | 1. Customize theme variables in app/assets/tailwind/application.css
27 | 2. Override icon helper in app/helpers/maquina_components_helper.rb
28 | 3. Start using components:
29 |
30 | <%%= render "components/card" do %>
31 | <%%= render "components/card/header" do %>
32 | <%%= render "components/card/title", text: "Hello World" %>
33 | <%% end %>
34 | <%% end %>
35 |
36 | 4. Use data attributes with form helpers:
37 |
38 | <%%= f.text_field :email, data: { component: "input" } %>
39 | <%%= f.submit "Save", data: { component: "button", variant: "primary" } %>
40 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches: [main]
7 |
8 | jobs:
9 | lint:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout code
13 | uses: actions/checkout@v4
14 |
15 | - name: Set up Ruby
16 | uses: ruby/setup-ruby@v1
17 | with:
18 | ruby-version: .ruby-version
19 | bundler-cache: true
20 |
21 | - name: Lint code for consistent style
22 | run: bin/standardrb
23 |
24 | test:
25 | runs-on: ubuntu-latest
26 |
27 | # services:
28 | # redis:
29 | # image: redis
30 | # ports:
31 | # - 6379:6379
32 | # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
33 | steps:
34 | - name: Install packages
35 | run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libjemalloc2 libvips sqlite3
36 |
37 | - name: Checkout code
38 | uses: actions/checkout@v4
39 |
40 | - name: Set up Ruby
41 | uses: ruby/setup-ruby@v1
42 | with:
43 | ruby-version: .ruby-version
44 | bundler-cache: true
45 |
46 | - name: Run tests
47 | env:
48 | RAILS_ENV: test
49 | # REDIS_URL: redis://localhost:6379/0
50 | run: bin/test
51 |
52 | - name: Keep screenshots from failed system tests
53 | uses: actions/upload-artifact@v4
54 | if: failure()
55 | with:
56 | name: screenshots
57 | path: ${{ github.workspace }}/tmp/screenshots
58 | if-no-files-found: ignore
59 |
--------------------------------------------------------------------------------
/app/views/components/pagination/_next.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (href: nil, label: nil, disabled: false, show_label: true, css_classes: "", **html_options) %>
2 | <% is_disabled = disabled || href.nil?
3 | display_label = label || t("maquina_components.pagination.next", default: "Next")
4 | merged_data = (html_options.delete(:data) || {}).merge("pagination-part": "next") %>
5 |
6 | <% if is_disabled %>
7 |
12 | >
13 | <% if show_label %>
14 | <%= display_label %>
15 | <% end %>
16 |
17 |
29 |
30 |
31 |
32 | <% else %>
33 | <%= link_to href,
34 | class: css_classes.presence,
35 | data: merged_data,
36 | "aria-label": display_label,
37 | **html_options do %>
38 | <% if show_label %>
39 | <%= display_label %>
40 | <% end %>
41 |
42 |
54 |
55 |
56 | <% end %>
57 | <% end %>
58 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/header.css:
--------------------------------------------------------------------------------
1 | /* ===== Header Component Styles ===== */
2 | /*
3 | * Page header component for use with sidebar layouts.
4 | * Provides a consistent header with proper height and alignment.
5 | */
6 |
7 | /* ===== Base Styles ===== */
8 | [data-component="header"] {
9 | display: flex;
10 | align-items: center;
11 | flex-shrink: 0;
12 | height: var(--header-height, 3.5rem);
13 | border-bottom: 1px solid var(--border);
14 | background-color: var(--background);
15 | }
16 |
17 | /* Inner container */
18 | [data-component="header"] [data-header-part="inner"] {
19 | display: flex;
20 | width: 100%;
21 | align-items: center;
22 | @apply gap-1 px-4 lg:gap-2 lg:px-6;
23 | }
24 |
25 | /* ===== Inset Variant (rounded top corners) ===== */
26 | /*
27 | * When header is inside sidebar/inset, add rounded top corners
28 | * to match the inset content area styling.
29 | */
30 | [data-sidebar-part="inset"] [data-component="header"] {
31 | @apply rounded-t-xl;
32 | }
33 |
34 | /* ===== Header Elements ===== */
35 |
36 | /* Separator between trigger and content */
37 | [data-component="header"] [data-component="separator"][data-orientation="vertical"] {
38 | height: 1rem;
39 | @apply mx-2;
40 | }
41 |
42 | /* Title text */
43 | [data-component="header"] [data-header-part="title"] {
44 | @apply text-sm font-medium;
45 | color: var(--foreground);
46 | }
47 |
48 | /* Actions container (right side) */
49 | [data-component="header"] [data-header-part="actions"] {
50 | display: flex;
51 | align-items: center;
52 | margin-left: auto;
53 | @apply gap-2;
54 | }
55 |
56 | /* ===== Responsive ===== */
57 | @media (max-width: 1024px) {
58 | [data-component="header"] [data-header-part="inner"] {
59 | @apply gap-1 px-4;
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/app/views/components/pagination/_previous.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (href: nil, label: nil, disabled: false, show_label: true, css_classes: "", **html_options) %>
2 | <% is_disabled = disabled || href.nil?
3 | display_label = label || t("maquina_components.pagination.previous", default: "Previous")
4 | merged_data = (html_options.delete(:data) || {}).merge("pagination-part": "previous") %>
5 |
6 | <% if is_disabled %>
7 |
12 | >
13 |
25 |
26 |
27 |
28 | <% if show_label %>
29 | <%= display_label %>
30 | <% end %>
31 |
32 | <% else %>
33 | <%= link_to href,
34 | class: css_classes.presence,
35 | data: merged_data,
36 | "aria-label": display_label,
37 | **html_options do %>
38 |
50 |
51 |
52 |
53 | <% if show_label %>
54 | <%= display_label %>
55 | <% end %>
56 | <% end %>
57 | <% end %>
58 |
--------------------------------------------------------------------------------
/test/dummy/config/puma.rb:
--------------------------------------------------------------------------------
1 | # This configuration file will be evaluated by Puma. The top-level methods that
2 | # are invoked here are part of Puma's configuration DSL. For more information
3 | # about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html.
4 | #
5 | # Puma starts a configurable number of processes (workers) and each process
6 | # serves each request in a thread from an internal thread pool.
7 | #
8 | # You can control the number of workers using ENV["WEB_CONCURRENCY"]. You
9 | # should only set this value when you want to run 2 or more workers. The
10 | # default is already 1.
11 | #
12 | # The ideal number of threads per worker depends both on how much time the
13 | # application spends waiting for IO operations and on how much you wish to
14 | # prioritize throughput over latency.
15 | #
16 | # As a rule of thumb, increasing the number of threads will increase how much
17 | # traffic a given process can handle (throughput), but due to CRuby's
18 | # Global VM Lock (GVL) it has diminishing returns and will degrade the
19 | # response time (latency) of the application.
20 | #
21 | # The default is set to 3 threads as it's deemed a decent compromise between
22 | # throughput and latency for the average Rails application.
23 | #
24 | # Any libraries that use a connection pool or another resource pool should
25 | # be configured to provide at least as many connections as the number of
26 | # threads. This includes Active Record's `pool` parameter in `database.yml`.
27 | threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
28 | threads threads_count, threads_count
29 |
30 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000.
31 | port ENV.fetch("PORT", 3000)
32 |
33 | # Allow puma to be restarted by `bin/rails restart` command.
34 | plugin :tmp_restart
35 |
36 | # Specify the PID file. Defaults to tmp/pids/server.pid in development.
37 | # In other environments, only set the PID file if requested.
38 | pidfile ENV["PIDFILE"] if ENV["PIDFILE"]
39 |
--------------------------------------------------------------------------------
/app/views/components/_simple_table.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (collection:, columns:, caption: nil, variant: nil, table_variant: nil, empty_message: "No data available", row_id: nil, html_options: {}) %>
2 | <%= render "components/table", variant: variant, table_variant: table_variant, **html_options do %>
3 | <% if caption.present? %>
4 | <%= render "components/table/caption" do %><%= caption %><% end %>
5 | <% end %>
6 |
7 | <%= render "components/table/header" do %>
8 | <%= render "components/table/row" do %>
9 | <% columns.each do |column| %>
10 | <%= render "components/table/head", css_classes: table_alignment_class(column[:align]) do %>
11 | <%= column[:label] %>
12 | <% end %>
13 | <% end %>
14 | <% end %>
15 | <% end %>
16 |
17 | <%= render "components/table/body" do %>
18 | <% if collection.empty? %>
19 | <%= render "components/table/row" do %>
20 | <%= render "components/table/cell", colspan: columns.size, data: { empty: "true" } do %>
21 | <%= empty_message %>
22 | <% end %>
23 | <% end %>
24 | <% else %>
25 | <% collection.each do |item| %>
26 | <%
27 | row_options = {}
28 | row_options[:id] = "row-#{item.public_send(row_id)}" if row_id && item.respond_to?(row_id)
29 | %>
30 | <%= render "components/table/row", **row_options do %>
31 | <% columns.each do |column| %>
32 | <%
33 | value = if column[:key].is_a?(Proc)
34 | column[:key].call(item)
35 | elsif item.is_a?(Hash)
36 | item[column[:key]] || item[column[:key].to_s]
37 | elsif item.respond_to?(column[:key])
38 | item.public_send(column[:key])
39 | end
40 | %>
41 | <%= render "components/table/cell", css_classes: table_alignment_class(column[:align]) do %>
42 | <%= value %>
43 | <% end %>
44 | <% end %>
45 | <% end %>
46 | <% end %>
47 | <% end %>
48 | <% end %>
49 | <% end %>
50 |
--------------------------------------------------------------------------------
/app/views/components/_menu_button.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (title: "", subtitle: nil, icon: nil, text_icon: nil, icon_classes: "", submenu: false) %>
2 |
3 |
45 |
--------------------------------------------------------------------------------
/app/helpers/maquina_components/sidebar_helper.rb:
--------------------------------------------------------------------------------
1 | module MaquinaComponents
2 | module SidebarHelper
3 | # Get sidebar state from cookie
4 | #
5 | # Reads the sidebar state cookie and returns a String.
6 | # Use this to set the state value in the sidebar
7 | # to ensure server-rendered state matches client state.
8 | #
9 | # @param cookie_name [String] The cookie name (default: "sidebar_state")
10 | # @return [String] expanded if sidebar should be open, collapsed otherwise
11 | #
12 | # @example In layout
13 | # <%= render "components/sidebar",
14 | # state: sidebar_state do %>
15 | #
16 | # <% end %>
17 | #
18 | # @example With custom cookie name
19 | # <%= render "components/sidebar",
20 | # state: sidebar_state("custom_sidebar_cookie") do %>
21 | #
22 | # <% end %>
23 | #
24 | def sidebar_state(cookie_name = "sidebar_state")
25 | # Read cookie value
26 | cookie_value = cookies[cookie_name]
27 |
28 | # Default to expanded when no cookie exists
29 | return :expanded if cookie_value.nil?
30 |
31 | # Return expanded if cookie says "true", otherwise collapsed
32 | (cookie_value == "true") ? :expanded : :collapsed
33 | end
34 |
35 | # Check if sidebar is currently open
36 | #
37 | # @param cookie_name [String] The cookie name (default: "sidebar_state")
38 | # @return [Boolean] true if sidebar is open
39 | #
40 | # @example
41 | # <% if sidebar_open? %>
42 | #
43 | # <% end %>
44 | #
45 | def sidebar_open?(cookie_name = "sidebar_state")
46 | sidebar_state(cookie_name) == :expanded
47 | end
48 |
49 | # Check if sidebar is currently closed
50 | #
51 | # @param cookie_name [String] The cookie name (default: "sidebar_state")
52 | # @return [Boolean] true if sidebar is closed
53 | #
54 | # @example
55 | # <% if sidebar_closed? %>
56 | #
57 | # <% end %>
58 | #
59 | def sidebar_closed?(cookie_name = "sidebar_state")
60 | sidebar_state(cookie_name) == :collapsed
61 | end
62 | end
63 | end
64 |
--------------------------------------------------------------------------------
/app/javascript/controllers/breadcrumb_controller.js:
--------------------------------------------------------------------------------
1 | import { Controller } from "@hotwired/stimulus"
2 |
3 | export default class extends Controller {
4 | static targets = ["item", "ellipsis", "ellipsisSeparator"]
5 |
6 | connect() {
7 | this.windowResizeHandler = this.handleResize.bind(this)
8 | window.addEventListener('resize', this.windowResizeHandler)
9 | this.handleResize()
10 | }
11 |
12 | disconnect() {
13 | window.removeEventListener('resize', this.windowResizeHandler)
14 | }
15 |
16 | handleResize() {
17 | // Get visible width of container
18 | const containerWidth = this.element.clientWidth
19 | const items = this.itemTargets
20 | const ellipsis = this.hasEllipsisTarget ? this.ellipsisTarget : null
21 | const ellipsisSeparator = this.hasEllipsisSeparatorTarget ? this.ellipsisSeparatorTarget : null
22 |
23 | // Always show first and last items
24 | if (items.length < 3 || !ellipsis) {
25 | return; // Not enough items to collapse or no ellipsis element
26 | }
27 |
28 | // Reset visibility
29 | if (ellipsis) ellipsis.classList.add('hidden')
30 | if (ellipsisSeparator) ellipsisSeparator.classList.add('hidden')
31 |
32 | items.forEach(item => {
33 | item.classList.remove('hidden')
34 | })
35 |
36 | // Check if we need to collapse items
37 | let totalWidth = 0
38 | items.forEach(item => {
39 | totalWidth += item.offsetWidth
40 | })
41 |
42 | if (totalWidth > containerWidth) {
43 | // We need to collapse items - show ellipsis
44 | if (ellipsis) ellipsis.classList.remove('hidden')
45 | if (ellipsisSeparator) ellipsisSeparator.classList.remove('hidden')
46 |
47 | // Start hiding middle items until we fit
48 | for (let i = items.length - 2; i > 0; i--) {
49 | if (i !== 0 && i !== items.length - 1) {
50 | items[i].classList.add('hidden')
51 |
52 | // Recalculate total width
53 | totalWidth = 0
54 |
55 | if (ellipsis) totalWidth += ellipsis.offsetWidth
56 | if (ellipsisSeparator) totalWidth += ellipsisSeparator.offsetWidth
57 |
58 | items.forEach(item => {
59 | if (!item.classList.contains('hidden')) {
60 | totalWidth += item.offsetWidth
61 | }
62 | })
63 |
64 | if (totalWidth <= containerWidth) {
65 | break
66 | }
67 | }
68 | }
69 | }
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/test/dummy/config/environments/test.rb:
--------------------------------------------------------------------------------
1 | # The test environment is used exclusively to run your application's
2 | # test suite. You never need to work with it otherwise. Remember that
3 | # your test database is "scratch space" for the test suite and is wiped
4 | # and recreated between test runs. Don't rely on the data there!
5 |
6 | Rails.application.configure do
7 | # Settings specified here will take precedence over those in config/application.rb.
8 |
9 | # While tests run files are not watched, reloading is not necessary.
10 | config.enable_reloading = false
11 |
12 | # Eager loading loads your entire application. When running a single test locally,
13 | # this is usually not necessary, and can slow down your test suite. However, it's
14 | # recommended that you enable it in continuous integration systems to ensure eager
15 | # loading is working properly before deploying your code.
16 | config.eager_load = ENV["CI"].present?
17 |
18 | # Configure public file server for tests with cache-control for performance.
19 | config.public_file_server.headers = {"cache-control" => "public, max-age=3600"}
20 |
21 | # Show full error reports.
22 | config.consider_all_requests_local = true
23 | config.cache_store = :null_store
24 |
25 | # Render exception templates for rescuable exceptions and raise for other exceptions.
26 | config.action_dispatch.show_exceptions = :rescuable
27 |
28 | # Disable request forgery protection in test environment.
29 | config.action_controller.allow_forgery_protection = false
30 |
31 | # Store uploaded files on the local file system in a temporary directory.
32 | config.active_storage.service = :test
33 |
34 | # Tell Action Mailer not to deliver emails to the real world.
35 | # The :test delivery method accumulates sent emails in the
36 | # ActionMailer::Base.deliveries array.
37 | config.action_mailer.delivery_method = :test
38 |
39 | # Set host to be used by links generated in mailer templates.
40 | config.action_mailer.default_url_options = {host: "example.com"}
41 |
42 | # Print deprecation notices to the stderr.
43 | config.active_support.deprecation = :stderr
44 |
45 | # Raises error for missing translations.
46 | # config.i18n.raise_on_missing_translations = true
47 |
48 | # Annotate rendered view with file names.
49 | # config.action_view.annotate_rendered_view_with_filenames = true
50 |
51 | # Raise error when a before_action's only/except options reference missing actions.
52 | config.action_controller.raise_on_missing_callback_actions = true
53 | end
54 |
--------------------------------------------------------------------------------
/lib/generators/maquina_components/install/templates/maquina_components_helper.rb.tt:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # MaquinaComponents Helper
4 | #
5 | # This helper provides customization hooks for maquina_components.
6 | # Override methods here to integrate with your application's icon system,
7 | # sidebar state management, and other customizations.
8 | #
9 | # Documentation: https://github.com/maquina-app/maquina_components
10 | #
11 | module MaquinaComponentsHelper
12 | # Icon Override
13 | #
14 | # Override this method to use your own icon system (Heroicons, Lucide, etc.)
15 | # The engine's icon_for helper will call this method first.
16 | #
17 | # @param name [Symbol] Icon name (e.g., :check, :chevron_right, :home)
18 | # @param options [Hash] Options hash with :class, :stroke_width, etc.
19 | # @return [String, nil] SVG string or nil to fall back to engine's icons
20 | #
21 | # @example Using Heroicons
22 | # def main_icon_svg_for(name)
23 | # heroicon_tag(name, variant: :outline)
24 | # end
25 | #
26 | # @example Using Lucide
27 | # def main_icon_svg_for(name)
28 | # lucide_icon(name)
29 | # end
30 | #
31 | # @example Using inline SVG files
32 | # def main_icon_svg_for(name)
33 | # file_path = Rails.root.join("app/assets/images/icons/#{name}.svg")
34 | # File.read(file_path) if File.exist?(file_path)
35 | # end
36 | #
37 | def main_icon_svg_for(name)
38 | # Return nil to use the engine's built-in icons
39 | # Override this method to use your own icon system
40 | nil
41 | end
42 |
43 | # Sidebar State Helpers
44 | #
45 | # These helpers are re-exported from the engine for convenience.
46 | # You can override them if you need custom cookie names or behavior.
47 |
48 | # Returns the current sidebar state from cookies
49 | # @param cookie_name [String] Cookie name (default: "sidebar_state")
50 | # @return [Symbol] :expanded or :collapsed
51 | def app_sidebar_state(cookie_name = "sidebar_state")
52 | sidebar_state(cookie_name)
53 | end
54 |
55 | # Check if sidebar is expanded
56 | # @param cookie_name [String] Cookie name (default: "sidebar_state")
57 | # @return [Boolean]
58 | def app_sidebar_open?(cookie_name = "sidebar_state")
59 | sidebar_open?(cookie_name)
60 | end
61 |
62 | # Check if sidebar is collapsed
63 | # @param cookie_name [String] Cookie name (default: "sidebar_state")
64 | # @return [Boolean]
65 | def app_sidebar_closed?(cookie_name = "sidebar_state")
66 | sidebar_closed?(cookie_name)
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/test/dummy/app/javascript/controllers/theme_color_controller.js:
--------------------------------------------------------------------------------
1 | import { Controller } from "@hotwired/stimulus"
2 |
3 | // Theme Color controller for switching color themes (neutral, green, rose, blue).
4 | //
5 | // Works alongside the theme controller (light/dark mode).
6 | // Applies data-theme attribute to element.
7 | // Persists preference in cookie.
8 | //
9 | // Cookie: "color_theme" with values "neutral", "green", "rose", "blue"
10 | //
11 | export default class extends Controller {
12 | static targets = ["item"]
13 | static values = {
14 | current: { type: String, default: "neutral" }
15 | }
16 |
17 | connect() {
18 | // Read preference from cookie
19 | const saved = this.getCookie("color_theme")
20 | this.currentValue = saved || "neutral"
21 |
22 | // Apply theme immediately
23 | this.applyTheme()
24 |
25 | // Update toggle group state
26 | this.updateToggleState()
27 | }
28 |
29 | // Called when toggle group emits change event
30 | change(event) {
31 | const newTheme = event.detail?.value
32 | if (newTheme && newTheme !== this.currentValue) {
33 | this.currentValue = newTheme
34 | this.saveCookie()
35 | this.applyTheme()
36 | }
37 | }
38 |
39 | // Called when clicking an item directly
40 | select(event) {
41 | const newTheme = event.currentTarget.dataset.themeColorValue
42 | if (newTheme && newTheme !== this.currentValue) {
43 | this.currentValue = newTheme
44 | this.saveCookie()
45 | this.applyTheme()
46 | this.updateToggleState()
47 | }
48 | }
49 |
50 | applyTheme() {
51 | const html = document.documentElement
52 |
53 | // Remove all theme attributes first
54 | if (this.currentValue === "neutral") {
55 | html.removeAttribute("data-theme")
56 | } else {
57 | html.setAttribute("data-theme", this.currentValue)
58 | }
59 | }
60 |
61 | updateToggleState() {
62 | // Update aria-pressed on toggle items
63 | this.itemTargets.forEach(item => {
64 | const isSelected = item.dataset.themeColorValue === this.currentValue
65 | item.setAttribute("aria-pressed", isSelected.toString())
66 | item.setAttribute("data-state", isSelected ? "on" : "off")
67 | })
68 | }
69 |
70 | saveCookie() {
71 | if (this.currentValue === "neutral") {
72 | // Remove cookie to use default
73 | document.cookie = "color_theme=; path=/; max-age=0"
74 | } else {
75 | // Set cookie for 1 year
76 | document.cookie = `color_theme=${this.currentValue}; path=/; max-age=31536000`
77 | }
78 | }
79 |
80 | getCookie(name) {
81 | const match = document.cookie.match(new RegExp("(^| )" + name + "=([^;]+)"))
82 | return match ? match[2] : null
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/test/dummy/config/environments/development.rb:
--------------------------------------------------------------------------------
1 | require "active_support/core_ext/integer/time"
2 |
3 | Rails.application.configure do
4 | # Settings specified here will take precedence over those in config/application.rb.
5 |
6 | # Make code changes take effect immediately without server restart.
7 | config.enable_reloading = true
8 |
9 | # Do not eager load code on boot.
10 | config.eager_load = false
11 |
12 | # Show full error reports.
13 | config.consider_all_requests_local = true
14 |
15 | # Enable server timing.
16 | config.server_timing = true
17 |
18 | # Enable/disable Action Controller caching. By default Action Controller caching is disabled.
19 | # Run rails dev:cache to toggle Action Controller caching.
20 | if Rails.root.join("tmp/caching-dev.txt").exist?
21 | config.action_controller.perform_caching = true
22 | config.action_controller.enable_fragment_cache_logging = true
23 | config.public_file_server.headers = {"cache-control" => "public, max-age=#{2.days.to_i}"}
24 | else
25 | config.action_controller.perform_caching = false
26 | end
27 |
28 | # Change to :null_store to avoid any caching.
29 | config.cache_store = :memory_store
30 |
31 | # Store uploaded files on the local file system (see config/storage.yml for options).
32 | config.active_storage.service = :local
33 |
34 | # Don't care if the mailer can't send.
35 | config.action_mailer.raise_delivery_errors = false
36 |
37 | # Make template changes take effect immediately.
38 | config.action_mailer.perform_caching = false
39 |
40 | # Set localhost to be used by links generated in mailer templates.
41 | config.action_mailer.default_url_options = {host: "localhost", port: 3000}
42 |
43 | # Print deprecation notices to the Rails logger.
44 | config.active_support.deprecation = :log
45 |
46 | # Raise an error on page load if there are pending migrations.
47 | config.active_record.migration_error = :page_load
48 |
49 | # Highlight code that triggered database queries in logs.
50 | config.active_record.verbose_query_logs = true
51 |
52 | # Append comments with runtime information tags to SQL queries in logs.
53 | config.active_record.query_log_tags_enabled = true
54 |
55 | # Highlight code that enqueued background job in logs.
56 | config.active_job.verbose_enqueue_logs = true
57 |
58 | # Raises error for missing translations.
59 | # config.i18n.raise_on_missing_translations = true
60 |
61 | # Annotate rendered view with file names.
62 | config.action_view.annotate_rendered_view_with_filenames = true
63 |
64 | # Uncomment if you wish to allow Action Cable access from any origin.
65 | # config.action_cable.disable_request_forgery_protection = true
66 |
67 | # Raise error when a before_action's only/except options reference missing actions.
68 | config.action_controller.raise_on_missing_callback_actions = true
69 | end
70 |
--------------------------------------------------------------------------------
/test/dummy/app/javascript/controllers/theme_controller.js:
--------------------------------------------------------------------------------
1 | import { Controller } from "@hotwired/stimulus"
2 |
3 | // Theme controller for light/dark mode toggle with system preference fallback.
4 | //
5 | // Reads preference from cookie, falls back to system preference.
6 | // Cycles through: light -> dark -> system -> light
7 | //
8 | // Cookie: "theme_preference" with values "light", "dark", or absent (system)
9 | //
10 | export default class extends Controller {
11 | static targets = ["icon"]
12 | static values = {
13 | preference: { type: String, default: "" }
14 | }
15 |
16 | connect() {
17 | // Read preference from cookie
18 | this.preferenceValue = this.getCookie("theme_preference") || ""
19 |
20 | // Listen for system preference changes
21 | this.mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
22 | this.boundHandleSystemChange = this.handleSystemChange.bind(this)
23 | this.mediaQuery.addEventListener("change", this.boundHandleSystemChange)
24 |
25 | // Apply initial theme
26 | this.applyTheme()
27 | }
28 |
29 | disconnect() {
30 | if (this.mediaQuery) {
31 | this.mediaQuery.removeEventListener("change", this.boundHandleSystemChange)
32 | }
33 | }
34 |
35 | toggle() {
36 | // Cycle: light -> dark -> system -> light
37 | switch (this.preferenceValue) {
38 | case "light":
39 | this.preferenceValue = "dark"
40 | break
41 | case "dark":
42 | this.preferenceValue = "" // system
43 | break
44 | default: // system or empty
45 | this.preferenceValue = "light"
46 | }
47 |
48 | this.saveCookie()
49 | this.applyTheme()
50 | }
51 |
52 | handleSystemChange() {
53 | // Only react to system changes when in system mode
54 | if (this.preferenceValue === "") {
55 | this.applyTheme()
56 | }
57 | }
58 |
59 | applyTheme() {
60 | const isDark = this.shouldBeDark()
61 | document.documentElement.classList.toggle("dark", isDark)
62 | this.updateIcon()
63 | }
64 |
65 | shouldBeDark() {
66 | if (this.preferenceValue === "dark") return true
67 | if (this.preferenceValue === "light") return false
68 | // System preference
69 | return this.mediaQuery.matches
70 | }
71 |
72 | updateIcon() {
73 | if (!this.hasIconTarget) return
74 |
75 | // Determine which icon to show based on preference
76 | let iconName
77 | switch (this.preferenceValue) {
78 | case "light":
79 | iconName = "sun"
80 | break
81 | case "dark":
82 | iconName = "moon"
83 | break
84 | default:
85 | iconName = "monitor"
86 | }
87 |
88 | // Update icon visibility
89 | this.iconTargets.forEach(icon => {
90 | const isActive = icon.dataset.themeIcon === iconName
91 | icon.classList.toggle("hidden", !isActive)
92 | })
93 | }
94 |
95 | saveCookie() {
96 | if (this.preferenceValue === "") {
97 | // Remove cookie to fall back to system
98 | document.cookie = "theme_preference=; path=/; max-age=0"
99 | } else {
100 | // Set cookie for 1 year
101 | document.cookie = `theme_preference=${this.preferenceValue}; path=/; max-age=31536000`
102 | }
103 | }
104 |
105 | getCookie(name) {
106 | const match = document.cookie.match(new RegExp("(^| )" + name + "=([^;]+)"))
107 | return match ? match[2] : null
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [Unreleased]
9 |
10 | ## [0.2.0] - 2025-01-01
11 |
12 | ### Added
13 |
14 | #### Layout Components
15 | - **Sidebar** — Collapsible navigation sidebar with cookie persistence for state
16 | - **Header** — Top navigation bar component
17 |
18 | #### Content Components
19 | - **Card** — Content container with header, title, description, content, action, and footer slots
20 | - **Alert** — Callout messages with title and description slots, supports default and destructive variants
21 | - **Badge** — Status indicators with variants (default, primary, secondary, destructive, success, warning, outline) and sizes (sm, md, lg)
22 | - **Table** — Data tables with header, body, row, cell, and head partials
23 | - **Empty State** — Placeholder component for empty lists and no-data scenarios
24 |
25 | #### Navigation Components
26 | - **Breadcrumbs** — Navigation trail with overflow handling, keyboard navigation, and customizable separators
27 | - **Dropdown Menu** — Accessible dropdown with keyboard navigation, focus management, and variant support
28 | - **Pagination** — Page navigation with Pagy integration (full and simple variants)
29 |
30 | #### Interactive Components
31 | - **Toggle Group** — Single/multiple selection button groups with Stimulus controller
32 |
33 | #### Form Components (CSS-only with data attributes)
34 | - **Button** — Variants: default, primary, secondary, destructive, outline, ghost, link; Sizes: sm, default, lg, icon
35 | - **Input** — Text input with validation error states
36 | - **Textarea** — Multi-line text input
37 | - **Select** — Dropdown select input
38 | - **Checkbox** — Checkbox input with custom styling
39 | - **Radio** — Radio button input with custom styling
40 | - **Switch** — Toggle switch input
41 |
42 | #### Infrastructure
43 | - Install generator (`bin/rails generate maquina_components:install`) for easy setup
44 | - Automatic engine CSS import configuration
45 | - Theme variables following shadcn/ui convention (light and dark mode)
46 | - Helper file generation for icon customization
47 | - Generator options: `--skip-theme`, `--skip-helper`
48 |
49 | #### Documentation
50 | - Component documentation at https://maquina.app/documentation/components/
51 | - Getting started guide
52 | - Individual component guides with examples and API reference
53 |
54 | #### Test/Dummy Application
55 | - Component showcase pages demonstrating all variants
56 | - Dark/light theme toggle implementation
57 | - Theme selector for switching color schemes
58 |
59 | ### Technical Details
60 | - ERB partials with strict locals for type safety
61 | - Data attributes for styling (no inline Tailwind classes in partials)
62 | - CSS variables for theming compatibility
63 | - Stimulus controllers only where JavaScript interactivity is required
64 | - TailwindCSS 4.0 with `@theme` directive support
65 | - Progressive enhancement (components work without JavaScript where possible)
66 |
67 | ## [0.1.0] - 2024-12-01
68 |
69 | ### Added
70 | - Initial project setup
71 | - Rails Engine structure
72 | - Basic TailwindCSS integration
73 |
74 | [Unreleased]: https://github.com/maquina-app/maquina_components/compare/v0.2.0...HEAD
75 | [0.2.0]: https://github.com/maquina-app/maquina_components/compare/v0.1.0...v0.2.0
76 | [0.1.0]: https://github.com/maquina-app/maquina_components/releases/tag/v0.1.0
77 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/empty.css:
--------------------------------------------------------------------------------
1 | /* ===== Empty Component Styles ===== */
2 | /*
3 | * Empty state component for displaying placeholder content when no data exists.
4 | * Uses data attributes for styling to avoid inline utility classes.
5 | * Fully compatible with dark mode via CSS variables.
6 | *
7 | * Structure:
8 | * - empty (root container)
9 | * - header (groups media + text)
10 | * - media (icon or avatar)
11 | * - title (heading)
12 | * - description (explanatory text)
13 | * - content (action buttons/links)
14 | */
15 |
16 | /* ===== Root Container ===== */
17 | [data-component="empty"] {
18 | display: flex;
19 | flex-direction: column;
20 | align-items: center;
21 | justify-content: center;
22 | text-align: center;
23 | @apply px-6 py-12;
24 | }
25 |
26 | /* ===== Size Variants ===== */
27 | [data-component="empty"][data-size="compact"] {
28 | @apply px-6 py-8;
29 | }
30 |
31 | /* ===== Visual Variants ===== */
32 | [data-component="empty"][data-variant="default"] {
33 | /* No additional styles for default */
34 | }
35 |
36 | [data-component="empty"][data-variant="outline"] {
37 | @apply rounded-lg;
38 | border: 1px dashed var(--border);
39 | }
40 |
41 | /* ===== Header ===== */
42 | [data-empty-part="header"] {
43 | display: flex;
44 | flex-direction: column;
45 | align-items: center;
46 | @apply gap-2;
47 | }
48 |
49 | /* ===== Media (Icon/Avatar Container) ===== */
50 | [data-empty-part="media"] {
51 | display: flex;
52 | align-items: center;
53 | justify-content: center;
54 | color: var(--muted-foreground);
55 | @apply mb-2;
56 | }
57 |
58 | /* Icon variant */
59 | [data-empty-part="media"][data-variant="icon"] {
60 | @apply opacity-60;
61 | }
62 |
63 | [data-empty-part="media"][data-variant="icon"] svg {
64 | @apply size-12;
65 | }
66 |
67 | /* Compact size adjustments */
68 | [data-component="empty"][data-size="compact"] [data-empty-part="media"][data-variant="icon"] svg {
69 | @apply size-8;
70 | }
71 |
72 | /* Avatar variant */
73 | [data-empty-part="media"][data-variant="avatar"] {
74 | @apply size-16 rounded-full overflow-hidden;
75 | background-color: var(--muted);
76 | }
77 |
78 | [data-empty-part="media"][data-variant="avatar"] img {
79 | @apply size-full object-cover;
80 | }
81 |
82 | /* ===== Title ===== */
83 | [data-empty-part="title"] {
84 | color: var(--foreground);
85 | @apply text-base font-medium;
86 | }
87 |
88 | /* Compact size */
89 | [data-component="empty"][data-size="compact"] [data-empty-part="title"] {
90 | @apply text-sm;
91 | }
92 |
93 | /* ===== Description ===== */
94 | [data-empty-part="description"] {
95 | color: var(--muted-foreground);
96 | @apply text-sm mt-1 max-w-md;
97 | }
98 |
99 | /* Links in description */
100 | [data-empty-part="description"] a {
101 | color: var(--primary);
102 | text-decoration: underline;
103 | text-underline-offset: 4px;
104 | }
105 |
106 | [data-empty-part="description"] a:hover {
107 | @apply opacity-80;
108 | }
109 |
110 | /* ===== Content (Actions) ===== */
111 | [data-empty-part="content"] {
112 | display: flex;
113 | flex-direction: column;
114 | align-items: center;
115 | @apply mt-6 gap-3;
116 | }
117 |
118 | /* Compact size */
119 | [data-component="empty"][data-size="compact"] [data-empty-part="content"] {
120 | @apply mt-4;
121 | }
122 |
123 | /* Flex container for multiple buttons */
124 | [data-empty-part="content"] > .flex {
125 | @apply gap-2;
126 | }
127 |
128 | /* ===== Dark Mode ===== */
129 | /*
130 | * Dark mode is handled automatically through CSS variables.
131 | * The theme variables change based on the .dark class on html/body.
132 | * No additional dark mode styles needed here.
133 | */
134 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/card.css:
--------------------------------------------------------------------------------
1 | /* ===== Card Component Styles ===== */
2 | /*
3 | * Card component for grouping related content with header, body, and footer.
4 | * Uses data attributes for styling to maintain consistency with other components.
5 | * Fully compatible with dark mode via CSS variables.
6 | */
7 |
8 | /* ===== Base Card Styles ===== */
9 | [data-component="card"] {
10 | display: flex;
11 | flex-direction: column;
12 |
13 | /* Border & Radius */
14 | @apply rounded-xl border shadow;
15 | border-color: var(--border);
16 |
17 | /* Colors */
18 | background-color: var(--card);
19 | color: var(--card-foreground);
20 | }
21 |
22 | /* ===== Card Header ===== */
23 | [data-component="card"] [data-card-part="header"] {
24 | display: flex;
25 | flex-direction: column;
26 | @apply gap-1.5 p-6;
27 | }
28 |
29 | /* Header with row layout (when using action) */
30 | [data-component="card"] [data-card-part="header"][data-layout="row"] {
31 | flex-direction: row;
32 | align-items: center;
33 | justify-content: space-between;
34 | @apply gap-4;
35 | }
36 |
37 | /* ===== Card Title ===== */
38 | [data-component="card"] [data-card-part="title"] {
39 | @apply text-lg font-semibold leading-none tracking-tight;
40 | }
41 |
42 | /* Small title variant (for compact headers) */
43 | [data-component="card"] [data-card-part="title"][data-size="sm"] {
44 | @apply text-sm font-medium;
45 | }
46 |
47 | /* ===== Card Description ===== */
48 | [data-component="card"] [data-card-part="description"] {
49 | @apply text-sm;
50 | color: var(--muted-foreground);
51 | }
52 |
53 | /* ===== Card Action ===== */
54 | [data-component="card"] [data-card-part="action"] {
55 | display: flex;
56 | align-items: center;
57 | flex-shrink: 0;
58 | @apply gap-2;
59 | }
60 |
61 | /* ===== Card Content ===== */
62 | [data-component="card"] [data-card-part="content"] {
63 | @apply p-6 pt-0;
64 | }
65 |
66 | /* Content with top padding (when no header) */
67 | [data-component="card"] [data-card-part="content"][data-spacing="full"] {
68 | @apply pt-6;
69 | }
70 |
71 | /* ===== Card Footer ===== */
72 | [data-component="card"] [data-card-part="footer"] {
73 | display: flex;
74 | align-items: center;
75 | @apply p-6 pt-0;
76 | }
77 |
78 | /* Footer with top padding (when no content) */
79 | [data-component="card"] [data-card-part="footer"][data-spacing="full"] {
80 | @apply pt-6;
81 | }
82 |
83 | /* Footer alignment variants */
84 | [data-component="card"] [data-card-part="footer"][data-align="between"] {
85 | justify-content: space-between;
86 | }
87 |
88 | [data-component="card"] [data-card-part="footer"][data-align="end"] {
89 | justify-content: flex-end;
90 | }
91 |
92 | [data-component="card"] [data-card-part="footer"][data-align="center"] {
93 | justify-content: center;
94 | }
95 |
96 | /* ===== Icon Support ===== */
97 | [data-component="card"] [data-card-part="header"] svg,
98 | [data-component="card"] [data-card-part="action"] svg {
99 | @apply size-4 shrink-0;
100 | color: var(--muted-foreground);
101 | }
102 |
103 | /* ===== Interactive Card (when used as link/button) ===== */
104 | a[data-component="card"],
105 | button[data-component="card"] {
106 | text-decoration: none;
107 | cursor: pointer;
108 | @apply transition-[border-color,box-shadow] duration-150;
109 | }
110 |
111 | a[data-component="card"]:hover,
112 | button[data-component="card"]:hover {
113 | border-color: var(--accent);
114 | }
115 |
116 | a[data-component="card"]:focus-visible,
117 | button[data-component="card"]:focus-visible {
118 | outline: none;
119 | box-shadow: 0 0 0 2px var(--background),
120 | 0 0 0 4px var(--border);
121 | }
122 |
123 | /* ===== Dark Mode ===== */
124 | /*
125 | * Dark mode is handled automatically through CSS variables.
126 | * The theme variables change based on the .dark class on html/body.
127 | * No additional dark mode styles needed here.
128 | */
129 |
--------------------------------------------------------------------------------
/test/dummy/config/environments/production.rb:
--------------------------------------------------------------------------------
1 | require "active_support/core_ext/integer/time"
2 |
3 | Rails.application.configure do
4 | # Settings specified here will take precedence over those in config/application.rb.
5 |
6 | # Code is not reloaded between requests.
7 | config.enable_reloading = false
8 |
9 | # Eager load code on boot for better performance and memory savings (ignored by Rake tasks).
10 | config.eager_load = true
11 |
12 | # Full error reports are disabled.
13 | config.consider_all_requests_local = false
14 |
15 | # Turn on fragment caching in view templates.
16 | config.action_controller.perform_caching = true
17 |
18 | # Cache assets for far-future expiry since they are all digest stamped.
19 | config.public_file_server.headers = {"cache-control" => "public, max-age=#{1.year.to_i}"}
20 |
21 | # Enable serving of images, stylesheets, and JavaScripts from an asset server.
22 | # config.asset_host = "http://assets.example.com"
23 |
24 | # Store uploaded files on the local file system (see config/storage.yml for options).
25 | config.active_storage.service = :local
26 |
27 | # Assume all access to the app is happening through a SSL-terminating reverse proxy.
28 | config.assume_ssl = true
29 |
30 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
31 | config.force_ssl = true
32 |
33 | # Skip http-to-https redirect for the default health check endpoint.
34 | # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }
35 |
36 | # Log to STDOUT with the current request id as a default log tag.
37 | config.log_tags = [:request_id]
38 | config.logger = ActiveSupport::TaggedLogging.logger(STDOUT)
39 |
40 | # Change to "debug" to log everything (including potentially personally-identifiable information!)
41 | config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
42 |
43 | # Prevent health checks from clogging up the logs.
44 | config.silence_healthcheck_path = "/up"
45 |
46 | # Don't log any deprecations.
47 | config.active_support.report_deprecations = false
48 |
49 | # Replace the default in-process memory cache store with a durable alternative.
50 | # config.cache_store = :mem_cache_store
51 |
52 | # Replace the default in-process and non-durable queuing backend for Active Job.
53 | # config.active_job.queue_adapter = :resque
54 |
55 | # Ignore bad email addresses and do not raise email delivery errors.
56 | # Set this to true and configure the email server for immediate delivery to raise delivery errors.
57 | # config.action_mailer.raise_delivery_errors = false
58 |
59 | # Set host to be used by links generated in mailer templates.
60 | config.action_mailer.default_url_options = {host: "example.com"}
61 |
62 | # Specify outgoing SMTP server. Remember to add smtp/* credentials via rails credentials:edit.
63 | # config.action_mailer.smtp_settings = {
64 | # user_name: Rails.application.credentials.dig(:smtp, :user_name),
65 | # password: Rails.application.credentials.dig(:smtp, :password),
66 | # address: "smtp.example.com",
67 | # port: 587,
68 | # authentication: :plain
69 | # }
70 |
71 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
72 | # the I18n.default_locale when a translation cannot be found).
73 | config.i18n.fallbacks = true
74 |
75 | # Do not dump schema after migrations.
76 | config.active_record.dump_schema_after_migration = false
77 |
78 | # Only use :id for inspections in production.
79 | config.active_record.attributes_for_inspect = [:id]
80 |
81 | # Enable DNS rebinding protection and other `Host` header attacks.
82 | # config.hosts = [
83 | # "example.com", # Allow requests from example.com
84 | # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com`
85 | # ]
86 | #
87 | # Skip DNS rebinding protection for the default health check endpoint.
88 | # config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
89 | end
90 |
--------------------------------------------------------------------------------
/app/views/components/stats/_stats_card.html.erb:
--------------------------------------------------------------------------------
1 | <%# locals: (title:, value:, icon: nil, icon_class: "", subtitle: nil, value_class: "", container_class: "") -%>
2 |
3 | "
5 | >
6 |
7 |
8 |
<%= title %>
9 |
"
11 | >
12 | <%= value %>
13 |
14 | <% if subtitle.present? %>
15 |
<%= subtitle %>
16 | <% end %>
17 |
18 | <% if icon.present? %>
19 |
">
20 | <% case icon %>
21 | <% when "message-square" %>
22 |
23 |
29 |
30 | <% when "arrow-up" %>
31 |
32 |
38 |
39 | <% when "arrow-down" %>
40 |
41 |
47 |
48 | <% when "check-circle" %>
49 |
50 |
56 |
57 | <% when "lightning-bolt" %>
58 |
59 |
65 |
66 | <% when "exclamation-circle" %>
67 |
68 |
74 |
75 | <% when "clipboard-list" %>
76 |
77 |
83 |
84 | <% when "chart-bar" %>
85 |
86 |
92 |
93 | <% else %>
94 | <%# Allow custom SVG or icon HTML to be passed %>
95 | <%= icon %>
96 | <% end %>
97 |
98 | <% end %>
99 |
100 |
101 |
--------------------------------------------------------------------------------
/app/helpers/maquina_components/empty_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module MaquinaComponents
4 | # Empty Helper
5 | #
6 | # Provides convenient methods for creating empty state components.
7 | #
8 | # @example Simple empty state
9 | # <%= empty_state title: "No projects", description: "Get started by creating one.", icon: :folder_open %>
10 | #
11 | # @example With action button
12 | # <%= empty_state title: "No projects", icon: :folder_open do %>
13 | # <%= link_to "Create Project", new_project_path, data: { component: "button", variant: "primary" } %>
14 | # <% end %>
15 | #
16 | # @example Full control with partials
17 | # <%= render "components/empty", variant: :outline do %>
18 | # <%= render "components/empty/header" do %>
19 | # <%= render "components/empty/media", icon: :search %>
20 | # <%= render "components/empty/title", text: "No results" %>
21 | # <% end %>
22 | # <% end %>
23 | #
24 | module EmptyHelper
25 | # Renders an empty state component with a simple API
26 | #
27 | # @param title [String] The title text
28 | # @param description [String, nil] Optional description text
29 | # @param icon [Symbol, nil] Icon name (uses icon_for helper)
30 | # @param variant [Symbol] Visual style (:default, :outline)
31 | # @param size [Symbol] Size variant (:default, :compact)
32 | # @param css_classes [String] Additional CSS classes
33 | # @param html_options [Hash] Additional HTML attributes
34 | # @yield Optional block for action content (buttons, links)
35 | # @return [String] Rendered HTML
36 | def empty_state(title:, description: nil, icon: nil, variant: :default, size: :default, css_classes: "", **html_options, &block)
37 | render "components/empty", variant: variant, size: size, css_classes: css_classes, **html_options do
38 | parts = []
39 |
40 | # Build header
41 | header_content = []
42 | header_content << render("components/empty/media", icon: icon) if icon
43 | header_content << render("components/empty/title", text: title)
44 | header_content << render("components/empty/description", text: description) if description
45 |
46 | parts << render("components/empty/header") { safe_join(header_content) }
47 |
48 | # Add content/actions if block given
49 | if block
50 | parts << render("components/empty/content") { capture(&block) }
51 | end
52 |
53 | safe_join(parts)
54 | end
55 | end
56 |
57 | # Renders an empty state for search results
58 | #
59 | # @param query [String, nil] The search query (for display)
60 | # @param reset_path [String, nil] Path to reset/clear search
61 | # @param size [Symbol] Size variant
62 | # @return [String] Rendered HTML
63 | def empty_search_state(query: nil, reset_path: nil, size: :default)
64 | description = if query.present?
65 | "No results found for \"#{query}\". Try a different search term."
66 | else
67 | "No results found. Try adjusting your search."
68 | end
69 |
70 | empty_state(
71 | title: "No results",
72 | description: description,
73 | icon: :search,
74 | size: size
75 | ) do
76 | if reset_path
77 | link_to "Clear search", reset_path, data: {component: "button", variant: "outline", size: "sm"}
78 | end
79 | end
80 | end
81 |
82 | # Renders an empty state for lists/tables
83 | #
84 | # @param resource_name [String] Name of the resource (e.g., "projects", "users")
85 | # @param new_path [String, nil] Path to create new resource
86 | # @param icon [Symbol] Icon to display
87 | # @param size [Symbol] Size variant
88 | # @return [String] Rendered HTML
89 | def empty_list_state(resource_name:, new_path: nil, icon: :folder_open, size: :default)
90 | empty_state(
91 | title: "No #{resource_name} yet",
92 | description: "Get started by creating your first #{resource_name.singularize}.",
93 | icon: icon,
94 | size: size
95 | ) do
96 | if new_path
97 | link_to "Create #{resource_name.singularize.titleize}", new_path, data: {component: "button", variant: "primary"}
98 | end
99 | end
100 | end
101 | end
102 | end
103 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/badge.css:
--------------------------------------------------------------------------------
1 | /* ===== Badge Component Styles ===== */
2 | /*
3 | * Badge component for status indicators, tags, counts, and labels.
4 | * Uses data attributes for styling to avoid inline utility classes.
5 | * Fully compatible with dark mode and supports icons.
6 | */
7 |
8 | /* ===== Base Badge Styles ===== */
9 | [data-component="badge"] {
10 | /* Layout */
11 | display: inline-flex;
12 | align-items: center;
13 | white-space: nowrap;
14 | @apply gap-1 rounded-md border border-transparent font-medium leading-none;
15 |
16 | /* Transitions */
17 | @apply transition-[background-color,border-color,color,opacity] duration-150;
18 | }
19 |
20 | /* ===== Icon Support ===== */
21 | [data-component="badge"] svg {
22 | /* Scales with font size using em */
23 | width: 0.875em;
24 | height: 0.875em;
25 | @apply shrink-0 pointer-events-none;
26 | }
27 |
28 | /* ===== Size Variants ===== */
29 |
30 | /* Small size - compact badges for inline use */
31 | [data-component="badge"][data-size="sm"] {
32 | @apply text-xs px-2 py-0.5 h-5;
33 | }
34 |
35 | /* Medium size - default, balanced size */
36 | [data-component="badge"][data-size="md"] {
37 | @apply text-sm px-2.5 py-1 h-6;
38 | }
39 |
40 | /* Large size - prominent badges */
41 | [data-component="badge"][data-size="lg"] {
42 | @apply text-sm px-3 py-1.5 h-7;
43 | }
44 |
45 | /* ===== Visual Variants ===== */
46 |
47 | /* Default variant - muted appearance */
48 | [data-component="badge"][data-variant="default"] {
49 | background-color: var(--muted);
50 | color: var(--muted-foreground);
51 | }
52 |
53 | [data-component="badge"][data-variant="default"]:hover {
54 | @apply opacity-90;
55 | }
56 |
57 | /* Primary variant - brand color */
58 | [data-component="badge"][data-variant="primary"] {
59 | background-color: var(--primary-color);
60 | color: var(--primary-foreground-color);
61 | }
62 |
63 | [data-component="badge"][data-variant="primary"]:hover {
64 | @apply opacity-90;
65 | }
66 |
67 | /* Secondary variant - subtle appearance */
68 | [data-component="badge"][data-variant="secondary"] {
69 | background-color: var(--secondary);
70 | color: var(--secondary-foreground);
71 | }
72 |
73 | [data-component="badge"][data-variant="secondary"]:hover {
74 | @apply opacity-90;
75 | }
76 |
77 | /* Destructive variant - errors, deletions */
78 | [data-component="badge"][data-variant="destructive"] {
79 | background-color: var(--destructive);
80 | color: var(--destructive-foreground);
81 | }
82 |
83 | [data-component="badge"][data-variant="destructive"]:hover {
84 | @apply opacity-90;
85 | }
86 |
87 | /* Success variant - positive status */
88 | [data-component="badge"][data-variant="success"] {
89 | background-color: var(--success);
90 | color: var(--success-foreground);
91 | }
92 |
93 | [data-component="badge"][data-variant="success"]:hover {
94 | @apply opacity-90;
95 | }
96 |
97 | /* Warning variant - caution status */
98 | [data-component="badge"][data-variant="warning"] {
99 | background-color: var(--warning);
100 | color: var(--warning-foreground);
101 | }
102 |
103 | [data-component="badge"][data-variant="warning"]:hover {
104 | @apply opacity-90;
105 | }
106 |
107 | /* Outline variant - transparent with border */
108 | [data-component="badge"][data-variant="outline"] {
109 | @apply bg-transparent;
110 | color: var(--foreground);
111 | border-color: var(--border);
112 | }
113 |
114 | [data-component="badge"][data-variant="outline"]:hover {
115 | background-color: var(--accent);
116 | color: var(--accent-foreground);
117 | }
118 |
119 | /* ===== Interactive States (when used as link/button) ===== */
120 |
121 | /* Focus state for keyboard navigation */
122 | a[data-component="badge"]:focus-visible,
123 | button[data-component="badge"]:focus-visible {
124 | @apply outline-none;
125 | box-shadow: 0 0 0 2px var(--background),
126 | 0 0 0 4px var(--border);
127 | }
128 |
129 | /* Active/pressed state */
130 | a[data-component="badge"]:active,
131 | button[data-component="badge"]:active {
132 | @apply scale-[0.98];
133 | }
134 |
135 | /* Disabled state (if used as button) */
136 | button[data-component="badge"]:disabled {
137 | @apply opacity-50 cursor-not-allowed pointer-events-none;
138 | }
139 |
140 | /* ===== Dark Mode ===== */
141 | /*
142 | * Dark mode is handled automatically through CSS variables.
143 | * The theme variables change based on the .dark class on html/body.
144 | * No additional dark mode styles needed here.
145 | */
146 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/maquina_components.css:
--------------------------------------------------------------------------------
1 | /* Base animation utilities */
2 | @utility animate-in {
3 | animation-duration: var(--duration-normal);
4 | animation-timing-function: var(--ease-out);
5 | animation-fill-mode: forwards;
6 | }
7 |
8 | @utility animate-out {
9 | animation-duration: var(--duration-normal);
10 | animation-timing-function: var(--ease-in);
11 | animation-fill-mode: forwards;
12 | }
13 |
14 | /* Fade animations */
15 | @keyframes fade-in-0 {
16 | from {
17 | opacity: 0;
18 | }
19 |
20 | to {
21 | opacity: 1;
22 | }
23 | }
24 |
25 | @keyframes fade-out-0 {
26 | from {
27 | opacity: 1;
28 | }
29 |
30 | to {
31 | opacity: 0;
32 | }
33 | }
34 |
35 | @utility fade-in-0 {
36 | animation-name: fade-in-0;
37 | }
38 |
39 | @utility fade-out-0 {
40 | animation-name: fade-out-0;
41 | }
42 |
43 | /* Zoom animations */
44 | @keyframes zoom-in-95 {
45 | from {
46 | transform: scale(0.95);
47 | opacity: 0;
48 | }
49 |
50 | to {
51 | transform: scale(1);
52 | opacity: 1;
53 | }
54 | }
55 |
56 | @keyframes zoom-out-95 {
57 | from {
58 | transform: scale(1);
59 | opacity: 1;
60 | }
61 |
62 | to {
63 | transform: scale(0.95);
64 | opacity: 0;
65 | }
66 | }
67 |
68 | @utility zoom-in-95 {
69 | animation-name: zoom-in-95;
70 | }
71 |
72 | @utility zoom-out-95 {
73 | animation-name: zoom-out-95;
74 | }
75 |
76 | /* Slide animations with specific measurements */
77 | @keyframes slide-in-from-top-2 {
78 | from {
79 | transform: translateY(-0.5rem);
80 | opacity: 0;
81 | }
82 |
83 | to {
84 | transform: translateY(0);
85 | opacity: 1;
86 | }
87 | }
88 |
89 | @keyframes slide-in-from-bottom-2 {
90 | from {
91 | transform: translateY(0.5rem);
92 | opacity: 0;
93 | }
94 |
95 | to {
96 | transform: translateY(0);
97 | opacity: 1;
98 | }
99 | }
100 |
101 | @keyframes slide-in-from-left-2 {
102 | from {
103 | transform: translateX(-0.5rem);
104 | opacity: 0;
105 | }
106 |
107 | to {
108 | transform: translateX(0);
109 | opacity: 1;
110 | }
111 | }
112 |
113 | @keyframes slide-in-from-right-2 {
114 | from {
115 | transform: translateX(0.5rem);
116 | opacity: 0;
117 | }
118 |
119 | to {
120 | transform: translateX(0);
121 | opacity: 1;
122 | }
123 | }
124 |
125 | @utility slide-in-from-top-2 {
126 | animation-name: slide-in-from-top-2;
127 | }
128 |
129 | @utility slide-in-from-bottom-2 {
130 | animation-name: slide-in-from-bottom-2;
131 | }
132 |
133 | @utility slide-in-from-left-2 {
134 | animation-name: slide-in-from-left-2;
135 | }
136 |
137 | @utility slide-in-from-right-2 {
138 | animation-name: slide-in-from-right-2;
139 | }
140 |
141 | /* Duration modifiers */
142 | @utility duration-fast {
143 | animation-duration: var(--duration-fast);
144 | }
145 |
146 | @utility duration-normal {
147 | animation-duration: var(--duration-normal);
148 | }
149 |
150 | @utility duration-slow {
151 | animation-duration: var(--duration-slow);
152 | }
153 |
154 | /* Sidebar */
155 |
156 | .sidebar-primary-button {
157 | @apply flex w-full items-center gap-2 overflow-hidden rounded-md p-2
158 | text-left outline-hidden ring-sidebar-ring transition-[width,height,padding]
159 | focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50
160 | group-has-data-[sidebar=menu-action]/menu-item:pr-8
161 | aria-disabled:pointer-events-none aria-disabled:opacity-50
162 | group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2!
163 | [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 h-8 text-sm
164 | bg-primary text-primary-foreground hover:bg-primary/90
165 | hover:text-primary-foreground min-w-8 duration-200 ease-linear;
166 | }
167 |
168 | .sidebar-outline-button {
169 | @apply inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md
170 | text-sm font-medium transition-all disabled:pointer-events-none
171 | disabled:opacity-50 [&_svg]:pointer-events-none
172 | [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none
173 | focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]
174 | aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40
175 | aria-invalid:border-destructive border bg-background shadow-xs hover:bg-accent
176 | hover:text-accent-foreground dark:bg-input/30 dark:border-input
177 | dark:hover:bg-input/50 size-8 group-data-[collapsible=icon]:opacity-0;
178 | }
179 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/pagination.css:
--------------------------------------------------------------------------------
1 | /* ===== Pagination Component Styles ===== */
2 | /*
3 | * Navigation for paginated content following shadcn/ui patterns.
4 | * Uses data attributes for styling to avoid inline utility classes.
5 | * Fully compatible with dark mode via CSS variables.
6 | *
7 | * Structure:
8 | * - pagination (nav root)
9 | * - content (ul list)
10 | * - item (li wrapper)
11 | * - link / previous / next / ellipsis
12 | */
13 |
14 | /* ===== Root Container ===== */
15 | [data-component="pagination"] {
16 | display: flex;
17 | justify-content: center;
18 | width: 100%;
19 | @apply mx-auto;
20 | }
21 |
22 | /* ===== Content List ===== */
23 | [data-pagination-part="content"] {
24 | display: flex;
25 | flex-direction: row;
26 | align-items: center;
27 | @apply gap-1;
28 | list-style: none;
29 | margin: 0;
30 | padding: 0;
31 | }
32 |
33 | /* ===== Item Wrapper ===== */
34 | [data-pagination-part="item"] {
35 | /* No specific styles needed, just a wrapper */
36 | }
37 |
38 | /* ===== Shared Button/Link Base Styles ===== */
39 | [data-pagination-part="link"],
40 | [data-pagination-part="previous"],
41 | [data-pagination-part="next"] {
42 | display: inline-flex;
43 | align-items: center;
44 | justify-content: center;
45 | white-space: nowrap;
46 | @apply rounded-md text-sm font-medium;
47 | @apply transition-colors duration-150;
48 |
49 | /* Size */
50 | @apply h-9 min-w-9 px-3;
51 |
52 | /* Colors - ghost style */
53 | background-color: transparent;
54 | color: var(--foreground);
55 |
56 | /* Remove default link/button styles */
57 | text-decoration: none;
58 | border: none;
59 | cursor: pointer;
60 | }
61 |
62 | /* ===== Page Number Links ===== */
63 | [data-pagination-part="link"]:hover:not([aria-disabled="true"]) {
64 | background-color: var(--accent);
65 | color: var(--accent-foreground);
66 | }
67 |
68 | [data-pagination-part="link"][data-active="true"] {
69 | border: 1px solid var(--border);
70 | background-color: var(--background);
71 | color: var(--foreground);
72 | }
73 |
74 | [data-pagination-part="link"][data-active="true"]:hover {
75 | background-color: var(--accent);
76 | color: var(--accent-foreground);
77 | }
78 |
79 | /* ===== Previous/Next Navigation ===== */
80 | [data-pagination-part="previous"],
81 | [data-pagination-part="next"] {
82 | @apply gap-1 px-2.5;
83 | }
84 |
85 | [data-pagination-part="previous"]:hover:not([aria-disabled="true"]),
86 | [data-pagination-part="next"]:hover:not([aria-disabled="true"]) {
87 | background-color: var(--accent);
88 | color: var(--accent-foreground);
89 | }
90 |
91 | /* Hide labels on small screens */
92 | @media (max-width: 640px) {
93 | [data-pagination-part="previous"] span,
94 | [data-pagination-part="next"] span {
95 | @apply sr-only;
96 | }
97 |
98 | [data-pagination-part="previous"],
99 | [data-pagination-part="next"] {
100 | @apply px-2;
101 | }
102 | }
103 |
104 | /* ===== Ellipsis ===== */
105 | [data-pagination-part="ellipsis"] {
106 | display: flex;
107 | align-items: center;
108 | justify-content: center;
109 | @apply h-9 w-9;
110 | color: var(--muted-foreground);
111 | }
112 |
113 | /* ===== Disabled State ===== */
114 | [data-pagination-part="link"][aria-disabled="true"],
115 | [data-pagination-part="previous"][aria-disabled="true"],
116 | [data-pagination-part="next"][aria-disabled="true"] {
117 | @apply opacity-50 pointer-events-none cursor-not-allowed;
118 | }
119 |
120 | /* ===== Focus Visible ===== */
121 | [data-pagination-part="link"]:focus-visible,
122 | [data-pagination-part="previous"]:focus-visible,
123 | [data-pagination-part="next"]:focus-visible {
124 | @apply outline-none;
125 | box-shadow: 0 0 0 2px var(--background),
126 | 0 0 0 4px var(--ring);
127 | }
128 |
129 | /* ===== Icon Sizing ===== */
130 | [data-pagination-part="previous"] svg,
131 | [data-pagination-part="next"] svg,
132 | [data-pagination-part="ellipsis"] svg {
133 | @apply size-4 shrink-0;
134 | }
135 |
136 | /* ===== Screen Reader Only ===== */
137 | .sr-only {
138 | position: absolute;
139 | width: 1px;
140 | height: 1px;
141 | padding: 0;
142 | margin: -1px;
143 | overflow: hidden;
144 | clip: rect(0, 0, 0, 0);
145 | white-space: nowrap;
146 | border-width: 0;
147 | }
148 |
149 | /* ===== Dark Mode ===== */
150 | /*
151 | * Dark mode is handled automatically through CSS variables.
152 | * The theme variables change based on the .dark class on html/body.
153 | * No additional dark mode styles needed here.
154 | */
155 |
--------------------------------------------------------------------------------
/app/helpers/maquina_components/breadcrumbs_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module MaquinaComponents
4 | # Breadcrumbs Helper
5 | #
6 | # Provides helper methods for rendering breadcrumb navigation.
7 | # Supports both simple hash-based API and composable partials.
8 | #
9 | module BreadcrumbsHelper
10 | # Render breadcrumbs from a hash of links
11 | #
12 | # @param links [Hash] Hash of text => path pairs
13 | # @param current_page [String, nil] Text for current page (no link)
14 | # @param css_classes [String] Additional CSS classes for nav element
15 | # @return [String] HTML string
16 | #
17 | # @example Basic usage
18 | # breadcrumbs({"Home" => root_path, "Users" => users_path}, "John Doe")
19 | #
20 | # @example Without current page
21 | # breadcrumbs({"Home" => root_path, "Users" => users_path})
22 | #
23 | def breadcrumbs(links = {}, current_page = nil, css_classes: "")
24 | render "components/breadcrumbs", css_classes: css_classes do
25 | render "components/breadcrumbs/list" do
26 | build_breadcrumb_items(links, current_page, responsive: false)
27 | end
28 | end
29 | end
30 |
31 | # Render responsive breadcrumbs that auto-collapse on overflow
32 | #
33 | # Uses Stimulus controller to hide middle items when space is limited,
34 | # showing an ellipsis element instead.
35 | #
36 | # @param links [Hash] Hash of text => path pairs
37 | # @param current_page [String, nil] Text for current page (no link)
38 | # @param css_classes [String] Additional CSS classes for nav element
39 | # @return [String] HTML string
40 | #
41 | # @example
42 | # responsive_breadcrumbs(
43 | # {"Home" => root_path, "Docs" => docs_path, "Components" => components_path},
44 | # "Button"
45 | # )
46 | #
47 | def responsive_breadcrumbs(links = {}, current_page = nil, css_classes: "")
48 | render "components/breadcrumbs", css_classes: css_classes, responsive: true do
49 | render "components/breadcrumbs/list" do
50 | build_breadcrumb_items(links, current_page, responsive: true)
51 | end
52 | end
53 | end
54 |
55 | private
56 |
57 | # Build breadcrumb items from links hash
58 | #
59 | # @param links [Hash] Hash of text => path pairs
60 | # @param current_page [String, nil] Text for current page
61 | # @param responsive [Boolean] Whether to include Stimulus targets
62 | # @return [String] Safe-joined HTML string
63 | #
64 | def build_breadcrumb_items(links, current_page, responsive: false)
65 | items = []
66 | link_array = links.to_a
67 |
68 | link_array.each_with_index do |(text, path), index|
69 | # Determine if this is a collapsible middle item (not first or last)
70 | is_middle = responsive && index > 0 && (index < link_array.size - 1 || current_page.present?)
71 | item_data = is_middle ? {breadcrumb_target: "item"} : {}
72 |
73 | items << capture do
74 | render "components/breadcrumbs/item", data: item_data do
75 | render "components/breadcrumbs/link", href: path do
76 | text
77 | end
78 | end
79 | end
80 |
81 | # Add separator after each link
82 | items << capture do
83 | render "components/breadcrumbs/separator"
84 | end
85 |
86 | # Insert ellipsis after first item for responsive mode
87 | if responsive && index == 0 && (link_array.size > 2 || (link_array.size > 1 && current_page.present?))
88 | items << capture do
89 | render "components/breadcrumbs/item", css_classes: "hidden", data: {breadcrumb_target: "ellipsis"} do
90 | render "components/breadcrumbs/ellipsis"
91 | end
92 | end
93 |
94 | items << capture do
95 | render "components/breadcrumbs/separator", css_classes: "hidden", data: {breadcrumb_target: "ellipsisSeparator"}
96 | end
97 | end
98 | end
99 |
100 | # Add current page if provided
101 | if current_page.present?
102 | # Remove last separator since current page follows
103 | items << capture do
104 | render "components/breadcrumbs/item" do
105 | render "components/breadcrumbs/page" do
106 | current_page
107 | end
108 | end
109 | end
110 | else
111 | # Remove trailing separator if no current page
112 | items.pop
113 | end
114 |
115 | safe_join(items)
116 | end
117 | end
118 | end
119 |
--------------------------------------------------------------------------------
/lib/generators/maquina_components/install/install_generator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "rails/generators/base"
4 |
5 | module MaquinaComponents
6 | module Generators
7 | class InstallGenerator < Rails::Generators::Base
8 | source_root File.expand_path("templates", __dir__)
9 |
10 | desc "Install maquina_components into your Rails application"
11 |
12 | class_option :skip_theme, type: :boolean, default: false,
13 | desc: "Skip adding theme variables to application.css"
14 | class_option :skip_helper, type: :boolean, default: false,
15 | desc: "Skip creating the maquina_components helper"
16 |
17 | def check_tailwindcss_rails
18 | css_path = "app/assets/tailwind/application.css"
19 |
20 | unless File.exist?(File.join(destination_root, css_path))
21 | say_status :warning, "tailwindcss-rails doesn't appear to be installed", :yellow
22 | say "Please install tailwindcss-rails first:"
23 | say " bundle add tailwindcss-rails"
24 | say " rails tailwindcss:install"
25 | say ""
26 | end
27 | end
28 |
29 | def add_engine_css_import
30 | css_path = "app/assets/tailwind/application.css"
31 | full_path = File.join(destination_root, css_path)
32 |
33 | unless File.exist?(full_path)
34 | say_status :skip, "#{css_path} not found", :yellow
35 | return
36 | end
37 |
38 | import_line = '@import "../builds/tailwind/maquina_components_engine.css";'
39 |
40 | if File.read(full_path).include?(import_line)
41 | say_status :skip, "Engine CSS import already present", :blue
42 | return
43 | end
44 |
45 | # Insert after @import "tailwindcss"; or @import 'tailwindcss'; line
46 | inject_into_file css_path, after: /@import\s+["']tailwindcss["'];?\n/ do
47 | "\n#{import_line}\n"
48 | end
49 |
50 | say_status :insert, "Added maquina_components engine CSS import", :green
51 | end
52 |
53 | def add_theme_variables
54 | return if options[:skip_theme]
55 |
56 | css_path = "app/assets/tailwind/application.css"
57 | full_path = File.join(destination_root, css_path)
58 |
59 | unless File.exist?(full_path)
60 | say_status :skip, "#{css_path} not found", :yellow
61 | return
62 | end
63 |
64 | if File.read(full_path).include?("--color-primary:")
65 | say_status :skip, "Theme variables already present", :blue
66 | return
67 | end
68 |
69 | theme_content = File.read(File.expand_path("templates/theme.css.tt", __dir__))
70 |
71 | append_to_file css_path, "\n#{theme_content}"
72 |
73 | say_status :append, "Added theme variables to application.css", :green
74 | end
75 |
76 | def create_helper
77 | return if options[:skip_helper]
78 |
79 | helper_path = "app/helpers/maquina_components_helper.rb"
80 | full_path = File.join(destination_root, helper_path)
81 |
82 | if File.exist?(full_path)
83 | say_status :skip, "#{helper_path} already exists", :blue
84 | return
85 | end
86 |
87 | template "maquina_components_helper.rb.tt", helper_path
88 | say_status :create, helper_path, :green
89 | end
90 |
91 | def show_post_install_message
92 | say ""
93 | say "=" * 60
94 | say " maquina_components installed successfully!", :green
95 | say "=" * 60
96 | say ""
97 | say "Next steps:"
98 | say ""
99 | say "1. Customize theme variables in app/assets/tailwind/application.css"
100 | say " to match your design system."
101 | say ""
102 | say "2. Override the icon helper in app/helpers/maquina_components_helper.rb"
103 | say " to use your own icon system (Heroicons, Lucide, etc.)."
104 | say ""
105 | say "3. Start using components in your views:"
106 | say ""
107 | say ' <%= render "components/card" do %>'
108 | say ' <%= render "components/card/header" do %>'
109 | say ' <%= render "components/card/title", text: "Hello" %>'
110 | say " <% end %>"
111 | say " <% end %>"
112 | say ""
113 | say "4. For form elements, use data attributes:"
114 | say ""
115 | say ' <%= f.text_field :email, data: { component: "input" } %>'
116 | say ' <%= f.submit "Save", data: { component: "button", variant: "primary" } %>'
117 | say ""
118 | say "Documentation: https://github.com/maquina-app/maquina_components"
119 | say ""
120 | end
121 | end
122 | end
123 | end
124 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/toggle_group.css:
--------------------------------------------------------------------------------
1 | /* ===== Toggle Group Component Styles ===== */
2 | /*
3 | * A group of two-state buttons that can be toggled on or off.
4 | * Uses data attributes for styling to avoid inline utility classes.
5 | * Fully compatible with dark mode via CSS variables.
6 | *
7 | * Structure:
8 | * - toggle-group (root container)
9 | * - item (toggle button)
10 | */
11 |
12 | /* ===== Root Container ===== */
13 | [data-component="toggle-group"] {
14 | display: inline-flex;
15 | align-items: center;
16 | @apply gap-1 rounded-md;
17 | }
18 |
19 | /* Group disabled state */
20 | [data-component="toggle-group"][aria-disabled="true"] {
21 | @apply opacity-50 pointer-events-none;
22 | }
23 |
24 | /* ===== Toggle Item Base ===== */
25 | [data-toggle-group-part="item"] {
26 | display: inline-flex;
27 | align-items: center;
28 | justify-content: center;
29 | @apply gap-2 rounded-md text-sm font-medium;
30 | @apply transition-colors duration-150;
31 |
32 | /* Default size */
33 | @apply h-9 px-3;
34 |
35 | /* Remove default button styles */
36 | border: none;
37 | cursor: pointer;
38 |
39 | /* Default variant colors */
40 | background-color: transparent;
41 | color: var(--muted-foreground);
42 | }
43 |
44 | /* ===== Size Variants ===== */
45 |
46 | /* Small */
47 | [data-component="toggle-group"][data-size="sm"] [data-toggle-group-part="item"] {
48 | @apply h-8 px-2.5 text-xs;
49 | }
50 |
51 | /* Large */
52 | [data-component="toggle-group"][data-size="lg"] [data-toggle-group-part="item"] {
53 | @apply h-10 px-4;
54 | }
55 |
56 | /* ===== Visual Variants ===== */
57 |
58 | /* Default variant */
59 | [data-component="toggle-group"][data-variant="default"] [data-toggle-group-part="item"] {
60 | background-color: transparent;
61 | }
62 |
63 | [data-component="toggle-group"][data-variant="default"] [data-toggle-group-part="item"]:hover:not(:disabled) {
64 | background-color: var(--muted);
65 | color: var(--muted-foreground);
66 | }
67 |
68 | [data-component="toggle-group"][data-variant="default"] [data-toggle-group-part="item"][data-state="on"] {
69 | background-color: var(--accent);
70 | color: var(--accent-foreground);
71 | }
72 |
73 | /* Outline variant */
74 | [data-component="toggle-group"][data-variant="outline"] {
75 | background-color: transparent;
76 | border: 1px solid var(--border);
77 | @apply gap-0;
78 | }
79 |
80 | [data-component="toggle-group"][data-variant="outline"] [data-toggle-group-part="item"] {
81 | @apply rounded-none;
82 | border-right: 1px solid var(--border);
83 | }
84 |
85 | [data-component="toggle-group"][data-variant="outline"] [data-toggle-group-part="item"]:first-child {
86 | @apply rounded-l-md;
87 | }
88 |
89 | [data-component="toggle-group"][data-variant="outline"] [data-toggle-group-part="item"]:last-child {
90 | @apply rounded-r-md;
91 | border-right: none;
92 | }
93 |
94 | [data-component="toggle-group"][data-variant="outline"] [data-toggle-group-part="item"]:hover:not(:disabled) {
95 | background-color: var(--muted);
96 | color: var(--muted-foreground);
97 | }
98 |
99 | [data-component="toggle-group"][data-variant="outline"] [data-toggle-group-part="item"][data-state="on"] {
100 | background-color: var(--accent);
101 | color: var(--accent-foreground);
102 | }
103 |
104 | /* ===== Interactive States ===== */
105 |
106 | /* Hover */
107 | [data-toggle-group-part="item"]:hover:not(:disabled) {
108 | background-color: var(--muted);
109 | color: var(--muted-foreground);
110 | }
111 |
112 | /* Pressed (on) state */
113 | [data-toggle-group-part="item"][data-state="on"] {
114 | background-color: var(--accent);
115 | color: var(--accent-foreground);
116 | }
117 |
118 | /* Focus visible */
119 | [data-toggle-group-part="item"]:focus-visible {
120 | @apply outline-none;
121 | box-shadow: 0 0 0 2px var(--background),
122 | 0 0 0 4px var(--ring);
123 | z-index: 1;
124 | position: relative;
125 | }
126 |
127 | /* Disabled */
128 | [data-toggle-group-part="item"]:disabled {
129 | @apply opacity-50 cursor-not-allowed pointer-events-none;
130 | }
131 |
132 | /* ===== Icon Support ===== */
133 | [data-toggle-group-part="item"] svg {
134 | @apply size-4 shrink-0 pointer-events-none;
135 | }
136 |
137 | /* Icon-only size adjustments */
138 | [data-component="toggle-group"][data-size="sm"] [data-toggle-group-part="item"] svg {
139 | @apply size-3.5;
140 | }
141 |
142 | [data-component="toggle-group"][data-size="lg"] [data-toggle-group-part="item"] svg {
143 | @apply size-5;
144 | }
145 |
146 | /* ===== Dark Mode ===== */
147 | /*
148 | * Dark mode is handled automatically through CSS variables.
149 | * The theme variables change based on the .dark class on html/body.
150 | * No additional dark mode styles needed here.
151 | */
152 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/alert.css:
--------------------------------------------------------------------------------
1 | /* ===== Alert Component Styles ===== */
2 | /*
3 | * Alert component for displaying callouts, messages, and notifications.
4 | * Uses data attributes for styling to maintain consistency with other components.
5 | * Fully compatible with dark mode via CSS variables.
6 | */
7 |
8 | /* ===== Base Alert Styles ===== */
9 | [data-component="alert"] {
10 | position: relative;
11 | display: grid;
12 | grid-template-columns: 1fr;
13 | @apply w-full rounded-lg border p-4 text-sm;
14 |
15 | /* Default colors */
16 | background-color: var(--background);
17 | color: var(--foreground);
18 | border-color: var(--border);
19 | }
20 |
21 | /* Alert with icon - add left padding for icon space */
22 | [data-component="alert"][data-has-icon="true"] {
23 | grid-template-columns: auto 1fr;
24 | @apply gap-3;
25 | }
26 |
27 | /* ===== Icon Support ===== */
28 | [data-component="alert"] > svg:first-child,
29 | [data-component="alert"] [data-alert-part="icon"] {
30 | @apply size-4 shrink-0;
31 | color: var(--foreground);
32 | /* Align with first line of text */
33 | margin-top: 0.125rem;
34 | }
35 |
36 | /* ===== Alert Title ===== */
37 | [data-component="alert"] [data-alert-part="title"] {
38 | @apply font-medium leading-none tracking-tight;
39 | color: var(--foreground);
40 | }
41 |
42 | /* Title followed by description needs margin */
43 | [data-component="alert"] [data-alert-part="title"]:has(+ [data-alert-part="description"]) {
44 | @apply mb-1;
45 | }
46 |
47 | /* ===== Alert Description ===== */
48 | [data-component="alert"] [data-alert-part="description"] {
49 | @apply text-sm;
50 | color: var(--muted-foreground);
51 | }
52 |
53 | /* Nested paragraphs */
54 | [data-component="alert"] [data-alert-part="description"] p {
55 | @apply leading-relaxed;
56 | }
57 |
58 | /* Lists inside description */
59 | [data-component="alert"] [data-alert-part="description"] ul {
60 | @apply mt-2 list-inside list-disc;
61 | }
62 |
63 | /* ===== Variant: Default ===== */
64 | [data-component="alert"][data-variant="default"] {
65 | background-color: var(--background);
66 | color: var(--foreground);
67 | border-color: var(--border);
68 | }
69 |
70 | [data-component="alert"][data-variant="default"] > svg:first-child,
71 | [data-component="alert"][data-variant="default"] [data-alert-part="icon"] {
72 | color: var(--foreground);
73 | }
74 |
75 | /* ===== Variant: Destructive ===== */
76 | [data-component="alert"][data-variant="destructive"] {
77 | background-color: var(--destructive);
78 | color: var(--destructive-foreground);
79 | border-color: var(--destructive);
80 | }
81 |
82 | [data-component="alert"][data-variant="destructive"] [data-alert-part="title"] {
83 | color: var(--destructive-foreground);
84 | }
85 |
86 | [data-component="alert"][data-variant="destructive"] [data-alert-part="description"] {
87 | color: var(--destructive-foreground);
88 | opacity: 0.9;
89 | }
90 |
91 | [data-component="alert"][data-variant="destructive"] > svg:first-child,
92 | [data-component="alert"][data-variant="destructive"] [data-alert-part="icon"] {
93 | color: var(--destructive-foreground);
94 | }
95 |
96 | /* ===== Variant: Success ===== */
97 | [data-component="alert"][data-variant="success"] {
98 | background-color: var(--success);
99 | color: var(--success-foreground);
100 | border-color: var(--success);
101 | }
102 |
103 | [data-component="alert"][data-variant="success"] [data-alert-part="title"] {
104 | color: var(--success-foreground);
105 | }
106 |
107 | [data-component="alert"][data-variant="success"] [data-alert-part="description"] {
108 | color: var(--success-foreground);
109 | opacity: 0.9;
110 | }
111 |
112 | [data-component="alert"][data-variant="success"] > svg:first-child,
113 | [data-component="alert"][data-variant="success"] [data-alert-part="icon"] {
114 | color: var(--success-foreground);
115 | }
116 |
117 | /* ===== Variant: Warning ===== */
118 | [data-component="alert"][data-variant="warning"] {
119 | background-color: var(--warning);
120 | color: var(--warning-foreground);
121 | border-color: var(--warning);
122 | }
123 |
124 | [data-component="alert"][data-variant="warning"] [data-alert-part="title"] {
125 | color: var(--warning-foreground);
126 | }
127 |
128 | [data-component="alert"][data-variant="warning"] [data-alert-part="description"] {
129 | color: var(--warning-foreground);
130 | opacity: 0.9;
131 | }
132 |
133 | [data-component="alert"][data-variant="warning"] > svg:first-child,
134 | [data-component="alert"][data-variant="warning"] [data-alert-part="icon"] {
135 | color: var(--warning-foreground);
136 | }
137 |
138 | /* ===== Dark Mode ===== */
139 | /*
140 | * Dark mode is handled automatically through CSS variables.
141 | * The theme variables change based on the .dark class on html/body.
142 | * No additional dark mode styles needed here.
143 | */
144 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/breadcrumbs.css:
--------------------------------------------------------------------------------
1 | /* ===== Breadcrumbs Component Styles ===== */
2 | /* Comprehensive CSS using data-attribute selectors */
3 | /* Uses @apply for theme-customizable properties */
4 |
5 | /* ===== Breadcrumbs Container (nav) ===== */
6 | [data-component="breadcrumbs"] {
7 | /* Container has no default styles - allows flexible placement */
8 | }
9 |
10 | /* ===== Breadcrumbs List ===== */
11 | [data-breadcrumb-part="list"] {
12 | @apply flex flex-wrap items-center gap-1.5;
13 | @apply text-sm break-words;
14 | list-style: none;
15 | padding: 0;
16 | margin: 0;
17 | }
18 |
19 | /* Compact variant */
20 | [data-component="breadcrumbs"][data-size="sm"] [data-breadcrumb-part="list"] {
21 | @apply gap-1 text-xs;
22 | }
23 |
24 | /* ===== Breadcrumb Item ===== */
25 | [data-breadcrumb-part="item"] {
26 | @apply inline-flex items-center;
27 | }
28 |
29 | /* Hidden state for responsive collapsing */
30 | [data-breadcrumb-part="item"].hidden {
31 | @apply hidden;
32 | }
33 |
34 | /* ===== Breadcrumb Link ===== */
35 | [data-breadcrumb-part="link"] {
36 | @apply inline-flex items-center;
37 | @apply text-sm font-medium;
38 | @apply underline-offset-4;
39 | @apply transition-colors;
40 | color: var(--muted-foreground);
41 | }
42 |
43 | [data-breadcrumb-part="link"]:hover {
44 | color: var(--foreground);
45 | @apply underline;
46 | }
47 |
48 | [data-breadcrumb-part="link"]:focus-visible {
49 | @apply outline-none underline;
50 | color: var(--foreground);
51 | }
52 |
53 | /* Link with icon */
54 | [data-breadcrumb-part="link"] svg {
55 | @apply size-3.5 shrink-0;
56 | }
57 |
58 | [data-breadcrumb-part="link"] svg:first-child {
59 | @apply mr-1.5;
60 | }
61 |
62 | /* ===== Breadcrumb Page (current) ===== */
63 | [data-breadcrumb-part="page"] {
64 | @apply inline-flex items-center;
65 | @apply text-sm font-medium;
66 | color: var(--foreground);
67 | }
68 |
69 | /* Page with icon */
70 | [data-breadcrumb-part="page"] svg {
71 | @apply size-3.5 shrink-0;
72 | }
73 |
74 | [data-breadcrumb-part="page"] svg:first-child {
75 | @apply mr-1.5;
76 | }
77 |
78 | /* ===== Breadcrumb Separator ===== */
79 | [data-breadcrumb-part="separator"] {
80 | @apply inline-flex items-center;
81 | color: var(--muted-foreground);
82 | }
83 |
84 | [data-breadcrumb-part="separator"] svg {
85 | @apply size-3.5 shrink-0;
86 | }
87 |
88 | /* Hidden state for responsive collapsing */
89 | [data-breadcrumb-part="separator"].hidden {
90 | @apply hidden;
91 | }
92 |
93 | /* ===== Breadcrumb Ellipsis ===== */
94 | [data-breadcrumb-part="ellipsis"] {
95 | @apply inline-flex size-9 items-center justify-center;
96 | color: var(--muted-foreground);
97 | }
98 |
99 | [data-breadcrumb-part="ellipsis"] svg {
100 | @apply size-4 shrink-0;
101 | }
102 |
103 | /* Ellipsis as dropdown trigger */
104 | [data-breadcrumb-part="ellipsis"][data-state="open"],
105 | [data-breadcrumb-part="ellipsis"]:hover {
106 | color: var(--foreground);
107 | }
108 |
109 | /* ===== Responsive Behavior ===== */
110 | /* Items hidden by Stimulus controller */
111 | [data-controller="breadcrumb"] [data-breadcrumb-target="item"].hidden,
112 | [data-controller="breadcrumb"] [data-breadcrumb-target="ellipsisSeparator"].hidden {
113 | @apply hidden;
114 | }
115 |
116 | /* Ellipsis shown by Stimulus controller */
117 | [data-controller="breadcrumb"] [data-breadcrumb-target="ellipsis"]:not(.hidden) {
118 | @apply inline-flex;
119 | }
120 |
121 | /* ===== Mobile Adjustments ===== */
122 | @media (max-width: 640px) {
123 | [data-breadcrumb-part="list"] {
124 | @apply gap-1;
125 | }
126 |
127 | /* Hide middle items on very small screens by default */
128 | [data-component="breadcrumbs"][data-auto-collapse] [data-breadcrumb-part="item"]:not(:first-child):not(:last-child) {
129 | @apply hidden sm:inline-flex;
130 | }
131 | }
132 |
133 | /* ===== Variants ===== */
134 |
135 | /* Slash separator variant */
136 | [data-component="breadcrumbs"][data-separator="slash"] [data-breadcrumb-part="separator"]::before {
137 | content: "/";
138 | @apply px-1;
139 | }
140 |
141 | [data-component="breadcrumbs"][data-separator="slash"] [data-breadcrumb-part="separator"] svg {
142 | @apply hidden;
143 | }
144 |
145 | /* Dot separator variant */
146 | [data-component="breadcrumbs"][data-separator="dot"] [data-breadcrumb-part="separator"]::before {
147 | content: "•";
148 | @apply px-1;
149 | }
150 |
151 | [data-component="breadcrumbs"][data-separator="dot"] [data-breadcrumb-part="separator"] svg {
152 | @apply hidden;
153 | }
154 |
155 | /* Arrow separator variant */
156 | [data-component="breadcrumbs"][data-separator="arrow"] [data-breadcrumb-part="separator"]::before {
157 | content: "→";
158 | @apply px-1;
159 | }
160 |
161 | [data-component="breadcrumbs"][data-separator="arrow"] [data-breadcrumb-part="separator"] svg {
162 | @apply hidden;
163 | }
164 |
--------------------------------------------------------------------------------
/app/javascript/controllers/toggle_group_controller.js:
--------------------------------------------------------------------------------
1 | import { Controller } from "@hotwired/stimulus"
2 |
3 | /**
4 | * Toggle Group Controller
5 | *
6 | * Manages a group of toggle buttons with single or multiple selection.
7 | *
8 | * @example Single selection
9 | *
12 | * Bold
13 | * Italic
14 | *
15 | *
16 | * @example Multiple selection
17 | *
20 | * ...
21 | *
22 | */
23 | export default class extends Controller {
24 | static targets = ["item"]
25 |
26 | static values = {
27 | type: { type: String, default: "single" },
28 | selected: { type: Array, default: [] }
29 | }
30 |
31 | connect() {
32 | this.syncItemStates()
33 | }
34 |
35 | /**
36 | * Toggle an item's state
37 | * @param {Event} event - Click event from toggle item
38 | */
39 | toggle(event) {
40 | const item = event.currentTarget
41 | if (item.disabled) return
42 |
43 | const value = item.dataset.value
44 | const isPressed = item.dataset.state === "on"
45 |
46 | if (this.typeValue === "single") {
47 | if (isPressed) {
48 | this.selectedValue = []
49 | } else {
50 | this.selectedValue = [value]
51 | }
52 | } else {
53 | if (isPressed) {
54 | this.selectedValue = this.selectedValue.filter(v => v !== value)
55 | } else {
56 | this.selectedValue = [...this.selectedValue, value]
57 | }
58 | }
59 |
60 | this.syncItemStates()
61 | this.dispatchChange()
62 | }
63 |
64 | /**
65 | * Handle keyboard navigation
66 | * @param {KeyboardEvent} event
67 | */
68 | handleKeydown(event) {
69 | const item = event.currentTarget
70 | const items = this.itemTargets.filter(i => !i.disabled)
71 | const currentIndex = items.indexOf(item)
72 |
73 | let nextIndex = currentIndex
74 |
75 | switch (event.key) {
76 | case "ArrowRight":
77 | case "ArrowDown":
78 | event.preventDefault()
79 | nextIndex = (currentIndex + 1) % items.length
80 | break
81 | case "ArrowLeft":
82 | case "ArrowUp":
83 | event.preventDefault()
84 | nextIndex = (currentIndex - 1 + items.length) % items.length
85 | break
86 | case "Home":
87 | event.preventDefault()
88 | nextIndex = 0
89 | break
90 | case "End":
91 | event.preventDefault()
92 | nextIndex = items.length - 1
93 | break
94 | case " ":
95 | case "Enter":
96 | return
97 | default:
98 | return
99 | }
100 |
101 | items[nextIndex]?.focus()
102 | }
103 |
104 | /**
105 | * Sync visual states with selectedValue
106 | */
107 | syncItemStates() {
108 | this.itemTargets.forEach(item => {
109 | const value = item.dataset.value
110 | const isSelected = this.selectedValue.includes(value)
111 |
112 | item.dataset.state = isSelected ? "on" : "off"
113 | item.setAttribute("aria-pressed", isSelected)
114 | })
115 | }
116 |
117 | /**
118 | * Dispatch change event
119 | */
120 | dispatchChange() {
121 | const detail = {
122 | type: this.typeValue,
123 | value: this.typeValue === "single"
124 | ? (this.selectedValue[0] || null)
125 | : this.selectedValue
126 | }
127 |
128 | this.dispatch("change", { detail })
129 |
130 | this.element.dispatchEvent(new CustomEvent("toggle-group:change", {
131 | bubbles: true,
132 | detail
133 | }))
134 | }
135 |
136 | /**
137 | * Programmatically select a value
138 | * @param {string} value - Value to select
139 | */
140 | select(value) {
141 | if (this.typeValue === "single") {
142 | this.selectedValue = [value]
143 | } else if (!this.selectedValue.includes(value)) {
144 | this.selectedValue = [...this.selectedValue, value]
145 | }
146 | this.syncItemStates()
147 | this.dispatchChange()
148 | }
149 |
150 | /**
151 | * Programmatically deselect a value
152 | * @param {string} value - Value to deselect
153 | */
154 | deselect(value) {
155 | this.selectedValue = this.selectedValue.filter(v => v !== value)
156 | this.syncItemStates()
157 | this.dispatchChange()
158 | }
159 |
160 | /**
161 | * Clear all selections
162 | */
163 | clear() {
164 | this.selectedValue = []
165 | this.syncItemStates()
166 | this.dispatchChange()
167 | }
168 |
169 | /**
170 | * Get current value(s)
171 | * @returns {string|string[]|null}
172 | */
173 | getValue() {
174 | return this.typeValue === "single"
175 | ? (this.selectedValue[0] || null)
176 | : this.selectedValue
177 | }
178 | }
179 |
--------------------------------------------------------------------------------
/test/dummy/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | The page you were looking for doesn’t exist (404 Not found)
8 |
9 |
10 |
11 |
12 |
13 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
107 |
108 | The page you were looking for doesn’t exist. You may have mistyped the address or the page may have moved. If you’re the application owner check the logs for more information.
109 |
110 |
111 |
112 |
113 |
114 |
115 |
--------------------------------------------------------------------------------
/app/helpers/maquina_components/table_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module MaquinaComponents
4 | # Table Component Helper
5 | #
6 | # Provides a simple helper for rendering basic tables from collections.
7 | # For complex tables, use the partials directly for full control.
8 | #
9 | # @example Basic usage with collection
10 | # <%= simple_table @invoices,
11 | # columns: [
12 | # { key: :number, label: "Invoice" },
13 | # { key: :status, label: "Status" },
14 | # { key: :amount, label: "Amount", align: :right }
15 | # ] %>
16 | #
17 | # @example With caption and bordered variant
18 | # <%= simple_table @users,
19 | # columns: [
20 | # { key: :name, label: "Name" },
21 | # { key: :email, label: "Email" },
22 | # { key: :role, label: "Role" }
23 | # ],
24 | # caption: "Active users",
25 | # variant: :bordered %>
26 | #
27 | module TableHelper
28 | # Render a simple table from a collection
29 | #
30 | # @param collection [Array, ActiveRecord::Relation] The collection to render
31 | # @param columns [Array] Column definitions with :key, :label, and optional :align
32 | # @param caption [String, nil] Optional table caption
33 | # @param variant [Symbol, nil] Container variant (:bordered)
34 | # @param table_variant [Symbol, nil] Table variant (:striped)
35 | # @param empty_message [String] Message to show when collection is empty
36 | # @param row_id [Symbol, nil] Method to call for row ID (e.g., :id)
37 | # @param html_options [Hash] Additional HTML options for the table
38 | # @return [String] Rendered HTML
39 | def simple_table(collection, columns:, caption: nil, variant: nil, table_variant: nil, empty_message: "No data available", row_id: nil, **html_options)
40 | render partial: "components/simple_table", locals: {
41 | collection: collection,
42 | columns: columns,
43 | caption: caption,
44 | variant: variant,
45 | table_variant: table_variant,
46 | empty_message: empty_message,
47 | row_id: row_id,
48 | html_options: html_options
49 | }
50 | end
51 |
52 | # Generate data attributes for table elements
53 | # Useful when composing tables with other Rails helpers
54 | #
55 | # @example Using with content_tag
56 | # <%= content_tag :table, **table_data_attrs do %>
57 | # ...
58 | # <% end %>
59 | #
60 | # @param variant [Symbol, nil] Table variant (:striped)
61 | # @return [Hash] Data attributes hash
62 | def table_data_attrs(variant: nil)
63 | attrs = { data: { component: "table" } }
64 | attrs[:data][:variant] = variant.to_s if variant
65 | attrs
66 | end
67 |
68 | # Generate data attributes for table container
69 | #
70 | # @param variant [Symbol, nil] Container variant (:bordered)
71 | # @return [Hash] Data attributes hash
72 | def table_container_data_attrs(variant: nil)
73 | attrs = { data: { table_part: "container" } }
74 | attrs[:data][:variant] = variant.to_s if variant
75 | attrs
76 | end
77 |
78 | # Generate data attributes for table row
79 | #
80 | # @param selected [Boolean] Whether the row is selected
81 | # @return [Hash] Data attributes hash
82 | def table_row_data_attrs(selected: false)
83 | attrs = { data: { table_part: "row" } }
84 | attrs[:data][:state] = "selected" if selected
85 | attrs
86 | end
87 |
88 | # Generate data attributes for table header
89 | #
90 | # @param sticky [Boolean] Whether the header is sticky
91 | # @return [Hash] Data attributes hash
92 | def table_header_data_attrs(sticky: false)
93 | attrs = { data: { table_part: "header" } }
94 | attrs[:data][:sticky] = "true" if sticky
95 | attrs
96 | end
97 |
98 | # Generate data attributes for table head cell
99 | # @return [Hash] Data attributes hash
100 | def table_head_data_attrs
101 | { data: { table_part: "head" } }
102 | end
103 |
104 | # Generate data attributes for table cell
105 | #
106 | # @param empty [Boolean] Whether this is an empty state cell
107 | # @return [Hash] Data attributes hash
108 | def table_cell_data_attrs(empty: false)
109 | attrs = { data: { table_part: "cell" } }
110 | attrs[:data][:empty] = "true" if empty
111 | attrs
112 | end
113 |
114 | # Generate data attributes for table body
115 | # @return [Hash] Data attributes hash
116 | def table_body_data_attrs
117 | { data: { table_part: "body" } }
118 | end
119 |
120 | # Generate data attributes for table footer
121 | # @return [Hash] Data attributes hash
122 | def table_footer_data_attrs
123 | { data: { table_part: "footer" } }
124 | end
125 |
126 | # Generate data attributes for table caption
127 | # @return [Hash] Data attributes hash
128 | def table_caption_data_attrs
129 | { data: { table_part: "caption" } }
130 | end
131 |
132 | # Convert alignment symbol to CSS class
133 | #
134 | # @param align [Symbol, nil] Alignment (:left, :center, :right)
135 | # @return [String, nil] CSS class name
136 | def table_alignment_class(align)
137 | case align&.to_sym
138 | when :right then "text-right"
139 | when :center then "text-center"
140 | else nil
141 | end
142 | end
143 | end
144 | end
145 |
--------------------------------------------------------------------------------
/app/javascript/controllers/dropdown_menu_controller.js:
--------------------------------------------------------------------------------
1 | import { Controller } from "@hotwired/stimulus"
2 |
3 | /**
4 | * DropdownMenu Controller
5 | *
6 | * Handles opening/closing dropdown menus with:
7 | * - Click to toggle
8 | * - Click outside to close
9 | * - Escape key to close
10 | * - Keyboard navigation within menu
11 | * - Focus management
12 | * - Animation states
13 | */
14 | export default class extends Controller {
15 | static targets = ["trigger", "content", "chevron"]
16 |
17 | static values = {
18 | open: { type: Boolean, default: false }
19 | }
20 |
21 | connect() {
22 | this.handleClickOutside = this.handleClickOutside.bind(this)
23 | this.handleKeydown = this.handleKeydown.bind(this)
24 |
25 | // Set initial state on root element
26 | this.element.dataset.state = "closed"
27 | }
28 |
29 | disconnect() {
30 | this.removeEventListeners()
31 | }
32 |
33 | toggle(event) {
34 | event?.preventDefault()
35 |
36 | if (this.openValue) {
37 | this.close()
38 | } else {
39 | this.open()
40 | }
41 | }
42 |
43 | open() {
44 | if (this.openValue || !this.hasContentTarget) return
45 |
46 | this.openValue = true
47 | this.element.dataset.state = "open"
48 | this.contentTarget.dataset.state = "open"
49 | this.contentTarget.hidden = false
50 |
51 | // Update trigger aria
52 | if (this.hasTriggerTarget) {
53 | this.triggerTarget.setAttribute("aria-expanded", "true")
54 | }
55 |
56 | // Add event listeners
57 | this.addEventListeners()
58 |
59 | // Focus first item after animation
60 | requestAnimationFrame(() => {
61 | this.focusFirstItem()
62 | })
63 | }
64 |
65 | close() {
66 | if (!this.openValue || !this.hasContentTarget) return
67 |
68 | // Start closing animation
69 | this.contentTarget.dataset.state = "closing"
70 |
71 | // Wait for animation to complete
72 | const animationDuration = 100 // matches CSS animation duration
73 |
74 | setTimeout(() => {
75 | this.openValue = false
76 | this.element.dataset.state = "closed"
77 | this.contentTarget.dataset.state = "closed"
78 | this.contentTarget.hidden = true
79 |
80 | // Update trigger aria
81 | if (this.hasTriggerTarget) {
82 | this.triggerTarget.setAttribute("aria-expanded", "false")
83 | }
84 |
85 | // Remove event listeners
86 | this.removeEventListeners()
87 |
88 | // Return focus to trigger
89 | if (this.hasTriggerTarget) {
90 | this.triggerTarget.focus()
91 | }
92 | }, animationDuration)
93 | }
94 |
95 | // Event Handlers
96 |
97 | handleClickOutside(event) {
98 | if (!this.openValue) return
99 | if (this.element.contains(event.target)) return
100 |
101 | this.close()
102 | }
103 |
104 | handleKeydown(event) {
105 | if (!this.openValue) return
106 |
107 | switch (event.key) {
108 | case "Escape":
109 | event.preventDefault()
110 | this.close()
111 | break
112 |
113 | case "ArrowDown":
114 | event.preventDefault()
115 | this.focusNextItem()
116 | break
117 |
118 | case "ArrowUp":
119 | event.preventDefault()
120 | this.focusPreviousItem()
121 | break
122 |
123 | case "Home":
124 | event.preventDefault()
125 | this.focusFirstItem()
126 | break
127 |
128 | case "End":
129 | event.preventDefault()
130 | this.focusLastItem()
131 | break
132 |
133 | case "Tab":
134 | // Close menu and let focus move naturally
135 | this.close()
136 | break
137 | }
138 | }
139 |
140 | // Focus Management
141 |
142 | get menuItems() {
143 | if (!this.hasContentTarget) return []
144 |
145 | return Array.from(
146 | this.contentTarget.querySelectorAll('[data-dropdown-menu-part="item"]:not([disabled]):not([aria-disabled="true"])')
147 | )
148 | }
149 |
150 | get focusedItemIndex() {
151 | const items = this.menuItems
152 | const focused = document.activeElement
153 | return items.indexOf(focused)
154 | }
155 |
156 | focusFirstItem() {
157 | const items = this.menuItems
158 | if (items.length > 0) {
159 | items[0].focus()
160 | }
161 | }
162 |
163 | focusLastItem() {
164 | const items = this.menuItems
165 | if (items.length > 0) {
166 | items[items.length - 1].focus()
167 | }
168 | }
169 |
170 | focusNextItem() {
171 | const items = this.menuItems
172 | if (items.length === 0) return
173 |
174 | const currentIndex = this.focusedItemIndex
175 | const nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0
176 | items[nextIndex].focus()
177 | }
178 |
179 | focusPreviousItem() {
180 | const items = this.menuItems
181 | if (items.length === 0) return
182 |
183 | const currentIndex = this.focusedItemIndex
184 | const prevIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1
185 | items[prevIndex].focus()
186 | }
187 |
188 | // Event Listener Management
189 |
190 | addEventListeners() {
191 | // Delay adding click outside listener to prevent immediate close
192 | setTimeout(() => {
193 | document.addEventListener("click", this.handleClickOutside)
194 | }, 0)
195 |
196 | document.addEventListener("keydown", this.handleKeydown)
197 | }
198 |
199 | removeEventListeners() {
200 | document.removeEventListener("click", this.handleClickOutside)
201 | document.removeEventListener("keydown", this.handleKeydown)
202 | }
203 | }
204 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/table.css:
--------------------------------------------------------------------------------
1 | /* ===== Table Component ===== */
2 | /* Based on shadcn/ui table with data-component and data-table-part selectors */
3 | /* Uses theme variables for colors, @apply for spacing/typography */
4 |
5 | /* ===== Container ===== */
6 | [data-table-part="container"] {
7 | position: relative;
8 | width: 100%;
9 | min-width: 100%;
10 | overflow-x: auto;
11 | }
12 |
13 | /* Bordered variant */
14 | [data-table-part="container"][data-variant="bordered"] {
15 | border: 1px solid var(--border);
16 | @apply rounded-lg overflow-hidden;
17 | }
18 |
19 | /* ===== Table ===== */
20 | [data-component="table"] {
21 | width: 100%;
22 | min-width: 100%;
23 | caption-side: bottom;
24 | border-collapse: collapse;
25 | @apply text-sm;
26 | }
27 |
28 | /* ===== Caption ===== */
29 | [data-table-part="caption"] {
30 | color: var(--muted-foreground);
31 | @apply mt-4 text-sm;
32 | }
33 |
34 | /* ===== Header ===== */
35 | [data-table-part="header"] {
36 | border-bottom: 1px solid var(--border);
37 | }
38 |
39 | [data-table-part="header"][data-sticky="true"] {
40 | position: sticky;
41 | top: 0;
42 | z-index: 10;
43 | background-color: var(--muted);
44 | }
45 |
46 | /* Header rows always have border */
47 | [data-table-part="header"] [data-table-part="row"] {
48 | border-bottom: 1px solid var(--border);
49 | }
50 |
51 | /* ===== Body ===== */
52 | /* Remove border from last row in body */
53 | [data-table-part="body"] [data-table-part="row"]:last-child {
54 | border-bottom: 0;
55 | }
56 |
57 | /* ===== Footer ===== */
58 | [data-table-part="footer"] {
59 | background-color: color-mix(in oklch, var(--muted) 50%, transparent);
60 | font-weight: 500;
61 | border-top: 1px solid var(--border);
62 | }
63 |
64 | /* Remove border from last row in footer */
65 | [data-table-part="footer"] [data-table-part="row"]:last-child {
66 | border-bottom: 0;
67 | }
68 |
69 | /* ===== Row ===== */
70 | [data-table-part="row"] {
71 | border-bottom: 1px solid var(--border);
72 | transition-property: background-color;
73 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
74 | @apply duration-150;
75 | }
76 |
77 | [data-table-part="row"]:hover {
78 | background-color: color-mix(in oklch, var(--muted) 50%, transparent);
79 | }
80 |
81 | [data-table-part="row"][data-state="selected"] {
82 | background-color: var(--muted);
83 | }
84 |
85 | /* ===== Head Cell ===== */
86 | [data-table-part="head"] {
87 | color: var(--foreground);
88 | text-align: left;
89 | vertical-align: middle;
90 | @apply h-10 px-4 py-2.5 font-medium whitespace-nowrap text-xs uppercase tracking-wider;
91 | }
92 |
93 | /* First column - extra left padding */
94 | [data-table-part="head"]:first-child {
95 | @apply pl-6;
96 | }
97 |
98 | /* Last column - extra right padding */
99 | [data-table-part="head"]:last-child {
100 | @apply pr-6;
101 | }
102 |
103 | /* Checkbox alignment in header */
104 | [data-table-part="head"]:has([role="checkbox"]) {
105 | @apply pr-0;
106 | }
107 |
108 | [data-table-part="head"] > [role="checkbox"] {
109 | @apply translate-y-0.5;
110 | }
111 |
112 | /* ===== Body Cell ===== */
113 | [data-table-part="cell"] {
114 | vertical-align: middle;
115 | @apply px-4 py-3 whitespace-nowrap;
116 | }
117 |
118 | /* First column - extra left padding */
119 | [data-table-part="cell"]:first-child {
120 | @apply pl-6;
121 | }
122 |
123 | /* Last column - extra right padding */
124 | [data-table-part="cell"]:last-child {
125 | @apply pr-6;
126 | }
127 |
128 | /* Checkbox alignment in cells */
129 | [data-table-part="cell"]:has([role="checkbox"]) {
130 | @apply pr-0;
131 | }
132 |
133 | [data-table-part="cell"] > [role="checkbox"] {
134 | @apply translate-y-0.5;
135 | }
136 |
137 | /* ===== Utility Classes for Cells ===== */
138 | /* These can be applied via css_classes parameter */
139 |
140 | /* Right-aligned text (for amounts, numbers) */
141 | [data-table-part="head"].text-right,
142 | [data-table-part="cell"].text-right {
143 | text-align: right;
144 | }
145 |
146 | /* Center-aligned text */
147 | [data-table-part="head"].text-center,
148 | [data-table-part="cell"].text-center {
149 | text-align: center;
150 | }
151 |
152 | /* Fixed width columns */
153 | [data-table-part="head"].w-checkbox,
154 | [data-table-part="cell"].w-checkbox {
155 | @apply w-10;
156 | }
157 |
158 | [data-table-part="head"].w-actions,
159 | [data-table-part="cell"].w-actions {
160 | @apply w-12;
161 | }
162 |
163 | /* Font weight for emphasis */
164 | [data-table-part="cell"].font-medium {
165 | @apply font-medium;
166 | }
167 |
168 | /* ===== Striped Variant ===== */
169 | /* Apply data-variant="striped" to the table element */
170 | [data-component="table"][data-variant="striped"] [data-table-part="body"] [data-table-part="row"]:nth-child(even) {
171 | background-color: color-mix(in oklch, var(--muted) 30%, transparent);
172 | }
173 |
174 | [data-component="table"][data-variant="striped"] [data-table-part="body"] [data-table-part="row"]:nth-child(even):hover {
175 | background-color: color-mix(in oklch, var(--muted) 50%, transparent);
176 | }
177 |
178 | /* ===== Empty State ===== */
179 | [data-table-part="cell"][data-empty="true"] {
180 | color: var(--muted-foreground);
181 | text-align: center;
182 | @apply py-8;
183 | }
184 |
185 | /* ===== Responsive: Compact on mobile ===== */
186 | @media (max-width: 640px) {
187 | [data-table-part="head"],
188 | [data-table-part="cell"] {
189 | @apply px-2 py-2;
190 | }
191 |
192 | [data-table-part="head"]:first-child,
193 | [data-table-part="cell"]:first-child {
194 | @apply pl-4;
195 | }
196 |
197 | [data-table-part="head"]:last-child,
198 | [data-table-part="cell"]:last-child {
199 | @apply pr-4;
200 | }
201 |
202 | [data-table-part="head"] {
203 | @apply h-8;
204 | }
205 | }
206 |
--------------------------------------------------------------------------------
/app/assets/images/maquina.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lib/generators/maquina_components/install/templates/theme.css.tt:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | maquina_components Theme Variables
3 |
4 | These CSS variables follow the shadcn/ui theming convention.
5 | Customize these values to match your design system.
6 |
7 | Documentation: https://github.com/maquina-app/maquina_components
8 | ========================================================================== */
9 |
10 | @custom-variant dark (&:is(.dark *));
11 |
12 | :root {
13 | /* Layout */
14 | --header-height: calc(var(--spacing) * 12 + 1px);
15 | --sidebar-width: calc(var(--spacing) * 72);
16 | --sidebar-width-icon: 3rem;
17 |
18 | /* Core Colors */
19 | --background: oklch(1 0 0);
20 | --foreground: oklch(0.145 0 0);
21 |
22 | /* Card */
23 | --card: oklch(1 0 0);
24 | --card-foreground: oklch(0.145 0 0);
25 |
26 | /* Popover */
27 | --popover: oklch(1 0 0);
28 | --popover-foreground: oklch(0.145 0 0);
29 |
30 | /* Primary - Main brand color */
31 | --primary: oklch(0.645 0.246 16);
32 | --primary-foreground: oklch(0.969 0.015 12);
33 |
34 | /* Secondary */
35 | --secondary: oklch(0.97 0 0);
36 | --secondary-foreground: oklch(0.205 0 0);
37 |
38 | /* Muted - Subtle backgrounds */
39 | --muted: oklch(0.97 0 0);
40 | --muted-foreground: oklch(0.556 0 0);
41 |
42 | /* Accent - Hover states */
43 | --accent: oklch(0.97 0 0);
44 | --accent-foreground: oklch(0.205 0 0);
45 |
46 | /* Success - Success states */
47 | --success: oklch(0.92 0.04 168);
48 | --success-foreground: oklch(0.35 0.08 168);
49 |
50 | /* Warning - Warning states */
51 | --warning: oklch(0.93 0.04 55);
52 | --warning-foreground: oklch(0.4 0.08 50);
53 |
54 | /* Destructive - Error/danger states */
55 | --destructive: oklch(0.92 0.05 8);
56 | --destructive-foreground: oklch(0.4 0.12 8);
57 |
58 | /* Borders & Inputs */
59 | --border: oklch(0.922 0 0);
60 | --input: oklch(0.922 0 0);
61 | --ring: oklch(0.645 0.246 16);
62 |
63 | /* Charts (optional) */
64 | --chart-1: oklch(0.645 0.246 16);
65 | --chart-2: oklch(0.65 0.09 168);
66 | --chart-3: oklch(0.72 0.09 55);
67 | --chart-4: oklch(0.58 0.18 17);
68 | --chart-5: oklch(0.5 0.13 8);
69 |
70 | /* Sidebar */
71 | --sidebar: oklch(0.96 0 0);
72 | --sidebar-foreground: oklch(0.2 0 0);
73 | --sidebar-primary: oklch(0.645 0.246 16);
74 | --sidebar-primary-foreground: oklch(0.969 0.015 12);
75 | --sidebar-accent: oklch(0.9 0 0);
76 | --sidebar-accent-foreground: oklch(0.145 0 0);
77 | --sidebar-border: oklch(0.88 0 0);
78 | --sidebar-ring: oklch(0.645 0.246 16);
79 |
80 | /* Dark Mode */
81 | .dark {
82 | --background: oklch(0.145 0 0);
83 | --foreground: oklch(0.985 0 0);
84 |
85 | --card: oklch(0.205 0 0);
86 | --card-foreground: oklch(0.985 0 0);
87 |
88 | --popover: oklch(0.269 0 0);
89 | --popover-foreground: oklch(0.985 0 0);
90 |
91 | --primary: oklch(0.712 0.194 13);
92 | --primary-foreground: oklch(0.15 0.052 13);
93 |
94 | --secondary: oklch(0.269 0 0);
95 | --secondary-foreground: oklch(0.985 0 0);
96 |
97 | --muted: oklch(0.269 0 0);
98 | --muted-foreground: oklch(0.708 0 0);
99 |
100 | --accent: oklch(0.371 0 0);
101 | --accent-foreground: oklch(0.985 0 0);
102 |
103 | --success: oklch(0.32 0.06 168);
104 | --success-foreground: oklch(0.82 0.06 168);
105 |
106 | --warning: oklch(0.36 0.06 55);
107 | --warning-foreground: oklch(0.86 0.06 55);
108 |
109 | --destructive: oklch(0.34 0.07 8);
110 | --destructive-foreground: oklch(0.88 0.06 8);
111 |
112 | --border: oklch(1 0 0 / 10%);
113 | --input: oklch(1 0 0 / 15%);
114 | --ring: oklch(0.455 0.188 13);
115 |
116 | --chart-1: oklch(0.712 0.194 13);
117 | --chart-2: oklch(0.72 0.09 168);
118 | --chart-3: oklch(0.78 0.09 55);
119 | --chart-4: oklch(0.645 0.246 16);
120 | --chart-5: oklch(0.58 0.13 8);
121 |
122 | --sidebar: oklch(0.14 0 0);
123 | --sidebar-foreground: oklch(0.9 0 0);
124 | --sidebar-primary: oklch(0.712 0.194 13);
125 | --sidebar-primary-foreground: oklch(0.15 0.052 13);
126 | --sidebar-accent: oklch(0.22 0 0);
127 | --sidebar-accent-foreground: oklch(0.95 0 0);
128 | --sidebar-border: oklch(1 0 0 / 12%);
129 | --sidebar-ring: oklch(0.455 0.188 13);
130 | }
131 | }
132 |
133 | /* Tailwind Theme Bindings
134 | These make the CSS variables available as Tailwind utilities:
135 | bg-primary, text-muted-foreground, border-sidebar, etc. */
136 | @theme {
137 | --color-background: var(--background);
138 | --color-foreground: var(--foreground);
139 |
140 | --color-primary: var(--primary);
141 | --color-primary-foreground: var(--primary-foreground);
142 |
143 | --color-muted: var(--muted);
144 | --color-muted-foreground: var(--muted-foreground);
145 |
146 | --color-secondary: var(--secondary);
147 | --color-secondary-foreground: var(--secondary-foreground);
148 |
149 | --color-accent: var(--accent);
150 | --color-accent-foreground: var(--accent-foreground);
151 |
152 | --color-destructive: var(--destructive);
153 | --color-destructive-foreground: var(--destructive-foreground);
154 |
155 | --color-input: var(--input);
156 | --color-border: var(--border);
157 | --color-ring: var(--ring);
158 | --color-ring-destructive: var(--destructive);
159 |
160 | --color-card: var(--card);
161 | --color-card-foreground: var(--card-foreground);
162 |
163 | --color-popover: var(--popover);
164 | --color-popover-foreground: var(--popover-foreground);
165 |
166 | --color-sidebar: var(--sidebar);
167 | --color-sidebar-foreground: var(--sidebar-foreground);
168 | --color-sidebar-primary: var(--sidebar-primary);
169 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
170 | --color-sidebar-accent: var(--sidebar-accent);
171 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
172 | --color-sidebar-border: var(--sidebar-border);
173 | --color-sidebar-ring: var(--sidebar-ring);
174 | }
175 |
176 | /* Global border color */
177 | * {
178 | border-color: var(--color-border);
179 | }
180 |
--------------------------------------------------------------------------------
/app/helpers/maquina_components/pagination_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module MaquinaComponents
4 | # Pagination Helper
5 | #
6 | # Provides convenient methods for creating pagination components with Pagy integration.
7 | #
8 | # @example Using the helper with Pagy
9 | # <%%= pagination_nav(@pagy, :users_path) %>
10 | #
11 | # @example With additional params
12 | # <%%= pagination_nav(@pagy, :search_users_path, params: { q: params[:q] }) %>
13 | #
14 | # @example With Turbo options
15 | # <%%= pagination_nav(@pagy, :users_path, turbo: { action: :replace, frame: "users" }) %>
16 | #
17 | # @example Using partials directly
18 | # <%%= render "components/pagination" do %>
19 | # <%%= render "components/pagination/content" do %>
20 | # <%%= render "components/pagination/item" do %>
21 | # <%%= render "components/pagination/previous", href: prev_path %>
22 | # <%% end %>
23 | # ...
24 | # <%% end %>
25 | # <%% end %>
26 | #
27 | module PaginationHelper
28 | # Renders a complete pagination navigation from a Pagy object
29 | #
30 | # @param pagy [Pagy] The Pagy pagination object
31 | # @param route_helper [Symbol] Route helper method name (e.g., :users_path)
32 | # @param params [Hash] Additional params to pass to route helper
33 | # @param turbo [Hash] Turbo-specific data attributes
34 | # @param show_labels [Boolean] Whether to show Previous/Next text labels
35 | # @param css_classes [String] Additional CSS classes for the nav
36 | # @return [String] Rendered HTML
37 | def pagination_nav(pagy, route_helper, params: {}, turbo: {action: :replace}, show_labels: true, css_classes: "", **html_options)
38 | return if pagy.pages <= 1
39 |
40 | render "components/pagination", css_classes: css_classes, **html_options do
41 | render "components/pagination/content" do
42 | safe_join([
43 | pagination_previous_item(pagy, route_helper, params, turbo, show_labels),
44 | pagination_page_items(pagy, route_helper, params, turbo),
45 | pagination_next_item(pagy, route_helper, params, turbo, show_labels)
46 | ])
47 | end
48 | end
49 | end
50 |
51 | # Simpler pagination with just Previous/Next (no page numbers)
52 | #
53 | # @param pagy [Pagy] The Pagy pagination object
54 | # @param route_helper [Symbol] Route helper method name
55 | # @param params [Hash] Additional params to pass to route helper
56 | # @param turbo [Hash] Turbo-specific data attributes
57 | # @return [String] Rendered HTML
58 | def pagination_simple(pagy, route_helper, params: {}, turbo: {action: :replace}, css_classes: "", **html_options)
59 | return if pagy.pages <= 1
60 |
61 | render "components/pagination", css_classes: css_classes, **html_options do
62 | render "components/pagination/content" do
63 | safe_join([
64 | pagination_previous_item(pagy, route_helper, params, turbo, true),
65 | pagination_next_item(pagy, route_helper, params, turbo, true)
66 | ])
67 | end
68 | end
69 | end
70 |
71 | # Build paginated path with page param
72 | #
73 | # @param route_helper [Symbol] Route helper method name
74 | # @param pagy [Pagy] The Pagy pagination object
75 | # @param page [Integer] Page number
76 | # @param extra_params [Hash] Additional params
77 | # @return [String] URL path
78 | def paginated_path(route_helper, pagy, page, extra_params = {})
79 | page_param = pagy.vars[:page_param] || Pagy::DEFAULT[:page_param]
80 | query_params = request.query_parameters.except(page_param.to_s).merge(extra_params)
81 | query_params[page_param] = page
82 |
83 | send(route_helper, query_params)
84 | end
85 |
86 | private
87 |
88 | def pagination_previous_item(pagy, route_helper, params, turbo, show_label)
89 | render "components/pagination/item" do
90 | if pagy.prev
91 | render "components/pagination/previous",
92 | href: paginated_path(route_helper, pagy, pagy.prev, params),
93 | show_label: show_label,
94 | data: turbo_data(turbo)
95 | else
96 | render "components/pagination/previous",
97 | disabled: true,
98 | show_label: show_label
99 | end
100 | end
101 | end
102 |
103 | def pagination_next_item(pagy, route_helper, params, turbo, show_label)
104 | render "components/pagination/item" do
105 | if pagy.next
106 | render "components/pagination/next",
107 | href: paginated_path(route_helper, pagy, pagy.next, params),
108 | show_label: show_label,
109 | data: turbo_data(turbo)
110 | else
111 | render "components/pagination/next",
112 | disabled: true,
113 | show_label: show_label
114 | end
115 | end
116 | end
117 |
118 | def pagination_page_items(pagy, route_helper, params, turbo)
119 | pagy.series.map do |item|
120 | render "components/pagination/item" do
121 | case item
122 | when Integer
123 | render "components/pagination/link",
124 | href: paginated_path(route_helper, pagy, item, params),
125 | active: item == pagy.page,
126 | data: turbo_data(turbo) do
127 | item.to_s
128 | end
129 | when String
130 | # Current page (string representation)
131 | render "components/pagination/link",
132 | href: paginated_path(route_helper, pagy, item.to_i, params),
133 | active: true,
134 | data: turbo_data(turbo) do
135 | item
136 | end
137 | when :gap
138 | render "components/pagination/ellipsis"
139 | end
140 | end
141 | end
142 | end
143 |
144 | def turbo_data(turbo)
145 | return {} if turbo.blank?
146 |
147 | data = {}
148 | data[:turbo_action] = turbo[:action] if turbo[:action]
149 | data[:turbo_frame] = turbo[:frame] if turbo[:frame]
150 | data
151 | end
152 | end
153 | end
154 |
--------------------------------------------------------------------------------
/app/helpers/maquina_components/toggle_group_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module MaquinaComponents
4 | # Toggle Group Helper
5 | #
6 | # Provides convenient methods for creating toggle group components.
7 | #
8 | # @example Using partials directly
9 | # <%%= render "components/toggle_group", type: :single, variant: :outline do %>
10 | # <%%= render "components/toggle_group/item", value: "bold", aria_label: "Toggle bold" do %>
11 | # <%%= icon_for :bold %>
12 | # <%% end %>
13 | # <%% end %>
14 | #
15 | # @example Using the helper with builder
16 | # <%%= toggle_group type: :multiple, variant: :outline do |group| %>
17 | # <%% group.item value: "bold", icon: :bold, aria_label: "Toggle bold" %>
18 | # <%% group.item value: "italic", icon: :italic, aria_label: "Toggle italic" %>
19 | # <%% end %>
20 | #
21 | # @example Simple data-driven helper
22 | # <%%= toggle_group_simple type: :single, items: [
23 | # { value: "left", icon: :align_left, aria_label: "Align left" },
24 | # { value: "center", icon: :align_center, aria_label: "Align center" },
25 | # { value: "right", icon: :align_right, aria_label: "Align right" }
26 | # ] %>
27 | #
28 | module ToggleGroupHelper
29 | # Renders a toggle group with builder pattern
30 | #
31 | # @param type [Symbol] Selection mode (:single, :multiple)
32 | # @param variant [Symbol] Visual style (:default, :outline)
33 | # @param size [Symbol] Size variant (:default, :sm, :lg)
34 | # @param value [String, Array, nil] Initially selected value(s)
35 | # @param disabled [Boolean] Disable all items
36 | # @param css_classes [String] Additional CSS classes
37 | # @param html_options [Hash] Additional HTML attributes
38 | # @yield [ToggleGroupBuilder] Builder for adding items
39 | # @return [String] Rendered HTML
40 | def toggle_group(type: :single, variant: :default, size: :default, value: nil, disabled: false, css_classes: "", **html_options, &block)
41 | builder = ToggleGroupBuilder.new(self, type: type, variant: variant, size: size, value: value, disabled: disabled)
42 |
43 | if block && block.arity == 1
44 | capture { yield(builder) }
45 | end
46 |
47 | render "components/toggle_group",
48 | type: type,
49 | variant: variant,
50 | size: size,
51 | value: value,
52 | disabled: disabled,
53 | css_classes: css_classes,
54 | **html_options do
55 | if block && block.arity == 1
56 | builder.to_html
57 | elsif block
58 | capture(&block)
59 | end
60 | end
61 | end
62 |
63 | # Renders a simple data-driven toggle group
64 | #
65 | # @param type [Symbol] Selection mode (:single, :multiple)
66 | # @param items [Array] Array of item configurations
67 | # @param variant [Symbol] Visual style
68 | # @param size [Symbol] Size variant
69 | # @param value [String, Array, nil] Initially selected value(s)
70 | # @return [String] Rendered HTML
71 | def toggle_group_simple(items:, type: :single, variant: :default, size: :default, value: nil, disabled: false, css_classes: "", **html_options)
72 | selected_values = normalize_value(value)
73 |
74 | render "components/toggle_group",
75 | type: type,
76 | variant: variant,
77 | size: size,
78 | value: value,
79 | disabled: disabled,
80 | css_classes: css_classes,
81 | **html_options do
82 | safe_join(items.map do |item|
83 | item_value = item[:value].to_s
84 | is_pressed = selected_values.include?(item_value)
85 |
86 | render "components/toggle_group/item",
87 | value: item_value,
88 | pressed: is_pressed,
89 | disabled: item[:disabled] || disabled,
90 | aria_label: item[:aria_label] do
91 | parts = []
92 | parts << icon_for(item[:icon]) if item[:icon] && respond_to?(:icon_for)
93 | parts << item[:label] if item[:label]
94 | safe_join(parts)
95 | end
96 | end)
97 | end
98 | end
99 |
100 | # Builder class for toggle group
101 | class ToggleGroupBuilder
102 | def initialize(view_context, type:, variant:, size:, value:, disabled:)
103 | @view = view_context
104 | @type = type
105 | @variant = variant
106 | @size = size
107 | @value = value
108 | @disabled = disabled
109 | @items = []
110 | @selected_values = normalize_value(value)
111 | end
112 |
113 | # Add an item to the toggle group
114 | def item(value:, label: nil, icon: nil, disabled: false, aria_label: nil, **options, &block)
115 | is_pressed = @selected_values.include?(value.to_s)
116 |
117 | @items << {
118 | value: value,
119 | label: label,
120 | icon: icon,
121 | disabled: disabled || @disabled,
122 | aria_label: aria_label,
123 | pressed: is_pressed,
124 | options: options,
125 | block: block
126 | }
127 | end
128 |
129 | def to_html
130 | @view.safe_join(@items.map { |item| render_item(item) })
131 | end
132 |
133 | private
134 |
135 | def render_item(item)
136 | @view.render "components/toggle_group/item",
137 | value: item[:value],
138 | pressed: item[:pressed],
139 | disabled: item[:disabled],
140 | aria_label: item[:aria_label],
141 | **item[:options] do
142 | if item[:block]
143 | @view.capture(&item[:block])
144 | else
145 | parts = []
146 | parts << @view.icon_for(item[:icon]) if item[:icon] && @view.respond_to?(:icon_for)
147 | parts << item[:label] if item[:label]
148 | @view.safe_join(parts)
149 | end
150 | end
151 | end
152 |
153 | def normalize_value(value)
154 | case value
155 | when Array then value.map(&:to_s)
156 | when nil then []
157 | else [value.to_s]
158 | end
159 | end
160 | end
161 |
162 | private
163 |
164 | def normalize_value(value)
165 | case value
166 | when Array then value.map(&:to_s)
167 | when nil then []
168 | else [value.to_s]
169 | end
170 | end
171 | end
172 | end
173 |
--------------------------------------------------------------------------------
/docs/header.md:
--------------------------------------------------------------------------------
1 | # Header
2 |
3 | > A page header component for use with sidebar layouts.
4 |
5 | ---
6 |
7 | ## Quick Reference
8 |
9 | ### Parameters
10 |
11 | | Parameter | Type | Default | Description |
12 | |-----------|------|---------|-------------|
13 | | `css_classes` | String | `""` | Additional CSS classes |
14 | | `**html_options` | Hash | `{}` | HTML attributes (`id:`, `data:`, etc.) |
15 |
16 | ### Data Attributes
17 |
18 | **Component Identifier**
19 |
20 | | Attribute | Element | Description |
21 | |-----------|---------|-------------|
22 | | `data-component="header"` | `` | Main component identifier |
23 |
24 | ---
25 |
26 | ## Basic Usage
27 |
28 | ```erb
29 | <%%= render "components/header" do %>
30 | <%%= render "components/sidebar/trigger" %>
31 | <%%= render "components/separator", orientation: :vertical %>
32 | <%%= breadcrumbs({"Dashboard" => dashboard_path}, @page_title) %>
33 | <%% end %>
34 | ```
35 |
36 | ---
37 |
38 | ## Examples
39 |
40 | ### With Breadcrumbs
41 |
42 | ```erb
43 | <%%= render "components/header" do %>
44 | <%%= render "components/sidebar/trigger" %>
45 | <%%= render "components/separator", orientation: :vertical %>
46 | <%%= breadcrumbs(
47 | {"Dashboard" => dashboard_path, "Users" => users_path},
48 | "John Doe"
49 | ) %>
50 | <%% end %>
51 | ```
52 |
53 | ### With Actions
54 |
55 | ```erb
56 | <%%= render "components/header" do %>
57 | <%%= render "components/sidebar/trigger" %>
58 | <%%= render "components/separator", orientation: :vertical %>
59 | <%%= breadcrumbs({"Projects" => projects_path}, @project.name) %>
60 |
61 |
62 | <%%= link_to "Edit", edit_project_path(@project), data: { component: "button", variant: "outline", size: "sm" } %>
63 | <%%= link_to "Delete", project_path(@project), data: { component: "button", variant: "destructive", size: "sm" }, method: :delete %>
64 |
65 | <%% end %>
66 | ```
67 |
68 | ### With Search
69 |
70 | ```erb
71 | <%%= render "components/header" do %>
72 | <%%= render "components/sidebar/trigger" %>
73 | <%%= render "components/separator", orientation: :vertical %>
74 |
75 |
76 | <%%= form_with url: search_path, method: :get, class: "relative" do |f| %>
77 | <%%= icon_for :search, class: "absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" %>
78 | <%%= f.search_field :q, data: { component: "input" }, class: "pl-10 h-8", placeholder: "Search..." %>
79 | <%% end %>
80 |
81 |
82 |
83 | <%%= render "components/dropdown_menu" do %>
84 | <%# User menu %>
85 | <%% end %>
86 |
87 | <%% end %>
88 | ```
89 |
90 | ### Simple Page Title
91 |
92 | ```erb
93 | <%%= render "components/header" do %>
94 | <%%= render "components/sidebar/trigger" %>
95 | <%%= render "components/separator", orientation: :vertical %>
96 | Dashboard
97 | <%% end %>
98 | ```
99 |
100 | ---
101 |
102 | ## Real-World Patterns
103 |
104 | ### Standard App Header
105 |
106 | ```erb
107 | <%%= render "components/header" do %>
108 | <%# Mobile menu trigger %>
109 | <%%= render "components/sidebar/trigger" %>
110 | <%%= render "components/separator", orientation: :vertical %>
111 |
112 | <%# Breadcrumbs %>
113 | <%%= responsive_breadcrumbs(@breadcrumb_links, @breadcrumb_current) %>
114 |
115 | <%# Actions %>
116 |
117 | <%# Notifications %>
118 |
119 | <%%= icon_for :bell, class: "size-4" %>
120 | 3
121 |
122 |
123 | <%# User menu %>
124 | <%%= dropdown_menu do |menu| %>
125 | <%% menu.trigger variant: :ghost, size: :sm do %>
126 | <%%= image_tag current_user.avatar, class: "size-6 rounded-full" %>
127 | <%% end %>
128 | <%% menu.content align: :end do %>
129 | <%% menu.label { current_user.name } %>
130 | <%% menu.separator %>
131 | <%% menu.item "Profile", href: profile_path, icon: :user %>
132 | <%% menu.item "Settings", href: settings_path, icon: :settings %>
133 | <%% menu.separator %>
134 | <%% menu.item "Logout", href: logout_path, method: :delete, icon: :log_out %>
135 | <%% end %>
136 | <%% end %>
137 |
138 | <%% end %>
139 | ```
140 |
141 | ### With Tabs
142 |
143 | ```erb
144 | <%%= render "components/header" do %>
145 | <%%= render "components/sidebar/trigger" %>
146 | <%%= render "components/separator", orientation: :vertical %>
147 |
148 |
149 | <%%= link_to "Overview", project_path(@project),
150 | class: "px-3 py-1.5 text-sm rounded-md #{'bg-accent text-accent-foreground' if current_page?(project_path(@project))}" %>
151 | <%%= link_to "Tasks", project_tasks_path(@project),
152 | class: "px-3 py-1.5 text-sm rounded-md #{'bg-accent text-accent-foreground' if current_page?(project_tasks_path(@project))}" %>
153 | <%%= link_to "Settings", edit_project_path(@project),
154 | class: "px-3 py-1.5 text-sm rounded-md #{'bg-accent text-accent-foreground' if current_page?(edit_project_path(@project))}" %>
155 |
156 | <%% end %>
157 | ```
158 |
159 | ---
160 |
161 | ## Theme Variables
162 |
163 | ```css
164 | var(--background)
165 | var(--border)
166 | ```
167 |
168 | ---
169 |
170 | ## Customization
171 |
172 | ### Fixed Height
173 |
174 | The header has a fixed height for consistency with sidebar layouts:
175 |
176 | ```css
177 | [data-component="header"] {
178 | @apply h-14;
179 | }
180 | ```
181 |
182 | ### Sticky Header
183 |
184 | ```erb
185 | <%%= render "components/header", css_classes: "sticky top-0 z-50" do %>
186 | <%# ... %>
187 | <%% end %>
188 | ```
189 |
190 | ---
191 |
192 | ## Accessibility
193 |
194 | - Uses semantic `` element
195 | - Works with skip links for keyboard navigation
196 | - Provides consistent landmark for screen readers
197 |
198 | ---
199 |
200 | ## File Structure
201 |
202 | ```
203 | app/views/components/
204 | └── _header.html.erb
205 |
206 | app/assets/stylesheets/header.css
207 | docs/header.md
208 | ```
209 |
--------------------------------------------------------------------------------