euv-ui 0.31.1

Reusable UI component library for the euv framework, providing buttons, cards, modals, inputs, and more.
Documentation
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
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
use super::*;

/// Returns the first navigable route inside a subtree of sidebar items.
///
/// A pure directory (`essay/2025`) carries no index page, so the group
/// itself is not a destination and the site would render an empty article
/// for it. The user's intent when clicking the group title is "show me
/// this section", and the section's first readable page is the only thing
/// in it that can satisfy that. The search therefore descends:
/// depth-first, in sidebar order, taking a group's own index page before
/// any of its children.
///
/// The descent repeats for nested groups, so `a/b/c` where only `c/d` has
/// content still resolves to the first readable page under `c`, following
/// the same rule at every level. A subtree with no readable page at all
/// yields `None`, and the caller keeps the current toggle-only behaviour
/// rather than navigating somewhere arbitrary.
///
/// # Arguments
///
/// - `&[EuvSidebarItem]` - The subtree to search, in sidebar order.
///
/// # Returns
///
/// - `Option<&'static str>` - The first readable route, or `None` when the
///   subtree holds none.
pub fn first_navigable_route(items: &[EuvSidebarItem]) -> Option<&'static str> {
    for item in items.iter() {
        if let Some(link) = item.link {
            return Some(link);
        }
        if let Some(found) = first_navigable_route(item.children) {
            return Some(found);
        }
    }
    None
}

/// A generic collapsible navigation tree aligned with common docs frameworks.
///
/// Renders leaf links and collapsible groups recursively. The collapse state
/// lives in the caller-owned `collapsed` signal so it survives re-renders and
/// can be shared between multiple sidebar instances. All groups start
/// expanded.
///
/// # Arguments
///
/// - `VirtualNode<EuvSidebarProps>` - The props node.
///
/// # Returns
///
/// - `VirtualNode` - The sidebar virtual DOM tree.
#[component]
pub fn euv_sidebar(node: VirtualNode<EuvSidebarProps>) -> VirtualNode {
    let EuvSidebarProps {
        route_signal,
        collapsed,
        items,
        prefix,
        on_navigate,
    }: EuvSidebarProps = node.try_get_props().unwrap_or_default();
    html! {
        div {
            for item in items.iter() {
                euv_sidebar_item {
                    route_signal
                    collapsed
                    item: *item
                    prefix: prefix.clone()
                    on_navigate: on_navigate.clone()
                }
            }
        }
    }
}

/// Renders one sidebar node: a leaf link or a collapsible group.
///
/// # Arguments
///
/// - `VirtualNode<EuvSidebarItemProps>` - The props node.
///
/// # Returns
///
/// - `VirtualNode` - The item virtual DOM tree.
#[component]
pub fn euv_sidebar_item(node: VirtualNode<EuvSidebarItemProps>) -> VirtualNode {
    let EuvSidebarItemProps {
        route_signal,
        collapsed,
        item,
        prefix,
        on_navigate,
    }: EuvSidebarItemProps = node.try_get_props().unwrap_or_default();
    let route: String = route_signal.get();
    let path: &str = strip_hash_anchor(&route);
    if item.children.is_empty() {
        let Some(link) = item.link else {
            return html! {
                ""
            };
        };
        let active: bool = path == link;
        // A leaf under a collapsed-in group is inset by `c_euv_sidebar_children`.
        // When it is the active page the accent fill has to reach back to the
        // sidebar edge, otherwise the highlight reads as a chip floating in a
        // gutter instead of a row of the nav. `prefix` is empty only for a
        // top-level leaf, which is already flush and keeps the plain class.
        let flush: bool = !prefix.is_empty();
        let link_class: fn() -> &'static Css = if active {
            if flush {
                c_euv_sidebar_link_active_flush
            } else {
                c_euv_sidebar_link_active
            }
        } else {
            c_euv_sidebar_link
        };
        return html! {
            a {
                class: link_class()
                // OPT 30: see comment in `euv_navbar` — replace
                // `format!("#{...}")` with a single `#`-prefix concat.
                href: {
                    let mut href: String =
                        String::with_capacity(ROUTE_HASH_PREFIX.len() + link.len());
                    href.push_str(ROUTE_HASH_PREFIX);
                    href.push_str(link);
                    href
                }
                onclick: navigate_link(route_signal, on_navigate.clone(), link)
                {
                    item.text
                }
            }
        };
    }
    // OPT 30: `format!("{prefix}/{}", item.text)` becomes a pre-sized
    // concatenation of the constant separator `/` between two borrowed
    // slices.
    let key: String = {
        let mut key: String = String::with_capacity(prefix.len() + 1 + item.text.len());
        key.push_str(&prefix);
        key.push('/');
        key.push_str(item.text);
        key
    };
    let open: bool = !collapsed.get().contains(&key);
    let active: bool = item.link.is_some_and(|link: &'static str| path == link);
    // The arrow span used to carry two `class:` attributes — the first
    // returned the base or open-arrow class, the second layered the active
    // class on top. On an active row that meant `c_euv_sidebar_group_arrow`'s
    // `:hover` inset bar in `foreground` (white in dark mode) was painted
    // straight over the accent fill, which is what showed up as a white edge
    // on hover. Pick exactly one class instead.
    //
    // That "exactly one" is also why the arrow stopped tracking the toggle:
    // `active` and `open` were alternatives, so an active group always took
    // the active class and never the rotation, whether it was expanded or
    // collapsed. The two concerns are orthogonal — colour follows the active
    // fill, rotation follows `collapsed` — so all four combinations exist.
    let arrow_class: fn() -> &'static Css = match (active, open) {
        (true, true) => c_euv_sidebar_group_arrow_active_open,
        (true, false) => c_euv_sidebar_group_arrow_active,
        (false, true) => c_euv_sidebar_group_arrow_open,
        (false, false) => c_euv_sidebar_group_arrow,
    };
    // A top-level group has no `c_euv_sidebar_children` ancestor to line up
    // with, so it uses the `_root` title variant — the same row with a
    // heavier, inset hover bar that stays clear of the sidebar edge.
    let is_root: bool = prefix.is_empty();
    let title_class: fn() -> &'static Css = if is_root {
        if active {
            c_euv_sidebar_group_title_root_active
        } else {
            c_euv_sidebar_group_title_root
        }
    } else if active {
        c_euv_sidebar_group_title_active
    } else {
        c_euv_sidebar_group_title
    };
    let title_node: VirtualNode = match item.link {
        Some(link) => html! {
            a {
                // OPT 30: same `#`-prefix concat as above.
                href: {
                    let mut
                    href: String =
                    String::with_capacity(ROUTE_HASH_PREFIX.len() + link.len());
                    href.push_str(ROUTE_HASH_PREFIX);
                    href.push_str(link);
                    href
                }
                onclick: toggle_navigate_group(collapsed, key.clone(), Some(link), item.children, on_navigate.clone(), active)
                {
                    item.text
                }
            }
        },
        None => html! {
            {
                item.text
            }
        },
    };
    html! {
    div {
        class: c_euv_sidebar_group()
        div {
            class: title_class()
            onclick: toggle_navigate_group(collapsed, key.clone(), item.link, item.children, on_navigate.clone(), active)
            span {
                title_node
            }
            // The triangle is its own control, not part of the title row's
            // click target. It used to sit inside the title `<div>` with no
            // handler of its own, so a click on the glyph bubbled up to
            // `toggle_navigate_group` and navigated to the group index (or
            // jumped to the first child) instead of folding the group —
            // the arrow has to mean "fold/unfold", nothing else. Give it its
            // own `stop_propagation` handler so it only ever toggles.
            span {
                class: arrow_class()
                onclick: toggle_group_only(collapsed, key.clone())
                "▸"
            }
        }
        if open {
            div {
                class: c_euv_sidebar_children()
                euv_sidebar {
                    route_signal
                    collapsed
                    items: item.children
                    prefix: key.clone()
                    on_navigate: on_navigate.clone()
                }
            }
        }
    }
    }
}

/// Builds the leaf-link click handler: the interceptor when set, otherwise
/// the default hash-router navigation. When the clicked leaf matches the
/// current route the handler is a no-op — re-triggering `hashchange` for the
/// same path would re-run the route subscriber and cause unnecessary work.
///
/// # Arguments
///
/// - `Signal<String>` - The current route signal (drives the no-op check).
/// - `Option<Rc<dyn Fn(&'static str)>>` - The navigation interceptor.
/// - `&'static str` - The target link.
///
/// # Returns
///
/// - `Option<Rc<dyn Fn(Event)>>` - The click handler.
fn navigate_link(
    route_signal: Signal<String>,
    on_navigate: Option<Rc<dyn Fn(&'static str)>>,
    link: &'static str,
) -> Option<Rc<dyn Fn(Event)>> {
    Some(Rc::new(move |event: Event| {
        event.prevent_default();
        if strip_hash_anchor(&route_signal.get()) == link {
            return;
        }
        match &on_navigate {
            Some(interceptor) => interceptor(link),
            None => Router::navigate(link),
        }
    }))
}

/// Group title click handler. When the group owns an index route (`link`),
/// the action taken depends on whether the index route is the current route:
///
/// - **Index is inactive** (`link != current route`): navigate to the
///   index page and leave the collapsed state alone. The whole group is
///   the user's way of saying "take me to this section", and changing the
///   expand/collapse state when the user only wants to navigate is a
///   surprising side effect.
/// - **Index is active** (`link == current route`): the user is already on
///   the index page, so skip navigation and just toggle collapsed. This is
///   the only state where flipping the expand arrow makes sense.
///
/// When the group has no index page, the click only toggles collapsed.
///
/// The matched `active` flag is captured up-front so the closure does not
/// re-read the route signal on every dispatch (avoids an unnecessary
/// signal subscription).
///
/// # Arguments
///
/// - `Signal<Vec<String>>` - The collapsed-keys signal.
/// - `String` - The group key.
/// - `Option<&'static str>` - The group's index route when it exists.
/// - `&'static [EuvSidebarItem]` - The group's children, searched for the
///   first readable page when the group has no index of its own.
/// - `Option<Rc<dyn Fn(&'static str)>>` - The navigation interceptor.
/// - `bool` - Whether the group's index route is the current route.
///
/// # Returns
///
/// - `Option<Rc<dyn Fn(Event)>>` - The click handler.
fn toggle_navigate_group(
    collapsed: Signal<Vec<String>>,
    key: String,
    link: Option<&'static str>,
    children: &'static [EuvSidebarItem],
    on_navigate: Option<Rc<dyn Fn(&'static str)>>,
    active: bool,
) -> Option<Rc<dyn Fn(Event)>> {
    Some(Rc::new(move |event: Event| {
        event.prevent_default();
        match link {
            // Group with index page.
            Some(link) if !active => {
                // Inactive index: just navigate, leave collapsed state alone.
                match &on_navigate {
                    Some(interceptor) => interceptor(link),
                    None => Router::navigate(link),
                }
            }
            Some(_) => {
                // Active index: user is already here — toggle collapsed only.
                let mut keys: Vec<String> = collapsed.get();
                if let Some(index) = keys.iter().position(|k: &String| k == &key) {
                    keys.remove(index);
                } else {
                    keys.push(key.clone());
                }
                collapsed.set(keys);
            }
            // Pure folder with no index page. It is not a destination of
            // its own — the site renders an empty article for it — so the
            // click means "take me into this section": navigate to the first
            // readable page below it, descending through nested directories
            // until one is found. Only a subtree with nothing readable at all
            // falls back to folding.
            None => match first_navigable_route(children) {
                Some(target) => match &on_navigate {
                    Some(interceptor) => interceptor(target),
                    None => Router::navigate(target),
                },
                None => {
                    let mut keys: Vec<String> = collapsed.get();
                    if let Some(index) = keys.iter().position(|k: &String| k == &key) {
                        keys.remove(index);
                    } else {
                        keys.push(key.clone());
                    }
                    collapsed.set(keys);
                }
            },
        }
    }))
}

/// Builds the group title's toggle-only click handler, used by the arrow.
///
/// Unlike [`toggle_navigate_group`] this never navigates: the arrow is a
/// disclosure control, so its only job is to add or remove `key` from the
/// collapsed set. The event is stopped so it does not bubble into the
/// enclosing title row, whose handler would otherwise treat the click as a
/// navigation request.
///
/// # Arguments
///
/// - `Signal<Vec<String>>` - The collapsed-keys signal.
/// - `String` - The group key.
///
/// # Returns
///
/// - `Option<Rc<dyn Fn(Event)>>` - The click handler.
fn toggle_group_only(collapsed: Signal<Vec<String>>, key: String) -> Option<Rc<dyn Fn(Event)>> {
    Some(Rc::new(move |event: Event| {
        event.prevent_default();
        event.stop_propagation();
        let mut keys: Vec<String> = collapsed.get();
        if let Some(index) = keys.iter().position(|k: &String| k == &key) {
            keys.remove(index);
        } else {
            keys.push(key.clone());
        }
        collapsed.set(keys);
    }))
}

/// Strips the `#anchor` suffix from a hash route.
///
/// # Arguments
///
/// - `&str` - The raw route string.
///
/// # Returns
///
/// - `&str` - The route path without the anchor.
fn strip_hash_anchor(route: &str) -> &str {
    match route.split_once('#') {
        Some((path, _)) => path,
        None => route,
    }
}