diff --git a/docs/src/examples/QRange/CustomMarkers.vue b/docs/src/examples/QRange/CustomMarkers.vue new file mode 100644 index 00000000000..00445e88583 --- /dev/null +++ b/docs/src/examples/QRange/CustomMarkers.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/docs/src/examples/QRange/InnerMinMax.vue b/docs/src/examples/QRange/InnerMinMax.vue new file mode 100644 index 00000000000..1bc72838b5d --- /dev/null +++ b/docs/src/examples/QRange/InnerMinMax.vue @@ -0,0 +1,23 @@ + + + diff --git a/docs/src/examples/QSlider/CustomMarkerIcons.vue b/docs/src/examples/QSlider/CustomMarkerIcons.vue new file mode 100644 index 00000000000..2271f8e0029 --- /dev/null +++ b/docs/src/examples/QSlider/CustomMarkerIcons.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/docs/src/examples/QSlider/CustomMarkerLabels.vue b/docs/src/examples/QSlider/CustomMarkerLabels.vue new file mode 100644 index 00000000000..8d7699dbc20 --- /dev/null +++ b/docs/src/examples/QSlider/CustomMarkerLabels.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/docs/src/examples/QSlider/CustomMarkerValue.vue b/docs/src/examples/QSlider/CustomMarkerValue.vue new file mode 100644 index 00000000000..6ba1e6ebc2b --- /dev/null +++ b/docs/src/examples/QSlider/CustomMarkerValue.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/docs/src/examples/QSlider/InnerMinMax.vue b/docs/src/examples/QSlider/InnerMinMax.vue new file mode 100644 index 00000000000..d9dc983e730 --- /dev/null +++ b/docs/src/examples/QSlider/InnerMinMax.vue @@ -0,0 +1,20 @@ + + + diff --git a/docs/src/pages/vue-components/range.md b/docs/src/pages/vue-components/range.md index 0bdedacf4f1..783f6b1facb 100644 --- a/docs/src/pages/vue-components/range.md +++ b/docs/src/pages/vue-components/range.md @@ -24,6 +24,12 @@ Notice we are using an object for the selection, which holds values for both the +### With inner min/max + +Sometimes you need to restrict the model value to an interval inside of the track's length. For this purpose, use `inner-min` and `inner-max` props. First prop needs to be higher or equal to `min` prop while the latter needs to be lower or equal to the `max` prop. + + + ### With step @@ -52,6 +58,10 @@ The example below is better highlighting how QRange handles label positioning so +### Custom markers slot + + + ### Dragging range Use the `drag-range` or `drag-only-range` props to allow the user to move the selected range or only a predetermined range as a whole. diff --git a/docs/src/pages/vue-components/slider.md b/docs/src/pages/vue-components/slider.md index d0cb6d8a957..29fb541fb5a 100644 --- a/docs/src/pages/vue-components/slider.md +++ b/docs/src/pages/vue-components/slider.md @@ -22,6 +22,12 @@ Also check its “sibling”, the [QRange](/vue-components/range) component. +### With inner min/max + +Sometimes you need to restrict the model value to an interval inside of the track's length. For this purpose, use `inner-min` and `inner-max` props. First prop needs to be higher or equal to `min` prop while the latter needs to be lower or equal to the `max` prop. + + + ### With step @@ -50,6 +56,14 @@ The example below is better highlighting how QSlider handles label positioning s +### Custom markers slot + + + + + + + ### Lazy input diff --git a/ui/dev/src/pages/form/range.vue b/ui/dev/src/pages/form/range.vue index 3b3f29ac198..a4f4b71311f 100644 --- a/ui/dev/src/pages/form/range.vue +++ b/ui/dev/src/pages/form/range.vue @@ -24,6 +24,16 @@ +

+ Inner min/max + + Model {{ innerMinMax.min }} to {{ innerMinMax.max }}   (0 to 50, inner 10 to 35 or 15 to 40) + +

+ + + +

Reverse @@ -246,6 +256,15 @@ export default { max: 35 }, + nullInnerMinMax: { + min: null, + max: null + }, + innerMinMax: { + min: 20, + max: 25 + }, + stepZero: { min: 34.05, max: 64.023 diff --git a/ui/dev/src/pages/form/slider-markers.vue b/ui/dev/src/pages/form/slider-markers.vue new file mode 100644 index 00000000000..6d04a13dc64 --- /dev/null +++ b/ui/dev/src/pages/form/slider-markers.vue @@ -0,0 +1,196 @@ + + + + + diff --git a/ui/dev/src/pages/form/slider-range-limits.vue b/ui/dev/src/pages/form/slider-range-limits.vue new file mode 100644 index 00000000000..3388fe04a3a --- /dev/null +++ b/ui/dev/src/pages/form/slider-range-limits.vue @@ -0,0 +1,129 @@ + + + diff --git a/ui/dev/src/pages/form/slider.vue b/ui/dev/src/pages/form/slider.vue index 44dd33071f5..ccfb7584ef0 100644 --- a/ui/dev/src/pages/form/slider.vue +++ b/ui/dev/src/pages/form/slider.vue @@ -23,6 +23,17 @@ +

+ Inner min/max + + Model {{ innerMinMax }}   (0 to 50, inner 10 to 35 or 15 to 40) + +

+ + + + +

Reverse @@ -189,6 +200,8 @@ export default { nullValue: null, nullValueMin: null, standalone: 20, + nullInnerMinMax: null, + innerMinMax: 25, stepZero: 30.05, precision: 0.4, step: 30, diff --git a/ui/src/components/range/QRange.js b/ui/src/components/range/QRange.js index 4c6ef2130c1..b61018b0a40 100644 --- a/ui/src/components/range/QRange.js +++ b/ui/src/components/range/QRange.js @@ -7,8 +7,8 @@ import { keyCodes } from '../slider/slider-utils.js' -import { stopAndPrevent } from '../../utils/event.js' import { between } from '../../utils/format.js' +import { stopAndPrevent } from '../../utils/event.js' const dragType = { MIN: 0, @@ -33,8 +33,6 @@ export default Vue.extend({ } }, - name: String, - dragRange: Boolean, dragOnlyRange: Boolean, @@ -48,11 +46,17 @@ export default Vue.extend({ }, data () { + const minModel = isNaN(this.innerMin) === true || this.innerMin < this.min + ? this.min + : this.innerMin + const maxModel = isNaN(this.innerMax) === true || this.innerMax > this.max + ? this.max + : this.innerMax + const min = this.value.min === null ? minModel : this.value.min + const max = this.value.max === null ? maxModel : this.value.max + return { - model: { - min: this.value.min === null ? this.min : this.value.min, - max: this.value.max === null ? this.max : this.value.max - }, + model: { min, max }, curMinRatio: 0, curMaxRatio: 0 } @@ -60,70 +64,84 @@ export default Vue.extend({ watch: { 'value.min' (val) { - this.model.min = val === null + const model = val === null ? this.min - : val + : between(val, this.minInnerValue, this.maxInnerValue) + + if (this.model.min !== model) { + this.model.min = model + + this.curMinRatio = this.__getModelRatio(model) + } }, 'value.max' (val) { - this.model.max = val === null + const model = val === null ? this.max - : val + : between(val, this.minInnerValue, this.maxInnerValue) + + if (this.model.max !== model) { + this.model.max = model + + this.curMaxRatio = this.__getModelRatio(model) + } }, - min (value) { - if (this.model.min < value) { - this.model.min = value + minInnerValue (val) { + if (this.model.min < val) { + this.model.min = val } - if (this.model.max < value) { - this.model.max = value + if (this.model.max < val) { + this.model.max = val } }, - max (value) { - if (this.model.min > value) { - this.model.min = value + maxInnerValue (val) { + if (this.model.min > val) { + this.model.min = val } - if (this.model.max > value) { - this.model.max = value + if (this.model.max > val) { + this.model.max = val } } }, computed: { - ratioMin () { + minRatio () { return this.active === true ? this.curMinRatio : this.modelMinRatio }, - ratioMax () { + maxRatio () { return this.active === true ? this.curMaxRatio : this.modelMaxRatio }, modelMinRatio () { - return this.minMaxDiff === 0 ? 0 : (this.model.min - this.min) / this.minMaxDiff + return this.__getModelRatio(this.model.min) }, modelMaxRatio () { - return this.minMaxDiff === 0 ? 0 : (this.model.max - this.min) / this.minMaxDiff + return this.__getModelRatio(this.model.max) }, trackStyle () { + const minRatio = between(this.minRatio, 0, 1) + return { - [ this.positionProp ]: `${100 * this.ratioMin}%`, - [ this.sizeProp ]: `${100 * (this.ratioMax - this.ratioMin)}%` + [ this.positionProp ]: `${100 * minRatio}%`, + [ this.sizeProp ]: `${100 * (between(this.maxRatio, 0, 1) - minRatio)}%` } }, minThumbStyle () { return { - [ this.positionProp ]: `${100 * this.ratioMin}%`, + [ this.positionProp ]: `${100 * this.minRatio}%`, 'z-index': this.__nextFocus === 'min' ? 2 : void 0 } }, maxThumbStyle () { return { - [ this.positionProp ]: `${100 * this.ratioMax}%` + [ this.positionProp ]: `${100 * this.maxRatio}%` } }, @@ -139,47 +157,6 @@ export default Vue.extend({ } }, - events () { - if (this.editable === true) { - if (this.$q.platform.is.mobile === true) { - return { click: this.__mobileClick } - } - - const evt = { mousedown: this.__activate } - - this.dragOnlyRange === true && Object.assign(evt, { - focus: () => { this.__focus('both') }, - blur: this.__blur, - keydown: this.__keydown, - keyup: this.__keyup - }) - - return evt - } - }, - - minEvents () { - if (this.editable === true && this.$q.platform.is.mobile !== true && this.dragOnlyRange !== true) { - return { - focus: () => { this.__focus('min') }, - blur: this.__blur, - keydown: this.__keydown, - keyup: this.__keyup - } - } - }, - - maxEvents () { - if (this.editable === true && this.$q.platform.is.mobile !== true && this.dragOnlyRange !== true) { - return { - focus: () => { this.__focus('max') }, - blur: this.__blur, - keydown: this.__keydown, - keyup: this.__keyup - } - } - }, - minPinClass () { const color = this.leftLabelColor || this.labelColor if (color) { @@ -208,26 +185,67 @@ export default Vue.extend({ } }, - minLabel () { + minPinStyle () { + const percent = (this.reverse === true ? -this.minRatio : this.minRatio - 1) + return this.__getPinStyle(percent, this.minRatio) + }, + + maxPinStyle () { + const percent = (this.reverse === true ? -this.maxRatio : this.maxRatio - 1) + return this.__getPinStyle(percent, this.maxRatio) + }, + + minComputedLabel () { return this.leftLabelValue !== void 0 ? this.leftLabelValue : this.model.min }, - maxLabel () { + maxComputedLabel () { return this.rightLabelValue !== void 0 ? this.rightLabelValue : this.model.max }, - minPinStyle () { - const percent = (this.reverse === true ? -this.ratioMin : this.ratioMin - 1) - return this.__getPinStyle(percent, this.ratioMin) + events () { + if (this.editable === true) { + if (this.$q.platform.is.mobile === true) { + return { click: this.__mobileClick } + } + + const evt = { mousedown: this.__activate } + + this.dragOnlyRange === true && Object.assign(evt, { + focus: () => { this.__focus('both') }, + blur: this.__blur, + keydown: this.__keydown, + keyup: this.__keyup + }) + + return evt + } }, - maxPinStyle () { - const percent = (this.reverse === true ? -this.ratioMax : this.ratioMax - 1) - return this.__getPinStyle(percent, this.ratioMax) + minEvents () { + if (this.editable === true && this.$q.platform.is.mobile !== true && this.dragOnlyRange !== true) { + return { + focus: () => { this.__focus('min') }, + blur: this.__blur, + keydown: this.__keydown, + keyup: this.__keyup + } + } + }, + + maxEvents () { + if (this.editable === true && this.$q.platform.is.mobile !== true && this.dragOnlyRange !== true) { + return { + focus: () => { this.__focus('max') }, + blur: this.__blur, + keydown: this.__keydown, + keyup: this.__keyup + } + } }, formAttrs () { @@ -250,42 +268,43 @@ export default Vue.extend({ __getDragging (event) { const { left, top, width, height } = this.$el.getBoundingClientRect(), - sensitivity = this.dragOnlyRange === true + thumb = this.$refs.minThumb || this.$refs.maxThumb, + sensitivity = this.dragOnlyRange === true || thumb === void 0 ? 0 : (this.vertical === true - ? this.$refs.minThumb.offsetHeight / (2 * height) - : this.$refs.minThumb.offsetWidth / (2 * width) - ) + ? thumb.offsetHeight / (2 * height) + : thumb.offsetWidth / (2 * width) + ) + (this.modelMaxRatio - this.modelMinRatio) / 15 const dragging = { left, top, width, height, - valueMin: this.model.min, - valueMax: this.model.max, - ratioMin: this.modelMinRatio, - ratioMax: this.modelMaxRatio + minValue: this.model.min, + maxValue: this.model.max, + minRatio: this.modelMinRatio, + maxRatio: this.modelMaxRatio } const ratio = getRatio(event, dragging, this.isReversed, this.vertical) let type - if (this.dragOnlyRange !== true && ratio < dragging.ratioMin + sensitivity) { + if (this.dragOnlyRange !== true && ratio < dragging.minRatio + sensitivity) { type = dragType.MIN } - else if (this.dragOnlyRange === true || ratio < dragging.ratioMax - sensitivity) { + else if (this.dragOnlyRange === true || ratio < dragging.maxRatio - sensitivity) { if (this.dragRange === true || this.dragOnlyRange === true) { type = dragType.RANGE Object.assign(dragging, { offsetRatio: ratio, offsetModel: getModel(ratio, this.min, this.max, this.step, this.decimals), - rangeValue: dragging.valueMax - dragging.valueMin, - rangeRatio: dragging.ratioMax - dragging.ratioMin + rangeValue: dragging.maxValue - dragging.minValue, + rangeRatio: dragging.maxRatio - dragging.minRatio }) } else { - type = dragging.ratioMax - ratio < ratio - dragging.ratioMin + type = dragging.maxRatio - ratio < ratio - dragging.minRatio ? dragType.MAX : dragType.MIN } @@ -308,20 +327,20 @@ export default Vue.extend({ switch (dragging.type) { case dragType.MIN: - if (ratio <= dragging.ratioMax) { + if (ratio <= dragging.maxRatio) { pos = { minR: ratio, - maxR: dragging.ratioMax, + maxR: dragging.maxRatio, min: model, - max: dragging.valueMax + max: dragging.maxValue } this.__nextFocus = 'min' } else { pos = { - minR: dragging.ratioMax, + minR: dragging.maxRatio, maxR: ratio, - min: dragging.valueMax, + min: dragging.maxValue, max: model } this.__nextFocus = 'max' @@ -329,11 +348,11 @@ export default Vue.extend({ break case dragType.MAX: - if (ratio >= dragging.ratioMin) { + if (ratio >= dragging.minRatio) { pos = { - minR: dragging.ratioMin, + minR: dragging.minRatio, maxR: ratio, - min: dragging.valueMin, + min: dragging.minValue, max: model } this.__nextFocus = 'max' @@ -341,9 +360,9 @@ export default Vue.extend({ else { pos = { minR: ratio, - maxR: dragging.ratioMin, + maxR: dragging.minRatio, min: model, - max: dragging.valueMin + max: dragging.minValue } this.__nextFocus = 'min' } @@ -352,9 +371,9 @@ export default Vue.extend({ case dragType.RANGE: const ratioDelta = ratio - dragging.offsetRatio, - minR = between(dragging.ratioMin + ratioDelta, 0, 1 - dragging.rangeRatio), + minR = between(dragging.minRatio + ratioDelta, this.minInnerRatio, this.maxInnerRatio - dragging.rangeRatio), modelDelta = model - dragging.offsetModel, - min = between(dragging.valueMin + modelDelta, this.min, this.max - dragging.rangeValue) + min = between(dragging.minValue + modelDelta, this.minInnerValue, this.maxInnerValue - dragging.rangeValue) pos = { minR, @@ -366,23 +385,31 @@ export default Vue.extend({ } this.model = { - min: pos.min, - max: pos.max + min: this.__nextFocus !== 'max' + ? between(pos.min, this.minInnerValue, this.maxInnerValue) + : pos.min, + max: this.__nextFocus !== 'min' + ? between(pos.max, this.minInnerValue, this.maxInnerValue) + : pos.max } // If either of the values to be emitted are null, set them to the defaults the user has entered. if (this.model.min === null || this.model.max === null) { - this.model.min = pos.min || this.min - this.model.max = pos.max || this.max + this.model.min = pos.min || this.minInnerValue + this.model.max = pos.max || this.maxInnerValue } if (this.snap !== true || this.step === 0) { - this.curMinRatio = pos.minR - this.curMaxRatio = pos.maxR + this.curMinRatio = this.__nextFocus !== 'max' + ? between(pos.minR, this.minInnerRatio, this.maxInnerRatio) + : pos.minR + this.curMaxRatio = this.__nextFocus !== 'min' + ? between(pos.maxR, this.minInnerRatio, this.maxInnerRatio) + : pos.maxR } else { - this.curMinRatio = this.minMaxDiff === 0 ? 0 : (this.model.min - this.min) / this.minMaxDiff - this.curMaxRatio = this.minMaxDiff === 0 ? 0 : (this.model.max - this.min) / this.minMaxDiff + this.curMinRatio = this.__getModelRatio(this.model.min) + this.curMaxRatio = this.__getModelRatio(this.model.max) } }, @@ -408,8 +435,8 @@ export default Vue.extend({ const min = between( parseFloat((this.model.min + offset).toFixed(this.decimals)), - this.min, - this.max - interval + this.minInnerValue, + this.maxInnerValue - interval ) this.model = { @@ -427,8 +454,8 @@ export default Vue.extend({ ...this.model, [which]: between( parseFloat((this.model[which] + offset).toFixed(this.decimals)), - which === 'min' ? this.min : this.model.min, - which === 'max' ? this.max : this.model.max + which === 'min' ? this.minInnerValue : this.model.min, + which === 'max' ? this.maxInnerValue : this.model.max ) } } @@ -437,6 +464,11 @@ export default Vue.extend({ }, __getThumb (h, which) { + const ratio = this[which + 'Ratio'] + if (ratio < 0 || ratio > 1) { + return + } + const child = [ this.__getThumbSvg(h), h('div', { staticClass: 'q-slider__focus-ring' }) @@ -457,7 +489,7 @@ export default Vue.extend({ staticClass: 'q-slider__pin-text', class: this[which + 'PinTextClass'] }, [ - this[which + 'Label'] + this[which + 'ComputedLabel'] ]) ]) ]), @@ -481,24 +513,10 @@ export default Vue.extend({ }, render (h) { - const track = [ - h('div', { - staticClass: `q-slider__track q-slider__track${this.axis} absolute`, - style: this.trackStyle - }) - ] - - this.markers !== false && track.push( - h('div', { - staticClass: `q-slider__track-markers q-slider__track-markers${this.axis} absolute-full fit`, - style: this.markerStyle - }) - ) - const child = [ h('div', { staticClass: `q-slider__track-container q-slider__track-container${this.axis} absolute` - }, track), + }, this.__getTrack(h)), this.__getThumb(h, 'min'), this.__getThumb(h, 'max') diff --git a/ui/src/components/range/QRange.json b/ui/src/components/range/QRange.json index f5c40a6c586..b96a66abb0f 100644 --- a/ui/src/components/range/QRange.json +++ b/ui/src/components/range/QRange.json @@ -27,7 +27,7 @@ "min": { "type": "Number", - "desc": "Minimum value of the model", + "desc": "Minimum value of the model; Set track's minimum value", "default": 0, "examples": [ ":min=\"0\"" ], "category": "model" @@ -35,7 +35,7 @@ "max": { "type": "Number", - "desc": "Maximum value of the model", + "desc": "Maximum value of the model; Set track's maximum value", "default": 100, "examples": [ ":max=\"100\"" ], "category": "model" @@ -49,6 +49,22 @@ "category": "model" }, + "inner-min": { + "type": "Number", + "desc": "Inner minimum value of the model; Use in case you need the model value to be inside of the track's min-max values; Needs to be higher or equal to 'min' prop; Defaults to 'min' prop", + "examples": [ ":inner-min=\"0\"" ], + "category": "content", + "addedIn": "v1.17" + }, + + "inner-max": { + "type": "Number", + "desc": "Inner maximum value of the model; Use in case you need the model value to be inside of the track's min-max values; Needs to be lower or equal to 'max' prop; Defaults to 'max' prop", + "examples": [ ":max-value=\"100\"" ], + "category": "content", + "addedIn": "v1.17" + }, + "reverse": { "type": "Boolean", "desc": "Work in reverse (changes direction)", @@ -215,5 +231,113 @@ }, "addedIn": "v1.14.0" } + }, + + "scopedSlots": { + "markers": { + "desc": "Slot for adding markers to the QRange selector", + "scope": { + "value": { + "type": "Object", + "desc": "The model of type { min, max }", + "definition": { + "min": { + "type": "Number", + "desc": "Smaller end of the range", + "examples": [ 2 ] + }, + "max": { + "type": "Number", + "desc": "Larger end of the range", + "examples": [ 12 ] + } + }, + "examples": [ "{ min: 2, max: 12 }" ] + }, + "vertical": { + "type": "Boolean", + "desc": "Component is displayed in vertical direction" + }, + "reverse": { + "type": "Boolean", + "desc": "Component works in reverse" + }, + "editable": { + "type": "Boolean", + "desc": "Component is editable" + }, + "min": { + "type": "Number", + "desc": "Minimum value on the track", + "examples": [ 0 ] + }, + "max": { + "type": "Number", + "desc": "Maximum value on the track", + "examples": [ 100 ] + }, + "minValue": { + "type": "Number", + "desc": "Minimum value of the model", + "examples": [ 10 ] + }, + "maxValue": { + "type": "Number", + "desc": "Maximum value of the model", + "examples": [ 90 ] + }, + "styleFn": { + "type": "Function", + "desc": "Calculates the style (position as %) for a value", + "params": { + "value": { + "type": "Number", + "desc": "Value for which to calculate the style", + "examples": [ 25 ] + } + }, + "returns": { + "type": "Object", + "desc": "Object with prop (the name of the CSS property), value (the value of the CSS property), and style (the full style as text)", + "definition": { + "prop": { + "type": "String", + "desc": "The name of the CSS property", + "examples": [ "left", "right", "top", "bottom" ] + }, + "value": { + "type": "String", + "desc": "The value of the CSS property", + "examples": [ "30%" ] + }, + "style": { + "type": "String", + "desc": "The full style", + "examples": [ "left: 30%" ] + } + }, + "examples": [ "{ prop: 'left', value: '30%', style: 'left: 30%' }" ] + } + }, + "setFn": { + "type": "Function", + "desc": "Set the internal model (and emits the events if necessary)", + "params": { + "model": { + "type": "Number", + "desc": "The new value of the model", + "examples": [ "{ min: 10, max: 45 }" ] + } + }, + "returns": null + }, + "stopEvents": { + "type": "Object", + "desc": "Listeners on mousedown and click that can be applied on the markers to stop propagation to the QRange; Can be used as `v-on=\"scope.stopEvents\"`", + "__exemption": [ "examples" ] + } + }, + "addedIn": "v1.16.0" + } } } diff --git a/ui/src/components/slider/QSlider.js b/ui/src/components/slider/QSlider.js index 766804f7389..f96bdd6a068 100644 --- a/ui/src/components/slider/QSlider.js +++ b/ui/src/components/slider/QSlider.js @@ -26,25 +26,36 @@ export default Vue.extend({ }, data () { + const minModel = isNaN(this.innerMin) === true || this.innerMin < this.min + ? this.min + : this.innerMin + const model = this.value === null ? minModel : this.value + return { - model: this.value === null ? this.min : this.value, + model, curRatio: 0 } }, watch: { - value (v) { - this.model = v === null - ? 0 - : between(v, this.min, this.max) + value (val) { + const model = val === null + ? this.minInnerValue + : between(val, this.minInnerValue, this.maxInnerValue) + + if (this.model !== model) { + this.model = model + + this.curRatio = this.__getModelRatio(model) + } }, - min (v) { - this.model = between(this.model, v, this.max) + minInnerValue (val) { + this.model = between(this.model, val, this.maxInnerValue) }, - max (v) { - this.model = between(this.model, this.min, v) + maxInnerValue (val) { + this.model = between(this.model, this.minInnerValue, val) } }, @@ -54,13 +65,13 @@ export default Vue.extend({ }, modelRatio () { - return this.minMaxDiff === 0 ? 0 : (this.model - this.min) / this.minMaxDiff + return this.__getModelRatio(this.model) }, trackStyle () { return { [ this.positionProp ]: 0, - [ this.sizeProp ]: `${100 * this.ratio}%` + [ this.sizeProp ]: `${100 * between(this.ratio, 0, 1)}%` } }, @@ -87,6 +98,17 @@ export default Vue.extend({ (this.labelTextColor !== void 0 ? ` text-${this.labelTextColor}` : '') }, + pinStyle () { + const percent = (this.reverse === true ? -this.ratio : this.ratio - 1) + return this.__getPinStyle(percent, this.ratio) + }, + + computedLabel () { + return this.labelValue !== void 0 + ? this.labelValue + : this.model + }, + events () { if (this.editable === true) { return this.$q.platform.is.mobile === true @@ -99,17 +121,6 @@ export default Vue.extend({ keyup: this.__keyup } } - }, - - computedLabel () { - return this.labelValue !== void 0 - ? this.labelValue - : this.model - }, - - pinStyle () { - const percent = (this.reverse === true ? -this.ratio : this.ratio - 1) - return this.__getPinStyle(percent, this.ratio) } }, @@ -126,21 +137,21 @@ export default Vue.extend({ }, __updatePosition (event, dragging = this.dragging) { - const ratio = getRatio( - event, - dragging, - this.isReversed, - this.vertical + const ratio = between( + getRatio(event, dragging, this.isReversed, this.vertical), + this.minInnerRatio, + this.maxInnerRatio + ) + + this.model = between( + getModel(ratio, this.min, this.max, this.step, this.decimals), + this.minInnerValue, + this.maxInnerValue ) - this.model = getModel(ratio, this.min, this.max, this.step, this.decimals) this.curRatio = this.snap !== true || this.step === 0 ? ratio - : ( - this.minMaxDiff === 0 - ? 0 - : (this.model - this.min) / this.minMaxDiff - ) + : this.__getModelRatio(this.model) }, __focus () { @@ -160,67 +171,75 @@ export default Vue.extend({ this.model = between( parseFloat((this.model + offset).toFixed(this.decimals)), - this.min, - this.max + this.minInnerValue, + this.maxInnerValue ) this.__updateValue() - } - }, + }, - render (h) { - const child = [ - this.__getThumbSvg(h), - h('div', { staticClass: 'q-slider__focus-ring' }) - ] + __getThumb (h) { + if (this.ratio < 0 || this.ratio > 1) { + return + } + + const child = [ + this.__getThumbSvg(h), + h('div', { staticClass: 'q-slider__focus-ring' }) + ] - if (this.label === true || this.labelAlways === true) { - child.push( - h('div', { - staticClass: `q-slider__pin q-slider__pin${this.axis} absolute`, - style: this.pinStyle.pin, - class: this.pinClass - }, [ + if (this.label === true || this.labelAlways === true) { + child.push( h('div', { - staticClass: `q-slider__pin-text-container q-slider__pin-text-container${this.axis}`, - style: this.pinStyle.pinTextContainer + staticClass: `q-slider__pin q-slider__pin${this.axis} absolute`, + style: this.pinStyle.pin, + class: this.pinClass }, [ - h('span', { - staticClass: 'q-slider__pin-text', - class: this.pinTextClass + h('div', { + staticClass: `q-slider__pin-text-container q-slider__pin-text-container${this.axis}`, + style: this.pinStyle.pinTextContainer }, [ - this.computedLabel + h('span', { + staticClass: 'q-slider__pin-text', + class: this.pinTextClass + }, [ + this.computedLabel + ]) ]) - ]) - ]), + ]), - h('div', { - staticClass: `q-slider__arrow q-slider__arrow${this.axis}`, - class: this.pinClass - }) - ) - } + h('div', { + staticClass: `q-slider__arrow q-slider__arrow${this.axis}`, + class: this.pinClass + }) + ) + } - if (this.name !== void 0 && this.disable !== true) { - this.__injectFormInput(child, 'push') + return h('div', { + staticClass: `q-slider__thumb-container q-slider__thumb-container${this.axis} absolute non-selectable`, + class: this.thumbClass, + style: this.thumbStyle + }, child) } + }, - const track = [ + render (h) { + const child = [ h('div', { - staticClass: `q-slider__track q-slider__track${this.axis} absolute`, - style: this.trackStyle - }) + staticClass: `q-slider__track-container q-slider__track-container${this.axis} absolute` + }, this.__getTrack(h)), + + this.__getThumb(h) ] - this.markers !== false && track.push( - h('div', { - staticClass: `q-slider__track-markers q-slider__track-markers${this.axis} absolute-full fit`, - style: this.markerStyle - }) - ) + if (this.name !== void 0 && this.disable !== true) { + this.__injectFormInput(child, 'push') + } return h('div', { - staticClass: this.value === null ? ' q-slider--no-value' : '', + staticClass: this.value === null + ? ' q-slider--no-value' + : void 0, attrs: { ...this.attrs, 'aria-valuenow': this.value, @@ -229,16 +248,6 @@ export default Vue.extend({ class: this.classes, on: this.events, directives: this.panDirectives - }, [ - h('div', { - staticClass: `q-slider__track-container q-slider__track-container${this.axis} absolute` - }, track), - - h('div', { - staticClass: `q-slider__thumb-container q-slider__thumb-container${this.axis} absolute non-selectable`, - class: this.thumbClass, - style: this.thumbStyle - }, child) - ]) + }, child) } }) diff --git a/ui/src/components/slider/QSlider.json b/ui/src/components/slider/QSlider.json index 423a5dc36c2..a87c2e2a982 100644 --- a/ui/src/components/slider/QSlider.json +++ b/ui/src/components/slider/QSlider.json @@ -15,7 +15,7 @@ "min": { "type": "Number", - "desc": "Minimum value of the model", + "desc": "Minimum value of the model; Set track's minimum value", "default": 0, "examples": [ ":min=\"0\"" ], "category": "model" @@ -23,7 +23,7 @@ "max": { "type": "Number", - "desc": "Maximum value of the model", + "desc": "Maximum value of the model; Set track's maximum value", "default": 100, "examples": [ ":max=\"100\"" ], "category": "model" @@ -37,6 +37,22 @@ "category": "model" }, + "inner-min": { + "type": "Number", + "desc": "Inner minimum value of the model; Use in case you need the model value to be inside of the track's min-max values; Needs to be higher or equal to 'min' prop; Defaults to 'min' prop", + "examples": [ ":inner-min=\"0\"" ], + "category": "content", + "addedIn": "v1.17" + }, + + "inner-max": { + "type": "Number", + "desc": "Inner maximum value of the model; Use in case you need the model value to be inside of the track's min-max values; Needs to be lower or equal to 'max' prop; Defaults to 'max' prop", + "examples": [ ":max-value=\"100\"" ], + "category": "content", + "addedIn": "v1.17" + }, + "reverse": { "type": "Boolean", "desc": "Work in reverse (changes direction)", @@ -152,5 +168,101 @@ }, "addedIn": "v1.14.0" } + }, + + "scopedSlots": { + "markers": { + "desc": "Slot for adding markers to the QSlide selector", + "scope": { + "value": { + "type": "Number", + "desc": "The model", + "examples": [ 0 ] + }, + "vertical": { + "type": "Boolean", + "desc": "Component is displayed in vertical direction" + }, + "reverse": { + "type": "Boolean", + "desc": "Component works in reverse" + }, + "editable": { + "type": "Boolean", + "desc": "Component is editable" + }, + "min": { + "type": "Number", + "desc": "Minimum value on the track", + "examples": [ 0 ] + }, + "max": { + "type": "Number", + "desc": "Maximum value on the track", + "examples": [ 100 ] + }, + "minValue": { + "type": "Number", + "desc": "Minimum value of the model", + "examples": [ 10 ] + }, + "maxValue": { + "type": "Number", + "desc": "Maximum value of the model", + "examples": [ 90 ] + }, + "styleFn": { + "type": "Function", + "desc": "Calculates the style (position as %) for a value", + "params": { + "value": { + "type": "Number", + "desc": "Value for which to calculate the style", + "examples": [ 25 ] + } + }, + "returns": { + "type": "Object", + "desc": "Object with prop (the name of the CSS property), value (the value of the CSS property), and style (the full style as text)", + "definition": { + "prop": { + "type": "String", + "desc": "The name of the CSS property", + "examples": [ "left", "right", "top", "bottom" ] + }, + "value": { + "type": "String", + "desc": "The value of the CSS property", + "examples": [ "30%" ] + }, + "style": { + "type": "String", + "desc": "The full style", + "examples": [ "left: 30%" ] + } + }, + "examples": [ "{ prop: 'left', value: '30%', style: 'left: 30%' }" ] + } + }, + "setFn": { + "type": "Function", + "desc": "Set the internal model (and emits the events if necessary)", + "params": { + "model": { + "type": "Number", + "desc": "The new value of the model", + "examples": [ 25 ] + } + }, + "returns": null + }, + "stopEvents": { + "type": "Object", + "desc": "Listeners on mousedown and click that can be applied on the markers to stop propagation to the QSlider; Can be used as `v-on=\"scope.stopEvents\"`", + "__exemption": [ "examples" ] + } + }, + "addedIn": "v1.16.0" + } } } diff --git a/ui/src/components/slider/QSlider.sass b/ui/src/components/slider/QSlider.sass index 3d1f914c11f..e9639ca25ee 100644 --- a/ui/src/components/slider/QSlider.sass +++ b/ui/src/components/slider/QSlider.sass @@ -3,6 +3,7 @@ color: $primary color: var(--q-color-primary) outline: 0 + isolation: isolate &--h width: 100% @@ -12,7 +13,8 @@ height: 200px &__track-container - background: rgba(0,0,0,.26) + background: rgba(0,0,0,.06) + border-radius: $generic-border-radius &--h top: 50% @@ -25,8 +27,21 @@ height: 100% width: 2px + &__inner-track + background: rgba(0,0,0,.2) + border-radius: $generic-border-radius + z-index: -1 + + &--h + top: 0 + bottom: 0 + &--v + left: 0 + right: 0 + &__track background: currentColor + border-radius: $generic-border-radius &--h will-change: width, left @@ -208,7 +223,9 @@ &--dark .q-slider__track-container - background: rgba(#fff, .3) + background: rgba(#fff, .1) + .q-slider__inner-track + background: rgba(#fff, .2) .q-slider__track-markers color: #fff diff --git a/ui/src/components/slider/QSlider.styl b/ui/src/components/slider/QSlider.styl index 2301f1a741d..4a91c64c309 100644 --- a/ui/src/components/slider/QSlider.styl +++ b/ui/src/components/slider/QSlider.styl @@ -3,6 +3,7 @@ color: $primary color: var(--q-color-primary) outline: 0 + isolation: isolate &--h width: 100% @@ -12,7 +13,8 @@ height: 200px &__track-container - background: rgba(0,0,0,.26) + background: rgba(0,0,0,.06) + border-radius: $generic-border-radius &--h top: 50% @@ -25,8 +27,21 @@ height: 100% width: 2px + &__inner-track + background: rgba(0,0,0,.2) + border-radius: $generic-border-radius + z-index: -1 + + &--h + top: 0 + bottom: 0 + &--v + left: 0 + right: 0 + &__track background: currentColor + border-radius: $generic-border-radius &--h will-change: width, left @@ -208,7 +223,9 @@ &--dark .q-slider__track-container - background: rgba(#fff, .3) + background: rgba(#fff, .1) + .q-slider__inner-track + background: rgba(#fff, .2) .q-slider__track-markers color: #fff diff --git a/ui/src/components/slider/slider-utils.js b/ui/src/components/slider/slider-utils.js index 97b364e054d..ccd7017ef0e 100644 --- a/ui/src/components/slider/slider-utils.js +++ b/ui/src/components/slider/slider-utils.js @@ -1,5 +1,5 @@ import { between } from '../../utils/format.js' -import { position } from '../../utils/event.js' +import { position, stop } from '../../utils/event.js' import { isNumber } from '../../utils/is.js' import FormMixin from '../../mixins/form.js' @@ -31,7 +31,7 @@ export function getModel (ratio, min, max, step, decimals) { model = parseFloat(model.toFixed(decimals)) } - return between(model, min, max) + return model } export const SliderMixin = { @@ -56,6 +56,9 @@ export const SliderMixin = { validator: v => v >= 0 }, + innerMin: Number, + innerMax: Number, + color: String, labelColor: String, @@ -106,7 +109,7 @@ export const SliderMixin = { }, editable () { - return this.disable !== true && this.readonly !== true && this.min < this.max + return this.disable !== true && this.readonly !== true && this.minInnerValue <= this.maxInnerValue }, decimals () { @@ -121,10 +124,37 @@ export const SliderMixin = { return this.max - this.min }, + minInnerValue () { + return isNaN(this.innerMin) === true || this.innerMin < this.min + ? this.min + : this.innerMin + }, + + maxInnerValue () { + return isNaN(this.innerMax) === true || this.innerMax > this.max + ? this.max + : this.innerMax + }, + + minInnerRatio () { + return this.__getModelRatio(this.minInnerValue) + }, + + maxInnerRatio () { + return this.__getModelRatio(this.maxInnerValue) + }, + markerStep () { return isNumber(this.markers) === true ? this.markers : this.computedStep }, + innerTrackStyle () { + return { + [ this.positionProp ]: `${100 * this.minInnerRatio}%`, + [ this.sizeProp ]: `${100 * (this.maxInnerRatio - this.minInnerRatio)}%` + } + }, + markerStyle () { if (this.minMaxDiff !== 0) { const size = 100 * this.markerStep / this.minMaxDiff @@ -137,6 +167,46 @@ export const SliderMixin = { } }, + markersScopeBase () { + return { + vertical: this.vertical, + reverse: this.reverse, + editable: this.editable, + + min: this.min, + max: this.max, + minValue: this.minInnerValue, + maxValue: this.maxInnerValue, + + styleFn: val => { + const value = `${val / this.minMaxDiff * 100}%` + + return { + prop: this.positionProp, + value, + style: `${this.positionProp}: ${value}` + } + }, + + setFn: val => { + this.model = val + this.__updateValue() + }, + + stopEvents: { + mousedown: stop, + click: stop + } + } + }, + + markersScope () { + return { + value: this.model, + ...this.markersScopeBase + } + }, + computedTabindex () { return this.editable === true ? this.tabindex || 0 : -1 }, @@ -165,8 +235,8 @@ export const SliderMixin = { attrs () { const attrs = { role: 'slider', - 'aria-valuemin': this.min, - 'aria-valuemax': this.max, + 'aria-valuemin': this.minInnerValue, + 'aria-valuemax': this.maxInnerValue, 'aria-orientation': this.orientation, 'data-step': this.step } @@ -199,6 +269,10 @@ export const SliderMixin = { }, methods: { + __getModelRatio (model) { + return this.minMaxDiff === 0 ? 0 : (model - this.min) / this.minMaxDiff + }, + __getThumbSvg (h) { return h('svg', { staticClass: 'q-slider__thumb absolute', @@ -218,6 +292,33 @@ export const SliderMixin = { ]) }, + __getTrack (h) { + const track = [ + h('div', { + staticClass: `q-slider__inner-track q-slider__inner-track${this.axis} absolute`, + style: this.innerTrackStyle + }), + + h('div', { + staticClass: `q-slider__track q-slider__track${this.axis} absolute`, + style: this.trackStyle + }) + ] + + this.markers !== false && track.push( + h('div', { + staticClass: `q-slider__track-markers q-slider__track-markers${this.axis} absolute-full fit`, + style: this.markerStyle + }) + ) + + this.$scopedSlots.markers !== void 0 && track.push( + ...this.$scopedSlots.markers(this.markersScope) + ) + + return track + }, + __getPinStyle (percent, ratio) { if (this.vertical === true) { return {}