console
const InputSpinner = {
name: "InputSpinner",
template: `
<div
class="input-spinner"
:class="{'is-dragging': dragging }"
>
<div
class="input-spinner__track u-flex-center"
:class="{'is-min': isMin, 'is-max': isMax }"
>
<svg class="input-spinner__icon input-spinner__icon--minus" version="1.1" xmlns="http://www.w3.org/2000/svg" width="640" height="640" viewBox="0 0 640 640">
<path d="M512 320c0 17.696-1.536 32-19.232 32h-345.536c-17.664 0-19.232-14.304-19.232-32s1.568-32 19.232-32h345.568c17.664 0 19.2 14.304 19.2 32z"></path>
</svg>
<div
class="input-spinner__knob u-flex-center"
ref="knob"
>
<span>{{value}}</span>
</div>
<svg class="input-spinner__icon input-spinner__icon--plus" version="1.1" xmlns="http://www.w3.org/2000/svg" width="768" height="768" viewBox="0 0 768 768">
<path d="M607.5 415.5h-192v192h-63v-192h-192v-63h192v-192h63v192h192v63z"></path>
</svg>
</div>
<div class="input-spinner__ball u-flex-center">
<svg class="input-spinner__ball-bg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 695.5"><title>drop</title><path d="M587.75,523.75,384,727.5,180.25,523.75a281.43,281.43,0,0,1-62.87-94.5,289.88,289.88,0,0,1,0-218.5A289.43,289.43,0,0,1,274.75,53.5a288.32,288.32,0,0,1,218.5,0A288.37,288.37,0,0,1,650.5,210.88a288.43,288.43,0,0,1-62.75,312.88Z" transform="translate(-96 -32)"/></svg>
<transition-group
:name="transitionName"
tag="div"
class="input-spinner__values u-flex-center"
>
<span v-for="item in activeValue" :key="item">
{{ item }}
</span>
</transition-group>
</div>
</div>
`,
props: {
defaultValue: {
type: Number,
default: 0
},
min: {
type: Number,
default: 0
},
max: {
type: Number
},
timerStep: {
type: Number,
default: 1
},
interval: {
type: Number,
default: 1000
},
thresholds: {
type: Array,
default: [
{ threshold: 30, value: 1 },
{ threshold: 100, value: 10 },
]
}
},
data() {
return {
value: this.defaultValue,
dragging: false,
position: 0,
activeValue: [0],
transitionName: 'list',
initialInterval: 1500
}
},
mounted () {
this.width = this.$el.getBoundingClientRect().width;
this.halfWidth = this.width / 2;
this.knobWidth = this.$refs.knob.getBoundingClientRect().width;
this.knobWidthHalf = this.knobWidth / 2;
this.$refs.knob.addEventListener('mousedown', this.onStart);
this.$refs.knob.addEventListener('touchstart', this.onStart);
window.addEventListener('mousemove', this.onMove);
window.addEventListener('touchmove', this.onMove);
window.addEventListener('mouseup', this.onEnd);
window.addEventListener('touchend', this.onEnd);
},
beforeDestroy () {
this.$refs.knob.removeEventListener('mousedown', this.onStart);
this.$refs.knob.addEventListener('touchstart', this.onStart);
window.removeEventListener('mousemove', this.onMove);
window.removeEventListener('touchmove', this.onMove);
window.removeEventListener('mouseup', this.onEnd);
window.removeEventListener('touchend', this.onEnd);
},
watch: {
'position': function(val, oldVal) {
this.transitionName = val > oldVal ? 'list' : 'list--reverse';
}
},
methods: {
onStart(e) {
this.dragging = true;
this.startX = this.getScreenX(e);
},
onEnd(e) {
const deltaX = this.getScreenX(e) - this.startX;
const position = this.positionPercent(deltaX);
const newValue = this.value + this.activeValue[0];
if(this.dragging) {
this.value = newValue;
if(newValue > this.max) {
this.value = this.max;
}
if(newValue < this.min) {
this.value = this.min;
}
}
this.$emit('update', this.value);
this.setPosition(0);
this.dragging = false;
},
onMove(e) {
if(this.dragging) {
const deltaXFromCenter = this.getScreenX(e) - this.startX;
this.setPosition(deltaXFromCenter);
}
},
setPosition(deltaXFromCenter){
this.position = this.positionPercent(deltaXFromCenter);
if(Math.abs(this.position) >= 100 && !this.timerEnabled) {
this.timerEnabled = true;
this.setActiveValue(this.getThresholdValue(this.position));
this.startTimer(this.position);
}
if(Math.abs(this.position) < 100) {
this.timerEnabled = false;
}
if(deltaXFromCenter <= (this.halfWidth) && deltaXFromCenter >= -this.halfWidth) {
this.setActiveValue(this.getThresholdValue(this.position));
this.$el.style.setProperty(`--position`, Math.round(deltaXFromCenter) + 'px');
}
},
setActiveValue(val) {
this.activeValue = [val];
},
startTimer() {
if(this.timer) {
clearTimeout(this.timer);
}
this.timer = setTimeout(() => {
if(this.timerEnabled) {
const newValue = this.position >= 0
? this.activeValue[0] + this.timerStep
: this.activeValue[0] - this.timerStep;
this.setActiveValue(newValue);
this.startTimer();
}
}, this.interval);
},
positionPercent(deltaXFromCenter) {
return (deltaXFromCenter / this.halfWidth * 100);
},
getThresholdValue(val) {
const availableValues = this.thresholds.filter(item => item.threshold <= Math.abs(Math.round(val)));
let value = 0;
if(availableValues.length > 0) {
value = availableValues.pop().value;
}
return val >= 0 ? value : -1 * value;
},
getScreenX(e) {
if(e.changedTouches) {
return e.changedTouches[0].screenX;
}
return e.screenX;
}
},
computed: {
isMin() {
return this.value <= this.min;
},
isMax() {
return this.value >= this.max;
}
}
};
new Vue({
el: '#app',
components: {
InputSpinner
},
data: {
currentVal: 0,
min: 0,
max: 50,
interval: 1000,
timerStep: 5,
thresholds: [
{ threshold: 30, value: 1 },
{ threshold: 100, value: 10 },
]
},
methods: {
updateValue(val) {
this.currentVal = val;
},
changeThreshold(thresholdIndex, evt) {
this.thresholds = this.thresholds.map((item, index) => {
if(thresholdIndex === index) {
return {
threshold: evt.target.value,
value: item.value
}
}
return item;
});
},
changeThresholdValue(thresholdIndex, evt) {
this.thresholds = this.thresholds.map((item, index) => {
if(thresholdIndex === index) {
return {
threshold: item.threshold,
value: evt.target.value
}
}
return item;
});
}
}
});
<a href="https://twitter.com/irkopal" class="twitter" target="_blank">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="640" height="640" viewBox="0 0 640 640">
<path d="M554.112 199.872c0.256 5.184 0.352 10.432 0.352 15.616 0 159.68-121.504 343.744-343.68 343.744-68.256 0-131.712-20-185.184-54.304 9.472 1.12 19.072 1.696 28.8 1.696 56.64 0 108.704-19.328 150.016-51.68-52.832-0.992-97.472-35.872-112.832-83.872 7.36 1.376 14.944 2.112 22.72 2.112 11.040 0 21.728-1.44 31.84-4.192-55.264-11.136-96.896-59.936-96.896-118.496 0-0.512 0-0.992 0-1.504 16.288 9.056 34.944 14.496 54.72 15.136-32.416-21.696-53.76-58.624-53.76-100.576 0-22.112 5.952-42.88 16.384-60.736 59.552 73.12 148.608 121.184 248.992 126.24-2.048-8.864-3.104-18.048-3.104-27.552 0-66.688 54.048-120.736 120.768-120.736 34.752 0 66.144 14.624 88.192 38.112 27.488-5.44 53.344-15.488 76.704-29.312-9.024 28.192-28.192 51.872-53.12 66.816 24.448-2.944 47.68-9.376 69.376-19.008-16.192 24.256-36.672 45.504-60.288 62.496z"></path>
</svg> @irkopal
</a>
<div id="app" v-cloak>
<div class="app">
<div class="settings">
<div class="settings__item">
<label for="">Min value</label>
<input type="number" v-model.number="min">
</div>
<div class="settings__item">
<label for="">Max value</label>
<input type="number" v-model.number="max">
</div>
<div class="settings__item">
<label for="">Timer increase/decrease</label>
<input type="number" v-model.number="timerStep">
</div>
<div class="settings__item">
<label for="">Interval</label>
<input type="number" v-model.number="interval" min="500" step="100">
</div>
<div class="settings__thresholds">
<h3>Thresholds</h3>
<table>
<tr class="settings__threshold" v-for="(threshold, index) in thresholds" :key="index">
<td>
<label for="">Threshold</label>
<input type="number" @change="changeThreshold(index, $event)" :value="threshold.threshold" max="100" min="0">
</td>
<td>
<label for="">Value</label>
<input type="number" @change="changeThresholdValue(index, $event)" :value="threshold.value"></td>
</tr>
</table>
</div>
</div>
<div class="app__content">
<transition name="current-value" mode="out-in">
<div class="current" :key="currentVal">{{ currentVal }}</div>
</transition>
<input-spinner :min="min" :max="max" @update="updateValue" :interval="interval" :thresholds="thresholds" :timer-step="timerStep"></input-spinner>
<p style="margin-top: 3em;">
Timer starts when the knob is dragged to the either end
</p>
</div>
</div>
</div>
@import url('https://fonts.googleapis.com/css?family=Roboto:100,400');
*,
*::after,
*::before {
box-sizing: border-box;
}
html,
body {
height: 100%;
min-height: 100%;
}
body {
margin: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: 'Roboto', sans-serif;
background: linear-gradient(to top right, #524ad0 10%, #D099FA);
background-repeat: no-repeat;
background-attachment: fixed;
-webkit-font-smoothing: antialiased;
}
[v-cloak] {
display: none;
}
.twitter {
position: absolute;
top: 1em;
left: 1em;
text-decoration: none;
color: rgba(#fff, .8);
> * {
vertical-align: middle;
}
svg {
fill: currentColor;
width: 1em;
height: 1em;
}
}
.app {
display: flex;
justify-content: center;
padding: 2em;
color: rgba(#fff, .8);
&__content {
display: flex;
flex-direction: column;
align-items: center;
}
}
.settings {
display: none;
justify-content: flex-start;
flex-wrap: wrap;
max-width: 30vw;
margin-right: 10vw;
@media (min-width: 780px) {
display: flex;
}
&__item {
padding: .2em;
}
&__thresholds {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
}
label {
display: block;
color: rgba(#fff, .8);
letter-spacing: 1px;
font-size: .8em;
margin-left: .5em;
margin-bottom: .2em;
}
input {
background: rgba(#fff, .3);
border: none;
border-radius: 2em;
padding: .5em 1em;
font-size: .8em;
font-weight: 600;
color: rgba(#fff, .8);
}
}
.current {
text-align: center;
color: rgba(#fff, .3);
font-size: calc(60px + 10vh);
margin-bottom: 5vh;
}
.current-value-enter-active,
.current-value-leave-active {
transition: all .2s ease;
}
.current-value-enter,
.current-value-leave-to {
transform: scale(1.1);
opacity: .5;
}
.u-flex-center {
display: flex;
justify-content: center;
align-items: center;
}
.input-spinner {
--position: 0%;
position: relative;
font-size: 20px;
&:after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 2em;
box-shadow: 0 .1em .6em rgba(#000, .4);
opacity: 0;
transition: opacity .3s;
}
&.is-dragging &__knob {
transition: none;
}
&.is-dragging &__ball {
opacity: 1;
transform: translate3d(calc(-50% + var(--position)), 0, 0);
transition: opacity .2s;
}
&.is-dragging:after {
opacity: 1;
}
}
.input-spinner__track {
z-index: 2;
position: relative;
overflow: hidden;
width: 12em;
border-radius: 2em;
background: rgba(#fff, 0.4);
&.is-max .input-spinner__icon--plus {
opacity: .3;
}
&.is-min .input-spinner__icon--minus {
opacity: .3;
}
}
.input-spinner__icon {
position: absolute;
top: 50%;
width: 1.1em;
height: 1.1em;
fill: #fff;
transform: translate(0, -50%);
transition: opacity .3s;
&--plus {
right: .7em;
}
&--minus {
left: .7em;
}
}
.input-spinner__knob {
z-index: 1;
position: relative;
height: 2.5em;
width: 2.5em;
font-size: 1.2em;
color: rgba(#000, .8);
border-radius: 50%;
background: #fff;
box-shadow: 0 0 2px rgba(#000, .2);
cursor: pointer;
transform: translate3d(calc(var(--position)), 0, 0);
transition: transform .5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
user-select: none;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
will-change: transform;
}
.input-spinner__ball {
z-index: 3;
position: absolute;
overflow: hidden;
left: 50%;
bottom: 105%;
opacity: 0;
transform: translate3d(calc(-50% + var(--position)), 0, 0);
transition: opacity .2s, transform .3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
will-change: transform;
}
.input-spinner__values {
position: absolute;
top: -2px;
left: 0;
width: 100%;
height: 100%;
color: rgba(#000, .8);
font-size: .6em;
}
.input-spinner__ball-bg {
width: 1.4em;
height: 1.4em;
fill: rgba(#fff, .4);
}
.list {
&-item {
display: inline-block;
user-select: none;
}
&-enter-active {
opacity: 0;
transform: translate3d(1em, 0, 0);
transition: transform 0.2s .1s, opacity .2s .12s;
}
&-leave-active {
position: absolute;
top: 22%;
transform: translate3d(0, 0, 0);
transition: transform .3s, opacity .2s;
}
&-enter-to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
&-leave-to {
position: absolute;
top: 22%;
opacity: 0;
transform: translate3d(-1em, 0, 0);
}
&--reverse {
&-enter-active {
opacity: 0;
transform: translate3d(-1em, 0, 0);
transition: transform 0.2s .1s, opacity .2s .1s;
}
&-leave-active {
position: absolute;
top: 22%;
transition: transform 0.3s, opacity .2s;
}
&-enter-to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
&-leave-to {
position: absolute;
top: 22%;
opacity: 0;
transform: translate3d(1em, 0, 0);
}
}
}