Skip to main content

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 name = label.unwrap_or_else(|| format!("item {}", index + 1));
86    let up_label = format!("Move {name} up");
87    let down_label = format!("Move {name} down");
88
89    rsx! {
90        span {
91            // Pressing a button must not start (or capture the pointer for) an
92            // enclosing drag surface - e.g. a `SortableList` row these are
93            // rendered inside - or the row would grab pointer capture on
94            // pointerdown and swallow the button's click. Stop the gesture at
95            // the buttons so taps stay taps and the parent still drags elsewhere.
96            onpointerdown: move |evt: PointerEvent| evt.stop_propagation(),
97            ..attributes,
98            button {
99                r#type: "button",
100                "data-reorder": "up",
101                aria_label: "{up_label}",
102                disabled: index == 0,
103                onclick: move |evt| {
104                    evt.stop_propagation();
105                    if index > 0 {
106                        on_sort.call(crate::sortable::SortEvent { from: index, to: index - 1 });
107                    }
108                },
109                "↑"
110            }
111            button {
112                r#type: "button",
113                "data-reorder": "down",
114                aria_label: "{down_label}",
115                disabled: index + 1 >= total,
116                onclick: move |evt| {
117                    evt.stop_propagation();
118                    if index + 1 < total {
119                        on_sort.call(crate::sortable::SortEvent { from: index, to: index + 1 });
120                    }
121                },
122                "↓"
123            }
124        }
125    }
126}