blob: 9f2f29bd75419fdc3719c7f8d8bb64c8b55c3dc8 [file] [log] [blame]
// Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
(function() {
/**
* @fileoverview
* night-light-slider is used to set the custom automatic schedule of the
* Night Light feature, so that users can set their desired start and end
* times.
*/
const HOURS_PER_DAY = 24;
const MIN_KNOBS_DISTANCE_MINUTES = 60;
const OFFSET_MINUTES_6PM = 18 * 60;
const TOTAL_MINUTES_PER_DAY = 24 * 60;
Polymer({
is: 'night-light-slider',
behaviors: [
PrefsBehavior,
Polymer.IronA11yKeysBehavior,
Polymer.IronResizableBehavior,
Polymer.PaperInkyFocusBehavior,
],
properties: {
/**
* Whether the element is ready and fully rendered.
* @private
*/
isReady_: Boolean,
/**
* Whether the window is in RTL locales.
* @private
*/
isRTL_: Boolean,
/**
* Whether to use the 24-hour format for the time shown in the label
* bubbles.
* @private
*/
shouldUse24Hours_: Boolean,
},
listeners: {
'iron-resize': 'onResize_',
},
observers: [
'updateKnobs_(prefs.ash.night_light.custom_start_time.*, ' +
'prefs.ash.night_light.custom_end_time.*, isRTL_, isReady_)',
'hourFormatChanged_(prefs.settings.clock.use_24hour_clock.*)',
],
keyBindings: {
'left': 'onLeftKey_',
'right': 'onRightKey_',
},
/**
* The object currently being dragged. Either the start or end knobs.
* @type {?Object}
* @private
*/
dragObject_: null,
/** @override */
attached: function() {
// Build the legend markers.
const markersContainer = this.$.markersContainer;
const width = markersContainer.offsetWidth;
for (let i = 0; i <= HOURS_PER_DAY; ++i) {
const marker = document.createElement('div');
marker.className = 'markers';
markersContainer.appendChild(marker);
marker.style.left = (i * 100 / HOURS_PER_DAY) + '%';
}
this.isRTL_ = window.getComputedStyle(this).direction == 'rtl';
this.$.sliderContainer.addEventListener('contextmenu', function(e) {
// Prevent the context menu from interfering with dragging the knobs using
// touch.
e.preventDefault();
return false;
});
this.async(function() {
// This is needed to make sure that the positions of the knobs and their
// label bubbles are correctly updated when the display settings page is
// opened for the first time after login. The page need to be fully
// rendered.
this.isReady_ = true;
});
},
/**
* Invoked when the element is resized and the knobs positions need to be
* updated.
* @private
*/
onResize_: function() {
this.updateKnobs_();
},
/**
* Called when the value of the pref associated with whether to use the
* 24-hour clock format is changed. This will also refresh the slider.
* @private
*/
hourFormatChanged_: function() {
this.shouldUse24Hours_ = /** @type {boolean} */ (
this.getPref('settings.clock.use_24hour_clock').value);
},
/**
* Gets the style of legend div determining its absolute left position.
* @param {number} percent The value of the div's left as a percent (0 - 100).
* @param {boolean} isRTL whether window is in RTL locale.
* @return {string} The CSS style of the legend div.
* @private
*/
getLegendStyle_: function(percent, isRTL) {
percent = isRTL ? 100 - percent : percent;
return 'left: ' + percent + '%';
},
/**
* Expands or un-expands the knob being dragged along with its corresponding
* label bubble.
* @param {boolean} expand True to expand, and false to un-expand.
* @private
*/
setExpanded_: function(expand) {
let knob = this.$.startKnob;
let label = this.$.startLabel;
if (this.dragObject_ == this.$.endKnob) {
knob = this.$.endKnob;
label = this.$.endLabel;
}
knob.classList.toggle('expanded-knob', expand);
label.classList.toggle('expanded-knob', expand);
},
/**
* If one of the two knobs is focused, this function blurs it.
* @private
*/
blurAnyFocusedKnob_: function() {
const activeElement = this.shadowRoot.activeElement;
if (activeElement == this.$.startKnob || activeElement == this.$.endKnob)
activeElement.blur();
},
/**
* Start dragging the target knob.
* @private
*/
startDrag_: function(event) {
event.preventDefault();
// Only handle start or end knobs. Use the "knob-inner" divs just to display
// the knobs.
if (event.target == this.$.startKnob ||
event.target == this.$.startKnob.firstElementChild) {
this.dragObject_ = this.$.startKnob;
} else if (event.target == this.$.endKnob ||
event.target == this.$.endKnob.firstElementChild) {
this.dragObject_ = this.$.endKnob;
} else {
return;
}
this.setExpanded_(true);
// Focus is only given to the knobs by means of keyboard tab navigations.
// When we start dragging, we don't want to see any focus halos around any
// knob.
this.blurAnyFocusedKnob_();
// However, our night-light-slider element must get the focus.
this.focus();
},
/**
* Continues dragging the selected knob if any.
* @private
*/
continueDrag_: function(event) {
if (!this.dragObject_)
return;
event.stopPropagation();
switch (event.detail.state) {
case 'start':
this.startDrag_(event);
break;
case 'track':
this.doKnobTracking_(event);
break;
case 'end':
this.endDrag_(event);
break;
}
},
/**
* Updates the knob's corresponding pref value in response to dragging, which
* will in turn update the location of the knob and its corresponding label
* bubble and its text contents.
* @private
*/
doKnobTracking_: function(event) {
const deltaRatio =
Math.abs(event.detail.ddx) / this.$.sliderBar.offsetWidth;
const deltaMinutes = Math.floor(deltaRatio * TOTAL_MINUTES_PER_DAY);
if (deltaMinutes <= 0)
return;
const knobPref = this.dragObject_ == this.$.startKnob ?
'ash.night_light.custom_start_time' :
'ash.night_light.custom_end_time';
const ddx = this.isRTL_ ? event.detail.ddx * -1 : event.detail.ddx;
if (ddx > 0) {
// Increment the knob's pref by the amount of deltaMinutes.
this.incrementPref_(knobPref, deltaMinutes);
} else {
// Decrement the knob's pref by the amount of deltaMinutes.
this.decrementPref_(knobPref, deltaMinutes);
}
},
/**
* Ends the dragging.
* @private
*/
endDrag_: function(event) {
event.preventDefault();
this.setExpanded_(false);
this.dragObject_ = null;
},
/**
* Gets the given knob's offset ratio with respect to its parent element
* (which is the slider bar).
* @param {HTMLDivElement} knob Either one of the two knobs.
* @return {number}
* @private
*/
getKnobRatio_: function(knob) {
return parseFloat(knob.style.left) / this.$.sliderBar.offsetWidth;
},
/**
* Converts the time of day, given as |hour| and |minutes|, to its language-
* sensitive time string representation.
* @param {number} hour The hour of the day (0 - 23).
* @param {number} minutes The minutes of the hour (0 - 59).
* @param {boolean} shouldUse24Hours Whether to use the 24-hour time format.
* @return {string}
* @private
*/
getLocaleTimeString_: function(hour, minutes, shouldUse24Hours) {
const d = new Date();
d.setHours(hour);
d.setMinutes(minutes);
d.setSeconds(0);
d.setMilliseconds(0);
return d.toLocaleTimeString(
[], {hour: '2-digit', minute: '2-digit', hour12: !shouldUse24Hours});
},
/**
* Converts the |offsetMinutes| value (which the number of minutes since
* 00:00) to its language-sensitive time string representation.
* @param {number} offsetMinutes The time of day represented as the number of
* minutes from 00:00.
* @param {boolean} shouldUse24Hours Whether to use the 24-hour time format.
* @return {string}
* @private
*/
getTimeString_: function(offsetMinutes, shouldUse24Hours) {
const hour = Math.floor(offsetMinutes / 60);
const minute = Math.floor(offsetMinutes % 60);
return this.getLocaleTimeString_(hour, minute, shouldUse24Hours);
},
/**
* Using the current start and end times prefs, this function updates the
* knobs and their label bubbles and refreshes the slider.
* @private
*/
updateKnobs_: function() {
const startOffsetMinutes = /** @type {number} */ (
this.getPref('ash.night_light.custom_start_time').value);
this.updateKnobLeft_(this.$.startKnob, startOffsetMinutes);
const endOffsetMinutes = /** @type {number} */ (
this.getPref('ash.night_light.custom_end_time').value);
this.updateKnobLeft_(this.$.endKnob, endOffsetMinutes);
this.refresh_();
},
/**
* Updates the absolute left coordinate of the given |knob| based on the time
* it represents given as an |offsetMinutes| value.
* @param {HTMLDivElement} knob
* @param {number} offsetMinutes
* @private
*/
updateKnobLeft_: function(knob, offsetMinutes) {
const offsetAfter6pm =
(offsetMinutes + TOTAL_MINUTES_PER_DAY - OFFSET_MINUTES_6PM) %
TOTAL_MINUTES_PER_DAY;
let ratio = offsetAfter6pm / TOTAL_MINUTES_PER_DAY;
if (ratio == 0) {
// If the ratio is 0, then there are two possibilities:
// - The knob time is 6:00 PM on the left side of the slider.
// - The knob time is 6:00 PM on the right side of the slider.
// We need to check the current knob offset ratio to determine which case
// it is.
const currentKnobRatio = this.getKnobRatio_(knob);
ratio = currentKnobRatio > 0.5 ? 1.0 : 0.0;
}
ratio = this.isRTL_ ? (1.0 - ratio) : ratio;
knob.style.left = (ratio * this.$.sliderBar.offsetWidth) + 'px';
},
/**
* Refreshes elements of the slider other than the knobs (the label bubbles,
* and the progress bar).
* @private
*/
refresh_: function() {
// The label bubbles have the same left coordinates as their corresponding
// knobs.
this.$.startLabel.style.left = this.$.startKnob.style.left;
this.$.endLabel.style.left = this.$.endKnob.style.left;
// In RTL locales, the relative positions of the knobs are flipped for the
// purpose of calculating the styles of the progress bars below.
const rtl = this.isRTL_;
const endKnob = rtl ? this.$.startKnob : this.$.endKnob;
const startKnob = rtl ? this.$.endKnob : this.$.startKnob;
const startProgress = rtl ? this.$.endProgress : this.$.startProgress;
const endProgress = rtl ? this.$.startProgress : this.$.endProgress;
// The end progress bar starts from either the start knob or the start of
// the slider (whichever is to its left) and ends at the end knob.
const endProgressLeft = startKnob.offsetLeft >= endKnob.offsetLeft ?
'0px' :
startKnob.style.left;
endProgress.style.left = endProgressLeft;
endProgress.style.width =
(parseFloat(endKnob.style.left) - parseFloat(endProgressLeft)) + 'px';
// The start progress bar starts at the start knob, and ends at either the
// end knob or the end of the slider (whichever is to its right).
const startProgressRight = endKnob.offsetLeft < startKnob.offsetLeft ?
this.$.sliderBar.offsetWidth :
endKnob.style.left;
startProgress.style.left = startKnob.style.left;
startProgress.style.width =
(parseFloat(startProgressRight) - parseFloat(startKnob.style.left)) +
'px';
this.fixLabelsOverlapIfAny_();
},
/**
* If the label bubbles overlap, this function fixes them by moving the end
* label up a little.
* @private
*/
fixLabelsOverlapIfAny_: function() {
const startLabel = this.$.startLabel;
const endLabel = this.$.endLabel;
const distance = Math.abs(
parseFloat(startLabel.style.left) - parseFloat(endLabel.style.left));
// Both knobs have the same width, but the one being dragged is scaled up by
// 125%.
if (distance <= (1.25 * startLabel.offsetWidth)) {
// Shift the end label up so that it doesn't overlap with the start label.
endLabel.classList.add('end-label-overlap');
} else {
endLabel.classList.remove('end-label-overlap');
}
},
/**
* Given the |prefPath| that corresponds to one knob time, it gets the value
* of the pref that corresponds to the other knob.
* @param {string} prefPath
* @return {number}
* @private
*/
getOtherKnobPrefValue_: function(prefPath) {
if (prefPath == 'ash.night_light.custom_start_time') {
return /** @type {number} */ (
this.getPref('ash.night_light.custom_end_time').value);
}
return /** @type {number} */ (
this.getPref('ash.night_light.custom_start_time').value);
},
/**
* Increments the value of the pref whose path is given by |prefPath| by the
* amount given in |increment|.
* @param {string} prefPath
* @param {number} increment
* @private
*/
incrementPref_: function(prefPath, increment) {
let value = this.getPref(prefPath).value + increment;
const otherValue = this.getOtherKnobPrefValue_(prefPath);
if (otherValue > value &&
((otherValue - value) < MIN_KNOBS_DISTANCE_MINUTES)) {
// We are incrementing the minutes offset moving towards the other knob.
// We have a minimum 60 minutes overlap threshold. Move this knob to the
// other side of the other knob.
//
// Was:
// ------ (+) --- 59 MIN --- + ------->>
//
// Now:
// ------ + --- 60 MIN --- (+) ------->>
//
// (+) ==> Knob being moved.
value = otherValue + MIN_KNOBS_DISTANCE_MINUTES;
}
// The knobs are allowed to wrap around.
this.setPrefValue(prefPath, (value % TOTAL_MINUTES_PER_DAY));
},
/**
* Decrements the value of the pref whose path is given by |prefPath| by the
* amount given in |decrement|.
* @param {string} prefPath
* @param {number} decrement
* @private
*/
decrementPref_: function(prefPath, decrement) {
let value =
/** @type {number} */ (this.getPref(prefPath).value) - decrement;
const otherValue = this.getOtherKnobPrefValue_(prefPath);
if (value > otherValue &&
((value - otherValue) < MIN_KNOBS_DISTANCE_MINUTES)) {
// We are decrementing the minutes offset moving towards the other knob.
// We have a minimum 60 minutes overlap threshold. Move this knob to the
// other side of the other knob.
//
// Was:
// <<------ + --- 59 MIN --- (+) -------
//
// Now:
// <<------ (+) --- 60 MIN --- + ------
//
// (+) ==> Knob being moved.
value = otherValue - MIN_KNOBS_DISTANCE_MINUTES;
}
// The knobs are allowed to wrap around.
if (value < 0)
value += TOTAL_MINUTES_PER_DAY;
this.setPrefValue(prefPath, Math.abs(value) % TOTAL_MINUTES_PER_DAY);
},
/**
* Gets the pref path of the currently focused knob. Returns null if no knob
* is currently focused.
* @return {?string}
* @private
*/
getFocusedKnobPrefPathIfAny_: function() {
const focusedElement = this.shadowRoot.activeElement;
if (focusedElement == this.$.startKnob)
return 'ash.night_light.custom_start_time';
if (focusedElement == this.$.endKnob)
return 'ash.night_light.custom_end_time';
return null;
},
/**
* Handles the 'left' key event.
* @private
*/
onLeftKey_: function(e) {
e.preventDefault();
const knobPref = this.getFocusedKnobPrefPathIfAny_();
if (!knobPref)
return;
if (this.isRTL_)
this.incrementPref_(knobPref, 1);
else
this.decrementPref_(knobPref, 1);
},
/**
* Handles the 'right' key event.
* @private
*/
onRightKey_: function(e) {
e.preventDefault();
const knobPref = this.getFocusedKnobPrefPathIfAny_();
if (!knobPref)
return;
if (this.isRTL_)
this.decrementPref_(knobPref, 1);
else
this.incrementPref_(knobPref, 1);
},
/**
* @return {boolean} Whether either of the two knobs is focused.
* @private
*/
isEitherKnobFocused_: function() {
const activeElement = this.shadowRoot.activeElement;
return activeElement == this.$.startKnob || activeElement == this.$.endKnob;
},
/**
* Overrides _createRipple() from PaperInkyFocusBehavior to create the ripple
* only on a knob if it's focused, or on a dummy hidden element so that it
* doesn't show.
* @private
*/
_createRipple: function() {
if (this.isEitherKnobFocused_()) {
this._rippleContainer = this.shadowRoot.activeElement;
} else {
// We can't just skip the ripple creation and return early with null here.
// The code inherited from PaperInkyFocusBehavior expects that this
// function returns a ripple element. So to avoid crashes, we'll setup the
// ripple to be created under a hidden element.
this._rippleContainer = this.$.dummyRippleContainer;
}
return Polymer.PaperInkyFocusBehaviorImpl._createRipple.call(this);
},
/**
* Handles focus events on the start and end knobs.
* @private
*/
onFocus_: function() {
this.ensureRipple();
if (this.hasRipple()) {
this._ripple.style.display = '';
this._ripple.holdDown = true;
}
},
/**
* Handles blur events on the start and end knobs.
* @private
*/
onBlur_: function() {
if (this.hasRipple()) {
this._ripple.remove();
this._ripple = null;
}
},
/** @private */
_focusedChanged: function(receivedFocusFromKeyboard) {
// Overrides the _focusedChanged() from the PaperInkyFocusBehavior so that
// it does nothing. This function is called only once for the entire
// night-light-slider element even when focus is moved between the two
// knobs. This doesn't allow us to decide on which knob the ripple will be
// created. Hence we handle focus and blur explicitly above.
}
});
})();