UNPKG

13.7 kBJavaScriptView Raw
1/*
2Copyright 2013-2015 ASIAL CORPORATION
3
4Licensed under the Apache License, Version 2.0 (the "License");
5you may not use this file except in compliance with the License.
6You may obtain a copy of the License at
7
8 http://www.apache.org/licenses/LICENSE-2.0
9
10Unless required by applicable law or agreed to in writing, software
11distributed under the License is distributed on an "AS IS" BASIS,
12WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13See the License for the specific language governing permissions and
14limitations under the License.
15
16*/
17
18import onsElements from '../ons/elements.js';
19import util from '../ons/util.js';
20import styler from '../ons/styler.js';
21import platform from '../ons/platform.js';
22import BaseElement from './base/base-element.js';
23import GestureDetector from '../ons/gesture-detector.js';
24import animit from '../ons/animit.js';
25
26const STATE_INITIAL = 'initial';
27const STATE_PREACTION = 'preaction';
28const STATE_ACTION = 'action';
29
30const throwType = (el, type) => util.throw(`"${el}" must be ${type}`);
31
32/**
33 * @element ons-pull-hook
34 * @category control
35 * @description
36 * [en]
37 * Component that adds **Pull to refresh** functionality to an `<ons-page>` element.
38 *
39 * It can be used to perform a task when the user pulls down at the top of the page. A common usage is to refresh the data displayed in a page.
40 * [/en]
41 * [ja][/ja]
42 * @codepen WbJogM
43 * @tutorial vanilla/Reference/pull-hook
44 * @example
45 * <ons-page>
46 * <ons-pull-hook>
47 * Release to refresh
48 * </ons-pull-hook>
49 * </ons-page>
50 *
51 * <script>
52 * document.querySelector('ons-pull-hook').onAction = function(done) {
53 * setTimeout(done, 1000);
54 * };
55 * </script>
56 */
57export default class PullHookElement extends BaseElement {
58
59 /**
60 * @event changestate
61 * @description
62 * [en]Fired when the state is changed. The state can be either "initial", "preaction" or "action".[/en]
63 * [ja]コンポーネントの状態が変わった場合に発火します。状態は、"initial", "preaction", "action"のいずれかです。[/ja]
64 * @param {Object} event
65 * [en]Event object.[/en]
66 * [ja]イベントオブジェクト。[/ja]
67 * @param {Object} event.pullHook
68 * [en]Component object.[/en]
69 * [ja]コンポーネントのオブジェクト。[/ja]
70 * @param {String} event.state
71 * [en]Current state.[/en]
72 * [ja]現在の状態名を参照できます。[/ja]
73 */
74
75 /**
76 * @event pull
77 * @description
78 * [en]Fired when the pull hook is pulled.[/en]
79 * [ja][/ja]
80 * @param {Object} event
81 * [en]Event object.[/en]
82 * [ja]イベントオブジェクト。[/ja]
83 * @param {Object} event.ratio
84 * [en]The pulled distance ratio (scroll / height).[/en]
85 * [ja][/ja]
86 * @param {String} event.animationOptions
87 * [en]The animation options object.[/en]
88 * [ja][/ja]
89 */
90
91 /**
92 * @attribute disabled
93 * @description
94 * [en]If this attribute is set the "pull-to-refresh" functionality is disabled.[/en]
95 * [ja]この属性がある時、disabled状態になりアクションが実行されなくなります[/ja]
96 */
97
98 /**
99 * @attribute height
100 * @type {String}
101 * @description
102 * [en]Specify the height of the component. When pulled down further than this value it will switch to the "preaction" state. The default value is "64px".[/en]
103 * [ja]コンポーネントの高さを指定します。この高さ以上にpull downすると"preaction"状態に移行します。デフォルトの値は"64px"です。[/ja]
104 */
105
106 /**
107 * @attribute threshold-height
108 * @type {String}
109 * @description
110 * [en]Specify the threshold height. The component automatically switches to the "action" state when pulled further than this value. The default value is "96px". A negative value will disable this property. If this value is lower than the height, it will skip "preaction" state.[/en]
111 * [ja]閾値となる高さを指定します。この値で指定した高さよりもpull downすると、このコンポーネントは自動的に"action"状態に移行します。[/ja]
112 */
113
114 /**
115 * @attribute fixed-content
116 * @description
117 * [en]If this attribute is set the content of the page will not move when pulling.[/en]
118 * [ja]この属性がある時、プルフックが引き出されている時にもコンテンツは動きません。[/ja]
119 */
120
121 /**
122 * @property fixedContent
123 * @type {Boolean}
124 * @description
125 * [en]If this property is set the content of the page will not move when pulling.[/en]
126 * [ja]この属性がある時、プルフックが引き出されている時にもコンテンツは動きません。[/ja]
127 */
128
129 constructor() {
130 super();
131
132 this._onDrag = this._onDrag.bind(this);
133 this._onDragStart = this._onDragStart.bind(this);
134 this._onDragEnd = this._onDragEnd.bind(this);
135 this._onScroll = this._onScroll.bind(this);
136
137 this._setState(STATE_INITIAL, true);
138 this._hide(); // Fix for transparent toolbar transitions
139
140 const {onConnected, onDisconnected} = util.defineListenerProperty(this, 'pull');
141 this._connectOnPull = onConnected;
142 this._disconnectOnPull = onDisconnected;
143 }
144
145 _setStyle() {
146 const height = this.height + 'px';
147 styler(this, { height, lineHeight: height });
148 this.style.display === '' && this._show();
149 }
150
151 _onScroll(event) {
152 const element = this._pageElement;
153
154 if (element.scrollTop < 0) {
155 element.scrollTop = 0;
156 }
157 }
158
159 _canConsumeGesture(gesture) {
160 return gesture.direction === 'up' || gesture.direction === 'down';
161 }
162
163 _onDragStart(event) {
164 if (!event.gesture || this.disabled) {
165 return;
166 }
167
168 const tapY = event.gesture.center.clientY + this._pageElement.scrollTop;
169 const maxY = window.innerHeight;
170 // Only use drags that start near the pullHook to reduce flickerings
171 const draggableAreaRatio = 1;
172
173 this._ignoreDrag = event.consumed || (tapY > maxY * draggableAreaRatio);
174
175 if (!this._ignoreDrag) {
176 const consume = event.consume;
177 event.consume = () => {
178 consume && consume();
179 this._ignoreDrag = true;
180 // This elements resizes .page__content so it is safer
181 // to hide it when other components are dragged.
182 this._hide();
183 };
184
185 if (this._canConsumeGesture(event.gesture)) {
186 consume && consume();
187 event.consumed = true;
188 this._show(); // Not enough due to 'dragLockAxis'
189 }
190 }
191
192 this._startScroll = this._pageElement.scrollTop;
193 }
194
195 _onDrag(event) {
196 if (!event.gesture || this.disabled || this._ignoreDrag || !this._canConsumeGesture(event.gesture)) {
197 return;
198 }
199
200 // Necessary due to 'dragLockAxis' (25px)
201 if (this.style.display === 'none') {
202 this._show();
203 }
204
205 event.stopPropagation();
206
207 const tapY = event.gesture.center.clientY + this._pageElement.scrollTop;
208 const maxY = window.innerHeight;
209
210 const scroll = Math.max(event.gesture.deltaY - this._startScroll, 0);
211 if (scroll !== this._currentTranslation) {
212
213 const th = this.thresholdHeight;
214 if (th > 0 && scroll >= th) {
215 event.gesture.stopDetect();
216 setImmediate(() => this._finish());
217
218 } else if (scroll >= this.height) {
219 this._setState(STATE_PREACTION);
220
221 } else {
222 this._setState(STATE_INITIAL);
223 }
224
225 this._translateTo(scroll);
226 }
227 }
228
229 _onDragEnd(event) {
230 if (!event.gesture || this.disabled || this._ignoreDrag) {
231 return;
232 }
233
234 event.stopPropagation();
235
236 if (this._currentTranslation > 0) {
237 const scroll = this._currentTranslation;
238
239 if (scroll > this.height) {
240 this._finish();
241 } else {
242 this._translateTo(0, {animate: true});
243 }
244 }
245 }
246
247 /**
248 * @property onAction
249 * @type {Function}
250 * @description
251 * [en]This will be called in the `action` state if it exists. The function will be given a `done` callback as its first argument.[/en]
252 * [ja][/ja]
253 */
254 get onAction() {
255 return this._onAction;
256 }
257
258 set onAction(value) {
259 if (value && !(value instanceof Function)) {
260 throwType('onAction', 'function or null');
261 }
262 this._onAction = value;
263 }
264
265 /**
266 * @property onPull
267 * @type {Function}
268 * @description
269 * [en]Hook called whenever the user pulls the element. It gets the pulled distance ratio (scroll / height) and an animationOptions object as arguments.[/en]
270 * [ja][/ja]
271 */
272
273 _finish() {
274 this._setState(STATE_ACTION);
275 this._translateTo(this.height, {animate: true});
276 const action = this.onAction || (done => done());
277 action(() => {
278 this._translateTo(0, {animate: true});
279 this._setState(STATE_INITIAL);
280 });
281 }
282
283 /**
284 * @property height
285 * @type {Number}
286 * @description
287 * [en]The height of the pull hook in pixels. The default value is `64px`.[/en]
288 * [ja][/ja]
289 */
290 set height(value) {
291 if (!util.isInteger(value)) {
292 throwType('height', 'integer');
293 }
294
295 this.setAttribute('height', `${value}px`);
296 }
297
298 get height() {
299 return parseInt(this.getAttribute('height') || '64', 10);
300 }
301
302 /**
303 * @property thresholdHeight
304 * @type {Number}
305 * @description
306 * [en]The thresholdHeight of the pull hook in pixels. The default value is `96px`.[/en]
307 * [ja][/ja]
308 */
309 set thresholdHeight(value) {
310 if (!util.isInteger(value)) {
311 throwType('thresholdHeight', 'integer');
312 }
313
314 this.setAttribute('threshold-height', `${value}px`);
315 }
316
317 get thresholdHeight() {
318 return parseInt(this.getAttribute('threshold-height') || '96', 10);
319 }
320
321 _setState(state, noEvent) {
322 const lastState = this.state;
323
324 this.setAttribute('state', state);
325
326 if (!noEvent && lastState !== this.state) {
327 util.triggerElementEvent(this, 'changestate', {
328 pullHook: this,
329 state: state,
330 lastState: lastState
331 });
332 }
333 }
334
335 /**
336 * @property state
337 * @readonly
338 * @type {String}
339 * @description
340 * [en]Current state of the element.[/en]
341 * [ja][/ja]
342 */
343 get state() {
344 return this.getAttribute('state');
345 }
346
347 /**
348 * @property pullDistance
349 * @readonly
350 * @type {Number}
351 * @description
352 * [en]The current number of pixels the pull hook has moved.[/en]
353 * [ja]現在のプルフックが引き出された距離をピクセル数。[/ja]
354 */
355 get pullDistance() {
356 return this._currentTranslation;
357 }
358
359 /**
360 * @property disabled
361 * @type {Boolean}
362 * @description
363 * [en]Whether the element is disabled or not.[/en]
364 * [ja]無効化されている場合に`true`。[/ja]
365 */
366
367 _show() {
368 // Run asyncrhonously to avoid conflicts with Animit's style clean
369 setImmediate(() => {
370 this.style.display = '';
371 if (this._pageElement) {
372 this._pageElement.style.marginTop = `-${this.height}px`;
373 }
374 });
375 }
376
377 _hide() {
378 this.style.display = 'none';
379 if (this._pageElement) {
380 this._pageElement.style.marginTop = '';
381 }
382 }
383
384 /**
385 * @param {Number} scroll
386 * @param {Object} options
387 * @param {Function} [options.callback]
388 */
389 _translateTo(scroll, options = {}) {
390 if (this._currentTranslation == 0 && scroll == 0) {
391 return;
392 }
393
394 this._currentTranslation = scroll;
395 const opt = options.animate ? { duration: .3, timing: 'cubic-bezier(.1, .7, .1, 1)' } : {};
396 util.triggerElementEvent(this, 'pull', { ratio: (scroll / this.height).toFixed(2), animationOptions: opt });
397 const scrollElement = this.hasAttribute('fixed-content') ? this : this._pageElement;
398
399 animit(scrollElement)
400 .queue({ transform: `translate3d(0px, ${scroll}px, 0px)` }, opt)
401 .play(() => {
402 scroll === 0 && styler.clear(scrollElement, 'transition transform');
403 options.callback instanceof Function && options.callback();
404 });
405 }
406
407 _disableDragLock() { // e2e tests need it
408 this._dragLockDisabled = true;
409 this._setupListeners(true);
410 }
411
412 _setupListeners(add) {
413 const scrollToggle = action => this._pageElement[`${action}EventListener`]('scroll', this._onScroll, false);
414 const gdToggle = action => {
415 const passive = { passive: true };
416 this._gestureDetector[action]('drag', this._onDrag, passive);
417 this._gestureDetector[action]('dragstart', this._onDragStart, passive);
418 this._gestureDetector[action]('dragend', this._onDragEnd, passive);
419 };
420
421 if (this._gestureDetector) {
422 gdToggle('off');
423 this._gestureDetector.dispose();
424 this._gestureDetector = null;
425 }
426 scrollToggle('remove');
427
428 if (add) {
429 this._gestureDetector = new GestureDetector(this._pageElement, {
430 dragMinDistance: 1,
431 dragDistanceCorrection: false,
432 dragLockToAxis: !this._dragLockDisabled,
433 passive: true
434 });
435
436 gdToggle('on');
437 scrollToggle('add');
438 }
439 }
440
441 connectedCallback() {
442 this._currentTranslation = 0;
443 this._pageElement = this.parentNode;
444
445 this._setupListeners(true);
446 this._setStyle();
447
448 this._connectOnPull();
449 }
450
451 disconnectedCallback() {
452 this._hide();
453 this._setupListeners(false);
454
455 this._disconnectOnPull();
456 }
457
458 static get observedAttributes() {
459 return ['height'];
460 }
461
462 attributeChangedCallback(name, last, current) {
463 if (name === 'height' && this._pageElement) {
464 this._setStyle();
465 }
466 }
467
468 static get events() {
469 return ['changestate', 'pull'];
470 }
471}
472
473util.defineBooleanProperties(PullHookElement, ['disabled', 'fixed-content']);
474
475onsElements.PullHook = PullHookElement;
476customElements.define('ons-pull-hook', PullHookElement);