forked from mirrors/gecko-dev
		
	 0773795931
			
		
	
	
		0773795931
		
	
	
	
	
		
			
			# ignore-this-changeset Differential Revision: https://phabricator.services.mozilla.com/D36053 --HG-- extra : source : 651d8f947a29f5d80b7e833f7e6b99e2afe8bf9d
		
			
				
	
	
		
			538 lines
		
	
	
	
		
			17 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			538 lines
		
	
	
	
		
			17 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /* This Source Code Form is subject to the terms of the Mozilla Public
 | |
|  * License, v. 2.0. If a copy of the MPL was not distributed with this
 | |
|  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 | |
| 
 | |
| "use strict";
 | |
| 
 | |
| /*
 | |
|  * The spinner is responsible for displaying the items, and does
 | |
|  * not care what the values represent. The setValue function is called
 | |
|  * when it detects a change in value triggered by scroll event.
 | |
|  * Supports scrolling, clicking on up or down, clicking on item, and
 | |
|  * dragging.
 | |
|  */
 | |
| 
 | |
| function Spinner(props, context) {
 | |
|   this.context = context;
 | |
|   this._init(props);
 | |
| }
 | |
| 
 | |
| {
 | |
|   const ITEM_HEIGHT = 2.5,
 | |
|     VIEWPORT_SIZE = 7,
 | |
|     VIEWPORT_COUNT = 5;
 | |
| 
 | |
|   Spinner.prototype = {
 | |
|     /**
 | |
|      * Initializes a spinner. Set the default states and properties, cache
 | |
|      * element references, create the HTML markup, and add event listeners.
 | |
|      *
 | |
|      * @param  {Object} props [Properties passed in from parent]
 | |
|      *         {
 | |
|      *           {Function} setValue: Takes a value and set the state to
 | |
|      *             the parent component.
 | |
|      *           {Function} getDisplayString: Takes a value, and output it
 | |
|      *             as localized strings.
 | |
|      *           {Number} viewportSize [optional]: Number of items in a
 | |
|      *             viewport.
 | |
|      *           {Boolean} hideButtons [optional]: Hide up & down buttons
 | |
|      *           {Number} rootFontSize [optional]: Used to support zoom in/out
 | |
|      *         }
 | |
|      */
 | |
|     _init(props) {
 | |
|       const {
 | |
|         id,
 | |
|         setValue,
 | |
|         getDisplayString,
 | |
|         hideButtons,
 | |
|         rootFontSize = 10,
 | |
|       } = props;
 | |
| 
 | |
|       const spinnerTemplate = document.getElementById("spinner-template");
 | |
|       const spinnerElement = document.importNode(spinnerTemplate.content, true);
 | |
| 
 | |
|       // Make sure viewportSize is an odd number because we want to have the selected
 | |
|       // item in the center. If it's an even number, use the default size instead.
 | |
|       const viewportSize =
 | |
|         props.viewportSize % 2 ? props.viewportSize : VIEWPORT_SIZE;
 | |
| 
 | |
|       this.state = {
 | |
|         items: [],
 | |
|         isScrolling: false,
 | |
|       };
 | |
|       this.props = {
 | |
|         setValue,
 | |
|         getDisplayString,
 | |
|         viewportSize,
 | |
|         rootFontSize,
 | |
|         // We can assume that the viewportSize is an odd number. Calculate how many
 | |
|         // items we need to insert on top of the spinner so that the selected is at
 | |
|         // the center. Ex: if viewportSize is 5, we need 2 items on top.
 | |
|         viewportTopOffset: (viewportSize - 1) / 2,
 | |
|       };
 | |
|       this.elements = {
 | |
|         container: spinnerElement.querySelector(".spinner-container"),
 | |
|         spinner: spinnerElement.querySelector(".spinner"),
 | |
|         up: spinnerElement.querySelector(".up"),
 | |
|         down: spinnerElement.querySelector(".down"),
 | |
|         itemsViewElements: [],
 | |
|       };
 | |
| 
 | |
|       this.elements.spinner.style.height = ITEM_HEIGHT * viewportSize + "rem";
 | |
| 
 | |
|       if (id) {
 | |
|         this.elements.container.id = id;
 | |
|       }
 | |
|       if (hideButtons) {
 | |
|         this.elements.container.classList.add("hide-buttons");
 | |
|       }
 | |
| 
 | |
|       this.context.appendChild(spinnerElement);
 | |
|       this._attachEventListeners();
 | |
|     },
 | |
| 
 | |
|     /**
 | |
|      * Only the parent component calls setState on the spinner.
 | |
|      * It checks if the items have changed and updates the spinner.
 | |
|      * If only the value has changed, smooth scrolls to the new value.
 | |
|      *
 | |
|      * @param {Object} newState [The new spinner state]
 | |
|      *        {
 | |
|      *          {Number/String} value: The centered value
 | |
|      *          {Array} items: The list of items for display
 | |
|      *          {Boolean} isInfiniteScroll: Whether or not the spinner should
 | |
|      *            have infinite scroll capability
 | |
|      *          {Boolean} isValueSet: true if user has selected a value
 | |
|      *        }
 | |
|      */
 | |
|     setState(newState) {
 | |
|       const { value, items } = this.state;
 | |
|       const {
 | |
|         value: newValue,
 | |
|         items: newItems,
 | |
|         isValueSet,
 | |
|         isInvalid,
 | |
|         smoothScroll = true,
 | |
|       } = newState;
 | |
| 
 | |
|       if (this._isArrayDiff(newItems, items)) {
 | |
|         this.state = Object.assign(this.state, newState);
 | |
|         this._updateItems();
 | |
|         this._scrollTo(newValue, true);
 | |
|       } else if (newValue != value) {
 | |
|         this.state = Object.assign(this.state, newState);
 | |
|         if (smoothScroll) {
 | |
|           this._smoothScrollTo(newValue, true);
 | |
|         } else {
 | |
|           this._scrollTo(newValue, true);
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       if (isValueSet && !isInvalid) {
 | |
|         this._updateSelection();
 | |
|       } else {
 | |
|         this._removeSelection();
 | |
|       }
 | |
|     },
 | |
| 
 | |
|     /**
 | |
|      * Whenever scroll event is detected:
 | |
|      * - Update the index state
 | |
|      * - If the value has changed, update the [value] state and call [setValue]
 | |
|      * - If infinite scrolling is on, reset the scrolling position if necessary
 | |
|      */
 | |
|     _onScroll() {
 | |
|       const { items, itemsView, isInfiniteScroll } = this.state;
 | |
|       const { viewportSize, viewportTopOffset } = this.props;
 | |
|       const { spinner } = this.elements;
 | |
| 
 | |
|       this.state.index = this._getIndexByOffset(spinner.scrollTop);
 | |
| 
 | |
|       const value = itemsView[this.state.index + viewportTopOffset].value;
 | |
| 
 | |
|       // Call setValue if value has changed
 | |
|       if (this.state.value != value) {
 | |
|         this.state.value = value;
 | |
|         this.props.setValue(value);
 | |
|       }
 | |
| 
 | |
|       // Do infinite scroll when items length is bigger or equal to viewport
 | |
|       // and isInfiniteScroll is not false.
 | |
|       if (items.length >= viewportSize && isInfiniteScroll) {
 | |
|         // If the scroll position is near the top or bottom, jump back to the middle
 | |
|         // so user can keep scrolling up or down.
 | |
|         if (
 | |
|           this.state.index < viewportSize ||
 | |
|           this.state.index > itemsView.length - viewportSize
 | |
|         ) {
 | |
|           this._scrollTo(this.state.value, true);
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       this.elements.spinner.classList.add("scrolling");
 | |
|     },
 | |
| 
 | |
|     /**
 | |
|      * Remove the "scrolling" state on scrollend.
 | |
|      */
 | |
|     _onScrollend() {
 | |
|       this.elements.spinner.classList.remove("scrolling");
 | |
|     },
 | |
| 
 | |
|     /**
 | |
|      * Updates the spinner items to the current states.
 | |
|      */
 | |
|     _updateItems() {
 | |
|       const { viewportSize, viewportTopOffset } = this.props;
 | |
|       const { items, isInfiniteScroll } = this.state;
 | |
| 
 | |
|       // Prepends null elements so the selected value is centered in spinner
 | |
|       let itemsView = new Array(viewportTopOffset).fill({}).concat(items);
 | |
| 
 | |
|       if (items.length >= viewportSize && isInfiniteScroll) {
 | |
|         // To achieve infinite scroll, we move the scroll position back to the
 | |
|         // center when it is near the top or bottom. The scroll momentum could
 | |
|         // be lost in the process, so to minimize that, we need at least 2 sets
 | |
|         // of items to act as buffer: one for the top and one for the bottom.
 | |
|         // But if the number of items is small ( < viewportSize * viewport count)
 | |
|         // we should add more sets.
 | |
|         let count =
 | |
|           Math.ceil((viewportSize * VIEWPORT_COUNT) / items.length) * 2;
 | |
|         for (let i = 0; i < count; i += 1) {
 | |
|           itemsView.push(...items);
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       // Reuse existing DOM nodes when possible. Create or remove
 | |
|       // nodes based on how big itemsView is.
 | |
|       this._prepareNodes(itemsView.length, this.elements.spinner);
 | |
|       // Once DOM nodes are ready, set display strings using textContent
 | |
|       this._setDisplayStringAndClass(
 | |
|         itemsView,
 | |
|         this.elements.itemsViewElements
 | |
|       );
 | |
| 
 | |
|       this.state.itemsView = itemsView;
 | |
|     },
 | |
| 
 | |
|     /**
 | |
|      * Make sure the number or child elements is the same as length
 | |
|      * and keep the elements' references for updating textContent
 | |
|      *
 | |
|      * @param {Number} length [The number of child elements]
 | |
|      * @param {DOMElement} parent [The parent element reference]
 | |
|      */
 | |
|     _prepareNodes(length, parent) {
 | |
|       const diff = length - parent.childElementCount;
 | |
| 
 | |
|       if (!diff) {
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       if (diff > 0) {
 | |
|         // Add more elements if length is greater than current
 | |
|         let frag = document.createDocumentFragment();
 | |
| 
 | |
|         // Remove margin bottom on the last element before appending
 | |
|         if (parent.lastChild) {
 | |
|           parent.lastChild.style.marginBottom = "";
 | |
|         }
 | |
| 
 | |
|         for (let i = 0; i < diff; i++) {
 | |
|           let el = document.createElement("div");
 | |
|           frag.appendChild(el);
 | |
|           this.elements.itemsViewElements.push(el);
 | |
|         }
 | |
|         parent.appendChild(frag);
 | |
|       } else if (diff < 0) {
 | |
|         // Remove elements if length is less than current
 | |
|         for (let i = 0; i < Math.abs(diff); i++) {
 | |
|           parent.removeChild(parent.lastChild);
 | |
|         }
 | |
|         this.elements.itemsViewElements.splice(diff);
 | |
|       }
 | |
| 
 | |
|       parent.lastChild.style.marginBottom =
 | |
|         ITEM_HEIGHT * this.props.viewportTopOffset + "rem";
 | |
|     },
 | |
| 
 | |
|     /**
 | |
|      * Set the display string and class name to the elements.
 | |
|      *
 | |
|      * @param {Array<Object>} items
 | |
|      *        [{
 | |
|      *          {Number/String} value: The value in its original form
 | |
|      *          {Boolean} enabled: Whether or not the item is enabled
 | |
|      *        }]
 | |
|      * @param {Array<DOMElement>} elements
 | |
|      */
 | |
|     _setDisplayStringAndClass(items, elements) {
 | |
|       const { getDisplayString } = this.props;
 | |
| 
 | |
|       items.forEach((item, index) => {
 | |
|         elements[index].textContent =
 | |
|           item.value != undefined ? getDisplayString(item.value) : "";
 | |
|         elements[index].className = item.enabled ? "" : "disabled";
 | |
|       });
 | |
|     },
 | |
| 
 | |
|     /**
 | |
|      * Attach event listeners to the spinner and buttons.
 | |
|      */
 | |
|     _attachEventListeners() {
 | |
|       const { spinner, container } = this.elements;
 | |
| 
 | |
|       spinner.addEventListener("scroll", this, { passive: true });
 | |
|       spinner.addEventListener("scrollend", this, { passive: true });
 | |
|       container.addEventListener("mouseup", this, { passive: true });
 | |
|       container.addEventListener("mousedown", this, { passive: true });
 | |
|     },
 | |
| 
 | |
|     /**
 | |
|      * Handle events
 | |
|      * @param  {DOMEvent} event
 | |
|      */
 | |
|     handleEvent(event) {
 | |
|       const { mouseState = {}, index, itemsView } = this.state;
 | |
|       const { viewportTopOffset, setValue } = this.props;
 | |
|       const { spinner, up, down } = this.elements;
 | |
| 
 | |
|       switch (event.type) {
 | |
|         case "scroll": {
 | |
|           this._onScroll();
 | |
|           break;
 | |
|         }
 | |
|         case "scrollend": {
 | |
|           this._onScrollend();
 | |
|           break;
 | |
|         }
 | |
|         case "mousedown": {
 | |
|           this.state.mouseState = {
 | |
|             down: true,
 | |
|             layerX: event.layerX,
 | |
|             layerY: event.layerY,
 | |
|           };
 | |
|           if (event.target == up) {
 | |
|             // An "active" class is needed to simulate :active pseudo-class
 | |
|             // because element is not focused.
 | |
|             event.target.classList.add("active");
 | |
|             this._smoothScrollToIndex(index - 1);
 | |
|           }
 | |
|           if (event.target == down) {
 | |
|             event.target.classList.add("active");
 | |
|             this._smoothScrollToIndex(index + 1);
 | |
|           }
 | |
|           if (event.target.parentNode == spinner) {
 | |
|             // Listen to dragging events
 | |
|             spinner.addEventListener("mousemove", this, { passive: true });
 | |
|             spinner.addEventListener("mouseleave", this, { passive: true });
 | |
|           }
 | |
|           break;
 | |
|         }
 | |
|         case "mouseup": {
 | |
|           this.state.mouseState.down = false;
 | |
|           if (event.target == up || event.target == down) {
 | |
|             event.target.classList.remove("active");
 | |
|           }
 | |
|           if (event.target.parentNode == spinner) {
 | |
|             // Check if user clicks or drags, scroll to the item if clicked,
 | |
|             // otherwise get the current index and smooth scroll there.
 | |
|             if (
 | |
|               event.layerX == mouseState.layerX &&
 | |
|               event.layerY == mouseState.layerY
 | |
|             ) {
 | |
|               const newIndex =
 | |
|                 this._getIndexByOffset(event.target.offsetTop) -
 | |
|                 viewportTopOffset;
 | |
|               if (index == newIndex) {
 | |
|                 // Set value manually if the clicked element is already centered.
 | |
|                 // This happens when the picker first opens, and user pick the
 | |
|                 // default value.
 | |
|                 setValue(itemsView[index + viewportTopOffset].value);
 | |
|               } else {
 | |
|                 this._smoothScrollToIndex(newIndex);
 | |
|               }
 | |
|             } else {
 | |
|               this._smoothScrollToIndex(
 | |
|                 this._getIndexByOffset(spinner.scrollTop)
 | |
|               );
 | |
|             }
 | |
|             // Stop listening to dragging
 | |
|             spinner.removeEventListener("mousemove", this, { passive: true });
 | |
|             spinner.removeEventListener("mouseleave", this, { passive: true });
 | |
|           }
 | |
|           break;
 | |
|         }
 | |
|         case "mouseleave": {
 | |
|           if (event.target == spinner) {
 | |
|             // Stop listening to drag event if mouse is out of the spinner
 | |
|             this._smoothScrollToIndex(
 | |
|               this._getIndexByOffset(spinner.scrollTop)
 | |
|             );
 | |
|             spinner.removeEventListener("mousemove", this, { passive: true });
 | |
|             spinner.removeEventListener("mouseleave", this, { passive: true });
 | |
|           }
 | |
|           break;
 | |
|         }
 | |
|         case "mousemove": {
 | |
|           // Change spinner position on drag
 | |
|           spinner.scrollTop -= event.movementY;
 | |
|           break;
 | |
|         }
 | |
|       }
 | |
|     },
 | |
| 
 | |
|     /**
 | |
|      * Find the index by offset
 | |
|      * @param {Number} offset: Offset value in pixel.
 | |
|      * @return {Number}  Index number
 | |
|      */
 | |
|     _getIndexByOffset(offset) {
 | |
|       return Math.round(offset / (ITEM_HEIGHT * this.props.rootFontSize));
 | |
|     },
 | |
| 
 | |
|     /**
 | |
|      * Find the index of a value that is the closest to the current position.
 | |
|      * If centering is true, find the index closest to the center.
 | |
|      *
 | |
|      * @param {Number/String} value: The value to find
 | |
|      * @param {Boolean} centering: Whether or not to find the value closest to center
 | |
|      * @return {Number} index of the value, returns -1 if value is not found
 | |
|      */
 | |
|     _getScrollIndex(value, centering) {
 | |
|       const { itemsView } = this.state;
 | |
|       const { viewportTopOffset } = this.props;
 | |
| 
 | |
|       // If index doesn't exist, or centering is true, start from the middle point
 | |
|       let currentIndex =
 | |
|         centering || this.state.index == undefined
 | |
|           ? Math.round((itemsView.length - viewportTopOffset) / 2)
 | |
|           : this.state.index;
 | |
|       let closestIndex = itemsView.length;
 | |
|       let indexes = [];
 | |
|       let diff = closestIndex;
 | |
|       let isValueFound = false;
 | |
| 
 | |
|       // Find indexes of items match the value
 | |
|       itemsView.forEach((item, index) => {
 | |
|         if (item.value == value) {
 | |
|           indexes.push(index);
 | |
|         }
 | |
|       });
 | |
| 
 | |
|       // Find the index closest to currentIndex
 | |
|       indexes.forEach(index => {
 | |
|         let d = Math.abs(index - currentIndex);
 | |
|         if (d < diff) {
 | |
|           diff = d;
 | |
|           closestIndex = index;
 | |
|           isValueFound = true;
 | |
|         }
 | |
|       });
 | |
| 
 | |
|       return isValueFound ? closestIndex - viewportTopOffset : -1;
 | |
|     },
 | |
| 
 | |
|     /**
 | |
|      * Scroll to a value.
 | |
|      *
 | |
|      * @param  {Number/String} value: Value to scroll to
 | |
|      * @param  {Boolean} centering: Whether or not to scroll to center location
 | |
|      */
 | |
|     _scrollTo(value, centering) {
 | |
|       const index = this._getScrollIndex(value, centering);
 | |
|       // Do nothing if the value is not found
 | |
|       if (index > -1) {
 | |
|         this.state.index = index;
 | |
|         this.elements.spinner.scrollTop =
 | |
|           this.state.index * ITEM_HEIGHT * this.props.rootFontSize;
 | |
|       }
 | |
|     },
 | |
| 
 | |
|     /**
 | |
|      * Smooth scroll to a value.
 | |
|      *
 | |
|      * @param  {Number/String} value: Value to scroll to
 | |
|      */
 | |
|     _smoothScrollTo(value) {
 | |
|       const index = this._getScrollIndex(value);
 | |
|       // Do nothing if the value is not found
 | |
|       if (index > -1) {
 | |
|         this.state.index = index;
 | |
|         this._smoothScrollToIndex(this.state.index);
 | |
|       }
 | |
|     },
 | |
| 
 | |
|     /**
 | |
|      * Smooth scroll to a value based on the index
 | |
|      *
 | |
|      * @param  {Number} index: Index number
 | |
|      */
 | |
|     _smoothScrollToIndex(index) {
 | |
|       const element = this.elements.spinner.children[index];
 | |
|       if (element) {
 | |
|         element.scrollIntoView({
 | |
|           behavior: "smooth",
 | |
|           block: "start",
 | |
|         });
 | |
|       }
 | |
|     },
 | |
| 
 | |
|     /**
 | |
|      * Update the selection state.
 | |
|      */
 | |
|     _updateSelection() {
 | |
|       const { itemsViewElements, selected } = this.elements;
 | |
|       const { itemsView, index } = this.state;
 | |
|       const { viewportTopOffset } = this.props;
 | |
|       const currentItemIndex = index + viewportTopOffset;
 | |
| 
 | |
|       if (selected && selected != itemsViewElements[currentItemIndex]) {
 | |
|         this._removeSelection();
 | |
|       }
 | |
| 
 | |
|       this.elements.selected = itemsViewElements[currentItemIndex];
 | |
|       if (itemsView[currentItemIndex] && itemsView[currentItemIndex].enabled) {
 | |
|         this.elements.selected.classList.add("selection");
 | |
|       }
 | |
|     },
 | |
| 
 | |
|     /**
 | |
|      * Remove selection if selected exists and different from current
 | |
|      */
 | |
|     _removeSelection() {
 | |
|       const { selected } = this.elements;
 | |
|       if (selected) {
 | |
|         selected.classList.remove("selection");
 | |
|       }
 | |
|     },
 | |
| 
 | |
|     /**
 | |
|      * Compares arrays of objects. It assumes the structure is an array of
 | |
|      * objects, and objects in a and b have the same number of properties.
 | |
|      *
 | |
|      * @param  {Array<Object>} a
 | |
|      * @param  {Array<Object>} b
 | |
|      * @return {Boolean}  Returns true if a and b are different
 | |
|      */
 | |
|     _isArrayDiff(a, b) {
 | |
|       // Check reference first, exit early if reference is the same.
 | |
|       if (a == b) {
 | |
|         return false;
 | |
|       }
 | |
| 
 | |
|       if (a.length != b.length) {
 | |
|         return true;
 | |
|       }
 | |
| 
 | |
|       for (let i = 0; i < a.length; i++) {
 | |
|         for (let prop in a[i]) {
 | |
|           if (a[i][prop] != b[i][prop]) {
 | |
|             return true;
 | |
|           }
 | |
|         }
 | |
|       }
 | |
|       return false;
 | |
|     },
 | |
|   };
 | |
| }
 |