form-rating.js 14.4 KB
var _watch;

function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }

function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }

function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }

import { extend } from '../../vue';
import { NAME_FORM_RATING, NAME_FORM_RATING_STAR } from '../../constants/components';
import { EVENT_NAME_CHANGE, EVENT_NAME_SELECTED } from '../../constants/events';
import { PROP_TYPE_ARRAY_STRING, PROP_TYPE_BOOLEAN, PROP_TYPE_NUMBER, PROP_TYPE_NUMBER_STRING, PROP_TYPE_STRING } from '../../constants/props';
import { CODE_LEFT, CODE_RIGHT, CODE_UP, CODE_DOWN } from '../../constants/key-codes';
import { SLOT_NAME_ICON_CLEAR, SLOT_NAME_ICON_EMPTY, SLOT_NAME_ICON_FULL, SLOT_NAME_ICON_HALF } from '../../constants/slots';
import { arrayIncludes, concat } from '../../utils/array';
import { attemptBlur, attemptFocus } from '../../utils/dom';
import { stopEvent } from '../../utils/events';
import { identity } from '../../utils/identity';
import { isNull } from '../../utils/inspect';
import { isLocaleRTL } from '../../utils/locale';
import { mathMax, mathMin } from '../../utils/math';
import { makeModelMixin } from '../../utils/model';
import { toInteger, toFloat } from '../../utils/number';
import { omit, sortKeys } from '../../utils/object';
import { makeProp, makePropsConfigurable } from '../../utils/props';
import { toString } from '../../utils/string';
import { formSizeMixin, props as formSizeProps } from '../../mixins/form-size';
import { idMixin, props as idProps } from '../../mixins/id';
import { normalizeSlotMixin } from '../../mixins/normalize-slot';
import { props as formControlProps } from '../../mixins/form-control';
import { BIcon } from '../../icons/icon';
import { BIconStar, BIconStarHalf, BIconStarFill, BIconX } from '../../icons/icons'; // --- Constants ---

var _makeModelMixin = makeModelMixin('value', {
  type: PROP_TYPE_NUMBER_STRING,
  event: EVENT_NAME_CHANGE
}),
    modelMixin = _makeModelMixin.mixin,
    modelProps = _makeModelMixin.props,
    MODEL_PROP_NAME = _makeModelMixin.prop,
    MODEL_EVENT_NAME = _makeModelMixin.event;

var MIN_STARS = 3;
var DEFAULT_STARS = 5; // --- Helper methods ---

var computeStars = function computeStars(stars) {
  return mathMax(MIN_STARS, toInteger(stars, DEFAULT_STARS));
};

var clampValue = function clampValue(value, min, max) {
  return mathMax(mathMin(value, max), min);
}; // --- Helper components ---
// @vue/component


var BVFormRatingStar = extend({
  name: NAME_FORM_RATING_STAR,
  mixins: [normalizeSlotMixin],
  props: {
    disabled: makeProp(PROP_TYPE_BOOLEAN, false),
    // If parent is focused
    focused: makeProp(PROP_TYPE_BOOLEAN, false),
    hasClear: makeProp(PROP_TYPE_BOOLEAN, false),
    rating: makeProp(PROP_TYPE_NUMBER, 0),
    readonly: makeProp(PROP_TYPE_BOOLEAN, false),
    star: makeProp(PROP_TYPE_NUMBER, 0),
    variant: makeProp(PROP_TYPE_STRING)
  },
  methods: {
    onClick: function onClick(event) {
      if (!this.disabled && !this.readonly) {
        stopEvent(event, {
          propagation: false
        });
        this.$emit(EVENT_NAME_SELECTED, this.star);
      }
    }
  },
  render: function render(h) {
    var rating = this.rating,
        star = this.star,
        focused = this.focused,
        hasClear = this.hasClear,
        variant = this.variant,
        disabled = this.disabled,
        readonly = this.readonly;
    var minStar = hasClear ? 0 : 1;
    var type = rating >= star ? 'full' : rating >= star - 0.5 ? 'half' : 'empty';
    var slotScope = {
      variant: variant,
      disabled: disabled,
      readonly: readonly
    };
    return h('span', {
      staticClass: 'b-rating-star',
      class: {
        // When not hovered, we use this class to focus the current (or first) star
        focused: focused && rating === star || !toInteger(rating) && star === minStar,
        // We add type classes to we can handle RTL styling
        'b-rating-star-empty': type === 'empty',
        'b-rating-star-half': type === 'half',
        'b-rating-star-full': type === 'full'
      },
      attrs: {
        tabindex: !disabled && !readonly ? '-1' : null
      },
      on: {
        click: this.onClick
      }
    }, [h('span', {
      staticClass: 'b-rating-icon'
    }, [this.normalizeSlot(type, slotScope)])]);
  }
}); // --- Props ---

export var props = makePropsConfigurable(sortKeys(_objectSpread(_objectSpread(_objectSpread(_objectSpread(_objectSpread({}, idProps), modelProps), omit(formControlProps, ['required', 'autofocus'])), formSizeProps), {}, {
  // CSS color string (overrides variant)
  color: makeProp(PROP_TYPE_STRING),
  iconClear: makeProp(PROP_TYPE_STRING, 'x'),
  iconEmpty: makeProp(PROP_TYPE_STRING, 'star'),
  iconFull: makeProp(PROP_TYPE_STRING, 'star-fill'),
  iconHalf: makeProp(PROP_TYPE_STRING, 'star-half'),
  inline: makeProp(PROP_TYPE_BOOLEAN, false),
  // Locale for the formatted value (if shown)
  // Defaults to the browser locale. Falls back to `en`
  locale: makeProp(PROP_TYPE_ARRAY_STRING),
  noBorder: makeProp(PROP_TYPE_BOOLEAN, false),
  precision: makeProp(PROP_TYPE_NUMBER_STRING),
  readonly: makeProp(PROP_TYPE_BOOLEAN, false),
  showClear: makeProp(PROP_TYPE_BOOLEAN, false),
  showValue: makeProp(PROP_TYPE_BOOLEAN, false),
  showValueMax: makeProp(PROP_TYPE_BOOLEAN, false),
  stars: makeProp(PROP_TYPE_NUMBER_STRING, DEFAULT_STARS, function (value) {
    return toInteger(value) >= MIN_STARS;
  }),
  variant: makeProp(PROP_TYPE_STRING)
})), NAME_FORM_RATING); // --- Main component ---
// @vue/component

export var BFormRating = /*#__PURE__*/extend({
  name: NAME_FORM_RATING,
  components: {
    BIconStar: BIconStar,
    BIconStarHalf: BIconStarHalf,
    BIconStarFill: BIconStarFill,
    BIconX: BIconX
  },
  mixins: [idMixin, modelMixin, formSizeMixin],
  props: props,
  data: function data() {
    var value = toFloat(this[MODEL_PROP_NAME], null);
    var stars = computeStars(this.stars);
    return {
      localValue: isNull(value) ? null : clampValue(value, 0, stars),
      hasFocus: false
    };
  },
  computed: {
    computedStars: function computedStars() {
      return computeStars(this.stars);
    },
    computedRating: function computedRating() {
      var value = toFloat(this.localValue, 0);
      var precision = toInteger(this.precision, 3); // We clamp the value between `0` and stars

      return clampValue(toFloat(value.toFixed(precision)), 0, this.computedStars);
    },
    computedLocale: function computedLocale() {
      var locales = concat(this.locale).filter(identity);
      var nf = new Intl.NumberFormat(locales);
      return nf.resolvedOptions().locale;
    },
    isInteractive: function isInteractive() {
      return !this.disabled && !this.readonly;
    },
    isRTL: function isRTL() {
      return isLocaleRTL(this.computedLocale);
    },
    formattedRating: function formattedRating() {
      var precision = toInteger(this.precision);
      var showValueMax = this.showValueMax;
      var locale = this.computedLocale;
      var formatOptions = {
        notation: 'standard',
        minimumFractionDigits: isNaN(precision) ? 0 : precision,
        maximumFractionDigits: isNaN(precision) ? 3 : precision
      };
      var stars = this.computedStars.toLocaleString(locale);
      var value = this.localValue;
      value = isNull(value) ? showValueMax ? '-' : '' : value.toLocaleString(locale, formatOptions);
      return showValueMax ? "".concat(value, "/").concat(stars) : value;
    }
  },
  watch: (_watch = {}, _defineProperty(_watch, MODEL_PROP_NAME, function (newValue, oldValue) {
    if (newValue !== oldValue) {
      var value = toFloat(newValue, null);
      this.localValue = isNull(value) ? null : clampValue(value, 0, this.computedStars);
    }
  }), _defineProperty(_watch, "localValue", function localValue(newValue, oldValue) {
    if (newValue !== oldValue && newValue !== (this.value || 0)) {
      this.$emit(MODEL_EVENT_NAME, newValue || null);
    }
  }), _defineProperty(_watch, "disabled", function disabled(newValue) {
    if (newValue) {
      this.hasFocus = false;
      this.blur();
    }
  }), _watch),
  methods: {
    // --- Public methods ---
    focus: function focus() {
      if (!this.disabled) {
        attemptFocus(this.$el);
      }
    },
    blur: function blur() {
      if (!this.disabled) {
        attemptBlur(this.$el);
      }
    },
    // --- Private methods ---
    onKeydown: function onKeydown(event) {
      var keyCode = event.keyCode;

      if (this.isInteractive && arrayIncludes([CODE_LEFT, CODE_DOWN, CODE_RIGHT, CODE_UP], keyCode)) {
        stopEvent(event, {
          propagation: false
        });
        var value = toInteger(this.localValue, 0);
        var min = this.showClear ? 0 : 1;
        var stars = this.computedStars; // In RTL mode, LEFT/RIGHT are swapped

        var amountRtl = this.isRTL ? -1 : 1;

        if (keyCode === CODE_LEFT) {
          this.localValue = clampValue(value - amountRtl, min, stars) || null;
        } else if (keyCode === CODE_RIGHT) {
          this.localValue = clampValue(value + amountRtl, min, stars);
        } else if (keyCode === CODE_DOWN) {
          this.localValue = clampValue(value - 1, min, stars) || null;
        } else if (keyCode === CODE_UP) {
          this.localValue = clampValue(value + 1, min, stars);
        }
      }
    },
    onSelected: function onSelected(value) {
      if (this.isInteractive) {
        this.localValue = value;
      }
    },
    onFocus: function onFocus(event) {
      this.hasFocus = !this.isInteractive ? false : event.type === 'focus';
    },
    // --- Render methods ---
    renderIcon: function renderIcon(icon) {
      return this.$createElement(BIcon, {
        props: {
          icon: icon,
          variant: this.disabled || this.color ? null : this.variant || null
        }
      });
    },
    iconEmptyFn: function iconEmptyFn() {
      return this.renderIcon(this.iconEmpty);
    },
    iconHalfFn: function iconHalfFn() {
      return this.renderIcon(this.iconHalf);
    },
    iconFullFn: function iconFullFn() {
      return this.renderIcon(this.iconFull);
    },
    iconClearFn: function iconClearFn() {
      return this.$createElement(BIcon, {
        props: {
          icon: this.iconClear
        }
      });
    }
  },
  render: function render(h) {
    var _this = this;

    var disabled = this.disabled,
        readonly = this.readonly,
        name = this.name,
        form = this.form,
        inline = this.inline,
        variant = this.variant,
        color = this.color,
        noBorder = this.noBorder,
        hasFocus = this.hasFocus,
        computedRating = this.computedRating,
        computedStars = this.computedStars,
        formattedRating = this.formattedRating,
        showClear = this.showClear,
        isRTL = this.isRTL,
        isInteractive = this.isInteractive,
        $scopedSlots = this.$scopedSlots;
    var $content = [];

    if (showClear && !disabled && !readonly) {
      var $icon = h('span', {
        staticClass: 'b-rating-icon'
      }, [($scopedSlots[SLOT_NAME_ICON_CLEAR] || this.iconClearFn)()]);
      $content.push(h('span', {
        staticClass: 'b-rating-star b-rating-star-clear flex-grow-1',
        class: {
          focused: hasFocus && computedRating === 0
        },
        attrs: {
          tabindex: isInteractive ? '-1' : null
        },
        on: {
          click: function click() {
            return _this.onSelected(null);
          }
        },
        key: 'clear'
      }, [$icon]));
    }

    for (var index = 0; index < computedStars; index++) {
      var value = index + 1;
      $content.push(h(BVFormRatingStar, {
        staticClass: 'flex-grow-1',
        style: color && !disabled ? {
          color: color
        } : {},
        props: {
          rating: computedRating,
          star: value,
          variant: disabled ? null : variant || null,
          disabled: disabled,
          readonly: readonly,
          focused: hasFocus,
          hasClear: showClear
        },
        on: {
          selected: this.onSelected
        },
        scopedSlots: {
          empty: $scopedSlots[SLOT_NAME_ICON_EMPTY] || this.iconEmptyFn,
          half: $scopedSlots[SLOT_NAME_ICON_HALF] || this.iconHalfFn,
          full: $scopedSlots[SLOT_NAME_ICON_FULL] || this.iconFullFn
        },
        key: index
      }));
    }

    if (name) {
      $content.push(h('input', {
        attrs: {
          type: 'hidden',
          value: isNull(this.localValue) ? '' : computedRating,
          name: name,
          form: form || null
        },
        key: 'hidden'
      }));
    }

    if (this.showValue) {
      $content.push(h('b', {
        staticClass: 'b-rating-value flex-grow-1',
        attrs: {
          'aria-hidden': 'true'
        },
        key: 'value'
      }, toString(formattedRating)));
    }

    return h('output', {
      staticClass: 'b-rating form-control align-items-center',
      class: [{
        'd-inline-flex': inline,
        'd-flex': !inline,
        'border-0': noBorder,
        disabled: disabled,
        readonly: !disabled && readonly
      }, this.sizeFormClass],
      attrs: {
        id: this.safeId(),
        dir: isRTL ? 'rtl' : 'ltr',
        tabindex: disabled ? null : '0',
        disabled: disabled,
        role: 'slider',
        'aria-disabled': disabled ? 'true' : null,
        'aria-readonly': !disabled && readonly ? 'true' : null,
        'aria-live': 'off',
        'aria-valuemin': showClear ? '0' : '1',
        'aria-valuemax': toString(computedStars),
        'aria-valuenow': computedRating ? toString(computedRating) : null
      },
      on: {
        keydown: this.onKeydown,
        focus: this.onFocus,
        blur: this.onFocus
      }
    }, $content);
  }
});