range.js

  1. import { queryOne, queryAll } from '@ecl/dom-utils';
  2. /**
  3. * @param {HTMLElement} element DOM element for component instantiation and scope
  4. * @param {Object} options
  5. * @param {String} options.rangeInputSelector Selector for the range input
  6. * @param {String} options.currentValueSelector Selector for the current value area
  7. * @param {String} options.bubbleSelector Selector for the value bubble
  8. * @param {Boolean} options.attachChangeListener Whether or not to bind change events on range
  9. * @param {Boolean} options.attachHoverListener Whether or not to bind hover events
  10. */
  11. export class Range {
  12. /**
  13. * @static
  14. * Shorthand for instance creation and initialisation.
  15. *
  16. * @param {HTMLElement} root DOM element for component instantiation and scope
  17. *
  18. * @return {Range} An instance of Range.
  19. */
  20. static autoInit(root, { RANGE: defaultOptions = {} } = {}) {
  21. const range = new Range(root, defaultOptions);
  22. range.init();
  23. root.ECLRange = range;
  24. return range;
  25. }
  26. constructor(
  27. element,
  28. {
  29. rangeInputSelector = '[data-ecl-range-input]',
  30. currentValueSelector = '[data-ecl-range-value-current]',
  31. bubbleSelector = '[data-ecl-range-bubble]',
  32. attachChangeListener = true,
  33. attachHoverListener = true,
  34. } = {},
  35. ) {
  36. // Check element
  37. if (!element || element.nodeType !== Node.ELEMENT_NODE) {
  38. throw new TypeError(
  39. 'DOM element should be given to initialize this widget.',
  40. );
  41. }
  42. this.element = element;
  43. // Options
  44. this.rangeInputSelector = rangeInputSelector;
  45. this.currentValueSelector = currentValueSelector;
  46. this.bubbleSelector = bubbleSelector;
  47. this.attachChangeListener = attachChangeListener;
  48. this.attachHoverListener = attachHoverListener;
  49. // Private variables
  50. this.rangeInput = null;
  51. this.currentValue = null;
  52. this.bubble = null;
  53. this.direction = 'ltr';
  54. // Bind `this` for use in callbacks
  55. this.placeBubble = this.placeBubble.bind(this);
  56. this.handleChange = this.handleChange.bind(this);
  57. this.handleHoverOn = this.handleHoverOn.bind(this);
  58. this.handleHoverOff = this.handleHoverOff.bind(this);
  59. }
  60. /**
  61. * Initialise component.
  62. */
  63. init() {
  64. if (!ECL) {
  65. throw new TypeError('Called init but ECL is not present');
  66. }
  67. ECL.components = ECL.components || new Map();
  68. this.rangeInput = queryOne(this.rangeInputSelector, this.element);
  69. this.currentValue = queryAll(this.currentValueSelector, this.element);
  70. this.bubble = queryOne(this.bubbleSelector, this.element);
  71. if (this.rangeInput && this.currentValue) {
  72. // Display default value
  73. this.currentValue.forEach((element) => {
  74. element.innerHTML = this.rangeInput.value;
  75. });
  76. // Bind change and hover event on range
  77. if (this.attachChangeListener) {
  78. this.rangeInput.addEventListener('input', this.handleChange);
  79. }
  80. if (this.attachHoverListener) {
  81. this.rangeInput.addEventListener('mouseover', this.handleHoverOn);
  82. this.rangeInput.addEventListener('mouseout', this.handleHoverOff);
  83. }
  84. }
  85. // RTL
  86. this.direction = getComputedStyle(this.element).direction;
  87. // Set ecl initialized attribute
  88. this.element.setAttribute('data-ecl-auto-initialized', 'true');
  89. ECL.components.set(this.element, this);
  90. }
  91. /**
  92. * Destroy component.
  93. */
  94. destroy() {
  95. if (this.rangeInput && this.currentValue) {
  96. if (this.attachChangeListener) {
  97. this.rangeInput.removeEventListener('input', this.handleChange);
  98. }
  99. if (this.attachHoverListener) {
  100. this.rangeInput.removeEventListener('mouseover', this.handleHoverOn);
  101. this.rangeInput.removeEventListener('mouseout', this.handleHoverOff);
  102. }
  103. }
  104. if (this.element) {
  105. this.element.removeAttribute('data-ecl-auto-initialized');
  106. ECL.components.delete(this.element);
  107. }
  108. }
  109. /**
  110. * Place value bubble
  111. */
  112. placeBubble() {
  113. // Quite complex calculus here
  114. // see https://stackoverflow.com/questions/46448994/get-the-offset-position-of-an-html5-range-slider-handle
  115. // Fixed values
  116. const halfThumbWidth = 8; // 1rem / 2
  117. const halfLabelWidth = this.bubble.offsetWidth / 2;
  118. // Get range input width
  119. const rect = this.rangeInput.getBoundingClientRect();
  120. const center = rect.width / 2;
  121. // Get position from center
  122. const percentOfRange =
  123. (this.rangeInput.value - this.rangeInput.min) /
  124. (this.rangeInput.max - this.rangeInput.min);
  125. const valuePxPosition = percentOfRange * rect.width;
  126. const distFromCenter = valuePxPosition - center;
  127. const percentDistFromCenter = distFromCenter / center;
  128. // Calculate bubble position
  129. const offset = percentDistFromCenter * halfThumbWidth;
  130. let pos = 0;
  131. if (this.direction === 'rtl') {
  132. pos = rect.right - valuePxPosition - halfLabelWidth + offset;
  133. } else {
  134. pos = rect.left + valuePxPosition - halfLabelWidth - offset;
  135. }
  136. this.bubble.style.left = `${pos}px`;
  137. }
  138. /**
  139. * Handle mouse hover
  140. */
  141. handleHoverOn() {
  142. // Display value bubble
  143. this.bubble.classList.add('ecl-range__bubble--visible');
  144. this.placeBubble();
  145. }
  146. handleHoverOff() {
  147. // Hide value bubble
  148. this.bubble.classList.remove('ecl-range__bubble--visible');
  149. }
  150. /**
  151. * Display value when changed
  152. */
  153. handleChange() {
  154. // Update value
  155. this.currentValue.forEach((element) => {
  156. element.innerHTML = this.rangeInput.value;
  157. });
  158. this.placeBubble();
  159. }
  160. }
  161. export default Range;