From 0c5e31331d9079c1e1b150ae149b6d53a1900ac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Sat, 9 May 2026 00:44:15 -0300 Subject: [PATCH 01/19] Add ruby_ui MCP server design spec --- specs/2026-05-09-ruby-ui-mcp-design.md | 226 +++++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 specs/2026-05-09-ruby-ui-mcp-design.md diff --git a/specs/2026-05-09-ruby-ui-mcp-design.md b/specs/2026-05-09-ruby-ui-mcp-design.md new file mode 100644 index 00000000..998d9cd5 --- /dev/null +++ b/specs/2026-05-09-ruby-ui-mcp-design.md @@ -0,0 +1,226 @@ +# Ruby UI MCP Server — Design + +**Date:** 2026-05-09 +**Status:** Approved +**Author:** Djalma Araújo (with Claude) + +## Summary + +Add a Model Context Protocol (MCP) server for `ruby_ui` so AI coding agents (Claude Code, Cursor, Claude Desktop, Windsurf, VS Code, Zed) can discover, inspect, and install RubyUI components programmatically. Mirrors the shadcn MCP feature set (full 7-tool surface) and is hosted as an HTTP endpoint mounted inside the existing `docs/` Rails 8 app at `https://rubyui.com/mcp`. + +## Goals + +- Agents can list, search, view, and install RubyUI components without manual file copying or doc reading. +- Agents can verify their install via an audit checklist. +- Zero local install for end users — HTTP transport, paste a URL into client config. +- Single source of truth: the existing `gem/` directory. No duplication. +- Independent release cadence from the `ruby_ui` gem. + +## Non-Goals + +- Local stdio binary distribution (deferred; HTTP-only for v1). +- Authentication / API keys (registry data is public). +- Multi-registry support (shadcn's primary registry pattern; ruby_ui ships one registry). +- Direct filesystem mutation by the MCP server. Installation is performed by the client agent running `rails g ruby_ui:component …` locally. + +## Architecture + +### Repo Layout + +``` +ruby_ui/ +├── gem/ # existing — Phlex components, generators +├── docs/ # existing — Rails 8.1 site (rubyui.com) +│ ├── Gemfile # adds: gem "ruby_ui-mcp", path: "../mcp" +│ ├── config/routes.rb # mounts RubyUI::MCP::Engine => "/mcp" +│ ├── app/views/docs/mcp.rb # NEW MCP docs page +│ ├── app/controllers/docs_controller.rb # +action :mcp +│ └── app/components/shared/menu.rb # +MCP entry +└── mcp/ # NEW — Rails engine gem + ├── ruby_ui-mcp.gemspec + ├── Gemfile + ├── Rakefile + ├── lib/ruby_ui/mcp/ + │ ├── version.rb + │ ├── engine.rb # Rails::Engine + │ ├── server.rb # MCP server (modelcontextprotocol/ruby-sdk) + │ ├── registry.rb # loads + queries registry.json + │ ├── tools/ # one file per MCP tool + │ │ ├── get_project_registries.rb + │ │ ├── list_items_in_registries.rb + │ │ ├── search_items_in_registries.rb + │ │ ├── view_items_in_registries.rb + │ │ ├── get_item_examples_from_registries.rb + │ │ ├── get_add_command_for_items.rb + │ │ └── get_audit_checklist.rb + │ └── builders/ + │ └── registry_builder.rb # reads ../gem, writes registry.json + ├── data/registry.json # built artifact, committed + ├── exe/ruby-ui-mcp-build # CLI: rebuild registry + └── test/ +``` + +### Request Flow + +``` +client (Claude Code, Cursor, etc.) + ↓ HTTPS POST /mcp (streamable HTTP transport) +docs/ Rails app + ↓ mount +RubyUI::MCP::Engine + ↓ +RubyUI::MCP::Server (mcp ruby-sdk) + ↓ tool dispatch +RubyUI::MCP::Tools::* + ↓ query +RubyUI::MCP::Registry (in-memory, loaded at boot from data/registry.json) + ↓ JSON-RPC response +client +``` + +## Registry + +### Schema + +`mcp/data/registry.json`: + +```json +{ + "version": "1.2.0", + "generated_at": "2026-05-09T12:00:00Z", + "components": { + "button": { + "name": "Button", + "description": "Trigger actions or events.", + "files": [ + {"path": "button.rb", "content": "..."}, + {"path": "button_controller.js", "content": "..."} + ], + "dependencies": { + "components": ["Icon"], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Button", + "docs_markdown": "# Button\n\n...", + "examples": [ + {"title": "Basic", "code": "RubyUI.Button { 'Click' }"} + ] + } + } +} +``` + +### Builder + +`RubyUI::MCP::Builders::RegistryBuilder`: + +- Walks `../gem/lib/ruby_ui/*/`. +- Reads all `.rb` and `.js` files per component directory. +- Parses `../gem/lib/generators/ruby_ui/dependencies.yml` for component/JS/gem deps. +- Reads `../gem/lib/ruby_ui/version.rb` for version pin. +- Renders each `_docs.rb` Phlex view to HTML, converts to markdown. +- Extracts `Docs::VisualCodeExample` blocks as `examples`. +- Writes `mcp/data/registry.json` deterministically (sorted keys, stable timestamps optional via env). + +### Build Lifecycle + +- Local: `bin/rake mcp:build` (wraps `exe/ruby-ui-mcp-build`). +- CI: a `mcp-registry-check` job rebuilds and fails if `git diff` on `data/registry.json` is non-empty. Contributors must commit the regenerated registry when `gem/` changes. +- Deploy: docs/ deploys with the latest committed `registry.json`. No build at deploy time. + +## MCP Tools (shadcn parity) + +| Tool | Purpose | Inputs | Outputs | +|------|---------|--------|---------| +| `get_project_registries` | List registries available. Single registry `ruby_ui` for client compat. | – | `[{name, url, description}]` | +| `list_items_in_registries` | All components, name + short description. | `registries[]` | `[{name, description}]` | +| `search_items_in_registries` | Fuzzy match name/description/docs. | `query`, `registries[]`, `limit?` | `[{name, description, score}]` | +| `view_items_in_registries` | Full source files + deps. | `items[]` | `[{name, files, dependencies, ...}]` | +| `get_item_examples_from_registries` | Code examples per component. | `items[]` | `[{name, examples}]` | +| `get_add_command_for_items` | Structured install command. | `items[]` | `{generator, components, command_string}` | +| `get_audit_checklist` | Static post-install verification list. | – | `[{check, description}]` | + +### Audit Checklist Items + +- `ruby_ui` gem present in `Gemfile`. +- Component files exist under `app/components/ruby_ui//`. +- Stimulus controllers registered (where applicable). +- JS packages from `dependencies.yml` present in `package.json`. +- Tailwind `content` paths include `app/components/ruby_ui/**/*`. +- Zeitwerk loads the `RubyUI` namespace. +- Generated views compile (no Phlex render errors). + +## Security + +- Component names in `get_add_command_for_items` validated against registry allowlist; regex `\A[A-Z][A-Za-z0-9]*\z`. No shell metacharacters reach the client. +- Output is structured (`{generator, components}`), not a raw shell string. The convenience `command_string` is built from validated tokens. +- MCP server is read-only. No filesystem writes. Execution risk lives in the client (Claude Code, etc.), gated by its own bash-permission layer. +- Tool exception handler returns MCP error without stack traces. + +## Hosting + +- Mounted in existing `docs/` Rails app at `/mcp`. Subdomain `mcp.rubyui.com` is a future option, not v1. +- Public, no auth. +- `Rack::Attack` rate limit: 60 requests/min/IP on `/mcp/*`. +- Registry loaded once at boot, cached in memory. Reload requires deploy. + +## Documentation Page + +New `docs/app/views/docs/mcp.rb` modeled after the shadcn MCP docs page. + +Sections: + +1. **Intro** — what MCP is, what ruby_ui MCP does. +2. **Setup** — tabbed install per client. Each tab is a copy-paste snippet: + - Claude Code: `claude mcp add --transport http ruby-ui https://rubyui.com/mcp` + - Cursor: `.cursor/mcp.json` JSON + - Claude Desktop: `claude_desktop_config.json` JSON + - Windsurf: `mcp_config.json` JSON + - VS Code: `.vscode/mcp.json` JSON + - Zed: `settings.json` snippet +3. **Usage** — example agent prompts ("Install Button and Dialog", "Show me Card source", "Search for date input", "Audit my install"). +4. **Tools reference** — table of all 7 tools with params and examples. +5. **Troubleshooting** — common errors. + +Wiring: + +- Route added to `docs/config/routes.rb` under existing docs scope. +- Action added to `DocsController`. +- Menu entry in `app/components/shared/menu.rb`. + +## Versioning + +- `mcp/lib/ruby_ui/mcp/version.rb` is independent of `gem/lib/ruby_ui/version.rb`. +- Registry embeds the gem version it was built from (`registry.version`). +- Tool responses include `gem_version` so agents know what they're consuming. + +## Testing + +- Minitest, mirrors `gem/` test style. +- Per-tool tests with a stub in-memory registry. +- Builder integration test against a small fixture gem directory. +- Server smoke test via an in-process MCP client. +- CI job: `cd mcp && bundle exec rake` (tests + standardrb). +- CI job: `mcp-registry-check` — rebuild registry, fail on diff. + +## Error Handling + +- Unknown component name → MCP error with top-3 fuzzy suggestions. +- Malformed args → JSON-RPC `InvalidParams`. +- Registry load failure at boot → Rails fails fast. +- Tool exceptions → caught, logged, returned as MCP error without stack traces. + +## Logging + +- Rails logger tagged `[MCP]`. +- Per request: tool name, arg count, latency, status. +- No component source in logs; names only. + +## Out of Scope (Future Work) + +- Local stdio gem distribution (`gem install ruby_ui-mcp && ruby-ui-mcp`). +- Hosted subdomain `mcp.rubyui.com`. +- API keys / higher-tier rate limits. +- Per-version registry serving (`?version=1.2.0`). +- Telemetry / metrics dashboards. From e7d25df080e0a594c309916c28b599550291c967 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Sat, 9 May 2026 00:47:35 -0300 Subject: [PATCH 02/19] Add ruby_ui MCP server implementation plan --- specs/2026-05-09-ruby-ui-mcp-plan.md | 1462 ++++++++++++++++++++++++++ 1 file changed, 1462 insertions(+) create mode 100644 specs/2026-05-09-ruby-ui-mcp-plan.md diff --git a/specs/2026-05-09-ruby-ui-mcp-plan.md b/specs/2026-05-09-ruby-ui-mcp-plan.md new file mode 100644 index 00000000..0595100f --- /dev/null +++ b/specs/2026-05-09-ruby-ui-mcp-plan.md @@ -0,0 +1,1462 @@ +# Ruby UI MCP Server Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship `ruby_ui-mcp` — a Rails engine gem in `mcp/` that exposes a 7-tool Model Context Protocol server (shadcn-parity) over HTTP, mounted in the existing `docs/` Rails app at `/mcp`, plus a docs page on rubyui.com explaining install + usage. + +**Architecture:** New `mcp/` sibling of `gem/` and `docs/`. Static `data/registry.json` built from `../gem/` by a CLI, committed to repo, loaded into memory at Rails boot. `RubyUI::MCP::Engine` mounts a streamable-HTTP MCP endpoint built on `modelcontextprotocol/ruby-sdk`. Seven tools (read-only) query the in-memory registry. Component install happens client-side via `rails g ruby_ui:component` — MCP only returns validated, structured commands. + +**Tech Stack:** Ruby 3.3+, Rails 8.1 engine, `mcp` gem (modelcontextprotocol/ruby-sdk), `rack-attack`, `phlex` (docs page), `kramdown` or `reverse_markdown` (HTML→md for docs build), Minitest, StandardRB. + +**Reference:** See `specs/2026-05-09-ruby-ui-mcp-design.md` for approved design. + +--- + +## Task 1: Scaffold `mcp/` Rails engine gem + +**Files:** +- Create: `mcp/.gitignore` +- Create: `mcp/Gemfile` +- Create: `mcp/Rakefile` +- Create: `mcp/ruby_ui-mcp.gemspec` +- Create: `mcp/lib/ruby_ui/mcp.rb` +- Create: `mcp/lib/ruby_ui/mcp/version.rb` +- Create: `mcp/lib/ruby_ui/mcp/engine.rb` +- Create: `mcp/.standard.yml` + +- [ ] **Step 1: Create gemspec** + +```ruby +# mcp/ruby_ui-mcp.gemspec +require_relative "lib/ruby_ui/mcp/version" + +Gem::Specification.new do |spec| + spec.name = "ruby_ui-mcp" + spec.version = RubyUI::MCP::VERSION + spec.authors = ["Ruby UI"] + spec.summary = "MCP server for ruby_ui — agent-driven component discovery and install." + spec.license = "MIT" + spec.required_ruby_version = ">= 3.3" + + spec.files = Dir["lib/**/*", "data/**/*", "exe/*", "README.md", "LICENSE"] + spec.bindir = "exe" + spec.executables = ["ruby-ui-mcp-build"] + spec.require_paths = ["lib"] + + spec.add_dependency "rails", ">= 8.0" + spec.add_dependency "mcp", ">= 0.1" + spec.add_dependency "rack-attack", ">= 6.7" + spec.add_dependency "reverse_markdown", ">= 2.1" + + spec.add_development_dependency "minitest", ">= 5.0" + spec.add_development_dependency "standard" + spec.add_development_dependency "rake" +end +``` + +- [ ] **Step 2: Create version + module + engine** + +```ruby +# mcp/lib/ruby_ui/mcp/version.rb +# frozen_string_literal: true +module RubyUI + module MCP + VERSION = "0.1.0" + end +end +``` + +```ruby +# mcp/lib/ruby_ui/mcp.rb +# frozen_string_literal: true +require "rails" +require "ruby_ui/mcp/version" +require "ruby_ui/mcp/engine" + +module RubyUI + module MCP + def self.registry + @registry ||= Registry.load_default + end + + def self.root + Engine.root + end + end +end +``` + +```ruby +# mcp/lib/ruby_ui/mcp/engine.rb +# frozen_string_literal: true +require "rails/engine" + +module RubyUI + module MCP + class Engine < ::Rails::Engine + isolate_namespace RubyUI::MCP + + initializer "ruby_ui.mcp.load_registry" do + require "ruby_ui/mcp/registry" + RubyUI::MCP.registry # eager load, fail fast on bad registry + end + end + end +end +``` + +- [ ] **Step 3: Create Gemfile + Rakefile** + +```ruby +# mcp/Gemfile +source "https://rubygems.org" +gemspec +``` + +```ruby +# mcp/Rakefile +require "rake/testtask" + +Rake::TestTask.new(:test) do |t| + t.libs << "test" << "lib" + t.pattern = "test/**/*_test.rb" + t.warning = false +end + +begin + require "standard/rake" + task default: %i[test standard] +rescue LoadError + task default: :test +end + +namespace :mcp do + desc "Rebuild registry.json from ../gem" + task :build do + sh "exe/ruby-ui-mcp-build" + end +end +``` + +- [ ] **Step 4: gitignore + standard config** + +``` +# mcp/.gitignore +/.bundle/ +/Gemfile.lock +/pkg/ +/tmp/ +``` + +```yaml +# mcp/.standard.yml +ruby_version: 3.3 +``` + +- [ ] **Step 5: Bundle install + verify load** + +Run: `cd mcp && bundle install && bundle exec ruby -Ilib -e "require 'ruby_ui/mcp'; puts RubyUI::MCP::VERSION"` +Expected: `0.1.0` + +- [ ] **Step 6: Commit** + +```bash +git add mcp/ +git commit -m "[Feature] Scaffold ruby_ui-mcp Rails engine gem" +``` + +--- + +## Task 2: Registry data model + +**Files:** +- Create: `mcp/lib/ruby_ui/mcp/registry.rb` +- Create: `mcp/test/test_helper.rb` +- Create: `mcp/test/registry_test.rb` +- Create: `mcp/test/fixtures/registry.json` + +- [ ] **Step 1: Write fixture registry** + +```json +// mcp/test/fixtures/registry.json +{ + "version": "1.2.0", + "generated_at": "2026-05-09T00:00:00Z", + "components": { + "button": { + "name": "Button", + "description": "Trigger actions or events.", + "files": [{"path": "button.rb", "content": "class Button; end\n"}], + "dependencies": {"components": [], "js_packages": [], "gems": []}, + "install_command": "rails g ruby_ui:component Button", + "docs_markdown": "# Button\n", + "examples": [{"title": "Basic", "code": "RubyUI.Button { 'x' }"}] + }, + "dialog": { + "name": "Dialog", + "description": "Modal dialog.", + "files": [{"path": "dialog.rb", "content": "class Dialog; end\n"}], + "dependencies": {"components": ["Button"], "js_packages": [], "gems": []}, + "install_command": "rails g ruby_ui:component Dialog", + "docs_markdown": "# Dialog\n", + "examples": [] + } + } +} +``` + +- [ ] **Step 2: Write failing tests** + +```ruby +# mcp/test/test_helper.rb +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) +require "minitest/autorun" +require "ruby_ui/mcp/registry" + +module TestSupport + FIXTURE_PATH = File.expand_path("fixtures/registry.json", __dir__) +end +``` + +```ruby +# mcp/test/registry_test.rb +require "test_helper" + +class RegistryTest < Minitest::Test + def setup + @registry = RubyUI::MCP::Registry.load(TestSupport::FIXTURE_PATH) + end + + def test_version + assert_equal "1.2.0", @registry.version + end + + def test_list_returns_all_components + names = @registry.list.map { |c| c[:name] } + assert_equal %w[Button Dialog], names.sort + end + + def test_find_by_name_case_insensitive + assert_equal "Button", @registry.find("button")[:name] + assert_equal "Button", @registry.find("Button")[:name] + end + + def test_find_unknown_returns_nil + assert_nil @registry.find("Nonexistent") + end + + def test_search_matches_name + results = @registry.search("dial") + assert_equal ["Dialog"], results.map { |r| r[:name] } + end + + def test_search_matches_description + results = @registry.search("modal") + assert_equal ["Dialog"], results.map { |r| r[:name] } + end + + def test_validate_names_returns_known_and_unknown + known, unknown = @registry.partition_names(["Button", "Bogus"]) + assert_equal ["Button"], known + assert_equal ["Bogus"], unknown + end +end +``` + +- [ ] **Step 3: Run tests, verify failure** + +Run: `cd mcp && bundle exec rake test` +Expected: FAIL — `Registry` not defined. + +- [ ] **Step 4: Implement Registry** + +```ruby +# mcp/lib/ruby_ui/mcp/registry.rb +# frozen_string_literal: true +require "json" + +module RubyUI + module MCP + class Registry + NAME_REGEX = /\A[A-Z][A-Za-z0-9]*\z/ + + def self.load_default + path = ENV["RUBY_UI_MCP_REGISTRY"] || default_path + load(path) + end + + def self.default_path + File.expand_path("../../../data/registry.json", __dir__) + end + + def self.load(path) + raw = JSON.parse(File.read(path), symbolize_names: true) + new(raw) + end + + attr_reader :version, :generated_at + + def initialize(raw) + @version = raw[:version] + @generated_at = raw[:generated_at] + @components = raw[:components] || {} + end + + def list + @components.values.map { |c| {name: c[:name], description: c[:description]} } + end + + def all + @components.values + end + + def find(name) + key = name.to_s.downcase + @components[key.to_sym] + end + + def search(query, limit: 10) + q = query.to_s.downcase + scored = @components.values.map do |c| + haystack = "#{c[:name]} #{c[:description]} #{c[:docs_markdown]}".downcase + score = haystack.include?(q) ? haystack.scan(q).length : 0 + [c, score] + end + scored.select { |_, s| s > 0 } + .sort_by { |_, s| -s } + .first(limit) + .map { |c, s| {name: c[:name], description: c[:description], score: s} } + end + + def partition_names(names) + known_set = @components.values.map { |c| c[:name] }.to_set + names.partition { |n| NAME_REGEX.match?(n) && known_set.include?(n) } + end + end + end +end +``` + +- [ ] **Step 5: Run tests, verify pass** + +Run: `cd mcp && bundle exec rake test` +Expected: PASS, 7 assertions. + +- [ ] **Step 6: Commit** + +```bash +git add mcp/lib/ruby_ui/mcp/registry.rb mcp/test/ +git commit -m "[Feature] MCP Registry data model + tests" +``` + +--- + +## Task 3: Registry Builder (reads `../gem`) + +**Files:** +- Create: `mcp/lib/ruby_ui/mcp/builders/registry_builder.rb` +- Create: `mcp/test/builders/registry_builder_test.rb` +- Create: `mcp/test/fixtures/fake_gem/lib/ruby_ui/version.rb` +- Create: `mcp/test/fixtures/fake_gem/lib/ruby_ui/button/button.rb` +- Create: `mcp/test/fixtures/fake_gem/lib/ruby_ui/button/button_docs.rb` +- Create: `mcp/test/fixtures/fake_gem/lib/generators/ruby_ui/dependencies.yml` + +- [ ] **Step 1: Build fake gem fixture** + +```ruby +# mcp/test/fixtures/fake_gem/lib/ruby_ui/version.rb +module RubyUI; VERSION = "9.9.9"; end +``` + +```ruby +# mcp/test/fixtures/fake_gem/lib/ruby_ui/button/button.rb +# RubyUI::Button — clickable. +module RubyUI + class Button + end +end +``` + +```ruby +# mcp/test/fixtures/fake_gem/lib/ruby_ui/button/button_docs.rb +class Views::Docs::Button + def view_template + h1 { "Button" } + p { "A clickable button." } + end +end +``` + +```yaml +# mcp/test/fixtures/fake_gem/lib/generators/ruby_ui/dependencies.yml +button: + components: [] + js_packages: [] +``` + +- [ ] **Step 2: Write failing test** + +```ruby +# mcp/test/builders/registry_builder_test.rb +require "test_helper" +require "ruby_ui/mcp/builders/registry_builder" + +class RegistryBuilderTest < Minitest::Test + def test_builds_registry_from_fake_gem + fixture = File.expand_path("../fixtures/fake_gem", __dir__) + registry = RubyUI::MCP::Builders::RegistryBuilder.new(gem_path: fixture).build + + assert_equal "9.9.9", registry[:version] + assert registry[:components][:button] + button = registry[:components][:button] + assert_equal "Button", button[:name] + assert_match(/clickable/i, button[:description]) + assert button[:files].any? { |f| f[:path] == "button.rb" } + assert_equal "rails g ruby_ui:component Button", button[:install_command] + end +end +``` + +- [ ] **Step 3: Run, verify failure** + +Run: `cd mcp && bundle exec rake test TEST=test/builders/registry_builder_test.rb` +Expected: FAIL — builder missing. + +- [ ] **Step 4: Implement Builder** + +```ruby +# mcp/lib/ruby_ui/mcp/builders/registry_builder.rb +# frozen_string_literal: true +require "yaml" +require "time" + +module RubyUI + module MCP + module Builders + class RegistryBuilder + SKIP_DIRS = %w[base.rb docs].freeze + + def initialize(gem_path:) + @gem_path = gem_path + end + + def build + { + version: read_version, + generated_at: (ENV["SOURCE_DATE_EPOCH"] ? Time.at(ENV["SOURCE_DATE_EPOCH"].to_i).utc : Time.now.utc).iso8601, + components: components_hash + } + end + + def write(path) + require "json" + File.write(path, JSON.pretty_generate(build) + "\n") + end + + private + + def read_version + eval(File.read(File.join(@gem_path, "lib/ruby_ui/version.rb"))) + RubyUI::VERSION + rescue + "unknown" + end + + def components_hash + deps = load_deps + Dir.children(File.join(@gem_path, "lib/ruby_ui")) + .select { |d| File.directory?(File.join(@gem_path, "lib/ruby_ui", d)) } + .reject { |d| SKIP_DIRS.include?(d) } + .sort + .each_with_object({}) { |d, h| h[d.to_sym] = build_component(d, deps[d] || {}) } + end + + def load_deps + path = File.join(@gem_path, "lib/generators/ruby_ui/dependencies.yml") + File.exist?(path) ? YAML.safe_load_file(path) || {} : {} + end + + def build_component(slug, dep_entry) + dir = File.join(@gem_path, "lib/ruby_ui", slug) + files = Dir.glob(File.join(dir, "*")) + .reject { |f| File.basename(f).end_with?("_docs.rb") } + .sort + .map { |f| {path: File.basename(f), content: File.read(f)} } + name = camelize(slug) + docs_md = render_docs_markdown(dir, slug) + { + name: name, + description: extract_description(files, docs_md), + files: files, + dependencies: { + components: Array(dep_entry["components"]), + js_packages: Array(dep_entry["js_packages"]), + gems: Array(dep_entry["gems"]) + }, + install_command: "rails g ruby_ui:component #{name}", + docs_markdown: docs_md, + examples: extract_examples(docs_md) + } + end + + def camelize(slug) + slug.split("_").map(&:capitalize).join + end + + def render_docs_markdown(dir, slug) + docs_file = File.join(dir, "#{slug}_docs.rb") + return "" unless File.exist?(docs_file) + # First-pass heuristic: extract h1/p text via regex. + # Phlex render is added in a follow-up if needed. + src = File.read(docs_file) + headings = src.scan(/h1\s*\{\s*"([^"]+)"\s*\}/).flatten.map { |t| "# #{t}" } + paras = src.scan(/p\s*\{\s*"([^"]+)"\s*\}/).flatten + (headings + paras).join("\n\n") + end + + def extract_description(files, docs_md) + if (m = docs_md.match(/^# .+?\n+([^\n#].+)/m)) + m[1].strip + elsif files.first && (m = files.first[:content].match(/^# (?:RubyUI::\w+ — )?(.+)$/)) + m[1].strip + else + "" + end + end + + def extract_examples(_docs_md) + [] # phase 1: no examples extracted; populated later via VisualCodeExample parser + end + end + end + end +end +``` + +- [ ] **Step 5: Run, verify pass** + +Run: `cd mcp && bundle exec rake test TEST=test/builders/registry_builder_test.rb` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add mcp/lib/ruby_ui/mcp/builders mcp/test/builders mcp/test/fixtures/fake_gem +git commit -m "[Feature] MCP RegistryBuilder reads gem source" +``` + +--- + +## Task 4: Build CLI + initial registry.json + +**Files:** +- Create: `mcp/exe/ruby-ui-mcp-build` +- Create: `mcp/data/registry.json` (committed artifact) + +- [ ] **Step 1: Write executable** + +```ruby +#!/usr/bin/env ruby +# mcp/exe/ruby-ui-mcp-build +# frozen_string_literal: true +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) +require "ruby_ui/mcp/builders/registry_builder" + +gem_path = ENV["RUBY_UI_GEM_PATH"] || File.expand_path("../../gem", __dir__) +out = File.expand_path("../data/registry.json", __dir__) +FileUtils.mkdir_p(File.dirname(out)) +RubyUI::MCP::Builders::RegistryBuilder.new(gem_path: gem_path).write(out) +puts "Wrote #{out}" +``` + +- [ ] **Step 2: Make executable** + +```bash +chmod +x mcp/exe/ruby-ui-mcp-build +``` + +- [ ] **Step 3: Run build against real gem** + +Run: `cd mcp && bundle exec exe/ruby-ui-mcp-build` +Expected: `Wrote .../mcp/data/registry.json`. File exists with all components from `gem/lib/ruby_ui/`. + +- [ ] **Step 4: Sanity-check output** + +Run: `cd mcp && ruby -rjson -e "r = JSON.parse(File.read('data/registry.json')); puts r['components'].keys.sort.join(', ')"` +Expected: comma-separated list of all component slugs (button, dialog, etc.). + +- [ ] **Step 5: Commit** + +```bash +git add mcp/exe/ruby-ui-mcp-build mcp/data/registry.json +git commit -m "[Feature] MCP build CLI + initial registry.json" +``` + +--- + +## Task 5: MCP Tools — list, search, view + +**Files:** +- Create: `mcp/lib/ruby_ui/mcp/tools/base.rb` +- Create: `mcp/lib/ruby_ui/mcp/tools/get_project_registries.rb` +- Create: `mcp/lib/ruby_ui/mcp/tools/list_items_in_registries.rb` +- Create: `mcp/lib/ruby_ui/mcp/tools/search_items_in_registries.rb` +- Create: `mcp/lib/ruby_ui/mcp/tools/view_items_in_registries.rb` +- Create: `mcp/test/tools/list_test.rb` +- Create: `mcp/test/tools/search_test.rb` +- Create: `mcp/test/tools/view_test.rb` + +- [ ] **Step 1: Tool base + tests** + +```ruby +# mcp/lib/ruby_ui/mcp/tools/base.rb +# frozen_string_literal: true +module RubyUI + module MCP + module Tools + class Base + def initialize(registry:) + @registry = registry + end + + def call(**args) + raise NotImplementedError + end + end + end + end +end +``` + +```ruby +# mcp/test/tools/list_test.rb +require "test_helper" +require "ruby_ui/mcp/tools/list_items_in_registries" + +class ListItemsToolTest < Minitest::Test + def setup + @registry = RubyUI::MCP::Registry.load(TestSupport::FIXTURE_PATH) + @tool = RubyUI::MCP::Tools::ListItemsInRegistries.new(registry: @registry) + end + + def test_returns_all_components + items = @tool.call[:items] + assert_equal 2, items.length + assert_equal %w[Button Dialog], items.map { |i| i[:name] }.sort + end +end +``` + +```ruby +# mcp/test/tools/search_test.rb +require "test_helper" +require "ruby_ui/mcp/tools/search_items_in_registries" + +class SearchItemsToolTest < Minitest::Test + def setup + @registry = RubyUI::MCP::Registry.load(TestSupport::FIXTURE_PATH) + @tool = RubyUI::MCP::Tools::SearchItemsInRegistries.new(registry: @registry) + end + + def test_finds_by_name + items = @tool.call(query: "dial")[:items] + assert_equal ["Dialog"], items.map { |i| i[:name] } + end + + def test_empty_when_no_match + assert_empty @tool.call(query: "zzz")[:items] + end +end +``` + +```ruby +# mcp/test/tools/view_test.rb +require "test_helper" +require "ruby_ui/mcp/tools/view_items_in_registries" + +class ViewItemsToolTest < Minitest::Test + def setup + @registry = RubyUI::MCP::Registry.load(TestSupport::FIXTURE_PATH) + @tool = RubyUI::MCP::Tools::ViewItemsInRegistries.new(registry: @registry) + end + + def test_returns_full_components + result = @tool.call(items: ["Button"]) + assert_equal 1, result[:items].length + assert_equal "Button", result[:items].first[:name] + assert result[:items].first[:files].any? + end + + def test_unknown_in_unresolved + result = @tool.call(items: ["Bogus"]) + assert_equal ["Bogus"], result[:unresolved] + end +end +``` + +- [ ] **Step 2: Run, verify failures** + +Run: `cd mcp && bundle exec rake test` +Expected: 3 failing tests. + +- [ ] **Step 3: Implement tools** + +```ruby +# mcp/lib/ruby_ui/mcp/tools/get_project_registries.rb +# frozen_string_literal: true +require "ruby_ui/mcp/tools/base" + +module RubyUI + module MCP + module Tools + class GetProjectRegistries < Base + def call(**) + { + registries: [{ + name: "ruby_ui", + url: "https://rubyui.com/mcp", + description: "Ruby UI components for Phlex + Rails." + }] + } + end + end + end + end +end +``` + +```ruby +# mcp/lib/ruby_ui/mcp/tools/list_items_in_registries.rb +# frozen_string_literal: true +require "ruby_ui/mcp/tools/base" + +module RubyUI + module MCP + module Tools + class ListItemsInRegistries < Base + def call(**) + {items: @registry.list, gem_version: @registry.version} + end + end + end + end +end +``` + +```ruby +# mcp/lib/ruby_ui/mcp/tools/search_items_in_registries.rb +# frozen_string_literal: true +require "ruby_ui/mcp/tools/base" + +module RubyUI + module MCP + module Tools + class SearchItemsInRegistries < Base + def call(query:, limit: 10, **) + {items: @registry.search(query, limit: limit), gem_version: @registry.version} + end + end + end + end +end +``` + +```ruby +# mcp/lib/ruby_ui/mcp/tools/view_items_in_registries.rb +# frozen_string_literal: true +require "ruby_ui/mcp/tools/base" + +module RubyUI + module MCP + module Tools + class ViewItemsInRegistries < Base + def call(items:, **) + resolved = [] + unresolved = [] + items.each do |name| + comp = @registry.find(name) + comp ? resolved << comp : unresolved << name + end + {items: resolved, unresolved: unresolved, gem_version: @registry.version} + end + end + end + end +end +``` + +- [ ] **Step 4: Run, verify pass** + +Run: `cd mcp && bundle exec rake test` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add mcp/lib/ruby_ui/mcp/tools mcp/test/tools +git commit -m "[Feature] MCP tools: list, search, view" +``` + +--- + +## Task 6: MCP Tools — examples, add command, audit + +**Files:** +- Create: `mcp/lib/ruby_ui/mcp/tools/get_item_examples_from_registries.rb` +- Create: `mcp/lib/ruby_ui/mcp/tools/get_add_command_for_items.rb` +- Create: `mcp/lib/ruby_ui/mcp/tools/get_audit_checklist.rb` +- Create: `mcp/test/tools/examples_test.rb` +- Create: `mcp/test/tools/add_command_test.rb` +- Create: `mcp/test/tools/audit_test.rb` + +- [ ] **Step 1: Write tests** + +```ruby +# mcp/test/tools/examples_test.rb +require "test_helper" +require "ruby_ui/mcp/tools/get_item_examples_from_registries" + +class ExamplesToolTest < Minitest::Test + def setup + @registry = RubyUI::MCP::Registry.load(TestSupport::FIXTURE_PATH) + @tool = RubyUI::MCP::Tools::GetItemExamplesFromRegistries.new(registry: @registry) + end + + def test_returns_examples_per_item + result = @tool.call(items: ["Button"]) + assert_equal 1, result[:items].length + assert_equal "Button", result[:items].first[:name] + assert_equal 1, result[:items].first[:examples].length + end + + def test_empty_examples_returned_for_components_without_any + result = @tool.call(items: ["Dialog"]) + assert_empty result[:items].first[:examples] + end +end +``` + +```ruby +# mcp/test/tools/add_command_test.rb +require "test_helper" +require "ruby_ui/mcp/tools/get_add_command_for_items" + +class AddCommandToolTest < Minitest::Test + def setup + @registry = RubyUI::MCP::Registry.load(TestSupport::FIXTURE_PATH) + @tool = RubyUI::MCP::Tools::GetAddCommandForItems.new(registry: @registry) + end + + def test_returns_structured_and_string_form + result = @tool.call(items: ["Button", "Dialog"]) + assert_equal "ruby_ui:component", result[:generator] + assert_equal ["Button", "Dialog"], result[:components] + assert_equal "rails g ruby_ui:component Button Dialog", result[:command_string] + end + + def test_filters_unknown_names + result = @tool.call(items: ["Button", "Bogus"]) + assert_equal ["Button"], result[:components] + assert_equal ["Bogus"], result[:unresolved] + end + + def test_rejects_shell_metachars + result = @tool.call(items: ["Button; rm -rf /"]) + assert_empty result[:components] + refute_match(/rm/, result[:command_string]) + end +end +``` + +```ruby +# mcp/test/tools/audit_test.rb +require "test_helper" +require "ruby_ui/mcp/tools/get_audit_checklist" + +class AuditChecklistToolTest < Minitest::Test + def test_returns_checklist + tool = RubyUI::MCP::Tools::GetAuditChecklist.new(registry: nil) + items = tool.call[:checklist] + assert items.length >= 5 + assert items.all? { |i| i[:check] && i[:description] } + end +end +``` + +- [ ] **Step 2: Verify failure** + +Run: `cd mcp && bundle exec rake test` +Expected: 3 failing test files. + +- [ ] **Step 3: Implement** + +```ruby +# mcp/lib/ruby_ui/mcp/tools/get_item_examples_from_registries.rb +# frozen_string_literal: true +require "ruby_ui/mcp/tools/base" + +module RubyUI + module MCP + module Tools + class GetItemExamplesFromRegistries < Base + def call(items:, **) + resolved = items.map do |n| + c = @registry.find(n) + c ? {name: c[:name], examples: c[:examples] || []} : nil + end.compact + {items: resolved, gem_version: @registry.version} + end + end + end + end +end +``` + +```ruby +# mcp/lib/ruby_ui/mcp/tools/get_add_command_for_items.rb +# frozen_string_literal: true +require "ruby_ui/mcp/tools/base" + +module RubyUI + module MCP + module Tools + class GetAddCommandForItems < Base + GENERATOR = "ruby_ui:component" + + def call(items:, **) + known, unresolved = @registry.partition_names(Array(items)) + { + generator: GENERATOR, + components: known, + unresolved: unresolved, + command_string: known.empty? ? "" : "rails g #{GENERATOR} #{known.join(" ")}", + gem_version: @registry.version + } + end + end + end + end +end +``` + +```ruby +# mcp/lib/ruby_ui/mcp/tools/get_audit_checklist.rb +# frozen_string_literal: true +require "ruby_ui/mcp/tools/base" + +module RubyUI + module MCP + module Tools + class GetAuditChecklist < Base + CHECKLIST = [ + {check: "gem_in_gemfile", description: "`ruby_ui` gem present in Gemfile."}, + {check: "components_copied", description: "Component files exist under app/components/ruby_ui//."}, + {check: "stimulus_registered", description: "Stimulus controllers registered (where applicable)."}, + {check: "js_packages_installed", description: "JS packages from dependencies.yml present in package.json."}, + {check: "tailwind_content_paths", description: "Tailwind content config includes app/components/ruby_ui/**/*."}, + {check: "zeitwerk_loads", description: "Zeitwerk loads the RubyUI namespace without errors."}, + {check: "views_compile", description: "Generated Phlex views render without errors."} + ].freeze + + def call(**) + {checklist: CHECKLIST} + end + end + end + end +end +``` + +- [ ] **Step 4: Run, verify pass** + +Run: `cd mcp && bundle exec rake test` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add mcp/lib/ruby_ui/mcp/tools mcp/test/tools +git commit -m "[Feature] MCP tools: examples, add_command, audit" +``` + +--- + +## Task 7: MCP Server wiring (ruby-sdk integration) + +**Files:** +- Create: `mcp/lib/ruby_ui/mcp/server.rb` +- Create: `mcp/test/server_test.rb` + +- [ ] **Step 1: Read ruby-sdk docs** + +Run: `cd mcp && bundle info mcp` and skim `https://github.com/modelcontextprotocol/ruby-sdk` README. Confirm Tool/Server API surface used below matches installed version. Adjust class/method names if SDK has evolved. + +- [ ] **Step 2: Write smoke test** + +```ruby +# mcp/test/server_test.rb +require "test_helper" +require "ruby_ui/mcp/server" + +class ServerTest < Minitest::Test + def test_lists_seven_tools + registry = RubyUI::MCP::Registry.load(TestSupport::FIXTURE_PATH) + server = RubyUI::MCP::Server.build(registry: registry) + names = server.tools.map(&:name).sort + assert_equal 7, names.length + expected = %w[ + get_add_command_for_items + get_audit_checklist + get_item_examples_from_registries + get_project_registries + list_items_in_registries + search_items_in_registries + view_items_in_registries + ] + assert_equal expected, names + end +end +``` + +- [ ] **Step 3: Run, verify failure** + +Run: `cd mcp && bundle exec rake test TEST=test/server_test.rb` +Expected: FAIL — Server missing. + +- [ ] **Step 4: Implement Server** + +```ruby +# mcp/lib/ruby_ui/mcp/server.rb +# frozen_string_literal: true +require "mcp" +require "ruby_ui/mcp/registry" +require "ruby_ui/mcp/tools/get_project_registries" +require "ruby_ui/mcp/tools/list_items_in_registries" +require "ruby_ui/mcp/tools/search_items_in_registries" +require "ruby_ui/mcp/tools/view_items_in_registries" +require "ruby_ui/mcp/tools/get_item_examples_from_registries" +require "ruby_ui/mcp/tools/get_add_command_for_items" +require "ruby_ui/mcp/tools/get_audit_checklist" + +module RubyUI + module MCP + class Server + TOOL_DEFINITIONS = [ + {name: "get_project_registries", klass: Tools::GetProjectRegistries, schema: {}}, + {name: "list_items_in_registries", klass: Tools::ListItemsInRegistries, schema: {}}, + {name: "search_items_in_registries", klass: Tools::SearchItemsInRegistries, + schema: {query: {type: :string, required: true}, limit: {type: :integer}}}, + {name: "view_items_in_registries", klass: Tools::ViewItemsInRegistries, + schema: {items: {type: :array, required: true}}}, + {name: "get_item_examples_from_registries", klass: Tools::GetItemExamplesFromRegistries, + schema: {items: {type: :array, required: true}}}, + {name: "get_add_command_for_items", klass: Tools::GetAddCommandForItems, + schema: {items: {type: :array, required: true}}}, + {name: "get_audit_checklist", klass: Tools::GetAuditChecklist, schema: {}} + ].freeze + + def self.build(registry: RubyUI::MCP.registry) + new(registry: registry).server + end + + attr_reader :tools + + def initialize(registry:) + @registry = registry + @tools = TOOL_DEFINITIONS.map { |d| build_tool(d) } + end + + def server + ::MCP::Server.new(name: "ruby_ui", version: RubyUI::MCP::VERSION, tools: @tools) + end + + private + + def build_tool(definition) + impl = definition[:klass].new(registry: @registry) + ::MCP::Tool.define( + name: definition[:name], + description: definition[:klass].name, + input_schema: definition[:schema] + ) do |args| + impl.call(**(args || {}).transform_keys(&:to_sym)) + rescue => e + {error: e.message} + end + end + end + end +end +``` + +NOTE: The exact `MCP::Tool.define` / `MCP::Server.new` API depends on the installed `mcp` gem version. If the API differs (e.g., subclassing `MCP::Tool` instead of `define`), adapt accordingly — keep the per-tool dispatch and exception trap behavior. + +- [ ] **Step 5: Run, verify pass** + +Run: `cd mcp && bundle exec rake test` +Expected: all PASS. + +- [ ] **Step 6: Commit** + +```bash +git add mcp/lib/ruby_ui/mcp/server.rb mcp/test/server_test.rb +git commit -m "[Feature] MCP Server wiring with 7 tools" +``` + +--- + +## Task 8: Rails Engine HTTP mount + +**Files:** +- Modify: `mcp/lib/ruby_ui/mcp/engine.rb` +- Create: `mcp/lib/ruby_ui/mcp/rack_app.rb` +- Create: `mcp/config/routes.rb` + +- [ ] **Step 1: Implement Rack app wrapping ruby-sdk HTTP transport** + +```ruby +# mcp/lib/ruby_ui/mcp/rack_app.rb +# frozen_string_literal: true +require "ruby_ui/mcp/server" +require "mcp/transports/streamable_http" # adjust if SDK path differs + +module RubyUI + module MCP + class RackApp + def self.call(env) + new.call(env) + end + + def call(env) + server = Server.build + transport = ::MCP::Transports::StreamableHTTP.new(server) + transport.call(env) + rescue => e + Rails.logger.tagged("MCP") { Rails.logger.error("#{e.class}: #{e.message}") } + [500, {"content-type" => "application/json"}, [{error: "internal"}.to_json]] + end + end + end +end +``` + +- [ ] **Step 2: Routes** + +```ruby +# mcp/config/routes.rb +RubyUI::MCP::Engine.routes.draw do + match "/", to: "RubyUI::MCP::RackApp", via: %i[get post], as: :mcp_root +end +``` + +If `match` to a Rack class doesn't work, use `mount` in the host app instead and skip engine-level routes. + +- [ ] **Step 3: Verify engine boots in isolation** + +Run: `cd mcp && bundle exec ruby -Ilib -e "require 'ruby_ui/mcp'; puts RubyUI::MCP::Engine.routes.routes.map(&:path).map(&:spec).join(', ')"` +Expected: lists `/` route. (If SDK transport differs, adjust before proceeding.) + +- [ ] **Step 4: Commit** + +```bash +git add mcp/lib/ruby_ui/mcp/rack_app.rb mcp/lib/ruby_ui/mcp/engine.rb mcp/config/routes.rb +git commit -m "[Feature] MCP Rails engine + Rack mount point" +``` + +--- + +## Task 9: Mount engine in `docs/` Rails app + +**Files:** +- Modify: `docs/Gemfile` +- Modify: `docs/config/routes.rb` +- Modify: `docs/config/application.rb` (add Rack::Attack middleware) +- Create: `docs/config/initializers/rack_attack.rb` + +- [ ] **Step 1: Add gem to docs Gemfile** + +Add line to `docs/Gemfile`: + +```ruby +gem "ruby_ui-mcp", path: "../mcp" +``` + +- [ ] **Step 2: Bundle** + +Run: `cd docs && bundle install` +Expected: resolves `ruby_ui-mcp 0.1.0` from path source. + +- [ ] **Step 3: Mount in routes** + +Append to `docs/config/routes.rb` (top-level, before catch-alls): + +```ruby +mount RubyUI::MCP::Engine => "/mcp" +``` + +- [ ] **Step 4: Configure rate limit** + +```ruby +# docs/config/initializers/rack_attack.rb +class Rack::Attack + throttle("mcp/ip", limit: 60, period: 60.seconds) do |req| + req.ip if req.path.start_with?("/mcp") + end +end + +Rails.application.config.middleware.use Rack::Attack +``` + +- [ ] **Step 5: Smoke test boot** + +Run: `cd docs && bin/rails runner "puts Rails.application.routes.routes.map { |r| r.path.spec.to_s }.grep(/mcp/)"` +Expected: prints `/mcp` route(s). + +- [ ] **Step 6: Smoke test request (in devcontainer)** + +Run docker exec rails server (per CLAUDE.local.md), then: + +```bash +curl -X POST http://localhost:3001/mcp \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +Expected: JSON response listing 7 tools. + +- [ ] **Step 7: Commit** + +```bash +git add docs/Gemfile docs/Gemfile.lock docs/config/routes.rb docs/config/initializers/rack_attack.rb +git commit -m "[Feature] Mount ruby_ui-mcp engine in docs app at /mcp" +``` + +--- + +## Task 10: Docs page — Views::Docs::Mcp + +**Files:** +- Create: `docs/app/views/docs/mcp.rb` +- Modify: `docs/app/controllers/docs_controller.rb` +- Modify: `docs/config/routes.rb` +- Modify: `docs/app/components/shared/menu.rb` + +- [ ] **Step 1: Add controller action** + +In `docs/app/controllers/docs_controller.rb` add: + +```ruby +def mcp +end +``` + +- [ ] **Step 2: Add route** + +In `docs/config/routes.rb` inside the existing docs scope: + +```ruby +get "mcp", to: "docs#mcp", as: :docs_mcp +``` + +- [ ] **Step 3: Add menu entry** + +In `docs/app/components/shared/menu.rb`, add "MCP" link to the Getting Started or Tools section: + +```ruby +{ title: "MCP Server", path: "/docs/mcp" } +``` + +(Match the exact data shape used in that file.) + +- [ ] **Step 4: Write the docs view (shadcn-style)** + +```ruby +# docs/app/views/docs/mcp.rb +class Views::Docs::Mcp < Views::Base + def view_template + div(class: "mx-auto w-full py-10 space-y-10") do + render Docs::Header.new( + title: "MCP Server", + description: "Use the Ruby UI MCP server to give your AI agent access to component source, examples, and an install command." + ) + + Heading(level: 2) { "Setup" } + P { "Add the MCP server to your editor or AI client. The endpoint is hosted at " } + Codeblock(content: "https://rubyui.com/mcp", language: "text") + + render Docs::ClientTabs.new do |tabs| + tabs.tab("Claude Code", "claude mcp add --transport http ruby-ui https://rubyui.com/mcp", "bash") + tabs.tab("Cursor", cursor_config_json, "json") + tabs.tab("Claude Desktop", claude_desktop_config_json, "json") + tabs.tab("Windsurf", windsurf_config_json, "json") + tabs.tab("VS Code", vscode_config_json, "json") + tabs.tab("Zed", zed_config_json, "json") + end + + Heading(level: 2) { "Usage" } + P { "Once the MCP is connected, ask your agent things like:" } + ul(class: "list-disc pl-6 space-y-1") do + li { "Install Button and Dialog from Ruby UI." } + li { "Show me the source of the Card component." } + li { "Search Ruby UI for a date input." } + li { "Audit my Ruby UI install." } + end + + Heading(level: 2) { "Tools" } + render Docs::ComponentsTable.new(tools_table_rows) + + Heading(level: 2) { "Troubleshooting" } + ul(class: "list-disc pl-6 space-y-2") do + li { "Endpoint must be reachable from the client; corporate proxies may block streamable HTTP." } + li { "If your agent can't find components, try `get_project_registries` first to confirm the registry is loaded." } + li { "Run `bundle exec rails g ruby_ui:component ` only inside a Rails app that has the `ruby_ui` gem in its Gemfile." } + end + end + end + + private + + def cursor_config_json + <<~JSON + { + "mcpServers": { + "ruby-ui": { "url": "https://rubyui.com/mcp" } + } + } + JSON + end + + def claude_desktop_config_json + <<~JSON + { + "mcpServers": { + "ruby-ui": { "url": "https://rubyui.com/mcp" } + } + } + JSON + end + + def windsurf_config_json = cursor_config_json + def vscode_config_json = cursor_config_json + def zed_config_json = cursor_config_json + + def tools_table_rows + [ + ["get_project_registries", "Lists available registries (always returns ruby_ui)."], + ["list_items_in_registries", "Returns all components with descriptions."], + ["search_items_in_registries", "Fuzzy search by name, description, or docs."], + ["view_items_in_registries", "Returns full source files and dependencies for selected components."], + ["get_item_examples_from_registries", "Returns code examples per component."], + ["get_add_command_for_items", "Returns a validated `rails g ruby_ui:component …` command."], + ["get_audit_checklist", "Returns a post-install verification checklist."] + ] + end +end +``` + +NOTE: `Docs::ClientTabs` may not exist — if the codebase doesn't already have a tabs component for code snippets, reuse the existing pattern from `docs/app/views/docs/installation.rb` (or whichever existing page has multi-tab install snippets) and adapt. Do not invent new components for this. + +- [ ] **Step 5: Browser-test in devcontainer** + +Visit `http://localhost:3001/docs/mcp`. Verify all sections render, tabs switch, code blocks copy. + +- [ ] **Step 6: Commit** + +```bash +git add docs/app/views/docs/mcp.rb docs/app/controllers/docs_controller.rb docs/config/routes.rb docs/app/components/shared/menu.rb +git commit -m "[Documentation] Add MCP docs page with multi-client install tabs" +``` + +--- + +## Task 11: CI integration + +**Files:** +- Modify: `.github/workflows/ci.yml` + +- [ ] **Step 1: Add mcp test job** + +Append to `.github/workflows/ci.yml`: + +```yaml + mcp-test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: mcp + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: true + working-directory: mcp + - run: bundle exec rake + + mcp-registry-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: true + working-directory: mcp + - name: Rebuild registry + working-directory: mcp + run: bundle exec exe/ruby-ui-mcp-build + - name: Fail on diff + run: | + if ! git diff --exit-code mcp/data/registry.json; then + echo "registry.json out of date — run 'cd mcp && bundle exec exe/ruby-ui-mcp-build' and commit" + exit 1 + fi +``` + +- [ ] **Step 2: Push branch, verify CI green** + +Run: `git push origin da/mcp` and watch GitHub Actions. Both new jobs must pass. + +- [ ] **Step 3: Commit (if not pushed yet)** + +```bash +git add .github/workflows/ci.yml +git commit -m "[CI] Add ruby_ui-mcp test + registry-drift jobs" +``` + +--- + +## Task 12: Documentation polish + README + +**Files:** +- Create: `mcp/README.md` +- Modify: `README.md` (root, if exists — add MCP section) + +- [ ] **Step 1: Write `mcp/README.md`** + +Cover: what it is, how to develop locally (`bundle && rake test`), how to rebuild registry (`exe/ruby-ui-mcp-build`), how it's deployed (mounted in docs/), tool reference link to docs site. + +- [ ] **Step 2: Commit** + +```bash +git add mcp/README.md README.md +git commit -m "[Documentation] Add MCP README" +``` + +--- + +## Self-Review Notes + +- All 7 tools from spec → Tasks 5 + 6. +- Registry schema → Task 2 (model) + Task 3 (builder) + Task 4 (artifact). +- Engine + HTTP transport → Tasks 7–9. +- Rate limit → Task 9. +- Docs page (shadcn-style multi-client install) → Task 10. +- CI gates including registry drift → Task 11. +- Security (allowlist + structured commands) → Task 6 add_command tests + impl. +- Audit checklist matches spec items → Task 6. + +Known soft spots: +- `mcp` ruby-sdk API surface (Tool.define / Server / StreamableHTTP) is assumed; Task 7 step 1 explicitly directs the implementer to verify against installed gem version and adjust. +- `docs_markdown` extraction is regex-based as a phase-1 heuristic (Task 3); a follow-up can render Phlex views properly. Examples extraction is empty in v1. +- `Docs::ClientTabs` may need to be implemented or replaced with existing codebase pattern (Task 10 step 4 NOTE). From 3bc91509d80572a53f8f98db5dab7e5561d33f5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 11:05:45 -0300 Subject: [PATCH 03/19] [Feature] Scaffold ruby_ui-mcp Rails engine gem --- mcp/.gitignore | 4 ++++ mcp/.standard.yml | 1 + mcp/Gemfile | 2 ++ mcp/Rakefile | 21 +++++++++++++++++++++ mcp/lib/ruby_ui/mcp.rb | 16 ++++++++++++++++ mcp/lib/ruby_ui/mcp/engine.rb | 15 +++++++++++++++ mcp/lib/ruby_ui/mcp/version.rb | 6 ++++++ mcp/ruby_ui-mcp.gemspec | 24 ++++++++++++++++++++++++ 8 files changed, 89 insertions(+) create mode 100644 mcp/.gitignore create mode 100644 mcp/.standard.yml create mode 100644 mcp/Gemfile create mode 100644 mcp/Rakefile create mode 100644 mcp/lib/ruby_ui/mcp.rb create mode 100644 mcp/lib/ruby_ui/mcp/engine.rb create mode 100644 mcp/lib/ruby_ui/mcp/version.rb create mode 100644 mcp/ruby_ui-mcp.gemspec diff --git a/mcp/.gitignore b/mcp/.gitignore new file mode 100644 index 00000000..b1824308 --- /dev/null +++ b/mcp/.gitignore @@ -0,0 +1,4 @@ +/.bundle/ +/Gemfile.lock +/pkg/ +/tmp/ diff --git a/mcp/.standard.yml b/mcp/.standard.yml new file mode 100644 index 00000000..49bff5f1 --- /dev/null +++ b/mcp/.standard.yml @@ -0,0 +1 @@ +ruby_version: 3.3 diff --git a/mcp/Gemfile b/mcp/Gemfile new file mode 100644 index 00000000..3be9c3cd --- /dev/null +++ b/mcp/Gemfile @@ -0,0 +1,2 @@ +source "https://rubygems.org" +gemspec diff --git a/mcp/Rakefile b/mcp/Rakefile new file mode 100644 index 00000000..3e892b6a --- /dev/null +++ b/mcp/Rakefile @@ -0,0 +1,21 @@ +require "rake/testtask" + +Rake::TestTask.new(:test) do |t| + t.libs << "test" << "lib" + t.pattern = "test/**/*_test.rb" + t.warning = false +end + +begin + require "standard/rake" + task default: %i[test standard] +rescue LoadError + task default: :test +end + +namespace :mcp do + desc "Rebuild registry.json from ../gem" + task :build do + sh "exe/ruby-ui-mcp-build" + end +end diff --git a/mcp/lib/ruby_ui/mcp.rb b/mcp/lib/ruby_ui/mcp.rb new file mode 100644 index 00000000..6741701d --- /dev/null +++ b/mcp/lib/ruby_ui/mcp.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +require "rails" +require "ruby_ui/mcp/version" +require "ruby_ui/mcp/engine" + +module RubyUI + module MCP + def self.registry + @registry ||= Registry.load_default + end + + def self.root + Engine.root + end + end +end diff --git a/mcp/lib/ruby_ui/mcp/engine.rb b/mcp/lib/ruby_ui/mcp/engine.rb new file mode 100644 index 00000000..cd751bcf --- /dev/null +++ b/mcp/lib/ruby_ui/mcp/engine.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true +require "rails/engine" + +module RubyUI + module MCP + class Engine < ::Rails::Engine + isolate_namespace RubyUI::MCP + + initializer "ruby_ui.mcp.load_registry" do + require "ruby_ui/mcp/registry" # TODO: Task 2 — Registry implementation + RubyUI::MCP.registry # eager load, fail fast on bad registry + end + end + end +end diff --git a/mcp/lib/ruby_ui/mcp/version.rb b/mcp/lib/ruby_ui/mcp/version.rb new file mode 100644 index 00000000..91fa96b6 --- /dev/null +++ b/mcp/lib/ruby_ui/mcp/version.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +module RubyUI + module MCP + VERSION = "0.1.0" + end +end diff --git a/mcp/ruby_ui-mcp.gemspec b/mcp/ruby_ui-mcp.gemspec new file mode 100644 index 00000000..04f2e183 --- /dev/null +++ b/mcp/ruby_ui-mcp.gemspec @@ -0,0 +1,24 @@ +require_relative "lib/ruby_ui/mcp/version" + +Gem::Specification.new do |spec| + spec.name = "ruby_ui-mcp" + spec.version = RubyUI::MCP::VERSION + spec.authors = ["Ruby UI"] + spec.summary = "MCP server for ruby_ui — agent-driven component discovery and install." + spec.license = "MIT" + spec.required_ruby_version = ">= 3.3" + + spec.files = Dir["lib/**/*", "data/**/*", "exe/*", "README.md", "LICENSE"] + spec.bindir = "exe" + spec.executables = ["ruby-ui-mcp-build"] + spec.require_paths = ["lib"] + + spec.add_dependency "rails", ">= 8.0" + spec.add_dependency "mcp", ">= 0.1" + spec.add_dependency "rack-attack", ">= 6.7" + spec.add_dependency "reverse_markdown", ">= 2.1" + + spec.add_development_dependency "minitest", ">= 5.0" + spec.add_development_dependency "standard" + spec.add_development_dependency "rake" +end From 10d2e71f72a83976c6ae40920ca8bb8d22333b40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 11:07:22 -0300 Subject: [PATCH 04/19] [Feature] MCP Registry data model + tests --- mcp/lib/ruby_ui/mcp/registry.rb | 65 +++++++++++++++++++++++++++++++++ mcp/test/fixtures/registry.json | 24 ++++++++++++ mcp/test/registry_test.rb | 41 +++++++++++++++++++++ mcp/test/test_helper.rb | 7 ++++ 4 files changed, 137 insertions(+) create mode 100644 mcp/lib/ruby_ui/mcp/registry.rb create mode 100644 mcp/test/fixtures/registry.json create mode 100644 mcp/test/registry_test.rb create mode 100644 mcp/test/test_helper.rb diff --git a/mcp/lib/ruby_ui/mcp/registry.rb b/mcp/lib/ruby_ui/mcp/registry.rb new file mode 100644 index 00000000..aeef6a0d --- /dev/null +++ b/mcp/lib/ruby_ui/mcp/registry.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "json" +require "set" + +module RubyUI + module MCP + class Registry + NAME_REGEX = /\A[A-Z][A-Za-z0-9]*\z/ + + def self.load_default + path = ENV["RUBY_UI_MCP_REGISTRY"] || default_path + load(path) + end + + def self.default_path + File.expand_path("../../../data/registry.json", __dir__) + end + + def self.load(path) + raw = JSON.parse(File.read(path), symbolize_names: true) + new(raw) + end + + attr_reader :version, :generated_at + + def initialize(raw) + @version = raw[:version] + @generated_at = raw[:generated_at] + @components = raw[:components] || {} + end + + def list + @components.values.map { |c| {name: c[:name], description: c[:description]} } + end + + def all + @components.values + end + + def find(name) + key = name.to_s.downcase.to_sym + @components[key] + end + + def search(query, limit: 10) + q = query.to_s.downcase + scored = @components.values.map do |c| + haystack = "#{c[:name]} #{c[:description]} #{c[:docs_markdown]}".downcase + score = haystack.include?(q) ? haystack.scan(q).length : 0 + [c, score] + end + scored.select { |_, s| s > 0 } + .sort_by { |_, s| -s } + .first(limit) + .map { |c, _s| {name: c[:name], description: c[:description]} } + end + + def partition_names(names) + known_set = @components.values.map { |c| c[:name] }.to_set + names.partition { |n| NAME_REGEX.match?(n) && known_set.include?(n) } + end + end + end +end diff --git a/mcp/test/fixtures/registry.json b/mcp/test/fixtures/registry.json new file mode 100644 index 00000000..050561d2 --- /dev/null +++ b/mcp/test/fixtures/registry.json @@ -0,0 +1,24 @@ +{ + "version": "1.2.0", + "generated_at": "2026-05-09T00:00:00Z", + "components": { + "button": { + "name": "Button", + "description": "Trigger actions or events.", + "files": [{"path": "button.rb", "content": "class Button; end\n"}], + "dependencies": {"components": [], "js_packages": [], "gems": []}, + "install_command": "rails g ruby_ui:component Button", + "docs_markdown": "# Button\n", + "examples": [{"title": "Basic", "code": "RubyUI.Button { 'x' }"}] + }, + "dialog": { + "name": "Dialog", + "description": "Modal dialog.", + "files": [{"path": "dialog.rb", "content": "class Dialog; end\n"}], + "dependencies": {"components": ["Button"], "js_packages": [], "gems": []}, + "install_command": "rails g ruby_ui:component Dialog", + "docs_markdown": "# Dialog\n", + "examples": [] + } + } +} diff --git a/mcp/test/registry_test.rb b/mcp/test/registry_test.rb new file mode 100644 index 00000000..e9ad58d1 --- /dev/null +++ b/mcp/test/registry_test.rb @@ -0,0 +1,41 @@ +require "test_helper" + +class RegistryTest < Minitest::Test + def setup + @registry = RubyUI::MCP::Registry.load(TestSupport::FIXTURE_PATH) + end + + def test_version + assert_equal "1.2.0", @registry.version + end + + def test_list_returns_all_components + names = @registry.list.map { |c| c[:name] } + assert_equal %w[Button Dialog], names.sort + end + + def test_find_by_name_case_insensitive + assert_equal "Button", @registry.find("button")[:name] + assert_equal "Button", @registry.find("Button")[:name] + end + + def test_find_unknown_returns_nil + assert_nil @registry.find("Nonexistent") + end + + def test_search_matches_name + results = @registry.search("dial") + assert_equal ["Dialog"], results.map { |r| r[:name] } + end + + def test_search_matches_description + results = @registry.search("modal") + assert_equal ["Dialog"], results.map { |r| r[:name] } + end + + def test_validate_names_returns_known_and_unknown + known, unknown = @registry.partition_names(["Button", "Bogus"]) + assert_equal ["Button"], known + assert_equal ["Bogus"], unknown + end +end diff --git a/mcp/test/test_helper.rb b/mcp/test/test_helper.rb new file mode 100644 index 00000000..3695eec5 --- /dev/null +++ b/mcp/test/test_helper.rb @@ -0,0 +1,7 @@ +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) +require "minitest/autorun" +require "ruby_ui/mcp/registry" + +module TestSupport + FIXTURE_PATH = File.expand_path("fixtures/registry.json", __dir__) +end From fc5f33391afb4b55e8c3bd8111693a5b83fcf7fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 11:09:02 -0300 Subject: [PATCH 05/19] [Feature] MCP RegistryBuilder reads gem source --- .../ruby_ui/mcp/builders/registry_builder.rb | 114 ++++++++++++++++++ mcp/test/builders/registry_builder_test.rb | 17 +++ .../lib/generators/ruby_ui/dependencies.yml | 3 + .../fake_gem/lib/ruby_ui/button/button.rb | 5 + .../lib/ruby_ui/button/button_docs.rb | 6 + .../fixtures/fake_gem/lib/ruby_ui/version.rb | 1 + 6 files changed, 146 insertions(+) create mode 100644 mcp/lib/ruby_ui/mcp/builders/registry_builder.rb create mode 100644 mcp/test/builders/registry_builder_test.rb create mode 100644 mcp/test/fixtures/fake_gem/lib/generators/ruby_ui/dependencies.yml create mode 100644 mcp/test/fixtures/fake_gem/lib/ruby_ui/button/button.rb create mode 100644 mcp/test/fixtures/fake_gem/lib/ruby_ui/button/button_docs.rb create mode 100644 mcp/test/fixtures/fake_gem/lib/ruby_ui/version.rb diff --git a/mcp/lib/ruby_ui/mcp/builders/registry_builder.rb b/mcp/lib/ruby_ui/mcp/builders/registry_builder.rb new file mode 100644 index 00000000..44b14955 --- /dev/null +++ b/mcp/lib/ruby_ui/mcp/builders/registry_builder.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require "yaml" +require "time" +require "json" +require "fileutils" + +module RubyUI + module MCP + module Builders + class RegistryBuilder + SKIP_DIRS = %w[docs].freeze + SKIP_FILES = %w[base.rb].freeze + + def initialize(gem_path:) + @gem_path = gem_path + end + + def build + { + version: read_version, + generated_at: (ENV["SOURCE_DATE_EPOCH"] ? Time.at(ENV["SOURCE_DATE_EPOCH"].to_i).utc : Time.now.utc).iso8601, + components: components_hash + } + end + + def write(path) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, JSON.pretty_generate(build) + "\n") + end + + private + + def read_version + path = File.join(@gem_path, "lib/ruby_ui/version.rb") + src = File.read(path) + if (m = src.match(/VERSION\s*=\s*["']([^"']+)["']/)) + m[1] + else + "unknown" + end + rescue + "unknown" + end + + def components_hash + deps = load_deps + base_dir = File.join(@gem_path, "lib/ruby_ui") + Dir.children(base_dir) + .select { |d| File.directory?(File.join(base_dir, d)) } + .reject { |d| SKIP_DIRS.include?(d) } + .sort + .each_with_object({}) { |d, h| h[d.to_sym] = build_component(d, deps[d] || {}) } + end + + def load_deps + path = File.join(@gem_path, "lib/generators/ruby_ui/dependencies.yml") + File.exist?(path) ? YAML.safe_load_file(path) || {} : {} + end + + def build_component(slug, dep_entry) + dir = File.join(@gem_path, "lib/ruby_ui", slug) + files = Dir.glob(File.join(dir, "*")) + .reject { |f| SKIP_FILES.include?(File.basename(f)) } + .reject { |f| File.basename(f).end_with?("_docs.rb") } + .sort + .map { |f| {path: File.basename(f), content: File.read(f)} } + name = camelize(slug) + docs_md = render_docs_markdown(dir, slug) + { + name: name, + description: extract_description(files, docs_md), + files: files, + dependencies: { + components: Array(dep_entry["components"]), + js_packages: Array(dep_entry["js_packages"]), + gems: Array(dep_entry["gems"]) + }, + install_command: "rails g ruby_ui:component #{name}", + docs_markdown: docs_md, + examples: extract_examples(docs_md) + } + end + + def camelize(slug) + slug.split("_").map(&:capitalize).join + end + + def render_docs_markdown(dir, slug) + docs_file = File.join(dir, "#{slug}_docs.rb") + return "" unless File.exist?(docs_file) + src = File.read(docs_file) + headings = src.scan(/h1\s*\{\s*"([^"]+)"\s*\}/).flatten.map { |t| "# #{t}" } + paras = src.scan(/p\s*\{\s*"([^"]+)"\s*\}/).flatten + (headings + paras).join("\n\n") + end + + def extract_description(files, docs_md) + if (m = docs_md.match(/^# .+?\n+([^\n#].+)/m)) + m[1].strip + elsif files.first && (m = files.first[:content].match(/^#\s*(?:RubyUI::\w+\s*[—-]\s*)?(.+)$/)) + m[1].strip + else + "" + end + end + + def extract_examples(_docs_md) + [] # phase 1: empty; populated later via VisualCodeExample parser + end + end + end + end +end diff --git a/mcp/test/builders/registry_builder_test.rb b/mcp/test/builders/registry_builder_test.rb new file mode 100644 index 00000000..ac6b5451 --- /dev/null +++ b/mcp/test/builders/registry_builder_test.rb @@ -0,0 +1,17 @@ +require "test_helper" +require "ruby_ui/mcp/builders/registry_builder" + +class RegistryBuilderTest < Minitest::Test + def test_builds_registry_from_fake_gem + fixture = File.expand_path("../fixtures/fake_gem", __dir__) + registry = RubyUI::MCP::Builders::RegistryBuilder.new(gem_path: fixture).build + + assert_equal "9.9.9", registry[:version] + assert registry[:components][:button] + button = registry[:components][:button] + assert_equal "Button", button[:name] + assert_match(/clickable/i, button[:description]) + assert button[:files].any? { |f| f[:path] == "button.rb" } + assert_equal "rails g ruby_ui:component Button", button[:install_command] + end +end diff --git a/mcp/test/fixtures/fake_gem/lib/generators/ruby_ui/dependencies.yml b/mcp/test/fixtures/fake_gem/lib/generators/ruby_ui/dependencies.yml new file mode 100644 index 00000000..eb1e98f7 --- /dev/null +++ b/mcp/test/fixtures/fake_gem/lib/generators/ruby_ui/dependencies.yml @@ -0,0 +1,3 @@ +button: + components: [] + js_packages: [] diff --git a/mcp/test/fixtures/fake_gem/lib/ruby_ui/button/button.rb b/mcp/test/fixtures/fake_gem/lib/ruby_ui/button/button.rb new file mode 100644 index 00000000..b0b529da --- /dev/null +++ b/mcp/test/fixtures/fake_gem/lib/ruby_ui/button/button.rb @@ -0,0 +1,5 @@ +# RubyUI::Button — clickable. +module RubyUI + class Button + end +end diff --git a/mcp/test/fixtures/fake_gem/lib/ruby_ui/button/button_docs.rb b/mcp/test/fixtures/fake_gem/lib/ruby_ui/button/button_docs.rb new file mode 100644 index 00000000..d7928b82 --- /dev/null +++ b/mcp/test/fixtures/fake_gem/lib/ruby_ui/button/button_docs.rb @@ -0,0 +1,6 @@ +class Views::Docs::Button + def view_template + h1 { "Button" } + p { "A clickable button." } + end +end diff --git a/mcp/test/fixtures/fake_gem/lib/ruby_ui/version.rb b/mcp/test/fixtures/fake_gem/lib/ruby_ui/version.rb new file mode 100644 index 00000000..bf251f98 --- /dev/null +++ b/mcp/test/fixtures/fake_gem/lib/ruby_ui/version.rb @@ -0,0 +1 @@ +module RubyUI; VERSION = "9.9.9"; end From 9340f720fb72cc46fdde1accbeb012fd82e0652a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 11:09:42 -0300 Subject: [PATCH 06/19] [Feature] MCP build CLI + initial registry.json --- mcp/data/registry.json | 1695 +++++++++++++++++ mcp/exe/ruby-ui-mcp-build | 11 + .../ruby_ui/mcp/builders/registry_builder.rb | 17 +- mcp/lib/ruby_ui/mcp/registry.rb | 2 +- 4 files changed, 1718 insertions(+), 7 deletions(-) create mode 100644 mcp/data/registry.json create mode 100755 mcp/exe/ruby-ui-mcp-build diff --git a/mcp/data/registry.json b/mcp/data/registry.json new file mode 100644 index 00000000..2206d429 --- /dev/null +++ b/mcp/data/registry.json @@ -0,0 +1,1695 @@ +{ + "version": "1.2.0", + "generated_at": "2026-05-11T14:10:27Z", + "components": { + "accordion": { + "name": "Accordion", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "accordion.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Accordion < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"w-full\"\n }\n end\n end\nend\n" + }, + { + "path": "accordion_content.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class AccordionContent < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n ruby_ui__accordion_target: \"content\"\n },\n class: \"overflow-y-hidden\",\n style: \"height: 0px;\"\n }\n end\n end\nend\n" + }, + { + "path": "accordion_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\";\nimport { animate } from \"motion\";\n\n// Connects to data-controller=\"ruby-ui--accordion\"\nexport default class extends Controller {\n static targets = [\"icon\", \"content\"];\n static values = {\n open: {\n type: Boolean,\n default: false,\n },\n animationDuration: {\n type: Number,\n default: 0.15, // Default animation duration (in seconds)\n },\n animationEasing: {\n type: String,\n default: \"ease-in-out\", // Default animation easing\n },\n rotateIcon: {\n type: Number,\n default: 180, // Default icon rotation (in degrees)\n },\n };\n\n connect() {\n // Set the initial state of the accordion\n let originalAnimationDuration = this.animationDurationValue;\n this.animationDurationValue = 0;\n this.openValue ? this.open() : this.close();\n this.animationDurationValue = originalAnimationDuration;\n }\n\n // Toggle the 'open' value\n toggle() {\n this.openValue = !this.openValue;\n }\n\n // Handle changes in the 'open' value\n openValueChanged(isOpen, wasOpen) {\n if (isOpen) {\n this.open();\n } else {\n this.close();\n }\n }\n\n // Open the accordion content\n open() {\n if (this.hasContentTarget) {\n this.revealContent();\n this.hasIconTarget && this.rotateIcon();\n this.openValue = true;\n }\n }\n\n // Close the accordion content\n close() {\n if (this.hasContentTarget) {\n this.hideContent();\n this.hasIconTarget && this.rotateIcon();\n this.openValue = false;\n }\n }\n\n // Reveal the accordion content with animation\n revealContent() {\n const contentHeight = this.contentTarget.scrollHeight;\n animate(\n this.contentTarget,\n { height: `${contentHeight}px` },\n {\n duration: this.animationDurationValue,\n easing: this.animationEasingValue,\n },\n );\n }\n\n // Hide the accordion content with animation\n hideContent() {\n animate(\n this.contentTarget,\n { height: 0 },\n {\n duration: this.animationDurationValue,\n easing: this.animationEasingValue,\n },\n );\n }\n\n // Rotate the accordion icon 180deg using animate function\n rotateIcon() {\n animate(this.iconTarget, {\n rotate: `${this.openValue ? this.rotateIconValue : 0}deg`,\n });\n }\n}\n" + }, + { + "path": "accordion_default_content.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class AccordionDefaultContent < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"pb-4 pt-0 text-sm\"\n }\n end\n end\nend\n" + }, + { + "path": "accordion_default_trigger.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class AccordionDefaultTrigger < Base\n def view_template(&block)\n div(class: \"flex items-center justify-between w-full\") do\n p(&block)\n RubyUI.AccordionIcon\n end\n end\n\n def default_attrs\n {\n data: {action: \"click->ruby-ui--accordion#toggle\"},\n class: \"w-full flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline\"\n }\n end\n end\nend\n" + }, + { + "path": "accordion_icon.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class AccordionIcon < Base\n def view_template(&block)\n span(**attrs) do\n if block\n block.call\n else\n icon\n end\n end\n end\n\n def icon\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n viewbox: \"0 0 20 20\",\n fill: \"currentColor\",\n class: \"w-4 h-4\"\n ) do |s|\n s.path(\n fill_rule: \"evenodd\",\n d:\n \"M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z\",\n clip_rule: \"evenodd\"\n )\n end\n end\n\n def default_attrs\n {\n class: \"opacity-50\",\n data: {ruby_ui__accordion_target: \"icon\"}\n }\n end\n end\nend\n" + }, + { + "path": "accordion_item.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class AccordionItem < Base\n def initialize(open: false, rotate_icon: 180, **attrs)\n @open = open\n @rotate_icon = rotate_icon\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n controller: \"ruby-ui--accordion\",\n ruby_ui__accordion_open_value: @open,\n ruby_ui__accordion_rotate_icon_value: @rotate_icon\n },\n class: \"border-b\"\n }\n end\n end\nend\n" + }, + { + "path": "accordion_trigger.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class AccordionTrigger < Base\n def view_template(&)\n button(**attrs, &)\n end\n\n def default_attrs\n {\n type: \"button\",\n data: {action: \"click->ruby-ui--accordion#toggle\"},\n class: \"w-full flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline\"\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [ + "motion" + ], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Accordion", + "docs_markdown": "", + "examples": [] + }, + "alert": { + "name": "Alert", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "alert.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Alert < Base\n def initialize(variant: nil, **attrs)\n @variant = variant\n super(**attrs) # must be called after variant is set\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def colors\n case @variant\n when nil\n \"ring-border bg-muted/20 text-foreground [&>svg]:opacity-80\"\n when :warning\n \"ring-warning/20 bg-warning/5 text-warning [&>svg]:text-warning/80\"\n when :success\n \"ring-success/20 bg-success/5 text-success [&>svg]:text-success/80\"\n when :destructive\n \"ring-destructive/20 bg-destructive/5 text-destructive [&>svg]:text-destructive/80\"\n end\n end\n\n def default_attrs\n base_classes = \"backdrop-blur relative w-full ring-1 ring-inset rounded-lg px-4 py-4 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:start-4 [&>svg]:top-4 [&>svg~*]:ps-8\"\n {\n class: [base_classes, colors]\n }\n end\n end\nend\n" + }, + { + "path": "alert_description.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class AlertDescription < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"text-sm [&_p]:leading-relaxed\"\n }\n end\n end\nend\n" + }, + { + "path": "alert_title.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class AlertTitle < Base\n def view_template(&)\n h5(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"mb-1 font-medium leading-none tracking-tight\"\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Alert", + "docs_markdown": "", + "examples": [] + }, + "alert_dialog": { + "name": "AlertDialog", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "alert_dialog.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class AlertDialog < Base\n def initialize(open: false, **attrs)\n @open = open\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n controller: \"ruby-ui--alert-dialog\",\n ruby_ui__alert_dialog_open_value: @open.to_s\n },\n class: \"inline-block\"\n }\n end\n end\nend\n" + }, + { + "path": "alert_dialog_action.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class AlertDialogAction < Base\n def view_template(&)\n render RubyUI::Button.new(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n variant: :primary\n }\n end\n end\nend\n" + }, + { + "path": "alert_dialog_cancel.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class AlertDialogCancel < Base\n def view_template(&)\n render RubyUI::Button.new(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n variant: :outline,\n data: {\n action: \"click->ruby-ui--alert-dialog#dismiss\"\n },\n class: \"mt-2 sm:mt-0\"\n }\n end\n end\nend\n" + }, + { + "path": "alert_dialog_content.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class AlertDialogContent < Base\n def view_template(&block)\n template(**attrs) do\n div(data: {controller: \"ruby-ui--alert-dialog\"}) do\n background\n container(&block)\n end\n end\n end\n\n def background\n div(\n data_state: \"open\",\n class: \"fixed inset-0 z-50 bg-black/80 backdrop-blur-sm data-[state=open]:animate-in\",\n style: \"pointer-events:auto\",\n data_aria_hidden: \"true\",\n aria_hidden: \"true\"\n )\n end\n\n def container(&)\n div(\n role: \"alertdialog\",\n data_state: \"open\",\n class: \"flex flex-col fixed left-[50%] top-[50%] z-50 w-full max-w-lg max-h-screen overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:rounded-lg md:w-full\",\n style: \"pointer-events:auto\",\n &\n )\n end\n\n private\n\n def default_attrs\n {\n data: {\n ruby_ui__alert_dialog_target: \"content\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "alert_dialog_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"ruby-ui--alert-dialog\"\nexport default class extends Controller {\n static targets = [\"content\"];\n static values = {\n open: {\n type: Boolean,\n default: false,\n },\n };\n\n connect() {\n if (this.openValue) {\n this.open();\n }\n }\n\n open() {\n document.body.insertAdjacentHTML(\"beforeend\", this.contentTarget.innerHTML);\n // prevent scroll on body\n document.body.classList.add(\"overflow-hidden\");\n }\n\n dismiss(e) {\n // allow scroll on body\n document.body.classList.remove(\"overflow-hidden\");\n // remove the element\n this.element.remove();\n }\n}\n" + }, + { + "path": "alert_dialog_description.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class AlertDialogDescription < Base\n def view_template(&)\n p(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"text-sm text-muted-foreground\"\n }\n end\n end\nend\n" + }, + { + "path": "alert_dialog_footer.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class AlertDialogFooter < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 rtl:space-x-reverse\"\n }\n end\n end\nend\n" + }, + { + "path": "alert_dialog_header.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class AlertDialogHeader < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"flex flex-col space-y-2 text-center sm:text-left rtl:sm:text-right\"\n }\n end\n end\nend\n" + }, + { + "path": "alert_dialog_title.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class AlertDialogTitle < Base\n def view_template(&)\n h2(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"text-lg font-semibold\"\n }\n end\n end\nend\n" + }, + { + "path": "alert_dialog_trigger.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class AlertDialogTrigger < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {action: \"click->ruby-ui--alert-dialog#open\"},\n class: \"inline-block\"\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [ + "Button" + ], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component AlertDialog", + "docs_markdown": "", + "examples": [] + }, + "aspect_ratio": { + "name": "AspectRatio", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "aspect_ratio.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class AspectRatio < Base\n def initialize(aspect_ratio: \"16/9\", **attrs)\n raise \"aspect_ratio must be in the format of a string with a slash in the middle (eg. '16/9', '1/1')\" unless aspect_ratio.is_a?(String) && aspect_ratio.include?(\"/\")\n\n @aspect_ratio = aspect_ratio\n super(**attrs)\n end\n\n def view_template(&block)\n div(\n class: \"relative w-full\",\n style: \"padding-bottom: #{padding_bottom}%;\"\n ) do\n div(**attrs, &block)\n end\n end\n\n private\n\n def padding_bottom\n @aspect_ratio.split(\"/\").map(&:to_i).reverse.reduce(&:fdiv) * 100\n end\n\n def default_attrs\n {\n class: \"bg-muted absolute inset-0 [&>img]:object-cover [&>img]:absolute [&>img]:h-full [&>img]:w-full [&>img]:inset-0 [&>img]:text-transparent\"\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component AspectRatio", + "docs_markdown": "", + "examples": [] + }, + "avatar": { + "name": "Avatar", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "avatar.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Avatar < Base\n SIZES = {\n xs: \"h-4 w-4 text-[0.5rem]\",\n sm: \"h-6 w-6 text-xs\",\n md: \"h-10 w-10 text-base\",\n lg: \"h-14 w-14 text-xl\",\n xl: \"h-20 w-20 text-3xl\"\n }\n\n def initialize(size: :md, **attrs)\n @size = size\n @size_classes = SIZES[@size]\n super(**attrs)\n end\n\n def view_template(&)\n span(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: [\"relative flex shrink-0 overflow-hidden rounded-full\", @size_classes]\n }\n end\n end\nend\n" + }, + { + "path": "avatar_fallback.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class AvatarFallback < Base\n def view_template(&)\n span(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"flex h-full w-full items-center justify-center rounded-full bg-muted\"\n }\n end\n end\nend\n" + }, + { + "path": "avatar_image.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class AvatarImage < Base\n def initialize(src:, alt: \"\", **attrs)\n @src = src\n @alt = alt\n super(**attrs)\n end\n\n def view_template\n img(**attrs)\n end\n\n private\n\n def default_attrs\n {\n loading: \"lazy\",\n class: \"aspect-square h-full w-full\",\n alt: @alt,\n src: @src\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Avatar", + "docs_markdown": "", + "examples": [] + }, + "badge": { + "name": "Badge", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "badge.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Badge < Base\n SIZES = {\n sm: \"px-1.5 py-0.5 text-xs\",\n md: \"px-2 py-1 text-xs\",\n lg: \"px-3 py-1 text-sm\"\n }\n\n COLORS = {\n primary: \"text-primary bg-primary/5 ring-primary/20\",\n secondary: \"text-secondary bg-secondary/10 ring-secondary/20\",\n outline: \"text-foreground bg-background ring-border\",\n destructive: \"text-destructive bg-destructive/10 ring-destructive/20\",\n success: \"text-success bg-success/10 ring-success/20\",\n warning: \"text-warning bg-warning/10 ring-warning/20\",\n slate: \"text-slate-500 bg-slate-500/10 ring-slate-500/20\",\n gray: \"text-gray-500 bg-gray-500/10 ring-gray-500/20\",\n zinc: \"text-zinc-500 bg-zinc-500/10 ring-zinc-500/20\",\n neutral: \"text-neutral-500 bg-neutral-500/10 ring-neutral-500/20\",\n stone: \"text-stone-500 bg-stone-500/10 ring-stone-500/20\",\n red: \"text-red-500 bg-red-500/10 ring-red-500/20\",\n orange: \"text-orange-500 bg-orange-500/10 ring-orange-500/20\",\n amber: \"text-amber-500 bg-amber-500/10 ring-amber-500/20\",\n yellow: \"text-yellow-500 bg-yellow-500/10 ring-yellow-500/20\",\n lime: \"text-lime-500 bg-lime-500/10 ring-lime-500/20\",\n green: \"text-green-500 bg-green-500/10 ring-green-500/20\",\n emerald: \"text-emerald-500 bg-emerald-500/10 ring-emerald-500/20\",\n teal: \"text-teal-500 bg-teal-500/10 ring-teal-500/20\",\n cyan: \"text-cyan-500 bg-cyan-500/10 ring-cyan-500/20\",\n sky: \"text-sky-500 bg-sky-500/10 ring-sky-500/20\",\n blue: \"text-blue-500 bg-blue-500/10 ring-blue-500/20\",\n indigo: \"text-indigo-500 bg-indigo-500/10 ring-indigo-500/20\",\n violet: \"text-violet-500 bg-violet-500/10 ring-violet-500/20\",\n purple: \"text-purple-500 bg-purple-500/10 ring-purple-500/20\",\n fuchsia: \"text-fuchsia-500 bg-fuchsia-500/10 ring-fuchsia-500/20\",\n pink: \"text-pink-500 bg-pink-500/10 ring-pink-500/20\",\n rose: \"text-rose-500 bg-rose-500/10 ring-rose-500/20\"\n }\n\n def initialize(variant: :primary, size: :md, **args)\n @variant = variant\n @size = size\n super(**args)\n end\n\n def view_template(&)\n span(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: [\"inline-flex items-center rounded-md font-medium ring-1 ring-inset\", SIZES[@size], COLORS[@variant]]\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Badge", + "docs_markdown": "", + "examples": [] + }, + "breadcrumb": { + "name": "Breadcrumb", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "breadcrumb.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Breadcrumb < Base\n def view_template(&)\n nav(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n aria: {label: \"breadcrumb\"}\n }\n end\n end\nend\n" + }, + { + "path": "breadcrumb_ellipsis.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class BreadcrumbEllipsis < Base\n def view_template(&)\n span(**attrs) do\n icon\n span(class: \"sr-only\") { \"More\" }\n end\n end\n\n private\n\n def icon\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n class: \"w-4 h-4\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\"\n ) do |s|\n s.circle(cx: \"12\", cy: \"12\", r: \"1\")\n s.circle(cx: \"19\", cy: \"12\", r: \"1\")\n s.circle(cx: \"5\", cy: \"12\", r: \"1\")\n end\n end\n\n def default_attrs\n {\n aria: {hidden: true},\n class: \"flex h-9 w-9 items-center justify-center\",\n role: \"presentation\"\n }\n end\n end\nend\n" + }, + { + "path": "breadcrumb_item.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class BreadcrumbItem < Base\n def view_template(&)\n li(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"inline-flex items-center gap-1.5\"\n }\n end\n end\nend\n" + }, + { + "path": "breadcrumb_link.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class BreadcrumbLink < Base\n def initialize(href: \"#\", **attrs)\n @href = href\n super(**attrs)\n end\n\n def view_template(&)\n a(href: @href, **attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"transition-colors hover:text-foreground\"\n }\n end\n end\nend\n" + }, + { + "path": "breadcrumb_list.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class BreadcrumbList < Base\n def view_template(&)\n ol(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5\"\n }\n end\n end\nend\n" + }, + { + "path": "breadcrumb_page.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class BreadcrumbPage < Base\n def view_template(&)\n span(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n aria: {disabled: true, current: \"page\"},\n class: \"font-normal text-foreground\",\n role: \"link\"\n }\n end\n end\nend\n" + }, + { + "path": "breadcrumb_separator.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class BreadcrumbSeparator < Base\n def view_template(&block)\n li(**attrs) do\n if block\n block.call\n else\n icon\n end\n end\n end\n\n private\n\n def icon\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n class: \"w-4 h-4\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\"\n ) { |s| s.path(d: \"m9 18 6-6-6-6\") }\n end\n\n def default_attrs\n {\n aria: {hidden: true},\n class: \"[&>svg]:w-3.5 [&>svg]:h-3.5\",\n role: \"presentation\"\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Breadcrumb", + "docs_markdown": "", + "examples": [] + }, + "button": { + "name": "Button", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "button.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Button < Base\n BASE_CLASSES = [\n \"whitespace-nowrap inline-flex items-center justify-center rounded-md font-medium transition-colors\",\n \"disabled:pointer-events-none disabled:opacity-50\",\n \"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\",\n \"aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-disabled:cursor-not-allowed\"\n ].freeze\n\n def initialize(type: :button, variant: :primary, size: :md, icon: false, **attrs)\n @type = type\n @variant = variant.to_sym\n @size = size.to_sym\n @icon = icon\n super(**attrs)\n end\n\n def view_template(&)\n button(**attrs, &)\n end\n\n private\n\n def size_classes\n if @icon\n case @size\n when :sm then \"h-6 w-6\"\n when :md then \"h-9 w-9\"\n when :lg then \"h-10 w-10\"\n when :xl then \"h-12 w-12\"\n end\n else\n case @size\n when :sm then \"px-3 py-1.5 h-8 text-xs\"\n when :md then \"px-4 py-2 h-9 text-sm\"\n when :lg then \"px-4 py-2 h-10 text-base\"\n when :xl then \"px-6 py-3 h-12 text-base\"\n end\n end\n end\n\n def primary_classes\n [\n BASE_CLASSES,\n size_classes,\n \"bg-primary text-primary-foreground shadow\",\n \"hover:bg-primary/90\"\n ]\n end\n\n def link_classes\n [\n BASE_CLASSES,\n size_classes,\n \"text-primary underline-offset-4\",\n \"hover:underline\"\n ]\n end\n\n def secondary_classes\n [\n BASE_CLASSES,\n size_classes,\n \"bg-secondary text-secondary-foreground\",\n \"hover:bg-opacity-80\"\n ]\n end\n\n def destructive_classes\n [\n BASE_CLASSES,\n size_classes,\n \"bg-destructive text-white shadow-sm\",\n \"[a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20\",\n \"dark:focus-visible:ring-destructive/40 dark:bg-destructive/60\"\n ]\n end\n\n def outline_classes\n [\n BASE_CLASSES,\n size_classes,\n \"border border-input bg-background shadow-sm\",\n \"hover:bg-accent hover:text-accent-foreground\"\n ]\n end\n\n def ghost_classes\n [\n BASE_CLASSES,\n size_classes,\n \"hover:bg-accent hover:text-accent-foreground\"\n ]\n end\n\n def default_classes\n case @variant\n when :primary then primary_classes\n when :link then link_classes\n when :secondary then secondary_classes\n when :destructive then destructive_classes\n when :outline then outline_classes\n when :ghost then ghost_classes\n end\n end\n\n def default_attrs\n {type: @type, class: default_classes}\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Button", + "docs_markdown": "", + "examples": [] + }, + "calendar": { + "name": "Calendar", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "calendar.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Calendar < Base\n def initialize(selected_date: nil, input_id: nil, date_format: \"yyyy-MM-dd\", **attrs)\n @selected_date = selected_date\n @input_id = input_id\n @date_format = date_format\n super(**attrs)\n end\n\n def view_template\n div(**attrs) do\n RubyUI.CalendarHeader do\n RubyUI.CalendarTitle\n RubyUI.CalendarPrev\n RubyUI.CalendarNext\n end\n RubyUI.CalendarBody # Where the calendar is rendered (Weekdays and Days)\n RubyUI.CalendarWeekdays # Template for the weekdays\n RubyUI.CalendarDays # Template for the days\n end\n end\n\n private\n\n def default_attrs\n {\n class: \"p-3 space-y-4\",\n data: {\n controller: \"ruby-ui--calendar\",\n ruby_ui__calendar_selected_date_value: @selected_date&.to_s,\n ruby_ui__calendar_format_value: @date_format,\n ruby_ui__calendar_ruby_ui__calendar_input_outlet: @input_id\n }\n }\n end\n end\nend\n" + }, + { + "path": "calendar_body.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CalendarBody < Base\n def view_template\n table(**attrs)\n end\n\n private\n\n def default_attrs\n {\n data: {\n ruby_ui__calendar_target: \"calendar\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "calendar_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\";\nimport Mustache from \"mustache\";\n\nexport default class extends Controller {\n static targets = [\n \"calendar\",\n \"title\",\n \"weekdaysTemplate\",\n \"selectedDateTemplate\",\n \"todayDateTemplate\",\n \"currentMonthDateTemplate\",\n \"otherMonthDateTemplate\",\n ];\n static values = {\n selectedDate: {\n type: String,\n default: null,\n },\n viewDate: {\n type: String,\n default: new Date().toISOString().slice(0, 10),\n },\n format: {\n type: String,\n default: \"yyyy-MM-dd\", // Default format\n },\n };\n static outlets = [\"ruby-ui--calendar-input\"];\n\n initialize() {\n this.updateCalendar(); // Initial calendar render\n }\n\n nextMonth(e) {\n e.preventDefault();\n this.viewDateValue = this.adjustMonth(1);\n }\n\n prevMonth(e) {\n e.preventDefault();\n this.viewDateValue = this.adjustMonth(-1);\n }\n\n selectDay(e) {\n e.preventDefault();\n // Set the selected date value\n this.selectedDateValue = e.currentTarget.dataset.day;\n }\n\n selectedDateValueChanged(value, prevValue) {\n // update the viewDateValue to the first day of month of the selected date (This will trigger updateCalendar() function)\n const newViewDate = new Date(this.selectedDateValue);\n newViewDate.setDate(2); // set the day to the 2nd (to avoid issues with months with different number of days and timezones)\n this.viewDateValue = newViewDate.toISOString().slice(0, 10);\n\n // Re-render the calendar\n this.updateCalendar();\n\n // update the input value\n this.rubyUiCalendarInputOutlets.forEach((outlet) => {\n const formattedDate = this.formatDate(this.selectedDate());\n outlet.setValue(formattedDate);\n });\n }\n\n viewDateValueChanged(value, prevValue) {\n this.updateCalendar();\n }\n\n adjustMonth(adjustment) {\n const date = this.viewDate();\n date.setDate(2); // set the day to the 2nd (to avoid issues with months with different number of days and timezones)\n date.setMonth(date.getMonth() + adjustment);\n return date.toISOString().slice(0, 10);\n }\n\n updateCalendar() {\n // Update the title with month and year\n this.titleTarget.textContent = this.monthAndYear();\n this.calendarTarget.innerHTML = this.calendarHTML();\n }\n\n calendarHTML() {\n return this.weekdaysTemplateTarget.innerHTML + this.calendarDays();\n }\n\n calendarDays() {\n return this.getFullWeeksStartAndEndInMonth()\n .map((week) => this.renderWeek(week))\n .join(\"\");\n }\n\n renderWeek(week) {\n const days = week\n .map((day) => {\n return this.renderDay(day);\n })\n .join(\"\");\n return `${days}`;\n }\n\n renderDay(day) {\n const today = new Date();\n let dateHTML = \"\";\n const data = { day: day, dayDate: day.getDate() };\n\n if (day.toDateString() === this.selectedDate().toDateString()) {\n // selectedDate\n // Render the selected date template target innerHTML with Mustache\n dateHTML = Mustache.render(\n this.selectedDateTemplateTarget.innerHTML,\n data,\n );\n } else if (day.toDateString() === today.toDateString()) {\n // todayDate\n dateHTML = Mustache.render(this.todayDateTemplateTarget.innerHTML, data);\n } else if (day.getMonth() === this.viewDate().getMonth()) {\n // currentMonthDate\n dateHTML = Mustache.render(\n this.currentMonthDateTemplateTarget.innerHTML,\n data,\n );\n } else {\n // otherMonthDate\n dateHTML = Mustache.render(\n this.otherMonthDateTemplateTarget.innerHTML,\n data,\n );\n }\n return dateHTML;\n }\n\n monthAndYear() {\n const month = this.viewDate().toLocaleString(\"en-US\", { month: \"long\" });\n const year = this.viewDate().getFullYear();\n return `${month} ${year}`;\n }\n\n selectedDate() {\n return new Date(this.selectedDateValue);\n }\n\n viewDate() {\n return this.viewDateValue\n ? new Date(this.viewDateValue)\n : this.selectedDate();\n }\n\n getFullWeeksStartAndEndInMonth() {\n const month = this.viewDate().getMonth();\n const year = this.viewDate().getFullYear();\n\n let weeks = [],\n firstDate = new Date(year, month, 1),\n lastDate = new Date(year, month + 1, 0),\n numDays = lastDate.getDate();\n\n let start = 1;\n let end;\n if (firstDate.getDay() === 1) {\n end = 7;\n } else if (firstDate.getDay() === 0) {\n let preMonthEndDay = new Date(year, month, 0);\n start = preMonthEndDay.getDate() - 6 + 1;\n end = 1;\n } else {\n let preMonthEndDay = new Date(year, month, 0);\n start = preMonthEndDay.getDate() + 1 - firstDate.getDay() + 1;\n end = 7 - firstDate.getDay() + 1;\n weeks.push({\n start: start,\n end: end,\n });\n start = end + 1;\n end = end + 7;\n }\n while (start <= numDays) {\n weeks.push({\n start: start,\n end: end,\n });\n start = end + 1;\n end = end + 7;\n end = start === 1 && end === 8 ? 1 : end;\n if (end > numDays && start <= numDays) {\n end = end - numDays;\n weeks.push({\n start: start,\n end: end,\n });\n break;\n }\n }\n // *** the magic starts here\n return weeks.map(({ start, end }, index) => {\n const sub = +(start > end && index === 0);\n return Array.from({ length: 7 }, (_, index) => {\n const date = new Date(year, month - sub, start + index);\n return date;\n });\n });\n }\n\n formatDate(date) {\n const format = this.formatValue;\n const day = date.getDate();\n const month = date.getMonth() + 1;\n const year = date.getFullYear();\n const hours = date.getHours();\n const minutes = date.getMinutes();\n const seconds = date.getSeconds();\n const dayOfWeek = date.toLocaleString(\"en-US\", { weekday: \"long\" });\n const monthName = date.toLocaleString(\"en-US\", { month: \"long\" });\n const daySuffix = this.getDaySuffix(day);\n\n const map = {\n yyyy: year,\n MM: (\"0\" + month).slice(-2),\n dd: (\"0\" + day).slice(-2),\n HH: (\"0\" + hours).slice(-2),\n mm: (\"0\" + minutes).slice(-2),\n ss: (\"0\" + seconds).slice(-2),\n EEEE: dayOfWeek,\n MMMM: monthName,\n do: day + daySuffix,\n PPPP: `${dayOfWeek}, ${monthName} ${day}${daySuffix}, ${year}`,\n };\n\n const formattedDate = format.replace(\n /yyyy|MM|dd|HH|mm|ss|EEEE|MMMM|do|PPPP/g,\n (matched) => map[matched],\n );\n return formattedDate;\n }\n\n getDaySuffix(day) {\n if (day > 3 && day < 21) return \"th\";\n switch (day % 10) {\n case 1:\n return \"st\";\n case 2:\n return \"nd\";\n case 3:\n return \"rd\";\n default:\n return \"th\";\n }\n }\n}\n" + }, + { + "path": "calendar_days.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CalendarDays < Base\n BASE_CLASS = \"inline-flex items-center justify-center rounded-md text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-8 w-8 p-0 font-normal aria-selected:opacity-100\"\n\n def view_template\n render_selected_date_template\n render_today_date_template\n render_current_month_date_template\n render_other_month_date_template\n end\n\n private\n\n def render_selected_date_template\n date_template(\"selectedDateTemplate\") do\n button(\n data_day: \"{{day}}\",\n data_action: \"click->ruby-ui--calendar#selectDay\",\n name: \"day\",\n class:\n [\n BASE_CLASS,\n \"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground\"\n ],\n role: \"gridcell\",\n tabindex: \"0\",\n type: \"button\",\n aria_selected: \"true\"\n ) { \"{{dayDate}}\" }\n end\n end\n\n def render_today_date_template\n date_template(\"todayDateTemplate\") do\n button(\n data_day: \"{{day}}\",\n data_action: \"click->ruby-ui--calendar#selectDay\",\n name: \"day\",\n class:\n [\n BASE_CLASS,\n \"bg-accent text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground\"\n ],\n role: \"gridcell\",\n tabindex: \"-1\",\n type: \"button\"\n ) { \"{{dayDate}}\" }\n end\n end\n\n def render_current_month_date_template\n date_template(\"currentMonthDateTemplate\") do\n button(\n data_day: \"{{day}}\",\n data_action: \"click->ruby-ui--calendar#selectDay\",\n name: \"day\",\n class:\n [\n BASE_CLASS,\n \"bg-background text-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground\"\n ],\n role: \"gridcell\",\n tabindex: \"-1\",\n type: \"button\"\n ) { \"{{dayDate}}\" }\n end\n end\n\n def render_other_month_date_template\n date_template(\"otherMonthDateTemplate\") do\n button(\n data_day: \"{{day}}\",\n data_action: \" click->ruby-ui--calendar#selectDay\",\n name: \"day\",\n class:\n [\n BASE_CLASS,\n \"bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground\"\n ],\n role: \"gridcell\",\n tabindex: \"-1\",\n type: \"button\"\n ) { \"{{dayDate}}\" }\n end\n end\n\n def date_template(target, &block)\n template(data: {ruby_ui__calendar_target: target}) do\n td(\n class:\n \"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected])]:rounded-md\",\n role: \"presentation\",\n &block\n )\n end\n end\n\n def default_attrs\n {}\n end\n end\nend\n" + }, + { + "path": "calendar_header.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CalendarHeader < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"flex justify-center pt-1 relative items-center\"\n }\n end\n end\nend\n" + }, + { + "path": "calendar_input_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\"\n\n// Connects to data-controller=\"input\"\nexport default class extends Controller {\n setValue(value) {\n this.element.value = value\n }\n}\n" + }, + { + "path": "calendar_next.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CalendarNext < Base\n def view_template(&block)\n button(**attrs) do\n icon\n end\n end\n\n private\n\n def icon\n svg(\n width: \"15\",\n height: \"15\",\n viewbox: \"0 0 15 15\",\n fill: \"none\",\n xmlns: \"http://www.w3.org/2000/svg\",\n class: \"h-4 w-4\"\n ) do |s|\n s.path(\n d:\n \"M6.1584 3.13508C6.35985 2.94621 6.67627 2.95642 6.86514 3.15788L10.6151 7.15788C10.7954 7.3502 10.7954 7.64949 10.6151 7.84182L6.86514 11.8418C6.67627 12.0433 6.35985 12.0535 6.1584 11.8646C5.95694 11.6757 5.94673 11.3593 6.1356 11.1579L9.565 7.49985L6.1356 3.84182C5.94673 3.64036 5.95694 3.32394 6.1584 3.13508Z\",\n fill: \"currentColor\",\n fill_rule: \"evenodd\",\n clip_rule: \"evenodd\"\n )\n end\n end\n\n def default_attrs\n {\n name: \"next-month\",\n aria_label: \"Go to next month\",\n class:\n \"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input hover:bg-accent hover:text-accent-foreground h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute right-1\",\n type: \"button\",\n data_action: \"click->ruby-ui--calendar#nextMonth\"\n }\n end\n end\nend\n" + }, + { + "path": "calendar_prev.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CalendarPrev < Base\n def view_template(&block)\n button(**attrs) do\n icon\n end\n end\n\n private\n\n def icon\n svg(\n width: \"15\",\n height: \"15\",\n viewbox: \"0 0 15 15\",\n fill: \"none\",\n xmlns: \"http://www.w3.org/2000/svg\",\n class: \"h-4 w-4\"\n ) do |s|\n s.path(\n d:\n \"M8.84182 3.13514C9.04327 3.32401 9.05348 3.64042 8.86462 3.84188L5.43521 7.49991L8.86462 11.1579C9.05348 11.3594 9.04327 11.6758 8.84182 11.8647C8.64036 12.0535 8.32394 12.0433 8.13508 11.8419L4.38508 7.84188C4.20477 7.64955 4.20477 7.35027 4.38508 7.15794L8.13508 3.15794C8.32394 2.95648 8.64036 2.94628 8.84182 3.13514Z\",\n fill: \"currentColor\",\n fill_rule: \"evenodd\",\n clip_rule: \"evenodd\"\n )\n end\n end\n\n def default_attrs\n {\n name: \"previous-month\",\n aria_label: \"Go to previous month\",\n class:\n \"rdp-button_reset rdp-button inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input hover:bg-accent hover:text-accent-foreground h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute left-1\",\n type: \"button\",\n data_action: \"click->ruby-ui--calendar#prevMonth\"\n }\n end\n end\nend\n" + }, + { + "path": "calendar_title.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CalendarTitle < Base\n def initialize(default: \"Month Year\", **attrs)\n @default = default\n super(**attrs)\n end\n\n def view_template\n div(**attrs) { @default }\n end\n\n private\n\n def default_attrs\n {\n class: \"text-sm font-medium\",\n aria_live: \"polite\",\n role: \"presentation\",\n data: {\n ruby_ui__calendar_target: \"title\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "calendar_weekdays.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CalendarWeekdays < Base\n DAYS = %w[Monday Tuesday Wednesday Thursday Friday Saturday Sunday].freeze\n\n def view_template\n template(data: {ruby_ui__calendar_target: \"weekdaysTemplate\"}) do\n thead(**attrs) do\n tr(class: \"flex\") do\n DAYS.each do |day|\n render_day(day)\n end\n end\n end\n end\n end\n\n private\n\n def render_day(day)\n th(\n scope: \"col\",\n class: \"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]\",\n aria_label: day\n ) { day[0..1] }\n end\n\n def default_attrs\n {}\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [ + "mustache" + ], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Calendar", + "docs_markdown": "", + "examples": [] + }, + "card": { + "name": "Card", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "card.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Card < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"rounded-xl border bg-background shadow\"\n }\n end\n end\nend\n" + }, + { + "path": "card_content.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CardContent < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"p-6 pt-0\"\n }\n end\n end\nend\n" + }, + { + "path": "card_description.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CardDescription < Base\n def view_template(&)\n p(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"text-sm text-muted-foreground\"\n }\n end\n end\nend\n" + }, + { + "path": "card_footer.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CardFooter < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"items-center p-6 pt-0\"\n }\n end\n end\nend\n" + }, + { + "path": "card_header.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CardHeader < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"flex flex-col space-y-1.5 p-6\"\n }\n end\n end\nend\n" + }, + { + "path": "card_title.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CardTitle < Base\n def view_template(&)\n h3(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"font-semibold leading-none tracking-tight\"\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Card", + "docs_markdown": "", + "examples": [] + }, + "carousel": { + "name": "Carousel", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "carousel.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Carousel < Base\n def initialize(orientation: :horizontal, options: {}, **user_attrs)\n @orientation = orientation\n @options = options\n\n super(**user_attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: [\"relative group\", orientation_classes],\n role: \"region\",\n aria_roledescription: \"carousel\",\n data: {\n controller: \"ruby-ui--carousel\",\n ruby_ui__carousel_options_value: default_options.merge(@options).to_json,\n action: %w[\n keydown.right->ruby-ui--carousel#scrollNext:prevent\n keydown.left->ruby-ui--carousel#scrollPrev:prevent\n ]\n }\n }\n end\n\n def default_options\n {\n axis: (@orientation == :horizontal) ? \"x\" : \"y\"\n }\n end\n\n def orientation_classes\n (@orientation == :horizontal) ? \"is-horizontal\" : \"is-vertical\"\n end\n end\nend\n" + }, + { + "path": "carousel_content.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CarouselContent < Base\n def view_template(&)\n div(class: \"overflow-hidden\", data: {ruby_ui__carousel_target: \"viewport\"}) do\n div(**attrs, &)\n end\n end\n\n private\n\n def default_attrs\n {\n class: [\n \"flex\",\n \"group-[.is-horizontal]:-ml-4\",\n \"group-[.is-vertical]:-mt-4 group-[.is-vertical]:flex-col\"\n ]\n }\n end\n end\nend\n" + }, + { + "path": "carousel_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\";\nimport EmblaCarousel from 'embla-carousel'\n\nconst DEFAULT_OPTIONS = {\n loop: true\n}\n\nexport default class extends Controller {\n static values = {\n options: {\n type: Object,\n default: {},\n }\n }\n static targets = [\"viewport\", \"nextButton\", \"prevButton\"]\n\n connect() {\n this.initCarousel(this.#mergedOptions)\n }\n\n disconnect() {\n this.destroyCarousel()\n }\n\n initCarousel(options, plugins = []) {\n this.carousel = EmblaCarousel(this.viewportTarget, options, plugins)\n\n this.carousel.on(\"init\", this.#updateControls.bind(this))\n this.carousel.on(\"reInit\", this.#updateControls.bind(this))\n this.carousel.on(\"select\", this.#updateControls.bind(this))\n }\n\n destroyCarousel() {\n this.carousel.destroy()\n }\n\n scrollNext() {\n this.carousel.scrollNext()\n }\n\n scrollPrev() {\n this.carousel.scrollPrev()\n }\n\n #updateControls() {\n this.#toggleButtonsDisabledState(this.nextButtonTargets, !this.carousel.canScrollNext())\n this.#toggleButtonsDisabledState(this.prevButtonTargets, !this.carousel.canScrollPrev())\n }\n\n #toggleButtonsDisabledState(buttons, isDisabled) {\n buttons.forEach((button) => button.disabled = isDisabled)\n }\n\n get #mergedOptions() {\n return {\n ...DEFAULT_OPTIONS,\n ...this.optionsValue\n }\n }\n}\n" + }, + { + "path": "carousel_item.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CarouselItem < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n role: \"group\",\n aria_roledescription: \"slide\",\n class: [\n \"min-w-0 shrink-0 grow-0 basis-full\",\n \"group-[.is-horizontal]:pl-4\",\n \"group-[.is-vertical]:pt-4\"\n ]\n }\n end\n end\nend\n" + }, + { + "path": "carousel_next.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CarouselNext < Base\n def view_template(&)\n Button(**attrs) do\n icon\n end\n end\n\n private\n\n def default_attrs\n {\n variant: :outline,\n icon: true,\n class: [\n \"absolute h-8 w-8 rounded-full\",\n \"group-[.is-horizontal]:-right-12 group-[.is-horizontal]:top-1/2 group-[.is-horizontal]:-translate-y-1/2\",\n \"group-[.is-vertical]:-bottom-12 group-[.is-vertical]:left-1/2 group-[.is-vertical]:-translate-x-1/2 group-[.is-vertical]:rotate-90\"\n ],\n disabled: true,\n data: {\n action: \"click->ruby-ui--carousel#scrollNext\",\n ruby_ui__carousel_target: \"nextButton\"\n }\n }\n end\n\n def icon\n svg(\n width: \"24\",\n height: \"24\",\n viewBox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n xmlns: \"http://www.w3.org/2000/svg\",\n class: \"w-4 h-4\"\n ) do |s|\n s.path(d: \"M5 12h14\")\n s.path(d: \"m12 5 7 7-7 7\")\n end\n end\n end\nend\n" + }, + { + "path": "carousel_previous.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CarouselPrevious < Base\n def view_template(&)\n Button(**attrs) do\n icon\n span(class: \"sr-only\") { \"Next slide\" }\n end\n end\n\n private\n\n def default_attrs\n {\n variant: :outline,\n icon: true,\n class: [\n \"absolute h-8 w-8 rounded-full\",\n \"group-[.is-horizontal]:-left-12 group-[.is-horizontal]:top-1/2 group-[.is-horizontal]:-translate-y-1/2\",\n \"group-[.is-vertical]:-top-12 group-[.is-vertical]:left-1/2 group-[.is-vertical]:-translate-x-1/2 group-[.is-vertical]:rotate-90\"\n ],\n disabled: true,\n data: {\n action: \"click->ruby-ui--carousel#scrollPrev\",\n ruby_ui__carousel_target: \"prevButton\"\n }\n }\n end\n\n def icon\n svg(\n width: \"24\",\n height: \"24\",\n viewBox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n xmlns: \"http://www.w3.org/2000/svg\",\n class: \"w-4 h-4\"\n ) do |s|\n s.path(d: \"m12 19-7-7 7-7\")\n s.path(d: \"M19 12H5\")\n end\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [ + "embla-carousel" + ], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Carousel", + "docs_markdown": "", + "examples": [] + }, + "chart": { + "name": "Chart", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "chart.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Chart < Base\n def initialize(options: {}, **attrs)\n @options = options.to_json\n super(**attrs)\n end\n\n def view_template(&)\n canvas(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data_controller: \"ruby-ui--chart\",\n data_ruby_ui__chart_options_value: @options\n }\n end\n end\nend\n" + }, + { + "path": "chart_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\"\nimport Chart from 'chart.js/auto'\n\n// Chart controller\nexport default class extends Controller {\n static values = {\n options: {\n type: Object,\n default: {},\n }\n }\n\n // Function to initialize the chart when the controller is connected\n connect() {\n this.initDarkModeObserver()\n this.initChart()\n }\n\n disconnect() {\n this.darkModeObserver?.disconnect()\n this.chart?.destroy()\n }\n\n // Function to initialize the chart\n initChart() {\n this.setColors()\n const ctx = this.element.getContext('2d');\n this.chart = new Chart(ctx, this.mergeOptionsWithDefaults());\n }\n\n setColors() {\n this.setDefaultColorsForChart()\n }\n\n getThemeColor(name) {\n const color = getComputedStyle(document.documentElement).getPropertyValue(`--${name}`)\n const [hue, saturation, lightness] = color.split(' ')\n return `hsl(${hue}, ${saturation}, ${lightness})`\n }\n\n defaultThemeColor() {\n return {\n backgroundColor: this.getThemeColor('background'),\n hoverBackgroundColor: this.getThemeColor('accent'),\n borderColor: this.getThemeColor('primary'),\n borderWidth: 1,\n }\n }\n\n // Function to set chart default colors\n setDefaultColorsForChart() {\n Chart.defaults.color = this.getThemeColor('muted-foreground') // font color\n Chart.defaults.borderColor = this.getThemeColor('border') // border color\n Chart.defaults.backgroundColor = this.getThemeColor('background') // background color\n\n // tooltip colors\n Chart.defaults.plugins.tooltip.backgroundColor = this.getThemeColor('background')\n Chart.defaults.plugins.tooltip.borderColor = this.getThemeColor('border')\n Chart.defaults.plugins.tooltip.titleColor = this.getThemeColor('foreground')\n Chart.defaults.plugins.tooltip.bodyColor = this.getThemeColor('muted-foreground')\n Chart.defaults.plugins.tooltip.borderWidth = 1\n\n // legend\n // options.plugins.legend.labels\n Chart.defaults.plugins.legend.labels.boxWidth = 12\n Chart.defaults.plugins.legend.labels.boxHeight = 12\n Chart.defaults.plugins.legend.labels.borderWidth = 0\n Chart.defaults.plugins.legend.labels.useBorderRadius = true\n Chart.defaults.plugins.legend.labels.borderRadius = this.getThemeColor('radius')\n }\n\n // Function to refresh the chart\n refreshChart() {\n // Destroy the chart if it's a valid Chart.js instance\n this.chart?.destroy()\n // Reinitialize the chart\n this.initChart()\n }\n\n // Function to initialize the dark mode observer\n initDarkModeObserver() {\n this.darkModeObserver = new MutationObserver(() => {\n this.refreshChart()\n })\n this.darkModeObserver.observe(document.documentElement, { attributeFilter: ['class'] })\n }\n\n // Function to merge the options with the defaults\n mergeOptionsWithDefaults() {\n return {\n ...this.optionsValue,\n data: {\n ...this.optionsValue.data,\n datasets: this.optionsValue.data.datasets.map((dataset) => {\n return {\n ...this.defaultThemeColor(),\n ...dataset,\n }\n })\n }\n }\n }\n}\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [ + "chart.js" + ], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Chart", + "docs_markdown": "", + "examples": [] + }, + "checkbox": { + "name": "Checkbox", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "checkbox.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Checkbox < Base\n def view_template\n input(**attrs)\n end\n\n private\n\n def default_attrs\n {\n type: \"checkbox\",\n data: {\n ruby_ui__form_field_target: \"input\",\n ruby_ui__checkbox_group_target: \"checkbox\",\n action: \"change->ruby-ui--checkbox-group#onChange change->ruby-ui--form-field#onInput invalid->ruby-ui--form-field#onInvalid\"\n },\n class: [\n \"peer h-4 w-4 shrink-0 rounded-sm border-input ring-offset-background accent-primary\",\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n \"checked:bg-primary checked:text-primary-foreground dark:checked:bg-secondary checked:text-primary checked:border-primary\",\n \"aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:pointer-events-none\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\"\n ]\n }\n end\n end\nend\n" + }, + { + "path": "checkbox_group.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CheckboxGroup < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n role: \"group\",\n data: {\n controller: \"ruby-ui--checkbox-group\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "checkbox_group_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\";\n\nexport default class extends Controller {\n static targets = [\"checkbox\"];\n\n connect() {\n this.#handleRequired();\n }\n\n onChange() {\n this.#handleRequired();\n }\n\n #handleRequired() {\n if (!this.element.hasAttribute(\"data-required\")) return;\n\n const checked = this.checkboxTargets.some(({ checked }) => checked);\n\n this.checkboxTargets.forEach((checkbox) => (checkbox.required = !checked));\n }\n}\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Checkbox", + "docs_markdown": "", + "examples": [] + }, + "clipboard": { + "name": "Clipboard", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "clipboard.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Clipboard < Base\n def initialize(options: {}, success: \"Copied!\", error: \"Copy Failed!\", **attrs)\n @options = options\n @success = success\n @error = error\n super(**attrs)\n end\n\n def view_template(&block)\n div(**attrs) do\n div(&block)\n success_popover\n error_popover\n end\n end\n\n private\n\n def success_popover\n ClipboardPopover(type: :success) { @success }\n end\n\n def error_popover\n ClipboardPopover(type: :error) { @error }\n end\n\n def default_attrs\n {\n data: {\n controller: \"ruby-ui--clipboard\",\n action: \"click@window->ruby-ui--clipboard#onClickOutside\",\n ruby_ui__clipboard_success_value: @success,\n ruby_ui__clipboard_error_value: @error,\n ruby_ui__clipboard_options_value: @options.to_json\n }\n }\n end\n end\nend\n" + }, + { + "path": "clipboard_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { computePosition, flip, shift } from \"@floating-ui/dom\";\n\n// Connects to data-controller=\"accordion\"\nexport default class extends Controller {\n static targets = ['trigger', 'source', 'successPopover', 'errorPopover']\n static values = {\n options: {\n type: Object,\n default: {},\n },\n }\n\n copy() {\n let sourceElement = this.sourceTarget.children[0];\n if (!sourceElement) {\n this.showErrorPopover();\n return;\n }\n let textToCopy = sourceElement.tagName === 'INPUT' ? sourceElement.value : sourceElement.innerText;\n navigator.clipboard.writeText(textToCopy).then(() => {\n this.#showSuccessPopover();\n }).catch(() => {\n this.#showErrorPopover();\n })\n }\n\n onClickOutside() {\n if (!this.successPopoverTarget.classList.contains(\"hidden\")) this.successPopoverTarget.classList.add(\"hidden\");\n if (!this.errorPopoverTarget.classList.contains(\"hidden\")) this.errorPopoverTarget.classList.add(\"hidden\");\n }\n\n #computeTooltip(popoverElement) {\n computePosition(this.triggerTarget, popoverElement, {\n placement: this.optionsValue.placement || \"top\",\n middleware: [flip(), shift()],\n }).then(({ x, y }) => {\n Object.assign(popoverElement.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n });\n }\n\n #showSuccessPopover() {\n this.#computeTooltip(this.successPopoverTarget);\n this.successPopoverTarget.classList.remove(\"hidden\");\n }\n\n #showErrorPopover() {\n this.#computeTooltip(this.errorPopoverTarget);\n this.errorPopoverTarget.classList.remove(\"hidden\");\n }\n}\n" + }, + { + "path": "clipboard_popover.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ClipboardPopover < Base\n def initialize(type:, **attrs)\n @type = type\n super(**attrs)\n end\n\n def view_template(&block)\n div(\n class: \"hidden\",\n style: \"width: max-content; position: absolute; top: 0; left: 0;\",\n data: {ruby_ui__clipboard_target: clipboard_target}\n ) do\n div(**attrs, &block)\n end\n end\n\n private\n\n def clipboard_target\n case @type\n when :success\n \"successPopover\"\n when :error\n \"errorPopover\"\n end\n end\n\n def default_attrs\n {\n data: {\n state: :open\n },\n class: \"z-50 rounded-md text-sm border bg-background px-2 py-0.5 text-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\"\n }\n end\n end\nend\n" + }, + { + "path": "clipboard_source.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ClipboardSource < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n ruby_ui__clipboard_target: \"source\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "clipboard_trigger.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ClipboardTrigger < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n ruby_ui__clipboard_target: \"trigger\",\n action: \"click->ruby-ui--clipboard#copy\"\n }\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [ + "@floating-ui/dom" + ], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Clipboard", + "docs_markdown": "", + "examples": [] + }, + "codeblock": { + "name": "Codeblock", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "codeblock.rb", + "content": "# frozen_string_literal: true\n\nrequire \"rouge\"\n\nmodule RubyUI\n class Codeblock < Base\n FORMATTER = ::Rouge::Formatters::HTML.new\n ROUGE_CSS = Rouge::Themes::Github.mode(:dark).render(scope: \".highlight\") # See themes here: https://rouge-ruby.github.io/docs/Rouge/CSSTheme.html\n\n def initialize(code, syntax:, clipboard: true, clipboard_success: \"Copied!\", clipboard_error: \"Copy failed!\", **attrs)\n @code = code\n @syntax = syntax.to_sym\n @clipboard = clipboard\n @clipboard_success = clipboard_success\n @clipboard_error = clipboard_error\n\n if @syntax == :ruby || @syntax == :html\n @code = @code.gsub(/(?:^|\\G) {2}/m, \"\t\")\n end\n\n super(**attrs)\n end\n\n def view_template\n style { ROUGE_CSS } # For faster load times, move this to the head of your document. (Also move ROUGE_CSS value to head of document)\n if @clipboard\n with_clipboard\n else\n codeblock\n end\n end\n\n private\n\n def default_attrs\n {\n style: {tab_size: 2},\n class: \"highlight text-sm max-h-[350px] after:content-none flex font-mono overflow-auto overflow-x rounded-md border !bg-stone-900 [&_pre]:p-4\"\n }\n end\n\n def with_clipboard\n RubyUI.Clipboard(success: @clipboard_success, error: @clipboard_error, class: \"relative\") do\n RubyUI.ClipboardSource do\n codeblock\n end\n div(class: \"absolute top-2 right-2\") do\n RubyUI.ClipboardTrigger do\n RubyUI.Button(variant: :ghost, size: :sm, icon: true, class: \"text-white hover:text-white hover:bg-white/20\") { clipboard_icon }\n end\n end\n end\n end\n\n def codeblock\n div(**attrs) do\n div(class: \"after:content-none\") do\n pre { raw(safe(FORMATTER.format(lexer.lex(@code)))) }\n end\n end\n end\n\n def lexer\n Rouge::Lexer.find(@syntax)\n end\n\n def clipboard_icon\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6\"\n )\n end\n end\n\n def check_icon\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z\"\n )\n end\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [ + "Button", + "Clipboard" + ], + "js_packages": [], + "gems": [ + "rouge" + ] + }, + "install_command": "rails g ruby_ui:component Codeblock", + "docs_markdown": "", + "examples": [] + }, + "collapsible": { + "name": "Collapsible", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "collapsible.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Collapsible < Base\n def initialize(open: false, **attrs)\n @open = open\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n controller: \"ruby-ui--collapsible\",\n ruby_ui__collapsible_open_value: @open\n }\n }\n end\n end\nend\n" + }, + { + "path": "collapsible_content.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CollapsibleContent < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {ruby_ui__collapsible_target: \"content\"},\n class: \"overflow-y-hidden\"\n }\n end\n end\nend\n" + }, + { + "path": "collapsible_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\"\n\n// Connects to data-controller=\"accordion\"\nexport default class extends Controller {\n static targets = ['content']\n static values = {\n open: {\n type: Boolean,\n default: false,\n },\n }\n\n connect() {\n // Set the initial state of the accordion\n this.openValue ? this.open() : this.close()\n }\n\n // Toggle the 'open' value\n toggle() {\n this.openValue = !this.openValue\n }\n\n // Handle changes in the 'open' value\n openValueChanged(isOpen, wasOpen) {\n if (isOpen) {\n this.open()\n } else {\n this.close()\n }\n }\n\n // Open the accordion content\n open() {\n if (this.hasContentTarget) {\n this.contentTarget.classList.remove('hidden')\n this.openValue = true\n }\n }\n\n // Close the accordion content\n close() {\n if (this.hasContentTarget) {\n this.contentTarget.classList.add('hidden')\n this.openValue = false\n }\n }\n}\n" + }, + { + "path": "collapsible_trigger.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CollapsibleTrigger < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n action: \"click->ruby-ui--collapsible#toggle\"\n }\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Collapsible", + "docs_markdown": "", + "examples": [] + }, + "combobox": { + "name": "Combobox", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "combobox.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Combobox < Base\n def initialize(term: nil, **)\n @term = term\n super(**)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n role: \"combobox\",\n data: {\n controller: \"ruby-ui--combobox\",\n ruby_ui__combobox_term_value: @term,\n action: \"turbo:morph@window->ruby-ui--combobox#updateTriggerContent\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "combobox_badge.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ComboboxBadge < Base\n def view_template(&)\n span(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-xs font-medium text-secondary-foreground\"\n }\n end\n end\nend\n" + }, + { + "path": "combobox_badge_trigger.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ComboboxBadgeTrigger < Base\n def initialize(placeholder: \"\", clear_button: false, **)\n @placeholder = placeholder\n @clear_button = clear_button\n super(**)\n end\n\n def view_template(&)\n div(**attrs) do\n div(data: {ruby_ui__combobox_target: \"badgeContainer\"}, class: \"hidden\")\n input(\n type: \"text\",\n class: \"flex-1 min-w-8 bg-transparent border-0 px-0 outline-none focus:ring-0 placeholder:text-muted-foreground text-sm\",\n autocomplete: \"off\",\n autocorrect: \"off\",\n spellcheck: \"false\",\n placeholder: @placeholder,\n data: {\n ruby_ui__combobox_target: \"badgeInput\",\n action: \"keyup->ruby-ui--combobox#filterItems input->ruby-ui--combobox#filterItems keydown.backspace->ruby-ui--combobox#handleBadgeInputBackspace\"\n }\n )\n render ComboboxClearButton.new if @clear_button\n end\n end\n\n private\n\n # JS-toggled classes (referenced here so Tailwind compiles them): h-auto min-h-9 pt-1.5\n def default_attrs\n {\n class: \"flex h-9 w-full flex-wrap items-center gap-1 rounded-md border border-input bg-background px-3 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 cursor-text\",\n data: {\n ruby_ui__combobox_target: \"trigger\",\n action: \"click->ruby-ui--combobox#openPopover focusin->ruby-ui--combobox#openPopover\"\n },\n aria: {\n haspopup: \"listbox\",\n expanded: \"false\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "combobox_checkbox.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ComboboxCheckbox < Base\n def view_template\n input(type: \"checkbox\", **attrs)\n end\n\n private\n\n def default_attrs\n {\n class: [\n \"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background accent-primary\",\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n \"checked:bg-primary checked:text-primary-foreground\",\n \"aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:pointer-events-none\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\"\n ],\n data: {\n ruby_ui__combobox_target: \"input\",\n action: \"ruby-ui--combobox#inputChanged\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "combobox_clear_button.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ComboboxClearButton < Base\n def view_template\n button(**attrs) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n width: \"24\",\n height: \"24\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n class: \"size-3.5\"\n ) do |s|\n s.path(d: \"M18 6 6 18\")\n s.path(d: \"m6 6 12 12\")\n end\n end\n end\n\n private\n\n def default_attrs\n {\n type: \"button\",\n class: \"ml-auto shrink-0 rounded-sm text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring hidden\",\n aria: {label: \"Clear selection\"},\n data: {\n ruby_ui__combobox_target: \"clearButton\",\n # JS implementation in combobox_controller.js\n action: \"ruby-ui--combobox#clearAll\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "combobox_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\";\nimport { computePosition, autoUpdate, offset, flip } from \"@floating-ui/dom\";\n\n// Connects to data-controller=\"ruby-ui--combobox\"\nexport default class extends Controller {\n static values = {\n term: String\n }\n\n static targets = [\n \"input\",\n \"toggleAll\",\n \"popover\",\n \"item\",\n \"emptyState\",\n \"searchInput\",\n \"trigger\",\n \"triggerContent\"\n ]\n\n selectedItemIndex = null\n\n connect() {\n this.updateTriggerContent()\n }\n\n disconnect() {\n if (this.cleanup) { this.cleanup() }\n }\n\n handlePopoverToggle(event) {\n // Keep ariaExpanded in sync with the actual popover state\n this.triggerTarget.ariaExpanded = event.newState === 'open' ? 'true' : 'false'\n }\n\n inputChanged(e) {\n this.updateTriggerContent()\n\n if (e.target.type == \"radio\") {\n this.closePopover()\n }\n\n if (this.hasToggleAllTarget && !e.target.checked) {\n this.toggleAllTarget.checked = false\n }\n }\n\n inputContent(input) {\n return input.dataset.text || input.parentElement.textContent\n }\n\n toggleAllItems() {\n const isChecked = this.toggleAllTarget.checked\n this.inputTargets.forEach(input => input.checked = isChecked)\n this.updateTriggerContent()\n }\n\n updateTriggerContent() {\n const checkedInputs = this.inputTargets.filter(input => input.checked)\n\n if (checkedInputs.length === 0) {\n this.triggerContentTarget.innerText = this.triggerTarget.dataset.placeholder\n } else if (this.termValue && checkedInputs.length > 1) {\n this.triggerContentTarget.innerText = `${checkedInputs.length} ${this.termValue}`\n } else {\n this.triggerContentTarget.innerText = checkedInputs.map((input) => this.inputContent(input)).join(\", \")\n }\n }\n\n togglePopover(event) {\n event.preventDefault()\n\n if (this.triggerTarget.ariaExpanded === \"true\") {\n this.closePopover()\n } else {\n this.openPopover(event)\n }\n }\n\n openPopover(event) {\n if (event) event.preventDefault()\n\n this.updatePopoverPosition()\n this.updatePopoverWidth()\n this.triggerTarget.ariaExpanded = \"true\"\n this.selectedItemIndex = null\n this.itemTargets.forEach(item => item.ariaCurrent = \"false\")\n this.popoverTarget.showPopover()\n }\n\n closePopover() {\n this.triggerTarget.ariaExpanded = \"false\"\n this.popoverTarget.hidePopover()\n }\n\n filterItems(e) {\n if ([\"ArrowDown\", \"ArrowUp\", \"Tab\", \"Enter\"].includes(e.key)) {\n return\n }\n\n const filterTerm = this.searchInputTarget.value.toLowerCase()\n\n if (this.hasToggleAllTarget) {\n if (filterTerm) this.toggleAllTarget.parentElement.classList.add(\"hidden\")\n else this.toggleAllTarget.parentElement.classList.remove(\"hidden\")\n }\n\n let resultCount = 0\n\n this.selectedItemIndex = null\n\n this.inputTargets.forEach((input) => {\n const text = this.inputContent(input).toLowerCase()\n\n if (text.indexOf(filterTerm) > -1) {\n input.parentElement.classList.remove(\"hidden\")\n resultCount++\n } else {\n input.parentElement.classList.add(\"hidden\")\n }\n })\n\n this.emptyStateTarget.classList.toggle(\"hidden\", resultCount !== 0)\n }\n\n keyDownPressed() {\n if (this.selectedItemIndex !== null) {\n this.selectedItemIndex++\n } else {\n this.selectedItemIndex = 0\n }\n\n this.focusSelectedInput()\n }\n\n keyUpPressed() {\n if (this.selectedItemIndex !== null) {\n this.selectedItemIndex--\n } else {\n this.selectedItemIndex = -1\n }\n\n this.focusSelectedInput()\n }\n\n focusSelectedInput() {\n const visibleInputs = this.inputTargets.filter(input => !input.parentElement.classList.contains(\"hidden\"))\n\n this.wrapSelectedInputIndex(visibleInputs.length)\n\n visibleInputs.forEach((input, index) => {\n if (index == this.selectedItemIndex) {\n input.parentElement.ariaCurrent = \"true\"\n input.parentElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })\n } else {\n input.parentElement.ariaCurrent = \"false\"\n }\n })\n }\n\n keyEnterPressed(event) {\n event.preventDefault()\n const option = this.itemTargets.find(item => item.ariaCurrent === \"true\")\n\n if (option) {\n option.click()\n }\n }\n\n wrapSelectedInputIndex(length) {\n this.selectedItemIndex = ((this.selectedItemIndex % length) + length) % length\n }\n\n updatePopoverPosition() {\n this.cleanup = autoUpdate(this.triggerTarget, this.popoverTarget, () => {\n computePosition(this.triggerTarget, this.popoverTarget, {\n placement: 'bottom-start',\n middleware: [offset(4), flip()],\n }).then(({ x, y }) => {\n Object.assign(this.popoverTarget.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n });\n });\n }\n\n updatePopoverWidth() {\n this.popoverTarget.style.width = `${this.triggerTarget.offsetWidth}px`\n }\n}\n" + }, + { + "path": "combobox_empty_state.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ComboboxEmptyState < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n role: \"presentation\",\n class: \"hidden py-6 text-center text-sm\",\n data: {\n ruby_ui__combobox_target: \"emptyState\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "combobox_input_trigger.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ComboboxInputTrigger < Base\n def initialize(placeholder: \"\", **)\n @placeholder = placeholder\n super(**)\n end\n\n def view_template\n div(**attrs) do\n input(\n type: \"text\",\n placeholder: @placeholder,\n autocomplete: \"off\",\n autocorrect: \"off\",\n spellcheck: \"false\",\n class: \"flex-1 border-0 px-0 bg-transparent outline-none focus:ring-0 placeholder:text-muted-foreground text-sm disabled:cursor-not-allowed\",\n data: {\n ruby_ui__combobox_target: \"inputTrigger\",\n action: \"keyup->ruby-ui--combobox#filterItems input->ruby-ui--combobox#filterItems\"\n }\n )\n chevron_icon\n end\n end\n\n private\n\n def default_attrs\n {\n class: \"flex h-9 w-full items-center rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 aria-invalid:border-destructive\",\n data: {\n ruby_ui__combobox_target: \"trigger\",\n placeholder: @placeholder,\n action: \"click->ruby-ui--combobox#openPopover focusin->ruby-ui--combobox#openPopover\"\n },\n aria: {\n haspopup: \"listbox\",\n expanded: \"false\"\n }\n }\n end\n\n def chevron_icon\n span(class: \"shrink-0 flex items-center justify-center size-6 rounded-sm hover:bg-muted hover:text-foreground\") do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n width: \"24\",\n height: \"24\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n class: \"pointer-events-none size-4 text-muted-foreground\"\n ) do |s|\n s.path(d: \"m6 9 6 6 6-6\")\n end\n end\n end\n end\nend\n" + }, + { + "path": "combobox_item.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ComboboxItem < Base\n def view_template(&)\n label(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: [\n \"flex flex-row w-full text-wrap [&>span,&>div]:truncate gap-2 items-center rounded-sm px-2 py-1 text-sm outline-none cursor-pointer\",\n \"select-none has-[:checked]:bg-accent hover:bg-accent p-2\",\n \"[&>svg]:pointer-events-none [&>svg]:size-4 [&>svg]:shrink-0 aria-[current=true]:bg-accent aria-[current=true]:ring aria-[current=true]:ring-offset-2\",\n \"has-disabled:opacity-50 has-disabled:cursor-not-allowed\"\n ],\n role: \"option\",\n data: {\n ruby_ui__combobox_target: \"item\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "combobox_item_indicator.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ComboboxItemIndicator < Base\n def view_template\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n width: \"24\",\n height: \"24\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n **attrs\n ) do |s|\n s.path(d: \"M20 6 9 17l-5-5\")\n end\n end\n\n private\n\n def default_attrs\n {\n class: \"ml-auto size-4 shrink-0 opacity-0 peer-checked:opacity-100\"\n }\n end\n end\nend\n" + }, + { + "path": "combobox_list.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ComboboxList < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"flex flex-col gap-1 p-1 max-h-72 overflow-y-auto text-foreground\",\n role: \"listbox\"\n }\n end\n end\nend\n" + }, + { + "path": "combobox_list_group.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ComboboxListGroup < Base\n LABEL_CLASSES = \"before:content-[attr(label)] before:px-2 before:py-1.5 before:text-xs before:font-medium before:text-muted-foreground before:not-italic\"\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: [\"hidden has-[label:not(.hidden)]:flex flex-col py-1 gap-1 border-b\", LABEL_CLASSES],\n role: \"group\"\n }\n end\n end\nend\n" + }, + { + "path": "combobox_popover.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ComboboxPopover < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"inset-auto m-0 absolute border bg-background shadow-lg rounded-lg\",\n role: \"popover\",\n autofocus: true,\n popover: true,\n data: {\n ruby_ui__combobox_target: \"popover\",\n action: %w[\n toggle->ruby-ui--combobox#handlePopoverToggle\n keydown.down->ruby-ui--combobox#keyDownPressed\n keydown.up->ruby-ui--combobox#keyUpPressed\n keydown.enter->ruby-ui--combobox#keyEnterPressed\n keydown.esc->ruby-ui--combobox#closePopover:prevent\n resize@window->ruby-ui--combobox#updatePopoverWidth\n ]\n }\n }\n end\n end\nend\n" + }, + { + "path": "combobox_radio.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ComboboxRadio < Base\n def view_template\n input(type: \"radio\", **attrs)\n end\n\n private\n\n def default_attrs\n {\n class: [\n \"aspect-square h-4 w-4 rounded-full border border-primary accent-primary text-primary shadow\",\n \"focus:outline-none\",\n \"focus-visible:ring-1 focus-visible:ring-ring\",\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n \"checked:bg-primary checked:text-primary-foreground\",\n \"aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:pointer-events-none\"\n ],\n data: {\n ruby_ui__combobox_target: \"input\",\n ruby_ui__form_field_target: \"input\",\n action: %w[\n ruby-ui--combobox#inputChanged\n input->ruby-ui--form-field#onInput\n invalid->ruby-ui--form-field#onInvalid\n ]\n }\n }\n end\n end\nend\n" + }, + { + "path": "combobox_search_input.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ComboboxSearchInput < Base\n def initialize(placeholder:, **)\n @placeholder = placeholder\n super(**)\n end\n\n def view_template\n div class: \"flex text-muted-foreground items-center border-b px-3\" do\n icon\n input(**attrs)\n end\n end\n\n private\n\n def default_attrs\n {\n type: \"search\",\n role: \"searchbox\",\n autocorrect: \"off\",\n autocomplete: \"off\",\n spellcheck: \"false\",\n placeholder: @placeholder,\n class: [\n \"flex h-9 w-full rounded-md bg-transparent py-3 text-sm outline-none border-none\",\n \"focus:ring-0\",\n \"placeholder:text-muted-foreground\",\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n \"aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:pointer-events-none\"\n ],\n data: {\n ruby_ui__combobox_target: \"searchInput\",\n action: \"keyup->ruby-ui--combobox#filterItems search->ruby-ui--combobox#filterItems\"\n }\n }\n end\n\n def icon\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n class: \"mr-2 h-4 w-4 shrink-0 opacity-50\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\"\n ) do |s|\n s.circle(cx: \"11\", cy: \"11\", r: \"8\")\n s.path(\n d: \"m21 21-4.3-4.3\"\n )\n end\n end\n end\nend\n" + }, + { + "path": "combobox_toggle_all_checkbox.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ComboboxToggleAllCheckbox < Base\n def view_template\n input(type: \"checkbox\", **attrs)\n end\n\n private\n\n def default_attrs\n {\n class: [\n \"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background accent-primary\",\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n \"checked:bg-primary checked:text-primary-foreground\",\n \"aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:pointer-events-none\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\"\n ],\n data: {\n ruby_ui__combobox_target: \"toggleAll\",\n action: \"change->ruby-ui--combobox#toggleAllItems\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "combobox_trigger.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ComboboxTrigger < Base\n def initialize(placeholder: \"\", **)\n @placeholder = placeholder\n super(**)\n end\n\n def view_template\n button(**attrs) do\n span(class: \"truncate\", data: {ruby_ui__combobox_target: \"triggerContent\"}) do\n @placeholder\n end\n icon\n end\n end\n\n private\n\n def default_attrs\n {\n type: \"button\",\n class: [\n \"flex h-full w-full items-center whitespace-nowrap rounded-md text-sm ring-offset-background transition-colors border border-input bg-background h-9 px-4 py-2 justify-between\",\n \"hover:bg-accent hover:text-accent-foreground\",\n \"disabled:pointer-events-none disabled:opacity-50\",\n \"aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-disabled:cursor-not-allowed\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\"\n ],\n data: {\n placeholder: @placeholder,\n ruby_ui__combobox_target: \"trigger\",\n action: \"ruby-ui--combobox#togglePopover\"\n },\n aria: {\n haspopup: \"listbox\",\n expanded: \"false\"\n }\n }\n end\n\n def icon\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n class: \"ml-2 h-4 w-4 shrink-0 opacity-50\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\"\n ) do |s|\n s.path(\n d: \"m7 15 5 5 5-5\"\n )\n s.path(\n d: \"m7 9 5-5 5 5\"\n )\n end\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [ + "@floating-ui/dom" + ], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Combobox", + "docs_markdown": "", + "examples": [] + }, + "command": { + "name": "Command", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "command.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Command < Base\n def view_template(&)\n div(**attrs, &)\n end\n end\nend\n" + }, + { + "path": "command_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\";\nimport Fuse from \"fuse.js\";\n\n// Connects to data-controller=\"ruby-ui--command\"\nexport default class extends Controller {\n static targets = [\"input\", \"group\", \"item\", \"empty\", \"content\"];\n\n static values = {\n open: {\n type: Boolean,\n default: false,\n },\n };\n\n connect() {\n this.selectedIndex = -1;\n\n if (!this.hasInputTarget) {\n return;\n }\n\n this.inputTarget.focus();\n this.searchIndex = this.buildSearchIndex();\n this.toggleVisibility(this.emptyTargets, false);\n\n if (this.openValue && this.hasContentTarget) {\n this.open();\n }\n }\n\n open(e) {\n if (e) {\n e.preventDefault();\n }\n\n if (!this.hasContentTarget) {\n return;\n }\n\n document.body.insertAdjacentHTML(\"beforeend\", this.contentTarget.innerHTML);\n // prevent scroll on body\n document.body.classList.add(\"overflow-hidden\");\n }\n\n dismiss() {\n // allow scroll on body\n document.body.classList.remove(\"overflow-hidden\");\n // remove the element\n this.element.remove();\n }\n\n filter(e) {\n // Deselect any previously selected item\n this.deselectAll();\n\n const query = e.target.value.toLowerCase();\n if (query.length === 0) {\n this.resetVisibility();\n return;\n }\n\n this.toggleVisibility(this.itemTargets, false);\n\n const results = this.searchIndex.search(query);\n results.forEach((result) =>\n this.toggleVisibility([result.item.element], true),\n );\n\n this.toggleVisibility(this.emptyTargets, results.length === 0);\n this.updateGroupVisibility();\n }\n\n toggleVisibility(elements, isVisible) {\n elements.forEach((el) => el.classList.toggle(\"hidden\", !isVisible));\n }\n\n updateGroupVisibility() {\n this.groupTargets.forEach((group) => {\n const hasVisibleItems =\n group.querySelectorAll(\n \"[data-ruby-ui--command-target='item']:not(.hidden)\",\n ).length > 0;\n this.toggleVisibility([group], hasVisibleItems);\n });\n }\n\n resetVisibility() {\n this.toggleVisibility(this.itemTargets, true);\n this.toggleVisibility(this.groupTargets, true);\n this.toggleVisibility(this.emptyTargets, false);\n }\n\n buildSearchIndex() {\n const options = {\n keys: [\"value\"],\n threshold: 0.2,\n includeMatches: true,\n };\n const items = this.itemTargets.map((el) => ({\n value: el.dataset.value,\n element: el,\n }));\n return new Fuse(items, options);\n }\n\n handleKeydown(e) {\n const visibleItems = this.itemTargets.filter(\n (item) => !item.classList.contains(\"hidden\"),\n );\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n this.updateSelectedItem(visibleItems, 1);\n } else if (e.key === \"ArrowUp\") {\n e.preventDefault();\n this.updateSelectedItem(visibleItems, -1);\n } else if (e.key === \"Enter\" && this.selectedIndex !== -1) {\n e.preventDefault();\n visibleItems[this.selectedIndex].click();\n }\n }\n\n updateSelectedItem(visibleItems, direction) {\n if (this.selectedIndex >= 0) {\n this.toggleAriaSelected(visibleItems[this.selectedIndex], false);\n }\n\n this.selectedIndex += direction;\n\n // Ensure the selected index is within the bounds of the visible items\n if (this.selectedIndex < 0) {\n this.selectedIndex = visibleItems.length - 1;\n } else if (this.selectedIndex >= visibleItems.length) {\n this.selectedIndex = 0;\n }\n\n this.toggleAriaSelected(visibleItems[this.selectedIndex], true);\n }\n\n toggleAriaSelected(element, isSelected) {\n element.setAttribute(\"aria-selected\", isSelected.toString());\n }\n\n deselectAll() {\n this.itemTargets.forEach((item) => this.toggleAriaSelected(item, false));\n this.selectedIndex = -1;\n }\n}\n" + }, + { + "path": "command_dialog.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CommandDialog < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {controller: \"ruby-ui--command\"}\n }\n end\n end\nend\n" + }, + { + "path": "command_dialog_content.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CommandDialogContent < Base\n SIZES = {\n xs: \"max-w-sm\",\n sm: \"max-w-md\",\n md: \"max-w-lg\",\n lg: \"max-w-2xl\",\n xl: \"max-w-4xl\",\n full: \"max-w-full\"\n }\n\n def initialize(size: :md, **attrs)\n @size = size\n super(**attrs)\n end\n\n def view_template(&block)\n template(data: {ruby_ui__command_target: \"content\"}) do\n div(data: {controller: \"ruby-ui--command\"}) do\n backdrop\n div(**attrs, &block)\n end\n end\n end\n\n private\n\n def default_attrs\n {\n data_state: \"open\",\n class: [\n \"fixed pointer-events-auto left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full\",\n SIZES[@size]\n ]\n }\n end\n\n def backdrop\n div(\n data_state: \"open\",\n data_action: \"click->ruby-ui--command#dismiss esc->ruby-ui--command#dismiss\",\n class: \"fixed pointer-events-auto inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\"\n )\n end\n end\nend\n" + }, + { + "path": "command_dialog_trigger.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CommandDialogTrigger < Base\n DEFAULT_KEYBINDINGS = [\n \"keydown.ctrl+k@window\",\n \"keydown.meta+k@window\"\n ].freeze\n\n def initialize(keybindings: DEFAULT_KEYBINDINGS, **attrs)\n @keybindings = keybindings.map { |kb| \"#{kb}->ruby-ui--command#open\" }\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n action: [\"click->ruby-ui--command#open\", @keybindings.join(\" \")]\n }\n }\n end\n end\nend\n" + }, + { + "path": "command_empty.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CommandEmpty < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"py-6 text-center text-sm\",\n role: \"presentation\",\n data: {ruby_ui__command_target: \"empty\"}\n }\n end\n end\nend\n" + }, + { + "path": "command_group.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CommandGroup < Base\n def initialize(title: nil, **attrs)\n @title = title\n super(**attrs)\n end\n\n def view_template(&block)\n div(**attrs) do\n render_header if @title\n render_items(&block)\n end\n end\n\n private\n\n def render_header\n div(group_heading: @title) do\n @title\n end\n end\n\n def render_items(&)\n div(group_items: \"\", role: \"group\", &)\n end\n\n def default_attrs\n {\n class: \"overflow-hidden p-1 text-foreground [&_[group-heading]]:px-2 [&_[group-heading]]:py-1.5 [&_[group-heading]]:text-xs [&_[group-heading]]:font-medium [&_[group-heading]]:text-muted-foreground\",\n role: \"presentation\",\n data: {\n value: @title,\n ruby_ui__command_target: \"group\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "command_input.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CommandInput < Base\n def initialize(placeholder: \"Type a command or search...\", **attrs)\n @placeholder = placeholder\n super(**attrs)\n end\n\n def view_template\n input_container do\n search_icon\n input(**attrs)\n end\n end\n\n private\n\n def search_icon\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n viewbox: \"0 0 20 20\",\n fill: \"currentColor\",\n class: \"w-4 h-4 mr-1.5\"\n ) do |s|\n s.path(\n fill_rule: \"evenodd\",\n d:\n \"M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z\",\n clip_rule: \"evenodd\"\n )\n end\n end\n\n def input_container(&)\n div(class: \"flex items-center border-b px-3\", &)\n end\n\n def default_attrs\n {\n class: \"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50\",\n placeholder: @placeholder,\n data_action: \"input->ruby-ui--command#filter keydown.down->ruby-ui--command#handleKeydown keydown.up->ruby-ui--command#handleKeydown keydown.enter->ruby-ui--command#handleKeydown keydown.esc->ruby-ui--command#dismiss\",\n data_ruby_ui__command_target: \"input\",\n autocomplete: \"off\",\n autocorrect: \"off\",\n spellcheck: false,\n autofocus: true,\n aria_autocomplete: \"list\",\n role: \"combobox\",\n aria_expanded: true,\n value: \"\"\n }\n end\n end\nend\n" + }, + { + "path": "command_item.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CommandItem < Base\n def initialize(value:, text: \"\", href: \"#\", **attrs)\n @value = value\n @text = text\n @href = href\n super(**attrs)\n end\n\n def view_template(&)\n a(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"relative flex cursor-pointer select-none items-center gap-x-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n href: @href,\n role: \"option\",\n data: {\n ruby_ui__command_target: \"item\",\n value: @value,\n text: @text\n }\n # aria_selected: \"true\", # Toggles aria-selected=\"true\" on keydown\n }\n end\n end\nend\n" + }, + { + "path": "command_list.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CommandList < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"divide-y divide-border\"\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [ + "fuse.js" + ], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Command", + "docs_markdown": "", + "examples": [] + }, + "context_menu": { + "name": "ContextMenu", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "context_menu.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ContextMenu < Base\n def initialize(options: {}, **attrs)\n @options = options\n @options[:trigger] ||= \"manual\"\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n controller: \"ruby-ui--context-menu\",\n ruby_ui__context_menu_options_value: @options.to_json\n }\n }\n end\n end\nend\n" + }, + { + "path": "context_menu_content.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ContextMenuContent < Base\n def view_template(&block)\n template(data: {ruby_ui__context_menu_target: \"content\"}) do\n div(**attrs, &block)\n end\n end\n\n private\n\n def default_attrs\n {\n role: \"menu\",\n aria_orientation: \"vertical\",\n data_state: \"open\",\n class:\n \"z-50 min-w-[8rem] outline-none pointer-events-auto overflow-hidden rounded-md border bg-background p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n tabindex: \"-1\",\n data_orientation: \"vertical\"\n }\n end\n end\nend\n" + }, + { + "path": "context_menu_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\";\nimport tippy from \"tippy.js\";\n\nexport default class extends Controller {\n static targets = [\"trigger\", \"content\", \"menuItem\"];\n static values = {\n options: {\n type: Object,\n default: {},\n },\n // make content width of the trigger element (true/false)\n matchWidth: {\n type: Boolean,\n default: false,\n }\n }\n\n connect() {\n this.boundHandleKeydown = this.handleKeydown.bind(this); // Bind the function so we can remove it later\n this.initializeTippy();\n this.selectedIndex = -1;\n }\n\n disconnect() {\n this.destroyTippy();\n }\n\n initializeTippy() {\n const defaultOptions = {\n content: this.contentTarget.innerHTML,\n allowHTML: true,\n interactive: true,\n onShow: (instance) => {\n this.matchWidthValue && this.setContentWidth(instance); // ensure content width matches trigger width\n this.addEventListeners();\n },\n onHide: () => {\n this.removeEventListeners();\n this.deselectAll();\n },\n popperOptions: {\n modifiers: [\n {\n name: \"offset\",\n options: {\n offset: [0, 4]\n },\n },\n ],\n }\n };\n\n const mergedOptions = { ...this.optionsValue, ...defaultOptions };\n this.tippy = tippy(this.triggerTarget, mergedOptions);\n }\n\n destroyTippy() {\n if (this.tippy) {\n this.tippy.destroy();\n }\n }\n\n setContentWidth(instance) {\n // box-sizing: border-box\n const content = instance.popper.querySelector('.tippy-content');\n if (content) {\n content.style.width = `${instance.reference.offsetWidth}px`;\n }\n }\n\n handleContextMenu(event) {\n event.preventDefault();\n this.open();\n }\n\n open() {\n this.tippy.show();\n }\n\n close() {\n this.tippy.hide();\n }\n\n handleKeydown(e) {\n // return if no menu items (one line fix for)\n if (this.menuItemTargets.length === 0) { return; }\n\n if (e.key === 'ArrowDown') {\n e.preventDefault();\n this.updateSelectedItem(1);\n } else if (e.key === 'ArrowUp') {\n e.preventDefault();\n this.updateSelectedItem(-1);\n } else if (e.key === 'Enter' && this.selectedIndex !== -1) {\n e.preventDefault();\n this.menuItemTargets[this.selectedIndex].click();\n }\n }\n\n updateSelectedItem(direction) {\n // Check if any of the menuItemTargets have aria-selected=\"true\" and set the selectedIndex to that index\n this.menuItemTargets.forEach((item, index) => {\n if (item.getAttribute('aria-selected') === 'true') {\n this.selectedIndex = index;\n }\n });\n\n if (this.selectedIndex >= 0) {\n this.toggleAriaSelected(this.menuItemTargets[this.selectedIndex], false);\n }\n\n this.selectedIndex += direction;\n\n if (this.selectedIndex < 0) {\n this.selectedIndex = this.menuItemTargets.length - 1;\n } else if (this.selectedIndex >= this.menuItemTargets.length) {\n this.selectedIndex = 0;\n }\n\n this.toggleAriaSelected(this.menuItemTargets[this.selectedIndex], true);\n }\n\n toggleAriaSelected(element, isSelected) {\n // Add or remove attribute\n if (isSelected) {\n element.setAttribute('aria-selected', 'true');\n } else {\n element.removeAttribute('aria-selected');\n }\n }\n\n deselectAll() {\n this.menuItemTargets.forEach(item => this.toggleAriaSelected(item, false));\n this.selectedIndex = -1;\n }\n\n addEventListeners() {\n document.addEventListener('keydown', this.boundHandleKeydown);\n }\n\n removeEventListeners() {\n document.removeEventListener('keydown', this.boundHandleKeydown);\n }\n}\n" + }, + { + "path": "context_menu_item.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ContextMenuItem < Base\n def initialize(href: \"#\", checked: false, shortcut: nil, disabled: false, **attrs)\n @href = href\n @checked = checked\n @shortcut = shortcut\n @disabled = disabled\n\n super(**attrs)\n end\n\n def view_template(&block)\n a(**attrs) do\n render_checkmark if @checked\n yield\n render_shortcut if @shortcut\n end\n end\n\n private\n\n def render_checkmark\n span(class: \"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\") do\n span(data_state: \"checked\") do\n svg(\n width: \"15\",\n height: \"15\",\n viewbox: \"0 0 15 15\",\n fill: \"none\",\n xmlns: \"http://www.w3.org/2000/svg\",\n class: \"h-4 w-4\"\n ) do |s|\n s.path(\n d:\n \"M11.4669 3.72684C11.7558 3.91574 11.8369 4.30308 11.648 4.59198L7.39799 11.092C7.29783 11.2452 7.13556 11.3467 6.95402 11.3699C6.77247 11.3931 6.58989 11.3355 6.45446 11.2124L3.70446 8.71241C3.44905 8.48022 3.43023 8.08494 3.66242 7.82953C3.89461 7.57412 4.28989 7.55529 4.5453 7.78749L6.75292 9.79441L10.6018 3.90792C10.7907 3.61902 11.178 3.53795 11.4669 3.72684Z\",\n fill: \"currentColor\",\n fill_rule: \"evenodd\",\n clip_rule: \"evenodd\"\n )\n end\n end\n end\n end\n\n def render_shortcut\n span(class: \"ml-auto text-xs tracking-widest text-muted-foreground\") { @shortcut }\n end\n\n def default_attrs\n {\n href: @href,\n role: \"menuitem\",\n class:\n \"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 pl-8\",\n tabindex: \"-1\",\n data_orientation: \"vertical\",\n data_action: \"click->ruby-ui--context-menu#close\",\n data_ruby_ui__context_menu_target: \"menuItem\",\n data_disabled: @disabled,\n disabled: @disabled\n }\n end\n end\nend\n" + }, + { + "path": "context_menu_label.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ContextMenuLabel < Base\n def initialize(inset: false, **attrs)\n @inset = inset\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def inset? = @inset\n\n def default_attrs\n {\n class: [\"px-2 py-1.5 text-sm font-semibold text-foreground\", inset?: \"pl-8\"]\n }\n end\n end\nend\n" + }, + { + "path": "context_menu_separator.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ContextMenuSeparator < Base\n def view_template\n div(**attrs)\n end\n\n private\n\n def default_attrs\n {\n role: \"separator\",\n aria_orientation: \"horizontal\",\n class: \"-mx-1 my-1 h-px bg-border\"\n }\n end\n end\nend\n" + }, + { + "path": "context_menu_trigger.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ContextMenuTrigger < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n ruby_ui__context_menu_target: \"trigger\",\n action: \"contextmenu->ruby-ui--context-menu#handleContextMenu\"\n }\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [ + "tippy.js" + ], + "gems": [] + }, + "install_command": "rails g ruby_ui:component ContextMenu", + "docs_markdown": "", + "examples": [] + }, + "data_table": { + "name": "DataTable", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "data_table.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DataTable < Base\n register_element :turbo_frame, tag: \"turbo-frame\"\n\n def initialize(id:, **attrs)\n @id = id\n super(**attrs)\n end\n\n def view_template(&block)\n turbo_frame(id: @id, target: \"_top\") do\n div(**attrs) do\n yield if block\n end\n end\n end\n\n private\n\n def default_attrs\n {\n class: \"w-full space-y-4\",\n data: {controller: \"ruby-ui--data-table\"}\n }\n end\n end\nend\n" + }, + { + "path": "data_table_bulk_actions.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DataTableBulkActions < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"hidden items-center gap-2\",\n data: {\"ruby-ui--data-table-target\": \"bulkActions\"}\n }\n end\n end\nend\n" + }, + { + "path": "data_table_column_toggle.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DataTableColumnToggle < Base\n def initialize(columns:, **attrs)\n @columns = columns\n super(**attrs)\n end\n\n def view_template\n div(**attrs) do\n render RubyUI::DropdownMenu.new do\n render RubyUI::DropdownMenuTrigger.new do\n render RubyUI::Button.new(variant: :outline, size: :sm) do\n plain \"Columns\"\n # inline chevron-down SVG (lucide 24px, 1px stroke)\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n width: \"16\",\n height: \"16\",\n viewBox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n class: \"w-4 h-4 ml-1\"\n ) do |s|\n s.polyline(points: \"6 9 12 15 18 9\")\n end\n end\n end\n render RubyUI::DropdownMenuContent.new do\n @columns.each do |col|\n label(class: \"flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer hover:bg-accent\") do\n input(\n type: \"checkbox\",\n checked: true,\n class: \"h-4 w-4 rounded border border-input accent-primary cursor-pointer\",\n data: {\n column_key: col[:key].to_s,\n action: \"change->ruby-ui--data-table-column-visibility#toggle\"\n }\n )\n span { plain col[:label] }\n end\n end\n end\n end\n end\n end\n\n private\n\n def default_attrs\n {\n class: \"relative\",\n data: {controller: \"ruby-ui--data-table-column-visibility\"}\n }\n end\n end\nend\n" + }, + { + "path": "data_table_column_visibility_controller.js", + "content": "// app/javascript/controllers/ruby_ui/data_table_column_visibility_controller.js\nimport { Controller } from \"@hotwired/stimulus\";\n\nexport default class extends Controller {\n toggle(event) {\n const key = event.target.dataset.columnKey;\n const visible = event.target.checked;\n const root = this.element.closest('[data-controller~=\"ruby-ui--data-table\"]');\n if (!root) return;\n root\n .querySelectorAll(`[data-column=\"${key}\"]`)\n .forEach((el) => el.classList.toggle(\"hidden\", !visible));\n }\n}\n" + }, + { + "path": "data_table_controller.js", + "content": "// app/javascript/controllers/ruby_ui/data_table_controller.js\nimport { Controller } from \"@hotwired/stimulus\";\n\nexport default class extends Controller {\n static targets = [\n \"selectAll\",\n \"rowCheckbox\",\n \"selectionSummary\",\n \"selectionBar\",\n \"bulkActions\",\n ];\n\n connect() {\n this.updateState();\n }\n\n toggleAll(event) {\n const checked = event.target.checked;\n this.rowCheckboxTargets.forEach((cb) => {\n cb.checked = checked;\n });\n this.updateState();\n }\n\n toggleRow() {\n this.updateState();\n }\n\n toggleRowDetail(event) {\n const button = event.currentTarget;\n const id = button.getAttribute(\"aria-controls\");\n if (!id) return;\n const target = document.getElementById(id);\n if (!target) return;\n const expanded = button.getAttribute(\"aria-expanded\") === \"true\";\n button.setAttribute(\"aria-expanded\", String(!expanded));\n target.classList.toggle(\"hidden\", expanded);\n }\n\n updateState() {\n const total = this.rowCheckboxTargets.length;\n const selected = this.rowCheckboxTargets.filter((cb) => cb.checked).length;\n\n if (this.hasSelectAllTarget) {\n this.selectAllTarget.checked = total > 0 && selected === total;\n this.selectAllTarget.indeterminate = selected > 0 && selected < total;\n }\n\n if (this.hasSelectionSummaryTarget) {\n this.selectionSummaryTarget.textContent = `${selected} of ${total} row(s) selected.`;\n }\n\n if (this.hasBulkActionsTarget) {\n this.bulkActionsTarget.classList.toggle(\"hidden\", selected === 0);\n }\n }\n}\n" + }, + { + "path": "data_table_expand_toggle.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DataTableExpandToggle < Base\n def initialize(controls:, expanded: false, label: \"Toggle row details\", **attrs)\n @controls = controls\n @expanded = expanded\n @label = label\n super(**attrs)\n end\n\n def view_template\n button(\n type: \"button\",\n aria_expanded: @expanded.to_s,\n aria_controls: @controls,\n aria_label: @label,\n data: {\n action: \"click->ruby-ui--data-table#toggleRowDetail\"\n },\n **attrs\n ) do\n render_icon\n end\n end\n\n private\n\n def render_icon\n # inline chevron-right SVG (lucide)\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n width: \"16\",\n height: \"16\",\n viewBox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n class: \"h-4 w-4 transition-transform duration-150 group-aria-expanded:rotate-90\"\n ) do |s|\n s.polyline(points: \"9 18 15 12 9 6\")\n end\n end\n\n def default_attrs\n {\n class: \"group inline-flex items-center justify-center h-8 w-8 rounded-md hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n }\n end\n end\nend\n" + }, + { + "path": "data_table_form.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DataTableForm < Base\n def initialize(action: \"\", method: \"post\", id: nil, **attrs)\n @action = action\n @method = method\n @id = id\n super(**attrs)\n end\n\n def view_template(&block)\n form_attrs = {action: @action, method: @method}\n form_attrs[:id] = @id if @id\n form(**form_attrs, **attrs) do\n input(type: \"hidden\", name: \"authenticity_token\", value: csrf_token)\n yield if block\n end\n end\n\n private\n\n def csrf_token\n # In a Rails app, view_context provides a real CSRF token.\n # Outside Rails (gem tests), fall back to a placeholder.\n if respond_to?(:helpers, true) && helpers.respond_to?(:form_authenticity_token)\n helpers.form_authenticity_token\n elsif respond_to?(:view_context, true) && view_context.respond_to?(:form_authenticity_token)\n view_context.form_authenticity_token\n else\n \"csrf-token-placeholder\"\n end\n end\n\n def default_attrs\n {}\n end\n end\nend\n" + }, + { + "path": "data_table_kaminari_adapter.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DataTableKaminariAdapter\n def initialize(collection)\n @collection = collection\n end\n\n def current_page = @collection.current_page\n\n def total_pages = @collection.total_pages\n\n def total_count = @collection.total_count\n\n def per_page = @collection.limit_value\n end\nend\n" + }, + { + "path": "data_table_manual_adapter.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DataTableManualAdapter\n attr_reader :current_page, :per_page, :total_count\n\n def initialize(page:, per_page:, total_count:)\n @current_page = page.to_i\n @per_page = [per_page.to_i, 1].max\n @total_count = total_count.to_i\n end\n\n def total_pages\n [(@total_count.to_f / @per_page).ceil, 1].max\n end\n end\nend\n" + }, + { + "path": "data_table_pagination.rb", + "content": "# frozen_string_literal: true\n\nrequire \"cgi\"\nrequire_relative \"data_table_manual_adapter\"\nrequire_relative \"data_table_pagy_adapter\"\nrequire_relative \"data_table_kaminari_adapter\"\n\nmodule RubyUI\n class DataTablePagination < Base\n def initialize(with: nil, pagy: nil, kaminari: nil, page: nil, per_page: nil, total_count: nil, page_param: \"page\", path: \"\", query: {}, window: 1, prev_label: \"<\", next_label: \">\", **attrs)\n @adapter = resolve_adapter(with:, pagy:, kaminari:, page:, per_page:, total_count:)\n @page_param = page_param\n @path = path\n @query = query.to_h.transform_keys(&:to_s)\n @window = window\n @prev_label = prev_label\n @next_label = next_label\n super(**attrs)\n end\n\n def view_template\n return if total <= 1\n\n render RubyUI::Pagination.new(class: \"mx-0 w-auto justify-end\", **attrs) do\n render RubyUI::PaginationContent.new do\n prev_item\n number_items\n next_item\n end\n end\n end\n\n private\n\n def resolve_adapter(with:, pagy:, kaminari:, page:, per_page:, total_count:)\n return with if with\n return RubyUI::DataTablePagyAdapter.new(pagy) if pagy\n return RubyUI::DataTableKaminariAdapter.new(kaminari) if kaminari\n if page && per_page && total_count\n return RubyUI::DataTableManualAdapter.new(page:, per_page:, total_count:)\n end\n raise ArgumentError, \"DataTablePagination requires one of: with:, pagy:, kaminari:, or page:+per_page:+total_count:\"\n end\n\n def current = @adapter.current_page\n\n def total = @adapter.total_pages\n\n def page_href(p)\n qs = build_query(@query.merge(@page_param => p.to_s))\n qs.empty? ? @path : \"#{@path}?#{qs}\"\n end\n\n def build_query(hash)\n hash.flat_map { |k, v|\n Array(v).map { |val| \"#{CGI.escape(k.to_s)}=#{CGI.escape(val.to_s)}\" }\n }.join(\"&\")\n end\n\n def prev_item\n if current <= 1\n li do\n span(class: \"opacity-50 pointer-events-none px-3 h-9 inline-flex items-center text-sm\") { @prev_label }\n end\n else\n render RubyUI::PaginationItem.new(href: page_href(current - 1)) { @prev_label }\n end\n end\n\n def next_item\n if current >= total\n li do\n span(class: \"opacity-50 pointer-events-none px-3 h-9 inline-flex items-center text-sm\") { @next_label }\n end\n else\n render RubyUI::PaginationItem.new(href: page_href(current + 1)) { @next_label }\n end\n end\n\n def number_items\n windowed_pages.each do |p|\n if p == :gap\n render RubyUI::PaginationEllipsis.new\n else\n render RubyUI::PaginationItem.new(href: page_href(p), active: p == current) { plain p.to_s }\n end\n end\n end\n\n def windowed_pages\n return (1..total).to_a if total <= 7\n pages = [1]\n pages << :gap if current - @window > 2\n ((current - @window)..(current + @window)).each { |p| pages << p if p > 1 && p < total }\n pages << :gap if current + @window < total - 1\n pages << total\n pages\n end\n end\nend\n" + }, + { + "path": "data_table_pagination_bar.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DataTablePaginationBar < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {class: \"flex items-center justify-between gap-4 py-2\"}\n end\n end\nend\n" + }, + { + "path": "data_table_pagy_adapter.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DataTablePagyAdapter\n def initialize(pagy)\n @pagy = pagy\n end\n\n def current_page = @pagy.page\n\n def total_pages = @pagy.pages\n\n def total_count = @pagy.count\n\n def per_page = @pagy.items\n end\nend\n" + }, + { + "path": "data_table_per_page_select.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DataTablePerPageSelect < Base\n def initialize(path:, name: \"per_page\", value: nil, frame_id: nil, options: [5, 10, 25, 50], **attrs)\n @path = path\n @name = name\n @value = value\n @frame_id = frame_id\n @options = options\n super(**attrs)\n end\n\n def view_template\n form_attrs = {action: @path, method: \"get\"}\n form_attrs[:data] = {turbo_frame: @frame_id} if @frame_id\n\n form(**attrs.merge(form_attrs)) do\n render RubyUI::NativeSelect.new(name: @name, onchange: safe(\"this.form.requestSubmit()\")) do\n @options.each do |opt|\n option_attrs = {value: opt.to_s}\n option_attrs[:selected] = true if opt.to_s == @value.to_s\n option(**option_attrs) { plain opt.to_s }\n end\n end\n end\n end\n\n private\n\n def default_attrs\n {}\n end\n end\nend\n" + }, + { + "path": "data_table_row_checkbox.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DataTableRowCheckbox < Base\n def initialize(value:, name: \"ids[]\", label: nil, **attrs)\n @value = value\n @name = name\n @label = label\n super(**attrs)\n end\n\n def view_template\n render RubyUI::Checkbox.new(**attrs)\n end\n\n private\n\n def default_attrs\n {\n name: @name,\n value: @value,\n aria_label: @label || \"Select row #{@value}\",\n data: {\n \"ruby-ui--data-table-target\": \"rowCheckbox\",\n action: \"change->ruby-ui--data-table#toggleRow\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "data_table_search.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DataTableSearch < Base\n def initialize(path:, name: \"search\", value: nil, frame_id: nil, placeholder: \"Search...\", debounce: 300, preserved_params: {}, **attrs)\n @path = path\n @name = name\n @value = value\n @frame_id = frame_id\n @placeholder = placeholder\n @debounce = debounce\n @preserved_params = preserved_params\n super(**attrs)\n end\n\n def view_template\n form_attrs = {method: \"get\", action: @path}\n form_attrs[:data] = form_data\n\n form(**attrs.merge(form_attrs)) do\n render RubyUI::Input.new(\n type: :search,\n name: @name,\n value: @value,\n placeholder: @placeholder,\n autocomplete: \"off\"\n )\n @preserved_params.each do |k, v|\n next if v.nil? || (v.respond_to?(:empty?) && v.empty?)\n next if k.to_s == @name\n input(type: \"hidden\", name: k.to_s, value: v.to_s)\n end\n end\n end\n\n private\n\n def debounce_enabled?\n @debounce && @debounce.to_i > 0\n end\n\n def form_data\n base = {}\n base[:turbo_frame] = @frame_id if @frame_id\n if debounce_enabled?\n base[:controller] = \"ruby-ui--data-table-search\"\n base[:\"ruby-ui--data-table-search-delay-value\"] = @debounce.to_i\n base[:action] = \"input->ruby-ui--data-table-search#submit\"\n end\n base\n end\n\n def default_attrs\n {class: \"max-w-sm flex-1\"}\n end\n end\nend\n" + }, + { + "path": "data_table_search_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Module-level map survives controller disconnect/connect across Turbo Frame swaps.\n// Keyed by the search form's action URL.\nconst PENDING_FOCUS = new Map();\n\nexport default class extends Controller {\n static values = { delay: { type: Number, default: 300 } };\n\n connect() {\n this.timer = null;\n this.beforeFrameRender = this.captureBeforeRender.bind(this);\n document.addEventListener(\"turbo:before-frame-render\", this.beforeFrameRender);\n // New instance after a Turbo Frame swap — check for captured state.\n this.restoreIfPending();\n }\n\n disconnect() {\n clearTimeout(this.timer);\n document.removeEventListener(\"turbo:before-frame-render\", this.beforeFrameRender);\n }\n\n submit(event) {\n if (event && event.type !== \"input\") return;\n clearTimeout(this.timer);\n if (this.delayValue <= 0) return;\n this.timer = setTimeout(() => this.element.requestSubmit(), this.delayValue);\n }\n\n captureBeforeRender() {\n const input = this.input();\n if (!input || document.activeElement !== input) return;\n PENDING_FOCUS.set(this.key(), {\n selectionStart: input.selectionStart,\n selectionEnd: input.selectionEnd\n });\n }\n\n restoreIfPending() {\n const state = PENDING_FOCUS.get(this.key());\n if (!state) return;\n PENDING_FOCUS.delete(this.key());\n const input = this.input();\n if (!input) return;\n input.focus();\n const len = input.value.length;\n try {\n input.setSelectionRange(\n Math.min(state.selectionStart ?? len, len),\n Math.min(state.selectionEnd ?? len, len)\n );\n } catch (e) {}\n }\n\n input() {\n return this.element.querySelector('input[type=\"search\"]');\n }\n\n key() {\n return this.element.action || \"_\";\n }\n}\n" + }, + { + "path": "data_table_select_all_checkbox.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DataTableSelectAllCheckbox < Base\n def view_template\n render RubyUI::Checkbox.new(**attrs)\n end\n\n private\n\n def default_attrs\n {\n aria_label: \"Select all\",\n data: {\n \"ruby-ui--data-table-target\": \"selectAll\",\n action: \"change->ruby-ui--data-table#toggleAll\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "data_table_selection_summary.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DataTableSelectionSummary < Base\n def initialize(total_on_page: 0, **attrs)\n @total_on_page = total_on_page\n super(**attrs)\n end\n\n def view_template\n div(**attrs) do\n plain \"0 of #{@total_on_page} row(s) selected.\"\n end\n end\n\n private\n\n def default_attrs\n {\n class: \"text-sm text-muted-foreground\",\n data: {\"ruby-ui--data-table-target\": \"selectionSummary\"}\n }\n end\n end\nend\n" + }, + { + "path": "data_table_sort_head.rb", + "content": "# frozen_string_literal: true\n\nrequire \"cgi\"\n\nmodule RubyUI\n class DataTableSortHead < Base\n def initialize(column_key:, label:, sort: nil, direction: nil, sort_param: \"sort\", direction_param: \"direction\", page_param: \"page\", path: \"\", query: {}, **attrs)\n @column_key = column_key\n @label = label\n @sort = sort\n @direction = direction\n @sort_param = sort_param\n @direction_param = direction_param\n @page_param = page_param\n @path = path\n @query = query.to_h.transform_keys(&:to_s)\n super(**attrs)\n end\n\n def view_template\n render RubyUI::TableHead.new(class: \"text-foreground whitespace-nowrap\", **attrs) do\n a(href: sort_href, class: \"inline-flex items-center gap-1 text-inherit no-underline hover:text-foreground transition-colors\") do\n plain @label\n sort_icon\n end\n end\n end\n\n private\n\n def current_direction\n (@sort.to_s == @column_key.to_s) ? @direction : nil\n end\n\n def next_params\n next_dir = {nil => \"asc\", \"asc\" => \"desc\", \"desc\" => nil}[current_direction]\n base = @query.except(@sort_param, @direction_param, @page_param)\n next_dir ? base.merge(@sort_param => @column_key.to_s, @direction_param => next_dir) : base\n end\n\n def sort_href\n qs = build_query(next_params)\n qs.empty? ? @path : \"#{@path}?#{qs}\"\n end\n\n def build_query(hash)\n hash.flat_map { |k, v|\n Array(v).map { |val| \"#{CGI.escape(k.to_s)}=#{CGI.escape(val.to_s)}\" }\n }.join(\"&\")\n end\n\n def sort_icon\n icon_name = case current_direction\n when \"asc\" then :chevron_up\n when \"desc\" then :chevron_down\n else :chevrons_up_down\n end\n icon_class = current_direction ? \"inline-block w-3 h-3\" : \"inline-block w-3 h-3 opacity-30\"\n render_sort_svg(icon_name, icon_class)\n end\n\n def render_sort_svg(icon_name, icon_class)\n case icon_name\n when :chevron_up\n # chevron-up: polyline pointing up\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n width: \"12\",\n height: \"12\",\n viewBox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n class: icon_class\n ) { |s| s.polyline(points: \"18 15 12 9 6 15\") }\n when :chevron_down\n # chevron-down: polyline pointing down\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n width: \"12\",\n height: \"12\",\n viewBox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n class: icon_class\n ) { |s| s.polyline(points: \"6 9 12 15 18 9\") }\n else\n # chevrons-up-down\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n width: \"12\",\n height: \"12\",\n viewBox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n class: icon_class\n ) do |s|\n s.polyline(points: \"8 15 12 19 16 15\")\n s.polyline(points: \"8 9 12 5 16 9\")\n end\n end\n end\n end\nend\n" + }, + { + "path": "data_table_toolbar.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DataTableToolbar < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {class: \"flex items-center justify-between gap-2\"}\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [ + "Button", + "Checkbox", + "DropdownMenu", + "Input", + "NativeSelect", + "Pagination", + "Table" + ], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component DataTable", + "docs_markdown": "Salary: $\\#{r.salary}\n\nStatus: \\#{r.status}", + "examples": [] + }, + "date_picker": { + "name": "DatePicker", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "date_picker.rb", + "content": "# frozen_string_literal: true\n\nrequire \"securerandom\"\n\nmodule RubyUI\n class DatePicker < Base\n def initialize(\n id: nil,\n name: nil,\n label: \"Select a date\",\n value: nil,\n placeholder: \"Select a date\",\n selected_date: value,\n date_format: \"yyyy-MM-dd\",\n popover_options: {},\n input_attrs: {},\n calendar_attrs: {},\n trigger_attrs: {},\n content_attrs: {},\n **attrs\n )\n @id = id || \"date-picker-#{SecureRandom.hex(4)}\"\n @name = name\n @label = label\n @value = value || selected_date&.to_s\n @placeholder = placeholder\n @selected_date = selected_date\n @date_format = date_format\n @popover_options = {trigger: \"click\"}.merge(popover_options)\n @input_attrs = input_attrs\n @calendar_attrs = calendar_attrs\n @trigger_attrs = trigger_attrs\n @content_attrs = content_attrs\n super(**attrs)\n end\n\n def view_template\n div(**attrs) do\n RubyUI.Popover(options: @popover_options) do\n RubyUI.PopoverTrigger(**trigger_attrs) do\n div(class: \"grid w-full max-w-sm items-center gap-1.5\") do\n label(for: @id) { @label } if @label\n RubyUI.Input(**input_attrs)\n end\n end\n RubyUI.PopoverContent(**content_attrs) do\n RubyUI.Calendar(input_id: \"##{@id}\", selected_date: @selected_date, date_format: @date_format, **calendar_attrs)\n end\n end\n end\n end\n\n private\n\n def default_attrs\n {\n class: \"space-y-4 w-[260px]\"\n }\n end\n\n def trigger_attrs\n mix({class: \"w-full\"}, @trigger_attrs)\n end\n\n def input_attrs\n mix({\n type: \"string\",\n placeholder: @placeholder,\n id: @id,\n name: @name,\n value: @value,\n data_controller: \"ruby-ui--calendar-input\",\n class: \"rounded-md border shadow\"\n }.compact, @input_attrs)\n end\n\n def calendar_attrs\n mix({}, @calendar_attrs)\n end\n\n def content_attrs\n mix({}, @content_attrs)\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [ + "Input", + "Popover", + "Calendar" + ], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component DatePicker", + "docs_markdown": "", + "examples": [] + }, + "dialog": { + "name": "Dialog", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "dialog.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Dialog < Base\n def initialize(open: false, **attrs)\n @open = open\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n controller: \"ruby-ui--dialog\",\n ruby_ui__dialog_open_value: @open\n }\n }\n end\n end\nend\n" + }, + { + "path": "dialog_content.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DialogContent < Base\n SIZES = {\n xs: \"max-w-sm\",\n sm: \"max-w-md\",\n md: \"max-w-lg\",\n lg: \"max-w-2xl\",\n xl: \"max-w-4xl\",\n full: \"max-w-full\"\n }\n\n def initialize(size: :md, **attrs)\n @size = size\n super(**attrs)\n end\n\n def view_template\n template(data: {ruby_ui__dialog_target: \"content\"}) do\n div(data_controller: \"ruby-ui--dialog\") do\n backdrop\n div(**attrs) do\n yield\n close_button\n end\n end\n end\n end\n\n private\n\n def default_attrs\n {\n data_state: \"open\",\n class: [\n \"fixed flex flex-col pointer-events-auto left-[50%] top-[50%] z-50 w-full max-h-screen overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:rounded-lg md:w-full\",\n SIZES[@size]\n ]\n }\n end\n\n def close_button\n button(\n type: \"button\",\n class: \"absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground\",\n data_action: \"click->ruby-ui--dialog#dismiss\"\n ) do\n svg(\n width: \"15\",\n height: \"15\",\n viewbox: \"0 0 15 15\",\n fill: \"none\",\n xmlns: \"http://www.w3.org/2000/svg\",\n class: \"h-4 w-4\"\n ) do |s|\n s.path(\n d:\n \"M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z\",\n fill: \"currentColor\",\n fill_rule: \"evenodd\",\n clip_rule: \"evenodd\"\n )\n end\n span(class: \"sr-only\") { \"Close\" }\n end\n end\n\n def backdrop\n div(\n data_state: \"open\",\n data_action: \"click->ruby-ui--dialog#dismiss esc->ruby-ui--dialog#dismiss\",\n class: \"fixed pointer-events-auto inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=open]:fade-in-0\"\n )\n end\n end\nend\n" + }, + { + "path": "dialog_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\"\n\n// Connects to data-controller=\"dialog\"\nexport default class extends Controller {\n static targets = [\"content\"]\n static values = {\n open: {\n type: Boolean,\n default: false\n },\n }\n\n connect() {\n if (this.openValue) {\n this.open()\n }\n }\n\n open(e) {\n e?.preventDefault();\n document.body.insertAdjacentHTML('beforeend', this.contentTarget.innerHTML)\n // prevent scroll on body\n document.body.classList.add('overflow-hidden')\n }\n\n dismiss() {\n // allow scroll on body\n document.body.classList.remove('overflow-hidden')\n // remove the element\n this.element.remove()\n }\n}\n" + }, + { + "path": "dialog_description.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DialogDescription < Base\n def view_template(&)\n p(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"text-sm text-muted-foreground\"\n }\n end\n end\nend\n" + }, + { + "path": "dialog_footer.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DialogFooter < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 gap-y-2 sm:gap-y-0 rtl:space-x-reverse\"\n }\n end\n end\nend\n" + }, + { + "path": "dialog_header.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DialogHeader < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"flex flex-col space-y-1.5 text-center sm:text-left rtl:sm:text-right\"\n }\n end\n end\nend\n" + }, + { + "path": "dialog_middle.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DialogMiddle < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"py-4\"\n }\n end\n end\nend\n" + }, + { + "path": "dialog_title.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DialogTitle < Base\n def view_template(&)\n h3(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"text-lg font-semibold leading-none tracking-tight\"\n }\n end\n end\nend\n" + }, + { + "path": "dialog_trigger.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DialogTrigger < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n action: \"click->ruby-ui--dialog#open\"\n },\n class: \"inline-block\"\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Dialog", + "docs_markdown": "", + "examples": [] + }, + "dropdown_menu": { + "name": "DropdownMenu", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "dropdown_menu.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DropdownMenu < Base\n def initialize(options: {}, **attrs)\n @options = options\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: [\n \"z-50\",\n \"group/dropdown-menu\",\n (strategy == \"absolute\") ? \"is-absolute\" : \"is-fixed\"\n ],\n data: {\n controller: \"ruby-ui--dropdown-menu\",\n action: \"click@window->ruby-ui--dropdown-menu#onClickOutside\",\n ruby_ui__dropdown_menu_options_value: @options.to_json\n }\n }\n end\n\n def strategy\n @_strategy ||= @options[:strategy] || \"absolute\"\n end\n end\nend\n" + }, + { + "path": "dropdown_menu_content.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DropdownMenuContent < Base\n def view_template(&block)\n div(**wrapper_attrs) do\n div(**attrs, &block)\n end\n end\n\n private\n\n def default_attrs\n {\n data: {\n state: :open\n },\n class: \"z-50 min-w-[8rem] rounded-md border bg-background p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 w-56\"\n }\n end\n\n def wrapper_attrs\n {\n class: [\n \"z-50 hidden group-[.is-absolute]/dropdown-menu:absolute\",\n \"group-[.is-fixed]/dropdown-menu:fixed\"\n ],\n data: {ruby_ui__dropdown_menu_target: \"content\"},\n style: {\n width: \"max-content\",\n top: \"0\",\n left: \"0\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "dropdown_menu_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\";\nimport {\n computePosition,\n flip,\n shift,\n offset,\n autoUpdate,\n} from \"@floating-ui/dom\";\n\nexport default class extends Controller {\n static targets = [\"trigger\", \"content\", \"menuItem\"];\n static values = {\n open: {\n type: Boolean,\n default: false,\n },\n options: {\n type: Object,\n default: {},\n },\n };\n\n connect() {\n this.boundHandleKeydown = this.#handleKeydown.bind(this); // Bind the function so we can remove it later\n this.selectedIndex = -1;\n\n this.#setupAutoUpdate();\n }\n\n disconnect() {\n if (this.autoUpdateCleanup) {\n this.autoUpdateCleanup();\n }\n }\n\n #setupAutoUpdate() {\n this.autoUpdateCleanup = autoUpdate(\n this.triggerTarget,\n this.contentTarget,\n this.#computeTooltip.bind(this),\n );\n }\n\n #computeTooltip() {\n computePosition(this.triggerTarget, this.contentTarget, {\n placement: this.optionsValue.placement || \"top\",\n middleware: [flip(), shift(), offset(8)],\n strategy: this.optionsValue.strategy || \"absolute\",\n }).then(({ x, y }) => {\n Object.assign(this.contentTarget.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n });\n }\n\n onClickOutside(event) {\n if (!this.openValue) return;\n if (this.element.contains(event.target)) return;\n\n event.preventDefault();\n this.close();\n }\n\n toggle() {\n this.contentTarget.classList.contains(\"hidden\")\n ? this.#open()\n : this.close();\n }\n\n #open() {\n this.openValue = true;\n this.#deselectAll();\n this.#addEventListeners();\n this.#computeTooltip();\n this.contentTarget.classList.remove(\"hidden\");\n }\n\n close() {\n this.openValue = false;\n this.#removeEventListeners();\n this.contentTarget.classList.add(\"hidden\");\n }\n\n #handleKeydown(e) {\n // return if no menu items (one line fix for)\n if (this.menuItemTargets.length === 0) {\n return;\n }\n\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n this.#updateSelectedItem(1);\n } else if (e.key === \"ArrowUp\") {\n e.preventDefault();\n this.#updateSelectedItem(-1);\n } else if (e.key === \"Enter\" && this.selectedIndex !== -1) {\n e.preventDefault();\n this.menuItemTargets[this.selectedIndex].click();\n }\n }\n\n #updateSelectedItem(direction) {\n // Check if any of the menuItemTargets have aria-selected=\"true\" and set the selectedIndex to that index\n this.menuItemTargets.forEach((item, index) => {\n if (item.getAttribute(\"aria-selected\") === \"true\") {\n this.selectedIndex = index;\n }\n });\n\n if (this.selectedIndex >= 0) {\n this.#toggleAriaSelected(this.menuItemTargets[this.selectedIndex], false);\n }\n\n this.selectedIndex += direction;\n\n if (this.selectedIndex < 0) {\n this.selectedIndex = this.menuItemTargets.length - 1;\n } else if (this.selectedIndex >= this.menuItemTargets.length) {\n this.selectedIndex = 0;\n }\n\n this.#toggleAriaSelected(this.menuItemTargets[this.selectedIndex], true);\n }\n\n #toggleAriaSelected(element, isSelected) {\n // Add or remove attribute\n if (isSelected) {\n element.setAttribute(\"aria-selected\", \"true\");\n } else {\n element.removeAttribute(\"aria-selected\");\n }\n }\n\n #deselectAll() {\n this.menuItemTargets.forEach((item) =>\n this.#toggleAriaSelected(item, false),\n );\n this.selectedIndex = -1;\n }\n\n #addEventListeners() {\n document.addEventListener(\"keydown\", this.boundHandleKeydown);\n }\n\n #removeEventListeners() {\n document.removeEventListener(\"keydown\", this.boundHandleKeydown);\n }\n}\n" + }, + { + "path": "dropdown_menu_item.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DropdownMenuItem < Base\n def initialize(href: \"#\", **attrs)\n @href = href\n super(**attrs)\n end\n\n def view_template(&)\n a(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n href: @href,\n role: \"menuitem\",\n class: \"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n data_action: \"click->ruby-ui--dropdown-menu#close\",\n data_ruby_ui__dropdown_menu_target: \"menuItem\",\n tabindex: \"-1\",\n data_orientation: \"vertical\"\n }\n end\n end\nend\n" + }, + { + "path": "dropdown_menu_label.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DropdownMenuLabel < Base\n def view_template(&)\n h3(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"px-2 py-1.5 text-sm font-semibold\"\n }\n end\n end\nend\n" + }, + { + "path": "dropdown_menu_separator.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DropdownMenuSeparator < Base\n def view_template\n div(**attrs)\n end\n\n private\n\n def default_attrs\n {\n role: \"separator\",\n aria_orientation: \"horizontal\",\n class: \"-mx-1 my-1 h-px bg-muted\"\n }\n end\n end\nend\n" + }, + { + "path": "dropdown_menu_trigger.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DropdownMenuTrigger < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {ruby_ui__dropdown_menu_target: \"trigger\", action: \"click->ruby-ui--dropdown-menu#toggle\"},\n class: \"inline-block\"\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [ + "@floating-ui/dom" + ], + "gems": [] + }, + "install_command": "rails g ruby_ui:component DropdownMenu", + "docs_markdown": "", + "examples": [] + }, + "form": { + "name": "Form", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "form.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Form < Base\n def view_template(&)\n form(**attrs, &)\n end\n\n private\n\n def default_attrs\n {}\n end\n end\nend\n" + }, + { + "path": "form_field.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class FormField < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n controller: \"ruby-ui--form-field\"\n },\n class: \"flex flex-col gap-2\"\n }\n end\n end\nend\n" + }, + { + "path": "form_field_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\";\n\nexport default class extends Controller {\n static targets = [\"input\", \"error\"];\n static values = { shouldValidate: false };\n\n connect() {\n if (this.hasErrorTarget) {\n if (this.errorTarget.textContent) {\n this.shouldValidateValue = true;\n } else {\n this.errorTarget.classList.add(\"hidden\");\n }\n }\n }\n\n onInvalid(error) {\n error.preventDefault();\n\n this.shouldValidateValue = true;\n this.#setErrorMessage();\n }\n\n onInput() {\n this.#setErrorMessage();\n }\n\n onChange() {\n this.#setErrorMessage();\n }\n\n #setErrorMessage() {\n if (!this.shouldValidateValue) return;\n\n if (this.inputTarget.validity.valid) {\n this.errorTarget.textContent = \"\";\n this.errorTarget.classList.add(\"hidden\");\n } else {\n this.errorTarget.textContent = this.#getValidationMessage();\n this.errorTarget.classList.remove(\"hidden\");\n }\n }\n\n #getValidationMessage() {\n let errorMessage;\n\n const { validity, dataset, validationMessage } = this.inputTarget;\n\n if (validity.tooLong) errorMessage = dataset.tooLong;\n if (validity.tooShort) errorMessage = dataset.tooShort;\n if (validity.badInput) errorMessage = dataset.badInput;\n if (validity.typeMismatch) errorMessage = dataset.typeMismatch;\n if (validity.stepMismatch) errorMessage = dataset.stepMismatch;\n if (validity.valueMissing) errorMessage = dataset.valueMissing;\n if (validity.rangeOverflow) errorMessage = dataset.rangeOverflow;\n if (validity.rangeUnderflow) errorMessage = dataset.rangeUnderflow;\n if (validity.patternMismatch) errorMessage = dataset.patternMismatch;\n\n return errorMessage || validationMessage;\n }\n}\n" + }, + { + "path": "form_field_error.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class FormFieldError < Base\n def view_template(&)\n p(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n ruby_ui__form_field_target: \"error\"\n },\n class: \"empty:hidden text-sm font-medium text-destructive\"\n }\n end\n end\nend\n" + }, + { + "path": "form_field_hint.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class FormFieldHint < Base\n def view_template(&)\n p(**attrs, &)\n end\n\n private\n\n def default_attrs\n {class: \"empty:hidden text-sm text-muted-foreground\"}\n end\n end\nend\n" + }, + { + "path": "form_field_label.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class FormFieldLabel < Base\n def view_template(&)\n label(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: [\n \"empty:hidden text-sm font-medium leading-none\",\n \"peer-disabled:cursor-not-allowed peer-disabled:opacity-70\",\n \"peer-aria-disabled:cursor-not-allowed peer-aria-disabled:opacity-70 peer-aria-disabled:pointer-events-none\"\n ]\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Form", + "docs_markdown": "", + "examples": [] + }, + "hover_card": { + "name": "HoverCard", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "hover_card.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class HoverCard < Base\n def initialize(option: {}, **attrs)\n @options = option\n @options[:delay] ||= [500, 250]\n @options[:trigger] ||= \"mouseenter focus click\"\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n controller: \"ruby-ui--hover-card\",\n ruby_ui__hover_card_options_value: @options.to_json\n }\n }\n end\n end\nend\n" + }, + { + "path": "hover_card_content.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class HoverCardContent < Base\n def view_template(&block)\n template(data: {ruby_ui__hover_card_target: \"content\"}) do\n div(**attrs, &block)\n end\n end\n\n private\n\n def default_attrs\n {\n data: {\n state: :open\n },\n class: \"z-50 rounded-md border bg-background p-4 text-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\"\n }\n end\n end\nend\n" + }, + { + "path": "hover_card_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\";\nimport tippy from \"tippy.js\";\n\nexport default class extends Controller {\n static targets = [\"trigger\", \"content\", \"menuItem\"];\n static values = {\n options: {\n type: Object,\n default: {},\n },\n // make content width of the trigger element (true/false)\n matchWidth: {\n type: Boolean,\n default: false,\n }\n }\n\n connect() {\n this.boundHandleKeydown = this.handleKeydown.bind(this); // Bind the function so we can remove it later\n this.initializeTippy();\n this.selectedIndex = -1;\n }\n\n disconnect() {\n this.destroyTippy();\n }\n\n initializeTippy() {\n const defaultOptions = {\n content: this.contentTarget.innerHTML,\n allowHTML: true,\n interactive: true,\n onShow: (instance) => {\n this.matchWidthValue && this.setContentWidth(instance); // ensure content width matches trigger width\n this.addEventListeners();\n },\n onHide: () => {\n this.removeEventListeners();\n this.deselectAll();\n },\n popperOptions: {\n modifiers: [\n {\n name: \"offset\",\n options: {\n offset: [0, 4]\n },\n },\n ],\n }\n };\n\n const mergedOptions = { ...this.optionsValue, ...defaultOptions };\n this.tippy = tippy(this.triggerTarget, mergedOptions);\n }\n\n destroyTippy() {\n if (this.tippy) {\n this.tippy.destroy();\n }\n }\n\n setContentWidth(instance) {\n // box-sizing: border-box\n const content = instance.popper.querySelector('.tippy-content');\n if (content) {\n content.style.width = `${instance.reference.offsetWidth}px`;\n }\n }\n\n handleContextMenu(event) {\n event.preventDefault();\n this.open();\n }\n\n open() {\n this.tippy.show();\n }\n\n close() {\n this.tippy.hide();\n }\n\n handleKeydown(e) {\n // return if no menu items (one line fix for)\n if (this.menuItemTargets.length === 0) { return; }\n\n if (e.key === 'ArrowDown') {\n e.preventDefault();\n this.updateSelectedItem(1);\n } else if (e.key === 'ArrowUp') {\n e.preventDefault();\n this.updateSelectedItem(-1);\n } else if (e.key === 'Enter' && this.selectedIndex !== -1) {\n e.preventDefault();\n this.menuItemTargets[this.selectedIndex].click();\n }\n }\n\n updateSelectedItem(direction) {\n // Check if any of the menuItemTargets have aria-selected=\"true\" and set the selectedIndex to that index\n this.menuItemTargets.forEach((item, index) => {\n if (item.getAttribute('aria-selected') === 'true') {\n this.selectedIndex = index;\n }\n });\n\n if (this.selectedIndex >= 0) {\n this.toggleAriaSelected(this.menuItemTargets[this.selectedIndex], false);\n }\n\n this.selectedIndex += direction;\n\n if (this.selectedIndex < 0) {\n this.selectedIndex = this.menuItemTargets.length - 1;\n } else if (this.selectedIndex >= this.menuItemTargets.length) {\n this.selectedIndex = 0;\n }\n\n this.toggleAriaSelected(this.menuItemTargets[this.selectedIndex], true);\n }\n\n toggleAriaSelected(element, isSelected) {\n // Add or remove attribute\n if (isSelected) {\n element.setAttribute('aria-selected', 'true');\n } else {\n element.removeAttribute('aria-selected');\n }\n }\n\n deselectAll() {\n this.menuItemTargets.forEach(item => this.toggleAriaSelected(item, false));\n this.selectedIndex = -1;\n }\n\n addEventListeners() {\n document.addEventListener('keydown', this.boundHandleKeydown);\n }\n\n removeEventListeners() {\n document.removeEventListener('keydown', this.boundHandleKeydown);\n }\n}\n" + }, + { + "path": "hover_card_trigger.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class HoverCardTrigger < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n ruby_ui__hover_card_target: \"trigger\"\n },\n class: \"inline-block\"\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [ + "tippy.js" + ], + "gems": [] + }, + "install_command": "rails g ruby_ui:component HoverCard", + "docs_markdown": "", + "examples": [] + }, + "input": { + "name": "Input", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "input.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Input < Base\n def initialize(type: :string, **attrs)\n @type = type.to_sym\n super(**attrs)\n end\n\n def view_template\n input(type: @type, **attrs)\n end\n\n private\n\n def default_attrs\n {\n data: {\n ruby_ui__form_field_target: \"input\",\n action: \"input->ruby-ui--form-field#onInput invalid->ruby-ui--form-field#onInvalid\"\n },\n class: [\n \"flex h-9 w-full rounded-md border bg-background px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] border-border ring-0 ring-ring/0\",\n \"placeholder:text-muted-foreground\",\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n \"file:border-0 file:bg-transparent file:text-sm file:font-medium\",\n \"aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:pointer-events-none\",\n \"focus-visible:outline-none focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:border-ring focus-visible:shadow-sm\",\n (@type.to_s == \"file\") ? \"pt-[7px]\" : \"\"\n ]\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Input", + "docs_markdown": "", + "examples": [] + }, + "link": { + "name": "Link", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "link.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Link < Base\n BASE_CLASSES = [\n \"whitespace-nowrap inline-flex items-center justify-center rounded-md font-medium transition-colors\",\n \"disabled:pointer-events-none disabled:opacity-50\",\n \"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\",\n \"aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-disabled:cursor-not-allowed\"\n ].freeze\n\n def initialize(href: \"#\", variant: :link, size: :md, icon: false, **attrs)\n @href = href\n @variant = variant.to_sym\n @size = size.to_sym\n @icon = icon\n super(**attrs)\n end\n\n def view_template(&)\n a(href: @href, **attrs, &)\n end\n\n private\n\n def size_classes\n if @icon\n case @size\n when :sm then \"h-6 w-6\"\n when :md then \"h-9 w-9\"\n when :lg then \"h-10 w-10\"\n when :xl then \"h-12 w-12\"\n end\n else\n case @size\n when :sm then \"px-3 py-1.5 h-8 text-xs\"\n when :md then \"px-4 py-2 h-9 text-sm\"\n when :lg then \"px-4 py-2 h-10 text-base\"\n when :xl then \"px-6 py-3 h-12 text-base\"\n end\n end\n end\n\n def primary_classes\n [\n BASE_CLASSES,\n size_classes,\n \"bg-primary text-primary-foreground shadow\",\n \"hover:bg-primary/90\"\n ]\n end\n\n def link_classes\n [\n BASE_CLASSES,\n size_classes,\n \"text-primary underline-offset-4\",\n \"hover:underline\"\n ]\n end\n\n def secondary_classes\n [\n BASE_CLASSES,\n size_classes,\n \"bg-secondary text-secondary-foreground\",\n \"hover:bg-opacity-80\"\n ]\n end\n\n def destructive_classes\n [\n BASE_CLASSES,\n size_classes,\n \"bg-destructive text-white shadow-sm\",\n \"[a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20\",\n \"dark:focus-visible:ring-destructive/40 dark:bg-destructive/60\"\n ]\n end\n\n def outline_classes\n [\n BASE_CLASSES,\n size_classes,\n \"border border-input bg-background shadow-sm\",\n \"hover:bg-accent hover:text-accent-foreground\"\n ]\n end\n\n def ghost_classes\n [\n BASE_CLASSES,\n size_classes,\n \"hover:bg-accent hover:text-accent-foreground\"\n ]\n end\n\n def default_classes\n case @variant\n when :primary then primary_classes\n when :link then link_classes\n when :secondary then secondary_classes\n when :destructive then destructive_classes\n when :outline then outline_classes\n when :ghost then ghost_classes\n end\n end\n\n def default_attrs\n {type: \"button\", class: default_classes}\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Link", + "docs_markdown": "", + "examples": [] + }, + "masked_input": { + "name": "MaskedInput", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "masked_input.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class MaskedInput < Base\n def view_template\n Input(type: \"text\", **attrs)\n end\n\n private\n\n def default_attrs\n {data: {controller: \"ruby-ui--masked-input\"}}\n end\n end\nend\n" + }, + { + "path": "masked_input_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\";\nimport { MaskInput } from \"maska\";\n\n// Connects to data-controller=\"ruby-ui--masked-input\"\nexport default class extends Controller {\n connect() {\n new MaskInput(this.element)\n }\n}\n" + } + ], + "dependencies": { + "components": [ + "Input" + ], + "js_packages": [ + "maska" + ], + "gems": [] + }, + "install_command": "rails g ruby_ui:component MaskedInput", + "docs_markdown": "", + "examples": [] + }, + "native_select": { + "name": "NativeSelect", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "native_select.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class NativeSelect < Base\n def initialize(size: :default, **attrs)\n @size = size\n super(**attrs)\n end\n\n def view_template(&block)\n div(\n class: \"group/native-select relative w-fit has-[select:disabled]:opacity-50\"\n ) do\n select(**attrs, &block)\n render RubyUI::NativeSelectIcon.new\n end\n end\n\n private\n\n def default_attrs\n {\n data: {\n ruby_ui__form_field_target: \"input\",\n action: \"change->ruby-ui--form-field#onChange invalid->ruby-ui--form-field#onInvalid\"\n },\n class: [\n \"border-border bg-transparent text-sm w-full min-w-0 appearance-none rounded-md border py-1 pr-8 pl-2.5 shadow-xs transition-[color,box-shadow] outline-none select-none ring-0 ring-ring/0\",\n \"placeholder:text-muted-foreground\",\n \"selection:bg-primary selection:text-primary-foreground\",\n \"focus-visible:outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2\",\n \"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50\",\n \"aria-invalid:ring-destructive/20 aria-invalid:border-destructive aria-invalid:ring-2\",\n (@size == :sm) ? \"h-7 rounded-md py-0.5\" : \"h-9\"\n ]\n }\n end\n end\nend\n" + }, + { + "path": "native_select_group.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class NativeSelectGroup < Base\n def view_template(&)\n optgroup(**attrs, &)\n end\n\n private\n\n def default_attrs\n {}\n end\n end\nend\n" + }, + { + "path": "native_select_icon.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class NativeSelectIcon < Base\n def view_template(&block)\n span(**attrs) do\n if block\n block.call\n else\n icon\n end\n end\n end\n\n private\n\n def icon\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n class: \"size-4\",\n aria_hidden: \"true\"\n ) do |s|\n s.path(d: \"m6 9 6 6 6-6\")\n end\n end\n\n def default_attrs\n {\n class: \"text-muted-foreground pointer-events-none absolute top-1/2 right-2.5 -translate-y-1/2 select-none\"\n }\n end\n end\nend\n" + }, + { + "path": "native_select_option.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class NativeSelectOption < Base\n def view_template(&)\n option(**attrs, &)\n end\n\n private\n\n def default_attrs\n {}\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component NativeSelect", + "docs_markdown": "NativeSelect: Choose for native browser behavior, superior performance, or mobile-optimized dropdowns.\n\nSelect: Choose for custom styling, animations, or complex interactions.", + "examples": [] + }, + "pagination": { + "name": "Pagination", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "pagination.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Pagination < Base\n def view_template(&)\n nav(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n aria: {label: \"pagination\"},\n class: \"mx-auto flex w-full justify-center\",\n role: \"navigation\"\n }\n end\n end\nend\n" + }, + { + "path": "pagination_content.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class PaginationContent < Base\n def view_template(&)\n ul(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"flex flex-row items-center gap-1\"\n }\n end\n end\nend\n" + }, + { + "path": "pagination_ellipsis.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class PaginationEllipsis < Base\n def view_template(&block)\n li do\n span(**attrs) do\n icon\n span(class: \"sr-only\") { \"More pages\" }\n end\n end\n end\n\n private\n\n def icon\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n width: \"24\",\n height: \"24\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n class: \"h-4 w-4\"\n ) do |s|\n s.circle(cx: \"12\", cy: \"12\", r: \"1\")\n s.circle(cx: \"19\", cy: \"12\", r: \"1\")\n s.circle(cx: \"5\", cy: \"12\", r: \"1\")\n end\n end\n\n def default_attrs\n {\n aria: {hidden: true},\n class: \"flex h-9 w-9 items-center justify-center\"\n }\n end\n end\nend\n" + }, + { + "path": "pagination_item.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class PaginationItem < Base\n def initialize(href: \"#\", active: false, **attrs)\n @href = href\n @active = active\n super(**attrs)\n end\n\n def view_template(&block)\n li do\n a(href: @href, **attrs, &block)\n end\n end\n\n private\n\n def default_attrs\n {\n aria: {current: @active ? \"page\" : nil},\n class: [\n RubyUI::Button.new(variant: @active ? :outline : :ghost).attrs[:class]\n ]\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [ + "Button" + ], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Pagination", + "docs_markdown": "", + "examples": [] + }, + "popover": { + "name": "Popover", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "popover.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Popover < Base\n def initialize(options: {}, **attrs)\n @options = options\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n controller: \"ruby-ui--popover\",\n ruby_ui__popover_options_value: @options.to_json,\n ruby_ui__popover_trigger_value: @options[:trigger] || \"hover\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "popover_content.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class PopoverContent < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n ruby_ui__popover_target: \"content\"\n },\n class: [\n \"hidden z-50 rounded-md border bg-background p-1 text-foreground shadow-md outline-none\",\n \"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0\",\n \"data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95\",\n \"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2\",\n \"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n \"absolute\"\n ]\n }\n end\n end\nend\n" + }, + { + "path": "popover_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\";\nimport {\n computePosition,\n flip,\n shift,\n offset,\n autoUpdate,\n} from \"@floating-ui/dom\";\n\nexport default class extends Controller {\n static targets = [\"trigger\", \"content\"];\n static values = {\n open: { type: Boolean, default: false },\n options: { type: Object, default: {} },\n trigger: { type: String, default: \"hover\" },\n };\n\n connect() {\n this.closeTimeout = null;\n this.cleanup = null;\n this.addEventListeners();\n }\n\n disconnect() {\n this.removeEventListeners();\n if (this.cleanup) {\n this.cleanup();\n }\n }\n\n addEventListeners() {\n if (this.triggerValue === \"hover\") {\n this.triggerTarget.addEventListener(\"mouseenter\", this.handleMouseEnter);\n this.triggerTarget.addEventListener(\"mouseleave\", this.handleMouseLeave);\n this.contentTarget.addEventListener(\"mouseenter\", this.handleMouseEnter);\n this.contentTarget.addEventListener(\"mouseleave\", this.handleMouseLeave);\n } else if (this.triggerValue === \"click\") {\n this.triggerTarget.addEventListener(\"click\", this.handleClick);\n document.addEventListener(\"click\", this.handleOutsideClick);\n }\n }\n\n removeEventListeners() {\n this.triggerTarget.removeEventListener(\"mouseenter\", this.handleMouseEnter);\n this.triggerTarget.removeEventListener(\"mouseleave\", this.handleMouseLeave);\n this.contentTarget.removeEventListener(\"mouseenter\", this.handleMouseEnter);\n this.contentTarget.removeEventListener(\"mouseleave\", this.handleMouseLeave);\n this.triggerTarget.removeEventListener(\"click\", this.handleClick);\n document.removeEventListener(\"click\", this.handleOutsideClick);\n }\n\n handleMouseEnter = () => {\n clearTimeout(this.closeTimeout);\n this.openValue = true;\n this.showPopover();\n };\n\n handleMouseLeave = () => {\n this.closeTimeout = setTimeout(() => {\n this.openValue = false;\n this.hidePopover();\n }, 100);\n };\n\n handleClick = (event) => {\n event.stopPropagation();\n this.openValue = !this.openValue;\n this.openValue ? this.showPopover() : this.hidePopover();\n };\n\n handleOutsideClick = (event) => {\n if (!this.element.contains(event.target) && this.openValue) {\n this.openValue = false;\n this.hidePopover();\n }\n };\n\n showPopover() {\n this.contentTarget.classList.remove(\"hidden\");\n this.updatePosition();\n }\n\n hidePopover() {\n this.contentTarget.classList.add(\"hidden\");\n if (this.cleanup) {\n this.cleanup();\n }\n }\n\n updatePosition() {\n if (this.cleanup) {\n this.cleanup();\n }\n\n this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => {\n computePosition(this.triggerTarget, this.contentTarget, {\n placement: this.optionsValue.placement || \"bottom\",\n middleware: [flip(), shift(), offset(8)],\n }).then(({ x, y }) => {\n Object.assign(this.contentTarget.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n });\n });\n }\n}\n" + }, + { + "path": "popover_trigger.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class PopoverTrigger < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n ruby_ui__popover_target: \"trigger\"\n },\n class: \"inline-block\"\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [ + "@floating-ui/dom" + ], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Popover", + "docs_markdown": "", + "examples": [] + }, + "progress": { + "name": "Progress", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "progress.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Progress < Base\n def initialize(value: 0, **attrs)\n @value = value.to_f.clamp(0, 100)\n\n super(**attrs)\n end\n\n def view_template\n div(**attrs) do\n div(**indicator_attrs)\n end\n end\n\n private\n\n def default_attrs\n {\n role: \"progressbar\",\n aria_valuenow: @value,\n aria_valuemin: 0,\n aria_valuemax: 100,\n aria_valuetext: \"#{@value}%\",\n class: \"relative h-2 overflow-hidden rounded-full bg-primary/20 [&>*]:bg-primary\"\n }\n end\n\n def indicator_attrs\n {\n class: \"h-full w-full flex-1\",\n style: \"transform: translateX(-#{100 - @value}%)\"\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Progress", + "docs_markdown": "", + "examples": [] + }, + "radio_button": { + "name": "RadioButton", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "radio_button.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class RadioButton < Base\n def view_template\n input(**attrs)\n end\n\n private\n\n def default_attrs\n {\n type: \"radio\",\n data: {\n ruby_ui__form_field_target: \"input\",\n action: \"change->ruby-ui--form-field#onInput invalid->ruby-ui--form-field#onInvalid\"\n },\n class: [\n \"h-4 w-4 p-0 border-primary rounded-full flex-none\",\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n \"checked:bg-primary checked:text-primary-foreground dark:checked:bg-secondary checked:text-primary checked:border-primary\",\n \"aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:pointer-events-none\"\n ]\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component RadioButton", + "docs_markdown": "", + "examples": [] + }, + "select": { + "name": "Select", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "select.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Select < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n controller: \"ruby-ui--select\",\n ruby_ui__select_open_value: \"false\",\n action: \"click@window->ruby-ui--select#clickOutside\",\n ruby_ui__select_ruby_ui__select_item_outlet: \".item\"\n },\n class: \"group/select w-full relative\"\n }\n end\n end\nend\n" + }, + { + "path": "select_content.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SelectContent < Base\n def initialize(**attrs)\n @id = \"content#{SecureRandom.hex(4)}\"\n super\n end\n\n def view_template(&block)\n div(**attrs) do\n div(\n class: \"max-h-96 w-full text-wrap overflow-auto rounded-md border bg-background p-1 text-foreground shadow-md animate-out group-data-[ruby-ui--select-open-value=true]/select:animate-in fade-out-0 group-data-[ruby-ui--select-open-value=true]/select:fade-in-0 zoom-out-95 group-data-[ruby-ui--select-open-value=true]/select:zoom-in-95 slide-in-from-top-2\", &block\n )\n end\n end\n\n private\n\n def default_attrs\n {\n id: @id,\n role: \"listbox\",\n tabindex: \"-1\",\n data: {\n ruby_ui__select_target: \"content\"\n },\n class: \"hidden w-full absolute top-0 left-0 z-50\"\n }\n end\n end\nend\n" + }, + { + "path": "select_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\";\nimport { computePosition, autoUpdate, offset, flip } from \"@floating-ui/dom\";\n\nexport default class extends Controller {\n static targets = [\"trigger\", \"content\", \"input\", \"value\", \"item\"];\n static values = { open: Boolean };\n static outlets = [\"ruby-ui--select-item\"];\n\n constructor(...args) {\n super(...args);\n this.cleanup;\n }\n\n connect() {\n this.setFloatingElement();\n this.generateItemsIds();\n }\n\n disconnect() {\n this.cleanup();\n }\n\n selectItem(event) {\n event.preventDefault();\n\n this.rubyUiSelectItemOutlets.forEach((item) =>\n item.handleSelectItem(event),\n );\n\n const oldValue = this.inputTarget.value;\n const newValue = event.target.dataset.value;\n\n this.inputTarget.value = newValue;\n this.valueTarget.innerText = event.target.innerText;\n\n this.dispatchOnChange(oldValue, newValue);\n this.closeContent();\n }\n\n onClick() {\n this.toogleContent();\n\n if (this.openValue) {\n this.setFocusAndCurrent();\n } else {\n this.resetCurrent();\n }\n }\n\n handleKeyDown(event) {\n event.preventDefault();\n\n const currentIndex = this.itemTargets.findIndex(\n (item) => item.getAttribute(\"aria-current\") === \"true\",\n );\n\n if (currentIndex + 1 < this.itemTargets.length) {\n this.itemTargets[currentIndex].removeAttribute(\"aria-current\");\n this.setAriaCurrentAndActiveDescendant(currentIndex + 1);\n }\n }\n\n handleKeyUp(event) {\n event.preventDefault();\n\n const currentIndex = this.itemTargets.findIndex(\n (item) => item.getAttribute(\"aria-current\") === \"true\",\n );\n\n if (currentIndex > 0) {\n this.itemTargets[currentIndex].removeAttribute(\"aria-current\");\n this.setAriaCurrentAndActiveDescendant(currentIndex - 1);\n }\n }\n\n handleEsc(event) {\n event.preventDefault();\n this.closeContent();\n }\n\n setFocusAndCurrent() {\n const selectedItem = this.itemTargets.find(\n (item) => item.getAttribute(\"aria-selected\") === \"true\",\n );\n\n if (selectedItem) {\n selectedItem.focus({ preventScroll: true });\n selectedItem.setAttribute(\"aria-current\", \"true\");\n this.triggerTarget.setAttribute(\n \"aria-activedescendant\",\n selectedItem.getAttribute(\"id\"),\n );\n } else {\n this.itemTarget.focus({ preventScroll: true });\n this.itemTarget.setAttribute(\"aria-current\", \"true\");\n this.triggerTarget.setAttribute(\n \"aria-activedescendant\",\n this.itemTarget.getAttribute(\"id\"),\n );\n }\n }\n\n resetCurrent() {\n this.itemTargets.forEach((item) => item.removeAttribute(\"aria-current\"));\n }\n\n clickOutside(event) {\n if (!this.openValue) return;\n if (this.element.contains(event.target)) return;\n\n event.preventDefault();\n this.toogleContent();\n }\n\n toogleContent() {\n this.openValue = !this.openValue;\n this.contentTarget.classList.toggle(\"hidden\");\n this.triggerTarget.setAttribute(\"aria-expanded\", this.openValue);\n }\n\n setFloatingElement() {\n this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => {\n computePosition(this.triggerTarget, this.contentTarget, {\n middleware: [offset(4), flip()],\n }).then(({ x, y }) => {\n Object.assign(this.contentTarget.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n });\n });\n }\n\n generateItemsIds() {\n const contentId = this.contentTarget.getAttribute(\"id\");\n this.triggerTarget.setAttribute(\"aria-controls\", contentId);\n\n this.itemTargets.forEach((item, index) => {\n item.id = `${contentId}-${index}`;\n });\n }\n\n setAriaCurrentAndActiveDescendant(currentIndex) {\n const currentItem = this.itemTargets[currentIndex];\n currentItem.focus({ preventScroll: true });\n currentItem.setAttribute(\"aria-current\", \"true\");\n this.triggerTarget.setAttribute(\n \"aria-activedescendant\",\n currentItem.getAttribute(\"id\"),\n );\n }\n\n closeContent() {\n this.toogleContent();\n this.resetCurrent();\n\n this.triggerTarget.setAttribute(\"aria-activedescendant\", true);\n this.triggerTarget.focus({ preventScroll: true });\n }\n\n dispatchOnChange(oldValue, newValue) {\n if (oldValue === newValue) return;\n\n const event = new InputEvent(\"change\", {\n bubbles: true,\n cancelable: true,\n });\n\n this.inputTarget.dispatchEvent(event);\n }\n}\n" + }, + { + "path": "select_group.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SelectGroup < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {}\n end\n end\nend\n" + }, + { + "path": "select_input.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SelectInput < Base\n def view_template\n input(**attrs)\n end\n\n private\n\n def default_attrs\n {\n class: \"hidden\",\n data: {\n ruby_ui__select_target: \"input\",\n ruby_ui__form_field_target: \"input\",\n action: \"change->ruby-ui--form-field#onChange invalid->ruby-ui--form-field#onInvalid\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "select_item.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SelectItem < Base\n def initialize(value: nil, **attrs)\n @value = value\n super(**attrs)\n end\n\n def view_template(&block)\n div(**attrs) do\n selected_icon\n block&.call\n end\n end\n\n private\n\n def selected_icon\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n class: \"invisible group-aria-selected:visible\tmr-2 h-4 w-4 flex-none\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\"\n ) do |s|\n s.path(\n d: \"M20 6 9 17l-5-5\"\n )\n end\n end\n\n def default_attrs\n {\n role: \"option\",\n tabindex: \"0\",\n data_value: @value,\n aria_selected: \"false\",\n data_orientation: \"vertical\",\n class: [\n \"item group relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors\",\n \"focus:bg-accent focus:text-accent-foreground\",\n \"hover:bg-accent hover:text-accent-foreground\",\n \"disabled:pointer-events-none disabled:opacity-50\",\n \"aria-selected:bg-accent aria-selected:text-accent-foreground\",\n \"data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n \"aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-disabled:cursor-not-allowed\"\n ],\n data: {\n controller: \"ruby-ui--select-item\",\n action: \"click->ruby-ui--select#selectItem keydown.enter->ruby-ui--select#selectItem keydown.down->ruby-ui--select#handleKeyDown keydown.up->ruby-ui--select#handleKeyUp keydown.esc->ruby-ui--select#handleEsc\",\n ruby_ui__select_target: \"item\"\n }\n\n }\n end\n end\nend\n" + }, + { + "path": "select_item_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\";\nexport default class extends Controller {\n\n handleSelectItem({ target }) {\n if (this.element.dataset.value == target.dataset.value) {\n this.element.setAttribute(\"aria-selected\", true);\n } else {\n this.element.removeAttribute(\"aria-selected\");\n }\n }\n}\n" + }, + { + "path": "select_label.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SelectLabel < Base\n def view_template(&)\n h3(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"px-2 py-1.5 text-sm font-semibold\"\n }\n end\n end\nend\n" + }, + { + "path": "select_trigger.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SelectTrigger < Base\n def view_template(&block)\n button(**attrs) do\n block&.call\n icon\n end\n end\n\n private\n\n def icon\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n class: \"ml-2 h-4 w-4 shrink-0 opacity-50\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\"\n ) do |s|\n s.path(\n d: \"m7 15 5 5 5-5\"\n )\n s.path(\n d: \"m7 9 5-5 5 5\"\n )\n end\n end\n\n def default_attrs\n {\n type: \"button\",\n role: \"combobox\",\n data: {\n action: \"ruby-ui--select#onClick\",\n ruby_ui__select_target: \"trigger\"\n },\n aria: {\n controls: \"radix-:r0:\",\n expanded: \"false\",\n autocomplete: \"none\",\n haspopup: \"listbox\",\n activedescendant: true\n },\n class: [\n \"truncate w-full flex h-9 items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background\",\n \"placeholder:text-muted-foreground\",\n \"focus:outline-none focus:ring-1 focus:ring-ring\",\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n \"aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:pointer-events-none\"\n ]\n }\n end\n end\nend\n" + }, + { + "path": "select_value.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SelectValue < Base\n def initialize(placeholder: nil, **attrs)\n @placeholder = placeholder\n super(**attrs)\n end\n\n def view_template(&block)\n span(**attrs) do\n value = block ? block.call : @placeholder\n value || @placeholder\n end\n end\n\n private\n\n def default_attrs\n {\n data: {\n ruby_ui__select_target: \"value\"\n },\n class: \"truncate pointer-events-none\"\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [ + "@floating-ui/dom" + ], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Select", + "docs_markdown": "", + "examples": [] + }, + "separator": { + "name": "Separator", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "separator.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Separator < Base\n ORIENTATIONS = %i[horizontal vertical].freeze\n\n def initialize(as: :div, orientation: :horizontal, decorative: true, **attrs)\n raise ArgumentError, \"Invalid orientation: #{orientation}\" unless ORIENTATIONS.include?(orientation.to_sym)\n\n @as = as\n @orientation = orientation.to_sym\n @decorative = decorative\n super(**attrs)\n end\n\n def view_template(&)\n tag(@as, **attrs, &)\n end\n\n private\n\n def default_attrs\n {\n role: (@decorative ? \"none\" : \"separator\"),\n class: [\n \"shrink-0 bg-border\",\n orientation_classes\n ]\n }\n end\n\n def orientation_classes\n return \"h-[1px] w-full\" if @orientation == :horizontal\n\n \"h-full w-[1px]\"\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Separator", + "docs_markdown": "", + "examples": [] + }, + "sheet": { + "name": "Sheet", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "sheet.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Sheet < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {controller: \"ruby-ui--sheet\"}\n }\n end\n end\nend\n" + }, + { + "path": "sheet_content.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SheetContent < Base\n SIDE_CLASS = {\n top: \"inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top\",\n right: \"inset-y-0 right-0 h-full border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right\",\n bottom: \"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom\",\n left: \"inset-y-0 left-0 h-full border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left\"\n }\n\n def initialize(side: :right, **attrs)\n @side = side\n @side_classes = SIDE_CLASS[side]\n super(**attrs)\n end\n\n def view_template(&block)\n template(data: {ruby_ui__sheet_target: \"content\"}) do\n div(data: {controller: \"ruby-ui--sheet-content\"}) do\n backdrop\n div(**attrs) do\n block&.call\n close_button\n end\n end\n end\n end\n\n private\n\n def default_attrs\n {\n data_state: \"open\", # For animate in\n class: [\n \"fixed pointer-events-auto z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500 overflow-scroll\",\n @side_classes\n ]\n }\n end\n\n def close_button\n button(\n type: \"button\",\n class: \"absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground\",\n data_action: \"click->ruby-ui--sheet-content#close\"\n ) do\n svg(\n width: \"15\",\n height: \"15\",\n viewbox: \"0 0 15 15\",\n fill: \"none\",\n xmlns: \"http://www.w3.org/2000/svg\",\n class: \"h-4 w-4\"\n ) do |s|\n s.path(\n d:\n \"M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z\",\n fill: \"currentColor\",\n fill_rule: \"evenodd\",\n clip_rule: \"evenodd\"\n )\n end\n span(class: \"sr-only\") { \"Close\" }\n end\n end\n\n def backdrop\n div(\n data_state: \"open\",\n data_action: \"click->ruby-ui--sheet-content#close\",\n class:\n \"fixed pointer-events-auto inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\"\n )\n end\n end\nend\n" + }, + { + "path": "sheet_content_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n close() {\n this.element.remove()\n }\n}\n" + }, + { + "path": "sheet_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n static targets = [\"content\"]\n\n open() {\n document.body.insertAdjacentHTML(\"beforeend\", this.contentTarget.innerHTML)\n }\n}\n" + }, + { + "path": "sheet_description.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SheetDescription < Base\n def view_template(&)\n p(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"text-sm text-muted-foreground\"\n }\n end\n end\nend\n" + }, + { + "path": "sheet_footer.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SheetFooter < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 gap-y-2 sm:gap-y-0\"\n }\n end\n end\nend\n" + }, + { + "path": "sheet_header.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SheetHeader < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"flex flex-col space-y-1.5 text-center sm:text-left\"\n }\n end\n end\nend\n" + }, + { + "path": "sheet_middle.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SheetMiddle < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"py-4\"\n }\n end\n end\nend\n" + }, + { + "path": "sheet_title.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SheetTitle < Base\n def view_template(&)\n h3(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"text-lg font-semibold leading-none tracking-tight\"\n }\n end\n end\nend\n" + }, + { + "path": "sheet_trigger.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SheetTrigger < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {action: \"click->ruby-ui--sheet#open\"}\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Sheet", + "docs_markdown": "", + "examples": [] + }, + "shortcut_key": { + "name": "ShortcutKey", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "shortcut_key.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ShortcutKey < Base\n def view_template(&)\n kbd(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100\"\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component ShortcutKey", + "docs_markdown": "", + "examples": [] + }, + "sidebar": { + "name": "Sidebar", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "collapsible_sidebar.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CollapsibleSidebar < Base\n def initialize(side: :left, variant: :sidebar, collapsible: :offcanvas, open: true, **attrs)\n @side = side\n @variant = variant\n @collapsible = collapsible\n @open = open\n super(**attrs)\n end\n\n def view_template(&)\n MobileSidebar(side: @side, **attrs, &)\n div(**mix(sidebar_attrs, attrs)) do\n div(**gap_element_attrs)\n div(**content_wrapper_attrs) do\n div(**content_attrs, &)\n end\n end\n end\n\n private\n\n def sidebar_attrs\n {\n class: \"group peer hidden text-sidebar-foreground md:block\",\n data: {\n state: @open ? \"expanded\" : \"collapsed\",\n collapsible: @open ? \"\" : @collapsible,\n variant: @variant,\n side: @side,\n collapsible_kind: @collapsible,\n ruby_ui__sidebar_target: \"sidebar\"\n }\n }\n end\n\n def gap_element_attrs\n {\n class: [\n \"relative w-[var(--sidebar-width)] bg-transparent transition-[width]\",\n \"duration-200 ease-linear\",\n \"group-data-[collapsible=offcanvas]:w-0\",\n \"group-data-[side=right]:rotate-180\",\n variant_classes\n ]\n }\n end\n\n def content_wrapper_attrs\n {\n class: [\n \"fixed inset-y-0 z-10 hidden h-svh w-[var(--sidebar-width)]\",\n \"transition-[left,right,width] duration-200 ease-linear md:flex\",\n content_wrapper_side_classes,\n content_wrapper_variant_classes\n ]\n }\n end\n\n def content_attrs\n {\n class: [\n \"flex h-full w-full flex-col bg-sidebar\",\n \"group-data-[variant=floating]:rounded-lg\",\n \"group-data-[variant=floating]:border\",\n \"group-data-[variant=floating]:border-sidebar-border\",\n \"group-data-[variant=floating]:shadow\"\n ],\n data: {\n sidebar: \"sidebar\"\n }\n }\n end\n\n def variant_classes\n if %i[floating inset].include?(@variant)\n \"group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]\"\n else\n \"group-data-[collapsible=icon]:w-[var(--sidebar-width-icon)]\"\n end\n end\n\n def content_wrapper_side_classes\n return \"left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]\" if @side == :left\n\n \"right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]\"\n end\n\n def content_wrapper_variant_classes\n if %i[floating inset].include?(@variant)\n \"p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]\"\n else\n \"group-data-[collapsible=icon]:w-[var(--sidebar-width-icon)] group-data-[side=left]:border-r group-data-[side=right]:border-l\"\n end\n end\n end\nend\n" + }, + { + "path": "mobile_sidebar.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class MobileSidebar < Base\n SIDEBAR_WIDTH_MOBILE = \"18rem\"\n\n def initialize(side: :left, **attrs)\n @side = side\n super(**attrs)\n end\n\n def view_template(&)\n Sheet(**attrs) do\n SheetContent(\n side: @side,\n class: \"w-[var(--sidebar-width)] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden\",\n style: {\n \"--sidebar-width\": SIDEBAR_WIDTH_MOBILE\n },\n data: {\n sidebar: \"sidebar\",\n mobile: \"true\"\n }\n ) do\n SheetHeader(class: \"sr-only\") do\n SheetTitle { \"Sidebar\" }\n SheetDescription { \"Displays the mobile sidebar.\" }\n end\n div(class: \"flex h-full w-full flex-col\", &)\n end\n end\n end\n\n private\n\n def default_attrs\n {\n data: {\n ruby_ui__sidebar_target: \"mobileSidebar\",\n action: \"ruby--ui-sidebar:open->ruby-ui--sheet#open:self\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "non_collapsible_sidebar.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class NonCollapsibleSidebar < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"flex h-full w-[var(--sidebar-width)] flex-col bg-sidebar text-sidebar-foreground\"\n }\n end\n end\nend\n" + }, + { + "path": "sidebar.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Sidebar < Base\n SIDES = %i[left right].freeze\n VARIANTS = %i[sidebar floating inset].freeze\n COLLAPSIBLES = %i[offcanvas icon none].freeze\n\n def initialize(side: :left, variant: :sidebar, collapsible: :offcanvas, open: true, **attrs)\n raise ArgumentError, \"Invalid side: #{side}.\" unless SIDES.include?(side.to_sym)\n raise ArgumentError \"Invalid variant: #{variant}.\" unless VARIANTS.include?(variant.to_sym)\n raise ArgumentError, \"Invalid collapsible: #{collapsible}.\" unless COLLAPSIBLES.include?(collapsible.to_sym)\n\n @side = side.to_sym\n @variant = variant.to_sym\n @collapsible = collapsible.to_sym\n @open = open\n super(**attrs)\n end\n\n def view_template(&)\n if @collapsible == :none\n NonCollapsibleSidebar(**attrs, &)\n else\n CollapsibleSidebar(side: @side, variant: @variant, collapsible: @collapsible, open: @open, **attrs, &)\n end\n end\n end\nend\n" + }, + { + "path": "sidebar_content.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SidebarContent < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden\",\n data: {\n sidebar: \"content\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "sidebar_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\";\n\nconst SIDEBAR_COOKIE_NAME = \"sidebar_state\";\nconst SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;\nconst State = {\n EXPANDED: \"expanded\",\n COLLAPSED: \"collapsed\",\n};\nconst MOBILE_BREAKPOINT = 768;\n\nexport default class extends Controller {\n static targets = [\"sidebar\", \"mobileSidebar\"];\n\n sidebarTargetConnected() {\n const { state, collapsibleKind } = this.sidebarTarget.dataset;\n\n this.open = state === State.EXPANDED;\n this.collapsibleKind = collapsibleKind;\n }\n\n toggle(e) {\n e.preventDefault();\n\n if (this.#isMobile()) {\n this.#openMobileSidebar();\n\n return;\n }\n\n this.open = !this.open;\n this.onToggle();\n }\n\n onToggle() {\n this.#updateSidebarState();\n this.#persistSidebarState();\n }\n\n #updateSidebarState() {\n if (!this.hasSidebarTarget) {\n return;\n }\n\n const { dataset } = this.sidebarTarget;\n\n dataset.state = this.open ? State.EXPANDED : State.COLLAPSED;\n dataset.collapsible = this.open ? \"\" : this.collapsibleKind;\n }\n\n #persistSidebarState() {\n document.cookie = `${SIDEBAR_COOKIE_NAME}=${this.open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;\n }\n\n #isMobile() {\n return window.innerWidth < MOBILE_BREAKPOINT;\n }\n\n #openMobileSidebar() {\n if (!this.hasMobileSidebarTarget) {\n return;\n }\n\n this.mobileSidebarTarget.dispatchEvent(\n new CustomEvent(\"ruby--ui-sidebar:open\"),\n );\n }\n}\n" + }, + { + "path": "sidebar_footer.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SidebarFooter < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"flex flex-col gap-2 p-2\",\n data: {\n sidebar: \"footer\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "sidebar_group.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SidebarGroup < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"relative flex w-full min-w-0 flex-col p-2\",\n data: {\n sidebar: \"group\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "sidebar_group_action.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SidebarGroupAction < Base\n def initialize(as: :button, **attrs)\n @as = as\n super(**attrs)\n end\n\n def view_template(&)\n tag(@as, **attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: [\n \"absolute right-3 top-3.5 flex aspect-square w-5 items-center\",\n \"justify-center rounded-md p-0 text-sidebar-foreground\",\n \"outline-none ring-sidebar-ring transition-transform\",\n \"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground\",\n \"focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0\",\n \"after:absolute after:-inset-2 after:md:hidden\",\n \"group-data-[collapsible=icon]:hidden\"\n ],\n data: {\n sidebar: \"group-action\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "sidebar_group_content.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SidebarGroupContent < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"w-full text-sm\",\n data: {\n sidebar: \"group-content\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "sidebar_group_label.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SidebarGroupLabel < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: [\n \"flex h-8 shrink-0 items-center rounded-md px-2 text-xs\",\n \"font-medium text-sidebar-foreground/70 outline-none\",\n \"ring-sidebar-ring transition-[margin,opacity] duration-200\",\n \"ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0\",\n \"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0\"\n ],\n data: {\n sidebar: \"group-label\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "sidebar_header.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SidebarHeader < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"flex flex-col gap-2 p-2\",\n data: {\n sidebar: \"header\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "sidebar_input.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SidebarInput < Base\n def view_template(&)\n Input(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring\",\n data: {\n sidebar: \"input\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "sidebar_inset.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SidebarInset < Base\n def view_template(&)\n main(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: [\n \"relative flex w-full flex-1 flex-col bg-background\",\n \"md:peer-data-[variant=inset]:m-2\",\n \"md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2\",\n \"md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl\",\n \"md:peer-data-[variant=inset]:shadow\"\n ]\n }\n end\n end\nend\n" + }, + { + "path": "sidebar_menu.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SidebarMenu < Base\n def view_template(&)\n ul(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"flex w-full min-w-0 flex-col gap-1\",\n data: {\n sidebar: \"menu\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "sidebar_menu_action.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SidebarMenuAction < Base\n def initialize(as: :button, show_on_hover: false, **attrs)\n @as = as\n super(**attrs)\n end\n\n def view_template(&)\n tag(@as, **attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: [\n \"absolute right-1 top-1.5 flex aspect-square w-5 items-center\",\n \"justify-center rounded-md p-0 text-sidebar-foreground outline-none\",\n \"ring-sidebar-ring transition-transform hover:bg-sidebar-accent\",\n \"hover:text-sidebar-accent-foreground focus-visible:ring-2\",\n \"peer-hover/menu-button:text-sidebar-accent-foreground\",\n \"[&>svg]:size-4 [&>svg]:shrink-0\",\n \"after:absolute after:-inset-2 after:md:hidden\",\n \"peer-data-[size=sm]/menu-button:top-1\",\n \"peer-data-[size=default]/menu-button:top-1.5\",\n \"peer-data-[size=lg]/menu-button:top-2.5\",\n \"group-data-[collapsible=icon]:hidden\",\n show_on_hover_classes\n ],\n data: {\n sidebar: \"menu-action\"\n }\n }\n end\n\n def show_on_hover_classes\n return unless @show_on_hover\n\n [\n \"group-focus-within/menu-item:opacity-100\",\n \"group-hover/menu-item:opacity-100 data-[state=open]:opacity-100\",\n \"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0\"\n ].join(\" \")\n end\n end\nend\n" + }, + { + "path": "sidebar_menu_badge.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SidebarMenuBadge < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: [\n \"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none\",\n \"items-center justify-center rounded-md px-1 text-xs font-medium\",\n \"tabular-nums text-sidebar-foreground\",\n \"peer-hover/menu-button:text-sidebar-accent-foreground\",\n \"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground\",\n \"peer-data-[size=sm]/menu-button:top-1\",\n \"peer-data-[size=default]/menu-button:top-1.5\",\n \"peer-data-[size=lg]/menu-button:top-2.5\",\n \"group-data-[collapsible=icon]:hidden\"\n ],\n data: {\n sidebar: \"menu-badge\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "sidebar_menu_button.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SidebarMenuButton < Base\n VARIANT_CLASSES = {\n default: \"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground\",\n outline:\n \"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]\"\n }.freeze\n\n SIZE_CLASSES = {\n default: \"h-8 text-sm\",\n sm: \"h-7 text-xs\",\n lg: \"h-12 text-sm group-data-[collapsible=icon]:!p-0\"\n }.freeze\n\n def initialize(as: :button, variant: :default, size: :default, active: false, **attrs)\n raise ArgumentError, \"Invalid variant: #{variant}\" unless VARIANT_CLASSES.key?(variant)\n raise ArgumentError, \"Invalid size: #{size}\" unless SIZE_CLASSES.key?(size)\n\n @as = as\n @variant = variant\n @size = size\n @active = active\n super(**attrs)\n end\n\n def view_template(&)\n tag(@as, **attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: [\n \"peer/menu-button flex w-full items-center gap-2 overflow-hidden\",\n \"rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring\",\n \"transition-[width,height,padding] hover:bg-sidebar-accent\",\n \"hover:text-sidebar-accent-foreground focus-visible:ring-2\",\n \"active:bg-sidebar-accent active:text-sidebar-accent-foreground\",\n \"disabled:pointer-events-none disabled:opacity-50\",\n \"group-has-[[data-sidebar=menu-action]]/menu-item:pr-8\",\n \"aria-disabled:pointer-events-none aria-disabled:opacity-50\",\n \"data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium\",\n \"data-[active=true]:text-sidebar-accent-foreground\",\n \"data-[state=open]:hover:bg-sidebar-accent\",\n \"data-[state=open]:hover:text-sidebar-accent-foreground\",\n \"group-data-[collapsible=icon]:!size-8\",\n \"group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate\",\n \"[&>svg]:size-4 [&>svg]:shrink-0\",\n VARIANT_CLASSES[@variant],\n SIZE_CLASSES[@size]\n ],\n data: {\n sidebar: \"menu-button\",\n size: @size,\n active: @active.to_s\n }\n }\n end\n end\nend\n" + }, + { + "path": "sidebar_menu_item.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SidebarMenuItem < Base\n def view_template(&)\n ul(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"group/menu-item relative\",\n data: {\n sidebar: \"menu-item\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "sidebar_menu_skeleton.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SidebarMenuSkeleton < Base\n def initialize(show_icon: false, **attrs)\n @show_icon = show_icon\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs) do\n Skeleton(class: \"size-4 rounded-md\", data: {sidebar: \"menu-skeleton-icon\"}) if @show_icon\n Skeleton(\n class: \"h-4 max-w-[var(--skeleton-width)] flex-1\",\n data: {sidebar: \"menu-skeleton-text\"},\n style: {\"--skeleton-width\" => \"#{skeleton_width}%\"}\n )\n end\n end\n\n private\n\n def default_attrs\n {\n class: \"flex h-8 items-center gap-2 rounded-md px-2\",\n data: {\n sidebar: \"menu-skeleton\"\n }\n }\n end\n\n def skeleton_width\n @_skeleton_width ||= rand(50..89)\n end\n end\nend\n" + }, + { + "path": "sidebar_menu_sub.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SidebarMenuSub < Base\n def view_template(&)\n ul(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: [\n \"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l\",\n \"border-sidebar-border px-2.5 py-0.5\",\n \"group-data-[collapsible=icon]:hidden\"\n ],\n data: {\n sidebar: \"menu-sub\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "sidebar_menu_sub_button.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SidebarMenuSubButton < Base\n SIZE_CLASSES = {\n sm: \"text-xs\",\n md: \"text-sm\"\n }.freeze\n\n def initialize(as: :button, size: :md, active: false, **attrs)\n raise ArgumentError, \"Invalid size: #{size}\" unless SIZE_CLASSES.key?(size)\n\n @as = as\n @size = size\n @active = active\n super(**attrs)\n end\n\n def view_template(&)\n tag(@as, **attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: [\n \"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden\",\n \"rounded-md px-2 text-sidebar-foreground outline-none\",\n \"ring-sidebar-ring hover:bg-sidebar-accent\",\n \"hover:text-sidebar-accent-foreground focus-visible:ring-2\",\n \"active:bg-sidebar-accent active:text-sidebar-accent-foreground\",\n \"disabled:pointer-events-none disabled:opacity-50\",\n \"aria-disabled:pointer-events-none aria-disabled:opacity-50\",\n \"[&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0\",\n \"[&>svg]:text-sidebar-accent-foreground\",\n \"data-[active=true]:bg-sidebar-accent\",\n \"data-[active=true]:text-sidebar-accent-foreground\",\n \"group-data-[collapsible=icon]:hidden\",\n SIZE_CLASSES[@size]\n ],\n data: {\n sidebar: \"menu-sub-button\",\n size: @size,\n active: @active.to_s\n }\n }\n end\n end\nend\n" + }, + { + "path": "sidebar_menu_sub_item.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SidebarMenuSubItem < Base\n def view_template(&)\n li(**attrs, &)\n end\n end\nend\n" + }, + { + "path": "sidebar_rail.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SidebarRail < Base\n def view_template(&)\n button(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: [\n \"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all\",\n \"ease-linear after:absolute after:inset-y-0 after:left-1/2\",\n \"after:w-[2px] hover:after:bg-sidebar-border\",\n \"group-data-[side=left]:-right-4 group-data-[side=right]:left-0\",\n \"sm:flex [[data-side=left]_&]:cursor-w-resize\",\n \"[[data-side=right]_&]:cursor-e-resize\",\n \"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize\",\n \"[[data-side=right][data-state=collapsed]_&]:cursor-w-resize\",\n \"group-data-[collapsible=offcanvas]:translate-x-0\",\n \"group-data-[collapsible=offcanvas]:after:left-full\",\n \"group-data-[collapsible=offcanvas]:hover:bg-sidebar\",\n \"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2\",\n \"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2\"\n ],\n data: {\n sidebar: \"rail\",\n tabindex: \"-1\",\n action: \"click->ruby-ui--sidebar#toggle\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "sidebar_separator.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SidebarSeparator < Base\n def view_template(&)\n Separator(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"mx-2 w-auto bg-sidebar-border\",\n data: {\n sidebar: \"separator\"\n }\n }\n end\n end\nend\n" + }, + { + "path": "sidebar_trigger.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SidebarTrigger < Base\n def view_template(&)\n Button(variant: :ghost, size: :icon, **attrs) do\n panel_left_icon\n span(class: \"sr-only\") { \"Toggle Sidebar\" }\n end\n end\n\n private\n\n def default_attrs\n {\n class: \"h-7 w-7 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0\",\n data: {\n sidebar: \"trigger\",\n action: \"click->ruby-ui--sidebar#toggle\"\n }\n }\n end\n\n def panel_left_icon\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n width: \"24\",\n height: \"24\",\n viewBox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n class: \"lucide lucide-panel-left\"\n ) do |s|\n s.rect(width: \"18\", height: \"18\", x: \"3\", y: \"3\", rx: \"2\")\n s.path(d: \"M9 3v18\")\n end\n end\n end\nend\n" + }, + { + "path": "sidebar_wrapper.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SidebarWrapper < Base\n SIDEBAR_WIDTH = \"16rem\"\n SIDEBAR_WIDTH_ICON = \"3rem\"\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"group/sidebar-wrapper [&:has([data-variant=inset])]:bg-sidebar flex min-h-svh w-full\",\n style: \"--sidebar-width: #{SIDEBAR_WIDTH}; --sidebar-width-icon: #{SIDEBAR_WIDTH_ICON};\",\n data: {\n controller: \"ruby-ui--sidebar\"\n }\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Sidebar", + "docs_markdown": "", + "examples": [] + }, + "skeleton": { + "name": "Skeleton", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "skeleton.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Skeleton < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"animate-pulse rounded-md bg-primary/10\"\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Skeleton", + "docs_markdown": "", + "examples": [] + }, + "switch": { + "name": "Switch", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "switch.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Switch < Base\n def initialize(include_hidden: true, checked_value: \"1\", unchecked_value: \"0\", **attrs)\n @include_hidden = include_hidden\n @checked_value = checked_value\n @unchecked_value = unchecked_value\n super(**attrs)\n end\n\n def view_template\n label(\n role: \"switch\",\n class: [\n \"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors bg-input\",\n \"has-checked:bg-primary\",\n \"has-disabled:cursor-not-allowed has-disabled:opacity-50\",\n \"has-aria-disabled:cursor-not-allowed has-aria-disabled:opacity-50 has-aria-disabled:pointer-events-none\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n ]\n ) do\n input(type: \"hidden\", name: attrs[:name], value: @unchecked_value) if @include_hidden\n\n input(**attrs.merge(type: \"checkbox\", class: \"hidden peer\", value: @checked_value))\n\n span(class: [\n \"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform translate-x-0\",\n \"peer-checked:translate-x-5\"\n ])\n end\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Switch", + "docs_markdown": "", + "examples": [] + }, + "table": { + "name": "Table", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "table.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Table < Base\n def view_template(&block)\n div(class: \"relative w-full overflow-auto\") do\n table(**attrs, &block)\n end\n end\n\n private\n\n def default_attrs\n {\n class: \"w-full caption-bottom text-sm\"\n }\n end\n end\nend\n" + }, + { + "path": "table_body.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TableBody < Base\n def view_template(&)\n tbody(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"[&_tr:last-child]:border-0\"\n }\n end\n end\nend\n" + }, + { + "path": "table_caption.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TableCaption < Base\n def view_template(&)\n caption(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"mt-4 text-sm text-muted-foreground\"\n }\n end\n end\nend\n" + }, + { + "path": "table_cell.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TableCell < Base\n def view_template(&)\n td(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]\"\n }\n end\n end\nend\n" + }, + { + "path": "table_footer.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TableFooter < Base\n def view_template(&)\n tfoot(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"border-t bg-muted/50 font-medium[& amp;>tr]:last:border-b-0\"\n }\n end\n end\nend\n" + }, + { + "path": "table_head.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TableHead < Base\n def view_template(&)\n th(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]\"\n }\n end\n end\nend\n" + }, + { + "path": "table_header.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TableHeader < Base\n def view_template(&)\n thead(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"[&_tr]:border-b\"\n }\n end\n end\nend\n" + }, + { + "path": "table_row.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TableRow < Base\n def view_template(&)\n tr(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted\"\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Table", + "docs_markdown": "", + "examples": [] + }, + "tabs": { + "name": "Tabs", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "tabs.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Tabs < Base\n def initialize(default: nil, **attrs)\n @default = default\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n controller: \"ruby-ui--tabs\",\n ruby_ui__tabs_active_value: @default\n }\n }\n end\n end\nend\n" + }, + { + "path": "tabs_content.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TabsContent < Base\n def initialize(value:, **attrs)\n @value = value\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n ruby_ui__tabs_target: :content,\n value: @value\n },\n class: \"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hidden\"\n }\n end\n end\nend\n" + }, + { + "path": "tabs_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"ruby-ui--tabs\"\nexport default class extends Controller {\n static targets = [\"trigger\", \"content\"];\n static values = { active: String };\n\n connect() {\n if (!this.hasActiveValue && this.triggerTargets.length > 0) {\n this.activeValue = this.triggerTargets[0].dataset.value;\n }\n }\n\n show(e) {\n this.activeValue = e.currentTarget.dataset.value;\n }\n\n activeValueChanged(currentValue, previousValue) {\n if (currentValue == \"\" || currentValue == previousValue) return;\n\n this.contentTargets.forEach((el) => {\n el.classList.add(\"hidden\");\n });\n\n this.triggerTargets.forEach((el) => {\n el.dataset.state = \"inactive\";\n });\n\n this.activeContentTarget() &&\n this.activeContentTarget().classList.remove(\"hidden\");\n this.activeTriggerTarget().dataset.state = \"active\";\n }\n\n activeTriggerTarget() {\n return this.triggerTargets.find(\n (el) => el.dataset.value == this.activeValue,\n );\n }\n\n activeContentTarget() {\n return this.contentTargets.find(\n (el) => el.dataset.value == this.activeValue,\n );\n }\n}\n" + }, + { + "path": "tabs_list.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TabsList < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground\"\n }\n end\n end\nend\n" + }, + { + "path": "tabs_trigger.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TabsTrigger < Base\n def initialize(value:, **attrs)\n @value = value\n super(**attrs)\n end\n\n def view_template(&)\n button(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n type: :button,\n data: {\n ruby_ui__tabs_target: \"trigger\",\n action: \"click->ruby-ui--tabs#show\",\n value: @value\n },\n class: [\n \"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all\",\n \"disabled:pointer-events-none disabled:opacity-50\",\n \"aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-disabled:cursor-not-allowed\",\n \"data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\"\n ]\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Tabs", + "docs_markdown": "", + "examples": [] + }, + "textarea": { + "name": "Textarea", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "textarea.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Textarea < Base\n def initialize(rows: 4, **attrs)\n @rows = rows\n super(**attrs)\n end\n\n def view_template(&)\n textarea(rows: @rows, **attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n ruby_ui__form_field_target: \"input\",\n action: \"input->ruby-ui--form-field#onInput invalid->ruby-ui--form-field#onInvalid\"\n },\n class: [\n \"flex w-full rounded-md border bg-background px-3 py-1 text-sm shadow-sm transition-colors border-border\",\n \"placeholder:text-muted-foreground\",\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n \"file:border-0 file:bg-transparent file:text-sm file:font-medium\",\n \"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\",\n \"aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:pointer-events-none\"\n ]\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Textarea", + "docs_markdown": "", + "examples": [] + }, + "theme_toggle": { + "name": "ThemeToggle", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "set_dark_mode.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SetDarkMode < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n def default_attrs\n {\n class: \"hidden dark:inline-block\",\n data: {controller: \"ruby-ui--theme-toggle\", action: \"click->ruby-ui--theme-toggle#setLightTheme\"}\n }\n end\n end\nend\n" + }, + { + "path": "set_light_mode.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SetLightMode < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n def default_attrs\n {\n class: \"dark:hidden\",\n data: {controller: \"ruby-ui--theme-toggle\", action: \"click->ruby-ui--theme-toggle#setDarkTheme\"}\n }\n end\n end\nend\n" + }, + { + "path": "theme_toggle.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ThemeToggle < Base\n def view_template(&)\n div(**attrs, &)\n end\n end\nend\n" + }, + { + "path": "theme_toggle_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n initialize() {\n this.setTheme()\n }\n\n setTheme() {\n // On page load or when changing themes, best to add inline in `head` to avoid FOUC\n if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {\n document.documentElement.classList.add('dark')\n document.documentElement.classList.remove('light')\n } else {\n document.documentElement.classList.remove('dark')\n document.documentElement.classList.add('light')\n }\n }\n\n setLightTheme() {\n // Whenever the user explicitly chooses light mode\n localStorage.theme = 'light'\n this.setTheme()\n }\n\n setDarkTheme() {\n // Whenever the user explicitly chooses dark mode\n localStorage.theme = 'dark'\n this.setTheme()\n }\n}\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component ThemeToggle", + "docs_markdown": "", + "examples": [] + }, + "tooltip": { + "name": "Tooltip", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "tooltip.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Tooltip < Base\n def initialize(placement: \"top\", **attrs)\n @placement = placement\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n controller: \"ruby-ui--tooltip\",\n ruby_ui__tooltip_placement_value: @placement\n },\n class: \"group\"\n }\n end\n end\nend\n" + }, + { + "path": "tooltip_content.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TooltipContent < Base\n def initialize(**attrs)\n @id = \"tooltip#{SecureRandom.hex(4)}\"\n super\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n id: @id,\n data: {\n ruby_ui__tooltip_target: \"content\"\n },\n class: \"invisible peer-hover:visible peer-focus:visible w-fit max-w-[calc(100vw-2rem)] text-balance break-words absolute top-0 left-0 z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md peer-focus:zoom-in-95 animate-out fade-out-0 zoom-out-95 peer-hover:animate-in peer-focus:animate-in peer-hover:fade-in-0 peer-focus:fade-in-0 peer-hover:zoom-in-95 group-data-[ruby-ui--tooltip-placement-value=bottom]:slide-in-from-top-2 group-data-[ruby-ui--tooltip-placement-value=left]:slide-in-from-right-2 group-data-[ruby-ui--tooltip-placement-value=right]:slide-in-from-left-2 group-data-[ruby-ui--tooltip-placement-value=top]:slide-in-from-bottom-2 delay-500\"\n }\n end\n end\nend\n" + }, + { + "path": "tooltip_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\";\nimport { computePosition, autoUpdate, offset, shift } from \"@floating-ui/dom\";\n\nexport default class extends Controller {\n static targets = [\"trigger\", \"content\"];\n static values = { placement: String }\n\n constructor(...args) {\n super(...args);\n this.cleanup;\n }\n\n connect() {\n this.setFloatingElement();\n\n const tooltipId = this.contentTarget.getAttribute(\"id\");\n this.triggerTarget.setAttribute(\"aria-describedby\", tooltipId);\n\n }\n\n disconnect() {\n this.cleanup();\n }\n\n setFloatingElement() {\n this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => {\n computePosition(this.triggerTarget, this.contentTarget, {\n placement: this.placementValue,\n middleware: [offset(4), shift()]\n }).then(({ x, y }) => {\n Object.assign(this.contentTarget.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n });\n });\n }\n}\n" + }, + { + "path": "tooltip_trigger.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TooltipTrigger < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {ruby_ui__tooltip_target: \"trigger\"},\n variant: :outline,\n class: \"peer\"\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [ + "Typography" + ], + "js_packages": [ + "@floating-ui/dom" + ], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Tooltip", + "docs_markdown": "", + "examples": [] + }, + "typography": { + "name": "Typography", + "description": "frozen_string_literal: true", + "files": [ + { + "path": "heading.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Heading < Base\n def initialize(level: nil, as: nil, size: nil, **attrs)\n @level = level\n @as = as\n @size = size\n super(**attrs)\n end\n\n def view_template(&)\n tag = determine_tag\n public_send(tag, **attrs, &)\n end\n\n private\n\n def determine_tag\n return @as if @as\n return \"h#{@level}\" if @level\n \"h1\"\n end\n\n def default_attrs\n {\n class: class_names\n }\n end\n\n def class_names\n base_classes = \"scroll-m-20 font-bold tracking-tight\"\n size_class = size_to_class[(@size || level_to_size[@level&.to_s] || \"6\").to_s]\n \"#{base_classes} #{size_class}\"\n end\n\n def size_to_class\n {\n \"1\" => \"text-xs\",\n \"2\" => \"text-sm\",\n \"3\" => \"text-base\",\n \"4\" => \"text-lg\",\n \"5\" => \"text-xl\",\n \"6\" => \"text-2xl\",\n \"7\" => \"text-3xl lg:text-4xl\",\n \"8\" => \"text-4xl\",\n \"9\" => \"text-5xl\"\n }\n end\n\n def level_to_size\n {\n \"1\" => \"7\",\n \"2\" => \"6\",\n \"3\" => \"5\",\n \"4\" => \"4\"\n }\n end\n end\nend\n" + }, + { + "path": "inline_code.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class InlineCode < Base\n def view_template(&)\n code(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold\"\n }\n end\n end\nend\n" + }, + { + "path": "inline_link.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class InlineLink < Base\n def initialize(href:, **attrs)\n super(**attrs)\n @href = href\n end\n\n def view_template(&)\n a(href: @href, **attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"text-primary font-medium hover:underline underline-offset-4 cursor-pointer\"\n }\n end\n end\nend\n" + }, + { + "path": "text.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Text < Base\n def initialize(as: \"p\", size: \"3\", weight: \"regular\", **attrs)\n @as = as\n @size = size\n @weight = weight\n super(**attrs)\n end\n\n def view_template(&)\n public_send(@as, **attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: class_names\n }\n end\n\n def class_names\n \"#{size_to_class[@size]} #{weight_to_class[@weight]}\"\n end\n\n def size_to_class\n {\n \"1\" => \"text-xs\", \"xs\" => \"text-xs\",\n \"2\" => \"text-sm\", \"sm\" => \"text-sm\",\n \"3\" => \"text-base\", \"base\" => \"text-base\",\n \"4\" => \"text-lg\", \"lg\" => \"text-lg\",\n \"5\" => \"text-xl\", \"xl\" => \"text-xl\",\n \"6\" => \"text-2xl\", \"2xl\" => \"text-2xl\",\n \"7\" => \"text-3xl\", \"3xl\" => \"text-3xl\",\n \"8\" => \"text-4xl\", \"4xl\" => \"text-4xl\",\n \"9\" => \"text-5xl\", \"5xl\" => \"text-5xl\"\n }\n end\n\n def weight_to_class\n {\n \"muted\" => \"text-muted-foreground\",\n \"light\" => \"font-light\",\n \"regular\" => \"font-normal\",\n \"medium\" => \"font-medium\",\n \"semibold\" => \"font-semibold\",\n \"bold\" => \"font-bold\"\n }\n end\n end\nend\n" + }, + { + "path": "typography_blockquote.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TypographyBlockquote < Base\n def view_template(&)\n blockquote(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"mt-6 border-l-2 pl-6 italic\"\n }\n end\n end\nend\n" + } + ], + "dependencies": { + "components": [], + "js_packages": [], + "gems": [] + }, + "install_command": "rails g ruby_ui:component Typography", + "docs_markdown": "", + "examples": [] + } + } +} diff --git a/mcp/exe/ruby-ui-mcp-build b/mcp/exe/ruby-ui-mcp-build new file mode 100755 index 00000000..c66864e8 --- /dev/null +++ b/mcp/exe/ruby-ui-mcp-build @@ -0,0 +1,11 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) +require "fileutils" +require "ruby_ui/mcp/builders/registry_builder" + +gem_path = ENV["RUBY_UI_GEM_PATH"] || File.expand_path("../../gem", __dir__) +out = File.expand_path("../data/registry.json", __dir__) +FileUtils.mkdir_p(File.dirname(out)) +RubyUI::MCP::Builders::RegistryBuilder.new(gem_path: gem_path).write(out) +puts "Wrote #{out}" diff --git a/mcp/lib/ruby_ui/mcp/builders/registry_builder.rb b/mcp/lib/ruby_ui/mcp/builders/registry_builder.rb index 44b14955..dc29fb82 100644 --- a/mcp/lib/ruby_ui/mcp/builders/registry_builder.rb +++ b/mcp/lib/ruby_ui/mcp/builders/registry_builder.rb @@ -32,13 +32,18 @@ def write(path) private def read_version - path = File.join(@gem_path, "lib/ruby_ui/version.rb") - src = File.read(path) - if (m = src.match(/VERSION\s*=\s*["']([^"']+)["']/)) - m[1] - else - "unknown" + candidates = [ + File.join(@gem_path, "lib/ruby_ui/version.rb"), + File.join(@gem_path, "lib/ruby_ui.rb") + ] + candidates.each do |path| + next unless File.exist?(path) + src = File.read(path) + if (m = src.match(/VERSION\s*=\s*["']([^"']+)["']/)) + return m[1] + end end + "unknown" rescue "unknown" end diff --git a/mcp/lib/ruby_ui/mcp/registry.rb b/mcp/lib/ruby_ui/mcp/registry.rb index aeef6a0d..86096576 100644 --- a/mcp/lib/ruby_ui/mcp/registry.rb +++ b/mcp/lib/ruby_ui/mcp/registry.rb @@ -53,7 +53,7 @@ def search(query, limit: 10) scored.select { |_, s| s > 0 } .sort_by { |_, s| -s } .first(limit) - .map { |c, _s| {name: c[:name], description: c[:description]} } + .map { |c, s| {name: c[:name], description: c[:description], score: s} } end def partition_names(names) From 8804a1a081755d415c5be8eec705a608dc30c7b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 11:11:40 -0300 Subject: [PATCH 07/19] [Feature] MCP tools: list, search, view --- mcp/lib/ruby_ui/mcp/tools/base.rb | 17 ++++++++++++++ .../mcp/tools/get_project_registries.rb | 21 +++++++++++++++++ .../mcp/tools/list_items_in_registries.rb | 15 ++++++++++++ .../mcp/tools/search_items_in_registries.rb | 15 ++++++++++++ .../mcp/tools/view_items_in_registries.rb | 21 +++++++++++++++++ mcp/test/tools/list_test.rb | 17 ++++++++++++++ mcp/test/tools/search_test.rb | 20 ++++++++++++++++ mcp/test/tools/view_test.rb | 23 +++++++++++++++++++ 8 files changed, 149 insertions(+) create mode 100644 mcp/lib/ruby_ui/mcp/tools/base.rb create mode 100644 mcp/lib/ruby_ui/mcp/tools/get_project_registries.rb create mode 100644 mcp/lib/ruby_ui/mcp/tools/list_items_in_registries.rb create mode 100644 mcp/lib/ruby_ui/mcp/tools/search_items_in_registries.rb create mode 100644 mcp/lib/ruby_ui/mcp/tools/view_items_in_registries.rb create mode 100644 mcp/test/tools/list_test.rb create mode 100644 mcp/test/tools/search_test.rb create mode 100644 mcp/test/tools/view_test.rb diff --git a/mcp/lib/ruby_ui/mcp/tools/base.rb b/mcp/lib/ruby_ui/mcp/tools/base.rb new file mode 100644 index 00000000..ad881aac --- /dev/null +++ b/mcp/lib/ruby_ui/mcp/tools/base.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module RubyUI + module MCP + module Tools + class Base + def initialize(registry:) + @registry = registry + end + + def call(**args) + raise NotImplementedError + end + end + end + end +end diff --git a/mcp/lib/ruby_ui/mcp/tools/get_project_registries.rb b/mcp/lib/ruby_ui/mcp/tools/get_project_registries.rb new file mode 100644 index 00000000..d572ed05 --- /dev/null +++ b/mcp/lib/ruby_ui/mcp/tools/get_project_registries.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "ruby_ui/mcp/tools/base" + +module RubyUI + module MCP + module Tools + class GetProjectRegistries < Base + def call(**) + { + registries: [{ + name: "ruby_ui", + url: "https://rubyui.com/mcp", + description: "Ruby UI components for Phlex + Rails." + }] + } + end + end + end + end +end diff --git a/mcp/lib/ruby_ui/mcp/tools/list_items_in_registries.rb b/mcp/lib/ruby_ui/mcp/tools/list_items_in_registries.rb new file mode 100644 index 00000000..a5276451 --- /dev/null +++ b/mcp/lib/ruby_ui/mcp/tools/list_items_in_registries.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "ruby_ui/mcp/tools/base" + +module RubyUI + module MCP + module Tools + class ListItemsInRegistries < Base + def call(**) + {items: @registry.list, gem_version: @registry.version} + end + end + end + end +end diff --git a/mcp/lib/ruby_ui/mcp/tools/search_items_in_registries.rb b/mcp/lib/ruby_ui/mcp/tools/search_items_in_registries.rb new file mode 100644 index 00000000..47537f16 --- /dev/null +++ b/mcp/lib/ruby_ui/mcp/tools/search_items_in_registries.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "ruby_ui/mcp/tools/base" + +module RubyUI + module MCP + module Tools + class SearchItemsInRegistries < Base + def call(query:, limit: 10, **) + {items: @registry.search(query, limit: limit), gem_version: @registry.version} + end + end + end + end +end diff --git a/mcp/lib/ruby_ui/mcp/tools/view_items_in_registries.rb b/mcp/lib/ruby_ui/mcp/tools/view_items_in_registries.rb new file mode 100644 index 00000000..c37eef06 --- /dev/null +++ b/mcp/lib/ruby_ui/mcp/tools/view_items_in_registries.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "ruby_ui/mcp/tools/base" + +module RubyUI + module MCP + module Tools + class ViewItemsInRegistries < Base + def call(items:, **) + resolved = [] + unresolved = [] + items.each do |name| + comp = @registry.find(name) + comp ? resolved << comp : unresolved << name + end + {items: resolved, unresolved: unresolved, gem_version: @registry.version} + end + end + end + end +end diff --git a/mcp/test/tools/list_test.rb b/mcp/test/tools/list_test.rb new file mode 100644 index 00000000..feea4f0d --- /dev/null +++ b/mcp/test/tools/list_test.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "test_helper" +require "ruby_ui/mcp/tools/list_items_in_registries" + +class ListItemsToolTest < Minitest::Test + def setup + @registry = RubyUI::MCP::Registry.load(TestSupport::FIXTURE_PATH) + @tool = RubyUI::MCP::Tools::ListItemsInRegistries.new(registry: @registry) + end + + def test_returns_all_components + items = @tool.call[:items] + assert_equal 2, items.length + assert_equal %w[Button Dialog], items.map { |i| i[:name] }.sort + end +end diff --git a/mcp/test/tools/search_test.rb b/mcp/test/tools/search_test.rb new file mode 100644 index 00000000..013fd064 --- /dev/null +++ b/mcp/test/tools/search_test.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "test_helper" +require "ruby_ui/mcp/tools/search_items_in_registries" + +class SearchItemsToolTest < Minitest::Test + def setup + @registry = RubyUI::MCP::Registry.load(TestSupport::FIXTURE_PATH) + @tool = RubyUI::MCP::Tools::SearchItemsInRegistries.new(registry: @registry) + end + + def test_finds_by_name + items = @tool.call(query: "dial")[:items] + assert_equal ["Dialog"], items.map { |i| i[:name] } + end + + def test_empty_when_no_match + assert_empty @tool.call(query: "zzz")[:items] + end +end diff --git a/mcp/test/tools/view_test.rb b/mcp/test/tools/view_test.rb new file mode 100644 index 00000000..fe86fef9 --- /dev/null +++ b/mcp/test/tools/view_test.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "test_helper" +require "ruby_ui/mcp/tools/view_items_in_registries" + +class ViewItemsToolTest < Minitest::Test + def setup + @registry = RubyUI::MCP::Registry.load(TestSupport::FIXTURE_PATH) + @tool = RubyUI::MCP::Tools::ViewItemsInRegistries.new(registry: @registry) + end + + def test_returns_full_components + result = @tool.call(items: ["Button"]) + assert_equal 1, result[:items].length + assert_equal "Button", result[:items].first[:name] + assert result[:items].first[:files].any? + end + + def test_unknown_in_unresolved + result = @tool.call(items: ["Bogus"]) + assert_equal ["Bogus"], result[:unresolved] + end +end From cf5a85fe78e46635af7d5331fc21a352ff7f3e12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 11:12:41 -0300 Subject: [PATCH 08/19] [Feature] MCP tools: examples, add_command, audit --- .../mcp/tools/get_add_command_for_items.rb | 24 +++++++++++++++ .../ruby_ui/mcp/tools/get_audit_checklist.rb | 25 ++++++++++++++++ .../get_item_examples_from_registries.rb | 19 ++++++++++++ mcp/test/tools/add_command_test.rb | 30 +++++++++++++++++++ mcp/test/tools/audit_test.rb | 13 ++++++++ mcp/test/tools/examples_test.rb | 23 ++++++++++++++ 6 files changed, 134 insertions(+) create mode 100644 mcp/lib/ruby_ui/mcp/tools/get_add_command_for_items.rb create mode 100644 mcp/lib/ruby_ui/mcp/tools/get_audit_checklist.rb create mode 100644 mcp/lib/ruby_ui/mcp/tools/get_item_examples_from_registries.rb create mode 100644 mcp/test/tools/add_command_test.rb create mode 100644 mcp/test/tools/audit_test.rb create mode 100644 mcp/test/tools/examples_test.rb diff --git a/mcp/lib/ruby_ui/mcp/tools/get_add_command_for_items.rb b/mcp/lib/ruby_ui/mcp/tools/get_add_command_for_items.rb new file mode 100644 index 00000000..22809252 --- /dev/null +++ b/mcp/lib/ruby_ui/mcp/tools/get_add_command_for_items.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "ruby_ui/mcp/tools/base" + +module RubyUI + module MCP + module Tools + class GetAddCommandForItems < Base + GENERATOR = "ruby_ui:component" + + def call(items:, **) + known, unresolved = @registry.partition_names(Array(items)) + { + generator: GENERATOR, + components: known, + unresolved: unresolved, + command_string: known.empty? ? "" : "rails g #{GENERATOR} #{known.join(" ")}", + gem_version: @registry.version + } + end + end + end + end +end diff --git a/mcp/lib/ruby_ui/mcp/tools/get_audit_checklist.rb b/mcp/lib/ruby_ui/mcp/tools/get_audit_checklist.rb new file mode 100644 index 00000000..f3be0c97 --- /dev/null +++ b/mcp/lib/ruby_ui/mcp/tools/get_audit_checklist.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "ruby_ui/mcp/tools/base" + +module RubyUI + module MCP + module Tools + class GetAuditChecklist < Base + CHECKLIST = [ + {check: "gem_in_gemfile", description: "`ruby_ui` gem present in Gemfile."}, + {check: "components_copied", description: "Component files exist under app/components/ruby_ui//."}, + {check: "stimulus_registered", description: "Stimulus controllers registered (where applicable)."}, + {check: "js_packages_installed", description: "JS packages from dependencies.yml present in package.json."}, + {check: "tailwind_content_paths", description: "Tailwind content config includes app/components/ruby_ui/**/*."}, + {check: "zeitwerk_loads", description: "Zeitwerk loads the RubyUI namespace without errors."}, + {check: "views_compile", description: "Generated Phlex views render without errors."} + ].freeze + + def call(**) + {checklist: CHECKLIST} + end + end + end + end +end diff --git a/mcp/lib/ruby_ui/mcp/tools/get_item_examples_from_registries.rb b/mcp/lib/ruby_ui/mcp/tools/get_item_examples_from_registries.rb new file mode 100644 index 00000000..b2a72443 --- /dev/null +++ b/mcp/lib/ruby_ui/mcp/tools/get_item_examples_from_registries.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "ruby_ui/mcp/tools/base" + +module RubyUI + module MCP + module Tools + class GetItemExamplesFromRegistries < Base + def call(items:, **) + resolved = items.map do |n| + c = @registry.find(n) + c ? {name: c[:name], examples: c[:examples] || []} : nil + end.compact + {items: resolved, gem_version: @registry.version} + end + end + end + end +end diff --git a/mcp/test/tools/add_command_test.rb b/mcp/test/tools/add_command_test.rb new file mode 100644 index 00000000..a5af4864 --- /dev/null +++ b/mcp/test/tools/add_command_test.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "test_helper" +require "ruby_ui/mcp/tools/get_add_command_for_items" + +class AddCommandToolTest < Minitest::Test + def setup + @registry = RubyUI::MCP::Registry.load(TestSupport::FIXTURE_PATH) + @tool = RubyUI::MCP::Tools::GetAddCommandForItems.new(registry: @registry) + end + + def test_returns_structured_and_string_form + result = @tool.call(items: ["Button", "Dialog"]) + assert_equal "ruby_ui:component", result[:generator] + assert_equal ["Button", "Dialog"], result[:components] + assert_equal "rails g ruby_ui:component Button Dialog", result[:command_string] + end + + def test_filters_unknown_names + result = @tool.call(items: ["Button", "Bogus"]) + assert_equal ["Button"], result[:components] + assert_equal ["Bogus"], result[:unresolved] + end + + def test_rejects_shell_metachars + result = @tool.call(items: ["Button; rm -rf /"]) + assert_empty result[:components] + refute_match(/rm/, result[:command_string]) + end +end diff --git a/mcp/test/tools/audit_test.rb b/mcp/test/tools/audit_test.rb new file mode 100644 index 00000000..24e8ba56 --- /dev/null +++ b/mcp/test/tools/audit_test.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "test_helper" +require "ruby_ui/mcp/tools/get_audit_checklist" + +class AuditChecklistToolTest < Minitest::Test + def test_returns_checklist + tool = RubyUI::MCP::Tools::GetAuditChecklist.new(registry: nil) + items = tool.call[:checklist] + assert items.length >= 5 + assert items.all? { |i| i[:check] && i[:description] } + end +end diff --git a/mcp/test/tools/examples_test.rb b/mcp/test/tools/examples_test.rb new file mode 100644 index 00000000..d38e3579 --- /dev/null +++ b/mcp/test/tools/examples_test.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "test_helper" +require "ruby_ui/mcp/tools/get_item_examples_from_registries" + +class ExamplesToolTest < Minitest::Test + def setup + @registry = RubyUI::MCP::Registry.load(TestSupport::FIXTURE_PATH) + @tool = RubyUI::MCP::Tools::GetItemExamplesFromRegistries.new(registry: @registry) + end + + def test_returns_examples_per_item + result = @tool.call(items: ["Button"]) + assert_equal 1, result[:items].length + assert_equal "Button", result[:items].first[:name] + assert_equal 1, result[:items].first[:examples].length + end + + def test_empty_examples_returned_for_components_without_any + result = @tool.call(items: ["Dialog"]) + assert_empty result[:items].first[:examples] + end +end From b6210f32a645dfa4701cf183099ebdb1c92ea1e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 11:14:28 -0300 Subject: [PATCH 09/19] [Feature] MCP Server wires 7 tools to ruby-sdk --- mcp/lib/ruby_ui/mcp/server.rb | 118 ++++++++++++++++++++++++++++++++++ mcp/test/server_test.rb | 40 ++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 mcp/lib/ruby_ui/mcp/server.rb create mode 100644 mcp/test/server_test.rb diff --git a/mcp/lib/ruby_ui/mcp/server.rb b/mcp/lib/ruby_ui/mcp/server.rb new file mode 100644 index 00000000..17194669 --- /dev/null +++ b/mcp/lib/ruby_ui/mcp/server.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require "json" +require "mcp" +require "ruby_ui/mcp/registry" +require "ruby_ui/mcp/tools/get_project_registries" +require "ruby_ui/mcp/tools/list_items_in_registries" +require "ruby_ui/mcp/tools/search_items_in_registries" +require "ruby_ui/mcp/tools/view_items_in_registries" +require "ruby_ui/mcp/tools/get_item_examples_from_registries" +require "ruby_ui/mcp/tools/get_add_command_for_items" +require "ruby_ui/mcp/tools/get_audit_checklist" + +module RubyUI + module MCP + class Server + TOOL_DEFINITIONS = [ + { + name: "get_project_registries", + klass: Tools::GetProjectRegistries, + description: "List available registries. Always returns the ruby_ui registry.", + input_schema: {properties: {}} + }, + { + name: "list_items_in_registries", + klass: Tools::ListItemsInRegistries, + description: "List all Ruby UI components with name and description.", + input_schema: {properties: {}} + }, + { + name: "search_items_in_registries", + klass: Tools::SearchItemsInRegistries, + description: "Fuzzy search Ruby UI components by name, description, or docs.", + input_schema: { + properties: { + query: {type: "string"}, + limit: {type: "integer"} + }, + required: ["query"] + } + }, + { + name: "view_items_in_registries", + klass: Tools::ViewItemsInRegistries, + description: "Return full source files and dependencies for the given components.", + input_schema: { + properties: {items: {type: "array", items: {type: "string"}}}, + required: ["items"] + } + }, + { + name: "get_item_examples_from_registries", + klass: Tools::GetItemExamplesFromRegistries, + description: "Return code examples for the given components.", + input_schema: { + properties: {items: {type: "array", items: {type: "string"}}}, + required: ["items"] + } + }, + { + name: "get_add_command_for_items", + klass: Tools::GetAddCommandForItems, + description: "Return a validated `rails g ruby_ui:component …` command for installing components.", + input_schema: { + properties: {items: {type: "array", items: {type: "string"}}}, + required: ["items"] + } + }, + { + name: "get_audit_checklist", + klass: Tools::GetAuditChecklist, + description: "Return a post-install verification checklist.", + input_schema: {properties: {}} + } + ].freeze + + def self.build(registry: RubyUI::MCP.registry) + new(registry: registry).mcp_server + end + + attr_reader :tool_classes + + def initialize(registry:) + @registry = registry + @tool_classes = TOOL_DEFINITIONS.map { |d| build_tool_class(d) } + end + + def mcp_server + ::MCP::Server.new( + name: "ruby_ui", + version: RubyUI::MCP::VERSION, + tools: @tool_classes + ) + end + + private + + def build_tool_class(definition) + impl = definition[:klass].new(registry: @registry) + ::MCP::Tool.define( + name: definition[:name], + description: definition[:description], + input_schema: definition[:input_schema] + ) do |server_context: nil, **args| + payload = impl.call(**args) + ::MCP::Tool::Response.new([ + {type: "text", text: JSON.pretty_generate(payload)} + ]) + rescue => e + ::MCP::Tool::Response.new( + [{type: "text", text: "error: #{e.class}: #{e.message}"}], + error: true + ) + end + end + end + end +end diff --git a/mcp/test/server_test.rb b/mcp/test/server_test.rb new file mode 100644 index 00000000..10689952 --- /dev/null +++ b/mcp/test/server_test.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "test_helper" +require "ruby_ui/mcp/server" + +class ServerTest < Minitest::Test + def setup + @registry = RubyUI::MCP::Registry.load(TestSupport::FIXTURE_PATH) + end + + def test_builds_with_seven_tools + builder = RubyUI::MCP::Server.new(registry: @registry) + names = builder.tool_classes.map(&:name_value).sort + expected = %w[ + get_add_command_for_items + get_audit_checklist + get_item_examples_from_registries + get_project_registries + list_items_in_registries + search_items_in_registries + view_items_in_registries + ] + assert_equal expected, names + end + + def test_server_instance_has_seven_tools + mcp_server = RubyUI::MCP::Server.build(registry: @registry) + assert_kind_of MCP::Server, mcp_server + end + + def test_list_tool_invocation_round_trips + builder = RubyUI::MCP::Server.new(registry: @registry) + list_tool = builder.tool_classes.find { |k| k.name_value == "list_items_in_registries" } + response = list_tool.call(server_context: nil) + assert_kind_of MCP::Tool::Response, response + # Response wraps a content array; payload should include items + serialized = response.to_h + assert serialized[:content] || serialized["content"] + end +end From e4c40bc411fe85e2bef139e1127ed83f0106ea57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 11:15:24 -0300 Subject: [PATCH 10/19] [Feature] MCP Rails engine + Rack endpoint --- mcp/config/routes.rb | 3 +++ mcp/lib/ruby_ui/mcp/engine.rb | 5 ++-- mcp/lib/ruby_ui/mcp/rack_app.rb | 45 +++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 mcp/config/routes.rb create mode 100644 mcp/lib/ruby_ui/mcp/rack_app.rb diff --git a/mcp/config/routes.rb b/mcp/config/routes.rb new file mode 100644 index 00000000..d75a0e44 --- /dev/null +++ b/mcp/config/routes.rb @@ -0,0 +1,3 @@ +RubyUI::MCP::Engine.routes.draw do + match "/", to: RubyUI::MCP::RackApp, via: %i[get post delete], as: :mcp +end diff --git a/mcp/lib/ruby_ui/mcp/engine.rb b/mcp/lib/ruby_ui/mcp/engine.rb index cd751bcf..391ba419 100644 --- a/mcp/lib/ruby_ui/mcp/engine.rb +++ b/mcp/lib/ruby_ui/mcp/engine.rb @@ -6,9 +6,8 @@ module MCP class Engine < ::Rails::Engine isolate_namespace RubyUI::MCP - initializer "ruby_ui.mcp.load_registry" do - require "ruby_ui/mcp/registry" # TODO: Task 2 — Registry implementation - RubyUI::MCP.registry # eager load, fail fast on bad registry + initializer "ruby_ui.mcp.eager_load_rack_app", after: :load_config_initializers do + require "ruby_ui/mcp/rack_app" end end end diff --git a/mcp/lib/ruby_ui/mcp/rack_app.rb b/mcp/lib/ruby_ui/mcp/rack_app.rb new file mode 100644 index 00000000..c3ca5413 --- /dev/null +++ b/mcp/lib/ruby_ui/mcp/rack_app.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "ruby_ui/mcp/server" +require "mcp/server/transports/streamable_http_transport" + +module RubyUI + module MCP + class RackApp + class << self + def call(env) + instance.call(env) + end + + def instance + @instance ||= new + end + + def reset! + @instance = nil + end + end + + def initialize(registry: RubyUI::MCP.registry) + server = RubyUI::MCP::Server.build(registry: registry) + @transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true) + end + + def call(env) + @transport.call(env) + rescue => e + log_error(e) + [500, {"content-type" => "application/json"}, [{error: "internal"}.to_json]] + end + + private + + def log_error(error) + return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger + Rails.logger.tagged("MCP") { Rails.logger.error("#{error.class}: #{error.message}") } + rescue + nil + end + end + end +end From e57d47985c4bd17e2342b1015ef21f3fa8964b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 11:17:32 -0300 Subject: [PATCH 11/19] [Feature] Mount ruby_ui-mcp engine in docs app at /mcp --- docs/Gemfile | 2 ++ docs/Gemfile.lock | 19 +++++++++++++++++++ docs/config/initializers/rack_attack.rb | 10 ++++++++++ docs/config/routes.rb | 2 ++ 4 files changed, 33 insertions(+) create mode 100644 docs/config/initializers/rack_attack.rb diff --git a/docs/Gemfile b/docs/Gemfile index 76201dbc..cd74b03a 100644 --- a/docs/Gemfile +++ b/docs/Gemfile @@ -81,3 +81,5 @@ gem "tailwind_merge", "~> 1.4.0" gem "rss", "0.3.2" gem "rouge", "~> 4.7" + +gem "ruby_ui-mcp", path: "../mcp" diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock index ce790efa..01b8c65d 100644 --- a/docs/Gemfile.lock +++ b/docs/Gemfile.lock @@ -17,6 +17,15 @@ PATH specs: ruby_ui (1.2.0) +PATH + remote: ../mcp + specs: + ruby_ui-mcp (0.1.0) + mcp (>= 0.1) + rack-attack (>= 6.7) + rails (>= 8.0) + reverse_markdown (>= 2.1) + GEM remote: https://rubygems.org/ specs: @@ -138,6 +147,9 @@ GEM jsbundling-rails (1.3.1) railties (>= 6.0.0) json (2.19.4) + json-schema (6.2.0) + addressable (~> 2.8) + bigdecimal (>= 3.1, < 5) language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) @@ -154,6 +166,8 @@ GEM net-smtp marcel (1.1.0) matrix (0.4.2) + mcp (0.15.0) + json-schema (>= 4.1) method_source (1.1.0) mini_mime (1.1.5) mini_portile2 (2.8.9) @@ -200,6 +214,8 @@ GEM nio4r (~> 2.0) racc (1.8.1) rack (3.2.6) + rack-attack (6.8.0) + rack (>= 1.0, < 4) rack-session (2.1.2) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -246,6 +262,8 @@ GEM regexp_parser (2.11.3) reline (0.6.3) io-console (~> 0.5) + reverse_markdown (3.0.2) + nokogiri rexml (3.4.4) rouge (4.7.0) rss (0.3.2) @@ -344,6 +362,7 @@ DEPENDENCIES rouge (~> 4.7) rss (= 0.3.2) ruby_ui! + ruby_ui-mcp! selenium-webdriver sqlite3 (= 2.9.4) standard diff --git a/docs/config/initializers/rack_attack.rb b/docs/config/initializers/rack_attack.rb new file mode 100644 index 00000000..94337153 --- /dev/null +++ b/docs/config/initializers/rack_attack.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +require "rack/attack" + +class Rack::Attack + throttle("mcp/ip", limit: 60, period: 60.seconds) do |req| + req.ip if req.path.start_with?("/mcp") + end +end + +Rails.application.config.middleware.use Rack::Attack diff --git a/docs/config/routes.rb b/docs/config/routes.rb index 71d5ed58..3f5c00a6 100644 --- a/docs/config/routes.rb +++ b/docs/config/routes.rb @@ -1,4 +1,6 @@ Rails.application.routes.draw do + mount RubyUI::MCP::Engine => "/mcp" + get "llms.txt", to: "site_files#llms", as: :llms_txt, format: false get "llms-full.txt", to: "site_files#llms_full", as: :llms_full_txt, format: false get "sitemap.xml", to: "site_files#sitemap", as: :sitemap_xml, format: false From 3cb1671e1438cc4bd59f48c000d879b2d92190d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 11:19:58 -0300 Subject: [PATCH 12/19] [Documentation] Add MCP docs page with multi-client install --- docs/app/components/shared/menu.rb | 3 +- docs/app/controllers/docs_controller.rb | 4 + docs/app/lib/site_files.rb | 7 ++ docs/app/views/docs/mcp.rb | 152 ++++++++++++++++++++++++ docs/config/routes.rb | 1 + docs/public/llms-full.txt | 5 + docs/public/llms.txt | 1 + docs/public/sitemap.xml | 5 + 8 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 docs/app/views/docs/mcp.rb diff --git a/docs/app/components/shared/menu.rb b/docs/app/components/shared/menu.rb index 8046f4a2..80099c3e 100644 --- a/docs/app/components/shared/menu.rb +++ b/docs/app/components/shared/menu.rb @@ -52,7 +52,8 @@ def getting_started_links {name: "Dark mode", path: docs_dark_mode_path}, {name: "Theming", path: docs_theming_path}, {name: "Customizing components", path: docs_customizing_components_path}, - {name: "Changelog", path: docs_changelog_path} + {name: "Changelog", path: docs_changelog_path}, + {name: "MCP Server", path: docs_mcp_path} ] end diff --git a/docs/app/controllers/docs_controller.rb b/docs/app/controllers/docs_controller.rb index f60ca123..29dcbcd8 100644 --- a/docs/app/controllers/docs_controller.rb +++ b/docs/app/controllers/docs_controller.rb @@ -4,6 +4,10 @@ class DocsController < ApplicationController layout -> { Views::Layouts::DocsLayout } # GETTING STARTED + def mcp + render Views::Docs::Mcp.new + end + def introduction render Views::Docs::GettingStarted::Introduction.new end diff --git a/docs/app/lib/site_files.rb b/docs/app/lib/site_files.rb index 2a6827e7..5b1ed20a 100644 --- a/docs/app/lib/site_files.rb +++ b/docs/app/lib/site_files.rb @@ -68,6 +68,13 @@ class SiteFiles description: "Recent RubyUI component and documentation changes.", priority: 0.6, changefreq: "weekly" + }, + { + title: "MCP Server", + path: "/docs/mcp", + description: "Connect AI agents to Ruby UI components, source, examples, and install commands via the Model Context Protocol.", + priority: 0.8, + changefreq: "monthly" } ].freeze diff --git a/docs/app/views/docs/mcp.rb b/docs/app/views/docs/mcp.rb new file mode 100644 index 00000000..821b66ae --- /dev/null +++ b/docs/app/views/docs/mcp.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +class Views::Docs::Mcp < Views::Base + def view_template + div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do + render Docs::Header.new( + title: "MCP Server", + description: "Connect AI agents to Ruby UI components, source, examples, and install commands." + ) + + # About MCP + div(class: "space-y-4") do + Heading(level: 2) { "About MCP" } + p(class: "text-foreground/80 leading-relaxed") do + plain "MCP (Model Context Protocol) is an open standard for connecting AI assistants to external data sources and tools. " + plain "Ruby UI exposes an MCP server so your AI agent can list available components, view their source files, search the docs, and generate the exact install command for your app." + end + end + + # Setup + div(class: "space-y-6") do + Heading(level: 2) { "Setup" } + p(class: "text-foreground/80") { "Add the Ruby UI MCP server to your editor or AI client using the snippets below." } + + # Claude Code + div(class: "space-y-2") do + Heading(level: 3) { "Claude Code" } + Codeblock("claude mcp add --transport http ruby-ui https://rubyui.com/mcp", syntax: :shell, clipboard: true) + end + + # Cursor + div(class: "space-y-2") do + Heading(level: 3) { "Cursor" } + p(class: "text-sm text-foreground/70") { "Add to .cursor/mcp.json:" } + Codeblock(cursor_config_json, syntax: :json, clipboard: true) + end + + # Claude Desktop + div(class: "space-y-2") do + Heading(level: 3) { "Claude Desktop" } + p(class: "text-sm text-foreground/70") { "Add to claude_desktop_config.json:" } + Codeblock(generic_config_json, syntax: :json, clipboard: true) + end + + # Windsurf + div(class: "space-y-2") do + Heading(level: 3) { "Windsurf" } + p(class: "text-sm text-foreground/70") { "Add to mcp_config.json:" } + Codeblock(generic_config_json, syntax: :json, clipboard: true) + end + + # VS Code + div(class: "space-y-2") do + Heading(level: 3) { "VS Code" } + p(class: "text-sm text-foreground/70") { "Add to .vscode/mcp.json:" } + Codeblock(generic_config_json, syntax: :json, clipboard: true) + end + + # Zed + div(class: "space-y-2") do + Heading(level: 3) { "Zed" } + p(class: "text-sm text-foreground/70") { "Add to settings.json:" } + Codeblock(zed_config_json, syntax: :json, clipboard: true) + end + end + + # Usage + div(class: "space-y-4") do + Heading(level: 2) { "Usage" } + p(class: "text-foreground/80") { "Once connected, ask your agent questions like:" } + ul(class: "list-disc list-inside space-y-1 text-foreground/80") do + li { "Install Button and Dialog from Ruby UI." } + li { "Show me the source of the Card component." } + li { "Search Ruby UI for a date input." } + li { "Audit my Ruby UI install." } + end + end + + # Tools + div(class: "space-y-4") do + Heading(level: 2) { "Tools" } + p(class: "text-foreground/80") { "The MCP server exposes the following tools:" } + div(class: "overflow-x-auto rounded-md border") do + table(class: "w-full text-sm") do + thead(class: "border-b bg-muted/50") do + tr do + th(class: "px-4 py-3 text-left font-medium") { "Tool" } + th(class: "px-4 py-3 text-left font-medium") { "Description" } + end + end + tbody do + tools_list.each_with_index do |(tool, description), i| + tr(class: i.even? ? "" : "bg-muted/30") do + td(class: "px-4 py-3 font-mono text-xs") { tool } + td(class: "px-4 py-3 text-foreground/80") { description } + end + end + end + end + end + end + + # Troubleshooting + div(class: "space-y-4") do + Heading(level: 2) { "Troubleshooting" } + ul(class: "list-disc list-inside space-y-2 text-foreground/80") do + li { "Endpoint must be reachable; corporate proxies may block streamable HTTP." } + li { "If the agent can't find components, ask it to call get_project_registries first." } + li { "Run bundle exec rails g ruby_ui:component only inside a Rails app with ruby_ui in its Gemfile." } + end + end + end + end + + private + + def cursor_config_json + <<~JSON + { + "mcpServers": { + "ruby-ui": { "url": "https://rubyui.com/mcp" } + } + } + JSON + end + + def generic_config_json + cursor_config_json + end + + def zed_config_json + <<~JSON + { + "context_servers": { + "ruby-ui": { "source": "http", "url": "https://rubyui.com/mcp" } + } + } + JSON + end + + def tools_list + [ + ["get_project_registries", "Lists available registries."], + ["list_items_in_registries", "Returns all components with descriptions."], + ["search_items_in_registries", "Fuzzy search by name, description, or docs."], + ["view_items_in_registries", "Returns full source files and dependencies."], + ["get_item_examples_from_registries", "Returns code examples per component."], + ["get_add_command_for_items", "Returns a validated rails g ruby_ui:component … command."], + ["get_audit_checklist", "Returns a post-install verification checklist."] + ] + end +end diff --git a/docs/config/routes.rb b/docs/config/routes.rb index 3f5c00a6..64c2c702 100644 --- a/docs/config/routes.rb +++ b/docs/config/routes.rb @@ -9,6 +9,7 @@ scope "docs" do # GETTING STARTED + get "mcp", to: "docs#mcp", as: :docs_mcp get "introduction", to: "docs#introduction", as: :docs_introduction get "installation", to: "docs#installation", as: :docs_installation get "theming", to: "docs#theming", as: :docs_theming diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 49e1b54c..c4fa1fdb 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -75,6 +75,11 @@ This file expands the curated /llms.txt map into a compact reference that can be - URL: https://rubyui.com/docs/changelog - Summary: Recent RubyUI component and documentation changes. +### MCP Server + +- URL: https://rubyui.com/docs/mcp +- Summary: Connect AI agents to Ruby UI components, source, examples, and install commands via the Model Context Protocol. + ## Component catalog diff --git a/docs/public/llms.txt b/docs/public/llms.txt index 602abddf..f799a537 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -17,6 +17,7 @@ Use the core docs first for installation, theming, dark mode, and customization - [Customizing components](https://rubyui.com/docs/customizing_components): Adapt generated RubyUI components when theme-level customization is not enough. - [Components](https://rubyui.com/docs/components): Catalog of available RubyUI components. - [Changelog](https://rubyui.com/docs/changelog): Recent RubyUI component and documentation changes. +- [MCP Server](https://rubyui.com/docs/mcp): Connect AI agents to Ruby UI components, source, examples, and install commands via the Model Context Protocol. ## Component docs diff --git a/docs/public/sitemap.xml b/docs/public/sitemap.xml index 578e4246..91a4d19b 100644 --- a/docs/public/sitemap.xml +++ b/docs/public/sitemap.xml @@ -55,6 +55,11 @@ weekly 0.6 + + https://rubyui.com/docs/mcp + monthly + 0.8 + https://rubyui.com/docs/accordion monthly From e7d0277dbf845941abfedd44e001a938cc4ef798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 11:20:21 -0300 Subject: [PATCH 13/19] [CI] Add MCP test + registry drift jobs --- .github/workflows/ci.yml | 43 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61c4b0bb..76f52f01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,49 @@ jobs: - name: Run tests run: bin/rails db:test:prepare test + mcp: + name: MCP (Ruby ${{ matrix.ruby }}) + runs-on: ubuntu-latest + defaults: + run: + working-directory: mcp + strategy: + fail-fast: false + matrix: + ruby: ["3.3", "3.4"] + steps: + - uses: actions/checkout@v6 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + rubygems: latest + working-directory: mcp + - name: Run tests + run: bundle exec rake test + + mcp-registry-check: + name: MCP registry up to date + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: true + working-directory: mcp + - name: Rebuild registry + working-directory: mcp + run: bundle exec exe/ruby-ui-mcp-build + - name: Fail on diff + run: | + if ! git diff --exit-code mcp/data/registry.json; then + echo "registry.json out of date — run 'cd mcp && bundle exec exe/ruby-ui-mcp-build' and commit" + exit 1 + fi + docker-build: name: Docker build (Devcontainer) if: github.ref == 'refs/heads/main' From ee8b1e2ec525325e81fa80d83b2cef6c29fa9cf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 11:20:37 -0300 Subject: [PATCH 14/19] [Documentation] Add MCP README --- mcp/README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 mcp/README.md diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 00000000..d4dc83d7 --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,45 @@ +# ruby_ui-mcp + +Model Context Protocol (MCP) server for [Ruby UI](https://rubyui.com). Lets AI coding agents discover, inspect, and install Ruby UI components. + +Hosted endpoint: **https://rubyui.com/mcp** + +## Tools + +| Tool | Purpose | +|------|---------| +| `get_project_registries` | List available registries (always returns `ruby_ui`). | +| `list_items_in_registries` | All components with descriptions. | +| `search_items_in_registries` | Fuzzy search by name, description, or docs. | +| `view_items_in_registries` | Full source + dependencies for given components. | +| `get_item_examples_from_registries` | Code examples per component. | +| `get_add_command_for_items` | Validated `rails g ruby_ui:component …` command. | +| `get_audit_checklist` | Post-install verification checklist. | + +## Architecture + +- Rails engine gem, sibling of `gem/` and `docs/` in the monorepo. +- Static `data/registry.json` built from `../gem/` by `exe/ruby-ui-mcp-build`, committed to the repo. +- Mounted inside the `docs/` Rails app at `/mcp` via `RubyUI::MCP::Engine`. +- HTTP transport via `mcp` ruby-sdk (`StreamableHTTPTransport`). +- Read-only; component installs happen client-side via the Ruby UI generator. + +## Development + +```bash +cd mcp +bundle install +bundle exec rake test +``` + +Rebuild the registry from the sibling gem: + +```bash +bundle exec exe/ruby-ui-mcp-build +``` + +CI verifies the committed `data/registry.json` matches a fresh build (`mcp-registry-check` job). + +## Client setup + +See https://rubyui.com/docs/mcp for per-client install snippets (Claude Code, Cursor, Claude Desktop, Windsurf, VS Code, Zed). From 73091a24927dd5df9514e4a7878a7c994cebb97d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 15:57:15 -0300 Subject: [PATCH 15/19] [Bug Fix] Standardrb whitespace + deterministic registry build --- docs/config/initializers/rack_attack.rb | 1 + mcp/data/registry.json | 1 - mcp/lib/ruby_ui/mcp/builders/registry_builder.rb | 1 - 3 files changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/config/initializers/rack_attack.rb b/docs/config/initializers/rack_attack.rb index 94337153..cb68e71c 100644 --- a/docs/config/initializers/rack_attack.rb +++ b/docs/config/initializers/rack_attack.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + require "rack/attack" class Rack::Attack diff --git a/mcp/data/registry.json b/mcp/data/registry.json index 2206d429..f6b39176 100644 --- a/mcp/data/registry.json +++ b/mcp/data/registry.json @@ -1,6 +1,5 @@ { "version": "1.2.0", - "generated_at": "2026-05-11T14:10:27Z", "components": { "accordion": { "name": "Accordion", diff --git a/mcp/lib/ruby_ui/mcp/builders/registry_builder.rb b/mcp/lib/ruby_ui/mcp/builders/registry_builder.rb index dc29fb82..16187a2d 100644 --- a/mcp/lib/ruby_ui/mcp/builders/registry_builder.rb +++ b/mcp/lib/ruby_ui/mcp/builders/registry_builder.rb @@ -19,7 +19,6 @@ def initialize(gem_path:) def build { version: read_version, - generated_at: (ENV["SOURCE_DATE_EPOCH"] ? Time.at(ENV["SOURCE_DATE_EPOCH"].to_i).utc : Time.now.utc).iso8601, components: components_hash } end From ef57d6fa40ad5a4eb26bacded98d02953d503fbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 16:05:14 -0300 Subject: [PATCH 16/19] [Bug Fix] COPY mcp/ in docs Dockerfile for ruby_ui-mcp path dep --- docs/Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/Dockerfile b/docs/Dockerfile index dacf5627..67c77954 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -48,8 +48,9 @@ RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz npm install -g pnpm@$PNPM_VERSION && \ rm -rf /tmp/node-build-master -# Copy the gem first so docs/Gemfile's `path: "../gem"` resolves during bundle install. +# Copy the gem and mcp sources first so docs/Gemfile's path: "../gem" / "../mcp" resolve during bundle install. COPY gem /gem +COPY mcp /mcp # Install application gems (cwd = /rails) COPY docs/Gemfile docs/Gemfile.lock ./ @@ -78,6 +79,7 @@ FROM base # Copy built artifacts: gems, application, and the gem subtree COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" COPY --from=build /gem /gem +COPY --from=build /mcp /mcp COPY --from=build /rails /rails # Run and own only the runtime files as a non-root user for security From b0976751002837feb49a95d041edf7d08babce96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 16:17:21 -0300 Subject: [PATCH 17/19] [Feature] Extract VisualCodeExample blocks + richer docs_markdown --- mcp/data/registry.json | 1135 +++++++++++++++-- .../ruby_ui/mcp/builders/registry_builder.rb | 142 ++- mcp/test/builders/registry_builder_test.rb | 22 + .../lib/ruby_ui/button/button_docs.rb | 17 +- 4 files changed, 1163 insertions(+), 153 deletions(-) diff --git a/mcp/data/registry.json b/mcp/data/registry.json index f6b39176..31cd7b9e 100644 --- a/mcp/data/registry.json +++ b/mcp/data/registry.json @@ -3,7 +3,7 @@ "components": { "accordion": { "name": "Accordion", - "description": "frozen_string_literal: true", + "description": "A vertically stacked set of interactive headings that each reveal a section of content.", "files": [ { "path": "accordion.rb", @@ -46,12 +46,18 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Accordion", - "docs_markdown": "", - "examples": [] + "docs_markdown": "## Usage\n\n### Example\n\n```ruby\ndiv(class: \"w-full\") do\n Accordion do\n AccordionItem do\n AccordionTrigger do\n p(class: \"font-medium\") { \"What is PhlexUI?\" }\n AccordionIcon()\n end\n\n AccordionContent do\n p(class: \"text-sm pb-4\") do\n \"PhlexUI is a UI component library for Ruby devs who want to build better, faster.\"\n end\n end\n end\n end\n\n Accordion do\n AccordionItem do\n AccordionTrigger do\n p(class: \"font-medium\") { \"Can I use it with Rails?\" }\n AccordionIcon()\n end\n\n AccordionContent do\n p(class: \"text-sm pb-4\") do\n \"Yes, PhlexUI is pure Ruby and works great with Rails. It's a Ruby gem that you can install into your Rails app.\"\n end\n end\n end\n end\nend\n```", + "examples": [ + { + "title": "Example", + "code": "div(class: \"w-full\") do\n Accordion do\n AccordionItem do\n AccordionTrigger do\n p(class: \"font-medium\") { \"What is PhlexUI?\" }\n AccordionIcon()\n end\n\n AccordionContent do\n p(class: \"text-sm pb-4\") do\n \"PhlexUI is a UI component library for Ruby devs who want to build better, faster.\"\n end\n end\n end\n end\n\n Accordion do\n AccordionItem do\n AccordionTrigger do\n p(class: \"font-medium\") { \"Can I use it with Rails?\" }\n AccordionIcon()\n end\n\n AccordionContent do\n p(class: \"text-sm pb-4\") do\n \"Yes, PhlexUI is pure Ruby and works great with Rails. It's a Ruby gem that you can install into your Rails app.\"\n end\n end\n end\n end\nend\n", + "language": "ruby" + } + ] }, "alert": { "name": "Alert", - "description": "frozen_string_literal: true", + "description": "Displays a callout for user attention.", "files": [ { "path": "alert.rb", @@ -72,12 +78,38 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Alert", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Alert\n\nDisplays a callout for user attention.\n\n## Usage\n\n### Example\n\n```ruby\nAlert do\n rocket_icon\n AlertTitle { \"Pro tip\" }\n AlertDescription { \"With RubyUI you'll ship faster.\" }\nend\n```\n\n### Without Icon\n\n```ruby\nAlert do\n AlertTitle { \"Pro tip\" }\n AlertDescription { \"Simply, don't include an icon and your alert will look like this.\" }\nend\n```\n\n### Warning\n\n```ruby\nAlert(variant: :warning) do\n info_icon\n AlertTitle { \"Ship often\" }\n AlertDescription { \"Shipping is good, your users will thank you for it.\" }\nend\n```\n\n### Destructive\n\n```ruby\nAlert(variant: :destructive) do\n alert_icon\n AlertTitle { \"Oopsie daisy!\" }\n AlertDescription { \"Your design system is non-existent.\" }\nend\n```\n\n### Success\n\n```ruby\nAlert(variant: :success) do\n check_icon\n AlertTitle { \"Installation successful\" }\n AlertDescription { \"You're all set to start using RubyUI in your application.\" }\nend\n```", + "examples": [ + { + "title": "Example", + "code": "Alert do\n rocket_icon\n AlertTitle { \"Pro tip\" }\n AlertDescription { \"With RubyUI you'll ship faster.\" }\nend\n", + "language": "ruby" + }, + { + "title": "Without Icon", + "code": "Alert do\n AlertTitle { \"Pro tip\" }\n AlertDescription { \"Simply, don't include an icon and your alert will look like this.\" }\nend\n", + "language": "ruby" + }, + { + "title": "Warning", + "code": "Alert(variant: :warning) do\n info_icon\n AlertTitle { \"Ship often\" }\n AlertDescription { \"Shipping is good, your users will thank you for it.\" }\nend\n", + "language": "ruby" + }, + { + "title": "Destructive", + "code": "Alert(variant: :destructive) do\n alert_icon\n AlertTitle { \"Oopsie daisy!\" }\n AlertDescription { \"Your design system is non-existent.\" }\nend\n", + "language": "ruby" + }, + { + "title": "Success", + "code": "Alert(variant: :success) do\n check_icon\n AlertTitle { \"Installation successful\" }\n AlertDescription { \"You're all set to start using RubyUI in your application.\" }\nend\n", + "language": "ruby" + } + ] }, "alert_dialog": { "name": "AlertDialog", - "description": "frozen_string_literal: true", + "description": "A modal dialog that interrupts the user with important content and expects a response.", "files": [ { "path": "alert_dialog.rb", @@ -128,12 +160,18 @@ "gems": [] }, "install_command": "rails g ruby_ui:component AlertDialog", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Alert Dialog\n\nA modal dialog that interrupts the user with important content and expects a response.\n\n## Usage\n\n### Example\n\n```ruby\nAlertDialog do\n AlertDialogTrigger do\n Button { \"Show dialog\" }\n end\n AlertDialogContent do\n AlertDialogHeader do\n AlertDialogTitle { \"Are you absolutely sure?\" }\n AlertDialogDescription { \"This action cannot be undone. This will permanently delete your account and remove your data from our servers.\" }\n end\n AlertDialogFooter do\n AlertDialogCancel { \"Cancel\" }\n AlertDialogAction { \"Continue\" } # Will probably be a link to a controller action (e.g. delete account)\n end\n end\nend\n```", + "examples": [ + { + "title": "Example", + "code": "AlertDialog do\n AlertDialogTrigger do\n Button { \"Show dialog\" }\n end\n AlertDialogContent do\n AlertDialogHeader do\n AlertDialogTitle { \"Are you absolutely sure?\" }\n AlertDialogDescription { \"This action cannot be undone. This will permanently delete your account and remove your data from our servers.\" }\n end\n AlertDialogFooter do\n AlertDialogCancel { \"Cancel\" }\n AlertDialogAction { \"Continue\" } # Will probably be a link to a controller action (e.g. delete account)\n end\n end\nend\n", + "language": "ruby" + } + ] }, "aspect_ratio": { "name": "AspectRatio", - "description": "frozen_string_literal: true", + "description": "Displays content within a desired ratio.", "files": [ { "path": "aspect_ratio.rb", @@ -146,12 +184,33 @@ "gems": [] }, "install_command": "rails g ruby_ui:component AspectRatio", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Aspect Ratio\n\nDisplays content within a desired ratio.\n\n## Usage\n\n### 16/9\n\n```ruby\nAspectRatio(aspect_ratio: \"16/9\", class: \"rounded-md overflow-hidden border shadow-sm\") do\n img(\n alt: \"Placeholder\",\n loading: \"lazy\",\n src: image_path('pattern.jpg')\n )\nend\n```\n\n### 4/3\n\n```ruby\nAspectRatio(aspect_ratio: \"4/3\", class: \"rounded-md overflow-hidden border shadow-sm\") do\n img(\n alt: \"Placeholder\",\n loading: \"lazy\",\n src: image_path('pattern.jpg')\n )\nend\n```\n\n### 1/1\n\n```ruby\nAspectRatio(aspect_ratio: \"1/1\", class: \"rounded-md overflow-hidden border shadow-sm\") do\n img(\n alt: \"Placeholder\",\n loading: \"lazy\",\n src: image_path('pattern.jpg')\n )\nend\n```\n\n### 21/9\n\n```ruby\nAspectRatio(aspect_ratio: \"21/9\", class: \"rounded-md overflow-hidden border shadow-sm\") do\n img(\n alt: \"Placeholder\",\n loading: \"lazy\",\n src: image_path('pattern.jpg')\n )\nend\n```", + "examples": [ + { + "title": "16/9", + "code": "AspectRatio(aspect_ratio: \"16/9\", class: \"rounded-md overflow-hidden border shadow-sm\") do\n img(\n alt: \"Placeholder\",\n loading: \"lazy\",\n src: image_path('pattern.jpg')\n )\nend\n", + "language": "ruby" + }, + { + "title": "4/3", + "code": "AspectRatio(aspect_ratio: \"4/3\", class: \"rounded-md overflow-hidden border shadow-sm\") do\n img(\n alt: \"Placeholder\",\n loading: \"lazy\",\n src: image_path('pattern.jpg')\n )\nend\n", + "language": "ruby" + }, + { + "title": "1/1", + "code": "AspectRatio(aspect_ratio: \"1/1\", class: \"rounded-md overflow-hidden border shadow-sm\") do\n img(\n alt: \"Placeholder\",\n loading: \"lazy\",\n src: image_path('pattern.jpg')\n )\nend\n", + "language": "ruby" + }, + { + "title": "21/9", + "code": "AspectRatio(aspect_ratio: \"21/9\", class: \"rounded-md overflow-hidden border shadow-sm\") do\n img(\n alt: \"Placeholder\",\n loading: \"lazy\",\n src: image_path('pattern.jpg')\n )\nend\n", + "language": "ruby" + } + ] }, "avatar": { "name": "Avatar", - "description": "frozen_string_literal: true", + "description": "An image element with a fallback for representing the user.", "files": [ { "path": "avatar.rb", @@ -172,12 +231,33 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Avatar", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Avatar\n\nAn image element with a fallback for representing the user.\n\n## Usage\n\n### Image & fallback\n\n```ruby\nAvatar do\n AvatarImage(src: \"https://avatars.githubusercontent.com/u/246692?v=4\", alt: \"joeldrapper\")\n AvatarFallback { \"JD\" }\nend\n```\n\n### Only fallback\n\n```ruby\nAvatar do\n AvatarFallback { \"JD\" }\nend\n```\n\n### Sizes\n\n```ruby\ndiv(class: 'flex items-center space-x-2') do\n # size: :xs\n Avatar(size: :xs) do\n AvatarImage(src: \"https://avatars.githubusercontent.com/u/246692?v=4\", alt: \"joeldrapper\")\n AvatarFallback { \"JD\" }\n end\n # size: :sm\n Avatar(size: :sm) do\n AvatarImage(src: \"https://avatars.githubusercontent.com/u/246692?v=4\", alt: \"joeldrapper\")\n AvatarFallback { \"JD\" }\n end\n # size: :md\n Avatar(size: :md) do\n AvatarImage(src: \"https://avatars.githubusercontent.com/u/246692?v=4\", alt: \"joeldrapper\")\n AvatarFallback { \"JD\" }\n end\n # size: :lg\n Avatar(size: :lg) do\n AvatarImage(src: \"https://avatars.githubusercontent.com/u/246692?v=4\", alt: \"joeldrapper\")\n AvatarFallback { \"JD\" }\n end\n # size: :xl\n Avatar(size: :xl) do\n AvatarImage(src: \"https://avatars.githubusercontent.com/u/246692?v=4\", alt: \"joeldrapper\")\n AvatarFallback { \"JD\" }\n end\nend\n```\n\n### Sizes (only fallback)\n\n```ruby\ndiv(class: 'flex items-center space-x-2') do\n # size: :xs\n Avatar(size: :xs) do\n AvatarFallback { \"JD\" }\n end\n # size: :sm\n Avatar(size: :sm) do\n AvatarFallback { \"JD\" }\n end\n # size: :md\n Avatar(size: :md) do\n AvatarFallback { \"JD\" }\n end\n # size: :lg\n Avatar(size: :lg) do\n AvatarFallback { \"JD\" }\n end\n # size: :xl\n Avatar(size: :xl) do\n AvatarFallback { \"JD\" }\n end\nend\n```", + "examples": [ + { + "title": "Image & fallback", + "code": "Avatar do\n AvatarImage(src: \"https://avatars.githubusercontent.com/u/246692?v=4\", alt: \"joeldrapper\")\n AvatarFallback { \"JD\" }\nend\n", + "language": "ruby" + }, + { + "title": "Only fallback", + "code": "Avatar do\n AvatarFallback { \"JD\" }\nend\n", + "language": "ruby" + }, + { + "title": "Sizes", + "code": "div(class: 'flex items-center space-x-2') do\n # size: :xs\n Avatar(size: :xs) do\n AvatarImage(src: \"https://avatars.githubusercontent.com/u/246692?v=4\", alt: \"joeldrapper\")\n AvatarFallback { \"JD\" }\n end\n # size: :sm\n Avatar(size: :sm) do\n AvatarImage(src: \"https://avatars.githubusercontent.com/u/246692?v=4\", alt: \"joeldrapper\")\n AvatarFallback { \"JD\" }\n end\n # size: :md\n Avatar(size: :md) do\n AvatarImage(src: \"https://avatars.githubusercontent.com/u/246692?v=4\", alt: \"joeldrapper\")\n AvatarFallback { \"JD\" }\n end\n # size: :lg\n Avatar(size: :lg) do\n AvatarImage(src: \"https://avatars.githubusercontent.com/u/246692?v=4\", alt: \"joeldrapper\")\n AvatarFallback { \"JD\" }\n end\n # size: :xl\n Avatar(size: :xl) do\n AvatarImage(src: \"https://avatars.githubusercontent.com/u/246692?v=4\", alt: \"joeldrapper\")\n AvatarFallback { \"JD\" }\n end\nend\n", + "language": "ruby" + }, + { + "title": "Sizes (only fallback)", + "code": "div(class: 'flex items-center space-x-2') do\n # size: :xs\n Avatar(size: :xs) do\n AvatarFallback { \"JD\" }\n end\n # size: :sm\n Avatar(size: :sm) do\n AvatarFallback { \"JD\" }\n end\n # size: :md\n Avatar(size: :md) do\n AvatarFallback { \"JD\" }\n end\n # size: :lg\n Avatar(size: :lg) do\n AvatarFallback { \"JD\" }\n end\n # size: :xl\n Avatar(size: :xl) do\n AvatarFallback { \"JD\" }\n end\nend\n", + "language": "ruby" + } + ] }, "badge": { "name": "Badge", - "description": "frozen_string_literal: true", + "description": "Displays a badge or a component that looks like a badge.", "files": [ { "path": "badge.rb", @@ -190,12 +270,43 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Badge", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Badge\n\nDisplays a badge or a component that looks like a badge.\n\n## Usage\n\n### Default\n\n```ruby\nBadge { \"Badge\" }\n```\n\n### Primary\n\n```ruby\nBadge(variant: :primary) { 'Primary' }\n```\n\n### Outline\n\n```ruby\nBadge(variant: :outline) { 'Outline' }\n```\n\n### Variants\n\n```ruby\ndiv(class: 'flex flex-wrap gap-2 justify-center') do\n Badge(variant: :destructive) { 'Destructive' }\n Badge(variant: :warning) { 'Warning' }\n Badge(variant: :success) { 'Success' }\nend\n```\n\n### Other Colors\n\n```ruby\ndiv(class: 'flex flex-wrap gap-2 justify-center') do\n Badge(variant: :red) { 'Red' }\n Badge(variant: :orange) { 'Orange' }\n Badge(variant: :amber) { 'Amber' }\n Badge(variant: :yellow) { 'Yellow' }\n Badge(variant: :lime) { 'Lime' }\n Badge(variant: :green) { 'Green' }\n Badge(variant: :emerald) { 'Emerald' }\n Badge(variant: :teal) { 'Teal' }\n Badge(variant: :cyan) { 'Cyan' }\n Badge(variant: :sky) { 'Sky' }\n Badge(variant: :blue) { 'Blue' }\n Badge(variant: :indigo) { 'Indigo' }\n Badge(variant: :violet) { 'Violet' }\n Badge(variant: :purple) { 'Purple' }\n Badge(variant: :fuchsia) { 'Fuchsia' }\n Badge(variant: :pink) { 'Pink' }\n Badge(variant: :rose) { 'Rose' }\nend\n```\n\n### Sizes\n\n```ruby\ndiv(class: 'flex flex-wrap gap-2 justify-center items-center') do\n Badge(size: :sm) { \"Small\" }\n Badge(size: :md) { \"Medium\" }\n Badge(size: :lg) { \"Large\" }\nend\n```", + "examples": [ + { + "title": "Default", + "code": "Badge { \"Badge\" }\n", + "language": "ruby" + }, + { + "title": "Primary", + "code": "Badge(variant: :primary) { 'Primary' }\n", + "language": "ruby" + }, + { + "title": "Outline", + "code": "Badge(variant: :outline) { 'Outline' }\n", + "language": "ruby" + }, + { + "title": "Variants", + "code": "div(class: 'flex flex-wrap gap-2 justify-center') do\n Badge(variant: :destructive) { 'Destructive' }\n Badge(variant: :warning) { 'Warning' }\n Badge(variant: :success) { 'Success' }\nend\n", + "language": "ruby" + }, + { + "title": "Other Colors", + "code": "div(class: 'flex flex-wrap gap-2 justify-center') do\n Badge(variant: :red) { 'Red' }\n Badge(variant: :orange) { 'Orange' }\n Badge(variant: :amber) { 'Amber' }\n Badge(variant: :yellow) { 'Yellow' }\n Badge(variant: :lime) { 'Lime' }\n Badge(variant: :green) { 'Green' }\n Badge(variant: :emerald) { 'Emerald' }\n Badge(variant: :teal) { 'Teal' }\n Badge(variant: :cyan) { 'Cyan' }\n Badge(variant: :sky) { 'Sky' }\n Badge(variant: :blue) { 'Blue' }\n Badge(variant: :indigo) { 'Indigo' }\n Badge(variant: :violet) { 'Violet' }\n Badge(variant: :purple) { 'Purple' }\n Badge(variant: :fuchsia) { 'Fuchsia' }\n Badge(variant: :pink) { 'Pink' }\n Badge(variant: :rose) { 'Rose' }\nend\n", + "language": "ruby" + }, + { + "title": "Sizes", + "code": "div(class: 'flex flex-wrap gap-2 justify-center items-center') do\n Badge(size: :sm) { \"Small\" }\n Badge(size: :md) { \"Medium\" }\n Badge(size: :lg) { \"Large\" }\nend\n", + "language": "ruby" + } + ] }, "breadcrumb": { "name": "Breadcrumb", - "description": "frozen_string_literal: true", + "description": "Indicates the user", "files": [ { "path": "breadcrumb.rb", @@ -232,12 +343,33 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Breadcrumb", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Breadcrumb\n\nIndicates the user\n\n## Usage\n\n### Example\n\n```ruby\nBreadcrumb do\n BreadcrumbList do\n BreadcrumbItem do\n BreadcrumbLink(href: \"/\") { \"Home\" }\n end\n BreadcrumbSeparator()\n BreadcrumbItem do\n BreadcrumbLink(href: \"/docs/accordion\") { \"Components\" }\n end\n BreadcrumbSeparator()\n BreadcrumbItem do\n BreadcrumbPage { \"Breadcrumb\" }\n end\n end\nend\n```\n\n### With custom separator\n\n```ruby\nBreadcrumb do\n BreadcrumbList do\n BreadcrumbItem do\n BreadcrumbLink(href: \"/\") { \"Home\" }\n end\n BreadcrumbSeparator { slash_icon }\n BreadcrumbItem do\n BreadcrumbLink(href: \"/docs/accordion\") { \"Components\" }\n end\n BreadcrumbSeparator { slash_icon }\n BreadcrumbItem do\n BreadcrumbPage { \"Breadcrumb\" }\n end\n end\nend\n```\n\n### Collapsed\n\n```ruby\nBreadcrumb do\n BreadcrumbList do\n BreadcrumbItem do\n BreadcrumbLink(href: \"/\") { \"Home\" }\n end\n BreadcrumbSeparator()\n BreadcrumbItem do\n BreadcrumbEllipsis()\n end\n BreadcrumbSeparator()\n BreadcrumbItem do\n BreadcrumbLink(href: \"/docs/accordion\") { \"Components\" }\n end\n BreadcrumbSeparator()\n BreadcrumbItem do\n BreadcrumbPage { \"Breadcrumb\" }\n end\n end\nend\n```\n\n### With Link component\n\n```ruby\nBreadcrumb do\n BreadcrumbList do\n BreadcrumbItem do\n BreadcrumbLink(href: \"/\") { \"Home\" }\n end\n BreadcrumbSeparator()\n BreadcrumbItem do\n Link(href: \"/docs/accordion\", class: \"px-0\") { \"Components\" }\n end\n BreadcrumbSeparator()\n BreadcrumbItem do\n BreadcrumbPage { \"Breadcrumb\" }\n end\n end\nend\n```", + "examples": [ + { + "title": "Example", + "code": "Breadcrumb do\n BreadcrumbList do\n BreadcrumbItem do\n BreadcrumbLink(href: \"/\") { \"Home\" }\n end\n BreadcrumbSeparator()\n BreadcrumbItem do\n BreadcrumbLink(href: \"/docs/accordion\") { \"Components\" }\n end\n BreadcrumbSeparator()\n BreadcrumbItem do\n BreadcrumbPage { \"Breadcrumb\" }\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "With custom separator", + "code": "Breadcrumb do\n BreadcrumbList do\n BreadcrumbItem do\n BreadcrumbLink(href: \"/\") { \"Home\" }\n end\n BreadcrumbSeparator { slash_icon }\n BreadcrumbItem do\n BreadcrumbLink(href: \"/docs/accordion\") { \"Components\" }\n end\n BreadcrumbSeparator { slash_icon }\n BreadcrumbItem do\n BreadcrumbPage { \"Breadcrumb\" }\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "Collapsed", + "code": "Breadcrumb do\n BreadcrumbList do\n BreadcrumbItem do\n BreadcrumbLink(href: \"/\") { \"Home\" }\n end\n BreadcrumbSeparator()\n BreadcrumbItem do\n BreadcrumbEllipsis()\n end\n BreadcrumbSeparator()\n BreadcrumbItem do\n BreadcrumbLink(href: \"/docs/accordion\") { \"Components\" }\n end\n BreadcrumbSeparator()\n BreadcrumbItem do\n BreadcrumbPage { \"Breadcrumb\" }\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "With Link component", + "code": "Breadcrumb do\n BreadcrumbList do\n BreadcrumbItem do\n BreadcrumbLink(href: \"/\") { \"Home\" }\n end\n BreadcrumbSeparator()\n BreadcrumbItem do\n Link(href: \"/docs/accordion\", class: \"px-0\") { \"Components\" }\n end\n BreadcrumbSeparator()\n BreadcrumbItem do\n BreadcrumbPage { \"Breadcrumb\" }\n end\n end\nend\n", + "language": "ruby" + } + ] }, "button": { "name": "Button", - "description": "frozen_string_literal: true", + "description": "Displays a button or a component that looks like a button.", "files": [ { "path": "button.rb", @@ -250,12 +382,78 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Button", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Button\n\nDisplays a button or a component that looks like a button.\n\n## Usage\n\n### Example\n\n```ruby\nButton { \"Button\" }\n```\n\n### Primary\n\n```ruby\nButton(variant: :primary) { \"Primary\" }\n```\n\n### Secondary\n\n```ruby\nButton(variant: :secondary) { \"Secondary\" }\n```\n\n### Destructive\n\n```ruby\nButton(variant: :destructive) { \"Destructive\" }\n```\n\n### Outline\n\n```ruby\nButton(variant: :outline) { \"Outline\" }\n```\n\n### Ghost\n\n```ruby\nButton(variant: :ghost) { \"Ghost\" }\n```\n\n### Link\n\n```ruby\nButton(variant: :link) { \"Link\" }\n```\n\n### Disabled\n\n```ruby\nButton(disabled: true) { \"Disabled\" }\n```\n\n### Aria Disabled\n\n```ruby\nButton(aria: {disabled: \"true\"}) { \"Aria Disabled\" }\n```\n\n### Icon\n\n```ruby\nButton(variant: :outline, icon: true) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n viewbox: \"0 0 20 20\",\n fill: \"currentColor\",\n class: \"w-5 h-5\"\n ) do |s|\n s.path(\n fill_rule: \"evenodd\",\n d:\n \"M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z\",\n clip_rule: \"evenodd\"\n )\n end\nend\n```\n\n### With Icon\n\n```ruby\nButton(variant: :primary) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75\"\n )\n end\n span { \"Login with Email\" }\nend\n```\n\n### With Icon\n\n```ruby\nButton(variant: :primary, disabled: true) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n viewbox: \"0 0 20 20\",\n fill: \"currentColor\",\n class: \"w-4 h-4 mr-2 animate-spin\"\n ) do |s|\n s.path(\n fill_rule: \"evenodd\",\n d:\n \"M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z\",\n clip_rule: \"evenodd\"\n )\n end\n span { \"Please wait\" }\nend\n```\n\n### Submit\n\n```ruby\nButton(variant: :primary, type: :submit) do\n span { \"Submit application\" }\nend\n```", + "examples": [ + { + "title": "Example", + "code": "Button { \"Button\" }\n", + "language": "ruby" + }, + { + "title": "Primary", + "code": "Button(variant: :primary) { \"Primary\" }\n", + "language": "ruby" + }, + { + "title": "Secondary", + "code": "Button(variant: :secondary) { \"Secondary\" }\n", + "language": "ruby" + }, + { + "title": "Destructive", + "code": "Button(variant: :destructive) { \"Destructive\" }\n", + "language": "ruby" + }, + { + "title": "Outline", + "code": "Button(variant: :outline) { \"Outline\" }\n", + "language": "ruby" + }, + { + "title": "Ghost", + "code": "Button(variant: :ghost) { \"Ghost\" }\n", + "language": "ruby" + }, + { + "title": "Link", + "code": "Button(variant: :link) { \"Link\" }\n", + "language": "ruby" + }, + { + "title": "Disabled", + "code": "Button(disabled: true) { \"Disabled\" }\n", + "language": "ruby" + }, + { + "title": "Aria Disabled", + "code": "Button(aria: {disabled: \"true\"}) { \"Aria Disabled\" }\n", + "language": "ruby" + }, + { + "title": "Icon", + "code": "Button(variant: :outline, icon: true) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n viewbox: \"0 0 20 20\",\n fill: \"currentColor\",\n class: \"w-5 h-5\"\n ) do |s|\n s.path(\n fill_rule: \"evenodd\",\n d:\n \"M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z\",\n clip_rule: \"evenodd\"\n )\n end\nend\n", + "language": "ruby" + }, + { + "title": "With Icon", + "code": "Button(variant: :primary) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75\"\n )\n end\n span { \"Login with Email\" }\nend\n", + "language": "ruby" + }, + { + "title": "With Icon", + "code": "Button(variant: :primary, disabled: true) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n viewbox: \"0 0 20 20\",\n fill: \"currentColor\",\n class: \"w-4 h-4 mr-2 animate-spin\"\n ) do |s|\n s.path(\n fill_rule: \"evenodd\",\n d:\n \"M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z\",\n clip_rule: \"evenodd\"\n )\n end\n span { \"Please wait\" }\nend\n", + "language": "ruby" + }, + { + "title": "Submit", + "code": "Button(variant: :primary, type: :submit) do\n span { \"Submit application\" }\nend\n", + "language": "ruby" + } + ] }, "calendar": { "name": "Calendar", - "description": "frozen_string_literal: true", + "description": "A date field component that allows users to enter and edit date.", "files": [ { "path": "calendar.rb", @@ -306,12 +504,23 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Calendar", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Calendar\n\nA date field component that allows users to enter and edit date.\n\n## Usage\n\n### Connect to input\n\n```ruby\ndiv(class: 'space-y-4') do\n Input(type: 'string', placeholder: \"Select a date\", class: 'rounded-md border shadow', id: 'date', data_controller: 'ruby-ui--calendar-input')\n Calendar(input_id: '#date', class: 'rounded-md border shadow')\nend\n```\n\n### Format date\n\n```ruby\ndiv(class: 'space-y-4') do\n Input(type: 'string', placeholder: \"Select a date\", class: 'rounded-md border shadow', id: 'formatted-date', data_controller: 'ruby-ui--calendar-input')\n Calendar(input_id: '#formatted-date', date_format: 'PPPP', class: 'rounded-md border shadow')\nend\n```", + "examples": [ + { + "title": "Connect to input", + "code": "div(class: 'space-y-4') do\n Input(type: 'string', placeholder: \"Select a date\", class: 'rounded-md border shadow', id: 'date', data_controller: 'ruby-ui--calendar-input')\n Calendar(input_id: '#date', class: 'rounded-md border shadow')\nend\n", + "language": "ruby" + }, + { + "title": "Format date", + "code": "div(class: 'space-y-4') do\n Input(type: 'string', placeholder: \"Select a date\", class: 'rounded-md border shadow', id: 'formatted-date', data_controller: 'ruby-ui--calendar-input')\n Calendar(input_id: '#formatted-date', date_format: 'PPPP', class: 'rounded-md border shadow')\nend\n", + "language": "ruby" + } + ] }, "card": { "name": "Card", - "description": "frozen_string_literal: true", + "description": "Displays a card with header, content, and footer.", "files": [ { "path": "card.rb", @@ -344,12 +553,28 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Card", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Card\n\nDisplays a card with header, content, and footer.\n\n## Usage\n\n### Card with image\n\n```ruby\nCard(class: 'w-96') do\n CardHeader do\n CardTitle { 'You might like \"RubyUI\"' }\n CardDescription { \"@joeldrapper\" }\n end\n CardContent do\n AspectRatio(aspect_ratio: \"16/9\", class: \"rounded-md overflow-hidden border\") do\n img(\n alt: \"Placeholder\",\n loading: \"lazy\",\n src: image_url('pattern.jpg')\n )\n end\n end\n CardFooter(class: 'flex justify-end gap-x-2') do\n Button(variant: :outline) { \"See more\" }\n Button(variant: :primary) { \"Buy now\" }\n end\nend\n```\n\n### Card with full-width image\n\n```ruby\nCard(class: 'w-96 overflow-hidden') do\n AspectRatio(aspect_ratio: \"16/9\", class: \"border-b\") do\n img(\n alt: \"Placeholder\",\n loading: \"lazy\",\n src: image_url('pattern.jpg')\n )\n end\n CardHeader do\n CardTitle { 'Introducing RubyUI' }\n CardDescription { \"Kickstart your project today!\" }\n end\n CardFooter(class: 'flex justify-end') do\n Button(variant: :outline) { \"Get started\" }\n end\nend\n```\n\n### Account balance\n\n```ruby\nCard(class: 'w-96 overflow-hidden') do\n CardHeader do\n div(class: 'w-10 h-10 rounded-xl flex items-center justify-center bg-violet-100 text-violet-700 -rotate-6') do\n cash_icon\n end\n end\n CardContent(class: 'space-y-1') do\n CardDescription(class: 'font-medium') { \"Current Balance\" }\n h5(class: 'font-semibold text-4xl') { '$2,602' }\n end\n CardFooter do\n Text(size: \"2\", class: \"text-muted-foreground\") { \"**** 4620\" }\n end\nend\n```", + "examples": [ + { + "title": "Card with image", + "code": "Card(class: 'w-96') do\n CardHeader do\n CardTitle { 'You might like \"RubyUI\"' }\n CardDescription { \"@joeldrapper\" }\n end\n CardContent do\n AspectRatio(aspect_ratio: \"16/9\", class: \"rounded-md overflow-hidden border\") do\n img(\n alt: \"Placeholder\",\n loading: \"lazy\",\n src: image_url('pattern.jpg')\n )\n end\n end\n CardFooter(class: 'flex justify-end gap-x-2') do\n Button(variant: :outline) { \"See more\" }\n Button(variant: :primary) { \"Buy now\" }\n end\nend\n", + "language": "ruby" + }, + { + "title": "Card with full-width image", + "code": "Card(class: 'w-96 overflow-hidden') do\n AspectRatio(aspect_ratio: \"16/9\", class: \"border-b\") do\n img(\n alt: \"Placeholder\",\n loading: \"lazy\",\n src: image_url('pattern.jpg')\n )\n end\n CardHeader do\n CardTitle { 'Introducing RubyUI' }\n CardDescription { \"Kickstart your project today!\" }\n end\n CardFooter(class: 'flex justify-end') do\n Button(variant: :outline) { \"Get started\" }\n end\nend\n", + "language": "ruby" + }, + { + "title": "Account balance", + "code": "Card(class: 'w-96 overflow-hidden') do\n CardHeader do\n div(class: 'w-10 h-10 rounded-xl flex items-center justify-center bg-violet-100 text-violet-700 -rotate-6') do\n cash_icon\n end\n end\n CardContent(class: 'space-y-1') do\n CardDescription(class: 'font-medium') { \"Current Balance\" }\n h5(class: 'font-semibold text-4xl') { '$2,602' }\n end\n CardFooter do\n Text(size: \"2\", class: \"text-muted-foreground\") { \"**** 4620\" }\n end\nend\n", + "language": "ruby" + } + ] }, "carousel": { "name": "Carousel", - "description": "frozen_string_literal: true", + "description": "A carousel with motion and swipe built using Embla.", "files": [ { "path": "carousel.rb", @@ -384,12 +609,33 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Carousel", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Carousel\n\nA carousel with motion and swipe built using Embla.\n\n## Usage\n\n### Example\n\n```ruby\nCarousel(options: {loop:false}, class: \"w-full max-w-xs\") do\n CarouselContent do\n 5.times do |index|\n CarouselItem do\n div(class: \"p-1\") do\n Card do\n CardContent(class: \"flex aspect-square items-center justify-center p-6\") do\n span(class: \"text-4xl font-semibold\") { index + 1 }\n end\n end\n end\n end\n end\n end\n CarouselPrevious()\n CarouselNext()\nend\n```\n\n### Sizes\n\n```ruby\nCarousel(class: \"w-full max-w-sm\") do\n CarouselContent do\n 5.times do |index|\n CarouselItem(class: \"md:basis-1/2 lg:basis-1/3\") do\n div(class: \"p-1\") do\n Card do\n CardContent(class: \"flex aspect-square items-center justify-center p-6\") do\n span(class: \"text-3xl font-semibold\") { index + 1 }\n end\n end\n end\n end\n end\n end\n CarouselPrevious()\n CarouselNext()\nend\n```\n\n### Spacing\n\n```ruby\nCarousel(class: \"w-full max-w-sm\") do\n CarouselContent(class: \"-ml-1\") do\n 5.times do |index|\n CarouselItem(class: \"pl-1 md:basis-1/2 lg:basis-1/3\") do\n div(class: \"p-1\") do\n Card do\n CardContent(class: \"flex aspect-square items-center justify-center p-6\") do\n span(class: \"text-2xl font-semibold\") { index + 1 }\n end\n end\n end\n end\n end\n end\n CarouselPrevious()\n CarouselNext()\nend\n```\n\n### Orientation\n\n```ruby\nCarousel(orientation: :vertical, options: {align: \"start\"}, class: \"w-full max-w-xs\") do\n CarouselContent(class: \"-mt-1 h-[200px]\") do\n 5.times do |index|\n CarouselItem(class: \"pt-1 md:basis-1/2\") do\n div(class: \"p-1\") do\n Card do\n CardContent(class: \"flex items-center justify-center p-6\") do\n span(class: \"text-3xl font-semibold\") { index + 1 }\n end\n end\n end\n end\n end\n end\n CarouselPrevious()\n CarouselNext()\nend\n```", + "examples": [ + { + "title": "Example", + "code": "Carousel(options: {loop:false}, class: \"w-full max-w-xs\") do\n CarouselContent do\n 5.times do |index|\n CarouselItem do\n div(class: \"p-1\") do\n Card do\n CardContent(class: \"flex aspect-square items-center justify-center p-6\") do\n span(class: \"text-4xl font-semibold\") { index + 1 }\n end\n end\n end\n end\n end\n end\n CarouselPrevious()\n CarouselNext()\nend\n", + "language": "ruby" + }, + { + "title": "Sizes", + "code": "Carousel(class: \"w-full max-w-sm\") do\n CarouselContent do\n 5.times do |index|\n CarouselItem(class: \"md:basis-1/2 lg:basis-1/3\") do\n div(class: \"p-1\") do\n Card do\n CardContent(class: \"flex aspect-square items-center justify-center p-6\") do\n span(class: \"text-3xl font-semibold\") { index + 1 }\n end\n end\n end\n end\n end\n end\n CarouselPrevious()\n CarouselNext()\nend\n", + "language": "ruby" + }, + { + "title": "Spacing", + "code": "Carousel(class: \"w-full max-w-sm\") do\n CarouselContent(class: \"-ml-1\") do\n 5.times do |index|\n CarouselItem(class: \"pl-1 md:basis-1/2 lg:basis-1/3\") do\n div(class: \"p-1\") do\n Card do\n CardContent(class: \"flex aspect-square items-center justify-center p-6\") do\n span(class: \"text-2xl font-semibold\") { index + 1 }\n end\n end\n end\n end\n end\n end\n CarouselPrevious()\n CarouselNext()\nend\n", + "language": "ruby" + }, + { + "title": "Orientation", + "code": "Carousel(orientation: :vertical, options: {align: \"start\"}, class: \"w-full max-w-xs\") do\n CarouselContent(class: \"-mt-1 h-[200px]\") do\n 5.times do |index|\n CarouselItem(class: \"pt-1 md:basis-1/2\") do\n div(class: \"p-1\") do\n Card do\n CardContent(class: \"flex items-center justify-center p-6\") do\n span(class: \"text-3xl font-semibold\") { index + 1 }\n end\n end\n end\n end\n end\n end\n CarouselPrevious()\n CarouselNext()\nend\n", + "language": "ruby" + } + ] }, "chart": { "name": "Chart", - "description": "frozen_string_literal: true", + "description": "Displays information in a visual way.", "files": [ { "path": "chart.rb", @@ -408,12 +654,28 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Chart", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Chart\n\nDisplays information in a visual way.\n\n## Introduction\n\n## Usage\n\n### Bar Chart\n\n```ruby\noptions = {\n type: 'bar',\n data: {\n labels: ['Phlex', 'VC', 'ERB'],\n datasets: [{\n label: 'render time (ms)',\n data: [100, 520, 1200],\n }]\n },\n options: {\n indexAxis: 'y',\n scales: {\n y: {\n beginAtZero: true\n }\n },\n },\n}\n\nChart(options: options)\n```\n\n### Line Graph\n\n```ruby\noptions = {\n type: 'line',\n data: {\n labels: ['Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul'],\n datasets: [{\n label: 'Github Stars',\n data: [40, 30, 79, 140, 290, 550],\n }]\n },\n options: {\n scales: {\n y: {\n beginAtZero: true\n }\n },\n plugins: {\n legend: {\n display: false\n }\n }\n },\n}\n\nChart(options: options)\n```\n\n### Pie Chart\n\n```ruby\noptions = {\n type: 'pie',\n data: {\n labels: [\n 'Red',\n 'Blue',\n 'Yellow'\n ],\n datasets: [{\n label: 'My First Dataset',\n data: [300, 50, 100],\n backgroundColor: [\n 'rgb(255, 99, 132)',\n 'rgb(54, 162, 235)',\n 'rgb(255, 205, 86)'\n ],\n hoverOffset: 4\n }]\n },\n}\n\nChart(options: options)\n```", + "examples": [ + { + "title": "Bar Chart", + "code": "options = {\n type: 'bar',\n data: {\n labels: ['Phlex', 'VC', 'ERB'],\n datasets: [{\n label: 'render time (ms)',\n data: [100, 520, 1200],\n }]\n },\n options: {\n indexAxis: 'y',\n scales: {\n y: {\n beginAtZero: true\n }\n },\n },\n}\n\nChart(options: options)\n", + "language": "ruby" + }, + { + "title": "Line Graph", + "code": "options = {\n type: 'line',\n data: {\n labels: ['Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul'],\n datasets: [{\n label: 'Github Stars',\n data: [40, 30, 79, 140, 290, 550],\n }]\n },\n options: {\n scales: {\n y: {\n beginAtZero: true\n }\n },\n plugins: {\n legend: {\n display: false\n }\n }\n },\n}\n\nChart(options: options)\n", + "language": "ruby" + }, + { + "title": "Pie Chart", + "code": "options = {\n type: 'pie',\n data: {\n labels: [\n 'Red',\n 'Blue',\n 'Yellow'\n ],\n datasets: [{\n label: 'My First Dataset',\n data: [300, 50, 100],\n backgroundColor: [\n 'rgb(255, 99, 132)',\n 'rgb(54, 162, 235)',\n 'rgb(255, 205, 86)'\n ],\n hoverOffset: 4\n }]\n },\n}\n\nChart(options: options)\n", + "language": "ruby" + } + ] }, "checkbox": { "name": "Checkbox", - "description": "frozen_string_literal: true", + "description": "A control that allows the user to toggle between checked and not checked.", "files": [ { "path": "checkbox.rb", @@ -434,12 +696,23 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Checkbox", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Checkbox\n\nA control that allows the user to toggle between checked and not checked.\n\n## Usage\n\n### Example\n\n```ruby\ndiv(class: 'flex items-center space-x-3') do\n Checkbox(id: 'terms')\n label(for: 'terms', class: 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70') { \"Accept terms and conditions\" }\nend\n```\n\n### Checked\n\n```ruby\ndiv(class: \"items-top flex space-x-3\") do\n Checkbox(id: 'terms1', checked: true)\n div(class: \"grid gap-1.5 leading-none\") do\n label(\n for: \"terms1\",\n class:\n \"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\"\n ) { \" Accept terms and conditions \" }\n p(class: \"text-sm text-muted-foreground\") { \" You agree to our Terms of Service and Privacy Policy.\" }\n end\nend\n```", + "examples": [ + { + "title": "Example", + "code": "div(class: 'flex items-center space-x-3') do\n Checkbox(id: 'terms')\n label(for: 'terms', class: 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70') { \"Accept terms and conditions\" }\nend\n", + "language": "ruby" + }, + { + "title": "Checked", + "code": "div(class: \"items-top flex space-x-3\") do\n Checkbox(id: 'terms1', checked: true)\n div(class: \"grid gap-1.5 leading-none\") do\n label(\n for: \"terms1\",\n class:\n \"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\"\n ) { \" Accept terms and conditions \" }\n p(class: \"text-sm text-muted-foreground\") { \" You agree to our Terms of Service and Privacy Policy.\" }\n end\nend\n", + "language": "ruby" + } + ] }, "clipboard": { "name": "Clipboard", - "description": "frozen_string_literal: true", + "description": "A control to allow you to copy content to the clipboard.", "files": [ { "path": "clipboard.rb", @@ -470,12 +743,18 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Clipboard", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Clipboard\n\nA control to allow you to copy content to the clipboard.\n\n## Usage\n\n### Example\n\n```ruby\nClipboard(success: \"Copied!\", error: \"Copy failed!\", class: \"relative\", options: {placement: \"top\"}) do\n ClipboardSource(class: \"hidden\") { span { \"Born rich!!!\" } }\n\n ClipboardTrigger do\n Link(href: \"#\", class: \"gap-1\") do\n Text(size: :small, class: \"text-primary\") { \"Copy the secret of success!!!\" }\n end\n end\nend\n```", + "examples": [ + { + "title": "Example", + "code": "Clipboard(success: \"Copied!\", error: \"Copy failed!\", class: \"relative\", options: {placement: \"top\"}) do\n ClipboardSource(class: \"hidden\") { span { \"Born rich!!!\" } }\n\n ClipboardTrigger do\n Link(href: \"#\", class: \"gap-1\") do\n Text(size: :small, class: \"text-primary\") { \"Copy the secret of success!!!\" }\n end\n end\nend\n", + "language": "ruby" + } + ] }, "codeblock": { "name": "Codeblock", - "description": "frozen_string_literal: true", + "description": "A component for displaying highlighted code.", "files": [ { "path": "codeblock.rb", @@ -493,12 +772,28 @@ ] }, "install_command": "rails g ruby_ui:component Codeblock", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Codeblock\n\nA component for displaying highlighted code.\n\n## Usage\n\n### With clipboard\n\n```ruby\ncode = <<~CODE\n def hello_world\n puts \"Hello, world!\"\n end\n CODE\ndiv(class: 'w-full') do\n Codeblock(code, syntax: :ruby)\nend\n```\n\n### Without clipboard\n\n```ruby\ncode = <<~CODE\n def hello_world\n puts \"Hello, world!\"\n end\n CODE\ndiv(class: 'w-full') do\n Codeblock(code, syntax: :ruby, clipboard: false)\nend\n```\n\n### Custom message\n\n```ruby\ncode = <<~CODE\n def hello_world\n puts \"Hello, world!\"\n end\n CODE\ndiv(class: 'w-full') do\n Codeblock(code, syntax: :ruby, clipboard_success: \"Nice one!\")\nend\n```", + "examples": [ + { + "title": "With clipboard", + "code": "code = <<~CODE\n def hello_world\n puts \"Hello, world!\"\n end\n CODE\ndiv(class: 'w-full') do\n Codeblock(code, syntax: :ruby)\nend\n", + "language": "ruby" + }, + { + "title": "Without clipboard", + "code": "code = <<~CODE\n def hello_world\n puts \"Hello, world!\"\n end\n CODE\ndiv(class: 'w-full') do\n Codeblock(code, syntax: :ruby, clipboard: false)\nend\n", + "language": "ruby" + }, + { + "title": "Custom message", + "code": "code = <<~CODE\n def hello_world\n puts \"Hello, world!\"\n end\n CODE\ndiv(class: 'w-full') do\n Codeblock(code, syntax: :ruby, clipboard_success: \"Nice one!\")\nend\n", + "language": "ruby" + } + ] }, "collapsible": { "name": "Collapsible", - "description": "frozen_string_literal: true", + "description": "An interactive component which expands/collapses a panel.", "files": [ { "path": "collapsible.rb", @@ -523,12 +818,23 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Collapsible", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Collapsible\n\nAn interactive component which expands/collapses a panel.\n\n## Usage\n\n### Example\n\n```ruby\nCollapsible do\n div(class: \"flex items-center justify-between space-x-4 px-4 py-2\") do\n h4(class: \"text-sm font-semibold\") { \" @joeldrapper starred 3 repositories\" }\n CollapsibleTrigger do\n Button(variant: :ghost, icon: true) do\n chevron_icon\n span(class: \"sr-only\") { \"Toggle\" }\n end\n end\n end\n\n div(class: \"rounded-md border px-4 py-2 font-mono text-sm shadow-sm\") do\n \"phlex-ruby/phlex\"\n end\n\n CollapsibleContent do\n div(class: 'space-y-2 mt-2') do\n div(class: \"rounded-md border px-4 py-2 font-mono text-sm shadow-sm\") do\n \"phlex-ruby/phlex-rails\"\n end\n div(class: \"rounded-md border px-4 py-2 font-mono text-sm shadow-sm\") do\n \"ruby-ui/ruby_ui\"\n end\n end\n end\nend\n```\n\n### Open\n\n```ruby\nCollapsible(open: true) do\n div(class: \"flex items-center justify-between space-x-4 px-4 py-2\") do\n h4(class: \"text-sm font-semibold\") { \" @joeldrapper starred 3 repositories\" }\n CollapsibleTrigger do\n Button(variant: :ghost, icon: true) do\n chevron_icon\n span(class: \"sr-only\") { \"Toggle\" }\n end\n end\n end\n\n div(class: \"rounded-md border px-4 py-2 font-mono text-sm shadow-sm\") do\n \"phlex-ruby/phlex\"\n end\n\n CollapsibleContent do\n div(class: 'space-y-2 mt-2') do\n div(class: \"rounded-md border px-4 py-2 font-mono text-sm shadow-sm\") do\n \"phlex-ruby/phlex-rails\"\n end\n div(class: \"rounded-md border px-4 py-2 font-mono text-sm shadow-sm\") do\n \"ruby-ui/ruby_ui\"\n end\n end\n end\nend\n```", + "examples": [ + { + "title": "Example", + "code": "Collapsible do\n div(class: \"flex items-center justify-between space-x-4 px-4 py-2\") do\n h4(class: \"text-sm font-semibold\") { \" @joeldrapper starred 3 repositories\" }\n CollapsibleTrigger do\n Button(variant: :ghost, icon: true) do\n chevron_icon\n span(class: \"sr-only\") { \"Toggle\" }\n end\n end\n end\n\n div(class: \"rounded-md border px-4 py-2 font-mono text-sm shadow-sm\") do\n \"phlex-ruby/phlex\"\n end\n\n CollapsibleContent do\n div(class: 'space-y-2 mt-2') do\n div(class: \"rounded-md border px-4 py-2 font-mono text-sm shadow-sm\") do\n \"phlex-ruby/phlex-rails\"\n end\n div(class: \"rounded-md border px-4 py-2 font-mono text-sm shadow-sm\") do\n \"ruby-ui/ruby_ui\"\n end\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "Open", + "code": "Collapsible(open: true) do\n div(class: \"flex items-center justify-between space-x-4 px-4 py-2\") do\n h4(class: \"text-sm font-semibold\") { \" @joeldrapper starred 3 repositories\" }\n CollapsibleTrigger do\n Button(variant: :ghost, icon: true) do\n chevron_icon\n span(class: \"sr-only\") { \"Toggle\" }\n end\n end\n end\n\n div(class: \"rounded-md border px-4 py-2 font-mono text-sm shadow-sm\") do\n \"phlex-ruby/phlex\"\n end\n\n CollapsibleContent do\n div(class: 'space-y-2 mt-2') do\n div(class: \"rounded-md border px-4 py-2 font-mono text-sm shadow-sm\") do\n \"phlex-ruby/phlex-rails\"\n end\n div(class: \"rounded-md border px-4 py-2 font-mono text-sm shadow-sm\") do\n \"ruby-ui/ruby_ui\"\n end\n end\n end\nend\n", + "language": "ruby" + } + ] }, "combobox": { "name": "Combobox", - "description": "frozen_string_literal: true", + "description": "Autocomplete input and command palette with a list of suggestions.", "files": [ { "path": "combobox.rb", @@ -607,12 +913,53 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Combobox", - "docs_markdown": "", - "examples": [] + "docs_markdown": "Autocomplete input and command palette with a list of suggestions.\n\n## Usage\n\n### Basic\n\n```ruby\ndiv(class: \"w-96\") do\n Combobox do\n ComboboxInputTrigger(placeholder: \"Select framework...\")\n\n ComboboxPopover do\n ComboboxList do\n ComboboxEmptyState { \"No results found.\" }\n\n ComboboxItem do\n ComboboxRadio(name: \"framework\", value: \"rails\")\n span { \"Rails\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"framework\", value: \"hanami\")\n span { \"Hanami\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"framework\", value: \"nextjs\")\n span { \"Next.js\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"framework\", value: \"nuxt\")\n span { \"Nuxt\" }\n end\n end\n end\n end\nend\n```\n\n### Popup\n\n```ruby\ndiv(class: \"w-96\") do\n Combobox do\n ComboboxTrigger(placeholder: \"Select framework...\")\n\n ComboboxPopover do\n ComboboxSearchInput(placeholder: \"Search framework...\")\n\n ComboboxList do\n ComboboxEmptyState { \"No results found.\" }\n\n ComboboxItem do\n ComboboxRadio(name: \"fw2\", value: \"rails\")\n span { \"Rails\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"fw2\", value: \"hanami\")\n span { \"Hanami\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"fw2\", value: \"nextjs\")\n span { \"Next.js\" }\n end\n end\n end\n end\nend\n```\n\n### Multiple\n\n```ruby\ndiv(class: \"w-96\") do\n Combobox do\n ComboboxBadgeTrigger(clear_button: true)\n\n ComboboxPopover do\n ComboboxList do\n ComboboxEmptyState { \"No results found.\" }\n\n ComboboxItem do\n ComboboxCheckbox(name: \"frameworks[]\", value: \"rails\")\n span { \"Rails\" }\n end\n ComboboxItem do\n ComboboxCheckbox(name: \"frameworks[]\", value: \"hanami\")\n span { \"Hanami\" }\n end\n ComboboxItem do\n ComboboxCheckbox(name: \"frameworks[]\", value: \"sinatra\")\n span { \"Sinatra\" }\n end\n ComboboxItem do\n ComboboxCheckbox(name: \"frameworks[]\", value: \"nextjs\", checked: true)\n span { \"Next.js\" }\n end\n ComboboxItem do\n ComboboxCheckbox(name: \"frameworks[]\", value: \"nuxt\")\n span { \"Nuxt\" }\n end\n ComboboxItem do\n ComboboxCheckbox(name: \"frameworks[]\", value: \"svelte\")\n span { \"SvelteKit\" }\n end\n ComboboxItem do\n ComboboxCheckbox(name: \"frameworks[]\", value: \"remix\")\n span { \"Remix\" }\n end\n ComboboxItem do\n ComboboxCheckbox(name: \"frameworks[]\", value: \"astro\")\n span { \"Astro\" }\n end\n end\n end\n end\nend\n```\n\n### Groups\n\n```ruby\ndiv(class: \"w-96\") do\n Combobox do\n ComboboxInputTrigger(placeholder: \"Select food...\")\n\n ComboboxPopover do\n ComboboxList do\n ComboboxEmptyState { \"No results found.\" }\n\n ComboboxListGroup(label: \"Fruits\") do\n ComboboxItem do\n ComboboxRadio(name: \"food\", value: \"apple\")\n span { \"Apple\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"food\", value: \"banana\")\n span { \"Banana\" }\n end\n end\n\n ComboboxListGroup(label: \"Vegetables\") do\n ComboboxItem do\n ComboboxRadio(name: \"food\", value: \"broccoli\")\n span { \"Broccoli\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"food\", value: \"carrot\")\n span { \"Carrot\" }\n end\n end\n\n ComboboxListGroup(label: \"Grains\") do\n ComboboxItem do\n ComboboxRadio(name: \"food\", value: \"rice\")\n span { \"Rice\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"food\", value: \"wheat\")\n span { \"Wheat\" }\n end\n end\n end\n end\n end\nend\n```\n\n### Custom Items\n\n```ruby\ndiv(class: \"w-96\") do\n Combobox do\n ComboboxInputTrigger(placeholder: \"Select status...\")\n\n ComboboxPopover do\n ComboboxList do\n ComboboxEmptyState { \"No results found.\" }\n\n ComboboxItem do\n ComboboxRadio(name: \"status\", value: \"backlog\", data: {text: \"Backlog\"})\n svg(xmlns: \"http://www.w3.org/2000/svg\", width: \"16\", height: \"16\", viewbox: \"0 0 24 24\", fill: \"none\", stroke: \"currentColor\", stroke_width: \"2\", class: \"text-muted-foreground\") { |s| s.circle(cx: \"12\", cy: \"12\", r: \"10\") }\n span { \"Backlog\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"status\", value: \"todo\", data: {text: \"Todo\"})\n svg(xmlns: \"http://www.w3.org/2000/svg\", width: \"16\", height: \"16\", viewbox: \"0 0 24 24\", fill: \"none\", stroke: \"currentColor\", stroke_width: \"2\", class: \"text-blue-500\") { |s| s.circle(cx: \"12\", cy: \"12\", r: \"10\") }\n span { \"Todo\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"status\", value: \"done\", data: {text: \"Done\"})\n svg(xmlns: \"http://www.w3.org/2000/svg\", width: \"16\", height: \"16\", viewbox: \"0 0 24 24\", fill: \"none\", stroke: \"currentColor\", stroke_width: \"2\", class: \"text-green-500\") { |s| s.path(d: \"M22 11.08V12a10 10 0 1 1-5.93-9.14\"); s.path(d: \"m9 11 3 3L22 4\") }\n span { \"Done\" }\n end\n end\n end\n end\nend\n```\n\n### Invalid\n\n```ruby\ndiv(class: \"w-96\") do\n Combobox do\n ComboboxInputTrigger(placeholder: \"Required field\", aria: {invalid: \"true\"})\n\n ComboboxPopover do\n ComboboxList do\n ComboboxEmptyState { \"No results found.\" }\n\n ComboboxItem do\n ComboboxRadio(name: \"req\", value: \"option1\")\n span { \"Option 1\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"req\", value: \"option2\")\n span { \"Option 2\" }\n end\n end\n end\n end\nend\n```\n\n### Disabled\n\n```ruby\ndiv(class: \"w-96 space-y-2\") do\n Combobox do\n ComboboxTrigger(disabled: true, placeholder: \"Disabled trigger\")\n end\n\n Combobox do\n ComboboxInputTrigger(placeholder: \"Disabled input\", disabled: true)\n end\nend\n```\n\n### Auto Highlight\n\n```ruby\ndiv(class: \"w-96\") do\n Combobox do\n ComboboxInputTrigger(placeholder: \"Type to search...\")\n\n ComboboxPopover do\n ComboboxList do\n ComboboxEmptyState { \"No results found.\" }\n\n ComboboxItem do\n ComboboxRadio(name: \"color\", value: \"red\")\n span { \"Red\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"color\", value: \"green\")\n span { \"Green\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"color\", value: \"blue\")\n span { \"Blue\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"color\", value: \"yellow\")\n span { \"Yellow\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"color\", value: \"purple\")\n span { \"Purple\" }\n end\n end\n end\n end\nend\n```", + "examples": [ + { + "title": "Basic", + "code": "div(class: \"w-96\") do\n Combobox do\n ComboboxInputTrigger(placeholder: \"Select framework...\")\n\n ComboboxPopover do\n ComboboxList do\n ComboboxEmptyState { \"No results found.\" }\n\n ComboboxItem do\n ComboboxRadio(name: \"framework\", value: \"rails\")\n span { \"Rails\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"framework\", value: \"hanami\")\n span { \"Hanami\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"framework\", value: \"nextjs\")\n span { \"Next.js\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"framework\", value: \"nuxt\")\n span { \"Nuxt\" }\n end\n end\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "Popup", + "code": "div(class: \"w-96\") do\n Combobox do\n ComboboxTrigger(placeholder: \"Select framework...\")\n\n ComboboxPopover do\n ComboboxSearchInput(placeholder: \"Search framework...\")\n\n ComboboxList do\n ComboboxEmptyState { \"No results found.\" }\n\n ComboboxItem do\n ComboboxRadio(name: \"fw2\", value: \"rails\")\n span { \"Rails\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"fw2\", value: \"hanami\")\n span { \"Hanami\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"fw2\", value: \"nextjs\")\n span { \"Next.js\" }\n end\n end\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "Multiple", + "code": "div(class: \"w-96\") do\n Combobox do\n ComboboxBadgeTrigger(clear_button: true)\n\n ComboboxPopover do\n ComboboxList do\n ComboboxEmptyState { \"No results found.\" }\n\n ComboboxItem do\n ComboboxCheckbox(name: \"frameworks[]\", value: \"rails\")\n span { \"Rails\" }\n end\n ComboboxItem do\n ComboboxCheckbox(name: \"frameworks[]\", value: \"hanami\")\n span { \"Hanami\" }\n end\n ComboboxItem do\n ComboboxCheckbox(name: \"frameworks[]\", value: \"sinatra\")\n span { \"Sinatra\" }\n end\n ComboboxItem do\n ComboboxCheckbox(name: \"frameworks[]\", value: \"nextjs\", checked: true)\n span { \"Next.js\" }\n end\n ComboboxItem do\n ComboboxCheckbox(name: \"frameworks[]\", value: \"nuxt\")\n span { \"Nuxt\" }\n end\n ComboboxItem do\n ComboboxCheckbox(name: \"frameworks[]\", value: \"svelte\")\n span { \"SvelteKit\" }\n end\n ComboboxItem do\n ComboboxCheckbox(name: \"frameworks[]\", value: \"remix\")\n span { \"Remix\" }\n end\n ComboboxItem do\n ComboboxCheckbox(name: \"frameworks[]\", value: \"astro\")\n span { \"Astro\" }\n end\n end\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "Groups", + "code": "div(class: \"w-96\") do\n Combobox do\n ComboboxInputTrigger(placeholder: \"Select food...\")\n\n ComboboxPopover do\n ComboboxList do\n ComboboxEmptyState { \"No results found.\" }\n\n ComboboxListGroup(label: \"Fruits\") do\n ComboboxItem do\n ComboboxRadio(name: \"food\", value: \"apple\")\n span { \"Apple\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"food\", value: \"banana\")\n span { \"Banana\" }\n end\n end\n\n ComboboxListGroup(label: \"Vegetables\") do\n ComboboxItem do\n ComboboxRadio(name: \"food\", value: \"broccoli\")\n span { \"Broccoli\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"food\", value: \"carrot\")\n span { \"Carrot\" }\n end\n end\n\n ComboboxListGroup(label: \"Grains\") do\n ComboboxItem do\n ComboboxRadio(name: \"food\", value: \"rice\")\n span { \"Rice\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"food\", value: \"wheat\")\n span { \"Wheat\" }\n end\n end\n end\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "Custom Items", + "code": "div(class: \"w-96\") do\n Combobox do\n ComboboxInputTrigger(placeholder: \"Select status...\")\n\n ComboboxPopover do\n ComboboxList do\n ComboboxEmptyState { \"No results found.\" }\n\n ComboboxItem do\n ComboboxRadio(name: \"status\", value: \"backlog\", data: {text: \"Backlog\"})\n svg(xmlns: \"http://www.w3.org/2000/svg\", width: \"16\", height: \"16\", viewbox: \"0 0 24 24\", fill: \"none\", stroke: \"currentColor\", stroke_width: \"2\", class: \"text-muted-foreground\") { |s| s.circle(cx: \"12\", cy: \"12\", r: \"10\") }\n span { \"Backlog\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"status\", value: \"todo\", data: {text: \"Todo\"})\n svg(xmlns: \"http://www.w3.org/2000/svg\", width: \"16\", height: \"16\", viewbox: \"0 0 24 24\", fill: \"none\", stroke: \"currentColor\", stroke_width: \"2\", class: \"text-blue-500\") { |s| s.circle(cx: \"12\", cy: \"12\", r: \"10\") }\n span { \"Todo\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"status\", value: \"done\", data: {text: \"Done\"})\n svg(xmlns: \"http://www.w3.org/2000/svg\", width: \"16\", height: \"16\", viewbox: \"0 0 24 24\", fill: \"none\", stroke: \"currentColor\", stroke_width: \"2\", class: \"text-green-500\") { |s| s.path(d: \"M22 11.08V12a10 10 0 1 1-5.93-9.14\"); s.path(d: \"m9 11 3 3L22 4\") }\n span { \"Done\" }\n end\n end\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "Invalid", + "code": "div(class: \"w-96\") do\n Combobox do\n ComboboxInputTrigger(placeholder: \"Required field\", aria: {invalid: \"true\"})\n\n ComboboxPopover do\n ComboboxList do\n ComboboxEmptyState { \"No results found.\" }\n\n ComboboxItem do\n ComboboxRadio(name: \"req\", value: \"option1\")\n span { \"Option 1\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"req\", value: \"option2\")\n span { \"Option 2\" }\n end\n end\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "Disabled", + "code": "div(class: \"w-96 space-y-2\") do\n Combobox do\n ComboboxTrigger(disabled: true, placeholder: \"Disabled trigger\")\n end\n\n Combobox do\n ComboboxInputTrigger(placeholder: \"Disabled input\", disabled: true)\n end\nend\n", + "language": "ruby" + }, + { + "title": "Auto Highlight", + "code": "div(class: \"w-96\") do\n Combobox do\n ComboboxInputTrigger(placeholder: \"Type to search...\")\n\n ComboboxPopover do\n ComboboxList do\n ComboboxEmptyState { \"No results found.\" }\n\n ComboboxItem do\n ComboboxRadio(name: \"color\", value: \"red\")\n span { \"Red\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"color\", value: \"green\")\n span { \"Green\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"color\", value: \"blue\")\n span { \"Blue\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"color\", value: \"yellow\")\n span { \"Yellow\" }\n end\n ComboboxItem do\n ComboboxRadio(name: \"color\", value: \"purple\")\n span { \"Purple\" }\n end\n end\n end\n end\nend\n", + "language": "ruby" + } + ] }, "command": { "name": "Command", - "description": "frozen_string_literal: true", + "description": "Fast, composable, unstyled command menu for Phlex.", "files": [ { "path": "command.rb", @@ -663,12 +1010,23 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Command", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Command\n\nFast, composable, unstyled command menu for Phlex.\n\n## Usage\n\n### Example\n\n```ruby\nCommandDialog do\n CommandDialogTrigger do\n Button(variant: \"outline\", class: 'w-56 pr-2 pl-3 justify-between') do\n div(class: \"flex items-center space-x-1\") do\n search_icon\n span(class: \"text-muted-foreground font-normal\") do\n plain \"Search\"\n end\n end\n ShortcutKey do\n span(class: \"text-xs\") { \"⌘\" }\n plain \"K\"\n end\n end\n end\n CommandDialogContent do\n Command do\n CommandInput(placeholder: \"Type a command or search...\")\n CommandEmpty { \"No results found.\" }\n CommandList do\n CommandGroup(title: \"Components\") do\n components_list.each do |component|\n CommandItem(value: component[:name], href: component[:path]) do\n default_icon\n plain component[:name]\n end\n end\n end\n CommandGroup(title: \"Settings\") do\n settings_list.each do |setting|\n CommandItem(value: setting[:name], href: setting[:path]) do\n default_icon\n plain setting[:name]\n end\n end\n end\n end\n end\n end\nend\n```\n\n### With keybinding\n\n```ruby\nCommandDialog do\n CommandDialogTrigger(keybindings: ['keydown.ctrl+j@window', 'keydown.meta+j@window']) do\n p(class: \"text-sm text-muted-foreground\") do\n span(class: 'mr-1') { \"Press\" }\n ShortcutKey do\n span(class: \"text-xs\") { \"⌘\" }\n plain \"J\"\n end\n end\n end\n CommandDialogContent do\n Command do\n CommandInput(placeholder: \"Type a command or search...\")\n CommandEmpty { \"No results found.\" }\n CommandList do\n CommandGroup(title: \"Components\") do\n components_list.each do |component|\n CommandItem(value: component[:name], href: component[:path]) do\n default_icon\n plain component[:name]\n end\n end\n end\n CommandGroup(title: \"Settings\") do\n settings_list.each do |setting|\n CommandItem(value: setting[:name], href: setting[:path]) do\n default_icon\n plain setting[:name]\n end\n end\n end\n end\n end\n end\nend\n```", + "examples": [ + { + "title": "Example", + "code": "CommandDialog do\n CommandDialogTrigger do\n Button(variant: \"outline\", class: 'w-56 pr-2 pl-3 justify-between') do\n div(class: \"flex items-center space-x-1\") do\n search_icon\n span(class: \"text-muted-foreground font-normal\") do\n plain \"Search\"\n end\n end\n ShortcutKey do\n span(class: \"text-xs\") { \"⌘\" }\n plain \"K\"\n end\n end\n end\n CommandDialogContent do\n Command do\n CommandInput(placeholder: \"Type a command or search...\")\n CommandEmpty { \"No results found.\" }\n CommandList do\n CommandGroup(title: \"Components\") do\n components_list.each do |component|\n CommandItem(value: component[:name], href: component[:path]) do\n default_icon\n plain component[:name]\n end\n end\n end\n CommandGroup(title: \"Settings\") do\n settings_list.each do |setting|\n CommandItem(value: setting[:name], href: setting[:path]) do\n default_icon\n plain setting[:name]\n end\n end\n end\n end\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "With keybinding", + "code": "CommandDialog do\n CommandDialogTrigger(keybindings: ['keydown.ctrl+j@window', 'keydown.meta+j@window']) do\n p(class: \"text-sm text-muted-foreground\") do\n span(class: 'mr-1') { \"Press\" }\n ShortcutKey do\n span(class: \"text-xs\") { \"⌘\" }\n plain \"J\"\n end\n end\n end\n CommandDialogContent do\n Command do\n CommandInput(placeholder: \"Type a command or search...\")\n CommandEmpty { \"No results found.\" }\n CommandList do\n CommandGroup(title: \"Components\") do\n components_list.each do |component|\n CommandItem(value: component[:name], href: component[:path]) do\n default_icon\n plain component[:name]\n end\n end\n end\n CommandGroup(title: \"Settings\") do\n settings_list.each do |setting|\n CommandItem(value: setting[:name], href: setting[:path]) do\n default_icon\n plain setting[:name]\n end\n end\n end\n end\n end\n end\nend\n", + "language": "ruby" + } + ] }, "context_menu": { "name": "ContextMenu", - "description": "frozen_string_literal: true", + "description": "Displays a menu to the user — such as a set of actions or functions — triggered by a right click.", "files": [ { "path": "context_menu.rb", @@ -707,8 +1065,19 @@ "gems": [] }, "install_command": "rails g ruby_ui:component ContextMenu", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Context Menu\n\nDisplays a menu to the user — such as a set of actions or functions — triggered by a right click.\n\n## Usage\n\n### Example\n\n```ruby\nContextMenu do\n ContextMenuTrigger(class: 'flex h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed text-sm') { \"Right click here\" }\n ContextMenuContent(class: 'w-64') do\n ContextMenuItem(href: '#', shortcut: \"⌘[\") { \"Back\" }\n ContextMenuItem(href: '#', shortcut: \"⌘]\", disabled: true) { \"Forward\" }\n ContextMenuItem(href: '#', shortcut: \"⌘R\") { \"Reload\" }\n ContextMenuSeparator\n ContextMenuItem(href: '#', shortcut: \"⌘⇧B\", checked: true) { \"Show Bookmarks Bar\" }\n ContextMenuItem(href: '#') { \"Show Full URLs\" }\n ContextMenuSeparator\n ContextMenuLabel(inset: true) { \"More Tools\" }\n ContextMenuSeparator\n ContextMenuItem(href: '#') { \"Developer Tools\" }\n ContextMenuItem(href: '#') { \"Task Manager\" }\n ContextMenuItem(href: '#') { \"Extensions\" }\n end\nend\n```\n\n### Placement\n\n```ruby\ndiv(class: 'space-y-4') do\n ContextMenu(options: { placement: 'right' }) do\n ContextMenuTrigger(class: 'flex flex-col items-center gap-y-2 h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed text-sm') do\n plain \"Right click here\"\n Badge(variant: :primary) { \"right\" }\n end\n ContextMenuContent(class: 'w-64') do\n ContextMenuItem(href: '#', shortcut: \"⌘[\") { \"Back\" }\n ContextMenuItem(href: '#', shortcut: \"⌘]\", disabled: true) { \"Forward\" }\n ContextMenuItem(href: '#', shortcut: \"⌘R\") { \"Reload\" }\n ContextMenuSeparator\n ContextMenuItem(href: '#', shortcut: \"⌘⇧B\", checked: true) { \"Show Bookmarks Bar\" }\n ContextMenuItem(href: '#') { \"Show Full URLs\" }\n ContextMenuSeparator\n ContextMenuLabel(inset: true) { \"More Tools\" }\n ContextMenuSeparator\n ContextMenuItem(href: '#') { \"Developer Tools\" }\n ContextMenuItem(href: '#') { \"Task Manager\" }\n ContextMenuItem(href: '#') { \"Extensions\" }\n end\n end\n ContextMenu(options: { placement: 'left' }) do\n ContextMenuTrigger(class: 'flex flex-col items-center gap-y-2 h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed text-sm') do\n plain \"Right click here\"\n Badge(variant: :primary) { \"left\" }\n end\n ContextMenuContent(class: 'w-64') do\n ContextMenuItem(href: '#', shortcut: \"⌘[\") { \"Back\" }\n ContextMenuItem(href: '#', shortcut: \"⌘]\", disabled: true) { \"Forward\" }\n ContextMenuItem(href: '#', shortcut: \"⌘R\") { \"Reload\" }\n ContextMenuSeparator\n ContextMenuItem(href: '#', shortcut: \"⌘⇧B\", checked: true) { \"Show Bookmarks Bar\" }\n ContextMenuItem(href: '#') { \"Show Full URLs\" }\n ContextMenuSeparator\n ContextMenuLabel(inset: true) { \"More Tools\" }\n ContextMenuSeparator\n ContextMenuItem(href: '#') { \"Developer Tools\" }\n ContextMenuItem(href: '#') { \"Task Manager\" }\n ContextMenuItem(href: '#') { \"Extensions\" }\n end\n end\nend\n```", + "examples": [ + { + "title": "Example", + "code": "ContextMenu do\n ContextMenuTrigger(class: 'flex h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed text-sm') { \"Right click here\" }\n ContextMenuContent(class: 'w-64') do\n ContextMenuItem(href: '#', shortcut: \"⌘[\") { \"Back\" }\n ContextMenuItem(href: '#', shortcut: \"⌘]\", disabled: true) { \"Forward\" }\n ContextMenuItem(href: '#', shortcut: \"⌘R\") { \"Reload\" }\n ContextMenuSeparator\n ContextMenuItem(href: '#', shortcut: \"⌘⇧B\", checked: true) { \"Show Bookmarks Bar\" }\n ContextMenuItem(href: '#') { \"Show Full URLs\" }\n ContextMenuSeparator\n ContextMenuLabel(inset: true) { \"More Tools\" }\n ContextMenuSeparator\n ContextMenuItem(href: '#') { \"Developer Tools\" }\n ContextMenuItem(href: '#') { \"Task Manager\" }\n ContextMenuItem(href: '#') { \"Extensions\" }\n end\nend\n", + "language": "ruby" + }, + { + "title": "Placement", + "code": "div(class: 'space-y-4') do\n ContextMenu(options: { placement: 'right' }) do\n ContextMenuTrigger(class: 'flex flex-col items-center gap-y-2 h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed text-sm') do\n plain \"Right click here\"\n Badge(variant: :primary) { \"right\" }\n end\n ContextMenuContent(class: 'w-64') do\n ContextMenuItem(href: '#', shortcut: \"⌘[\") { \"Back\" }\n ContextMenuItem(href: '#', shortcut: \"⌘]\", disabled: true) { \"Forward\" }\n ContextMenuItem(href: '#', shortcut: \"⌘R\") { \"Reload\" }\n ContextMenuSeparator\n ContextMenuItem(href: '#', shortcut: \"⌘⇧B\", checked: true) { \"Show Bookmarks Bar\" }\n ContextMenuItem(href: '#') { \"Show Full URLs\" }\n ContextMenuSeparator\n ContextMenuLabel(inset: true) { \"More Tools\" }\n ContextMenuSeparator\n ContextMenuItem(href: '#') { \"Developer Tools\" }\n ContextMenuItem(href: '#') { \"Task Manager\" }\n ContextMenuItem(href: '#') { \"Extensions\" }\n end\n end\n ContextMenu(options: { placement: 'left' }) do\n ContextMenuTrigger(class: 'flex flex-col items-center gap-y-2 h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed text-sm') do\n plain \"Right click here\"\n Badge(variant: :primary) { \"left\" }\n end\n ContextMenuContent(class: 'w-64') do\n ContextMenuItem(href: '#', shortcut: \"⌘[\") { \"Back\" }\n ContextMenuItem(href: '#', shortcut: \"⌘]\", disabled: true) { \"Forward\" }\n ContextMenuItem(href: '#', shortcut: \"⌘R\") { \"Reload\" }\n ContextMenuSeparator\n ContextMenuItem(href: '#', shortcut: \"⌘⇧B\", checked: true) { \"Show Bookmarks Bar\" }\n ContextMenuItem(href: '#') { \"Show Full URLs\" }\n ContextMenuSeparator\n ContextMenuLabel(inset: true) { \"More Tools\" }\n ContextMenuSeparator\n ContextMenuItem(href: '#') { \"Developer Tools\" }\n ContextMenuItem(href: '#') { \"Task Manager\" }\n ContextMenuItem(href: '#') { \"Extensions\" }\n end\n end\nend\n", + "language": "ruby" + } + ] }, "data_table": { "name": "DataTable", @@ -809,12 +1178,33 @@ "gems": [] }, "install_command": "rails g ruby_ui:component DataTable", - "docs_markdown": "Salary: $\\#{r.salary}\n\nStatus: \\#{r.status}", - "examples": [] + "docs_markdown": "## Usage\n\n### Server-driven table\n\n```ruby\nDataTable(id: \"employees\") do\n DataTableToolbar do\n DataTableSearch(path: employees_path, value: @search)\n DataTablePerPageSelect(path: employees_path, value: @per_page)\n end\n\n div(class: \"rounded-md border\") do\n Table do\n TableHeader do\n TableRow do\n TableHead { \"Name\" }\n DataTableSortHead(column_key: :email, label: \"Email\",\n sort: @sort, direction: @direction,\n path: employees_path)\n TableHead(class: \"text-right\") { \"Salary\" }\n end\n end\n TableBody do\n @rows.each do |r|\n TableRow do\n TableCell { r.name }\n TableCell { r.email }\n TableCell(class: \"text-right\") { r.salary }\n end\n end\n end\n end\n end\n\n DataTablePaginationBar do\n DataTableSelectionSummary(total_on_page: @rows.size)\n DataTablePagination(page: @page, per_page: @per_page,\n total_count: @total_count, path: employees_path)\n end\nend\n```\n\n### Selection + bulk actions\n\n```ruby\nFORM_ID = \"employees_form\"\n\nDataTable(id: \"employees_select\") do\n DataTableToolbar do\n DataTableSearch(path: employees_path, value: @search)\n DataTableBulkActions do\n Button(type: \"submit\", form: FORM_ID,\n formaction: bulk_delete_employees_path,\n formmethod: \"post\",\n variant: :destructive, size: :sm) { \"Delete\" }\n end\n end\n\n DataTableForm(id: FORM_ID, action: \"\") do\n div(class: \"rounded-md border\") do\n Table do\n TableHeader do\n TableRow do\n TableHead(class: \"w-10\") { DataTableSelectAllCheckbox() }\n TableHead { \"Name\" }\n TableHead { \"Email\" }\n end\n end\n TableBody do\n @rows.each do |r|\n TableRow do\n TableCell { DataTableRowCheckbox(value: r.id) }\n TableCell { r.name }\n TableCell { r.email }\n end\n end\n end\n end\n end\n end\n\n DataTablePaginationBar do\n DataTableSelectionSummary(total_on_page: @rows.size)\n DataTablePagination(page: @page, per_page: @per_page,\n total_count: @total_count, path: employees_path)\n end\nend\n```\n\n### Column visibility\n\n```ruby\nDataTable(id: \"employees_cols\") do\n DataTableToolbar do\n DataTableColumnToggle(columns: [\n {key: :email, label: \"Email\"},\n {key: :salary, label: \"Salary\"}\n ])\n end\n\n Table do\n TableHeader do\n TableRow do\n TableHead { \"Name\" }\n TableHead(data: {column: \"email\"}) { \"Email\" }\n TableHead(data: {column: \"salary\"}) { \"Salary\" }\n end\n end\n TableBody do\n @rows.each do |r|\n TableRow do\n TableCell { r.name }\n TableCell(data: {column: \"email\"}) { r.email }\n TableCell(data: {column: \"salary\"}) { r.salary }\n end\n end\n end\n end\nend\n```\n\n### Expandable rows\n\n```ruby\nDataTable(id: \"employees_expand\") do\n Table do\n TableHeader do\n TableRow do\n TableHead(class: \"w-10\") { }\n TableHead { \"Name\" }\n TableHead { \"Email\" }\n end\n end\n TableBody do\n @rows.each do |r|\n detail_id = \"row-\\#{r.id}-detail\"\n TableRow do\n TableCell { DataTableExpandToggle(controls: detail_id, label: \"Toggle \\#{r.name}\") }\n TableCell { r.name }\n TableCell { r.email }\n end\n TableRow(id: detail_id, class: \"hidden\", role: \"region\") do\n TableCell(colspan: 3, class: \"bg-muted/40\") do\n div(class: \"p-4\") do\n p { \"Salary: $\\#{r.salary}\" }\n p { \"Status: \\#{r.status}\" }\n end\n end\n end\n end\n end\n end\nend\n```", + "examples": [ + { + "title": "Server-driven table", + "code": "DataTable(id: \"employees\") do\n DataTableToolbar do\n DataTableSearch(path: employees_path, value: @search)\n DataTablePerPageSelect(path: employees_path, value: @per_page)\n end\n\n div(class: \"rounded-md border\") do\n Table do\n TableHeader do\n TableRow do\n TableHead { \"Name\" }\n DataTableSortHead(column_key: :email, label: \"Email\",\n sort: @sort, direction: @direction,\n path: employees_path)\n TableHead(class: \"text-right\") { \"Salary\" }\n end\n end\n TableBody do\n @rows.each do |r|\n TableRow do\n TableCell { r.name }\n TableCell { r.email }\n TableCell(class: \"text-right\") { r.salary }\n end\n end\n end\n end\n end\n\n DataTablePaginationBar do\n DataTableSelectionSummary(total_on_page: @rows.size)\n DataTablePagination(page: @page, per_page: @per_page,\n total_count: @total_count, path: employees_path)\n end\nend\n", + "language": "ruby" + }, + { + "title": "Selection + bulk actions", + "code": "FORM_ID = \"employees_form\"\n\nDataTable(id: \"employees_select\") do\n DataTableToolbar do\n DataTableSearch(path: employees_path, value: @search)\n DataTableBulkActions do\n Button(type: \"submit\", form: FORM_ID,\n formaction: bulk_delete_employees_path,\n formmethod: \"post\",\n variant: :destructive, size: :sm) { \"Delete\" }\n end\n end\n\n DataTableForm(id: FORM_ID, action: \"\") do\n div(class: \"rounded-md border\") do\n Table do\n TableHeader do\n TableRow do\n TableHead(class: \"w-10\") { DataTableSelectAllCheckbox() }\n TableHead { \"Name\" }\n TableHead { \"Email\" }\n end\n end\n TableBody do\n @rows.each do |r|\n TableRow do\n TableCell { DataTableRowCheckbox(value: r.id) }\n TableCell { r.name }\n TableCell { r.email }\n end\n end\n end\n end\n end\n end\n\n DataTablePaginationBar do\n DataTableSelectionSummary(total_on_page: @rows.size)\n DataTablePagination(page: @page, per_page: @per_page,\n total_count: @total_count, path: employees_path)\n end\nend\n", + "language": "ruby" + }, + { + "title": "Column visibility", + "code": "DataTable(id: \"employees_cols\") do\n DataTableToolbar do\n DataTableColumnToggle(columns: [\n {key: :email, label: \"Email\"},\n {key: :salary, label: \"Salary\"}\n ])\n end\n\n Table do\n TableHeader do\n TableRow do\n TableHead { \"Name\" }\n TableHead(data: {column: \"email\"}) { \"Email\" }\n TableHead(data: {column: \"salary\"}) { \"Salary\" }\n end\n end\n TableBody do\n @rows.each do |r|\n TableRow do\n TableCell { r.name }\n TableCell(data: {column: \"email\"}) { r.email }\n TableCell(data: {column: \"salary\"}) { r.salary }\n end\n end\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "Expandable rows", + "code": "DataTable(id: \"employees_expand\") do\n Table do\n TableHeader do\n TableRow do\n TableHead(class: \"w-10\") { }\n TableHead { \"Name\" }\n TableHead { \"Email\" }\n end\n end\n TableBody do\n @rows.each do |r|\n detail_id = \"row-\\#{r.id}-detail\"\n TableRow do\n TableCell { DataTableExpandToggle(controls: detail_id, label: \"Toggle \\#{r.name}\") }\n TableCell { r.name }\n TableCell { r.email }\n end\n TableRow(id: detail_id, class: \"hidden\", role: \"region\") do\n TableCell(colspan: 3, class: \"bg-muted/40\") do\n div(class: \"p-4\") do\n p { \"Salary: $\\#{r.salary}\" }\n p { \"Status: \\#{r.status}\" }\n end\n end\n end\n end\n end\n end\nend\n", + "language": "ruby" + } + ] }, "date_picker": { "name": "DatePicker", - "description": "frozen_string_literal: true", + "description": "A date picker component with input.", "files": [ { "path": "date_picker.rb", @@ -831,12 +1221,18 @@ "gems": [] }, "install_command": "rails g ruby_ui:component DatePicker", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Date Picker\n\nA date picker component with input.\n\n## Usage\n\n### Single Date\n\n```ruby\nDatePicker(id: \"date\")\n```", + "examples": [ + { + "title": "Single Date", + "code": "DatePicker(id: \"date\")\n", + "language": "ruby" + } + ] }, "dialog": { "name": "Dialog", - "description": "frozen_string_literal: true", + "description": "A window overlaid on either the primary window or another dialog window, rendering the content underneath inert.", "files": [ { "path": "dialog.rb", @@ -881,12 +1277,23 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Dialog", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Dialog\n\nA window overlaid on either the primary window or another dialog window, rendering the content underneath inert.\n\n## Usage\n\n### Example\n\n```ruby\nDialog do\n DialogTrigger do\n Button { \"Open Dialog\" }\n end\n DialogContent do\n DialogHeader do\n DialogTitle { \"RubyUI to the rescue\" }\n DialogDescription { \"RubyUI helps you build accessible standard compliant web apps with ease\" }\n end\n DialogMiddle do\n AspectRatio(aspect_ratio: \"16/9\", class: 'rounded-md overflow-hidden border') do\n img(\n alt: \"Placeholder\",\n loading: \"lazy\",\n src: image_path(\"pattern.jpg\")\n )\n end\n end\n DialogFooter do\n Button(variant: :outline, data: { action: 'click->ruby-ui--dialog#dismiss' }) { \"Cancel\" }\n Button { \"Save\" }\n end\n end\nend\n```\n\n### Size\n\n```ruby\ndiv(class: 'flex flex-wrap justify-center gap-2') do\n Dialog do\n DialogTrigger do\n Button { \"Small Dialog\" }\n end\n DialogContent(size: :sm) do\n DialogHeader do\n DialogTitle { \"RubyUI to the rescue\" }\n DialogDescription { \"RubyUI helps you build accessible standard compliant web apps with ease\" }\n end\n DialogMiddle do\n AspectRatio(aspect_ratio: \"16/9\", class: 'rounded-md overflow-hidden border') do\n img(\n alt: \"Placeholder\",\n loading: \"lazy\",\n src: image_path(\"pattern.jpg\")\n )\n end\n end\n DialogFooter do\n Button(variant: :outline, data: { action: 'click->ruby-ui--dialog#dismiss' }) { \"Cancel\" }\n Button { \"Save\" }\n end\n end\n end\n\n Dialog do\n DialogTrigger do\n Button { \"Large Dialog\" }\n end\n DialogContent(size: :lg) do\n DialogHeader do\n DialogTitle { \"RubyUI to the rescue\" }\n DialogDescription { \"RubyUI helps you build accessible standard compliant web apps with ease\" }\n end\n DialogMiddle do\n AspectRatio(aspect_ratio: \"16/9\", class: 'rounded-md overflow-hidden border') do\n img(\n alt: \"Placeholder\",\n loading: \"lazy\",\n src: image_path(\"pattern.jpg\")\n )\n end\n end\n DialogFooter do\n Button(variant: :outline, data: { action: 'click->ruby-ui--dialog#dismiss' }) { \"Cancel\" }\n Button { \"Save\" }\n end\n end\n end\nend\n```", + "examples": [ + { + "title": "Example", + "code": "Dialog do\n DialogTrigger do\n Button { \"Open Dialog\" }\n end\n DialogContent do\n DialogHeader do\n DialogTitle { \"RubyUI to the rescue\" }\n DialogDescription { \"RubyUI helps you build accessible standard compliant web apps with ease\" }\n end\n DialogMiddle do\n AspectRatio(aspect_ratio: \"16/9\", class: 'rounded-md overflow-hidden border') do\n img(\n alt: \"Placeholder\",\n loading: \"lazy\",\n src: image_path(\"pattern.jpg\")\n )\n end\n end\n DialogFooter do\n Button(variant: :outline, data: { action: 'click->ruby-ui--dialog#dismiss' }) { \"Cancel\" }\n Button { \"Save\" }\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "Size", + "code": "div(class: 'flex flex-wrap justify-center gap-2') do\n Dialog do\n DialogTrigger do\n Button { \"Small Dialog\" }\n end\n DialogContent(size: :sm) do\n DialogHeader do\n DialogTitle { \"RubyUI to the rescue\" }\n DialogDescription { \"RubyUI helps you build accessible standard compliant web apps with ease\" }\n end\n DialogMiddle do\n AspectRatio(aspect_ratio: \"16/9\", class: 'rounded-md overflow-hidden border') do\n img(\n alt: \"Placeholder\",\n loading: \"lazy\",\n src: image_path(\"pattern.jpg\")\n )\n end\n end\n DialogFooter do\n Button(variant: :outline, data: { action: 'click->ruby-ui--dialog#dismiss' }) { \"Cancel\" }\n Button { \"Save\" }\n end\n end\n end\n\n Dialog do\n DialogTrigger do\n Button { \"Large Dialog\" }\n end\n DialogContent(size: :lg) do\n DialogHeader do\n DialogTitle { \"RubyUI to the rescue\" }\n DialogDescription { \"RubyUI helps you build accessible standard compliant web apps with ease\" }\n end\n DialogMiddle do\n AspectRatio(aspect_ratio: \"16/9\", class: 'rounded-md overflow-hidden border') do\n img(\n alt: \"Placeholder\",\n loading: \"lazy\",\n src: image_path(\"pattern.jpg\")\n )\n end\n end\n DialogFooter do\n Button(variant: :outline, data: { action: 'click->ruby-ui--dialog#dismiss' }) { \"Cancel\" }\n Button { \"Save\" }\n end\n end\n end\nend\n", + "language": "ruby" + } + ] }, "dropdown_menu": { "name": "DropdownMenu", - "description": "frozen_string_literal: true", + "description": "Displays a menu to the user — such as a set of actions or functions — triggered by a button.", "files": [ { "path": "dropdown_menu.rb", @@ -925,12 +1332,23 @@ "gems": [] }, "install_command": "rails g ruby_ui:component DropdownMenu", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Dropdown Menu\n\nDisplays a menu to the user — such as a set of actions or functions — triggered by a button.\n\n## Usage\n\n### Example\n\n```ruby\nDropdownMenu do\n DropdownMenuTrigger(class: 'w-full') do\n Button(variant: :outline) { \"Open\" }\n end\n DropdownMenuContent do\n DropdownMenuLabel { \"My Account\" }\n DropdownMenuSeparator\n DropdownMenuItem(href: '#') { \"Profile\" }\n DropdownMenuItem(href: '#') { \"Billing\" }\n DropdownMenuItem(href: '#') { \"Team\" }\n DropdownMenuItem(href: '#') { \"Subscription\" }\n end\nend\n```\n\n### Placement\n\n```ruby\ndiv(class: 'grid grid-cols-1 sm:grid-cols-3 gap-4') do\n # -- TOP --\n DropdownMenu(options: { placement: 'top' }) do\n DropdownMenuTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'top' }\n end\n DropdownMenuContent do\n DropdownMenuLabel { \"My Account\" }\n DropdownMenuSeparator\n DropdownMenuItem(href: '#') { \"Profile\" }\n DropdownMenuItem(href: '#') { \"Billing\" }\n DropdownMenuItem(href: '#') { \"Team\" }\n DropdownMenuItem(href: '#') { \"Subscription\" }\n end\n end\n\n DropdownMenu(options: { placement: 'top-start' }) do\n DropdownMenuTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'top-start' }\n end\n DropdownMenuContent do\n DropdownMenuLabel { \"My Account\" }\n DropdownMenuSeparator\n DropdownMenuItem(href: '#') { \"Profile\" }\n DropdownMenuItem(href: '#') { \"Billing\" }\n DropdownMenuItem(href: '#') { \"Team\" }\n DropdownMenuItem(href: '#') { \"Subscription\" }\n end\n end\n\n DropdownMenu(options: { placement: 'top-end' }) do\n DropdownMenuTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'top-end' }\n end\n DropdownMenuContent do\n DropdownMenuLabel { \"My Account\" }\n DropdownMenuSeparator\n DropdownMenuItem(href: '#') { \"Profile\" }\n DropdownMenuItem(href: '#') { \"Billing\" }\n DropdownMenuItem(href: '#') { \"Team\" }\n DropdownMenuItem(href: '#') { \"Subscription\" }\n end\n end\n\n # -- BOTTOM --\n DropdownMenu(options: { placement: 'bottom' }) do\n DropdownMenuTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'bottom' }\n end\n DropdownMenuContent do\n DropdownMenuLabel { \"My Account\" }\n DropdownMenuSeparator\n DropdownMenuItem(href: '#') { \"Profile\" }\n DropdownMenuItem(href: '#') { \"Billing\" }\n DropdownMenuItem(href: '#') { \"Team\" }\n DropdownMenuItem(href: '#') { \"Subscription\" }\n end\n end\n\n DropdownMenu(options: { placement: 'bottom-start' }) do\n DropdownMenuTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'bottom-start' }\n end\n DropdownMenuContent do\n DropdownMenuLabel { \"My Account\" }\n DropdownMenuSeparator\n DropdownMenuItem(href: '#') { \"Profile\" }\n DropdownMenuItem(href: '#') { \"Billing\" }\n DropdownMenuItem(href: '#') { \"Team\" }\n DropdownMenuItem(href: '#') { \"Subscription\" }\n end\n end\n\n DropdownMenu(options: { placement: 'bottom-end' }) do\n DropdownMenuTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'bottom-end' }\n end\n DropdownMenuContent do\n DropdownMenuLabel { \"My Account\" }\n DropdownMenuSeparator\n DropdownMenuItem(href: '#') { \"Profile\" }\n DropdownMenuItem(href: '#') { \"Billing\" }\n DropdownMenuItem(href: '#') { \"Team\" }\n DropdownMenuItem(href: '#') { \"Subscription\" }\n end\n end\n\n # -- LEFT --\n DropdownMenu(options: { placement: 'left' }) do\n DropdownMenuTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'left' }\n end\n DropdownMenuContent do\n DropdownMenuLabel { \"My Account\" }\n DropdownMenuSeparator\n DropdownMenuItem(href: '#') { \"Profile\" }\n DropdownMenuItem(href: '#') { \"Billing\" }\n DropdownMenuItem(href: '#') { \"Team\" }\n DropdownMenuItem(href: '#') { \"Subscription\" }\n end\n end\n\n DropdownMenu(options: { placement: 'left-start' }) do\n DropdownMenuTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'left-start' }\n end\n DropdownMenuContent do\n DropdownMenuLabel { \"My Account\" }\n DropdownMenuSeparator\n DropdownMenuItem(href: '#') { \"Profile\" }\n DropdownMenuItem(href: '#') { \"Billing\" }\n DropdownMenuItem(href: '#') { \"Team\" }\n DropdownMenuItem(href: '#') { \"Subscription\" }\n end\n end\n\n DropdownMenu(options: { placement: 'left-end' }) do\n DropdownMenuTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'left-end' }\n end\n DropdownMenuContent do\n DropdownMenuLabel { \"My Account\" }\n DropdownMenuSeparator\n DropdownMenuItem(href: '#') { \"Profile\" }\n DropdownMenuItem(href: '#') { \"Billing\" }\n DropdownMenuItem(href: '#') { \"Team\" }\n DropdownMenuItem(href: '#') { \"Subscription\" }\n end\n end\n\n # -- RIGHT --\n DropdownMenu(options: { placement: 'right' }) do\n DropdownMenuTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'right' }\n end\n DropdownMenuContent do\n DropdownMenuLabel { \"My Account\" }\n DropdownMenuSeparator\n DropdownMenuItem(href: '#') { \"Profile\" }\n DropdownMenuItem(href: '#') { \"Billing\" }\n DropdownMenuItem(href: '#') { \"Team\" }\n DropdownMenuItem(href: '#') { \"Subscription\" }\n end\n end\n\n DropdownMenu(options: { placement: 'right-start' }) do\n DropdownMenuTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'right-start' }\n end\n DropdownMenuContent do\n DropdownMenuLabel { \"My Account\" }\n DropdownMenuSeparator\n DropdownMenuItem(href: '#') { \"Profile\" }\n DropdownMenuItem(href: '#') { \"Billing\" }\n DropdownMenuItem(href: '#') { \"Team\" }\n DropdownMenuItem(href: '#') { \"Subscription\" }\n end\n end\n\n DropdownMenu(options: { placement: 'right-end' }) do\n DropdownMenuTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'right-end' }\n end\n DropdownMenuContent do\n DropdownMenuLabel { \"My Account\" }\n DropdownMenuSeparator\n DropdownMenuItem(href: '#') { \"Profile\" }\n DropdownMenuItem(href: '#') { \"Billing\" }\n DropdownMenuItem(href: '#') { \"Team\" }\n DropdownMenuItem(href: '#') { \"Subscription\" }\n end\n end\nend\n```", + "examples": [ + { + "title": "Example", + "code": "DropdownMenu do\n DropdownMenuTrigger(class: 'w-full') do\n Button(variant: :outline) { \"Open\" }\n end\n DropdownMenuContent do\n DropdownMenuLabel { \"My Account\" }\n DropdownMenuSeparator\n DropdownMenuItem(href: '#') { \"Profile\" }\n DropdownMenuItem(href: '#') { \"Billing\" }\n DropdownMenuItem(href: '#') { \"Team\" }\n DropdownMenuItem(href: '#') { \"Subscription\" }\n end\nend\n", + "language": "ruby" + }, + { + "title": "Placement", + "code": "div(class: 'grid grid-cols-1 sm:grid-cols-3 gap-4') do\n # -- TOP --\n DropdownMenu(options: { placement: 'top' }) do\n DropdownMenuTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'top' }\n end\n DropdownMenuContent do\n DropdownMenuLabel { \"My Account\" }\n DropdownMenuSeparator\n DropdownMenuItem(href: '#') { \"Profile\" }\n DropdownMenuItem(href: '#') { \"Billing\" }\n DropdownMenuItem(href: '#') { \"Team\" }\n DropdownMenuItem(href: '#') { \"Subscription\" }\n end\n end\n\n DropdownMenu(options: { placement: 'top-start' }) do\n DropdownMenuTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'top-start' }\n end\n DropdownMenuContent do\n DropdownMenuLabel { \"My Account\" }\n DropdownMenuSeparator\n DropdownMenuItem(href: '#') { \"Profile\" }\n DropdownMenuItem(href: '#') { \"Billing\" }\n DropdownMenuItem(href: '#') { \"Team\" }\n DropdownMenuItem(href: '#') { \"Subscription\" }\n end\n end\n\n DropdownMenu(options: { placement: 'top-end' }) do\n DropdownMenuTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'top-end' }\n end\n DropdownMenuContent do\n DropdownMenuLabel { \"My Account\" }\n DropdownMenuSeparator\n DropdownMenuItem(href: '#') { \"Profile\" }\n DropdownMenuItem(href: '#') { \"Billing\" }\n DropdownMenuItem(href: '#') { \"Team\" }\n DropdownMenuItem(href: '#') { \"Subscription\" }\n end\n end\n\n # -- BOTTOM --\n DropdownMenu(options: { placement: 'bottom' }) do\n DropdownMenuTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'bottom' }\n end\n DropdownMenuContent do\n DropdownMenuLabel { \"My Account\" }\n DropdownMenuSeparator\n DropdownMenuItem(href: '#') { \"Profile\" }\n DropdownMenuItem(href: '#') { \"Billing\" }\n DropdownMenuItem(href: '#') { \"Team\" }\n DropdownMenuItem(href: '#') { \"Subscription\" }\n end\n end\n\n DropdownMenu(options: { placement: 'bottom-start' }) do\n DropdownMenuTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'bottom-start' }\n end\n DropdownMenuContent do\n DropdownMenuLabel { \"My Account\" }\n DropdownMenuSeparator\n DropdownMenuItem(href: '#') { \"Profile\" }\n DropdownMenuItem(href: '#') { \"Billing\" }\n DropdownMenuItem(href: '#') { \"Team\" }\n DropdownMenuItem(href: '#') { \"Subscription\" }\n end\n end\n\n DropdownMenu(options: { placement: 'bottom-end' }) do\n DropdownMenuTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'bottom-end' }\n end\n DropdownMenuContent do\n DropdownMenuLabel { \"My Account\" }\n DropdownMenuSeparator\n DropdownMenuItem(href: '#') { \"Profile\" }\n DropdownMenuItem(href: '#') { \"Billing\" }\n DropdownMenuItem(href: '#') { \"Team\" }\n DropdownMenuItem(href: '#') { \"Subscription\" }\n end\n end\n\n # -- LEFT --\n DropdownMenu(options: { placement: 'left' }) do\n DropdownMenuTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'left' }\n end\n DropdownMenuContent do\n DropdownMenuLabel { \"My Account\" }\n DropdownMenuSeparator\n DropdownMenuItem(href: '#') { \"Profile\" }\n DropdownMenuItem(href: '#') { \"Billing\" }\n DropdownMenuItem(href: '#') { \"Team\" }\n DropdownMenuItem(href: '#') { \"Subscription\" }\n end\n end\n\n DropdownMenu(options: { placement: 'left-start' }) do\n DropdownMenuTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'left-start' }\n end\n DropdownMenuContent do\n DropdownMenuLabel { \"My Account\" }\n DropdownMenuSeparator\n DropdownMenuItem(href: '#') { \"Profile\" }\n DropdownMenuItem(href: '#') { \"Billing\" }\n DropdownMenuItem(href: '#') { \"Team\" }\n DropdownMenuItem(href: '#') { \"Subscription\" }\n end\n end\n\n DropdownMenu(options: { placement: 'left-end' }) do\n DropdownMenuTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'left-end' }\n end\n DropdownMenuContent do\n DropdownMenuLabel { \"My Account\" }\n DropdownMenuSeparator\n DropdownMenuItem(href: '#') { \"Profile\" }\n DropdownMenuItem(href: '#') { \"Billing\" }\n DropdownMenuItem(href: '#') { \"Team\" }\n DropdownMenuItem(href: '#') { \"Subscription\" }\n end\n end\n\n # -- RIGHT --\n DropdownMenu(options: { placement: 'right' }) do\n DropdownMenuTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'right' }\n end\n DropdownMenuContent do\n DropdownMenuLabel { \"My Account\" }\n DropdownMenuSeparator\n DropdownMenuItem(href: '#') { \"Profile\" }\n DropdownMenuItem(href: '#') { \"Billing\" }\n DropdownMenuItem(href: '#') { \"Team\" }\n DropdownMenuItem(href: '#') { \"Subscription\" }\n end\n end\n\n DropdownMenu(options: { placement: 'right-start' }) do\n DropdownMenuTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'right-start' }\n end\n DropdownMenuContent do\n DropdownMenuLabel { \"My Account\" }\n DropdownMenuSeparator\n DropdownMenuItem(href: '#') { \"Profile\" }\n DropdownMenuItem(href: '#') { \"Billing\" }\n DropdownMenuItem(href: '#') { \"Team\" }\n DropdownMenuItem(href: '#') { \"Subscription\" }\n end\n end\n\n DropdownMenu(options: { placement: 'right-end' }) do\n DropdownMenuTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'right-end' }\n end\n DropdownMenuContent do\n DropdownMenuLabel { \"My Account\" }\n DropdownMenuSeparator\n DropdownMenuItem(href: '#') { \"Profile\" }\n DropdownMenuItem(href: '#') { \"Billing\" }\n DropdownMenuItem(href: '#') { \"Team\" }\n DropdownMenuItem(href: '#') { \"Subscription\" }\n end\n end\nend\n", + "language": "ruby" + } + ] }, "form": { "name": "Form", - "description": "frozen_string_literal: true", + "description": "Building forms with built-in client-side validations.", "files": [ { "path": "form.rb", @@ -963,12 +1381,53 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Form", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Form\n\nBuilding forms with built-in client-side validations.\n\n## Usage\n\n### Example\n\n```ruby\nForm(class: \"w-2/3 space-y-6\") do\n FormField do\n FormFieldLabel { \"Default error\" }\n Input(placeholder: \"Joel Drapper\", required: true, minlength: \"3\") { \"Joel Drapper\" }\n FormFieldHint()\n FormFieldError()\n end\n Button(type: \"submit\") { \"Save\" }\nend\n```\n\n### Disabled\n\n```ruby\nFormField do\n FormFieldLabel { \"Disabled\" }\n Input(disabled: true, placeholder: \"Joel Drapper\", required: true, minlength: \"3\") { \"Joel Drapper\" }\nend\n```\n\n### Aria Disabled\n\n```ruby\nFormField do\n FormFieldLabel { \"Aria Disabled\" }\n Input(aria: {disabled: \"true\"}, placeholder: \"Joel Drapper\", required: true, minlength: \"3\") { \"Joel Drapper\" }\nend\n```\n\n### Custom error message\n\n```ruby\nForm(class: \"w-2/3 space-y-6\") do\n FormField do\n FormFieldLabel { \"Custom error message\" }\n Input(placeholder: \"joel@drapper.me\", required: true, data_value_missing: \"Custom error message\")\n FormFieldError()\n end\n Button(type: \"submit\") { \"Save\" }\nend\n```\n\n### Backend error\n\n```ruby\nForm(class: \"w-2/3 space-y-6\") do\n FormField do\n FormFieldLabel { \"Backend error\" }\n Input(placeholder: \"Joel Drapper\", required: true)\n FormFieldError { \"Error from backend\" }\n end\n Button(type: \"submit\") { \"Save\" }\nend\n```\n\n### Checkbox\n\n```ruby\nForm(class: \"w-2/3 space-y-6\") do\n FormField do\n Checkbox(required: true)\n label(\n class:\n \"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\"\n ) { \" Accept terms and conditions \" }\n FormFieldError()\n end\n Button(type: \"submit\") { \"Save\" }\nend\n```\n\n### Select\n\n```ruby\nForm(class: \"w-2/3 space-y-6\") do\n FormField do\n FormFieldLabel { \"Select\" }\n Select do\n SelectInput(required: true)\n SelectTrigger do\n SelectValue(placeholder: \"Select a fruit\")\n end\n SelectContent() do\n SelectGroup do\n SelectLabel { \"Fruits\" }\n SelectItem(value: \"apple\") { \"Apple\" }\n SelectItem(value: \"orange\") { \"Orange\" }\n SelectItem(value: \"banana\") { \"Banana\" }\n SelectItem(value: \"watermelon\") { \"Watermelon\" }\n end\n end\n end\n FormFieldError()\n end\n Button(type: \"submit\") { \"Save\" }\nend\n```\n\n### Combobox\n\n```ruby\nForm(class: \"w-2/3 space-y-6\") do\n FormField do\n FormFieldLabel { \"Combobox\" }\n\n Combobox do\n ComboboxTrigger placeholder: \"Pick value\"\n\n ComboboxPopover do\n ComboboxSearchInput(placeholder: \"Pick value or type anything\")\n\n ComboboxList do\n ComboboxEmptyState { \"No result\" }\n\n ComboboxListGroup label: \"Fruits\" do\n ComboboxItem do\n ComboboxRadio(name: \"food\", value: \"apple\", required: true)\n span { \"Apple\" }\n end\n\n ComboboxItem do\n ComboboxRadio(name: \"food\", value: \"banana\", required: true)\n span { \"Banana\" }\n end\n end\n\n ComboboxListGroup label: \"Vegetable\" do\n ComboboxItem do\n ComboboxRadio(name: \"food\", value: \"brocoli\", required: true)\n span { \"Broccoli\" }\n end\n\n ComboboxItem do\n ComboboxRadio(name: \"food\", value: \"carrot\", required: true)\n span { \"Carrot\" }\n end\n end\n\n ComboboxListGroup label: \"Others\" do\n ComboboxItem do\n ComboboxRadio(name: \"food\", value: \"chocolate\", required: true)\n span { \"Chocolate\" }\n end\n\n ComboboxItem do\n ComboboxRadio(name: \"food\", value: \"milk\", required: true)\n span { \"Milk\" }\n end\n end\n end\n end\n end\n\n FormFieldError()\n end\n Button(type: \"submit\") { \"Save\" }\nend\n```", + "examples": [ + { + "title": "Example", + "code": "Form(class: \"w-2/3 space-y-6\") do\n FormField do\n FormFieldLabel { \"Default error\" }\n Input(placeholder: \"Joel Drapper\", required: true, minlength: \"3\") { \"Joel Drapper\" }\n FormFieldHint()\n FormFieldError()\n end\n Button(type: \"submit\") { \"Save\" }\nend\n", + "language": "ruby" + }, + { + "title": "Disabled", + "code": "FormField do\n FormFieldLabel { \"Disabled\" }\n Input(disabled: true, placeholder: \"Joel Drapper\", required: true, minlength: \"3\") { \"Joel Drapper\" }\nend\n", + "language": "ruby" + }, + { + "title": "Aria Disabled", + "code": "FormField do\n FormFieldLabel { \"Aria Disabled\" }\n Input(aria: {disabled: \"true\"}, placeholder: \"Joel Drapper\", required: true, minlength: \"3\") { \"Joel Drapper\" }\nend\n", + "language": "ruby" + }, + { + "title": "Custom error message", + "code": "Form(class: \"w-2/3 space-y-6\") do\n FormField do\n FormFieldLabel { \"Custom error message\" }\n Input(placeholder: \"joel@drapper.me\", required: true, data_value_missing: \"Custom error message\")\n FormFieldError()\n end\n Button(type: \"submit\") { \"Save\" }\nend\n", + "language": "ruby" + }, + { + "title": "Backend error", + "code": "Form(class: \"w-2/3 space-y-6\") do\n FormField do\n FormFieldLabel { \"Backend error\" }\n Input(placeholder: \"Joel Drapper\", required: true)\n FormFieldError { \"Error from backend\" }\n end\n Button(type: \"submit\") { \"Save\" }\nend\n", + "language": "ruby" + }, + { + "title": "Checkbox", + "code": "Form(class: \"w-2/3 space-y-6\") do\n FormField do\n Checkbox(required: true)\n label(\n class:\n \"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\"\n ) { \" Accept terms and conditions \" }\n FormFieldError()\n end\n Button(type: \"submit\") { \"Save\" }\nend\n", + "language": "ruby" + }, + { + "title": "Select", + "code": "Form(class: \"w-2/3 space-y-6\") do\n FormField do\n FormFieldLabel { \"Select\" }\n Select do\n SelectInput(required: true)\n SelectTrigger do\n SelectValue(placeholder: \"Select a fruit\")\n end\n SelectContent() do\n SelectGroup do\n SelectLabel { \"Fruits\" }\n SelectItem(value: \"apple\") { \"Apple\" }\n SelectItem(value: \"orange\") { \"Orange\" }\n SelectItem(value: \"banana\") { \"Banana\" }\n SelectItem(value: \"watermelon\") { \"Watermelon\" }\n end\n end\n end\n FormFieldError()\n end\n Button(type: \"submit\") { \"Save\" }\nend\n", + "language": "ruby" + }, + { + "title": "Combobox", + "code": "Form(class: \"w-2/3 space-y-6\") do\n FormField do\n FormFieldLabel { \"Combobox\" }\n\n Combobox do\n ComboboxTrigger placeholder: \"Pick value\"\n\n ComboboxPopover do\n ComboboxSearchInput(placeholder: \"Pick value or type anything\")\n\n ComboboxList do\n ComboboxEmptyState { \"No result\" }\n\n ComboboxListGroup label: \"Fruits\" do\n ComboboxItem do\n ComboboxRadio(name: \"food\", value: \"apple\", required: true)\n span { \"Apple\" }\n end\n\n ComboboxItem do\n ComboboxRadio(name: \"food\", value: \"banana\", required: true)\n span { \"Banana\" }\n end\n end\n\n ComboboxListGroup label: \"Vegetable\" do\n ComboboxItem do\n ComboboxRadio(name: \"food\", value: \"brocoli\", required: true)\n span { \"Broccoli\" }\n end\n\n ComboboxItem do\n ComboboxRadio(name: \"food\", value: \"carrot\", required: true)\n span { \"Carrot\" }\n end\n end\n\n ComboboxListGroup label: \"Others\" do\n ComboboxItem do\n ComboboxRadio(name: \"food\", value: \"chocolate\", required: true)\n span { \"Chocolate\" }\n end\n\n ComboboxItem do\n ComboboxRadio(name: \"food\", value: \"milk\", required: true)\n span { \"Milk\" }\n end\n end\n end\n end\n end\n\n FormFieldError()\n end\n Button(type: \"submit\") { \"Save\" }\nend\n", + "language": "ruby" + } + ] }, "hover_card": { "name": "HoverCard", - "description": "frozen_string_literal: true", + "description": "For sighted users to preview content available behind a link.", "files": [ { "path": "hover_card.rb", @@ -995,12 +1454,18 @@ "gems": [] }, "install_command": "rails g ruby_ui:component HoverCard", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Hover Card\n\nFor sighted users to preview content available behind a link.\n\n## Usage\n\n### Example\n\n```ruby\nHoverCard do\n HoverCardTrigger do\n Button(variant: :link) { \"@joeldrapper\" } # Make this a link in order to navigate somewhere\n end\n HoverCardContent do\n div(class: \"flex justify-between space-x-4\") do\n Avatar do\n AvatarImage(src: \"https://avatars.githubusercontent.com/u/246692?v=4\", alt: \"joeldrapper\")\n AvatarFallback { \"JD\" }\n end\n div(class: \"space-y-1\") do\n h4(class: \"text-sm font-medium\") { \"@joeldrapper\" }\n p(class: \"text-sm\") do\n \"Creator of Phlex Components. Ruby on Rails developer.\"\n end\n div(class: \"flex items-center pt-2\") do\n svg(\n width: \"15\",\n height: \"15\",\n viewbox: \"0 0 15 15\",\n fill: \"none\",\n xmlns: \"http://www.w3.org/2000/svg\",\n class: \"mr-2 h-4 w-4 opacity-70\"\n ) do |s|\n s.path(\n d:\n \"M4.5 1C4.77614 1 5 1.22386 5 1.5V2H10V1.5C10 1.22386 10.2239 1 10.5 1C10.7761 1 11 1.22386 11 1.5V2H12.5C13.3284 2 14 2.67157 14 3.5V12.5C14 13.3284 13.3284 14 12.5 14H2.5C1.67157 14 1 13.3284 1 12.5V3.5C1 2.67157 1.67157 2 2.5 2H4V1.5C4 1.22386 4.22386 1 4.5 1ZM10 3V3.5C10 3.77614 10.2239 4 10.5 4C10.7761 4 11 3.77614 11 3.5V3H12.5C12.7761 3 13 3.22386 13 3.5V5H2V3.5C2 3.22386 2.22386 3 2.5 3H4V3.5C4 3.77614 4.22386 4 4.5 4C4.77614 4 5 3.77614 5 3.5V3H10ZM2 6V12.5C2 12.7761 2.22386 13 2.5 13H12.5C12.7761 13 13 12.7761 13 12.5V6H2ZM7 7.5C7 7.22386 7.22386 7 7.5 7C7.77614 7 8 7.22386 8 7.5C8 7.77614 7.77614 8 7.5 8C7.22386 8 7 7.77614 7 7.5ZM9.5 7C9.22386 7 9 7.22386 9 7.5C9 7.77614 9.22386 8 9.5 8C9.77614 8 10 7.77614 10 7.5C10 7.22386 9.77614 7 9.5 7ZM11 7.5C11 7.22386 11.2239 7 11.5 7C11.7761 7 12 7.22386 12 7.5C12 7.77614 11.7761 8 11.5 8C11.2239 8 11 7.77614 11 7.5ZM11.5 9C11.2239 9 11 9.22386 11 9.5C11 9.77614 11.2239 10 11.5 10C11.7761 10 12 9.77614 12 9.5C12 9.22386 11.7761 9 11.5 9ZM9 9.5C9 9.22386 9.22386 9 9.5 9C9.77614 9 10 9.22386 10 9.5C10 9.77614 9.77614 10 9.5 10C9.22386 10 9 9.77614 9 9.5ZM7.5 9C7.22386 9 7 9.22386 7 9.5C7 9.77614 7.22386 10 7.5 10C7.77614 10 8 9.77614 8 9.5C8 9.22386 7.77614 9 7.5 9ZM5 9.5C5 9.22386 5.22386 9 5.5 9C5.77614 9 6 9.22386 6 9.5C6 9.77614 5.77614 10 5.5 10C5.22386 10 5 9.77614 5 9.5ZM3.5 9C3.22386 9 3 9.22386 3 9.5C3 9.77614 3.22386 10 3.5 10C3.77614 10 4 9.77614 4 9.5C4 9.22386 3.77614 9 3.5 9ZM3 11.5C3 11.2239 3.22386 11 3.5 11C3.77614 11 4 11.2239 4 11.5C4 11.7761 3.77614 12 3.5 12C3.22386 12 3 11.7761 3 11.5ZM5.5 11C5.22386 11 5 11.2239 5 11.5C5 11.7761 5.22386 12 5.5 12C5.77614 12 6 11.7761 6 11.5C6 11.2239 5.77614 11 5.5 11ZM7 11.5C7 11.2239 7.22386 11 7.5 11C7.77614 11 8 11.2239 8 11.5C8 11.7761 7.77614 12 7.5 12C7.22386 12 7 11.7761 7 11.5ZM9.5 11C9.22386 11 9 11.2239 9 11.5C9 11.7761 9.22386 12 9.5 12C9.77614 12 10 11.7761 10 11.5C10 11.2239 9.77614 11 9.5 11Z\",\n fill: \"currentColor\",\n fill_rule: \"evenodd\",\n clip_rule: \"evenodd\"\n )\n end\n span(class: \"text-xs text-muted-foreground\") { \"Joined December 2021\" }\n end\n end\n end\n end\nend\n```", + "examples": [ + { + "title": "Example", + "code": "HoverCard do\n HoverCardTrigger do\n Button(variant: :link) { \"@joeldrapper\" } # Make this a link in order to navigate somewhere\n end\n HoverCardContent do\n div(class: \"flex justify-between space-x-4\") do\n Avatar do\n AvatarImage(src: \"https://avatars.githubusercontent.com/u/246692?v=4\", alt: \"joeldrapper\")\n AvatarFallback { \"JD\" }\n end\n div(class: \"space-y-1\") do\n h4(class: \"text-sm font-medium\") { \"@joeldrapper\" }\n p(class: \"text-sm\") do\n \"Creator of Phlex Components. Ruby on Rails developer.\"\n end\n div(class: \"flex items-center pt-2\") do\n svg(\n width: \"15\",\n height: \"15\",\n viewbox: \"0 0 15 15\",\n fill: \"none\",\n xmlns: \"http://www.w3.org/2000/svg\",\n class: \"mr-2 h-4 w-4 opacity-70\"\n ) do |s|\n s.path(\n d:\n \"M4.5 1C4.77614 1 5 1.22386 5 1.5V2H10V1.5C10 1.22386 10.2239 1 10.5 1C10.7761 1 11 1.22386 11 1.5V2H12.5C13.3284 2 14 2.67157 14 3.5V12.5C14 13.3284 13.3284 14 12.5 14H2.5C1.67157 14 1 13.3284 1 12.5V3.5C1 2.67157 1.67157 2 2.5 2H4V1.5C4 1.22386 4.22386 1 4.5 1ZM10 3V3.5C10 3.77614 10.2239 4 10.5 4C10.7761 4 11 3.77614 11 3.5V3H12.5C12.7761 3 13 3.22386 13 3.5V5H2V3.5C2 3.22386 2.22386 3 2.5 3H4V3.5C4 3.77614 4.22386 4 4.5 4C4.77614 4 5 3.77614 5 3.5V3H10ZM2 6V12.5C2 12.7761 2.22386 13 2.5 13H12.5C12.7761 13 13 12.7761 13 12.5V6H2ZM7 7.5C7 7.22386 7.22386 7 7.5 7C7.77614 7 8 7.22386 8 7.5C8 7.77614 7.77614 8 7.5 8C7.22386 8 7 7.77614 7 7.5ZM9.5 7C9.22386 7 9 7.22386 9 7.5C9 7.77614 9.22386 8 9.5 8C9.77614 8 10 7.77614 10 7.5C10 7.22386 9.77614 7 9.5 7ZM11 7.5C11 7.22386 11.2239 7 11.5 7C11.7761 7 12 7.22386 12 7.5C12 7.77614 11.7761 8 11.5 8C11.2239 8 11 7.77614 11 7.5ZM11.5 9C11.2239 9 11 9.22386 11 9.5C11 9.77614 11.2239 10 11.5 10C11.7761 10 12 9.77614 12 9.5C12 9.22386 11.7761 9 11.5 9ZM9 9.5C9 9.22386 9.22386 9 9.5 9C9.77614 9 10 9.22386 10 9.5C10 9.77614 9.77614 10 9.5 10C9.22386 10 9 9.77614 9 9.5ZM7.5 9C7.22386 9 7 9.22386 7 9.5C7 9.77614 7.22386 10 7.5 10C7.77614 10 8 9.77614 8 9.5C8 9.22386 7.77614 9 7.5 9ZM5 9.5C5 9.22386 5.22386 9 5.5 9C5.77614 9 6 9.22386 6 9.5C6 9.77614 5.77614 10 5.5 10C5.22386 10 5 9.77614 5 9.5ZM3.5 9C3.22386 9 3 9.22386 3 9.5C3 9.77614 3.22386 10 3.5 10C3.77614 10 4 9.77614 4 9.5C4 9.22386 3.77614 9 3.5 9ZM3 11.5C3 11.2239 3.22386 11 3.5 11C3.77614 11 4 11.2239 4 11.5C4 11.7761 3.77614 12 3.5 12C3.22386 12 3 11.7761 3 11.5ZM5.5 11C5.22386 11 5 11.2239 5 11.5C5 11.7761 5.22386 12 5.5 12C5.77614 12 6 11.7761 6 11.5C6 11.2239 5.77614 11 5.5 11ZM7 11.5C7 11.2239 7.22386 11 7.5 11C7.77614 11 8 11.2239 8 11.5C8 11.7761 7.77614 12 7.5 12C7.22386 12 7 11.7761 7 11.5ZM9.5 11C9.22386 11 9 11.2239 9 11.5C9 11.7761 9.22386 12 9.5 12C9.77614 12 10 11.7761 10 11.5C10 11.2239 9.77614 11 9.5 11Z\",\n fill: \"currentColor\",\n fill_rule: \"evenodd\",\n clip_rule: \"evenodd\"\n )\n end\n span(class: \"text-xs text-muted-foreground\") { \"Joined December 2021\" }\n end\n end\n end\n end\nend\n", + "language": "ruby" + } + ] }, "input": { "name": "Input", - "description": "frozen_string_literal: true", + "description": "Displays a form input field or a component that looks like an input field.", "files": [ { "path": "input.rb", @@ -1013,12 +1478,43 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Input", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Input\n\nDisplays a form input field or a component that looks like an input field.\n\n## Usage\n\n### Email\n\n```ruby\ndiv(class: 'grid w-full max-w-sm items-center gap-1.5') do\n Input(type: \"email\", placeholder: \"Email\")\nend\n```\n\n### File\n\n```ruby\ndiv(class: \"grid w-full max-w-sm items-center gap-1.5\") do\n label(for: \"picture\") { \"Picture\" }\n Input(type: \"file\", id: \"picture\")\nend\n```\n\n### Disabled\n\n```ruby\ndiv(class: 'grid w-full max-w-sm items-center gap-1.5') do\n Input(disabled: true, type: \"email\", placeholder: \"Email\")\nend\n```\n\n### Aria Disabled\n\n```ruby\ndiv(class: 'grid w-full max-w-sm items-center gap-1.5') do\n Input(aria: {disabled: \"true\"}, type: \"email\", placeholder: \"Email\")\nend\n```\n\n### With label\n\n```ruby\ndiv(class: 'grid w-full max-w-sm items-center gap-1.5') do\n label(for: \"email1\") { \"Email\" }\n Input(type: \"email\", placeholder: \"Email\", id: \"email1\")\nend\n```\n\n### With button\n\n```ruby\ndiv(class: 'flex w-full max-w-sm items-center space-x-2') do\n Input(type: \"email\", placeholder: \"Email\")\n Button { \"Subscribe\" }\nend\n```", + "examples": [ + { + "title": "Email", + "code": "div(class: 'grid w-full max-w-sm items-center gap-1.5') do\n Input(type: \"email\", placeholder: \"Email\")\nend\n", + "language": "ruby" + }, + { + "title": "File", + "code": "div(class: \"grid w-full max-w-sm items-center gap-1.5\") do\n label(for: \"picture\") { \"Picture\" }\n Input(type: \"file\", id: \"picture\")\nend\n", + "language": "ruby" + }, + { + "title": "Disabled", + "code": "div(class: 'grid w-full max-w-sm items-center gap-1.5') do\n Input(disabled: true, type: \"email\", placeholder: \"Email\")\nend\n", + "language": "ruby" + }, + { + "title": "Aria Disabled", + "code": "div(class: 'grid w-full max-w-sm items-center gap-1.5') do\n Input(aria: {disabled: \"true\"}, type: \"email\", placeholder: \"Email\")\nend\n", + "language": "ruby" + }, + { + "title": "With label", + "code": "div(class: 'grid w-full max-w-sm items-center gap-1.5') do\n label(for: \"email1\") { \"Email\" }\n Input(type: \"email\", placeholder: \"Email\", id: \"email1\")\nend\n", + "language": "ruby" + }, + { + "title": "With button", + "code": "div(class: 'flex w-full max-w-sm items-center space-x-2') do\n Input(type: \"email\", placeholder: \"Email\")\n Button { \"Subscribe\" }\nend\n", + "language": "ruby" + } + ] }, "link": { "name": "Link", - "description": "frozen_string_literal: true", + "description": "Displays a link that looks like a button or underline link.", "files": [ { "path": "link.rb", @@ -1031,12 +1527,53 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Link", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Link\n\nDisplays a link that looks like a button or underline link.\n\n## Usage\n\n### Example\n\n```ruby\nLink(href: \"#\") { \"Link\" }\n```\n\n### Aria Disabled\n\n```ruby\nLink(aria: {disabled: \"true\"}, href: \"#\") { \"Link\" }\n```\n\n### Primary\n\n```ruby\nLink(href: \"#\", variant: :primary) { \"Primary\" }\n```\n\n### Secondary\n\n```ruby\nLink(href: \"#\", variant: :secondary) { \"Secondary\" }\n```\n\n### Destructive\n\n```ruby\nLink(href: \"#\", variant: :destructive) { \"Destructive\" }\n```\n\n### Icon\n\n```ruby\nLink(href: \"#\", variant: :outline, icon: true) do\n chevron_icon\nend\n```\n\n### With Icon\n\n```ruby\nLink(href: \"#\", variant: :primary) do\n email_icon\n span { \"Login with Email\" }\nend\n```\n\n### Ghost\n\n```ruby\nLink(href: \"#\", variant: :ghost) { \"Ghost\" }\n```", + "examples": [ + { + "title": "Example", + "code": "Link(href: \"#\") { \"Link\" }\n", + "language": "ruby" + }, + { + "title": "Aria Disabled", + "code": "Link(aria: {disabled: \"true\"}, href: \"#\") { \"Link\" }\n", + "language": "ruby" + }, + { + "title": "Primary", + "code": "Link(href: \"#\", variant: :primary) { \"Primary\" }\n", + "language": "ruby" + }, + { + "title": "Secondary", + "code": "Link(href: \"#\", variant: :secondary) { \"Secondary\" }\n", + "language": "ruby" + }, + { + "title": "Destructive", + "code": "Link(href: \"#\", variant: :destructive) { \"Destructive\" }\n", + "language": "ruby" + }, + { + "title": "Icon", + "code": "Link(href: \"#\", variant: :outline, icon: true) do\n chevron_icon\nend\n", + "language": "ruby" + }, + { + "title": "With Icon", + "code": "Link(href: \"#\", variant: :primary) do\n email_icon\n span { \"Login with Email\" }\nend\n", + "language": "ruby" + }, + { + "title": "Ghost", + "code": "Link(href: \"#\", variant: :ghost) { \"Ghost\" }\n", + "language": "ruby" + } + ] }, "masked_input": { "name": "MaskedInput", - "description": "frozen_string_literal: true", + "description": "Displays a form input field with applied mask.", "files": [ { "path": "masked_input.rb", @@ -1057,12 +1594,28 @@ "gems": [] }, "install_command": "rails g ruby_ui:component MaskedInput", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# MaskedInput\n\nDisplays a form input field with applied mask.\n\n## Usage\n\n### Phone number\n\n```ruby\ndiv(class: 'grid w-full max-w-sm items-center gap-1.5') do\n MaskedInput(data: {maska: \"(##) #####-####\"})\nend\n```\n\n### Hex color code\n\n```ruby\ndiv(class: 'grid w-full max-w-sm items-center gap-1.5') do\n MaskedInput(data: {maska: \"!#HHHHHH\", maska_tokens: \"H:[0-9a-fA-F]\"})\nend\n```\n\n### CPF / CNPJ\n\n```ruby\ndiv(class: 'grid w-full max-w-sm items-center gap-1.5') do\n MaskedInput(data: {maska: \"['###.###.###-##', '##.###.###/####-##']\"})\nend\n```", + "examples": [ + { + "title": "Phone number", + "code": "div(class: 'grid w-full max-w-sm items-center gap-1.5') do\n MaskedInput(data: {maska: \"(##) #####-####\"})\nend\n", + "language": "ruby" + }, + { + "title": "Hex color code", + "code": "div(class: 'grid w-full max-w-sm items-center gap-1.5') do\n MaskedInput(data: {maska: \"!#HHHHHH\", maska_tokens: \"H:[0-9a-fA-F]\"})\nend\n", + "language": "ruby" + }, + { + "title": "CPF / CNPJ", + "code": "div(class: 'grid w-full max-w-sm items-center gap-1.5') do\n MaskedInput(data: {maska: \"['###.###.###-##', '##.###.###/####-##']\"})\nend\n", + "language": "ruby" + } + ] }, "native_select": { "name": "NativeSelect", - "description": "frozen_string_literal: true", + "description": "A styled native HTML select element with consistent design system integration.", "files": [ { "path": "native_select.rb", @@ -1087,12 +1640,33 @@ "gems": [] }, "install_command": "rails g ruby_ui:component NativeSelect", - "docs_markdown": "NativeSelect: Choose for native browser behavior, superior performance, or mobile-optimized dropdowns.\n\nSelect: Choose for custom styling, animations, or complex interactions.", - "examples": [] + "docs_markdown": "# Native Select\n\nA styled native HTML select element with consistent design system integration.\n\n## Usage\n\n### Default\n\n```ruby\ndiv(class: \"grid w-full max-w-sm items-center gap-1.5\") do\n NativeSelect do\n NativeSelectOption(value: \"\") { \"Select a fruit\" }\n NativeSelectOption(value: \"apple\") { \"Apple\" }\n NativeSelectOption(value: \"banana\") { \"Banana\" }\n NativeSelectOption(value: \"blueberry\") { \"Blueberry\" }\n NativeSelectOption(value: \"pineapple\") { \"Pineapple\" }\n end\nend\n```\n\n### Groups\n\n```ruby\ndiv(class: \"grid w-full max-w-sm items-center gap-1.5\") do\n NativeSelect do\n NativeSelectOption(value: \"\") { \"Select a department\" }\n NativeSelectGroup(label: \"Engineering\") do\n NativeSelectOption(value: \"frontend\") { \"Frontend\" }\n NativeSelectOption(value: \"backend\") { \"Backend\" }\n NativeSelectOption(value: \"devops\") { \"DevOps\" }\n end\n NativeSelectGroup(label: \"Sales\") do\n NativeSelectOption(value: \"account_executive\") { \"Account Executive\" }\n NativeSelectOption(value: \"sales_development\") { \"Sales Development\" }\n end\n end\nend\n```\n\n### Disabled\n\n```ruby\ndiv(class: \"grid w-full max-w-sm items-center gap-1.5\") do\n NativeSelect(disabled: true) do\n NativeSelectOption(value: \"\") { \"Select a fruit\" }\n NativeSelectOption(value: \"apple\") { \"Apple\" }\n NativeSelectOption(value: \"banana\") { \"Banana\" }\n NativeSelectOption(value: \"blueberry\") { \"Blueberry\" }\n end\nend\n```\n\n### Invalid\n\n```ruby\ndiv(class: \"grid w-full max-w-sm items-center gap-1.5\") do\n NativeSelect(aria: {invalid: \"true\"}) do\n NativeSelectOption(value: \"\") { \"Select a fruit\" }\n NativeSelectOption(value: \"apple\") { \"Apple\" }\n NativeSelectOption(value: \"banana\") { \"Banana\" }\n NativeSelectOption(value: \"blueberry\") { \"Blueberry\" }\n end\nend\n```\n\n## Native Select vs Select\n\nNativeSelect: Choose for native browser behavior, superior performance, or mobile-optimized dropdowns.\n\nSelect: Choose for custom styling, animations, or complex interactions.", + "examples": [ + { + "title": "Default", + "code": "div(class: \"grid w-full max-w-sm items-center gap-1.5\") do\n NativeSelect do\n NativeSelectOption(value: \"\") { \"Select a fruit\" }\n NativeSelectOption(value: \"apple\") { \"Apple\" }\n NativeSelectOption(value: \"banana\") { \"Banana\" }\n NativeSelectOption(value: \"blueberry\") { \"Blueberry\" }\n NativeSelectOption(value: \"pineapple\") { \"Pineapple\" }\n end\nend\n", + "language": "ruby" + }, + { + "title": "Groups", + "code": "div(class: \"grid w-full max-w-sm items-center gap-1.5\") do\n NativeSelect do\n NativeSelectOption(value: \"\") { \"Select a department\" }\n NativeSelectGroup(label: \"Engineering\") do\n NativeSelectOption(value: \"frontend\") { \"Frontend\" }\n NativeSelectOption(value: \"backend\") { \"Backend\" }\n NativeSelectOption(value: \"devops\") { \"DevOps\" }\n end\n NativeSelectGroup(label: \"Sales\") do\n NativeSelectOption(value: \"account_executive\") { \"Account Executive\" }\n NativeSelectOption(value: \"sales_development\") { \"Sales Development\" }\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "Disabled", + "code": "div(class: \"grid w-full max-w-sm items-center gap-1.5\") do\n NativeSelect(disabled: true) do\n NativeSelectOption(value: \"\") { \"Select a fruit\" }\n NativeSelectOption(value: \"apple\") { \"Apple\" }\n NativeSelectOption(value: \"banana\") { \"Banana\" }\n NativeSelectOption(value: \"blueberry\") { \"Blueberry\" }\n end\nend\n", + "language": "ruby" + }, + { + "title": "Invalid", + "code": "div(class: \"grid w-full max-w-sm items-center gap-1.5\") do\n NativeSelect(aria: {invalid: \"true\"}) do\n NativeSelectOption(value: \"\") { \"Select a fruit\" }\n NativeSelectOption(value: \"apple\") { \"Apple\" }\n NativeSelectOption(value: \"banana\") { \"Banana\" }\n NativeSelectOption(value: \"blueberry\") { \"Blueberry\" }\n end\nend\n", + "language": "ruby" + } + ] }, "pagination": { "name": "Pagination", - "description": "frozen_string_literal: true", + "description": "Pagination with page navigation, next and previous links.", "files": [ { "path": "pagination.rb", @@ -1119,12 +1693,18 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Pagination", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Pagination\n\nPagination with page navigation, next and previous links.\n\n## Usage\n\n### Example\n\n```ruby\nPagination do\n PaginationContent do\n PaginationItem(href: \"#\") do\n chevrons_left_icon\n plain \"First\"\n end\n PaginationItem(href: \"#\") do\n chevron_left_icon\n plain \"Prev\"\n end\n\n PaginationEllipsis\n\n PaginationItem(href: \"#\") { \"4\" }\n PaginationItem(href: \"#\", active: true) { \"5\" }\n PaginationItem(href: \"#\") { \"6\" }\n\n PaginationEllipsis\n\n PaginationItem(href: \"#\") do\n plain \"Next\"\n chevron_right_icon\n end\n PaginationItem(href: \"#\") do\n plain \"Last\"\n chevrons_right_icon\n end\n end\nend\n```", + "examples": [ + { + "title": "Example", + "code": "Pagination do\n PaginationContent do\n PaginationItem(href: \"#\") do\n chevrons_left_icon\n plain \"First\"\n end\n PaginationItem(href: \"#\") do\n chevron_left_icon\n plain \"Prev\"\n end\n\n PaginationEllipsis\n\n PaginationItem(href: \"#\") { \"4\" }\n PaginationItem(href: \"#\", active: true) { \"5\" }\n PaginationItem(href: \"#\") { \"6\" }\n\n PaginationEllipsis\n\n PaginationItem(href: \"#\") do\n plain \"Next\"\n chevron_right_icon\n end\n PaginationItem(href: \"#\") do\n plain \"Last\"\n chevrons_right_icon\n end\n end\nend\n", + "language": "ruby" + } + ] }, "popover": { "name": "Popover", - "description": "frozen_string_literal: true", + "description": "Displays rich content in a portal, triggered by a button.", "files": [ { "path": "popover.rb", @@ -1151,12 +1731,28 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Popover", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Popover\n\nDisplays rich content in a portal, triggered by a button.\n\n### Example\n\n```ruby\nPopover do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline) { \"Open Popover\" }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\nend\n```\n\n### Placement\n\n```ruby\ndiv(class: 'grid grid-cols-1 sm:grid-cols-3 gap-4') do\n # -- TOP --\n Popover(options: { placement: 'top' }) do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'top' }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\n end\n\n Popover(options: { placement: 'top-start' }) do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'top-start' }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\n end\n\n Popover(options: { placement: 'top-end' }) do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'top-end' }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\n end\n\n # -- RIGHT --\n Popover(options: { placement: 'right' }) do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'right' }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\n end\n\n Popover(options: { placement: 'right-start' }) do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'right-start' }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\n end\n\n Popover(options: { placement: 'right-end' }) do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'right-end' }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\n end\n\n # -- LEFT --\n Popover(options: { placement: 'left' }) do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'left' }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\n end\n\n Popover(options: { placement: 'left-start' }) do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'left-start' }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\n end\n\n Popover(options: { placement: 'left-end' }) do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'left-end' }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\n end\n\n # -- BOTTOM --\n Popover(options: { placement: 'bottom' }) do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'bottom' }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\n end\n\n Popover(options: { placement: 'bottom-start' }) do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'bottom-start' }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\n end\n\n Popover(options: { placement: 'bottom-end' }) do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'bottom-end' }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\n end\nend\n```\n\n### Trigger\n\n```ruby\nPopover(options: { trigger: 'click' }) do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline) { \"Click\" }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\nend\n```", + "examples": [ + { + "title": "Example", + "code": "Popover do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline) { \"Open Popover\" }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "Placement", + "code": "div(class: 'grid grid-cols-1 sm:grid-cols-3 gap-4') do\n # -- TOP --\n Popover(options: { placement: 'top' }) do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'top' }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\n end\n\n Popover(options: { placement: 'top-start' }) do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'top-start' }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\n end\n\n Popover(options: { placement: 'top-end' }) do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'top-end' }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\n end\n\n # -- RIGHT --\n Popover(options: { placement: 'right' }) do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'right' }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\n end\n\n Popover(options: { placement: 'right-start' }) do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'right-start' }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\n end\n\n Popover(options: { placement: 'right-end' }) do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'right-end' }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\n end\n\n # -- LEFT --\n Popover(options: { placement: 'left' }) do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'left' }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\n end\n\n Popover(options: { placement: 'left-start' }) do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'left-start' }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\n end\n\n Popover(options: { placement: 'left-end' }) do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'left-end' }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\n end\n\n # -- BOTTOM --\n Popover(options: { placement: 'bottom' }) do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'bottom' }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\n end\n\n Popover(options: { placement: 'bottom-start' }) do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'bottom-start' }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\n end\n\n Popover(options: { placement: 'bottom-end' }) do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline, class: 'w-full justify-center') { 'bottom-end' }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "Trigger", + "code": "Popover(options: { trigger: 'click' }) do\n PopoverTrigger(class: 'w-full') do\n Button(variant: :outline) { \"Click\" }\n end\n PopoverContent(class: 'w-40') do\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Profile\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z\"\n )\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n )\n end\n plain \"Settings\"\n end\n Link(href: \"#\", variant: :ghost, class: 'w-full justify-start pl-2') do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n fill: \"none\",\n viewbox: \"0 0 24 24\",\n stroke_width: \"1.5\",\n stroke: \"currentColor\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n d:\n \"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9\"\n )\n end\n plain \"Logout\"\n end\n end\nend\n", + "language": "ruby" + } + ] }, "progress": { "name": "Progress", - "description": "frozen_string_literal: true", + "description": "Displays an indicator showing the completion progress of a task, typically displayed as a progress bar.", "files": [ { "path": "progress.rb", @@ -1169,12 +1765,23 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Progress", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Progress\n\nDisplays an indicator showing the completion progress of a task, typically displayed as a progress bar.\n\n### Example\n\n```ruby\nProgress(value: 50, class: \"w-[60%]\")\n```\n\n### With custom indicator color\n\n```ruby\nProgress(value: 35, class: \"w-[60%] [&>*]:bg-success\")\n```", + "examples": [ + { + "title": "Example", + "code": "Progress(value: 50, class: \"w-[60%]\")\n", + "language": "ruby" + }, + { + "title": "With custom indicator color", + "code": "Progress(value: 35, class: \"w-[60%] [&>*]:bg-success\")\n", + "language": "ruby" + } + ] }, "radio_button": { "name": "RadioButton", - "description": "frozen_string_literal: true", + "description": "A control that allows users to make a single selection from a list of options.", "files": [ { "path": "radio_button.rb", @@ -1187,12 +1794,33 @@ "gems": [] }, "install_command": "rails g ruby_ui:component RadioButton", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Radio Button\n\nA control that allows users to make a single selection from a list of options.\n\n## Usage\n\n### Example\n\n```ruby\ndiv(class: \"flex items-center space-x-2\") do\n RadioButton(id: \"default\")\n FormFieldLabel(for: \"default\") { \"Default\" }\nend\n```\n\n### Checked\n\n```ruby\ndiv(class: \"flex items-center space-x-2\") do\n RadioButton(id: \"checked\", checked: true)\n FormFieldLabel(for: \"checked\") { \"Checked\" }\nend\n```\n\n### Disabled\n\n```ruby\ndiv(class: \"flex flex-row items-center gap-2\") do\n RadioButton(class: \"peer\",id: \"disabled\", disabled: true)\n FormFieldLabel(for: \"disabled\") { \"Disabled\" }\nend\n```\n\n### Aria Disabled\n\n```ruby\ndiv(class: \"flex flex-row items-center gap-2\") do\n RadioButton(class: \"peer\", id: \"aria-disabled\", aria: {disabled: \"true\"})\n FormFieldLabel(for: \"aria-disabled\") { \"Aria Disabled\" }\nend\n```", + "examples": [ + { + "title": "Example", + "code": "div(class: \"flex items-center space-x-2\") do\n RadioButton(id: \"default\")\n FormFieldLabel(for: \"default\") { \"Default\" }\nend\n", + "language": "ruby" + }, + { + "title": "Checked", + "code": "div(class: \"flex items-center space-x-2\") do\n RadioButton(id: \"checked\", checked: true)\n FormFieldLabel(for: \"checked\") { \"Checked\" }\nend\n", + "language": "ruby" + }, + { + "title": "Disabled", + "code": "div(class: \"flex flex-row items-center gap-2\") do\n RadioButton(class: \"peer\",id: \"disabled\", disabled: true)\n FormFieldLabel(for: \"disabled\") { \"Disabled\" }\nend\n", + "language": "ruby" + }, + { + "title": "Aria Disabled", + "code": "div(class: \"flex flex-row items-center gap-2\") do\n RadioButton(class: \"peer\", id: \"aria-disabled\", aria: {disabled: \"true\"})\n FormFieldLabel(for: \"aria-disabled\") { \"Aria Disabled\" }\nend\n", + "language": "ruby" + } + ] }, "select": { "name": "Select", - "description": "frozen_string_literal: true", + "description": "Displays a list of options for the user to pick from—triggered by a button.", "files": [ { "path": "select.rb", @@ -1243,12 +1871,43 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Select", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Select\n\nDisplays a list of options for the user to pick from—triggered by a button.\n\n## Usage\n\n### Select (Deconstructed)\n\n```ruby\nSelect(class: \"w-56\") do\n SelectInput(value: \"apple\", id: \"select-a-fruit\")\n\n SelectTrigger do\n SelectValue(placeholder: \"Select a fruit\", id: \"select-a-fruit\") { \"Apple\" }\n end\n\n SelectContent(outlet_id: \"select-a-fruit\") do\n SelectGroup do\n SelectLabel { \"Fruits\" }\n SelectItem(value: \"apple\") { \"Apple\" }\n SelectItem(value: \"orange\") { \"Orange\" }\n SelectItem(value: \"banana\") { \"Banana\" }\n SelectItem(value: \"watermelon\") { \"Watermelon\" }\n end\n end\nend\n```\n\n### Pre-selected Item\n\n```ruby\nSelect(class: \"w-56\") do\n SelectInput(value: \"banana\", id: \"select-preselected-fruit\")\n\n SelectTrigger do\n SelectValue(placeholder: \"Select a fruit\", id: \"select-preselected-fruit\") { \"Banana\" }\n end\n\n SelectContent(outlet_id: \"select-preselected-fruit\") do\n SelectGroup do\n SelectLabel { \"Fruits\" }\n SelectItem(value: \"apple\") { \"Apple\" }\n SelectItem(value: \"orange\") { \"Orange\" }\n SelectItem(value: \"banana\") { \"Banana\" }\n SelectItem(value: \"watermelon\") { \"Watermelon\" }\n end\n end\nend\n```\n\n### Disabled\n\n```ruby\nSelect(class: \"w-56\") do\n SelectInput(value: \"apple\", id: \"select-a-fruit\")\n\n SelectTrigger(disabled: true) do\n SelectValue(placeholder: \"Select a fruit\", id: \"select-a-fruit\") { \"Apple\" }\n end\nend\n```\n\n### Data Disabled\n\n```ruby\nSelect(class: \"w-56\") do\n SelectInput(value: \"apple\", id: \"select-a-fruit\")\n\n SelectTrigger do\n SelectValue(placeholder: \"Select a fruit\", id: \"select-a-fruit\") { \"Apple\" }\n end\n\n SelectContent(outlet_id: \"select-a-fruit\") do\n SelectGroup do\n SelectLabel { \"Fruits\" }\n SelectItem(data: {disabled: true}, value: \"apple\") { \"Apple\" }\n SelectItem(value: \"orange\") { \"Orange\" }\n SelectItem(value: \"banana\") { \"Banana\" }\n SelectItem(data: {disabled: true}, value: \"watermelon\") { \"Watermelon\" }\n end\n end\nend\n```\n\n### Aria Disabled Trigger\n\n```ruby\nSelect(class: \"w-56\") do\n SelectInput(value: \"apple\", id: \"select-a-fruit\")\n\n SelectTrigger(aria: {disabled: \"true\"}) do\n SelectValue(placeholder: \"Select a fruit\", id: \"select-a-fruit\") { \"Apple\" }\n end\nend\n```\n\n### Aria Disabled Item\n\n```ruby\nSelect(class: \"w-56\") do\n SelectInput(value: \"apple\", id: \"select-a-fruit\")\n\n SelectTrigger do\n SelectValue(placeholder: \"Select a fruit\", id: \"select-a-fruit\") { \"Apple\" }\n end\n\n SelectContent(outlet_id: \"select-a-fruit\") do\n SelectGroup do\n SelectLabel { \"Fruits\" }\n SelectItem(aria: {disabled: \"true\"}, value: \"apple\") { \"Apple\" }\n SelectItem(value: \"orange\") { \"Orange\" }\n SelectItem(value: \"banana\") { \"Banana\" }\n SelectItem(aria: {disabled: \"true\"}, value: \"watermelon\") { \"Watermelon\" }\n end\n end\nend\n```", + "examples": [ + { + "title": "Select (Deconstructed)", + "code": "Select(class: \"w-56\") do\n SelectInput(value: \"apple\", id: \"select-a-fruit\")\n\n SelectTrigger do\n SelectValue(placeholder: \"Select a fruit\", id: \"select-a-fruit\") { \"Apple\" }\n end\n\n SelectContent(outlet_id: \"select-a-fruit\") do\n SelectGroup do\n SelectLabel { \"Fruits\" }\n SelectItem(value: \"apple\") { \"Apple\" }\n SelectItem(value: \"orange\") { \"Orange\" }\n SelectItem(value: \"banana\") { \"Banana\" }\n SelectItem(value: \"watermelon\") { \"Watermelon\" }\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "Pre-selected Item", + "code": "Select(class: \"w-56\") do\n SelectInput(value: \"banana\", id: \"select-preselected-fruit\")\n\n SelectTrigger do\n SelectValue(placeholder: \"Select a fruit\", id: \"select-preselected-fruit\") { \"Banana\" }\n end\n\n SelectContent(outlet_id: \"select-preselected-fruit\") do\n SelectGroup do\n SelectLabel { \"Fruits\" }\n SelectItem(value: \"apple\") { \"Apple\" }\n SelectItem(value: \"orange\") { \"Orange\" }\n SelectItem(value: \"banana\") { \"Banana\" }\n SelectItem(value: \"watermelon\") { \"Watermelon\" }\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "Disabled", + "code": "Select(class: \"w-56\") do\n SelectInput(value: \"apple\", id: \"select-a-fruit\")\n\n SelectTrigger(disabled: true) do\n SelectValue(placeholder: \"Select a fruit\", id: \"select-a-fruit\") { \"Apple\" }\n end\nend\n", + "language": "ruby" + }, + { + "title": "Data Disabled", + "code": "Select(class: \"w-56\") do\n SelectInput(value: \"apple\", id: \"select-a-fruit\")\n\n SelectTrigger do\n SelectValue(placeholder: \"Select a fruit\", id: \"select-a-fruit\") { \"Apple\" }\n end\n\n SelectContent(outlet_id: \"select-a-fruit\") do\n SelectGroup do\n SelectLabel { \"Fruits\" }\n SelectItem(data: {disabled: true}, value: \"apple\") { \"Apple\" }\n SelectItem(value: \"orange\") { \"Orange\" }\n SelectItem(value: \"banana\") { \"Banana\" }\n SelectItem(data: {disabled: true}, value: \"watermelon\") { \"Watermelon\" }\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "Aria Disabled Trigger", + "code": "Select(class: \"w-56\") do\n SelectInput(value: \"apple\", id: \"select-a-fruit\")\n\n SelectTrigger(aria: {disabled: \"true\"}) do\n SelectValue(placeholder: \"Select a fruit\", id: \"select-a-fruit\") { \"Apple\" }\n end\nend\n", + "language": "ruby" + }, + { + "title": "Aria Disabled Item", + "code": "Select(class: \"w-56\") do\n SelectInput(value: \"apple\", id: \"select-a-fruit\")\n\n SelectTrigger do\n SelectValue(placeholder: \"Select a fruit\", id: \"select-a-fruit\") { \"Apple\" }\n end\n\n SelectContent(outlet_id: \"select-a-fruit\") do\n SelectGroup do\n SelectLabel { \"Fruits\" }\n SelectItem(aria: {disabled: \"true\"}, value: \"apple\") { \"Apple\" }\n SelectItem(value: \"orange\") { \"Orange\" }\n SelectItem(value: \"banana\") { \"Banana\" }\n SelectItem(aria: {disabled: \"true\"}, value: \"watermelon\") { \"Watermelon\" }\n end\n end\nend\n", + "language": "ruby" + } + ] }, "separator": { "name": "Separator", - "description": "frozen_string_literal: true", + "description": "Visually or semantically separates content.", "files": [ { "path": "separator.rb", @@ -1261,12 +1920,18 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Separator", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Separator\n\nVisually or semantically separates content.\n\n## Usage\n\n### Example\n\n```ruby\ndiv do\n div(class: \"space-y-1\") do\n h4(class: \"text-sm font-medium leading-none\") { \"RubyUI\" }\n p(class: \"text-sm text-muted-foreground\") { \"An open-source UI component library.\" }\n end\n Separator(class: \"my-4\")\n div(class: \"flex h-5 items-center space-x-4 text-sm\") do\n div { \"Blog\" }\n Separator(as: :hr, orientation: :vertical)\n div { \"Docs\" }\n Separator(orientation: :vertical)\n div { \"Source\" }\n end\nend\n```", + "examples": [ + { + "title": "Example", + "code": "div do\n div(class: \"space-y-1\") do\n h4(class: \"text-sm font-medium leading-none\") { \"RubyUI\" }\n p(class: \"text-sm text-muted-foreground\") { \"An open-source UI component library.\" }\n end\n Separator(class: \"my-4\")\n div(class: \"flex h-5 items-center space-x-4 text-sm\") do\n div { \"Blog\" }\n Separator(as: :hr, orientation: :vertical)\n div { \"Docs\" }\n Separator(orientation: :vertical)\n div { \"Source\" }\n end\nend\n", + "language": "ruby" + } + ] }, "sheet": { "name": "Sheet", - "description": "frozen_string_literal: true", + "description": "Extends the Sheet component to display content that complements the main content of the screen.", "files": [ { "path": "sheet.rb", @@ -1315,12 +1980,23 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Sheet", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Sheet\n\nExtends the Sheet component to display content that complements the main content of the screen.\n\n## Usage\n\n### Example\n\n```ruby\nSheet do\n SheetTrigger do\n Button(variant: :outline) { \"Open Sheet\" }\n end\n SheetContent(class: 'sm:max-w-sm') do\n SheetHeader do\n SheetTitle { \"Edit profile\" }\n SheetDescription { \"Make changes to your profile here. Click save when you're done.\" }\n end\n\n SheetMiddle do\n label { \"Name\" }\n Input(placeholder: \"Joel Drapper\") { \"Joel Drapper\" }\n label { \"Email\" }\n Input(placeholder: \"joel@drapper.me\")\n end\n SheetFooter do\n Button(variant: :outline, data: { action: 'click->ruby-ui--sheet-content#close' }) { \"Cancel\" }\n Button(type: \"submit\") { \"Save\" }\n end\n end\nend\n```\n\n### Side\n\n```ruby\ndiv(class: 'grid grid-cols-2 gap-4') do\n # -- TOP --\n Sheet do\n SheetTrigger do\n Button(variant: :outline, class: 'w-full justify-center') { :top }\n end\n SheetContent(side: :top, class: (\"sm:max-w-sm\" if [:left, :right].include?(:top))) do\n SheetHeader do\n SheetTitle { \"Edit profile\" }\n SheetDescription { \"Make changes to your profile here. Click save when you're done.\" }\n end\n Form do\n SheetMiddle do\n label { \"Name\" }\n Input(placeholder: \"Joel Drapper\") { \"Joel Drapper\" }\n\n label { \"Email\" }\n Input(placeholder: \"joel@drapper.me\")\n end\n SheetFooter do\n Button(variant: :outline, data: { action: 'click->ruby-ui--sheet-content#close' }) { \"Cancel\" }\n Button(type: \"submit\") { \"Save\" }\n end\n end\n end\n end\nend\n```", + "examples": [ + { + "title": "Example", + "code": "Sheet do\n SheetTrigger do\n Button(variant: :outline) { \"Open Sheet\" }\n end\n SheetContent(class: 'sm:max-w-sm') do\n SheetHeader do\n SheetTitle { \"Edit profile\" }\n SheetDescription { \"Make changes to your profile here. Click save when you're done.\" }\n end\n\n SheetMiddle do\n label { \"Name\" }\n Input(placeholder: \"Joel Drapper\") { \"Joel Drapper\" }\n label { \"Email\" }\n Input(placeholder: \"joel@drapper.me\")\n end\n SheetFooter do\n Button(variant: :outline, data: { action: 'click->ruby-ui--sheet-content#close' }) { \"Cancel\" }\n Button(type: \"submit\") { \"Save\" }\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "Side", + "code": "div(class: 'grid grid-cols-2 gap-4') do\n # -- TOP --\n Sheet do\n SheetTrigger do\n Button(variant: :outline, class: 'w-full justify-center') { :top }\n end\n SheetContent(side: :top, class: (\"sm:max-w-sm\" if [:left, :right].include?(:top))) do\n SheetHeader do\n SheetTitle { \"Edit profile\" }\n SheetDescription { \"Make changes to your profile here. Click save when you're done.\" }\n end\n Form do\n SheetMiddle do\n label { \"Name\" }\n Input(placeholder: \"Joel Drapper\") { \"Joel Drapper\" }\n\n label { \"Email\" }\n Input(placeholder: \"joel@drapper.me\")\n end\n SheetFooter do\n Button(variant: :outline, data: { action: 'click->ruby-ui--sheet-content#close' }) { \"Cancel\" }\n Button(type: \"submit\") { \"Save\" }\n end\n end\n end\n end\nend\n", + "language": "ruby" + } + ] }, "shortcut_key": { "name": "ShortcutKey", - "description": "frozen_string_literal: true", + "description": "A component for displaying keyboard shortcuts.", "files": [ { "path": "shortcut_key.rb", @@ -1333,12 +2009,18 @@ "gems": [] }, "install_command": "rails g ruby_ui:component ShortcutKey", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Shortcut Key\n\nA component for displaying keyboard shortcuts.\n\n## Usage\n\n### Example\n\n```ruby\ndiv(class: \"flex flex-col items-center gap-y-4\") do\n ShortcutKey do\n span(class: \"text-xs\") { \"⌘\" }\n plain \"K\"\n end\n p(class: \"text-muted-foreground text-sm text-center\") { \"Note this does not trigger anything, it is purely a visual prompt\" }\nend\n```", + "examples": [ + { + "title": "Example", + "code": "div(class: \"flex flex-col items-center gap-y-4\") do\n ShortcutKey do\n span(class: \"text-xs\") { \"⌘\" }\n plain \"K\"\n end\n p(class: \"text-muted-foreground text-sm text-center\") { \"Note this does not trigger anything, it is purely a visual prompt\" }\nend\n", + "language": "ruby" + } + ] }, "sidebar": { "name": "Sidebar", - "description": "frozen_string_literal: true", + "description": "A composable, themeable and customizable sidebar component.", "files": [ { "path": "collapsible_sidebar.rb", @@ -1455,12 +2137,18 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Sidebar", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Sidebar\n\nA composable, themeable and customizable sidebar component.\n\n## Usage\n\n### Dialog variant\n\n```ruby\nDialog(data: {action: \"ruby-ui--dialog:connect->ruby-ui--dialog#open\"}) do\n DialogTrigger do\n Button { \"Open Dialog\" }\n end\n DialogContent(class: \"grid overflow-hidden p-0 md:max-h-[500px] md:max-w-[700px] lg:max-w-[800px]\") do\n SidebarWrapper(class: \"items-start\") do\n Sidebar(collapsible: :none, class: \"hidden md:flex\") do\n SidebarContent do\n SidebarGroup do\n SidebarGroupContent do\n SidebarMenu do\n SidebarMenuItem do\n SidebarMenuButton(as: :a, href: \"#\") do\n search_icon()\n span { \"Search\" }\n end\n end\n SidebarMenuItem do\n SidebarMenuButton(as: :a, href: \"#\", active: true) do\n home_icon()\n span { \"Home\" }\n end\n end\n SidebarMenuItem do\n SidebarMenuButton(as: :a, href: \"#\") do\n inbox_icon()\n span { \"Inbox\" }\n end\n end\n end\n end\n end\n end\n end\n main(class: \"flex h-[480px] flex-1 flex-col overflow-hidden\") do\n end\n end\n end\nend\n```", + "examples": [ + { + "title": "Dialog variant", + "code": "Dialog(data: {action: \"ruby-ui--dialog:connect->ruby-ui--dialog#open\"}) do\n DialogTrigger do\n Button { \"Open Dialog\" }\n end\n DialogContent(class: \"grid overflow-hidden p-0 md:max-h-[500px] md:max-w-[700px] lg:max-w-[800px]\") do\n SidebarWrapper(class: \"items-start\") do\n Sidebar(collapsible: :none, class: \"hidden md:flex\") do\n SidebarContent do\n SidebarGroup do\n SidebarGroupContent do\n SidebarMenu do\n SidebarMenuItem do\n SidebarMenuButton(as: :a, href: \"#\") do\n search_icon()\n span { \"Search\" }\n end\n end\n SidebarMenuItem do\n SidebarMenuButton(as: :a, href: \"#\", active: true) do\n home_icon()\n span { \"Home\" }\n end\n end\n SidebarMenuItem do\n SidebarMenuButton(as: :a, href: \"#\") do\n inbox_icon()\n span { \"Inbox\" }\n end\n end\n end\n end\n end\n end\n end\n main(class: \"flex h-[480px] flex-1 flex-col overflow-hidden\") do\n end\n end\n end\nend\n", + "language": "ruby" + } + ] }, "skeleton": { "name": "Skeleton", - "description": "frozen_string_literal: true", + "description": "Use to show a placeholder while content is loading.", "files": [ { "path": "skeleton.rb", @@ -1473,12 +2161,18 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Skeleton", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Skeleton\n\nUse to show a placeholder while content is loading.\n\n## Usage\n\n### Example\n\n```ruby\ndiv(class: \"flex items-center space-x-4\") do\n Skeleton(class: \"h-12 w-12 rounded-full\")\n div(class: \"space-y-2\") do\n Skeleton(class: \"h-4 w-[250px]\")\n Skeleton(class: \"h-4 w-[200px]\")\n end\nend\n```", + "examples": [ + { + "title": "Example", + "code": "div(class: \"flex items-center space-x-4\") do\n Skeleton(class: \"h-12 w-12 rounded-full\")\n div(class: \"space-y-2\") do\n Skeleton(class: \"h-4 w-[250px]\")\n Skeleton(class: \"h-4 w-[200px]\")\n end\nend\n", + "language": "ruby" + } + ] }, "switch": { "name": "Switch", - "description": "frozen_string_literal: true", + "description": "A control that allows the user to toggle between checked and not checked.", "files": [ { "path": "switch.rb", @@ -1491,12 +2185,38 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Switch", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Switch\n\nA control that allows the user to toggle between checked and not checked.\n\n## Usage\n\n### Default\n\n```ruby\nSwitch(name: \"switch\")\n```\n\n### Checked\n\n```ruby\nSwitch(name: \"switch\", checked: true)\n```\n\n### Disabled\n\n```ruby\nSwitch(name: \"switch\", disabled: true)\n```\n\n### Aria Disabled\n\n```ruby\nSwitch(name: \"switch\", aria: {disabled: \"true\"})\n```\n\n### With flag include_hidden false\n\n```ruby\n# Supports the creation of a hidden input to be used in forms inspired by the Ruby on Rails implementation of check_box. Default is true.\nSwitch(name: \"switch\", include_hidden: false)\n```", + "examples": [ + { + "title": "Default", + "code": "Switch(name: \"switch\")\n", + "language": "ruby" + }, + { + "title": "Checked", + "code": "Switch(name: \"switch\", checked: true)\n", + "language": "ruby" + }, + { + "title": "Disabled", + "code": "Switch(name: \"switch\", disabled: true)\n", + "language": "ruby" + }, + { + "title": "Aria Disabled", + "code": "Switch(name: \"switch\", aria: {disabled: \"true\"})\n", + "language": "ruby" + }, + { + "title": "With flag include_hidden false", + "code": "# Supports the creation of a hidden input to be used in forms inspired by the Ruby on Rails implementation of check_box. Default is true.\nSwitch(name: \"switch\", include_hidden: false)\n", + "language": "ruby" + } + ] }, "table": { "name": "Table", - "description": "frozen_string_literal: true", + "description": "A responsive table component.", "files": [ { "path": "table.rb", @@ -1537,12 +2257,18 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Table", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Table\n\nA responsive table component.\n\n## Usage\n\n### Without builder\n\n```ruby\nTable do\n TableCaption { \"Employees at Acme inc.\" }\n TableHeader do\n TableRow do\n TableHead { \"Name\" }\n TableHead { \"Email\" }\n TableHead { \"Status\" }\n TableHead(class: \"text-right\") { \"Role\" }\n end\n end\n TableBody do\n invoices.each do |invoice|\n TableRow do\n TableCell(class: 'font-medium') { invoice.identifier }\n TableCell { render_status_badge(invoice.status) }\n TableCell { invoice.method }\n TableCell(class: \"text-right\") { format_amount(invoice.amount) }\n end\n end\n end\n TableFooter do\n TableRow do\n TableHead(class: \"font-medium\", colspan: 3) { \"Total\" }\n TableHead(class: \"font-medium text-right\") { format_amount(invoices.sum(&:amount)) }\n end\n end\nend\n```", + "examples": [ + { + "title": "Without builder", + "code": "Table do\n TableCaption { \"Employees at Acme inc.\" }\n TableHeader do\n TableRow do\n TableHead { \"Name\" }\n TableHead { \"Email\" }\n TableHead { \"Status\" }\n TableHead(class: \"text-right\") { \"Role\" }\n end\n end\n TableBody do\n invoices.each do |invoice|\n TableRow do\n TableCell(class: 'font-medium') { invoice.identifier }\n TableCell { render_status_badge(invoice.status) }\n TableCell { invoice.method }\n TableCell(class: \"text-right\") { format_amount(invoice.amount) }\n end\n end\n end\n TableFooter do\n TableRow do\n TableHead(class: \"font-medium\", colspan: 3) { \"Total\" }\n TableHead(class: \"font-medium text-right\") { format_amount(invoices.sum(&:amount)) }\n end\n end\nend\n", + "language": "ruby" + } + ] }, "tabs": { "name": "Tabs", - "description": "frozen_string_literal: true", + "description": "A set of layered sections of content—known as tab panels—that are displayed one at a time.", "files": [ { "path": "tabs.rb", @@ -1571,12 +2297,38 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Tabs", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Tabs\n\nA set of layered sections of content—known as tab panels—that are displayed one at a time.\n\n## Usage\n\n### Example\n\n```ruby\nTabs(default_value: \"account\", class: 'w-96') do\n TabsList do\n TabsTrigger(value: \"account\") { \"Account\" }\n TabsTrigger(value: \"password\") { \"Password\" }\n end\n TabsContent(value: \"account\") do\n div(class: \"rounded-lg border p-6 space-y-4 bg-background text-foreground\") do\n div(class: \"space-y-0\") do\n Text(size: \"4\", weight: \"semibold\") { \"Account\" }\n Text(size: \"2\", class: \"text-muted-foreground\") { \"Update your account details.\" }\n end\n end\n end\n TabsContent(value: \"password\") do\n div(class: \"rounded-lg border p-6 space-y-4 bg-background text-foreground\") do\n div do\n Text(size: \"4\", weight: \"semibold\") { \"Password\" }\n Text(size: \"2\", class: \"text-muted-foreground\") { \"Change your password here. After saving, you'll be logged out.\" }\n end\n end\n end\nend\n```\n\n### Disabled\n\n```ruby\nTabs(default_value: \"account\", class: 'w-96') do\n TabsList do\n TabsTrigger(disabled: true, value: \"account\") { \"Account\" }\n TabsTrigger(disabled: true, value: \"password\") { \"Password\" }\n end\nend\n```\n\n### Aria Disabled\n\n```ruby\nTabs(default_value: \"account\", class: 'w-96') do\n TabsList do\n TabsTrigger(aria: {disabled: \"true\"}, value: \"account\") { \"Account\" }\n TabsTrigger(aria: {disabled: \"true\"}, value: \"password\") { \"Password\" }\n end\nend\n```\n\n### Full width\n\n```ruby\nTabs(default_value: \"overview\", class: 'w-96') do\n TabsList(class: 'w-full grid grid-cols-2') do\n TabsTrigger(value: \"overview\") do\n book_icon\n span(class: 'ml-2') { \"Overview\" }\n end\n TabsTrigger(value: \"repositories\") do\n repo_icon\n span(class: 'ml-2') { \"Repositories\" }\n end\n end\n TabsContent(value: \"overview\") do\n div(class: \"rounded-lg border p-6 bg-background text-foreground flex justify-between space-x-4\") do\n Avatar do\n AvatarImage(src: \"https://avatars.githubusercontent.com/u/246692?v=4\", alt: \"joeldrapper\")\n AvatarFallback { \"JD\" }\n end\n div(class: \"space-y-4\") do\n div do\n Text(size: \"4\", weight: \"semibold\") { \"Joel Drapper\" }\n Text(size: \"2\", class: \"text-muted-foreground\") { \"Creator of Phlex Components. Ruby on Rails developer.\" }\n end\n Link(href: \"https://github.com/joeldrapper\", variant: :outline, size: :sm) do\n github_icon\n span(class: 'ml-2') { \"View profile\" }\n end\n end\n end\n end\n TabsContent(value: \"repositories\") do\n div(class: \"rounded-lg border p-6 space-y-4 bg-background text-foreground\") do\n repo = repositories.first\n Link(href: repo.github_url, variant: :link, class: \"pl-0\") { repo.name }\n Badge { repo.version }\n end\n end\nend\n```\n\n### Change default value\n\n```ruby\nTabs(default: \"password\", class: 'w-96') do\n TabsList do\n TabsTrigger(value: \"account\") { \"Account\" }\n TabsTrigger(value: \"password\") { \"Password\" }\n end\n TabsContent(value: \"account\") do\n div(class: \"rounded-lg border p-6 space-y-4 bg-background text-foreground\") do\n div(class: \"space-y-0\") do\n Text(size: \"4\", weight: \"semibold\") { \"Account\" }\n Text(size: \"2\", class: \"text-muted-foreground\") { \"Update your account details.\" }\n end\n end\n end\n TabsContent(value: \"password\") do\n div(class: \"rounded-lg border p-6 space-y-4 bg-background text-foreground\") do\n div do\n Text(size: \"4\", weight: \"semibold\") { \"Password\" }\n Text(size: \"2\", class: \"text-muted-foreground\") { \"Change your password here. After saving, you'll be logged out.\" }\n end\n end\n end\nend\n```", + "examples": [ + { + "title": "Example", + "code": "Tabs(default_value: \"account\", class: 'w-96') do\n TabsList do\n TabsTrigger(value: \"account\") { \"Account\" }\n TabsTrigger(value: \"password\") { \"Password\" }\n end\n TabsContent(value: \"account\") do\n div(class: \"rounded-lg border p-6 space-y-4 bg-background text-foreground\") do\n div(class: \"space-y-0\") do\n Text(size: \"4\", weight: \"semibold\") { \"Account\" }\n Text(size: \"2\", class: \"text-muted-foreground\") { \"Update your account details.\" }\n end\n end\n end\n TabsContent(value: \"password\") do\n div(class: \"rounded-lg border p-6 space-y-4 bg-background text-foreground\") do\n div do\n Text(size: \"4\", weight: \"semibold\") { \"Password\" }\n Text(size: \"2\", class: \"text-muted-foreground\") { \"Change your password here. After saving, you'll be logged out.\" }\n end\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "Disabled", + "code": "Tabs(default_value: \"account\", class: 'w-96') do\n TabsList do\n TabsTrigger(disabled: true, value: \"account\") { \"Account\" }\n TabsTrigger(disabled: true, value: \"password\") { \"Password\" }\n end\nend\n", + "language": "ruby" + }, + { + "title": "Aria Disabled", + "code": "Tabs(default_value: \"account\", class: 'w-96') do\n TabsList do\n TabsTrigger(aria: {disabled: \"true\"}, value: \"account\") { \"Account\" }\n TabsTrigger(aria: {disabled: \"true\"}, value: \"password\") { \"Password\" }\n end\nend\n", + "language": "ruby" + }, + { + "title": "Full width", + "code": "Tabs(default_value: \"overview\", class: 'w-96') do\n TabsList(class: 'w-full grid grid-cols-2') do\n TabsTrigger(value: \"overview\") do\n book_icon\n span(class: 'ml-2') { \"Overview\" }\n end\n TabsTrigger(value: \"repositories\") do\n repo_icon\n span(class: 'ml-2') { \"Repositories\" }\n end\n end\n TabsContent(value: \"overview\") do\n div(class: \"rounded-lg border p-6 bg-background text-foreground flex justify-between space-x-4\") do\n Avatar do\n AvatarImage(src: \"https://avatars.githubusercontent.com/u/246692?v=4\", alt: \"joeldrapper\")\n AvatarFallback { \"JD\" }\n end\n div(class: \"space-y-4\") do\n div do\n Text(size: \"4\", weight: \"semibold\") { \"Joel Drapper\" }\n Text(size: \"2\", class: \"text-muted-foreground\") { \"Creator of Phlex Components. Ruby on Rails developer.\" }\n end\n Link(href: \"https://github.com/joeldrapper\", variant: :outline, size: :sm) do\n github_icon\n span(class: 'ml-2') { \"View profile\" }\n end\n end\n end\n end\n TabsContent(value: \"repositories\") do\n div(class: \"rounded-lg border p-6 space-y-4 bg-background text-foreground\") do\n repo = repositories.first\n Link(href: repo.github_url, variant: :link, class: \"pl-0\") { repo.name }\n Badge { repo.version }\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "Change default value", + "code": "Tabs(default: \"password\", class: 'w-96') do\n TabsList do\n TabsTrigger(value: \"account\") { \"Account\" }\n TabsTrigger(value: \"password\") { \"Password\" }\n end\n TabsContent(value: \"account\") do\n div(class: \"rounded-lg border p-6 space-y-4 bg-background text-foreground\") do\n div(class: \"space-y-0\") do\n Text(size: \"4\", weight: \"semibold\") { \"Account\" }\n Text(size: \"2\", class: \"text-muted-foreground\") { \"Update your account details.\" }\n end\n end\n end\n TabsContent(value: \"password\") do\n div(class: \"rounded-lg border p-6 space-y-4 bg-background text-foreground\") do\n div do\n Text(size: \"4\", weight: \"semibold\") { \"Password\" }\n Text(size: \"2\", class: \"text-muted-foreground\") { \"Change your password here. After saving, you'll be logged out.\" }\n end\n end\n end\nend\n", + "language": "ruby" + } + ] }, "textarea": { "name": "Textarea", - "description": "frozen_string_literal: true", + "description": "Displays a textarea field.", "files": [ { "path": "textarea.rb", @@ -1589,12 +2341,33 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Textarea", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Textarea\n\nDisplays a textarea field.\n\n## Usage\n\n### Textarea\n\n```ruby\ndiv(class: \"grid w-full max-w-sm items-center gap-1.5\") do\n Textarea(placeholder: \"Textarea\")\nend\n```\n\n### Disabled\n\n```ruby\ndiv(class: \"grid w-full max-w-sm items-center gap-1.5\") do\n Textarea(disabled: true, placeholder: \"Disabled\")\nend\n```\n\n### Aria Disabled\n\n```ruby\ndiv(class: \"grid w-full max-w-sm items-center gap-1.5\") do\n Textarea(aria: {disabled: \"true\"}, placeholder: \"Aria Disabled\")\nend\n```\n\n### With FormField\n\n```ruby\ndiv(class: \"grid w-full max-w-sm items-center gap-1.5\") do\n FormField do\n FormFieldLabel(for: \"textarea\") { \"Textarea\" }\n FormFieldHint { \"This is a textarea\" }\n Textarea(placeholder: \"Textarea\", id: \"textarea\")\n FormFieldError()\n end\nend\n```", + "examples": [ + { + "title": "Textarea", + "code": "div(class: \"grid w-full max-w-sm items-center gap-1.5\") do\n Textarea(placeholder: \"Textarea\")\nend\n", + "language": "ruby" + }, + { + "title": "Disabled", + "code": "div(class: \"grid w-full max-w-sm items-center gap-1.5\") do\n Textarea(disabled: true, placeholder: \"Disabled\")\nend\n", + "language": "ruby" + }, + { + "title": "Aria Disabled", + "code": "div(class: \"grid w-full max-w-sm items-center gap-1.5\") do\n Textarea(aria: {disabled: \"true\"}, placeholder: \"Aria Disabled\")\nend\n", + "language": "ruby" + }, + { + "title": "With FormField", + "code": "div(class: \"grid w-full max-w-sm items-center gap-1.5\") do\n FormField do\n FormFieldLabel(for: \"textarea\") { \"Textarea\" }\n FormFieldHint { \"This is a textarea\" }\n Textarea(placeholder: \"Textarea\", id: \"textarea\")\n FormFieldError()\n end\nend\n", + "language": "ruby" + } + ] }, "theme_toggle": { "name": "ThemeToggle", - "description": "frozen_string_literal: true", + "description": "Toggle between dark/light theme.", "files": [ { "path": "set_dark_mode.rb", @@ -1619,12 +2392,23 @@ "gems": [] }, "install_command": "rails g ruby_ui:component ThemeToggle", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Theme Toggle\n\nToggle between dark/light theme.\n\n## Usage\n\n### With icon\n\n```ruby\nThemeToggle do |toggle|\n SetLightMode do\n Button(variant: :ghost, icon: true) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n viewbox: \"0 0 24 24\",\n fill: \"currentColor\",\n class: \"w-4 h-4\"\n ) do |s|\n s.path(\n d:\n \"M12 2.25a.75.75 0 01.75.75v2.25a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.591 1.59a.75.75 0 101.06 1.061l1.591-1.59zM21.75 12a.75.75 0 01-.75.75h-2.25a.75.75 0 010-1.5H21a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 10-1.061 1.06l1.59 1.591zM12 18a.75.75 0 01.75.75V21a.75.75 0 01-1.5 0v-2.25A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.591 1.59a.75.75 0 001.06 1.061l1.591-1.59zM6 12a.75.75 0 01-.75.75H3a.75.75 0 010-1.5h2.25A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 00-1.061 1.06l1.59 1.591z\"\n )\n end\n end\n end\n\n SetDarkMode do\n Button(variant: :ghost, icon: true) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n viewbox: \"0 0 24 24\",\n fill: \"currentColor\",\n class: \"w-4 h-4\"\n ) do |s|\n s.path(\n fill_rule: \"evenodd\",\n d:\n \"M9.528 1.718a.75.75 0 01.162.819A8.97 8.97 0 009 6a9 9 0 009 9 8.97 8.97 0 003.463-.69.75.75 0 01.981.98 10.503 10.503 0 01-9.694 6.46c-5.799 0-10.5-4.701-10.5-10.5 0-4.368 2.667-8.112 6.46-9.694a.75.75 0 01.818.162z\",\n clip_rule: \"evenodd\"\n )\n end\n end\n end\nend\n```\n\n### With text\n\n```ruby\nThemeToggle do |toggle|\n SetLightMode do\n Button(variant: :primary) { \"Light\" }\n end\n\n SetDarkMode do\n Button(variant: :primary) { \"Dark\" }\n end\nend\n```", + "examples": [ + { + "title": "With icon", + "code": "ThemeToggle do |toggle|\n SetLightMode do\n Button(variant: :ghost, icon: true) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n viewbox: \"0 0 24 24\",\n fill: \"currentColor\",\n class: \"w-4 h-4\"\n ) do |s|\n s.path(\n d:\n \"M12 2.25a.75.75 0 01.75.75v2.25a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.591 1.59a.75.75 0 101.06 1.061l1.591-1.59zM21.75 12a.75.75 0 01-.75.75h-2.25a.75.75 0 010-1.5H21a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 10-1.061 1.06l1.59 1.591zM12 18a.75.75 0 01.75.75V21a.75.75 0 01-1.5 0v-2.25A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.591 1.59a.75.75 0 001.06 1.061l1.591-1.59zM6 12a.75.75 0 01-.75.75H3a.75.75 0 010-1.5h2.25A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 00-1.061 1.06l1.59 1.591z\"\n )\n end\n end\n end\n\n SetDarkMode do\n Button(variant: :ghost, icon: true) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n viewbox: \"0 0 24 24\",\n fill: \"currentColor\",\n class: \"w-4 h-4\"\n ) do |s|\n s.path(\n fill_rule: \"evenodd\",\n d:\n \"M9.528 1.718a.75.75 0 01.162.819A8.97 8.97 0 009 6a9 9 0 009 9 8.97 8.97 0 003.463-.69.75.75 0 01.981.98 10.503 10.503 0 01-9.694 6.46c-5.799 0-10.5-4.701-10.5-10.5 0-4.368 2.667-8.112 6.46-9.694a.75.75 0 01.818.162z\",\n clip_rule: \"evenodd\"\n )\n end\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "With text", + "code": "ThemeToggle do |toggle|\n SetLightMode do\n Button(variant: :primary) { \"Light\" }\n end\n\n SetDarkMode do\n Button(variant: :primary) { \"Dark\" }\n end\nend\n", + "language": "ruby" + } + ] }, "tooltip": { "name": "Tooltip", - "description": "frozen_string_literal: true", + "description": "A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it.", "files": [ { "path": "tooltip.rb", @@ -1653,12 +2437,23 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Tooltip", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Tooltip\n\nA popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it.\n\n## Usage\n\n### Example\n\n```ruby\nTooltip do\n TooltipTrigger do\n Button(variant: :outline, icon: true) do\n bookmark_icon\n end\n end\n TooltipContent do\n Text { \"Add to library\" }\n end\nend\n```\n\n### Long content\n\n```ruby\nTooltip do\n TooltipTrigger do\n Button(variant: :outline) { \"Hover me\" }\n end\n TooltipContent do\n Text { \"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\" }\n end\nend\n```", + "examples": [ + { + "title": "Example", + "code": "Tooltip do\n TooltipTrigger do\n Button(variant: :outline, icon: true) do\n bookmark_icon\n end\n end\n TooltipContent do\n Text { \"Add to library\" }\n end\nend\n", + "language": "ruby" + }, + { + "title": "Long content", + "code": "Tooltip do\n TooltipTrigger do\n Button(variant: :outline) { \"Hover me\" }\n end\n TooltipContent do\n Text { \"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\" }\n end\nend\n", + "language": "ruby" + } + ] }, "typography": { "name": "Typography", - "description": "frozen_string_literal: true", + "description": "Sensible defaults to use for text.", "files": [ { "path": "heading.rb", @@ -1687,8 +2482,74 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Typography", - "docs_markdown": "", - "examples": [] + "docs_markdown": "# Typography\n\nSensible defaults to use for text.\n\n## Usage\n\n### h1\n\n```ruby\nHeading(level: 1) { \"This is an H1 title\" }\n```\n\n### h2\n\n```ruby\nHeading(level: 2) { \"This is an H2 title\" }\n```\n\n### h3\n\n```ruby\nHeading(level: 3) { \"This is an H3 title\" }\n```\n\n### h4\n\n```ruby\nHeading(level: 4) { \"This is an H4 title\" }\n```\n\n### p\n\n```ruby\nText { \"This is an P tag\" }\n```\n\n### Inline Link\n\n```ruby\nText(class: 'text-center') do\n plain \"Checkout our \"\n InlineLink(href: docs_installation_path) { \"installation instructions\" }\n plain \" to get started.\"\nend\n```\n\n### List\n\n```ruby\nComponents.TypographyList(items: [\n 'Phlex is fast',\n 'Phlex is easy to use',\n 'Phlex is awesome',\n ])\n```\n\n### Numbered List\n\n```ruby\nComponents.TypographyList(items: [\n 'Copy',\n 'Paste',\n 'Customize',\n ], numbered: true)\n```\n\n### Inline Code\n\n```ruby\nInlineCode { \"This is an inline code block\" }\n```\n\n### Lead\n\n```ruby\nText(as: \"p\", size: \"5\", weight: \"muted\") { \"A modal dialog that interrupts the user with important content and expects a response.\" }\n```\n\n### Large\n\n```ruby\nText(size: \"4\", weight: \"semibold\") { \"Are you sure absolutely sure?\" }\n```\n\n### Small\n\n```ruby\nText(size: \"sm\") { \"Email address\" }\n```\n\n### Muted\n\n```ruby\nText(size: \"2\", class: \"text-muted-foreground\") { \"Enter your email address.\" }\n```", + "examples": [ + { + "title": "h1", + "code": "Heading(level: 1) { \"This is an H1 title\" }\n", + "language": "ruby" + }, + { + "title": "h2", + "code": "Heading(level: 2) { \"This is an H2 title\" }\n", + "language": "ruby" + }, + { + "title": "h3", + "code": "Heading(level: 3) { \"This is an H3 title\" }\n", + "language": "ruby" + }, + { + "title": "h4", + "code": "Heading(level: 4) { \"This is an H4 title\" }\n", + "language": "ruby" + }, + { + "title": "p", + "code": "Text { \"This is an P tag\" }\n", + "language": "ruby" + }, + { + "title": "Inline Link", + "code": "Text(class: 'text-center') do\n plain \"Checkout our \"\n InlineLink(href: docs_installation_path) { \"installation instructions\" }\n plain \" to get started.\"\nend\n", + "language": "ruby" + }, + { + "title": "List", + "code": "Components.TypographyList(items: [\n 'Phlex is fast',\n 'Phlex is easy to use',\n 'Phlex is awesome',\n ])\n", + "language": "ruby" + }, + { + "title": "Numbered List", + "code": "Components.TypographyList(items: [\n 'Copy',\n 'Paste',\n 'Customize',\n ], numbered: true)\n", + "language": "ruby" + }, + { + "title": "Inline Code", + "code": "InlineCode { \"This is an inline code block\" }\n", + "language": "ruby" + }, + { + "title": "Lead", + "code": "Text(as: \"p\", size: \"5\", weight: \"muted\") { \"A modal dialog that interrupts the user with important content and expects a response.\" }\n", + "language": "ruby" + }, + { + "title": "Large", + "code": "Text(size: \"4\", weight: \"semibold\") { \"Are you sure absolutely sure?\" }\n", + "language": "ruby" + }, + { + "title": "Small", + "code": "Text(size: \"sm\") { \"Email address\" }\n", + "language": "ruby" + }, + { + "title": "Muted", + "code": "Text(size: \"2\", class: \"text-muted-foreground\") { \"Enter your email address.\" }\n", + "language": "ruby" + } + ] } } } diff --git a/mcp/lib/ruby_ui/mcp/builders/registry_builder.rb b/mcp/lib/ruby_ui/mcp/builders/registry_builder.rb index 16187a2d..521e0fb5 100644 --- a/mcp/lib/ruby_ui/mcp/builders/registry_builder.rb +++ b/mcp/lib/ruby_ui/mcp/builders/registry_builder.rb @@ -70,10 +70,13 @@ def build_component(slug, dep_entry) .sort .map { |f| {path: File.basename(f), content: File.read(f)} } name = camelize(slug) - docs_md = render_docs_markdown(dir, slug) + docs_src = read_docs_source(dir, slug) + parsed = parse_docs_source(docs_src) + docs_md = parsed[:markdown] + examples = parsed[:examples] { name: name, - description: extract_description(files, docs_md), + description: extract_description(files, docs_src, docs_md), files: files, dependencies: { components: Array(dep_entry["components"]), @@ -82,7 +85,7 @@ def build_component(slug, dep_entry) }, install_command: "rails g ruby_ui:component #{name}", docs_markdown: docs_md, - examples: extract_examples(docs_md) + examples: examples } end @@ -90,16 +93,131 @@ def camelize(slug) slug.split("_").map(&:capitalize).join end - def render_docs_markdown(dir, slug) + def read_docs_source(dir, slug) docs_file = File.join(dir, "#{slug}_docs.rb") - return "" unless File.exist?(docs_file) - src = File.read(docs_file) - headings = src.scan(/h1\s*\{\s*"([^"]+)"\s*\}/).flatten.map { |t| "# #{t}" } - paras = src.scan(/p\s*\{\s*"([^"]+)"\s*\}/).flatten - (headings + paras).join("\n\n") + File.exist?(docs_file) ? File.read(docs_file) : "" end - def extract_description(files, docs_md) + # Single-pass parser: returns {markdown: String, examples: Array} + def parse_docs_source(src) + return {markdown: "", examples: []} if src.empty? + + lines = src.lines + markdown = +"" + examples = [] + i = 0 + + while i < lines.length + line = lines[i] + + # Docs::Header.new(title: "X", description: "Y") + if (m = line.match(/Docs::Header\.new\(([^)]*)\)/)) + args = m[1] + title = extract_kwarg(args, "title") + desc = extract_kwarg(args, "description") + markdown << "# #{title}\n\n" if title + markdown << "#{desc}\n\n" if desc + i += 1 + next + end + + # Heading(level: N) { "X" } + if (m = line.match(/Heading\(level:\s*(\d+)\)\s*\{\s*"([^"]+)"\s*\}/)) + level = [m[1].to_i, 6].min + markdown << "#{"#" * level} #{m[2]}\n\n" + i += 1 + next + end + + # P { "X" } or p { "X" } (standalone paragraph) + if (m = line.match(/\bP\s*\{\s*"([^"]+)"\s*\}/) || line.match(/\bp\s*\{\s*"([^"]+)"\s*\}/)) + markdown << "#{m[1]}\n\n" + i += 1 + next + end + + # VisualCodeExample.new(...) + if line.match(/VisualCodeExample\.new\(/) + # collect full invocation (may span multiple lines until closing paren + do) + invocation = +line + j = i + while j < lines.length && !invocation.match(/\bdo\b/) + j += 1 + break if j >= lines.length + invocation << lines[j] + end + + title = extract_kwarg(invocation, "title") + + # find heredoc opener on following lines + k = j + 1 + heredoc_lang = nil + while k < lines.length + if (hm = lines[k].match(/<<~([A-Z]+)/)) + heredoc_lang = hm[1] + k += 1 + break + end + # stop searching if we hit end / another render call + break if lines[k].match(/^\s*end\b/) && !lines[k].match(/<<~/) + k += 1 + end + + if heredoc_lang + # accumulate heredoc body + body_lines = [] + while k < lines.length + break if lines[k].match(/^\s*#{Regexp.escape(heredoc_lang)}\s*$/) + body_lines << lines[k] + k += 1 + end + # strip common leading whitespace (squiggly heredoc) + code = dedent(body_lines) + lang = heredoc_lang.downcase == "ruby" ? "ruby" : heredoc_lang.downcase + + examples << {title: title, code: code, language: lang} + + if title + markdown << "### #{title}\n\n" + end + markdown << "```#{lang}\n#{code}```\n\n" + + i = k + 1 + next + end + + i = j + 1 + next + end + + i += 1 + end + + {markdown: markdown.strip, examples: examples} + end + + def extract_kwarg(str, key) + # handles: key: "value" or key: 'value' + if (m = str.match(/#{Regexp.escape(key)}:\s*["']([^"']+)["']/)) + m[1] + end + end + + def dedent(lines) + return "" if lines.empty? + # find minimum indentation (ignore blank lines) + non_blank = lines.reject { |l| l.strip.empty? } + return lines.join if non_blank.empty? + min_indent = non_blank.map { |l| l.match(/^(\s*)/)[1].length }.min + lines.map { |l| l.length >= min_indent ? l[min_indent..] : l }.join + end + + def extract_description(files, docs_src, docs_md) + # prefer description from Docs::Header + if (m = docs_src.match(/Docs::Header\.new\(([^)]*)\)/m)) + desc = extract_kwarg(m[1], "description") + return desc if desc + end if (m = docs_md.match(/^# .+?\n+([^\n#].+)/m)) m[1].strip elsif files.first && (m = files.first[:content].match(/^#\s*(?:RubyUI::\w+\s*[—-]\s*)?(.+)$/)) @@ -108,10 +226,6 @@ def extract_description(files, docs_md) "" end end - - def extract_examples(_docs_md) - [] # phase 1: empty; populated later via VisualCodeExample parser - end end end end diff --git a/mcp/test/builders/registry_builder_test.rb b/mcp/test/builders/registry_builder_test.rb index ac6b5451..f3663022 100644 --- a/mcp/test/builders/registry_builder_test.rb +++ b/mcp/test/builders/registry_builder_test.rb @@ -14,4 +14,26 @@ def test_builds_registry_from_fake_gem assert button[:files].any? { |f| f[:path] == "button.rb" } assert_equal "rails g ruby_ui:component Button", button[:install_command] end + + def test_extracts_examples_from_visual_code_example_blocks + fixture = File.expand_path("../fixtures/fake_gem", __dir__) + registry = RubyUI::MCP::Builders::RegistryBuilder.new(gem_path: fixture).build + button = registry[:components][:button] + + assert_equal 2, button[:examples].length + assert_equal "Example", button[:examples][0][:title] + assert_match(/Button \{ "Button" \}/, button[:examples][0][:code]) + assert_equal "ruby", button[:examples][0][:language] + end + + def test_docs_markdown_contains_header_and_examples + fixture = File.expand_path("../fixtures/fake_gem", __dir__) + registry = RubyUI::MCP::Builders::RegistryBuilder.new(gem_path: fixture).build + md = registry[:components][:button][:docs_markdown] + + assert_match(/# Button/, md) + assert_match(/A clickable button\./, md) + assert_match(/### Example/, md) + assert_match(/```ruby/, md) + end end diff --git a/mcp/test/fixtures/fake_gem/lib/ruby_ui/button/button_docs.rb b/mcp/test/fixtures/fake_gem/lib/ruby_ui/button/button_docs.rb index d7928b82..b4892f9b 100644 --- a/mcp/test/fixtures/fake_gem/lib/ruby_ui/button/button_docs.rb +++ b/mcp/test/fixtures/fake_gem/lib/ruby_ui/button/button_docs.rb @@ -1,6 +1,19 @@ class Views::Docs::Button def view_template - h1 { "Button" } - p { "A clickable button." } + render Docs::Header.new(title: "Button", description: "A clickable button.") + + Heading(level: 2) { "Usage" } + + render Docs::VisualCodeExample.new(title: "Example", context: self) do + <<~RUBY + Button { "Button" } + RUBY + end + + render Docs::VisualCodeExample.new(title: "Primary", context: self) do + <<~RUBY + Button(variant: :primary) { "Primary" } + RUBY + end end end From b2df2cdddc5ef6d14193cb9ec438e16769690be2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 16:17:24 -0300 Subject: [PATCH 18/19] [Feature] MCP tool get_install_command_for_project --- docs/app/views/docs/mcp.rb | 3 ++- mcp/lib/ruby_ui/mcp/server.rb | 7 ++++++ .../tools/get_install_command_for_project.rb | 22 +++++++++++++++++++ mcp/test/server_test.rb | 3 ++- mcp/test/tools/install_command_test.rb | 11 ++++++++++ 5 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 mcp/lib/ruby_ui/mcp/tools/get_install_command_for_project.rb create mode 100644 mcp/test/tools/install_command_test.rb diff --git a/docs/app/views/docs/mcp.rb b/docs/app/views/docs/mcp.rb index 821b66ae..158cb63e 100644 --- a/docs/app/views/docs/mcp.rb +++ b/docs/app/views/docs/mcp.rb @@ -146,7 +146,8 @@ def tools_list ["view_items_in_registries", "Returns full source files and dependencies."], ["get_item_examples_from_registries", "Returns code examples per component."], ["get_add_command_for_items", "Returns a validated rails g ruby_ui:component … command."], - ["get_audit_checklist", "Returns a post-install verification checklist."] + ["get_audit_checklist", "Returns a post-install verification checklist."], + ["get_install_command_for_project", "Returns commands to bootstrap ruby_ui in a fresh Rails project."] ] end end diff --git a/mcp/lib/ruby_ui/mcp/server.rb b/mcp/lib/ruby_ui/mcp/server.rb index 17194669..991fb13b 100644 --- a/mcp/lib/ruby_ui/mcp/server.rb +++ b/mcp/lib/ruby_ui/mcp/server.rb @@ -10,6 +10,7 @@ require "ruby_ui/mcp/tools/get_item_examples_from_registries" require "ruby_ui/mcp/tools/get_add_command_for_items" require "ruby_ui/mcp/tools/get_audit_checklist" +require "ruby_ui/mcp/tools/get_install_command_for_project" module RubyUI module MCP @@ -71,6 +72,12 @@ class Server klass: Tools::GetAuditChecklist, description: "Return a post-install verification checklist.", input_schema: {properties: {}} + }, + { + name: "get_install_command_for_project", + klass: Tools::GetInstallCommandForProject, + description: "Return the commands to bootstrap ruby_ui in a fresh Rails project (gem install + ruby_ui:install generator).", + input_schema: {properties: {}} } ].freeze diff --git a/mcp/lib/ruby_ui/mcp/tools/get_install_command_for_project.rb b/mcp/lib/ruby_ui/mcp/tools/get_install_command_for_project.rb new file mode 100644 index 00000000..4626f790 --- /dev/null +++ b/mcp/lib/ruby_ui/mcp/tools/get_install_command_for_project.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "ruby_ui/mcp/tools/base" + +module RubyUI + module MCP + module Tools + class GetInstallCommandForProject < Base + def call(**) + { + steps: [ + {title: "Add the gem", command: "bundle add ruby_ui"}, + {title: "Run the installer", command: "bin/rails g ruby_ui:install"}, + {title: "Add a component", command: "bin/rails g ruby_ui:component Button"} + ], + note: "Use `get_add_command_for_items` once you know which components to install." + } + end + end + end + end +end diff --git a/mcp/test/server_test.rb b/mcp/test/server_test.rb index 10689952..883918be 100644 --- a/mcp/test/server_test.rb +++ b/mcp/test/server_test.rb @@ -8,12 +8,13 @@ def setup @registry = RubyUI::MCP::Registry.load(TestSupport::FIXTURE_PATH) end - def test_builds_with_seven_tools + def test_builds_with_eight_tools builder = RubyUI::MCP::Server.new(registry: @registry) names = builder.tool_classes.map(&:name_value).sort expected = %w[ get_add_command_for_items get_audit_checklist + get_install_command_for_project get_item_examples_from_registries get_project_registries list_items_in_registries diff --git a/mcp/test/tools/install_command_test.rb b/mcp/test/tools/install_command_test.rb new file mode 100644 index 00000000..9a98fe7a --- /dev/null +++ b/mcp/test/tools/install_command_test.rb @@ -0,0 +1,11 @@ +require "test_helper" +require "ruby_ui/mcp/tools/get_install_command_for_project" + +class InstallCommandToolTest < Minitest::Test + def test_returns_install_steps + tool = RubyUI::MCP::Tools::GetInstallCommandForProject.new(registry: nil) + result = tool.call + assert_equal 3, result[:steps].length + assert_match(/bundle add ruby_ui/, result[:steps].first[:command]) + end +end From 3eb08216e6e6fc987d26c5a6b1bab95fc26edad9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 16:58:51 -0300 Subject: [PATCH 19/19] [Refactor] Drop dead code + centralize tool response helpers --- mcp/lib/ruby_ui/mcp/rack_app.rb | 14 +- mcp/lib/ruby_ui/mcp/registry.rb | 8 +- mcp/lib/ruby_ui/mcp/server.rb | 11 +- mcp/lib/ruby_ui/mcp/tools/base.rb | 13 + .../mcp/tools/get_add_command_for_items.rb | 2 +- .../get_item_examples_from_registries.rb | 2 +- .../mcp/tools/list_items_in_registries.rb | 2 +- .../mcp/tools/search_items_in_registries.rb | 2 +- .../mcp/tools/view_items_in_registries.rb | 2 +- mcp/test/fixtures/registry.json | 1 - mcp/test/server_test.rb | 12 +- specs/2026-05-09-ruby-ui-mcp-design.md | 226 --- specs/2026-05-09-ruby-ui-mcp-plan.md | 1462 ----------------- 13 files changed, 23 insertions(+), 1734 deletions(-) delete mode 100644 specs/2026-05-09-ruby-ui-mcp-design.md delete mode 100644 specs/2026-05-09-ruby-ui-mcp-plan.md diff --git a/mcp/lib/ruby_ui/mcp/rack_app.rb b/mcp/lib/ruby_ui/mcp/rack_app.rb index c3ca5413..c9211170 100644 --- a/mcp/lib/ruby_ui/mcp/rack_app.rb +++ b/mcp/lib/ruby_ui/mcp/rack_app.rb @@ -6,18 +6,8 @@ module RubyUI module MCP class RackApp - class << self - def call(env) - instance.call(env) - end - - def instance - @instance ||= new - end - - def reset! - @instance = nil - end + def self.call(env) + (@instance ||= new).call(env) end def initialize(registry: RubyUI::MCP.registry) diff --git a/mcp/lib/ruby_ui/mcp/registry.rb b/mcp/lib/ruby_ui/mcp/registry.rb index 86096576..12cf06df 100644 --- a/mcp/lib/ruby_ui/mcp/registry.rb +++ b/mcp/lib/ruby_ui/mcp/registry.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "json" -require "set" module RubyUI module MCP @@ -22,11 +21,10 @@ def self.load(path) new(raw) end - attr_reader :version, :generated_at + attr_reader :version def initialize(raw) @version = raw[:version] - @generated_at = raw[:generated_at] @components = raw[:components] || {} end @@ -34,10 +32,6 @@ def list @components.values.map { |c| {name: c[:name], description: c[:description]} } end - def all - @components.values - end - def find(name) key = name.to_s.downcase.to_sym @components[key] diff --git a/mcp/lib/ruby_ui/mcp/server.rb b/mcp/lib/ruby_ui/mcp/server.rb index 991fb13b..87f14c01 100644 --- a/mcp/lib/ruby_ui/mcp/server.rb +++ b/mcp/lib/ruby_ui/mcp/server.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require "json" require "mcp" require "ruby_ui/mcp/registry" require "ruby_ui/mcp/tools/get_project_registries" @@ -109,15 +108,7 @@ def build_tool_class(definition) description: definition[:description], input_schema: definition[:input_schema] ) do |server_context: nil, **args| - payload = impl.call(**args) - ::MCP::Tool::Response.new([ - {type: "text", text: JSON.pretty_generate(payload)} - ]) - rescue => e - ::MCP::Tool::Response.new( - [{type: "text", text: "error: #{e.class}: #{e.message}"}], - error: true - ) + impl.respond { impl.call(**args) } end end end diff --git a/mcp/lib/ruby_ui/mcp/tools/base.rb b/mcp/lib/ruby_ui/mcp/tools/base.rb index ad881aac..b099103a 100644 --- a/mcp/lib/ruby_ui/mcp/tools/base.rb +++ b/mcp/lib/ruby_ui/mcp/tools/base.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "json" + module RubyUI module MCP module Tools @@ -11,6 +13,17 @@ def initialize(registry:) def call(**args) raise NotImplementedError end + + def gem_version + @registry&.version + end + + def respond + payload = yield + ::MCP::Tool::Response.new([{type: "text", text: ::JSON.pretty_generate(payload)}]) + rescue => e + ::MCP::Tool::Response.new([{type: "text", text: "error: #{e.class}: #{e.message}"}], error: true) + end end end end diff --git a/mcp/lib/ruby_ui/mcp/tools/get_add_command_for_items.rb b/mcp/lib/ruby_ui/mcp/tools/get_add_command_for_items.rb index 22809252..72e49a84 100644 --- a/mcp/lib/ruby_ui/mcp/tools/get_add_command_for_items.rb +++ b/mcp/lib/ruby_ui/mcp/tools/get_add_command_for_items.rb @@ -15,7 +15,7 @@ def call(items:, **) components: known, unresolved: unresolved, command_string: known.empty? ? "" : "rails g #{GENERATOR} #{known.join(" ")}", - gem_version: @registry.version + gem_version: gem_version } end end diff --git a/mcp/lib/ruby_ui/mcp/tools/get_item_examples_from_registries.rb b/mcp/lib/ruby_ui/mcp/tools/get_item_examples_from_registries.rb index b2a72443..fa2bf555 100644 --- a/mcp/lib/ruby_ui/mcp/tools/get_item_examples_from_registries.rb +++ b/mcp/lib/ruby_ui/mcp/tools/get_item_examples_from_registries.rb @@ -11,7 +11,7 @@ def call(items:, **) c = @registry.find(n) c ? {name: c[:name], examples: c[:examples] || []} : nil end.compact - {items: resolved, gem_version: @registry.version} + {items: resolved, gem_version: gem_version} end end end diff --git a/mcp/lib/ruby_ui/mcp/tools/list_items_in_registries.rb b/mcp/lib/ruby_ui/mcp/tools/list_items_in_registries.rb index a5276451..1af1ebca 100644 --- a/mcp/lib/ruby_ui/mcp/tools/list_items_in_registries.rb +++ b/mcp/lib/ruby_ui/mcp/tools/list_items_in_registries.rb @@ -7,7 +7,7 @@ module MCP module Tools class ListItemsInRegistries < Base def call(**) - {items: @registry.list, gem_version: @registry.version} + {items: @registry.list, gem_version: gem_version} end end end diff --git a/mcp/lib/ruby_ui/mcp/tools/search_items_in_registries.rb b/mcp/lib/ruby_ui/mcp/tools/search_items_in_registries.rb index 47537f16..a5c167c8 100644 --- a/mcp/lib/ruby_ui/mcp/tools/search_items_in_registries.rb +++ b/mcp/lib/ruby_ui/mcp/tools/search_items_in_registries.rb @@ -7,7 +7,7 @@ module MCP module Tools class SearchItemsInRegistries < Base def call(query:, limit: 10, **) - {items: @registry.search(query, limit: limit), gem_version: @registry.version} + {items: @registry.search(query, limit: limit), gem_version: gem_version} end end end diff --git a/mcp/lib/ruby_ui/mcp/tools/view_items_in_registries.rb b/mcp/lib/ruby_ui/mcp/tools/view_items_in_registries.rb index c37eef06..7b2497f8 100644 --- a/mcp/lib/ruby_ui/mcp/tools/view_items_in_registries.rb +++ b/mcp/lib/ruby_ui/mcp/tools/view_items_in_registries.rb @@ -13,7 +13,7 @@ def call(items:, **) comp = @registry.find(name) comp ? resolved << comp : unresolved << name end - {items: resolved, unresolved: unresolved, gem_version: @registry.version} + {items: resolved, unresolved: unresolved, gem_version: gem_version} end end end diff --git a/mcp/test/fixtures/registry.json b/mcp/test/fixtures/registry.json index 050561d2..e3a04e6c 100644 --- a/mcp/test/fixtures/registry.json +++ b/mcp/test/fixtures/registry.json @@ -1,6 +1,5 @@ { "version": "1.2.0", - "generated_at": "2026-05-09T00:00:00Z", "components": { "button": { "name": "Button", diff --git a/mcp/test/server_test.rb b/mcp/test/server_test.rb index 883918be..f1a4f694 100644 --- a/mcp/test/server_test.rb +++ b/mcp/test/server_test.rb @@ -24,18 +24,8 @@ def test_builds_with_eight_tools assert_equal expected, names end - def test_server_instance_has_seven_tools + def test_build_returns_mcp_server mcp_server = RubyUI::MCP::Server.build(registry: @registry) assert_kind_of MCP::Server, mcp_server end - - def test_list_tool_invocation_round_trips - builder = RubyUI::MCP::Server.new(registry: @registry) - list_tool = builder.tool_classes.find { |k| k.name_value == "list_items_in_registries" } - response = list_tool.call(server_context: nil) - assert_kind_of MCP::Tool::Response, response - # Response wraps a content array; payload should include items - serialized = response.to_h - assert serialized[:content] || serialized["content"] - end end diff --git a/specs/2026-05-09-ruby-ui-mcp-design.md b/specs/2026-05-09-ruby-ui-mcp-design.md deleted file mode 100644 index 998d9cd5..00000000 --- a/specs/2026-05-09-ruby-ui-mcp-design.md +++ /dev/null @@ -1,226 +0,0 @@ -# Ruby UI MCP Server — Design - -**Date:** 2026-05-09 -**Status:** Approved -**Author:** Djalma Araújo (with Claude) - -## Summary - -Add a Model Context Protocol (MCP) server for `ruby_ui` so AI coding agents (Claude Code, Cursor, Claude Desktop, Windsurf, VS Code, Zed) can discover, inspect, and install RubyUI components programmatically. Mirrors the shadcn MCP feature set (full 7-tool surface) and is hosted as an HTTP endpoint mounted inside the existing `docs/` Rails 8 app at `https://rubyui.com/mcp`. - -## Goals - -- Agents can list, search, view, and install RubyUI components without manual file copying or doc reading. -- Agents can verify their install via an audit checklist. -- Zero local install for end users — HTTP transport, paste a URL into client config. -- Single source of truth: the existing `gem/` directory. No duplication. -- Independent release cadence from the `ruby_ui` gem. - -## Non-Goals - -- Local stdio binary distribution (deferred; HTTP-only for v1). -- Authentication / API keys (registry data is public). -- Multi-registry support (shadcn's primary registry pattern; ruby_ui ships one registry). -- Direct filesystem mutation by the MCP server. Installation is performed by the client agent running `rails g ruby_ui:component …` locally. - -## Architecture - -### Repo Layout - -``` -ruby_ui/ -├── gem/ # existing — Phlex components, generators -├── docs/ # existing — Rails 8.1 site (rubyui.com) -│ ├── Gemfile # adds: gem "ruby_ui-mcp", path: "../mcp" -│ ├── config/routes.rb # mounts RubyUI::MCP::Engine => "/mcp" -│ ├── app/views/docs/mcp.rb # NEW MCP docs page -│ ├── app/controllers/docs_controller.rb # +action :mcp -│ └── app/components/shared/menu.rb # +MCP entry -└── mcp/ # NEW — Rails engine gem - ├── ruby_ui-mcp.gemspec - ├── Gemfile - ├── Rakefile - ├── lib/ruby_ui/mcp/ - │ ├── version.rb - │ ├── engine.rb # Rails::Engine - │ ├── server.rb # MCP server (modelcontextprotocol/ruby-sdk) - │ ├── registry.rb # loads + queries registry.json - │ ├── tools/ # one file per MCP tool - │ │ ├── get_project_registries.rb - │ │ ├── list_items_in_registries.rb - │ │ ├── search_items_in_registries.rb - │ │ ├── view_items_in_registries.rb - │ │ ├── get_item_examples_from_registries.rb - │ │ ├── get_add_command_for_items.rb - │ │ └── get_audit_checklist.rb - │ └── builders/ - │ └── registry_builder.rb # reads ../gem, writes registry.json - ├── data/registry.json # built artifact, committed - ├── exe/ruby-ui-mcp-build # CLI: rebuild registry - └── test/ -``` - -### Request Flow - -``` -client (Claude Code, Cursor, etc.) - ↓ HTTPS POST /mcp (streamable HTTP transport) -docs/ Rails app - ↓ mount -RubyUI::MCP::Engine - ↓ -RubyUI::MCP::Server (mcp ruby-sdk) - ↓ tool dispatch -RubyUI::MCP::Tools::* - ↓ query -RubyUI::MCP::Registry (in-memory, loaded at boot from data/registry.json) - ↓ JSON-RPC response -client -``` - -## Registry - -### Schema - -`mcp/data/registry.json`: - -```json -{ - "version": "1.2.0", - "generated_at": "2026-05-09T12:00:00Z", - "components": { - "button": { - "name": "Button", - "description": "Trigger actions or events.", - "files": [ - {"path": "button.rb", "content": "..."}, - {"path": "button_controller.js", "content": "..."} - ], - "dependencies": { - "components": ["Icon"], - "js_packages": [], - "gems": [] - }, - "install_command": "rails g ruby_ui:component Button", - "docs_markdown": "# Button\n\n...", - "examples": [ - {"title": "Basic", "code": "RubyUI.Button { 'Click' }"} - ] - } - } -} -``` - -### Builder - -`RubyUI::MCP::Builders::RegistryBuilder`: - -- Walks `../gem/lib/ruby_ui/*/`. -- Reads all `.rb` and `.js` files per component directory. -- Parses `../gem/lib/generators/ruby_ui/dependencies.yml` for component/JS/gem deps. -- Reads `../gem/lib/ruby_ui/version.rb` for version pin. -- Renders each `_docs.rb` Phlex view to HTML, converts to markdown. -- Extracts `Docs::VisualCodeExample` blocks as `examples`. -- Writes `mcp/data/registry.json` deterministically (sorted keys, stable timestamps optional via env). - -### Build Lifecycle - -- Local: `bin/rake mcp:build` (wraps `exe/ruby-ui-mcp-build`). -- CI: a `mcp-registry-check` job rebuilds and fails if `git diff` on `data/registry.json` is non-empty. Contributors must commit the regenerated registry when `gem/` changes. -- Deploy: docs/ deploys with the latest committed `registry.json`. No build at deploy time. - -## MCP Tools (shadcn parity) - -| Tool | Purpose | Inputs | Outputs | -|------|---------|--------|---------| -| `get_project_registries` | List registries available. Single registry `ruby_ui` for client compat. | – | `[{name, url, description}]` | -| `list_items_in_registries` | All components, name + short description. | `registries[]` | `[{name, description}]` | -| `search_items_in_registries` | Fuzzy match name/description/docs. | `query`, `registries[]`, `limit?` | `[{name, description, score}]` | -| `view_items_in_registries` | Full source files + deps. | `items[]` | `[{name, files, dependencies, ...}]` | -| `get_item_examples_from_registries` | Code examples per component. | `items[]` | `[{name, examples}]` | -| `get_add_command_for_items` | Structured install command. | `items[]` | `{generator, components, command_string}` | -| `get_audit_checklist` | Static post-install verification list. | – | `[{check, description}]` | - -### Audit Checklist Items - -- `ruby_ui` gem present in `Gemfile`. -- Component files exist under `app/components/ruby_ui//`. -- Stimulus controllers registered (where applicable). -- JS packages from `dependencies.yml` present in `package.json`. -- Tailwind `content` paths include `app/components/ruby_ui/**/*`. -- Zeitwerk loads the `RubyUI` namespace. -- Generated views compile (no Phlex render errors). - -## Security - -- Component names in `get_add_command_for_items` validated against registry allowlist; regex `\A[A-Z][A-Za-z0-9]*\z`. No shell metacharacters reach the client. -- Output is structured (`{generator, components}`), not a raw shell string. The convenience `command_string` is built from validated tokens. -- MCP server is read-only. No filesystem writes. Execution risk lives in the client (Claude Code, etc.), gated by its own bash-permission layer. -- Tool exception handler returns MCP error without stack traces. - -## Hosting - -- Mounted in existing `docs/` Rails app at `/mcp`. Subdomain `mcp.rubyui.com` is a future option, not v1. -- Public, no auth. -- `Rack::Attack` rate limit: 60 requests/min/IP on `/mcp/*`. -- Registry loaded once at boot, cached in memory. Reload requires deploy. - -## Documentation Page - -New `docs/app/views/docs/mcp.rb` modeled after the shadcn MCP docs page. - -Sections: - -1. **Intro** — what MCP is, what ruby_ui MCP does. -2. **Setup** — tabbed install per client. Each tab is a copy-paste snippet: - - Claude Code: `claude mcp add --transport http ruby-ui https://rubyui.com/mcp` - - Cursor: `.cursor/mcp.json` JSON - - Claude Desktop: `claude_desktop_config.json` JSON - - Windsurf: `mcp_config.json` JSON - - VS Code: `.vscode/mcp.json` JSON - - Zed: `settings.json` snippet -3. **Usage** — example agent prompts ("Install Button and Dialog", "Show me Card source", "Search for date input", "Audit my install"). -4. **Tools reference** — table of all 7 tools with params and examples. -5. **Troubleshooting** — common errors. - -Wiring: - -- Route added to `docs/config/routes.rb` under existing docs scope. -- Action added to `DocsController`. -- Menu entry in `app/components/shared/menu.rb`. - -## Versioning - -- `mcp/lib/ruby_ui/mcp/version.rb` is independent of `gem/lib/ruby_ui/version.rb`. -- Registry embeds the gem version it was built from (`registry.version`). -- Tool responses include `gem_version` so agents know what they're consuming. - -## Testing - -- Minitest, mirrors `gem/` test style. -- Per-tool tests with a stub in-memory registry. -- Builder integration test against a small fixture gem directory. -- Server smoke test via an in-process MCP client. -- CI job: `cd mcp && bundle exec rake` (tests + standardrb). -- CI job: `mcp-registry-check` — rebuild registry, fail on diff. - -## Error Handling - -- Unknown component name → MCP error with top-3 fuzzy suggestions. -- Malformed args → JSON-RPC `InvalidParams`. -- Registry load failure at boot → Rails fails fast. -- Tool exceptions → caught, logged, returned as MCP error without stack traces. - -## Logging - -- Rails logger tagged `[MCP]`. -- Per request: tool name, arg count, latency, status. -- No component source in logs; names only. - -## Out of Scope (Future Work) - -- Local stdio gem distribution (`gem install ruby_ui-mcp && ruby-ui-mcp`). -- Hosted subdomain `mcp.rubyui.com`. -- API keys / higher-tier rate limits. -- Per-version registry serving (`?version=1.2.0`). -- Telemetry / metrics dashboards. diff --git a/specs/2026-05-09-ruby-ui-mcp-plan.md b/specs/2026-05-09-ruby-ui-mcp-plan.md deleted file mode 100644 index 0595100f..00000000 --- a/specs/2026-05-09-ruby-ui-mcp-plan.md +++ /dev/null @@ -1,1462 +0,0 @@ -# Ruby UI MCP Server Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Ship `ruby_ui-mcp` — a Rails engine gem in `mcp/` that exposes a 7-tool Model Context Protocol server (shadcn-parity) over HTTP, mounted in the existing `docs/` Rails app at `/mcp`, plus a docs page on rubyui.com explaining install + usage. - -**Architecture:** New `mcp/` sibling of `gem/` and `docs/`. Static `data/registry.json` built from `../gem/` by a CLI, committed to repo, loaded into memory at Rails boot. `RubyUI::MCP::Engine` mounts a streamable-HTTP MCP endpoint built on `modelcontextprotocol/ruby-sdk`. Seven tools (read-only) query the in-memory registry. Component install happens client-side via `rails g ruby_ui:component` — MCP only returns validated, structured commands. - -**Tech Stack:** Ruby 3.3+, Rails 8.1 engine, `mcp` gem (modelcontextprotocol/ruby-sdk), `rack-attack`, `phlex` (docs page), `kramdown` or `reverse_markdown` (HTML→md for docs build), Minitest, StandardRB. - -**Reference:** See `specs/2026-05-09-ruby-ui-mcp-design.md` for approved design. - ---- - -## Task 1: Scaffold `mcp/` Rails engine gem - -**Files:** -- Create: `mcp/.gitignore` -- Create: `mcp/Gemfile` -- Create: `mcp/Rakefile` -- Create: `mcp/ruby_ui-mcp.gemspec` -- Create: `mcp/lib/ruby_ui/mcp.rb` -- Create: `mcp/lib/ruby_ui/mcp/version.rb` -- Create: `mcp/lib/ruby_ui/mcp/engine.rb` -- Create: `mcp/.standard.yml` - -- [ ] **Step 1: Create gemspec** - -```ruby -# mcp/ruby_ui-mcp.gemspec -require_relative "lib/ruby_ui/mcp/version" - -Gem::Specification.new do |spec| - spec.name = "ruby_ui-mcp" - spec.version = RubyUI::MCP::VERSION - spec.authors = ["Ruby UI"] - spec.summary = "MCP server for ruby_ui — agent-driven component discovery and install." - spec.license = "MIT" - spec.required_ruby_version = ">= 3.3" - - spec.files = Dir["lib/**/*", "data/**/*", "exe/*", "README.md", "LICENSE"] - spec.bindir = "exe" - spec.executables = ["ruby-ui-mcp-build"] - spec.require_paths = ["lib"] - - spec.add_dependency "rails", ">= 8.0" - spec.add_dependency "mcp", ">= 0.1" - spec.add_dependency "rack-attack", ">= 6.7" - spec.add_dependency "reverse_markdown", ">= 2.1" - - spec.add_development_dependency "minitest", ">= 5.0" - spec.add_development_dependency "standard" - spec.add_development_dependency "rake" -end -``` - -- [ ] **Step 2: Create version + module + engine** - -```ruby -# mcp/lib/ruby_ui/mcp/version.rb -# frozen_string_literal: true -module RubyUI - module MCP - VERSION = "0.1.0" - end -end -``` - -```ruby -# mcp/lib/ruby_ui/mcp.rb -# frozen_string_literal: true -require "rails" -require "ruby_ui/mcp/version" -require "ruby_ui/mcp/engine" - -module RubyUI - module MCP - def self.registry - @registry ||= Registry.load_default - end - - def self.root - Engine.root - end - end -end -``` - -```ruby -# mcp/lib/ruby_ui/mcp/engine.rb -# frozen_string_literal: true -require "rails/engine" - -module RubyUI - module MCP - class Engine < ::Rails::Engine - isolate_namespace RubyUI::MCP - - initializer "ruby_ui.mcp.load_registry" do - require "ruby_ui/mcp/registry" - RubyUI::MCP.registry # eager load, fail fast on bad registry - end - end - end -end -``` - -- [ ] **Step 3: Create Gemfile + Rakefile** - -```ruby -# mcp/Gemfile -source "https://rubygems.org" -gemspec -``` - -```ruby -# mcp/Rakefile -require "rake/testtask" - -Rake::TestTask.new(:test) do |t| - t.libs << "test" << "lib" - t.pattern = "test/**/*_test.rb" - t.warning = false -end - -begin - require "standard/rake" - task default: %i[test standard] -rescue LoadError - task default: :test -end - -namespace :mcp do - desc "Rebuild registry.json from ../gem" - task :build do - sh "exe/ruby-ui-mcp-build" - end -end -``` - -- [ ] **Step 4: gitignore + standard config** - -``` -# mcp/.gitignore -/.bundle/ -/Gemfile.lock -/pkg/ -/tmp/ -``` - -```yaml -# mcp/.standard.yml -ruby_version: 3.3 -``` - -- [ ] **Step 5: Bundle install + verify load** - -Run: `cd mcp && bundle install && bundle exec ruby -Ilib -e "require 'ruby_ui/mcp'; puts RubyUI::MCP::VERSION"` -Expected: `0.1.0` - -- [ ] **Step 6: Commit** - -```bash -git add mcp/ -git commit -m "[Feature] Scaffold ruby_ui-mcp Rails engine gem" -``` - ---- - -## Task 2: Registry data model - -**Files:** -- Create: `mcp/lib/ruby_ui/mcp/registry.rb` -- Create: `mcp/test/test_helper.rb` -- Create: `mcp/test/registry_test.rb` -- Create: `mcp/test/fixtures/registry.json` - -- [ ] **Step 1: Write fixture registry** - -```json -// mcp/test/fixtures/registry.json -{ - "version": "1.2.0", - "generated_at": "2026-05-09T00:00:00Z", - "components": { - "button": { - "name": "Button", - "description": "Trigger actions or events.", - "files": [{"path": "button.rb", "content": "class Button; end\n"}], - "dependencies": {"components": [], "js_packages": [], "gems": []}, - "install_command": "rails g ruby_ui:component Button", - "docs_markdown": "# Button\n", - "examples": [{"title": "Basic", "code": "RubyUI.Button { 'x' }"}] - }, - "dialog": { - "name": "Dialog", - "description": "Modal dialog.", - "files": [{"path": "dialog.rb", "content": "class Dialog; end\n"}], - "dependencies": {"components": ["Button"], "js_packages": [], "gems": []}, - "install_command": "rails g ruby_ui:component Dialog", - "docs_markdown": "# Dialog\n", - "examples": [] - } - } -} -``` - -- [ ] **Step 2: Write failing tests** - -```ruby -# mcp/test/test_helper.rb -$LOAD_PATH.unshift File.expand_path("../lib", __dir__) -require "minitest/autorun" -require "ruby_ui/mcp/registry" - -module TestSupport - FIXTURE_PATH = File.expand_path("fixtures/registry.json", __dir__) -end -``` - -```ruby -# mcp/test/registry_test.rb -require "test_helper" - -class RegistryTest < Minitest::Test - def setup - @registry = RubyUI::MCP::Registry.load(TestSupport::FIXTURE_PATH) - end - - def test_version - assert_equal "1.2.0", @registry.version - end - - def test_list_returns_all_components - names = @registry.list.map { |c| c[:name] } - assert_equal %w[Button Dialog], names.sort - end - - def test_find_by_name_case_insensitive - assert_equal "Button", @registry.find("button")[:name] - assert_equal "Button", @registry.find("Button")[:name] - end - - def test_find_unknown_returns_nil - assert_nil @registry.find("Nonexistent") - end - - def test_search_matches_name - results = @registry.search("dial") - assert_equal ["Dialog"], results.map { |r| r[:name] } - end - - def test_search_matches_description - results = @registry.search("modal") - assert_equal ["Dialog"], results.map { |r| r[:name] } - end - - def test_validate_names_returns_known_and_unknown - known, unknown = @registry.partition_names(["Button", "Bogus"]) - assert_equal ["Button"], known - assert_equal ["Bogus"], unknown - end -end -``` - -- [ ] **Step 3: Run tests, verify failure** - -Run: `cd mcp && bundle exec rake test` -Expected: FAIL — `Registry` not defined. - -- [ ] **Step 4: Implement Registry** - -```ruby -# mcp/lib/ruby_ui/mcp/registry.rb -# frozen_string_literal: true -require "json" - -module RubyUI - module MCP - class Registry - NAME_REGEX = /\A[A-Z][A-Za-z0-9]*\z/ - - def self.load_default - path = ENV["RUBY_UI_MCP_REGISTRY"] || default_path - load(path) - end - - def self.default_path - File.expand_path("../../../data/registry.json", __dir__) - end - - def self.load(path) - raw = JSON.parse(File.read(path), symbolize_names: true) - new(raw) - end - - attr_reader :version, :generated_at - - def initialize(raw) - @version = raw[:version] - @generated_at = raw[:generated_at] - @components = raw[:components] || {} - end - - def list - @components.values.map { |c| {name: c[:name], description: c[:description]} } - end - - def all - @components.values - end - - def find(name) - key = name.to_s.downcase - @components[key.to_sym] - end - - def search(query, limit: 10) - q = query.to_s.downcase - scored = @components.values.map do |c| - haystack = "#{c[:name]} #{c[:description]} #{c[:docs_markdown]}".downcase - score = haystack.include?(q) ? haystack.scan(q).length : 0 - [c, score] - end - scored.select { |_, s| s > 0 } - .sort_by { |_, s| -s } - .first(limit) - .map { |c, s| {name: c[:name], description: c[:description], score: s} } - end - - def partition_names(names) - known_set = @components.values.map { |c| c[:name] }.to_set - names.partition { |n| NAME_REGEX.match?(n) && known_set.include?(n) } - end - end - end -end -``` - -- [ ] **Step 5: Run tests, verify pass** - -Run: `cd mcp && bundle exec rake test` -Expected: PASS, 7 assertions. - -- [ ] **Step 6: Commit** - -```bash -git add mcp/lib/ruby_ui/mcp/registry.rb mcp/test/ -git commit -m "[Feature] MCP Registry data model + tests" -``` - ---- - -## Task 3: Registry Builder (reads `../gem`) - -**Files:** -- Create: `mcp/lib/ruby_ui/mcp/builders/registry_builder.rb` -- Create: `mcp/test/builders/registry_builder_test.rb` -- Create: `mcp/test/fixtures/fake_gem/lib/ruby_ui/version.rb` -- Create: `mcp/test/fixtures/fake_gem/lib/ruby_ui/button/button.rb` -- Create: `mcp/test/fixtures/fake_gem/lib/ruby_ui/button/button_docs.rb` -- Create: `mcp/test/fixtures/fake_gem/lib/generators/ruby_ui/dependencies.yml` - -- [ ] **Step 1: Build fake gem fixture** - -```ruby -# mcp/test/fixtures/fake_gem/lib/ruby_ui/version.rb -module RubyUI; VERSION = "9.9.9"; end -``` - -```ruby -# mcp/test/fixtures/fake_gem/lib/ruby_ui/button/button.rb -# RubyUI::Button — clickable. -module RubyUI - class Button - end -end -``` - -```ruby -# mcp/test/fixtures/fake_gem/lib/ruby_ui/button/button_docs.rb -class Views::Docs::Button - def view_template - h1 { "Button" } - p { "A clickable button." } - end -end -``` - -```yaml -# mcp/test/fixtures/fake_gem/lib/generators/ruby_ui/dependencies.yml -button: - components: [] - js_packages: [] -``` - -- [ ] **Step 2: Write failing test** - -```ruby -# mcp/test/builders/registry_builder_test.rb -require "test_helper" -require "ruby_ui/mcp/builders/registry_builder" - -class RegistryBuilderTest < Minitest::Test - def test_builds_registry_from_fake_gem - fixture = File.expand_path("../fixtures/fake_gem", __dir__) - registry = RubyUI::MCP::Builders::RegistryBuilder.new(gem_path: fixture).build - - assert_equal "9.9.9", registry[:version] - assert registry[:components][:button] - button = registry[:components][:button] - assert_equal "Button", button[:name] - assert_match(/clickable/i, button[:description]) - assert button[:files].any? { |f| f[:path] == "button.rb" } - assert_equal "rails g ruby_ui:component Button", button[:install_command] - end -end -``` - -- [ ] **Step 3: Run, verify failure** - -Run: `cd mcp && bundle exec rake test TEST=test/builders/registry_builder_test.rb` -Expected: FAIL — builder missing. - -- [ ] **Step 4: Implement Builder** - -```ruby -# mcp/lib/ruby_ui/mcp/builders/registry_builder.rb -# frozen_string_literal: true -require "yaml" -require "time" - -module RubyUI - module MCP - module Builders - class RegistryBuilder - SKIP_DIRS = %w[base.rb docs].freeze - - def initialize(gem_path:) - @gem_path = gem_path - end - - def build - { - version: read_version, - generated_at: (ENV["SOURCE_DATE_EPOCH"] ? Time.at(ENV["SOURCE_DATE_EPOCH"].to_i).utc : Time.now.utc).iso8601, - components: components_hash - } - end - - def write(path) - require "json" - File.write(path, JSON.pretty_generate(build) + "\n") - end - - private - - def read_version - eval(File.read(File.join(@gem_path, "lib/ruby_ui/version.rb"))) - RubyUI::VERSION - rescue - "unknown" - end - - def components_hash - deps = load_deps - Dir.children(File.join(@gem_path, "lib/ruby_ui")) - .select { |d| File.directory?(File.join(@gem_path, "lib/ruby_ui", d)) } - .reject { |d| SKIP_DIRS.include?(d) } - .sort - .each_with_object({}) { |d, h| h[d.to_sym] = build_component(d, deps[d] || {}) } - end - - def load_deps - path = File.join(@gem_path, "lib/generators/ruby_ui/dependencies.yml") - File.exist?(path) ? YAML.safe_load_file(path) || {} : {} - end - - def build_component(slug, dep_entry) - dir = File.join(@gem_path, "lib/ruby_ui", slug) - files = Dir.glob(File.join(dir, "*")) - .reject { |f| File.basename(f).end_with?("_docs.rb") } - .sort - .map { |f| {path: File.basename(f), content: File.read(f)} } - name = camelize(slug) - docs_md = render_docs_markdown(dir, slug) - { - name: name, - description: extract_description(files, docs_md), - files: files, - dependencies: { - components: Array(dep_entry["components"]), - js_packages: Array(dep_entry["js_packages"]), - gems: Array(dep_entry["gems"]) - }, - install_command: "rails g ruby_ui:component #{name}", - docs_markdown: docs_md, - examples: extract_examples(docs_md) - } - end - - def camelize(slug) - slug.split("_").map(&:capitalize).join - end - - def render_docs_markdown(dir, slug) - docs_file = File.join(dir, "#{slug}_docs.rb") - return "" unless File.exist?(docs_file) - # First-pass heuristic: extract h1/p text via regex. - # Phlex render is added in a follow-up if needed. - src = File.read(docs_file) - headings = src.scan(/h1\s*\{\s*"([^"]+)"\s*\}/).flatten.map { |t| "# #{t}" } - paras = src.scan(/p\s*\{\s*"([^"]+)"\s*\}/).flatten - (headings + paras).join("\n\n") - end - - def extract_description(files, docs_md) - if (m = docs_md.match(/^# .+?\n+([^\n#].+)/m)) - m[1].strip - elsif files.first && (m = files.first[:content].match(/^# (?:RubyUI::\w+ — )?(.+)$/)) - m[1].strip - else - "" - end - end - - def extract_examples(_docs_md) - [] # phase 1: no examples extracted; populated later via VisualCodeExample parser - end - end - end - end -end -``` - -- [ ] **Step 5: Run, verify pass** - -Run: `cd mcp && bundle exec rake test TEST=test/builders/registry_builder_test.rb` -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add mcp/lib/ruby_ui/mcp/builders mcp/test/builders mcp/test/fixtures/fake_gem -git commit -m "[Feature] MCP RegistryBuilder reads gem source" -``` - ---- - -## Task 4: Build CLI + initial registry.json - -**Files:** -- Create: `mcp/exe/ruby-ui-mcp-build` -- Create: `mcp/data/registry.json` (committed artifact) - -- [ ] **Step 1: Write executable** - -```ruby -#!/usr/bin/env ruby -# mcp/exe/ruby-ui-mcp-build -# frozen_string_literal: true -$LOAD_PATH.unshift File.expand_path("../lib", __dir__) -require "ruby_ui/mcp/builders/registry_builder" - -gem_path = ENV["RUBY_UI_GEM_PATH"] || File.expand_path("../../gem", __dir__) -out = File.expand_path("../data/registry.json", __dir__) -FileUtils.mkdir_p(File.dirname(out)) -RubyUI::MCP::Builders::RegistryBuilder.new(gem_path: gem_path).write(out) -puts "Wrote #{out}" -``` - -- [ ] **Step 2: Make executable** - -```bash -chmod +x mcp/exe/ruby-ui-mcp-build -``` - -- [ ] **Step 3: Run build against real gem** - -Run: `cd mcp && bundle exec exe/ruby-ui-mcp-build` -Expected: `Wrote .../mcp/data/registry.json`. File exists with all components from `gem/lib/ruby_ui/`. - -- [ ] **Step 4: Sanity-check output** - -Run: `cd mcp && ruby -rjson -e "r = JSON.parse(File.read('data/registry.json')); puts r['components'].keys.sort.join(', ')"` -Expected: comma-separated list of all component slugs (button, dialog, etc.). - -- [ ] **Step 5: Commit** - -```bash -git add mcp/exe/ruby-ui-mcp-build mcp/data/registry.json -git commit -m "[Feature] MCP build CLI + initial registry.json" -``` - ---- - -## Task 5: MCP Tools — list, search, view - -**Files:** -- Create: `mcp/lib/ruby_ui/mcp/tools/base.rb` -- Create: `mcp/lib/ruby_ui/mcp/tools/get_project_registries.rb` -- Create: `mcp/lib/ruby_ui/mcp/tools/list_items_in_registries.rb` -- Create: `mcp/lib/ruby_ui/mcp/tools/search_items_in_registries.rb` -- Create: `mcp/lib/ruby_ui/mcp/tools/view_items_in_registries.rb` -- Create: `mcp/test/tools/list_test.rb` -- Create: `mcp/test/tools/search_test.rb` -- Create: `mcp/test/tools/view_test.rb` - -- [ ] **Step 1: Tool base + tests** - -```ruby -# mcp/lib/ruby_ui/mcp/tools/base.rb -# frozen_string_literal: true -module RubyUI - module MCP - module Tools - class Base - def initialize(registry:) - @registry = registry - end - - def call(**args) - raise NotImplementedError - end - end - end - end -end -``` - -```ruby -# mcp/test/tools/list_test.rb -require "test_helper" -require "ruby_ui/mcp/tools/list_items_in_registries" - -class ListItemsToolTest < Minitest::Test - def setup - @registry = RubyUI::MCP::Registry.load(TestSupport::FIXTURE_PATH) - @tool = RubyUI::MCP::Tools::ListItemsInRegistries.new(registry: @registry) - end - - def test_returns_all_components - items = @tool.call[:items] - assert_equal 2, items.length - assert_equal %w[Button Dialog], items.map { |i| i[:name] }.sort - end -end -``` - -```ruby -# mcp/test/tools/search_test.rb -require "test_helper" -require "ruby_ui/mcp/tools/search_items_in_registries" - -class SearchItemsToolTest < Minitest::Test - def setup - @registry = RubyUI::MCP::Registry.load(TestSupport::FIXTURE_PATH) - @tool = RubyUI::MCP::Tools::SearchItemsInRegistries.new(registry: @registry) - end - - def test_finds_by_name - items = @tool.call(query: "dial")[:items] - assert_equal ["Dialog"], items.map { |i| i[:name] } - end - - def test_empty_when_no_match - assert_empty @tool.call(query: "zzz")[:items] - end -end -``` - -```ruby -# mcp/test/tools/view_test.rb -require "test_helper" -require "ruby_ui/mcp/tools/view_items_in_registries" - -class ViewItemsToolTest < Minitest::Test - def setup - @registry = RubyUI::MCP::Registry.load(TestSupport::FIXTURE_PATH) - @tool = RubyUI::MCP::Tools::ViewItemsInRegistries.new(registry: @registry) - end - - def test_returns_full_components - result = @tool.call(items: ["Button"]) - assert_equal 1, result[:items].length - assert_equal "Button", result[:items].first[:name] - assert result[:items].first[:files].any? - end - - def test_unknown_in_unresolved - result = @tool.call(items: ["Bogus"]) - assert_equal ["Bogus"], result[:unresolved] - end -end -``` - -- [ ] **Step 2: Run, verify failures** - -Run: `cd mcp && bundle exec rake test` -Expected: 3 failing tests. - -- [ ] **Step 3: Implement tools** - -```ruby -# mcp/lib/ruby_ui/mcp/tools/get_project_registries.rb -# frozen_string_literal: true -require "ruby_ui/mcp/tools/base" - -module RubyUI - module MCP - module Tools - class GetProjectRegistries < Base - def call(**) - { - registries: [{ - name: "ruby_ui", - url: "https://rubyui.com/mcp", - description: "Ruby UI components for Phlex + Rails." - }] - } - end - end - end - end -end -``` - -```ruby -# mcp/lib/ruby_ui/mcp/tools/list_items_in_registries.rb -# frozen_string_literal: true -require "ruby_ui/mcp/tools/base" - -module RubyUI - module MCP - module Tools - class ListItemsInRegistries < Base - def call(**) - {items: @registry.list, gem_version: @registry.version} - end - end - end - end -end -``` - -```ruby -# mcp/lib/ruby_ui/mcp/tools/search_items_in_registries.rb -# frozen_string_literal: true -require "ruby_ui/mcp/tools/base" - -module RubyUI - module MCP - module Tools - class SearchItemsInRegistries < Base - def call(query:, limit: 10, **) - {items: @registry.search(query, limit: limit), gem_version: @registry.version} - end - end - end - end -end -``` - -```ruby -# mcp/lib/ruby_ui/mcp/tools/view_items_in_registries.rb -# frozen_string_literal: true -require "ruby_ui/mcp/tools/base" - -module RubyUI - module MCP - module Tools - class ViewItemsInRegistries < Base - def call(items:, **) - resolved = [] - unresolved = [] - items.each do |name| - comp = @registry.find(name) - comp ? resolved << comp : unresolved << name - end - {items: resolved, unresolved: unresolved, gem_version: @registry.version} - end - end - end - end -end -``` - -- [ ] **Step 4: Run, verify pass** - -Run: `cd mcp && bundle exec rake test` -Expected: all PASS. - -- [ ] **Step 5: Commit** - -```bash -git add mcp/lib/ruby_ui/mcp/tools mcp/test/tools -git commit -m "[Feature] MCP tools: list, search, view" -``` - ---- - -## Task 6: MCP Tools — examples, add command, audit - -**Files:** -- Create: `mcp/lib/ruby_ui/mcp/tools/get_item_examples_from_registries.rb` -- Create: `mcp/lib/ruby_ui/mcp/tools/get_add_command_for_items.rb` -- Create: `mcp/lib/ruby_ui/mcp/tools/get_audit_checklist.rb` -- Create: `mcp/test/tools/examples_test.rb` -- Create: `mcp/test/tools/add_command_test.rb` -- Create: `mcp/test/tools/audit_test.rb` - -- [ ] **Step 1: Write tests** - -```ruby -# mcp/test/tools/examples_test.rb -require "test_helper" -require "ruby_ui/mcp/tools/get_item_examples_from_registries" - -class ExamplesToolTest < Minitest::Test - def setup - @registry = RubyUI::MCP::Registry.load(TestSupport::FIXTURE_PATH) - @tool = RubyUI::MCP::Tools::GetItemExamplesFromRegistries.new(registry: @registry) - end - - def test_returns_examples_per_item - result = @tool.call(items: ["Button"]) - assert_equal 1, result[:items].length - assert_equal "Button", result[:items].first[:name] - assert_equal 1, result[:items].first[:examples].length - end - - def test_empty_examples_returned_for_components_without_any - result = @tool.call(items: ["Dialog"]) - assert_empty result[:items].first[:examples] - end -end -``` - -```ruby -# mcp/test/tools/add_command_test.rb -require "test_helper" -require "ruby_ui/mcp/tools/get_add_command_for_items" - -class AddCommandToolTest < Minitest::Test - def setup - @registry = RubyUI::MCP::Registry.load(TestSupport::FIXTURE_PATH) - @tool = RubyUI::MCP::Tools::GetAddCommandForItems.new(registry: @registry) - end - - def test_returns_structured_and_string_form - result = @tool.call(items: ["Button", "Dialog"]) - assert_equal "ruby_ui:component", result[:generator] - assert_equal ["Button", "Dialog"], result[:components] - assert_equal "rails g ruby_ui:component Button Dialog", result[:command_string] - end - - def test_filters_unknown_names - result = @tool.call(items: ["Button", "Bogus"]) - assert_equal ["Button"], result[:components] - assert_equal ["Bogus"], result[:unresolved] - end - - def test_rejects_shell_metachars - result = @tool.call(items: ["Button; rm -rf /"]) - assert_empty result[:components] - refute_match(/rm/, result[:command_string]) - end -end -``` - -```ruby -# mcp/test/tools/audit_test.rb -require "test_helper" -require "ruby_ui/mcp/tools/get_audit_checklist" - -class AuditChecklistToolTest < Minitest::Test - def test_returns_checklist - tool = RubyUI::MCP::Tools::GetAuditChecklist.new(registry: nil) - items = tool.call[:checklist] - assert items.length >= 5 - assert items.all? { |i| i[:check] && i[:description] } - end -end -``` - -- [ ] **Step 2: Verify failure** - -Run: `cd mcp && bundle exec rake test` -Expected: 3 failing test files. - -- [ ] **Step 3: Implement** - -```ruby -# mcp/lib/ruby_ui/mcp/tools/get_item_examples_from_registries.rb -# frozen_string_literal: true -require "ruby_ui/mcp/tools/base" - -module RubyUI - module MCP - module Tools - class GetItemExamplesFromRegistries < Base - def call(items:, **) - resolved = items.map do |n| - c = @registry.find(n) - c ? {name: c[:name], examples: c[:examples] || []} : nil - end.compact - {items: resolved, gem_version: @registry.version} - end - end - end - end -end -``` - -```ruby -# mcp/lib/ruby_ui/mcp/tools/get_add_command_for_items.rb -# frozen_string_literal: true -require "ruby_ui/mcp/tools/base" - -module RubyUI - module MCP - module Tools - class GetAddCommandForItems < Base - GENERATOR = "ruby_ui:component" - - def call(items:, **) - known, unresolved = @registry.partition_names(Array(items)) - { - generator: GENERATOR, - components: known, - unresolved: unresolved, - command_string: known.empty? ? "" : "rails g #{GENERATOR} #{known.join(" ")}", - gem_version: @registry.version - } - end - end - end - end -end -``` - -```ruby -# mcp/lib/ruby_ui/mcp/tools/get_audit_checklist.rb -# frozen_string_literal: true -require "ruby_ui/mcp/tools/base" - -module RubyUI - module MCP - module Tools - class GetAuditChecklist < Base - CHECKLIST = [ - {check: "gem_in_gemfile", description: "`ruby_ui` gem present in Gemfile."}, - {check: "components_copied", description: "Component files exist under app/components/ruby_ui//."}, - {check: "stimulus_registered", description: "Stimulus controllers registered (where applicable)."}, - {check: "js_packages_installed", description: "JS packages from dependencies.yml present in package.json."}, - {check: "tailwind_content_paths", description: "Tailwind content config includes app/components/ruby_ui/**/*."}, - {check: "zeitwerk_loads", description: "Zeitwerk loads the RubyUI namespace without errors."}, - {check: "views_compile", description: "Generated Phlex views render without errors."} - ].freeze - - def call(**) - {checklist: CHECKLIST} - end - end - end - end -end -``` - -- [ ] **Step 4: Run, verify pass** - -Run: `cd mcp && bundle exec rake test` -Expected: all PASS. - -- [ ] **Step 5: Commit** - -```bash -git add mcp/lib/ruby_ui/mcp/tools mcp/test/tools -git commit -m "[Feature] MCP tools: examples, add_command, audit" -``` - ---- - -## Task 7: MCP Server wiring (ruby-sdk integration) - -**Files:** -- Create: `mcp/lib/ruby_ui/mcp/server.rb` -- Create: `mcp/test/server_test.rb` - -- [ ] **Step 1: Read ruby-sdk docs** - -Run: `cd mcp && bundle info mcp` and skim `https://github.com/modelcontextprotocol/ruby-sdk` README. Confirm Tool/Server API surface used below matches installed version. Adjust class/method names if SDK has evolved. - -- [ ] **Step 2: Write smoke test** - -```ruby -# mcp/test/server_test.rb -require "test_helper" -require "ruby_ui/mcp/server" - -class ServerTest < Minitest::Test - def test_lists_seven_tools - registry = RubyUI::MCP::Registry.load(TestSupport::FIXTURE_PATH) - server = RubyUI::MCP::Server.build(registry: registry) - names = server.tools.map(&:name).sort - assert_equal 7, names.length - expected = %w[ - get_add_command_for_items - get_audit_checklist - get_item_examples_from_registries - get_project_registries - list_items_in_registries - search_items_in_registries - view_items_in_registries - ] - assert_equal expected, names - end -end -``` - -- [ ] **Step 3: Run, verify failure** - -Run: `cd mcp && bundle exec rake test TEST=test/server_test.rb` -Expected: FAIL — Server missing. - -- [ ] **Step 4: Implement Server** - -```ruby -# mcp/lib/ruby_ui/mcp/server.rb -# frozen_string_literal: true -require "mcp" -require "ruby_ui/mcp/registry" -require "ruby_ui/mcp/tools/get_project_registries" -require "ruby_ui/mcp/tools/list_items_in_registries" -require "ruby_ui/mcp/tools/search_items_in_registries" -require "ruby_ui/mcp/tools/view_items_in_registries" -require "ruby_ui/mcp/tools/get_item_examples_from_registries" -require "ruby_ui/mcp/tools/get_add_command_for_items" -require "ruby_ui/mcp/tools/get_audit_checklist" - -module RubyUI - module MCP - class Server - TOOL_DEFINITIONS = [ - {name: "get_project_registries", klass: Tools::GetProjectRegistries, schema: {}}, - {name: "list_items_in_registries", klass: Tools::ListItemsInRegistries, schema: {}}, - {name: "search_items_in_registries", klass: Tools::SearchItemsInRegistries, - schema: {query: {type: :string, required: true}, limit: {type: :integer}}}, - {name: "view_items_in_registries", klass: Tools::ViewItemsInRegistries, - schema: {items: {type: :array, required: true}}}, - {name: "get_item_examples_from_registries", klass: Tools::GetItemExamplesFromRegistries, - schema: {items: {type: :array, required: true}}}, - {name: "get_add_command_for_items", klass: Tools::GetAddCommandForItems, - schema: {items: {type: :array, required: true}}}, - {name: "get_audit_checklist", klass: Tools::GetAuditChecklist, schema: {}} - ].freeze - - def self.build(registry: RubyUI::MCP.registry) - new(registry: registry).server - end - - attr_reader :tools - - def initialize(registry:) - @registry = registry - @tools = TOOL_DEFINITIONS.map { |d| build_tool(d) } - end - - def server - ::MCP::Server.new(name: "ruby_ui", version: RubyUI::MCP::VERSION, tools: @tools) - end - - private - - def build_tool(definition) - impl = definition[:klass].new(registry: @registry) - ::MCP::Tool.define( - name: definition[:name], - description: definition[:klass].name, - input_schema: definition[:schema] - ) do |args| - impl.call(**(args || {}).transform_keys(&:to_sym)) - rescue => e - {error: e.message} - end - end - end - end -end -``` - -NOTE: The exact `MCP::Tool.define` / `MCP::Server.new` API depends on the installed `mcp` gem version. If the API differs (e.g., subclassing `MCP::Tool` instead of `define`), adapt accordingly — keep the per-tool dispatch and exception trap behavior. - -- [ ] **Step 5: Run, verify pass** - -Run: `cd mcp && bundle exec rake test` -Expected: all PASS. - -- [ ] **Step 6: Commit** - -```bash -git add mcp/lib/ruby_ui/mcp/server.rb mcp/test/server_test.rb -git commit -m "[Feature] MCP Server wiring with 7 tools" -``` - ---- - -## Task 8: Rails Engine HTTP mount - -**Files:** -- Modify: `mcp/lib/ruby_ui/mcp/engine.rb` -- Create: `mcp/lib/ruby_ui/mcp/rack_app.rb` -- Create: `mcp/config/routes.rb` - -- [ ] **Step 1: Implement Rack app wrapping ruby-sdk HTTP transport** - -```ruby -# mcp/lib/ruby_ui/mcp/rack_app.rb -# frozen_string_literal: true -require "ruby_ui/mcp/server" -require "mcp/transports/streamable_http" # adjust if SDK path differs - -module RubyUI - module MCP - class RackApp - def self.call(env) - new.call(env) - end - - def call(env) - server = Server.build - transport = ::MCP::Transports::StreamableHTTP.new(server) - transport.call(env) - rescue => e - Rails.logger.tagged("MCP") { Rails.logger.error("#{e.class}: #{e.message}") } - [500, {"content-type" => "application/json"}, [{error: "internal"}.to_json]] - end - end - end -end -``` - -- [ ] **Step 2: Routes** - -```ruby -# mcp/config/routes.rb -RubyUI::MCP::Engine.routes.draw do - match "/", to: "RubyUI::MCP::RackApp", via: %i[get post], as: :mcp_root -end -``` - -If `match` to a Rack class doesn't work, use `mount` in the host app instead and skip engine-level routes. - -- [ ] **Step 3: Verify engine boots in isolation** - -Run: `cd mcp && bundle exec ruby -Ilib -e "require 'ruby_ui/mcp'; puts RubyUI::MCP::Engine.routes.routes.map(&:path).map(&:spec).join(', ')"` -Expected: lists `/` route. (If SDK transport differs, adjust before proceeding.) - -- [ ] **Step 4: Commit** - -```bash -git add mcp/lib/ruby_ui/mcp/rack_app.rb mcp/lib/ruby_ui/mcp/engine.rb mcp/config/routes.rb -git commit -m "[Feature] MCP Rails engine + Rack mount point" -``` - ---- - -## Task 9: Mount engine in `docs/` Rails app - -**Files:** -- Modify: `docs/Gemfile` -- Modify: `docs/config/routes.rb` -- Modify: `docs/config/application.rb` (add Rack::Attack middleware) -- Create: `docs/config/initializers/rack_attack.rb` - -- [ ] **Step 1: Add gem to docs Gemfile** - -Add line to `docs/Gemfile`: - -```ruby -gem "ruby_ui-mcp", path: "../mcp" -``` - -- [ ] **Step 2: Bundle** - -Run: `cd docs && bundle install` -Expected: resolves `ruby_ui-mcp 0.1.0` from path source. - -- [ ] **Step 3: Mount in routes** - -Append to `docs/config/routes.rb` (top-level, before catch-alls): - -```ruby -mount RubyUI::MCP::Engine => "/mcp" -``` - -- [ ] **Step 4: Configure rate limit** - -```ruby -# docs/config/initializers/rack_attack.rb -class Rack::Attack - throttle("mcp/ip", limit: 60, period: 60.seconds) do |req| - req.ip if req.path.start_with?("/mcp") - end -end - -Rails.application.config.middleware.use Rack::Attack -``` - -- [ ] **Step 5: Smoke test boot** - -Run: `cd docs && bin/rails runner "puts Rails.application.routes.routes.map { |r| r.path.spec.to_s }.grep(/mcp/)"` -Expected: prints `/mcp` route(s). - -- [ ] **Step 6: Smoke test request (in devcontainer)** - -Run docker exec rails server (per CLAUDE.local.md), then: - -```bash -curl -X POST http://localhost:3001/mcp \ - -H 'content-type: application/json' \ - -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' -``` - -Expected: JSON response listing 7 tools. - -- [ ] **Step 7: Commit** - -```bash -git add docs/Gemfile docs/Gemfile.lock docs/config/routes.rb docs/config/initializers/rack_attack.rb -git commit -m "[Feature] Mount ruby_ui-mcp engine in docs app at /mcp" -``` - ---- - -## Task 10: Docs page — Views::Docs::Mcp - -**Files:** -- Create: `docs/app/views/docs/mcp.rb` -- Modify: `docs/app/controllers/docs_controller.rb` -- Modify: `docs/config/routes.rb` -- Modify: `docs/app/components/shared/menu.rb` - -- [ ] **Step 1: Add controller action** - -In `docs/app/controllers/docs_controller.rb` add: - -```ruby -def mcp -end -``` - -- [ ] **Step 2: Add route** - -In `docs/config/routes.rb` inside the existing docs scope: - -```ruby -get "mcp", to: "docs#mcp", as: :docs_mcp -``` - -- [ ] **Step 3: Add menu entry** - -In `docs/app/components/shared/menu.rb`, add "MCP" link to the Getting Started or Tools section: - -```ruby -{ title: "MCP Server", path: "/docs/mcp" } -``` - -(Match the exact data shape used in that file.) - -- [ ] **Step 4: Write the docs view (shadcn-style)** - -```ruby -# docs/app/views/docs/mcp.rb -class Views::Docs::Mcp < Views::Base - def view_template - div(class: "mx-auto w-full py-10 space-y-10") do - render Docs::Header.new( - title: "MCP Server", - description: "Use the Ruby UI MCP server to give your AI agent access to component source, examples, and an install command." - ) - - Heading(level: 2) { "Setup" } - P { "Add the MCP server to your editor or AI client. The endpoint is hosted at " } - Codeblock(content: "https://rubyui.com/mcp", language: "text") - - render Docs::ClientTabs.new do |tabs| - tabs.tab("Claude Code", "claude mcp add --transport http ruby-ui https://rubyui.com/mcp", "bash") - tabs.tab("Cursor", cursor_config_json, "json") - tabs.tab("Claude Desktop", claude_desktop_config_json, "json") - tabs.tab("Windsurf", windsurf_config_json, "json") - tabs.tab("VS Code", vscode_config_json, "json") - tabs.tab("Zed", zed_config_json, "json") - end - - Heading(level: 2) { "Usage" } - P { "Once the MCP is connected, ask your agent things like:" } - ul(class: "list-disc pl-6 space-y-1") do - li { "Install Button and Dialog from Ruby UI." } - li { "Show me the source of the Card component." } - li { "Search Ruby UI for a date input." } - li { "Audit my Ruby UI install." } - end - - Heading(level: 2) { "Tools" } - render Docs::ComponentsTable.new(tools_table_rows) - - Heading(level: 2) { "Troubleshooting" } - ul(class: "list-disc pl-6 space-y-2") do - li { "Endpoint must be reachable from the client; corporate proxies may block streamable HTTP." } - li { "If your agent can't find components, try `get_project_registries` first to confirm the registry is loaded." } - li { "Run `bundle exec rails g ruby_ui:component ` only inside a Rails app that has the `ruby_ui` gem in its Gemfile." } - end - end - end - - private - - def cursor_config_json - <<~JSON - { - "mcpServers": { - "ruby-ui": { "url": "https://rubyui.com/mcp" } - } - } - JSON - end - - def claude_desktop_config_json - <<~JSON - { - "mcpServers": { - "ruby-ui": { "url": "https://rubyui.com/mcp" } - } - } - JSON - end - - def windsurf_config_json = cursor_config_json - def vscode_config_json = cursor_config_json - def zed_config_json = cursor_config_json - - def tools_table_rows - [ - ["get_project_registries", "Lists available registries (always returns ruby_ui)."], - ["list_items_in_registries", "Returns all components with descriptions."], - ["search_items_in_registries", "Fuzzy search by name, description, or docs."], - ["view_items_in_registries", "Returns full source files and dependencies for selected components."], - ["get_item_examples_from_registries", "Returns code examples per component."], - ["get_add_command_for_items", "Returns a validated `rails g ruby_ui:component …` command."], - ["get_audit_checklist", "Returns a post-install verification checklist."] - ] - end -end -``` - -NOTE: `Docs::ClientTabs` may not exist — if the codebase doesn't already have a tabs component for code snippets, reuse the existing pattern from `docs/app/views/docs/installation.rb` (or whichever existing page has multi-tab install snippets) and adapt. Do not invent new components for this. - -- [ ] **Step 5: Browser-test in devcontainer** - -Visit `http://localhost:3001/docs/mcp`. Verify all sections render, tabs switch, code blocks copy. - -- [ ] **Step 6: Commit** - -```bash -git add docs/app/views/docs/mcp.rb docs/app/controllers/docs_controller.rb docs/config/routes.rb docs/app/components/shared/menu.rb -git commit -m "[Documentation] Add MCP docs page with multi-client install tabs" -``` - ---- - -## Task 11: CI integration - -**Files:** -- Modify: `.github/workflows/ci.yml` - -- [ ] **Step 1: Add mcp test job** - -Append to `.github/workflows/ci.yml`: - -```yaml - mcp-test: - runs-on: ubuntu-latest - defaults: - run: - working-directory: mcp - steps: - - uses: actions/checkout@v4 - - uses: ruby/setup-ruby@v1 - with: - ruby-version: "3.3" - bundler-cache: true - working-directory: mcp - - run: bundle exec rake - - mcp-registry-check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ruby/setup-ruby@v1 - with: - ruby-version: "3.3" - bundler-cache: true - working-directory: mcp - - name: Rebuild registry - working-directory: mcp - run: bundle exec exe/ruby-ui-mcp-build - - name: Fail on diff - run: | - if ! git diff --exit-code mcp/data/registry.json; then - echo "registry.json out of date — run 'cd mcp && bundle exec exe/ruby-ui-mcp-build' and commit" - exit 1 - fi -``` - -- [ ] **Step 2: Push branch, verify CI green** - -Run: `git push origin da/mcp` and watch GitHub Actions. Both new jobs must pass. - -- [ ] **Step 3: Commit (if not pushed yet)** - -```bash -git add .github/workflows/ci.yml -git commit -m "[CI] Add ruby_ui-mcp test + registry-drift jobs" -``` - ---- - -## Task 12: Documentation polish + README - -**Files:** -- Create: `mcp/README.md` -- Modify: `README.md` (root, if exists — add MCP section) - -- [ ] **Step 1: Write `mcp/README.md`** - -Cover: what it is, how to develop locally (`bundle && rake test`), how to rebuild registry (`exe/ruby-ui-mcp-build`), how it's deployed (mounted in docs/), tool reference link to docs site. - -- [ ] **Step 2: Commit** - -```bash -git add mcp/README.md README.md -git commit -m "[Documentation] Add MCP README" -``` - ---- - -## Self-Review Notes - -- All 7 tools from spec → Tasks 5 + 6. -- Registry schema → Task 2 (model) + Task 3 (builder) + Task 4 (artifact). -- Engine + HTTP transport → Tasks 7–9. -- Rate limit → Task 9. -- Docs page (shadcn-style multi-client install) → Task 10. -- CI gates including registry drift → Task 11. -- Security (allowlist + structured commands) → Task 6 add_command tests + impl. -- Audit checklist matches spec items → Task 6. - -Known soft spots: -- `mcp` ruby-sdk API surface (Tool.define / Server / StreamableHTTP) is assumed; Task 7 step 1 explicitly directs the implementer to verify against installed gem version and adjust. -- `docs_markdown` extraction is regex-based as a phase-1 heuristic (Task 3); a follow-up can render Phlex views properly. Examples extraction is empty in v1. -- `Docs::ClientTabs` may need to be implemented or replaced with existing codebase pattern (Task 10 step 4 NOTE).