Skip to main content

yew_nav_link/components/
tab_item.rs

1// SPDX-FileCopyrightText: RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4//! # `NavTab`
5//!
6//! A single tab button within a [`NavTabs`](super::NavTabs) container.
7//! Renders a `<li>` with a `<button>` that has proper ARIA attributes
8//! (`role="tab"`, `aria-selected`, `aria-controls`).
9//!
10//! # Example
11//!
12//! ```rust
13//! use yew::prelude::*;
14//! use yew_nav_link::components::{NavTab, NavTabs};
15//!
16//! #[component]
17//! fn TabBar() -> Html {
18//!     html! {
19//!         <NavTabs id="my-tabs">
20//!             <NavTab active=true id="tab-1" panel_id="panel-1">
21//!                 { "Overview" }
22//!             </NavTab>
23//!             <NavTab active=false id="tab-2" panel_id="panel-2">
24//!                 { "Details" }
25//!             </NavTab>
26//!             <NavTab active=false disabled=true>
27//!                 { "Disabled" }
28//!             </NavTab>
29//!         </NavTabs>
30//!     }
31//! }
32//! ```
33//!
34//! # CSS Classes
35//!
36//! | Class | Condition |
37//! |-------|-----------|
38//! | `nav-tab` | Always applied |
39//! | `active` | Applied when `active` is `true` |
40//! | `disabled` | Applied when `disabled` is `true` |
41//!
42//! # Props
43//!
44//! | Prop | Type | Default | Description |
45//! |------|------|---------|-------------|
46//! | `active` | `bool` | — | Whether this tab is selected (required) |
47//! | `disabled` | `bool` | `false` | Whether this tab is disabled |
48//! | `id` | `Option<AttrValue>` | `None` | Tab button id |
49//! | `panel_id` | `Option<AttrValue>` | `None` | aria-controls target |
50//! | `onclick` | `Option<Callback<MouseEvent>>` | `None` | Click handler |
51//! | `classes` | `Classes` | — | Additional CSS classes |
52//! | `children` | `Children` | — | Tab content |
53
54use yew::prelude::*;
55
56/// Properties for the [`NavTab`] component.
57///
58/// | Prop | Type | Default | Description |
59/// |------|------|---------|-------------|
60/// | `active` | `bool` | — | Whether this tab is selected (required) |
61/// | `disabled` | `bool` | `false` | Whether this tab is disabled |
62/// | `id` | `Option<AttrValue>` | `None` | Tab button id |
63/// | `panel_id` | `Option<AttrValue>` | `None` | aria-controls target |
64/// | `onclick` | `Option<Callback<MouseEvent>>` | `None` | Click handler |
65/// | `classes` | `Classes` | — | Additional CSS classes |
66/// | `children` | `Children` | — | Tab content |
67#[derive(Properties, Clone, PartialEq, Debug)]
68pub struct NavTabProps {
69    /// Additional CSS classes applied to the tab.
70    #[prop_or_default]
71    pub classes: Classes,
72
73    /// Whether this tab is currently selected.
74    pub active: bool,
75
76    /// Whether this tab is disabled.
77    #[prop_or_default]
78    pub disabled: bool,
79
80    /// Optional `id` attribute for the tab button.
81    #[prop_or_default]
82    pub id: Option<AttrValue>,
83
84    /// Optional `aria-controls` referencing the associated panel `id`.
85    #[prop_or_default]
86    pub panel_id: Option<AttrValue>,
87
88    /// Content rendered inside the tab button.
89    #[prop_or_default]
90    pub children: Children,
91
92    /// Click handler invoked when the tab is selected.
93    #[prop_or_default]
94    pub onclick: Option<Callback<MouseEvent>>
95}
96
97/// A single tab button within a [`NavTabs`](super::NavTabs) container.
98///
99/// The rendered button participates in the tabs-pattern roving tabindex:
100/// the active tab carries `tabindex="0"` and every other tab `tabindex="-1"`,
101/// so `Tab` enters the tablist on the selected tab and arrow keys (handled
102/// by [`NavTabs`](super::NavTabs)) move within it.
103///
104/// # CSS Classes
105///
106/// - `nav-tab` - Always applied
107/// - `active` - Applied when `active` is `true`
108/// - `disabled` - Applied when `disabled` is `true`
109#[function_component]
110pub fn NavTab(props: &NavTabProps) -> Html {
111    let mut classes = props.classes.clone();
112    classes.push("nav-tab");
113
114    if props.active {
115        classes.push("active");
116    }
117
118    if props.disabled {
119        classes.push("disabled");
120    }
121
122    let onclick = props.onclick.clone();
123    let onclick = onclick.map(|cb| {
124        move |e: MouseEvent| {
125            e.prevent_default();
126            cb.emit(e);
127        }
128    });
129
130    html! {
131        <li class={classes} role="presentation">
132            <button
133                type="button"
134                role="tab"
135                id={props.id.clone()}
136                aria-selected={if props.active { "true" } else { "false" }}
137                tabindex={if props.active { "0" } else { "-1" }}
138                aria-controls={props.panel_id.clone()}
139                disabled={props.disabled}
140                onclick={onclick}
141            >
142                { for props.children.iter() }
143            </button>
144        </li>
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn nav_tab_active() {
154        let props = NavTabProps {
155            classes:  Classes::default(),
156            active:   true,
157            disabled: false,
158            id:       None,
159            panel_id: None,
160            children: Children::new(vec![]),
161            onclick:  None
162        };
163
164        assert!(props.active);
165        assert!(!props.disabled);
166    }
167
168    #[test]
169    fn nav_tab_disabled() {
170        let props = NavTabProps {
171            classes:  Classes::default(),
172            active:   false,
173            disabled: true,
174            id:       None,
175            panel_id: None,
176            children: Children::new(vec![]),
177            onclick:  None
178        };
179
180        assert!(props.disabled);
181        assert!(!props.active);
182    }
183
184    #[test]
185    fn nav_tab_with_id_and_panel_id() {
186        let props = NavTabProps {
187            classes:  Classes::default(),
188            active:   true,
189            disabled: false,
190            id:       Some(AttrValue::Static("tab-1")),
191            panel_id: Some(AttrValue::Static("panel-1")),
192            children: Children::new(vec![]),
193            onclick:  None
194        };
195
196        assert_eq!(props.id.as_deref(), Some("tab-1"));
197        assert_eq!(props.panel_id.as_deref(), Some("panel-1"));
198    }
199
200    #[test]
201    fn nav_tab_with_custom_classes() {
202        let mut classes = Classes::new();
203        classes.push("custom-tab");
204        let props = NavTabProps {
205            classes,
206            active: false,
207            disabled: false,
208            id: None,
209            panel_id: None,
210            children: Children::new(vec![]),
211            onclick: None
212        };
213
214        assert!(props.classes.contains("custom-tab"));
215    }
216
217    #[test]
218    fn nav_tab_with_callback() {
219        let callback = Callback::from(|_: MouseEvent| {});
220        let props = NavTabProps {
221            classes:  Classes::default(),
222            active:   false,
223            disabled: false,
224            id:       None,
225            panel_id: None,
226            children: Children::new(vec![]),
227            onclick:  Some(callback)
228        };
229
230        assert!(props.onclick.is_some());
231    }
232
233    #[test]
234    fn nav_tab_active_and_disabled() {
235        let props = NavTabProps {
236            classes:  Classes::default(),
237            active:   true,
238            disabled: true,
239            id:       None,
240            panel_id: None,
241            children: Children::new(vec![]),
242            onclick:  None
243        };
244
245        assert!(props.active);
246        assert!(props.disabled);
247    }
248}