wbotelhos/raty

View on GitHub
src/raty.js

Summary

Maintainability
F
3 days
Test Coverage
/*!
 * Raty - A Star Rating Plugin
 *
 * author: Washington Botelho
 * github: wbotelhos/raty
 * version: 4.3.0
 */
class Raty {
  /**
   *
   * @param {object} element
   * @param {object} options
   *
   */
  constructor(element, options = {}) {
    this.element = element;
    this.opt = { ...this.defaultOptions(), ...options, ...this._parseOptions(element.dataset) };
  }

  defaultOptions() {
    return {
      cancelButton: false,
      cancelClass: 'raty-cancel',
      cancelHint: 'Cancel this rating!',
      cancelOff: 'cancel-off.png',
      cancelOn: 'cancel-on.png',
      cancelPlace: 'left',
      click: undefined,
      half: false,
      halfShow: true,
      hints: ['bad', 'poor', 'regular', 'good', 'gorgeous'],
      iconRange: undefined,
      iconRangeSame: false,
      mouseout: undefined,
      mouseover: undefined,
      noRatedMsg: 'Not rated yet!',
      number: 5,
      numberMax: 20,
      path: undefined,
      precision: false,
      readOnly: false,
      round: { down: 0.25, full: 0.6, up: 0.76 },
      score: undefined,
      scoreName: 'score',
      single: false,
      space: true,
      starHalf: 'star-half.png',
      starOff: 'star-off.png',
      starOn: 'star-on.png',
      starType: 'img',
      target: undefined,
      targetFormat: '{score}',
      targetKeep: false,
      targetScore: undefined,
      targetText: '',
      targetType: 'hint',
    };
  }

  cancel(click) {
    if (!this._isReadOnly()) {
      this[click ? 'click' : 'score'](null);
      this.scoreField.removeAttribute('value');
    }
  }

  // TODO: model spec
  click(score) {
    if (!this._isReadOnly()) {
      score = this._adjustedScore(score);

      this._apply(score);

      if (this.opt.click) {
        this.opt.click.call(this, score, this.element);
      }

      this._target(score);
    }
  }

  // TODO: model spec
  move(score) {
    var integer = parseInt(score, 10);
    var decimal = this._getDecimal(score, 1);

    if (integer >= this.opt.number) {
      integer = this.opt.number - 1;
      decimal = 10;
    }

    var width = this._getWidth();
    var steps = width / 10;
    var star = this.stars[integer];
    var percent = star.offsetLeft + steps * decimal;
    var evt = new Event('mousemove');
    evt.pageX = percent;
    this.isMove = true;
    star.dispatchEvent(evt);
    this.isMove = false;
  }

  // TODO: model spec
  readOnly(readonly) {
    if (this._isReadOnly() !== readonly) {
      if (readonly) {
        this._lock();
      } else {
        this._binds();
        this._unlock();
      }
      this.element.dataset.readOnly = readonly;
    }
  }

  score() {
    return arguments.length ? this.setScore(arguments[0]) : this.getScore();
  }

  setScore(score) {
    if (!this._isReadOnly()) {
      score = this._adjustedScore(score);

      this._apply(score);
      this._target(score);
    }
  }
  // TODO: model spec
  getScore() {
    var score = [];
    var value;

    value = this.scoreField.value;

    score.push(value ? +value : undefined);

    return score.length > 1 ? score : score[0];
  }

  init() {
    this._executeCallbacks();
    this._adjustNumber();
    this._adjustHints();

    this.opt.score = this._adjustedScore(this.opt.score);

    if (this.opt.starType !== 'img') {
      this._adjustStarName();
    }

    this._setPath();
    this._createStars();

    if (this.opt.cancelButton) {
      this._createCancel();
    }

    if (this.opt.precision) {
      this._adjustPrecision();
    }

    this._createScore();

    this._apply(this.opt.score);
    this._setTitle(this.opt.score);
    this._target(this.opt.score);

    if (this.opt.readOnly) {
      this._lock();
    } else {
      this.element.style.cursor = 'pointer';
      this._binds();
    }

    return this;
  }

  // private

  // TODO: model spec
  _adjustedScore(score) {
    if (score || score === 0) {
      return this._between(score, 0, this.opt.number);
    }
  }

  _adjustHints() {
    // TODO: is it possible `hints` does not exist?
    if (!this.opt.hints) {
      this.opt.hints = [];
    }

    if (!this.opt.halfShow && !this.opt.half) {
      return;
    }

    var steps = this.opt.precision ? 10 : 2;

    for (let i = 0; i < this.opt.number; i++) {
      var group = this.opt.hints[i];

      if (Object.prototype.toString.call(group) !== '[object Array]') {
        group = [group];
      }

      this.opt.hints[i] = [];

      for (let j = 0; j < steps; j++) {
        var hint = group[j];
        var last = group[group.length - 1];

        if (last === undefined) {
          last = null;
        }

        this.opt.hints[i][j] = hint === undefined ? last : hint;
      }
    }
  }

  _adjustNumber() {
    this.opt.number = this._between(this.opt.number, 1, this.opt.numberMax);
  }

  _adjustPrecision() {
    this.opt.half = true;
  }

  _adjustStarName() {
    const replaces = ['cancelOff', 'cancelOn', 'starHalf', 'starOff', 'starOn'];

    this.opt.path = '';

    for (let i = 0; i < replaces.length; i++) {
      this.opt[replaces[i]] = this.opt[replaces[i]].replace('.', '-');
    }
  }

  // TODO: model spec
  _apply(score) {
    this._fill(score);

    if (score) {
      if (score > 0) {
        this.scoreField.value = score;
      }

      this._roundStars(score);
    }
  }

  _attributesForIndex(i) {
    var name = this._nameForIndex(i);
    var attributes = { alt: i, src: this.opt.path + this.opt[name] };

    if (this.opt.starType !== 'img') {
      attributes = { 'data-alt': i, 'class': this.opt[name] };
    }

    attributes.title = this._getHint(i);

    return attributes;
  }

  _between(value, min, max) {
    return Math.min(Math.max(parseFloat(value), min), max);
  }

  // TODO: model spec
  _binds() {
    if (this.cancelButton) {
      this._bindOverCancel();
      this._bindClickCancel();
      this._bindOutCancel();
    }
    this._bindOver();
    this._bindClick();
    this._bindOut();
  }

  // TODO: model spec
  _bindClick() {
    this.stars.forEach((value) => {
      value.addEventListener('click', (evt) => {
        if (this._isReadOnly()) {
          return;
        }

        let execute;
        let score = this.opt.half || this.opt.precision ? this.element.dataset.score : value.alt || value.dataset.alt;

        if (this.opt.half && !this.opt.precision) {
          score = this._roundHalfScore(score);
        }

        if (this.opt.click) {
          execute = this.opt.click.call(this, +score, this.element, evt);
        }

        if (execute || execute === undefined) {
          this._apply(+score);
        }
      });
    });
  }

  // TODO: model spec
  _bindClickCancel() {
    this.cancelButton.addEventListener('click', (evt) => {
      this.scoreField.removeAttribute('value');

      if (this.opt.click) {
        this.opt.click.call(this, null, this.element, evt);
      }
    });
  }

  // TODO: model spec
  _bindOut() {
    this.element.addEventListener('mouseleave', (evt) => {
      const score = +this.scoreField.value || undefined;

      this._apply(score);
      this._target(score, evt);
      this._resetTitle();

      if (this.opt.mouseout) {
        this.opt.mouseout.call(this, score, this.element, evt);
      }
    });
  }

  // TODO: model spec
  _bindOutCancel() {
    this.cancelButton.addEventListener('mouseleave', (evt) => {
      let icon = this.opt.cancelOff;

      if (this.opt.starType !== 'img') {
        icon = `${this.opt.cancelClass} ${icon}`;
      }

      this._setIcon(this.cancelButton, icon);

      if (this.opt.mouseout) {
        const score = +this.scoreField.value || undefined;

        this.opt.mouseout.call(this, score, this.element, evt);
      }
    });
  }

  // TODO: model spec
  _bindOver() {
    const action = this.opt.half ? 'mousemove' : 'mouseover';

    this.stars.forEach((value) => {
      value.addEventListener(action, (evt) => {
        const score = this._getScoreByPosition(evt, value);

        this._fill(score);

        if (this.opt.half) {
          this._roundStars(score, evt);
          this._setTitle(score, evt);

          this.element.dataset.score = score;
        }

        this._target(score, evt);

        if (this.opt.mouseover) {
          this.opt.mouseover.call(this, score, this.element, evt);
        }
      });
    });
  }

  // TODO: model spec
  _bindOverCancel() {
    this.cancelButton.addEventListener('mouseover', (evt) => {
      if (this._isReadOnly()) {
        return;
      }

      const starOff = this.opt.path + this.opt.starOff;

      let icon = this.opt.cancelOn;

      if (this.opt.starType === 'img') {
        this.stars.forEach((value) => {
          value.src = starOff;
        });
      } else {
        icon = this.opt.cancelClass + ' ' + icon;

        this.stars.forEach((value) => {
          value.className = starOff;
        });
      }

      this._setIcon(this.cancelButton, icon);
      this._target(null, evt);

      if (this.opt.mouseover) {
        this.opt.mouseover.call(this, null, this.element, evt);
      }
    });
  }

  // TODO: model spec
  _buildScoreField() {
    const input = document.createElement('input');

    input.name = this.opt.scoreName;
    input.type = 'hidden';

    this.element.appendChild(input);

    return input;
  }

  // TODO: model spec
  _createCancel() {
    const button = document.createElement(this.opt.starType);
    const icon = this.opt.path + this.opt.cancelOff;

    button.setAttribute('class', this.opt.cancelClass);
    button.setAttribute('title', this.opt.cancelHint);

    if (this.opt.starType === 'img') {
      button.setAttribute('alt', 'x');
      button.setAttribute('src', icon);
    } else {
      button.classList.add(icon);

      // TODO: use the dataset
      button.setAttribute('data-alt', 'x');
    }

    if (this.opt.cancelPlace === 'left') {
      this.element.prepend('\u00A0');
      this.element.prepend(button);
    } else {
      this.element.append('\u00A0');
      this.element.appendChild(button);
    }

    this.cancelButton = button;
  }

  // TODO: model spec
  _createScore() {
    this.scoreField = document.querySelector(this.opt.targetScore) || this._buildScoreField();
  }

  _createStars() {
    for (let i = 1; i <= this.opt.number; i++) {
      const attributes = this._attributesForIndex(i);

      let star = document.createElement(this.opt.starType);

      for (const key in attributes) {
        star.setAttribute(key, attributes[key]);
      }

      this.element.appendChild(star);

      if (this.opt.space && i < this.opt.number) {
        this.element.append('\u00A0');
      }
    }

    this.stars = this.element.querySelectorAll(this.opt.starType);
  }

  // TODO: model spec
  _error(message) {
    throw new Error(message);
  }

  _executeCallbacks() {
    const options = ['number', 'readOnly', 'score', 'scoreName', 'target', 'path'];

    for (let i = 0; i < options.length; i++) {
      if (typeof this.opt[options[i]] === 'function') {
        const value = this.opt[options[i]].call(this, this.element);

        if (value) {
          this.opt[options[i]] = value;
        } else {
          delete this.opt[options[i]];
        }
      }
    }
  }

  // TODO: model spec
  _fill(score) {
    let hash = 0;

    if (this.opt.iconRangeSame && this.opt.iconRange) {
      while (hash < this.opt.iconRange.length && this.opt.iconRange[hash].range < score) {
        hash++;
      }
    }

    for (let i = 1; i <= this.stars.length; i++) {
      const star = this.stars[i - 1];
      const turnOn = this._turnOn(i, score);

      let icon;

      if (this.opt.iconRange && this.opt.iconRange.length > hash) {
        const irange = this.opt.iconRange[hash];

        icon = this._getRangeIcon(irange, turnOn);

        if (i <= irange.range) {
          this._setIcon(star, icon);
        }

        if (i === irange.range) {
          hash++;
        }
      } else {
        icon = this.opt[turnOn ? 'starOn' : 'starOff'];

        this._setIcon(star, icon);
      }
    }
  }

  _getDecimal(number, fractions) {
    const decimal = number.toString().split('.')[1];

    let result = 0;

    if (decimal) {
      result = parseInt(decimal.slice(0, fractions), 10);

      if (decimal.slice(1, 5) === '9999') {
        result++;
      }
    }

    return result;
  }

  // TODO: model spec
  _getRangeIcon(irange, turnOn) {
    return turnOn ? irange.on || this.opt.starOn : irange.off || this.opt.starOff;
  }

  // TODO: model spec
  _getScoreByPosition(evt, icon) {
    let score = parseInt(icon.alt || icon.getAttribute('data-alt'), 10);

    if (this.opt.half) {
      const size = this._getWidth();
      const percent = parseFloat((evt.pageX - icon.getBoundingClientRect().x) / size);

      score = score - 1 + percent;
    }

    return score;
  }

  // TODO: model spec
  _getHint(score, evt) {
    if (score !== 0 && !score) {
      return this.opt.noRatedMsg;
    }

    const integer = Math.ceil(score);
    const group = this.opt.hints[(integer || 1) - 1];
    const set = !evt || this.isMove;

    let decimal = this._getDecimal(score, 1);
    let hint = group;

    if (this.opt.precision) {
      if (set) {
        decimal = decimal === 0 ? 9 : decimal - 1;
      }

      hint = group[decimal];
    } else if (this.opt.halfShow || this.opt.half) {
      decimal = set && decimal === 0 ? 1 : decimal > 5 ? 1 : 0;

      hint = group[decimal];
    }

    return hint === '' ? '' : hint || score;
  }

  // TODO: model spec
  _getWidth() {
    // 16 is the default font-size px when icons is not redered yet
    const width = parseFloat(this.stars[0].offsetWidth) || 16;

    if (!width) {
      this._error('Could not get the icon width!');
    }

    return width;
  }

  _isReadOnly() {
    return { true: true }[this.element.dataset.readOnly] || false;
  }

  // TODO: model spec
  _lock() {
    const hint = this._getHint(this.scoreField.value);

    this.element.style.pointerEvents = 'none';
    this.element.title = hint;

    this.scoreField.readOnly = true;

    this.stars.forEach((value) => {
      value.title = hint;
    });

    if (this.cancelButton) {
      this.cancelButton.style.display = 'none';
    }

    this.element.dataset.readOnly = true;
  }

  _nameForIndex(i) {
    return this.opt.score && this.opt.score >= i ? 'starOn' : 'starOff';
  }

  // TODO: model spec
  _resetTitle() {
    for (let i = 0; i < this.opt.number; i++) {
      this.stars[i].title = this._getHint(i + 1);
    }
  }

  _parseOptions(dataset) {
    return Object.keys(dataset).reduce((acc, key) => {
      let value = { true: true, false: false }[dataset[key]];

      value = value !== null && value !== undefined ? value : dataset[key];

      if (!isNaN(value) && Number.isInteger(parseFloat(value))) {
        value = Number(value);
      }

      acc[key] = value;

      return acc;
    }, {});
  }

  // TODO: model spec
  _roundHalfScore(score) {
    const integer = parseInt(score, 10);

    let decimal = this._getDecimal(score, 1);

    if (decimal !== 0) {
      decimal = decimal > 5 ? 1 : 0.5;
    }

    return integer + decimal;
  }

  // TODO: model spec
  _roundStars(score, evt) {
    const name = this._starName(score, evt);

    if (name) {
      const icon = this.opt[name];
      const star = this.stars[Math.ceil(score) - 1];

      star && this._setIcon(star, icon);
    } // Full down: [x.00 .. x.25]
  }

  // TODO: model spec
  _setIcon(star, icon) {
    star[this.opt.starType === 'img' ? 'src' : 'className'] = this.opt.path + icon;
  }

  _setPath() {
    this.opt.path = this.opt.path || '';

    if (this.opt.path && this.opt.path.slice(-1) !== '/') {
      this.opt.path += '/';
    }
  }

  // TODO: model spec
  _setTarget(target, score) {
    if (score) {
      score = this.opt.targetFormat.toString().replace('{score}', score);
    }
    if (target instanceof HTMLInputElement || target instanceof HTMLSelectElement) {
      target.value = score;
    } else {
      target.textContent = score;
    }
  }

  // TODO: model spec
  _setTitle(score, evt) {
    if (score) {
      const integer = parseInt(Math.ceil(score), 10);
      const star = this.stars.item(integer - 1);

      star.title = this._getHint(score, evt);
    }
  }

  _starName(score, evt) {
    const decimal = +(score % 1).toFixed(2);

    if (evt || this.isMove) {
      return decimal > 0.5 ? 'starOn' : 'starHalf';
    }

    if (decimal <= this.opt.round.down) {
      // Down: [x.00 ... x.25]
      return;
    }

    if (this.opt.halfShow && decimal < this.opt.round.up) {
      // Half: [x.26 ... x.75]
      return 'starHalf';
    }

    if (decimal < this.opt.round.full) {
      // Off: [x.26 .. x.6]
      return 'starOff';
    }

    return 'starOn'; // Up: [x.26 ...] || [x.6 ...]
  }

  // TODO: model spec
  _target(score, evt) {
    if (this.opt.target) {
      const target = document.querySelector(this.opt.target);

      if (!target) {
        this._error('Target selector invalid or missing!');
      }

      const mouseover = evt && evt.type === 'mouseover';

      if (score === undefined) {
        score = this.opt.targetText;
      } else if (score === null) {
        score = mouseover ? this.opt.cancelHint : this.opt.targetText;
      } else {
        if (this.opt.targetType === 'hint') {
          score = this._getHint(score, evt);
        } else if (this.opt.precision) {
          score = parseFloat(score).toFixed(1);
        }

        const mousemove = evt && evt.type === 'mousemove';

        if (!mouseover && !mousemove && !this.opt.targetKeep) {
          score = this.opt.targetText;
        }
      }

      this._setTarget(target, score);
    }
  }

  // TODO: model spec
  _turnOn(i, score) {
    return this.opt.single ? i === score : i <= score;
  }

  // TODO: model spec
  _unlock() {
    this.element.style.cursor = 'pointer';
    this.element.style.pointerEvents = 'auto';

    this.element.removeAttribute('title');

    this.element.dataset.readOnly = false;

    this.scoreField.readOnly = false;

    this._resetTitle();

    if (this.cancelButton) {
      this.cancelButton.style.display = '';
    }
  }
}

export default Raty;