Skip to main content

dioxus_dnd/
a11y.rs

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