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:
amos 2023-04-04 20:34:33 +02:00 committed by GitHub
parent a705c433b8
commit bbd613206a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 129 additions and 88 deletions

View file

@ -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"

View file

@ -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;

View file

@ -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,33 +65,31 @@ 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 {
return false;
}
let track_rect = self.track_ref.cast::<Element>().expect("no track ref"); let track_rect = self.track_ref.cast::<Element>().expect("no track ref");
let tick_size = (track_rect.client_width() as f64) let tick_size = (track_rect.client_width() as f64)
/ ctx.props().values.len().saturating_sub(1) as f64; / ctx.props().values.len().saturating_sub(1) as f64;
let pixel_delta = let pixel_delta = (client_x as f64) - track_rect.get_bounding_client_rect().left();
(event.client_x() as f64) - track_rect.get_bounding_client_rect().left();
let position = (pixel_delta / tick_size).round() as usize; let position = (pixel_delta / tick_size).round() as usize;
@ -109,29 +104,27 @@ impl<T: ImplicitClone + PartialEq + 'static> Component for Slider<T> {
if Some(&value) != ctx.props().selected.as_ref() { if Some(&value) != ctx.props().selected.as_ref() {
ctx.props().onchange.emit(value); 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
> >