dioxus_dnd/core/components/draggable.rs
1//! The [`Draggable`] drag source: pointer and keyboard interaction, the
2//! pointer-capture substitute, and the hierarchical keyboard navigation
3//! that walks the zone registry.
4
5use dioxus::html::MountedData;
6use dioxus::prelude::*;
7
8use std::rc::Rc;
9
10use crate::core::hooks::{use_dnd, use_zone_registry, SettleFlag};
11use crate::core::monitor::CancelReason;
12use crate::core::session::DragCompletion;
13use crate::core::state::DragStart;
14use crate::core::strings::use_dnd_strings;
15use crate::core::types::{
16 effective_effect, Direction, DragId, DragMode, DragSessionId, DropEffect, Point, PointerKind,
17 Rect, TouchSense, ZoneId,
18};
19use crate::core::world::{use_joined_window, WorldHit};
20use crate::core::{
21 platform, transition_with, ActivationConstraint, ActivationPolicy, Activator, GestureEffect,
22 GestureEvent, GesturePhase, Promotion,
23};
24
25use super::delivery::{
26 deliver_drop, drop_query, resolve_drag_hover, resolve_drag_target, DropCompletion, SettleRoute,
27 RELEASE_RECOVERY_MOVES,
28};
29use super::merge_style_invariant_last;
30use super::pointer::{pointer_client, primary_press, touch_style, HoldTimer};
31use super::ActivatorContext;
32
33/// Internal: which hierarchical move an arrow key requested.
34#[derive(Debug, Clone, Copy, PartialEq)]
35enum NavKey {
36 Next,
37 Prev,
38 Descend,
39 Ascend,
40}
41
42/// Map an arrow key to a hierarchical move, honoring layout direction:
43/// horizontal arrows mirror under RTL (the WAI-ARIA tree convention), so
44/// "into" is always the arrow pointing along reading order. Pure, for
45/// testability.
46fn nav_key(key: &Key, dir: Direction) -> Option<NavKey> {
47 Some(match (key, dir) {
48 (Key::ArrowDown, _) => NavKey::Next,
49 (Key::ArrowUp, _) => NavKey::Prev,
50 (Key::ArrowRight, Direction::Ltr) | (Key::ArrowLeft, Direction::Rtl) => NavKey::Descend,
51 (Key::ArrowLeft, Direction::Ltr) | (Key::ArrowRight, Direction::Rtl) => NavKey::Ascend,
52 _ => return None,
53 })
54}
55
56fn keyboard_drop_points(rect: Option<Rect>) -> (Point, Point) {
57 match rect {
58 Some(r) => {
59 let client = r.center();
60 (client, client - r.origin())
61 }
62 None => (Point::default(), Point::default()),
63 }
64}
65
66fn finish_pointer_source<T: Clone + 'static>(
67 membership: Option<crate::core::world::JoinedWindow<T>>,
68 dnd: &mut crate::core::state::DndContext<T>,
69 session: DragSessionId,
70 completion: DragCompletion,
71) -> bool {
72 match membership {
73 Some(joined) => joined.world.finish_session(session, completion),
74 None if completion.dropped() => dnd.finish_source(session, true),
75 None => {
76 let DragCompletion::Cancelled(reason) = completion else {
77 unreachable!("dropped completion handled above")
78 };
79 dnd.cancel_session(session, reason)
80 }
81 }
82}
83
84/// Wraps its children in a focusable pointer/keyboard drag source and pushes
85/// `payload` into the shared context on drag start.
86///
87/// Any extra attributes (`class`, `style`, `id`…) are forwarded to the div.
88///
89/// While this element's payload is in flight the div carries
90/// `data-dragging="true"`, and `data-disabled="true"` when `disabled` -
91/// both are *absent* otherwise, so presence-based selectors (CSS
92/// `[data-dragging]`, Tailwind `data-dragging:opacity-50`) work directly.
93#[component]
94pub fn Draggable<T: Clone + PartialEq + 'static>(
95 /// The value delivered to whichever `DropZone` receives this drag.
96 payload: T,
97 /// Stable source identity. Auto-generated once per mounted draggable.
98 #[props(default)]
99 drag_id: Option<DragId>,
100 /// The zone this item currently lives in (reported in `DropOutcome::from`).
101 #[props(default)]
102 zone: Option<ZoneId>,
103 /// Drop effect. Defaults to `Move`.
104 #[props(default)]
105 effect: DropEffect,
106 /// Disable dragging without unmounting.
107 #[props(default)]
108 disabled: bool,
109 /// Movement in CSS px before a pointer press becomes a drag.
110 #[props(default = 8.0)]
111 threshold: f64,
112 /// Composable activation policy. When omitted, `threshold` retains the
113 /// 3.x distance behavior.
114 #[props(default)]
115 activation: Option<ActivationPolicy>,
116 /// How a finger shares this element with native scrolling.
117 /// [`TouchSense::Auto`] (default) keeps vertical swipes scrolling the
118 /// page and picks up on a short hold or a sideways pull;
119 /// [`TouchSense::Immediate`] owns every touch from the first pixel.
120 /// Mouse drags are identical under both; pens follow the finger rules.
121 #[props(default)]
122 touch: TouchSense,
123 /// Human label used in screen-reader announcements ("Picked up {label}").
124 #[props(default)]
125 label: Option<String>,
126 /// Fired when a drag begins.
127 #[props(default)]
128 on_drag_start: Option<EventHandler<()>>,
129 /// Fired when the drag ends; `true` if a zone consumed the payload,
130 /// `false` if it was cancelled.
131 #[props(default)]
132 on_drag_end: Option<EventHandler<bool>>,
133 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
134 children: Element,
135) -> Element {
136 let auto_drag_id = use_hook(DragId::auto);
137 let drag_id = drag_id.unwrap_or(auto_drag_id);
138 rsx! {
139 for keyed_drag_id in [drag_id] {
140 DraggableInstance::<T> {
141 key: "{keyed_drag_id.0}",
142 payload: payload.clone(),
143 drag_id: keyed_drag_id,
144 zone,
145 effect,
146 disabled,
147 threshold,
148 activation: activation.clone(),
149 touch,
150 label: label.clone(),
151 on_drag_start,
152 on_drag_end,
153 attributes: attributes.clone(),
154 {children.clone()}
155 }
156 }
157 }
158}
159
160#[component]
161fn DraggableInstance<T: Clone + PartialEq + 'static>(
162 payload: T,
163 drag_id: DragId,
164 zone: Option<ZoneId>,
165 effect: DropEffect,
166 disabled: bool,
167 threshold: f64,
168 activation: Option<ActivationPolicy>,
169 touch: TouchSense,
170 label: Option<String>,
171 on_drag_start: Option<EventHandler<()>>,
172 on_drag_end: Option<EventHandler<bool>>,
173 attributes: Vec<Attribute>,
174 children: Element,
175) -> Element {
176 let mut dnd = use_dnd::<T>();
177 let registry = use_zone_registry::<T>();
178 let settle_flag = try_use_context::<SettleFlag<T>>();
179 // Multi-window: when the provider joined a `DndWorld`, pointer moves
180 // and releases resolve across every joined window. `None` (the normal
181 // single-window case) leaves every path below exactly as it was.
182 let membership = use_joined_window::<T>();
183 // Everything the keyboard path voices, localizable through context.
184 let strings = use_dnd_strings();
185 let requested_activation = activation.unwrap_or(ActivationPolicy::surface(
186 ActivationConstraint::Distance(threshold),
187 ));
188 let activation = use_memo(use_reactive!(|requested_activation| requested_activation));
189 let activation_threshold = activation
190 .peek()
191 .constraint
192 .distance()
193 .unwrap_or(f64::INFINITY);
194 let activation_delays = activation.peek().constraint.delays();
195 let has_activation_delay = !activation_delays.is_empty();
196 let mut handle_pointer = use_signal(|| None::<i32>);
197 let mut handle_keyboard = use_signal(|| false);
198 use_context_provider(|| ActivatorContext {
199 pointer: handle_pointer,
200 keyboard: handle_keyboard,
201 });
202 // Separate clones for the two closures that need the payload.
203 let kb_payload = payload.clone();
204 let pointer_payload = payload.clone();
205 let attr_payload = payload.clone();
206 let kb_label = label.clone();
207 // For claiming a keyboard drop's focus restoration on mount.
208 let mount_payload = payload.clone();
209 let mut phase = use_signal(|| GesturePhase::Idle);
210 // Generation of the pointer drag currently owned by this source. The
211 // shared completion slot carries its callback across VirtualDom/window
212 // boundaries; this local copy guards delayed measurement tasks.
213 let mut session = use_signal(|| None::<DragSessionId>);
214 // Did native pointer capture engage for the current press? When it
215 // did, events retarget to this element and no capture substitute is
216 // needed (or wanted - see the layer below).
217 let mut captured = use_signal(|| false);
218 // Consecutive empty-held moves seen mid-drag (lost-release debounce).
219 let mut empty_held_moves = use_signal(|| 0u8);
220 // Some(pid) while a touch press under `Auto` waits on its hold timer;
221 // doubles as the timer element's render condition.
222 let mut hold_pid = use_signal(|| None::<i32>);
223 // Greatest distance reached during this press. Delay tolerances describe
224 // the whole gesture, so moving out and back in must not revalidate one.
225 let mut press_max_travel = use_signal(|| 0.0_f64);
226 // The initiating press's device kind, recorded into the drag state at
227 // promotion so host-side glue can tell captured pointers from blind ones.
228 let mut press_kind = use_signal(PointerKind::default);
229 let mut step = move |event: GestureEvent, threshold: f64| -> GestureEffect {
230 let promotion = if touch == TouchSense::Auto && *press_kind.peek() != PointerKind::Mouse {
231 Promotion::HoldOrSideways
232 } else {
233 Promotion::Distance
234 };
235 let (next, fx) = transition_with(*phase.peek(), event, threshold, promotion);
236 phase.set(next);
237 // Any exit from Pressed retires the pending hold - the drag began,
238 // the press tapped out, or a vertical pull yielded to the scroll.
239 if hold_pid.peek().is_some() && !matches!(next, GesturePhase::Pressed { .. }) {
240 hold_pid.set(None);
241 }
242 fx
243 };
244 let mut node = use_signal(|| None::<Rc<MountedData>>);
245 let mut press_offset = use_signal(Point::default);
246 // The element's rect, measured at press time - so a promotion can hand
247 // the ghost its size synchronously. Measuring at Begin instead left the
248 // `match_source` overlay blank for the measurement roundtrip (~a few
249 // frames), a visible pop-in at every pickup.
250 let mut press_rect = use_signal(|| None::<Rect>);
251 let mut press_measure_generation = use_signal(|| 0u64);
252 let mut mods = use_signal(Modifiers::empty);
253 let mut attributes = attributes;
254 super::protect_attributes(
255 &mut attributes,
256 &[
257 "data-dragging",
258 "data-disabled",
259 "onmounted",
260 "onpointerdown",
261 "onpointermove",
262 "onpointerup",
263 "onpointercancel",
264 "onlostpointercapture",
265 "ontouchmove",
266 "oncontextmenu",
267 "tabindex",
268 "role",
269 "aria-roledescription",
270 "onkeydown",
271 ],
272 );
273 let style = merge_style_invariant_last(
274 &mut attributes,
275 touch_style(touch),
276 &["touch-action", "user-select", "-webkit-user-select"],
277 );
278
279 // Every pointer end path (DOM, host bridge, cancel, or source unmount)
280 // consumes the same shared callback. It runs in this source runtime and
281 // resets the gesture before notifying the application.
282 let source_completion = use_callback(move |dropped: bool| {
283 let pointer_id = match *phase.peek() {
284 GesturePhase::Dragging { pointer_id, .. } => Some(pointer_id),
285 _ => None,
286 };
287 phase.set(GesturePhase::Idle);
288 session.set(None);
289 if let Some(pointer_id) = pointer_id {
290 if let Some(n) = node.peek().clone() {
291 platform::release_pointer(&n, pointer_id);
292 }
293 }
294 captured.set(false);
295 empty_held_moves.set(0);
296 hold_pid.set(None);
297 press_max_travel.set(0.0);
298 press_rect.set(None);
299 press_measure_generation += 1;
300 press_kind.set(PointerKind::default());
301 mods.set(Modifiers::empty());
302 if let Some(h) = &on_drag_end {
303 h.call(dropped);
304 }
305 });
306 use_drop(move || {
307 let Some(id) = *session.peek() else {
308 return;
309 };
310 finish_pointer_source(
311 membership,
312 &mut dnd,
313 id,
314 DragCompletion::Cancelled(CancelReason::SourceUnmounted),
315 );
316 });
317
318 // Begin is reachable from two places - a pointer-move promotion and the
319 // hold timer's alarm - so the sequence lives in one callback.
320 let begin_drag = use_callback(move |at: Point| {
321 let source_rect = *press_rect.peek();
322 let id = dnd.start_tracked_with_metadata(
323 drag_id,
324 DragStart::new(pointer_payload.clone(), at)
325 .with_source(zone)
326 .with_grab(*press_offset.peek())
327 .with_effect(effect)
328 .with_pointer_kind(*press_kind.peek())
329 .with_source_rect(source_rect),
330 source_completion,
331 );
332 if !dnd.is_session(id) {
333 return;
334 }
335 dnd.set_proposed_effect(effective_effect(effect, *mods.peek()));
336 session.set(Some(id));
337 // Dress a size-matched ghost immediately from the press-time
338 // measurement; fall back to measuring now only if the press's
339 // measurement hasn't landed yet (a press promoted within a frame).
340 if source_rect.is_none() {
341 if let Some(m) = node.peek().clone() {
342 let mut dnd = dnd;
343 spawn(async move {
344 if let Ok(r) = m.get_client_rect().await {
345 if dnd.is_session(id) {
346 dnd.set_source_rect(Some(Rect::new(
347 r.origin.x,
348 r.origin.y,
349 r.size.width,
350 r.size.height,
351 )));
352 }
353 }
354 });
355 }
356 }
357 // A world drag anchors its coordinates to this window and needs
358 // every joined window's rects fresh, not just this one's.
359 match membership {
360 Some(j) => {
361 j.world.begin_from(j.key);
362 j.world.update_modifiers(*mods.peek());
363 j.world.refresh_all_rects();
364 }
365 None => registry.refresh_rects(),
366 }
367 if let Some(h) = &on_drag_start {
368 h.call(());
369 }
370 });
371
372 let mut deliver_to = move |target: ZoneId, point: Point, effect: DropEffect| -> bool {
373 // Delivery may synchronously finish the source and run
374 // `source_completion`, which clears this signal. Snapshot the token so
375 // no `peek` guard remains borrowed across that callback boundary.
376 let active_session = *session.peek();
377 match membership {
378 Some(joined) => deliver_drop(
379 registry,
380 &mut dnd,
381 SettleRoute {
382 flag: settle_flag,
383 owner: Some((&joined.world, joined.key)),
384 },
385 DropCompletion::World {
386 world: &joined.world,
387 session: active_session,
388 },
389 target,
390 point,
391 effect,
392 ),
393 None => deliver_drop(
394 registry,
395 &mut dnd,
396 SettleRoute {
397 flag: settle_flag,
398 owner: None,
399 },
400 match active_session {
401 Some(session) => DropCompletion::Local(session),
402 None => DropCompletion::None,
403 },
404 target,
405 point,
406 effect,
407 ),
408 }
409 };
410
411 let mut finish_drop = move |point: Point| {
412 let Some(id) = *session.peek() else {
413 return;
414 };
415 dnd.update_pointer(point);
416 if !dnd.is_session(id) {
417 return;
418 }
419 if let Some(joined) = membership {
420 // Record an authoritative release point even when no final move
421 // preceded it. Receiver intent and settle anchoring consume the
422 // global projection updated by this lookup.
423 let _ = joined.zone_under(point);
424 joined.world.update_modifiers(*mods.peek());
425 }
426 let effect = effective_effect(effect, *mods.peek());
427 dnd.set_proposed_effect(effect);
428 // A release the world resolves into a FOREIGN window delivers
429 // there: that window's registry and settle flag, coordinates in
430 // its client px (including its own 48px snap, in its own CSS px).
431 // Own-window and unresolved releases (no geometry, outside every
432 // window) fall through to the classic path below, so
433 // single-window behavior is untouched - origin-window snap
434 // included.
435 if let Some(j) = membership {
436 if let Some((rec, local)) = j.foreign_window_under(point) {
437 let mut dnd = dnd;
438 spawn(async move {
439 if !dnd.is_session(id) || !j.world.is_drag_session(id) {
440 return;
441 }
442 // Resolve exact cached hits through the acceptance-aware
443 // path so a rejecting later registry record falls through.
444 // Only a miss pays for a fresh measurement + 48px snap.
445 let query = dnd
446 .payload()
447 .map(|payload| drop_query(&dnd, payload, effect));
448 let mut target = query.as_ref().and_then(|query| {
449 rec.registry
450 .resolve(query, local, j.world.active_rect_in(rec, local), 0.0)
451 .map(|(zone, _)| zone)
452 });
453 if target.is_none() {
454 rec.registry.measure_all().await;
455 if !dnd.is_session(id) || !j.world.is_drag_session(id) {
456 return;
457 }
458 let query = dnd
459 .payload()
460 .map(|payload| drop_query(&dnd, payload, effect));
461 target = query.as_ref().and_then(|query| {
462 rec.registry
463 .resolve(
464 query,
465 local,
466 j.world.active_rect_in(rec, local),
467 rec.registry.release_policy().recovery_radius,
468 )
469 .map(|(zone, _)| zone)
470 });
471 }
472 if !dnd.is_session(id) || !j.world.is_drag_session(id) {
473 return;
474 }
475 let dropped = target
476 .map(|t| {
477 deliver_drop(
478 rec.registry,
479 &mut dnd,
480 SettleRoute {
481 flag: Some(rec.settle),
482 owner: Some((&j.world, rec.key)),
483 },
484 DropCompletion::World {
485 world: &j.world,
486 session: Some(id),
487 },
488 t,
489 local,
490 effect,
491 )
492 })
493 .unwrap_or(false);
494 if !dropped {
495 finish_pointer_source(
496 Some(j),
497 &mut dnd,
498 id,
499 DragCompletion::Cancelled(CancelReason::NoTarget),
500 );
501 }
502 });
503 return;
504 }
505 }
506 let cached_target = resolve_drag_target(registry, &dnd, point, effect, 0.0);
507 if let Some(target) = cached_target {
508 if deliver_to(target, point, effect) {
509 return;
510 }
511 }
512 spawn(async move {
513 registry.measure_all().await;
514 if !dnd.is_session(id)
515 || membership.is_some_and(|joined| !joined.world.is_drag_session(id))
516 {
517 return;
518 }
519 let target = resolve_drag_target(
520 registry,
521 &dnd,
522 point,
523 effect,
524 registry.release_policy().recovery_radius,
525 );
526 let dropped = match target {
527 Some(t) => deliver_to(t, point, effect),
528 None => false,
529 };
530 if !dropped {
531 finish_pointer_source(
532 membership,
533 &mut dnd,
534 id,
535 DragCompletion::Cancelled(CancelReason::NoTarget),
536 );
537 }
538 });
539 };
540
541 rsx! {
542 div {
543 style: style,
544 "data-dragging": if dnd.dragging()
545 && (dnd.drag_id() == Some(drag_id)
546 || (!dnd.has_explicit_drag_id()
547 && dnd.payload().as_ref() == Some(&attr_payload)))
548 { "true" },
549 "data-disabled": if disabled { "true" },
550 onmounted: move |evt: Event<MountedData>| {
551 let m: Rc<MountedData> = evt.data();
552 node.set(Some(m.clone()));
553 // Focus continuity for keyboard drops: if this mount IS the
554 // just-dropped payload landing in its new place, take the
555 // focus the browser dropped when the source unmounted.
556 if !disabled && dnd.claim_refocus(&mount_payload) {
557 spawn(async move {
558 let _ = m.set_focus(true).await;
559 });
560 }
561 },
562 onpointerdown: move |evt: PointerEvent| {
563 let handle_match = *handle_pointer.peek() == Some(evt.pointer_id());
564 // Consume the one-event capability even when this press is
565 // disabled or non-primary. A rejected handle event must not
566 // authorize a later mouse press that reuses the pointer id.
567 handle_pointer.set(None);
568 if disabled || !primary_press(&evt) {
569 return;
570 }
571 if activation.peek().constraint.is_manual()
572 || matches!(activation.peek().activator, Activator::Manual)
573 || (matches!(activation.peek().activator, Activator::Handle) && !handle_match)
574 {
575 return;
576 }
577 // A prior release may still be awaiting its async snap
578 // measurement; its Up already moved the machine out of
579 // Dragging, so retire that stale generation before the
580 // machine sees a new Down. Gated on the phase: a session
581 // with the machine still in Dragging is a LIVE drag, and a
582 // second primary press (a mouse click during a touch drag,
583 // a pen tap during a mouse drag) must not steal it -
584 // (Dragging, Down) is deliberately inert.
585 if !matches!(*phase.peek(), GesturePhase::Dragging { .. }) {
586 // Copy out of the peek BEFORE finishing: an `if let` on
587 // `*session.peek()` keeps the read guard alive through
588 // the body (edition 2021 scrutinee temporaries), and
589 // `finish_pointer_source` synchronously runs the
590 // completion callback, whose `session.set(None)` then
591 // aborts the process from an unwind-proof Win32 callback
592 // (AlreadyBorrowed; observed live on Windows 11).
593 let stale = *session.peek();
594 if let Some(id) = stale {
595 finish_pointer_source(
596 membership,
597 &mut dnd,
598 id,
599 DragCompletion::Cancelled(CancelReason::Replaced),
600 );
601 }
602 }
603 empty_held_moves.set(0);
604 press_max_travel.set(0.0);
605 mods.set(evt.modifiers());
606 // Suppress the press's default actions - the same line the
607 // sortable rows carry. The one that matters: `tabindex=0`
608 // makes this div mouse-focusable as a browser side effect,
609 // and that stray focus outlives the drop (the model mutates,
610 // nodes get reused, and the ring can surface on an unrelated
611 // item). Keyboard focus via Tab is untouched, and clicks
612 // on inner controls still fire (`click` is not a
613 // compatibility mouse event).
614 evt.prevent_default();
615 evt.stop_propagation();
616 captured.set(match node.peek().clone() {
617 Some(n) => platform::capture_pointer(&n, evt.pointer_id()),
618 None => false,
619 });
620 let o = evt.element_coordinates();
621 press_offset.set(Point::new(o.x, o.y));
622 press_kind.set(PointerKind::from_pointer_type(&evt.pointer_type()));
623 // Measure at press so a later promotion can size the ghost
624 // without waiting on a roundtrip (see `press_rect`).
625 press_rect.set(None);
626 press_measure_generation += 1;
627 let measurement_generation = *press_measure_generation.peek();
628 if let Some(m) = node.peek().clone() {
629 spawn(async move {
630 if let Ok(r) = m.get_client_rect().await {
631 if *press_measure_generation.peek() != measurement_generation {
632 return;
633 }
634 press_rect.set(Some(Rect::new(
635 r.origin.x,
636 r.origin.y,
637 r.size.width,
638 r.size.height,
639 )));
640 }
641 });
642 }
643 // Defense in depth: tracked completion resets the source
644 // immediately for host-ended drags. If custom integration
645 // bypassed that path, do not let a stale Dragging phase eat
646 // this press ((Dragging, Down) is deliberately inert).
647 if !dnd.dragging() && matches!(*phase.peek(), GesturePhase::Dragging { .. }) {
648 let _ = step(GestureEvent::Cancel, activation_threshold);
649 }
650 let pid = evt.pointer_id();
651 let _ = step(
652 GestureEvent::Down { at: pointer_client(&evt), pointer_id: pid },
653 activation_threshold,
654 );
655 // Arm the long-press clock: fingers (and pens) under `Auto`
656 // promote on hold-or-sideways; mice promote on travel alone.
657 let legacy_touch_hold = !has_activation_delay
658 && touch == TouchSense::Auto
659 && evt.pointer_type() != "mouse";
660 if (has_activation_delay || legacy_touch_hold)
661 && matches!(*phase.peek(), GesturePhase::Pressed { pointer_id, .. } if pointer_id == pid)
662 {
663 hold_pid.set(Some(pid));
664 }
665 },
666 onpointermove: move |evt: PointerEvent| {
667 let at = pointer_client(&evt);
668 if let GesturePhase::Pressed { origin, pointer_id } = *phase.peek() {
669 if pointer_id == evt.pointer_id() {
670 let delta = at - origin;
671 let travel = delta.x.hypot(delta.y);
672 if travel > *press_max_travel.peek() {
673 press_max_travel.set(travel);
674 }
675 if activation
676 .peek()
677 .constraint
678 .exceeded_delay_tolerance(*press_max_travel.peek(), 0.0)
679 {
680 hold_pid.set(None);
681 if activation.peek().constraint.distance().is_none() {
682 let _ = step(GestureEvent::Cancel, activation_threshold);
683 return;
684 }
685 }
686 }
687 }
688 mods.set(evt.modifiers());
689 if let Some(joined) = membership {
690 joined.world.update_modifiers(evt.modifiers());
691 }
692 // Lost-release recovery, debounced: only a RUN of empty-
693 // held moves is believed (see RELEASE_RECOVERY_MOVES).
694 let released = if matches!(*phase.peek(), GesturePhase::Dragging { .. })
695 && evt.held_buttons().is_empty()
696 {
697 let streak = empty_held_moves.peek().saturating_add(1);
698 empty_held_moves.set(streak);
699 streak >= RELEASE_RECOVERY_MOVES
700 } else {
701 if *empty_held_moves.peek() != 0 {
702 empty_held_moves.set(0);
703 }
704 false
705 };
706 let event = if released {
707 if let Some(n) = node.peek().clone() {
708 platform::release_pointer(&n, evt.pointer_id());
709 }
710 GestureEvent::Up { at, pointer_id: evt.pointer_id() }
711 } else {
712 GestureEvent::Move { at, pointer_id: evt.pointer_id() }
713 };
714 match step(event, activation_threshold) {
715 GestureEffect::Begin { at, .. } => begin_drag.call(at),
716 GestureEffect::Track { at } => {
717 let Some(id) = *session.peek() else {
718 return;
719 };
720 dnd.update_pointer(at);
721 if !dnd.is_session(id) {
722 return;
723 }
724 let proposed = effective_effect(effect, *mods.peek());
725 dnd.set_proposed_effect(proposed);
726 let query = dnd
727 .payload()
728 .map(|payload| drop_query(&dnd, payload, proposed));
729 // World-resolved hits are authoritative even when
730 // zoneless: a foreign window IN FRONT of one of our
731 // zones must not let the covered zone light up.
732 match membership {
733 Some(joined) => match query
734 .as_ref()
735 .map(|query| joined.zone_under_query(at, query))
736 .unwrap_or(WorldHit::Unresolved)
737 {
738 WorldHit::Zone(location) => joined.enter(location),
739 WorldHit::Window => joined.clear_hover(),
740 WorldHit::Unresolved => match resolve_drag_hover(
741 registry, &dnd, at, proposed,
742 ) {
743 Some(zone) => joined.enter(joined.location(zone)),
744 None => joined.clear_hover(),
745 },
746 },
747 None => match resolve_drag_hover(registry, &dnd, at, proposed) {
748 Some(zone) => dnd.enter(zone),
749 None => {
750 if let Some(over) = dnd.over() {
751 dnd.leave(over);
752 }
753 }
754 },
755 }
756 }
757 GestureEffect::Drop { at: point } => finish_drop(point),
758 _ => {}
759 }
760 },
761 onpointerup: move |evt: PointerEvent| {
762 if let Some(n) = node.peek().clone() {
763 platform::release_pointer(&n, evt.pointer_id());
764 }
765 mods.set(evt.modifiers());
766 if let Some(joined) = membership {
767 joined.world.update_modifiers(evt.modifiers());
768 }
769 let GestureEffect::Drop { at: point } = step(
770 GestureEvent::Up { at: pointer_client(&evt), pointer_id: evt.pointer_id() },
771 activation_threshold,
772 ) else {
773 return;
774 };
775 finish_drop(point);
776 },
777 onpointercancel: move |evt: PointerEvent| {
778 if let Some(n) = node.peek().clone() {
779 platform::release_pointer(&n, evt.pointer_id());
780 }
781 if step(GestureEvent::Cancel, activation_threshold) == GestureEffect::Abort {
782 // Copied out of the peek before finishing - same borrow
783 // discipline as the pointerdown retire above.
784 let cancelled = *session.peek();
785 if let Some(id) = cancelled {
786 finish_pointer_source(
787 membership,
788 &mut dnd,
789 id,
790 DragCompletion::Cancelled(CancelReason::PointerCancelled),
791 );
792 }
793 }
794 },
795 onlostpointercapture: move |_| {
796 if step(GestureEvent::Cancel, activation_threshold) == GestureEffect::Abort {
797 // Copied out of the peek before finishing - same borrow
798 // discipline as the pointerdown retire above.
799 let lost = *session.peek();
800 if let Some(id) = lost {
801 finish_pointer_source(
802 membership,
803 &mut dnd,
804 id,
805 DragCompletion::Cancelled(CancelReason::PointerCancelled),
806 );
807 }
808 }
809 },
810 // A promoted drag owns the touch: cancel its moves so the
811 // browser can't start a pan mid-drag. (`touch-action` is only
812 // consulted at gesture start, so `pan-y` alone can't do this.)
813 // dioxus-web's delegated listener is non-passive - see the
814 // touch-sensor browser spec.
815 ontouchmove: move |evt: TouchEvent| {
816 if matches!(*phase.peek(), GesturePhase::Dragging { .. }) {
817 evt.prevent_default();
818 }
819 },
820 // Android pops a context menu on touch long-press (the iOS
821 // callout is already off via touch_style); mid-gesture that
822 // would tear the hold or the drag. Idle presses keep the menu.
823 oncontextmenu: move |evt: Event<MouseData>| {
824 if !matches!(*phase.peek(), GesturePhase::Idle) {
825 evt.prevent_default();
826 }
827 },
828 // --- keyboard interaction ---------------------------------
829 // Space/Enter picks the item up, arrow keys cycle acceptable
830 // zones, Space/Enter drops, Escape cancels. Announcements go
831 // through the context; render `a11y::LiveRegion` to voice them.
832 tabindex: if disabled || !matches!(activation.peek().activator, Activator::Surface) { -1_i64 } else { 0 },
833 role: if matches!(activation.peek().activator, Activator::Surface) { Some("button") } else { None },
834 aria_roledescription: "draggable",
835 onkeydown: move |evt: KeyboardEvent| {
836 if disabled {
837 return;
838 }
839 let from_handle = *handle_keyboard.peek();
840 handle_keyboard.set(false);
841 if activation.peek().constraint.is_manual()
842 || matches!(activation.peek().activator, Activator::Manual)
843 || (matches!(activation.peek().activator, Activator::Handle) && !from_handle)
844 {
845 return;
846 }
847 let registry = registry;
848 let key = evt.key();
849 let is_activate = matches!(key, Key::Enter)
850 || matches!(&key, Key::Character(c) if c == " ");
851 let kb_drag = dnd.dragging() && dnd.mode() == DragMode::Keyboard;
852
853 if !dnd.dragging() && is_activate {
854 evt.prevent_default();
855 dnd.start_with_id(
856 drag_id,
857 DragStart::new(kb_payload.clone(), Point::default())
858 .with_source(zone)
859 .with_effect(effect)
860 .with_mode(DragMode::Keyboard),
861 );
862 if !dnd.dragging()
863 || dnd.drag_id() != Some(drag_id)
864 || dnd.mode() != DragMode::Keyboard
865 {
866 return;
867 }
868 if let Some(joined) = membership {
869 joined.world.begin_from(joined.key);
870 }
871 // Measure zones so arrow-key order can follow visual
872 // (top-to-bottom, left-to-right) layout.
873 registry.refresh_rects();
874 let name = kb_label.clone().unwrap_or_else(|| (strings.item)());
875 dnd.announce((strings.picked_up)(&name));
876 if let Some(h) = &on_drag_start {
877 h.call(());
878 }
879 return;
880 }
881
882 if !kb_drag {
883 return;
884 }
885
886 // Hierarchical navigation (WAI-ARIA tree convention):
887 // Up/Down cycle siblings at the current level; the arrow
888 // along reading order descends into the hovered zone's
889 // children; the opposite one ascends to its parent (both
890 // mirror under RTL). In flat apps (no nesting) they fall
891 // back to next/previous, preserving the simple behavior.
892 let nav = nav_key(&key, registry.direction());
893 if let (Some(nav), Some(p)) = (nav, dnd.payload()) {
894 evt.prevent_default();
895 let over = dnd.over();
896 let query = drop_query(&dnd, p, effect);
897 let next = match nav {
898 NavKey::Next => registry.step_sibling_query(over, &query, 1),
899 NavKey::Prev => registry.step_sibling_query(over, &query, -1),
900 NavKey::Descend => over
901 .and_then(|z| registry.first_child_query(z, &query))
902 .or_else(|| registry.step_sibling_query(over, &query, 1)),
903 NavKey::Ascend => over
904 .and_then(|z| registry.ascend(z))
905 .or_else(|| registry.step_sibling_query(over, &query, -1)),
906 };
907 if let Some(next) = next {
908 match membership {
909 Some(joined) => joined.enter(joined.location(next)),
910 None => dnd.enter(next),
911 }
912 let record = registry.get(next);
913 let name = record
914 .as_ref()
915 .and_then(|z| z.label.clone())
916 .unwrap_or_else(|| (strings.zone)(next.0));
917 let inside = record
918 .as_ref()
919 .and_then(|z| z.parent)
920 .and_then(|pid| registry.get(pid))
921 .and_then(|pz| pz.label);
922 match inside {
923 Some(parent) => dnd.announce((strings.over_inside)(&name, &parent)),
924 None => dnd.announce((strings.over)(&name)),
925 }
926 } else {
927 dnd.announce((strings.no_targets)());
928 }
929 return;
930 }
931
932 if is_activate {
933 evt.prevent_default();
934 // A custom source can enter() an id from another type's
935 // registry; falling back keeps Enter from dying silently.
936 let target = dnd.over().filter(|z| registry.contains(*z)).or_else(|| {
937 dnd.payload().and_then(|payload| {
938 let query = drop_query(&dnd, payload, effect);
939 registry.step_zone_query(None, &query, 1)
940 })
941 });
942 let Some(target) = target else {
943 dnd.announce((strings.no_target_selected)());
944 return;
945 };
946 if let Some(record) = registry.get(target) {
947 if let Some(payload) = dnd.payload() {
948 let (client, _) = keyboard_drop_points(registry.cached_rect(target));
949 let delivered = match membership {
950 Some(joined) => deliver_drop(
951 registry,
952 &mut dnd,
953 SettleRoute {
954 flag: settle_flag,
955 owner: Some((&joined.world, joined.key)),
956 },
957 DropCompletion::World {
958 world: &joined.world,
959 session: None,
960 },
961 target,
962 client,
963 effect,
964 ),
965 None => deliver_drop(
966 registry,
967 &mut dnd,
968 SettleRoute {
969 flag: settle_flag,
970 owner: None,
971 },
972 DropCompletion::None,
973 target,
974 client,
975 effect,
976 ),
977 };
978 if !delivered {
979 dnd.announce((strings.no_target_selected)());
980 return;
981 }
982 // The drop re-mounts the moved item. Its new
983 // source claims this request and restores focus.
984 dnd.request_refocus(payload);
985 let name = record
986 .label
987 .unwrap_or_else(|| (strings.zone)(target.0));
988 dnd.announce((strings.dropped_in)(&name));
989 if let Some(h) = &on_drag_end {
990 h.call(true);
991 }
992 }
993 }
994 return;
995 }
996
997 if matches!(key, Key::Escape) {
998 evt.prevent_default();
999 if let Some(joined) = membership {
1000 joined
1001 .world
1002 .finish_untracked(DragCompletion::Cancelled(CancelReason::User));
1003 } else {
1004 dnd.cancel();
1005 }
1006 dnd.announce((strings.cancelled)());
1007 if let Some(h) = &on_drag_end {
1008 h.call(false);
1009 }
1010 }
1011 },
1012 ..attributes,
1013 // Pointer-capture SUBSTITUTE, rendered only when native capture
1014 // did not engage. With capture (the `web` feature), events
1015 // retarget to this element already - and the layer must not
1016 // exist, so the page's own hit-testing (`elementFromPoint`
1017 // introspection included) stays untouched. Without capture
1018 // (desktop webviews, web without the feature) nothing
1019 // retargets: the moment the cursor left this element mid-drag
1020 // the move stream died and the ghost froze. This full-viewport
1021 // child then owns every pointer event and lets it bubble to
1022 // the handlers above - no separate handlers, no renderer API.
1023 // Gated on the shared context too, so a drag completed from
1024 // outside this element (host-driven drop, another window's
1025 // delivery) can never leave a stale layer eating input.
1026 // (Being position: fixed, it is clipped by any transformed
1027 // ancestor - the standard containing-block caveat, shared with
1028 // the overlay.)
1029 if matches!(phase(), GesturePhase::Dragging { .. }) && dnd.dragging() && !captured() {
1030 div {
1031 style: "position: fixed; inset: 0; z-index: 9998; touch-action: none;",
1032 aria_hidden: true,
1033 }
1034 }
1035 // Armed only while a touch press waits under `Auto`; the alarm
1036 // promotes exactly like a threshold crossing, at the origin.
1037 if let Some(pid) = hold_pid() {
1038 if activation_delays.is_empty() {
1039 HoldTimer {
1040 pointer_id: pid,
1041 delay_ms: super::pointer::HOLD_DELAY_MS,
1042 on_hold: move |pid| {
1043 if let GestureEffect::Begin { at, .. } =
1044 step(GestureEvent::Hold { pointer_id: pid }, activation_threshold)
1045 {
1046 begin_drag.call(at);
1047 }
1048 },
1049 }
1050 } else {
1051 for (timer_index, (duration_ms, tolerance)) in
1052 activation_delays.iter().copied().enumerate()
1053 {
1054 HoldTimer {
1055 key: "{timer_index}-{duration_ms}",
1056 pointer_id: pid,
1057 delay_ms: duration_ms as f64,
1058 on_hold: move |pid| {
1059 if *press_max_travel.peek() <= tolerance {
1060 if let GestureEffect::Begin { at, .. } = step(
1061 GestureEvent::Hold { pointer_id: pid },
1062 activation_threshold,
1063 ) {
1064 begin_drag.call(at);
1065 }
1066 }
1067 },
1068 }
1069 }
1070 }
1071 }
1072 {children}
1073 }
1074 }
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079 use super::*;
1080
1081 /// Horizontal arrows mirror under RTL: "descend into" is always the
1082 /// arrow pointing along reading order. Vertical arrows never mirror.
1083 #[test]
1084 fn nav_keys_mirror_under_rtl() {
1085 for dir in [Direction::Ltr, Direction::Rtl] {
1086 assert_eq!(nav_key(&Key::ArrowDown, dir), Some(NavKey::Next));
1087 assert_eq!(nav_key(&Key::ArrowUp, dir), Some(NavKey::Prev));
1088 assert_eq!(nav_key(&Key::Enter, dir), None);
1089 }
1090 assert_eq!(
1091 nav_key(&Key::ArrowRight, Direction::Ltr),
1092 Some(NavKey::Descend)
1093 );
1094 assert_eq!(
1095 nav_key(&Key::ArrowLeft, Direction::Ltr),
1096 Some(NavKey::Ascend)
1097 );
1098 assert_eq!(
1099 nav_key(&Key::ArrowRight, Direction::Rtl),
1100 Some(NavKey::Ascend)
1101 );
1102 assert_eq!(
1103 nav_key(&Key::ArrowLeft, Direction::Rtl),
1104 Some(NavKey::Descend)
1105 );
1106 }
1107
1108 #[test]
1109 fn keyboard_drop_points_use_zone_center_and_element_offset() {
1110 let rect = Rect::new(40.0, 80.0, 200.0, 100.0);
1111 let (client, element) = keyboard_drop_points(Some(rect));
1112
1113 assert_eq!(client, Point::new(140.0, 130.0));
1114 assert_eq!(element, Point::new(100.0, 50.0));
1115 }
1116
1117 #[test]
1118 fn keyboard_drop_points_fall_back_to_origin_without_rect() {
1119 let (client, element) = keyboard_drop_points(None);
1120
1121 assert_eq!(client, Point::default());
1122 assert_eq!(element, Point::default());
1123 }
1124}