leptos_helios/accessibility/
screen_reader.rs1use super::{AccessibilityError, DataTable, ScreenReaderSupport};
6use crate::chart::{ChartSpec, MarkType};
7use polars::prelude::DataFrame;
8use std::collections::HashMap;
9
10pub struct ScreenReaderManager {
12 config: ScreenReaderSupport,
13 live_regions: HashMap<String, String>,
14 announcements: Vec<String>,
15}
16
17impl ScreenReaderManager {
18 pub fn new(config: ScreenReaderSupport) -> Self {
20 Self {
21 config,
22 live_regions: HashMap::new(),
23 announcements: Vec::new(),
24 }
25 }
26
27 pub fn generate_description(
29 &self,
30 spec: &ChartSpec,
31 data: &DataFrame,
32 ) -> Result<String, AccessibilityError> {
33 if !self.config.enabled {
34 return Ok(String::new());
35 }
36
37 let mut description = String::new();
38
39 if !spec.config.title.is_empty() {
41 description.push_str(&format!("Chart titled: {}. ", spec.config.title));
42 }
43
44 description.push_str(&format!(
45 "This is a {} chart. ",
46 self.get_chart_type_description(&spec.mark)
47 ));
48
49 if let Ok(summary) = self.generate_data_summary(data) {
51 description.push_str(&summary);
52 }
53
54 description.push_str(" Use arrow keys to navigate data points. Press Enter to select.");
56
57 Ok(description)
58 }
59
60 pub fn create_data_table(
62 &self,
63 spec: &ChartSpec,
64 data: &DataFrame,
65 ) -> Result<DataTable, AccessibilityError> {
66 if !self.config.create_data_tables {
67 return Err(AccessibilityError::ScreenReaderError(
68 "Data table creation is disabled".to_string(),
69 ));
70 }
71
72 let title = if spec.config.title.is_empty() {
73 "Chart Data".to_string()
74 } else {
75 format!("{} Data", spec.config.title)
76 };
77
78 let summary = self
79 .generate_data_summary(data)
80 .unwrap_or_else(|_| "Chart data table".to_string());
81
82 let headers = data.get_column_names();
84
85 let mut rows = Vec::new();
87 for i in 0..data.height() {
88 let mut row = Vec::new();
89 for col in &headers {
90 if let Ok(series) = data.column(col) {
91 let value = series
92 .get(i)
93 .map(|v| format!("{}", v))
94 .unwrap_or_else(|_| "N/A".to_string());
95 row.push(value);
96 }
97 }
98 rows.push(row);
99 }
100
101 Ok(DataTable {
102 title: title.clone(),
103 summary,
104 headers: headers.into_iter().map(|h| h.to_string()).collect(),
105 rows,
106 caption: Some(format!("Data table for {}", title)),
107 scope_attributes: HashMap::new(),
108 })
109 }
110
111 pub fn generate_aria_labels(
113 &self,
114 spec: &ChartSpec,
115 data: &DataFrame,
116 ) -> Result<HashMap<String, String>, AccessibilityError> {
117 if !self.config.aria_labels {
118 return Ok(HashMap::new());
119 }
120
121 let mut labels = HashMap::new();
122
123 labels.insert(
125 "chart-container".to_string(),
126 format!("Interactive {} chart", self.get_chart_type_name(&spec.mark)),
127 );
128
129 for (i, row) in data.iter().enumerate() {
131 let row_values: Vec<_> = row.iter().map(|v| v.clone()).collect();
132 let point_label = self.generate_point_label(&spec.mark, &row_values, i);
133 labels.insert(format!("data-point-{}", i), point_label);
134 }
135
136 if let Some(legend) = &spec.config.legend {
138 if let Some(title) = &legend.title {
139 labels.insert("legend-title".to_string(), format!("Legend: {}", title));
140 }
141 }
142
143 Ok(labels)
144 }
145
146 pub fn announce_update(&mut self, message: &str) -> Result<(), AccessibilityError> {
148 if !self.config.announce_updates {
149 return Ok(());
150 }
151
152 self.announcements.push(message.to_string());
153 Ok(())
154 }
155
156 pub fn get_pending_announcements(&mut self) -> Vec<String> {
158 self.announcements.drain(..).collect()
159 }
160
161 pub fn generate_navigation_structure(
163 &self,
164 spec: &ChartSpec,
165 _data: &DataFrame,
166 ) -> Result<NavigationStructure, AccessibilityError> {
167 if !self.config.structured_navigation {
168 return Err(AccessibilityError::ScreenReaderError(
169 "Structured navigation is disabled".to_string(),
170 ));
171 }
172
173 let mut structure = NavigationStructure {
174 landmarks: Vec::new(),
175 headings: Vec::new(),
176 regions: Vec::new(),
177 };
178
179 structure.landmarks.push(Landmark {
181 role: "main".to_string(),
182 label: "Chart Content".to_string(),
183 id: "chart-main".to_string(),
184 });
185
186 structure.headings.push(Heading {
188 level: 1,
189 text: spec.config.title.clone(),
190 id: "chart-title".to_string(),
191 });
192
193 structure.regions.push(Region {
195 role: "region".to_string(),
196 label: "Chart Data".to_string(),
197 id: "chart-data".to_string(),
198 aria_live: Some("polite".to_string()),
199 });
200
201 Ok(structure)
202 }
203
204 fn generate_data_summary(&self, data: &DataFrame) -> Result<String, AccessibilityError> {
206 let row_count = data.height();
207 let col_count = data.width();
208
209 Ok(format!(
210 "The chart contains {} data points across {} categories. ",
211 row_count, col_count
212 ))
213 }
214
215 fn get_chart_type_description(&self, mark: &MarkType) -> &'static str {
217 match mark {
218 MarkType::Line { .. } => "line",
219 MarkType::Bar { .. } => "bar",
220 MarkType::Point { .. } => "scatter plot",
221 MarkType::Area { .. } => "area",
222 MarkType::Text { .. } => "text",
223 _ => "data visualization",
224 }
225 }
226
227 fn get_chart_type_name(&self, mark: &MarkType) -> &'static str {
229 match mark {
230 MarkType::Line { .. } => "line",
231 MarkType::Bar { .. } => "bar",
232 MarkType::Point { .. } => "scatter",
233 MarkType::Area { .. } => "area",
234 MarkType::Text { .. } => "text",
235 _ => "data",
236 }
237 }
238
239 fn generate_point_label(
241 &self,
242 mark: &MarkType,
243 _row: &[polars::prelude::AnyValue],
244 index: usize,
245 ) -> String {
246 match mark {
247 MarkType::Point { .. } => format!("Data point {}", index + 1),
248 MarkType::Bar { .. } => format!("Bar {}", index + 1),
249 MarkType::Line { .. } => format!("Line point {}", index + 1),
250 _ => format!("Data element {}", index + 1),
251 }
252 }
253}
254
255#[derive(Debug, Clone)]
257pub struct NavigationStructure {
258 pub landmarks: Vec<Landmark>,
259 pub headings: Vec<Heading>,
260 pub regions: Vec<Region>,
261}
262
263#[derive(Debug, Clone)]
265pub struct Landmark {
266 pub role: String,
267 pub label: String,
268 pub id: String,
269}
270
271#[derive(Debug, Clone)]
273pub struct Heading {
274 pub level: u32,
275 pub text: String,
276 pub id: String,
277}
278
279#[derive(Debug, Clone)]
281pub struct Region {
282 pub role: String,
283 pub label: String,
284 pub id: String,
285 pub aria_live: Option<String>,
286}