Repository: exadel-inc/esl
Pull request #2111 (2023-12-12 17:23:15)

feat(esl-utils): improve `rect` utility (it's almost ready to be immutable)


Patch:
diff --git a/src/modules/esl-popup/core/esl-popup-position.ts b/src/modules/esl-popup/core/esl-popup-position.ts
index e41995a53..b07b3973a 100644
--- a/src/modules/esl-popup/core/esl-popup-position.ts
+++ b/src/modules/esl-popup/core/esl-popup-position.ts
@@ -1,11 +1,10 @@
+// TODO: make implemenatation immutable
+
 import {Rect} from '../../esl-utils/dom/rect';
 
-export type PositionType = 'top' | 'bottom' | 'left' | 'right';
+import type {Point} from '../../esl-utils/dom/point';
 
-export interface Point {
-  x: number;
-  y: number;
-}
+export type PositionType = 'top' | 'bottom' | 'left' | 'right';
 
 export interface PopupPositionValue {
   placedAt: PositionType;
@@ -97,7 +96,7 @@ function getOppositePosition(position: PositionType): PositionType {
  * @param rect - popup position rect
  * @param arrow - arrow position value
  * */
-function fitOnMajorAxis(cfg: PopupPositionConfig, rect: Rect, arrow: Point): PositionType {
+function fitOnMajorAxis(cfg: PopupPositionConfig, rect: Rect): PositionType {
   if (cfg.behavior !== 'fit' && cfg.behavior !== 'fit-on-major') return cfg.position;
 
   let isMirrored = false;
@@ -232,7 +231,7 @@ export function calcPopupPosition(cfg: PopupPositionConfig): PopupPositionValue
     position: cfg.position
   };
 
-  const placedAt = fitOnMajorAxis(cfg, popup, arrow);
+  const placedAt = fitOnMajorAxis(cfg, popup);
   fitOnMinorAxis(cfg, popup, arrow);
 
   return {
diff --git a/src/modules/esl-popup/core/esl-popup.ts b/src/modules/esl-popup/core/esl-popup.ts
index db24cb193..825aae321 100644
--- a/src/modules/esl-popup/core/esl-popup.ts
+++ b/src/modules/esl-popup/core/esl-popup.ts
@@ -130,10 +130,8 @@ export class ESLPopup extends ESLToggleable {
 
   /** Get the size and position of the container */
   protected get containerRect(): Rect {
-    const {$container} = this;
-    if (!$container) return getWindowRect();
-    const containerRect = $container.getBoundingClientRect();
-    return new Rect(containerRect.left, containerRect.top + window.pageYOffset, containerRect.width, containerRect.height);
+    if (!this.$container) return getWindowRect();
+    return Rect.from(this.$container).shift(window.pageXOffset, window.pageYOffset);
   }
 
   @ready
diff --git a/src/modules/esl-utils/dom.ts b/src/modules/esl-utils/dom.ts
index b2b751418..fcb353b15 100644
--- a/src/modules/esl-utils/dom.ts
+++ b/src/modules/esl-utils/dom.ts
@@ -3,6 +3,7 @@ export * from './dom/class';
 export * from './dom/events';
 export * from './dom/focus';
 export * from './dom/keys';
+export * from './dom/point';
 export * from './dom/ready';
 export * from './dom/rtl';
 export * from './dom/sanitize';
diff --git a/src/modules/esl-utils/dom/events/misc.ts b/src/modules/esl-utils/dom/events/misc.ts
index a08125c88..2f007812a 100644
--- a/src/modules/esl-utils/dom/events/misc.ts
+++ b/src/modules/esl-utils/dom/events/misc.ts
@@ -1,3 +1,5 @@
+import type {Point} from '../point';
+
 /** Checks if the passed event is {@link MouseEvent} */
 export const isMouseEvent = (event: Event): event is MouseEvent => window.MouseEvent && event instanceof MouseEvent;
 
@@ -20,12 +22,6 @@ export const getCompositeTarget = (e: CustomEvent): EventTarget | null => {
   return targets ? targets[0] : e.target;
 };
 
-/** Object that describes coordinates */
-export interface Point {
-  x: number;
-  y: number;
-}
-
 /** @returns touch point coordinates of {@link TouchEvent} or {@link PointerEvent} */
 export const getTouchPoint = (event: TouchEvent | PointerEvent | MouseEvent): Point => {
   const source = isTouchEvent(event) ? event.changedTouches[0] : event;
diff --git a/src/modules/esl-utils/dom/point.ts b/src/modules/esl-utils/dom/point.ts
new file mode 100644
index 000000000..192588bcf
--- /dev/null
+++ b/src/modules/esl-utils/dom/point.ts
@@ -0,0 +1,5 @@
+/** Basic Point interface */
+export interface Point {
+  x: number;
+  y: number;
+}
diff --git a/src/modules/esl-utils/dom/rect.ts b/src/modules/esl-utils/dom/rect.ts
index 044d24e86..06a7a77a4 100644
--- a/src/modules/esl-utils/dom/rect.ts
+++ b/src/modules/esl-utils/dom/rect.ts
@@ -1,22 +1,33 @@
+import {isElement} from './api';
+
+import type {Point} from './point';
+
 /**
  * A Rect describes the size and position of a rectangle.
  */
-export class Rect {
+export class Rect implements Point {
   /** The X coordinate of the Rect's origin (top-left corner of the rectangle). */
-  public x = 0;
+  public x;
   /** The Y coordinate of the Rect's origin (top-left corner of the rectangle). */
-  public y = 0;
+  public y;
   /** The width of the Rect. */
-  public width = 0;
+  public width;
   /** The height of the Rect. */
-  public height = 0;
+  public height;
 
+  /**
+   * The static method creates a new Rect instance from a DOMRect object of BoundingClientRect.
+   * @param el - DOM Element
+   */
+  public static from(el: Element): Rect;
   /**
    * The static method creates a new Rect instance from a rect-like object.
    * @param rect - rect-like object
    */
-  public static from(rect: {x?: number, left?: number, y?: number, top?: number, width?: number, height?: number} = {}): Rect {
-    return new this(rect.x || rect.left, rect.y || rect.top, rect.width, rect.height);
+  public static from(rect: {x?: number, left?: number, y?: number, top?: number, width?: number, height?: number}): Rect;
+  public static from(rect: {x?: number, left?: number, y?: number, top?: number, width?: number, height?: number} | Element): Rect {
+    if (isElement(rect)) return Rect.from(rect.getBoundingClientRect());
+    return new this(rect.x ?? rect.left, rect.y ?? rect.top, rect.width, rect.height);
   }
 
   /**
@@ -25,7 +36,7 @@ export class Rect {
    * @param rect2 - second instance of Rect
    */
   public static isEqual(rect1: Rect, rect2: Rect): boolean {
-    return rect1.x === rect2.x && rect1.y === rect2.y && rect1.width === rect2.width && rect1.height === rect2.height;
+    return rect1.isEqual(rect2);
   }
 
   /**
@@ -34,92 +45,84 @@ export class Rect {
    * @param rect2 - second instance of Rect
    */
   public static intersect(rect1: Rect, rect2: Rect): Rect {
-    const top = Math.max(rect1.top, rect2.top);
-    const left = Math.max(rect1.left, rect2.left);
-    const bottom = Math.min(rect1.bottom, rect2.bottom);
-    const right = Math.min(rect1.right, rect2.right);
-    const width = Math.max(right - left, 0);
-    const height = Math.max(bottom - top, 0);
-    return Rect.from({top, left, width, height});
+    return rect1.intersect(rect2);
   }
 
   public constructor(x?: number, y?: number, width?: number, height?: number) {
     this.x = x || 0;
     this.y = y || 0;
-    this.width = width || 0;
-    this.height = height || 0;
+    this.width = Math.max(0, width || 0);
+    this.height = Math.max(0, height || 0);
   }
 
-  /**
-   * Get the top coordinate value of the Rect (has the same value as y).
-   */
+  /** @returns the top coordinate value of the Rect (has the same value as y) */
   public get top(): number {
     return this.y;
   }
 
-  /**
-   * Get the left coordinate value of the Rect (has the same value as x).
-   */
+  /** @returns the left coordinate value of the Rect (has the same value as x) */
   public get left(): number {
     return this.x;
   }
 
-  /**
-   * Get the right coordinate value of the DOMRect.
-   */
+  /** @returns the right coordinate value of the DOMRect */
   public get right(): number {
     return this.x + this.width;
   }
 
-  /**
-   * Get the bottom coordinate value of the Rect.
-   */
+  /** @returns the bottom coordinate value of the Rect */
   public get bottom(): number {
     return this.y + this.height;
   }
 
-  /**
-   * Get the center X coordinate value of the Rect.
-   */
+  /** @returns the center X coordinate value of the Rect */
   public get cx(): number {
     return this.x + this.width / 2;
   }
 
-  /**
-   * Get the center Y coordinate value of the Rect.
-   */
+  /** @returns the center Y coordinate value of the Rect */
   public get cy(): number {
     return this.y + this.height / 2;
   }
 
+  /** @returns the numeric value of rectangle area */
+  public get area(): number {
+    return Math.abs(this.height * this.width);
+  }
+
   /**
    * Method that accepts one argument and grows the Rect by the specified increment in pixels.
    * It increases the size of the Rect by moving each point on the edge of the Rect to a certain distance further away from the center of the Rect.
+   * @returns new {@link Rect}
    * @param increment - distance to grow in pixels for both X and Y-axis
    */
   public grow(increment: number): Rect;
   /**
    * Method that accepts two arguments where each specified increment grows the rect in different axis in pixels.
+   * @returns new {@link Rect}
    * @param incrementX -  distance to grow in pixels for X-axis
    * @param incrementY -  distance to grow in pixels for Y-axis
    */
   public grow(incrementX: number, incrementY: number): Rect;
   public grow(incrementX: number, incrementY: number = incrementX): Rect {
-    this.y -= incrementY;
-    this.x -= incrementX;
-    this.height += 2 * incrementY;
-    this.width += 2 * incrementX;
-    return this;
+    return new Rect(
+      this.x - incrementX,
+      this.y - incrementY,
+      this.width + 2 * incrementX,
+      this.height + 2 * incrementY
+    );
   }
 
   /**
    * Method that accepts one argument and shrinks the Rect by the specified decrement in pixels.
    * It reduces the size of the Rect by moving each point on the edge of the Rect to a certain distance closer to the center of the Rect.
+   * @returns new {@link Rect}
    * @param decrement - distance to shrink in pixels for both X and Y-axis
    */
   public shrink(decrement: number): Rect;
   /**
    * Method that accepts two arguments where each specified decrement shrinks the rect in different axis in pixels.
+   * @returns new {@link Rect}
    * @param decrementX -  distance to shrink in pixels for X-axis
    * @param decrementY -  distance to shrink in pixels for Y-axis
    */
@@ -128,8 +131,24 @@ export class Rect {
     return this.grow(-decrementX, -decrementY);
   }
 
-  /** @returns the numeric value of rectangle area */
-  public get area(): number {
-    return this.height * this.width;
+  /** @returns new {@link Rect} that is shifted by x and y axis */
+  public shift(x: number = 0, y: number = 0): Rect {
+    return new Rect(this.x + x, this.y + y, this.width, this.height);
+  }
+
+  /** @returns new {@link Rect} an intersection of current and passed Rectangle */
+  public intersect(rect: Rect): Rect {
+    const top = Math.max(this.top, rect.top);
+    const left = Math.max(this.left, rect.left);
+    const bottom = Math.min(this.bottom, rect.bottom);
+    const right = Math.min(this.right, rect.right);
+    const width = Math.max(right - left, 0);
+    const height = Math.max(bottom - top, 0);
+    return new Rect(left, top, width, height);
+  }
+
+  /** @returns if the passed rect is equal to current */
+  public isEqual(rect: Rect): boolean {
+    return this.x === rect.x && this.y === rect.y && this.width === rect.width && this.height === rect.height;
   }
 }
diff --git a/src/modules/esl-utils/dom/visible.ts b/src/modules/esl-utils/dom/visible.ts
index a54b54507..438e23e98 100644
--- a/src/modules/esl-utils/dom/visible.ts
+++ b/src/modules/esl-utils/dom/visible.ts
@@ -36,8 +36,9 @@ export function isVisible(el: Element, options: VisibilityOptions = {visibility:
  * @param el - element to be checked
  */
 export function isInViewport(el: Element): boolean {
-  const wndIntersection = Rect.intersect(getWindowRect(), Rect.from(el.getBoundingClientRect()));
+  const wndIntersection = Rect.from(el).intersect(getWindowRect());
   if (wndIntersection.area === 0) return false;
-  return !getListScrollParents(el).some((parent: Element) =>
-    Rect.intersect(wndIntersection, Rect.from(parent.getBoundingClientRect())).area === 0);
+  return !getListScrollParents(el).some(
+    (parent: Element) => Rect.from(parent).intersect(wndIntersection).area === 0
+  );
 }


Test patch:
diff --git a/src/modules/esl-utils/dom/test/rect.test.ts b/src/modules/esl-utils/dom/test/rect.test.ts
index 865e3e05f..b3be6c95e 100644
--- a/src/modules/esl-utils/dom/test/rect.test.ts
+++ b/src/modules/esl-utils/dom/test/rect.test.ts
@@ -1,92 +1,120 @@
 import {Rect} from '../rect';
 
-describe('Rect instance constructor()', () => {
-  test('constructor should set passed values to variables', () => {
-    const rect1 = new Rect(1, 2, 3, 4);
-    const rect2 = new Rect();
-    rect2.x = 1;
-    rect2.y = 2;
-    rect2.width = 3;
-    rect2.height = 4;
-    expect(rect2).toEqual(rect1);
-  });
+describe('Rect API', () => {
+  describe('Rect instance constructor()', () => {
+    test('constructor should set passed values to variables', () => {
+      const rect = new Rect(1, 2, 3, 4);
+      expect(rect).toEqual(expect.objectContaining({x: 1, y: 2, width: 3, height: 4}));
+    });
 
-  test('constructor called without parameters should set values to 0', () => {
-    const rect1 = new Rect();
-    const rect2 = new Rect();
-    rect2.x = 0;
-    rect2.y = 0;
-    rect2.width = 0;
-    rect2.height = 0;
-    expect(rect2).toEqual(rect1);
+    test('constructor called without parameters should set values to 0', () => {
+      expect(new Rect()).toEqual(expect.objectContaining({x: 0, y: 0, width: 0, height: 0}));
+    });
   });
-});
 
-describe('Rect static methods', () => {
-  test('Rect.from', () => {
-    const rect = new Rect(1, 2, 3, 4);
-    expect(Rect.from({})).toEqual(new Rect());
-    expect(Rect.from({left: 1, top: 2, width: 3, height: 4})).toEqual(rect);
-    expect(Rect.from({x: 1, y: 2, width: 3, height: 4})).toEqual(rect);
-  });
+  describe('Rect static methods', () => {
+    test('Rect.from', () => {
+      const rect = new Rect(1, 2, 3, 4);
+      expect(Rect.from({})).toEqual(new Rect());
+      expect(Rect.from({left: 1, top: 2, width: 3, height: 4})).toEqual(rect);
+      expect(Rect.from({x: 1, y: 2, width: 3, height: 4})).toEqual(rect);
+    });
 
-  test.each([
-    [[0, 0, 100, 100], [10, 10, 10, 10], [10, 10, 10, 10]],
-    [[0, 0, 100, 100], [-10, -10, 5, 5], [0, 0, 0, 0]],
-    [[0, 0, 100, 100], [-10, -10, 10, 10], [0, 0, 0, 0]],
-    [[0, 0, 100, 100], [-5, -5, 10, 10], [0, 0, 5, 5]]
-  ])('Rect.intersect of %s and %p.', (rect1, rect2, expected) => {
-    expect(Rect.intersect(new Rect(...rect1), new Rect(...rect2))).toStrictEqual(new Rect(...expected));
+    test.each([
+      [[0, 0, 100, 100], [10, 10, 10, 10], [10, 10, 10, 10]],
+      [[0, 0, 100, 100], [-10, -10, 5, 5], [0, 0, 0, 0]],
+      [[0, 0, 100, 100], [-10, -10, 10, 10], [0, 0, 0, 0]],
+      [[0, 0, 100, 100], [-5, -5, 10, 10], [0, 0, 5, 5]],
+      [[1, 2, 3, 4], [0, 0, 10, 10], [1, 2, 3, 4]]
+    ])('Rect.intersect of %s and %p.', (rect1, rect2, expected) => {
+      expect(Rect.intersect(new Rect(...rect1), new Rect(...rect2))).toStrictEqual(new Rect(...expected));
+    });
   });
-});
 
-describe('Rect instance methods', () => {
-  let rect: Rect;
+  describe('Rect instance methods', () => {
+    test('returns top coordinate value of Rect', () => {
+      const rect = new Rect(1, 2, 3, 4);
+      expect(rect.top).toEqual(rect.y);
+    });
 
-  beforeEach(() => {
-    rect = new Rect(1, 2, 3, 4);
-  });
+    test('returns left coordinate value of Rect', () => {
+      const rect = new Rect(1, 2, 3, 4);
+      expect(rect.left).toEqual(rect.x);
+    });
 
-  test('returns top coordinate value of Rect', () => {
-    expect(rect.top).toEqual(rect.y);
-  });
+    test('returns right coordinate value of Rect', () => {
+      const rect = new Rect(1, 2, 3, 4);
+      expect(rect.right).toEqual(rect.x + rect.width);
+    });
 
-  test('returns left coordinate value of Rect', () => {
-    expect(rect.left).toEqual(rect.x);
-  });
+    test('returns bottom coordinate value of Rect', () => {
+      const rect = new Rect(1, 2, 3, 4);
+      expect(rect.bottom).toEqual(rect.y + rect.height);
+    });
 
-  test('returns right coordinate value of Rect', () => {
-    expect(rect.right).toEqual(rect.x + rect.width);
-  });
+    test('returns center X coordinate value of the Rect', () => {
+      const rect = new Rect(1, 2, 3, 4);
+      expect(rect.cx).toEqual(rect.x + 3 / 2);
+    });
 
-  test('returns bottom coordinate value of Rect', () => {
-    expect(rect.bottom).toEqual(rect.y + rect.height);
-  });
+    test('returns center Y coordinate value of the Rect', () => {
+      const rect = new Rect(1, 2, 3, 4);
+      expect(rect.cy).toEqual(rect.y + 4 / 2);
+    });
 
-  test('returns center X coordinate value of the Rect', () => {
-    expect(rect.cx).toEqual(rect.x + 3  / 2);
-  });
+    test('returns Rect grown by passed increment', () => {
+      const rect = new Rect(1, 2, 3, 4);
+      const rect2 = new Rect(1 - 2, 2 - 2, 3 + 4, 4 + 4);
+      expect(rect.grow(2)).toEqual(rect2);
+    });
 
-  test('returns center Y coordinate value of the Rect', () => {
-    expect(rect.cy).toEqual(rect.y + 4 / 2);
-  });
+    test('returns Rect grown by passed increments by axis-es', () => {
+      const rect = new Rect(1, 2, 3, 4);
+      const rect2 = new Rect(1 - 2, 2 - 3, 3 + 4, 4 + 6);
+      expect(rect.grow(2, 3)).toEqual(rect2);
+    });
 
-  test('returns Rect grown by passed increment', () => {
-    const value = 2;
-    expect(rect.grow(value)).toEqual(new Rect(1 - value, 2 - value, 3 + 2 * value, 4 +  2 * value));
-    expect(rect.grow(value, value + 2)).toEqual(new Rect(value - 5, value - 6, 7 + 2 * value, 12 +  2 * value));
-  });
+    test('returns Rect shrunk by passed decrement', () => {
+      const rect = new Rect(1, 2, 3, 4);
+      const rect2 = new Rect(1 + 1, 2 + 1, 1, 2);
+      expect(rect.shrink(1)).toEqual(rect2);
+    });
 
-  test('returns Rect shrunk by passed decrement', () => {
-    const value = 2;
-    expect(rect.shrink(value)).toEqual(new Rect(1 + value, 2 + value, 3 + 2 * (-value), 4 +  2 * (-value)));
-    expect(rect.shrink(value + 1, value + 3)).toEqual(new Rect(4 + value, 7 + value, 2 * (-value) - 3, 2 * (-value) - 6));
-  });
+    test('returns Rect shrunk by passed decrement to point', () => {
+      const rect = new Rect(1, 2, 3, 4);
+      const rect2 = new Rect(1 + 2, 2 + 2, 0, 0);
+      expect(rect.shrink(2)).toEqual(rect2);
+    });
+
+    test('returns Rect shrunk by passed decrement by axis-es', () => {
+      const rect = new Rect(2, 4, 6, 8);
+      const rect2 = new Rect(3, 6, 4, 4);
+      expect(rect.shrink(1, 2)).toEqual(rect2);
+    });
+
+    test('returns Rect shifted by passed values', () => {
+      const rect = new Rect(1, 2, 3, 4);
+      const rect2 = new Rect(1 + 2, 2 + 3, 3, 4);
+      expect(rect.shift(2, 3)).toEqual(rect2);
+    });
+
+    test.each([
+      [{x: 0, y: 0, width: 0, height: 0}, {x: 0, y: 0, width: 0, height: 0}, true],
+      [{x: 0, y: 0, width: 0, height: 0}, {left: 0, top: 0, width: 0, height: 0}, true],
+      [{x: 0, y: 0, width: 10, height: 10}, {x: 0, y: 0, width: 10, height: 10}, true],
+      [{x: 0, y: 0, width: 0, height: 0}, {x: 1, y: 0, width: 0, height: 0}, false],
+      [{x: 0, y: 0, width: 0, height: 0}, {x: 0, y: 1, width: 0, height: 0}, false],
+      [{x: 0, y: 0, width: 0, height: 0}, {x: 0, y: 0, width: 1, height: 0}, false],
+      [{x: 0, y: 0, width: 0, height: 0}, {x: 0, y: 0, width: 0, height: 1}, false]
+    ])('%p isEqual %p returns %s', (rect1, rect2, expected) => {
+      expect(Rect.from(rect1).isEqual(Rect.from(rect2))).toBe(expected);
+    });
 
-  test.each([
-    [[0, 0, 10, 10], 100],
-    [[0, 0, 0, 0], 0]
-  ])('isEmpty: %s', (coords, expected) => {
-    expect(new Rect(...coords).area).toBe(expected);
+    test.each([
+      [[0, 0, 10, 10], 100],
+      [[0, 0, 0, 0], 0]
+    ])('Rect.instance.area: %s', (coords, expected) => {
+      expect(new Rect(...coords).area).toBe(expected);
+    });
   });
 });