Skip to content

Commit dea0047

Browse files
jacksonpiresclaude
andauthored
[Feature] Allow TabsTrigger to render as an anchor via as: (#425)
* [Feature] Allow TabsTrigger to render as an anchor via as: Add an `as:` option to `RubyUI::TabsTrigger` (default `:button`) so a trigger can render as `<a href>` for tabs that are navigation links. This avoids nesting a `<button>` inside an `<a>` (invalid HTML and a keyboard/accessibility issue) when tabs are server-rendered links. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [Bug Fix] Emit type="button" for any non-anchor TabsTrigger Tie the `type="button"` attribute to the element actually rendered (`unless @as == :a`) instead of an exact `@as == :button` check. An unexpected/mistyped `as:` value fell through to the `<button>` branch without a type, which defaults to `type="submit"` and could trigger accidental form submission. Issue identified by cubic. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [Chore] Rebuild MCP registry for TabsTrigger as: option Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5a78514 commit dea0047

4 files changed

Lines changed: 63 additions & 5 deletions

File tree

docs/app/views/docs/tabs.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,20 @@ def view_template
102102
RUBY
103103
end
104104

105+
render Docs::VisualCodeExample.new(title: "As navigation links", context: self) do
106+
<<~RUBY
107+
# Render triggers as `<a>` instead of `<button>` for tabs that navigate
108+
# (e.g. server-rendered tabs driven by a query param). This avoids
109+
# nesting interactive elements and keeps the markup valid and accessible.
110+
Tabs(default_value: "account", class: 'w-96') do
111+
TabsList do
112+
TabsTrigger(value: "account", as: :a, href: "?tab=account") { "Account" }
113+
TabsTrigger(value: "password", as: :a, href: "?tab=password") { "Password" }
114+
end
115+
end
116+
RUBY
117+
end
118+
105119
render Docs::VisualCodeExample.new(title: "Change default value", context: self) do
106120
<<~RUBY
107121
Tabs(default: "password", class: 'w-96') do

gem/lib/ruby_ui/tabs/tabs_trigger.rb

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,24 @@
22

33
module RubyUI
44
class TabsTrigger < Base
5-
def initialize(value:, **attrs)
5+
def initialize(value:, as: :button, **attrs)
66
@value = value
7+
@as = as
78
super(**attrs)
89
end
910

1011
def view_template(&)
11-
button(**attrs, &)
12+
if @as == :a
13+
a(**attrs, &)
14+
else
15+
button(**attrs, &)
16+
end
1217
end
1318

1419
private
1520

1621
def default_attrs
17-
{
18-
type: :button,
22+
base = {
1923
data: {
2024
ruby_ui__tabs_target: "trigger",
2125
action: "click->ruby-ui--tabs#show",
@@ -29,6 +33,8 @@ def default_attrs
2933
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
3034
]
3135
}
36+
base[:type] = :button unless @as == :a
37+
base
3238
end
3339
end
3440
end
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
5+
class RubyUI::TabsTriggerTest < ComponentTest
6+
def test_renders_as_button_by_default
7+
output = phlex { RubyUI.TabsTrigger(value: "account") { "Account" } }
8+
9+
assert_match(/\A<button/, output)
10+
assert_match(/type="button"/, output)
11+
assert_match(/Account/, output)
12+
refute_match(/<a[\s>]/, output)
13+
end
14+
15+
def test_renders_as_anchor_with_href_when_as_a
16+
output = phlex { RubyUI.TabsTrigger(value: "account", as: :a, href: "/account") { "Account" } }
17+
18+
assert_match(/\A<a/, output)
19+
assert_match(/href="\/account"/, output)
20+
assert_match(/Account/, output)
21+
refute_match(/<button/, output)
22+
refute_match(/type="button"/, output)
23+
end
24+
25+
def test_unexpected_as_value_still_renders_a_typed_button
26+
output = phlex { RubyUI.TabsTrigger(value: "account", as: :buton) { "Account" } }
27+
28+
assert_match(/\A<button/, output)
29+
assert_match(/type="button"/, output)
30+
end
31+
32+
def test_keeps_trigger_data_attributes_when_rendered_as_anchor
33+
output = phlex { RubyUI.TabsTrigger(value: "account", as: :a, href: "/account") { "Account" } }
34+
35+
assert_match(/data-ruby-ui--tabs-target="trigger"/, output)
36+
assert_match(/data-value="account"/, output)
37+
end
38+
end

mcp/data/registry.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2301,7 +2301,7 @@
23012301
},
23022302
{
23032303
"path": "tabs_trigger.rb",
2304-
"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"
2304+
"content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TabsTrigger < Base\n def initialize(value:, as: :button, **attrs)\n @value = value\n @as = as\n super(**attrs)\n end\n\n def view_template(&)\n if @as == :a\n a(**attrs, &)\n else\n button(**attrs, &)\n end\n end\n\n private\n\n def default_attrs\n base = {\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 base[:type] = :button unless @as == :a\n base\n end\n end\nend\n"
23052305
}
23062306
],
23072307
"dependencies": {

0 commit comments

Comments
 (0)