UNPKG

14.6 kBJavaScriptView Raw
1'use strict';
2
3const helpers = require('./helpers-d381ec4d.js');
4
5const cloneMap = new WeakMap();
6const relocateInput = (componentEl, inputEl, shouldRelocate, inputRelativeY = 0) => {
7 if (cloneMap.has(componentEl) === shouldRelocate) {
8 return;
9 }
10 if (shouldRelocate) {
11 addClone(componentEl, inputEl, inputRelativeY);
12 }
13 else {
14 removeClone(componentEl, inputEl);
15 }
16};
17const isFocused = (input) => {
18 return input === input.getRootNode().activeElement;
19};
20const addClone = (componentEl, inputEl, inputRelativeY) => {
21 // this allows for the actual input to receive the focus from
22 // the user's touch event, but before it receives focus, it
23 // moves the actual input to a location that will not screw
24 // up the app's layout, and does not allow the native browser
25 // to attempt to scroll the input into place (messing up headers/footers)
26 // the cloned input fills the area of where native input should be
27 // while the native input fakes out the browser by relocating itself
28 // before it receives the actual focus event
29 // We hide the focused input (with the visible caret) invisible by making it scale(0),
30 const parentEl = inputEl.parentNode;
31 // DOM WRITES
32 const clonedEl = inputEl.cloneNode(false);
33 clonedEl.classList.add('cloned-input');
34 clonedEl.tabIndex = -1;
35 parentEl.appendChild(clonedEl);
36 cloneMap.set(componentEl, clonedEl);
37 const doc = componentEl.ownerDocument;
38 const tx = doc.dir === 'rtl' ? 9999 : -9999;
39 componentEl.style.pointerEvents = 'none';
40 inputEl.style.transform = `translate3d(${tx}px,${inputRelativeY}px,0) scale(0)`;
41};
42const removeClone = (componentEl, inputEl) => {
43 const clone = cloneMap.get(componentEl);
44 if (clone) {
45 cloneMap.delete(componentEl);
46 clone.remove();
47 }
48 componentEl.style.pointerEvents = '';
49 inputEl.style.transform = '';
50};
51
52const enableHideCaretOnScroll = (componentEl, inputEl, scrollEl) => {
53 if (!scrollEl || !inputEl) {
54 return () => { return; };
55 }
56 const scrollHideCaret = (shouldHideCaret) => {
57 if (isFocused(inputEl)) {
58 relocateInput(componentEl, inputEl, shouldHideCaret);
59 }
60 };
61 const onBlur = () => relocateInput(componentEl, inputEl, false);
62 const hideCaret = () => scrollHideCaret(true);
63 const showCaret = () => scrollHideCaret(false);
64 helpers.addEventListener(scrollEl, 'ionScrollStart', hideCaret);
65 helpers.addEventListener(scrollEl, 'ionScrollEnd', showCaret);
66 inputEl.addEventListener('blur', onBlur);
67 return () => {
68 helpers.removeEventListener(scrollEl, 'ionScrollStart', hideCaret);
69 helpers.removeEventListener(scrollEl, 'ionScrollEnd', showCaret);
70 inputEl.addEventListener('ionBlur', onBlur);
71 };
72};
73
74const SKIP_SELECTOR = 'input, textarea, [no-blur], [contenteditable]';
75const enableInputBlurring = () => {
76 let focused = true;
77 let didScroll = false;
78 const doc = document;
79 const onScroll = () => {
80 didScroll = true;
81 };
82 const onFocusin = () => {
83 focused = true;
84 };
85 const onTouchend = (ev) => {
86 // if app did scroll return early
87 if (didScroll) {
88 didScroll = false;
89 return;
90 }
91 const active = doc.activeElement;
92 if (!active) {
93 return;
94 }
95 // only blur if the active element is a text-input or a textarea
96 if (active.matches(SKIP_SELECTOR)) {
97 return;
98 }
99 // if the selected target is the active element, do not blur
100 const tapped = ev.target;
101 if (tapped === active) {
102 return;
103 }
104 if (tapped.matches(SKIP_SELECTOR) || tapped.closest(SKIP_SELECTOR)) {
105 return;
106 }
107 focused = false;
108 // TODO: find a better way, why 50ms?
109 setTimeout(() => {
110 if (!focused) {
111 active.blur();
112 }
113 }, 50);
114 };
115 helpers.addEventListener(doc, 'ionScrollStart', onScroll);
116 doc.addEventListener('focusin', onFocusin, true);
117 doc.addEventListener('touchend', onTouchend, false);
118 return () => {
119 helpers.removeEventListener(doc, 'ionScrollStart', onScroll, true);
120 doc.removeEventListener('focusin', onFocusin, true);
121 doc.removeEventListener('touchend', onTouchend, false);
122 };
123};
124
125const SCROLL_ASSIST_SPEED = 0.3;
126const getScrollData = (componentEl, contentEl, keyboardHeight) => {
127 const itemEl = componentEl.closest('ion-item,[ion-item]') || componentEl;
128 return calcScrollData(itemEl.getBoundingClientRect(), contentEl.getBoundingClientRect(), keyboardHeight, componentEl.ownerDocument.defaultView.innerHeight);
129};
130const calcScrollData = (inputRect, contentRect, keyboardHeight, platformHeight) => {
131 // compute input's Y values relative to the body
132 const inputTop = inputRect.top;
133 const inputBottom = inputRect.bottom;
134 // compute visible area
135 const visibleAreaTop = contentRect.top;
136 const visibleAreaBottom = Math.min(contentRect.bottom, platformHeight - keyboardHeight);
137 // compute safe area
138 const safeAreaTop = visibleAreaTop + 15;
139 const safeAreaBottom = visibleAreaBottom * 0.75;
140 // figure out if each edge of the input is within the safe area
141 const distanceToBottom = safeAreaBottom - inputBottom;
142 const distanceToTop = safeAreaTop - inputTop;
143 // desiredScrollAmount is the negated distance to the safe area according to our calculations.
144 const desiredScrollAmount = Math.round((distanceToBottom < 0)
145 ? -distanceToBottom
146 : (distanceToTop > 0)
147 ? -distanceToTop
148 : 0);
149 // our calculations make some assumptions that aren't always true, like the keyboard being closed when an input
150 // gets focus, so make sure we don't scroll the input above the visible area
151 const scrollAmount = Math.min(desiredScrollAmount, inputTop - visibleAreaTop);
152 const distance = Math.abs(scrollAmount);
153 const duration = distance / SCROLL_ASSIST_SPEED;
154 const scrollDuration = Math.min(400, Math.max(150, duration));
155 return {
156 scrollAmount,
157 scrollDuration,
158 scrollPadding: keyboardHeight,
159 inputSafeY: -(inputTop - safeAreaTop) + 4
160 };
161};
162
163const enableScrollAssist = (componentEl, inputEl, contentEl, footerEl, keyboardHeight) => {
164 let coord;
165 const touchStart = (ev) => {
166 coord = helpers.pointerCoord(ev);
167 };
168 const touchEnd = (ev) => {
169 // input cover touchend/mouseup
170 if (!coord) {
171 return;
172 }
173 // get where the touchend/mouseup ended
174 const endCoord = helpers.pointerCoord(ev);
175 // focus this input if the pointer hasn't moved XX pixels
176 // and the input doesn't already have focus
177 if (!hasPointerMoved(6, coord, endCoord) && !isFocused(inputEl)) {
178 ev.stopPropagation();
179 // begin the input focus process
180 jsSetFocus(componentEl, inputEl, contentEl, footerEl, keyboardHeight);
181 }
182 };
183 componentEl.addEventListener('touchstart', touchStart, true);
184 componentEl.addEventListener('touchend', touchEnd, true);
185 return () => {
186 componentEl.removeEventListener('touchstart', touchStart, true);
187 componentEl.removeEventListener('touchend', touchEnd, true);
188 };
189};
190const jsSetFocus = async (componentEl, inputEl, contentEl, footerEl, keyboardHeight) => {
191 if (!contentEl && !footerEl) {
192 return;
193 }
194 const scrollData = getScrollData(componentEl, (contentEl || footerEl), keyboardHeight);
195 if (contentEl && Math.abs(scrollData.scrollAmount) < 4) {
196 // the text input is in a safe position that doesn't
197 // require it to be scrolled into view, just set focus now
198 inputEl.focus();
199 return;
200 }
201 // temporarily move the focus to the focus holder so the browser
202 // doesn't freak out while it's trying to get the input in place
203 // at this point the native text input still does not have focus
204 relocateInput(componentEl, inputEl, true, scrollData.inputSafeY);
205 inputEl.focus();
206 /**
207 * Relocating/Focusing input causes the
208 * click event to be cancelled, so
209 * manually fire one here.
210 */
211 helpers.raf(() => componentEl.click());
212 /* tslint:disable-next-line */
213 if (typeof window !== 'undefined') {
214 let scrollContentTimeout;
215 const scrollContent = async () => {
216 // clean up listeners and timeouts
217 if (scrollContentTimeout !== undefined) {
218 clearTimeout(scrollContentTimeout);
219 }
220 window.removeEventListener('ionKeyboardDidShow', doubleKeyboardEventListener);
221 window.removeEventListener('ionKeyboardDidShow', scrollContent);
222 // scroll the input into place
223 if (contentEl) {
224 await contentEl.scrollByPoint(0, scrollData.scrollAmount, scrollData.scrollDuration);
225 }
226 // the scroll view is in the correct position now
227 // give the native text input focus
228 relocateInput(componentEl, inputEl, false, scrollData.inputSafeY);
229 // ensure this is the focused input
230 inputEl.focus();
231 };
232 const doubleKeyboardEventListener = () => {
233 window.removeEventListener('ionKeyboardDidShow', doubleKeyboardEventListener);
234 window.addEventListener('ionKeyboardDidShow', scrollContent);
235 };
236 if (contentEl) {
237 const scrollEl = await contentEl.getScrollElement();
238 /**
239 * scrollData will only consider the amount we need
240 * to scroll in order to properly bring the input
241 * into view. It will not consider the amount
242 * we can scroll in the content element.
243 * As a result, scrollData may request a greater
244 * scroll position than is currently available
245 * in the DOM. If this is the case, we need to
246 * wait for the webview to resize/the keyboard
247 * to show in order for additional scroll
248 * bandwidth to become available.
249 */
250 const totalScrollAmount = scrollEl.scrollHeight - scrollEl.clientHeight;
251 if (scrollData.scrollAmount > (totalScrollAmount - scrollEl.scrollTop)) {
252 /**
253 * On iOS devices, the system will show a "Passwords" bar above the keyboard
254 * after the initial keyboard is shown. This prevents the webview from resizing
255 * until the "Passwords" bar is shown, so we need to wait for that to happen first.
256 */
257 if (inputEl.type === 'password') {
258 // Add 50px to account for the "Passwords" bar
259 scrollData.scrollAmount += 50;
260 window.addEventListener('ionKeyboardDidShow', doubleKeyboardEventListener);
261 }
262 else {
263 window.addEventListener('ionKeyboardDidShow', scrollContent);
264 }
265 /**
266 * This should only fire in 2 instances:
267 * 1. The app is very slow.
268 * 2. The app is running in a browser on an old OS
269 * that does not support Ionic Keyboard Events
270 */
271 scrollContentTimeout = setTimeout(scrollContent, 1000);
272 return;
273 }
274 }
275 scrollContent();
276 }
277};
278const hasPointerMoved = (threshold, startCoord, endCoord) => {
279 if (startCoord && endCoord) {
280 const deltaX = (startCoord.x - endCoord.x);
281 const deltaY = (startCoord.y - endCoord.y);
282 const distance = deltaX * deltaX + deltaY * deltaY;
283 return distance > (threshold * threshold);
284 }
285 return false;
286};
287
288const PADDING_TIMER_KEY = '$ionPaddingTimer';
289const enableScrollPadding = (keyboardHeight) => {
290 const doc = document;
291 const onFocusin = (ev) => {
292 setScrollPadding(ev.target, keyboardHeight);
293 };
294 const onFocusout = (ev) => {
295 setScrollPadding(ev.target, 0);
296 };
297 doc.addEventListener('focusin', onFocusin);
298 doc.addEventListener('focusout', onFocusout);
299 return () => {
300 doc.removeEventListener('focusin', onFocusin);
301 doc.removeEventListener('focusout', onFocusout);
302 };
303};
304const setScrollPadding = (input, keyboardHeight) => {
305 if (input.tagName !== 'INPUT') {
306 return;
307 }
308 if (input.parentElement && input.parentElement.tagName === 'ION-INPUT') {
309 return;
310 }
311 if (input.parentElement &&
312 input.parentElement.parentElement &&
313 input.parentElement.parentElement.tagName === 'ION-SEARCHBAR') {
314 return;
315 }
316 const el = input.closest('ion-content');
317 if (el === null) {
318 return;
319 }
320 const timer = el[PADDING_TIMER_KEY];
321 if (timer) {
322 clearTimeout(timer);
323 }
324 if (keyboardHeight > 0) {
325 el.style.setProperty('--keyboard-offset', `${keyboardHeight}px`);
326 }
327 else {
328 el[PADDING_TIMER_KEY] = setTimeout(() => {
329 el.style.setProperty('--keyboard-offset', '0px');
330 }, 120);
331 }
332};
333
334const INPUT_BLURRING = true;
335const SCROLL_PADDING = true;
336const startInputShims = (config) => {
337 const doc = document;
338 const keyboardHeight = config.getNumber('keyboardHeight', 290);
339 const scrollAssist = config.getBoolean('scrollAssist', true);
340 const hideCaret = config.getBoolean('hideCaretOnScroll', true);
341 const inputBlurring = config.getBoolean('inputBlurring', true);
342 const scrollPadding = config.getBoolean('scrollPadding', true);
343 const inputs = Array.from(doc.querySelectorAll('ion-input, ion-textarea'));
344 const hideCaretMap = new WeakMap();
345 const scrollAssistMap = new WeakMap();
346 const registerInput = async (componentEl) => {
347 await new Promise(resolve => helpers.componentOnReady(componentEl, resolve));
348 const inputRoot = componentEl.shadowRoot || componentEl;
349 const inputEl = inputRoot.querySelector('input') || inputRoot.querySelector('textarea');
350 const scrollEl = componentEl.closest('ion-content');
351 const footerEl = (!scrollEl) ? componentEl.closest('ion-footer') : null;
352 if (!inputEl) {
353 return;
354 }
355 if (!!scrollEl && hideCaret && !hideCaretMap.has(componentEl)) {
356 const rmFn = enableHideCaretOnScroll(componentEl, inputEl, scrollEl);
357 hideCaretMap.set(componentEl, rmFn);
358 }
359 if ((!!scrollEl || !!footerEl) && scrollAssist && !scrollAssistMap.has(componentEl)) {
360 const rmFn = enableScrollAssist(componentEl, inputEl, scrollEl, footerEl, keyboardHeight);
361 scrollAssistMap.set(componentEl, rmFn);
362 }
363 };
364 const unregisterInput = (componentEl) => {
365 if (hideCaret) {
366 const fn = hideCaretMap.get(componentEl);
367 if (fn) {
368 fn();
369 }
370 hideCaretMap.delete(componentEl);
371 }
372 if (scrollAssist) {
373 const fn = scrollAssistMap.get(componentEl);
374 if (fn) {
375 fn();
376 }
377 scrollAssistMap.delete(componentEl);
378 }
379 };
380 if (inputBlurring && INPUT_BLURRING) {
381 enableInputBlurring();
382 }
383 if (scrollPadding && SCROLL_PADDING) {
384 enableScrollPadding(keyboardHeight);
385 }
386 // Input might be already loaded in the DOM before ion-device-hacks did.
387 // At this point we need to look for all of the inputs not registered yet
388 // and register them.
389 for (const input of inputs) {
390 registerInput(input);
391 }
392 doc.addEventListener('ionInputDidLoad', ((ev) => {
393 registerInput(ev.detail);
394 }));
395 doc.addEventListener('ionInputDidUnload', ((ev) => {
396 unregisterInput(ev.detail);
397 }));
398};
399
400exports.startInputShims = startInputShims;