/* 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"; // Prepares the spinner container to function as a spinbutton and expose // its properties to assistive technology this.elements.spinner.setAttribute("role", "spinbutton"); this.elements.spinner.setAttribute("tabindex", "0"); // Remove up/down buttons from the focus order, because a keyboard-only // user can adjust values by pressing Up/Down arrow keys on a spinbutton, // otherwise it creates extra, redundant tab order stops for users this.elements.up.setAttribute("tabindex", "-1"); this.elements.down.setAttribute("tabindex", "-1"); 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, /* centering = */ true, /* smooth = */ false); } else if (newValue != value) { this.state = Object.assign(this.state, newState); this._scrollTo(newValue, /* centering = */ true, smoothScroll); } this.elements.spinner.setAttribute( "aria-valuemin", this.state.items[0].value ); this.elements.spinner.setAttribute( "aria-valuemax", this.state.items.at(-1).value ); this.elements.spinner.setAttribute("aria-valuenow", this.state.value); if (!this.elements.spinner.getAttribute("aria-valuetext")) { this.elements.spinner.setAttribute( "aria-valuetext", this.props.getDisplayString(this.state.value) ); } // Show selection even if it's passed down from the parent if ((isValueSet && !isInvalid) || this.state.index) { 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"); this.elements.spinner.setAttribute( "aria-valuetext", this.props.getDisplayString(this.state.value) ); }, /** * 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"); // Spinbutton elements should be hidden from assistive technology: el.setAttribute("aria-hidden", "true"); 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 }); spinner.addEventListener("keydown", this); container.addEventListener("mouseup", this, { passive: true }); container.addEventListener("mousedown", this, { passive: true }); container.addEventListener("keydown", this); }, /** * 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; } case "keydown": { // Providing keyboard navigation support in accordance with // the ARIA Spinbutton design pattern if (event.target === spinner) { switch (event.key) { case "ArrowUp": { // While the spinner is focused, selects previous value and centers it this._setValueForSpinner(event, index - 1); break; } case "ArrowDown": { // While the spinner is focused, selects next value and centers it this._setValueForSpinner(event, index + 1); break; } case "PageUp": { // While the spinner is focused, selects 5th value above and centers it this._setValueForSpinner(event, index - 5); break; } case "PageDown": { // While the spinner is focused, selects 5th value below and centers it this._setValueForSpinner(event, index + 5); break; } case "Home": { // While the spinner is focused, selects the min value and centers it let targetValue; for (let i = 0; i < this.state.items.length - 1; i++) { if (this.state.items[i].enabled) { targetValue = this.state.items[i].value; break; } } this._smoothScrollTo(targetValue); event.stopPropagation(); event.preventDefault(); break; } case "End": { // While the spinner is focused, selects the max value and centers it let targetValue; for (let i = this.state.items.length - 1; i >= 0; i--) { if (this.state.items[i].enabled) { targetValue = this.state.items[i].value; break; } } this._smoothScrollTo(targetValue); event.stopPropagation(); event.preventDefault(); 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 based on the index * * @param {Number} index: Index number * @param {Boolean} smooth: Whether or not scroll should be smooth by default */ _scrollToIndex(index, smooth) { // Do nothing if the value is not found if (index < 0) { return; } this.state.index = index; const element = this.elements.spinner.children[index]; if (!element) { return; } element.scrollIntoView({ behavior: smooth ? "auto" : "instant", block: "start", }); }, /** * Scroll to a value. * * @param {Number/String} value: Value to scroll to * @param {Boolean} centering: Whether or not to scroll to center location * @param {Boolean} smooth: Whether or not scroll should be smooth by default */ _scrollTo(value, centering, smooth) { const index = this._getScrollIndex(value, centering); this._scrollToIndex(index, smooth); }, _smoothScrollTo(value) { this._scrollTo(value, /* centering = */ false, /* smooth = */ true); }, _smoothScrollToIndex(index) { this._scrollToIndex(index, /* smooth = */ true); }, /** * 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; }, /** * While the spinner is focused and keyboard command is used, selects an * appropriate index and centers it, while preventing default behavior and * stopping event propagation. * * @param {Object} event: Keyboard event * @param {Number} index: The index of the expected next item */ _setValueForSpinner(event, index) { this._smoothScrollToIndex(index); event.stopPropagation(); event.preventDefault(); }, }; }