Bug 1301284 - Update time picker style to match visual spec; r=mconley
MozReview-Commit-ID: LqYl8qRzlBg
--- a/toolkit/content/timepicker.xhtml
+++ b/toolkit/content/timepicker.xhtml
@@ -10,21 +10,19 @@
<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>
+ <button class="up"/>
+ <div class="spinner"></div>
+ <button class="down"/>
</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).
--- a/toolkit/content/widgets/datetimepopup.xml
+++ b/toolkit/content/widgets/datetimepopup.xml
@@ -10,25 +10,27 @@
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>
+ <field name="TIME_PICKER_WIDTH" readonly="true">"12em"</field>
+ <field name="TIME_PICKER_HEIGHT" readonly="true">"21em"</field>
<method name="loadPicker">
<parameter name="type"/>
<parameter name="detail"/>
<body><![CDATA[
this.hidden = false;
this.type = type;
this.pickerState = {};
+ // TODO: Resize picker according to content zoom level
+ this.style.fontSize = "10px";
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;
--- a/toolkit/content/widgets/spinner.js
+++ b/toolkit/content/widgets/spinner.js
@@ -13,66 +13,75 @@
*/
function Spinner(props, context) {
this.context = context;
this._init(props);
}
{
- const debug = 0 ? console.log.bind(console, '[spinner]') : function() {};
+ const debug = 0 ? console.log.bind(console, "[spinner]") : function() {};
- const ITEM_HEIGHT = 20,
- VIEWPORT_SIZE = 5,
- VIEWPORT_COUNT = 5;
+ const ITEM_HEIGHT = 2.5,
+ VIEWPORT_SIZE = 7,
+ VIEWPORT_COUNT = 5,
+ SCROLL_TIMEOUT = 100;
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.
+ * {Boolean} hideButtons [optional]: Hide up & down buttons
+ * {Number} rootFontSize [optional]: Used to support zoom in/out
* }
*/
_init(props) {
- const { setValue, getDisplayString, itemHeight = ITEM_HEIGHT } = props;
+ const { 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, itemHeight, viewportSize,
+ setValue, getDisplayString, viewportSize, rootFontSize,
// We can assume that the viewportSize is an odd number. Calculate how many
// items we need to insert on top of the spinner so that the selected is at
// the center. Ex: if viewportSize is 5, we need 2 items on top.
viewportTopOffset: (viewportSize - 1) / 2
};
this.elements = {
+ container: spinnerElement.querySelector(".spinner-container"),
spinner: spinnerElement.querySelector(".spinner"),
up: spinnerElement.querySelector(".up"),
down: spinnerElement.querySelector(".down"),
itemsViewElements: []
};
+ this.elements.spinner.style.height = (ITEM_HEIGHT * viewportSize) + "rem";
+
+ if (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.
@@ -114,17 +123,17 @@ function Spinner(props, context) {
* - 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 { viewportSize, viewportTopOffset } = 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.
@@ -143,16 +152,26 @@ function Spinner(props, context) {
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);
}
}
+
+ // Use a timer to detect if a scroll event has not fired within some time
+ // (defined in SCROLL_TIMEOUT). This is required because we need to hide
+ // highlight and hover state when user is scrolling.
+ clearTimeout(this.state.scrollTimer);
+ this.elements.spinner.classList.add("scrolling");
+ this.state.scrollTimer = setTimeout(() => {
+ this.elements.spinner.classList.remove("scrolling");
+ this.elements.spinner.dispatchEvent(new CustomEvent("ScrollStop"));
+ }, SCROLL_TIMEOUT);
},
/**
* Updates the spinner items to the current states.
*/
_updateItems() {
const { viewportSize, viewportTopOffset } = this.props;
const { items, isInfiniteScroll } = this.state;
@@ -186,35 +205,46 @@ function Spinner(props, context) {
* 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) {
+ return;
+ }
if (diff > 0) {
// Add more elements if length is greater than current
let frag = document.createDocumentFragment();
- for (let i = 0; i < count; i++) {
+ // Remove margin bottom on the last element before appending
+ if (parent.lastChild) {
+ parent.lastChild.style.marginBottom = "";
+ }
+
+ for (let i = 0; i < diff; i++) {
let el = document.createElement("div");
frag.appendChild(el);
this.elements.itemsViewElements.push(el);
}
parent.appendChild(frag);
} else if (diff < 0) {
// Remove elements if length is less than current
- for (let i = 0; i < count; i++) {
+ 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
@@ -255,37 +285,44 @@ function Spinner(props, context) {
switch (event.type) {
case "scroll": {
this._onScroll();
break;
}
case "mousedown": {
// Use preventDefault to keep focus on input boxes
event.preventDefault();
+ event.target.setCapture();
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
- 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 == 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
@@ -321,17 +358,17 @@ function Spinner(props, context) {
},
/**
* 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);
+ 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
@@ -376,17 +413,17 @@ function Spinner(props, context) {
* @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;
+ this.elements.spinner.scrollTop = this.state.index * ITEM_HEIGHT * this.props.rootFontSize;
}
},
/**
* Smooth scroll to a value.
*
* @param {Number/String} value: Value to scroll to
*/
--- a/toolkit/content/widgets/timepicker.js
+++ b/toolkit/content/widgets/timepicker.js
@@ -1,21 +1,21 @@
/* 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';
+"use strict";
function TimePicker(context) {
this.context = context;
this._attachEventListeners();
}
{
- const debug = 0 ? console.log.bind(console, '[timepicker]') : function() {};
+ 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 = {
/**
@@ -56,19 +56,16 @@ function TimePicker(context) {
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.
*/
@@ -113,30 +110,62 @@ function TimePicker(context) {
setValue: wrapSetValueFn(value => {
timeKeeper.setMinute(value);
this.state.isMinuteSet = true;
}),
getDisplayString: minute => numberFormat(minute)
}, this.context)
};
+ this._insertLayoutElement({
+ tag: "div",
+ textContent: ":",
+ className: "colon",
+ insertBefore: this.components.minute.elements.container
+ });
+
// 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"
+ getDisplayString: dayPeriod => dayPeriod == 0 ? "AM" : "PM",
+ hideButtons: true
}, this.context);
+
+ this._insertLayoutElement({
+ tag: "div",
+ className: "spacer",
+ insertBefore: this.components.dayPeriod.elements.container
+ });
}
},
/**
+ * Insert element for layout purposes.
+ *
+ * @param {Object}
+ * {
+ * {String} tag: The tag to create
+ * {DOMElement} insertBefore: The DOM node to insert before
+ * {String} className [optional]: Class name
+ * {String} textContent [optional]: Text content
+ * }
+ */
+ _insertLayoutElement({ tag, insertBefore, className, textContent }) {
+ let el = document.createElement(tag);
+ el.textContent = textContent;
+ el.className = className;
+ this.context.insertBefore(el, insertBefore);
+ },
+
+ /**
* 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;
@@ -183,17 +212,17 @@ function TimePicker(context) {
minute,
isHourSet,
isMinuteSet,
isDayPeriodSet
}
}, "*");
},
_attachEventListeners() {
- window.addEventListener('message', this);
+ window.addEventListener("message", this);
},
/**
* Handle events.
*
* @param {Event} event
*/
handleEvent(event) {
--- a/toolkit/themes/shared/timepicker.css
+++ b/toolkit/themes/shared/timepicker.css
@@ -1,88 +1,153 @@
/* 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;
+ --font-size-default: 1.1rem;
+ --spinner-width: 3rem;
+ --spinner-margin-top-bottom: 0.4rem;
+ --spinner-item-height: 2.4rem;
+ --spinner-item-margin-bottom: 0.1rem;
+ --spinner-button-height: 1.2rem;
+ --colon-width: 2rem;
+ --day-period-spacing-width: 1rem;
+
+ --border: 0.1rem solid #D6D6D6;
+ --border-radius: 0.3rem;
+
+ --font-color: #191919;
+ --fill-color: #EBEBEB;
+
+ --selected-font-color: #FFFFFF;
+ --selected-fill-color: #0996F8;
+
+ --button-font-color: #858585;
+ --button-font-color-hover: #4D4D4D;
+ --button-font-color-active: #191919;
+
+ --disabled-opacity: 0.2;
+}
+
+html {
+ font-size: 10px;
}
body {
margin: 0;
- font-size: var(--font-size);
+ color: var(--font-color);
+ font-size: var(--font-size-default);
}
#time-picker {
display: flex;
flex-direction: row;
+ justify-content: space-around;
}
.spinner-container {
font-family: sans-serif;
display: flex;
flex-direction: column;
width: var(--spinner-width);
}
-.spinner-container button {
+.spinner-container > button {
-moz-appearance: none;
border: none;
background: none;
- height: var(--spinner-item-height);
+ background-color: var(--button-font-color);
+ height: var(--spinner-button-height);
+}
+
+.spinner-container > button:hover {
+ background-color: var(--button-font-color-hover);
+}
+
+.spinner-container > button.active {
+ background-color: var(--button-font-color-active);
}
-.spinner-container .stack {
- position: relative;
- height: var(--spinner-height);
+.spinner-container > button.up {
+ mask: url("chrome://global/skin/icons/find-arrows.svg#glyph-find-previous") no-repeat 50% 50%;
+}
+
+.spinner-container > button.down {
+ mask: url("chrome://global/skin/icons/find-arrows.svg#glyph-find-next") no-repeat 50% 50%;
}
-.spinner-container .spinner {
- position: absolute;
- height: var(--spinner-height);
+.spinner-container.hide-buttons > button {
+ visibility: hidden;
+}
+
+.spinner-container > .spinner {
+ position: relative;
width: 100%;
+ margin: var(--spinner-margin-top-bottom) 0;
cursor: default;
overflow-y: scroll;
scroll-snap-type: mandatory;
scroll-snap-points-y: repeat(100%);
}
-.spinner-container .spinner > div {
+.spinner-container > .spinner > div {
+ box-sizing: border-box;
position: relative;
text-align: center;
- padding: calc(var(--spinner-item-height) / 4) 0;
- height: calc(var(--spinner-item-height) / 2);
+ padding: calc((var(--spinner-item-height) - var(--font-size-default)) / 2) 0;
+ margin-bottom: var(--spinner-item-margin-bottom);
+ height: var(--spinner-item-height);
-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 {
+.spinner-container > .spinner > div:hover::before {
+ background: var(--fill-color);
+ border: var(--border);
+ border-radius: var(--border-radius);
content: "";
- background: var(--selected-bgcolor);
position: absolute;
- top: 5%;
- bottom: 5%;
- left: 10%;
- right: 10%;
- border-radius: 5%;
+ top: 0%;
+ bottom: 0%;
+ left: 0%;
+ right: 0%;
z-index: -10;
}
-.spinner-container .spinner > div.disabled {
- color: var(--disabled-color);
+.spinner-container > .spinner:not(.scrolling) > div.selection {
+ color: var(--selected-font-color);
+}
+
+.spinner-container > .spinner > div.selection::before {
+ background: var(--selected-fill-color);
+ border: none;
+ border-radius: var(--border-radius);
+ content: "";
+ position: absolute;
+ top: 0%;
+ bottom: 0%;
+ left: 0%;
+ right: 0%;
+ z-index: -10;
}
+
+.spinner-container > .spinner > div.disabled::before,
+.spinner-container > .spinner.scrolling > div.selection::before,
+.spinner-container > .spinner.scrolling > div:hover::before {
+ display: none;
+}
+
+.spinner-container > .spinner > div.disabled {
+ opacity: var(--disabled-opacity);
+}
+
+.colon {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: var(--colon-width);
+ margin-bottom: 0.3rem;
+}
+
+.spacer {
+ width: var(--day-period-spacing-width);
+}
\ No newline at end of file