UNPKG

23 kBJavaScriptView Raw
1'use strict';
2
3import $ from 'jquery';
4import { Keyboard } from './foundation.util.keyboard';
5import { Move } from './foundation.util.motion';
6import { GetYoDigits, rtl as Rtl } from './foundation.util.core';
7
8import { Plugin } from './foundation.plugin';
9
10import { Touch } from './foundation.util.touch';
11
12import { Triggers } from './foundation.util.triggers';
13/**
14 * Slider module.
15 * @module foundation.slider
16 * @requires foundation.util.motion
17 * @requires foundation.util.triggers
18 * @requires foundation.util.keyboard
19 * @requires foundation.util.touch
20 */
21
22class Slider extends Plugin {
23 /**
24 * Creates a new instance of a slider control.
25 * @class
26 * @name Slider
27 * @param {jQuery} element - jQuery object to make into a slider control.
28 * @param {Object} options - Overrides to the default plugin settings.
29 */
30 _setup(element, options) {
31 this.$element = element;
32 this.options = $.extend({}, Slider.defaults, this.$element.data(), options);
33 this.className = 'Slider'; // ie9 back compat
34
35 // Touch and Triggers inits are idempotent, we just need to make sure it's initialied.
36 Touch.init($);
37 Triggers.init($);
38
39 this._init();
40
41 Keyboard.register('Slider', {
42 'ltr': {
43 'ARROW_RIGHT': 'increase',
44 'ARROW_UP': 'increase',
45 'ARROW_DOWN': 'decrease',
46 'ARROW_LEFT': 'decrease',
47 'SHIFT_ARROW_RIGHT': 'increase_fast',
48 'SHIFT_ARROW_UP': 'increase_fast',
49 'SHIFT_ARROW_DOWN': 'decrease_fast',
50 'SHIFT_ARROW_LEFT': 'decrease_fast',
51 'HOME': 'min',
52 'END': 'max'
53 },
54 'rtl': {
55 'ARROW_LEFT': 'increase',
56 'ARROW_RIGHT': 'decrease',
57 'SHIFT_ARROW_LEFT': 'increase_fast',
58 'SHIFT_ARROW_RIGHT': 'decrease_fast'
59 }
60 });
61 }
62
63 /**
64 * Initilizes the plugin by reading/setting attributes, creating collections and setting the initial position of the handle(s).
65 * @function
66 * @private
67 */
68 _init() {
69 this.inputs = this.$element.find('input');
70 this.handles = this.$element.find('[data-slider-handle]');
71
72 this.$handle = this.handles.eq(0);
73 this.$input = this.inputs.length ? this.inputs.eq(0) : $(`#${this.$handle.attr('aria-controls')}`);
74 this.$fill = this.$element.find('[data-slider-fill]').css(this.options.vertical ? 'height' : 'width', 0);
75
76 var isDbl = false,
77 _this = this;
78 if (this.options.disabled || this.$element.hasClass(this.options.disabledClass)) {
79 this.options.disabled = true;
80 this.$element.addClass(this.options.disabledClass);
81 }
82 if (!this.inputs.length) {
83 this.inputs = $().add(this.$input);
84 this.options.binding = true;
85 }
86
87 this._setInitAttr(0);
88
89 if (this.handles[1]) {
90 this.options.doubleSided = true;
91 this.$handle2 = this.handles.eq(1);
92 this.$input2 = this.inputs.length > 1 ? this.inputs.eq(1) : $(`#${this.$handle2.attr('aria-controls')}`);
93
94 if (!this.inputs[1]) {
95 this.inputs = this.inputs.add(this.$input2);
96 }
97 isDbl = true;
98
99 // this.$handle.triggerHandler('click.zf.slider');
100 this._setInitAttr(1);
101 }
102
103 // Set handle positions
104 this.setHandles();
105
106 this._events();
107 }
108
109 setHandles() {
110 if(this.handles[1]) {
111 this._setHandlePos(this.$handle, this.inputs.eq(0).val(), true, () => {
112 this._setHandlePos(this.$handle2, this.inputs.eq(1).val(), true);
113 });
114 } else {
115 this._setHandlePos(this.$handle, this.inputs.eq(0).val(), true);
116 }
117 }
118
119 _reflow() {
120 this.setHandles();
121 }
122 /**
123 * @function
124 * @private
125 * @param {Number} value - floating point (the value) to be transformed using to a relative position on the slider (the inverse of _value)
126 */
127 _pctOfBar(value) {
128 var pctOfBar = percent(value - this.options.start, this.options.end - this.options.start)
129
130 switch(this.options.positionValueFunction) {
131 case "pow":
132 pctOfBar = this._logTransform(pctOfBar);
133 break;
134 case "log":
135 pctOfBar = this._powTransform(pctOfBar);
136 break;
137 }
138
139 return pctOfBar.toFixed(2)
140 }
141
142 /**
143 * @function
144 * @private
145 * @param {Number} pctOfBar - floating point, the relative position of the slider (typically between 0-1) to be transformed to a value
146 */
147 _value(pctOfBar) {
148 switch(this.options.positionValueFunction) {
149 case "pow":
150 pctOfBar = this._powTransform(pctOfBar);
151 break;
152 case "log":
153 pctOfBar = this._logTransform(pctOfBar);
154 break;
155 }
156 var value = (this.options.end - this.options.start) * pctOfBar + this.options.start;
157
158 return value
159 }
160
161 /**
162 * @function
163 * @private
164 * @param {Number} value - floating point (typically between 0-1) to be transformed using the log function
165 */
166 _logTransform(value) {
167 return baseLog(this.options.nonLinearBase, ((value*(this.options.nonLinearBase-1))+1))
168 }
169
170 /**
171 * @function
172 * @private
173 * @param {Number} value - floating point (typically between 0-1) to be transformed using the power function
174 */
175 _powTransform(value) {
176 return (Math.pow(this.options.nonLinearBase, value) - 1) / (this.options.nonLinearBase - 1)
177 }
178
179 /**
180 * Sets the position of the selected handle and fill bar.
181 * @function
182 * @private
183 * @param {jQuery} $hndl - the selected handle to move.
184 * @param {Number} location - floating point between the start and end values of the slider bar.
185 * @param {Function} cb - callback function to fire on completion.
186 * @fires Slider#moved
187 * @fires Slider#changed
188 */
189 _setHandlePos($hndl, location, noInvert, cb) {
190 // don't move if the slider has been disabled since its initialization
191 if (this.$element.hasClass(this.options.disabledClass)) {
192 return;
193 }
194 //might need to alter that slightly for bars that will have odd number selections.
195 location = parseFloat(location);//on input change events, convert string to number...grumble.
196
197 // prevent slider from running out of bounds, if value exceeds the limits set through options, override the value to min/max
198 if (location < this.options.start) { location = this.options.start; }
199 else if (location > this.options.end) { location = this.options.end; }
200
201 var isDbl = this.options.doubleSided;
202
203 if (isDbl) { //this block is to prevent 2 handles from crossing eachother. Could/should be improved.
204 if (this.handles.index($hndl) === 0) {
205 var h2Val = parseFloat(this.$handle2.attr('aria-valuenow'));
206 location = location >= h2Val ? h2Val - this.options.step : location;
207 } else {
208 var h1Val = parseFloat(this.$handle.attr('aria-valuenow'));
209 location = location <= h1Val ? h1Val + this.options.step : location;
210 }
211 }
212
213 //this is for single-handled vertical sliders, it adjusts the value to account for the slider being "upside-down"
214 //for click and drag events, it's weird due to the scale(-1, 1) css property
215 if (this.options.vertical && !noInvert) {
216 location = this.options.end - location;
217 }
218
219 var _this = this,
220 vert = this.options.vertical,
221 hOrW = vert ? 'height' : 'width',
222 lOrT = vert ? 'top' : 'left',
223 handleDim = $hndl[0].getBoundingClientRect()[hOrW],
224 elemDim = this.$element[0].getBoundingClientRect()[hOrW],
225 //percentage of bar min/max value based on click or drag point
226 pctOfBar = this._pctOfBar(location),
227 //number of actual pixels to shift the handle, based on the percentage obtained above
228 pxToMove = (elemDim - handleDim) * pctOfBar,
229 //percentage of bar to shift the handle
230 movement = (percent(pxToMove, elemDim) * 100).toFixed(this.options.decimal);
231 //fixing the decimal value for the location number, is passed to other methods as a fixed floating-point value
232 location = parseFloat(location.toFixed(this.options.decimal));
233 // declare empty object for css adjustments, only used with 2 handled-sliders
234 var css = {};
235
236 this._setValues($hndl, location);
237
238 // TODO update to calculate based on values set to respective inputs??
239 if (isDbl) {
240 var isLeftHndl = this.handles.index($hndl) === 0,
241 //empty variable, will be used for min-height/width for fill bar
242 dim,
243 //percentage w/h of the handle compared to the slider bar
244 handlePct = ~~(percent(handleDim, elemDim) * 100);
245 //if left handle, the math is slightly different than if it's the right handle, and the left/top property needs to be changed for the fill bar
246 if (isLeftHndl) {
247 //left or top percentage value to apply to the fill bar.
248 css[lOrT] = `${movement}%`;
249 //calculate the new min-height/width for the fill bar.
250 dim = parseFloat(this.$handle2[0].style[lOrT]) - movement + handlePct;
251 //this callback is necessary to prevent errors and allow the proper placement and initialization of a 2-handled slider
252 //plus, it means we don't care if 'dim' isNaN on init, it won't be in the future.
253 if (cb && typeof cb === 'function') { cb(); }//this is only needed for the initialization of 2 handled sliders
254 } else {
255 //just caching the value of the left/bottom handle's left/top property
256 var handlePos = parseFloat(this.$handle[0].style[lOrT]);
257 //calculate the new min-height/width for the fill bar. Use isNaN to prevent false positives for numbers <= 0
258 //based on the percentage of movement of the handle being manipulated, less the opposing handle's left/top position, plus the percentage w/h of the handle itself
259 dim = movement - (isNaN(handlePos) ? (this.options.initialStart - this.options.start)/((this.options.end-this.options.start)/100) : handlePos) + handlePct;
260 }
261 // assign the min-height/width to our css object
262 css[`min-${hOrW}`] = `${dim}%`;
263 }
264
265 this.$element.one('finished.zf.animate', function() {
266 /**
267 * Fires when the handle is done moving.
268 * @event Slider#moved
269 */
270 _this.$element.trigger('moved.zf.slider', [$hndl]);
271 });
272
273 //because we don't know exactly how the handle will be moved, check the amount of time it should take to move.
274 var moveTime = this.$element.data('dragging') ? 1000/60 : this.options.moveTime;
275
276 Move(moveTime, $hndl, function() {
277 // adjusting the left/top property of the handle, based on the percentage calculated above
278 // if movement isNaN, that is because the slider is hidden and we cannot determine handle width,
279 // fall back to next best guess.
280 if (isNaN(movement)) {
281 $hndl.css(lOrT, `${pctOfBar * 100}%`);
282 }
283 else {
284 $hndl.css(lOrT, `${movement}%`);
285 }
286
287 if (!_this.options.doubleSided) {
288 //if single-handled, a simple method to expand the fill bar
289 _this.$fill.css(hOrW, `${pctOfBar * 100}%`);
290 } else {
291 //otherwise, use the css object we created above
292 _this.$fill.css(css);
293 }
294 });
295
296
297 /**
298 * Fires when the value has not been change for a given time.
299 * @event Slider#changed
300 */
301 clearTimeout(_this.timeout);
302 _this.timeout = setTimeout(function(){
303 _this.$element.trigger('changed.zf.slider', [$hndl]);
304 }, _this.options.changedDelay);
305 }
306
307 /**
308 * Sets the initial attribute for the slider element.
309 * @function
310 * @private
311 * @param {Number} idx - index of the current handle/input to use.
312 */
313 _setInitAttr(idx) {
314 var initVal = (idx === 0 ? this.options.initialStart : this.options.initialEnd)
315 var id = this.inputs.eq(idx).attr('id') || GetYoDigits(6, 'slider');
316 this.inputs.eq(idx).attr({
317 'id': id,
318 'max': this.options.end,
319 'min': this.options.start,
320 'step': this.options.step
321 });
322 this.inputs.eq(idx).val(initVal);
323 this.handles.eq(idx).attr({
324 'role': 'slider',
325 'aria-controls': id,
326 'aria-valuemax': this.options.end,
327 'aria-valuemin': this.options.start,
328 'aria-valuenow': initVal,
329 'aria-orientation': this.options.vertical ? 'vertical' : 'horizontal',
330 'tabindex': 0
331 });
332 }
333
334 /**
335 * Sets the input and `aria-valuenow` values for the slider element.
336 * @function
337 * @private
338 * @param {jQuery} $handle - the currently selected handle.
339 * @param {Number} val - floating point of the new value.
340 */
341 _setValues($handle, val) {
342 var idx = this.options.doubleSided ? this.handles.index($handle) : 0;
343 this.inputs.eq(idx).val(val);
344 $handle.attr('aria-valuenow', val);
345 }
346
347 /**
348 * Handles events on the slider element.
349 * Calculates the new location of the current handle.
350 * If there are two handles and the bar was clicked, it determines which handle to move.
351 * @function
352 * @private
353 * @param {Object} e - the `event` object passed from the listener.
354 * @param {jQuery} $handle - the current handle to calculate for, if selected.
355 * @param {Number} val - floating point number for the new value of the slider.
356 * TODO clean this up, there's a lot of repeated code between this and the _setHandlePos fn.
357 */
358 _handleEvent(e, $handle, val) {
359 var value, hasVal;
360 if (!val) {//click or drag events
361 e.preventDefault();
362 var _this = this,
363 vertical = this.options.vertical,
364 param = vertical ? 'height' : 'width',
365 direction = vertical ? 'top' : 'left',
366 eventOffset = vertical ? e.pageY : e.pageX,
367 halfOfHandle = this.$handle[0].getBoundingClientRect()[param] / 2,
368 barDim = this.$element[0].getBoundingClientRect()[param],
369 windowScroll = vertical ? $(window).scrollTop() : $(window).scrollLeft();
370
371
372 var elemOffset = this.$element.offset()[direction];
373
374 // touch events emulated by the touch util give position relative to screen, add window.scroll to event coordinates...
375 // best way to guess this is simulated is if clientY == pageY
376 if (e.clientY === e.pageY) { eventOffset = eventOffset + windowScroll; }
377 var eventFromBar = eventOffset - elemOffset;
378 var barXY;
379 if (eventFromBar < 0) {
380 barXY = 0;
381 } else if (eventFromBar > barDim) {
382 barXY = barDim;
383 } else {
384 barXY = eventFromBar;
385 }
386 var offsetPct = percent(barXY, barDim);
387
388 value = this._value(offsetPct);
389
390 // turn everything around for RTL, yay math!
391 if (Rtl() && !this.options.vertical) {value = this.options.end - value;}
392
393 value = _this._adjustValue(null, value);
394 //boolean flag for the setHandlePos fn, specifically for vertical sliders
395 hasVal = false;
396
397 if (!$handle) {//figure out which handle it is, pass it to the next function.
398 var firstHndlPos = absPosition(this.$handle, direction, barXY, param),
399 secndHndlPos = absPosition(this.$handle2, direction, barXY, param);
400 $handle = firstHndlPos <= secndHndlPos ? this.$handle : this.$handle2;
401 }
402
403 } else {//change event on input
404 value = this._adjustValue(null, val);
405 hasVal = true;
406 }
407
408 this._setHandlePos($handle, value, hasVal);
409 }
410
411 /**
412 * Adjustes value for handle in regard to step value. returns adjusted value
413 * @function
414 * @private
415 * @param {jQuery} $handle - the selected handle.
416 * @param {Number} value - value to adjust. used if $handle is falsy
417 */
418 _adjustValue($handle, value) {
419 var val,
420 step = this.options.step,
421 div = parseFloat(step/2),
422 left, prev_val, next_val;
423 if (!!$handle) {
424 val = parseFloat($handle.attr('aria-valuenow'));
425 }
426 else {
427 val = value;
428 }
429 left = val % step;
430 prev_val = val - left;
431 next_val = prev_val + step;
432 if (left === 0) {
433 return val;
434 }
435 val = val >= prev_val + div ? next_val : prev_val;
436 return val;
437 }
438
439 /**
440 * Adds event listeners to the slider elements.
441 * @function
442 * @private
443 */
444 _events() {
445 this._eventsForHandle(this.$handle);
446 if(this.handles[1]) {
447 this._eventsForHandle(this.$handle2);
448 }
449 }
450
451
452 /**
453 * Adds event listeners a particular handle
454 * @function
455 * @private
456 * @param {jQuery} $handle - the current handle to apply listeners to.
457 */
458 _eventsForHandle($handle) {
459 var _this = this,
460 curHandle,
461 timer;
462
463 this.inputs.off('change.zf.slider').on('change.zf.slider', function(e) {
464 var idx = _this.inputs.index($(this));
465 _this._handleEvent(e, _this.handles.eq(idx), $(this).val());
466 });
467
468 if (this.options.clickSelect) {
469 this.$element.off('click.zf.slider').on('click.zf.slider', function(e) {
470 if (_this.$element.data('dragging')) { return false; }
471
472 if (!$(e.target).is('[data-slider-handle]')) {
473 if (_this.options.doubleSided) {
474 _this._handleEvent(e);
475 } else {
476 _this._handleEvent(e, _this.$handle);
477 }
478 }
479 });
480 }
481
482 if (this.options.draggable) {
483 this.handles.addTouch();
484
485 var $body = $('body');
486 $handle
487 .off('mousedown.zf.slider')
488 .on('mousedown.zf.slider', function(e) {
489 $handle.addClass('is-dragging');
490 _this.$fill.addClass('is-dragging');//
491 _this.$element.data('dragging', true);
492
493 curHandle = $(e.currentTarget);
494
495 $body.on('mousemove.zf.slider', function(e) {
496 e.preventDefault();
497 _this._handleEvent(e, curHandle);
498
499 }).on('mouseup.zf.slider', function(e) {
500 _this._handleEvent(e, curHandle);
501
502 $handle.removeClass('is-dragging');
503 _this.$fill.removeClass('is-dragging');
504 _this.$element.data('dragging', false);
505
506 $body.off('mousemove.zf.slider mouseup.zf.slider');
507 });
508 })
509 // prevent events triggered by touch
510 .on('selectstart.zf.slider touchmove.zf.slider', function(e) {
511 e.preventDefault();
512 });
513 }
514
515 $handle.off('keydown.zf.slider').on('keydown.zf.slider', function(e) {
516 var _$handle = $(this),
517 idx = _this.options.doubleSided ? _this.handles.index(_$handle) : 0,
518 oldValue = parseFloat(_this.inputs.eq(idx).val()),
519 newValue;
520
521 // handle keyboard event with keyboard util
522 Keyboard.handleKey(e, 'Slider', {
523 decrease: function() {
524 newValue = oldValue - _this.options.step;
525 },
526 increase: function() {
527 newValue = oldValue + _this.options.step;
528 },
529 decrease_fast: function() {
530 newValue = oldValue - _this.options.step * 10;
531 },
532 increase_fast: function() {
533 newValue = oldValue + _this.options.step * 10;
534 },
535 min: function() {
536 newValue = _this.options.start;
537 },
538 max: function() {
539 newValue = _this.options.end;
540 },
541 handled: function() { // only set handle pos when event was handled specially
542 e.preventDefault();
543 _this._setHandlePos(_$handle, newValue, true);
544 }
545 });
546 /*if (newValue) { // if pressed key has special function, update value
547 e.preventDefault();
548 _this._setHandlePos(_$handle, newValue);
549 }*/
550 });
551 }
552
553 /**
554 * Destroys the slider plugin.
555 */
556 _destroy() {
557 this.handles.off('.zf.slider');
558 this.inputs.off('.zf.slider');
559 this.$element.off('.zf.slider');
560
561 clearTimeout(this.timeout);
562 }
563}
564
565Slider.defaults = {
566 /**
567 * Minimum value for the slider scale.
568 * @option
569 * @type {number}
570 * @default 0
571 */
572 start: 0,
573 /**
574 * Maximum value for the slider scale.
575 * @option
576 * @type {number}
577 * @default 100
578 */
579 end: 100,
580 /**
581 * Minimum value change per change event.
582 * @option
583 * @type {number}
584 * @default 1
585 */
586 step: 1,
587 /**
588 * Value at which the handle/input *(left handle/first input)* should be set to on initialization.
589 * @option
590 * @type {number}
591 * @default 0
592 */
593 initialStart: 0,
594 /**
595 * Value at which the right handle/second input should be set to on initialization.
596 * @option
597 * @type {number}
598 * @default 100
599 */
600 initialEnd: 100,
601 /**
602 * Allows the input to be located outside the container and visible. Set to by the JS
603 * @option
604 * @type {boolean}
605 * @default false
606 */
607 binding: false,
608 /**
609 * Allows the user to click/tap on the slider bar to select a value.
610 * @option
611 * @type {boolean}
612 * @default true
613 */
614 clickSelect: true,
615 /**
616 * Set to true and use the `vertical` class to change alignment to vertical.
617 * @option
618 * @type {boolean}
619 * @default false
620 */
621 vertical: false,
622 /**
623 * Allows the user to drag the slider handle(s) to select a value.
624 * @option
625 * @type {boolean}
626 * @default true
627 */
628 draggable: true,
629 /**
630 * Disables the slider and prevents event listeners from being applied. Double checked by JS with `disabledClass`.
631 * @option
632 * @type {boolean}
633 * @default false
634 */
635 disabled: false,
636 /**
637 * Allows the use of two handles. Double checked by the JS. Changes some logic handling.
638 * @option
639 * @type {boolean}
640 * @default false
641 */
642 doubleSided: false,
643 /**
644 * Potential future feature.
645 */
646 // steps: 100,
647 /**
648 * Number of decimal places the plugin should go to for floating point precision.
649 * @option
650 * @type {number}
651 * @default 2
652 */
653 decimal: 2,
654 /**
655 * Time delay for dragged elements.
656 */
657 // dragDelay: 0,
658 /**
659 * Time, in ms, to animate the movement of a slider handle if user clicks/taps on the bar. Needs to be manually set if updating the transition time in the Sass settings.
660 * @option
661 * @type {number}
662 * @default 200
663 */
664 moveTime: 200,//update this if changing the transition time in the sass
665 /**
666 * Class applied to disabled sliders.
667 * @option
668 * @type {string}
669 * @default 'disabled'
670 */
671 disabledClass: 'disabled',
672 /**
673 * Will invert the default layout for a vertical<span data-tooltip title="who would do this???"> </span>slider.
674 * @option
675 * @type {boolean}
676 * @default false
677 */
678 invertVertical: false,
679 /**
680 * Milliseconds before the `changed.zf-slider` event is triggered after value change.
681 * @option
682 * @type {number}
683 * @default 500
684 */
685 changedDelay: 500,
686 /**
687 * Basevalue for non-linear sliders
688 * @option
689 * @type {number}
690 * @default 5
691 */
692 nonLinearBase: 5,
693 /**
694 * Basevalue for non-linear sliders, possible values are: `'linear'`, `'pow'` & `'log'`. Pow and Log use the nonLinearBase setting.
695 * @option
696 * @type {string}
697 * @default 'linear'
698 */
699 positionValueFunction: 'linear',
700};
701
702function percent(frac, num) {
703 return (frac / num);
704}
705function absPosition($handle, dir, clickPos, param) {
706 return Math.abs(($handle.position()[dir] + ($handle[param]() / 2)) - clickPos);
707}
708function baseLog(base, value) {
709 return Math.log(value)/Math.log(base)
710}
711
712export {Slider};