/* 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} items * [{ * {Number/String} value: The value in its original form * {Boolean} enabled: Whether or not the item is enabled * }] * @param {Array} 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} a * @param {Array} 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; } }; }