Skip to content

[Feature] Add Spinner component - #521

Open
tvq wants to merge 2 commits into
ruby-ui:mainfrom
tvq:add_spinner
Open

[Feature] Add Spinner component#521
tvq wants to merge 2 commits into
ruby-ui:mainfrom
tvq:add_spinner

Conversation

@tvq

@tvq tvq commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

What

RubyUI::Spinner — a port of shadcn/ui Spinner. Inline SVG (lucide loader-circle), role="status", aria-label, data-slot="spinner", size-4 animate-spin. No JS, no Stimulus, no dependencies. Size and colour come from the caller via class:.

Docs page at /docs/spinner with the upstream examples that fit our component set: standalone, sizes, colours, inside Button (disabled + aria-busy), inside Badge, inside Empty as a loading screen, plus Customization and Accessibility sections.

Why

shadcn has no loading prop on Button. It ships a standalone Spinner that gets composed into buttons, badges, input groups and empty states. RubyUI had Skeleton, Progress and Empty but nothing to put inside them — this closes that gap without touching Button, so it can land in any order relative to the Button work.

Departures from upstream (deliberate, each forced by Phlex or by our Button)

  • label: kwarg instead of overriding aria-label through attrs. Phlex mix concatenates attribute values, so aria: {label: "Saving"} rendered aria-label="Loading Saving". Same trade-off every other RubyUI component makes (variant:, size:, pressed:).
  • label: nil → decorative (aria-hidden="true", no role). For a spinner inside a control that announces aria-busy itself. Upstream nests a role="status" spinner inside the button, which makes the button's accessible name read the label twice. Default stays "Loading" for parity on a standalone spinner.
  • shrink-0 on the icon. Our Button lacks shadcn's [&_svg]:shrink-0; this can go once Button gets it.

Everything else passes through **attrs, so data: {icon: "inline-start"} works unchanged when Button learns the vega padding convention.

Also in this PR

Separate first commit: the MCP registry builder ignored standalone Codeblock(<<~RUBY, ...) samples in docs views, leaving empty headings in docs_markdown. Fixed so the Spinner docs (and Form / InputOTP, which had the same gap) render fully for MCP consumers. registry.json, llms.txt, llms-full.txt and sitemap.xml are regenerated.

Test steps

cd gem  && bundle exec rake                                   # 294 runs, 0 failures, standardrb clean
cd docs && bin/rails db:test:prepare test && bundle exec standardrb
cd docs && pnpm build:css && grep -c animate-spin app/assets/builds/application.css
cd mcp  && bundle exec rake test                              # 30 runs, 0 failures

Then cd docs && bin/dev, open /docs/spinner, toggle dark mode. Inspect a Button example: the <button> carries aria-busy="true", the <svg> inside carries aria-hidden="true".

Screenshots (light / dark) to follow.


Summary by cubic

Adds RubyUI::Spinner, an inline SVG loading indicator ported from shadcn/ui, with no JS, Stimulus, or icon dependencies. Size and color come from the caller via class:, so it composes into Button, Badge, and Empty without changes to those components.

New Features

  • Standalone spinners announce themselves with role="status" and an aria-label defaulting to "Loading"; pass label: to describe the operation.
  • Pass label: nil to render the spinner decorative (aria-hidden="true") inside a control that announces aria-busy itself, so the label is not read twice.
  • Adds a docs page at /docs/spinner covering sizes, colors, composition, customization, and accessibility.

Bug Fixes

  • Fixes the MCP registry builder so standalone Codeblock(<<~RUBY, ...) samples in docs views are included in docs_markdown; Form and InputOTP previously rendered with empty headings.
  • Regenerates registry.json, llms.txt, llms-full.txt, and sitemap.xml.

Written for commit e040d23. Summary will update on new commits.

Review in cubic

tvq and others added 2 commits August 29, 2026 13:24
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@tvq
tvq requested a review from cirdes as a code owner August 29, 2026 11:25

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 13 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="gem/lib/ruby_ui/spinner/spinner.rb">

<violation number="1" location="gem/lib/ruby_ui/spinner/spinner.rb:40">
P3: When `label: ""` (an empty string) is passed, `@label.nil?` is false, so the spinner renders `role="status" aria-label=""` — an empty live region that announces nothing. `label` only falls back to decorative mode on `nil`, not on blank values. Treat blank strings as decorative too (or validate them) so an accidentally empty label can't produce a silent `role="status"`.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment on lines +40 to +43
def announcement_attrs
return {aria: {hidden: "true"}} if @label.nil?

{role: "status", aria: {label: @label}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When label: "" (an empty string) is passed, @label.nil? is false, so the spinner renders role="status" aria-label="" — an empty live region that announces nothing. label only falls back to decorative mode on nil, not on blank values. Treat blank strings as decorative too (or validate them) so an accidentally empty label can't produce a silent role="status".

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/lib/ruby_ui/spinner/spinner.rb, line 40:

<comment>When `label: ""` (an empty string) is passed, `@label.nil?` is false, so the spinner renders `role="status" aria-label=""` — an empty live region that announces nothing. `label` only falls back to decorative mode on `nil`, not on blank values. Treat blank strings as decorative too (or validate them) so an accidentally empty label can't produce a silent `role="status"`.</comment>

<file context>
@@ -0,0 +1,46 @@
+    end
+
+    # A spinner inside a labelled control is decorative — the control announces busy.
+    def announcement_attrs
+      return {aria: {hidden: "true"}} if @label.nil?
+
</file context>
Suggested change
def announcement_attrs
return {aria: {hidden: "true"}} if @label.nil?
{role: "status", aria: {label: @label}}
def announcement_attrs
return {aria: {hidden: "true"}} if @label.nil? || @label.empty?
{role: "status", aria: {label: @label}}
end

@cirdes
cirdes requested review from djalmaaraujo and removed request for cirdes August 31, 2026 11:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant