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
//! ScrollArea component — custom-styled scrollbar with auto-hide behavior.
//!
//! ## Accessibility decision: delegate to native scroll
//!
//! The viewport is the real scroll container (it has `overflow: auto`) and is
//! exposed as a focusable `role="region"` with `aria-label="Scrollable region"`,
//! which means assistive technology and keyboard users interact with the native
//! browser scrollbar semantics directly — PageUp/PageDown, arrow keys, scroll
//! gestures, and AT-driven scrolling all work for free.
//!
//! The custom thumb (`.mui-scroll-area__thumb`) is purely a visual decoration
//! that tracks native scroll position via CSS. We intentionally keep
//! `aria-hidden="true"` on the scrollbar track rather than promoting it to
//! `role="scrollbar"` because:
//!
//! 1. Our thumb is not draggable / interactive — the native scrollbar is the
//! real control. Promoting the decoration to `role="scrollbar"` would
//! advertise keyboard/focus semantics the decoration does not implement,
//! which is worse than hiding it.
//! 2. This matches the shadcn Base UI pattern of delegating to native scroll
//! whenever the platform offers it, and only synthesizing ARIA scrollbar
//! semantics when the component intercepts scroll (e.g. virtualized lists).
//!
//! If a future variant virtualizes or intercepts scroll, add a `role="scrollbar"`
//! path alongside `aria-valuenow` / `aria-valuemin` / `aria-valuemax` wiring.
use maud::{html, Markup};
/// ScrollArea rendering properties
#[derive(Debug, Clone)]
pub struct Props {
/// CSS value for max-height (e.g., "12rem", "200px")
pub max_height: String,
/// Unique identifier for the viewport
pub id: String,
/// Content to scroll
pub children: Markup,
}
impl Default for Props {
fn default() -> Self {
Self {
max_height: "12rem".to_string(),
id: "scroll-area-default".to_string(),
children: html! {},
}
}
}
/// Render a ScrollArea with custom scrollbar and auto-hide behavior
pub fn render(props: Props) -> Markup {
html! {
div class="mui-scroll-area" data-mui="scroll-area" style={"max-height: " (props.max_height)} {
div class="mui-scroll-area__viewport" id=(props.id) tabindex="0" role="region" aria-label="Scrollable region" {
(props.children)
}
div class="mui-scroll-area__scrollbar" aria-hidden="true" {
div class="mui-scroll-area__thumb" {}
}
}
}
}
/// Showcase the ScrollArea component
pub fn showcase() -> Markup {
let tags: [&str; 18] = [
"v1.4.0-beta.1",
"v1.3.2",
"v1.3.1",
"v1.3.0",
"v1.2.5",
"v1.2.4",
"v1.2.3",
"v1.2.2",
"v1.2.1",
"v1.2.0",
"v1.1.3",
"v1.1.2",
"v1.1.1",
"v1.1.0",
"v1.0.2",
"v1.0.1",
"v1.0.0",
"v0.9.0-rc.1",
];
let tag_list = html! {
div style="display:flex;flex-direction:column;" {
@for tag in tags.iter() {
div style="padding:0.5rem 0.75rem;font-size:0.8125rem;font-family:var(--mui-font-mono);border-bottom:1px solid var(--mui-border);" {
(tag)
}
}
}
};
let changelog = html! {
div style="padding:0.75rem;font-size:0.8125rem;font-family:var(--mui-font-mono);line-height:1.6;white-space:pre;" {
"commit a1b2c3d\n"
"Author: Jane Smith\n"
"Date: Mon Apr 13 09:14:22 2026 +0000\n\n"
" fix: resolve race condition in SSE reconnect\n\n"
"commit e4f5a6b\n"
"Author: Alex Chen\n"
"Date: Sun Apr 12 17:32:08 2026 +0000\n\n"
" feat: add pagination to search results\n\n"
"commit c7d8e9f\n"
"Author: Jane Smith\n"
"Date: Sat Apr 11 11:05:44 2026 +0000\n\n"
" refactor: extract calendar date math\n\n"
"commit 0a1b2c3\n"
"Author: Sam Lee\n"
"Date: Fri Apr 10 14:22:11 2026 +0000\n\n"
" docs: update API reference for v1.3\n\n"
"commit d4e5f6a\n"
"Author: Alex Chen\n"
"Date: Thu Apr 9 08:47:33 2026 +0000\n\n"
" fix: breadcrumb separator a11y\n"
}
};
html! {
div.mui-showcase__grid {
div {
p.mui-showcase__caption { "Release tags" }
div style="border:1px solid var(--mui-border);border-radius:var(--mui-radius-lg);overflow:hidden;max-width:14rem;" {
(render(Props {
max_height: "14rem".to_string(),
id: "demo-scroll-tags".to_string(),
children: tag_list,
}))
}
}
div {
p.mui-showcase__caption { "Commit log" }
div style="border:1px solid var(--mui-border);border-radius:var(--mui-radius-lg);overflow:hidden;max-width:26rem;" {
(render(Props {
max_height: "14rem".to_string(),
id: "demo-scroll-log".to_string(),
children: changelog,
}))
}
}
}
}
}