Skip to main content

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}