├── 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 |
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 |
2 | <% @tests.each do |example| %>
3 | - <%= link_to example, test_path(example), class: "text-primary underline" %>
4 | <% end %>
5 |
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 |
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 | [](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