From c47efb0fc7ec82ee2ac140f5ade4214c011e8cf2 Mon Sep 17 00:00:00 2001 From: Cirdes Date: Wed, 4 Nov 2026 11:54:18 -0300 Subject: [PATCH 1/3] [Feature] Tabs: match shadcn (Base UI + vega) - Vega classes on every part: flex column root with gap-2, p-[3px] list, trigger with border, ring-[3px] focus ring, hover color and dark bg-input/30; text-sm panel. - Tab semantics: role tablist/tab/tabpanel, aria-selected, aria-controls on every tab and aria-labelledby on every panel. Panels stay mounted and hide with the hidden attribute. - Keyboard as Base UI: roving tabindex, arrow keys with wrap, Home/End, manual activation, focusable panel. - Disabled tabs use aria-disabled + data-disabled and stay reachable. - data-slot, data-orientation, data-active, data-variant as in shadcn. - New: Tabs(orientation:), TabsList(variant: :line, activate_on_focus:, loop_focus:), and a ruby-ui--tabs:change event. - Tabs takes default_value: (shadcn's defaultValue). The docs already passed it, but it rendered a stray default-value attribute and was ignored. default: stays as an alias. - Audit map, fixtures, divergences and baseline for tabs; MCP registry. Co-Authored-By: Claude Opus 5.5 --- audit/component_map/tabs.yml | 24 +++ audit/divergences/tabs.yml | 12 ++ audit/fixtures/tabs/content.yml | 9 + audit/fixtures/tabs/rubyui.rb | 21 +++ audit/fixtures/tabs/shadcn.tsx | 16 ++ docs/app/components/themes/grid/repo_tabs.rb | 4 +- docs/app/views/docs/tabs.rb | 53 +++++- gem/lib/ruby_ui/tabs/tabs.rb | 23 ++- gem/lib/ruby_ui/tabs/tabs_content.rb | 8 +- gem/lib/ruby_ui/tabs/tabs_controller.js | 176 ++++++++++++++++--- gem/lib/ruby_ui/tabs/tabs_docs.rb | 53 +++++- gem/lib/ruby_ui/tabs/tabs_list.rb | 29 ++- gem/lib/ruby_ui/tabs/tabs_trigger.rb | 29 ++- gem/test/ruby_ui/tabs_test.rb | 138 +++++++++++++-- mcp/data/registry/components/tabs.json | 31 +++- 15 files changed, 562 insertions(+), 64 deletions(-) create mode 100644 audit/component_map/tabs.yml create mode 100644 audit/divergences/tabs.yml create mode 100644 audit/fixtures/tabs/content.yml create mode 100644 audit/fixtures/tabs/rubyui.rb create mode 100644 audit/fixtures/tabs/shadcn.tsx 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..f209f36a9 --- /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: "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..5cac367b2 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,13 @@ 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 + }, + class: ["group/tabs flex gap-2", ("flex-col" if @orientation == :horizontal)] } 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..59add7cda 100644 --- a/gem/lib/ruby_ui/tabs/tabs_controller.js +++ b/gem/lib/ruby_ui/tabs/tabs_controller.js @@ -1,45 +1,175 @@ 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.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(); + } + + refresh() { + this.wire(); + 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); } - show(e) { - this.activeValue = e.currentTarget.dataset.value; + get loopFocus() { + return !this.hasListTarget || this.listTarget.dataset.loopFocus !== "false"; } - activeValueChanged(currentValue, previousValue) { - if (currentValue == "" || currentValue == previousValue) return; + get activateOnFocus() { + return this.hasListTarget && this.listTarget.dataset.activateOnFocus === "true"; + } + + // --- State ------------------------------------------------------------------- + + wire() { + this.triggerTargets.forEach((tab) => { + if (!tab.id) tab.id = this.idFor("tab", tab.dataset.value); + }); + this.contentTargets.forEach((panel) => { + if (!panel.id) panel.id = this.idFor("panel", panel.dataset.value); + const tab = this.triggerFor(panel.dataset.value); + if (tab) { + tab.setAttribute("aria-controls", panel.id); + panel.setAttribute("aria-labelledby", tab.id); + } + }); + } + + sync() { + const orientation = this.orientationValue; + const active = this.triggerFor(this.activeValue); - this.contentTargets.forEach((el) => { - el.classList.add("hidden"); + 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; }); + } - this.activeContentTarget() && - this.activeContentTarget().classList.remove("hidden"); - this.activeTriggerTarget().dataset.state = "active"; + triggerFor(value) { + if (value === undefined || value === null || value === "") return undefined; + return this.triggerTargets.find((tab) => tab.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, - ); + idFor(kind, value) { + return `${this.idBase}-${kind}-${String(value).replace(/[^\w-]/g, "-")}`; } } 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..300967cd0 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" + }, + 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..bc01420fc 100644 --- a/gem/test/ruby_ui/tabs_test.rb +++ b/gem/test/ruby_ui/tabs_test.rb @@ -3,25 +3,137 @@ 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(/class="[^"]*\bflex-col\b/, 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_the_controller_picks_the_first_tab + 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) + refute_match(/data-slot="tabs"[^>]*flex-col/, 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/, 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(/]*role="tab"/, output) + assert_match(/type="button"/, output) + assert_match(/aria-selected="false"/, output) + assert_match(/data-slot="tabs-trigger"/, output) + assert_match(/data-value="account"/, output) + refute_match(/aria-disabled="|data-disabled[\s>=]/, output) + end + + def test_disabled_trigger_uses_aria_disabled + output = phlex { RubyUI.TabsTrigger(value: "billing", disabled: true) { "Billing" } } + + assert_match(/aria-disabled="true"/, output) + assert_match(/data-disabled/, output) + refute_match(/\sdisabled(=|\s|>)/, output) + end + + def test_trigger_as_link + output = phlex { RubyUI.TabsTrigger(value: "account", as: :a, href: "?tab=account") { "Account" } } + + assert_match(/]*role="tab"/, output) + assert_match(/href="\?tab=account"/, output) + refute_match(/type="button"/, output) + end + + def test_content_is_a_hidden_focusable_tabpanel + output = phlex { RubyUI.TabsContent(value: "account") { "Panel" } } - assert_match(/Account/, output) + assert_match(/role="tabpanel"/, output) + assert_match(/tabindex="0"/, output) + assert_match(/\shidden(\s|>)/, output) + assert_match(/data-slot="tabs-content"/, output) + assert_match(/text-sm/, output) + refute_match(/\bmt-2\b/, output) end end diff --git a/mcp/data/registry/components/tabs.json b/mcp/data/registry/components/tabs.json index f2a90572c..f1d5a7bc6 100644 --- a/mcp/data/registry/components/tabs.json +++ b/mcp/data/registry/components/tabs.json @@ -4,23 +4,23 @@ "files": [ { "path": "tabs.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Tabs < Base\n def initialize(default: nil, **attrs)\n @default = default\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n controller: \"ruby-ui--tabs\",\n ruby_ui__tabs_active_value: @default\n }\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # Base UI's Tabs.Root. default_value: is the tab shown first (shadcn's\n # defaultValue); without it the first enabled tab is shown. default: is its\n # old name, kept as an alias. orientation: :horizontal or :vertical.\n #\n # The controller marks the active tab and panel, wires aria-controls /\n # aria-labelledby, and dispatches ruby-ui--tabs:change ({value,\n # previousValue}) when someone picks another tab. Setting\n # data-ruby-ui--tabs-active-value switches tabs from outside.\n class Tabs < Base\n ORIENTATIONS = %i[horizontal vertical].freeze\n\n def initialize(default_value: nil, default: nil, orientation: :horizontal, **attrs)\n @default_value = default_value.nil? ? default : default_value\n @orientation = ORIENTATIONS.include?(orientation.to_s.to_sym) ? orientation.to_s.to_sym : :horizontal\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n slot: \"tabs\",\n orientation: @orientation,\n controller: \"ruby-ui--tabs\",\n ruby_ui__tabs_active_value: @default_value,\n ruby_ui__tabs_orientation_value: @orientation\n },\n class: [\"group/tabs flex gap-2\", (\"flex-col\" if @orientation == :horizontal)]\n }\n end\n end\nend\n" }, { "path": "tabs_content.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TabsContent < Base\n def initialize(value:, **attrs)\n @value = value\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n ruby_ui__tabs_target: :content,\n value: @value\n },\n class: \"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hidden\"\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # Base UI's Tabs.Panel (role=tabpanel). Every panel stays in the page; the\n # inactive ones carry the hidden attribute, set by the controller.\n class TabsContent < Base\n def initialize(value:, **attrs)\n @value = value\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n role: \"tabpanel\",\n tabindex: 0,\n hidden: true,\n data: {\n slot: \"tabs-content\",\n ruby_ui__tabs_target: :content,\n value: @value\n },\n class: \"flex-1 text-sm outline-none\"\n }\n end\n end\nend\n" }, { "path": "tabs_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"ruby-ui--tabs\"\nexport default class extends Controller {\n static targets = [\"trigger\", \"content\"];\n static values = { active: String };\n\n connect() {\n if (!this.hasActiveValue && this.triggerTargets.length > 0) {\n this.activeValue = this.triggerTargets[0].dataset.value;\n }\n }\n\n show(e) {\n this.activeValue = e.currentTarget.dataset.value;\n }\n\n activeValueChanged(currentValue, previousValue) {\n if (currentValue == \"\" || currentValue == previousValue) return;\n\n this.contentTargets.forEach((el) => {\n el.classList.add(\"hidden\");\n });\n\n this.triggerTargets.forEach((el) => {\n el.dataset.state = \"inactive\";\n });\n\n this.activeContentTarget() &&\n this.activeContentTarget().classList.remove(\"hidden\");\n this.activeTriggerTarget().dataset.state = \"active\";\n }\n\n activeTriggerTarget() {\n return this.triggerTargets.find(\n (el) => el.dataset.value == this.activeValue,\n );\n }\n\n activeContentTarget() {\n return this.contentTargets.find(\n (el) => el.dataset.value == this.activeValue,\n );\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"ruby-ui--tabs\"\n//\n// Follows Base UI's Tabs: role=tablist/tab/tabpanel, each tab pointing at its\n// panel (aria-controls) and each panel labelled by its tab, a roving tabindex\n// on the tabs, arrow keys that move focus (wrapping unless the list says\n// data-loop-focus=\"false\"), Home/End, and manual activation by Enter, Space or\n// click (or on focus with data-activate-on-focus=\"true\"). Disabled tabs\n// (aria-disabled) stay reachable but never activate.\nexport default class extends Controller {\n static targets = [\"list\", \"trigger\", \"content\"];\n static values = { active: String, orientation: { type: String, default: \"horizontal\" } };\n\n connect() {\n this.idBase = this.element.id || `ruby-ui-tabs-${Math.random().toString(36).slice(2, 10)}`;\n this.connected = true;\n this.refresh();\n }\n\n disconnect() {\n this.connected = false;\n }\n\n activeValueChanged() {\n if (this.connected) this.sync();\n }\n\n orientationValueChanged() {\n if (this.connected) this.sync();\n }\n\n // Tabs and panels added later (a morph, lazy content) get wired too.\n triggerTargetConnected() {\n if (this.connected) this.refresh();\n }\n\n contentTargetConnected() {\n if (this.connected) this.refresh();\n }\n\n refresh() {\n this.wire();\n if (!this.triggerFor(this.activeValue)) {\n const first = this.triggerTargets.find((tab) => !this.isDisabled(tab)) ?? this.triggerTargets[0];\n // Assigning the value runs sync through activeValueChanged.\n if (first && first.dataset.value !== this.activeValue) {\n this.activeValue = first.dataset.value;\n return;\n }\n }\n this.sync();\n }\n\n // --- Activation --------------------------------------------------------------\n\n show(event) {\n const tab = event.currentTarget;\n if (this.isDisabled(tab)) {\n event.preventDefault();\n return;\n }\n this.activate(tab);\n }\n\n activate(tab) {\n const value = tab.dataset.value;\n if (value === this.activeValue) return;\n const previousValue = this.activeValue;\n this.activeValue = value;\n this.dispatch(\"change\", { detail: { value, previousValue } });\n }\n\n // --- Keyboard ----------------------------------------------------------------\n\n keydown(event) {\n const tab = event.target.closest('[role=\"tab\"]');\n if (!tab || !this.triggerTargets.includes(tab)) return;\n\n // A link tab activates on Enter by itself; Space needs a click.\n if (event.key === \" \" && tab.tagName === \"A\") {\n event.preventDefault();\n tab.click();\n return;\n }\n\n const tabs = this.triggerTargets;\n const index = tabs.indexOf(tab);\n const last = tabs.length - 1;\n const [previousKey, nextKey] = this.navigationKeys();\n let target;\n\n if (event.key === nextKey) target = index === last ? (this.loopFocus ? 0 : last) : index + 1;\n else if (event.key === previousKey) target = index === 0 ? (this.loopFocus ? last : 0) : index - 1;\n else if (event.key === \"Home\") target = 0;\n else if (event.key === \"End\") target = last;\n else return;\n\n event.preventDefault();\n this.focusTab(tabs[target]);\n }\n\n navigationKeys() {\n if (this.orientationValue === \"vertical\") return [\"ArrowUp\", \"ArrowDown\"];\n const rtl = getComputedStyle(this.hasListTarget ? this.listTarget : this.element).direction === \"rtl\";\n return rtl ? [\"ArrowRight\", \"ArrowLeft\"] : [\"ArrowLeft\", \"ArrowRight\"];\n }\n\n focusTab(tab) {\n this.triggerTargets.forEach((el) => el.setAttribute(\"tabindex\", el === tab ? \"0\" : \"-1\"));\n tab.focus();\n if (this.activateOnFocus && !this.isDisabled(tab)) this.activate(tab);\n }\n\n get loopFocus() {\n return !this.hasListTarget || this.listTarget.dataset.loopFocus !== \"false\";\n }\n\n get activateOnFocus() {\n return this.hasListTarget && this.listTarget.dataset.activateOnFocus === \"true\";\n }\n\n // --- State -------------------------------------------------------------------\n\n wire() {\n this.triggerTargets.forEach((tab) => {\n if (!tab.id) tab.id = this.idFor(\"tab\", tab.dataset.value);\n });\n this.contentTargets.forEach((panel) => {\n if (!panel.id) panel.id = this.idFor(\"panel\", panel.dataset.value);\n const tab = this.triggerFor(panel.dataset.value);\n if (tab) {\n tab.setAttribute(\"aria-controls\", panel.id);\n panel.setAttribute(\"aria-labelledby\", tab.id);\n }\n });\n }\n\n sync() {\n const orientation = this.orientationValue;\n const active = this.triggerFor(this.activeValue);\n\n if (this.hasListTarget) {\n this.listTarget.dataset.orientation = orientation;\n if (orientation === \"vertical\") this.listTarget.setAttribute(\"aria-orientation\", \"vertical\");\n else this.listTarget.removeAttribute(\"aria-orientation\");\n }\n\n this.triggerTargets.forEach((tab, index) => {\n const selected = tab === active;\n tab.dataset.orientation = orientation;\n tab.setAttribute(\"aria-selected\", String(selected));\n tab.toggleAttribute(\"data-active\", selected);\n tab.setAttribute(\"tabindex\", selected || (!active && index === 0) ? \"0\" : \"-1\");\n });\n\n this.contentTargets.forEach((panel) => {\n panel.dataset.orientation = orientation;\n panel.hidden = panel.dataset.value !== this.activeValue || !active;\n });\n }\n\n triggerFor(value) {\n if (value === undefined || value === null || value === \"\") return undefined;\n return this.triggerTargets.find((tab) => tab.dataset.value === value);\n }\n\n isDisabled(tab) {\n return tab.disabled || tab.getAttribute(\"aria-disabled\") === \"true\";\n }\n\n idFor(kind, value) {\n return `${this.idBase}-${kind}-${String(value).replace(/[^\\w-]/g, \"-\")}`;\n }\n}\n" }, { "path": "tabs_list.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TabsList < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground\"\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # Base UI's Tabs.List (role=tablist). variant: :default (muted pill) or\n # :line (underline on the active tab). activate_on_focus: true activates a\n # tab as the arrow keys reach it (default: Enter/Space or click activate).\n # loop_focus: false stops the arrow keys at the first and last tab.\n class TabsList < Base\n VARIANTS = %i[default line].freeze\n\n def initialize(variant: :default, activate_on_focus: false, loop_focus: true, **attrs)\n @variant = VARIANTS.include?(variant.to_s.to_sym) ? variant.to_s.to_sym : :default\n @activate_on_focus = activate_on_focus\n @loop_focus = loop_focus\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n role: \"tablist\",\n data: {\n slot: \"tabs-list\",\n variant: @variant,\n activate_on_focus: (@activate_on_focus ? \"true\" : nil),\n loop_focus: (@loop_focus ? nil : \"false\"),\n ruby_ui__tabs_target: \"list\",\n action: \"keydown->ruby-ui--tabs#keydown\"\n },\n class: [\n \"group/tabs-list inline-flex w-fit items-center justify-center p-[3px] text-muted-foreground\",\n # Bare h-9, so a caller's h-* replaces it; vertical lists size to content.\n \"h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col\",\n (@variant == :line) ? \"gap-1 rounded-none bg-transparent\" : \"rounded-lg bg-muted\"\n ]\n }\n end\n end\nend\n" }, { "path": "tabs_trigger.rb", - "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" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # Base UI's Tabs.Tab (role=tab). as: :a renders a link for tabs that\n # navigate. disabled: true keeps the tab reachable with the arrow keys\n # (aria-disabled, as Base UI does) but it can't be activated.\n class TabsTrigger < Base\n def initialize(value:, as: :button, disabled: false, **attrs)\n @value = value\n @as = as\n @disabled = disabled\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 role: \"tab\",\n aria: {selected: \"false\", disabled: (@disabled ? \"true\" : nil)},\n data: {\n slot: \"tabs-trigger\",\n disabled: (@disabled ? true : nil),\n ruby_ui__tabs_target: \"trigger\",\n action: \"click->ruby-ui--tabs#show\",\n value: @value\n },\n class: [\n \"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\",\n \"hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground\",\n \"focus-visible:border-ring focus-visible:outline-1 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n \"disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50\",\n \"group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start\",\n \"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground\",\n \"group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none\",\n \"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\",\n # The line variant's indicator.\n \"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[variant=line]/tabs-list:data-active:after:opacity-100\",\n \"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\",\n \"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\",\n \"has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5\",\n \"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\"\n ]\n }\n base[:type] = :button unless @as == :a\n base\n end\n end\nend\n" } ], "dependencies": { @@ -29,7 +29,7 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Tabs", - "docs_markdown": "# Tabs\n\nA set of layered sections of content—known as tab panels—that are displayed one at a time.\n\n## Usage\n\n### Example\n\n```ruby\nTabs(default_value: \"account\", class: 'w-96') do\n TabsList do\n TabsTrigger(value: \"account\") { \"Account\" }\n TabsTrigger(value: \"password\") { \"Password\" }\n end\n TabsContent(value: \"account\") do\n div(class: \"rounded-lg border p-6 space-y-4 bg-background text-foreground\") do\n div(class: \"space-y-0\") do\n Text(size: \"4\", weight: \"semibold\") { \"Account\" }\n Text(size: \"2\", class: \"text-muted-foreground\") { \"Update your account details.\" }\n end\n end\n end\n TabsContent(value: \"password\") do\n div(class: \"rounded-lg border p-6 space-y-4 bg-background text-foreground\") do\n div do\n Text(size: \"4\", weight: \"semibold\") { \"Password\" }\n Text(size: \"2\", class: \"text-muted-foreground\") { \"Change your password here. After saving, you'll be logged out.\" }\n end\n end\n end\nend\n```\n\n### Disabled\n\n```ruby\nTabs(default_value: \"account\", class: 'w-96') do\n TabsList do\n TabsTrigger(disabled: true, value: \"account\") { \"Account\" }\n TabsTrigger(disabled: true, value: \"password\") { \"Password\" }\n end\nend\n```\n\n### Aria Disabled\n\n```ruby\nTabs(default_value: \"account\", class: 'w-96') do\n TabsList do\n TabsTrigger(aria: {disabled: \"true\"}, value: \"account\") { \"Account\" }\n TabsTrigger(aria: {disabled: \"true\"}, value: \"password\") { \"Password\" }\n end\nend\n```\n\n### Full width\n\n```ruby\nTabs(default_value: \"overview\", class: 'w-96') do\n TabsList(class: 'w-full grid grid-cols-2') do\n TabsTrigger(value: \"overview\") do\n book_icon\n span(class: 'ml-2') { \"Overview\" }\n end\n TabsTrigger(value: \"repositories\") do\n repo_icon\n span(class: 'ml-2') { \"Repositories\" }\n end\n end\n TabsContent(value: \"overview\") do\n div(class: \"rounded-lg border p-6 bg-background text-foreground flex justify-between space-x-4\") do\n Avatar do\n AvatarImage(src: \"https://avatars.githubusercontent.com/u/246692?v=4\", alt: \"joeldrapper\")\n AvatarFallback { \"JD\" }\n end\n div(class: \"space-y-4\") do\n div do\n Text(size: \"4\", weight: \"semibold\") { \"Joel Drapper\" }\n Text(size: \"2\", class: \"text-muted-foreground\") { \"Creator of Phlex Components. Ruby on Rails developer.\" }\n end\n Link(href: \"https://github.com/joeldrapper\", variant: :outline, size: :sm) do\n github_icon\n span(class: 'ml-2') { \"View profile\" }\n end\n end\n end\n end\n TabsContent(value: \"repositories\") do\n div(class: \"rounded-lg border p-6 space-y-4 bg-background text-foreground\") do\n repo = repositories.first\n Link(href: repo.github_url, variant: :link, class: \"pl-0\") { repo.name }\n Badge { repo.version }\n end\n end\nend\n```\n\n### Change default value\n\n```ruby\nTabs(default: \"password\", class: 'w-96') do\n TabsList do\n TabsTrigger(value: \"account\") { \"Account\" }\n TabsTrigger(value: \"password\") { \"Password\" }\n end\n TabsContent(value: \"account\") do\n div(class: \"rounded-lg border p-6 space-y-4 bg-background text-foreground\") do\n div(class: \"space-y-0\") do\n Text(size: \"4\", weight: \"semibold\") { \"Account\" }\n Text(size: \"2\", class: \"text-muted-foreground\") { \"Update your account details.\" }\n end\n end\n end\n TabsContent(value: \"password\") do\n div(class: \"rounded-lg border p-6 space-y-4 bg-background text-foreground\") do\n div do\n Text(size: \"4\", weight: \"semibold\") { \"Password\" }\n Text(size: \"2\", class: \"text-muted-foreground\") { \"Change your password here. After saving, you'll be logged out.\" }\n end\n end\n end\nend\n```", + "docs_markdown": "# Tabs\n\nA set of layered sections of content—known as tab panels—that are displayed one at a time.\n\n## Usage\n\n### Example\n\n```ruby\nTabs(default_value: \"account\", class: 'w-96') do\n TabsList do\n TabsTrigger(value: \"account\") { \"Account\" }\n TabsTrigger(value: \"password\") { \"Password\" }\n end\n TabsContent(value: \"account\") do\n div(class: \"rounded-lg border p-6 space-y-4 bg-background text-foreground\") do\n div(class: \"space-y-0\") do\n Text(size: \"4\", weight: \"semibold\") { \"Account\" }\n Text(size: \"2\", class: \"text-muted-foreground\") { \"Update your account details.\" }\n end\n end\n end\n TabsContent(value: \"password\") do\n div(class: \"rounded-lg border p-6 space-y-4 bg-background text-foreground\") do\n div do\n Text(size: \"4\", weight: \"semibold\") { \"Password\" }\n Text(size: \"2\", class: \"text-muted-foreground\") { \"Change your password here. After saving, you'll be logged out.\" }\n end\n end\n end\nend\n```\n\n### Disabled\n\n```ruby\nTabs(default_value: \"account\", class: 'w-96') do\n TabsList do\n TabsTrigger(disabled: true, value: \"account\") { \"Account\" }\n TabsTrigger(disabled: true, value: \"password\") { \"Password\" }\n end\nend\n```\n\n### Aria Disabled\n\n```ruby\nTabs(default_value: \"account\", class: 'w-96') do\n TabsList do\n TabsTrigger(aria: {disabled: \"true\"}, value: \"account\") { \"Account\" }\n TabsTrigger(aria: {disabled: \"true\"}, value: \"password\") { \"Password\" }\n end\nend\n```\n\n### Full width\n\n```ruby\nTabs(default_value: \"overview\", class: 'w-96') do\n TabsList(class: 'w-full grid grid-cols-2') do\n TabsTrigger(value: \"overview\") do\n book_icon\n span { \"Overview\" }\n end\n TabsTrigger(value: \"repositories\") do\n repo_icon\n span { \"Repositories\" }\n end\n end\n TabsContent(value: \"overview\") do\n div(class: \"rounded-lg border p-6 bg-background text-foreground flex justify-between space-x-4\") do\n Avatar do\n AvatarImage(src: \"https://avatars.githubusercontent.com/u/246692?v=4\", alt: \"joeldrapper\")\n AvatarFallback { \"JD\" }\n end\n div(class: \"space-y-4\") do\n div do\n Text(size: \"4\", weight: \"semibold\") { \"Joel Drapper\" }\n Text(size: \"2\", class: \"text-muted-foreground\") { \"Creator of Phlex Components. Ruby on Rails developer.\" }\n end\n Link(href: \"https://github.com/joeldrapper\", variant: :outline, size: :sm) do\n github_icon\n span(class: 'ml-2') { \"View profile\" }\n end\n end\n end\n end\n TabsContent(value: \"repositories\") do\n div(class: \"rounded-lg border p-6 space-y-4 bg-background text-foreground\") do\n repo = repositories.first\n Link(href: repo.github_url, variant: :link, class: \"pl-0\") { repo.name }\n Badge { repo.version }\n end\n end\nend\n```\n\n### Line\n\n```ruby\nTabs(default_value: \"overview\", class: 'w-96') do\n TabsList(variant: :line) do\n TabsTrigger(value: \"overview\") { \"Overview\" }\n TabsTrigger(value: \"analytics\") { \"Analytics\" }\n TabsTrigger(value: \"reports\") { \"Reports\" }\n end\n TabsContent(value: \"overview\") { \"A summary of your account activity.\" }\n TabsContent(value: \"analytics\") { \"Traffic and conversion over time.\" }\n TabsContent(value: \"reports\") { \"Download monthly reports.\" }\nend\n```\n\n### Vertical\n\n```ruby\n# The arrow keys follow the orientation: Up/Down here, Left/Right otherwise.\nTabs(default_value: \"general\", orientation: :vertical, class: 'w-96') do\n TabsList do\n TabsTrigger(value: \"general\") { \"General\" }\n TabsTrigger(value: \"security\") { \"Security\" }\n TabsTrigger(value: \"billing\") { \"Billing\" }\n end\n TabsContent(value: \"general\") { \"Name, language and time zone.\" }\n TabsContent(value: \"security\") { \"Password and two-factor authentication.\" }\n TabsContent(value: \"billing\") { \"Plan, invoices and payment method.\" }\nend\n```\n\n### Keyboard\n\n```ruby\n# By default the arrow keys move focus and Enter/Space activate.\n# activate_on_focus: true activates as focus moves; loop_focus: false\n# stops at the first and last tab.\nTabs(default_value: \"account\", class: 'w-96') do\n TabsList(activate_on_focus: true, loop_focus: false) do\n TabsTrigger(value: \"account\") { \"Account\" }\n TabsTrigger(value: \"password\") { \"Password\" }\n end\n TabsContent(value: \"account\") { \"Make changes to your account here.\" }\n TabsContent(value: \"password\") { \"Change your password here.\" }\nend\n```\n\n### Change default value\n\n```ruby\nTabs(default_value: \"password\", class: 'w-96') do\n TabsList do\n TabsTrigger(value: \"account\") { \"Account\" }\n TabsTrigger(value: \"password\") { \"Password\" }\n end\n TabsContent(value: \"account\") do\n div(class: \"rounded-lg border p-6 space-y-4 bg-background text-foreground\") do\n div(class: \"space-y-0\") do\n Text(size: \"4\", weight: \"semibold\") { \"Account\" }\n Text(size: \"2\", class: \"text-muted-foreground\") { \"Update your account details.\" }\n end\n end\n end\n TabsContent(value: \"password\") do\n div(class: \"rounded-lg border p-6 space-y-4 bg-background text-foreground\") do\n div do\n Text(size: \"4\", weight: \"semibold\") { \"Password\" }\n Text(size: \"2\", class: \"text-muted-foreground\") { \"Change your password here. After saving, you'll be logged out.\" }\n end\n end\n end\nend\n```", "examples": [ { "title": "Example", @@ -48,12 +48,27 @@ }, { "title": "Full width", - "code": "Tabs(default_value: \"overview\", class: 'w-96') do\n TabsList(class: 'w-full grid grid-cols-2') do\n TabsTrigger(value: \"overview\") do\n book_icon\n span(class: 'ml-2') { \"Overview\" }\n end\n TabsTrigger(value: \"repositories\") do\n repo_icon\n span(class: 'ml-2') { \"Repositories\" }\n end\n end\n TabsContent(value: \"overview\") do\n div(class: \"rounded-lg border p-6 bg-background text-foreground flex justify-between space-x-4\") do\n Avatar do\n AvatarImage(src: \"https://avatars.githubusercontent.com/u/246692?v=4\", alt: \"joeldrapper\")\n AvatarFallback { \"JD\" }\n end\n div(class: \"space-y-4\") do\n div do\n Text(size: \"4\", weight: \"semibold\") { \"Joel Drapper\" }\n Text(size: \"2\", class: \"text-muted-foreground\") { \"Creator of Phlex Components. Ruby on Rails developer.\" }\n end\n Link(href: \"https://github.com/joeldrapper\", variant: :outline, size: :sm) do\n github_icon\n span(class: 'ml-2') { \"View profile\" }\n end\n end\n end\n end\n TabsContent(value: \"repositories\") do\n div(class: \"rounded-lg border p-6 space-y-4 bg-background text-foreground\") do\n repo = repositories.first\n Link(href: repo.github_url, variant: :link, class: \"pl-0\") { repo.name }\n Badge { repo.version }\n end\n end\nend\n", + "code": "Tabs(default_value: \"overview\", class: 'w-96') do\n TabsList(class: 'w-full grid grid-cols-2') do\n TabsTrigger(value: \"overview\") do\n book_icon\n span { \"Overview\" }\n end\n TabsTrigger(value: \"repositories\") do\n repo_icon\n span { \"Repositories\" }\n end\n end\n TabsContent(value: \"overview\") do\n div(class: \"rounded-lg border p-6 bg-background text-foreground flex justify-between space-x-4\") do\n Avatar do\n AvatarImage(src: \"https://avatars.githubusercontent.com/u/246692?v=4\", alt: \"joeldrapper\")\n AvatarFallback { \"JD\" }\n end\n div(class: \"space-y-4\") do\n div do\n Text(size: \"4\", weight: \"semibold\") { \"Joel Drapper\" }\n Text(size: \"2\", class: \"text-muted-foreground\") { \"Creator of Phlex Components. Ruby on Rails developer.\" }\n end\n Link(href: \"https://github.com/joeldrapper\", variant: :outline, size: :sm) do\n github_icon\n span(class: 'ml-2') { \"View profile\" }\n end\n end\n end\n end\n TabsContent(value: \"repositories\") do\n div(class: \"rounded-lg border p-6 space-y-4 bg-background text-foreground\") do\n repo = repositories.first\n Link(href: repo.github_url, variant: :link, class: \"pl-0\") { repo.name }\n Badge { repo.version }\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "Line", + "code": "Tabs(default_value: \"overview\", class: 'w-96') do\n TabsList(variant: :line) do\n TabsTrigger(value: \"overview\") { \"Overview\" }\n TabsTrigger(value: \"analytics\") { \"Analytics\" }\n TabsTrigger(value: \"reports\") { \"Reports\" }\n end\n TabsContent(value: \"overview\") { \"A summary of your account activity.\" }\n TabsContent(value: \"analytics\") { \"Traffic and conversion over time.\" }\n TabsContent(value: \"reports\") { \"Download monthly reports.\" }\nend\n", + "language": "ruby" + }, + { + "title": "Vertical", + "code": "# The arrow keys follow the orientation: Up/Down here, Left/Right otherwise.\nTabs(default_value: \"general\", orientation: :vertical, class: 'w-96') do\n TabsList do\n TabsTrigger(value: \"general\") { \"General\" }\n TabsTrigger(value: \"security\") { \"Security\" }\n TabsTrigger(value: \"billing\") { \"Billing\" }\n end\n TabsContent(value: \"general\") { \"Name, language and time zone.\" }\n TabsContent(value: \"security\") { \"Password and two-factor authentication.\" }\n TabsContent(value: \"billing\") { \"Plan, invoices and payment method.\" }\nend\n", + "language": "ruby" + }, + { + "title": "Keyboard", + "code": "# By default the arrow keys move focus and Enter/Space activate.\n# activate_on_focus: true activates as focus moves; loop_focus: false\n# stops at the first and last tab.\nTabs(default_value: \"account\", class: 'w-96') do\n TabsList(activate_on_focus: true, loop_focus: false) do\n TabsTrigger(value: \"account\") { \"Account\" }\n TabsTrigger(value: \"password\") { \"Password\" }\n end\n TabsContent(value: \"account\") { \"Make changes to your account here.\" }\n TabsContent(value: \"password\") { \"Change your password here.\" }\nend\n", "language": "ruby" }, { "title": "Change default value", - "code": "Tabs(default: \"password\", class: 'w-96') do\n TabsList do\n TabsTrigger(value: \"account\") { \"Account\" }\n TabsTrigger(value: \"password\") { \"Password\" }\n end\n TabsContent(value: \"account\") do\n div(class: \"rounded-lg border p-6 space-y-4 bg-background text-foreground\") do\n div(class: \"space-y-0\") do\n Text(size: \"4\", weight: \"semibold\") { \"Account\" }\n Text(size: \"2\", class: \"text-muted-foreground\") { \"Update your account details.\" }\n end\n end\n end\n TabsContent(value: \"password\") do\n div(class: \"rounded-lg border p-6 space-y-4 bg-background text-foreground\") do\n div do\n Text(size: \"4\", weight: \"semibold\") { \"Password\" }\n Text(size: \"2\", class: \"text-muted-foreground\") { \"Change your password here. After saving, you'll be logged out.\" }\n end\n end\n end\nend\n", + "code": "Tabs(default_value: \"password\", class: 'w-96') do\n TabsList do\n TabsTrigger(value: \"account\") { \"Account\" }\n TabsTrigger(value: \"password\") { \"Password\" }\n end\n TabsContent(value: \"account\") do\n div(class: \"rounded-lg border p-6 space-y-4 bg-background text-foreground\") do\n div(class: \"space-y-0\") do\n Text(size: \"4\", weight: \"semibold\") { \"Account\" }\n Text(size: \"2\", class: \"text-muted-foreground\") { \"Update your account details.\" }\n end\n end\n end\n TabsContent(value: \"password\") do\n div(class: \"rounded-lg border p-6 space-y-4 bg-background text-foreground\") do\n div do\n Text(size: \"4\", weight: \"semibold\") { \"Password\" }\n Text(size: \"2\", class: \"text-muted-foreground\") { \"Change your password here. After saving, you'll be logged out.\" }\n end\n end\n end\nend\n", "language": "ruby" } ] From 242ecbea909d62510c3d2f04a139ae5a8e9c46eb Mon Sep 17 00:00:00 2001 From: Cirdes Date: Wed, 4 Nov 2026 12:04:49 -0300 Subject: [PATCH 2/3] [Bug Fix] Tabs: harden the controller after review - Removing the active tab (or its panel) hands selection to a remaining enabled tab, so the list never ends up with no tabbable tab. - Each sync re-wires ids and aria-controls/aria-labelledby, so a morph that drops them gets them back. - Generated ids use a counter, so values like "a/b" and "a-b" no longer collide on one id. - Leaving the tab list without activating puts the tab stop back on the selected tab, so Tab back in lands there (APG). Co-Authored-By: Claude Opus 5.5 --- gem/lib/ruby_ui/tabs/tabs_controller.js | 30 ++++++++++++++++++++----- gem/lib/ruby_ui/tabs/tabs_list.rb | 2 +- gem/test/ruby_ui/tabs_test.rb | 2 +- mcp/data/registry/components/tabs.json | 4 ++-- 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/gem/lib/ruby_ui/tabs/tabs_controller.js b/gem/lib/ruby_ui/tabs/tabs_controller.js index 59add7cda..a47ff84d7 100644 --- a/gem/lib/ruby_ui/tabs/tabs_controller.js +++ b/gem/lib/ruby_ui/tabs/tabs_controller.js @@ -13,6 +13,7 @@ export default class extends Controller { static values = { active: String, orientation: { type: String, default: "horizontal" } }; connect() { + this.idCount = 0; this.idBase = this.element.id || `ruby-ui-tabs-${Math.random().toString(36).slice(2, 10)}`; this.connected = true; this.refresh(); @@ -39,8 +40,23 @@ export default class extends Controller { 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() { - this.wire(); if (!this.triggerFor(this.activeValue)) { const first = this.triggerTargets.find((tab) => !this.isDisabled(tab)) ?? this.triggerTargets[0]; // Assigning the value runs sync through activeValueChanged. @@ -124,10 +140,10 @@ export default class extends Controller { wire() { this.triggerTargets.forEach((tab) => { - if (!tab.id) tab.id = this.idFor("tab", tab.dataset.value); + if (!tab.id) tab.id = this.nextId("tab"); }); this.contentTargets.forEach((panel) => { - if (!panel.id) panel.id = this.idFor("panel", panel.dataset.value); + if (!panel.id) panel.id = this.nextId("panel"); const tab = this.triggerFor(panel.dataset.value); if (tab) { tab.setAttribute("aria-controls", panel.id); @@ -137,6 +153,8 @@ export default class extends Controller { } sync() { + // Re-wiring is idempotent, and restores ids/ARIA a morph may have dropped. + this.wire(); const orientation = this.orientationValue; const active = this.triggerFor(this.activeValue); @@ -169,7 +187,9 @@ export default class extends Controller { return tab.disabled || tab.getAttribute("aria-disabled") === "true"; } - idFor(kind, value) { - return `${this.idBase}-${kind}-${String(value).replace(/[^\w-]/g, "-")}`; + // A counter, not the value: distinct values never collide on one id. + nextId(kind) { + this.idCount += 1; + return `${this.idBase}-${kind}-${this.idCount}`; } } diff --git a/gem/lib/ruby_ui/tabs/tabs_list.rb b/gem/lib/ruby_ui/tabs/tabs_list.rb index 300967cd0..a9253064b 100644 --- a/gem/lib/ruby_ui/tabs/tabs_list.rb +++ b/gem/lib/ruby_ui/tabs/tabs_list.rb @@ -30,7 +30,7 @@ def default_attrs 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" + 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", diff --git a/gem/test/ruby_ui/tabs_test.rb b/gem/test/ruby_ui/tabs_test.rb index bc01420fc..ad0b949e4 100644 --- a/gem/test/ruby_ui/tabs_test.rb +++ b/gem/test/ruby_ui/tabs_test.rb @@ -72,7 +72,7 @@ def test_list_is_a_tablist_with_the_default_variant 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/, 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 diff --git a/mcp/data/registry/components/tabs.json b/mcp/data/registry/components/tabs.json index f1d5a7bc6..973ae3c40 100644 --- a/mcp/data/registry/components/tabs.json +++ b/mcp/data/registry/components/tabs.json @@ -12,11 +12,11 @@ }, { "path": "tabs_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"ruby-ui--tabs\"\n//\n// Follows Base UI's Tabs: role=tablist/tab/tabpanel, each tab pointing at its\n// panel (aria-controls) and each panel labelled by its tab, a roving tabindex\n// on the tabs, arrow keys that move focus (wrapping unless the list says\n// data-loop-focus=\"false\"), Home/End, and manual activation by Enter, Space or\n// click (or on focus with data-activate-on-focus=\"true\"). Disabled tabs\n// (aria-disabled) stay reachable but never activate.\nexport default class extends Controller {\n static targets = [\"list\", \"trigger\", \"content\"];\n static values = { active: String, orientation: { type: String, default: \"horizontal\" } };\n\n connect() {\n this.idBase = this.element.id || `ruby-ui-tabs-${Math.random().toString(36).slice(2, 10)}`;\n this.connected = true;\n this.refresh();\n }\n\n disconnect() {\n this.connected = false;\n }\n\n activeValueChanged() {\n if (this.connected) this.sync();\n }\n\n orientationValueChanged() {\n if (this.connected) this.sync();\n }\n\n // Tabs and panels added later (a morph, lazy content) get wired too.\n triggerTargetConnected() {\n if (this.connected) this.refresh();\n }\n\n contentTargetConnected() {\n if (this.connected) this.refresh();\n }\n\n refresh() {\n this.wire();\n if (!this.triggerFor(this.activeValue)) {\n const first = this.triggerTargets.find((tab) => !this.isDisabled(tab)) ?? this.triggerTargets[0];\n // Assigning the value runs sync through activeValueChanged.\n if (first && first.dataset.value !== this.activeValue) {\n this.activeValue = first.dataset.value;\n return;\n }\n }\n this.sync();\n }\n\n // --- Activation --------------------------------------------------------------\n\n show(event) {\n const tab = event.currentTarget;\n if (this.isDisabled(tab)) {\n event.preventDefault();\n return;\n }\n this.activate(tab);\n }\n\n activate(tab) {\n const value = tab.dataset.value;\n if (value === this.activeValue) return;\n const previousValue = this.activeValue;\n this.activeValue = value;\n this.dispatch(\"change\", { detail: { value, previousValue } });\n }\n\n // --- Keyboard ----------------------------------------------------------------\n\n keydown(event) {\n const tab = event.target.closest('[role=\"tab\"]');\n if (!tab || !this.triggerTargets.includes(tab)) return;\n\n // A link tab activates on Enter by itself; Space needs a click.\n if (event.key === \" \" && tab.tagName === \"A\") {\n event.preventDefault();\n tab.click();\n return;\n }\n\n const tabs = this.triggerTargets;\n const index = tabs.indexOf(tab);\n const last = tabs.length - 1;\n const [previousKey, nextKey] = this.navigationKeys();\n let target;\n\n if (event.key === nextKey) target = index === last ? (this.loopFocus ? 0 : last) : index + 1;\n else if (event.key === previousKey) target = index === 0 ? (this.loopFocus ? last : 0) : index - 1;\n else if (event.key === \"Home\") target = 0;\n else if (event.key === \"End\") target = last;\n else return;\n\n event.preventDefault();\n this.focusTab(tabs[target]);\n }\n\n navigationKeys() {\n if (this.orientationValue === \"vertical\") return [\"ArrowUp\", \"ArrowDown\"];\n const rtl = getComputedStyle(this.hasListTarget ? this.listTarget : this.element).direction === \"rtl\";\n return rtl ? [\"ArrowRight\", \"ArrowLeft\"] : [\"ArrowLeft\", \"ArrowRight\"];\n }\n\n focusTab(tab) {\n this.triggerTargets.forEach((el) => el.setAttribute(\"tabindex\", el === tab ? \"0\" : \"-1\"));\n tab.focus();\n if (this.activateOnFocus && !this.isDisabled(tab)) this.activate(tab);\n }\n\n get loopFocus() {\n return !this.hasListTarget || this.listTarget.dataset.loopFocus !== \"false\";\n }\n\n get activateOnFocus() {\n return this.hasListTarget && this.listTarget.dataset.activateOnFocus === \"true\";\n }\n\n // --- State -------------------------------------------------------------------\n\n wire() {\n this.triggerTargets.forEach((tab) => {\n if (!tab.id) tab.id = this.idFor(\"tab\", tab.dataset.value);\n });\n this.contentTargets.forEach((panel) => {\n if (!panel.id) panel.id = this.idFor(\"panel\", panel.dataset.value);\n const tab = this.triggerFor(panel.dataset.value);\n if (tab) {\n tab.setAttribute(\"aria-controls\", panel.id);\n panel.setAttribute(\"aria-labelledby\", tab.id);\n }\n });\n }\n\n sync() {\n const orientation = this.orientationValue;\n const active = this.triggerFor(this.activeValue);\n\n if (this.hasListTarget) {\n this.listTarget.dataset.orientation = orientation;\n if (orientation === \"vertical\") this.listTarget.setAttribute(\"aria-orientation\", \"vertical\");\n else this.listTarget.removeAttribute(\"aria-orientation\");\n }\n\n this.triggerTargets.forEach((tab, index) => {\n const selected = tab === active;\n tab.dataset.orientation = orientation;\n tab.setAttribute(\"aria-selected\", String(selected));\n tab.toggleAttribute(\"data-active\", selected);\n tab.setAttribute(\"tabindex\", selected || (!active && index === 0) ? \"0\" : \"-1\");\n });\n\n this.contentTargets.forEach((panel) => {\n panel.dataset.orientation = orientation;\n panel.hidden = panel.dataset.value !== this.activeValue || !active;\n });\n }\n\n triggerFor(value) {\n if (value === undefined || value === null || value === \"\") return undefined;\n return this.triggerTargets.find((tab) => tab.dataset.value === value);\n }\n\n isDisabled(tab) {\n return tab.disabled || tab.getAttribute(\"aria-disabled\") === \"true\";\n }\n\n idFor(kind, value) {\n return `${this.idBase}-${kind}-${String(value).replace(/[^\\w-]/g, \"-\")}`;\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"ruby-ui--tabs\"\n//\n// Follows Base UI's Tabs: role=tablist/tab/tabpanel, each tab pointing at its\n// panel (aria-controls) and each panel labelled by its tab, a roving tabindex\n// on the tabs, arrow keys that move focus (wrapping unless the list says\n// data-loop-focus=\"false\"), Home/End, and manual activation by Enter, Space or\n// click (or on focus with data-activate-on-focus=\"true\"). Disabled tabs\n// (aria-disabled) stay reachable but never activate.\nexport default class extends Controller {\n static targets = [\"list\", \"trigger\", \"content\"];\n static values = { active: String, orientation: { type: String, default: \"horizontal\" } };\n\n connect() {\n this.idCount = 0;\n this.idBase = this.element.id || `ruby-ui-tabs-${Math.random().toString(36).slice(2, 10)}`;\n this.connected = true;\n this.refresh();\n }\n\n disconnect() {\n this.connected = false;\n }\n\n activeValueChanged() {\n if (this.connected) this.sync();\n }\n\n orientationValueChanged() {\n if (this.connected) this.sync();\n }\n\n // Tabs and panels added later (a morph, lazy content) get wired too.\n triggerTargetConnected() {\n if (this.connected) this.refresh();\n }\n\n contentTargetConnected() {\n if (this.connected) this.refresh();\n }\n\n // ...and removing the active tab hands selection to a remaining one.\n triggerTargetDisconnected() {\n if (this.connected) this.refresh();\n }\n\n contentTargetDisconnected() {\n if (this.connected) this.refresh();\n }\n\n // Leaving the list without activating: Tab back in lands on the selected\n // tab again, as the APG asks.\n listFocusout(event) {\n if (this.hasListTarget && this.listTarget.contains(event.relatedTarget)) return;\n this.sync();\n }\n\n refresh() {\n if (!this.triggerFor(this.activeValue)) {\n const first = this.triggerTargets.find((tab) => !this.isDisabled(tab)) ?? this.triggerTargets[0];\n // Assigning the value runs sync through activeValueChanged.\n if (first && first.dataset.value !== this.activeValue) {\n this.activeValue = first.dataset.value;\n return;\n }\n }\n this.sync();\n }\n\n // --- Activation --------------------------------------------------------------\n\n show(event) {\n const tab = event.currentTarget;\n if (this.isDisabled(tab)) {\n event.preventDefault();\n return;\n }\n this.activate(tab);\n }\n\n activate(tab) {\n const value = tab.dataset.value;\n if (value === this.activeValue) return;\n const previousValue = this.activeValue;\n this.activeValue = value;\n this.dispatch(\"change\", { detail: { value, previousValue } });\n }\n\n // --- Keyboard ----------------------------------------------------------------\n\n keydown(event) {\n const tab = event.target.closest('[role=\"tab\"]');\n if (!tab || !this.triggerTargets.includes(tab)) return;\n\n // A link tab activates on Enter by itself; Space needs a click.\n if (event.key === \" \" && tab.tagName === \"A\") {\n event.preventDefault();\n tab.click();\n return;\n }\n\n const tabs = this.triggerTargets;\n const index = tabs.indexOf(tab);\n const last = tabs.length - 1;\n const [previousKey, nextKey] = this.navigationKeys();\n let target;\n\n if (event.key === nextKey) target = index === last ? (this.loopFocus ? 0 : last) : index + 1;\n else if (event.key === previousKey) target = index === 0 ? (this.loopFocus ? last : 0) : index - 1;\n else if (event.key === \"Home\") target = 0;\n else if (event.key === \"End\") target = last;\n else return;\n\n event.preventDefault();\n this.focusTab(tabs[target]);\n }\n\n navigationKeys() {\n if (this.orientationValue === \"vertical\") return [\"ArrowUp\", \"ArrowDown\"];\n const rtl = getComputedStyle(this.hasListTarget ? this.listTarget : this.element).direction === \"rtl\";\n return rtl ? [\"ArrowRight\", \"ArrowLeft\"] : [\"ArrowLeft\", \"ArrowRight\"];\n }\n\n focusTab(tab) {\n this.triggerTargets.forEach((el) => el.setAttribute(\"tabindex\", el === tab ? \"0\" : \"-1\"));\n tab.focus();\n if (this.activateOnFocus && !this.isDisabled(tab)) this.activate(tab);\n }\n\n get loopFocus() {\n return !this.hasListTarget || this.listTarget.dataset.loopFocus !== \"false\";\n }\n\n get activateOnFocus() {\n return this.hasListTarget && this.listTarget.dataset.activateOnFocus === \"true\";\n }\n\n // --- State -------------------------------------------------------------------\n\n wire() {\n this.triggerTargets.forEach((tab) => {\n if (!tab.id) tab.id = this.nextId(\"tab\");\n });\n this.contentTargets.forEach((panel) => {\n if (!panel.id) panel.id = this.nextId(\"panel\");\n const tab = this.triggerFor(panel.dataset.value);\n if (tab) {\n tab.setAttribute(\"aria-controls\", panel.id);\n panel.setAttribute(\"aria-labelledby\", tab.id);\n }\n });\n }\n\n sync() {\n // Re-wiring is idempotent, and restores ids/ARIA a morph may have dropped.\n this.wire();\n const orientation = this.orientationValue;\n const active = this.triggerFor(this.activeValue);\n\n if (this.hasListTarget) {\n this.listTarget.dataset.orientation = orientation;\n if (orientation === \"vertical\") this.listTarget.setAttribute(\"aria-orientation\", \"vertical\");\n else this.listTarget.removeAttribute(\"aria-orientation\");\n }\n\n this.triggerTargets.forEach((tab, index) => {\n const selected = tab === active;\n tab.dataset.orientation = orientation;\n tab.setAttribute(\"aria-selected\", String(selected));\n tab.toggleAttribute(\"data-active\", selected);\n tab.setAttribute(\"tabindex\", selected || (!active && index === 0) ? \"0\" : \"-1\");\n });\n\n this.contentTargets.forEach((panel) => {\n panel.dataset.orientation = orientation;\n panel.hidden = panel.dataset.value !== this.activeValue || !active;\n });\n }\n\n triggerFor(value) {\n if (value === undefined || value === null || value === \"\") return undefined;\n return this.triggerTargets.find((tab) => tab.dataset.value === value);\n }\n\n isDisabled(tab) {\n return tab.disabled || tab.getAttribute(\"aria-disabled\") === \"true\";\n }\n\n // A counter, not the value: distinct values never collide on one id.\n nextId(kind) {\n this.idCount += 1;\n return `${this.idBase}-${kind}-${this.idCount}`;\n }\n}\n" }, { "path": "tabs_list.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # Base UI's Tabs.List (role=tablist). variant: :default (muted pill) or\n # :line (underline on the active tab). activate_on_focus: true activates a\n # tab as the arrow keys reach it (default: Enter/Space or click activate).\n # loop_focus: false stops the arrow keys at the first and last tab.\n class TabsList < Base\n VARIANTS = %i[default line].freeze\n\n def initialize(variant: :default, activate_on_focus: false, loop_focus: true, **attrs)\n @variant = VARIANTS.include?(variant.to_s.to_sym) ? variant.to_s.to_sym : :default\n @activate_on_focus = activate_on_focus\n @loop_focus = loop_focus\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n role: \"tablist\",\n data: {\n slot: \"tabs-list\",\n variant: @variant,\n activate_on_focus: (@activate_on_focus ? \"true\" : nil),\n loop_focus: (@loop_focus ? nil : \"false\"),\n ruby_ui__tabs_target: \"list\",\n action: \"keydown->ruby-ui--tabs#keydown\"\n },\n class: [\n \"group/tabs-list inline-flex w-fit items-center justify-center p-[3px] text-muted-foreground\",\n # Bare h-9, so a caller's h-* replaces it; vertical lists size to content.\n \"h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col\",\n (@variant == :line) ? \"gap-1 rounded-none bg-transparent\" : \"rounded-lg bg-muted\"\n ]\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # Base UI's Tabs.List (role=tablist). variant: :default (muted pill) or\n # :line (underline on the active tab). activate_on_focus: true activates a\n # tab as the arrow keys reach it (default: Enter/Space or click activate).\n # loop_focus: false stops the arrow keys at the first and last tab.\n class TabsList < Base\n VARIANTS = %i[default line].freeze\n\n def initialize(variant: :default, activate_on_focus: false, loop_focus: true, **attrs)\n @variant = VARIANTS.include?(variant.to_s.to_sym) ? variant.to_s.to_sym : :default\n @activate_on_focus = activate_on_focus\n @loop_focus = loop_focus\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n role: \"tablist\",\n data: {\n slot: \"tabs-list\",\n variant: @variant,\n activate_on_focus: (@activate_on_focus ? \"true\" : nil),\n loop_focus: (@loop_focus ? nil : \"false\"),\n ruby_ui__tabs_target: \"list\",\n action: \"keydown->ruby-ui--tabs#keydown focusout->ruby-ui--tabs#listFocusout\"\n },\n class: [\n \"group/tabs-list inline-flex w-fit items-center justify-center p-[3px] text-muted-foreground\",\n # Bare h-9, so a caller's h-* replaces it; vertical lists size to content.\n \"h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col\",\n (@variant == :line) ? \"gap-1 rounded-none bg-transparent\" : \"rounded-lg bg-muted\"\n ]\n }\n end\n end\nend\n" }, { "path": "tabs_trigger.rb", From 06e1d3ca01ec75f79f8fc2ecb3189572ac58c263 Mon Sep 17 00:00:00 2001 From: Cirdes Date: Wed, 4 Nov 2026 12:12:44 -0300 Subject: [PATCH 3/3] [Bug Fix] Tabs: address Cubic review - The root's flex direction follows data-orientation, which the controller keeps in sync, so a runtime orientation change re-lays out. - Tabs and panels drop aria-controls/aria-labelledby when their counterpart is removed. - Generated ids survive reconnects: the base and counter persist and ids already in the document are skipped. - The audit fixture uses default_value:; a test name matches what it checks. Co-Authored-By: Claude Opus 5.5 --- audit/fixtures/tabs/rubyui.rb | 2 +- gem/lib/ruby_ui/tabs/tabs.rb | 3 +- gem/lib/ruby_ui/tabs/tabs_controller.js | 37 +++++++++++++++++++------ gem/test/ruby_ui/tabs_test.rb | 5 ++-- mcp/data/registry/components/tabs.json | 4 +-- 5 files changed, 35 insertions(+), 16 deletions(-) diff --git a/audit/fixtures/tabs/rubyui.rb b/audit/fixtures/tabs/rubyui.rb index f209f36a9..2c464f273 100644 --- a/audit/fixtures/tabs/rubyui.rb +++ b/audit/fixtures/tabs/rubyui.rb @@ -6,7 +6,7 @@ class Tabs < Phlex::HTML include RubyUI def view_template - Tabs(default: "account", class: "w-96") do + Tabs(default_value: "account", class: "w-96") do TabsList do TabsTrigger(value: "account") { "Account" } TabsTrigger(value: "password", data: {audit_trigger: true}) { "Password" } diff --git a/gem/lib/ruby_ui/tabs/tabs.rb b/gem/lib/ruby_ui/tabs/tabs.rb index 5cac367b2..040b5a408 100644 --- a/gem/lib/ruby_ui/tabs/tabs.rb +++ b/gem/lib/ruby_ui/tabs/tabs.rb @@ -33,7 +33,8 @@ def default_attrs ruby_ui__tabs_active_value: @default_value, ruby_ui__tabs_orientation_value: @orientation }, - class: ["group/tabs flex gap-2", ("flex-col" if @orientation == :horizontal)] + # 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_controller.js b/gem/lib/ruby_ui/tabs/tabs_controller.js index a47ff84d7..74c2df29c 100644 --- a/gem/lib/ruby_ui/tabs/tabs_controller.js +++ b/gem/lib/ruby_ui/tabs/tabs_controller.js @@ -13,8 +13,8 @@ export default class extends Controller { static values = { active: String, orientation: { type: String, default: "horizontal" } }; connect() { - this.idCount = 0; - this.idBase = this.element.id || `ruby-ui-tabs-${Math.random().toString(36).slice(2, 10)}`; + this.idCount ??= 0; + this.idBase ||= this.element.id || `ruby-ui-tabs-${Math.random().toString(36).slice(2, 10)}`; this.connected = true; this.refresh(); } @@ -144,11 +144,18 @@ export default class extends Controller { }); 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) { - tab.setAttribute("aria-controls", panel.id); - panel.setAttribute("aria-labelledby", tab.id); - } + if (tab) panel.setAttribute("aria-labelledby", tab.id); + else panel.removeAttribute("aria-labelledby"); }); } @@ -156,6 +163,8 @@ export default class extends Controller { // 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) { @@ -183,13 +192,23 @@ export default class extends Controller { return this.triggerTargets.find((tab) => tab.dataset.value === value); } + contentFor(value) { + if (value === undefined || value === null || value === "") return undefined; + return this.contentTargets.find((panel) => panel.dataset.value === value); + } + isDisabled(tab) { return tab.disabled || tab.getAttribute("aria-disabled") === "true"; } - // A counter, not the value: distinct values never collide on one id. + // 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) { - this.idCount += 1; - return `${this.idBase}-${kind}-${this.idCount}`; + let id; + do { + this.idCount += 1; + id = `${this.idBase}-${kind}-${this.idCount}`; + } while (document.getElementById(id)); + return id; } } diff --git a/gem/test/ruby_ui/tabs_test.rb b/gem/test/ruby_ui/tabs_test.rb index ad0b949e4..4365405b4 100644 --- a/gem/test/ruby_ui/tabs_test.rb +++ b/gem/test/ruby_ui/tabs_test.rb @@ -29,7 +29,7 @@ def test_root_carries_slot_orientation_and_default_value assert_match(/data-slot="tabs"/, output) assert_match(/data-orientation="horizontal"/, output) assert_match(/data-ruby-ui--tabs-active-value="password"/, output) - assert_match(/class="[^"]*\bflex-col\b/, output) + assert_match(/data-\[orientation=horizontal\]:flex-col/, output) refute_match(/default-value=/, output) end @@ -46,7 +46,7 @@ def test_default_value_wins_over_the_alias assert_match(/data-ruby-ui--tabs-active-value="account"/, output) end - def test_without_default_value_the_controller_picks_the_first_tab + def test_without_default_value_no_active_value_is_rendered output = render_tabs refute_match(/data-ruby-ui--tabs-active-value/, output) @@ -57,7 +57,6 @@ def test_vertical_orientation assert_match(/data-orientation="vertical"/, output) assert_match(/data-ruby-ui--tabs-orientation-value="vertical"/, output) - refute_match(/data-slot="tabs"[^>]*flex-col/, output) end def test_unknown_orientation_falls_back_to_horizontal diff --git a/mcp/data/registry/components/tabs.json b/mcp/data/registry/components/tabs.json index 973ae3c40..99b41cf62 100644 --- a/mcp/data/registry/components/tabs.json +++ b/mcp/data/registry/components/tabs.json @@ -4,7 +4,7 @@ "files": [ { "path": "tabs.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # Base UI's Tabs.Root. default_value: is the tab shown first (shadcn's\n # defaultValue); without it the first enabled tab is shown. default: is its\n # old name, kept as an alias. orientation: :horizontal or :vertical.\n #\n # The controller marks the active tab and panel, wires aria-controls /\n # aria-labelledby, and dispatches ruby-ui--tabs:change ({value,\n # previousValue}) when someone picks another tab. Setting\n # data-ruby-ui--tabs-active-value switches tabs from outside.\n class Tabs < Base\n ORIENTATIONS = %i[horizontal vertical].freeze\n\n def initialize(default_value: nil, default: nil, orientation: :horizontal, **attrs)\n @default_value = default_value.nil? ? default : default_value\n @orientation = ORIENTATIONS.include?(orientation.to_s.to_sym) ? orientation.to_s.to_sym : :horizontal\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n slot: \"tabs\",\n orientation: @orientation,\n controller: \"ruby-ui--tabs\",\n ruby_ui__tabs_active_value: @default_value,\n ruby_ui__tabs_orientation_value: @orientation\n },\n class: [\"group/tabs flex gap-2\", (\"flex-col\" if @orientation == :horizontal)]\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # Base UI's Tabs.Root. default_value: is the tab shown first (shadcn's\n # defaultValue); without it the first enabled tab is shown. default: is its\n # old name, kept as an alias. orientation: :horizontal or :vertical.\n #\n # The controller marks the active tab and panel, wires aria-controls /\n # aria-labelledby, and dispatches ruby-ui--tabs:change ({value,\n # previousValue}) when someone picks another tab. Setting\n # data-ruby-ui--tabs-active-value switches tabs from outside.\n class Tabs < Base\n ORIENTATIONS = %i[horizontal vertical].freeze\n\n def initialize(default_value: nil, default: nil, orientation: :horizontal, **attrs)\n @default_value = default_value.nil? ? default : default_value\n @orientation = ORIENTATIONS.include?(orientation.to_s.to_sym) ? orientation.to_s.to_sym : :horizontal\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n slot: \"tabs\",\n orientation: @orientation,\n controller: \"ruby-ui--tabs\",\n ruby_ui__tabs_active_value: @default_value,\n ruby_ui__tabs_orientation_value: @orientation\n },\n # Keyed on data-orientation, which the controller keeps in sync.\n class: \"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col\"\n }\n end\n end\nend\n" }, { "path": "tabs_content.rb", @@ -12,7 +12,7 @@ }, { "path": "tabs_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"ruby-ui--tabs\"\n//\n// Follows Base UI's Tabs: role=tablist/tab/tabpanel, each tab pointing at its\n// panel (aria-controls) and each panel labelled by its tab, a roving tabindex\n// on the tabs, arrow keys that move focus (wrapping unless the list says\n// data-loop-focus=\"false\"), Home/End, and manual activation by Enter, Space or\n// click (or on focus with data-activate-on-focus=\"true\"). Disabled tabs\n// (aria-disabled) stay reachable but never activate.\nexport default class extends Controller {\n static targets = [\"list\", \"trigger\", \"content\"];\n static values = { active: String, orientation: { type: String, default: \"horizontal\" } };\n\n connect() {\n this.idCount = 0;\n this.idBase = this.element.id || `ruby-ui-tabs-${Math.random().toString(36).slice(2, 10)}`;\n this.connected = true;\n this.refresh();\n }\n\n disconnect() {\n this.connected = false;\n }\n\n activeValueChanged() {\n if (this.connected) this.sync();\n }\n\n orientationValueChanged() {\n if (this.connected) this.sync();\n }\n\n // Tabs and panels added later (a morph, lazy content) get wired too.\n triggerTargetConnected() {\n if (this.connected) this.refresh();\n }\n\n contentTargetConnected() {\n if (this.connected) this.refresh();\n }\n\n // ...and removing the active tab hands selection to a remaining one.\n triggerTargetDisconnected() {\n if (this.connected) this.refresh();\n }\n\n contentTargetDisconnected() {\n if (this.connected) this.refresh();\n }\n\n // Leaving the list without activating: Tab back in lands on the selected\n // tab again, as the APG asks.\n listFocusout(event) {\n if (this.hasListTarget && this.listTarget.contains(event.relatedTarget)) return;\n this.sync();\n }\n\n refresh() {\n if (!this.triggerFor(this.activeValue)) {\n const first = this.triggerTargets.find((tab) => !this.isDisabled(tab)) ?? this.triggerTargets[0];\n // Assigning the value runs sync through activeValueChanged.\n if (first && first.dataset.value !== this.activeValue) {\n this.activeValue = first.dataset.value;\n return;\n }\n }\n this.sync();\n }\n\n // --- Activation --------------------------------------------------------------\n\n show(event) {\n const tab = event.currentTarget;\n if (this.isDisabled(tab)) {\n event.preventDefault();\n return;\n }\n this.activate(tab);\n }\n\n activate(tab) {\n const value = tab.dataset.value;\n if (value === this.activeValue) return;\n const previousValue = this.activeValue;\n this.activeValue = value;\n this.dispatch(\"change\", { detail: { value, previousValue } });\n }\n\n // --- Keyboard ----------------------------------------------------------------\n\n keydown(event) {\n const tab = event.target.closest('[role=\"tab\"]');\n if (!tab || !this.triggerTargets.includes(tab)) return;\n\n // A link tab activates on Enter by itself; Space needs a click.\n if (event.key === \" \" && tab.tagName === \"A\") {\n event.preventDefault();\n tab.click();\n return;\n }\n\n const tabs = this.triggerTargets;\n const index = tabs.indexOf(tab);\n const last = tabs.length - 1;\n const [previousKey, nextKey] = this.navigationKeys();\n let target;\n\n if (event.key === nextKey) target = index === last ? (this.loopFocus ? 0 : last) : index + 1;\n else if (event.key === previousKey) target = index === 0 ? (this.loopFocus ? last : 0) : index - 1;\n else if (event.key === \"Home\") target = 0;\n else if (event.key === \"End\") target = last;\n else return;\n\n event.preventDefault();\n this.focusTab(tabs[target]);\n }\n\n navigationKeys() {\n if (this.orientationValue === \"vertical\") return [\"ArrowUp\", \"ArrowDown\"];\n const rtl = getComputedStyle(this.hasListTarget ? this.listTarget : this.element).direction === \"rtl\";\n return rtl ? [\"ArrowRight\", \"ArrowLeft\"] : [\"ArrowLeft\", \"ArrowRight\"];\n }\n\n focusTab(tab) {\n this.triggerTargets.forEach((el) => el.setAttribute(\"tabindex\", el === tab ? \"0\" : \"-1\"));\n tab.focus();\n if (this.activateOnFocus && !this.isDisabled(tab)) this.activate(tab);\n }\n\n get loopFocus() {\n return !this.hasListTarget || this.listTarget.dataset.loopFocus !== \"false\";\n }\n\n get activateOnFocus() {\n return this.hasListTarget && this.listTarget.dataset.activateOnFocus === \"true\";\n }\n\n // --- State -------------------------------------------------------------------\n\n wire() {\n this.triggerTargets.forEach((tab) => {\n if (!tab.id) tab.id = this.nextId(\"tab\");\n });\n this.contentTargets.forEach((panel) => {\n if (!panel.id) panel.id = this.nextId(\"panel\");\n const tab = this.triggerFor(panel.dataset.value);\n if (tab) {\n tab.setAttribute(\"aria-controls\", panel.id);\n panel.setAttribute(\"aria-labelledby\", tab.id);\n }\n });\n }\n\n sync() {\n // Re-wiring is idempotent, and restores ids/ARIA a morph may have dropped.\n this.wire();\n const orientation = this.orientationValue;\n const active = this.triggerFor(this.activeValue);\n\n if (this.hasListTarget) {\n this.listTarget.dataset.orientation = orientation;\n if (orientation === \"vertical\") this.listTarget.setAttribute(\"aria-orientation\", \"vertical\");\n else this.listTarget.removeAttribute(\"aria-orientation\");\n }\n\n this.triggerTargets.forEach((tab, index) => {\n const selected = tab === active;\n tab.dataset.orientation = orientation;\n tab.setAttribute(\"aria-selected\", String(selected));\n tab.toggleAttribute(\"data-active\", selected);\n tab.setAttribute(\"tabindex\", selected || (!active && index === 0) ? \"0\" : \"-1\");\n });\n\n this.contentTargets.forEach((panel) => {\n panel.dataset.orientation = orientation;\n panel.hidden = panel.dataset.value !== this.activeValue || !active;\n });\n }\n\n triggerFor(value) {\n if (value === undefined || value === null || value === \"\") return undefined;\n return this.triggerTargets.find((tab) => tab.dataset.value === value);\n }\n\n isDisabled(tab) {\n return tab.disabled || tab.getAttribute(\"aria-disabled\") === \"true\";\n }\n\n // A counter, not the value: distinct values never collide on one id.\n nextId(kind) {\n this.idCount += 1;\n return `${this.idBase}-${kind}-${this.idCount}`;\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"ruby-ui--tabs\"\n//\n// Follows Base UI's Tabs: role=tablist/tab/tabpanel, each tab pointing at its\n// panel (aria-controls) and each panel labelled by its tab, a roving tabindex\n// on the tabs, arrow keys that move focus (wrapping unless the list says\n// data-loop-focus=\"false\"), Home/End, and manual activation by Enter, Space or\n// click (or on focus with data-activate-on-focus=\"true\"). Disabled tabs\n// (aria-disabled) stay reachable but never activate.\nexport default class extends Controller {\n static targets = [\"list\", \"trigger\", \"content\"];\n static values = { active: String, orientation: { type: String, default: \"horizontal\" } };\n\n connect() {\n this.idCount ??= 0;\n this.idBase ||= this.element.id || `ruby-ui-tabs-${Math.random().toString(36).slice(2, 10)}`;\n this.connected = true;\n this.refresh();\n }\n\n disconnect() {\n this.connected = false;\n }\n\n activeValueChanged() {\n if (this.connected) this.sync();\n }\n\n orientationValueChanged() {\n if (this.connected) this.sync();\n }\n\n // Tabs and panels added later (a morph, lazy content) get wired too.\n triggerTargetConnected() {\n if (this.connected) this.refresh();\n }\n\n contentTargetConnected() {\n if (this.connected) this.refresh();\n }\n\n // ...and removing the active tab hands selection to a remaining one.\n triggerTargetDisconnected() {\n if (this.connected) this.refresh();\n }\n\n contentTargetDisconnected() {\n if (this.connected) this.refresh();\n }\n\n // Leaving the list without activating: Tab back in lands on the selected\n // tab again, as the APG asks.\n listFocusout(event) {\n if (this.hasListTarget && this.listTarget.contains(event.relatedTarget)) return;\n this.sync();\n }\n\n refresh() {\n if (!this.triggerFor(this.activeValue)) {\n const first = this.triggerTargets.find((tab) => !this.isDisabled(tab)) ?? this.triggerTargets[0];\n // Assigning the value runs sync through activeValueChanged.\n if (first && first.dataset.value !== this.activeValue) {\n this.activeValue = first.dataset.value;\n return;\n }\n }\n this.sync();\n }\n\n // --- Activation --------------------------------------------------------------\n\n show(event) {\n const tab = event.currentTarget;\n if (this.isDisabled(tab)) {\n event.preventDefault();\n return;\n }\n this.activate(tab);\n }\n\n activate(tab) {\n const value = tab.dataset.value;\n if (value === this.activeValue) return;\n const previousValue = this.activeValue;\n this.activeValue = value;\n this.dispatch(\"change\", { detail: { value, previousValue } });\n }\n\n // --- Keyboard ----------------------------------------------------------------\n\n keydown(event) {\n const tab = event.target.closest('[role=\"tab\"]');\n if (!tab || !this.triggerTargets.includes(tab)) return;\n\n // A link tab activates on Enter by itself; Space needs a click.\n if (event.key === \" \" && tab.tagName === \"A\") {\n event.preventDefault();\n tab.click();\n return;\n }\n\n const tabs = this.triggerTargets;\n const index = tabs.indexOf(tab);\n const last = tabs.length - 1;\n const [previousKey, nextKey] = this.navigationKeys();\n let target;\n\n if (event.key === nextKey) target = index === last ? (this.loopFocus ? 0 : last) : index + 1;\n else if (event.key === previousKey) target = index === 0 ? (this.loopFocus ? last : 0) : index - 1;\n else if (event.key === \"Home\") target = 0;\n else if (event.key === \"End\") target = last;\n else return;\n\n event.preventDefault();\n this.focusTab(tabs[target]);\n }\n\n navigationKeys() {\n if (this.orientationValue === \"vertical\") return [\"ArrowUp\", \"ArrowDown\"];\n const rtl = getComputedStyle(this.hasListTarget ? this.listTarget : this.element).direction === \"rtl\";\n return rtl ? [\"ArrowRight\", \"ArrowLeft\"] : [\"ArrowLeft\", \"ArrowRight\"];\n }\n\n focusTab(tab) {\n this.triggerTargets.forEach((el) => el.setAttribute(\"tabindex\", el === tab ? \"0\" : \"-1\"));\n tab.focus();\n if (this.activateOnFocus && !this.isDisabled(tab)) this.activate(tab);\n }\n\n get loopFocus() {\n return !this.hasListTarget || this.listTarget.dataset.loopFocus !== \"false\";\n }\n\n get activateOnFocus() {\n return this.hasListTarget && this.listTarget.dataset.activateOnFocus === \"true\";\n }\n\n // --- State -------------------------------------------------------------------\n\n wire() {\n this.triggerTargets.forEach((tab) => {\n if (!tab.id) tab.id = this.nextId(\"tab\");\n });\n this.contentTargets.forEach((panel) => {\n if (!panel.id) panel.id = this.nextId(\"panel\");\n });\n // Point each tab and panel at its counterpart, and drop references to one\n // that is gone.\n this.triggerTargets.forEach((tab) => {\n const panel = this.contentFor(tab.dataset.value);\n if (panel) tab.setAttribute(\"aria-controls\", panel.id);\n else tab.removeAttribute(\"aria-controls\");\n });\n this.contentTargets.forEach((panel) => {\n const tab = this.triggerFor(panel.dataset.value);\n if (tab) panel.setAttribute(\"aria-labelledby\", tab.id);\n else panel.removeAttribute(\"aria-labelledby\");\n });\n }\n\n sync() {\n // Re-wiring is idempotent, and restores ids/ARIA a morph may have dropped.\n this.wire();\n const orientation = this.orientationValue;\n // The root's layout and the parts' group-data variants follow this.\n this.element.dataset.orientation = orientation;\n const active = this.triggerFor(this.activeValue);\n\n if (this.hasListTarget) {\n this.listTarget.dataset.orientation = orientation;\n if (orientation === \"vertical\") this.listTarget.setAttribute(\"aria-orientation\", \"vertical\");\n else this.listTarget.removeAttribute(\"aria-orientation\");\n }\n\n this.triggerTargets.forEach((tab, index) => {\n const selected = tab === active;\n tab.dataset.orientation = orientation;\n tab.setAttribute(\"aria-selected\", String(selected));\n tab.toggleAttribute(\"data-active\", selected);\n tab.setAttribute(\"tabindex\", selected || (!active && index === 0) ? \"0\" : \"-1\");\n });\n\n this.contentTargets.forEach((panel) => {\n panel.dataset.orientation = orientation;\n panel.hidden = panel.dataset.value !== this.activeValue || !active;\n });\n }\n\n triggerFor(value) {\n if (value === undefined || value === null || value === \"\") return undefined;\n return this.triggerTargets.find((tab) => tab.dataset.value === value);\n }\n\n contentFor(value) {\n if (value === undefined || value === null || value === \"\") return undefined;\n return this.contentTargets.find((panel) => panel.dataset.value === value);\n }\n\n isDisabled(tab) {\n return tab.disabled || tab.getAttribute(\"aria-disabled\") === \"true\";\n }\n\n // A counter, not the value, so distinct values never share an id; ids\n // already in the document (kept across a reconnect) are skipped.\n nextId(kind) {\n let id;\n do {\n this.idCount += 1;\n id = `${this.idBase}-${kind}-${this.idCount}`;\n } while (document.getElementById(id));\n return id;\n }\n}\n" }, { "path": "tabs_list.rb",