Tabs
A set of layered sections of content that are displayed one at a time.
Default
Click a tab to switch content. The first tab is selected by default.
Account
Make changes to your account here. Click save when you're done.
Password
Change your password here. After saving, you'll be logged out.
Settings
Configure your preferences and notification settings.
With Default Value
The second tab is pre-selected.
Your photo library.
Your music collection. This tab is selected by default.
Your video library.
With Disabled Tab
The middle tab is disabled and cannot be selected.
First active tab content.
You shouldn't see this - the tab is disabled.
Second active tab content.
Manual Activation
Tabs are activated only when clicked or when pressing Enter/Space. Arrow keys move focus without activating.
Content for Tab 1. Use arrow keys to move focus, then Enter/Space to activate.
Content for Tab 2.
Content for Tab 3.
Keyboard Navigation
- Arrow Left/Right: Move focus between tabs (for horizontal tabs)
- Arrow Up/Down: Move focus between tabs (for vertical tabs)
- Home: Move focus to first tab
- End: Move focus to last tab
- Enter/Space: Activate tab (in manual mode)
Tabbed content regions.
$ proa ui add tabsInstalls the reviewed component source, dependencies, shared support files, and required legal notices.
CLI and registry setup →// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Tabs recipe — typed root orientation and named slot classes.
//!
//! The root resolves orientation once. Descendant slots inherit its visual
//! direction through the semantic `group/tabs` root and never resolve an
//! independent orientation style.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
mod base {
use super::*;
pub const TABS_ROOT: &str = "group/tabs";
pub const TABS_LIST: &str = tw_join!(
"inline-flex",
"h-9",
"items-center",
Tabs source
Browse and copy the reviewed source included with this Free component.
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! rsjs-controlled tabs pilot.
use proa_core::{DataLoader, WebContext, WebRenderSync, WriteBuf, WriteError};
use rsjs::Handler;
use super::recipe::TabsOrientation;
/// A single tab entry for [`ControlledTabs`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, rsjs::ClientProps)]
pub struct ControlledTab {
pub value: &'static str,
pub label: &'static str,
pub content: &'static str,
pub disabled: bool,
}
impl ControlledTab {
pub const fn new(value: &'static str, label: &'static str, content: &'static str) -> Self {
Self {
value,
label,
content,
disabled: false,
}
}
pub const fn disabled(value: &'static str, label: &'static str, content: &'static str) -> Self {
Self {
value,
label,
content,
disabled: true,
}
}
}
/// Minimal controlled tabs component for proving rsjs composition over repeated
/// triggers and panels.
///
/// The component keeps all panels mounted and drives the active view through
/// reactive attributes (`aria-selected`, `data-state`, `tabindex`, `hidden`)
/// so it does not depend on repeated conditional sections. `data-active`
/// remains as a compatibility marker, but `data-state` is the canonical
/// contract shared with the delegated-runtime Tabs implementation.
pub struct ControlledTabs {
pub value: String,
pub tabs: &'static [ControlledTab],
pub orientation: TabsOrientation,
pub class: Option<&'static str>,
pub on_change: Handler<&'static str>,
}
impl ControlledTabs {
pub fn new(
value: String,
tabs: &'static [ControlledTab],
on_change: Handler<&'static str>,
) -> Self {
Self {
value,
tabs,
orientation: TabsOrientation::Horizontal,
class: None,
on_change,
}
}
}
#[rsjs::rsjs(component, client)]
impl<B: WriteBuf, L: DataLoader> WebRenderSync<L, B> for ControlledTabs {
fn render(self, cx: &mut WebContext<L, B>) -> Result<(), WriteError> {
let value = rsjs::signal(self.value);
let style = ::proa_ui::components::tabs::TABS_RECIPE.resolve(
::proa_ui::components::tabs::TabsRecipeProps {
orientation: Some(self.orientation),
},
);
let orientation = style.orientation();
let on_tab_click = rsjs::event_handler(
|_: rsjs::MouseEvent<rsjs::elem::Button>, tab_value: &'static str| {
self.on_change.call(tab_value);
},
);
proa_macros::html_sync! {
<div
data_controlled_tabs
data_scope={::proa_ui::components::tabs::TABS_ANATOMY.scope()}
data_part="root"
data_slot="tabs"
data_value={value.get()}
data_orientation={rsjs::server_attr(orientation.as_str())}
class={rsjs::server_attr(proa_macros::text!("{} {}", style, self.class))}
>
<div
data_controlled_tabs_list
data_scope={::proa_ui::components::tabs::TABS_ANATOMY.scope()}
data_part="list"
data_slot="tabs-list"
role="tablist"
aria_orientation={rsjs::server_attr(orientation.as_str())}
class={::proa_ui::components::tabs::TABS_RECIPE.list}
>
{for tab in self.tabs { proa_macros::html_sync! {
<button type="button" role="tab" data_controlled_tabs_trigger data_scope={::proa_ui::components::tabs::TABS_ANATOMY.scope()} data_part="trigger" data_slot="tabs-trigger" data_value={tab.value} data_state={if value.get() == tab.value { "active" } else { "inactive" }} data_active={value.get() == tab.value} data_disabled={if tab.disabled { "true" } else { "false" }} aria_selected={value.get() == tab.value} tabindex={if value.get() == tab.value { "0" } else { "-1" }} disabled={tab.disabled} class={::proa_ui::components::tabs::TABS_RECIPE.trigger} on_click={on_tab_click(tab.value)}>
{tab.label}
</button>
} }}
</div>
{for tab in self.tabs { proa_macros::html_sync! {
<div data_controlled_tabs_content data_scope={::proa_ui::components::tabs::TABS_ANATOMY.scope()} data_part="content" data_slot="tabs-content" data_value={tab.value} data_state={if value.get() == tab.value { "active" } else { "inactive" }} data_active={value.get() == tab.value} role="tabpanel" tabindex="0" hidden={value.get()!= tab.value} class={::proa_ui::components::tabs::TABS_RECIPE.content}>
{tab.content}
</div>
} }}
</div>
}
.render(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
const TABS: [ControlledTab; 3] = [
ControlledTab::new("overview", "Overview", "Overview panel"),
ControlledTab::new("billing", "Billing", "Billing panel"),
ControlledTab::disabled("archive", "Archive", "Archive panel"),
];
#[test]
fn controlled_tabs_renders() {
let html = proa_core::render_to_string(ControlledTabs {
value: "billing".to_string(),
tabs: &TABS,
orientation: TabsOrientation::Horizontal,
class: Some("extra-tabs"),
on_change: rsjs::noop_handler(),
});
assert!(html.contains("data-controlled-tabs"));
assert!(html.contains("data-slot=\"tabs\""));
assert!(html.contains("role=\"tablist\""));
assert!(html.contains("aria-orientation=\"horizontal\""));
assert!(html.contains("role=\"tab\""));
assert!(html.contains("role=\"tabpanel\""));
assert_eq!(html.matches(r#"data-scope="tabs""#).count(), 8, "{html}");
for part in ["root", "list", "trigger", "content"] {
assert!(
::proa_ui::components::tabs::TABS_ANATOMY.contains(part),
"undeclared anatomy part {part}"
);
assert!(
html.contains(&format!(r#"data-part="{part}""#)),
"missing {part}: {html}"
);
}
assert!(html.contains("Overview"));
assert!(html.contains("Billing panel"));
assert!(html.contains("data-value=\"billing\""));
assert!(html.contains("data-state=\"active\""));
assert!(html.contains("data-state=\"inactive\""));
assert!(html.contains("data-active=\"true\""));
assert!(html.contains("aria-selected=\"true\""));
assert!(html.contains("tabindex=\"0\""));
assert!(html.contains("tabindex=\"-1\""));
assert!(html.contains("data-disabled=\"true\""), "{html}");
assert!(html.contains("hidden"));
assert!(html.contains("extra-tabs"));
assert!(html.contains("group/tabs"));
assert!(html.contains("data-[active=true]:bg-background"));
}
#[test]
fn controlled_tabs_propagates_vertical_root_orientation() {
let html = proa_core::render_to_string(ControlledTabs {
value: "overview".to_string(),
tabs: &TABS,
orientation: TabsOrientation::Vertical,
class: None,
on_change: rsjs::noop_handler(),
});
assert!(html.contains("data-orientation=\"vertical\""));
assert!(html.contains("aria-orientation=\"vertical\""));
assert!(html.contains("flex w-full items-start gap-4"));
assert!(html.contains("group-data-[orientation=vertical]/tabs:flex-col"));
assert!(html.contains("group-data-[orientation=vertical]/tabs:w-full"));
assert!(html.contains("group-data-[orientation=vertical]/tabs:mt-0"));
}
}
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
use super::{Tabs, TabsContent};
use crate::components::md_support::block_boundary;
use proa_core::WebContext;
use proa_core::{MdRenderSync, WriteBuf, WriteError};
/// Tabs is a passthrough — all tab content is visible in markdown.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for Tabs<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
self.children.render_md(cx)?;
Ok(())
}
}
/// TabsContent renders as a heading with the tab name, then content.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for TabsContent<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
cx.md_heading(3, self.value)?;
self.children.render_md(cx)?;
block_boundary(cx)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tabs_content_renders_heading() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
TabsContent {
value: "account",
force_mount: false,
class: None,
children: Some("Account settings here."),
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("### account"), "output: {output}");
assert!(
output.contains("Account settings here\\."),
"output: {output}"
);
}
#[test]
fn tabs_content_composes_after_inline_text() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
cx.write_md_prepared_static(b"intro prose").unwrap();
TabsContent {
value: "account",
force_mount: false,
class: None,
children: Some("Account settings here."),
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(
output.contains("intro prose\n\n### account"),
"tab heading must start at a block boundary, got:\n{output}"
);
}
}
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND MIT
//! Tabs component module
//!
//! A set of layered sections of content that are displayed one at a time.
//!
//! Based on Radix UI Tabs with shadcn/ui styling.
#![allow(clippy::module_inception)]
use crate::Anatomy;
#[cfg(feature = "rsjs")]
mod controlled;
mod md;
mod recipe;
mod tabs;
/// Stable DOM parts shared by delegated-runtime and controlled tabs.
pub const TABS_ANATOMY: Anatomy = Anatomy::new("tabs", &["root", "list", "trigger", "content"]);
#[cfg(feature = "rsjs")]
pub use controlled::{ControlledTab, ControlledTabs};
pub use recipe::{
tabs_content_classes, tabs_list_classes, tabs_recipe, tabs_trigger_classes, TabsOrientation,
TabsRecipe, TabsRecipeProps, TabsStyle, TABS_RECIPE,
};
pub use tabs::{Tabs, TabsActivationMode, TabsContent, TabsList, TabsTrigger};
#[cfg(test)]
mod anatomy_tests {
use super::*;
#[test]
fn anatomy_declares_every_public_part() {
assert_eq!(TABS_ANATOMY.scope(), "tabs");
assert_eq!(
TABS_ANATOMY.parts(),
&["root", "list", "trigger", "content"]
);
}
}
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Tabs recipe — typed root orientation and named slot classes.
//!
//! The root resolves orientation once. Descendant slots inherit its visual
//! direction through the semantic `group/tabs` root and never resolve an
//! independent orientation style.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
mod base {
use super::*;
pub const TABS_ROOT: &str = "group/tabs";
pub const TABS_LIST: &str = tw_join!(
"inline-flex",
"h-9",
"items-center",
"justify-center",
"rounded-lg",
"bg-muted",
"p-1",
"text-muted-foreground",
"group-data-[orientation=vertical]/tabs:h-auto",
"group-data-[orientation=vertical]/tabs:flex-col",
"group-data-[orientation=vertical]/tabs:items-stretch",
"group-data-[orientation=vertical]/tabs:justify-start"
);
pub const TABS_TRIGGER: &str = tw_join!(
"inline-flex",
"items-center",
"justify-center",
"whitespace-nowrap",
"rounded-md",
"px-3",
"py-1",
"text-sm",
"font-medium",
"ring-offset-background",
"transition-all",
"focus-visible:outline-hidden",
"focus-visible:ring-2",
"focus-visible:ring-ring",
"focus-visible:ring-offset-2",
"disabled:pointer-events-none",
"disabled:opacity-50",
"data-[state=active]:bg-background",
"data-[state=active]:text-foreground",
"data-[state=active]:shadow",
"data-[active=true]:bg-background",
"data-[active=true]:text-foreground",
"data-[active=true]:shadow",
"group-data-[orientation=vertical]/tabs:w-full",
"group-data-[orientation=vertical]/tabs:justify-start"
);
pub const TABS_CONTENT: &str = tw_join!(
"mt-2",
"ring-offset-background",
"focus-visible:outline-hidden",
"focus-visible:ring-2",
"focus-visible:ring-ring",
"focus-visible:ring-offset-2",
"group-data-[orientation=vertical]/tabs:mt-0",
"group-data-[orientation=vertical]/tabs:min-w-0",
"group-data-[orientation=vertical]/tabs:flex-1"
);
}
/// Visual and keyboard orientation owned by the Tabs root.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "orientation")]
pub enum TabsOrientation {
#[default]
Horizontal,
Vertical,
}
impl TabsOrientation {
pub const fn as_str(self) -> &'static str {
match self {
Self::Horizontal => "horizontal",
Self::Vertical => "vertical",
}
}
pub const fn classes(self) -> &'static str {
match self {
Self::Horizontal => "",
Self::Vertical => tw_join!("flex", "w-full", "items-start", "gap-4"),
}
}
}
/// Optional author inputs accepted by [`TabsRecipe::resolve`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TabsRecipeProps {
pub orientation: Option<TabsOrientation>,
}
/// Static named classes for every Tabs slot.
#[derive(Debug, Clone, Copy)]
pub struct TabsRecipe {
pub root: &'static str,
pub list: &'static str,
pub trigger: &'static str,
pub content: &'static str,
orientation_default: TabsOrientation,
}
impl TabsRecipe {
/// Resolve the root's effective orientation without allocating class
/// storage for it or any descendant.
pub const fn resolve(&'static self, props: TabsRecipeProps) -> TabsStyle {
let orientation = match props.orientation {
Some(orientation) => orientation,
None => self.orientation_default,
};
TabsStyle {
recipe: self,
orientation,
}
}
}
/// Fully resolved, streamable Tabs root style.
#[must_use]
#[derive(Debug, Clone, Copy)]
pub struct TabsStyle {
recipe: &'static TabsRecipe,
orientation: TabsOrientation,
}
impl TabsStyle {
pub const fn orientation(self) -> TabsOrientation {
self.orientation
}
}
impl AttrValue for TabsStyle {
#[inline(always)]
fn should_render(&self) -> bool {
true
}
#[inline(always)]
fn render_attr_value<B: WriteBuf, L: DataLoader>(
&self,
cx: &mut WebContext<L, B>,
) -> Result<(), WriteError> {
cx.out.extend_static(self.recipe.root.as_bytes())?;
let orientation = self.orientation.classes();
if !orientation.is_empty() {
cx.out.extend_static(b" ")?;
cx.out.extend_static(orientation.as_bytes())?;
}
Ok(())
}
}
/// Global, zero-initialization named-slot Tabs recipe.
pub const TABS_RECIPE: TabsRecipe = TabsRecipe {
root: base::TABS_ROOT,
list: base::TABS_LIST,
trigger: base::TABS_TRIGGER,
content: base::TABS_CONTENT,
orientation_default: TabsOrientation::Horizontal,
};
pub const fn tabs_recipe() -> &'static TabsRecipe {
&TABS_RECIPE
}
impl crate::variant_spec::ComponentSpec for TabsRecipe {
const NAME: &'static str = "Tabs";
fn base_classes(&self) -> &'static str {
self.root
}
fn variant_metadata(&self) -> Vec<crate::variant_spec::VariantMetadata> {
vec![crate::variant_spec::variant_metadata::<TabsOrientation>()]
}
fn anatomy(&self) -> Option<crate::Anatomy> {
Some(super::TABS_ANATOMY)
}
}
/// Compatibility accessors for the former standalone slot helpers.
pub const fn tabs_list_classes() -> &'static str {
TABS_RECIPE.list
}
pub const fn tabs_trigger_classes() -> &'static str {
TABS_RECIPE.trigger
}
pub const fn tabs_content_classes() -> &'static str {
TABS_RECIPE.content
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recipe_exposes_named_slots() {
let recipe = tabs_recipe();
assert!(recipe.root.contains("group/tabs"));
assert!(recipe.list.contains("inline-flex"));
assert!(recipe.trigger.contains("transition-all"));
assert!(recipe.content.contains("ring-offset-background"));
}
#[test]
fn recipe_resolves_defaults_and_overrides() {
let default = tabs_recipe().resolve(TabsRecipeProps::default());
assert_eq!(default.orientation(), TabsOrientation::Horizontal);
let vertical = tabs_recipe().resolve(TabsRecipeProps {
orientation: Some(TabsOrientation::Vertical),
});
assert_eq!(vertical.orientation(), TabsOrientation::Vertical);
assert!(vertical.orientation().classes().contains("items-start"));
}
#[test]
fn vertical_descendant_layout_is_owned_by_root_orientation() {
assert!(TABS_RECIPE
.list
.contains("group-data-[orientation=vertical]/tabs:h-auto"));
assert!(TABS_RECIPE
.list
.contains("group-data-[orientation=vertical]/tabs:flex-col"));
assert!(TABS_RECIPE
.trigger
.contains("group-data-[orientation=vertical]/tabs:w-full"));
assert!(TABS_RECIPE
.content
.contains("group-data-[orientation=vertical]/tabs:mt-0"));
assert!(!TABS_RECIPE
.list
.split_ascii_whitespace()
.any(|class| class.starts_with("data-[orientation=")));
}
#[test]
fn controlled_and_static_active_states_share_trigger_styling() {
assert!(TABS_RECIPE
.trigger
.contains("data-[state=active]:bg-background"));
assert!(TABS_RECIPE
.trigger
.contains("data-[active=true]:bg-background"));
}
#[test]
fn tabs_orientation_as_str() {
assert_eq!(TabsOrientation::Horizontal.as_str(), "horizontal");
assert_eq!(TabsOrientation::Vertical.as_str(), "vertical");
}
#[test]
fn component_metadata_uses_root_axis_and_canonical_anatomy() {
use crate::variant_spec::ComponentSpec;
let variants = TABS_RECIPE.variant_metadata();
assert_eq!(variants.len(), 1);
assert_eq!(variants[0].name, "orientation");
assert_eq!(TABS_RECIPE.anatomy(), Some(super::super::TABS_ANATOMY));
}
}
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Tabs component implementations
//!
//! SSR markup for tabs. The JavaScript hydration handles the
//! tab switching behavior and keyboard navigation.
use super::recipe::{TabsOrientation, TabsRecipeProps, TABS_RECIPE};
use super::TABS_ANATOMY;
use proa_core::{WebContext, WebRenderSync, WriteBuf};
use proa_macros::html_sync;
/// Tabs root component - manages the tabs state
///
/// # Example
///
/// ```ignore
/// use proa_ui::{Tabs, TabsList, TabsTrigger, TabsContent};
/// use proa_macros::html_sync;
///
/// html_sync! {
/// {Tabs {
/// default_value: Some("account"),
/// children: Some(html_sync! {
/// {TabsList {
/// children: Some(html_sync! {
/// {TabsTrigger { value: "account", children: Some("Account"), ..Default::default() }}
/// {TabsTrigger { value: "password", children: Some("Password"), ..Default::default() }}
/// }),
/// ..Default::default()
/// }}
/// {TabsContent { value: "account", children: Some("Account content"), ..Default::default() }}
/// {TabsContent { value: "password", children: Some("Password content"), ..Default::default() }}
/// }),
/// ..Tabs::new()
/// }}
/// }
/// ```
pub struct Tabs<C = ()> {
/// Default selected tab value
pub default_value: Option<&'static str>,
/// Tabs orientation (horizontal or vertical)
pub orientation: TabsOrientation,
/// Activation mode: "automatic" (on focus) or "manual" (on click/enter)
pub activation_mode: TabsActivationMode,
/// Additional classes
pub class: Option<&'static str>,
/// Tab children (list + content panels)
pub children: Option<C>,
}
/// Activation mode for tabs
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum TabsActivationMode {
/// Tab activates on focus (arrow key navigation activates tab)
#[default]
Automatic,
/// Tab activates only on explicit selection (Enter/Space/Click)
Manual,
}
impl TabsActivationMode {
pub const fn as_str(self) -> &'static str {
match self {
TabsActivationMode::Automatic => "automatic",
TabsActivationMode::Manual => "manual",
}
}
}
impl Tabs<()> {
pub const fn new() -> Self {
Self {
default_value: None,
orientation: TabsOrientation::Horizontal,
activation_mode: TabsActivationMode::Automatic,
class: None,
children: None,
}
}
}
impl<C> Default for Tabs<C> {
fn default() -> Self {
Self {
default_value: None,
orientation: TabsOrientation::Horizontal,
activation_mode: TabsActivationMode::Automatic,
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for Tabs<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let style = TABS_RECIPE.resolve(TabsRecipeProps {
orientation: Some(self.orientation),
});
let orientation = style.orientation();
html_sync! {
<div
data-tabs-root
data-scope={TABS_ANATOMY.scope()}
data-part="root"
data-slot="tabs"
data-orientation={orientation.as_str()}
data-activation-mode={self.activation_mode.as_str()}
data-value={self.default_value}
class={proa_macros::text!("{} {}", style, self.class)}
>
{self.children}
</div>
}
.render(cx)
}
}
/// TabsList - container for tab triggers
pub struct TabsList<C = ()> {
/// Whether to loop focus at edges
pub loop_focus: bool,
/// Semantic orientation emitted on the tablist for assistive technology.
///
/// Visual orientation is always inherited from the nearest [`Tabs`] root;
/// this value never selects classes.
pub orientation: Option<TabsOrientation>,
/// Additional classes
pub class: Option<&'static str>,
/// Tab triggers
pub children: Option<C>,
}
impl<C> Default for TabsList<C> {
fn default() -> Self {
Self {
loop_focus: true,
orientation: None,
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for TabsList<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let aria_orientation = self.orientation.unwrap_or_default();
let loop_attr = if self.loop_focus { "true" } else { "false" };
html_sync! {
<div
data-tabs-list
data-scope={TABS_ANATOMY.scope()}
data-part="list"
data-slot="tabs-list"
role="tablist"
aria-orientation={aria_orientation.as_str()}
data-loop={loop_attr}
class={proa_macros::text!("{} {}", TABS_RECIPE.list, self.class)}
>
{self.children}
</div>
}
.render(cx)
}
}
/// TabsTrigger - clickable tab header
pub struct TabsTrigger<C = ()> {
/// Unique value for this tab (required)
pub value: &'static str,
/// Whether this tab is disabled
pub disabled: bool,
/// Additional classes
pub class: Option<&'static str>,
/// Trigger content (usually text)
pub children: Option<C>,
}
impl<C> Default for TabsTrigger<C> {
fn default() -> Self {
Self {
value: "",
disabled: false,
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for TabsTrigger<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let disabled = if self.disabled { "true" } else { "false" };
html_sync! {
<button
type="button"
role="tab"
data-tabs-trigger
data-scope={TABS_ANATOMY.scope()}
data-part="trigger"
data-slot="tabs-trigger"
data-value={self.value}
data-state="inactive"
data-disabled={disabled}
disabled={self.disabled}
aria-selected="false"
tabindex="-1"
class={proa_macros::text!("{} {}", TABS_RECIPE.trigger, self.class)}
>
{self.children}
</button>
}
.render(cx)
}
}
/// TabsContent - the content panel for a tab
pub struct TabsContent<C = ()> {
/// Value that matches the trigger (required)
pub value: &'static str,
/// Force mount even when inactive
pub force_mount: bool,
/// Additional classes
pub class: Option<&'static str>,
/// Content to show when tab is active
pub children: Option<C>,
}
impl<C> Default for TabsContent<C> {
fn default() -> Self {
Self {
value: "",
force_mount: false,
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for TabsContent<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<div
data-tabs-content
data-scope={TABS_ANATOMY.scope()}
data-part="content"
data-slot="tabs-content"
data-value={self.value}
data-state="inactive"
data-force-mount={self.force_mount}
role="tabpanel"
hidden
tabindex="0"
class={proa_macros::text!("{} {}", TABS_RECIPE.content, self.class)}
>
{self.children}
</div>
}
.render(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use proa_core::ctx::Ctx;
use proa_core::text::text;
fn class_value(html: &str) -> &str {
html.split_once("class=\"")
.and_then(|(_, rest)| rest.split_once('"'))
.map(|(classes, _)| classes)
.expect("class attribute")
}
#[test]
fn tabs_renders_with_data_attributes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Tabs {
default_value: Some("tab1"),
orientation: TabsOrientation::Horizontal,
class: None,
children: Some(text("Content")),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-tabs-root"));
assert!(html.contains("data-slot=\"tabs\""));
assert!(html.contains("data-orientation=\"horizontal\""));
assert!(html.contains("data-value=\"tab1\""));
assert!(html.contains("group/tabs"));
}
#[test]
fn vertical_root_owns_compound_layout() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Tabs {
orientation: TabsOrientation::Vertical,
class: Some("custom-root"),
children: Some(TabsList {
orientation: Some(TabsOrientation::Vertical),
children: Some(TabsTrigger {
value: "account",
children: Some("Account"),
..Default::default()
}),
..Default::default()
}),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-orientation=\"vertical\""));
assert!(html.contains("flex w-full items-start gap-4"));
assert!(html.contains("custom-root"));
assert!(html.contains("aria-orientation=\"vertical\""));
assert!(html.contains("group-data-[orientation=vertical]/tabs:h-auto"));
assert!(html.contains("group-data-[orientation=vertical]/tabs:w-full"));
}
#[test]
fn tabs_list_renders_with_tablist_role() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
TabsList {
class: None,
children: Some(text("Triggers")),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-tabs-list"));
assert!(html.contains("data-slot=\"tabs-list\""));
assert!(html.contains("role=\"tablist\""));
assert!(html.contains("aria-orientation=\"horizontal\""));
}
#[test]
fn list_orientation_is_semantic_only() {
let render_list = |orientation| {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
TabsList {
orientation: Some(orientation),
children: Some("Triggers"),
..Default::default()
}
.render(&mut cx)
.unwrap();
String::from_utf8(out).unwrap()
};
let horizontal = render_list(TabsOrientation::Horizontal);
let vertical = render_list(TabsOrientation::Vertical);
assert!(horizontal.contains("aria-orientation=\"horizontal\""));
assert!(vertical.contains("aria-orientation=\"vertical\""));
assert_eq!(class_value(&horizontal), class_value(&vertical));
}
#[test]
fn tabs_trigger_renders_as_button() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
TabsTrigger {
value: "account",
disabled: false,
class: None,
children: Some(text("Account")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-tabs-trigger"));
assert!(html.contains("data-slot=\"tabs-trigger\""));
assert!(html.contains("role=\"tab\""));
assert!(html.contains("data-value=\"account\""));
assert!(html.contains("aria-selected=\"false\""));
}
#[test]
fn tabs_content_renders_hidden() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
TabsContent {
value: "account",
class: None,
children: Some(text("Account content")),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-tabs-content"));
assert!(html.contains("data-slot=\"tabs-content\""));
assert!(html.contains("role=\"tabpanel\""));
assert!(html.contains("data-value=\"account\""));
assert!(html.contains("hidden"));
}
#[test]
fn disabled_trigger_renders_correctly() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
TabsTrigger {
value: "disabled-tab",
disabled: true,
class: None,
children: Some(text("Disabled")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-disabled=\"true\""));
assert!(html.contains(" disabled"));
}
#[test]
fn static_markup_matches_public_anatomy() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Tabs {
default_value: Some("account"),
children: Some(html_sync! {
{TabsList {
children: Some(TabsTrigger {
value: "account",
children: Some("Account"),
..Default::default()
}),
..Default::default()
}}
{TabsContent {
value: "account",
children: Some("Profile"),
..Default::default()
}}
}),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert_eq!(html.matches(r#"data-scope="tabs""#).count(), 4, "{html}");
for part in ["root", "list", "trigger", "content"] {
assert!(TABS_ANATOMY.contains(part));
assert!(
html.contains(&format!(r#"data-part="{part}""#)),
"missing {part}: {html}"
);
}
assert!(html.contains(r#"data-slot="tabs-trigger""#));
assert!(html.contains(r#"data-slot="tabs-content""#));
}
}