Repository: exadel-inc/esl
Pull request #1341 (2022-11-21 12:58:11)

feat(esl-utils): isVisible predicate
In the scope of this PR:
- function isVisible to Check if specified element is visible. By default check similar to jQuery is ran in combination with check for presence of element in viewport
- function findParentByStyles to find element's parent with specified styles
#931


Patch:
diff --git a/src/modules/esl-traversing-query/README.md b/src/modules/esl-traversing-query/README.md
index 4008c6c1e..d85ad4c32 100644
--- a/src/modules/esl-traversing-query/README.md
+++ b/src/modules/esl-traversing-query/README.md
@@ -16,6 +16,7 @@ NOTE: Selectors that don't start from a plain selector will use passed base Elem
  - `::parent`, `::closest` and `::child` pseudo-selectors
  - `::find` pseudo-selector
  - `::first`, `::last` and `:nth(#)` limitation pseudo-selectors
+ - `::visible` visible element pseudo-selector
 
 ---
 
diff --git a/src/modules/esl-traversing-query/core/esl-traversing-query.ts b/src/modules/esl-traversing-query/core/esl-traversing-query.ts
index 449445497..c76e61582 100644
--- a/src/modules/esl-traversing-query/core/esl-traversing-query.ts
+++ b/src/modules/esl-traversing-query/core/esl-traversing-query.ts
@@ -2,6 +2,7 @@ import {ExportNs} from '../../esl-utils/environment/export-ns';
 import {tuple, wrap, uniq} from '../../esl-utils/misc/array';
 import {unwrapParenthesis} from '../../esl-utils/misc/format';
 import {findAll, findChildren, findNext, findParent, findClosest, findPrev} from '../../esl-utils/dom/traversing';
+import {isVisible} from '../../esl-utils/dom/visible';
 
 type ProcessorDescriptor = [string?, string?];
 type ElementProcessor = (base: Element, sel: string) => Element | Element[] | null;
@@ -52,6 +53,7 @@ export class ESLTraversingQuery {
       return wrap(list[index - 1]);
     },
     '::not': (list: Element[], sel?: string) => list.filter((el) => !el.matches(sel || '')),
+    '::visible': (list: Element[]) => list.filter((el) => (el instanceof HTMLElement) && isVisible(el)),
     '::filter': (list: Element[], sel?: string) => list.filter((el) => el.matches(sel || ''))
   };
 
diff --git a/src/modules/esl-utils/README.md b/src/modules/esl-utils/README.md
index 98d9a90b5..9077b0697 100644
--- a/src/modules/esl-utils/README.md
+++ b/src/modules/esl-utils/README.md
@@ -93,6 +93,8 @@ Even if you use the whole utils module, it is still tiny.
   
     - ##### <a href="./dom/traversing.ts" target="_blank">DOM Traversing</a> - a set of utils to find DOM elements or check their relations.
 
+    - ##### <a href="./dom/visible.ts" target="_blank">Visible</a> - utility to check whether an element is visible or not.
+
     - ##### <a href="./dom/window.ts" target="_blank">Window</a> - a browser window object utils.
   
 - #### Environment
diff --git a/src/modules/esl-utils/dom.ts b/src/modules/esl-utils/dom.ts
index 1e78ad048..b2b751418 100644
--- a/src/modules/esl-utils/dom.ts
+++ b/src/modules/esl-utils/dom.ts
@@ -9,6 +9,7 @@ export * from './dom/sanitize';
 export * from './dom/script';
 export * from './dom/scroll';
 export * from './dom/traversing';
+export * from './dom/visible';
 export * from './dom/window';
 export * from './dom/attr';
 export * from './dom/rect';
diff --git a/src/modules/esl-utils/dom/rect.ts b/src/modules/esl-utils/dom/rect.ts
index 979f81819..044d24e86 100644
--- a/src/modules/esl-utils/dom/rect.ts
+++ b/src/modules/esl-utils/dom/rect.ts
@@ -28,6 +28,21 @@ export class Rect {
     return rect1.x === rect2.x && rect1.y === rect2.y && rect1.width === rect2.width && rect1.height === rect2.height;
   }
 
+  /**
+   * The static method returns intersection Rect of two Rect instances
+   * @param rect1 - first instance of 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});
+  }
+
   public constructor(x?: number, y?: number, width?: number, height?: number) {
     this.x = x || 0;
     this.y = y || 0;
@@ -112,4 +127,9 @@ export class Rect {
   public shrink(decrementX: number, decrementY: number = decrementX): Rect {
     return this.grow(-decrementX, -decrementY);
   }
+
+  /** @returns the numeric value of rectangle area */
+  public get area(): number {
+    return this.height * this.width;
+  }
 }
diff --git a/src/modules/esl-utils/dom/traversing.ts b/src/modules/esl-utils/dom/traversing.ts
index 9ca6c8349..8dd77975b 100644
--- a/src/modules/esl-utils/dom/traversing.ts
+++ b/src/modules/esl-utils/dom/traversing.ts
@@ -33,6 +33,11 @@ export const findPrev = createSequenceFinder((el) => el.previousElementSibling);
 export const findParent = createSequenceFinder((el) => el.parentElement);
 /** @returns first matching ancestor starting from passed element or null*/
 export const findClosest = createSequenceFinder((el) => el.parentElement, true);
+/** @returns first matching host element starting from passed element*/
+export const findHost = createSequenceFinder((el) => {
+  const root = el.getRootNode();
+  return (root instanceof ShadowRoot) ? root.host : null;
+}, true);
 
 /** @returns Array of all matching elements in subtree or empty array */
 export const findAll = (base: Element, sel: string): Element[] => {
diff --git a/src/modules/esl-utils/dom/visible.ts b/src/modules/esl-utils/dom/visible.ts
new file mode 100644
index 000000000..4c5d7e2de
--- /dev/null
+++ b/src/modules/esl-utils/dom/visible.ts
@@ -0,0 +1,37 @@
+import {Rect} from './rect';
+import {getListScrollParents} from './scroll';
+import {findClosestBy, findHost} from './traversing';
+import {getWindowRect} from './window';
+
+export interface VisibilityOptions {
+  /** Element will be considered invisible if opacity set to '0' */
+  opacity?: boolean;
+  /** Element will be considered invisible if visibility set to 'hidden' */
+  visibility?: boolean;
+  /** Element will be considered invisible if it's not in viewport */
+  viewport?: boolean;
+}
+
+/**
+ * Checks if the specified element is visible
+ * @param el - element to be checked
+ * @param options - object of additional visibility options to include
+ */
+export function isVisible(el: HTMLElement, options: VisibilityOptions = {visibility: true}): boolean {
+  if (!el.getClientRects().length) return false;
+  if (options.visibility && findHost(el, (host) => getComputedStyle(host).visibility === 'hidden')) return false;
+  if (options.opacity &&
+    findClosestBy(el, (parent) => parent instanceof Element && getComputedStyle(parent).opacity === '0')) return false;
+  return !(options.viewport && !isInViewport(el));
+}
+
+/**
+ * Checks if the specified element is inside the viewport
+ * @param el - element to be checked
+ */
+export function isInViewport(el: HTMLElement): boolean {
+  const wndIntersection = Rect.intersect(getWindowRect(), Rect.from(el.getBoundingClientRect()));
+  if (wndIntersection.area === 0) return false;
+  return !getListScrollParents(el).some((parent: HTMLElement) =>
+    Rect.intersect(wndIntersection, Rect.from(parent.getBoundingClientRect())).area === 0);
+}


Test patch:
diff --git a/src/modules/esl-traversing-query/test/esl-traversing-query.test.ts b/src/modules/esl-traversing-query/test/esl-traversing-query.test.ts
index 89c5db42c..58cf654df 100644
--- a/src/modules/esl-traversing-query/test/esl-traversing-query.test.ts
+++ b/src/modules/esl-traversing-query/test/esl-traversing-query.test.ts
@@ -136,13 +136,14 @@ describe('Traversing Query tests', () => {
     ])('Main check: ESLTraversingQuery.all/one, Sel: %s, Base: %p.', traversingQueryWrap);
   });
 
-  describe('filters (::filter, ::not)', () => {
+  describe('filters (::filter, ::not, ::visible)', () => {
     test.each([
       ['::find(.btn)::filter(.un-existing)', root, []],
       ['::find(.btn)::not(button)', root, []],
       ['::find(.btn)::filter([data-test])', root, [btn2, btn5, btn6]],
       ['::find(.btn)::not([data-test])', root, [btn1, btn3, btn4]],
       ['::find(.btn)::filter(:first-child)', root, [btn1]],
+      ['::find(.btn)::visible', root, []],
       ['::find(.btn)::not(:first-child)', root, [btn2, btn3, btn4, btn5, btn6]]
     ])('Main check: ESLTraversingQuery.all/one, Sel: %s, Base: %p.', traversingQueryWrap);
   });
diff --git a/src/modules/esl-utils/dom/test/rect.test.ts b/src/modules/esl-utils/dom/test/rect.test.ts
index 99082fe65..865e3e05f 100644
--- a/src/modules/esl-utils/dom/test/rect.test.ts
+++ b/src/modules/esl-utils/dom/test/rect.test.ts
@@ -22,13 +22,22 @@ describe('Rect instance constructor()', () => {
   });
 });
 
-describe('Rect static method from()', () => {
-  test('creates instance of Rect with passed values', () => {
+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));
+  });
 });
 
 describe('Rect instance methods', () => {
@@ -73,4 +82,11 @@ describe('Rect instance methods', () => {
     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.each([
+    [[0, 0, 10, 10], 100],
+    [[0, 0, 0, 0], 0]
+  ])('isEmpty: %s', (coords, expected) => {
+    expect(new Rect(...coords).area).toBe(expected);
+  });
 });
diff --git a/src/modules/esl-utils/dom/test/visible.test.ts b/src/modules/esl-utils/dom/test/visible.test.ts
new file mode 100644
index 000000000..b2d2fe927
--- /dev/null
+++ b/src/modules/esl-utils/dom/test/visible.test.ts
@@ -0,0 +1,109 @@
+import {isVisible} from '../visible';
+import {Rect} from '../rect';
+
+document.body.innerHTML = `
+  <div id="row1" class="row">
+    <button id="btn1"></button>
+  </div>
+`;
+
+const div = document.querySelector('#row1') as HTMLDivElement;
+const btn = document.querySelector('#btn1') as HTMLButtonElement;
+
+jest.mock('../scroll', () => ({
+  getListScrollParents: () => [div]
+}));
+
+jest.mock('../window', () => ({
+  getWindowRect: () => Rect.from({x: 0, y: 0, width: 2000, height: 2000})
+}));
+
+const mockClientRects = (el: HTMLElement) => jest.spyOn(el, 'getClientRects').mockReturnValue([{}] as any);
+const mockBoundingRect = (el: HTMLElement, rect: Rect) => jest.spyOn(el, 'getBoundingClientRect').mockReturnValueOnce(rect as any);
+
+describe('Function isVisible', () => {
+  test('display set to `none`', () => {
+    expect(isVisible(btn)).toBe(false);
+  });
+
+  test('empty options object', () => {
+    expect(isVisible(btn, {})).toBe(false);
+    mockClientRects(btn);
+    expect(isVisible(btn, {})).toBe(true);
+  });
+
+  test('default dimensions check', () => {
+    mockClientRects(btn);
+    expect(isVisible(btn)).toBe(true);
+  });
+
+  describe('Visibility css property', () => {
+    test('visibility = hidden property on target', () => {
+      expect(isVisible(btn)).toBe(true);
+      btn.style.visibility = 'hidden';
+      expect(isVisible(btn)).toBe(false);
+      btn.style.visibility = 'visible';
+    });
+
+    test('visibility = hidden inherited from body', () => {
+      document.body.style.visibility = 'hidden';
+      expect(isVisible(div)).toBe(false);
+    });
+
+    test('visibility = hidden isn`t accounted for parent nodes', () => {
+      div.style.visibility = 'hidden';
+      expect(isVisible(btn)).toBe(true);
+      div.style.visibility = 'visible';
+    });
+
+    test('visibility = hidden on Shadow DOM host', () => {
+      const shadow = div.attachShadow({mode: 'open'});
+      const shadowDiv = document.createElement('div');
+      mockClientRects(shadowDiv);
+      shadow.appendChild(shadowDiv);
+      expect(isVisible(shadowDiv)).toBe(true);
+      div.style.visibility = 'hidden';
+      expect(isVisible(shadowDiv)).toBe(false);
+    });
+  });
+
+  test('check without css visibility property', () => {
+    expect(isVisible(btn, {visibility: false})).toBe(true);
+    div.style.visibility = 'hidden';
+    expect(isVisible(btn, {visibility: false})).toBe(true);
+    div.style.visibility = 'initial';
+  });
+
+  test('additional check for css opacity property', () => {
+    div.style.visibility = 'initial';
+    div.style.opacity = '0';
+    expect(isVisible(btn)).toBe(true);
+    expect(isVisible(btn, {opacity : true})).toBe(false);
+  });
+
+  describe('additional check for visibility in viewport', () => {
+    test('element is out of window`s bounds', () => {
+      mockBoundingRect(div, new Rect(-10, -200, 400, 300));
+      mockBoundingRect(btn, new Rect(-10, -20, 10, 10));
+      expect(isVisible(btn, {viewport: true})).toBe(false);
+    });
+
+    test('element is out of parent`s bounds', () => {
+      mockBoundingRect(div, new Rect(0, 0, 100, 100));
+      mockBoundingRect(btn, new Rect(0, 100, 10, 10));
+      expect(isVisible(btn, {viewport: true})).toBe(false);
+    });
+
+    test('element is in parent`s and window`s bounds', () => {
+      mockBoundingRect(div, new Rect(-10, -200, 200, 300,));
+      mockBoundingRect(btn, new Rect(0, 0, 10, 10));
+      expect(isVisible(btn, {viewport: true})).toBe(true);
+    });
+
+    test('element dimensions are 0', () => {
+      mockBoundingRect(div, new Rect(-10, -200, 200, 300));
+      mockBoundingRect(btn, new Rect(0, 0, 0, 0));
+      expect(isVisible(btn, {viewport: true})).toBe(false);
+    });
+  });
+});