Repository: exadel-inc/esl
Pull request #1398 (2023-01-09 14:01:46)

feat(esl-related-target): make related target able to targeting multiple related togglable
- was made related target able to target multiple related togglable
- was introduced `esl-related-target-action` with  `'hide'|'show'|'all'(default)` as a values

Closes: #427




Patch:
diff --git a/build/jest.html-loader.js b/build/jest.html-loader.js
new file mode 100644
index 000000000..aa8d4ca9f
--- /dev/null
+++ b/build/jest.html-loader.js
@@ -0,0 +1,7 @@
+module.exports = {
+  process(sourceText) {
+    return {
+      code: `module.exports = ${JSON.stringify(sourceText)};`
+    };
+  }
+};
diff --git a/jest.config.js b/jest.config.js
index 5f78687ac..da0394ed3 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -1,11 +1,12 @@
 module.exports = {
   transform: {
-    '^.+\\.tsx?$': ['ts-jest']
+    '^.+\\.tsx?$': 'ts-jest',
+    '^.+\\.html?$': './build/jest.html-loader.js'
   },
   testEnvironment: 'jsdom',
   roots: ['src/modules', 'src/polyfills'],
   testRegex: '/test/(.+)\\.test\\.ts$',
-  moduleFileExtensions: ['ts', 'js', 'json'],
+  moduleFileExtensions: ['ts', 'js', 'json', 'html'],
   coverageDirectory: '.report',
   coverageReporters: ['lcov', 'html'],
   setupFiles: [
diff --git a/package-lock.json b/package-lock.json
index 55deaef7d..4ec10976b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16655,9 +16655,9 @@
       }
     },
     "node_modules/terser": {
-      "version": "5.14.2",
-      "resolved": "https://registry.npmjs.org/terser/-/terser-5.14.2.tgz",
-      "integrity": "sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA==",
+      "version": "5.16.1",
+      "resolved": "https://registry.npmjs.org/terser/-/terser-5.16.1.tgz",
+      "integrity": "sha512-xvQfyfA1ayT0qdK47zskQgRZeWLoOQ8JQ6mIgRGVNwZKdQMU+5FkCBjmv4QjcrTzyZquRw2FVtlJSRUmMKQslw==",
       "dev": true,
       "dependencies": {
         "@jridgewell/source-map": "^0.3.2",
@@ -30581,9 +30581,9 @@
       }
     },
     "terser": {
-      "version": "5.14.2",
-      "resolved": "https://registry.npmjs.org/terser/-/terser-5.14.2.tgz",
-      "integrity": "sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA==",
+      "version": "5.16.1",
+      "resolved": "https://registry.npmjs.org/terser/-/terser-5.16.1.tgz",
+      "integrity": "sha512-xvQfyfA1ayT0qdK47zskQgRZeWLoOQ8JQ6mIgRGVNwZKdQMU+5FkCBjmv4QjcrTzyZquRw2FVtlJSRUmMKQslw==",
       "dev": true,
       "requires": {
         "@jridgewell/source-map": "^0.3.2",
diff --git a/pages/views/examples/accordion.njk b/pages/views/examples/accordion.njk
index c74156ed8..a341948a4 100644
--- a/pages/views/examples/accordion.njk
+++ b/pages/views/examples/accordion.njk
@@ -24,7 +24,7 @@ aside:
       <esl-panel-group class="accordion-group mb-4"/>
     {% endcode %}
 
-    <esl-panel-group class="accordion-group mb-4" >
+    <esl-panel-group class="accordion-group mb-4">
       <div class="esl-d-accordion">
         <esl-trigger class="esl-d-accordion-header h4 esl-d-accordion-header-arrow" target="::next">
           Toggle Accordion #1
@@ -110,7 +110,9 @@ aside:
 
     <esl-panel-group class="accordion-group mb-4" min-open-items="0">
       <div class="esl-d-accordion">
-        <esl-trigger class="esl-d-accordion-header h4 esl-d-accordion-header-arrow" target="::next" track-click="not all" track-hover hide-delay="250">
+        <esl-trigger class="esl-d-accordion-header h4 esl-d-accordion-header-arrow"
+                     target="::next"
+                     track-click="not all" track-hover hide-delay="250">
           Toggle Accordion #1
         </esl-trigger>
         <esl-panel class="esl-d-accordion-panel" default-params="{hideDelay: 100}">
@@ -120,7 +122,11 @@ aside:
         </esl-panel>
       </div>
       <div class="esl-d-accordion">
-        <esl-trigger class="esl-d-accordion-header h4 esl-d-accordion-header-arrow" target="::next" track-click="not all" track-hover hide-delay="250">
+        <esl-trigger class="esl-d-accordion-header h4 esl-d-accordion-header-arrow"
+                     target="::next"
+                     track-click="not all"
+                     track-hover
+                     hide-delay="250">
           Toggle Accordion #2
         </esl-trigger>
         <esl-panel class="esl-d-accordion-panel" default-params="{hideDelay: 100}">
@@ -130,7 +136,9 @@ aside:
         </esl-panel>
       </div>
       <div class="esl-d-accordion">
-        <esl-trigger class="esl-d-accordion-header h4 esl-d-accordion-header-arrow" target="::next" track-click="not all" track-hover hide-delay="250">
+        <esl-trigger class="esl-d-accordion-header h4 esl-d-accordion-header-arrow"
+                     target="::next"
+                     track-click="not all" track-hover hide-delay="250">
           Toggle Accordion #3
         </esl-trigger>
         <esl-panel class="esl-d-accordion-panel" default-params="{hideDelay: 100}">
@@ -184,3 +192,127 @@ aside:
     </esl-panel-group>
   </div>
 </section>
+
+<section class="row">
+  <div class="col-12">
+    <h2>Nested accordion with auto-closable nested panels (esl-related-target)</h2>
+        {% code 'html', 'code-block' %}
+          <esl-panel class="esl-d-accordion-panel"
+                     esl-related-target="::find(esl-panel)"
+                     esl-related-target-action="hide">
+            <esl-panel-group class="accordion-group mb-4">
+              <esl-panel .../> <esl-panel .../> <esl-panel .../>
+            </esl-panel-group>
+          </esl-panel>
+        {% endcode %}
+
+    <esl-panel-group class="accordion-group mb-4" min-open-items="0" max-open-items="all">
+      <div class="esl-d-accordion">
+        <esl-trigger class="esl-d-accordion-header h4 esl-d-accordion-header-arrow" target="::next">
+          Toggle Accordion #1 (auto-closable)
+        </esl-trigger>
+        <esl-panel class="esl-d-accordion-panel"
+                   esl-related-target="::find(esl-panel)"
+                   esl-related-target-action="hide">
+          <div class="esl-d-accordion-body">
+            {{ lorem.paragraphs(2) }}
+
+            <esl-panel-group class="accordion-group mb-4" max-open-items="all" min-open-items="0">
+              <div class="esl-d-accordion">
+                <esl-trigger class="esl-d-accordion-header h4 esl-d-accordion-header-arrow" target="::next">
+                  Toggle Nested Accordion #1.1
+                </esl-trigger>
+                <esl-panel class="esl-d-accordion-panel">
+                  <div class="esl-d-accordion-body">
+                    {{ lorem.paragraphs(3) }}
+                  </div>
+                </esl-panel>
+              </div>
+              <div class="esl-d-accordion">
+                <esl-trigger class="esl-d-accordion-header h4 esl-d-accordion-header-arrow" target="::next">
+                  Toggle Nested Accordion #1.2
+                </esl-trigger>
+                <esl-panel class="esl-d-accordion-panel">
+                  <div class="esl-d-accordion-body">
+                    {{ lorem.paragraphs(2) }}
+                  </div>
+                </esl-panel>
+              </div>
+              <div class="esl-d-accordion">
+                <esl-trigger class="esl-d-accordion-header h4 esl-d-accordion-header-arrow" target="::next">
+                  Toggle Nested Accordion #1.3 (auto-closable)
+                </esl-trigger>
+                <esl-panel class="esl-d-accordion-panel"
+                           esl-related-target="::find(esl-panel)"
+                           esl-related-target-action="hide">
+                  <div class="esl-d-accordion-body">
+                    {{ lorem.paragraphs(2) }}
+
+                    <esl-panel-group class="accordion-group mb-4" max-open-items="all" min-open-items="0">
+                      <div class="esl-d-accordion">
+                        <esl-trigger class="esl-d-accordion-header h4 esl-d-accordion-header-arrow" target="::next">
+                          Toggle Nested Accordion #1.3.1
+                        </esl-trigger>
+                        <esl-panel class="esl-d-accordion-panel">
+                          <div class="esl-d-accordion-body">
+                            {{ lorem.paragraphs(2) }}
+                          </div>
+                        </esl-panel>
+                      </div>
+                      <div class="esl-d-accordion">
+                        <esl-trigger class="esl-d-accordion-header h4 esl-d-accordion-header-arrow" target="::next">
+                          Toggle Nested Accordion #1.3.2
+                        </esl-trigger>
+                        <esl-panel class="esl-d-accordion-panel">
+                          <div class="esl-d-accordion-body">
+                            {{ lorem.paragraphs(2) }}
+                          </div>
+                        </esl-panel>
+                      </div>
+                      <div class="esl-d-accordion">
+                        <esl-trigger class="esl-d-accordion-header h4 esl-d-accordion-header-arrow" target="::next">
+                          Toggle Nested Accordion #1.3.3
+                        </esl-trigger>
+                        <esl-panel class="esl-d-accordion-panel">
+                          <div class="esl-d-accordion-body">
+                            {{ lorem.paragraphs(2) }}
+                          </div>
+                        </esl-panel>
+                      </div>
+                    </esl-panel-group>
+
+                    {{ lorem.paragraphs(2) }}
+                  </div>
+                </esl-panel>
+              </div>
+            </esl-panel-group>
+
+          </div>
+        </esl-panel>
+      </div>
+      <div class="esl-d-accordion">
+        <esl-trigger class="esl-d-accordion-header h4 esl-d-accordion-header-arrow" target="::next">
+          Toggle Accordion #2 (not auto-closable)
+        </esl-trigger>
+        <esl-panel class="esl-d-accordion-panel (not auto-closable)">
+          <div class="esl-d-accordion-body">
+            {{ lorem.paragraphs(2) }}
+
+            <esl-panel-group class="accordion-group mb-4" max-open-items="all" min-open-items="0">
+              <div class="esl-d-accordion">
+                <esl-trigger class="esl-d-accordion-header h4 esl-d-accordion-header-arrow" target="::next">
+                  Toggle Nested Accordion #2.1
+                </esl-trigger>
+                <esl-panel class="esl-d-accordion-panel">
+                  <div class="esl-d-accordion-body">
+                    {{ lorem.paragraphs(3) }}
+                  </div>
+                </esl-panel>
+              </div>
+            </esl-panel-group>
+          </div>
+        </esl-panel>
+      </div>
+    </esl-panel-group>
+  </div>
+</section>
diff --git a/src/modules/esl-related-target/README.md b/src/modules/esl-related-target/README.md
index 9e381490b..ebd17ce45 100644
--- a/src/modules/esl-related-target/README.md
+++ b/src/modules/esl-related-target/README.md
@@ -1,12 +1,26 @@
 # [ESL](../../../) Related Target Mixin
 
-Version: *1.0.0*
+Version: *1.1.0*
 
-Authors: *Anastasiya Lesun, Alexey Stsefanovich (ala'n)*.
+Authors: *Anastasiya Lesun, Natalia Smirnova, Alexey Stsefanovich (ala'n)*.
 
 <a name="intro"></a>
 
-**ESLRelatedTarget** is a custom mixin that can be used in combination with `ESLToggleable` (or based on it) instance to link it with another `ESLToggleable` to sync their states.
+**ESLRelatedTarget** is a custom mixin that can be used in combination with `ESLToggleable` instance (or any inheritor)
+to link it with one or more other `ESLToggleable` to sync their states.
 
-Mixin attaches to the element via the `esl-related-target` attribute. The attribute's value defines a selector to find the corresponding element.
-Activation (set open state) or deactivation of the related target happens when the corresponding `esl:show` and `esl:hide` DOM events are dispatched on the current host element.
+Mixin attaches to the element via the `esl-related-target` attribute. 
+The attribute's value defines a selector to find the corresponding element.
+Activation (set open state) or deactivation of the related target happens when the corresponding `esl:show` or 
+`esl:hide` DOM events are dispatched on the current host element.
+
+Mixin supports `esl-related-target-action` attribute to define required for synchronization actions:
+ - `all` (default) - to synchronize both: show and hide events of the observed toggleable
+ - `hide` - to synchronize only hide events of the observed toggleable
+ - `show` - to synchronize only show events of the observed toggleable
+
+To make cross-reference (bidirectional binding) of toggleables you need to cross-reference both of the toggleables:
+```html
+<esl-toggleable class="first" esl-related-target=".second"></esl-toggleable>
+<esl-toggleable class="second" esl-related-target=".first"></esl-toggleable>
+```
diff --git a/src/modules/esl-related-target/core/esl-related-target.ts b/src/modules/esl-related-target/core/esl-related-target.ts
index 1f6c8adc2..cb512350a 100644
--- a/src/modules/esl-related-target/core/esl-related-target.ts
+++ b/src/modules/esl-related-target/core/esl-related-target.ts
@@ -7,6 +7,8 @@ import {copyDefinedKeys} from '../../esl-utils/misc/object/copy';
 
 import type {ESLToggleableActionParams} from '../../esl-toggleable/core/esl-toggleable';
 
+export type ESLRelatedTargetActionType = 'all' | 'show' | 'hide';
+
 /**
  * ESLRelatedTarget mixin element
  * @author Anastasia Lesun, Alexey Stsefanovich (ala'n)
@@ -19,17 +21,21 @@ export class ESLRelatedTarget extends ESLMixinElement {
 
   /** Selector/query ({@link ESLTraversingQuery}) to find related ESLToggleable element */
   @attr({name: ESLRelatedTarget.is}) public selector: string;
+  /** Action to synchronize between toggleables */
+  @attr({name: ESLRelatedTarget.is + '-action', defaultValue: 'all'}) public action: ESLRelatedTargetActionType;
 
-  /** @returns related toggleable instance */
-  public get $relatedTarget(): ESLToggleable | null {
+  /** @returns related toggleable instances */
+  public get $targets(): ESLToggleable[] {
     const {selector} = this;
-    if (!selector) return null;
-    const target = ESLTraversingQuery.first(selector);
-    return target instanceof ESLToggleable ? target : null;
+    if (!selector) return [];
+    const targets = ESLTraversingQuery.all(selector, this.$host);
+    return targets.filter(
+      (target: HTMLElement): target is ESLToggleable => target instanceof ESLToggleable && target !== this.$host
+    );
   }
 
-  /** Merges params that are used by toggleable for actions */
-  protected mergeLocalParams(params: ESLToggleableActionParams): ESLToggleableActionParams {
+  /** Merges params that are used to initiate actions */
+  protected mergeParams(params: ESLToggleableActionParams): ESLToggleableActionParams {
     return Object.assign(copyDefinedKeys(params), {
       initiator: 'relatedToggleable',
       activator: this.$host
@@ -39,14 +45,16 @@ export class ESLRelatedTarget extends ESLMixinElement {
   /** Processes {@link ESLToggleable} show event */
   @listen('esl:show')
   protected onShow(e: CustomEvent): void {
+    if (this.action === 'hide') return;
     const {params} = e.detail;
-    this.$relatedTarget?.show({params: this.mergeLocalParams(params)});
+    this.$targets.forEach((target) => target.show({params: this.mergeParams(params)}));
   }
 
   /** Processes {@link ESLToggleable} hide event */
   @listen('esl:hide')
   protected onHide(e: CustomEvent): void {
+    if (this.action === 'show') return;
     const {params} = e.detail;
-    this.$relatedTarget?.hide({params: this.mergeLocalParams(params)});
+    this.$targets.forEach((target) => target.hide({params: this.mergeParams(params)}));
   }
 }


Test patch:
diff --git a/src/modules/esl-related-target/test/related-target.test.ts b/src/modules/esl-related-target/test/related-target.test.ts
index 9ca0c2c08..f0bd61481 100644
--- a/src/modules/esl-related-target/test/related-target.test.ts
+++ b/src/modules/esl-related-target/test/related-target.test.ts
@@ -1,53 +1,82 @@
 import {ESLRelatedTarget} from '../core/esl-related-target';
+import {ESLPanelGroup} from '../../esl-panel-group/core/esl-panel-group';
+import {ESLPanel} from '../../esl-panel/core/esl-panel';
 import {ESLToggleable} from '../../esl-toggleable/core/esl-toggleable';
 
-describe('ESLRelatedTarget: show/hide depending on mixin element state', () => {
-  const $el = ESLToggleable.create();
-  const $relatedEl = ESLToggleable.create();
-  $relatedEl.setAttribute('id', 'related-toggleable');
-  $el.setAttribute(ESLRelatedTarget.is, '#related-toggleable');
+// @ts-ignore
+import accordionTestTemplate from './template.html';
 
+describe('ESLRelatedTarget (mixin): tests', () => {
   jest.useFakeTimers();
-  beforeAll(() =>  {
+
+  let initialContent: string;
+  let $panel: ESLPanel;
+  let $targetPanels: ESLPanel[];
+
+  beforeAll(() => {
+    ESLPanelGroup.register();
+    ESLPanel.register();
     ESLToggleable.register();
     ESLRelatedTarget.register();
-    document.body.appendChild($el);
-    document.body.appendChild($relatedEl);
+
+    initialContent = document.body.innerHTML;
+    document.body.innerHTML = accordionTestTemplate;
+    $panel = document.querySelector('[esl-related-target]') as ESLPanel;
+    $targetPanels = [...document.querySelectorAll('.chosen-panel')] as ESLPanel[];
   });
   afterAll(() => {
-    ($el.parentElement === document.body) && document.body.removeChild($el);
-    ($relatedEl.parentElement === document.body) && document.body.removeChild($relatedEl);
+    document.body.innerHTML = initialContent;
   });
 
-  describe('Synchronization of mixin and target states: SHOW_REQUEST on the current mixin element leads to show target element', () => {
-    beforeEach(() =>  $el.hide());
-    test('Direct call of show function', () => {
-      expect($relatedEl.open).toBe(false);
-      $el.show();
+  describe('ESLRelatedTarget: behaviour tests', () => {
+    const showRelationTest = () => {
+      $targetPanels.forEach((panel: ESLPanel, i) => panel.toggle(i % 2 === 0));
       jest.advanceTimersByTime(1);
-      expect($relatedEl.open).toBe(true);
-    });
-    test('SHOW_REQUEST_EVENT dispatching', () => {
-      expect($relatedEl.open).toBe(false);
-      $el.dispatchEvent(new CustomEvent($el.SHOW_REQUEST_EVENT));
+      $panel.show();
       jest.advanceTimersByTime(1);
-      expect($relatedEl.open).toBe(true);
+      $targetPanels.forEach(($chosenPanel: ESLPanel) => expect($chosenPanel.open).toBe(true));
+    };
+    const hideRelationTest = () => {
+      $targetPanels.forEach((panel: ESLPanel, i) => panel.toggle(i % 2 === 0));
+      jest.advanceTimersByTime(1);
+      $panel.hide();
+      jest.advanceTimersByTime(1);
+      $targetPanels.forEach(($chosenPanel: ESLPanel) => expect($chosenPanel.open).toBe(false));
+    };
+
+    test('ESLRelatedTarget with \'all\' action observation synchronize show state of toggleables', () => {
+      $panel.setAttribute('esl-related-target-action', 'all');
+      showRelationTest();
+    });
+    test('ESLRelatedTarget with \'all\' action observation synchronize hide state of toggleables', () => {
+      $panel.setAttribute('esl-related-target-action', 'all');
+      hideRelationTest();
+    });
+    test('ESLRelatedTarget with \'show\' action observation synchronize show state of toggleables', () => {
+      $panel.setAttribute('esl-related-target-action', 'show');
+      showRelationTest();
+    });
+    test('ESLRelatedTarget with \'hide\' action observation synchronize hide state of toggleables', () => {
+      $panel.setAttribute('esl-related-target-action', 'hide');
+      hideRelationTest();
     });
-  });
 
-  describe('Synchronization of mixin and target states: HIDE_REQUEST on the current mixin element leads to show target element', () => {
-    beforeEach(() =>  $el.show());
-    test('Direct call of hide function', () => {
-      expect($relatedEl.open).toBe(true);
-      $el.hide();
+    test('ESLRelatedTarget with \'hide\' action observation does not synchronize show state of toggleables', () => {
+      $panel.setAttribute('esl-related-target-action', 'hide');
+      $targetPanels.forEach((panel: ESLPanel) => panel.hide());
       jest.advanceTimersByTime(1);
-      expect($relatedEl.open).toBe(false);
+      $panel.show();
+      jest.advanceTimersByTime(1);
+      $targetPanels.forEach(($chosenPanel: ESLPanel) => expect($chosenPanel.open).toBe(false));
     });
-    test('HIDE_REQUEST_EVENT dispatching', () => {
-      expect($relatedEl.open).toBe(true);
-      $el.dispatchEvent(new CustomEvent($el.HIDE_REQUEST_EVENT));
+
+    test('ESLRelatedTarget with \'show\' action observation does not synchronize hide state of toggleables', () => {
+      $panel.setAttribute('esl-related-target-action', 'show');
+      $targetPanels.forEach((panel: ESLPanel) => panel.show());
+      jest.advanceTimersByTime(1);
+      $panel.hide();
       jest.advanceTimersByTime(1);
-      expect($relatedEl.open).toBe(false);
+      $targetPanels.forEach(($chosenPanel: ESLPanel) => expect($chosenPanel.open).toBe(true));
     });
   });
 });
diff --git a/src/modules/esl-related-target/test/template.html b/src/modules/esl-related-target/test/template.html
new file mode 100644
index 000000000..7724a15e4
--- /dev/null
+++ b/src/modules/esl-related-target/test/template.html
@@ -0,0 +1,16 @@
+<esl-toggleable id="parent-panel"
+                esl-related-target=".chosen-panel"></esl-toggleable>
+
+<esl-toggleable class="chosen-panel"></esl-toggleable>
+<esl-toggleable class="chosen-panel"></esl-toggleable>
+<esl-toggleable class="chosen-panel"></esl-toggleable>
+<esl-toggleable class="chosen-panel"></esl-toggleable>
+
+<esl-panel-group max-open-items="all" min-open-items="0">
+  <esl-panel class="chosen-panel"></esl-panel>
+  <esl-panel class="chosen-panel">
+    <esl-panel-group class="accordion-group mb-4" max-open-items="all" min-open-items="0">
+      <esl-panel class="chosen-panel"></esl-panel>
+    </esl-panel-group>
+  </esl-panel>
+</esl-panel-group>