diff --git a/audit/component_map/tabs.yml b/audit/component_map/tabs.yml
new file mode 100644
index 000000000..c285cf5f3
--- /dev/null
+++ b/audit/component_map/tabs.yml
@@ -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]
diff --git a/audit/divergences/tabs.yml b/audit/divergences/tabs.yml
new file mode 100644
index 000000000..53d0773d7
--- /dev/null
+++ b/audit/divergences/tabs.yml
@@ -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"
diff --git a/audit/fixtures/tabs/content.yml b/audit/fixtures/tabs/content.yml
new file mode 100644
index 000000000..55aac96dd
--- /dev/null
+++ b/audit/fixtures/tabs/content.yml
@@ -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.
diff --git a/audit/fixtures/tabs/rubyui.rb b/audit/fixtures/tabs/rubyui.rb
new file mode 100644
index 000000000..2c464f273
--- /dev/null
+++ b/audit/fixtures/tabs/rubyui.rb
@@ -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
diff --git a/audit/fixtures/tabs/shadcn.tsx b/audit/fixtures/tabs/shadcn.tsx
new file mode 100644
index 000000000..d4cd22e56
--- /dev/null
+++ b/audit/fixtures/tabs/shadcn.tsx
@@ -0,0 +1,16 @@
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+
+export default function TabsFixture() {
+ return (
+
+
+ Account
+ Password
+ Billing
+
+ Make changes to your account here.
+ Change your password here.
+ Manage your billing details here.
+
+ )
+}
diff --git a/docs/app/components/themes/grid/repo_tabs.rb b/docs/app/components/themes/grid/repo_tabs.rb
index 18a7f86de..305273999 100644
--- a/docs/app/components/themes/grid/repo_tabs.rb
+++ b/docs/app/components/themes/grid/repo_tabs.rb
@@ -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
diff --git a/docs/app/views/docs/tabs.rb b/docs/app/views/docs/tabs.rb
index 48f1f2d6c..dda68a85e 100644
--- a/docs/app/views/docs/tabs.rb
+++ b/docs/app/views/docs/tabs.rb
@@ -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
@@ -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" }
diff --git a/gem/lib/ruby_ui/tabs/tabs.rb b/gem/lib/ruby_ui/tabs/tabs.rb
index 57e5a0ddd..040b5a408 100644
--- a/gem/lib/ruby_ui/tabs/tabs.rb
+++ b/gem/lib/ruby_ui/tabs/tabs.rb
@@ -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
@@ -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
diff --git a/gem/lib/ruby_ui/tabs/tabs_content.rb b/gem/lib/ruby_ui/tabs/tabs_content.rb
index c27b70723..e6db40ac6 100644
--- a/gem/lib/ruby_ui/tabs/tabs_content.rb
+++ b/gem/lib/ruby_ui/tabs/tabs_content.rb
@@ -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
@@ -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
diff --git a/gem/lib/ruby_ui/tabs/tabs_controller.js b/gem/lib/ruby_ui/tabs/tabs_controller.js
index e46d69c46..74c2df29c 100644
--- a/gem/lib/ruby_ui/tabs/tabs_controller.js
+++ b/gem/lib/ruby_ui/tabs/tabs_controller.js
@@ -1,45 +1,214 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="ruby-ui--tabs"
+//
+// Follows Base UI's Tabs: role=tablist/tab/tabpanel, each tab pointing at its
+// panel (aria-controls) and each panel labelled by its tab, a roving tabindex
+// on the tabs, arrow keys that move focus (wrapping unless the list says
+// data-loop-focus="false"), Home/End, and manual activation by Enter, Space or
+// click (or on focus with data-activate-on-focus="true"). Disabled tabs
+// (aria-disabled) stay reachable but never activate.
export default class extends Controller {
- static targets = ["trigger", "content"];
- static values = { active: String };
+ static targets = ["list", "trigger", "content"];
+ static values = { active: String, orientation: { type: String, default: "horizontal" } };
connect() {
- if (!this.hasActiveValue && this.triggerTargets.length > 0) {
- this.activeValue = this.triggerTargets[0].dataset.value;
+ this.idCount ??= 0;
+ this.idBase ||= this.element.id || `ruby-ui-tabs-${Math.random().toString(36).slice(2, 10)}`;
+ this.connected = true;
+ this.refresh();
+ }
+
+ disconnect() {
+ this.connected = false;
+ }
+
+ activeValueChanged() {
+ if (this.connected) this.sync();
+ }
+
+ orientationValueChanged() {
+ if (this.connected) this.sync();
+ }
+
+ // Tabs and panels added later (a morph, lazy content) get wired too.
+ triggerTargetConnected() {
+ if (this.connected) this.refresh();
+ }
+
+ contentTargetConnected() {
+ if (this.connected) this.refresh();
+ }
+
+ // ...and removing the active tab hands selection to a remaining one.
+ triggerTargetDisconnected() {
+ if (this.connected) this.refresh();
+ }
+
+ contentTargetDisconnected() {
+ if (this.connected) this.refresh();
+ }
+
+ // Leaving the list without activating: Tab back in lands on the selected
+ // tab again, as the APG asks.
+ listFocusout(event) {
+ if (this.hasListTarget && this.listTarget.contains(event.relatedTarget)) return;
+ this.sync();
+ }
+
+ refresh() {
+ if (!this.triggerFor(this.activeValue)) {
+ const first = this.triggerTargets.find((tab) => !this.isDisabled(tab)) ?? this.triggerTargets[0];
+ // Assigning the value runs sync through activeValueChanged.
+ if (first && first.dataset.value !== this.activeValue) {
+ this.activeValue = first.dataset.value;
+ return;
+ }
+ }
+ this.sync();
+ }
+
+ // --- Activation --------------------------------------------------------------
+
+ show(event) {
+ const tab = event.currentTarget;
+ if (this.isDisabled(tab)) {
+ event.preventDefault();
+ return;
+ }
+ this.activate(tab);
+ }
+
+ activate(tab) {
+ const value = tab.dataset.value;
+ if (value === this.activeValue) return;
+ const previousValue = this.activeValue;
+ this.activeValue = value;
+ this.dispatch("change", { detail: { value, previousValue } });
+ }
+
+ // --- Keyboard ----------------------------------------------------------------
+
+ keydown(event) {
+ const tab = event.target.closest('[role="tab"]');
+ if (!tab || !this.triggerTargets.includes(tab)) return;
+
+ // A link tab activates on Enter by itself; Space needs a click.
+ if (event.key === " " && tab.tagName === "A") {
+ event.preventDefault();
+ tab.click();
+ return;
}
+
+ const tabs = this.triggerTargets;
+ const index = tabs.indexOf(tab);
+ const last = tabs.length - 1;
+ const [previousKey, nextKey] = this.navigationKeys();
+ let target;
+
+ if (event.key === nextKey) target = index === last ? (this.loopFocus ? 0 : last) : index + 1;
+ else if (event.key === previousKey) target = index === 0 ? (this.loopFocus ? last : 0) : index - 1;
+ else if (event.key === "Home") target = 0;
+ else if (event.key === "End") target = last;
+ else return;
+
+ event.preventDefault();
+ this.focusTab(tabs[target]);
+ }
+
+ navigationKeys() {
+ if (this.orientationValue === "vertical") return ["ArrowUp", "ArrowDown"];
+ const rtl = getComputedStyle(this.hasListTarget ? this.listTarget : this.element).direction === "rtl";
+ return rtl ? ["ArrowRight", "ArrowLeft"] : ["ArrowLeft", "ArrowRight"];
+ }
+
+ focusTab(tab) {
+ this.triggerTargets.forEach((el) => el.setAttribute("tabindex", el === tab ? "0" : "-1"));
+ tab.focus();
+ if (this.activateOnFocus && !this.isDisabled(tab)) this.activate(tab);
+ }
+
+ get loopFocus() {
+ return !this.hasListTarget || this.listTarget.dataset.loopFocus !== "false";
}
- show(e) {
- this.activeValue = e.currentTarget.dataset.value;
+ get activateOnFocus() {
+ return this.hasListTarget && this.listTarget.dataset.activateOnFocus === "true";
}
- activeValueChanged(currentValue, previousValue) {
- if (currentValue == "" || currentValue == previousValue) return;
+ // --- State -------------------------------------------------------------------
- this.contentTargets.forEach((el) => {
- el.classList.add("hidden");
+ wire() {
+ this.triggerTargets.forEach((tab) => {
+ if (!tab.id) tab.id = this.nextId("tab");
+ });
+ this.contentTargets.forEach((panel) => {
+ if (!panel.id) panel.id = this.nextId("panel");
+ });
+ // Point each tab and panel at its counterpart, and drop references to one
+ // that is gone.
+ this.triggerTargets.forEach((tab) => {
+ const panel = this.contentFor(tab.dataset.value);
+ if (panel) tab.setAttribute("aria-controls", panel.id);
+ else tab.removeAttribute("aria-controls");
+ });
+ this.contentTargets.forEach((panel) => {
+ const tab = this.triggerFor(panel.dataset.value);
+ if (tab) panel.setAttribute("aria-labelledby", tab.id);
+ else panel.removeAttribute("aria-labelledby");
+ });
+ }
+
+ sync() {
+ // Re-wiring is idempotent, and restores ids/ARIA a morph may have dropped.
+ this.wire();
+ const orientation = this.orientationValue;
+ // The root's layout and the parts' group-data variants follow this.
+ this.element.dataset.orientation = orientation;
+ const active = this.triggerFor(this.activeValue);
+
+ if (this.hasListTarget) {
+ this.listTarget.dataset.orientation = orientation;
+ if (orientation === "vertical") this.listTarget.setAttribute("aria-orientation", "vertical");
+ else this.listTarget.removeAttribute("aria-orientation");
+ }
+
+ this.triggerTargets.forEach((tab, index) => {
+ const selected = tab === active;
+ tab.dataset.orientation = orientation;
+ tab.setAttribute("aria-selected", String(selected));
+ tab.toggleAttribute("data-active", selected);
+ tab.setAttribute("tabindex", selected || (!active && index === 0) ? "0" : "-1");
});
- this.triggerTargets.forEach((el) => {
- el.dataset.state = "inactive";
+ this.contentTargets.forEach((panel) => {
+ panel.dataset.orientation = orientation;
+ panel.hidden = panel.dataset.value !== this.activeValue || !active;
});
+ }
+
+ triggerFor(value) {
+ if (value === undefined || value === null || value === "") return undefined;
+ return this.triggerTargets.find((tab) => tab.dataset.value === value);
+ }
- this.activeContentTarget() &&
- this.activeContentTarget().classList.remove("hidden");
- this.activeTriggerTarget().dataset.state = "active";
+ contentFor(value) {
+ if (value === undefined || value === null || value === "") return undefined;
+ return this.contentTargets.find((panel) => panel.dataset.value === value);
}
- activeTriggerTarget() {
- return this.triggerTargets.find(
- (el) => el.dataset.value == this.activeValue,
- );
+ isDisabled(tab) {
+ return tab.disabled || tab.getAttribute("aria-disabled") === "true";
}
- activeContentTarget() {
- return this.contentTargets.find(
- (el) => el.dataset.value == this.activeValue,
- );
+ // A counter, not the value, so distinct values never share an id; ids
+ // already in the document (kept across a reconnect) are skipped.
+ nextId(kind) {
+ let id;
+ do {
+ this.idCount += 1;
+ id = `${this.idBase}-${kind}-${this.idCount}`;
+ } while (document.getElementById(id));
+ return id;
}
}
diff --git a/gem/lib/ruby_ui/tabs/tabs_docs.rb b/gem/lib/ruby_ui/tabs/tabs_docs.rb
index b37a2ff80..0323e1ede 100644
--- a/gem/lib/ruby_ui/tabs/tabs_docs.rb
+++ b/gem/lib/ruby_ui/tabs/tabs_docs.rb
@@ -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
@@ -102,9 +102,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" }
diff --git a/gem/lib/ruby_ui/tabs/tabs_list.rb b/gem/lib/ruby_ui/tabs/tabs_list.rb
index 650e00c05..a9253064b 100644
--- a/gem/lib/ruby_ui/tabs/tabs_list.rb
+++ b/gem/lib/ruby_ui/tabs/tabs_list.rb
@@ -1,7 +1,20 @@
# frozen_string_literal: true
module RubyUI
+ # Base UI's Tabs.List (role=tablist). variant: :default (muted pill) or
+ # :line (underline on the active tab). activate_on_focus: true activates a
+ # tab as the arrow keys reach it (default: Enter/Space or click activate).
+ # loop_focus: false stops the arrow keys at the first and last tab.
class TabsList < Base
+ VARIANTS = %i[default line].freeze
+
+ def initialize(variant: :default, activate_on_focus: false, loop_focus: true, **attrs)
+ @variant = VARIANTS.include?(variant.to_s.to_sym) ? variant.to_s.to_sym : :default
+ @activate_on_focus = activate_on_focus
+ @loop_focus = loop_focus
+ super(**attrs)
+ end
+
def view_template(&)
div(**attrs, &)
end
@@ -10,7 +23,21 @@ def view_template(&)
def default_attrs
{
- class: "inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground"
+ role: "tablist",
+ data: {
+ slot: "tabs-list",
+ variant: @variant,
+ activate_on_focus: (@activate_on_focus ? "true" : nil),
+ loop_focus: (@loop_focus ? nil : "false"),
+ ruby_ui__tabs_target: "list",
+ action: "keydown->ruby-ui--tabs#keydown focusout->ruby-ui--tabs#listFocusout"
+ },
+ class: [
+ "group/tabs-list inline-flex w-fit items-center justify-center p-[3px] text-muted-foreground",
+ # Bare h-9, so a caller's h-* replaces it; vertical lists size to content.
+ "h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
+ (@variant == :line) ? "gap-1 rounded-none bg-transparent" : "rounded-lg bg-muted"
+ ]
}
end
end
diff --git a/gem/lib/ruby_ui/tabs/tabs_trigger.rb b/gem/lib/ruby_ui/tabs/tabs_trigger.rb
index d68ab72e5..5176c957f 100644
--- a/gem/lib/ruby_ui/tabs/tabs_trigger.rb
+++ b/gem/lib/ruby_ui/tabs/tabs_trigger.rb
@@ -1,10 +1,14 @@
# frozen_string_literal: true
module RubyUI
+ # Base UI's Tabs.Tab (role=tab). as: :a renders a link for tabs that
+ # navigate. disabled: true keeps the tab reachable with the arrow keys
+ # (aria-disabled, as Base UI does) but it can't be activated.
class TabsTrigger < Base
- def initialize(value:, as: :button, **attrs)
+ def initialize(value:, as: :button, disabled: false, **attrs)
@value = value
@as = as
+ @disabled = disabled
super(**attrs)
end
@@ -20,17 +24,30 @@ def view_template(&)
def default_attrs
base = {
+ role: "tab",
+ aria: {selected: "false", disabled: (@disabled ? "true" : nil)},
data: {
+ slot: "tabs-trigger",
+ disabled: (@disabled ? true : nil),
ruby_ui__tabs_target: "trigger",
action: "click->ruby-ui--tabs#show",
value: @value
},
class: [
- "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all",
- "disabled:pointer-events-none disabled:opacity-50",
- "aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-disabled:cursor-not-allowed",
- "data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
+ "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 text-sm font-medium text-foreground/60 transition-all",
+ "hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground",
+ "focus-visible:border-ring focus-visible:outline-1 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
+ "disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50",
+ "group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start",
+ "data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
+ "group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none",
+ "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
+ # The line variant's indicator.
+ "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
+ "group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5",
+ "group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5",
+ "has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
+ "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
]
}
base[:type] = :button unless @as == :a
diff --git a/gem/test/ruby_ui/tabs_test.rb b/gem/test/ruby_ui/tabs_test.rb
index f0e028e6b..4365405b4 100644
--- a/gem/test/ruby_ui/tabs_test.rb
+++ b/gem/test/ruby_ui/tabs_test.rb
@@ -3,25 +3,136 @@
require "test_helper"
class RubyUI::TabsTest < ComponentTest
- def test_render_with_all_items
- output = phlex do
- RubyUI.Tabs(default_value: "account", class: "w-96") do
+ def render_tabs(**tabs_attrs)
+ phlex do
+ RubyUI.Tabs(**tabs_attrs) do
RubyUI.TabsList do
RubyUI.TabsTrigger(value: "account") { "Account" }
RubyUI.TabsTrigger(value: "password") { "Password" }
end
- RubyUI.TabsContent(value: "account") do
- RubyUI::Text(as: "p", size: "4") { "Account" }
- RubyUI::Text(size: "5", weight: "semibold") { "Are you sure absolutely sure?" }
- RubyUI::Text(size: "2", class: "text-muted-foreground") { "Update your account details." }
- end
- RubyUI.TabsContent(value: "password") do
- RubyUI::Text(as: "p", size: "4") { "Password" }
- RubyUI::Text(size: "2", class: "text-muted-foreground") { "Change your password here. After saving, you'll be logged out." }
- end
+ RubyUI.TabsContent(value: "account") { "Account panel" }
+ RubyUI.TabsContent(value: "password") { "Password panel" }
end
end
+ end
+
+ def test_render_with_all_items
+ output = render_tabs(default_value: "account", class: "w-96")
+
+ assert_match(/Account panel/, output)
+ assert_match(/Password panel/, output)
+ end
+
+ def test_root_carries_slot_orientation_and_default_value
+ output = render_tabs(default_value: "password")
+
+ assert_match(/data-slot="tabs"/, output)
+ assert_match(/data-orientation="horizontal"/, output)
+ assert_match(/data-ruby-ui--tabs-active-value="password"/, output)
+ assert_match(/data-\[orientation=horizontal\]:flex-col/, output)
+ refute_match(/default-value=/, output)
+ end
+
+ def test_default_is_an_alias_for_default_value
+ output = render_tabs(default: "password")
+
+ assert_match(/data-ruby-ui--tabs-active-value="password"/, output)
+ refute_match(/\sdefault=/, output)
+ end
+
+ def test_default_value_wins_over_the_alias
+ output = render_tabs(default_value: "account", default: "password")
+
+ assert_match(/data-ruby-ui--tabs-active-value="account"/, output)
+ end
+
+ def test_without_default_value_no_active_value_is_rendered
+ output = render_tabs
+
+ refute_match(/data-ruby-ui--tabs-active-value/, output)
+ end
+
+ def test_vertical_orientation
+ output = render_tabs(orientation: :vertical)
+
+ assert_match(/data-orientation="vertical"/, output)
+ assert_match(/data-ruby-ui--tabs-orientation-value="vertical"/, output)
+ end
+
+ def test_unknown_orientation_falls_back_to_horizontal
+ output = render_tabs(orientation: :diagonal)
+
+ assert_match(/data-orientation="horizontal"/, output)
+ end
+
+ def test_list_is_a_tablist_with_the_default_variant
+ output = phlex { RubyUI.TabsList { "" } }
+
+ assert_match(/role="tablist"/, output)
+ assert_match(/data-slot="tabs-list"/, output)
+ assert_match(/data-variant="default"/, output)
+ assert_match(/keydown->ruby-ui--tabs#keydown focusout->ruby-ui--tabs#listFocusout/, output)
+ assert_match(/bg-muted/, output)
+ refute_match(/data-activate-on-focus|data-loop-focus/, output)
+ end
+
+ def test_line_variant
+ output = phlex { RubyUI.TabsList(variant: :line) { "" } }
+
+ assert_match(/data-variant="line"/, output)
+ assert_match(/bg-transparent/, output)
+ refute_match(/bg-muted/, output)
+ end
+
+ def test_list_keyboard_options
+ output = phlex { RubyUI.TabsList(activate_on_focus: true, loop_focus: false) { "" } }
+
+ assert_match(/data-activate-on-focus="true"/, output)
+ assert_match(/data-loop-focus="false"/, output)
+ end
+
+ def test_list_height_can_be_overridden
+ output = phlex { RubyUI.TabsList(class: "h-10") { "" } }
+
+ assert_match(/\bh-10\b/, output)
+ refute_match(/\bh-9\b/, output)
+ end
+
+ def test_trigger_is_a_tab_button
+ output = phlex { RubyUI.TabsTrigger(value: "account") { "Account" } }
+
+ assert_match(/