blob: 35214d14c30be9004c178d0900a58e4a789ff2b1 [file] [log] [blame]
// Copyright 2018 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.
/**
* @fileoverview 'cr-slider' is a slider component used to select a number from
* a continuous or discrete range of numbers.
*/
cr.exportPath('cr_slider');
/**
* The |value| is the corresponding value that the current slider tick is
* associated with. The string |label| is shown in the UI as the label for the
* current slider value. The |ariaValue| number is used for aria-valuemin,
* aria-valuemax, and aria-valuenow, and is optional. If missing, |value| will
* be used instead.
* @typedef {{
* value: number,
* label: string,
* ariaValue: (number|undefined),
* }}
*/
cr_slider.SliderTick;
(() => {
/**
* @param {number} min
* @param {number} max
* @param {number} value
* @return {number}
*/
function clamp(min, max, value) {
return Math.min(max, Math.max(min, value));
}
/**
* The following are the events emitted from cr-slider.
*
* cr-slider-value-changed-from-ui: fired when updating slider via the UI.
* dragging-changed: fired on pointer down and on pointer up.
* value-changed: fired anytime |value| is changed, manually or via the UI.
*/
Polymer({
is: 'cr-slider',
behaviors: [
Polymer.PaperRippleBehavior,
],
properties: {
disabled: {
type: Boolean,
value: false,
},
/**
* Internal representation of disabled depending on |disabled| and
* |ticks|.
* @private
*/
disabled_: {
type: Boolean,
computed: 'computeDisabled_(disabled, ticks.*)',
reflectToAttribute: true,
observer: 'onDisabledChanged_',
},
dragging: {
type: Boolean,
value: false,
notify: true,
reflectToAttribute: true,
},
markerCount: {
type: Number,
value: 0,
},
max: {
type: Number,
value: 100,
},
min: {
type: Number,
value: 0,
},
/**
* When set to false, the keybindings are not handled by this component,
* for example when the owner of the component wants to set up its own
* keybindings.
*/
noKeybindings: {
type: Boolean,
value: false,
},
snaps: {
type: Boolean,
value: false,
},
/**
* The data associated with each tick on the slider. Each element in the
* array contains a value and the label corresponding to that value.
* @type {!Array<cr_slider.SliderTick>|!Array<number>}
*/
ticks: {
type: Array,
value: () => [],
},
value: {
type: Number,
value: 0,
notify: true,
observer: 'onValueChanged_',
},
/** @private */
holdDown_: {
type: Boolean,
value: false,
observer: 'onHoldDownChanged_',
reflectToAttribute: true,
},
/** @private */
label_: {
type: String,
value: '',
},
/** @private */
isRtl_: {
type: Boolean,
value: false,
reflectToAttribute: true,
},
/**
* |transiting_| is set to true when bar is touched or clicked. This
* triggers a single position transition effect to take place for the
* knob, bar and label. When the transition is complete, |transiting_| is
* set to false resulting in no transition effect during dragging, manual
* value updates and keyboard events.
* @private
*/
transiting_: {
type: Boolean,
value: false,
reflectToAttribute: true,
},
},
hostAttributes: {
role: 'slider',
},
observers: [
'onTicksChanged_(ticks.*)',
'updateLabelAndAria_(value, min, max)',
'updateKnobAndBar_(value, min, max)',
],
listeners: {
focus: 'onFocus_',
blur: 'onBlur_',
keydown: 'onKeyDown_',
pointerdown: 'onPointerDown_',
},
/** @private {Map<string, number>} */
deltaKeyMap_: null,
/** @private {EventTracker} */
draggingEventTracker_: null,
/** @override */
attached: function() {
this.isRtl_ = window.getComputedStyle(this)['direction'] === 'rtl';
this.deltaKeyMap_ = new Map([
['ArrowDown', -1],
['ArrowUp', 1],
['PageDown', -1],
['PageUp', 1],
['ArrowLeft', this.isRtl_ ? 1 : -1],
['ArrowRight', this.isRtl_ ? -1 : 1],
]);
this.draggingEventTracker_ = new EventTracker();
},
/** @private */
computeDisabled_: function() {
return this.disabled || this.ticks.length == 1;
},
/**
* When markers are displayed on the slider, they are evenly spaced across
* the entire slider bar container and are rendered on top of the bar and
* bar container. The location of the marks correspond to the discrete
* values that the slider can have.
* @return {!Array} The array items have no type since this is used to
* create |markerCount| number of markers.
* @private
*/
getMarkers_: function() {
return new Array(Math.max(0, this.markerCount - 1));
},
/**
* @param {number} index
* @return {string}
* @private
*/
getMarkerClass_: function(index) {
const currentStep = (this.markerCount - 1) * this.getRatio();
return index < currentStep ? 'active-marker' : 'inactive-marker';
},
/**
* The ratio is a value from 0 to 1.0 corresponding to a location along the
* slider bar where 0 is the minimum value and 1.0 is the maximum value.
* This is a helper function used to calculate the bar width, knob location
* and label location.
* @return {number}
*/
getRatio: function() {
return (this.value - this.min) / (this.max - this.min);
},
/** @private */
ensureValidValue_: function() {
if (this.value == undefined)
return;
let validValue = clamp(this.min, this.max, this.value);
validValue = this.snaps ? Math.round(validValue) : validValue;
this.value = validValue;
},
/**
* Removes all event listeners related to dragging, and cancels ripple.
* @param {number} pointerId
* @private
*/
stopDragging_: function(pointerId) {
this.draggingEventTracker_.removeAll();
this.releasePointerCapture(pointerId);
this.dragging = false;
// If there is a ripple animation in progress, setTimeout will hold off
// on updating |holdDown_|.
setTimeout(() => {
this.holdDown_ = false;
});
},
/** @private */
onBlur_: function() {
this.holdDown_ = false;
},
/** @private */
onDisabledChanged_: function() {
this.setAttribute('tabindex', this.disabled_ ? -1 : 0);
this.$.knob.setAttribute('tabindex', this.disabled_ ? -1 : 0);
this.blur();
},
/** @private */
onFocus_: function() {
this.holdDown_ = true;
},
/** @private */
onHoldDownChanged_: function() {
this.getRipple().holdDown = this.holdDown_;
},
/**
* @param {!Event} event
* @private
*/
onKeyDown_: function(event) {
if (this.disabled_ || this.noKeybindings)
return;
if (event.metaKey || event.shiftKey || event.altKey || event.ctrlKey)
return;
let handled = true;
if (event.key == 'Home') {
this.value = this.min;
} else if (event.key == 'End') {
this.value = this.max;
} else if (this.deltaKeyMap_.has(event.key)) {
const newValue = this.value + this.deltaKeyMap_.get(event.key);
this.value = clamp(this.min, this.max, newValue);
} else {
handled = false;
}
if (handled) {
this.fire('cr-slider-value-changed-from-ui');
event.preventDefault();
event.stopPropagation();
setTimeout(() => {
this.holdDown_ = true;
});
}
},
/** @private */
onKnobTransitionEnd_: function() {
this.transiting_ = false;
},
/**
* When the left-mouse button is pressed, the knob location is updated and
* dragging starts.
* @param {!PointerEvent} event
* @private
*/
onPointerDown_: function(event) {
if (this.disabled_ || event.buttons != 1 && event.pointerType == 'mouse')
return;
this.dragging = true;
this.transiting_ = true;
this.updateValueFromClientX_(event.clientX);
// If there is a ripple animation in progress, setTimeout will hold off on
// updating |holdDown_|.
setTimeout(() => {
this.$.knob.focus();
this.holdDown_ = true;
});
this.setPointerCapture(event.pointerId);
const stopDragging = this.stopDragging_.bind(this, event.pointerId);
this.draggingEventTracker_.add(this, 'pointermove', e => {
// If the left-button on the mouse is pressed by itself, then update.
// Otherwise stop capturing the mouse events because the drag operation
// is complete.
if (e.buttons != 1 && e.pointerType == 'mouse') {
stopDragging();
return;
}
this.updateValueFromClientX_(e.clientX);
});
this.draggingEventTracker_.add(this, 'pointercancel', stopDragging);
this.draggingEventTracker_.add(this, 'pointerdown', stopDragging);
this.draggingEventTracker_.add(this, 'pointerup', stopDragging);
this.draggingEventTracker_.add(this, 'keydown', e => {
if (e.key == 'Escape' || e.key == 'Tab')
stopDragging();
});
},
/** @private */
onTicksChanged_: function() {
if (this.ticks.length == 0) {
this.snaps = false;
} else if (this.ticks.length > 1) {
this.snaps = true;
this.max = this.ticks.length - 1;
this.min = 0;
}
this.ensureValidValue_();
this.updateLabelAndAria_();
},
/**
* Update |value| which is used for rendering when |value| is
* updated either programmatically or from a keyboard input or a mouse drag.
* @private
*/
onValueChanged_: function() {
this.ensureValidValue_();
},
/** @private */
updateKnobAndBar_: function() {
const percent = `${this.getRatio() * 100}%`;
this.$.bar.style.width = percent;
this.$.knob.style.marginInlineStart = percent;
},
/** @private */
updateLabelAndAria_: function() {
const ticks = this.ticks;
const index = this.value;
if (!ticks || ticks.length == 0 || index >= ticks.length ||
!Number.isInteger(index) || !this.snaps) {
this.setAttribute('aria-valuetext', index);
this.setAttribute('aria-valuemin', this.min);
this.setAttribute('aria-valuemax', this.max);
this.setAttribute('aria-valuenow', index);
return;
}
const tick = ticks[index];
this.label_ = Number.isFinite(tick) ? '' : tick.label;
// Update label location after it has been rendered.
this.async(() => {
const label = this.$.label;
const parentWidth = label.parentElement.offsetWidth;
const labelWidth = label.offsetWidth;
// The left and right margin are 16px.
const margin = 16;
const knobLocation = parentWidth * this.getRatio() + margin;
const offsetStart = knobLocation - (labelWidth / 2);
// The label should be centered over the knob. Clamping the offset to a
// min and max value prevents the label from being cutoff.
const max = parentWidth + 2 * margin - labelWidth;
label.style.marginInlineStart =
`${Math.round(clamp(0, max, offsetStart))}px`;
});
const ariaValues = [tick, ticks[0], ticks[ticks.length - 1]].map(t => {
if (Number.isFinite(t))
return t;
return Number.isFinite(t.ariaValue) ? t.ariaValue : t.value;
});
this.setAttribute(
'aria-valuetext',
this.label_.length > 0 ? this.label_ : ariaValues[0]);
this.setAttribute('aria-valuenow', ariaValues[0]);
this.setAttribute('aria-valuemin', ariaValues[1]);
this.setAttribute('aria-valuemax', ariaValues[2]);
},
/**
* @param {number} clientX
* @private
*/
updateValueFromClientX_: function(clientX) {
const rect = this.$.barContainer.getBoundingClientRect();
let ratio = (clientX - rect.left) / rect.width;
if (this.isRtl_)
ratio = 1 - ratio;
this.value = ratio * (this.max - this.min) + this.min;
this.fire('cr-slider-value-changed-from-ui');
},
_createRipple: function() {
this._rippleContainer = this.$.knob;
const ripple = Polymer.PaperRippleBehavior._createRipple();
ripple.id = 'ink';
ripple.setAttribute('recenters', '');
ripple.classList.add('circle', 'toggle-ink');
return ripple;
},
});
})();