From f95fb0a44e512080a8e68222160c25bd2f688b1a Mon Sep 17 00:00:00 2001 From: Tomek Date: Sat, 29 Aug 2026 13:24:38 +0200 Subject: [PATCH 1/3] [Bug Fix] MCP registry: capture Codeblock samples in docs_markdown The registry builder only read heredocs inside VisualCodeExample, so standalone Codeblock(<<~RUBY, ...) samples in a component's docs (Form, InputOTP) were dropped and left empty headings behind. Co-Authored-By: Claude Fable 5 --- mcp/data/registry.json | 4 +-- .../ruby_ui/mcp/builders/registry_builder.rb | 31 +++++++++++++------ 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/mcp/data/registry.json b/mcp/data/registry.json index ed4cd153a..868833a04 100644 --- a/mcp/data/registry.json +++ b/mcp/data/registry.json @@ -1524,7 +1524,7 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Form", - "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```\n\n## Rails Integration\n\n### Minimal Rails form\n\n### Devise-style login form", + "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```\n\n## Rails Integration\n\n### Minimal Rails form\n\n```ruby\n# In your Phlex view, call form_with via helpers:\n# form_with(url: users_path, method: :post) passes action + CSRF automatically.\n#\n# You can also set action and the CSRF token manually:\nForm(action: helpers.users_path, method: \"post\", class: \"w-2/3 space-y-6\") do\n input(type: \"hidden\", name: \"authenticity_token\", value: helpers.form_authenticity_token)\n\n FormField do\n FormFieldLabel(for: \"user_email\") { \"Email\" }\n Input(\n type: \"email\",\n id: \"user_email\",\n name: \"user[email]\",\n placeholder: \"you@example.com\",\n required: true\n )\n FormFieldError()\n end\n\n Button(type: \"submit\") { \"Continue\" }\nend\n```\n\n### Devise-style login form\n\n```ruby\n# Full sign-in form mirroring Devise session[email] / session[password] params.\n# Pass backend errors (e.g. \"Invalid email or password\") into FormFieldError.\nForm(action: helpers.user_session_path, method: \"post\", class: \"space-y-6\") do\n input(type: \"hidden\", name: \"authenticity_token\", value: helpers.form_authenticity_token)\n\n FormField do\n FormFieldLabel(for: \"session_email\") { \"Email\" }\n Input(\n type: \"email\",\n id: \"session_email\",\n name: \"session[email]\",\n placeholder: \"you@example.com\",\n autocomplete: \"email\",\n required: true\n )\n FormFieldError { @error_message }\n end\n\n FormField do\n FormFieldLabel(for: \"session_password\") { \"Password\" }\n Input(\n type: \"password\",\n id: \"session_password\",\n name: \"session[password]\",\n autocomplete: \"current-password\",\n required: true,\n minlength: \"8\"\n )\n FormFieldError()\n end\n\n FormField do\n div(class: \"flex items-center gap-2\") do\n Checkbox(id: \"session_remember_me\", name: \"session[remember_me]\", value: \"1\")\n FormFieldLabel(for: \"session_remember_me\") { \"Remember me\" }\n end\n end\n\n Button(type: \"submit\", class: \"w-full\") { \"Sign in\" }\nend\n```", "examples": [ { "title": "Example", @@ -1686,7 +1686,7 @@ "gems": [] }, "install_command": "rails g ruby_ui:component InputOtp", - "docs_markdown": "# Input OTP\n\nAccessible one-time-password input with keyboard navigation and paste support.\n\n## Usage\n\n### Example\n\n```ruby\nInputOtp(length: 6, name: \"otp\") do\n InputOtpGroup do\n InputOtpSlot(index: 0)\n InputOtpSlot(index: 1)\n InputOtpSlot(index: 2)\n InputOtpSlot(index: 3)\n InputOtpSlot(index: 4)\n InputOtpSlot(index: 5)\n end\nend\n```\n\n## Composition\n\n### Example\n\n```ruby\nInputOtp(length: 6, name: \"otp\") do\n InputOtpGroup do\n InputOtpSlot(index: 0)\n InputOtpSlot(index: 1)\n end\n InputOtpSeparator()\n InputOtpGroup do\n InputOtpSlot(index: 2)\n InputOtpSlot(index: 3)\n end\n InputOtpSeparator()\n InputOtpGroup do\n InputOtpSlot(index: 4)\n InputOtpSlot(index: 5)\n end\nend\n```\n\n## Pattern\n\n### Alphanumeric\n\n```ruby\nInputOtp(length: 6, name: \"otp\", pattern: \"[0-9A-Za-z]\") do\n InputOtpGroup do\n InputOtpSlot(index: 0)\n InputOtpSlot(index: 1)\n InputOtpSlot(index: 2)\n InputOtpSlot(index: 3)\n InputOtpSlot(index: 4)\n InputOtpSlot(index: 5)\n end\nend\n```\n\n## Four digits\n\n### Example\n\n```ruby\nInputOtp(length: 4, name: \"pin\") do\n InputOtpGroup do\n InputOtpSlot(index: 0)\n InputOtpSlot(index: 1)\n InputOtpSlot(index: 2)\n InputOtpSlot(index: 3)\n end\nend\n```\n\n## Disabled\n\n### Example\n\n```ruby\nInputOtp(length: 6, name: \"otp\", disabled: true) do\n InputOtpGroup do\n InputOtpSlot(index: 0)\n InputOtpSlot(index: 1)\n InputOtpSlot(index: 2)\n InputOtpSlot(index: 3)\n InputOtpSlot(index: 4)\n InputOtpSlot(index: 5)\n end\nend\n```\n\n## Invalid\n\n### Example\n\n```ruby\nInputOtp(length: 6, name: \"otp\") do\n InputOtpGroup do\n InputOtpSlot(index: 0, aria_invalid: \"true\")\n InputOtpSlot(index: 1, aria_invalid: \"true\")\n InputOtpSlot(index: 2, aria_invalid: \"true\")\n InputOtpSlot(index: 3, aria_invalid: \"true\")\n InputOtpSlot(index: 4, aria_invalid: \"true\")\n InputOtpSlot(index: 5, aria_invalid: \"true\")\n end\nend\n```\n\n## Form\n\n### Verify your login\n\n```ruby\nCard(class: \"mx-auto max-w-md\") do\n CardHeader do\n CardTitle { \"Verify your login\" }\n CardDescription do\n plain \"Enter the verification code we sent to your email address: \"\n span(class: \"font-medium\") { \"m@example.com\" }\n plain \".\"\n end\n end\n CardContent(class: \"space-y-4\") do\n div(class: \"flex items-center justify-between\") do\n label(class: \"text-sm font-medium\") { \"Verification code\" }\n Button(variant: :outline, size: :sm) do\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: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(d: \"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8\")\n s.path(d: \"M21 3v5h-5\")\n s.path(d: \"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16\")\n s.path(d: \"M8 16H3v5\")\n end\n plain \"Resend Code\"\n end\n end\n InputOtp(length: 6, name: \"otp\", required: true) do\n InputOtpGroup do\n InputOtpSlot(index: 0, class: \"h-12 w-11 text-xl\")\n InputOtpSlot(index: 1, class: \"h-12 w-11 text-xl\")\n InputOtpSlot(index: 2, class: \"h-12 w-11 text-xl\")\n end\n InputOtpSeparator(class: \"mx-2\")\n InputOtpGroup do\n InputOtpSlot(index: 3, class: \"h-12 w-11 text-xl\")\n InputOtpSlot(index: 4, class: \"h-12 w-11 text-xl\")\n InputOtpSlot(index: 5, class: \"h-12 w-11 text-xl\")\n end\n end\n InlineLink(href: \"#\") { \"I no longer have access to this email address.\" }\n end\n CardFooter(class: \"flex flex-col items-stretch gap-2\") do\n Button(class: \"w-full\") { \"Verify\" }\n Text(size: \"sm\", weight: \"muted\") do\n plain \"Having trouble signing in? \"\n InlineLink(href: \"#\") { \"Contact support\" }\n end\n end\nend\n```\n\n## Reacting to completion", + "docs_markdown": "# Input OTP\n\nAccessible one-time-password input with keyboard navigation and paste support.\n\n## Usage\n\n### Example\n\n```ruby\nInputOtp(length: 6, name: \"otp\") do\n InputOtpGroup do\n InputOtpSlot(index: 0)\n InputOtpSlot(index: 1)\n InputOtpSlot(index: 2)\n InputOtpSlot(index: 3)\n InputOtpSlot(index: 4)\n InputOtpSlot(index: 5)\n end\nend\n```\n\n## Composition\n\n### Example\n\n```ruby\nInputOtp(length: 6, name: \"otp\") do\n InputOtpGroup do\n InputOtpSlot(index: 0)\n InputOtpSlot(index: 1)\n end\n InputOtpSeparator()\n InputOtpGroup do\n InputOtpSlot(index: 2)\n InputOtpSlot(index: 3)\n end\n InputOtpSeparator()\n InputOtpGroup do\n InputOtpSlot(index: 4)\n InputOtpSlot(index: 5)\n end\nend\n```\n\n## Pattern\n\n### Alphanumeric\n\n```ruby\nInputOtp(length: 6, name: \"otp\", pattern: \"[0-9A-Za-z]\") do\n InputOtpGroup do\n InputOtpSlot(index: 0)\n InputOtpSlot(index: 1)\n InputOtpSlot(index: 2)\n InputOtpSlot(index: 3)\n InputOtpSlot(index: 4)\n InputOtpSlot(index: 5)\n end\nend\n```\n\n## Four digits\n\n### Example\n\n```ruby\nInputOtp(length: 4, name: \"pin\") do\n InputOtpGroup do\n InputOtpSlot(index: 0)\n InputOtpSlot(index: 1)\n InputOtpSlot(index: 2)\n InputOtpSlot(index: 3)\n end\nend\n```\n\n## Disabled\n\n### Example\n\n```ruby\nInputOtp(length: 6, name: \"otp\", disabled: true) do\n InputOtpGroup do\n InputOtpSlot(index: 0)\n InputOtpSlot(index: 1)\n InputOtpSlot(index: 2)\n InputOtpSlot(index: 3)\n InputOtpSlot(index: 4)\n InputOtpSlot(index: 5)\n end\nend\n```\n\n## Invalid\n\n### Example\n\n```ruby\nInputOtp(length: 6, name: \"otp\") do\n InputOtpGroup do\n InputOtpSlot(index: 0, aria_invalid: \"true\")\n InputOtpSlot(index: 1, aria_invalid: \"true\")\n InputOtpSlot(index: 2, aria_invalid: \"true\")\n InputOtpSlot(index: 3, aria_invalid: \"true\")\n InputOtpSlot(index: 4, aria_invalid: \"true\")\n InputOtpSlot(index: 5, aria_invalid: \"true\")\n end\nend\n```\n\n## Form\n\n### Verify your login\n\n```ruby\nCard(class: \"mx-auto max-w-md\") do\n CardHeader do\n CardTitle { \"Verify your login\" }\n CardDescription do\n plain \"Enter the verification code we sent to your email address: \"\n span(class: \"font-medium\") { \"m@example.com\" }\n plain \".\"\n end\n end\n CardContent(class: \"space-y-4\") do\n div(class: \"flex items-center justify-between\") do\n label(class: \"text-sm font-medium\") { \"Verification code\" }\n Button(variant: :outline, size: :sm) do\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: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(d: \"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8\")\n s.path(d: \"M21 3v5h-5\")\n s.path(d: \"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16\")\n s.path(d: \"M8 16H3v5\")\n end\n plain \"Resend Code\"\n end\n end\n InputOtp(length: 6, name: \"otp\", required: true) do\n InputOtpGroup do\n InputOtpSlot(index: 0, class: \"h-12 w-11 text-xl\")\n InputOtpSlot(index: 1, class: \"h-12 w-11 text-xl\")\n InputOtpSlot(index: 2, class: \"h-12 w-11 text-xl\")\n end\n InputOtpSeparator(class: \"mx-2\")\n InputOtpGroup do\n InputOtpSlot(index: 3, class: \"h-12 w-11 text-xl\")\n InputOtpSlot(index: 4, class: \"h-12 w-11 text-xl\")\n InputOtpSlot(index: 5, class: \"h-12 w-11 text-xl\")\n end\n end\n InlineLink(href: \"#\") { \"I no longer have access to this email address.\" }\n end\n CardFooter(class: \"flex flex-col items-stretch gap-2\") do\n Button(class: \"w-full\") { \"Verify\" }\n Text(size: \"sm\", weight: \"muted\") do\n plain \"Having trouble signing in? \"\n InlineLink(href: \"#\") { \"Contact support\" }\n end\n end\nend\n```\n\n## Reacting to completion\n\n```js\n// app/javascript/controllers/otp_form_controller.js\nimport { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n submit() {\n this.element.requestSubmit()\n }\n}\n```\n\n```html\n
otp-form#submit\">\n \n
\n```", "examples": [ { "title": "Example", diff --git a/mcp/lib/ruby_ui/mcp/builders/registry_builder.rb b/mcp/lib/ruby_ui/mcp/builders/registry_builder.rb index 521e0fb51..3541724eb 100644 --- a/mcp/lib/ruby_ui/mcp/builders/registry_builder.rb +++ b/mcp/lib/ruby_ui/mcp/builders/registry_builder.rb @@ -136,6 +136,15 @@ def parse_docs_source(src) next end + # Codeblock(<<~LANG, ...) — standalone sample, not a live example + if (m = line.match(/\bCodeblock\(<<~([A-Z]+)/)) + lang = m[1] + code, k = heredoc_body(lines, i + 1, lang) + markdown << "```#{lang.downcase}\n#{code}```\n\n" + i = k + 1 + next + end + # VisualCodeExample.new(...) if line.match(/VisualCodeExample\.new\(/) # collect full invocation (may span multiple lines until closing paren + do) @@ -164,15 +173,7 @@ def parse_docs_source(src) 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) + code, k = heredoc_body(lines, k, heredoc_lang) lang = heredoc_lang.downcase == "ruby" ? "ruby" : heredoc_lang.downcase examples << {title: title, code: code, language: lang} @@ -203,6 +204,18 @@ def extract_kwarg(str, key) end end + # Collects a squiggly-heredoc body starting at `start`; returns [dedented code, terminator index]. + def heredoc_body(lines, start, terminator) + k = start + body = [] + while k < lines.length + break if lines[k].match?(/^\s*#{Regexp.escape(terminator)}\s*$/) + body << lines[k] + k += 1 + end + [dedent(body), k] + end + def dedent(lines) return "" if lines.empty? # find minimum indentation (ignore blank lines) From e040d23f069b028585e16dac42b4225aaa863167 Mon Sep 17 00:00:00 2001 From: Tomek Date: Sat, 29 Aug 2026 13:24:49 +0200 Subject: [PATCH 2/3] [Feature] Add Spinner component Port of shadcn/ui Spinner: an inline SVG loading indicator with role="status" and animate-spin, sized from the outside with size-*. shadcn has no `loading` prop; it composes a standalone Spinner into buttons, badges and empty states. RubyUI had no equivalent. Departures from upstream, each forced by Phlex or by our Button: - `label:` kwarg instead of overriding aria-label through attrs. Phlex `mix` concatenates attribute values, so passing aria: {label: "Saving"} rendered aria-label="Loading Saving". - `label: nil` renders aria-hidden="true" with no role, for a spinner inside a control that announces aria-busy itself. Upstream nests a role="status" spinner inside the button, which reads the label twice. - `shrink-0` on the icon: our Button has no [&_svg]:shrink-0 yet. The SVG is inlined because the gem ships no icon dependency. Docs show how to swap view_template for phlex-icons once the file is generated into the app, which is the shadcn customization model. Co-Authored-By: Claude Fable 5 --- docs/app/components/shared/components_list.rb | 1 + docs/app/controllers/docs_controller.rb | 4 + docs/app/lib/site_files.rb | 1 + docs/app/views/docs/spinner.rb | 171 ++++++++++++++++++ docs/config/routes.rb | 1 + docs/public/llms-full.txt | 5 + docs/public/llms.txt | 1 + docs/public/sitemap.xml | 5 + gem/lib/ruby_ui/spinner/spinner.rb | 46 +++++ gem/lib/ruby_ui/spinner/spinner_docs.rb | 171 ++++++++++++++++++ gem/test/ruby_ui/spinner_test.rb | 60 ++++++ mcp/data/registry.json | 49 +++++ 12 files changed, 515 insertions(+) create mode 100644 docs/app/views/docs/spinner.rb create mode 100644 gem/lib/ruby_ui/spinner/spinner.rb create mode 100644 gem/lib/ruby_ui/spinner/spinner_docs.rb create mode 100644 gem/test/ruby_ui/spinner_test.rb diff --git a/docs/app/components/shared/components_list.rb b/docs/app/components/shared/components_list.rb index a87ac08d5..37e66ea37 100644 --- a/docs/app/components/shared/components_list.rb +++ b/docs/app/components/shared/components_list.rb @@ -50,6 +50,7 @@ def components {name: "Shortcut Key", path: docs_shortcut_key_path}, {name: "Sidebar", path: docs_sidebar_path}, {name: "Skeleton", path: docs_skeleton_path}, + {name: "Spinner", path: docs_spinner_path}, {name: "Switch", path: docs_switch_path}, {name: "Table", path: docs_table_path}, {name: "Tabs", path: docs_tabs_path}, diff --git a/docs/app/controllers/docs_controller.rb b/docs/app/controllers/docs_controller.rb index da3b0464e..63864bd48 100644 --- a/docs/app/controllers/docs_controller.rb +++ b/docs/app/controllers/docs_controller.rb @@ -226,6 +226,10 @@ def skeleton render Views::Docs::Skeleton.new end + def spinner + render Views::Docs::Spinner.new + end + def switch render Views::Docs::Switch.new end diff --git a/docs/app/lib/site_files.rb b/docs/app/lib/site_files.rb index 94ef4ef81..b37c71f4e 100644 --- a/docs/app/lib/site_files.rb +++ b/docs/app/lib/site_files.rb @@ -123,6 +123,7 @@ class SiteFiles {title: "Shortcut Key", path: "/docs/shortcut_key", description: "Keyboard shortcut display component."}, {title: "Sidebar", path: "/docs/sidebar", description: "Composable, themeable sidebar component."}, {title: "Skeleton", path: "/docs/skeleton", description: "Placeholder for loading states."}, + {title: "Spinner", path: "/docs/spinner", description: "Animated indicator for loading states."}, {title: "Switch", path: "/docs/switch", description: "Toggle control for binary settings."}, {title: "Table", path: "/docs/table", description: "Responsive table component."}, {title: "Tabs", path: "/docs/tabs", description: "Layered tab panels displayed one at a time."}, diff --git a/docs/app/views/docs/spinner.rb b/docs/app/views/docs/spinner.rb new file mode 100644 index 000000000..679fd2432 --- /dev/null +++ b/docs/app/views/docs/spinner.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +class Views::Docs::Spinner < Views::Base + def view_template + component = "Spinner" + + div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do + render Docs::Header.new(title: "Spinner", description: "An indicator that can be used to show a loading state.") + + Heading(level: 2) { "Usage" } + + render Docs::VisualCodeExample.new(title: "Example", context: self) do + <<~RUBY + Spinner() + RUBY + end + + render Docs::VisualCodeExample.new(title: "Size", description: "The size is controlled from the outside with any size utility.", context: self) do + <<~RUBY + div(class: "flex items-center gap-6") do + Spinner(class: "size-3") + Spinner(class: "size-4") + Spinner(class: "size-6") + Spinner(class: "size-8") + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Color", description: "The spinner inherits the current text color.", context: self) do + <<~RUBY + div(class: "flex items-center gap-6") do + Spinner(class: "size-6 text-red-500") + Spinner(class: "size-6 text-green-500") + Spinner(class: "size-6 text-blue-500") + Spinner(class: "size-6 text-muted-foreground") + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Button", description: "The button carries the state, the spinner is decorative.", context: self) do + <<~RUBY + div(class: "flex flex-wrap items-center gap-4") do + Button(disabled: true, aria: {busy: "true"}, class: "gap-2") do + Spinner(label: nil) + plain "Loading..." + end + Button(variant: :outline, disabled: true, aria: {busy: "true"}, class: "gap-2") do + Spinner(label: nil) + plain "Please wait" + end + Button(variant: :secondary, size: :sm, disabled: true, aria: {busy: "true"}, class: "gap-2") do + Spinner(label: nil, class: "size-3") + plain "Processing" + end + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Badge", context: self) do + <<~RUBY + div(class: "flex flex-wrap items-center gap-4") do + Badge(class: "gap-1.5") do + Spinner(label: nil, class: "size-3") + plain "Syncing" + end + Badge(variant: :outline, class: "gap-1.5") do + Spinner(label: nil, class: "size-3") + plain "Updating" + end + Badge(variant: :destructive, class: "gap-1.5") do + Spinner(label: nil, class: "size-3") + plain "Deleting" + end + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Empty", description: "Use a spinner as the media of an empty state to build a loading screen.", context: self) do + <<~RUBY + Empty(class: "w-full") do + EmptyHeader do + EmptyMedia(variant: :icon) do + Spinner() + end + EmptyTitle { "Processing your request" } + EmptyDescription { "Please wait while we process your request. Do not refresh the page." } + end + EmptyContent do + Button(variant: :outline, size: :sm) { "Cancel" } + end + end + RUBY + end + + Heading(level: 2) { "Customization" } + + Text do + plain "The generator copies " + InlineCode { "spinner.rb" } + plain " into " + InlineCode { "app/components/ruby_ui/spinner/" } + plain ", so the component belongs to your app. RubyUI inlines the SVG because the gem ships no icon dependency — but if your app has one, such as " + InlineLink(href: "https://github.com/AliOsm/phlex-icons", target: "_blank") { "phlex-icons" } + plain ", replace " + InlineCode { "view_template" } + plain " with the icon component and drop the two constants." + end + + Codeblock(<<~RUBY, syntax: :ruby) + # app/components/ruby_ui/spinner/spinner.rb + module RubyUI + class Spinner < Base + include PhlexIcons + + def view_template + Lucide::LoaderPinwheel(**attrs) + end + + # initialize and default_attrs stay as they are; + # ICON_PATH and ICON_ATTRS are no longer needed + end + end + RUBY + + Text do + plain "Without an icon gem, swap " + InlineCode { "ICON_PATH" } + plain " for the path data of any other 24x24 stroke icon. An icon drawn with more than one path needs those extra elements added to " + InlineCode { "view_template" } + plain " too." + end + + Heading(level: 2) { "Accessibility" } + + Text do + plain "A standalone spinner announces itself with " + InlineCode { 'role="status"' } + plain " and " + InlineCode { 'aria-label="Loading"' } + plain ". Pass " + InlineCode { "label:" } + plain " to describe the specific operation." + end + + Text do + plain "Inside a button, badge or any other labelled control, pass " + InlineCode { "label: nil" } + plain " to render the spinner as decoration (" + InlineCode { 'aria-hidden="true"' } + plain ") and let the control announce the state with " + InlineCode { 'aria-busy="true"' } + plain ". A labelled spinner would otherwise be read out as part of the control's own name." + end + + Codeblock(<<~RUBY, syntax: :ruby) + # announces itself + Spinner(label: "Saving changes") + # => + + # decorative, inside a control that announces the state + Spinner(label: nil) + # =>