yew_nav_link/components/tabs.rs
1// SPDX-FileCopyrightText: RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4//! # `NavTabs`
5//!
6//! Tab navigation container that wraps [`NavTab`](super::NavTab) items.
7//! Renders a `<ul>` with `role="tablist"`, optional full-width layout, and
8//! WAI-ARIA tabs-pattern keyboard support: arrow keys move focus between
9//! enabled tabs with wrap-around, `Home`/`End` jump to the first/last tab,
10//! and the active tab is the only one in the tab sequence (roving
11//! tabindex on [`NavTab`](super::NavTab)).
12//!
13//! # Example
14//!
15//! ```rust
16//! use yew::prelude::*;
17//! use yew_nav_link::components::{NavTab, NavTabs};
18//!
19//! #[component]
20//! fn TabBar() -> Html {
21//! html! {
22//! <NavTabs id="main-tabs">
23//! <NavTab active=true>{ "Tab 1" }</NavTab>
24//! <NavTab active=false>{ "Tab 2" }</NavTab>
25//! </NavTabs>
26//! }
27//! }
28//! ```
29//!
30//! # CSS Classes
31//!
32//! | Class | Condition |
33//! |-------|-----------|
34//! | `nav-tabs` | Always applied |
35//! | `nav-tabs-fill` | Applied when `full_width` is `true` |
36//!
37//! # Props
38//!
39//! | Prop | Type | Default | Description |
40//! |------|------|---------|-------------|
41//! | `full_width` | `bool` | `false` | Stretch tabs to fill width |
42//! | `vertical` | `bool` | `false` | Vertical tablist (`aria-orientation` + up/down arrows) |
43//! | `role` | `AttrValue` | `"tablist"` | ARIA role |
44//! | `id` | `Option<AttrValue>` | `None` | Container id |
45//! | `classes` | `Classes` | — | Additional CSS classes |
46//! | `children` | `Children` | — | Tab items |
47
48use web_sys::KeyboardEvent;
49use yew::prelude::*;
50
51use super::focus::{focusable_elements, focused_position, next_focus_index};
52
53/// Properties for the [`NavTabs`] component.
54///
55/// | Prop | Type | Default | Description |
56/// |------|------|---------|-------------|
57/// | `full_width` | `bool` | `false` | Stretch tabs to fill width |
58/// | `vertical` | `bool` | `false` | Vertical tablist (`aria-orientation` + up/down arrows) |
59/// | `role` | `AttrValue` | `"tablist"` | ARIA role |
60/// | `id` | `Option<AttrValue>` | `None` | Container id |
61/// | `classes` | `Classes` | — | Additional CSS classes |
62/// | `children` | `Children` | — | Tab items |
63#[derive(Properties, Clone, PartialEq, Debug)]
64pub struct NavTabsProps {
65 /// Additional CSS classes applied to the tabs container.
66 #[prop_or_default]
67 pub classes: Classes,
68
69 /// ARIA role for the tab list. Defaults to `"tablist"`.
70 #[prop_or(AttrValue::Static("tablist"))]
71 pub role: AttrValue,
72
73 /// Optional `id` attribute for the tabs container.
74 #[prop_or_default]
75 pub id: Option<AttrValue>,
76
77 /// Whether tabs should stretch to fill the full width of the container.
78 #[prop_or_default]
79 pub full_width: bool,
80
81 /// Whether the tablist is vertical: emits `aria-orientation="vertical"`
82 /// and switches keyboard navigation to the up/down arrows.
83 #[prop_or_default]
84 pub vertical: bool,
85
86 /// Tab items rendered inside the container.
87 pub children: Children
88}
89
90/// Tab navigation container that wraps [`NavTab`](super::NavTab) items.
91///
92/// Renders a `<ul>` element with ARIA `role="tablist"` and implements the
93/// keyboard interaction of the WAI-ARIA tabs pattern: `ArrowRight` /
94/// `ArrowLeft` (or `ArrowDown`/`ArrowUp` when `vertical`) move focus over
95/// the enabled tabs with wrap-around, and `Home`/`End` jump to the first
96/// and last tab. Activation stays with the consumer via each tab's
97/// `onclick` (manual activation model).
98///
99/// # CSS Classes
100///
101/// - `nav-tabs` - Always applied
102/// - `nav-tabs-fill` - Applied when `full_width` is `true`
103#[function_component]
104pub fn NavTabs(props: &NavTabsProps) -> Html {
105 let mut classes = props.classes.clone();
106 classes.push("nav-tabs");
107
108 if props.full_width {
109 classes.push("nav-tabs-fill");
110 }
111
112 let list_ref = use_node_ref();
113 let vertical = props.vertical;
114
115 let onkeydown = {
116 let list_ref = list_ref.clone();
117 Callback::from(move |event: KeyboardEvent| {
118 let key = event.key();
119 let tabs = focusable_elements(&list_ref, "[role='tab']:not([disabled])");
120 if tabs.is_empty() {
121 return;
122 }
123 let position = focused_position(&tabs);
124 if let Some(tab) =
125 next_focus_index(&key, position, tabs.len(), vertical).and_then(|i| tabs.get(i))
126 {
127 event.prevent_default();
128 let _ = tab.focus();
129 }
130 })
131 };
132
133 let aria_orientation = props.vertical.then_some("vertical");
134
135 html! {
136 <ul
137 ref={list_ref}
138 class={classes}
139 id={props.id.clone()}
140 role={props.role.clone()}
141 aria-orientation={aria_orientation}
142 onkeydown={onkeydown}
143 >
144 { for props.children.iter() }
145 </ul>
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
154 fn nav_tabs_props_default() {
155 let props = NavTabsProps {
156 classes: Classes::default(),
157 role: AttrValue::Static("tablist"),
158 id: None,
159 full_width: false,
160 vertical: false,
161 children: Children::new(vec![])
162 };
163
164 assert_eq!(props.role, "tablist");
165 assert!(!props.full_width);
166 }
167
168 #[test]
169 fn nav_tabs_full_width() {
170 let props = NavTabsProps {
171 classes: Classes::default(),
172 role: AttrValue::Static("tablist"),
173 id: Some(AttrValue::Static("main-tabs")),
174 full_width: true,
175 vertical: false,
176 children: Children::new(vec![])
177 };
178
179 assert!(props.full_width);
180 assert_eq!(props.id.as_deref(), Some("main-tabs"));
181 }
182}