5
\$\begingroup\$

I'm currently trying to learn how to OOP in JavaScript by making use of ES6 classes. As an example I created this Slider using classes. I didn't find any class example where the DOM is highly involved

class Slider {
  constructor(_options, _className) {
    this.options = _options;
    this.container = document.getElementsByClassName(_className)[0];
    this.slider = new SliderBackground(this.container.getElementsByClassName("slider-background")[0]);
    this.minThumb = new Thumb(this.container.getElementsByClassName("slider-min")[0]);
    this.maxThumb = new Thumb(this.container.getElementsByClassName("slider-max")[0]);
    this.minLabel = this.container.getElementsByClassName("input-min")[0];
    this.maxLabel = this.container.getElementsByClassName("input-max")[0];
    this.minDragged = false;
    this.maxDragged = false;
    this.setup();
  }

  setup() {
    this.setStartValues();
    this.assignEvents();
  }

  setStartValues() {
    const minValue = this.options.defaultValue[0] > this.options.range[0] ? this.options.defaultValue[0] : this.options.range[0];
    const maxValue = this.options.defaultValue[1] > this.options.range[1] ? this.options.defaultValue[1] : this.options.range[1];
    const minValuePercent = this.getValueToPercent(minValue);
    const maxValuePercent = this.getValueToPercent(maxValue);
    this.minThumb.position = minValuePercent;
    this.maxThumb.position = maxValuePercent;
    this.minLabel.value = minValue;
    this.maxLabel.value = maxValue;
    this.slider.setBackground(minValuePercent, maxValuePercent);

    if (this.options.prefix !== "" && this.options.prefix !== undefined && this.options.prefix !== null) {
      let inputs = this.container.getElementsByClassName("input-value");
      for (let i = 0; i < inputs.length; i++) {
        let prefix = document.createElement("span");
        prefix.className = "slider-input-prefix";
        prefix.innerHTML = this.options.prefix;
        inputs[i].parentNode.insertBefore(prefix, inputs[i]);
      }
    }
  }

  assignEvents() {
    this.minThumb.element.addEventListener("mousedown", (e) => this.mouseDown(e));
    this.maxThumb.element.addEventListener("mousedown", (e) => this.mouseDown(e));
    this.minLabel.addEventListener("input", (e) => this.changeInputValue(e));
    this.maxLabel.addEventListener("input", (e) => this.changeInputValue(e));
    this.minLabel.addEventListener("focusout", (e) => this.formatInput(e));
    this.maxLabel.addEventListener("focusout", (e) => this.formatInput(e));
    window.addEventListener("mouseup", () => this.mouseUp());
    window.addEventListener("mousemove", (e) => this.mouseMove(e));
  }

  mouseDown(e) {
    if (e.target === this.minThumb.element) {
      e.preventDefault();
      this.minDragged = true;
      this.setActive(this.maxThumb.element, this.minThumb.element);
    } else if (e.target === this.maxThumb.element) {
      e.preventDefault();
      this.maxDragged = true;
      this.setActive(this.minThumb.element, this.maxThumb.element);
    }
  }

  mouseUp() {
    this.minDragged = false;
    this.maxDragged = false;
  }

  mouseMove(e) {
    if (this.minDragged || this.maxDragged) {
      e.preventDefault();
      let input = this.minDragged ? this.minLabel : this.maxLabel;
      const newValue = this.setPositions(this.getXToPercent(e.clientX), this.minDragged);
      if (newValue != null) input.value = Math.round(this.getPercentToValue(newValue));
    }
  }

  changeInputValue(e) {
    const valueInput = parseFloat(e.target.value);
    const value = this.getValueToPercent(valueInput);

    const minActive = e.target.className.includes("min");
    this.setPositions(value, minActive);
    this.setActive(this.container.getElementsByClassName("active")[0], this.container.querySelector(".slider-btn:not(.active)"));
  }

  setPositions(inputValue, minActive) {
    const percentPosMin = this.getXToPercent(this.minThumb.position);
    const percentPosMax = this.getXToPercent(this.maxThumb.position);
    let value;
    if (minActive) {
      if (inputValue >= 0 && inputValue <= percentPosMax) value = inputValue;
      else if (inputValue < 0) value = 0;
      else if (inputValue > percentPosMax) value = percentPosMax;
    } else if (!minActive) {
      if (inputValue <= 100 && inputValue >= percentPosMin) value = inputValue;
      else if (inputValue > 100) value = 100;
      else if (inputValue < percentPosMin) value = percentPosMin;
    } else return;
    let thumb = minActive ? this.minThumb : this.maxThumb;
    thumb.position = value;
    this.slider.setBackground(minActive ? value : percentPosMin, minActive ? percentPosMax : value);
    return value;
  }

  formatInput(e) {
    const minValue = this.minLabel.value;
    const maxValue = this.maxLabel.value;

    if (maxValue < minValue) this.maxLabel.value = minValue;
    else if (minValue > maxValue) this.minLabel.value = maxValue;
  }

  getXToPercent(elmX) {
    const slider = this.slider.bounding;
    return (elmX - slider.left) / slider.width * 100;
  }

  getPercentToValue(percent) {
    return (percent * (this.options.range[1] - this.options.range[0]) * 0.01 + this.options.range[0]);
  }

  getValueToPercent(value) {
    return (value - this.options.range[0]) / (this.options.range[1] - this.options.range[0]) * 100;
  }

  setActive(curActiveElement, newActiveElement) {
    curActiveElement.classList.remove("active");
    newActiveElement.className += " active";
  }
}

class Thumb {
  constructor(_element) {
    this.element = _element;
  }

  get bounding() {
    return this.element.getBoundingClientRect();
  }

  get x() {
    return this.element.getBoundingClientRect().x;
  }

  get position() {
    let bounding = this.bounding;
    return (bounding.x + bounding.width / 2);
  }

  set position(value) {
    this.element.style.left = "calc(" + value + "% - " + this.bounding.width / 2 + "px)";
  }
}

class SliderBackground {
  constructor(_element) {
    this.element = _element;
  }

  get bounding() {
    return this.element.getBoundingClientRect();
  }

  setBackground(min, max) {
    this.element.style.background = "linear-gradient(to right, var(--primary-color) " + min + "%, var(--primary-focus-color) " + min + "%, var(--primary-focus-color) " + max + "%, var(--primary-color) " + max + "%)";
  }
}

let options = {
  range: [0, 15000],
  defaultValue: [0, 15000],
  prefix: "€"
};
let slider = new Slider(options, "container-slider");
html {
  --border-radius: 7px;
  --border-radius-small: 3px;
  --default-shadow: -3px 5px 15px 6px rgba(0, 0, 0, .35);
  --btn-shadow: -1px 1px 10px 1px rgba(0, 0, 0, .35);
  --hover-time: 0.2s;
  --bg-color: #1F1B24;
  --sf-color: #332940;
  --sf2-color: #332940;
  --primary-color: #1f2d82;
  --primary-hover-color: #243394;
  --primary-focus-color: #2E41BD;
  --header-bg-color: #2C2735;
  --primary-font-color: white;
  --primary-font-hover-color: #BABABA;
}

body {
  background-color: grey;
}

.flex {
  width: 60%;
  margin-left: 20%;
  margin-top: 20%;
}

.container-slider {
  width: 100%;
  margin-top: 0.5em !important;
}

.slider-background {
  width: 100%;
  height: 5px;
  position: relative;
  display: flex;
  align-items: center;
  background-color: yellow;
}

.slider-btn {
  cursor: pointer;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background-color: var(--primary-font-color);
  display: inline-block;
  position: absolute;
  left: 0;
}

.slider-btn.active {
  z-index: 99;
}

.slider-labels {
  margin-top: 1em;
  width: 100%;
  color: var(--primary-font-color);
  display: flex;
  justify-content: space-between;
}

.prefix_and_input {
  position: relative;
  display: inline-block;
  color: var(--primary-font-color);
}

.input-value {
  width: 4em;
  background: none;
  border: none;
  outline: none;
  color: var(--primary-font-color);
  font-size: 1em;
  text-align: center;
  border-bottom: 1px solid var(--primary-font-color);
}

.underline-outer {
  display: flex;
  justify-content: center;
  bottom: 0;
  left: 0;
  position: absolute;
  width: 100%;
  height: 2px;
  background-color: #64e4fe;
}

.underline-inner {
  transition: transform 0.15s ease-out;
  width: 100%;
  transform: scale(0, 1);
  background-color: #1F1B24;
  height: 100%;
}

.prefix_and_input>.input-value:focus+.underline-outer>.underline-inner {
  transform: scale(1);
}

input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
  -webkit-appearance: none;
  margin: 0;
}

input[type=number] {
  -moz-appearance: textfield;
}
<div class="flex">
  <div class="container-slider">
    <div class="slider-background">
      <span class="slider-btn slider-min active"></span>
      <span class="slider-btn slider-max"></span>
    </div>
    <div class="slider-labels">
      <div class="min-label_and_input">
        <label class="label-value label-min" for="input-min">Min: </label>
        <div class="prefix_and_input">
          <input type="number" step="1" id="input-min" class="input-value input-min">
          <div class="underline-outer">
            <div class="underline-inner"></div>
          </div>
        </div>
      </div>
      <div class="max-label_and_input">
        <label class="label-value label-max" for="input-max">Max: </label>
        <div class="prefix_and_input">
          <input type="number" step="1" id="input-max" class="input-value input-max">
          <div class="underline-outer">
            <div class="underline-inner"></div>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>

Problem/Question: Am I doing OOP in Javascript right? Anything else I'm doing wrong in JS?

\$\endgroup\$
2
  • 1
    \$\begingroup\$ This is not very OOP from the looks of it. It's using classes as namespaces basically. Many of your functions don't return anything and look more procedural. OOP is mostly about composition and encapsulation. There is some encapsulation going on in your code, but not much composition. Nevertheless, your code looks clean and well organized. \$\endgroup\$
    – elclanrs
    Commented Apr 11, 2020 at 3:09
  • \$\begingroup\$ @elclanrs Alright thanks for the info I'll try to take care of composition and encapsulation in my next project :) \$\endgroup\$ Commented Apr 11, 2020 at 8:57

1 Answer 1

1
\$\begingroup\$

While your Thumb and SliderBackground classes look fine from an OOP perspective, your Slider, where the bulk of the logic is, has a decent amount of repetitive code in long-ish methods. Many of these can be split apart to into smaller, more abstract methods, which are then more readily understandable from a high level when there's an action to be performed. See the bottom of the answer for a full snippet.

If all you're interested in doing is selecting the first element which matches a selector, the best method is to use querySelector. (getElementsByClassName returns a collection, which means you have to extract the first element of the collection first, which is a bit ugly to do every time you want to get an element). Also, minLabel and maxLabel aren't actually <label>s - they're inputs. Maybe call them minInput and maxInput instead. You can change the lines that are like

this.minLabel = this.container.getElementsByClassName("input-min")[0];

to

this.minInput = this.container.querySelector(".input-min");

Rather than using the conditional operator to determine the maximum and minimum:

const minValue = this.options.defaultValue[0] > this.options.range[0] ? this.options.defaultValue[0] : this.options.range[0];
const maxValue = this.options.defaultValue[1] > this.options.range[1] ? this.options.defaultValue[1] : this.options.range[1];

You could consider using Math.max and Math.min. Also, since defaultValue is an array, not a single value, maybe call it defaultValues:

const { defaultValues, range, prefix } = this.options;
const minValue = Math.min(defaultValues[0], range[0]);
const maxValue = Math.max(defaultValues[1], range[1]);

The prefix setter is a bit verbose:

if (this.options.prefix !== "" && this.options.prefix !== undefined && this.options.prefix !== null) {
  let inputs = this.container.getElementsByClassName("input-value");
  for (let i = 0; i < inputs.length; i++) {
    let prefix = document.createElement("span");
    prefix.className = "slider-input-prefix";
    prefix.innerHTML = this.options.prefix;
    inputs[i].parentNode.insertBefore(prefix, inputs[i]);
  }
}

You can simplify the if condition to a truthy check, and use the prefix property destructured earlier. I'd call the created span prefixSpan to distinguish it. Conventional for loops are pretty verbose, require manual iteration, and have no abstraction. Since you're using ES6, consider for..of instead. Assigning to innerHTML can result in unexpected elements being created and unexepcted (possibly malicious) scripts being run - use textContent instead, it's safer and faster.

if (this.options.prefix) {
  for (const input of this.container.getElementsByClassName("input-value")) {
    const prefixSpan = document.createElement('span');
    prefixSpan.className = 'slider-input-prefix';
    prefixSpan.textContent = prefix;
    input.parentElement.insertBefore(prefixSpan, input);
  }
}

(also make sure to always use const whenever possible - let warns readers of the code that you may reassign the variable later, leading to more cognitive overhead)

Your assignEvents has eight anonymous functions that call class methods:

assignEvents() {
  this.minThumb.element.addEventListener("mousedown", (e) => this.mouseDown(e));
  // ...
}

Consider using class field syntax to define the methods instead, then just pass the method name:

mouseDown = (e) => {
  // ...
}
// ...
assignEvents() {
  this.minThumb.element.addEventListener("mousedown", this.assignEvents);

This is pretty new syntax, but it's the cleanest modern way of making sure this refers to what you want it to in a method. (As always, if you're afraid of old browsers being incompatible with modern syntax, use Babel to transpile to ES5 for production)

When your setActive is called, it always has to be called with both the element getting the active class and the element to remove the active class from. It would probably be easier if setActive itself checked for an active class, removed it, then set active on the first (and only) argument. This way the removal logic is encapsulated inside setActive, rather than having the consumer have to figure it out.

setActive(newActiveElement) {
  this.container.querySelector('.active').classList.remove("active");
  newActiveElement.classList.add('active');
}

You currently have both a minDragged and a maxDragged property, but both can't be active at the same time. Rather than having two separate properties that do something very similar, maybe have a single dragged property instead, that you assign min or max to?

mouseDown = (e) => {
  this.setActive(e.target);
  this.dragged = e.target === this.minThumb.element ? 'min' : 'max';
}

There are a bunch of places in the code where the thumb positions need to be set, given one or both of the inputs, and the slider background needs to be set, given the inputs. These both require a bit of calculations. How about setSliderBackground and updateThumbPositions methods, as well as an updateUI method which calls both? Then you just need to call updateUI when something needs to be updated.

setSliderBackground() {
  this.slider.setBackground(
    this.getXToPercent(this.minThumb.position),
    this.getXToPercent(this.maxThumb.position)
  );
}
updateThumbPositions() {
  this.minThumb.position = this.getValueToPercent(this.minInput.value);
  this.maxThumb.position = this.getValueToPercent(this.maxInput.value);
}
updateUI() {
  this.setSliderBackground();
  this.updateThumbPositions();
}

Your setPositions function has a lot of blocks that may reassign value depending on the range and other slider. Another issue is that the user is permitted to input numbers that are out of range, or have leading zeros, or are empty strings. The slider line currently disappears when an input goes blank. Rather than leaving the possibly-invalid values as is, you could sanitize the inputs to make sure they're within the required range with Math.min and Math.max beforehand - not only when interpreting the values, but also display the sanitized values to the user so they can clearly see what's going on.

sanitizeInput(input) {
  const { range: [low, high] } = this.options;
  // Make sure value is in range of slider:
  const valueInRange = Math.round(Math.max(Math.min(input.value || 0, high), low));
  // Make sure lower value is below or equal to higher:
  input.value = input === this.minInput
    ? Math.min(valueInRange, this.maxInput.value)
    : Math.max(valueInRange, this.minInput.value);
}

There now isn't any need for the setPositions method - it wasn't very abstract anyway. Instead, you can call sanitizeInput and then updateUI.

Instead of

this.element.style.background = "linear-gradient(to right, var(--primary-color) " + min + "%, var(--primary-focus-color) " + min + "%, var(--primary-focus-color) " + max + "%, var(--primary-color) " + max + "%)";

You can consider using template literals to make interpolation much easier, as well as permitting an easy-to-read multiline format:

setBackground(min, max) {
  this.element.style.background = `
    linear-gradient(
      to right,
      var(--primary-color) ${min}%,
      var(--primary-focus-color) ${min}%,
      var(--primary-focus-color) ${max}%,
      var(--primary-color) ${max}%
    )
  `;
}

In full:

'use strict';

class Slider {
  constructor(_options, _className) {
    this.options = _options;
    this.container = document.querySelector('.' + _className);
    this.slider = new SliderBackground(this.container.querySelector(".slider-background"));
    this.minThumb = new Thumb(this.container.querySelector(".slider-min"));
    this.maxThumb = new Thumb(this.container.querySelector(".slider-max"));
    this.minInput = this.container.querySelector(".input-min");
    this.maxInput = this.container.querySelector(".input-max");
    this.dragged = null;
    this.setup();
  }
  setup() {
    this.setStartValues();
    this.assignEvents();
  }
  setStartValues() {
    const { defaultValues, range, prefix } = this.options;
    this.minInput.value = Math.min(defaultValues[0], range[0]);
    this.maxInput.value = Math.max(defaultValues[1], range[1]);
    this.updateUI();
    if (this.options.prefix) {
      for (const input of this.container.getElementsByClassName("input-value")) {
        const prefixSpan = document.createElement('span');
        prefixSpan.className = 'slider-input-prefix';
        prefixSpan.textContent = prefix;
        input.parentElement.insertBefore(prefixSpan, input);
      }
    }
  }
  assignEvents() {
    this.minThumb.element.addEventListener("mousedown", this.mouseDown);
    this.maxThumb.element.addEventListener("mousedown", this.mouseDown);
    this.minInput.addEventListener("input", this.changeInputValue);
    this.maxInput.addEventListener("input", this.changeInputValue);
    window.addEventListener("mouseup", this.mouseUp);
    window.addEventListener("mousemove", this.mouseMove);
  }
  mouseDown = (e) => {
    e.preventDefault();
    this.setActive(e.target);
    this.dragged = e.target === this.minThumb.element ? 'min' : 'max';
  }
  mouseUp = () => {
    this.dragged = null;
  }
  mouseMove = (e) => {
    const { dragged } = this;
    if (!dragged) {
      return;
    }
    e.preventDefault();
    const input = dragged === 'min' ? this.minInput : this.maxInput;
    const { range: [low, high] } = this.options;
    const percent = this.getXToPercent(e.clientX);
    input.value = low + (percent / 100) * (high - low);
    this.sanitizeInput(input);
    this.updateUI();
  }
  sanitizeInput(input) {
    const { range: [low, high] } = this.options;
    // Make sure value is in range of slider:
    const valueInRange = Math.round(Math.max(Math.min(input.value || 0, high), low));
    // Make sure lower value is below or equal to higher:
    input.value = input === this.minInput
      ? Math.min(valueInRange, this.maxInput.value)
      : Math.max(valueInRange, this.minInput.value);
  }
  changeInputValue = (e) => {
    this.sanitizeInput(e.target);
    this.updateUI();
    this.setActive(this.container.querySelector(".slider-btn:not(.active)"));
  }
  setSliderBackground() {
    this.slider.setBackground(
      this.getXToPercent(this.minThumb.position),
      this.getXToPercent(this.maxThumb.position)
    );
  }
  updateThumbPositions() {
    this.minThumb.position = this.getValueToPercent(this.minInput.value);
    this.maxThumb.position = this.getValueToPercent(this.maxInput.value);
  }
  updateUI() {
    this.updateThumbPositions();
    this.setSliderBackground();
  }
  getXToPercent(elmX) {
    const slider = this.slider.bounding;
    return (elmX - slider.left) / slider.width * 100;
  }
  getPercentToValue(percent) {
    return (percent * (this.options.range[1] - this.options.range[0]) * 0.01 + this.options.range[0]);
  }
  getValueToPercent(value) {
    return (value - this.options.range[0]) / (this.options.range[1] - this.options.range[0]) * 100;
  }
  setActive(newActiveElement) {
    this.container.querySelector('.active').classList.remove("active");
    newActiveElement.classList.add('active');
  }
}

class Thumb {
  constructor(_element) {
    this.element = _element;
  }
  get bounding() {
    return this.element.getBoundingClientRect();
  }
  get x() {
    return this.element.getBoundingClientRect().x;
  }
  get position() {
    const { bounding } = this;
    return (bounding.x + bounding.width / 2);
  }
  set position(value) {
    this.element.style.left = `calc(${value}% - ${this.bounding.width / 2}px)`;
  }
}

class SliderBackground {
  constructor(_element) {
    this.element = _element;
  }
  get bounding() {
    return this.element.getBoundingClientRect();
  }
  setBackground(min, max) {
    this.element.style.background = `
      linear-gradient(
        to right,
        var(--primary-color) ${min}%,
        var(--primary-focus-color) ${min}%,
        var(--primary-focus-color) ${max}%,
        var(--primary-color) ${max}%
      )
    `;
  }
}

const options = {
  range: [0, 15000],
  defaultValues: [0, 15000],
  prefix: "€"
};
const slider = new Slider(options, "container-slider");
html {
  --border-radius: 7px;
  --border-radius-small: 3px;
  --default-shadow: -3px 5px 15px 6px rgba(0, 0, 0, .35);
  --btn-shadow: -1px 1px 10px 1px rgba(0, 0, 0, .35);
  --hover-time: 0.2s;
  --bg-color: #1F1B24;
  --sf-color: #332940;
  --sf2-color: #332940;
  --primary-color: #1f2d82;
  --primary-hover-color: #243394;
  --primary-focus-color: #2E41BD;
  --header-bg-color: #2C2735;
  --primary-font-color: white;
  --primary-font-hover-color: #BABABA;
}

body {
  background-color: grey;
}

.flex {
  width: 60%;
  margin-left: 20%;
  margin-top: 20%;
}

.container-slider {
  width: 100%;
  margin-top: 0.5em !important;
}

.slider-background {
  width: 100%;
  height: 5px;
  position: relative;
  display: flex;
  align-items: center;
  background-color: yellow;
}

.slider-btn {
  cursor: pointer;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background-color: var(--primary-font-color);
  display: inline-block;
  position: absolute;
  left: 0;
}

.slider-btn.active {
  z-index: 99;
}

.slider-labels {
  margin-top: 1em;
  width: 100%;
  color: var(--primary-font-color);
  display: flex;
  justify-content: space-between;
}

.prefix_and_input {
  position: relative;
  display: inline-block;
  color: var(--primary-font-color);
}

.input-value {
  width: 4em;
  background: none;
  border: none;
  outline: none;
  color: var(--primary-font-color);
  font-size: 1em;
  text-align: center;
  border-bottom: 1px solid var(--primary-font-color);
}

.underline-outer {
  display: flex;
  justify-content: center;
  bottom: 0;
  left: 0;
  position: absolute;
  width: 100%;
  height: 2px;
  background-color: #64e4fe;
}

.underline-inner {
  transition: transform 0.15s ease-out;
  width: 100%;
  transform: scale(0, 1);
  background-color: #1F1B24;
  height: 100%;
}

.prefix_and_input>.input-value:focus+.underline-outer>.underline-inner {
  transform: scale(1);
}

input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
  -webkit-appearance: none;
  margin: 0;
}

input[type=number] {
  -moz-appearance: textfield;
}
<div class="flex">
  <div class="container-slider">
    <div class="slider-background">
      <span class="slider-btn slider-min active"></span>
      <span class="slider-btn slider-max"></span>
    </div>
    <div class="slider-labels">
      <div class="min-label_and_input">
        <label class="label-value label-min" for="input-min">Min: </label>
        <div class="prefix_and_input">
          <input type="number" step="1" id="input-min" class="input-value input-min">
          <div class="underline-outer">
            <div class="underline-inner"></div>
          </div>
        </div>
      </div>
      <div class="max-label_and_input">
        <label class="label-value label-max" for="input-max">Max: </label>
        <div class="prefix_and_input">
          <input type="number" step="1" id="input-max" class="input-value input-max">
          <div class="underline-outer">
            <div class="underline-inner"></div>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.