зеркало из https://github.com/mozilla/gecko-dev.git
Bug 1283384 - Implement time picker UI w/ message passing, r=mconley
MozReview-Commit-ID: Gn3Itf0yFrN --HG-- extra : rebase_source : 7eda91dfe52cd48a0cf795293f4220f7258796cb
This commit is contained in:
Родитель
b83f17b535
Коммит
7fd5ee8fa1
|
@ -520,6 +520,10 @@ toolbar:not(#TabsToolbar) > #personal-bookmarks {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#DateTimePickerPanel {
|
||||||
|
-moz-binding: url("chrome://global/content/bindings/datetimepopup.xml#datetime-popup");
|
||||||
|
}
|
||||||
|
|
||||||
#urlbar[pageproxystate="invalid"] > #urlbar-icons > .urlbar-icon,
|
#urlbar[pageproxystate="invalid"] > #urlbar-icons > .urlbar-icon,
|
||||||
#urlbar[pageproxystate="invalid"][focused="true"] > #urlbar-go-button ~ toolbarbutton,
|
#urlbar[pageproxystate="invalid"][focused="true"] > #urlbar-go-button ~ toolbarbutton,
|
||||||
#urlbar[pageproxystate="valid"] > #urlbar-go-button,
|
#urlbar[pageproxystate="valid"] > #urlbar-go-button,
|
||||||
|
|
|
@ -155,10 +155,14 @@
|
||||||
level="parent"/>
|
level="parent"/>
|
||||||
|
|
||||||
<panel id="DateTimePickerPanel"
|
<panel id="DateTimePickerPanel"
|
||||||
|
type="arrow"
|
||||||
hidden="true"
|
hidden="true"
|
||||||
|
orient="vertical"
|
||||||
noautofocus="true"
|
noautofocus="true"
|
||||||
consumeoutsideclicks="false"
|
consumeoutsideclicks="false"
|
||||||
level="parent"/>
|
level="parent">
|
||||||
|
<iframe id="dateTimePopupFrame"/>
|
||||||
|
</panel>
|
||||||
|
|
||||||
<!-- for select dropdowns. The menupopup is what shows the list of options,
|
<!-- for select dropdowns. The menupopup is what shows the list of options,
|
||||||
and the popuponly menulist makes things like the menuactive attributes
|
and the popuponly menulist makes things like the menuactive attributes
|
||||||
|
|
|
@ -1640,10 +1640,11 @@ let DateTimePickerListener = {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper function that returns the rect of the element, which is the position
|
* Helper function that returns the rect of the element, which is the position
|
||||||
* in "screen" coordinates.
|
* relative to the left/top of the content area.
|
||||||
*/
|
*/
|
||||||
getBoundingContentRect: function(aElement) {
|
getBoundingContentRect: function(aElement) {
|
||||||
return BrowserUtils.getElementBoundingScreenRect(aElement);
|
return BrowserUtils.getElementBoundingRect(aElement);
|
||||||
|
// return BrowserUtils.getElementBoundingScreenRect(aElement);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -63,6 +63,7 @@ toolkit.jar:
|
||||||
content/global/resetProfileProgress.xul
|
content/global/resetProfileProgress.xul
|
||||||
content/global/select-child.js
|
content/global/select-child.js
|
||||||
content/global/TopLevelVideoDocument.js
|
content/global/TopLevelVideoDocument.js
|
||||||
|
content/global/timepicker.xhtml
|
||||||
content/global/treeUtils.js
|
content/global/treeUtils.js
|
||||||
content/global/viewZoomOverlay.js
|
content/global/viewZoomOverlay.js
|
||||||
content/global/bindings/autocomplete.xml (widgets/autocomplete.xml)
|
content/global/bindings/autocomplete.xml (widgets/autocomplete.xml)
|
||||||
|
@ -71,6 +72,7 @@ toolkit.jar:
|
||||||
content/global/bindings/checkbox.xml (widgets/checkbox.xml)
|
content/global/bindings/checkbox.xml (widgets/checkbox.xml)
|
||||||
content/global/bindings/colorpicker.xml (widgets/colorpicker.xml)
|
content/global/bindings/colorpicker.xml (widgets/colorpicker.xml)
|
||||||
content/global/bindings/datetimepicker.xml (widgets/datetimepicker.xml)
|
content/global/bindings/datetimepicker.xml (widgets/datetimepicker.xml)
|
||||||
|
content/global/bindings/datetimepopup.xml (widgets/datetimepopup.xml)
|
||||||
content/global/bindings/datetimebox.xml (widgets/datetimebox.xml)
|
content/global/bindings/datetimebox.xml (widgets/datetimebox.xml)
|
||||||
content/global/bindings/datetimebox.css (widgets/datetimebox.css)
|
content/global/bindings/datetimebox.css (widgets/datetimebox.css)
|
||||||
* content/global/bindings/dialog.xml (widgets/dialog.xml)
|
* content/global/bindings/dialog.xml (widgets/dialog.xml)
|
||||||
|
@ -95,12 +97,15 @@ toolkit.jar:
|
||||||
content/global/bindings/scale.xml (widgets/scale.xml)
|
content/global/bindings/scale.xml (widgets/scale.xml)
|
||||||
content/global/bindings/scrollbar.xml (widgets/scrollbar.xml)
|
content/global/bindings/scrollbar.xml (widgets/scrollbar.xml)
|
||||||
content/global/bindings/scrollbox.xml (widgets/scrollbox.xml)
|
content/global/bindings/scrollbox.xml (widgets/scrollbox.xml)
|
||||||
|
content/global/bindings/spinner.js (widgets/spinner.js)
|
||||||
content/global/bindings/splitter.xml (widgets/splitter.xml)
|
content/global/bindings/splitter.xml (widgets/splitter.xml)
|
||||||
content/global/bindings/spinbuttons.xml (widgets/spinbuttons.xml)
|
content/global/bindings/spinbuttons.xml (widgets/spinbuttons.xml)
|
||||||
content/global/bindings/stringbundle.xml (widgets/stringbundle.xml)
|
content/global/bindings/stringbundle.xml (widgets/stringbundle.xml)
|
||||||
* content/global/bindings/tabbox.xml (widgets/tabbox.xml)
|
* content/global/bindings/tabbox.xml (widgets/tabbox.xml)
|
||||||
content/global/bindings/text.xml (widgets/text.xml)
|
content/global/bindings/text.xml (widgets/text.xml)
|
||||||
* content/global/bindings/textbox.xml (widgets/textbox.xml)
|
* content/global/bindings/textbox.xml (widgets/textbox.xml)
|
||||||
|
content/global/bindings/timekeeper.js (widgets/timekeeper.js)
|
||||||
|
content/global/bindings/timepicker.js (widgets/timepicker.js)
|
||||||
content/global/bindings/toolbar.xml (widgets/toolbar.xml)
|
content/global/bindings/toolbar.xml (widgets/toolbar.xml)
|
||||||
content/global/bindings/toolbarbutton.xml (widgets/toolbarbutton.xml)
|
content/global/bindings/toolbarbutton.xml (widgets/toolbarbutton.xml)
|
||||||
* content/global/bindings/tree.xml (widgets/tree.xml)
|
* content/global/bindings/tree.xml (widgets/tree.xml)
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE html [
|
||||||
|
<!ENTITY % htmlDTD PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "DTD/xhtml1-strict.dtd">
|
||||||
|
%htmlDTD;
|
||||||
|
]>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
|
||||||
|
<head>
|
||||||
|
<title>Time Picker</title>
|
||||||
|
<link rel="stylesheet" href="chrome://global/skin/timepicker.css"/>
|
||||||
|
<script type="application/javascript" src="chrome://global/content/bindings/timekeeper.js"></script>
|
||||||
|
<script type="application/javascript" src="chrome://global/content/bindings/spinner.js"></script>
|
||||||
|
<script type="application/javascript" src="chrome://global/content/bindings/timepicker.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="time-picker"></div>
|
||||||
|
<template id="spinner-template">
|
||||||
|
<div class="spinner-container">
|
||||||
|
<button class="up">▲</button>
|
||||||
|
<div class="stack">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
</div>
|
||||||
|
<button class="down">▼</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script type="application/javascript">
|
||||||
|
// We need to hide the scroll bar but maintain its scrolling
|
||||||
|
// capability, so using |overflow: hidden| is not an option.
|
||||||
|
// Instead, we are inserting a user agent stylesheet that is
|
||||||
|
// capable of selecting scrollbars, and do |display: none|.
|
||||||
|
var domWinUtls = window.QueryInterface(Components.interfaces.nsIInterfaceRequestor).
|
||||||
|
getInterface(Components.interfaces.nsIDOMWindowUtils);
|
||||||
|
domWinUtls.loadSheetUsingURIString('data:text/css,@namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"); scrollbar { display: none; }', domWinUtls.AGENT_SHEET);
|
||||||
|
// Create a TimePicker instance and prepare to be
|
||||||
|
// initialized by the "TimePickerInit" event from timepicker.xml
|
||||||
|
new TimePicker(document.getElementById("time-picker"));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,179 @@
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
|
||||||
|
<!-- 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/. -->
|
||||||
|
|
||||||
|
<bindings id="dateTimePopupBindings"
|
||||||
|
xmlns="http://www.mozilla.org/xbl"
|
||||||
|
xmlns:html="http://www.w3.org/1999/xhtml"
|
||||||
|
xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
|
||||||
|
xmlns:xbl="http://www.mozilla.org/xbl">
|
||||||
|
<binding id="datetime-popup"
|
||||||
|
extends="chrome://global/content/bindings/popup.xml#arrowpanel">
|
||||||
|
<implementation>
|
||||||
|
<field name="dateTimePopupFrame">
|
||||||
|
this.querySelector("#dateTimePopupFrame");
|
||||||
|
</field>
|
||||||
|
<field name="TIME_PICKER_WIDTH" readonly="true">"14em"</field>
|
||||||
|
<field name="TIME_PICKER_HEIGHT" readonly="true">"14em"</field>
|
||||||
|
<method name="loadPicker">
|
||||||
|
<parameter name="type"/>
|
||||||
|
<parameter name="detail"/>
|
||||||
|
<body><![CDATA[
|
||||||
|
this.hidden = false;
|
||||||
|
this.type = type;
|
||||||
|
this.pickerState = {};
|
||||||
|
switch (type) {
|
||||||
|
case "time": {
|
||||||
|
this.detail = detail;
|
||||||
|
this.dateTimePopupFrame.addEventListener("load", this, true);
|
||||||
|
this.dateTimePopupFrame.setAttribute("src", "chrome://global/content/timepicker.xhtml");
|
||||||
|
this.dateTimePopupFrame.style.width = this.TIME_PICKER_WIDTH;
|
||||||
|
this.dateTimePopupFrame.style.height = this.TIME_PICKER_HEIGHT;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]]></body>
|
||||||
|
</method>
|
||||||
|
<method name="closePicker">
|
||||||
|
<body><![CDATA[
|
||||||
|
this.hidden = true;
|
||||||
|
this.setInputBoxValue(true);
|
||||||
|
this.pickerState = {};
|
||||||
|
this.type = undefined;
|
||||||
|
this.dateTimePopupFrame.removeEventListener("load", this, true);
|
||||||
|
this.dateTimePopupFrame.contentDocument.removeEventListener("TimePickerPopupChanged", this, false);
|
||||||
|
this.dateTimePopupFrame.setAttribute("src", "");
|
||||||
|
]]></body>
|
||||||
|
</method>
|
||||||
|
<method name="setPopupValue">
|
||||||
|
<parameter name="data"/>
|
||||||
|
<body><![CDATA[
|
||||||
|
switch (this.type) {
|
||||||
|
case "time": {
|
||||||
|
this.postMessageToPicker({
|
||||||
|
name: "TimePickerSetValue",
|
||||||
|
detail: data.value
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]]></body>
|
||||||
|
</method>
|
||||||
|
<method name="initPicker">
|
||||||
|
<parameter name="detail"/>
|
||||||
|
<body><![CDATA[
|
||||||
|
switch (this.type) {
|
||||||
|
case "time": {
|
||||||
|
const { hour, minute } = detail.value;
|
||||||
|
const format = detail.format || "12";
|
||||||
|
const locale = Components.classes["@mozilla.org/chrome/chrome-registry;1"].getService(Ci.nsIXULChromeRegistry).getSelectedLocale("global");
|
||||||
|
|
||||||
|
this.postMessageToPicker({
|
||||||
|
name: "TimePickerInit",
|
||||||
|
detail: {
|
||||||
|
hour,
|
||||||
|
minute,
|
||||||
|
format,
|
||||||
|
locale,
|
||||||
|
min: detail.min,
|
||||||
|
max: detail.max,
|
||||||
|
step: detail.step,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]]></body>
|
||||||
|
</method>
|
||||||
|
<method name="setInputBoxValue">
|
||||||
|
<parameter name="passAllValues"/>
|
||||||
|
<body><![CDATA[
|
||||||
|
/**
|
||||||
|
* @param {Boolean} passAllValues: Pass spinner values regardless if they've been set/changed or not
|
||||||
|
*/
|
||||||
|
switch (this.type) {
|
||||||
|
case "time": {
|
||||||
|
const { hour, minute, isHourSet, isMinuteSet, isDayPeriodSet } = this.pickerState;
|
||||||
|
const isAnyValueSet = isHourSet || isMinuteSet || isDayPeriodSet;
|
||||||
|
if (passAllValues && isAnyValueSet) {
|
||||||
|
this.sendPickerValueChanged({ hour, minute });
|
||||||
|
} else {
|
||||||
|
this.sendPickerValueChanged({
|
||||||
|
hour: isHourSet || isDayPeriodSet ? hour : undefined,
|
||||||
|
minute: isMinuteSet ? minute : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]]></body>
|
||||||
|
</method>
|
||||||
|
<method name="sendPickerValueChanged">
|
||||||
|
<parameter name="value"/>
|
||||||
|
<body><![CDATA[
|
||||||
|
switch (this.type) {
|
||||||
|
case "time": {
|
||||||
|
this.dispatchEvent(new CustomEvent("DateTimePickerValueChanged", {
|
||||||
|
detail: {
|
||||||
|
hour: value.hour,
|
||||||
|
minute: value.minute
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]]></body>
|
||||||
|
</method>
|
||||||
|
<method name="handleEvent">
|
||||||
|
<parameter name="aEvent"/>
|
||||||
|
<body><![CDATA[
|
||||||
|
switch (aEvent.type) {
|
||||||
|
case "load": {
|
||||||
|
this.initPicker(this.detail);
|
||||||
|
this.dateTimePopupFrame.contentWindow.addEventListener("message", this, false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "message": {
|
||||||
|
this.handleMessage(aEvent);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]]></body>
|
||||||
|
</method>
|
||||||
|
<method name="handleMessage">
|
||||||
|
<parameter name="aEvent"/>
|
||||||
|
<body><![CDATA[
|
||||||
|
if (!this.dateTimePopupFrame.contentDocument.nodePrincipal.isSystemPrincipal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (aEvent.data.name) {
|
||||||
|
case "TimePickerPopupChanged": {
|
||||||
|
this.pickerState = aEvent.data.detail;
|
||||||
|
this.setInputBoxValue();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]]></body>
|
||||||
|
</method>
|
||||||
|
<method name="postMessageToPicker">
|
||||||
|
<parameter name="data"/>
|
||||||
|
<body><![CDATA[
|
||||||
|
if (this.dateTimePopupFrame.contentDocument.nodePrincipal.isSystemPrincipal) {
|
||||||
|
this.dateTimePopupFrame.contentWindow.postMessage(data, "*");
|
||||||
|
}
|
||||||
|
]]></body>
|
||||||
|
</method>
|
||||||
|
|
||||||
|
</implementation>
|
||||||
|
<handlers>
|
||||||
|
<handler event="popuphiding">
|
||||||
|
<![CDATA[
|
||||||
|
this.closePicker();
|
||||||
|
]]>
|
||||||
|
</handler>
|
||||||
|
</handlers>
|
||||||
|
</binding>
|
||||||
|
</bindings>
|
|
@ -0,0 +1,477 @@
|
||||||
|
/* 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 debug = 0 ? console.log.bind(console, '[spinner]') : function() {};
|
||||||
|
|
||||||
|
const ITEM_HEIGHT = 20,
|
||||||
|
VIEWPORT_SIZE = 5,
|
||||||
|
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} itemHeight [optional]: Item height in pixels.
|
||||||
|
* {Number} viewportSize [optional]: Number of items in a
|
||||||
|
* viewport.
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
_init(props) {
|
||||||
|
const { setValue, getDisplayString, itemHeight = ITEM_HEIGHT } = 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, itemHeight, viewportSize,
|
||||||
|
// 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 = {
|
||||||
|
spinner: spinnerElement.querySelector(".spinner"),
|
||||||
|
up: spinnerElement.querySelector(".up"),
|
||||||
|
down: spinnerElement.querySelector(".down"),
|
||||||
|
itemsViewElements: []
|
||||||
|
};
|
||||||
|
|
||||||
|
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 { spinner } = this.elements;
|
||||||
|
const { value, items } = this.state;
|
||||||
|
const { value: newValue, items: newItems, isValueSet, isInvalid } = 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);
|
||||||
|
this._smoothScrollTo(newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isValueSet) {
|
||||||
|
if (isInvalid) {
|
||||||
|
this._removeSelection();
|
||||||
|
} else {
|
||||||
|
this._updateSelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whenever scroll event is detected:
|
||||||
|
* - Update the index state
|
||||||
|
* - If a smooth scroll has reached its destination, set [isScrolling] state
|
||||||
|
* to false
|
||||||
|
* - 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, itemHeight } = this.props;
|
||||||
|
const { spinner, itemsViewElements } = this.elements;
|
||||||
|
|
||||||
|
this.state.index = this._getIndexByOffset(spinner.scrollTop);
|
||||||
|
|
||||||
|
const value = itemsView[this.state.index + viewportTopOffset].value;
|
||||||
|
|
||||||
|
// Check if smooth scrolling has reached its destination.
|
||||||
|
// This prevents input box jump when input box changes values.
|
||||||
|
if (this.state.value == value && this.state.isScrolling) {
|
||||||
|
this.state.isScrolling = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call setValue if value has changed, and is not smooth scrolling
|
||||||
|
if (this.state.value != value && !this.state.isScrolling) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
let count = Math.abs(diff);
|
||||||
|
|
||||||
|
if (diff > 0) {
|
||||||
|
// Add more elements if length is greater than current
|
||||||
|
let frag = document.createDocumentFragment();
|
||||||
|
|
||||||
|
for (let i = 0; i < count; 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 < count; i++) {
|
||||||
|
parent.removeChild(parent.lastChild);
|
||||||
|
}
|
||||||
|
this.elements.itemsViewElements.splice(diff);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 } = this.elements;
|
||||||
|
|
||||||
|
spinner.addEventListener("scroll", this, { passive: true });
|
||||||
|
document.addEventListener("mouseup", this, { passive: true });
|
||||||
|
document.addEventListener("mousedown", 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 "mousedown": {
|
||||||
|
// Use preventDefault to keep focus on input boxes
|
||||||
|
event.preventDefault();
|
||||||
|
this.state.mouseState = {
|
||||||
|
down: true,
|
||||||
|
layerX: event.layerX,
|
||||||
|
layerY: event.layerY
|
||||||
|
};
|
||||||
|
if (event.target == up) {
|
||||||
|
this._smoothScrollToIndex(index + 1);
|
||||||
|
}
|
||||||
|
if (event.target == down) {
|
||||||
|
this._smoothScrollToIndex(index - 1);
|
||||||
|
}
|
||||||
|
if (event.target.parentNode == spinner) {
|
||||||
|
// Listen to dragging events
|
||||||
|
event.target.setCapture();
|
||||||
|
spinner.addEventListener("mousemove", this, { passive: true });
|
||||||
|
spinner.addEventListener("mouseleave", this, { passive: true });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "mouseup": {
|
||||||
|
this.state.mouseState.down = false;
|
||||||
|
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 / this.props.itemHeight);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 * this.props.itemHeight;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
// Set the isScrolling flag before smooth scrolling begins
|
||||||
|
// and remove it when it has reached the destination.
|
||||||
|
// This prevents input box jump when input box changes values
|
||||||
|
this.state.isScrolling = true;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,418 @@
|
||||||
|
/* 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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TimeKeeper keeps track of the time states. Given min, max, step, and
|
||||||
|
* format (12/24hr), TimeKeeper will determine the ranges of possible
|
||||||
|
* selections, and whether or not the current time state is out of range
|
||||||
|
* or off step.
|
||||||
|
*
|
||||||
|
* @param {Object} props
|
||||||
|
* {
|
||||||
|
* {Date} min
|
||||||
|
* {Date} max
|
||||||
|
* {Number} stepInMs
|
||||||
|
* {String} format: Either "12" or "24"
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
function TimeKeeper(props) {
|
||||||
|
this.props = props;
|
||||||
|
this.state = { time: new Date(0), ranges: {} };
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
const debug = 0 ? console.log.bind(console, '[timekeeper]') : function() {};
|
||||||
|
|
||||||
|
const DAY_PERIOD_IN_HOURS = 12,
|
||||||
|
SECOND_IN_MS = 1000,
|
||||||
|
MINUTE_IN_MS = 60000,
|
||||||
|
HOUR_IN_MS = 3600000,
|
||||||
|
DAY_PERIOD_IN_MS = 43200000,
|
||||||
|
DAY_IN_MS = 86400000,
|
||||||
|
TIME_FORMAT_24 = "24";
|
||||||
|
|
||||||
|
TimeKeeper.prototype = {
|
||||||
|
/**
|
||||||
|
* Getters for different time units.
|
||||||
|
* @return {Number}
|
||||||
|
*/
|
||||||
|
get hour() {
|
||||||
|
return this.state.time.getUTCHours();
|
||||||
|
},
|
||||||
|
get minute() {
|
||||||
|
return this.state.time.getUTCMinutes();
|
||||||
|
},
|
||||||
|
get second() {
|
||||||
|
return this.state.time.getUTCSeconds();
|
||||||
|
},
|
||||||
|
get millisecond() {
|
||||||
|
return this.state.time.getUTCMilliseconds();
|
||||||
|
},
|
||||||
|
get dayPeriod() {
|
||||||
|
// 0 stands for AM and 12 for PM
|
||||||
|
return this.state.time.getUTCHours() < DAY_PERIOD_IN_HOURS ? 0 : DAY_PERIOD_IN_HOURS;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the ranges of different time units.
|
||||||
|
* @return {Object}
|
||||||
|
* {
|
||||||
|
* {Array<Number>} dayPeriod
|
||||||
|
* {Array<Number>} hours
|
||||||
|
* {Array<Number>} minutes
|
||||||
|
* {Array<Number>} seconds
|
||||||
|
* {Array<Number>} milliseconds
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
get ranges() {
|
||||||
|
return this.state.ranges;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set new time, check if the current state is valid, and set ranges.
|
||||||
|
*
|
||||||
|
* @param {Object} timeState: The new time
|
||||||
|
* {
|
||||||
|
* {Number} hour [optional]
|
||||||
|
* {Number} minute [optional]
|
||||||
|
* {Number} second [optional]
|
||||||
|
* {Number} millisecond [optional]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
setState(timeState) {
|
||||||
|
const { min, max } = this.props;
|
||||||
|
const { hour, minute, second, millisecond } = timeState;
|
||||||
|
|
||||||
|
if (hour != undefined) {
|
||||||
|
this.state.time.setUTCHours(hour);
|
||||||
|
}
|
||||||
|
if (minute != undefined) {
|
||||||
|
this.state.time.setUTCMinutes(minute);
|
||||||
|
}
|
||||||
|
if (second != undefined) {
|
||||||
|
this.state.time.setUTCSeconds(second);
|
||||||
|
}
|
||||||
|
if (millisecond != undefined) {
|
||||||
|
this.state.time.setUTCMilliseconds(millisecond);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.isOffStep = this._isOffStep(this.state.time);
|
||||||
|
this.state.isOutOfRange = (this.state.time < min || this.state.time > max);
|
||||||
|
this.state.isInvalid = this.state.isOutOfRange || this.state.isOffStep;
|
||||||
|
|
||||||
|
this._setRanges(this.dayPeriod, this.hour, this.minute, this.second);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set day-period (AM/PM)
|
||||||
|
* @param {Number} dayPeriod: 0 as AM, 12 as PM
|
||||||
|
*/
|
||||||
|
setDayPeriod(dayPeriod) {
|
||||||
|
if (dayPeriod == this.dayPeriod) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dayPeriod == 0) {
|
||||||
|
this.setState({ hour: this.hour - DAY_PERIOD_IN_HOURS });
|
||||||
|
} else {
|
||||||
|
this.setState({ hour: this.hour + DAY_PERIOD_IN_HOURS });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set hour in 24hr format (0 ~ 23)
|
||||||
|
* @param {Number} hour
|
||||||
|
*/
|
||||||
|
setHour(hour) {
|
||||||
|
this.setState({ hour });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set minute (0 ~ 59)
|
||||||
|
* @param {Number} minute
|
||||||
|
*/
|
||||||
|
setMinute(minute) {
|
||||||
|
this.setState({ minute });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set second (0 ~ 59)
|
||||||
|
* @param {Number} second
|
||||||
|
*/
|
||||||
|
setSecond(second) {
|
||||||
|
this.setState({ second });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set millisecond (0 ~ 999)
|
||||||
|
* @param {Number} millisecond
|
||||||
|
*/
|
||||||
|
setMillisecond(millisecond) {
|
||||||
|
this.setState({ millisecond });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the range of possible choices for each time unit.
|
||||||
|
* Reuse the old result if the input has not changed.
|
||||||
|
*
|
||||||
|
* @param {Number} dayPeriod
|
||||||
|
* @param {Number} hour
|
||||||
|
* @param {Number} minute
|
||||||
|
* @param {Number} second
|
||||||
|
*/
|
||||||
|
_setRanges(dayPeriod, hour, minute, second) {
|
||||||
|
this.state.ranges.dayPeriod =
|
||||||
|
this.state.ranges.dayPeriod || this._getDayPeriodRange();
|
||||||
|
|
||||||
|
if (this.state.dayPeriod != dayPeriod) {
|
||||||
|
this.state.ranges.hours = this._getHoursRange(dayPeriod);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state.hour != hour) {
|
||||||
|
this.state.ranges.minutes = this._getMinutesRange(hour);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state.hour != hour || this.state.minute != minute) {
|
||||||
|
this.state.ranges.seconds = this._getSecondsRange(hour, minute);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state.hour != hour || this.state.minute != minute || this.state.second != second) {
|
||||||
|
this.state.ranges.milliseconds = this._getMillisecondsRange(hour, minute, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the time states for comparison.
|
||||||
|
this.state.dayPeriod = dayPeriod;
|
||||||
|
this.state.hour = hour;
|
||||||
|
this.state.minute = minute;
|
||||||
|
this.state.second = second;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the AM/PM range. Return an empty array if in 24hr mode.
|
||||||
|
*
|
||||||
|
* @return {Array<Number>}
|
||||||
|
*/
|
||||||
|
_getDayPeriodRange() {
|
||||||
|
if (this.props.format == TIME_FORMAT_24) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = 0;
|
||||||
|
const end = DAY_IN_MS - 1;
|
||||||
|
const minStep = DAY_PERIOD_IN_MS;
|
||||||
|
const formatter = (time) =>
|
||||||
|
new Date(time).getUTCHours() < DAY_PERIOD_IN_HOURS ? 0 : DAY_PERIOD_IN_HOURS;
|
||||||
|
|
||||||
|
return this._getSteps(start, end, minStep, formatter);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the hours range.
|
||||||
|
*
|
||||||
|
* @param {Number} dayPeriod
|
||||||
|
* @return {Array<Number>}
|
||||||
|
*/
|
||||||
|
_getHoursRange(dayPeriod) {
|
||||||
|
const { format } = this.props;
|
||||||
|
const start = format == "24" ? 0 : dayPeriod * HOUR_IN_MS;
|
||||||
|
const end = format == "24" ? DAY_IN_MS - 1 : start + DAY_PERIOD_IN_MS - 1;
|
||||||
|
const minStep = HOUR_IN_MS;
|
||||||
|
const formatter = (time) => new Date(time).getUTCHours();
|
||||||
|
|
||||||
|
return this._getSteps(start, end, minStep, formatter);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the minutes range
|
||||||
|
*
|
||||||
|
* @param {Number} hour
|
||||||
|
* @return {Array<Number>}
|
||||||
|
*/
|
||||||
|
_getMinutesRange(hour) {
|
||||||
|
const start = hour * HOUR_IN_MS;
|
||||||
|
const end = start + HOUR_IN_MS - 1;
|
||||||
|
const minStep = MINUTE_IN_MS;
|
||||||
|
const formatter = (time) => new Date(time).getUTCMinutes();
|
||||||
|
|
||||||
|
return this._getSteps(start, end, minStep, formatter);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the seconds range
|
||||||
|
*
|
||||||
|
* @param {Number} hour
|
||||||
|
* @param {Number} minute
|
||||||
|
* @return {Array<Number>}
|
||||||
|
*/
|
||||||
|
_getSecondsRange(hour, minute) {
|
||||||
|
const start = hour * HOUR_IN_MS + minute * MINUTE_IN_MS;
|
||||||
|
const end = start + MINUTE_IN_MS - 1;
|
||||||
|
const minStep = SECOND_IN_MS;
|
||||||
|
const formatter = (time) => new Date(time).getUTCSeconds();
|
||||||
|
|
||||||
|
return this._getSteps(start, end, minStep, formatter);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the milliseconds range
|
||||||
|
* @param {Number} hour
|
||||||
|
* @param {Number} minute
|
||||||
|
* @param {Number} second
|
||||||
|
* @return {Array<Number>}
|
||||||
|
*/
|
||||||
|
_getMillisecondsRange(hour, minute, second) {
|
||||||
|
const start = hour * HOUR_IN_MS + minute * MINUTE_IN_MS + second * SECOND_IN_MS;
|
||||||
|
const end = start + SECOND_IN_MS - 1;
|
||||||
|
const minStep = 1;
|
||||||
|
const formatter = (time) => new Date(time).getUTCMilliseconds();
|
||||||
|
|
||||||
|
return this._getSteps(start, end, minStep, formatter);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the range of possible steps.
|
||||||
|
*
|
||||||
|
* @param {Number} startValue: Start time in ms
|
||||||
|
* @param {Number} endValue: End time in ms
|
||||||
|
* @param {Number} minStep: Smallest step in ms for the time unit
|
||||||
|
* @param {Function} formatter: Outputs time in a particular format
|
||||||
|
* @return {Array<Object>}
|
||||||
|
* {
|
||||||
|
* {Number} value
|
||||||
|
* {Boolean} enabled
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
_getSteps(startValue, endValue, minStep, formatter) {
|
||||||
|
const { min, max, stepInMs } = this.props;
|
||||||
|
// The timeStep should be big enough so that there won't be
|
||||||
|
// duplications. Ex: minimum step for minute should be 60000ms,
|
||||||
|
// if smaller than that, next step might return the same minute.
|
||||||
|
const timeStep = Math.max(minStep, stepInMs);
|
||||||
|
|
||||||
|
// Make sure the starting point and end point is not off step
|
||||||
|
let time = min.valueOf() + Math.ceil((startValue - min.valueOf()) / timeStep) * timeStep;
|
||||||
|
let maxValue = min.valueOf() + Math.floor((max.valueOf() - min.valueOf()) / stepInMs) * stepInMs;
|
||||||
|
let steps = [];
|
||||||
|
|
||||||
|
// Increment by timeStep until reaching the end of the range.
|
||||||
|
while (time <= endValue) {
|
||||||
|
steps.push({
|
||||||
|
value: formatter(time),
|
||||||
|
// Check if the value is within the min and max. If it's out of range,
|
||||||
|
// also check for the case when minStep is too large, and has stepped out
|
||||||
|
// of range when it should be enabled.
|
||||||
|
enabled: (time >= min.valueOf() && time <= max.valueOf()) ||
|
||||||
|
(time > maxValue && startValue <= maxValue &&
|
||||||
|
endValue >= maxValue && formatter(time) == formatter(maxValue))
|
||||||
|
});
|
||||||
|
time += timeStep;
|
||||||
|
}
|
||||||
|
|
||||||
|
return steps;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A generic function for stepping up or down from a value of a range.
|
||||||
|
* It stops at the upper and lower limits.
|
||||||
|
*
|
||||||
|
* @param {Number} current: The current value
|
||||||
|
* @param {Number} offset: The offset relative to current value
|
||||||
|
* @param {Array<Object>} range: List of possible steps
|
||||||
|
* @return {Number} The new value
|
||||||
|
*/
|
||||||
|
_step(current, offset, range) {
|
||||||
|
const index = range.findIndex(step => step.value == current);
|
||||||
|
const newIndex = offset > 0 ?
|
||||||
|
Math.min(index + offset, range.length - 1) :
|
||||||
|
Math.max(index + offset, 0);
|
||||||
|
return range[newIndex].value;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step up or down AM/PM
|
||||||
|
*
|
||||||
|
* @param {Number} offset
|
||||||
|
*/
|
||||||
|
stepDayPeriodBy(offset) {
|
||||||
|
const current = this.dayPeriod;
|
||||||
|
const dayPeriod = this._step(current, offset, this.state.ranges.dayPeriod);
|
||||||
|
|
||||||
|
if (current != dayPeriod) {
|
||||||
|
this.hour < DAY_PERIOD_IN_HOURS ?
|
||||||
|
this.setState({ hour: this.hour + DAY_PERIOD_IN_HOURS }) :
|
||||||
|
this.setState({ hour: this.hour - DAY_PERIOD_IN_HOURS });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step up or down hours
|
||||||
|
*
|
||||||
|
* @param {Number} offset
|
||||||
|
*/
|
||||||
|
stepHourBy(offset) {
|
||||||
|
const current = this.hour;
|
||||||
|
const hour = this._step(current, offset, this.state.ranges.hours);
|
||||||
|
|
||||||
|
if (current != hour) {
|
||||||
|
this.setState({ hour });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step up or down minutes
|
||||||
|
*
|
||||||
|
* @param {Number} offset
|
||||||
|
*/
|
||||||
|
stepMinuteBy(offset) {
|
||||||
|
const current = this.minute;
|
||||||
|
const minute = this._step(current, offset, this.state.ranges.minutes);
|
||||||
|
|
||||||
|
if (current != minute) {
|
||||||
|
this.setState({ minute });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step up or down seconds
|
||||||
|
*
|
||||||
|
* @param {Number} offset
|
||||||
|
*/
|
||||||
|
stepSecondBy(offset) {
|
||||||
|
const current = this.second;
|
||||||
|
const second = this._step(current, offset, this.state.ranges.seconds);
|
||||||
|
|
||||||
|
if (current != second) {
|
||||||
|
this.setState({ second });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step up or down milliseconds
|
||||||
|
*
|
||||||
|
* @param {Number} offset
|
||||||
|
*/
|
||||||
|
stepMillisecondBy(offset) {
|
||||||
|
const current = this.milliseconds;
|
||||||
|
const millisecond = this._step(current, offset, this.state.ranges.millisecond);
|
||||||
|
|
||||||
|
if (current != millisecond) {
|
||||||
|
this.setState({ millisecond });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the time state is off step.
|
||||||
|
*
|
||||||
|
* @param {Date} time
|
||||||
|
* @return {Boolean}
|
||||||
|
*/
|
||||||
|
_isOffStep(time) {
|
||||||
|
const { min, stepInMs } = this.props;
|
||||||
|
|
||||||
|
return (time.valueOf() - min.valueOf()) % stepInMs != 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,249 @@
|
||||||
|
/* 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';
|
||||||
|
|
||||||
|
function TimePicker(context) {
|
||||||
|
this.context = context;
|
||||||
|
this._attachEventListeners();
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
const debug = 0 ? console.log.bind(console, '[timepicker]') : function() {};
|
||||||
|
|
||||||
|
const DAY_PERIOD_IN_HOURS = 12,
|
||||||
|
SECOND_IN_MS = 1000,
|
||||||
|
MINUTE_IN_MS = 60000,
|
||||||
|
DAY_IN_MS = 86400000;
|
||||||
|
|
||||||
|
TimePicker.prototype = {
|
||||||
|
/**
|
||||||
|
* Initializes the time picker. Set the default states and properties.
|
||||||
|
* @param {Object} props
|
||||||
|
* {
|
||||||
|
* {Number} hour [optional]: Hour in 24 hours format (0~23), default is current hour
|
||||||
|
* {Number} minute [optional]: Minute (0~59), default is current minute
|
||||||
|
* {String} min [optional]: Minimum time, in 24 hours format. ex: "05:45"
|
||||||
|
* {String} max [optional]: Maximum time, in 24 hours format. ex: "23:00"
|
||||||
|
* {Number} step [optional]: Step size in minutes. Default is 60.
|
||||||
|
* {String} format [optional]: "12" for 12 hours, "24" for 24 hours format
|
||||||
|
* {String} locale [optional]: User preferred locale
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
init(props) {
|
||||||
|
this.props = props || {};
|
||||||
|
this._setDefaultState();
|
||||||
|
this._createComponents();
|
||||||
|
this._setComponentStates();
|
||||||
|
},
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Set initial time states. If there's no hour & minute, it will
|
||||||
|
* use the current time. The Time module keeps track of the time states,
|
||||||
|
* and calculates the valid options given the time, min, max, step,
|
||||||
|
* and format (12 or 24).
|
||||||
|
*/
|
||||||
|
_setDefaultState() {
|
||||||
|
const { hour, minute, min, max, step, format } = this.props;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
let timerHour = hour == undefined ? now.getHours() : hour;
|
||||||
|
let timerMinute = minute == undefined ? now.getMinutes() : minute;
|
||||||
|
|
||||||
|
// The spec defines 1 step == 1 second, need to convert to ms for timekeeper
|
||||||
|
let timeKeeper = new TimeKeeper({
|
||||||
|
min: this._parseTimeString(min) || new Date(0),
|
||||||
|
max: this._parseTimeString(max) || new Date(DAY_IN_MS - 1),
|
||||||
|
stepInMs: step ? step * SECOND_IN_MS : MINUTE_IN_MS,
|
||||||
|
format: format || "12"
|
||||||
|
});
|
||||||
|
timeKeeper.setState({ hour: timerHour, minute: timerMinute });
|
||||||
|
|
||||||
|
this.state = { timeKeeper };
|
||||||
|
|
||||||
|
// TODO: Resize picker based on zoom level
|
||||||
|
document.documentElement.style.fontSize = "10px";
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a time string from DOM attribute to a date object.
|
||||||
|
*
|
||||||
|
* @param {String} timeString: (ex. "10:30", "23:55", "12:34:56.789")
|
||||||
|
* @return {Date/Boolean} Date object or false if date is invalid.
|
||||||
|
*/
|
||||||
|
_parseTimeString(timeString) {
|
||||||
|
let time = new Date("1970-01-01T" + timeString + "Z");
|
||||||
|
return time.toString() == "Invalid Date" ? false : time;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initalize the spinner components.
|
||||||
|
*/
|
||||||
|
_createComponents() {
|
||||||
|
const { locale, step, format } = this.props;
|
||||||
|
const { timeKeeper } = this.state;
|
||||||
|
|
||||||
|
const wrapSetValueFn = (setTimeFunction) => {
|
||||||
|
return (value) => {
|
||||||
|
setTimeFunction(value);
|
||||||
|
this._setComponentStates();
|
||||||
|
this._dispatchState();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const numberFormat = new Intl.NumberFormat(locale).format;
|
||||||
|
|
||||||
|
this.components = {
|
||||||
|
hour: new Spinner({
|
||||||
|
setValue: wrapSetValueFn(value => {
|
||||||
|
timeKeeper.setHour(value);
|
||||||
|
this.state.isHourSet = true;
|
||||||
|
}),
|
||||||
|
getDisplayString: hour => {
|
||||||
|
if (format == "24") {
|
||||||
|
return numberFormat(hour);
|
||||||
|
} else {
|
||||||
|
// Hour 0 in 12 hour format is displayed as 12.
|
||||||
|
const hourIn12 = hour % DAY_PERIOD_IN_HOURS;
|
||||||
|
return hourIn12 == 0 ? numberFormat(12)
|
||||||
|
: numberFormat(hourIn12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, this.context),
|
||||||
|
minute: new Spinner({
|
||||||
|
setValue: wrapSetValueFn(value => {
|
||||||
|
timeKeeper.setMinute(value);
|
||||||
|
this.state.isMinuteSet = true;
|
||||||
|
}),
|
||||||
|
getDisplayString: minute => numberFormat(minute)
|
||||||
|
}, this.context)
|
||||||
|
};
|
||||||
|
|
||||||
|
// The AM/PM spinner is only available in 12hr mode
|
||||||
|
// TODO: Replace AM & PM string with localized string
|
||||||
|
if (format == "12") {
|
||||||
|
this.components.dayPeriod = new Spinner({
|
||||||
|
setValue: wrapSetValueFn(value => {
|
||||||
|
timeKeeper.setDayPeriod(value);
|
||||||
|
this.state.isDayPeriodSet = true;
|
||||||
|
}),
|
||||||
|
getDisplayString: dayPeriod => dayPeriod == 0 ? "AM" : "PM"
|
||||||
|
}, this.context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set component states.
|
||||||
|
*/
|
||||||
|
_setComponentStates() {
|
||||||
|
const { timeKeeper, isHourSet, isMinuteSet, isDayPeriodSet } = this.state;
|
||||||
|
const isInvalid = timeKeeper.state.isInvalid;
|
||||||
|
// Value is set to min if it's first opened and time state is invalid
|
||||||
|
const setToMinValue = !isHourSet && !isMinuteSet && !isDayPeriodSet && isInvalid;
|
||||||
|
|
||||||
|
this.components.hour.setState({
|
||||||
|
value: setToMinValue ? timeKeeper.ranges.hours[0].value : timeKeeper.hour,
|
||||||
|
items: timeKeeper.ranges.hours,
|
||||||
|
isInfiniteScroll: true,
|
||||||
|
isValueSet: isHourSet,
|
||||||
|
isInvalid
|
||||||
|
});
|
||||||
|
|
||||||
|
this.components.minute.setState({
|
||||||
|
value: setToMinValue ? timeKeeper.ranges.minutes[0].value : timeKeeper.minute,
|
||||||
|
items: timeKeeper.ranges.minutes,
|
||||||
|
isInfiniteScroll: true,
|
||||||
|
isValueSet: isMinuteSet,
|
||||||
|
isInvalid
|
||||||
|
});
|
||||||
|
|
||||||
|
// The AM/PM spinner is only available in 12hr mode
|
||||||
|
if (this.props.format == "12") {
|
||||||
|
this.components.dayPeriod.setState({
|
||||||
|
value: setToMinValue ? timeKeeper.ranges.dayPeriod[0].value : timeKeeper.dayPeriod,
|
||||||
|
items: timeKeeper.ranges.dayPeriod,
|
||||||
|
isInfiniteScroll: false,
|
||||||
|
isValueSet: isDayPeriodSet,
|
||||||
|
isInvalid
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch CustomEvent to pass the state of picker to the panel.
|
||||||
|
*/
|
||||||
|
_dispatchState() {
|
||||||
|
const { hour, minute } = this.state.timeKeeper;
|
||||||
|
const { isHourSet, isMinuteSet, isDayPeriodSet } = this.state;
|
||||||
|
// The panel is listening to window for postMessage event, so we
|
||||||
|
// do postMessage to itself to send data to input boxes.
|
||||||
|
window.postMessage({
|
||||||
|
name: "TimePickerPopupChanged",
|
||||||
|
detail: {
|
||||||
|
hour,
|
||||||
|
minute,
|
||||||
|
isHourSet,
|
||||||
|
isMinuteSet,
|
||||||
|
isDayPeriodSet
|
||||||
|
}
|
||||||
|
}, "*");
|
||||||
|
},
|
||||||
|
_attachEventListeners() {
|
||||||
|
window.addEventListener('message', this);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle events.
|
||||||
|
*
|
||||||
|
* @param {Event} event
|
||||||
|
*/
|
||||||
|
handleEvent(event) {
|
||||||
|
switch (event.type) {
|
||||||
|
case "message": {
|
||||||
|
this.handleMessage(event);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle postMessage events.
|
||||||
|
*
|
||||||
|
* @param {Event} event
|
||||||
|
*/
|
||||||
|
handleMessage(event) {
|
||||||
|
switch (event.data.name) {
|
||||||
|
case "TimePickerSetValue": {
|
||||||
|
this.set(event.data.detail);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "TimePickerInit": {
|
||||||
|
this.init(event.data.detail);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the time state and update the components with the new state.
|
||||||
|
*
|
||||||
|
* @param {Object} timeState
|
||||||
|
* {
|
||||||
|
* {Number} hour [optional]
|
||||||
|
* {Number} minute [optional]
|
||||||
|
* {Number} second [optional]
|
||||||
|
* {Number} millisecond [optional]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
set(timeState) {
|
||||||
|
if (timeState.hour != undefined) {
|
||||||
|
this.state.isHourSet = true;
|
||||||
|
}
|
||||||
|
if (timeState.minute != undefined) {
|
||||||
|
this.state.isMinuteSet = true;
|
||||||
|
}
|
||||||
|
this.state.timeKeeper.setState(timeState);
|
||||||
|
this._setComponentStates();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
|
@ -62,13 +62,11 @@ this.DateTimePickerHelper = {
|
||||||
if (!this.picker) {
|
if (!this.picker) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.picker.hidePopup();
|
this.picker.closePicker();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "FormDateTime:UpdatePicker": {
|
case "FormDateTime:UpdatePicker": {
|
||||||
let value = aMessage.data.value;
|
this.picker.setPopupValue(aMessage.data);
|
||||||
debug("Input box value is now: " + value.hour + ":" + value.minute);
|
|
||||||
// TODO: updating picker will be handled in Bug 1283384.
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
@ -115,6 +113,14 @@ this.DateTimePickerHelper = {
|
||||||
let dir = aData.dir;
|
let dir = aData.dir;
|
||||||
let type = aData.type;
|
let type = aData.type;
|
||||||
let detail = aData.detail;
|
let detail = aData.detail;
|
||||||
|
|
||||||
|
this._anchor = aBrowser.ownerGlobal.gBrowser.popupAnchor;
|
||||||
|
this._anchor.left = rect.left;
|
||||||
|
this._anchor.top = rect.top;
|
||||||
|
this._anchor.width = rect.width;
|
||||||
|
this._anchor.height = rect.height;
|
||||||
|
this._anchor.hidden = false;
|
||||||
|
|
||||||
debug("Opening picker with details: " + JSON.stringify(detail));
|
debug("Opening picker with details: " + JSON.stringify(detail));
|
||||||
|
|
||||||
let window = aBrowser.ownerDocument.defaultView;
|
let window = aBrowser.ownerDocument.defaultView;
|
||||||
|
@ -132,9 +138,11 @@ this.DateTimePickerHelper = {
|
||||||
debug("aBrowser.dateTimePicker not found, exiting now.");
|
debug("aBrowser.dateTimePicker not found, exiting now.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.picker.hidden = false;
|
this.picker.loadPicker(type, detail);
|
||||||
this.picker.openPopupAtScreenRect("after_start", rect.left, rect.top,
|
// The arrow panel needs an anchor to work. The popupAnchor (this._anchor)
|
||||||
rect.width, rect.height, false, false);
|
// is a transparent div that the arrow can point to.
|
||||||
|
this.picker.openPopup(this._anchor, "after_start", rect.left, rect.top);
|
||||||
|
|
||||||
this.addPickerListeners();
|
this.addPickerListeners();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -143,6 +151,7 @@ this.DateTimePickerHelper = {
|
||||||
this.removePickerListeners();
|
this.removePickerListeners();
|
||||||
this.picker = null;
|
this.picker = null;
|
||||||
this.weakBrowser = null;
|
this.weakBrowser = null;
|
||||||
|
this._anchor.hidden = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Listen to picker's event.
|
// Listen to picker's event.
|
||||||
|
|
|
@ -21,6 +21,7 @@ toolkit.jar:
|
||||||
skin/classic/global/aboutSupport.css (../../shared/aboutSupport.css)
|
skin/classic/global/aboutSupport.css (../../shared/aboutSupport.css)
|
||||||
skin/classic/global/appPicker.css (../../shared/appPicker.css)
|
skin/classic/global/appPicker.css (../../shared/appPicker.css)
|
||||||
skin/classic/global/config.css (../../shared/config.css)
|
skin/classic/global/config.css (../../shared/config.css)
|
||||||
|
skin/classic/global/timepicker.css (../../shared/timepicker.css)
|
||||||
skin/classic/global/icons/find-arrows.svg (../../shared/icons/find-arrows.svg)
|
skin/classic/global/icons/find-arrows.svg (../../shared/icons/find-arrows.svg)
|
||||||
skin/classic/global/icons/info.svg (../../shared/incontent-icons/info.svg)
|
skin/classic/global/icons/info.svg (../../shared/incontent-icons/info.svg)
|
||||||
skin/classic/global/icons/input-clear.svg (../../shared/icons/input-clear.svg)
|
skin/classic/global/icons/input-clear.svg (../../shared/icons/input-clear.svg)
|
||||||
|
|
|
@ -0,0 +1,88 @@
|
||||||
|
/* 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/. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--font-size: 1.2rem;
|
||||||
|
--spinner-item-height: 2rem;
|
||||||
|
--spinner-width: 5rem;
|
||||||
|
--spinner-height: 10rem;
|
||||||
|
--scroller-width: 1.5rem;
|
||||||
|
--disabled-color: #ccc;
|
||||||
|
--selected-color: #fff;
|
||||||
|
--selected-bgcolor: #83BFFC;
|
||||||
|
--hover-bgcolor: #aaa;
|
||||||
|
--hover-outline: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
#time-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-container {
|
||||||
|
font-family: sans-serif;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: var(--spinner-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-container button {
|
||||||
|
-moz-appearance: none;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
height: var(--spinner-item-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-container .stack {
|
||||||
|
position: relative;
|
||||||
|
height: var(--spinner-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-container .spinner {
|
||||||
|
position: absolute;
|
||||||
|
height: var(--spinner-height);
|
||||||
|
width: 100%;
|
||||||
|
cursor: default;
|
||||||
|
overflow-y: scroll;
|
||||||
|
scroll-snap-type: mandatory;
|
||||||
|
scroll-snap-points-y: repeat(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-container .spinner > div {
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
padding: calc(var(--spinner-item-height) / 4) 0;
|
||||||
|
height: calc(var(--spinner-item-height) / 2);
|
||||||
|
-moz-user-select: none;
|
||||||
|
scroll-snap-coordinate: 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-container .spinner > div:last-child {
|
||||||
|
margin-bottom: calc(var(--spinner-item-height) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-container .spinner > div.selection {
|
||||||
|
color: var(--selected-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-container .spinner > div.selection::before {
|
||||||
|
content: "";
|
||||||
|
background: var(--selected-bgcolor);
|
||||||
|
position: absolute;
|
||||||
|
top: 5%;
|
||||||
|
bottom: 5%;
|
||||||
|
left: 10%;
|
||||||
|
right: 10%;
|
||||||
|
border-radius: 5%;
|
||||||
|
z-index: -10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-container .spinner > div.disabled {
|
||||||
|
color: var(--disabled-color);
|
||||||
|
}
|
Загрузка…
Ссылка в новой задаче