Skip to main content

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}