Repository: exadel-inc/esl
Pull request #1872 (2023-08-17 13:07:45)

test(esl-trigger): cover trigger module with module tests
This PR is a part of 50% coverage goal [1786](https://github.com/exadel-inc/esl/issues/1786)

Note: A bug was discovered with target tracking, that's why test cases currently have extra command added:
```
const trigger = ESLTrigger.create();
trigger.target = '';
```
Explanation:
In order to avoid empty subscription, the trigger requires target to be added before it's appended to a document, that's why the code should look like this:
```
const trigger = ESLTrigger.create();
trigger.$target = createToggleableMock();
document.body.append($trigger)
```

But, after trigger is appended, the target gets reset, because we are calling `updateTagetFromSelector` method in connected callback:
```
  public updateTargetFromSelector(): void {
    if (!this.target) return;
    this.$target = ESLTraversingQuery.first(this.target, this) as ESLToggleable;

    if (this.$target instanceof ESLToggleablePlaceholder && this.$target.$origin) {
      // change target if it is an instance of the placeholder element
      this.$target = this.$target.$origin;
    }
  }
```
By default `this.$target` isn't empty, so target gets overwritten by what was found using selector. That's why `trigger.target = '';` was used


Patch:
diff --git a/src/modules/esl-trigger/README.md b/src/modules/esl-trigger/README.md
index 913335b24..9dea8dd62 100644
--- a/src/modules/esl-trigger/README.md
+++ b/src/modules/esl-trigger/README.md
@@ -16,7 +16,7 @@ Authors: *Alexey Stsefanovich (ala'n)*, *Julia Murashko*.
 
 - `active-class-target` - target element ESLTraversingQuery selector to set `active-class`
 
-- `target` - target Toggleable ESLTraversingQuery selector (`next` by default)
+- `target` - target Toggleable ESLTraversingQuery selector
 
 - `track-click` - [MediaQuery](../esl-media-query/README.md) to define allowed to track click event media. (Default: `all`)
   
diff --git a/src/modules/esl-trigger/core/esl-trigger.ts b/src/modules/esl-trigger/core/esl-trigger.ts
index d9cb4b96d..69cf4e230 100644
--- a/src/modules/esl-trigger/core/esl-trigger.ts
+++ b/src/modules/esl-trigger/core/esl-trigger.ts
@@ -35,8 +35,8 @@ export class ESLTrigger extends ESLBaseElement {
   /** Selector for ignored inner elements */
   @attr({defaultValue: 'a[href]'}) public ignore: string;
 
-  /** Target Toggleable {@link ESLTraversingQuery} selector. `::next` by default */
-  @attr({defaultValue: '::next'}) public target: string;
+  /** Target Toggleable {@link ESLTraversingQuery} selector */
+  @attr({defaultValue: ''}) public target: string;
   /** Action to pass to the Toggleable. Supports `show`, `hide` and `toggle` values. `toggle` by default */
   @attr({defaultValue: 'toggle'}) public mode: string;
 


Test patch:
diff --git a/src/modules/esl-toggleable/test/toggleable.mock.ts b/src/modules/esl-toggleable/test/toggleable.mock.ts
new file mode 100644
index 000000000..04b3684fd
--- /dev/null
+++ b/src/modules/esl-toggleable/test/toggleable.mock.ts
@@ -0,0 +1,17 @@
+import {ESLEventUtils} from '../../esl-utils/dom/events';
+import type {ESLToggleable} from '../core';
+
+export function createToggleableMock(init: Partial<ESLToggleable> = {}): ESLToggleable {
+  const et = document.createElement('div');
+  return Object.assign(et, {
+    show: jest.fn(function () {
+      this.open = true;
+      ESLEventUtils.dispatch(this, 'esl:show');
+    }),
+    hide: jest.fn(function () {
+      this.open = false;
+      ESLEventUtils.dispatch(this, 'esl:hide');
+    }),
+    open: false
+  }, init) as any;
+}
diff --git a/src/modules/esl-trigger/test/esl-trigger.a11y.test.ts b/src/modules/esl-trigger/test/esl-trigger.a11y.test.ts
index 29589abfa..9d4900f33 100644
--- a/src/modules/esl-trigger/test/esl-trigger.a11y.test.ts
+++ b/src/modules/esl-trigger/test/esl-trigger.a11y.test.ts
@@ -1,173 +1,248 @@
 import '../../../polyfills/es5-target-shim';
-import {SyntheticEventTarget} from '../../esl-utils/dom/events/target';
-import {ESLEventUtils} from '../../esl-utils/dom/events';
 import {ESLTrigger} from '../core/esl-trigger';
-import type {ESLToggleable} from '../../esl-toggleable/core/esl-toggleable';
-
-function createTargetMock(init: Partial<ESLToggleable> = {}): ESLToggleable {
-  const et = new SyntheticEventTarget();
-  return Object.assign(et, {
-    show: jest.fn(function () {
-      this.open = true;
-      ESLEventUtils.dispatch(this, 'esl:show');
-    }),
-    hide: jest.fn(function () {
-      this.open = false;
-      ESLEventUtils.dispatch(this, 'esl:hide');
-    }),
-    open: false
-  }, init) as any;
-}
+import {createToggleableMock} from '../../esl-toggleable/test/toggleable.mock';
+
+jest.mock('../../esl-utils/dom/ready', () => ({
+  onDocumentReady: (cb: any) => cb()
+}));
 
 describe('esl-trigger a11y attributes test', () => {
   const LABEL_ATTR = 'aria-label';
 
-  beforeAll(() => {
-    ESLTrigger.register();
-  });
+  beforeAll(() => ESLTrigger.register());
+
+  afterAll(() => jest.clearAllMocks());
 
   describe(
-    'a11yLabelActive attribute basic tests',
+    'a11yTarget attribute basic tests',
     () => {
-      const trigger = ESLTrigger.create();
-
-      beforeAll(() => {
-        trigger.a11yLabelActive = 'active';
-        document.body.append(trigger);
-        trigger.$target = createTargetMock();
-      });
-
-      test(
-        '"aria-label" should be empty in initial state',
-        () => expect(trigger.getAttribute(LABEL_ATTR)).toBeNull()
-      );
-
       test(
-        '"aria-label" should be set to `a11yLabelActive` value when target becomes active',
+        'default a11y target',
         () => {
-          trigger.$target?.show();
-          expect(trigger.getAttribute(LABEL_ATTR)).toBe('active');
+          const trigger = ESLTrigger.create();
+          trigger.$target = createToggleableMock();
+          document.body.append(trigger);
+
+          expect(trigger.getAttribute('role')).toBe('button');
+          expect(trigger.getAttribute('tabindex')).toBe('0');
         }
       );
 
       test(
-        '"aria-label" should be empty after the target becomes inactive',
+        'invalid a11y target',
         () => {
-          trigger.$target?.hide();
-          expect(trigger.getAttribute(LABEL_ATTR)).toBeNull();
+          const trigger = ESLTrigger.create();
+          trigger.$target = createToggleableMock();
+          trigger.a11yTarget = 'invalid';
+          document.body.append(trigger);
+          trigger.$target?.show();
+
+          expect(trigger.getAttribute('role')).toBe(null);
+          expect(trigger.getAttribute('tabindex')).toBe(null);
+          expect(trigger.getAttribute('aria-controls')).toBe(null);
         }
       );
-
-      afterAll(() => document.body.removeChild(trigger));
     }
   );
 
   describe(
-    'a11yLabelInactive attribute basic tests',
+    'a11y attributes',
     () => {
-      const trigger = ESLTrigger.create();
-
-      beforeAll(() => {
-        trigger.a11yLabelInactive = 'inactive';
-        document.body.append(trigger);
-        trigger.$target = createTargetMock();
-      });
-
-      test(
-        '"aria-label" should be set to a11yLabelInactive in initial state',
-        () => expect(trigger.getAttribute(LABEL_ATTR)).toBe('inactive')
-      );
-
-      test(
-        '"aria-label" should be empty when target becomes active',
+      describe(
+        '"aria-controls" a11y attributes',
         () => {
-          trigger.$target?.show();
-          expect(trigger.getAttribute(LABEL_ATTR)).toBeNull();
+          const trigger = ESLTrigger.create();
+          trigger.$target = createToggleableMock();
+          const controlId = 'test-control';
+
+          beforeAll(() => {
+            document.body.append(trigger);
+            Object.assign(trigger.$target!, {id: controlId});
+          });
+
+          test(
+            '"aria-controls" attribute should be set if target`s id is specified',
+            () => {
+              trigger.$target?.show();
+              expect(trigger.getAttribute('aria-controls')).toBe(controlId);
+            }
+          );
         }
       );
 
-      test(
-        '"aria-label" should be set to a11yLabelInactive after the target becomes inactive',
+      describe(
+        '"aria-expanded" a11y attributes',
         () => {
-          trigger.$target?.hide();
-          expect(trigger.getAttribute(LABEL_ATTR)).toBe('inactive');
+          const trigger = ESLTrigger.create();
+
+          beforeAll(() => {
+            document.body.append(trigger);
+            trigger.$target = createToggleableMock();
+          });
+
+          test(
+            '"aria-expanded" should be false by default',
+            () => expect(trigger.getAttribute('aria-expanded')).toBe('false')
+          );
+
+          test(
+            '"aria-expanded" should be true on open',
+            () => {
+              trigger.$target?.show();
+              expect(trigger.getAttribute('aria-expanded')).toBe('true');
+            }
+          );
         }
       );
 
-      afterAll(() => document.body.removeChild(trigger));
-    }
-  );
-
-  // TODO: split on separate tests for initial state and test for attributes conflict
-  describe(
-    'a11yLabelActive and a11yLabelInactive are both present (+ initially active flow)',
-    () => {
-      const trigger = ESLTrigger.create();
-
-      beforeAll(() => {
-        trigger.a11yLabelActive = 'active';
-        trigger.a11yLabelInactive = 'inactive';
-        document.body.append(trigger);
-        trigger.$target = createTargetMock({open: true});
-      });
-
-      test(
-        '"aria-label" should be set to a11yLabelActive in initial state for initially active target',
-        () => expect(trigger.getAttribute(LABEL_ATTR)).toBe('active')
-      );
-      test(
-        '"aria-label" should be set to a11yLabelInactive after the target becomes inactive',
+      describe(
+        'a11yLabelActive attribute basic tests',
         () => {
-          trigger.$target?.hide();
-          expect(trigger.getAttribute(LABEL_ATTR)).toBe('inactive');
+          const trigger = ESLTrigger.create();
+
+          beforeAll(() => {
+            trigger.a11yLabelActive = 'active';
+            trigger.$target = createToggleableMock();
+            document.body.append(trigger);
+          });
+
+          test(
+            'a11y label getter should return null by default',
+            () => expect(ESLTrigger.create().a11yLabel).toBeNull()
+          );
+
+          test(
+            '"aria-label" should be empty in initial state',
+            () => expect(trigger.getAttribute(LABEL_ATTR)).toBeNull()
+          );
+
+          test(
+            '"aria-label" should be set to `a11yLabelActive` value when target becomes active',
+            () => {
+              trigger.$target?.show();
+              expect(trigger.getAttribute(LABEL_ATTR)).toBe('active');
+            }
+          );
+
+          test(
+            '"aria-label" should be empty after the target becomes inactive',
+            () => {
+              trigger.$target?.hide();
+              expect(trigger.getAttribute(LABEL_ATTR)).toBeNull();
+            }
+          );
+
+          afterAll(() => document.body.removeChild(trigger));
         }
       );
-      test(
-        '"aria-label" should be set to a11yLabelActive when target becomes active',
+
+      describe(
+        'a11yLabelInactive attribute basic tests',
         () => {
-          trigger.$target?.show();
-          expect(trigger.getAttribute(LABEL_ATTR)).toBe('active');
+          const trigger = ESLTrigger.create();
+
+          beforeAll(() => {
+            trigger.a11yLabelInactive = 'inactive';
+            document.body.append(trigger);
+            trigger.$target = createToggleableMock();
+          });
+
+          test(
+            '"aria-label" should be set to a11yLabelInactive in initial state',
+            () => expect(trigger.getAttribute(LABEL_ATTR)).toBe('inactive')
+          );
+
+          test(
+            '"aria-label" should be empty when target becomes active',
+            () => {
+              trigger.$target?.show();
+              expect(trigger.getAttribute(LABEL_ATTR)).toBeNull();
+            }
+          );
+
+          test(
+            '"aria-label" should be set to a11yLabelInactive after the target becomes inactive',
+            () => {
+              trigger.$target?.hide();
+              expect(trigger.getAttribute(LABEL_ATTR)).toBe('inactive');
+            }
+          );
+
+          afterAll(() => document.body.removeChild(trigger));
         }
       );
 
-      afterAll(() => document.body.removeChild(trigger));
-    }
-  );
-
-  describe(
-    'a11yLabelInactive and a11yLabelActive attributes are not specified',
-    () => {
-      const INIT_VAL = 'test-init-val';
-      const trigger = ESLTrigger.create();
-
-      beforeAll(() => {
-        trigger.setAttribute(LABEL_ATTR, INIT_VAL);
-        document.body.append(trigger);
-        trigger.$target = createTargetMock();
-      });
-
-      test(
-        '"aria-label" should have initial value on start',
-        () => expect(trigger.getAttribute(LABEL_ATTR)).toBe(INIT_VAL)
-      );
-
-      test(
-        '"aria-label" should have initial value when target becomes active',
+      // TODO: split on separate tests for initial state and test for attributes conflict
+      describe(
+        'a11yLabelActive and a11yLabelInactive are both present (+ initially active flow)',
         () => {
-          trigger.$target?.show();
-          expect(trigger.getAttribute(LABEL_ATTR)).toBe(INIT_VAL);
+          const trigger = ESLTrigger.create();
+
+          beforeAll(() => {
+            trigger.a11yLabelActive = 'active';
+            trigger.a11yLabelInactive = 'inactive';
+            document.body.append(trigger);
+            trigger.$target = createToggleableMock({open: true}); // Calling `$target` before appending to DOM will not execute
+          });
+
+          test(
+            '"aria-label" should be set to a11yLabelActive in initial state for initially active target',
+            () => expect(trigger.getAttribute(LABEL_ATTR)).toBe('active')
+          );
+          test(
+            '"aria-label" should be set to a11yLabelInactive after the target becomes inactive',
+            () => {
+              trigger.$target?.hide();
+              expect(trigger.getAttribute(LABEL_ATTR)).toBe('inactive');
+            }
+          );
+          test(
+            '"aria-label" should be set to a11yLabelActive when target becomes active',
+            () => {
+              trigger.$target?.show();
+              expect(trigger.getAttribute(LABEL_ATTR)).toBe('active');
+            }
+          );
+
+          afterAll(() => document.body.removeChild(trigger));
         }
       );
 
-      test(
-        '"aria-label" should have initial value after the target becomes inactive',
+      describe(
+        'a11yLabelInactive and a11yLabelActive attributes are not specified',
         () => {
-          trigger.$target?.hide();
-          expect(trigger.getAttribute(LABEL_ATTR)).toBe(INIT_VAL);
+          const INIT_VAL = 'test-init-val';
+          const trigger = ESLTrigger.create();
+
+          beforeAll(() => {
+            trigger.setAttribute(LABEL_ATTR, INIT_VAL);
+            document.body.append(trigger);
+          });
+
+          test(
+            '"aria-label" should have initial value on start',
+            () => expect(trigger.getAttribute(LABEL_ATTR)).toBe(INIT_VAL)
+          );
+
+          test(
+            '"aria-label" should have initial value when target becomes active',
+            () => {
+              trigger.$target?.show();
+              expect(trigger.getAttribute(LABEL_ATTR)).toBe(INIT_VAL);
+            }
+          );
+
+          test(
+            '"aria-label" should have initial value after the target becomes inactive',
+            () => {
+              trigger.$target?.hide();
+              expect(trigger.getAttribute(LABEL_ATTR)).toBe(INIT_VAL);
+            }
+          );
+
+          afterAll(() => document.body.removeChild(trigger));
         }
       );
-
-      afterAll(() => document.body.removeChild(trigger));
     }
   );
 });
diff --git a/src/modules/esl-trigger/test/esl-trigger.actions.test.ts b/src/modules/esl-trigger/test/esl-trigger.actions.test.ts
new file mode 100644
index 000000000..840b72995
--- /dev/null
+++ b/src/modules/esl-trigger/test/esl-trigger.actions.test.ts
@@ -0,0 +1,266 @@
+import {ESLTrigger} from '../core/esl-trigger';
+import {createToggleableMock} from '../../esl-toggleable/test/toggleable.mock';
+
+jest.mock('../../esl-utils/dom/ready', () => ({
+  onDocumentReady: (cb: any) => cb()
+}));
+
+jest.mock('../../esl-utils/environment/device-detector', () => ({
+  DeviceDetector: {
+    hasHover: true
+  }
+}));
+
+describe('ESLTrigger event handling', () => {
+  beforeAll(() => {
+    ESLTrigger.register();
+    jest.useFakeTimers();
+  });
+
+  afterAll(() => jest.clearAllMocks());
+
+  describe('Click actions', () => {
+    describe('Default click tracking', () => {
+      const $trigger = ESLTrigger.create();
+      $trigger.$target = createToggleableMock();
+
+      beforeAll(() => document.body.append($trigger));
+
+      test('toggle (show)', () => {
+        $trigger.click();
+        expect($trigger.$target!.show).toBeCalledTimes(1);
+      });
+
+      test('toggle (hide)', () => {
+        $trigger.click();
+        expect($trigger.$target!.hide).toBeCalledTimes(1);
+      });
+    });
+
+    describe('Click tracking disabled', () => {
+      const $trigger = ESLTrigger.create();
+      $trigger.$target = createToggleableMock();
+      $trigger.trackClick = 'none';
+
+      beforeAll(() => document.body.append($trigger));
+
+      test('toggle (show) disabled', () => {
+        $trigger.click();
+        expect($trigger.$target!.show).toBeCalledTimes(0);
+      });
+
+      test('toggle (hide) disabled', () => {
+        $trigger.click();
+        expect($trigger.$target!.hide).toBeCalledTimes(0);
+      });
+    });
+
+    describe('Click tracking only for show actions', () => {
+      const $trigger = ESLTrigger.create();
+      $trigger.$target = createToggleableMock();
+      $trigger.mode = 'show';
+
+      beforeAll(() => document.body.append($trigger));
+
+      test('click toggle (show)', () => {
+        $trigger.dispatchEvent(new MouseEvent('click'));
+        expect($trigger.$target!.show).toBeCalledTimes(1);
+      });
+
+      test('click toggle (hide) disabled', () => {
+        $trigger.dispatchEvent(new MouseEvent('click'));
+        expect($trigger.$target!.hide).toBeCalledTimes(0);
+      });
+    });
+
+    describe('Click tracking only for hide actions', () => {
+      const $trigger = ESLTrigger.create();
+      $trigger.$target = createToggleableMock();
+      $trigger.mode = 'hide';
+
+      beforeAll(() => document.body.append($trigger));
+
+      test('toggle (hide)', () => {
+        $trigger.click();
+        expect($trigger.$target!.hide).toBeCalledTimes(1);
+      });
+
+      test('toggle (show) disabled', () => {
+        $trigger.click();
+        expect($trigger.$target!.show).toBeCalledTimes(0);
+      });
+    });
+
+    describe('Not igonred target', () => {
+      const $trigger = ESLTrigger.create();
+      $trigger.$target = createToggleableMock();
+      $trigger.ignore = '';
+      const $target = document.createElement('div');
+
+      beforeAll(() => {
+        document.body.append($trigger);
+        $trigger.append($target);});
+
+      test('toggle (show)', () => {
+        $target.click();
+        expect($trigger.$target!.show).toBeCalledTimes(1);
+      });
+
+      test('toggle (hide)', () => {
+        $target.click();
+        expect($trigger.$target!.hide).toBeCalledTimes(1);
+      });
+    });
+
+    describe('Igonred target', () => {
+      const $trigger = ESLTrigger.create();
+      $trigger.$target = createToggleableMock();
+      $trigger.ignore = 'div';
+      const div = document.createElement('div');
+
+      beforeAll(() => {
+        document.body.append($trigger);
+        $trigger.append(div);});
+
+      test('toggle (show) ignored', () => {
+        div.click();
+        expect($trigger.$target!.show).toBeCalledTimes(0);
+      });
+
+      test('toggle (hide) ignored', () => {
+        div.click();
+        expect($trigger.$target!.hide).toBeCalledTimes(0);
+      });
+    });
+  });
+
+  describe('Keydown', () => {
+    describe('Default behavior', () => {
+      const $trigger = ESLTrigger.create();
+      $trigger.$target = createToggleableMock();
+
+      beforeAll(() => document.body.append($trigger));
+
+      test('toggle (show)', () => {
+        $trigger.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter'}));
+        expect($trigger.$target!.show).toHaveBeenCalledTimes(1);
+      });
+
+      test('toggle (hide)', () => {
+        $trigger.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape'}));
+        expect($trigger.$target!.hide).toHaveBeenCalledTimes(1);
+      });
+    });
+
+    describe('Invalid key', () => {
+      const $trigger = ESLTrigger.create();
+      $trigger.$target = createToggleableMock();
+
+      beforeAll(() => document.body.append($trigger));
+
+      test('toggle (show) ignored', () => {
+        $trigger.dispatchEvent(new KeyboardEvent('keydown', {key: 'invalid'}));
+        expect($trigger.$target!.show).toHaveBeenCalledTimes(0);
+      });
+
+      test('toggle (hide) ignored', () => {
+        $trigger.dispatchEvent(new KeyboardEvent('keydown', {key: 'invalid'}));
+        expect($trigger.$target!.hide).toHaveBeenCalledTimes(0);
+      });
+    });
+
+    describe('Escape key tracking disabled', () => {
+      const $trigger = ESLTrigger.create();
+      $trigger.$target = createToggleableMock();
+      $trigger.ignoreEsc = true;
+
+      beforeAll(() => document.body.append($trigger));
+
+      test('toggle (show) ignored', () => {
+        $trigger.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape'}));
+        expect($trigger.$target!.show).toHaveBeenCalledTimes(0);
+      });
+
+      test('toggle (hide) ignored', () => {
+        $trigger.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape'}));
+        expect($trigger.$target!.hide).toHaveBeenCalledTimes(0);
+      });
+    });
+  });
+
+  describe('Hover', () => {
+    describe('Hover tracking enabled', () => {
+      const $trigger = ESLTrigger.create();
+      $trigger.$target = createToggleableMock();
+      $trigger.trackHover = 'all';
+
+      beforeAll(() => document.body.append($trigger));
+
+      test('change event fired', () => {
+        $trigger.dispatchEvent(new MouseEvent('mouseenter'));
+        expect($trigger.$target!.show).toHaveBeenCalledTimes(1);
+      });
+
+      test('change event fired', () => {
+        $trigger.dispatchEvent(new MouseEvent('mouseleave'));
+        expect($trigger.$target!.hide).toHaveBeenCalledTimes(1);
+      });
+    });
+
+    describe('Hover tracking disabled', () => {
+      const $trigger = ESLTrigger.create();
+      $trigger.$target = createToggleableMock();
+      $trigger.trackHover = 'none';
+
+      beforeAll(() => document.body.append($trigger));
+
+      test('toggle (show) ignored', () => {
+        $trigger.dispatchEvent(new MouseEvent('mouseenter'));
+        expect($trigger.$target!.show).toHaveBeenCalledTimes(0);
+      });
+
+      test('toggle (hide) ignored', () => {
+        $trigger.dispatchEvent(new MouseEvent('mouseleave'));
+        expect($trigger.$target!.hide).toHaveBeenCalledTimes(0);
+      });
+    });
+
+    describe('Hover tracking with show only mode', () => {
+      const $trigger = ESLTrigger.create();
+      $trigger.$target = createToggleableMock();
+      $trigger.trackHover = 'all';
+      $trigger.mode = 'show';
+
+      beforeAll(() => document.body.append($trigger));
+
+      test('toggle (show)', () => {
+        $trigger.dispatchEvent(new MouseEvent('mouseenter'));
+        expect($trigger.$target!.show).toHaveBeenCalledTimes(1);
+      });
+
+      test('toggle (hide) ignored', () => {
+        $trigger.dispatchEvent(new MouseEvent('mouseleave'));
+        expect($trigger.$target!.hide).toHaveBeenCalledTimes(0);
+      });
+    });
+
+    describe('hover tracking with hide only mode', () => {
+      const $trigger = ESLTrigger.create();
+      $trigger.$target = createToggleableMock();
+      $trigger.trackHover = 'all';
+      $trigger.mode = 'hide';
+
+      beforeAll(() => document.body.append($trigger));
+
+      test('toggle (show) ignored', () => {
+        $trigger.dispatchEvent(new MouseEvent('mouseenter'));
+        expect($trigger.$target!.show).toHaveBeenCalledTimes(0);
+      });
+
+      test('toggle (hide)', () => {
+        $trigger.dispatchEvent(new MouseEvent('mouseleave'));
+        expect($trigger.$target!.hide).toHaveBeenCalledTimes(1);
+      });
+    });
+  });
+});
diff --git a/src/modules/esl-trigger/test/esl-trigger.targeting.test.ts b/src/modules/esl-trigger/test/esl-trigger.targeting.test.ts
new file mode 100644
index 000000000..23a38b63a
--- /dev/null
+++ b/src/modules/esl-trigger/test/esl-trigger.targeting.test.ts
@@ -0,0 +1,47 @@
+import {ESLTrigger} from '../core/esl-trigger';
+import {createToggleableMock} from '../../esl-toggleable/test/toggleable.mock';
+
+jest.mock('../../esl-utils/dom/ready', () => ({
+  onDocumentReady: (cb: any) => cb()
+}));
+
+describe('ESLTrigger find target according to the query in target attribute', () => {
+  ESLTrigger.register();
+  const $trigger = ESLTrigger.create();
+  const $tgl1 = createToggleableMock();
+  const $tgl2 = createToggleableMock();
+  const $tgl3 = createToggleableMock();
+
+  beforeAll(() => {
+    $trigger.target = '::prev';
+    document.body.append($tgl1);
+    document.body.append($trigger);
+    document.body.append($tgl2);
+  });
+
+  afterEach(() => jest.resetAllMocks());
+
+  test('target from selector', () => {
+    $trigger.click();
+    expect($tgl1.show).toBeCalledTimes(1);
+  });
+
+  test('selector target removed', () => {
+    $tgl1.remove();
+    $trigger.click();
+    expect($tgl1.show).toBeCalledTimes(0);
+  });
+
+  test('selector updated', () => {
+    $trigger.target = '::next';
+    $trigger.setAttribute('target', '::next');
+    $trigger.click();
+    expect($tgl2.show).toBeCalledTimes(1);
+  });
+
+  test('target set explicitly', () => {
+    $trigger.$target = $tgl3;
+    $trigger.click();
+    expect($tgl3.show).toBeCalledTimes(1);
+  });
+});
diff --git a/src/modules/esl-trigger/test/esl-trigger.tracking.test.ts b/src/modules/esl-trigger/test/esl-trigger.tracking.test.ts
new file mode 100644
index 000000000..74412a483
--- /dev/null
+++ b/src/modules/esl-trigger/test/esl-trigger.tracking.test.ts
@@ -0,0 +1,87 @@
+import {ESLTrigger} from '../core/esl-trigger';
+import {createToggleableMock} from '../../esl-toggleable/test/toggleable.mock';
+
+jest.mock('../../esl-utils/dom/ready', () => ({
+  onDocumentReady: (cb: any) => cb()
+}));
+
+describe('ESLTrigger find target according to the query in target attribute', () => {
+  ESLTrigger.register();
+  const $trigger = ESLTrigger.create();
+
+  beforeEach(() => {
+    $trigger.$target = createToggleableMock();
+    document.body.append($trigger);
+    document.body.append($trigger.$target);
+  });
+  afterEach(() => {
+    jest.resetAllMocks();
+    $trigger.remove();
+    $trigger.$target?.remove();
+  });
+
+  test('trigger does not active marker initially', () => {
+    expect($trigger.hasAttribute('active')).toBe(false);
+  });
+
+  test('trigger active appears on target activation', () => {
+    $trigger.$target?.show();
+    expect($trigger.hasAttribute('active')).toBe(true);
+  });
+
+  test('trigger active disappears on target deactivation', () => {
+    $trigger.$target?.show();
+    $trigger.$target?.hide();
+    expect($trigger.hasAttribute('active')).toBe(false);
+  });
+
+  describe('active class support', () => {
+    beforeEach(() => {
+      $trigger.activeClass = '';
+      $trigger.activeClassTarget = '';
+    });
+
+    test('trigger does not active class initially', () => {
+      $trigger.activeClass = 'active';
+      expect($trigger.classList.contains('active')).toBe(false);
+    });
+
+    test('trigger active class appears on target activation', () => {
+      $trigger.activeClass = 'active';
+      $trigger.$target?.show();
+      expect($trigger.classList.contains('active')).toBe(true);
+    });
+
+    test('trigger active class disappears on target deactivation', () => {
+      $trigger.activeClass = 'active';
+      $trigger.$target?.show();
+      $trigger.$target?.hide();
+      expect($trigger.classList.contains('active')).toBe(false);
+    });
+
+    test('trigger active class supports inversion', () => {
+      $trigger.activeClass = '!inactive';
+      $trigger.$target?.hide();
+      expect($trigger.classList.contains('inactive')).toBe(true);
+      $trigger.$target?.show();
+      expect($trigger.classList.contains('inactive')).toBe(false);
+    });
+
+    test('trigger active class supports custom target', () => {
+      $trigger.activeClass = 'active';
+      $trigger.activeClassTarget = 'body';
+      $trigger.$target?.show();
+      expect(document.body.classList.contains('active')).toBe(true);
+    });
+
+    test('trigger active class supports relative custom target', () => {
+      const $div = document.createElement('div');
+      $trigger.activeClass = 'active';
+      $trigger.activeClassTarget = '::next';
+      $trigger.insertAdjacentElement('afterend', $div);
+
+      $trigger.$target?.show();
+      expect($div.classList.contains('active')).toBe(true);
+    });
+  });
+});