B-form-spinbutton : how to add two features : default value, and direction (+/-) on @change event
Wewill opened this issue · comments
Hello everyone, I'm struggling with adding a default starting value to this component when the v-model
is, for example, undefined
. Indeed, I need to start at 30 instead of the minimum 0 ( if min='0'
). How can I achieve this?
Additionally, I'm trying to include in the @change event the direction: whether the dash
or plus
button was clicked. But I do not succeed for the moment.
Thanks in advance!
Hello again, between, I did a custom component based on <b-form-spinbutton>
, I hope it's clean & can help. ( it's based on custom needs )
<template>
<div
class="b-form-spinbutton form-control d-inline-flex flex-column"
:class="[
groupClass,
'form-control-' + size,
state === undefined ? 'border-0' : '',
state === false ? 'border border-2 border-danger' : '',
state === true ? 'border border-2 border-success' : '',
]"
role="group"
:tabindex="disabled ? null : '-1'"
:title="ariaLabel"
@keydown="onKeydown"
@keyup="onKeyup"
@focus="onFocusBlur"
@blur="onFocusBlur"
>
<code v-if="false">value:: {{ value }} localValue:: {{ localValue }} valueAsFixed:: {{ valueAsFixed }} state:: {{ state }} </code>
<!-- Plus btn -->
<b-button
variant="none"
class="btn-sm border-0 rounded-0 py-0 pt-1"
@click="valueChange(undefined, 'plus')"
tabindex="-1"
:disabled="disabled || readonly"
:aria-disabled="disabled || readonly ? 'true' : null"
:aria-controls="`${id}_inc`"
aria-label="Click to increment value"
aria-keyshortcuts="ArrowUp"
@mousedown="btnHandler($event, stepUp)"
@touchstart="btnHandler($event, stepUp)"
key="inc"
ref="inc"
>
<b-icon icon="plus" font-scale="1.6" />
</b-button>
<!-- Input -->
<b-form-input
:id="id"
:size="size"
v-model="localValue"
type="text"
:min="min"
:max="max"
class="text-center border-top border-bottom w-100 rounded-0 p-0 m-0"
:class="[elClass, state === false ? 'pl-17 text-danger-i' : '', state === true ? 'pl-17 text-success-i' : '']"
:placeholder="placeholder"
:disabled="disabled"
:state="state"
number
v-mask="{ regex: '[-]?\\d+', allowMinus: true }"
role="input"
:tabindex="disabled ? null : '0'"
aria-live="off"
:aria-label="this.ariaLabel || null"
:aria-controls="this.ariaControls || null"
:aria-invalid="(typeof value == 'undefined' || value == null) && required ? 'true' : null"
:aria-required="required ? 'true' : null"
:aria-valuemin="toString(computedMin)"
:aria-valuemax="toString(computedMax)"
:aria-valuenow="typeof value != 'undefined' && value !== null ? value : null"
:aria-valuetext="typeof value != 'undefined' && value !== null ? computedFormatter(value) : null"
/>
<!-- Dash btn -->
<b-button
variant="none"
class="btn-sm border-0 rounded-0 py-0 pt-1"
@click="valueChange(undefined, 'dash')"
tabindex="-1"
:disabled="disabled || readonly"
:aria-disabled="disabled || readonly ? 'true' : null"
:aria-controls="`${id}_dec`"
aria-label="Click to decrement value"
aria-keyshortcuts="ArrowDown"
@mousedown="btnHandler($event, stepDown)"
@touchstart="btnHandler($event, stepDown)"
key="dec"
ref="dec"
>
<b-icon icon="dash" font-scale="1.6" />
</b-button>
</div>
</template>
<script>
import { CODE_DOWN, CODE_END, CODE_HOME, CODE_PAGEUP, CODE_UP, CODE_PAGEDOWN } from "bootstrap-vue/src/constants/key-codes";
import { arrayIncludes, concat } from "bootstrap-vue/src/utils/array";
import { attemptBlur, attemptFocus } from "bootstrap-vue/src/utils/dom";
import { eventOnOff, stopEvent } from "bootstrap-vue/src/utils/events";
import { isNull } from "bootstrap-vue/src/utils/inspect";
import { mathFloor, mathMax, mathPow, mathRound } from "bootstrap-vue/src/utils/math";
import { toFloat, toInteger } from "bootstrap-vue/src/utils/number";
import { hasPropFunction } from "bootstrap-vue/src/utils/props"; //makeProp, makePropsConfigurable
import { BIcon, BIconDash, BIconPlus } from "bootstrap-vue";
// Default for spin button range and step
const DEFAULT_MIN = 1;
const DEFAULT_MAX = 100;
const DEFAULT_STEP = 1;
// Delay before auto-repeat in ms
const DEFAULT_REPEAT_DELAY = 500;
// Repeat interval in ms
const DEFAULT_REPEAT_INTERVAL = 100;
// Repeat rate increased after number of repeats
const DEFAULT_REPEAT_THRESHOLD = 10;
// Repeat speed multiplier (step multiplier, must be an integer)
const DEFAULT_REPEAT_MULTIPLIER = 4;
const KEY_CODES = [
CODE_UP,
CODE_DOWN,
CODE_HOME,
CODE_END,
CODE_PAGEUP,
CODE_PAGEDOWN,
// Numeric : numbers on top of keyboard ( 0 - 9 ) : 48 - 57
48,
49,
50,
51,
52,
53,
54,
55,
56,
57,
// numbers on numeric keypad ( 0 - 9 ) : 96 - 105
96,
97,
98,
99,
100,
101,
102,
103,
104,
105,
];
export default {
name: "FormSpinbuttonWithInput",
components: {
BIcon,
/* eslint-disable vue/no-unused-components */
BIconDash,
BIconPlus,
},
props: {
id: {
type: String,
required: true,
},
groupClass: {
type: Array,
required: false,
},
elClass: {
type: Array,
required: false,
},
size: {
type: String,
required: false,
default: "md",
validator: function (value) {
return ["sm", "md", "lg"].includes(value);
},
},
value: {
type: Number,
required: false,
default: undefined,
},
defaultValue: {
type: Number,
required: false,
default: 0,
},
step: {
type: Number,
required: false,
},
min: {
type: Number,
required: false,
},
max: {
type: Number,
required: false,
},
repeatInterval: {
type: Number,
required: false,
},
repeatDelay: {
type: Number,
required: false,
},
repeatThreshold: {
type: Number,
required: false,
},
repeatStepMultiplier: {
type: Number,
required: false,
},
placeholder: {
type: String,
required: false,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
readonly: {
type: Boolean,
required: false,
default: false,
},
wrap: {
type: Boolean,
required: false,
default: false,
},
ariaControls: {
type: String,
required: false,
},
ariaLabel: {
type: String,
required: false,
},
formatterFn: {
type: Function,
required: false,
},
state: {
type: Boolean,
required: false,
},
},
data() {
return {
localValue: toFloat(this.value, null),
hasFocus: false,
};
},
computed: {
required() {
return false;
},
computedReadonly() {
return this.readonly && !this.disabled;
},
computedStep() {
return toFloat(this.step, DEFAULT_STEP);
},
computedDefault() {
return toFloat(this.defaultValue, DEFAULT_MIN);
},
computedMin() {
return toFloat(this.min, DEFAULT_MIN);
},
computedMax() {
// We round down to the nearest maximum step value
const max = toFloat(this.max, DEFAULT_MAX);
const step = this.computedStep;
const min = this.computedMin;
return mathFloor((max - min) / step) * step + min;
},
computedDelay() {
const delay = toInteger(this.repeatDelay, 0);
return delay > 0 ? delay : DEFAULT_REPEAT_DELAY;
},
computedInterval() {
const interval = toInteger(this.repeatInterval, 0);
return interval > 0 ? interval : DEFAULT_REPEAT_INTERVAL;
},
computedThreshold() {
return mathMax(toInteger(this.repeatThreshold, DEFAULT_REPEAT_THRESHOLD), 1);
},
computedStepMultiplier() {
return mathMax(toInteger(this.repeatStepMultiplier, DEFAULT_REPEAT_MULTIPLIER), 1);
},
computedPrecision() {
// Quick and dirty way to get the number of decimals
const step = this.computedStep;
return mathFloor(step) === step ? 0 : (step.toString().split(".")[1] || "").length;
},
computedMultiplier() {
return mathPow(10, this.computedPrecision || 0);
},
valueAsFixed() {
const value = this.localValue;
return isNull(value) ? "" : value.toFixed(this.computedPrecision);
},
defaultFormatter() {
// Returns and `Intl.NumberFormat` formatter method reference
const precision = this.computedPrecision;
const nf = new Intl.NumberFormat(this.computedLocale, {
style: "decimal",
useGrouping: false,
minimumIntegerDigits: 1,
minimumFractionDigits: precision,
maximumFractionDigits: precision,
notation: "standard",
});
// Return the format method reference
return nf.format;
},
computedFormatter() {
const { formatterFn } = this;
return hasPropFunction(formatterFn) ? formatterFn : this.defaultFormatter;
},
},
watch: {
value(value) {
console.info("FromSpinButtonW/Input:: watch set value", value);
this.localValue = toFloat(value, null);
},
localValue(value) {
console.info("FromSpinButtonW/Input:: watch localValue", value);
this.$emit("change", { value: value, event: "watch" });
},
disabled(disabled) {
if (disabled) {
this.clearRepeat();
}
},
readonly(readonly) {
if (readonly) {
this.clearRepeat();
}
},
},
created() {
// Create non reactive properties
this.$_autoDelayTimer = null;
this.$_autoRepeatTimer = null;
this.$_keyIsDown = false;
},
beforeDestroy() {
this.clearRepeat();
},
/* istanbul ignore next */
deactivated() {
this.clearRepeat();
},
methods: {
// --- Public methods ---
focus() {
if (!this.disabled) {
attemptFocus(this.$refs.spinner);
}
},
blur() {
if (!this.disabled) {
attemptBlur(this.$refs.spinner);
}
},
// --- Private methods ---
emitChange(eventType = "change") {
console.info("FromSpinButtonW/Input:: emitChange", this.localValue, eventType);
this.$emit("change", { value: this.localValue, event: eventType });
},
stepValue(direction) {
console.info("FromSpinButtonW/Input:: stepValue", direction);
// Sets a new incremented or decremented value, supporting optional wrapping
// Direction is either +1 or -1 (or a multiple thereof)
let value = this.localValue;
if (!this.disabled && !isNull(value)) {
const step = this.computedStep * direction;
const min = this.computedMin;
const max = this.computedMax;
const multiplier = this.computedMultiplier;
const wrap = this.wrap;
// We ensure that the value steps like a native input
value = mathRound((value - min) / step) * step + min + step;
// We ensure that precision is maintained (decimals)
value = mathRound(value * multiplier) / multiplier;
// Handle if wrapping is enabled
this.localValue = value > max ? (wrap ? min : max) : value < min ? (wrap ? max : min) : value;
}
},
onFocusBlur(event) {
this.hasFocus = this.disabled ? false : event.type === "focus";
},
stepUp(multiplier = 1) {
const value = this.localValue;
console.info("FromSpinButtonW/Input:: stepUp", multiplier, isNull(value));
if (isNull(value)) {
this.localValue = this.computedDefault > 0 ? this.computedDefault + 1 : this.computedMin;
} else {
this.stepValue(+1 * multiplier);
}
},
stepDown(multiplier = 1) {
const value = this.localValue;
console.info("FromSpinButtonW/Input:: stepDown", multiplier, isNull(value), this.wrap);
if (isNull(value)) {
this.localValue = this.wrap ? this.computedMax : this.computedDefault > 0 ? this.computedDefault - 1 : this.computedMin;
} else {
this.stepValue(-1 * multiplier);
}
},
onKeydown(event) {
console.info("FromSpinButtonW/Input:: onKeydown", event);
const { key, keyCode, altKey, ctrlKey, metaKey } = event;
/* istanbul ignore if */
if (this.disabled || this.readonly || altKey || ctrlKey || metaKey || (keyCode >= 48 && keyCode <= 57) || (keyCode >= 96 && keyCode <= 105)) {
return;
}
if (arrayIncludes(KEY_CODES, keyCode)) {
// https://w3c.github.io/aria-practices/#spinbutton
stopEvent(event, { propagation: false });
/* istanbul ignore if */
if (this.$_keyIsDown) {
// Keypress is already in progress
return;
}
this.resetTimers();
if (arrayIncludes([CODE_UP, CODE_DOWN], keyCode)) {
// The following use the custom auto-repeat handling
this.$_keyIsDown = true;
if (keyCode === CODE_UP) {
this.handleStepRepeat(event, this.stepUp);
} else if (keyCode === CODE_DOWN) {
this.handleStepRepeat(event, this.stepDown);
}
} else {
// These use native OS key repeating
if (keyCode === CODE_PAGEUP) {
this.stepUp(this.computedStepMultiplier);
} else if (keyCode === CODE_PAGEDOWN) {
this.stepDown(this.computedStepMultiplier);
} else if (keyCode === CODE_HOME) {
this.localValue = this.computedMin;
} else if (keyCode === CODE_END) {
this.localValue = this.computedMax;
}
}
}
},
onKeyup(event) {
console.info("FromSpinButtonW/Input:: onKeyup", event, this.disabled, this.readonly, altKey, ctrlKey, metaKey, this.value, this.localValue);
// Emit a change event when the keyup happens
const { keyCode, altKey, ctrlKey, metaKey } = event;
console.info("FromSpinButtonW/Input:: keyCode", keyCode);
/* istanbul ignore if */
if (this.disabled || this.readonly || altKey || ctrlKey || metaKey) {
return;
}
if (arrayIncludes(KEY_CODES, keyCode)) {
stopEvent(event, { propagation: false });
this.resetTimers();
this.$_keyIsDown = false;
this.emitChange("change");
}
},
handleStepRepeat(event, stepper) {
console.info("FromSpinButtonW/Input:: handleStepRepeat", event, stepper);
const { type, button } = event || {};
if (!this.disabled && !this.readonly) {
/* istanbul ignore if */
if (type === "mousedown" && button) {
// We only respond to left (main === 0) button clicks
return;
}
this.resetTimers();
// Step the counter initially
stepper(1);
const threshold = this.computedThreshold;
const multiplier = this.computedStepMultiplier;
const delay = this.computedDelay;
const interval = this.computedInterval;
// Initiate the delay/repeat interval
this.$_autoDelayTimer = setTimeout(() => {
let count = 0;
this.$_autoRepeatTimer = setInterval(() => {
// After N initial repeats, we increase the incrementing step amount
// We do this to minimize screen reader announcements of the value
// (values are announced every change, which can be chatty for SR users)
// And to make it easer to select a value when the range is large
stepper(count < threshold ? 1 : multiplier);
count++;
}, interval);
}, delay);
}
},
onMouseup(event) {
console.info("FromSpinButtonW/Input:: onMouseup", event);
// `<body>` listener, only enabled when mousedown starts
const { type, button } = event || {};
/* istanbul ignore if */
if (type === "mouseup" && button) {
// Ignore non left button (main === 0) mouse button click
return;
}
stopEvent(event, { propagation: false });
this.resetTimers();
this.setMouseup(false);
// Trigger the change event
this.emitChange();
},
setMouseup(on) {
// Enable or disabled the body mouseup/touchend handlers
// Use try/catch to handle case when called server side
try {
eventOnOff(on, document.body, "mouseup", this.onMouseup, false);
eventOnOff(on, document.body, "touchend", this.onMouseup, false);
} catch {}
},
resetTimers() {
console.info("FromSpinButtonW/Input:: resetTimers");
clearTimeout(this.$_autoDelayTimer);
clearInterval(this.$_autoRepeatTimer);
this.$_autoDelayTimer = null;
this.$_autoRepeatTimer = null;
},
clearRepeat() {
console.info("FromSpinButtonW/Input:: clearRepeat");
this.resetTimers();
this.setMouseup(false);
this.$_keyIsDown = false;
},
// Custom events
btnHandler(event, stepper) {
console.info("FromSpinButtonW/Input:: btnHandler", event, stepper);
if (!this.disabled && !this.readonly) {
stopEvent(event, { propagation: false });
this.setMouseup(true);
// Since we `preventDefault()`, we must manually focus the button
attemptFocus(event.currentTarget);
this.handleStepRepeat(event, stepper);
}
},
// Custom method
valueChange(newValue, event) {
console.info(
"FromSpinButtonW/Input:: ValueChange:: FPFR",
typeof this.value != "undefined" ? this.value : undefined,
newValue,
this.defaultValue,
this.min,
this.max,
event
);
if (newValue === undefined) {
this.$emit("change", { value: this.localValue, event: event });
} else {
this.$emit("change", { value: newValue, event: event });
}
},
},
};
</script>
<style scoped>
/* Remove up and down arrows inside number input */
/* Chrome, Safari, Edge, Opera */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type="number"] {
-moz-appearance: textfield;
}
.b-form-spinbutton.flex-column input {
margin: 0 0.25rem;
padding: 0.25rem 0;
}
.b-form-spinbutton input {
font-size: inherit;
outline: 0;
border: 0;
background-color: transparent;
width: auto;
margin: 0;
padding: 0 0.25rem;
}
.b-form-spinbutton input > div {
display: block;
min-width: 2.25em;
height: 1.5em;
}
</style>
Best !
I would like to test your code.
"bootstrap-vue/src/constants/key-codes" How do I get this part?
I would like to test your code.
"bootstrap-vue/src/constants/key-codes" How do I get this part?
Hi, from your node packages