├── CLAUDE.md ├── app ├── helpers │ ├── .keep │ └── nitro_kit │ │ ├── card_helper.rb │ │ ├── tabs_helper.rb │ │ ├── textarea_helper.rb │ │ ├── dialog_helper.rb │ │ ├── icon_helper.rb │ │ ├── switch_helper.rb │ │ ├── table_helper.rb │ │ ├── tooltip_helper.rb │ │ ├── dropdown_helper.rb │ │ ├── accordion_helper.rb │ │ ├── datepicker_helper.rb │ │ ├── field_helper.rb │ │ ├── fieldset_helper.rb │ │ ├── button_group_helper.rb │ │ ├── field_group_helper.rb │ │ ├── combobox_helper.rb │ │ ├── alert_helper.rb │ │ ├── badge_helper.rb │ │ ├── avatar_helper.rb │ │ ├── form_helper.rb │ │ ├── label_helper.rb │ │ ├── select_helper.rb │ │ ├── radio_button_helper.rb │ │ ├── toast_helper.rb │ │ ├── input_helper.rb │ │ ├── pagination_helper.rb │ │ ├── checkbox_helper.rb │ │ └── button_helper.rb ├── javascript │ └── controllers │ │ └── nk │ │ ├── datepicker_controller.js │ │ ├── dialog_controller.js │ │ ├── accordion_controller.js │ │ ├── switch_controller.js │ │ ├── tabs_controller.js │ │ ├── tooltip_controller.js │ │ ├── toast_controller.js │ │ ├── dropdown_controller.js │ │ └── combobox_controller.js ├── components │ └── nitro_kit │ │ ├── datepicker.rb │ │ ├── field_group.rb │ │ ├── label.rb │ │ ├── avatar_stack.rb │ │ ├── input.rb │ │ ├── button_group.rb │ │ ├── textarea.rb │ │ ├── icon.rb │ │ ├── checkbox_group.rb │ │ ├── avatar.rb │ │ ├── fieldset.rb │ │ ├── tooltip.rb │ │ ├── radio_button_group.rb │ │ ├── card.rb │ │ ├── alert.rb │ │ ├── radio_button.rb │ │ ├── table.rb │ │ ├── switch.rb │ │ ├── component.rb │ │ ├── checkbox.rb │ │ ├── tabs.rb │ │ ├── accordion.rb │ │ ├── pagination.rb │ │ ├── select.rb │ │ ├── dialog.rb │ │ ├── form_builder.rb │ │ ├── badge.rb │ │ ├── toast.rb │ │ ├── button.rb │ │ ├── combobox.rb │ │ └── dropdown.rb └── assets │ └── tailwind │ └── application.css ├── test ├── helpers │ └── .keep ├── mailers │ └── .keep ├── models │ └── .keep ├── controllers │ └── .keep ├── dummy │ ├── log │ │ └── .keep │ ├── app │ │ ├── assets │ │ │ ├── images │ │ │ │ └── .keep │ │ │ └── tailwind │ │ │ │ └── application.css │ │ ├── models │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ ├── application_record.rb │ │ │ └── user.rb │ │ ├── controllers │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ ├── application_controller.rb │ │ │ └── tests_controller.rb │ │ ├── views │ │ │ ├── tests │ │ │ │ ├── nk_select_helper.html.erb │ │ │ │ ├── form_builder_select.html.erb │ │ │ │ ├── field_select.html.erb │ │ │ │ ├── index.html.erb │ │ │ │ ├── form_builder_select_with_blank.html.erb │ │ │ │ ├── form_builder_select_with_prompt.html.erb │ │ │ │ ├── field_select_with_prompt.html.erb │ │ │ │ └── examples │ │ │ │ │ ├── avatar.html.erb │ │ │ │ │ ├── badge.html.erb │ │ │ │ │ ├── radio_button.html.erb │ │ │ │ │ └── dropdown.html.erb │ │ │ ├── pwa │ │ │ │ ├── manifest.json.erb │ │ │ │ └── service-worker.js │ │ │ └── layouts │ │ │ │ └── application.html.erb │ │ ├── mailers │ │ │ └── application_mailer.rb │ │ ├── javascript │ │ │ ├── application.js │ │ │ └── controllers │ │ │ │ ├── hello_controller.js │ │ │ │ ├── application.js │ │ │ │ └── index.js │ │ ├── helpers │ │ │ └── application_helper.rb │ │ └── jobs │ │ │ └── application_job.rb │ ├── vendor │ │ └── javascript │ │ │ ├── .keep │ │ │ ├── @floating-ui--utils.js │ │ │ ├── @floating-ui--utils--dom.js │ │ │ └── @github--combobox-nav.js │ ├── bin │ │ ├── dev │ │ ├── rake │ │ ├── importmap │ │ ├── rails │ │ └── setup │ ├── public │ │ ├── icon.png │ │ ├── icon.svg │ │ ├── 404.html │ │ ├── 400.html │ │ ├── 406-unsupported-browser.html │ │ ├── 500.html │ │ └── 422.html │ ├── config │ │ ├── environment.rb │ │ ├── cable.yml │ │ ├── boot.rb │ │ ├── initializers │ │ │ ├── assets.rb │ │ │ ├── filter_parameter_logging.rb │ │ │ ├── inflections.rb │ │ │ └── content_security_policy.rb │ │ ├── routes.rb │ │ ├── importmap.rb │ │ ├── locales │ │ │ └── en.yml │ │ ├── database.yml │ │ ├── storage.yml │ │ ├── application.rb │ │ ├── puma.rb │ │ └── environments │ │ │ ├── test.rb │ │ │ ├── development.rb │ │ │ └── production.rb │ ├── config.ru │ └── Rakefile ├── integration │ ├── .keep │ ├── navigation_test.rb │ └── select_test.rb ├── fixtures │ └── files │ │ └── .keep ├── nitro_kit_test.rb └── test_helper.rb ├── .rubyfmtignore ├── .prettierignore ├── .prettierrc ├── lib ├── nitro_kit │ ├── version.rb │ ├── engine.rb │ ├── variants.rb │ └── schema_builder.rb ├── tasks │ └── nitro_kit_tasks.rake ├── nitro_kit.rb └── generators │ └── nitro_kit │ └── component_generator.rb ├── bin ├── dev ├── format ├── rubocop └── rails ├── .herb.yml ├── Rakefile ├── Gemfile ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .rubocop.yml ├── .gitignore ├── package.json ├── README.md ├── nitro_kit.gemspec ├── LICENSE ├── CHANGELOG.md └── AGENTS.md /CLAUDE.md: -------------------------------------------------------------------------------- 1 | AGENTS.md -------------------------------------------------------------------------------- /app/helpers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/helpers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/mailers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/models/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/controllers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/integration/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/files/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/vendor/javascript/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.rubyfmtignore: -------------------------------------------------------------------------------- 1 | test/dummy/**/* 2 | 3 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | config/ 3 | test/dummy/ 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-tailwindcss"] 3 | } 4 | -------------------------------------------------------------------------------- /lib/nitro_kit/version.rb: -------------------------------------------------------------------------------- 1 | module NitroKit 2 | VERSION = "0.8.0" 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/bin/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | exec "./bin/rails", "server", *ARGV 3 | -------------------------------------------------------------------------------- /bin/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | exec("./bin/rails", "server", "-p", "3031", *ARGV) 3 | -------------------------------------------------------------------------------- /lib/nitro_kit/engine.rb: -------------------------------------------------------------------------------- 1 | module NitroKit 2 | class Engine < ::Rails::Engine 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikker/nitro_kit/HEAD/test/dummy/public/icon.png -------------------------------------------------------------------------------- /.herb.yml: -------------------------------------------------------------------------------- 1 | formatter: 2 | enabled: true # Must be enabled for formatting to work 3 | exclude: 4 | - "test/dummy/**/*" 5 | -------------------------------------------------------------------------------- /test/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /lib/tasks/nitro_kit_tasks.rake: -------------------------------------------------------------------------------- 1 | # desc "Explaining what the task does" 2 | # task :nitro_kit do 3 | # # Task goes here 4 | # end 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 | -------------------------------------------------------------------------------- /test/dummy/app/views/tests/nk_select_helper.html.erb: -------------------------------------------------------------------------------- 1 | <%= nk_select "journey", "direction", %w[North East South West], value: "South" %> -------------------------------------------------------------------------------- /bin/format: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # ruby 4 | rubocop -a 5 | 6 | # js,css,yaml 7 | prettier . --write 8 | 9 | # erb 10 | npm run herb:format 11 | -------------------------------------------------------------------------------- /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/app/assets/tailwind/application.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "../../../../../app/assets/tailwind/application.css"; 3 | @source "../../../../../app"; 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/dummy/app/views/tests/form_builder_select.html.erb: -------------------------------------------------------------------------------- 1 | <%= nk_form_for @user, url: "#" do |f| %> 2 | <%= f.select :status, [["Active", "active"], ["Inactive", "inactive"]] %> 3 | <% end %> -------------------------------------------------------------------------------- /app/javascript/controllers/nk/datepicker_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus"; 2 | 3 | export default class extends Controller { 4 | static targets = ["input"]; 5 | } 6 | -------------------------------------------------------------------------------- /test/integration/navigation_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class NavigationTest < ActionDispatch::IntegrationTest 4 | # test "the truth" do 5 | # assert true 6 | # end 7 | end 8 | -------------------------------------------------------------------------------- /test/nitro_kit_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class NitroKitTest < ActiveSupport::TestCase 4 | test("it has a version number") do 5 | assert NitroKit::VERSION 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/app/views/tests/field_select.html.erb: -------------------------------------------------------------------------------- 1 | <%= nk_form_for @user, url: "#" do |f| %> 2 | <%= f.field :status, as: :select, options: [["Active", "active"], ["Inactive", "inactive"]] %> 3 | <% end %> -------------------------------------------------------------------------------- /test/dummy/app/views/tests/index.html.erb: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /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/dummy/app/javascript/application.js: -------------------------------------------------------------------------------- 1 | // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails 2 | import "@hotwired/turbo-rails" 3 | import "controllers" 4 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | 3 | APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__) 4 | load "rails/tasks/engine.rake" 5 | 6 | load "rails/tasks/statistics.rake" 7 | 8 | require "bundler/gem_tasks" 9 | -------------------------------------------------------------------------------- /test/dummy/app/views/tests/form_builder_select_with_blank.html.erb: -------------------------------------------------------------------------------- 1 | <%= nk_form_for @user, url: "#" do |f| %> 2 | <%= f.select :status, [["Active", "active"], ["Inactive", "inactive"]], { include_blank: true } %> 3 | <% end %> -------------------------------------------------------------------------------- /test/dummy/app/javascript/controllers/hello_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus" 2 | 3 | export default class extends Controller { 4 | connect() { 5 | this.element.textContent = "Hello World!" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /test/dummy/app/views/tests/form_builder_select_with_prompt.html.erb: -------------------------------------------------------------------------------- 1 | <%= nk_form_for @user, url: "#" do |f| %> 2 | <%= f.select :status, [["Active", "active"], ["Inactive", "inactive"]], { include_blank: "Choose status..." } %> 3 | <% end %> -------------------------------------------------------------------------------- /test/dummy/app/views/tests/field_select_with_prompt.html.erb: -------------------------------------------------------------------------------- 1 | <%= nk_form_for @user, url: "#" do |f| %> 2 | <%= f.field :status, as: :select, options: [["Active", "active"], ["Inactive", "inactive"]], include_blank: "Pick one..." %> 3 | <% end %> -------------------------------------------------------------------------------- /app/helpers/nitro_kit/card_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | module CardHelper 5 | def nk_card(**attrs, &block) 6 | render(Card.from_template(**attrs), &block) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/helpers/nitro_kit/tabs_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | module TabsHelper 5 | def nk_tabs(**attrs, &block) 6 | render(Tabs.from_template(**attrs), &block) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/helpers/nitro_kit/textarea_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | module TextareaHelper 5 | def nk_textarea(**attrs) 6 | render(Textarea.from_template(**attrs)) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/helpers/nitro_kit/dialog_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | module DialogHelper 5 | def nk_dialog(**attrs, &block) 6 | render(Dialog.from_template(**attrs), &block) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/helpers/nitro_kit/icon_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | module IconHelper 5 | def nk_icon(name, **attrs) 6 | render(NitroKit::Icon.from_template(name, **attrs)) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/helpers/nitro_kit/switch_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | module SwitchHelper 5 | def nk_switch(**attrs, &block) 6 | render(Switch.from_template(**attrs), &block) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/helpers/nitro_kit/table_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | module TableHelper 5 | def nk_table(**attrs, &block) 6 | render(Table.from_template(**attrs), &block) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | def back_link 3 | tag.div { nk_button_link_to("Back", tests_path) } 4 | end 5 | 6 | def box 7 | tag.div(class: "px-5 mt-5") { yield } 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/helpers/nitro_kit/tooltip_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | module TooltipHelper 5 | def nk_tooltip(**attrs, &block) 6 | render(Tooltip.from_template(**attrs), &block) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/helpers/nitro_kit/dropdown_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | module DropdownHelper 5 | def nk_dropdown(**attrs, &block) 6 | render(Dropdown.from_template(**attrs), &block) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/helpers/nitro_kit/accordion_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | module AccordionHelper 5 | def nk_accordion(**attrs, &block) 6 | render(Accordion.from_template(**attrs), &block) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/helpers/nitro_kit/datepicker_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | module DatepickerHelper 5 | def nk_datepicker(**attrs, &block) 6 | render(Datepicker.from_template(**attrs), &block) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/helpers/nitro_kit/field_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | module FieldHelper 5 | def nk_field(*args, **attrs, &block) 6 | render(NitroKit::Field.from_template(*args, **attrs), &block) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/helpers/nitro_kit/fieldset_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | module FieldsetHelper 5 | def nk_fieldset(**attrs, &block) 6 | render(NitroKit::Fieldset.from_template(**attrs), &block) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/helpers/nitro_kit/button_group_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | module ButtonGroupHelper 5 | def nk_button_group(**attrs, &block) 6 | render(ButtonGroup.from_template(**attrs), &block) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/helpers/nitro_kit/field_group_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | module FieldGroupHelper 5 | def nk_field_group(**attrs) 6 | render(NitroKit::FieldGroup.from_template(**attrs)) { yield } 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /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 | 6 | $LOAD_PATH.unshift(File.expand_path("../../../lib", __dir__)) 7 | -------------------------------------------------------------------------------- /app/components/nitro_kit/datepicker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class Datepicker < Component 5 | def view_template 6 | render(Input.new(type: "text", **attrs, data: { controller: "nk--datepicker" })) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/dummy/app/views/tests/examples/avatar.html.erb: -------------------------------------------------------------------------------- 1 | <%= back_link %> 2 | <%= nk_avatar "https://placecage.lucidinternets.com/249/249", size: :sm %> 3 | <%= nk_avatar "https://placecage.lucidinternets.com/249/249" %> 4 | <%= nk_avatar "https://placecage.lucidinternets.com/249/249", size: :lg %> 5 | -------------------------------------------------------------------------------- /app/helpers/nitro_kit/combobox_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | module ComboboxHelper 5 | def nk_combobox(name = nil, options = [], **attrs) 6 | render(NitroKit::Combobox.from_template(name:, options:, **attrs)) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/helpers/nitro_kit/alert_helper.rb: -------------------------------------------------------------------------------- 1 | module NitroKit 2 | module AlertHelper 3 | include Variants 4 | 5 | def nk_alert(**attrs, &block) 6 | render(Alert.from_template(**attrs), &block) 7 | end 8 | 9 | automatic_variants(Alert::VARIANTS, :nk_alert) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "puma" 6 | gem "sqlite3" 7 | gem "propshaft" 8 | 9 | gem "tailwindcss-rails" 10 | gem "stimulus-rails" 11 | gem "turbo-rails" 12 | gem "importmap-rails" 13 | gem "hotwire-spark" 14 | gem "rubocop-rails-omakase", require: false 15 | -------------------------------------------------------------------------------- /test/dummy/app/javascript/controllers/application.js: -------------------------------------------------------------------------------- 1 | import { Application } from "@hotwired/stimulus" 2 | 3 | const application = Application.start() 4 | 5 | // Configure Stimulus development experience 6 | application.debug = false 7 | window.Stimulus = application 8 | 9 | export { application } 10 | -------------------------------------------------------------------------------- /test/dummy/app/javascript/controllers/index.js: -------------------------------------------------------------------------------- 1 | // Import and register all your controllers from the importmap via controllers/**/*_controller 2 | import { application } from "controllers/application" 3 | import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" 4 | eagerLoadControllersFrom("controllers", application) 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | - package-ecosystem: github-actions 9 | directory: "/" 10 | schedule: 11 | interval: daily 12 | open-pull-requests-limit: 10 13 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # Omakase Ruby styling for Rails 2 | inherit_gem: { rubocop-rails-omakase: rubocop.yml } 3 | # Overwrite or add rules to create your own house style 4 | # 5 | # # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` 6 | # Layout/SpaceInsideArrayLiteralBrackets: 7 | # Enabled: false 8 | 9 | AllCops: 10 | TargetRubyVersion: 3.4 11 | -------------------------------------------------------------------------------- /test/dummy/app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User 2 | include ActiveModel::Model 3 | include ActiveModel::Attributes 4 | 5 | attribute :status, :string 6 | 7 | def self.model_name 8 | ActiveModel::Name.new(self, nil, "User") 9 | end 10 | 11 | def to_key 12 | nil 13 | end 14 | 15 | def persisted? 16 | false 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = "1.0" 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | -------------------------------------------------------------------------------- /app/helpers/nitro_kit/badge_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | module BadgeHelper 5 | include Variants 6 | 7 | automatic_variants(Badge::VARIANTS, :nk_badge) 8 | 9 | def nk_badge(text = nil, **attrs, &block) 10 | render(NitroKit::Badge.from_template(text, **attrs), &block) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /.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 | 12 | /test/dummy/app/assets/builds/* 13 | !/test/dummy//app/assets/builds/.keep 14 | Gemfile.lock 15 | .claude/settings.local.json 16 | .nvim.lua 17 | package-lock.json 18 | -------------------------------------------------------------------------------- /app/helpers/nitro_kit/avatar_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | module AvatarHelper 5 | def nk_avatar(src = nil, **attrs, &block) 6 | render(Avatar.from_template(src, **attrs), &block) 7 | end 8 | 9 | def nk_avatar_stack(**attrs, &block) 10 | render(AvatarStack.from_template(**attrs), &block) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/helpers/nitro_kit/form_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | module FormHelper 5 | def nk_form_with(**attrs, &block) 6 | form_with(**attrs, builder: NitroKit::FormBuilder, &block) 7 | end 8 | 9 | def nk_form_for(*args, **attrs, &block) 10 | form_for(*args, **attrs, builder: NitroKit::FormBuilder, &block) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "herb:format": "herb-format", 5 | "herb:check": "herb-format --check" 6 | }, 7 | "devDependencies": { 8 | "@herb-tools/formatter": "^0.8.2", 9 | "@herb-tools/language-server": "^0.8.2", 10 | "@herb-tools/linter": "^0.8.2", 11 | "prettier": "^3.6.2", 12 | "prettier-plugin-tailwindcss": "^0.7.1" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rubocop' 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 | require "rubygems" 14 | require "bundler/setup" 15 | 16 | load Gem.bin_path("rubocop", "rubocop") 17 | -------------------------------------------------------------------------------- /app/components/nitro_kit/field_group.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class FieldGroup < Component 5 | def initialize(**attrs) 6 | super(attrs, class: base_class, data: { slot: "control" }) 7 | end 8 | 9 | def view_template(&block) 10 | div(**attrs, &block) 11 | end 12 | 13 | private 14 | 15 | def base_class 16 | "space-y-6" 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/javascript/controllers/nk/dialog_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus"; 2 | 3 | export default class extends Controller { 4 | static targets = ["trigger", "dialog"]; 5 | 6 | open() { 7 | this.dialogTarget.showModal(); 8 | } 9 | 10 | close() { 11 | this.dialogTarget.close(); 12 | } 13 | 14 | clickOutside(event) { 15 | if (event.target === this.dialogTarget) { 16 | this.close(); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /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 | 5 | Dir["#{Rails.root}/../../app/helpers/nitro_kit/*_helper.rb"].each do |path| 6 | cnst = ("NitroKit::" + path.split("/").last.split(".").first.camelize).constantize 7 | helper(cnst) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/dummy/app/views/tests/examples/badge.html.erb: -------------------------------------------------------------------------------- 1 | <%= back_link %> 2 | <%= box do %> 3 | <% %i[default outline].each do |variant| %> 4 | <%= nk_badge variant.to_s.humanize, variant: %> 5 | <% end %> 6 | <% end %> 7 | <%= box do %> 8 | <% tailwind_color_names.each do |color| %> 9 | <%= nk_badge color.to_s.humanize, color: %> 10 | <% end %> 11 | <% end %> 12 | <%= box do %> 13 | <% %i[sm md].each do |size| %> 14 | <%= nk_badge size.to_s.humanize, size: %> 15 | <% end %> 16 | <% end %> 17 | -------------------------------------------------------------------------------- /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/components/nitro_kit/label.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class Label < Component 5 | def initialize(text = nil, **attrs) 6 | @text = text 7 | 8 | super( 9 | attrs, 10 | class: "text-sm font-medium select-none", 11 | data: { slot: "label" } 12 | ) 13 | end 14 | 15 | attr_reader :text 16 | 17 | def view_template(&block) 18 | label(**attrs) do 19 | text_or_block(text, &block) 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/components/nitro_kit/avatar_stack.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class AvatarStack < Component 5 | def initialize(size: :md, **attrs) 6 | @size = size 7 | 8 | super(attrs) 9 | end 10 | 11 | attr_reader :size 12 | 13 | def view_template 14 | div(**mattr(attrs, class: "flex items-center -space-x-3 [&>div]:ring-2 [&>div]:ring-background")) do 15 | yield 16 | end 17 | end 18 | 19 | def avatar(*args, **attrs, &block) 20 | render(Avatar.new(*args, size:, **attrs, &block)) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/helpers/nitro_kit/label_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | module LabelHelper 5 | def nk_label(compat_object_name = nil, compat_method = nil, content_or_options = nil, **attrs, &block) 6 | name = field_name(compat_object_name, compat_method) 7 | 8 | case content_or_options 9 | when String 10 | text = content_or_options 11 | when Hash 12 | text = nil 13 | attrs.merge!(content_or_options) 14 | end 15 | 16 | render(Label.from_template(text, for: name, **attrs), &block) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # This command will automatically be run when you run "rails" with Rails gems 3 | # installed from the root of your application. 4 | 5 | ENGINE_ROOT = File.expand_path("..", __dir__) 6 | ENGINE_PATH = File.expand_path("../lib/nitro_kit/engine", __dir__) 7 | APP_PATH = File.expand_path("../test/dummy/config/application", __dir__) 8 | 9 | # Set up gems listed in the Gemfile. 10 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 11 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 12 | 13 | require "rails/all" 14 | require "rails/engine/commands" 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, 8 | :email, 9 | :secret, 10 | :token, 11 | :_key, 12 | :crypt, 13 | :salt, 14 | :certificate, 15 | :otp, 16 | :ssn, 17 | :cvv, 18 | :cvc 19 | ] 20 | -------------------------------------------------------------------------------- /app/components/nitro_kit/input.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class Input < Component 5 | def initialize(**attrs) 6 | super( 7 | attrs, 8 | class: base_class 9 | ) 10 | end 11 | 12 | def view_template 13 | input(**attrs) 14 | end 15 | 16 | private 17 | 18 | def base_class 19 | [ 20 | "block rounded-md border bg-background border-border text-base px-3 py-2 h-10", 21 | # Focus 22 | "focus-visible:outline-none ring-ring ring-offset-2 ring-offset-background focus-visible:ring-2" 23 | ] 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/javascript/controllers/nk/accordion_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus"; 2 | 3 | export default class extends Controller { 4 | static targets = ["trigger", "content"]; 5 | 6 | toggle(event) { 7 | const trigger = event.target; 8 | const content = trigger.parentElement.querySelector( 9 | "[data-nk--accordion-target=content]", 10 | ); 11 | 12 | const isExpanded = trigger.getAttribute("aria-expanded") === "true"; 13 | 14 | // Toggle current item 15 | trigger.setAttribute("aria-expanded", (!isExpanded).toString()); 16 | content.setAttribute("aria-hidden", isExpanded.toString()); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lib/nitro_kit/variants.rb: -------------------------------------------------------------------------------- 1 | module NitroKit 2 | module Variants 3 | module ClassMethods 4 | def automatic_variants(variants, method_name) 5 | _, prefix, original = method_name.match(/(nk_)(.+)/).to_a 6 | 7 | variants.each do |variant, class_name| 8 | variant_method_name = "#{prefix}#{variant}_#{original}" 9 | 10 | define_method(variant_method_name) do |*args, **kwargs, &block| 11 | send(method_name, *args, variant:, **kwargs, &block) 12 | end 13 | end 14 | end 15 | end 16 | 17 | def self.included(base) 18 | base.extend(ClassMethods) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/helpers/nitro_kit/select_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | module SelectHelper 5 | def nk_select( 6 | compat_object_name = nil, 7 | compat_method = nil, 8 | options = nil, 9 | compat_options = {}, 10 | value: nil, 11 | include_blank: false, 12 | prompt: nil, 13 | index: nil, 14 | **attrs, 15 | &block 16 | ) 17 | name = field_name(compat_object_name, compat_method) 18 | 19 | # TODO: support index 20 | 21 | render(Select.from_template(options, value:, include_blank:, prompt:, name:, **compat_options, **attrs), &block) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /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/helpers/nitro_kit/radio_button_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | module RadioButtonHelper 5 | def nk_radio_button( 6 | compat_object_name = nil, 7 | compat_method = nil, 8 | compat_tag_value = nil, 9 | compat_options = {}, 10 | label: nil, 11 | **attrs 12 | ) 13 | name = field_name(compat_object_name, compat_method) 14 | value = compat_tag_value || attrs[:value] 15 | 16 | render(RadioButton.from_template(label:, name:, value:, **attrs)) 17 | end 18 | 19 | def nk_radio_button_group(**attrs, &block) 20 | render(RadioButtonGroup.from_template(**attrs), &block) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/components/nitro_kit/button_group.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class ButtonGroup < Component 5 | def initialize(**attrs) 6 | super( 7 | attrs, 8 | class: [ 9 | "flex -space-x-px isolate", 10 | # Remove rounded corners from middle buttons 11 | "[&>*:not(:first-child):not(:last-child)]:rounded-none [&>*:first-child:not(:last-child)]:rounded-r-none [&>*:last-child:not(:first-child)]:rounded-l-none", 12 | # Put focused button on top 13 | "[&>*]:focus:z-10" 14 | ] 15 | ) 16 | end 17 | 18 | def view_template 19 | div(**attrs) do 20 | yield 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/components/nitro_kit/textarea.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class Textarea < Component 5 | def initialize(value: nil, **attrs) 6 | @value = value 7 | 8 | super( 9 | attrs, 10 | class: default_class 11 | ) 12 | end 13 | 14 | attr_reader :value 15 | 16 | def view_template 17 | textarea(**attrs) { plain(value) } 18 | end 19 | 20 | private 21 | 22 | def default_class 23 | [ 24 | "rounded-md border bg-background border-border text-base px-3 py-2", 25 | # Focus 26 | "focus:outline-none ring-ring ring-offset-2 ring-offset-background focus-visible:ring-2" 27 | ] 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /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 | # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) 8 | # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest 9 | # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker 10 | 11 | resources(:tests) 12 | root(to: "tests#index") 13 | end 14 | -------------------------------------------------------------------------------- /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/importmap.rb: -------------------------------------------------------------------------------- 1 | # Pin npm packages by running ./bin/importmap 2 | 3 | pin "application" 4 | pin "@hotwired/turbo-rails", to: "turbo.min.js" 5 | pin "@hotwired/stimulus", to: "stimulus.min.js" 6 | pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" 7 | pin "@github/combobox-nav", to: "@github--combobox-nav.js" # @3.0.1 8 | pin "@floating-ui/core", to: "@floating-ui--core.js" # @1.6.9 9 | pin "@floating-ui/dom", to: "@floating-ui--dom.js" # @1.6.13 10 | pin "@floating-ui/utils", to: "@floating-ui--utils.js" # @0.2.9 11 | pin "@floating-ui/utils/dom", to: "@floating-ui--utils--dom.js" # @0.2.9 12 | pin_all_from "app/javascript/controllers", under: "controllers" 13 | pin_all_from "../../app/javascript/controllers", under: "controllers" 14 | -------------------------------------------------------------------------------- /app/helpers/nitro_kit/toast_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | module ToastHelper 5 | def nk_toast(**attrs, &block) 6 | render(Toast.from_template(**attrs), &block) 7 | end 8 | 9 | def nk_toast_action(title: nil, description: nil, event: nil) 10 | { 11 | action: "#{event ? "#{event}->" : ""}nk--toast#toast", 12 | nk__toast_title_param: title, 13 | nk__toast_description_param: description 14 | } 15 | end 16 | 17 | def nk_toast_flash_messages 18 | render(Toast::FlashMessages.from_template(flash)) 19 | end 20 | 21 | def nk_toast_turbo_stream_refresh 22 | turbo_stream.append("nk--toast-sink", nk_toast_flash_messages) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/helpers/nitro_kit/input_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | module InputHelper 5 | def nk_input(**attrs) 6 | render(Input.from_template(**attrs)) 7 | end 8 | 9 | %w[ 10 | color 11 | date 12 | datetime 13 | datetime_local 14 | email 15 | file 16 | hidden 17 | month 18 | number 19 | password 20 | phone 21 | range 22 | search 23 | telephone 24 | text 25 | time 26 | url 27 | week 28 | ] 29 | .each do |type| 30 | define_method("nk_#{type}_field_tag") do |compat_name = nil, compat_value = nil, **attrs| 31 | attrs[:name] ||= compat_name 32 | attrs[:value] ||= compat_value 33 | nk_input(type:, **attrs) 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /app/javascript/controllers/nk/switch_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus"; 2 | 3 | export default class extends Controller { 4 | connect() { 5 | if (!this.element.hasAttribute("tabindex")) { 6 | this.element.setAttribute("tabindex", "0"); 7 | } 8 | } 9 | 10 | click() { 11 | if (this.element.hasAttribute("disabled")) return; 12 | 13 | this.toggle(); 14 | } 15 | 16 | keydown(event) { 17 | if (this.element.hasAttribute("disabled")) return; 18 | 19 | if (event.code === "Space" || event.code === "Enter") { 20 | event.preventDefault(); 21 | this.toggle(); 22 | } 23 | } 24 | 25 | get checked() { 26 | return this.element.getAttribute("aria-checked") === "true"; 27 | } 28 | 29 | toggle() { 30 | this.element.setAttribute("aria-checked", (!this.checked).toString()); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
Nitro Kit
2 | 3 |

4 | 5 | **Nitro Kit** is a set of **generic UI components** to help you build your **Ruby on Rails** application. 6 | 7 | Rather than being fancy it is **purposefully modest**. Instead of giving you a black box, it is provided as a bunch of generators that give you a starting point to build upon. 8 | 9 | Easy to customize, accessible, Rails native. 10 | 11 | _Nitro Kit is still pre 1.0 and is in active development._ 12 | 13 | See [nitrokit.dev](https://nitrokit.dev). 14 | 15 | [![Rubygems](https://img.shields.io/gem/v/nitro_kit.svg)](https://rubygems.org/gems/nitro_kit) 16 | 17 | --- 18 | 19 | ### Development 20 | 21 | ```sh 22 | bin/setup 23 | bin/dev 24 | ``` 25 | 26 | ### License 27 | 28 | MIT 29 | -------------------------------------------------------------------------------- /app/components/nitro_kit/icon.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class Icon < Component 5 | register_output_helper :lucide_icon 6 | 7 | def initialize(name, size: :md, **attrs) 8 | @name = name 9 | @size = size 10 | 11 | super( 12 | attrs, 13 | class: size_class, 14 | stroke_width: 1.5 15 | ) 16 | end 17 | 18 | attr_reader :name, :size 19 | 20 | def view_template 21 | lucide_icon(name, **dasherized_attrs) 22 | end 23 | 24 | private 25 | 26 | def size_class 27 | case size 28 | when :xs 29 | "size-3" 30 | when :sm 31 | "size-4" 32 | when :md 33 | "size-5" 34 | when :lg 35 | "size-7" 36 | else 37 | raise ArgumentError, "Unknown size `#{size}'" 38 | end 39 | end 40 | 41 | def dasherized_attrs 42 | attrs.transform_keys { |k| k.to_s.dasherize } 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/dummy/app/views/tests/examples/radio_button.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <%= nk_radio_button name: "basic", label: "Unchecked" %> 3 | <%= nk_radio_button name: "basic", label: "Checked", checked: true %> 4 |
5 |
6 | <%= nk_radio_button name: "basic", size: :lg, label: "Unchecked" %> 7 | <%= nk_radio_button name: "basic", size: :lg, label: "Checked", checked: true %> 8 |
9 |
10 | <%= nk_radio_button_group options: [["Hi", 1], ["There", 2]], value: 2 %> 11 |
12 |
13 | <%= nk_form_for :golem do |f| %> 14 | <%= f.field :element, 15 | options: [%w[Earth earth], %w[Fire fire], %w[Water water], %w[Air air]], 16 | value: "fire", 17 | as: :radio_group, 18 | label: "Golem type", 19 | description: "He'll be invincible" %> 20 | <% end %> 21 |
22 | -------------------------------------------------------------------------------- /test/dummy/app/views/tests/examples/dropdown.html.erb: -------------------------------------------------------------------------------- 1 | <%= back_link %> 2 | 3 |

Simplest

4 | <%= nk_dropdown do |d| %> 5 | <%= d.trigger { "simple" } %> 6 | <%= d.content { "hi" } %> 7 | <% end %> 8 | 9 |

With `as:`

10 | <%= nk_dropdown do |d| %> 11 | <%= d.trigger(as: :button, class: "border border-green-500 p-1 inline-flex shrink-0 rounded-full cursor-pointer") do %> 12 | <%= nk_avatar("https://placecage.lucidinternets.com/249/249", size: :sm) %> 13 | <% end %> 14 | <%= d.content { "hi" } %> 15 | <% end %> 16 | 17 |
18 |

Inside overflow table

19 | <%= nk_table(class: "border border-red-500") do |t| %> 20 | <%= t.body do %> 21 | <%= t.tr do %> 22 | <%= t.td do %> 23 | <%= nk_dropdown do |d| %> 24 | <%= d.trigger(size: :xs) { "simple" } %> 25 | <%= d.content { "oh no!" } %> 26 | <% end %> 27 | <% end %> 28 | <% end %> 29 | <% end %> 30 | <% end %> 31 |
32 | -------------------------------------------------------------------------------- /app/components/nitro_kit/checkbox_group.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class CheckboxGroup < Component 5 | def initialize(options = nil, **attrs) 6 | @options = options 7 | 8 | super( 9 | attrs, 10 | class: "flex items-start flex-col gap-2" 11 | ) 12 | end 13 | 14 | attr_reader :options 15 | 16 | def view_template 17 | div(**attrs) do 18 | if block_given? 19 | yield 20 | else 21 | options.map { |option| item(*option) } 22 | end 23 | end 24 | end 25 | 26 | def title(text = nil, **attrs, &block) 27 | builder do 28 | render(Label.new(**attrs)) do 29 | text_or_block(text, &block) 30 | end 31 | end 32 | end 33 | 34 | def item(text = nil, **attrs, &block) 35 | builder do 36 | render(Checkbox.new(**attrs)) do 37 | text_or_block(text, &block) 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= content_for(:title) || "Dummy" %> 5 | 6 | 7 | 8 | <%= csrf_meta_tags %> 9 | <%= csp_meta_tag %> 10 | 11 | <%= yield :head %> 12 | 13 | <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %> 14 | <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> 15 | 16 | 17 | 18 | 19 | 20 | <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> 21 | <%= javascript_importmap_tags %> 22 | 23 | 24 | 25 |
26 | <%= yield %> 27 |
28 | 29 | 30 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /nitro_kit.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/nitro_kit/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "nitro_kit" 5 | 6 | spec.version = NitroKit::VERSION 7 | spec.authors = [ "Mikkel Malmberg" ] 8 | spec.email = [ "mikkel@brnbw.com" ] 9 | spec.homepage = "https://github.com/mikker/nitro_kit" 10 | spec.summary = "WIP, not usable yet" 11 | spec.description = "WIP, not usable yet" 12 | spec.license = "MIT" 13 | 14 | spec.metadata["homepage_uri"] = spec.homepage 15 | spec.metadata["source_code_uri"] = "https://github.com/mikker/nitro_kit" 16 | spec.metadata["changelog_uri"] = "https://github.com/mikker/nitro_kit/releases" 17 | 18 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 19 | Dir[ 20 | "app/{helpers,components,javascript}/**/*", 21 | "lib/**/*", 22 | "MIT-LICENSE", 23 | "Rakefile", 24 | "README.md" 25 | ] 26 | end 27 | 28 | spec.add_dependency("rails", ">= 7.0.0") 29 | spec.add_dependency("tailwind_merge", ">= 0.13.0") 30 | spec.add_dependency("phlex-rails", ">= 2.1.0") 31 | end 32 | -------------------------------------------------------------------------------- /app/components/nitro_kit/avatar.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class Avatar < Component 5 | include Phlex::Rails::Helpers::ImageTag 6 | 7 | def initialize(src_arg = nil, src: nil, size: :md, **attrs) 8 | @src = src_arg || src 9 | @size = size 10 | 11 | super( 12 | attrs, 13 | class: [ container_class, size_classes ] 14 | ) 15 | end 16 | 17 | attr_reader :src, :size 18 | 19 | def view_template(&block) 20 | div(**attrs) do 21 | image 22 | end 23 | end 24 | 25 | def image 26 | image_tag(src, class: image_class) 27 | end 28 | 29 | private 30 | 31 | def size_classes 32 | case size 33 | when :sm 34 | "size-8" 35 | when :md 36 | "size-12" 37 | when :lg 38 | "size-16" 39 | else 40 | raise ArgumentError, "Invalid size: #{size}" 41 | end 42 | end 43 | 44 | def container_class 45 | "inline-flex overflow-hidden rounded-full" 46 | end 47 | 48 | def image_class 49 | "block size-full bg-muted" 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | ruby: 16 | - "3.0" 17 | - "3.1" 18 | - "3.2" 19 | - "3.3" 20 | - "3.4" 21 | 22 | steps: 23 | - name: Install packages 24 | run: sudo apt-get update && sudo apt-get install --no-install-recommends -y build-essential git pkg-config google-chrome-stable 25 | 26 | - name: Checkout code 27 | uses: actions/checkout@v6 28 | 29 | - name: Set up Ruby 30 | uses: ruby/setup-ruby@v1 31 | with: 32 | ruby-version: ruby-3.4.1 33 | bundler-cache: true 34 | 35 | - name: Run tests 36 | env: 37 | RAILS_ENV: test 38 | run: bin/rails test 39 | 40 | - name: Keep screenshots from failed system tests 41 | uses: actions/upload-artifact@v6 42 | if: failure() 43 | with: 44 | name: screenshots 45 | path: ${{ github.workspace }}/tmp/screenshots 46 | if-no-files-found: ignore 47 | -------------------------------------------------------------------------------- /app/javascript/controllers/nk/tabs_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus"; 2 | 3 | export default class extends Controller { 4 | static targets = ["tab", "panel"]; 5 | static values = { active: String }; 6 | 7 | setActiveTab(event) { 8 | this.activeValue = event.params.key; 9 | } 10 | 11 | prevTab(event) { 12 | const prevTab = event.target.previousElementSibling; 13 | if (prevTab) { 14 | prevTab.click(); 15 | prevTab.focus(); 16 | } 17 | } 18 | 19 | nextTab(event) { 20 | const nextTab = event.target.nextElementSibling; 21 | if (nextTab) { 22 | nextTab.click(); 23 | nextTab.focus(); 24 | } 25 | } 26 | 27 | activeValueChanged() { 28 | const value = this.activeValue; 29 | 30 | this.panelTargets.forEach((panel) => { 31 | if (panel.dataset.key === value) { 32 | panel.ariaHidden = false; 33 | } else { 34 | panel.ariaHidden = true; 35 | } 36 | }); 37 | 38 | this.tabTargets.forEach((tab) => { 39 | if (tab.dataset.key === value) { 40 | tab.ariaSelected = true; 41 | } else { 42 | tab.ariaSelected = false; 43 | } 44 | }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /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/helpers/nitro_kit/pagination_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pagy/toolbox/helpers/support/series" 4 | 5 | module NitroKit 6 | module PaginationHelper 7 | 8 | def nk_pagination(**attrs, &block) 9 | render(Pagination.from_template(**attrs), &block) 10 | end 11 | 12 | def nk_pagy_nav(pagy, id: nil, aria_label: nil, **attrs) 13 | attrs[:aria] ||= { label: aria_label } 14 | 15 | nk_pagination(id:, **attrs) do |p| 16 | if prev_page = pagy.previous 17 | p.prev(href: pagy.page_url(prev_page)) 18 | else 19 | p.prev(disabled: true) 20 | end 21 | 22 | pagy.send(:series).each do |item| 23 | case item 24 | when Integer 25 | p.page(item.to_s, href: pagy.page_url(item)) 26 | when String 27 | p.page(item, current: true) 28 | when :gap 29 | p.ellipsis 30 | else 31 | raise ArgumentError, "Unknown item type: #{item.class}" 32 | end 33 | end 34 | 35 | if next_page = pagy.next 36 | p.next(href: pagy.page_url(next_page)) 37 | else 38 | p.next(disabled: true) 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | # For compatibility with applications that use this config 14 | config.action_controller.include_all_helpers = false 15 | 16 | # Please, add to the `ignore` list any other `lib` subdirectories that do 17 | # not contain `.rb` files, or that should not be reloaded or eager loaded. 18 | # Common ones are `templates`, `generators`, or `middleware`, for example. 19 | config.autoload_lib(ignore: %w[assets tasks]) 20 | 21 | # Configuration for the application, engines, and railties goes here. 22 | # 23 | # These settings can be overridden in specific environments using the files 24 | # in config/environments, which are processed later. 25 | # 26 | # config.time_zone = "Central Time (US & Canada)" 27 | 28 | config.assets.paths << Rails.root.join("../../app/javascript") 29 | 30 | config.hotwire.spark.html_paths << Rails.root.join("../../app/components") 31 | config.hotwire.spark.stimulus_paths << Rails.root.join("../../app/javascript") 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /app/components/nitro_kit/fieldset.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class Fieldset < Component 5 | def initialize(legend: nil, description: nil, **attrs) 6 | @legend = legend 7 | @description = description 8 | super( 9 | attrs, 10 | class: base_class 11 | ) 12 | end 13 | 14 | def view_template 15 | fieldset(**attrs) do 16 | legend(@legend) if @legend 17 | description(@description) if @description 18 | 19 | yield 20 | end 21 | end 22 | 23 | alias :html_legend :legend 24 | 25 | def legend(text = nil, **attrs, &block) 26 | builder do 27 | html_legend(**mattr(attrs, class: legend_class)) do 28 | text_or_block(text, &block) 29 | end 30 | end 31 | end 32 | 33 | def description(text = nil, **attrs, &block) 34 | builder do 35 | div(**mattr(attrs, class: description_class, data: { slot: "text" })) do 36 | text_or_block(text, &block) 37 | end 38 | end 39 | end 40 | 41 | private 42 | 43 | def base_class 44 | [ 45 | "[&>*+[data-slot=control]]:mt-6 [&>*+[data-slot=text]]:mt-1", 46 | "[&+&]:mt-8" 47 | ] 48 | end 49 | 50 | def legend_class 51 | "text-lg font-semibold" 52 | end 53 | 54 | def description_class 55 | "text-sm text-muted-content" 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /app/components/nitro_kit/tooltip.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class Tooltip < Component 5 | def initialize(content: nil, placement: nil, **attrs) 6 | @string_content = content 7 | @placement = placement 8 | 9 | super( 10 | attrs, 11 | data: { 12 | action: "mouseover->nk--tooltip#open mouseout->nk--tooltip#close", 13 | controller: "nk--tooltip", 14 | nk__tooltip_placement_value: placement 15 | } 16 | ) 17 | end 18 | 19 | attr_reader :placement, :string_content 20 | 21 | def view_template 22 | span(**attrs) do 23 | content(string_content) if string_content 24 | yield 25 | end 26 | end 27 | 28 | def content(text = nil, **attrs, &block) 29 | builder do 30 | div( 31 | **mattr( 32 | attrs, 33 | class: tooltip_class, 34 | data: { 35 | state: "closed", 36 | nk__tooltip_target: "content" 37 | } 38 | ) 39 | ) do 40 | text_or_block(text, &block) 41 | end 42 | end 43 | end 44 | 45 | private 46 | 47 | def tooltip_class 48 | [ 49 | "absolute z-50 overflow-hidden w-fit max-w-sm", 50 | "px-3 py-1.5 text-sm bg-background rounded-md border shadow-sm", 51 | "data-[state=closed]:hidden" 52 | ] 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /app/components/nitro_kit/radio_button_group.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class RadioButtonGroup < Component 5 | def initialize(options_arg = nil, options: [], name: nil, value: nil, **attrs) 6 | @options = options_arg || options 7 | 8 | @name = name 9 | @group_value = value 10 | 11 | super( 12 | attrs, 13 | name: @name, 14 | class: "flex items-start flex-col gap-2" 15 | ) 16 | end 17 | 18 | attr_reader :name, :group_value, :options 19 | 20 | def view_template 21 | div(**attrs) do 22 | if block_given? 23 | yield 24 | else 25 | options.map { |o| item(*o) } 26 | end 27 | end 28 | end 29 | 30 | def title(text = nil, **attrs, &block) 31 | builder do 32 | render(Label.new(**attrs)) do 33 | text_or_block(text, &block) 34 | end 35 | end 36 | end 37 | 38 | def item(text = nil, value_as_arg = nil, value: nil, **attrs, &block) 39 | builder do 40 | value ||= value_as_arg 41 | 42 | render( 43 | RadioButton.new( 44 | **mattr( 45 | attrs, 46 | name: attrs.fetch(:name, name), 47 | value:, 48 | checked: group_value.presence == value 49 | ) 50 | ) 51 | ) do 52 | text_or_block(text, &block) 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /app/javascript/controllers/nk/tooltip_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus"; 2 | import { 3 | computePosition, 4 | offset, 5 | flip, 6 | shift, 7 | autoUpdate, 8 | } from "@floating-ui/dom"; 9 | 10 | export default class extends Controller { 11 | static targets = ["content"]; 12 | static values = { 13 | open: { type: Boolean, default: false }, 14 | // Options for floating-ui 15 | placement: { type: String, default: "top" }, 16 | }; 17 | 18 | connect() { 19 | this.updatePosition(); 20 | } 21 | 22 | disconnect() { 23 | this.close(); 24 | } 25 | 26 | open() { 27 | this.openValue = true; 28 | } 29 | 30 | close() { 31 | this.openValue = false; 32 | } 33 | 34 | openValueChanged(state, _previous) { 35 | this.contentTarget.dataset.state = state ? "open" : "closed"; 36 | 37 | if (state) { 38 | this.clearAutoUpdate = autoUpdate( 39 | this.element, 40 | this.contentTarget, 41 | this.updatePosition, 42 | ); 43 | } else { 44 | if (this.clearAutoUpdate) { 45 | this.clearAutoUpdate(); 46 | } 47 | } 48 | } 49 | 50 | updatePosition = () => { 51 | computePosition(this.element, this.contentTarget, { 52 | placement: this.placementValue, 53 | middleware: [offset(5), flip(), shift({ padding: 5 })], 54 | }).then(({ x, y }) => { 55 | this.contentTarget.style.left = `${x}px`; 56 | this.contentTarget.style.top = `${y}px`; 57 | }); 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /app/helpers/nitro_kit/checkbox_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | module CheckboxHelper 5 | # Make API compatible with Rails' checkbox but allow empty arguments 6 | def nk_checkbox( 7 | compat_object_name = nil, 8 | compat_method = nil, 9 | compat_options = {}, 10 | compat_checked_value = "1", 11 | compat_unchecked_value = "0", 12 | label: nil, 13 | **attrs 14 | ) 15 | name = field_name(compat_object_name, compat_method) 16 | checked = compat_options["checked"] || attrs[:checked] 17 | 18 | # TODO: multiple, unchecked hidden field 19 | 20 | render(Checkbox.from_template(name:, label:, value: compat_checked_value, checked:, **attrs)) 21 | end 22 | 23 | def nk_checkbox_tag(name, *args) 24 | if args.length >= 4 25 | raise ArgumentError, "wrong number of arguments (given #{args.length + 1}, expected 1..4)" 26 | end 27 | 28 | options = args.extract_options! 29 | value, checked = args.empty? ? [ "1", false ] : [ *args, false ] 30 | attrs = { 31 | type: "checkbox", 32 | name: name, 33 | id: sanitize_to_id(name), 34 | value: value 35 | }.update( 36 | options.symbolize_keys 37 | ) 38 | 39 | attrs[:checked] = "checked" if checked 40 | 41 | render(Checkbox.from_template(name:, **attrs)) 42 | end 43 | 44 | alias :nk_check_box_tag :nk_checkbox_tag 45 | 46 | def nk_checkbox_group(**attrs, &block) 47 | render(CheckboxGroup.from_template(**attrs), &block) 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/tests_controller.rb: -------------------------------------------------------------------------------- 1 | class TestsController < ApplicationController 2 | helper_method :tailwind_color_names 3 | 4 | def index 5 | @tests = Dir["#{Rails.root}/app/views/tests/examples/**/*.html.erb"].map do |path| 6 | File.basename(path).split(".").first 7 | end 8 | end 9 | 10 | def show 11 | template_name = params[:id] 12 | 13 | # Check if it's an example template first 14 | example_path = "tests/examples/#{template_name}" 15 | if template_exists?(example_path) 16 | render(example_path) 17 | return 18 | end 19 | 20 | # Handle test templates with setup 21 | test_path = "tests/#{template_name}" 22 | if template_exists?(test_path) 23 | setup_test_data(template_name) 24 | render(template: test_path) 25 | return 26 | end 27 | 28 | # Fallback to 404 if template doesn't exist 29 | raise ActionController::RoutingError, "Template not found: #{template_name}" 30 | end 31 | 32 | private 33 | 34 | def setup_test_data(template_name) 35 | # Setup data based on template name patterns 36 | case template_name 37 | when /select/, /form_builder/ 38 | @user = User.new(status: "active") 39 | end 40 | end 41 | 42 | def tailwind_color_names 43 | %i[ 44 | gray 45 | red 46 | orange 47 | amber 48 | yellow 49 | lime 50 | green 51 | emerald 52 | teal 53 | cyan 54 | sky 55 | blue 56 | indigo 57 | violet 58 | purple 59 | fuchsia 60 | pink 61 | rose 62 | ] 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /app/helpers/nitro_kit/button_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | module ButtonHelper 5 | include Variants 6 | 7 | def nk_button(text = nil, **attrs, &block) 8 | render(NitroKit::Button.from_template(text, **attrs), &block) 9 | end 10 | 11 | automatic_variants(Button::VARIANTS, :nk_button) 12 | 13 | # Matches the API of UrlHelper#button_to 14 | def nk_button_to(name = nil, url_for_options = nil, **attrs, &block) 15 | url_for_options = name if block_given? 16 | 17 | form_options = attrs.delete(:form) || {} 18 | form_options.merge!(attrs.slice(:multipart, :data, :method, :authenticity_token, :remote, :enforce_utf8)) 19 | 20 | form_tag(url_for_options, form_options) do 21 | nk_button(name, type: "submit", **attrs, &block) 22 | end 23 | end 24 | 25 | automatic_variants(Button::VARIANTS, :nk_button_to) 26 | 27 | # Matches the API of UrlHelper#link_to 28 | def nk_button_link_to(*args, **attrs, &block) 29 | case args.length 30 | when 1 31 | options, text = args 32 | when 2 33 | text, options = args 34 | else 35 | raise ArgumentError, "1..2 arguments expected, got #{args.length}" 36 | end 37 | 38 | href = attrs[:href] || url_target(text, options) 39 | 40 | render(NitroKit::Button.from_template(text, **attrs, href:), &block) 41 | end 42 | 43 | automatic_variants(Button::VARIANTS, :nk_button_link_to) 44 | 45 | def nk_button_group(**attrs, &block) 46 | render(ButtonGroup.from_template(**attrs), &block) 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # NitroKit License (v1.0) 2 | 3 | © 2025 Brainbow / Mikkel Malmberg 4 | 5 | ## Grant of Rights 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to use, modify, and share the Software (including in commercial products), subject to the conditions below. 8 | 9 | ## Conditions 10 | 11 | ### 1. Commercial Usage Restrictions 12 | 13 | - You may use the Software for commercial purposes, including within commercial products or services. 14 | - You may not sell the Software as a standalone product. 15 | - You may not sell a product whose primary value derives from the Software with minimal modifications or additions (a "Repackaged Product"). 16 | 17 | ### 2. Attribution Requirements 18 | 19 | - All copies or substantial portions of the Software must include this license and copyright notice. 20 | - If the Software is used in a user-facing application, attribution in documentation or about section is sufficient. 21 | 22 | ### 3. Disclaimer of Warranty 23 | 24 | The Software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and noninfringement. In no event shall the authors or copyright holders be liable for any claim, damages, or other liability, whether in an action of contract, tort or otherwise, arising from, out of, or in connection with the Software or the use or other dealings in the Software. 25 | 26 | ## Termination 27 | 28 | Your rights under this license will terminate automatically if you fail to comply with any of its terms. 29 | -------------------------------------------------------------------------------- /app/assets/tailwind/application.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | @theme { 4 | --color-foreground: var(--color-zinc-900); 5 | --color-background: var(--color-white); 6 | 7 | --color-border: var(--color-zinc-200); 8 | --color-ring: var(--color-blue-600); 9 | --color-muted: var(--color-zinc-100); 10 | --color-muted-content: var(--color-zinc-500); 11 | --color-primary: var(--color-zinc-800); 12 | --color-primary-content: var(--zinc-800); 13 | --color-primary-foreground: var(--color-zinc-100); 14 | --color-destructive: var(--color-red-500); 15 | --color-destructive-content: var(--color-red-800); 16 | --color-destructive-foreground: var(--color-white); 17 | } 18 | 19 | @layer base { 20 | [data-theme="dark"] { 21 | --color-foreground: var(--color-zinc-100); 22 | --color-background: var(--color-zinc-950); 23 | 24 | --color-border: var(--color-zinc-700); 25 | --color-ring: var(--color-blue-700); 26 | --color-muted: var(--color-zinc-800); 27 | --color-muted-content: var(--color-zinc-400); 28 | --color-primary: var(--color-zinc-50); 29 | --color-primary-content: var(--color-zinc-50); 30 | --color-primary-foreground: var(--color-zinc-900); 31 | --color-destructive: var(--color-red-900); 32 | --color-destructive-content: var(--color-red-600); 33 | --color-destructive-foreground: var(--color-white); 34 | } 35 | } 36 | 37 | @variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); 38 | 39 | @layer base { 40 | * { 41 | @apply border-border ring-ring min-w-0; 42 | } 43 | 44 | body { 45 | /* Nitro Kit looks great in Inter as well */ 46 | /* https://rsms.me/inter */ 47 | @apply bg-background text-foreground font-sans; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /lib/nitro_kit.rb: -------------------------------------------------------------------------------- 1 | require "tailwind_merge" 2 | require "phlex/rails" 3 | 4 | require "nitro_kit/version" 5 | require "nitro_kit/engine" 6 | require "nitro_kit/variants" 7 | require "nitro_kit/schema_builder" 8 | 9 | module NitroKit 10 | extend SchemaBuilder 11 | 12 | SCHEMA = build_schema do |s| 13 | s.add(:accordion, js: [ :accordion ]) 14 | s.add(:alert) 15 | s.add(:avatar) 16 | s.add(:badge) 17 | s.add(:button, [ :icon ], components: [ :button, :button_group ], helpers: [ :button, :button_group ]) 18 | s.add(:card) 19 | s.add(:checkbox, [ :label ], components: [ :checkbox, :checkbox_group ]) 20 | s.add( 21 | :combobox, 22 | [ :input ], 23 | js: [ :combobox ], 24 | modules: [ "@floating-ui/core", "@floating-ui/dom", "@github/combobox-nav" ] 25 | ) 26 | s.add(:datepicker) 27 | s.add(:dialog, [ :button, :icon ], js: [ :dialog ]) 28 | s.add(:dropdown, [ :button ], js: [ :dropdown ], modules: [ "@floating-ui/core", "@floating-ui/dom" ]) 29 | s.add(:field, [ :label, :checkbox, :combobox, :label, :radio_button, :select, :switch, :textarea ]) 30 | s.add(:field_group) 31 | s.add(:fieldset, [ :field_group ]) 32 | s.add(:form_builder, [ :field ], helpers: [ :form ]) 33 | s.add(:icon, gems: [ "lucide-rails" ]) 34 | s.add(:input) 35 | s.add(:label) 36 | s.add(:pagination, [ :icon, :button ]) 37 | s.add(:radio_button, [ :label ], components: [ :radio_button, :radio_button_group ]) 38 | s.add(:select) 39 | s.add(:switch, js: [ :switch ]) 40 | s.add(:table) 41 | s.add(:tabs, js: [ :tabs ]) 42 | s.add(:textarea) 43 | s.add(:toast, js: [ :toast ]) 44 | s.add(:tooltip, js: [ :tooltip ], modules: [ "@floating-ui/core", "@floating-ui/dom" ]) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /app/javascript/controllers/nk/toast_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus"; 2 | 3 | export default class extends Controller { 4 | static targets = ["list", "template", "sink"]; 5 | static values = { 6 | duration: { type: Number, default: 5000 }, 7 | }; 8 | 9 | connect() { 10 | if (this.hasSinkTarget) { 11 | this.mutationObserver = new MutationObserver(([event]) => { 12 | if (event.addedNodes.length === 0) return; 13 | this.#flushSink(); 14 | }); 15 | this.mutationObserver.observe(this.sinkTarget, { childList: true }); 16 | } 17 | 18 | this.#flushSink(); 19 | } 20 | 21 | disconnect() { 22 | if (this.mutationObserver) this.mutationObserver.disconnect(); 23 | } 24 | 25 | toast({ params }) { 26 | const { title, description } = params; 27 | const item = this.templateTarget.content.cloneNode(true); 28 | 29 | item.querySelector("[data-slot=title]").textContent = title; 30 | item.querySelector("[data-slot=description]").textContent = description; 31 | 32 | this.show(item); 33 | } 34 | 35 | show(item) { 36 | this.clear(); 37 | this.listTarget.appendChild(item); 38 | 39 | requestAnimationFrame(() => { 40 | this.listTarget.children[0].dataset.state = "open"; 41 | }); 42 | 43 | if (this.timer) clearTimeout(this.timer); 44 | 45 | this.timer = setTimeout(() => { 46 | this.hide(); 47 | }, this.durationValue); 48 | } 49 | 50 | hide() { 51 | this.listTarget.children[0].dataset.state = "closed"; 52 | 53 | setTimeout(() => { 54 | this.clear(); 55 | }, 250); 56 | } 57 | 58 | clear() { 59 | this.listTarget.innerHTML = ""; 60 | } 61 | 62 | #flushSink() { 63 | if (!this.hasSinkTarget) return; 64 | 65 | for (const li of this.sinkTarget.children) { 66 | this.show(li.cloneNode(true)); 67 | li.remove(); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.8.0 4 | 5 | ### Changed 6 | 7 | - Reformat as Rails Omakase rubocop style 8 | - Update Avatar component rendering for improved consistency 9 | - Add formatting tools and minor cleanup improvements 10 | 11 | ### Fixed 12 | 13 | - Fix Select component empty rendering issue 14 | - Add missing `select` method to FormBuilder 15 | - Fix toast controller initialization timing issues 16 | - Add Table wrapper parameter 17 | - Remove default whitespace-nowrap from table cells 18 | 19 | ## 0.7.0 20 | 21 | ### Changed 22 | 23 | - Migrate from `builder_method` to `from_template` pattern 24 | - Update Input focus styling to use `focus-visible` for better accessibility 25 | - Improve `text_or_block` method with SafeBuffer handling 26 | - Remove I18n dependency from FormBuilder 27 | 28 | ### Added 29 | 30 | - XL size option for Button component 31 | - `radio_button` method to FormBuilder 32 | - `nk_avatar_stack` helper and component 33 | - Pass options to Combobox in `combobox` method 34 | - Support for rendering fields as custom component classes 35 | 36 | ### Fixed 37 | 38 | - Remove duplicate id from field checkbox hidden field 39 | - Fix checkbox field and textarea rendering 40 | - Add spacing between consecutive fieldsets 41 | - Improve dropdown functionality 42 | 43 | ## 0.6.0 44 | 45 | NB: Nitro Kit has been re-licensed to disallow reselling as is. It is still free to use in your projects you just can't sell copies of it. 46 | 47 | ### Breaking changes 48 | 49 | - Update color definitions and add some more. **You'll need to update the color definitions in your CSS file.** See [Getting Started](https://nitrokit.dev/getting_started) 50 | 51 | ### Changed 52 | 53 | - Update `phlex` and `phlex-rails` dependencies 54 | - Increase button[size=sm] size 55 | 56 | ### Added 57 | 58 | - Badge colors! 59 | - Support for `as:` prop in Dialog 60 | - `align:` prop for table cells 61 | 62 | ## 0.5.2 63 | 64 | Start of this document 65 | -------------------------------------------------------------------------------- /app/components/nitro_kit/card.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class Card < Component 5 | def initialize(**attrs) 6 | super( 7 | attrs, 8 | class: base_class 9 | ) 10 | end 11 | 12 | def view_template 13 | div(**attrs) do 14 | yield 15 | end 16 | end 17 | 18 | def title(text = nil, **attrs, &block) 19 | builder do 20 | h2(**mattr(attrs, class: "text-lg font-bold -mb-2")) do 21 | text_or_block(text, &block) 22 | end 23 | end 24 | end 25 | 26 | def body(text = nil, **attrs, &block) 27 | builder do 28 | div(**mattr(attrs, class: "text-muted-content text-sm leading-relaxed")) do 29 | text_or_block(text, &block) 30 | end 31 | end 32 | end 33 | 34 | def footer(text = nil, **attrs, &block) 35 | builder do 36 | div(**mattr(attrs, class: "flex gap-2 items-center")) do 37 | text_or_block(text, &block) 38 | end 39 | end 40 | end 41 | 42 | def divider(**attrs) 43 | builder do 44 | full_width do 45 | hr(**attrs) 46 | end 47 | end 48 | end 49 | 50 | def full_width(**attrs) 51 | builder do 52 | div(**mattr(attrs, data: { slot: "full" }, class: "-mx-(--gap)")) do 53 | yield 54 | end 55 | end 56 | end 57 | 58 | private 59 | 60 | def base_class 61 | [ 62 | # Configure spacing with breakpoints 63 | "[--gap:calc(var(--spacing)*4)] sm:[--gap:calc(var(--spacing)*6)]", 64 | # Base styles 65 | "flex flex-col items-stretch rounded-lg bg-background border p-(--gap) gap-(--gap) overflow-hidden", 66 | # If a `data-slot=full` is the first thing, move it to the top 67 | "[&>[data-slot=full]:first-child]:-mt-(--gap)", 68 | # Group for hover, focus 69 | "group/card" 70 | ] 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /app/components/nitro_kit/alert.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class Alert < Component 5 | VARIANTS = %i[default warning error success] 6 | 7 | def initialize(variant: :default, **attrs) 8 | @variant = variant 9 | 10 | super( 11 | attrs, 12 | role: "alert", 13 | class: [ base_class, variant_class ] 14 | ) 15 | end 16 | 17 | attr_reader :variant 18 | 19 | def view_template 20 | div(**attrs) do 21 | yield 22 | end 23 | end 24 | 25 | def title(text = nil, **attrs, &block) 26 | builder do 27 | h5(**mattr(attrs, class: title_class)) do 28 | text_or_block(text, &block) 29 | end 30 | end 31 | end 32 | 33 | def description(text = nil, **attrs, &block) 34 | builder do 35 | div(**mattr(attrs, class: description_class)) do 36 | text_or_block(text, &block) 37 | end 38 | end 39 | end 40 | 41 | private 42 | 43 | def base_class 44 | [ 45 | "relative border w-full rounded-md p-5 text-sm space-y-2", 46 | "[&>svg~*]:pl-8 [&>svg]:absolute [&>svg]:top-5 [&>svg]:left-5" 47 | ] 48 | end 49 | 50 | def variant_class 51 | case variant 52 | when :default 53 | "border-border bg-background text-foreground" 54 | when :warning 55 | "bg-yellow-300/20 dark:bg-yellow-300/20 text-yellow-900 dark:text-yellow-100 border-yellow-500/80 dark:border-yellow-400/50" 56 | when :success 57 | "bg-green-300/20 dark:bg-green-300/20 text-green-900 dark:text-green-100 border-green-500/80 dark:border-green-400/50" 58 | when :error 59 | "bg-red-300/20 dark:bg-red-300/20 text-red-900 dark:text-red-100 border-red-400/80 dark:border-red-400/50" 60 | else 61 | raise ArgumentError, "Invalid variant: #{variant}" 62 | end 63 | end 64 | 65 | def title_class 66 | "font-medium text-lg leading-5" 67 | end 68 | 69 | def description_class 70 | "" 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /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 | 40 | plugin(:tailwindcss) if ENV.fetch("RAILS_ENV", "development") == "development" 41 | -------------------------------------------------------------------------------- /app/javascript/controllers/nk/dropdown_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus"; 2 | import { 3 | computePosition, 4 | offset, 5 | flip, 6 | shift, 7 | autoUpdate, 8 | } from "@floating-ui/dom"; 9 | 10 | export default class extends Controller { 11 | static targets = ["trigger", "content"]; 12 | static values = { 13 | placement: { type: String, default: "bottom" }, 14 | }; 15 | 16 | connect() { 17 | this.updatePosition(); 18 | } 19 | 20 | disconnect() { 21 | this.close(); 22 | } 23 | 24 | get isExpanded() { 25 | return this.triggerTarget.getAttribute("aria-expanded") === "true"; 26 | } 27 | 28 | updatePosition = () => { 29 | computePosition(this.triggerTarget, this.contentTarget, { 30 | placement: this.placementValue, 31 | middleware: [offset(5), flip(), shift({ padding: 5 })], 32 | }).then(({ x, y }) => { 33 | this.contentTarget.style.left = `${x}px`; 34 | this.contentTarget.style.top = `${y}px`; 35 | }); 36 | }; 37 | 38 | open = () => { 39 | this.triggerTarget.setAttribute("aria-expanded", "true"); 40 | this.contentTarget.setAttribute("aria-hidden", "false"); 41 | 42 | document.addEventListener("click", this.clickOutside); 43 | 44 | this.clearAutoUpdate = autoUpdate( 45 | this.triggerTarget, 46 | this.contentTarget, 47 | this.updatePosition, 48 | ); 49 | }; 50 | 51 | close = () => { 52 | this.triggerTarget.setAttribute("aria-expanded", "false"); 53 | this.contentTarget.setAttribute("aria-hidden", "true"); 54 | 55 | document.removeEventListener("click", this.clickOutside); 56 | 57 | if (this.clearAutoUpdate) { 58 | this.clearAutoUpdate(); 59 | } 60 | }; 61 | 62 | toggle = () => { 63 | if (this.isExpanded) { 64 | this.close(); 65 | } else { 66 | this.open(); 67 | } 68 | }; 69 | 70 | clickOutside = (event) => { 71 | if ( 72 | !this.contentTarget.contains(event.target) && 73 | !this.triggerTarget.contains(event.target) 74 | ) { 75 | this.close(); 76 | } 77 | }; 78 | } 79 | -------------------------------------------------------------------------------- /app/components/nitro_kit/radio_button.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class RadioButton < Component 5 | def initialize(label: nil, id: nil, wrapper: {}, size: :md, **attrs) 6 | @label = label 7 | @id = id || "nk--" + SecureRandom.hex(4) 8 | @size = size 9 | @wrapper = wrapper 10 | 11 | super( 12 | attrs, 13 | id: @id, 14 | type: "radio", 15 | class: input_class 16 | ) 17 | end 18 | 19 | alias :html_label :label 20 | 21 | attr_reader :id, :label, :size, :wrapper 22 | 23 | def view_template 24 | div(**mattr(wrapper, class: wrapper_class)) do 25 | html_label(class: merge_class("inline-grid *:[grid-area:1/1] shrink-0 place-items-center", size_class)) do 26 | input(**attrs) 27 | dot 28 | end 29 | 30 | if label.present? || block_given? 31 | render(Label.new(for: id)) do 32 | label || (block_given? ? yield : nil) 33 | end 34 | end 35 | end 36 | end 37 | 38 | private 39 | 40 | def dot 41 | svg( 42 | class: merge_class( 43 | "text-primary opacity-0 pointer-events-none", 44 | "peer-checked:opacity-100" 45 | ), 46 | viewbox: "0 0 20 20", 47 | fill: "currentColor", 48 | stroke: "none" 49 | ) do |svg| 50 | svg.circle(cx: 10, cy: 10, r: 10) 51 | end 52 | end 53 | 54 | def wrapper_class 55 | "inline-flex items-center gap-2" 56 | end 57 | 58 | def input_class 59 | [ 60 | "peer appearance-none shadow-sm rounded-full border text-foreground bg-background", 61 | "[&[aria-checked='true']]:bg-primary", 62 | "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" 63 | ] 64 | end 65 | 66 | def size_class 67 | case size 68 | when :md 69 | "[&>input]:size-5 [&>svg]:size-2.5" 70 | when :lg 71 | "[&>input]:size-7 [&>svg]:size-3.5" 72 | else 73 | raise ArgumentError, "Unknown size `#{size}'" 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /app/components/nitro_kit/table.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class Table < Component 5 | def initialize(wrapper: {}, **attrs) 6 | super(attrs) 7 | @wrapper = wrapper 8 | end 9 | 10 | attr_reader :wrapper 11 | 12 | def view_template 13 | div(**mattr(wrapper, class: "w-full overflow-x-scroll")) do 14 | table( 15 | **mattr( 16 | attrs, 17 | class: "w-full caption-bottom text-sm divide-y" 18 | ) 19 | ) do 20 | yield 21 | end 22 | end 23 | end 24 | 25 | alias :html_thead :thead 26 | alias :html_tbody :tbody 27 | alias :html_tr :tr 28 | alias :html_th :th 29 | alias :html_td :td 30 | 31 | def thead(**attrs) 32 | builder do 33 | html_thead(**attrs) { yield } 34 | end 35 | end 36 | 37 | def tbody(**attrs) 38 | builder do 39 | html_tbody(**mattr(attrs, class: "[&_tr:last-child]:border-0")) { yield } 40 | end 41 | end 42 | 43 | def tr(**attrs) 44 | builder do 45 | html_tr(**mattr(attrs, class: "border-b")) { yield } 46 | end 47 | end 48 | 49 | def th(text = nil, align: :left, **attrs, &block) 50 | builder do 51 | html_th(**mattr(attrs, class: [ header_cell_classes, cell_classes, align_classes(align), "font-medium" ])) do 52 | text_or_block(text, &block) 53 | end 54 | end 55 | end 56 | 57 | def td(text = nil, align: nil, **attrs, &block) 58 | builder do 59 | html_td(**mattr(attrs, class: [ cell_classes, align_classes(align) ])) do 60 | text_or_block(text, &block) 61 | end 62 | end 63 | end 64 | 65 | private 66 | 67 | def header_cell_classes 68 | "" 69 | end 70 | 71 | def cell_classes 72 | "py-2 min-h-10 px-2" 73 | end 74 | 75 | def align_classes(align = nil) 76 | case align 77 | when :left 78 | "text-left" 79 | when :center 80 | "text-center" 81 | when :right 82 | "text-right" 83 | else 84 | nil 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /app/components/nitro_kit/switch.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class Switch < Component 5 | def initialize( 6 | checked: false, 7 | size: :md, 8 | description: nil, 9 | **attrs 10 | ) 11 | @checked = checked 12 | @size = size 13 | @description = description 14 | 15 | super(**attrs) 16 | end 17 | 18 | attr_reader :checked, :description, :size 19 | 20 | def view_template 21 | button( 22 | **mattr( 23 | **attrs, 24 | type: "button", 25 | class: [ base_class, size_class ], 26 | data: { controller: "nk--switch", action: "nk--switch#toggle" }, 27 | role: "switch", 28 | aria: { checked: checked.to_s } 29 | ) 30 | ) do 31 | span(class: "sr-only") { description } 32 | handle 33 | end 34 | end 35 | 36 | private 37 | 38 | def handle 39 | span(aria: { hidden: true }, class: handle_class, data: { slot: "handle" }) 40 | end 41 | 42 | def base_class 43 | [ 44 | "inline-flex items-center shrink-0", 45 | "bg-background rounded-full border", 46 | "transition-colors duration-200 ease-in-out", 47 | "focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ring-offset-background", 48 | 49 | # Checked 50 | "[&[aria-checked=true]]:bg-foreground [&[aria-checked=true]]:border-foreground", 51 | 52 | # Checked > Handle 53 | "[&[aria-checked=false]_[data-slot=handle]]:bg-primary", 54 | "[&[aria-checked=true]_[data-slot=handle]]:bg-background" 55 | ] 56 | end 57 | 58 | def handle_class 59 | [ 60 | "pointer-events-none inline-block rounded-full shadow-sm ring-0", 61 | "transition translate-x-[3px] duration-200 ease-in-out" 62 | ] 63 | end 64 | 65 | def size_class 66 | case size 67 | when :md 68 | "h-6 w-10 [&_[data-slot=handle]]:size-4 [&[aria-checked=true]_[data-slot=handle]]:translate-x-[calc(theme(spacing.5)-1px)]" 69 | when :sm 70 | "h-5 w-8 [&_[data-slot=handle]]:size-3 [&[aria-checked=true]_[data-slot=handle]]:translate-x-[calc(theme(spacing.4)-1px)]" 71 | else 72 | raise ArgumentError, "Unknown size: #{size}" 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /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/nitro_kit/component_generator.rb: -------------------------------------------------------------------------------- 1 | module NitroKit 2 | class ComponentGenerator < Rails::Generators::Base 3 | argument :component_names, type: :array 4 | 5 | source_root File.expand_path("../../../", __dir__).tap { |path| puts path } 6 | 7 | def copy_base_component 8 | copy_file("app/components/nitro_kit/component.rb", "app/components/nitro_kit/component.rb") 9 | end 10 | 11 | def copy_component_files 12 | components.map(&:all_files).flatten.uniq.each do |path| 13 | copy_file(path, path) 14 | end 15 | end 16 | 17 | def add_gems 18 | gems = components.flat_map(&:all_gems) 19 | 20 | return unless gems.any? 21 | 22 | gems.each do |name| 23 | gem(name) unless has_gem?(name) 24 | end 25 | 26 | run("bundle install") 27 | end 28 | 29 | def install_modules 30 | modules = components.flat_map(&:all_modules).uniq 31 | 32 | return unless modules.any? 33 | 34 | case js_strategy 35 | when :importmaps 36 | run("bin/importmap pin #{modules.join(" ")}") 37 | when :yarn 38 | run("yarn add #{modules.join(" ")}") 39 | when :npm 40 | run("npm install --save #{modules.join(" ")}") 41 | when :bun 42 | run("bun add #{modules.join(" ")}") 43 | else 44 | say("Could not determine JS strategy. Please install one of: npm, yarn, bun, or importmaps") 45 | end 46 | end 47 | 48 | private 49 | 50 | def components 51 | return @components if @components 52 | 53 | if component_names == [ "all" ] 54 | return @components = SCHEMA.all 55 | end 56 | 57 | # Component names + their dependencies 58 | @components = component_names 59 | .flat_map do |name| 60 | component = SCHEMA.find(name) 61 | [ component ] + component.dependencies 62 | end 63 | end 64 | 65 | def js_strategy 66 | if File.exist?(File.expand_path("bin/importmap", Rails.root)) 67 | :importmaps 68 | elsif File.exist?(File.expand_path("yarn.lock", Rails.root)) 69 | :yarn 70 | elsif File.exist?(File.expand_path("package-lock.json", Rails.root)) 71 | :npm 72 | elsif File.exist?(File.expand_path("bun.lockb", Rails.root)) 73 | :bun 74 | else 75 | nil 76 | end 77 | end 78 | 79 | def has_gem?(name) 80 | gemfile = File.read(File.expand_path("Gemfile", Rails.root)) 81 | gemfile.include?("gem '#{name}'") || gemfile.include?("gem \"#{name}\"") 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /test/integration/select_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class SelectTest < ActionDispatch::IntegrationTest 4 | test("form builder select matches Rails API") do 5 | get test_path("form_builder_select") 6 | assert_response :success 7 | 8 | # Check that select element is rendered 9 | assert_select "select[name='user[status]']" 10 | 11 | # Check options are rendered correctly 12 | assert_select "option[value='active']", text: "Active" 13 | assert_select "option[value='inactive']", text: "Inactive" 14 | 15 | # Check selected value 16 | assert_select "option[value='active'][selected]" 17 | end 18 | 19 | test("select with include_blank") do 20 | get test_path("form_builder_select_with_blank") 21 | assert_response :success 22 | 23 | # Check blank option is first 24 | assert_select "option:first-child[value='']" 25 | # blank + 2 options 26 | assert_select "select option", count: 3 27 | end 28 | 29 | test("select with prompt") do 30 | get test_path("form_builder_select_with_prompt") 31 | assert_response :success 32 | 33 | # Check prompt option 34 | assert_select "option[value='']", text: "Choose status..." 35 | end 36 | 37 | test("field with as: :select") do 38 | get test_path("field_select") 39 | assert_response :success 40 | 41 | # Check that select element is rendered 42 | assert_select "select[name='user[status]']" 43 | 44 | # Check options are rendered correctly 45 | assert_select "option[value='active']", text: "Active" 46 | assert_select "option[value='inactive']", text: "Inactive" 47 | 48 | # Check selected value 49 | assert_select "option[value='active'][selected]" 50 | end 51 | 52 | test("field select with prompt") do 53 | get test_path("field_select_with_prompt") 54 | assert_response :success 55 | 56 | # Check prompt option 57 | assert_select "option[value='']", text: "Pick one..." 58 | end 59 | 60 | test("nk_select helper") do 61 | get test_path("nk_select_helper") 62 | assert_response :success 63 | 64 | # Check that select element is rendered with correct name 65 | assert_select "select[name='journey[direction]']" 66 | 67 | # Check options are rendered correctly 68 | assert_select "option[value='North']", text: "North" 69 | assert_select "option[value='East']", text: "East" 70 | assert_select "option[value='South']", text: "South" 71 | assert_select "option[value='West']", text: "West" 72 | 73 | # Check selected value 74 | assert_select "option[value='South'][selected]" 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/dummy/vendor/javascript/@floating-ui--utils.js: -------------------------------------------------------------------------------- 1 | // @floating-ui/utils@0.2.9 downloaded from https://ga.jspm.io/npm:@floating-ui/utils@0.2.9/dist/floating-ui.utils.mjs 2 | 3 | const t=["top","right","bottom","left"];const e=["start","end"];const n=t.reduce(((t,n)=>t.concat(n,n+"-"+e[0],n+"-"+e[1])),[]);const i=Math.min;const o=Math.max;const g=Math.round;const c=Math.floor;const createCoords=t=>({x:t,y:t});const s={left:"right",right:"left",bottom:"top",top:"bottom"};const r={start:"end",end:"start"};function clamp(t,e,n){return o(t,i(e,n))}function evaluate(t,e){return typeof t==="function"?t(e):t}function getSide(t){return t.split("-")[0]}function getAlignment(t){return t.split("-")[1]}function getOppositeAxis(t){return t==="x"?"y":"x"}function getAxisLength(t){return t==="y"?"height":"width"}function getSideAxis(t){return["top","bottom"].includes(getSide(t))?"y":"x"}function getAlignmentAxis(t){return getOppositeAxis(getSideAxis(t))}function getAlignmentSides(t,e,n){n===void 0&&(n=false);const i=getAlignment(t);const o=getAlignmentAxis(t);const g=getAxisLength(o);let c=o==="x"?i===(n?"end":"start")?"right":"left":i==="start"?"bottom":"top";e.reference[g]>e.floating[g]&&(c=getOppositePlacement(c));return[c,getOppositePlacement(c)]}function getExpandedPlacements(t){const e=getOppositePlacement(t);return[getOppositeAlignmentPlacement(t),e,getOppositeAlignmentPlacement(e)]}function getOppositeAlignmentPlacement(t){return t.replace(/start|end/g,(t=>r[t]))}function getSideList(t,e,n){const i=["left","right"];const o=["right","left"];const g=["top","bottom"];const c=["bottom","top"];switch(t){case"top":case"bottom":return n?e?o:i:e?i:o;case"left":case"right":return e?g:c;default:return[]}}function getOppositeAxisPlacements(t,e,n,i){const o=getAlignment(t);let g=getSideList(getSide(t),n==="start",i);if(o){g=g.map((t=>t+"-"+o));e&&(g=g.concat(g.map(getOppositeAlignmentPlacement)))}return g}function getOppositePlacement(t){return t.replace(/left|right|bottom|top/g,(t=>s[t]))}function expandPaddingObject(t){return{top:0,right:0,bottom:0,left:0,...t}}function getPaddingObject(t){return typeof t!=="number"?expandPaddingObject(t):{top:t,right:t,bottom:t,left:t}}function rectToClientRect(t){const{x:e,y:n,width:i,height:o}=t;return{width:i,height:o,top:n,left:e,right:e+i,bottom:n+o,x:e,y:n}}export{e as alignments,clamp,createCoords,evaluate,expandPaddingObject,c as floor,getAlignment,getAlignmentAxis,getAlignmentSides,getAxisLength,getExpandedPlacements,getOppositeAlignmentPlacement,getOppositeAxis,getOppositeAxisPlacements,getOppositePlacement,getPaddingObject,getSide,getSideAxis,o as max,i as min,n as placements,rectToClientRect,g as round,t as sides}; 4 | 5 | -------------------------------------------------------------------------------- /app/components/nitro_kit/component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class Component < Phlex::HTML 5 | def initialize(attrs = {}, **defaults) 6 | @attrs = merge_attrs(attrs, **defaults) 7 | end 8 | 9 | attr_reader :attrs 10 | 11 | def self.from_template(*args, **attrs, &block) 12 | new(*args, **attrs, &block).tap { |instance| instance.instance_variable_set(:@_nk_from_template, true) } 13 | end 14 | 15 | def builder(&block) 16 | @_nk_from_template && !self.is_a?(NitroKit::Component) ? capture(&block) : yield 17 | end 18 | 19 | private 20 | 21 | # Class-level helper method for builder style components. 22 | # When called from erb or templates, we need to wrap the return value 23 | # in capture so we don't write to the output buffer immediately. 24 | # However when called from other components, we don't. 25 | def self.builder_method(method_name) 26 | warn( 27 | "[DEPRECATION] builder_method is deprecated. Please migrate to using the builder(&) pattern. See https://github.com/mikker/nitro_kit/issues/35 for details." 28 | ) 29 | 30 | nil 31 | end 32 | 33 | # Merge attributes with some special cases for matching keys 34 | def merge_attrs(*hashes, **defaults) 35 | defaults 36 | .merge(*hashes) do |key, old_value, new_value| 37 | case key 38 | when :class 39 | # Use TailwindMerge to merge class names 40 | merge_class(old_value, new_value) 41 | when :data 42 | # Merge data hashes with some special cases for Stimulus 43 | merge_data(old_value, new_value) 44 | else 45 | new_value 46 | end 47 | end 48 | end 49 | 50 | alias :mattr :merge_attrs 51 | 52 | def merge_class(*args) 53 | @@merger ||= TailwindMerge::Merger.new 54 | @@merger.merge(args) 55 | end 56 | 57 | def merge_data(*hashes) 58 | hashes 59 | .compact 60 | .reduce({}) do |acc, hash| 61 | acc.deep_merge(hash) do |key, old_val, new_val| 62 | # Concat Stimulus actions 63 | case key 64 | when :action, :controller 65 | [ new_val, old_val ].compact.join(" ") 66 | else 67 | new_val 68 | end 69 | end 70 | end 71 | end 72 | 73 | def text_or_block(text = nil, &block) 74 | if text && text.is_a?(ActiveSupport::SafeBuffer) 75 | plain(text) 76 | elsif text 77 | text 78 | elsif block_given? 79 | yield 80 | else 81 | nil 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /app/components/nitro_kit/checkbox.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class Checkbox < Component 5 | def initialize(label: nil, id: nil, wrapper: {}, **attrs) 6 | @id = id || "nk--" + SecureRandom.hex(4) 7 | @label = label 8 | @wrapper = wrapper 9 | 10 | super( 11 | attrs, 12 | id: @id, 13 | type: "checkbox", 14 | class: input_class 15 | ) 16 | end 17 | 18 | alias :html_label :label 19 | 20 | attr_reader :label, :id, :wrapper 21 | 22 | def view_template 23 | div(**mattr(wrapper, class: wrapper_class)) do 24 | html_label( 25 | class: "inline-grid *:[grid-area:1/1] shrink-0 place-items-center group/checkbox has-checked:not-has-indeterminate:[&>[data-check]]:visible has-indeterminate:[&>[data-indeterminate]]:visible" 26 | ) do 27 | input(**attrs) 28 | checkmark 29 | dash 30 | end 31 | 32 | if label.present? || block_given? 33 | render(Label.new(for: id)) do 34 | label || (block_given? ? yield : nil) 35 | end 36 | end 37 | end 38 | end 39 | 40 | private 41 | 42 | def checkmark 43 | svg( 44 | class: merge_class(svg_class, "invisible"), 45 | viewbox: "0 0 16 16", 46 | fill: "none", 47 | stroke: "currentColor", 48 | stroke_linecap: "round", 49 | stroke_linejoin: "round", 50 | stroke_width: 3, 51 | data: { check: "" } 52 | ) do |svg| 53 | svg.path(d: "M 3 8 L 6 12 L 13 5") 54 | end 55 | end 56 | 57 | def dash 58 | svg( 59 | class: merge_class(svg_class, "invisible"), 60 | viewbox: "0 0 16 16", 61 | fill: "none", 62 | stroke: "currentColor", 63 | stroke_linecap: "round", 64 | stroke_width: 3, 65 | data: { indeterminate: "" } 66 | ) do |svg| 67 | svg.line(x1: "3", y1: "8", x2: "13", y2: "8") 68 | end 69 | end 70 | 71 | def input_class 72 | [ 73 | "appearance-none shadow-sm size-4 rounded-sm border text-foreground", 74 | "checked:bg-primary checked:border-primary indeterminate:bg-primary indeterminate:border-primary", 75 | "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ring-offset-2 ring-offset-background", 76 | "disabled:pointer-events-none" 77 | ] 78 | end 79 | 80 | def svg_class 81 | "size-3 text-zinc-50 dark:text-zinc-950 pointer-events-none invisible" 82 | end 83 | 84 | def wrapper_class 85 | [ 86 | "isolate inline-flex items-center gap-2", 87 | "has-disabled:opacity-70" 88 | ] 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/components/nitro_kit/tabs.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class Tabs < Component 5 | def initialize(default: nil, **attrs) 6 | @default = default 7 | @id = attrs[:id] || SecureRandom.hex(6) 8 | 9 | super( 10 | attrs, 11 | data: { controller: "nk--tabs", nk__tabs_active_value: default }, 12 | class: base_class 13 | ) 14 | end 15 | 16 | attr_reader :default, :id 17 | 18 | def view_template 19 | div(**attrs) do 20 | yield 21 | end 22 | end 23 | 24 | def tabs(**attrs) 25 | builder do 26 | div(**mattr, role: "tabtabs", class: tabs_class) do 27 | yield 28 | end 29 | end 30 | end 31 | 32 | def tab(key, text = nil, **attrs, &block) 33 | builder do 34 | button( 35 | **mattr( 36 | attrs, 37 | aria: { 38 | selected: (default == key).to_s, 39 | controls: tab_id(key, :panel) 40 | }, 41 | class: tab_class, 42 | data: { 43 | action: "nk--tabs#setActiveTab keydown.left->nk--tabs#prevTab keydown.right->nk--tabs#nextTab", 44 | key:, 45 | nk__tabs_key_param: key, 46 | nk__tabs_target: "tab" 47 | }, 48 | id: tab_id(key, :tab), 49 | role: "tab", 50 | tabindex: default == key ? 0 : -1 51 | ) 52 | ) do 53 | text_or_block(text, &block) 54 | end 55 | end 56 | end 57 | 58 | def panel(key, **attrs) 59 | builder do 60 | div( 61 | **mattr( 62 | attrs, 63 | aria: { 64 | hidden: (default != key).to_s, 65 | labelledby: tab_id(key, :tab) 66 | }, 67 | class: panel_class, 68 | data: { 69 | key:, 70 | nk__tabs_target: "panel" 71 | }, 72 | id: tab_id(key, :panel), 73 | name: key, 74 | role: "tabpanel" 75 | ) 76 | ) do 77 | yield 78 | end 79 | end 80 | end 81 | 82 | private 83 | 84 | def tab_id(key, suffix) 85 | "#{id}-#{key}-#{suffix}" 86 | end 87 | 88 | def base_class 89 | "w-full" 90 | end 91 | 92 | def tabs_class 93 | "flex gap-4 border-b h-10" 94 | end 95 | 96 | def tab_class 97 | "border-b-2 border-transparent hover:border-primary focus-visible:border-primary cursor-pointer text-muted-content aria-[selected=true]:text-foreground font-medium aria-[selected=true]:border-primary -mb-px px-2" 98 | end 99 | 100 | def panel_class 101 | "aria-[hidden=true]:hidden py-4" 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /app/components/nitro_kit/accordion.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class Accordion < Component 5 | def initialize(**attrs) 6 | super( 7 | attrs, 8 | class: item_class, 9 | data: { controller: "nk--accordion" } 10 | ) 11 | end 12 | 13 | def view_template 14 | div(**attrs) do 15 | yield 16 | end 17 | end 18 | 19 | def item(**attrs) 20 | builder do 21 | div(**attrs) do 22 | yield 23 | end 24 | end 25 | end 26 | 27 | def trigger(text = nil, **attrs) 28 | builder do 29 | button( 30 | **mattr( 31 | attrs, 32 | type: "button", 33 | class: trigger_class, 34 | data: { 35 | action: "nk--accordion#toggle", 36 | nk__accordion_target: "trigger" 37 | }, 38 | aria: { expanded: "false" } 39 | ) 40 | ) do 41 | block_given? ? yield : plain(text) 42 | chevron_icon 43 | end 44 | end 45 | end 46 | 47 | def content(**attrs) 48 | builder do 49 | div( 50 | **mattr( 51 | attrs, 52 | class: content_class, 53 | data: { 54 | nk__accordion_target: "content" 55 | }, 56 | aria: { hidden: "true" } 57 | ) 58 | ) do 59 | div(class: "pb-4") { yield } 60 | end 61 | end 62 | end 63 | 64 | private 65 | 66 | def item_class 67 | "divide-y" 68 | end 69 | 70 | def trigger_class 71 | [ 72 | "flex w-full items-center justify-between py-4 font-medium cursor-pointer", 73 | "group/accordion-trigger hover:underline transition-colors", 74 | "[&[aria-expanded='true']>svg]:rotate-180", 75 | "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" 76 | ] 77 | end 78 | 79 | def content_class 80 | [ 81 | "overflow-hidden transition-all duration-200", 82 | "[&[aria-hidden='true']]:h-0 [&[aria-hidden='false']]:h-auto" 83 | ] 84 | end 85 | 86 | def arrow_class 87 | "transition-transform duration-200 text-muted-content group-hover/accordion-trigger:text-primary" 88 | end 89 | 90 | def chevron_icon 91 | svg( 92 | class: "transition-transform duration-200 size-4 self-center place-self-end mr-2 pointer-events-none text-muted-content group-hover/accordion-trigger:text-primary", 93 | viewbox: "0 0 24 24", 94 | fill: "none", 95 | stroke: "currentColor", 96 | stroke_width: 1 97 | ) do |svg| 98 | svg.path(d: "m6 9 6 6 6-6") 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /app/components/nitro_kit/pagination.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class Pagination < Component 5 | def initialize(**attrs) 6 | super( 7 | attrs, 8 | class: merge_class(nav_class, attrs[:class]), 9 | aria: { label: "Pagination" } 10 | ) 11 | end 12 | 13 | def view_template 14 | nav(**attrs) do 15 | yield 16 | end 17 | end 18 | 19 | def prev(text = nil, **attrs, &block) 20 | builder do 21 | page_link(**mattr(attrs, aria: { label: "Previous page" })) do 22 | if text || block_given? 23 | text_or_block(text, &block) 24 | else 25 | render(Icon.new("arrow-left")) 26 | plain("Previous") 27 | end 28 | end 29 | end 30 | end 31 | 32 | def next(text = nil, **attrs, &block) 33 | builder do 34 | page_link(**mattr(attrs, aria: { label: "Next page" })) do 35 | if text || block_given? 36 | text_or_block(text, &block) 37 | else 38 | plain("Next") 39 | render(Icon.new("arrow-right")) 40 | end 41 | end 42 | end 43 | end 44 | 45 | def page(text = nil, current: false, **attrs, &block) 46 | builder do 47 | page_link( 48 | **mattr( 49 | attrs, 50 | aria: { 51 | current: current ? "page" : nil 52 | }, 53 | disabled: current, 54 | class: [ page_class, current && "bg-zinc-200/50 dark:bg-zinc-800/50" ] 55 | ) 56 | ) do 57 | text_or_block(text, &block) 58 | end 59 | end 60 | end 61 | 62 | def ellipsis(**attrs) 63 | builder do 64 | render( 65 | Button.new( 66 | **mattr( 67 | attrs, 68 | variant: :ghost, 69 | disabled: true, 70 | class: page_class 71 | ) 72 | ) 73 | ) do 74 | "…" 75 | end 76 | end 77 | end 78 | 79 | private 80 | 81 | def page_link(text = nil, disabled: nil, **attrs) 82 | a( 83 | **mattr( 84 | attrs, 85 | role: "link", 86 | aria: { disabled: disabled.to_s }, 87 | class: link_class 88 | ) 89 | ) do 90 | yield 91 | end 92 | end 93 | 94 | def nav_class 95 | "flex items-center justify-center gap-1 text-sm font-medium" 96 | end 97 | 98 | def link_class 99 | "inline-flex items-center justify-center rounded-md border font-medium h-9 px-3 gap-2 border-transparent aria-disabled:text-muted-content [&>svg]:size-4" 100 | end 101 | 102 | def page_class 103 | "w-9 px-0" 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /app/components/nitro_kit/select.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class Select < Component 5 | def initialize(options = nil, value: nil, include_empty: false, prompt: nil, index: nil, **attrs) 6 | @options = options 7 | @value = value&.to_s 8 | @include_empty = include_empty 9 | @prompt = prompt 10 | @index = index 11 | 12 | super( 13 | attrs, 14 | class: wrapper_class 15 | ) 16 | end 17 | 18 | attr_reader :value, :options, :include_empty, :prompts, :index 19 | 20 | def view_template 21 | span(class: wrapper_class, data: { slot: "control" }) do 22 | select(**attrs, class: select_class) do 23 | if include_empty 24 | blank_text = if include_empty.is_a?(String) 25 | include_empty 26 | elsif @prompt 27 | @prompt 28 | else 29 | "" 30 | end 31 | 32 | html_option(value: "", selected: @value == "") { blank_text } 33 | end 34 | 35 | if options 36 | options.each do |opt| 37 | if opt.is_a?(Array) && opt.length >= 2 38 | html_option(value: opt[1], selected: @value == opt[1].to_s) { opt[0] } 39 | else 40 | # Handle simple strings - use as both label and value 41 | html_option(value: opt.to_s, selected: @value == opt.to_s) { opt.to_s } 42 | end 43 | end 44 | else 45 | yield if block_given? 46 | end 47 | end 48 | 49 | chevron_icon 50 | end 51 | end 52 | 53 | alias :html_option :option 54 | 55 | def option(text = nil, value = nil, **attrs, &block) 56 | builder do 57 | value ||= text 58 | 59 | html_option(value:, selected: @value == value.to_s, **attrs) do 60 | if block_given? 61 | yield 62 | else 63 | text 64 | end 65 | end 66 | end 67 | end 68 | 69 | private 70 | 71 | def wrapper_class 72 | "w-fit inline-grid *:[grid-area:1/1] group/select" 73 | end 74 | 75 | def select_class 76 | [ 77 | "appearance-none bg-background text-foreground rounded-md border px-3 py-2 pr-10 w-full", 78 | # Focus 79 | "focus:outline-none ring-ring ring-offset-2 ring-offset-background focus-visible:ring-2" 80 | ] 81 | end 82 | 83 | def chevron_icon 84 | svg( 85 | class: "size-4 self-center place-self-end mr-1.5 text-muted-content pointer-events-none group-hover/select:text-foreground", 86 | viewbox: "0 0 24 24", 87 | fill: "none", 88 | stroke: "currentColor", 89 | stroke_width: 1 90 | ) do |svg| 91 | svg.path(d: "m7 15 5 5 5-5") 92 | svg.path(d: "m7 9 5-5 5 5") 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /app/components/nitro_kit/dialog.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class Dialog < Component 5 | def initialize(identifier: nil, **attrs) 6 | @identifier = identifier || SecureRandom.hex(6) 7 | 8 | super( 9 | attrs, 10 | data: { controller: "nk--dialog", action: "click->nk--dialog#clickOutside" } 11 | ) 12 | end 13 | 14 | attr_reader :identifier 15 | 16 | def view_template 17 | div(**attrs) do 18 | yield 19 | end 20 | end 21 | 22 | def trigger(text = nil, as: Button, **attrs, &block) 23 | builder do 24 | trigger_attrs = mattr( 25 | attrs, 26 | data: { 27 | nk__dialog_target: "trigger", 28 | action: "click->nk--dialog#open" 29 | } 30 | ) 31 | 32 | case as 33 | when Symbol 34 | send(as, **trigger_attrs) do 35 | text_or_block(text, &block) 36 | end 37 | else 38 | render(as.new(**trigger_attrs)) do 39 | text_or_block(text, &block) 40 | end 41 | end 42 | end 43 | end 44 | 45 | alias :html_dialog :dialog 46 | 47 | def dialog(**attrs) 48 | builder do 49 | html_dialog( 50 | **mattr( 51 | attrs, 52 | class: dialog_class, 53 | data: { nk__dialog_target: "dialog" }, 54 | aria: { 55 | labelledby: id(:title), 56 | describedby: id(:description) 57 | } 58 | ) 59 | ) do 60 | yield 61 | end 62 | end 63 | end 64 | 65 | def close_button(**attrs) 66 | builder do 67 | render( 68 | Button.new( 69 | **mattr( 70 | attrs, 71 | variant: :ghost, 72 | size: :sm, 73 | class: "absolute top-2 right-2", 74 | data: { action: "nk--dialog#close" } 75 | ) 76 | ) 77 | ) do 78 | render(Icon.new(:x)) 79 | end 80 | end 81 | end 82 | 83 | def title(text = nil, **attrs, &block) 84 | builder do 85 | h2(**mattr(attrs, id: id(:title), class: "text-lg font-semibold mb-2")) do 86 | text_or_block(text, &block) 87 | end 88 | end 89 | end 90 | 91 | def description(text = nil, **attrs, &block) 92 | builder do 93 | div( 94 | **mattr( 95 | attrs, 96 | id: id(:description), 97 | class: "text-muted-content mb-6 text-sm leading-relaxed" 98 | ) 99 | ) do 100 | text_or_block(text, &block) 101 | end 102 | end 103 | end 104 | 105 | private 106 | 107 | def id(suffix = nil) 108 | "nk-#{identifier}#{suffix ? "-#{suffix}" : ""}" 109 | end 110 | 111 | def dialog_class 112 | [ 113 | "border rounded-xl max-w-lg w-full bg-background text-foreground shadow-lg m-auto p-6", 114 | "dark:backdrop:bg-black/50" 115 | ] 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /app/components/nitro_kit/form_builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class FormBuilder < ActionView::Helpers::FormBuilder 5 | # Fields 6 | 7 | def fieldset(**attrs, &block) 8 | @template.render(NitroKit::Fieldset.new(**attrs), &block) 9 | end 10 | 11 | def field(field_name, label: nil, errors: nil, **attrs, &block) 12 | if label.nil? 13 | label = attrs.fetch(:label, field_name.to_s.humanize) 14 | end 15 | 16 | if errors.nil? 17 | errors = object && object.respond_to?(:errors) && object.errors.include?(field_name) ? object 18 | .errors 19 | .full_messages_for(field_name) : nil 20 | end 21 | 22 | @template.render(NitroKit::Field.new(self, field_name, label:, errors:, **attrs), &block) 23 | end 24 | 25 | def group(**attrs, &block) 26 | @template.render(FieldGroup.new(**attrs), &block) 27 | end 28 | 29 | # Input types 30 | 31 | %i[ 32 | color_field 33 | date_field 34 | datetime_field 35 | datetime_local_field 36 | email_field 37 | file_field 38 | hidden_field 39 | month_field 40 | number_field 41 | password_field 42 | phone_field 43 | radio_button 44 | range_field 45 | search_field 46 | telephone_field 47 | text_area 48 | text_field 49 | time_field 50 | url_field 51 | week_field 52 | ] 53 | .each do |method| 54 | define_method(method) do |*args, **attrs, &block| 55 | type = method.to_s.gsub(/_field$/, "") 56 | field(*args, **attrs, type:, label: false, &block) 57 | end 58 | end 59 | 60 | def radio_button(method, value = "1", **attrs) 61 | field(method, as: :radio_button, label: false, value:, **attrs) 62 | end 63 | 64 | def checkbox(method, checked_value = "1", unchecked_value = "0", *args, include_hidden: true, **attrs) 65 | if include_hidden 66 | @template.concat(hidden_field(method, value: unchecked_value)) 67 | end 68 | 69 | field(method, *args, as: :checkbox, label: false, value: checked_value, **attrs) 70 | end 71 | 72 | # Buttons 73 | 74 | def submit(value = nil, **attrs, &block) 75 | if value.nil? && !block_given? 76 | value = "Save changes" 77 | end 78 | 79 | @template.render(NitroKit::Button.new(value, variant: :primary, type: :submit, **attrs), &block) 80 | end 81 | 82 | def button(value = nil, **attrs, &block) 83 | if value.nil? && !block_given? 84 | value = "Save changes" 85 | end 86 | 87 | @template.render(NitroKit::Button.new(value, **attrs), &block) 88 | end 89 | 90 | def select(method, choices = nil, options = {}, html_options = {}, &block) 91 | field_options = { 92 | as: :select, 93 | options: choices, 94 | include_blank: options[:include_blank], 95 | prompt: options[:prompt] 96 | }.compact 97 | 98 | field_attributes = options.except(:include_blank, :prompt, :selected) 99 | 100 | field(method, **field_options, **field_attributes, **html_options, &block) 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/nitro_kit/schema_builder.rb: -------------------------------------------------------------------------------- 1 | module NitroKit 2 | module SchemaBuilder 3 | class Component 4 | def initialize(schema, name, dependencies:, files:, modules:, gems:) 5 | @schema = schema 6 | @name = name 7 | @unresolved_dependencies = dependencies 8 | @files = files 9 | @modules = modules 10 | @gems = gems 11 | @resolved = false 12 | end 13 | 14 | attr_reader :name, :files, :modules, :gems, :unresolved_dependencies 15 | 16 | def dependencies 17 | raise "Component not resolved" unless resolved? 18 | @dependencies 19 | end 20 | 21 | def resolve! 22 | raise "Component already resolved" if resolved? 23 | 24 | @dependencies = @unresolved_dependencies 25 | .each_with_object(Set.new) do |name, list| 26 | list.add(name) 27 | list.merge(@schema.find(name).unresolved_dependencies) 28 | end 29 | .map { |name| @schema.find(name) } 30 | 31 | @resolved = true 32 | end 33 | 34 | def resolved? 35 | @resolved 36 | end 37 | 38 | def all_files 39 | (files + dependencies.flat_map(&:files)).sort 40 | end 41 | 42 | def all_modules 43 | (modules + dependencies.flat_map(&:modules)).sort 44 | end 45 | 46 | def all_gems 47 | (gems + dependencies.flat_map(&:gems)).sort 48 | end 49 | 50 | def has_dependencies? 51 | return true if gems.any? 52 | return true if modules.any? 53 | false 54 | end 55 | end 56 | 57 | class Schema 58 | def initialize 59 | @schema = [] 60 | yield self 61 | resolve! 62 | end 63 | 64 | def add( 65 | name, 66 | dependencies = [], 67 | components: nil, 68 | helpers: nil, 69 | js: [], 70 | modules: [], 71 | gems: [] 72 | ) 73 | # Default is one component, one helper with same name 74 | components ||= [ name ] 75 | helpers ||= [ name ] 76 | 77 | files = [ 78 | components.map { |c| "app/components/nitro_kit/#{c}.rb" }, 79 | helpers.map { |c| "app/helpers/nitro_kit/#{c}_helper.rb" }, 80 | js.map { |c| "app/javascript/controllers/nk/#{c}_controller.js" } 81 | ].flatten 82 | 83 | component = Component.new( 84 | self, 85 | name, 86 | dependencies:, 87 | files:, 88 | modules:, 89 | gems: 90 | ) 91 | 92 | @schema.push(component) 93 | end 94 | 95 | def all 96 | @schema 97 | end 98 | 99 | def find(name) 100 | component = @schema.find { |c| c.name == name.to_sym } 101 | raise "Component not found: #{name}" unless component 102 | component 103 | end 104 | 105 | def resolved? 106 | @resolved 107 | end 108 | 109 | private 110 | 111 | def resolve! 112 | all.each(&:resolve!) 113 | @resolved = true 114 | end 115 | end 116 | 117 | def build_schema(&block) 118 | Schema.new(&block) 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /app/components/nitro_kit/badge.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class Badge < Component 5 | VARIANTS = %i[default outline] 6 | 7 | def initialize(text = nil, variant: :default, size: :md, color: :gray, **attrs) 8 | @text = text 9 | @variant = variant 10 | @size = size 11 | @color = color 12 | 13 | super( 14 | attrs, 15 | class: [ 16 | base_class, 17 | variant_class, 18 | size_class 19 | ] 20 | ) 21 | end 22 | 23 | attr_reader :text, :variant, :size, :color 24 | 25 | def view_template(&block) 26 | span(**attrs) do 27 | text_or_block(text, &block) 28 | end 29 | end 30 | 31 | private 32 | 33 | def base_class 34 | "inline-flex items-center gap-x-1.5 rounded-md font-medium whitespace-nowrap" 35 | end 36 | 37 | def variant_class 38 | case variant 39 | when :default 40 | color_class 41 | when :outline 42 | "border" 43 | else 44 | raise ArgumentError, "Invalid variant: #{variant}" 45 | end 46 | end 47 | 48 | def size_class 49 | case size 50 | when :sm 51 | "text-xs px-1.5 py-0.5" 52 | when :md 53 | "text-sm px-2 py-0.5" 54 | else 55 | raise ArgumentError, "Invalid size: #{size}" 56 | end 57 | end 58 | 59 | def color_class 60 | case color 61 | when :gray 62 | "text-zinc-700 dark:text-zinc-200 bg-zinc-400/15 dark:bg-zinc-400/40" 63 | when :red 64 | "text-red-900 dark:text-red-200 bg-red-300/50 dark:bg-red-400/40" 65 | when :orange 66 | "text-orange-700 dark:text-orange-200 bg-orange-400/20 dark:bg-orange-400/40" 67 | when :amber 68 | "text-amber-700 dark:text-amber-200 bg-amber-400/20 dark:bg-amber-400/40" 69 | when :yellow 70 | "text-yellow-900 dark:text-yellow-200 bg-yellow-400/40 dark:bg-yellow-400/40" 71 | when :lime 72 | "text-lime-700 dark:text-lime-200 bg-lime-400/20 dark:bg-lime-400/40" 73 | when :green 74 | "text-green-800 dark:text-green-200 bg-green-500/20 dark:bg-green-400/40" 75 | when :emerald 76 | "text-emerald-700 dark:text-emerald-200 bg-emerald-400/20 dark:bg-emerald-400/40" 77 | when :teal 78 | "text-teal-700 dark:text-teal-200 bg-teal-400/20 dark:bg-teal-400/40" 79 | when :cyan 80 | "text-cyan-700 dark:text-cyan-200 bg-cyan-400/20 dark:bg-cyan-400/40" 81 | when :sky 82 | "text-sky-700 dark:text-sky-200 bg-sky-400/20 dark:bg-sky-400/40" 83 | when :blue 84 | "text-blue-700 dark:text-blue-200 bg-blue-400/20 dark:bg-blue-400/40" 85 | when :indigo 86 | "text-indigo-700 dark:text-indigo-200 bg-indigo-400/20 dark:bg-indigo-400/40" 87 | when :violet 88 | "text-violet-700 dark:text-violet-200 bg-violet-400/20 dark:bg-violet-400/40" 89 | when :purple 90 | "text-purple-700 dark:text-purple-200 bg-purple-400/20 dark:bg-purple-400/40" 91 | when :fuchsia 92 | "text-fuchsia-700 dark:text-fuchsia-200 bg-fuchsia-400/20 dark:bg-fuchsia-400/40" 93 | when :pink 94 | "text-pink-700 dark:text-pink-200 bg-pink-400/20 dark:bg-pink-400/40" 95 | when :rose 96 | "text-rose-700 dark:text-rose-200 bg-rose-400/20 dark:bg-rose-400/40" 97 | else 98 | raise ArgumentError, "Unknown color `#{color}'" 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /test/dummy/vendor/javascript/@floating-ui--utils--dom.js: -------------------------------------------------------------------------------- 1 | // @floating-ui/utils/dom@0.2.9 downloaded from https://ga.jspm.io/npm:@floating-ui/utils@0.2.9/dist/floating-ui.utils.dom.mjs 2 | 3 | function hasWindow(){return typeof window!=="undefined"}function getNodeName(e){return isNode(e)?(e.nodeName||"").toLowerCase():"#document"}function getWindow(e){var t;return(e==null||(t=e.ownerDocument)==null?void 0:t.defaultView)||window}function getDocumentElement(e){var t;return(t=(isNode(e)?e.ownerDocument:e.document)||window.document)==null?void 0:t.documentElement}function isNode(e){return!!hasWindow()&&(e instanceof Node||e instanceof getWindow(e).Node)}function isElement(e){return!!hasWindow()&&(e instanceof Element||e instanceof getWindow(e).Element)}function isHTMLElement(e){return!!hasWindow()&&(e instanceof HTMLElement||e instanceof getWindow(e).HTMLElement)}function isShadowRoot(e){return!(!hasWindow()||typeof ShadowRoot==="undefined")&&(e instanceof ShadowRoot||e instanceof getWindow(e).ShadowRoot)}function isOverflowElement(e){const{overflow:t,overflowX:n,overflowY:o,display:r}=getComputedStyle(e);return/auto|scroll|overlay|hidden|clip/.test(t+o+n)&&!["inline","contents"].includes(r)}function isTableElement(e){return["table","td","th"].includes(getNodeName(e))}function isTopLayer(e){return[":popover-open",":modal"].some((t=>{try{return e.matches(t)}catch(e){return false}}))}function isContainingBlock(e){const t=isWebKit();const n=isElement(e)?getComputedStyle(e):e;return["transform","translate","scale","rotate","perspective"].some((e=>!!n[e]&&n[e]!=="none"))||!!n.containerType&&n.containerType!=="normal"||!t&&!!n.backdropFilter&&n.backdropFilter!=="none"||!t&&!!n.filter&&n.filter!=="none"||["transform","translate","scale","rotate","perspective","filter"].some((e=>(n.willChange||"").includes(e)))||["paint","layout","strict","content"].some((e=>(n.contain||"").includes(e)))}function getContainingBlock(e){let t=getParentNode(e);while(isHTMLElement(t)&&!isLastTraversableNode(t)){if(isContainingBlock(t))return t;if(isTopLayer(t))return null;t=getParentNode(t)}return null}function isWebKit(){return!(typeof CSS==="undefined"||!CSS.supports)&&CSS.supports("-webkit-backdrop-filter","none")}function isLastTraversableNode(e){return["html","body","#document"].includes(getNodeName(e))}function getComputedStyle(e){return getWindow(e).getComputedStyle(e)}function getNodeScroll(e){return isElement(e)?{scrollLeft:e.scrollLeft,scrollTop:e.scrollTop}:{scrollLeft:e.scrollX,scrollTop:e.scrollY}}function getParentNode(e){if(getNodeName(e)==="html")return e;const t=e.assignedSlot||e.parentNode||isShadowRoot(e)&&e.host||getDocumentElement(e);return isShadowRoot(t)?t.host:t}function getNearestOverflowAncestor(e){const t=getParentNode(e);return isLastTraversableNode(t)?e.ownerDocument?e.ownerDocument.body:e.body:isHTMLElement(t)&&isOverflowElement(t)?t:getNearestOverflowAncestor(t)}function getOverflowAncestors(e,t,n){var o;t===void 0&&(t=[]);n===void 0&&(n=true);const r=getNearestOverflowAncestor(e);const i=r===((o=e.ownerDocument)==null?void 0:o.body);const l=getWindow(r);if(i){const e=getFrameElement(l);return t.concat(l,l.visualViewport||[],isOverflowElement(r)?r:[],e&&n?getOverflowAncestors(e):[])}return t.concat(r,getOverflowAncestors(r,[],n))}function getFrameElement(e){return e.parent&&Object.getPrototypeOf(e.parent)?e.frameElement:null}export{getComputedStyle,getContainingBlock,getDocumentElement,getFrameElement,getNearestOverflowAncestor,getNodeName,getNodeScroll,getOverflowAncestors,getParentNode,getWindow,isContainingBlock,isElement,isHTMLElement,isLastTraversableNode,isNode,isOverflowElement,isShadowRoot,isTableElement,isTopLayer,isWebKit}; 4 | 5 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Overview 6 | 7 | Nitro Kit is a Ruby gem providing generic UI components for Ruby on Rails applications. It uses Phlex for component rendering, Tailwind CSS for styling, and Stimulus.js for JavaScript behaviors. 8 | 9 | ## Common Development Commands 10 | 11 | ### Development Server 12 | 13 | - `bin/dev` - Start development server on port 3031 14 | - Visit http://localhost:3031/tests to see all component examples 15 | 16 | ### Testing 17 | 18 | - `bin/rails test` - Run the test suite 19 | - `rake app:test:db` - Run tests with database reset 20 | - `rake app:test` - Run all tests 21 | 22 | ### Building & Releasing 23 | 24 | - `rake build` - Build the gem 25 | - `rake install:local` - Install gem locally 26 | - `rake release` - Release to RubyGems 27 | 28 | ### Tailwind CSS 29 | 30 | - `rake app:tailwindcss:build` - Build CSS 31 | - `rake app:tailwindcss:watch` - Watch for CSS changes 32 | 33 | ### View All Tasks 34 | 35 | - `rake -T` - List all available rake tasks 36 | 37 | ## Architecture 38 | 39 | ### Component Pattern 40 | 41 | All components inherit from `NitroKit::Component` (which extends `Phlex::HTML`) and follow this structure: 42 | 43 | ```ruby 44 | class ComponentName < Component 45 | def initialize(params, **attrs) 46 | # Store parameters, merge attributes 47 | super(**attrs_with_defaults(attrs)) 48 | end 49 | 50 | def view_template 51 | # Main rendering logic 52 | end 53 | 54 | builder_method def part_name 55 | # Nested component parts 56 | end 57 | end 58 | ``` 59 | 60 | ### Key Architectural Principles 61 | 62 | 1. **Phlex-Based Rendering**: Components use Phlex::HTML for Ruby-based templating 63 | 2. **TailwindMerge Integration**: CSS classes are intelligently merged to prevent conflicts 64 | 3. **Stimulus Controllers**: Interactive behavior uses minimal JavaScript with naming convention `nk--{component-name}` 65 | 4. **Builder Methods**: Components use builder pattern for composable nested parts 66 | 5. **Rails Integration**: Each component has a corresponding helper module (e.g., `nk_button` for `Button` component) 67 | 68 | ### Form Builder 69 | 70 | Custom `NitroKit::FormBuilder` extends Rails' form builder to automatically use NitroKit components for form fields. Access via `form.nk` namespace in forms. 71 | 72 | ### File Organization 73 | 74 | - `app/components/nitro_kit/` - Component classes 75 | - `app/helpers/nitro_kit/` - Rails helper modules 76 | - `app/javascript/controllers/nk/` - Stimulus controllers 77 | - `test/dummy/` - Dummy Rails app for testing/development 78 | 79 | ### Adding New Components 80 | 81 | Use the generator: `rails generate nitro_kit:component ComponentName` 82 | 83 | ### Testing Components 84 | 85 | 1. Add example view in `test/dummy/app/views/tests/examples/` 86 | 2. Component will appear in development server at http://localhost:3031/tests 87 | 3. Write integration tests in `test/integration/` 88 | 89 | ### CSS Architecture 90 | 91 | - Uses Tailwind utility classes with CSS custom properties for theming 92 | - Dark mode support via Tailwind's dark variant 93 | - Theme colors defined as CSS variables (--foreground, --background, --border, etc.) 94 | - Component styles encapsulated in private methods (base_class, variant_class, size_class) 95 | 96 | ### JavaScript Pattern 97 | 98 | - Minimal Stimulus controllers for UI interactions only 99 | - Data attributes follow pattern: `data-nk--{component}-target` and `data-action` 100 | - External libraries (floating-ui, etc.) integrated via importmaps or package manager 101 | -------------------------------------------------------------------------------- /app/javascript/controllers/nk/combobox_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus"; 2 | import Combobox from "@github/combobox-nav"; 3 | import { 4 | computePosition, 5 | offset, 6 | flip, 7 | shift, 8 | autoUpdate, 9 | } from "@floating-ui/dom"; 10 | 11 | export default class extends Controller { 12 | static targets = ["input", "list", "hiddenField", "clearButton"]; 13 | static values = { 14 | open: { type: Boolean, default: false }, 15 | // Options for floating-ui 16 | placement: { type: String }, 17 | // Options for combobox-nav 18 | tabInsertsSuggestions: { type: Boolean }, 19 | firstOptionSelectionMode: { type: String }, 20 | scrollIntoViewOptions: { type: Object }, 21 | }; 22 | 23 | connect() { 24 | this.combobox = new Combobox(this.inputTarget, this.listTarget, { 25 | tabInsertsSuggestions: this.tabInsertsSuggestionsValue, 26 | firstOptionSelectionMode: this.firstOptionSelectionModeValue, 27 | scrollIntoViewOptions: this.scrollIntoViewOptionsValue, 28 | }); 29 | 30 | this.updatePosition(); 31 | 32 | this.listTarget.addEventListener("combobox-commit", (event) => { 33 | this.select(event); 34 | this.close(); 35 | }); 36 | } 37 | 38 | disconnect() { 39 | this.combobox.destroy(); 40 | } 41 | 42 | select(event) { 43 | this.inputTarget.value = event.target.textContent; 44 | this.hiddenFieldTarget.value = 45 | event.target.dataset.value || event.target.textContent; 46 | } 47 | 48 | open() { 49 | this.openValue = true; 50 | } 51 | 52 | close() { 53 | this.openValue = false; 54 | } 55 | 56 | focusShift({ target }) { 57 | if (!this.openValue) return; 58 | if (this.element.contains(target)) return; 59 | 60 | this.close(); 61 | } 62 | 63 | windowClick({ target }) { 64 | if (!this.openValue) return; 65 | if (this.element.contains(target)) return; 66 | 67 | this.close(); 68 | } 69 | 70 | clear() { 71 | this.combobox.resetSelection(); 72 | this.inputTarget.value = ""; 73 | this.input(); 74 | this.hiddenFieldTarget.value = ""; 75 | } 76 | 77 | input(_event) { 78 | if (!this.isOpen) this.open(); 79 | 80 | const filter = this.inputTarget.value.toLowerCase(); 81 | 82 | Array.from(this.listTarget.children).forEach((item) => { 83 | const value = item.dataset.value?.toLowerCase(); 84 | const text = item.textContent.toLowerCase(); 85 | 86 | if (value?.includes(filter) || text.includes(filter)) { 87 | item.setAttribute("role", "option"); 88 | } else { 89 | item.removeAttribute("role"); 90 | } 91 | }); 92 | 93 | this.hiddenFieldTarget.value = this.inputTarget.value; 94 | } 95 | 96 | openValueChanged(state, _previous) { 97 | if (!this.combobox) return; 98 | 99 | if (state) { 100 | this.combobox.start(); 101 | 102 | this.listTarget.dataset.state = "open"; 103 | 104 | this.clearAutoUpdate = autoUpdate( 105 | this.inputTarget, 106 | this.listTarget, 107 | this.updatePosition, 108 | ); 109 | } else { 110 | this.combobox.stop(); 111 | 112 | this.listTarget.dataset.state = "closed"; 113 | 114 | if (this.clearAutoUpdate) { 115 | this.clearAutoUpdate(); 116 | } 117 | } 118 | } 119 | 120 | updatePosition = () => { 121 | computePosition(this.inputTarget, this.listTarget, { 122 | placement: this.placementValue, 123 | middleware: [offset(5), flip(), shift({ padding: 5 })], 124 | }).then(({ x, y }) => { 125 | this.listTarget.style.left = `${x}px`; 126 | this.listTarget.style.top = `${y}px`; 127 | this.listTarget.style.width = `${this.inputTarget.clientWidth}px`; 128 | }); 129 | }; 130 | } 131 | -------------------------------------------------------------------------------- /app/components/nitro_kit/toast.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class Toast < Component 5 | class FlashMessages < Component 6 | def initialize(flash) 7 | @flash = flash 8 | end 9 | 10 | attr_reader :flash 11 | 12 | def view_template 13 | flash.each do |severity, message| 14 | render( 15 | Toast::Item.new( 16 | description: message, 17 | variant: severity.to_sym == :alert ? :error : :default 18 | ) 19 | ) 20 | end 21 | end 22 | end 23 | 24 | class Item < Component 25 | VARIANTS = %i[default warning error success] 26 | 27 | def initialize(title: nil, description: nil, variant: :default, **attrs) 28 | @title = title 29 | @description = description 30 | @variant = variant 31 | 32 | super( 33 | **mattr( 34 | attrs, 35 | class: [ 36 | base_class, 37 | variant_class 38 | ], 39 | role: "status", 40 | aria: { live: "off", atomic: "true" }, 41 | tabindex: "0", 42 | data: { state: "closed" } 43 | ) 44 | ) 45 | end 46 | 47 | attr_reader :title, :description, :variant 48 | 49 | def view_template(&block) 50 | li(**attrs) do 51 | div(class: "grid gap-1") do 52 | div(class: "text-sm font-semibold", data: { slot: "title" }) do 53 | title && plain(title) 54 | end 55 | 56 | div(class: "text-sm opacity-90", data: { slot: "description" }) do 57 | text_or_block(description, &block) 58 | end 59 | end 60 | end 61 | end 62 | 63 | private 64 | 65 | def base_class 66 | "shrink-0 pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md p-4 pr-8 shadow-lg transition-all border opacity-0 transition-all data-[state=open]:opacity-100 data-[state=open]:-translate-y-full" 67 | end 68 | 69 | def variant_class 70 | case variant 71 | when :default 72 | "border-border bg-background text-foreground" 73 | when :warning 74 | "bg-yellow-50 dark:bg-yellow-950 text-yellow-900 dark:text-yellow-100 border-yellow-500/80 dark:border-yellow-400/50" 75 | when :success 76 | "bg-green-50 dark:bg-green-950 text-green-900 dark:text-green-100 border-green-500/80 dark:border-green-400/50" 77 | when :error 78 | "bg-red-50 dark:bg-red-950 text-red-900 dark:text-red-100 border-red-400/80 dark:border-red-400/50" 79 | else 80 | raise ArgumentError, "Invalid variant `#{variant}'" 81 | end 82 | end 83 | end 84 | 85 | def initialize(**attrs) 86 | super( 87 | attrs, 88 | role: "region", 89 | tabindex: "-1", 90 | aria: { label: "Notifications" }, 91 | class: "pointer-events-none" 92 | ) 93 | end 94 | 95 | def view_template 96 | div(**attrs) do 97 | ol(class: list_class, data: { nk__toast_target: "list" }) 98 | end 99 | 100 | flash_sink 101 | 102 | template(data: { nk__toast_target: "template" }) do 103 | item 104 | end 105 | end 106 | 107 | def item(title: nil, description: nil, **attrs, &block) 108 | render(Item.new(title:, description:, **attrs), &block) 109 | end 110 | 111 | def flash_sink 112 | div(id: "nk--toast-sink", data: { nk__toast_target: "sink" }, hidden: true) do 113 | render(FlashMessages.new(view_context.flash)) 114 | end 115 | end 116 | 117 | private 118 | 119 | def list_class 120 | "fixed z-[100] flex max-h-screen w-full p-5 bottom-0 right-0 flex-col h-0 md:max-w-[420px]" 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /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 | # Enable DNS rebinding protection and other `Host` header attacks. 81 | # config.hosts = [ 82 | # "example.com", # Allow requests from example.com 83 | # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` 84 | # ] 85 | # 86 | # Skip DNS rebinding protection for the default health check endpoint. 87 | # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } 88 | end 89 | -------------------------------------------------------------------------------- /app/components/nitro_kit/button.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class Button < Component 5 | VARIANTS = %i[default primary destructive ghost] 6 | 7 | def initialize( 8 | text = nil, 9 | href: nil, 10 | variant: :default, 11 | size: :md, 12 | icon: nil, 13 | icon_right: nil, 14 | **attrs 15 | ) 16 | @text = text 17 | @href = href 18 | @icon = icon 19 | @icon_right = icon_right 20 | @size = size 21 | @variant = variant 22 | 23 | super( 24 | attrs, 25 | class: [ 26 | base_class, 27 | variant_class, 28 | size_class 29 | ] 30 | ) 31 | end 32 | 33 | attr_reader( 34 | :text, 35 | :href, 36 | :icon, 37 | :icon_right, 38 | :size, 39 | :variant 40 | ) 41 | 42 | def view_template(&block) 43 | if href 44 | a(href:, **attrs) do 45 | contents(&block) 46 | end 47 | else 48 | button(type: :button, **attrs) do 49 | contents(&block) 50 | end 51 | end 52 | end 53 | 54 | private 55 | 56 | def contents(&block) 57 | has_content = text.present? || block_given? 58 | 59 | if !has_content 60 | return render(Icon.new(icon)) 61 | end 62 | 63 | render(Icon.new(icon)) if icon 64 | 65 | if block_given? 66 | yield 67 | else 68 | span { plain(text) } 69 | end 70 | 71 | render(Icon.new(icon_right)) if icon_right 72 | end 73 | 74 | def base_class 75 | [ 76 | "inline-flex items-center cursor-pointer shrink-0 justify-center rounded-md border gap-2 font-medium select-none", 77 | # Disabled 78 | "disabled:opacity-70 disabled:pointer-events-none", 79 | # Focus 80 | "focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-offset-2 focus-visible:ring-ring ring-offset-background", 81 | # Icon 82 | "[&_svg]:pointer-events-none [&_svg]:shrink-0" 83 | ] 84 | end 85 | 86 | def variant_class 87 | case variant 88 | when :default 89 | [ 90 | "bg-background text-foreground border-border", 91 | "hover:bg-zinc-50 dark:hover:bg-zinc-900" 92 | ] 93 | when :primary 94 | [ 95 | "bg-primary text-primary-foreground border-primary", 96 | "hover:bg-primary/90 dark:hover:bg-primary/90" 97 | ] 98 | when :destructive 99 | [ 100 | "bg-destructive text-destructive-foreground border-destructive", 101 | "hover:bg-destructive/90 dark:hover:bg-destructive/90", 102 | "disabled:text-destructive-foreground/80" 103 | ] 104 | when :ghost 105 | [ 106 | "bg-transparent text-foreground border-transparent", 107 | "hover:bg-zinc-200/50 dark:hover:bg-zinc-900", 108 | "disabled:text-muted-content" 109 | ] 110 | else 111 | raise ArgumentError, "Unknown variant `#{variant}'" 112 | end 113 | end 114 | 115 | def size_class 116 | case size 117 | when :xs 118 | "px-1.5 h-6 text-xs [&_svg]:size-3" 119 | when :sm 120 | [ 121 | "px-2.5 h-7 text-sm [&_svg]:size-4", 122 | "[&_svg:first-child:last-child]:-mx-1" 123 | ] 124 | when :md 125 | [ 126 | "px-4 h-10 text-base [&_svg]:size-4", 127 | # If icon only, make square 128 | "[&_svg:first-child:last-child]:-mx-1" 129 | ] 130 | when :lg 131 | [ 132 | "px-5 h-11 text-lg [&_svg]:size-5", 133 | # If icon only, make square 134 | "[&_svg:first-child:last-child]:-mx-2" 135 | ] 136 | when :xl 137 | [ 138 | "px-8 h-14 text-2xl [&_svg]:size-5 gap-x-4", 139 | # If icon only, make square 140 | "[&_svg:first-child:last-child]:-mx-8" 141 | ] 142 | else 143 | raise ArgumentError, "Unknown size `#{size}'" 144 | end 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /app/components/nitro_kit/combobox.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NitroKit 4 | class Combobox < Component 5 | def initialize( 6 | options: [], 7 | id: nil, 8 | 9 | placement: "bottom", 10 | tab_inserts_suggestions: true, 11 | first_option_selection_mode: "selected", 12 | scroll_into_view_options: { block: "nearest", inline: "nearest" }, 13 | 14 | **attrs 15 | ) 16 | # floating-ui options 17 | @placement = placement 18 | 19 | # combobox-nav options 20 | @tab_inserts_suggestions = tab_inserts_suggestions 21 | @first_option_selection_mode = first_option_selection_mode 22 | @scroll_into_view_options = scroll_into_view_options 23 | 24 | @id = id || "nk--combobox-" + SecureRandom.hex(4) 25 | 26 | @options = options 27 | 28 | super( 29 | attrs, 30 | type: "text", 31 | class: input_class, 32 | data: { 33 | nk__combobox_target: "input", 34 | action: %w[ 35 | focusin->nk--combobox#open 36 | focusin@window->nk--combobox#focusShift 37 | click@window->nk--combobox#windowClick 38 | input->nk--combobox#input 39 | keydown.esc->nk--combobox#clear 40 | keydown.down->nk--combobox#open 41 | ] 42 | }, 43 | aria: { 44 | controls: id(:listbox) 45 | } 46 | ) 47 | end 48 | 49 | attr_reader( 50 | :options, 51 | :placement, 52 | :tab_inserts_suggestions, 53 | :first_option_selection_mode, 54 | :scroll_into_view_options 55 | ) 56 | 57 | def view_template 58 | div( 59 | data: { 60 | class: "isolate", 61 | slot: "control", 62 | controller: "nk--combobox", 63 | nk__combobox_placement_value: placement, 64 | nk__combobox_tab_inserts_suggestions_value: tab_inserts_suggestions.to_s, 65 | nk__combobox_first_option_selection_mode_value: first_option_selection_mode.to_s, 66 | nk__combobox_scroll_into_view_options_value: scroll_into_view_options&.to_json 67 | } 68 | ) do 69 | span(class: wrapper_class) do 70 | render(Input.new(**attrs, value: display_value)) 71 | chevron_icon 72 | end 73 | 74 | # Since a combobox can function like a