Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions audit/component_map/tabs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
shadcn: tabs
shadcn_sha: 8a3536678a528cc012b9e62484290e79d0be410f
kind: disclosure
docs_view: tabs
parts:
Tabs: tabs
TabsList: tabs-list
TabsTrigger: tabs-trigger
TabsContent: tabs-content
selectors:
Tabs: '[data-slot="tabs"]'
TabsList: '[data-slot="tabs-list"]'
TabsTrigger: '[data-slot="tabs-trigger"]'
TabsContent: '[data-slot="tabs-content"]:not([hidden])'
# orientation (Tabs) and variant (TabsList) from the .tsx; the rest from
# @base-ui/react tabs. value/onValueChange: see audit/divergences/tabs.yml.
props:
Tabs: {defaultValue: default_value, value: null, onValueChange: null, orientation: orientation}
TabsList: {variant: variant, activateOnFocus: activate_on_focus, loopFocus: loop_focus}
TabsTrigger: {value: value, disabled: disabled}
TabsContent: {value: value, keepMounted: null}
rubyui_only:
Tabs: [default]
TabsTrigger: [as]
12 changes: 12 additions & 0 deletions audit/divergences/tabs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
- component: tabs
dimension: HTML
rubyui: "Every TabsContent stays in the page; the inactive ones carry the hidden attribute"
shadcn: "Only the active panel is mounted (Base UI Tabs.Panel keepMounted defaults to false)"
reason: "Panels are server-rendered, and keeping them lets every tab point aria-controls at its panel, as the APG tabs pattern asks (Base UI's unmounted panels leave inactive tabs without it)."
since: "2026-11-04"
- component: tabs
dimension: Props
rubyui: "No value:/on_value_change: and no keep_mounted: (panels always stay mounted, see the HTML entry); the controller dispatches ruby-ui--tabs:change ({value, previousValue}) and follows data-ruby-ui--tabs-active-value"
shadcn: "Controlled value + onValueChange; TabsContent keepMounted (default false)"
reason: "A server-rendered component has no React state to control: the Stimulus value attribute is the controlled input and the DOM event is the change callback."
since: "2026-11-04"
9 changes: 9 additions & 0 deletions audit/fixtures/tabs/content.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Three tabs, the first active on load, the third disabled. data-audit-trigger
# sits on the second (inactive) tab: "open" = after clicking it. With a roving
# tabindex, Tab never lands on an inactive tab, so "focus" may stay unreached.
account: Account
password: Password
billing: Billing
account_body: Make changes to your account here.
password_body: Change your password here.
billing_body: Manage your billing details here.
21 changes: 21 additions & 0 deletions audit/fixtures/tabs/rubyui.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

module ShadcnAuditFixtures
# RubyUI side of audit/fixtures/tabs (strings in content.yml).
class Tabs < Phlex::HTML
include RubyUI

def view_template
Tabs(default_value: "account", class: "w-96") do
TabsList do
TabsTrigger(value: "account") { "Account" }
TabsTrigger(value: "password", data: {audit_trigger: true}) { "Password" }
TabsTrigger(value: "billing", disabled: true) { "Billing" }
end
TabsContent(value: "account") { "Make changes to your account here." }
TabsContent(value: "password") { "Change your password here." }
TabsContent(value: "billing") { "Manage your billing details here." }
end
end
end
end
16 changes: 16 additions & 0 deletions audit/fixtures/tabs/shadcn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"

export default function TabsFixture() {
return (
<Tabs defaultValue="account" className="w-96">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password" data-audit-trigger="">Password</TabsTrigger>
<TabsTrigger value="billing" disabled>Billing</TabsTrigger>
</TabsList>
<TabsContent value="account">Make changes to your account here.</TabsContent>
<TabsContent value="password">Change your password here.</TabsContent>
<TabsContent value="billing">Manage your billing details here.</TabsContent>
</Tabs>
)
}
4 changes: 2 additions & 2 deletions docs/app/components/themes/grid/repo_tabs.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ def view_template
TabsList(class: "w-full grid grid-cols-2") do
TabsTrigger(value: "overview") do
book_icon
span(class: "ml-2") { "Overview" }
span { "Overview" }
end
TabsTrigger(value: "repositories") do
repo_icon
span(class: "ml-2") { "Repositories" }
span { "Repositories" }
end
end
TabsContent(value: "overview") do
Expand Down
53 changes: 50 additions & 3 deletions docs/app/views/docs/tabs.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,11 @@ def view_template
TabsList(class: 'w-full grid grid-cols-2') do
TabsTrigger(value: "overview") do
book_icon
span(class: 'ml-2') { "Overview" }
span { "Overview" }
end
TabsTrigger(value: "repositories") do
repo_icon
span(class: 'ml-2') { "Repositories" }
span { "Repositories" }
end
end
TabsContent(value: "overview") do
Expand Down Expand Up @@ -116,9 +116,56 @@ def view_template
RUBY
end

render Docs::VisualCodeExample.new(title: "Line", context: self) do
<<~RUBY
Tabs(default_value: "overview", class: 'w-96') do
TabsList(variant: :line) do
TabsTrigger(value: "overview") { "Overview" }
TabsTrigger(value: "analytics") { "Analytics" }
TabsTrigger(value: "reports") { "Reports" }
end
TabsContent(value: "overview") { "A summary of your account activity." }
TabsContent(value: "analytics") { "Traffic and conversion over time." }
TabsContent(value: "reports") { "Download monthly reports." }
end
RUBY
end

render Docs::VisualCodeExample.new(title: "Vertical", context: self) do
<<~RUBY
# The arrow keys follow the orientation: Up/Down here, Left/Right otherwise.
Tabs(default_value: "general", orientation: :vertical, class: 'w-96') do
TabsList do
TabsTrigger(value: "general") { "General" }
TabsTrigger(value: "security") { "Security" }
TabsTrigger(value: "billing") { "Billing" }
end
TabsContent(value: "general") { "Name, language and time zone." }
TabsContent(value: "security") { "Password and two-factor authentication." }
TabsContent(value: "billing") { "Plan, invoices and payment method." }
end
RUBY
end

render Docs::VisualCodeExample.new(title: "Keyboard", context: self) do
<<~RUBY
# By default the arrow keys move focus and Enter/Space activate.
# activate_on_focus: true activates as focus moves; loop_focus: false
# stops at the first and last tab.
Tabs(default_value: "account", class: 'w-96') do
TabsList(activate_on_focus: true, loop_focus: false) do
TabsTrigger(value: "account") { "Account" }
TabsTrigger(value: "password") { "Password" }
end
TabsContent(value: "account") { "Make changes to your account here." }
TabsContent(value: "password") { "Change your password here." }
end
RUBY
end

render Docs::VisualCodeExample.new(title: "Change default value", context: self) do
<<~RUBY
Tabs(default: "password", class: 'w-96') do
Tabs(default_value: "password", class: 'w-96') do
TabsList do
TabsTrigger(value: "account") { "Account" }
TabsTrigger(value: "password") { "Password" }
Expand Down
24 changes: 20 additions & 4 deletions gem/lib/ruby_ui/tabs/tabs.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
# frozen_string_literal: true

module RubyUI
# Base UI's Tabs.Root. default_value: is the tab shown first (shadcn's
# defaultValue); without it the first enabled tab is shown. default: is its
# old name, kept as an alias. orientation: :horizontal or :vertical.
#
# The controller marks the active tab and panel, wires aria-controls /
# aria-labelledby, and dispatches ruby-ui--tabs:change ({value,
# previousValue}) when someone picks another tab. Setting
# data-ruby-ui--tabs-active-value switches tabs from outside.
class Tabs < Base
def initialize(default: nil, **attrs)
@default = default
ORIENTATIONS = %i[horizontal vertical].freeze

def initialize(default_value: nil, default: nil, orientation: :horizontal, **attrs)
@default_value = default_value.nil? ? default : default_value
@orientation = ORIENTATIONS.include?(orientation.to_s.to_sym) ? orientation.to_s.to_sym : :horizontal
super(**attrs)
end

Expand All @@ -16,9 +27,14 @@ def view_template(&)
def default_attrs
{
data: {
slot: "tabs",
orientation: @orientation,
controller: "ruby-ui--tabs",
ruby_ui__tabs_active_value: @default
}
ruby_ui__tabs_active_value: @default_value,
ruby_ui__tabs_orientation_value: @orientation
},
# Keyed on data-orientation, which the controller keeps in sync.
class: "group/tabs flex gap-2 data-[orientation=horizontal]:flex-col"
}
end
end
Expand Down
8 changes: 7 additions & 1 deletion gem/lib/ruby_ui/tabs/tabs_content.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# frozen_string_literal: true

module RubyUI
# Base UI's Tabs.Panel (role=tabpanel). Every panel stays in the page; the
# inactive ones carry the hidden attribute, set by the controller.
class TabsContent < Base
def initialize(value:, **attrs)
@value = value
Expand All @@ -15,11 +17,15 @@ def view_template(&)

def default_attrs
{
role: "tabpanel",
tabindex: 0,
hidden: true,
data: {
slot: "tabs-content",
ruby_ui__tabs_target: :content,
value: @value
},
class: "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hidden"
class: "flex-1 text-sm outline-none"
}
end
end
Expand Down
Loading
Loading