This commit is contained in:
spushk 2026-10-10 02:34:50 +03:00 • committed by spushk
commit f068161aea
Signed by: spushk
GPG key ID: 7797B761E105E8AA
4 changed files with 3546 additions and 0 deletions

285
src/data.rs Normal file
View file

@ -0,0 +1,285 @@
use std::{hash::Hash, ops::Deref, sync::atomic::AtomicU64};
use leptos::reactive::traits::{ReadUntracked, Track, Write};
/// All reader method implementations should be untracked:
/// e.g. don't implement `SortableData::item_at` with `RwSignal::read()` - use `RwSignal::read_untracked()` instead.
///
/// All writer method implementations should notify reactive context:
/// e.g. don't implement `SortableData::item_move` with `RwSignal::write_untracked()` - use `RwSignal::write()` instead.
pub trait SortableData {
/// The target Item type that consumer will render
type Item: SortableItem + Send + Sync + 'static;
/// Get the unique item key at stable index position.
/// Does not track reactive ctx
///
/// Implementation may not panic: if `index` is out of bounds, `None` return is expected
fn item_key_at(&self, index: usize) -> Option<<Self::Item as SortableItem>::Key>;
/// Get item at stable index position.
/// Does not track reactive ctx
///
/// Implementation may not panic: if `index` is out of bounds, `None` return is expected
fn item_at(&self, index: usize) -> Option<Self::Item>;
/// Move item from `from` index to `to` index.
/// Notifies subscribers once.
///
/// If any index is out of bounds, implementation **must not** perform move operation.
///
/// Implementation may not panic: if either index is out of bounds - no-op
fn item_move(&self, from: usize, to: usize);
/// Get amount of items.
/// Does not track reactive ctx
fn len(&self) -> usize;
/// Check if empty.
/// Does not track reactive ctx
fn is_empty(&self) -> bool;
/// Manually track changes to the item sequence
/// provided by this trait implementee.
fn track(&self);
}
/// Covers every Leptos read/write signal over a `Vec<T>`, e.g:
/// - `RwSignal<Vec<T>>`
/// - `Store<Vec<T>>`
/// - `Store`'s `Vec` field accessor
/// - `ArcRwSignal<Vec<T>>`
impl<T, R, S> SortableData for S
where
T: SortableItem + Clone + Send + Sync + 'static,
S: ReadUntracked<Value = R> + Write<Value = Vec<T>> + Track,
R: Deref<Target = Vec<T>>,
{
type Item = T;
fn item_key_at(&self, index: usize) -> Option<<T as SortableItem>::Key> {
self.read_untracked().get(index).map(SortableItem::key)
}
fn item_at(&self, index: usize) -> Option<T> {
self.read_untracked().get(index).cloned()
}
fn item_move(&self, from: usize, to: usize) {
let len = self.len();
if from >= len || to >= len || from == to {
return;
}
let mut guard = self.write();
let item = guard.remove(from);
guard.insert(to, item);
}
fn len(&self) -> usize {
self.read_untracked().len()
}
fn is_empty(&self) -> bool {
self.read_untracked().is_empty()
}
fn track(&self) {
Track::track(self);
}
}
/// An element that can live in a sortable sequence.
///
/// Supplies the stable identity the visual layer uses to key rows, so the same
/// DOM node follows its item across reorders.
pub trait SortableItem {
/// Stable, immutable identity of the item, unique within a sequence.
type Key: Send + Sync + Eq + Hash + Clone + 'static;
/// Returns this item's identity key.
fn key(&self) -> Self::Key;
}
/// Provided [`SortableItem`] implementation which uses a process-global atomic
/// counter for the id.
///
/// Serde round-trips the wrapped value only: the id is runtime-only and is
/// regenerated on deserialization.
#[derive(Debug, Clone)]
pub struct Item<T>(u64, T);
static ITEM_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
impl<T> Item<T> {
pub fn new(value: T) -> Self {
let id = ITEM_ID_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Self(id, value)
}
pub fn into_inner(self) -> T {
self.1
}
}
/// Serializes as the wrapped value: the id is not persisted.
impl<T> serde::Serialize for Item<T>
where
T: serde::Serialize,
{
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
<T as serde::Serialize>::serialize(&self.1, serializer)
}
}
/// Deserializes the wrapped value and assigns a fresh runtime id.
impl<'de, T> serde::Deserialize<'de> for Item<T>
where
T: serde::de::DeserializeOwned,
{
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
<T as serde::Deserialize<'de>>::deserialize(deserializer).map(Item::new)
}
}
impl<T> AsRef<T> for Item<T> {
fn as_ref(&self) -> &T {
&self.1
}
}
impl<T: PartialEq> PartialEq for Item<T> {
fn eq(&self, other: &Self) -> bool {
self.1 == other.1
}
}
impl<T: Eq> Eq for Item<T> {}
impl<T: PartialOrd> PartialOrd for Item<T> {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
self.1.partial_cmp(&other.1)
}
}
impl<T: Ord> Ord for Item<T> {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.1.cmp(&other.1)
}
}
impl<T> SortableItem for Item<T> {
type Key = u64;
fn key(&self) -> Self::Key {
self.0
}
}
#[cfg(test)]
mod tests {
use super::*;
use leptos::reactive::{signal::RwSignal, traits::GetUntracked};
use reactive_stores::Store;
#[derive(Debug, Clone, PartialEq, Eq)]
struct TestItem {
id: u32,
}
impl SortableItem for TestItem {
type Key = u32;
fn key(&self) -> u32 {
self.id
}
}
fn test_sortable_data_correctness<T: SortableData<Item = TestItem>>(
data: T,
get_data: impl Fn(&T) -> Vec<TestItem>,
) {
assert!(!data.is_empty());
assert_eq!(data.len(), 3);
assert_eq!(data.item_key_at(0), Some(0));
assert_eq!(data.item_key_at(3), None);
assert_eq!(data.item_at(1), Some(TestItem { id: 1 }));
assert_eq!(data.item_at(3), None);
data.item_move(0, 2); //tail
assert_eq!(
get_data(&data),
vec![TestItem { id: 1 }, TestItem { id: 2 }, TestItem { id: 0 }]
);
data.item_move(2, 1); // from > to
assert_eq!(
get_data(&data),
vec![TestItem { id: 1 }, TestItem { id: 0 }, TestItem { id: 2 }]
);
data.item_move(0, 0); // from == to
assert_eq!(
get_data(&data),
vec![TestItem { id: 1 }, TestItem { id: 0 }, TestItem { id: 2 }]
);
data.item_move(0, 3); // OOB
assert_eq!(
get_data(&data),
vec![TestItem { id: 1 }, TestItem { id: 0 }, TestItem { id: 2 }]
);
data.item_move(4, 2); // OOB
assert_eq!(
get_data(&data),
vec![TestItem { id: 1 }, TestItem { id: 0 }, TestItem { id: 2 }]
);
data.item_move(10, 20); // OOB
}
#[test]
fn sortable_data_blankets() {
// Vec
let data = RwSignal::new(vec![
TestItem { id: 0 },
TestItem { id: 1 },
TestItem { id: 2 },
]);
test_sortable_data_correctness(data, |d| d.get_untracked());
// Store
let data = Store::new(vec![
TestItem { id: 0 },
TestItem { id: 1 },
TestItem { id: 2 },
]);
test_sortable_data_correctness(data, |d| d.get_untracked());
// Store field
#[derive(Clone, Store)]
struct Model {
#[store(key: u32 = |row| row.id)]
items: Vec<TestItem>,
}
let store = Store::new(Model {
items: vec![TestItem { id: 0 }, TestItem { id: 1 }, TestItem { id: 2 }],
});
let data = store.items();
test_sortable_data_correctness(data, |d| d.get_untracked());
}
#[test]
fn item_serde() {
let item = Item::new(-126);
let json = serde_json::to_string(&item).unwrap();
assert_eq!(json, "-126");
let restored: Item<i32> = serde_json::from_str(&json).unwrap();
assert_ne!(restored.key(), item.key()); // runtime ID is next
assert_eq!(restored.into_inner(), -126);
}
}

373
src/drag.rs Normal file
View file

@ -0,0 +1,373 @@
use leptos::ev::PointerEvent;
use leptos::prelude::*;
use leptos::web_sys;
use leptos_use::use_window;
use wasm_bindgen::JsCast;
/// Pointer travel (px) after which an armed card starts following the cursor;
/// below it, pressing the handle stays a plain click.
const DRAG_THRESHOLD_PX: f64 = 8.0;
#[inline]
fn passes_drag_threshold(dx: f64, dy: f64) -> bool {
dx * dx + dy * dy >= DRAG_THRESHOLD_PX * DRAG_THRESHOLD_PX
}
/// Clamp the floating item's document-space top,
/// so its box stays inside `[stack_top, stack_top + stack_height]`
#[inline]
fn clamp_doc_top(doc_top: f64, stack_top: f64, stack_height: f64, item_height: f64) -> f64 {
let min = stack_top;
let max = (stack_top + stack_height - item_height).max(min);
doc_top.clamp(min, max)
}
/// `(document-space top, height)` per item, in stack order.
type ItemlistGeoms = Vec<(f64, f64)>;
/// Shared drag state for a single sortable list.
pub(crate) struct DragState<K> {
/// Key of the row that is currently floating
dragged: RwSignal<Option<K>>,
/// Key of the row captured at pointer-down. Preserved while dragging
armed: RwSignal<Option<K>>,
/// Geometry of the dragged row, captured at pointer-down
dragged_geom: RwSignal<Option<DraggedItem>>,
/// Landing index computed from the pointer position
drop_target_index: RwSignal<Option<usize>>,
/// Clamped `client_y` of the floating item, in client space
float_top: RwSignal<Option<f64>>,
/// Snapshot of every row's geometry, in stack order
itemlist_geoms: RwSignal<ItemlistGeoms>,
/// Pointer id owning the active gesture
pointer_id: RwSignal<Option<i32>>,
}
impl<K> Clone for DragState<K> {
fn clone(&self) -> Self {
*self
}
}
impl<K> Copy for DragState<K> {}
impl<K> Default for DragState<K>
where
K: Send + Sync + 'static,
{
fn default() -> Self {
Self {
dragged: RwSignal::new(None),
armed: RwSignal::new(None),
dragged_geom: RwSignal::new(None),
drop_target_index: RwSignal::new(None),
pointer_id: RwSignal::new(None),
itemlist_geoms: RwSignal::new(ItemlistGeoms::new()),
float_top: RwSignal::new(None),
}
}
}
impl<K> DragState<K>
where
K: Clone + Send + Sync + PartialEq + 'static,
{
/// Whether a gesture is armed or being dragged
pub fn has_armed(&self) -> bool {
self.armed.read().is_some()
}
/// Whether a gesture is armed or being dragged
pub fn has_armed_untracked(&self) -> bool {
self.armed.read_untracked().is_some()
}
/// Whether `pointer_id` owns the active gesture
pub fn owns_pointer_untracked(&self, pointer_id: i32) -> bool {
self.pointer_id.get_untracked() == Some(pointer_id)
}
/// Whether the item with `key` is currently being dragged
pub fn is_dragging(&self, key: &K) -> bool {
self.dragged.read().as_ref() == Some(key)
}
/// Whether any item is currently being dragged
pub fn has_dragging(&self) -> bool {
self.dragged.read().is_some()
}
/// Whether any item is being dragged
pub fn has_dragging_untracked(&self) -> bool {
self.dragged.read_untracked().is_some()
}
/// Reactive height of the dragged item, `None` if nothing is dragged.
pub fn dragged_height(&self) -> Option<f64> {
Some(self.dragged_geom.read().as_ref()?.height)
}
/// Reactive `(source position, landing index)` while item is mid-drag
pub fn landing(&self) -> Option<(usize, usize)> {
let source = self
.dragged_geom
.read()
.as_ref()
.map(|item| item.source_index)?;
let to = self.drop_target_index.get()?;
Some((source, to))
}
/// Reactive floating-item inline geometry `(left, top, width)` in client space for the
/// item with `key`, `None` if item is not the one being dragged
pub fn float_coords(&self, key: &K) -> Option<(f64, f64, f64)> {
if !self.is_dragging(key) {
return None;
}
let top = self.float_top.get()?;
let (left, width) = self.dragged_geom.with_untracked(|item| {
let i = item.as_ref()?;
Some((i.left, i.width))
})?;
Some((left, top, width))
}
/// Capture the gesture for row `key` at `source_index`.
///
/// Measures the whole list from `stack` and takes pointer capture on
/// `ev` pointer. No-op if a gesture is already armed or the geometry
/// cannot be measured
#[cfg_attr(
feature = "tracing",
tracing::instrument(skip_all, fields(source_index))
)]
pub fn arm(
&self,
key: K,
source_index: usize,
ev: &PointerEvent,
stack: &web_sys::HtmlElement,
scroll_y: f64,
) {
if self.has_armed_untracked() {
maybe_debug!("halt: already armed");
return;
}
let Some((item, itemlist_geoms)) = discover_stack(
stack,
source_index,
(f64::from(ev.client_x()), f64::from(ev.client_y())),
scroll_y,
) else {
maybe_debug!("halt: cant measure geometry");
return;
};
_ = stack
.set_pointer_capture(ev.pointer_id())
.inspect_err(|err| {
maybe_debug!(?err, "soft-fail: cant set pointer capture");
let _ = &err;
});
ev.prevent_default();
let doc_top = (item.pointerdown_point.1 + scroll_y) - item.pointerdown_top_offset;
let float_top =
clamp_doc_top(doc_top, item.stack_top, item.stack_height, item.height) - scroll_y;
self.pointer_id.set(Some(ev.pointer_id()));
self.float_top.set(Some(float_top));
self.dragged_geom.set(Some(item));
self.armed.set(Some(key));
self.itemlist_geoms.set(itemlist_geoms);
maybe_debug!("armed");
}
/// Check pointer position and start the drag once the threshold is passed.
///
/// Target index is computed against the pointer-down snapshot.
#[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
pub fn pointer_move(&self, x: f64, y: f64, scroll_y: f64) {
if !self.has_armed_untracked() {
return;
}
self.dragged_geom.with_untracked(|geom| {
let Some(item) = geom else {
maybe_debug!("halt: no geom");
return;
};
if self.dragged.read_untracked().is_none() {
let (dx, dy) = (x - item.pointerdown_point.0, y - item.pointerdown_point.1);
if !passes_drag_threshold(dx, dy) {
return;
}
let Some(key) = self.armed.get_untracked() else {
return;
};
maybe_debug!("start dragging");
self.dragged.set(Some(key));
}
// The float is clamped to the stack container; the landing `center`
// below stays based on the raw pointer, so `to` can still reach the
// first and last positions while the float is pinned at the edge.
let doc_top = (y + scroll_y) - item.pointerdown_top_offset;
let float_top =
clamp_doc_top(doc_top, item.stack_top, item.stack_height, item.height) - scroll_y;
self.float_top.set(Some(float_top));
let center = (y + scroll_y) - item.pointerdown_top_offset + item.height / 2.0;
let to = {
let mut landings = 0usize;
for (i, &(top, h)) in self.itemlist_geoms.read_untracked().iter().enumerate() {
if i == item.source_index {
continue;
}
let boundary = top + h / 2.0
- if i > item.source_index {
item.height
} else {
0.0
};
landings += usize::from(boundary < center);
}
landings
};
if self.drop_target_index.get_untracked() != Some(to) {
self.drop_target_index.set(Some(to));
maybe_debug!(src = item.source_index, to, "drop target change");
}
});
}
/// Stop dragging, returning `(source_index, landing index)` when item is
/// floating, `None` otherwise
#[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
pub fn drop(&self) -> Option<(usize, usize)> {
self.has_dragging_untracked().then_some(())?;
let to = self.drop_target_index.get_untracked()?;
let source = self
.dragged_geom
.with_untracked(|geom| geom.as_ref().map(|item| item.source_index))?;
self.disarm();
maybe_debug!(to, source, "dropped");
Some((source, to))
}
/// Reset all gesture state
pub fn disarm(&self) {
self.pointer_id.set(None);
self.armed.set(None);
self.dragged.set(None);
self.drop_target_index.set(None);
self.dragged_geom.set(None);
self.float_top.set(None);
self.itemlist_geoms.write().clear();
}
}
/// Current page scroll offset, converting client-space coordinates to document space.
/// `None` where there is no window (e.g. SSR).
pub(crate) fn window_scroll_y() -> Option<f64> {
use_window()
.as_ref()
.map(|window| window.scroll_y().unwrap_or_default())
}
/// Geometry of the dragged row, captured at pointer-down.
#[derive(Debug)]
pub(crate) struct DraggedItem {
/// Position of the dragged item in the stack order captured before the drag
pub source_index: usize,
/// Row height, in CSS pixels
pub height: f64,
/// Offset from the row's top edge to the pointer-down `client_y`
pub pointerdown_top_offset: f64,
/// Row's `client_x` left edge
pub left: f64,
/// Row width, in CSS pixels
pub width: f64,
/// Pointer-down position `(client_x, client_y)`
pub pointerdown_point: (f64, f64),
/// Stack container top edge, in document space
pub stack_top: f64,
/// Stack container height, in CSS pixels
pub stack_height: f64,
}
/// Snapshot list geometry from the live DOM.
///
/// Rows are discovered by the `data-leptos-sp-sortable-item` marker attribute, so
/// the stack markup is the only source of geometry. `source_index` is the
/// dragged item position
///
/// Items are measured in DOM order
#[cfg_attr(feature = "tracing", tracing::instrument(skip(stack)))]
fn discover_stack(
stack: &web_sys::HtmlElement,
source_index: usize,
grab: (f64, f64),
scroll_y: f64,
) -> Option<(DraggedItem, ItemlistGeoms)> {
let Ok(nodes) = stack.query_selector_all(":scope > [data-leptos-sp-sortable-item]") else {
maybe_debug!("halt: stack query failed");
return None;
};
let stack_rect = stack.get_bounding_client_rect();
let stack_top = stack_rect.top() + scroll_y;
let stack_height = stack_rect.height();
let len = nodes.length();
let mut items = Vec::with_capacity(len as usize);
// (height, pointerdown offset, left, width) of the source row.
let mut src: Option<(f64, f64, f64, f64)> = None;
for i in 0..len {
let Some(node) = nodes.get(i) else {
maybe_debug!(index = i, "stack holds inaccessible node");
continue;
};
let Ok(el) = node.dyn_into::<web_sys::HtmlElement>() else {
maybe_debug!("stack holds a non-element node");
continue;
};
let rect = el.get_bounding_client_rect();
let top = rect.top();
let height = rect.height();
if i as usize == source_index {
src = Some((height, grab.1 - top, rect.left(), rect.width()));
}
items.push((top + scroll_y, height));
}
#[cfg_attr(not(feature = "tracing"), allow(clippy::question_mark))]
let Some((height, pointerdown_top_offset, left, width)) = src else {
maybe_debug!(source_index, "halt: dragged element not found");
return None;
};
Some((
DraggedItem {
source_index,
height,
pointerdown_top_offset,
left,
width,
pointerdown_point: grab,
stack_top,
stack_height,
},
items,
))
}

298
src/lib.rs Normal file
View file

@ -0,0 +1,298 @@
use leptos::either::{Either, EitherOf3};
use leptos::ev::{PointerEvent, blur, pointercancel, pointermove, pointerup, resize};
use leptos::prelude::*;
use leptos_meta::Style;
use leptos_use::{use_document, use_event_listener, use_window};
use sabry::styly;
use crate::drag::{DragState, window_scroll_y};
#[macro_use]
mod tracing_wrapper;
mod data;
mod drag;
pub use data::{Item, SortableData, SortableItem};
type SortableNodeRef = NodeRef<leptos::html::Ul>;
/// One item of the rendered stack
#[derive(Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
enum ItemSlot<K> {
Gap,
Item(K),
}
/// Injects the crate stylesheet
#[component]
pub fn Styles() -> impl IntoView {
view! { <Style id="leptos-sp-sortable">{SORTABLE_CSS}</Style> }
}
#[component]
pub fn Sortable<T, ItemFn, ItemView>(
data: T,
#[prop(name = "item")] item_render: ItemFn,
#[prop(optional, name = "handle", into)] handle_render: Option<HandleSlot>,
#[prop(optional, name = "gap", into)] gap_render: Option<GapSlot>,
) -> impl IntoView
where
T: SortableData + Copy + Send + Sync + 'static,
<T::Item as SortableItem>::Key: serde::Serialize + serde::de::DeserializeOwned,
ItemFn: Fn(T::Item) -> ItemView + Copy + Send + 'static,
ItemView: IntoView + 'static,
{
let drag: DragState<<T::Item as SortableItem>::Key> = DragState::default();
let ul_ref: SortableNodeRef = NodeRef::new();
_ = use_event_listener(use_window(), blur, move |_| drag.disarm());
_ = use_event_listener(use_window(), resize, move |_| drag.disarm());
_ = use_event_listener(use_window(), pointercancel, move |ev| {
if drag.owns_pointer_untracked(ev.pointer_id()) {
drag.disarm();
}
});
_ = use_event_listener(use_window(), pointerup, move |ev| {
if !drag.has_armed_untracked() || !drag.owns_pointer_untracked(ev.pointer_id()) {
return;
}
if let Some((from, to)) = drag.drop() {
SortableData::item_move(&data, from, to);
}
});
_ = use_event_listener(use_window(), pointermove, move |ev| {
if !drag.has_armed_untracked() || !drag.owns_pointer_untracked(ev.pointer_id()) {
return;
}
if let Some(scroll_y) = window_scroll_y() {
drag.pointer_move(f64::from(ev.client_x()), f64::from(ev.client_y()), scroll_y);
}
});
// Keys depend on the item sequence only, so moving the pointer never
// rebuilds them.
let keys = Memo::new(move |_| {
SortableData::track(&data);
(0..SortableData::len(&data))
.filter_map(|index| SortableData::item_key_at(&data, index))
.collect::<Vec<_>>()
});
// Rows depend on the keys and on the current drag landing.
let items = Memo::new(move |_| {
let keys = keys.get();
let Some((source, to)) = drag.landing() else {
return keys.into_iter().map(ItemSlot::Item).collect::<Vec<_>>();
};
let len = keys.len();
let gap_at = if to < source { to } else { to + 1 };
let mut items = Vec::with_capacity(len + 1);
for (index, key) in keys.into_iter().enumerate() {
if index == gap_at {
items.push(ItemSlot::Gap);
}
items.push(ItemSlot::Item(key));
}
if gap_at == len {
items.push(ItemSlot::Gap);
}
items
});
let dragged_height = Signal::derive(move || drag.dragged_height().unwrap_or(0.0));
Effect::new(move |_| set_dragging_cursor(drag.has_armed()));
on_cleanup(|| set_dragging_cursor(false));
view! { class=SORTABLE,
<Styles />
<ul node_ref={ul_ref}>
<ForEnumerate
each={move || items.get()}
key={|item: &ItemSlot<<T::Item as SortableItem>::Key>| item.clone()}
children={move |
index: ReadSignal<usize>,
item: ItemSlot<<T::Item as SortableItem>::Key>|
match item {
ItemSlot::Gap if let Some(render) = gap_render => {
EitherOf3::A(render.0.run(dragged_height))
}
ItemSlot::Gap => EitherOf3::B(default_gap(dragged_height)),
ItemSlot::Item(key) => {
let item = SortableData::item_at(&data, index.get_untracked())
.expect("key from tracked sequence maps to an item");
EitherOf3::C(
item_snippet::<
T,
ItemFn,
ItemView,
>(key, index, item, drag, ul_ref, item_render, handle_render),
)
}
}}
/>
</ul>
}
}
fn set_dragging_cursor(dragging: bool) {
let doc = use_document();
let Some(body) = doc.body() else {
return;
};
let style = body.style();
let _ = style.set_property("cursor", if dragging { "grabbing" } else { "auto" });
let _ = style.set_property("user-select", if dragging { "none" } else { "auto" });
}
fn item_snippet<T, ItemFn, ItemView>(
key: <T::Item as SortableItem>::Key,
index: ReadSignal<usize>,
item: T::Item,
drag: DragState<<T::Item as SortableItem>::Key>,
ul_ref: SortableNodeRef,
item_render: ItemFn,
handle_render: Option<HandleSlot>,
) -> impl IntoView
where
T: SortableData + Copy + Send + Sync + 'static,
ItemFn: Fn(T::Item) -> ItemView + Copy + Send + 'static,
ItemView: IntoView + 'static,
{
// Shared, `Copy` derived state so the attribute closures below do not each
// hold their own clone of `key`.
let floating = {
let key = key.clone();
Memo::new(move |_| drag.is_dragging(&key))
};
let geometry = {
let key = key.clone();
Memo::new(move |_| drag.float_coords(&key))
};
let on_handle_down = move |ev: PointerEvent| {
let Some(stack) = ul_ref.get() else {
return;
};
let Some(scroll_y) = window_scroll_y() else {
return;
};
drag.arm(key.clone(), index.get_untracked(), &ev, &stack, scroll_y);
};
view! { class=SORTABLE,
<li
data-leptos-sp-sortable-item=true
class:float={move || floating.get()}
style={move || {
geometry
.get()
.map(|(left, top, width)| {
format!("left:{left}px;top:{top}px;width:{width}px")
})
}}
role="listitem"
aria-roledescription="Draggable item"
aria-pressed={move || floating.get()}
>
<span
class="handle"
on:pointerdown={on_handle_down}
role="button"
tabindex="0"
aria-disabled={move || drag.has_dragging()}
>
{match handle_render {
Some(render) => Either::Left(render.0.run(())),
None => Either::Right(default_handle()),
}}
</span>
<div class="item">{item_render(item)}</div>
</li>
}
}
/// Convenience wrapper struct which allows better ergonomic for Sortable/handle_render prop:
///
/// `move || view!{}` instead of `move|_| view!{}.into_any()`
#[derive(Clone, Copy)]
pub struct HandleSlot(Callback<(), AnyView>);
impl<F, V> From<F> for HandleSlot
where
F: Fn() -> V + Send + Sync + 'static,
V: IntoView + 'static,
{
fn from(f: F) -> Self {
Self(Callback::new(move |_| f().into_any()))
}
}
/// Convenience wrapper struct which allows better ergonomic for Sortable/gap_render prop:
///
/// `move |height| view!{}` instead of `move |height| view!{}.into_any()`
#[derive(Clone, Copy)]
pub struct GapSlot(Callback<Signal<f64>, AnyView>);
impl<F, V> From<F> for GapSlot
where
F: Fn(Signal<f64>) -> V + Send + Sync + 'static,
V: IntoView + 'static,
{
fn from(f: F) -> Self {
Self(Callback::new(move |h| f(h).into_any()))
}
}
fn default_handle() -> impl IntoView {
view! { class=SORTABLE, <div class="handle">"☰"</div> }
}
fn default_gap(height: Signal<f64>) -> impl IntoView {
view! { class=SORTABLE,
<li class="gap" style:height={move || { format!("{}px", height.get()) }} />
}
}
styly!(const sortable:scss {"
ul {
display: flex;
flex-direction: column;
gap: 0;
list-style: none;
}
li {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
}
.float {
cursor: grabbing;
position: fixed;
z-index: 50;
& > .item {
pointer-events: none;
}
& > .handle {
cursor: grabbing;
}
}
.item {
flex-grow: 1;
flex-shrink: 0;
}
.handle {
flex-grow: 0;
flex-shrink: 1;
cursor: grab;
user-select: none;
touch-action: none;
margin-right: 4px;
}
"});

8
src/tracing_wrapper.rs Normal file
View file

@ -0,0 +1,8 @@
macro_rules! maybe_debug {
($($any:tt)*) => {
cfg_select! {
feature = "tracing" => {tracing::debug!($($any)*)},
_ => {}
}
};
}