mirror of
https://github.com/yewprint/yewprint
synced 2024-11-22 03:23:03 +00:00
Make Slider component work on touch devices (#168)
blueprintjs, even on v4, apparently only works on desktop targets (it uses mouse events), but I'm using yewprint for _something_ and I needed it to work on mobile devices. It seems pointer events + pointer capture is the way to go in 2023, so that's what this uses! Tested on Chrome macOS, Firefox macOS, Safari macOS, and Chrome Android and it works fine. You can drag starting from the handle or starting from anywhere on the slider itself, you can still move the pointer up/down outside the slider while holding it, focusing the handle + using arrow keys still works as before. I guess the only breaking change is that you can move the slider with a secondary mouse button now, but since PointerEvent inherits from MouseEvent it might be possible to re-add that restriction, if anyone cares about it enough.
This commit is contained in:
parent
a705c433b8
commit
bbd613206a
3 changed files with 129 additions and 88 deletions
|
@ -22,7 +22,7 @@ gloo = "0.8"
|
||||||
id_tree = { version = "1.8", optional = true }
|
id_tree = { version = "1.8", optional = true }
|
||||||
implicit-clone = "0.3.5"
|
implicit-clone = "0.3.5"
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
web-sys = { version = "0.3", features = ["DomRect", "Element", "Event", "HtmlSelectElement", "DomTokenList"] }
|
web-sys = { version = "0.3", features = ["DomRect", "Element", "Event", "HtmlSelectElement", "DomTokenList", "Element"] }
|
||||||
yew = "0.20"
|
yew = "0.20"
|
||||||
yew-callbacks = "0.2.1"
|
yew-callbacks = "0.2.1"
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,8 @@
|
||||||
clippy::inconsistent_struct_constructor,
|
clippy::inconsistent_struct_constructor,
|
||||||
clippy::type_complexity,
|
clippy::type_complexity,
|
||||||
clippy::derive_partial_eq_without_eq,
|
clippy::derive_partial_eq_without_eq,
|
||||||
clippy::uninlined_format_args
|
clippy::uninlined_format_args,
|
||||||
|
clippy::derivable_impls
|
||||||
)]
|
)]
|
||||||
|
|
||||||
mod button_group;
|
mod button_group;
|
||||||
|
@ -75,6 +76,7 @@ use yew::Classes;
|
||||||
// See https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons
|
// See https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
const MOUSE_EVENT_BUTTONS_NONE: u16 = 0;
|
const MOUSE_EVENT_BUTTONS_NONE: u16 = 0;
|
||||||
|
#[allow(dead_code)]
|
||||||
const MOUSE_EVENT_BUTTONS_PRIMARY: u16 = 1;
|
const MOUSE_EVENT_BUTTONS_PRIMARY: u16 = 1;
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
const MOUSE_EVENT_BUTTONS_SECONDARY: u16 = 2;
|
const MOUSE_EVENT_BUTTONS_SECONDARY: u16 = 2;
|
||||||
|
|
211
src/slider.rs
211
src/slider.rs
|
@ -1,17 +1,19 @@
|
||||||
use crate::Intent;
|
use crate::Intent;
|
||||||
use implicit_clone::{unsync::IArray, ImplicitClone};
|
use implicit_clone::{unsync::IArray, ImplicitClone};
|
||||||
use std::marker::PhantomData;
|
use std::marker::PhantomData;
|
||||||
use wasm_bindgen::closure::Closure;
|
|
||||||
use wasm_bindgen::JsCast;
|
use wasm_bindgen::JsCast;
|
||||||
use web_sys::Element;
|
use web_sys::Element;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
|
|
||||||
pub struct Slider<T: ImplicitClone + PartialEq + 'static> {
|
pub struct Slider<T: ImplicitClone + PartialEq + 'static> {
|
||||||
mouse_move: Closure<dyn FnMut(MouseEvent)>,
|
slider_ref: NodeRef,
|
||||||
mouse_up: Closure<dyn FnMut(MouseEvent)>,
|
|
||||||
handle_ref: NodeRef,
|
handle_ref: NodeRef,
|
||||||
track_ref: NodeRef,
|
track_ref: NodeRef,
|
||||||
is_moving: bool,
|
is_moving: bool,
|
||||||
|
// The value we had just before we started moving. We need to track it
|
||||||
|
// in case the gesture turns from something we handle (dragging left/right)
|
||||||
|
// into something the user-agent should be handling (pan-y = scrolling up/down)
|
||||||
|
value_before_move: Option<T>,
|
||||||
focus_handle: bool,
|
focus_handle: bool,
|
||||||
phantom: PhantomData<T>,
|
phantom: PhantomData<T>,
|
||||||
}
|
}
|
||||||
|
@ -31,10 +33,17 @@ pub struct SliderProps<T: ImplicitClone + PartialEq + 'static> {
|
||||||
pub selected: Option<T>,
|
pub selected: Option<T>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
StartChange,
|
// started a gesture, either via mouse ("desktop") or touch ("mobile")
|
||||||
Mouse(MouseEvent),
|
PointerDown { pointer_id: Option<i32> },
|
||||||
StopChange,
|
// pointer moved, we only track X for now (vertical isn't supported for now)
|
||||||
|
PointerMove { client_x: i32 },
|
||||||
|
// gesture cancelled: turns out we were scrolling or something
|
||||||
|
PointerCancel,
|
||||||
|
// gesture completed successfully
|
||||||
|
PointerUp,
|
||||||
|
// adjusting via keyboard, completely independent from mouse gestures
|
||||||
Keyboard(KeyboardEvent),
|
Keyboard(KeyboardEvent),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -42,25 +51,13 @@ impl<T: ImplicitClone + PartialEq + 'static> Component for Slider<T> {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = SliderProps<T>;
|
type Properties = SliderProps<T>;
|
||||||
|
|
||||||
fn create(ctx: &Context<Self>) -> Self {
|
fn create(_ctx: &Context<Self>) -> Self {
|
||||||
let mouse_move = {
|
|
||||||
let link = ctx.link().clone();
|
|
||||||
Closure::wrap(Box::new(move |event: web_sys::MouseEvent| {
|
|
||||||
link.send_message(Msg::Mouse(event));
|
|
||||||
}) as Box<dyn FnMut(_)>)
|
|
||||||
};
|
|
||||||
let mouse_up = {
|
|
||||||
let link = ctx.link().clone();
|
|
||||||
Closure::wrap(Box::new(move |_event: web_sys::MouseEvent| {
|
|
||||||
link.send_message(Msg::StopChange);
|
|
||||||
}) as Box<dyn FnMut(_)>)
|
|
||||||
};
|
|
||||||
Self {
|
Self {
|
||||||
mouse_move,
|
slider_ref: NodeRef::default(),
|
||||||
mouse_up,
|
|
||||||
handle_ref: NodeRef::default(),
|
handle_ref: NodeRef::default(),
|
||||||
track_ref: NodeRef::default(),
|
track_ref: NodeRef::default(),
|
||||||
is_moving: false,
|
is_moving: false,
|
||||||
|
value_before_move: None,
|
||||||
focus_handle: false,
|
focus_handle: false,
|
||||||
phantom: PhantomData,
|
phantom: PhantomData,
|
||||||
}
|
}
|
||||||
|
@ -68,70 +65,66 @@ impl<T: ImplicitClone + PartialEq + 'static> Component for Slider<T> {
|
||||||
|
|
||||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::StartChange if ctx.props().values.len() > 1 => {
|
Msg::PointerDown { pointer_id } if ctx.props().values.len() > 1 => {
|
||||||
let document = gloo::utils::document();
|
if let Some(pointer_id) = pointer_id {
|
||||||
let event_target: &web_sys::EventTarget = document.as_ref();
|
if let Some(slider) = self.slider_ref.cast::<Element>() {
|
||||||
|
slider.set_pointer_capture(pointer_id).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.is_moving = true;
|
self.is_moving = true;
|
||||||
event_target
|
if let Some(selected) = ctx.props().selected.as_ref() {
|
||||||
.add_event_listener_with_callback(
|
// save the current value in case we need to restore it
|
||||||
"mousemove",
|
self.value_before_move = Some(selected.clone());
|
||||||
self.mouse_move.as_ref().unchecked_ref(),
|
}
|
||||||
)
|
|
||||||
.expect("No event listener to add");
|
|
||||||
event_target
|
|
||||||
.add_event_listener_with_callback(
|
|
||||||
"mouseup",
|
|
||||||
self.mouse_up.as_ref().unchecked_ref(),
|
|
||||||
)
|
|
||||||
.expect("No event listener to add");
|
|
||||||
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Msg::StartChange => false,
|
Msg::PointerDown { .. } => false,
|
||||||
Msg::Mouse(event) if ctx.props().values.len() > 1 => {
|
Msg::PointerMove { client_x } if ctx.props().values.len() > 1 => {
|
||||||
if event.buttons() == crate::MOUSE_EVENT_BUTTONS_PRIMARY {
|
if !self.is_moving {
|
||||||
let track_rect = self.track_ref.cast::<Element>().expect("no track ref");
|
return false;
|
||||||
let tick_size = (track_rect.client_width() as f64)
|
}
|
||||||
/ ctx.props().values.len().saturating_sub(1) as f64;
|
|
||||||
let pixel_delta =
|
|
||||||
(event.client_x() as f64) - track_rect.get_bounding_client_rect().left();
|
|
||||||
|
|
||||||
let position = (pixel_delta / tick_size).round() as usize;
|
let track_rect = self.track_ref.cast::<Element>().expect("no track ref");
|
||||||
|
let tick_size = (track_rect.client_width() as f64)
|
||||||
|
/ ctx.props().values.len().saturating_sub(1) as f64;
|
||||||
|
let pixel_delta = (client_x as f64) - track_rect.get_bounding_client_rect().left();
|
||||||
|
|
||||||
let (value, _) = ctx.props().values.get(position).unwrap_or_else(|| {
|
let position = (pixel_delta / tick_size).round() as usize;
|
||||||
ctx.props()
|
|
||||||
.values
|
|
||||||
.last()
|
|
||||||
.cloned()
|
|
||||||
.expect("No value in the array")
|
|
||||||
});
|
|
||||||
|
|
||||||
if Some(&value) != ctx.props().selected.as_ref() {
|
let (value, _) = ctx.props().values.get(position).unwrap_or_else(|| {
|
||||||
ctx.props().onchange.emit(value);
|
ctx.props()
|
||||||
}
|
.values
|
||||||
|
.last()
|
||||||
|
.cloned()
|
||||||
|
.expect("No value in the array")
|
||||||
|
});
|
||||||
|
|
||||||
|
if Some(&value) != ctx.props().selected.as_ref() {
|
||||||
|
ctx.props().onchange.emit(value);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Msg::PointerMove { .. } => false,
|
||||||
|
Msg::PointerCancel => {
|
||||||
|
self.is_moving = false;
|
||||||
|
|
||||||
|
if let Some(value_before_move) = self.value_before_move.take() {
|
||||||
|
// there's a slight bug here: if the value was None before
|
||||||
|
// the cancelled gesture started, we have no way to restore
|
||||||
|
// it to None since `onchange` only accepts `T`, not `Option<T>`.
|
||||||
|
// fixing this requires breaking the API
|
||||||
|
ctx.props().onchange.emit(value_before_move);
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Msg::Mouse(_) => false,
|
Msg::PointerUp => {
|
||||||
Msg::StopChange => {
|
// gesture completed successfully
|
||||||
let document = gloo::utils::document();
|
|
||||||
let event_target: &web_sys::EventTarget = document.as_ref();
|
|
||||||
self.is_moving = false;
|
self.is_moving = false;
|
||||||
event_target
|
self.value_before_move = None;
|
||||||
.remove_event_listener_with_callback(
|
|
||||||
"mousemove",
|
|
||||||
self.mouse_move.as_ref().unchecked_ref(),
|
|
||||||
)
|
|
||||||
.expect("No event listener to remove");
|
|
||||||
event_target
|
|
||||||
.remove_event_listener_with_callback(
|
|
||||||
"mouseup",
|
|
||||||
self.mouse_up.as_ref().unchecked_ref(),
|
|
||||||
)
|
|
||||||
.expect("No event listener to remove");
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Msg::Keyboard(event) if ctx.props().values.len() > 1 => match event.key().as_str() {
|
Msg::Keyboard(event) if ctx.props().values.len() > 1 => match event.key().as_str() {
|
||||||
|
@ -228,19 +221,73 @@ impl<T: ImplicitClone + PartialEq + 'static> Component for Slider<T> {
|
||||||
"bp3-slider",
|
"bp3-slider",
|
||||||
ctx.props().vertical.then_some("bp3-vertical"),
|
ctx.props().vertical.then_some("bp3-vertical"),
|
||||||
)}
|
)}
|
||||||
onmousedown={(ctx.props().values.len() > 1).then(
|
ref={self.slider_ref.clone()}
|
||||||
|
style={"touch-action: pan-y pinch-zoom; -webkit-touch-callout: none;"}
|
||||||
|
onpointerdown={(ctx.props().values.len() > 1).then(
|
||||||
|| ctx.link().batch_callback(
|
|| ctx.link().batch_callback(
|
||||||
|event: MouseEvent| {
|
|event: PointerEvent| {
|
||||||
if event.buttons() ==
|
if event.is_primary() {
|
||||||
crate::MOUSE_EVENT_BUTTONS_PRIMARY
|
let down = Msg::PointerDown {
|
||||||
{
|
pointer_id: Some(event.pointer_id())
|
||||||
vec![Msg::StartChange, Msg::Mouse(event)]
|
};
|
||||||
|
if event.pointer_type() == "touch" {
|
||||||
|
// for touch devices, wait for some dragging
|
||||||
|
// to occur to know if we're dragging the
|
||||||
|
// slider or scrolling the page. this avoids
|
||||||
|
// some jumps on pointercancel, in most
|
||||||
|
// cases. it also doesn't affect "clicks"
|
||||||
|
// which do one-time adjustments.
|
||||||
|
vec![down]
|
||||||
|
} else {
|
||||||
|
vec![down, Msg::PointerMove { client_x: event.client_x() }]
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
vec![]
|
vec![]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
onpointermove={(ctx.props().values.len() > 1).then(
|
||||||
|
|| ctx.link().batch_callback(
|
||||||
|
|event: PointerEvent| {
|
||||||
|
if let Some(target) = event.target() {
|
||||||
|
if let Some(el) = target.dyn_ref::<web_sys::Element>() {
|
||||||
|
if el.has_pointer_capture(event.pointer_id()) {
|
||||||
|
return vec![
|
||||||
|
Msg::PointerMove { client_x: event.client_x() }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
onpointerup={(ctx.props().values.len() > 1).then(
|
||||||
|
|| ctx.link().callback(
|
||||||
|
|_event: PointerEvent| {
|
||||||
|
Msg::PointerUp
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
onpointercancel={(ctx.props().values.len() > 1).then(
|
||||||
|
|| ctx.link().callback(
|
||||||
|
|_event: PointerEvent| {
|
||||||
|
Msg::PointerCancel
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
onclick={(ctx.props().values.len() > 1).then(
|
||||||
|
|| ctx.link().batch_callback(
|
||||||
|
|event: MouseEvent| {
|
||||||
|
vec![
|
||||||
|
Msg::PointerDown { pointer_id: None },
|
||||||
|
Msg::PointerMove { client_x: event.client_x() },
|
||||||
|
Msg::PointerUp
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class={classes!("bp3-slider-track")}
|
class={classes!("bp3-slider-track")}
|
||||||
|
@ -304,18 +351,10 @@ impl<T: ImplicitClone + PartialEq + 'static> Component for Slider<T> {
|
||||||
)}
|
)}
|
||||||
ref={self.handle_ref.clone()}
|
ref={self.handle_ref.clone()}
|
||||||
style={format!(
|
style={format!(
|
||||||
"left: calc({}% - 8px);",
|
"-webkit-touch-callout: none; left: calc({}% - 8px);",
|
||||||
100.0 * (index as f64)
|
100.0 * (index as f64)
|
||||||
/ (ctx.props().values.len() as f64 - 1.0),
|
/ (ctx.props().values.len() as f64 - 1.0),
|
||||||
)}
|
)}
|
||||||
onmousedown={ctx.link().batch_callback(
|
|
||||||
|event: MouseEvent| {
|
|
||||||
if event.buttons() == crate::MOUSE_EVENT_BUTTONS_PRIMARY {
|
|
||||||
vec![Msg::StartChange]
|
|
||||||
} else {
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
onkeydown={ctx.link().callback(|event| Msg::Keyboard(event))}
|
onkeydown={ctx.link().callback(|event| Msg::Keyboard(event))}
|
||||||
tabindex=0
|
tabindex=0
|
||||||
>
|
>
|
||||||
|
|
Loading…
Reference in a new issue