bootstrap-vue / bootstrap-vue

BootstrapVue provides one of the most comprehensive implementations of Bootstrap v4 for Vue.js. With extensive and automated WAI-ARIA accessibility markup.

Home Page:https://bootstrap-vue.org

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

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