1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
pub(crate) mod item;
pub use item::{BreadcrumbItem, BreadcrumbItemProps};
use crate::icon::{Icon, IconKind, SvgIcon};
use dioxus::prelude::*;
/// Struttura dati per definire una tappa della gerarchia breadcrumb in modalità guidata dai dati.
#[derive(Clone, PartialEq, Debug)]
pub struct BreadcrumbData {
/// Testo visibile dell'elemento.
pub label: String,
/// URL di navigazione opzionale.
pub href: Option<String>,
/// Icona opzionale posizionata prima del testo.
pub icon: Option<SvgIcon>,
}
impl BreadcrumbData {
/// Crea un nuovo elemento breadcrumb con etichetta.
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
href: None,
icon: None,
}
}
/// Imposta l'URL di navigazione.
#[must_use]
pub fn with_href(mut self, href: impl Into<String>) -> Self {
self.href = Some(href.into());
self
}
/// Imposta un'icona vettoriale associata.
#[must_use]
pub fn with_icon(mut self, icon: SvgIcon) -> Self {
self.icon = Some(icon);
self
}
}
/// Proprietà del componente [`Breadcrumbs`].
#[derive(Props, Clone, PartialEq)]
pub struct BreadcrumbsProps {
/// Lista dichiarativa degli elementi di navigazione (supporta ellissi automatica con `max_items`).
#[props(default)]
pub items: Option<Vec<BreadcrumbData>>,
/// Numero massimo di elementi visibili prima dell'attivazione dell'ellissi (`...`).
#[props(default)]
pub max_items: Option<usize>,
/// Numero di elementi mantenuti all'inizio prima dell'ellissi (default: 1).
#[props(default = 1)]
pub items_before_collapse: usize,
/// Numero di elementi mantenuti alla fine dopo l'ellissi (default: 1).
#[props(default = 1)]
pub items_after_collapse: usize,
/// Stato di espansione controllato opzionale (se fornito, sovrascrive lo stato interno).
#[props(default)]
pub expanded: Option<Signal<bool>>,
/// Callback invocato quando lo stato di espansione cambia (tramite click sull'ellissi o pulsante di compressione).
#[props(default)]
pub on_toggle_expand: Option<EventHandler<bool>>,
/// Elemento visivo personalizzato per il separatore (sovrascrive il separatore di default).
#[props(default)]
pub separator: Option<Element>,
/// Separatore testuale semplice (es. "/", ">", "•").
#[props(default)]
pub separator_text: Option<String>,
/// Classi CSS addizionali per il contenitore di navigazione `<nav>`.
#[props(default)]
pub class: String,
/// Contenuto compositivo personalizzato (istanze di [`BreadcrumbItem`]).
#[props(default)]
pub children: Element,
}
/// Calcola se il percorso deve essere collassato e la quantità di elementi da preservare in testa (`head`) e coda (`tail`).
/// Quando `max_items` è impostato e `items_before_collapse + items_after_collapse < max`, distribuisce gli elementi
/// visibili in modo da mostrare esattamente `max` elementi.
#[must_use]
pub fn calculate_collapse_bounds(
total: usize,
max_items: Option<usize>,
items_before_collapse: usize,
items_after_collapse: usize,
) -> (bool, usize, usize) {
let Some(max) = max_items else {
return (false, items_before_collapse, items_after_collapse);
};
if total <= max || max == 0 {
return (false, items_before_collapse, items_after_collapse);
}
// Se l'utente non ha impostato una somma esplicita maggiore o uguale a max,
// distribuiamo gli elementi visibili per raggiungere esattamente `max`.
let (before, after) = if items_before_collapse + items_after_collapse < max {
let b = items_before_collapse.max(1);
let a = items_after_collapse.max(max.saturating_sub(b));
(b, a)
} else {
(items_before_collapse, items_after_collapse)
};
let should_collapse = total > (before + after);
(should_collapse, before, after)
}
/// Componente Breadcrumbs per navigazione gerarchica, wayfinding e percorsi strutturati.
#[component]
pub fn Breadcrumbs(props: BreadcrumbsProps) -> Element {
let mut internal_expanded = use_signal(|| false);
let mut last_max_items = use_signal(|| props.max_items);
// Se max_items cambia da esterno, ripristiniamo la compressione interna
if *last_max_items.read() != props.max_items {
last_max_items.set(props.max_items);
internal_expanded.set(false);
}
let is_expanded = match props.expanded {
Some(s) => s(),
None => internal_expanded(),
};
let mut set_expanded = move |val: bool| {
if let Some(mut ext_sig) = props.expanded {
ext_sig.set(val);
} else {
internal_expanded.set(val);
}
if let Some(ref handler) = props.on_toggle_expand {
handler.call(val);
}
};
#[cfg(feature = "theme")]
let theme_val = crate::theme::use_theme()();
#[cfg(feature = "theme")]
let base_override = theme_val
.resolve_component_base("breadcrumbs")
.unwrap_or("");
#[cfg(feature = "theme")]
let class_modifiers = theme_val.resolve_class_modifiers("breadcrumbs", &props.class);
#[cfg(not(feature = "theme"))]
let base_override = "";
#[cfg(not(feature = "theme"))]
let class_modifiers = String::new();
let render_separator = |idx: usize| -> Element {
if let Some(ref custom_sep) = props.separator {
rsx! {
li { key: "sep-{idx}", class: "sd-breadcrumbs__separator", "aria-hidden": "true",
{custom_sep.clone()}
}
}
} else if let Some(ref text) = props.separator_text {
rsx! {
li { key: "sep-{idx}", class: "sd-breadcrumbs__separator sd-breadcrumbs__separator--text", "aria-hidden": "true",
"{text}"
}
}
} else {
rsx! {
li { key: "sep-{idx}", class: "sd-breadcrumbs__separator", "aria-hidden": "true",
Icon { kind: IconKind::ChevronRight, size: 12 }
}
}
}
};
rsx! {
nav {
class: "sd-breadcrumbs {base_override} {class_modifiers} {props.class}",
"aria-label": "Breadcrumb",
ol { class: "sd-breadcrumbs__list",
if let Some(ref items) = props.items {
{
let total = items.len();
let (can_collapse, before_count, after_count) = calculate_collapse_bounds(
total,
props.max_items,
props.items_before_collapse,
props.items_after_collapse,
);
if can_collapse && !is_expanded {
let head = &items[0..before_count.min(total)];
let tail_start = total.saturating_sub(after_count).max(head.len());
let tail = &items[tail_start..total];
rsx! {
for (idx, item) in head.iter().enumerate() {
BreadcrumbItem {
key: "item-head-{idx}",
href: item.href.clone(),
icon: item.icon.clone(),
"{item.label}"
}
{render_separator(idx)}
}
li {
class: "sd-breadcrumbs__ellipsis",
button {
r#type: "button",
class: "sd-breadcrumbs__ellipsis-button",
title: "Mostra percorso completo",
onclick: move |_| set_expanded(true),
"..."
}
}
{render_separator(head.len())}
for (idx, item) in tail.iter().enumerate() {
{
let is_last = idx + 1 == tail.len();
rsx! {
BreadcrumbItem {
key: "item-tail-{idx}",
href: if is_last { None } else { item.href.clone() },
is_current: is_last,
icon: item.icon.clone(),
"{item.label}"
}
if !is_last {
{render_separator(total - tail.len() + idx)}
}
}
}
}
}
} else {
rsx! {
for (idx, item) in items.iter().enumerate() {
{
let is_last = idx + 1 == total;
rsx! {
BreadcrumbItem {
key: "item-{idx}",
href: if is_last { None } else { item.href.clone() },
is_current: is_last,
icon: item.icon.clone(),
"{item.label}"
}
if !is_last {
{render_separator(idx)}
}
}
}
}
if can_collapse && is_expanded {
li {
class: "sd-breadcrumbs__ellipsis",
button {
r#type: "button",
class: "sd-breadcrumbs__ellipsis-button",
title: "Comprimi percorso",
onclick: move |_| set_expanded(false),
"«"
}
}
}
}
}
}
} else {
{props.children}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_breadcrumb_data_builder() {
let b = BreadcrumbData::new("Home").with_href("/");
assert_eq!(b.label, "Home");
assert_eq!(b.href, Some("/".to_string()));
}
#[test]
fn test_collapse_bounds_with_max_3() {
// 5 elementi con max_items = 3: deve collassare e mostrare 1 elemento in testa e 2 in coda (totale 3 visibili)
let (should_collapse, before, after) = calculate_collapse_bounds(5, Some(3), 1, 1);
assert!(should_collapse);
assert_eq!(before, 1);
assert_eq!(after, 2);
assert_eq!(before + after, 3);
}
#[test]
fn test_collapse_bounds_not_exceeding_max() {
// 3 elementi con max_items = 3: non deve collassare
let (should_collapse, _, _) = calculate_collapse_bounds(3, Some(3), 1, 1);
assert!(!should_collapse);
}
#[test]
fn test_collapse_bounds_custom_overrides() {
// Utente definisce esplicitamente 2 in testa e 2 in coda con max_items = 4
let (should_collapse, before, after) = calculate_collapse_bounds(6, Some(4), 2, 2);
assert!(should_collapse);
assert_eq!(before, 2);
assert_eq!(after, 2);
}
}