feat(terminal): add screen reader buffer and OS accessibility preferences
- Add debounced aria-live region that announces visible terminal text to screen readers (500ms throttle, non-empty trimmed rows only) - Add role=application, aria-label, aria-roledescription to terminal - Add prefers-reduced-motion listener: disables cursor blink animation and sets --terminal-reduce-motion CSS variable - Add prefers-contrast: more media query with Canvas/CanvasText system color fallback - Add visually hidden .terminal-screen-reader CSS utility - Mark P7 complete in roadmap
This commit is contained in:
@@ -535,6 +535,12 @@ pub fn TerminalPanel(
|
||||
let webgl_state: Rc<std::cell::RefCell<Option<WebGlRendererState>>> =
|
||||
Rc::new(std::cell::RefCell::new(None));
|
||||
let webgl_context_lost: Rc<std::cell::Cell<bool>> = Rc::new(std::cell::Cell::new(false));
|
||||
// Accessibility: screen-reader buffer, system motion/contrast preferences.
|
||||
let screen_reader_text = RwSignal::new(String::new());
|
||||
let screen_reader_debounce: Rc<std::cell::Cell<f64>> =
|
||||
Rc::new(std::cell::Cell::new(0.0));
|
||||
let prefers_reduced_motion = RwSignal::new(false);
|
||||
|
||||
|
||||
handle.serialize_text_fn.set_value(Some(Arc::new(move || {
|
||||
core_signal
|
||||
@@ -643,12 +649,8 @@ pub fn TerminalPanel(
|
||||
lifecycle_webgl_state.borrow_mut().take();
|
||||
return None;
|
||||
}
|
||||
let Some(canvas) = canvas_ref.get() else {
|
||||
return None;
|
||||
};
|
||||
let Some(canvas_el) = canvas.dyn_ref::<HtmlCanvasElement>() else {
|
||||
return None;
|
||||
};
|
||||
let canvas = canvas_ref.get()?;
|
||||
let canvas_el = canvas.dyn_ref::<HtmlCanvasElement>()?;
|
||||
|
||||
attach_webgl_context_lifecycle(
|
||||
canvas_el,
|
||||
@@ -1119,12 +1121,8 @@ pub fn TerminalPanel(
|
||||
Effect::new(move |prev: Option<Option<ResizeObserverHandle>>| {
|
||||
drop(prev);
|
||||
|
||||
let Some(element) = terminal_ref.get() else {
|
||||
return None;
|
||||
};
|
||||
let Some(socket) = ws_signal.get() else {
|
||||
return None;
|
||||
};
|
||||
let element = terminal_ref.get()?;
|
||||
let socket = ws_signal.get()?;
|
||||
|
||||
let resize_socket = socket.clone();
|
||||
let resize_viewport = current_viewport;
|
||||
@@ -1831,10 +1829,93 @@ pub fn TerminalPanel(
|
||||
focus_ime_textarea(&ime_ref);
|
||||
};
|
||||
|
||||
// -- Accessibility: screen-reader buffer ---------------------------------
|
||||
//
|
||||
// Extract visible terminal text and push it into an aria-live region so
|
||||
// screen readers can announce new output. Updates are debounced to avoid
|
||||
// flooding the screen reader on rapid output (e.g. cat of a large file).
|
||||
let sr_core = core_signal;
|
||||
let sr_tick = render_tick;
|
||||
let sr_text = screen_reader_text;
|
||||
let sr_debounce = screen_reader_debounce.clone();
|
||||
let sr_view_mode = view_mode;
|
||||
let sr_scroll_top = scroll_top;
|
||||
let sr_vp = current_viewport;
|
||||
let sr_line_height = move || options_signal.get().line_height_px;
|
||||
Effect::new(move |_| {
|
||||
let _ = sr_tick.get();
|
||||
let now = window().performance().map_or(0.0, |p| p.now());
|
||||
// Debounce: at most one aria-live update per 500 ms.
|
||||
if now - sr_debounce.get() < 500.0 {
|
||||
return;
|
||||
}
|
||||
sr_debounce.set(now);
|
||||
|
||||
let line_height_px = sr_line_height();
|
||||
let is_live = sr_view_mode.get().is_live();
|
||||
let st = sr_scroll_top.get();
|
||||
let vp_rows = sr_vp.get().rows as usize;
|
||||
let visible_text = sr_core
|
||||
.try_with_untracked(|core| {
|
||||
let (_total, _offset, visible) =
|
||||
virtual_window(core, is_live, st, vp_rows, line_height_px);
|
||||
visible
|
||||
.iter()
|
||||
.filter_map(|row| {
|
||||
let text: String = row
|
||||
.segments
|
||||
.iter()
|
||||
.map(|seg| seg.text.as_str())
|
||||
.collect();
|
||||
let trimmed = text.trim_end();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_owned())
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if !visible_text.is_empty() {
|
||||
sr_text.set(visible_text);
|
||||
}
|
||||
});
|
||||
|
||||
// -- Accessibility: prefers-reduced-motion ------------------------------
|
||||
{
|
||||
let prm = prefers_reduced_motion;
|
||||
if let Some(window) = web_sys::window()
|
||||
&& let Ok(Some(mq)) =
|
||||
window.match_media("(prefers-reduced-motion: reduce)")
|
||||
{
|
||||
let mq = Rc::new(mq);
|
||||
prm.set(mq.matches());
|
||||
let prm2 = prm;
|
||||
let mq2 = mq.clone();
|
||||
let mq3 = mq.clone();
|
||||
let cb = Closure::<dyn Fn()>::new(move || {
|
||||
prm2.set(mq2.matches());
|
||||
});
|
||||
let _ = mq3.add_event_listener_with_callback(
|
||||
"change",
|
||||
cb.as_ref().unchecked_ref(),
|
||||
);
|
||||
cb.forget();
|
||||
}
|
||||
}
|
||||
|
||||
// -- View ---------------------------------------------------------------
|
||||
|
||||
view! {
|
||||
<section class="terminal-shell-card" style=move || terminal_options_style(&options_signal.get())>
|
||||
<section class="terminal-shell-card" style=move || {
|
||||
let mut style = terminal_options_style(&options_signal.get());
|
||||
if prefers_reduced_motion.get() {
|
||||
style.push_str("--terminal-reduce-motion:reduce;");
|
||||
}
|
||||
style
|
||||
}>
|
||||
<Show when=move || options_signal.get().show_header>
|
||||
<div class="terminal-header">
|
||||
<div>
|
||||
@@ -1875,6 +1956,9 @@ pub fn TerminalPanel(
|
||||
<div
|
||||
node_ref=terminal_ref
|
||||
class="terminal-screen"
|
||||
role="application"
|
||||
aria-label="Terminal"
|
||||
aria-roledescription="terminal emulator"
|
||||
on:click=handle_click
|
||||
on:scroll=handle_scroll
|
||||
on:wheel=handle_wheel
|
||||
@@ -1899,6 +1983,15 @@ pub fn TerminalPanel(
|
||||
<span node_ref=measure_ref class="terminal-measure" aria-hidden="true">
|
||||
{MEASURE_SAMPLE_TEXT}
|
||||
</span>
|
||||
<div
|
||||
class="terminal-screen-reader"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
aria-label="Terminal output"
|
||||
>
|
||||
{move || screen_reader_text.get()}
|
||||
</div>
|
||||
<div class="terminal-grid">
|
||||
<Show
|
||||
when=move || renderer_uses_canvas_surface(options_signal.get().renderer)
|
||||
|
||||
Reference in New Issue
Block a user