leptos_aria_interactions/
text_selection.rs

1use std::ptr::eq;
2use std::time::Duration;
3
4use leptos::create_rw_signal;
5use leptos::document;
6use leptos::js_sys::JsString;
7use leptos::set_timeout;
8use leptos::web_sys::Element;
9use leptos::web_sys::HtmlElement;
10use leptos::web_sys::SvgElement;
11use leptos::JsCast;
12use leptos::RwSignal;
13use leptos::Scope;
14use leptos::UntrackedGettableSignal;
15use leptos::UntrackedSettableSignal;
16use leptos_aria_utils::is_ios;
17use leptos_aria_utils::run_after_transition;
18use leptos_aria_utils::ContextProvider;
19use leptos_aria_utils::Map;
20
21#[derive(Copy, Clone)]
22pub(crate) struct SelectionContext(RwSignal<Selection>);
23
24impl ContextProvider for SelectionContext {
25  type Value = Selection;
26
27  fn from_leptos_scope(cx: Scope) -> Self {
28    Self(create_rw_signal(cx, Self::Value::default()))
29  }
30
31  fn get(&self) -> Self::Value {
32    self.0.get_untracked()
33  }
34
35  fn set(&self, value: Self::Value) {
36    self.0.set_untracked(value);
37  }
38}
39
40#[derive(Copy, Clone)]
41pub(crate) struct UserSelectContext(RwSignal<Option<String>>);
42
43impl ContextProvider for UserSelectContext {
44  type Value = Option<String>;
45
46  fn from_leptos_scope(cx: Scope) -> Self {
47    Self(create_rw_signal(cx, None))
48  }
49
50  fn get(&self) -> Self::Value {
51    self.0.get_untracked()
52  }
53
54  fn set(&self, value: Self::Value) {
55    self.0.set(value)
56  }
57}
58
59type ElementMap = Map<Element, JsString>;
60
61#[derive(Copy, Clone)]
62pub(crate) struct ElementMapContext(RwSignal<ElementMap>);
63
64impl ContextProvider for ElementMapContext {
65  type Value = ElementMap;
66
67  fn from_leptos_scope(cx: Scope) -> Self {
68    Self(create_rw_signal(cx, Default::default()))
69  }
70
71  fn get(&self) -> Self::Value {
72    self.0.get_untracked()
73  }
74
75  fn set(&self, value: Self::Value) {
76    let reference = &value;
77    self.0.set_untracked(if eq(reference, &self.get()) {
78      // this happens when the value was directly mutated.
79      reference.clone()
80    } else {
81      value
82    });
83  }
84}
85
86pub(crate) fn disable_text_selection(cx: Scope, element: Option<impl AsRef<Element>>) {
87  if is_ios() {
88    let selection = SelectionContext::provide(cx);
89    let user_select = UserSelectContext::provide(cx);
90
91    if selection.get() == Selection::Default {
92      let style = document()
93        .document_element()
94        .unwrap()
95        .unchecked_ref::<HtmlElement>()
96        .style();
97      user_select.set(style.get_property_value("-webkit-user-select").ok());
98      style.set_property("-webkit-user-select", "none").ok();
99    }
100
101    selection.set(Selection::Disabled);
102    return;
103  }
104
105  let Some(target) = element.as_ref().map(|item| item.as_ref()) else {
106      return;
107    };
108
109  if !target.is_instance_of::<HtmlElement>() && !target.is_instance_of::<HtmlElement>() {
110    return;
111  }
112
113  let _should_append = true;
114  let element_list = ElementMapContext::provide(cx);
115  let style = target.unchecked_ref::<HtmlElement>().style();
116  let map = element_list.get();
117  let _cloned_target = target.clone();
118  let user_select = style.get_property_value("user-select").unwrap_or("".into());
119  map.set(target, &user_select.into());
120
121  element_list.set(map);
122}
123
124/// Safari on iOS starts selecting text on long press. The only way to avoid
125/// this, it seems, is to add user-select: none to the entire page. Adding it
126/// to the pressable element prevents that element from being selected, but
127/// nearby elements may still receive selection. We add user-select: none on
128/// touch start, and remove it again on touch end to prevent this. This must
129/// be implemented using global state to avoid race conditions between
130/// multiple elements.
131///
132/// There are three possible states due to the delay before removing
133/// user-select: none after pointer up. The 'default' state always transitions
134/// to the 'disabled' state, which transitions to 'restoring'. The 'restoring'
135/// state can either transition back to 'disabled' or 'default'.
136///
137/// For non-iOS devices, we apply user-select: none to the pressed element
138/// instead to avoid possible performance issues that arise from applying and
139/// removing user-select: none to the entire page (see https://github.com/adobe/react-spectrum/issues/1609).
140pub(crate) fn restore_text_selection(cx: Scope, element: impl AsRef<Element>) {
141  if is_ios() {
142    let selection = SelectionContext::provide(cx);
143    let user_select = UserSelectContext::provide(cx);
144
145    // If the state is already the default, there's nothing to do.
146    // If restoring, then there's no need to queue a second restore.
147    // if state != "disable"
148    if selection.get() != Selection::Default {
149      return;
150    }
151
152    selection.set(Selection::Restoring);
153
154    let timeout_callback = move || {
155      if selection.get() != Selection::Default {
156        return;
157      }
158
159      let document_element: HtmlElement = document().document_element().unwrap().unchecked_into();
160
161      if document_element
162        .style()
163        .get_property_value("-webkit-user-select")
164        .ok()
165        .as_deref()
166        == Some("none")
167      {
168        document_element
169          .style()
170          .set_property(
171            "-webkit-user-select",
172            user_select.get().as_deref().unwrap_or(""),
173          )
174          .ok();
175      }
176
177      selection.set(Selection::Default);
178      user_select.set(None);
179    };
180
181    set_timeout(
182      move || run_after_transition(cx, timeout_callback),
183      Duration::from_millis(300),
184    );
185
186    return;
187  }
188
189  let target = element.as_ref();
190
191  if !target.is_instance_of::<HtmlElement>() && !target.is_instance_of::<SvgElement>() {
192    return;
193  }
194
195  let element_map = ElementMapContext::provide(cx);
196  let map = element_map.get();
197
198  let Some(found_selection) = map.get(target) else {
199    return;
200  };
201
202  let style = target.unchecked_ref::<HtmlElement>().style();
203  if style.get_property_value("user-select").ok().as_deref() == Some("none") {
204    let found_selection: String = found_selection.into();
205    style
206      .set_property("user-select", found_selection.as_str())
207      .ok();
208  }
209
210  if target
211    .get_attribute("style")
212    .as_ref()
213    .filter(|value| !value.is_empty())
214    .is_none()
215  {
216    target.remove_attribute("style").ok();
217  }
218
219  map.delete(target);
220  element_map.set(map);
221}
222
223#[derive(Copy, Clone, PartialEq)]
224pub(crate) enum Selection {
225  Default,
226  Disabled,
227  Restoring,
228}
229
230impl Default for Selection {
231  fn default() -> Self {
232    Self::Default
233  }
234}