dioxus_dnd/a11y.rs
1//! Accessibility helpers.
2//!
3//! The keyboard interaction itself is built into the core
4//! [`crate::core::Draggable`] - every draggable is focusable and operable
5//! with Space/Enter (pick up / drop), arrow keys (choose a drop target from
6//! the registered zones) and Escape (cancel). What this module adds is the
7//! **voice**: a visually-hidden `aria-live` region that reads the context's
8//! announcement channel to screen readers.
9//!
10//! Render exactly one `LiveRegion` per provider, anywhere in the subtree:
11//!
12//! ```text
13//! DndProvider::<Card> {
14//! LiveRegion::<Card> {}
15//! // ... draggables and zones ...
16//! }
17//! ```
18//!
19//! Give `Draggable` a `label` and `DropZone` a `label` for meaningful
20//! announcements ("Picked up Ship it. …", "Over Done."). Custom flows can
21//! push their own messages with [`crate::core::DndContext::announce`].
22
23use dioxus::prelude::*;
24
25use crate::core::use_dnd;
26
27/// Visually-hidden `aria-live="polite"` region voicing drag announcements.
28#[component]
29pub fn LiveRegion<T: Clone + PartialEq + 'static>(
30 /// Internal marker; never set this.
31 #[props(default)]
32 phantom: std::marker::PhantomData<T>,
33) -> Element {
34 let _ = phantom;
35 let dnd = use_dnd::<T>();
36 let text = dnd.announcement();
37
38 rsx! {
39 div {
40 aria_live: "polite",
41 aria_atomic: "true",
42 role: "status",
43 // Standard visually-hidden recipe: present to the accessibility
44 // tree, invisible on screen.
45 style: "position: absolute; width: 1px; height: 1px; padding: 0; \
46 margin: -1px; overflow: hidden; clip: rect(0 0 0 0); \
47 white-space: nowrap; border: 0;",
48 "{text}"
49 }
50 }
51}
52
53/// Headless move-up / move-down buttons - the most robust accessibility
54/// fallback of all: reordering with plain button presses, no drag gesture
55/// (pointer *or* keyboard-drag) required.
56///
57/// Renders two `button`s with ARIA labels, disabled at the list edges, and
58/// `data-reorder="up" | "down"` hooks for styling. Emits the same
59/// [`crate::sortable::SortEvent`] your drag path already handles, so one
60/// `on_sort` serves both inputs.
61///
62/// ```text
63/// SortableList {
64/// len: items.read().len(),
65/// render: move |ix: usize| rsx! {
66/// span { "{items.read()[ix]}" }
67/// ReorderButtons { index: ix, total: items.read().len(), on_sort }
68/// },
69/// on_sort,
70/// }
71/// ```
72#[component]
73pub fn ReorderButtons(
74 /// This row's index.
75 index: usize,
76 /// Total number of rows.
77 total: usize,
78 /// Accessible name of the item, used in the button labels.
79 #[props(default)]
80 label: Option<String>,
81 /// Fired with the same event shape as drag-reordering.
82 on_sort: EventHandler<crate::sortable::SortEvent>,
83 #[props(extends = span, extends = GlobalAttributes)] attributes: Vec<Attribute>,
84) -> Element {
85 let strings = crate::core::use_dnd_strings();
86 let name = label.unwrap_or_else(|| (strings.row)(index + 1));
87 let up_label = (strings.move_up)(&name);
88 let down_label = (strings.move_down)(&name);
89
90 rsx! {
91 span {
92 // Pressing a button must not start (or capture the pointer for) an
93 // enclosing drag surface - e.g. a `SortableList` row these are
94 // rendered inside - or the row would grab pointer capture on
95 // pointerdown and swallow the button's click. Stop the gesture at
96 // the buttons so taps stay taps and the parent still drags elsewhere.
97 onpointerdown: move |evt: PointerEvent| evt.stop_propagation(),
98 ..attributes,
99 button {
100 r#type: "button",
101 "data-reorder": "up",
102 aria_label: "{up_label}",
103 disabled: index == 0,
104 onclick: move |evt| {
105 evt.stop_propagation();
106 if index > 0 {
107 on_sort.call(crate::sortable::SortEvent { from: index, to: index - 1 });
108 }
109 },
110 "↑"
111 }
112 button {
113 r#type: "button",
114 "data-reorder": "down",
115 aria_label: "{down_label}",
116 disabled: index + 1 >= total,
117 onclick: move |evt| {
118 evt.stop_propagation();
119 if index + 1 < total {
120 on_sort.call(crate::sortable::SortEvent { from: index, to: index + 1 });
121 }
122 },
123 "↓"
124 }
125 }
126 }
127}
128
129/// The reduced-motion override: when the user asks the OS for less motion,
130/// every animated element the crate marks with `data-dnd-motion` snaps
131/// instead of gliding. Near-zero rather than zero so `transitionend` still
132/// fires for anything listening.
133pub(crate) const REDUCED_MOTION_CSS: &str = "@media (prefers-reduced-motion: reduce) { \
134 [data-dnd-motion] { transition-duration: 0.01ms !important; } }";
135
136/// Marker context: the reduced-motion stylesheet already renders somewhere
137/// above, so nested animated components skip theirs.
138#[derive(Clone, Copy)]
139pub(crate) struct MotionCssProvided;
140
141/// One `<style>` with [`REDUCED_MOTION_CSS`] per subtree: the outermost
142/// animated component renders it and marks the context; anything below
143/// gets `None`. (Sibling subtrees each render one - duplicate CSS rules
144/// are idempotent, so that's harmless.)
145///
146/// The element carries an inline `display: none`. The UA stylesheet hides
147/// `<style>` anyway, but at zero specificity: an app rule like
148/// `.list > * { display: flex }` would override it and paint the CSS
149/// source as visible text inside the list. An inline declaration outranks
150/// any selector, so the sheet stays invisible whatever the page styles.
151pub(crate) fn use_reduced_motion_css() -> Option<Element> {
152 use_reduced_motion_css_if(true)
153}
154
155/// [`use_reduced_motion_css`] behind a condition, for components whose
156/// animation is an opt-in prop (`DragOverlay`'s settle). When `enabled` is
157/// false the hook neither renders the sheet nor marks the context, so an
158/// inactive component doesn't make nested animated ones skip theirs.
159pub(crate) fn use_reduced_motion_css_if(enabled: bool) -> Option<Element> {
160 let first = use_hook(|| {
161 if enabled && try_consume_context::<MotionCssProvided>().is_none() {
162 provide_context(MotionCssProvided);
163 true
164 } else {
165 false
166 }
167 });
168 first.then(|| {
169 rsx! {
170 style { style: "display: none;", {REDUCED_MOTION_CSS} }
171 }
172 })
173}