├── .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 | 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 | 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 | 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 | * 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 | 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 | <%= subtitle %> 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 | 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 | 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 | 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 | 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 | * 13 | * 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 |
105 | 106 |
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 | 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 | 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 | --------------------------------------------------------------------------------