Source code

Revision control

Copy as Markdown

Other Tools

/* 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<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 });
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<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;
},
/**
* 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();
},
};
}