summaryrefslogtreecommitdiffstats
path: root/toolkit/content/widgets/spinner.js
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/content/widgets/spinner.js')
-rw-r--r--toolkit/content/widgets/spinner.js637
1 files changed, 637 insertions, 0 deletions
diff --git a/toolkit/content/widgets/spinner.js b/toolkit/content/widgets/spinner.js
new file mode 100644
index 0000000000..2adefc7392
--- /dev/null
+++ b/toolkit/content/widgets/spinner.js
@@ -0,0 +1,637 @@
+/* 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();
+ },
+ };
+}