Repository: exadel-inc/esl
Pull request #2069 (2023-11-23 15:30:04)

[🚩esl-share]: final follow up
Closes: #2012 

Contains next points:
 - fixed deduplication behavior on selecting buttons from config
 - fixed visual issue with displaying a huge list of share buttons in a popup
 - added ready state attribute and ready state event
 - revised observed attributes
 - retired esl-share and renamed esl-share-popup-trigger into esl-share
 - added ExportNS using
 - added support types for correct use in Typescript



Patch:
diff --git a/pages/src/esl-share/esl-share.ts b/pages/src/esl-share/esl-share.ts
index b413da462..69bfc6bc1 100644
--- a/pages/src/esl-share/esl-share.ts
+++ b/pages/src/esl-share/esl-share.ts
@@ -1,8 +1,9 @@
 import '@exadel/esl/modules/esl-share/buttons/all';
-import {ESLShare, ESLShareConfig} from '@exadel/esl/modules/esl-share/core';
+import {ESLShare, ESLShareList, ESLShareConfig} from '@exadel/esl/modules/esl-share/core';
 
 ESLShareConfig.append({name: 'site', 'list': 'facebook twitter linkedin telegram copy'});
 ESLShare.register();
+ESLShareList.register();
 
 ESLShareConfig.set(
   fetch('/assets/share/config.json').then((response) => response.json())
diff --git a/pages/views/_includes/navigation/header.njk b/pages/views/_includes/navigation/header.njk
index 6652e6b62..351e1bb9c 100644
--- a/pages/views/_includes/navigation/header.njk
+++ b/pages/views/_includes/navigation/header.njk
@@ -39,7 +39,7 @@
                    default-params="{hideDelay: 500}"
                    close-on-esc
                    close-on-outside-action>
-          <esl-share list="group:site"></esl-share>
+          <esl-share-list list="group:site"></esl-share-list>
           <span class="esl-popup-arrow"></span>
         </esl-popup>
       </li>
diff --git a/pages/views/_includes/navigation/sidebar.njk b/pages/views/_includes/navigation/sidebar.njk
index 6d4516e2c..204ff7395 100644
--- a/pages/views/_includes/navigation/sidebar.njk
+++ b/pages/views/_includes/navigation/sidebar.njk
@@ -50,8 +50,8 @@
         <hr class="sidebar-nav-item-divider show-xs"/>
 
         <li class="sidebar-nav-item sidebar-nav-item-share">
-          <esl-share aria-label="Share this page"
-                     list="group:site"></esl-share>
+          <esl-share-list list="group:site"
+                          aria-label="Share this page"></esl-share-list>
         </li>
       </ul>
     </div>
diff --git a/pages/views/draft/share-regression.njk b/pages/views/draft/share-regression.njk
index 8a6128e2b..cd8e23a9b 100644
--- a/pages/views/draft/share-regression.njk
+++ b/pages/views/draft/share-regression.njk
@@ -21,7 +21,7 @@ aside:
 {% macro shareAttrs4() %}share-url="https://esl-ui.com/examples/" share-title="Examples"{% endmacro %}
 
 <style>
-  esl-share-popup-trigger {
+  esl-share {
     margin: 10px;
   }
   .demo-trigger-svg-icon {
@@ -35,27 +35,27 @@ aside:
 
   <h3>Displays list of share buttons without list attribute</h3>
   <p>Shows all available buttons from config.</p>
-  <esl-share></esl-share>
+  <esl-share-list></esl-share-list>
   <hr>
 
   <h3>Displays list of share buttons with list="all"</h3>
   <p>Shows all available buttons from config.</p>
-  <esl-share list="all"></esl-share>
+  <esl-share-list list="all"></esl-share-list>
   <hr>
 
   <h3>Displays list of share buttons with empty list (list="")</h3>
   <p>Shows empty component without buttons.</p>
-  <esl-share list=""></esl-share>
+  <esl-share-list list=""></esl-share-list>
   <hr>
 
   <h3>Displays list of share buttons for nonexistent group</h3>
   <p>Shows empty list when a nonexistent group is specified.</p>
-  <esl-share list="group:foo"></esl-share>
+  <esl-share-list list="group:foo"></esl-share-list>
   <hr>
 
   <h3>Displays list of share buttons only for existent groups or buttons</h3>
   <p>Shows specified buttons. Displays nothing in the case when specifying the wrong button or group name. (should display LinkedIn, Facebook, Twitter, LinkedIn, Wykop and Copy buttons)</p>
-  <esl-share list="linkedin alladin group:alladin group:demo"></esl-share>
+  <esl-share-list list="linkedin alladin group:alladin group:demo"></esl-share-list>
   <hr>
 
 {#  TODO: discuss behaviour #}
@@ -66,68 +66,68 @@ aside:
 
   <h3>Displays list of share buttons via incorrect list</h3>
   <p>Shows buttons only with the correct names. (should display only LinkedIn button)</p>
-  <esl-share list="twittor  copi group:exsampl linkedin rint"></esl-share>
+  <esl-share-list list="twittor  copi group:exsampl linkedin rint"></esl-share-list>
   <hr>
 
   <h3>Trigger madness</h3>
   <p>Displays a row of share triggers related to three variations of the popup. The purpose is to check that popup is consistently following the active trigger.</p>
   <p>Hint: the first number is a variant of the share popup, and the second number is a variant of the share attributes.<p>
 
-  <esl-share mode="popup">
+  <esl-share dir="rtl" lang="he" popup-params="{extraClass: 'foo', inlineStyles: 'max-width:325px; padding:20px'}">
     {{ triggerSVGicon('#00BFFF', '1/1') }}
   </esl-share>
-  <esl-share mode="popup" list="group:demo">
+  <esl-share list="group:demo">
     {{ triggerSVGicon('#00CED1', '2/1') }}
   </esl-share>
-  <esl-share mode="popup" list="group:alternative">
+  <esl-share dir="ltr" lang="fr" popup-params="{extraClass: 'bar', inlineStyles: 'padding:10px'}" list="group:alternative">
     {{ triggerSVGicon('#8FBC8F', '3/1') }}
   </esl-share>
-  <esl-share mode="popup">
+  <esl-share>
     {{ triggerSVGicon('#00BFFF', '1/1') }}
   </esl-share>
-  <esl-share mode="popup" {{ shareAttrs2() }}>
+  <esl-share {{ shareAttrs2() }}>
     {{ triggerSVGicon('#00BFFF', '1/2') }}
   </esl-share>
-  <esl-share mode="popup" list="group:demo">
+  <esl-share list="group:demo">
     {{ triggerSVGicon('#00CED1', '2/1') }}
   </esl-share>
-  <esl-share mode="popup" list="group:demo" {{ shareAttrs2() }}>
+  <esl-share list="group:demo" {{ shareAttrs2() }}>
     {{ triggerSVGicon('#00CED1', '2/2') }}
   </esl-share>
-  <esl-share mode="popup" list="group:demo" {{ shareAttrs3() }}>
+  <esl-share list="group:demo" {{ shareAttrs3() }}>
     {{ triggerSVGicon('#00CED1', '2/3') }}
   </esl-share>
-  <esl-share mode="popup" list="group:demo" {{ shareAttrs4() }}>
+  <esl-share list="group:demo" {{ shareAttrs4() }}>
     {{ triggerSVGicon('#00CED1', '2/4') }}
   </esl-share>
-  <esl-share mode="popup" list="group:demo" {{ shareAttrs3() }}>
+  <esl-share list="group:demo" {{ shareAttrs3() }}>
     {{ triggerSVGicon('#00CED1', '2/3') }}
   </esl-share>
-  <esl-share mode="popup" list="group:demo" {{ shareAttrs4() }}>
+  <esl-share list="group:demo" {{ shareAttrs4() }}>
     {{ triggerSVGicon('#00CED1', '2/4') }}
   </esl-share>
-  <esl-share mode="popup" list="group:demo">
+  <esl-share list="group:demo">
     {{ triggerSVGicon('#00CED1', '2/1') }}
   </esl-share>
-  <esl-share mode="popup" list="group:alternative">
+  <esl-share list="group:alternative">
     {{ triggerSVGicon('#8FBC8F', '3/1') }}
   </esl-share>
-  <esl-share mode="popup" list="group:alternative" {{ shareAttrs2() }}>
+  <esl-share list="group:alternative" {{ shareAttrs2() }}>
     {{ triggerSVGicon('#8FBC8F', '3/2') }}
   </esl-share>
-  <esl-share mode="popup" list="group:alternative" {{ shareAttrs3() }}>
+  <esl-share list="group:alternative" {{ shareAttrs3() }}>
     {{ triggerSVGicon('#8FBC8F', '3/3') }}
   </esl-share>
-  <esl-share mode="popup" list="group:alternative" {{ shareAttrs4() }}>
+  <esl-share list="group:alternative" {{ shareAttrs4() }}>
     {{ triggerSVGicon('#8FBC8F', '3/4') }}
   </esl-share>
-  <esl-share mode="popup" list="group:alternative">
+  <esl-share list="group:alternative">
     {{ triggerSVGicon('#8FBC8F', '3/1') }}
   </esl-share>
-  <esl-share mode="popup">
+  <esl-share>
     {{ triggerSVGicon('#00BFFF', '1/1') }}
   </esl-share>
-  <esl-share mode="popup">
+  <esl-share>
     {{ triggerSVGicon('#00BFFF', '1/1') }}
   </esl-share>
 </section>
diff --git a/pages/views/examples/share.njk b/pages/views/examples/share.njk
index 80882dda4..3f72e5876 100644
--- a/pages/views/examples/share.njk
+++ b/pages/views/examples/share.njk
@@ -10,10 +10,10 @@ aside:
 ---
 
 <style>
-  esl-share-popup-trigger {
+  esl-share {
     margin: 10px;
   }
-  esl-share-popup-trigger svg {
+  esl-share svg {
     width: 40px;
     height: 40px;
   }
@@ -76,11 +76,6 @@ aside:
 
   <h3>Share popup trigger example</h3>
   <p>Displays trigger icon. Hovering or clicking on the icon shows a popup with the list of share icons. The component's configuration uses attributes to specify the URL and title of the page to be shared.</p>
-  <esl-share mode="popup" list="group:demo" share-url="https://esl-ui.com/" share-title="Exadel Smart Library">{% include 'static/assets/examples/share-trigger-icon.svg' %}</esl-share>
-  <hr>
+  <esl-share list="group:demo" share-url="https://esl-ui.com/" share-title="Exadel Smart Library">{% include 'static/assets/examples/share-trigger-icon.svg' %}</esl-share>
 
-  <h3>Share (universal) component example (with both list and popup modes)</h3>
-  <p>Shows share list from a specified group and share popup trigger for the same group.</p>
-  <esl-share mode="list" list="group:demo"></esl-share>
-  <esl-share mode="popup" list="group:demo">{% include 'static/assets/examples/share-trigger-icon.svg' %}</esl-share>
 </section>
diff --git a/src/modules/esl-alert/core/esl-alert.ts b/src/modules/esl-alert/core/esl-alert.ts
index e709f3b45..b60f40e3e 100644
--- a/src/modules/esl-alert/core/esl-alert.ts
+++ b/src/modules/esl-alert/core/esl-alert.ts
@@ -31,8 +31,14 @@ export class ESLAlert extends ESLToggleable {
   public static override is = 'esl-alert';
   public static override observedAttributes = ['target'];
 
+  /**
+   * Legacy default show/hide params for all ESLAlert instances
+   * @deprecated Use {@link ESLAlert.DEFAULT_PARAMS} instead
+   */
+  public static defaultConfig: AlertActionParams = {};
+
   /** Default show/hide params for all ESLAlert instances */
-  public static defaultConfig: AlertActionParams = {
+  public static override DEFAULT_PARAMS: AlertActionParams = {
     hideTime: 300,
     hideDelay: 2500
   };
@@ -70,7 +76,7 @@ export class ESLAlert extends ESLToggleable {
 
   protected override mergeDefaultParams(params?: ESLToggleableActionParams): ESLToggleableActionParams {
     const type = this.constructor as typeof ESLAlert;
-    return Object.assign({}, type.defaultConfig, this.defaultParams || {}, params || {});
+    return Object.assign({}, type.defaultConfig, type.DEFAULT_PARAMS, this.defaultParams || {}, params || {});
   }
 
   protected override attributeChangedCallback(attrName: string, oldVal: string, newVal: string): void {
diff --git a/src/modules/esl-panel/core/esl-panel.ts b/src/modules/esl-panel/core/esl-panel.ts
index 0b5ed79de..c2b354f21 100644
--- a/src/modules/esl-panel/core/esl-panel.ts
+++ b/src/modules/esl-panel/core/esl-panel.ts
@@ -143,8 +143,9 @@ export class ESLPanel extends ESLToggleable {
 
   /** Merge params that are used by panel group for actions */
   protected override mergeDefaultParams(params?: ESLToggleableActionParams): ESLToggleableActionParams {
+    const type = this.constructor as typeof ESLToggleable;
     const stackConfig = this.$group?.panelConfig || {};
-    return Object.assign({}, stackConfig, this.defaultParams, params || {});
+    return Object.assign({}, stackConfig, type.DEFAULT_PARAMS, this.defaultParams, params || {});
   }
 }
 
diff --git a/src/modules/esl-popup/core/esl-popup.ts b/src/modules/esl-popup/core/esl-popup.ts
index fa8b0f295..fe95169a5 100644
--- a/src/modules/esl-popup/core/esl-popup.ts
+++ b/src/modules/esl-popup/core/esl-popup.ts
@@ -17,7 +17,6 @@ import {ESLPopupPlaceholder} from './esl-popup-placeholder';
 import type {ESLToggleableActionParams} from '../../esl-toggleable/core';
 import type {PositionType, IntersectionRatioRect} from './esl-popup-position';
 
-
 const INTERSECTION_LIMIT_FOR_ADJACENT_AXIS = 0.7;
 const DEFAULT_OFFSET_ARROW = 50;
 
@@ -57,6 +56,13 @@ export interface PopupActionParams extends ESLToggleableActionParams {
 export class ESLPopup extends ESLToggleable {
   public static override is = 'esl-popup';
 
+  /** Default params to pass into the popup on show/hide actions */
+  public static override DEFAULT_PARAMS: PopupActionParams = {
+    offsetTrigger: 3,
+    offsetContainer: 15,
+    intersectionMargin: '0px'
+  };
+
   /** Classname of popups arrow element */
   @attr({defaultValue: 'esl-popup-arrow'}) public arrowClass: string;
 
@@ -89,12 +95,8 @@ export class ESLPopup extends ESLToggleable {
   /** Target to container element {@link ESLTraversingQuery} to define bounds of popups visibility (window by default) */
   @attr() public container: string;
 
-  /** Default params to merge into passed action params */
-  @jsonAttr<PopupActionParams>({defaultValue: {
-    offsetTrigger: 3,
-    offsetContainer: 15,
-    intersectionMargin: '0px'
-  }})
+  /** Default show/hide params for current ESLAlert instance */
+  @jsonAttr<PopupActionParams>()
   public override defaultParams: PopupActionParams;
 
   @attr({parser: parseBoolean, serializer: toBooleanAttribute, defaultValue: true})
diff --git a/src/modules/esl-share/core.less b/src/modules/esl-share/core.less
index 28f98e2a2..9a3818bfd 100644
--- a/src/modules/esl-share/core.less
+++ b/src/modules/esl-share/core.less
@@ -1,3 +1,3 @@
 @import './core/esl-share-button.less';
 @import './core/esl-share-list.less';
-@import './core/esl-share-popup-trigger.less';
+@import './core/esl-share.less';
diff --git a/src/modules/esl-share/core.ts b/src/modules/esl-share/core.ts
index 3dd93ce94..90e86719c 100644
--- a/src/modules/esl-share/core.ts
+++ b/src/modules/esl-share/core.ts
@@ -5,4 +5,3 @@ export * from './core/esl-share-button';
 export * from './core/esl-share-config';
 export * from './core/esl-share-list';
 export * from './core/esl-share-popup';
-export * from './core/esl-share-popup-trigger';
diff --git a/src/modules/esl-share/core/esl-share-action-registry.ts b/src/modules/esl-share/core/esl-share-action-registry.ts
index bb793d48a..1f8807019 100644
--- a/src/modules/esl-share/core/esl-share-action-registry.ts
+++ b/src/modules/esl-share/core/esl-share-action-registry.ts
@@ -1,7 +1,6 @@
 import {memoize} from '../../esl-utils/decorators';
 
 import type {ESLShareActionType, ESLShareBaseAction} from './esl-share-action';
-import type {ESLShareButton} from './esl-share-button';
 
 /**
  * ESLShareActionRegistry class to store share actions
@@ -34,10 +33,4 @@ export class ESLShareActionRegistry {
     if (!name) return null;
     return this.actionsMap.get(name.toLowerCase()) || null;
   }
-
-  /** Does the share action on passed Share button {@link ESLShareButton} */
-  public share(button: ESLShareButton): void {
-    const action = this.get(button.shareAction);
-    action && action.share(button);
-  }
 }
diff --git a/src/modules/esl-share/core/esl-share-button.ts b/src/modules/esl-share/core/esl-share-button.ts
index 17188b9fa..b7f2c1d11 100644
--- a/src/modules/esl-share/core/esl-share-button.ts
+++ b/src/modules/esl-share/core/esl-share-button.ts
@@ -1,3 +1,4 @@
+import {ExportNs} from '../../esl-utils/environment/export-ns';
 import {ESLBaseElement} from '../../esl-base-element/core';
 import {attr, boolAttr, jsonAttr, listen, memoize, prop} from '../../esl-utils/decorators';
 import {ENTER, SPACE} from '../../esl-utils/dom/keys';
@@ -9,12 +10,15 @@ import {ESLShareConfig} from './esl-share-config';
 import type {ESLShareBaseAction} from './esl-share-action';
 import type {ESLShareButtonConfig} from './esl-share-config';
 
+export type {ESLShareButtonTagShape} from './esl-share-button.shape';
+
 /**
  * ESLShareButton
  * @author Dmytro Shovchko
  *
  * ESLShareButton is a custom element to invoke a share actions, defined by {@link ESLShareBaseAction}
  */
+@ExportNs('ShareButton')
 export class ESLShareButton extends ESLBaseElement {
   public static override is = 'esl-share-button';
   public static observedAttributes = ['action', 'name'];
@@ -24,13 +28,15 @@ export class ESLShareButton extends ESLBaseElement {
     const $button = document.createElement(this.is) as InstanceType<T>;
     if (buttonName) {
       $button.name = buttonName;
-      $button.initIcon();
+      $button.defaultIcon = true;
     }
     return $button;
   }
 
   /** Event to dispatch when {@link ESLShareButton} configuration is changed */
-  @prop('esl:share:button:changed') public SHARE_BUTTON_CHANGED_EVENT: string;
+  @prop('esl:share:changed') public SHARE_CHANGED_EVENT: string;
+  /** Event to dispatch on {@link ESLShareButton} ready state */
+  @prop('esl:share:ready') public SHARE_READY_EVENT: string;
 
   /** Name of share action that occurs after button click */
   @attr() public action: string;
@@ -122,7 +128,7 @@ export class ESLShareButton extends ESLBaseElement {
     if (this.defaultIcon) this.initIcon();
     this.initA11y();
     this.updateAction();
-    this.$$attr('ready', true);
+    this.onReady();
   }
 
   /** Sets initial a11y attributes */
@@ -186,6 +192,22 @@ export class ESLShareButton extends ESLBaseElement {
     memoize.clear(this, 'config');
     if (isEqual(this.config, config)) return;
     this.init(true);
-    this.$$fire(this.SHARE_BUTTON_CHANGED_EVENT, {bubbles: false});
+    this.$$fire(this.SHARE_CHANGED_EVENT, {bubbles: false});
+  }
+
+  /** Actions on complete init and ready component */
+  private onReady(): void {
+    if (this.ready) return;
+    this.$$attr('ready', true);
+    this.$$fire(this.SHARE_READY_EVENT, {bubbles: false});
+  }
+}
+
+declare global {
+  export interface ESLLibrary {
+    ShareButton: typeof ESLShareButton;
+  }
+  export interface HTMLElementTagNameMap {
+    'esl-share-button': ESLShareButton;
   }
 }
diff --git a/src/modules/esl-share/core/esl-share-config.ts b/src/modules/esl-share/core/esl-share-config.ts
index 7b339a1c8..7a6945a2c 100644
--- a/src/modules/esl-share/core/esl-share-config.ts
+++ b/src/modules/esl-share/core/esl-share-config.ts
@@ -119,8 +119,10 @@ export class ESLShareConfig extends SyntheticEventTarget {
    */
   public get(query: string): ESLShareButtonConfig[] {
     const terms = this.resolve(query);
-    if (terms.includes('all')) return this.buttons;
-    const list = terms.map((name) => this.getButton(name));
+    const list: (ESLShareButtonConfig | undefined)[] = [];
+    terms.forEach((name) => {
+      name === 'all' ? list.push(...this.buttons) : list.push(this.getButton(name));
+    });
     return uniq(list.filter(Boolean) as ESLShareButtonConfig[]);
   }
 
diff --git a/src/modules/esl-share/core/esl-share-list.ts b/src/modules/esl-share/core/esl-share-list.ts
index 167b44992..340482ab2 100644
--- a/src/modules/esl-share/core/esl-share-list.ts
+++ b/src/modules/esl-share/core/esl-share-list.ts
@@ -1,3 +1,4 @@
+import {ExportNs} from '../../esl-utils/environment/export-ns';
 import {ESLBaseElement} from '../../esl-base-element/core';
 import {isEqual} from '../../esl-utils/misc/object/compare';
 import {attr, boolAttr, listen, memoize, prop} from '../../esl-utils/decorators';
@@ -6,14 +7,18 @@ import {ESLShareConfig} from './esl-share-config';
 
 import type {ESLShareButtonConfig} from './esl-share-config';
 
+export type {ESLShareListTagShape} from './esl-share-list.shape';
+
 /**
  * ESLShareList
  * @author Dmytro Shovchko
  *
  * ESLShareList is a custom element to dynamically draw {@link ESLShareButton}s using simplified shared config
  */
+@ExportNs('ShareList')
 export class ESLShareList extends ESLBaseElement {
   public static override is = 'esl-share-list';
+  public static observedAttributes = ['list'];
 
   /** Register {@link ESLShareList} component and dependent {@link ESLShareButton} */
   public static override register(): void {
@@ -23,6 +28,8 @@ export class ESLShareList extends ESLBaseElement {
 
   /** Event to dispatch on change of {@link ESLShareList} */
   @prop('esl:share:changed') public SHARE_CHANGED_EVENT: string;
+  /** Event to dispatch on {@link ESLShareList} ready state */
+  @prop('esl:share:ready') public SHARE_READY_EVENT: string;
 
   /**
    * List of social networks or groups of them to display (all by default).
@@ -41,7 +48,12 @@ export class ESLShareList extends ESLBaseElement {
     return ESLShareConfig.instance.get(this.list);
   }
 
-  public override connectedCallback(): void {
+  protected override attributeChangedCallback(attrName: string, oldVal: string, newVal: string): void {
+    if (!this.connected || oldVal === newVal) return;
+    this.update();
+  }
+
+  protected override connectedCallback(): void {
     super.connectedCallback();
     this.init();
   }
@@ -53,6 +65,14 @@ export class ESLShareList extends ESLBaseElement {
     this.onReady();
   }
 
+  /** Updates the component if the buttons config was changed */
+  protected update(): void {
+    const {buttonsConfig} = this;
+    memoize.clear(this, 'buttonsConfig');
+    if (isEqual(this.buttonsConfig, buttonsConfig)) return;
+    this.init(true);
+  }
+
   /** Appends buttons to the component. */
   protected buildContent(): void {
     this.innerHTML = '';
@@ -64,15 +84,23 @@ export class ESLShareList extends ESLBaseElement {
 
   /** Actions on complete init and ready component. */
   private onReady(): void {
+    if (this.ready) return;
     this.$$attr('ready', true);
-    this.$$fire(this.SHARE_CHANGED_EVENT, {bubbles: false});
+    this.$$fire(this.SHARE_READY_EVENT, {bubbles: false});
   }
 
   @listen({event: 'change', target: ESLShareConfig.instance})
   protected _onConfigChange(): void {
-    const {buttonsConfig} = this;
-    memoize.clear(this, 'buttonsConfig');
-    if (isEqual(this.buttonsConfig, buttonsConfig)) return;
-    this.init(true);
+    this.update();
+    this.$$fire(this.SHARE_CHANGED_EVENT, {bubbles: false});
+  }
+}
+
+declare global {
+  export interface ESLLibrary {
+    ShareList: typeof ESLShareList;
+  }
+  export interface HTMLElementTagNameMap {
+    'esl-share-list': ESLShareList;
   }
 }
diff --git a/src/modules/esl-share/core/esl-share-popup-trigger.less b/src/modules/esl-share/core/esl-share-popup-trigger.less
deleted file mode 100644
index cd5d88d10..000000000
--- a/src/modules/esl-share/core/esl-share-popup-trigger.less
+++ /dev/null
@@ -1,4 +0,0 @@
-esl-share-popup-trigger {
-  display: inline-block;
-  cursor: pointer;
-}
diff --git a/src/modules/esl-share/core/esl-share-popup-trigger.shape.ts b/src/modules/esl-share/core/esl-share-popup-trigger.shape.ts
deleted file mode 100644
index 714315626..000000000
--- a/src/modules/esl-share/core/esl-share-popup-trigger.shape.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import type {ESLBaseElementShape} from '../../esl-base-element/core/esl-base-element.shape';
-import type {ESLSharePopupTrigger} from './esl-share-popup-trigger';
-
-/**
- * Tag declaration interface of ESLSharePopupTrigger element
- * Used for TSX declaration
- */
-export interface ESLSharePopupTriggerTagShape extends ESLBaseElementShape<ESLSharePopupTrigger> {
-  /** Define the list of social networks or groups of them to display at popup (all by default) */
-  list?: string;
-  /** Define URL to share (current page URL by default) */
-  'share-url'?: string;
-  /** Define title to share (current document title by default) */
-  'share-title'?: string;
-
-  /** Allowed children */
-  children?: any;
-}
-
-declare global {
-  namespace JSX {
-    export interface IntrinsicElements {
-      /** {@link ESLSharePopupTrigger} custom tag */
-      'esl-share-popup-trigger': ESLSharePopupTriggerTagShape;
-    }
-  }
-}
diff --git a/src/modules/esl-share/core/esl-share-popup-trigger.ts b/src/modules/esl-share/core/esl-share-popup-trigger.ts
deleted file mode 100644
index c778ed418..000000000
--- a/src/modules/esl-share/core/esl-share-popup-trigger.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import {attr, jsonAttr} from '../../esl-utils/decorators';
-import {ESLTraversingQuery} from '../../esl-traversing-query/core';
-import {ESLTrigger} from '../../esl-trigger/core';
-import {ESLSharePopup} from './esl-share-popup';
-
-import type {ESLToggleable} from '../../esl-toggleable/core/esl-toggleable';
-import type {ESLSharePopupActionParams} from './esl-share-popup';
-
-/**
- * ESLSharePopupTrigger component
- * @author Dmytro Shovchko
- *
- * ESLSharePopupTrigger is a component that allows triggering {@link ESLSharePopup} instance state changes.
- */
-export class ESLSharePopupTrigger extends ESLTrigger {
-  public static override is = 'esl-share-popup-trigger';
-
-  /** Register {@link ESLSharePopupTrigger} component and dependent {@link ESLSharePopup} */
-  public static override register(): void {
-    ESLSharePopup.register();
-    super.register();
-  }
-
-  /**
-   * List of social networks or groups of them to display (all by default).
-   * The value - a string containing the names of the buttons or groups (specified with
-   * the prefix `group:`) separated by spaces.
-   * @example "facebook reddit group:default"
-   * */
-  @attr({defaultValue: 'all'}) public list: string;
-
-  /** Hover event tracking media query. Default: `all` */
-  @attr({defaultValue: 'all'}) public override trackHover: string;
-
-  /** Default initial params to pass into the popup */
-  @jsonAttr<ESLSharePopupActionParams>({defaultValue: {
-    position: 'top',
-    hideDelay: 300
-  }})
-  public popupInitialParams: ESLSharePopupActionParams;
-
-  /** Target observable Toggleable */
-  public override get $target(): ESLToggleable | null {
-    return ESLSharePopup.sharedInstance;
-  }
-  public override set $target(value: any) {}
-
-  /** Checks that the target is in active state */
-  public override get isTargetActive(): boolean {
-    return !!this.$target?.open && this.$target?.activator === this;
-  }
-
-  /** The text writing directionality of the element */
-  protected get currentDir(): string {
-    return getComputedStyle(this).direction;
-  }
-
-  /** The base language of the element */
-  protected get currentLang(): string {
-    const el = this.closest('[lang]');
-    return (el) ? (el as HTMLElement).lang : '';
-  }
-
-  /** Container element that defines bounds of popups visibility */
-  protected get $containerEl(): HTMLElement | undefined {
-    const container = this.getClosestRelatedAttr('container');
-    return container ? ESLTraversingQuery.first(container, this) as HTMLElement : undefined;
-  }
-
-  /** Update `$target` Toggleable  from `target` selector */
-  public override updateTargetFromSelector(): void {}
-
-  /** Gets attribute value from the closest element with group behavior settings */
-  protected getClosestRelatedAttr(attrName: string): string | null {
-    const relatedAttrName = `${this.baseTagName}-${attrName}`;
-    const $closest = this.closest(`[${relatedAttrName}]`);
-    return $closest ? $closest.getAttribute(relatedAttrName) : null;
-  }
-
-  /** Merges params to pass to the toggleable */
-  protected override mergeToggleableParams(this: ESLSharePopupTrigger, ...params: ESLSharePopupActionParams[]): ESLSharePopupActionParams {
-    return Object.assign({
-      initiator: 'share',
-      activator: this,
-      containerEl: this.$containerEl,
-      list: this.list,
-      dir: this.currentDir,
-      lang: this.currentLang
-    }, this.popupInitialParams, ...params);
-  }
-}
-
-declare global {
-  export interface ESLLibrary {
-    SharePopupTrigger: typeof ESLSharePopupTrigger;
-  }
-  export interface HTMLElementTagNameMap {
-    'esl-share-popup-trigger': ESLSharePopupTrigger;
-  }
-}
diff --git a/src/modules/esl-share/core/esl-share-popup.ts b/src/modules/esl-share/core/esl-share-popup.ts
index 0e273a9da..c652f1725 100644
--- a/src/modules/esl-share/core/esl-share-popup.ts
+++ b/src/modules/esl-share/core/esl-share-popup.ts
@@ -1,3 +1,4 @@
+import {ExportNs} from '../../esl-utils/environment/export-ns';
 import {ESLTooltip} from '../../esl-tooltip/core/esl-tooltip';
 import {bind, listen, memoize} from '../../esl-utils/decorators';
 import {ESLShareButton} from './esl-share-button';
@@ -7,6 +8,8 @@ import type {ESLToggleableActionParams} from '../../esl-toggleable/core';
 import type {TooltipActionParams} from '../../esl-tooltip/core/esl-tooltip';
 import type {ESLShareButtonConfig} from './esl-share-config';
 
+export type {ESLSharePopupTagShape} from './esl-share-popup.shape';
+
 function stringifyButtonsList(btns: ESLShareButtonConfig[]): string {
   return btns.map((btn) => btn.name).join(',');
 }
@@ -25,9 +28,17 @@ export interface ESLSharePopupActionParams extends TooltipActionParams {
  * - renders a list of {@link ESLShareButton}s based on the {@link ESLShareConfig} config from host {@link ESLShare} component
  * - forwards the sharing attributes from the host share {@link ESLShare} component
  */
+@ExportNs('SharePopup')
 export class ESLSharePopup extends ESLTooltip {
   static override is = 'esl-share-popup';
 
+  /** Default params to pass into the share popup */
+  static override DEFAULT_PARAMS: ESLSharePopupActionParams = {
+    ...ESLTooltip.DEFAULT_PARAMS,
+    position: 'top',
+    hideDelay: 300
+  };
+
   /** List of attributes to forward from the activator to the {@link ESLSharePopup} */
   public static forwardedAttrs = ['share-title', 'share-url'];
 
diff --git a/src/modules/esl-share/core/esl-share.less b/src/modules/esl-share/core/esl-share.less
new file mode 100644
index 000000000..953f78bbe
--- /dev/null
+++ b/src/modules/esl-share/core/esl-share.less
@@ -0,0 +1,8 @@
+esl-share {
+  display: inline-block;
+  cursor: pointer;
+}
+
+esl-share-popup {
+  max-width: var(--esl-share-popup-max-width, 202px);
+}
diff --git a/src/modules/esl-share/core/esl-share.shape.ts b/src/modules/esl-share/core/esl-share.shape.ts
index dca07882a..074225489 100644
--- a/src/modules/esl-share/core/esl-share.shape.ts
+++ b/src/modules/esl-share/core/esl-share.shape.ts
@@ -1,22 +1,22 @@
-import type {ESLBaseElementShape} from '../../esl-base-element/core/esl-base-element.shape';
+import type {ESLTriggerTagShape} from '../../esl-trigger/core/esl-trigger.shape';
 import type {ESLShare} from './esl-share';
 
 /**
- * Tag declaration interface of ESL Share element
+* Tag declaration interface of {@link ESLShare} element
  * Used for TSX declaration
  */
-export interface ESLShareTagShape extends ESLBaseElementShape<ESLShare> {
-  /** Define the list of social networks or groups of them to display (all by default) */
+export interface ESLShareTagShape<T extends ESLShare = ESLShare> extends ESLTriggerTagShape<T> {
+  /** Define the list of social networks or groups of them to display at popup (all by default) */
   list?: string;
-  /** Define share buttons rendering mode */
-  mode?: string;
   /** Define URL to share (current page URL by default) */
   'share-url'?: string;
   /** Define title to share (current document title by default) */
   'share-title'?: string;
+  /** Define JSON of params to pass into the popup */
+  'popup-params'?: string;
 
-  /** Children are not allowed for ESLShare */
-  children?: never[];
+  /** Allowed children */
+  children?: any;
 }
 
 declare global {
diff --git a/src/modules/esl-share/core/esl-share.ts b/src/modules/esl-share/core/esl-share.ts
index e41339094..c23cf45e0 100644
--- a/src/modules/esl-share/core/esl-share.ts
+++ b/src/modules/esl-share/core/esl-share.ts
@@ -1,100 +1,132 @@
-import {ESLBaseElement} from '../../esl-base-element/core';
-import {attr, bind, boolAttr, jsonAttr} from '../../esl-utils/decorators';
-import {ESLShareList} from './esl-share-list';
-import {ESLSharePopupTrigger} from './esl-share-popup-trigger';
-import {ESLShareConfig} from './esl-share-config';
+import {ExportNs} from '../../esl-utils/environment/export-ns';
+import {attr, boolAttr, jsonAttr, prop, ready} from '../../esl-utils/decorators';
+import {ESLTraversingQuery} from '../../esl-traversing-query/core';
+import {ESLTrigger} from '../../esl-trigger/core';
+import {ESLSharePopup} from './esl-share-popup';
 
+import type {ESLToggleable} from '../../esl-toggleable/core/esl-toggleable';
 import type {ESLSharePopupActionParams} from './esl-share-popup';
 
+export type {ESLShareTagShape} from './esl-share.shape';
+
 /**
  * ESLShare
  * @author Dmytro Shovchko
  *
- * ESLShare is a custom element to dynamically draw {@link ESLShareList}
- * or {@link ESLSharePopupTrigger} depending on the specified mode
+ * ESLShare is a component that allows triggering {@link ESLSharePopup} instance state changes.
  */
-export class ESLShare extends ESLBaseElement {
+@ExportNs('Share')
+export class ESLShare extends ESLTrigger {
   public static override is = 'esl-share';
+  public static override observedAttributes = ['list'];
 
-  /** Register {@link ESLShare} component and dependent {@link ESLShareList} and {@link ESLSharePopupTrigger} */
+  /** Register {@link ESLShare} component and dependent {@link ESLSharePopup} */
   public static override register(): void {
-    ESLShareList.register();
-    ESLSharePopupTrigger.register();
+    ESLSharePopup.register();
     super.register();
   }
 
-  /**
-   * Updates the configuration with either a new config object or by using a configuration provider function.
-   * Every button and group specified in the new config will be added to the current configuration.
-   * @returns Promise of the current config
-   * @deprecated alias for ESLShareConfig.set(), will be removed soon
-   */
-  public static readonly config = ESLShareConfig.set;
-
-  /** Default initial params to pass into the popup trigger */
-  @jsonAttr<ESLSharePopupActionParams>({defaultValue: {
-    trackClick: true,
-    trackHover: true
-  }}) public triggerInitialParams: ESLSharePopupTrigger;
+  /** Event to dispatch on {@link ESLShare} ready state */
+  @prop('esl:share:ready') public SHARE_READY_EVENT: string;
 
   /**
    * List of social networks or groups of them to display (all by default).
    * The value - a string containing the names of the buttons or groups (specified with
-   * the prefix group:) separated by spaces.
+   * the prefix `group:`) separated by spaces.
    * @example "facebook reddit group:default"
    * */
   @attr({defaultValue: 'all'}) public list: string;
-  /** URL to share (current page URL by default) */
-  @attr() public shareUrl: string;
-  /** Title to share (current document title by default) */
-  @attr() public shareTitle: string;
-  /** Rendering mode of the share buttons ('list' by default) */
-  @attr() public mode: 'list' | 'popup';
+
+  /** Hover event tracking media query. Default: `all` */
+  @attr({defaultValue: 'all'}) public override trackHover: string;
+
+  /** Action params to pass into the popup */
+  @jsonAttr<ESLSharePopupActionParams>({defaultValue: {}})
+  public popupParams: ESLSharePopupActionParams;
 
   /** @readonly Ready state marker */
   @boolAttr({readonly: true}) public ready: boolean;
 
-  protected _content: string;
+  /** Target observable Toggleable */
+  public override get $target(): ESLToggleable | null {
+    return ESLSharePopup.sharedInstance;
+  }
+  public override set $target(value: any) {}
+
+  /** Checks that the target is in active state */
+  public override get isTargetActive(): boolean {
+    return !!this.$target?.open && this.$target?.activator === this;
+  }
 
-  public override connectedCallback(): void {
-    super.connectedCallback();
-    this.init();
+  /** The text writing directionality of the element */
+  protected get currentDir(): string {
+    return getComputedStyle(this).direction;
+  }
+
+  /** The base language of the element */
+  protected get currentLang(): string {
+    const el = this.closest('[lang]');
+    return (el) ? (el as HTMLElement).lang : '';
   }
 
-  /** Initializes the component */
-  protected init(force?: boolean): void {
-    if (this.ready && !force) return;
-    if (!this.mode) this.mode = 'list';
-    if (!this._content) this._content = this.innerHTML;
-    this.buildContent();
+  /** Container element that defines bounds of popups visibility */
+  protected get $containerEl(): HTMLElement | undefined {
+    const container = this.getClosestRelatedAttr('container');
+    return container ? ESLTraversingQuery.first(container, this) as HTMLElement : undefined;
+  }
+
+  @ready
+  protected override connectedCallback(): void {
+    super.connectedCallback();
     this.onReady();
   }
 
-  /** Builds component's content. */
-  @bind
-  protected buildContent(): void {
-    this.innerHTML = '';
-    this.mode === 'list' ? this.appendList() : this.appendPopupTrigger();
+  protected override attributeChangedCallback(attrName: string, oldValue: string | null, newValue: string | null): void {
+    if (!this.connected || oldValue === newValue) return;
+    this.update();
+  }
+
+  /** Updates the component and related popup */
+  protected update(): void {
+    if (!this.isTargetActive) return;
+    this.$target?.hide();
   }
 
-  /** Appends share list to the share component. */
-  protected appendList(): void {
-    const $list = ESLShareList.create();
-    $list.list = this.list;
-    this.appendChild($list);
+  /** Updates `$target` Toggleable  from `target` selector */
+  public override updateTargetFromSelector(): void {}
+
+  /** Gets attribute value from the closest element with group behavior settings */
+  protected getClosestRelatedAttr(attrName: string): string | null {
+    const relatedAttrName = `${this.baseTagName}-${attrName}`;
+    const $closest = this.closest(`[${relatedAttrName}]`);
+    return $closest ? $closest.getAttribute(relatedAttrName) : null;
   }
 
-  /** Appends share popup trigger to the share component. */
-  protected appendPopupTrigger(): void {
-    const {list} = this;
-    const $trigger = ESLSharePopupTrigger.create();
-    Object.assign($trigger, this.triggerInitialParams, {list});
-    $trigger.innerHTML = this._content;
-    this.appendChild($trigger);
+  /** Merges params to pass to the toggleable */
+  protected override mergeToggleableParams(this: ESLShare, ...params: ESLSharePopupActionParams[]): ESLSharePopupActionParams {
+    return Object.assign({
+      initiator: 'share',
+      activator: this,
+      containerEl: this.$containerEl,
+      list: this.list,
+      dir: this.currentDir,
+      lang: this.currentLang
+    }, this.popupParams, ...params);
   }
 
-  /** Actions on complete init and ready component. */
+  /** Actions on complete init and ready component */
   private onReady(): void {
+    if (this.ready) return;
     this.$$attr('ready', true);
+    this.$$fire(this.SHARE_READY_EVENT, {bubbles: false});
+  }
+}
+
+declare global {
+  export interface ESLLibrary {
+    Share: typeof ESLShare;
+  }
+  export interface HTMLElementTagNameMap {
+    'esl-share': ESLShare;
   }
 }
diff --git a/src/modules/esl-toggleable/core/esl-toggleable.ts b/src/modules/esl-toggleable/core/esl-toggleable.ts
index 2cdb5fb7f..44be3628e 100644
--- a/src/modules/esl-toggleable/core/esl-toggleable.ts
+++ b/src/modules/esl-toggleable/core/esl-toggleable.ts
@@ -58,6 +58,9 @@ export class ESLToggleable extends ESLBaseElement {
   public static override is = 'esl-toggleable';
   public static observedAttributes = ['open', 'group'];
 
+  /** Default show/hide params for all ESLToggleable instances */
+  public static DEFAULT_PARAMS: ESLToggleableActionParams = {};
+
   /** Event to dispatch when toggleable is going to be activated */
   @prop('esl:before:show') public BEFORE_SHOW_EVENT: string;
   /** Event to dispatch when toggleable is going to be deactivated */
@@ -189,7 +192,8 @@ export class ESLToggleable extends ESLBaseElement {
 
   /** Function to merge the result action params */
   protected mergeDefaultParams(params?: ESLToggleableActionParams): ESLToggleableActionParams {
-    return Object.assign({}, this.defaultParams, copyDefinedKeys(params));
+    const type = this.constructor as typeof ESLToggleable;
+    return Object.assign({}, type.DEFAULT_PARAMS, this.defaultParams, copyDefinedKeys(params));
   }
 
   /** Toggle the element state */
diff --git a/src/modules/esl-trigger/core/esl-trigger.ts b/src/modules/esl-trigger/core/esl-trigger.ts
index 4008b0c70..d9cb4b96d 100644
--- a/src/modules/esl-trigger/core/esl-trigger.ts
+++ b/src/modules/esl-trigger/core/esl-trigger.ts
@@ -74,7 +74,7 @@ export class ESLTrigger extends ESLBaseElement {
 
   protected _$target: ESLToggleable | null;
 
-  protected override attributeChangedCallback(attrName: string): void {
+  protected override attributeChangedCallback(attrName: string, oldValue: string | null, newValue: string | null): void {
     if (!this.connected) return;
     if (attrName === 'target') return this.updateTargetFromSelector();
   }


Test patch:
diff --git a/src/modules/esl-share/test/actions/copy-action.test.ts b/src/modules/esl-share/test/actions/copy-action.test.ts
new file mode 100644
index 000000000..32375c2ce
--- /dev/null
+++ b/src/modules/esl-share/test/actions/copy-action.test.ts
@@ -0,0 +1,57 @@
+import '../../actions/copy-action';
+import {ESLShareActionRegistry} from '../../core/esl-share-action-registry';
+import {ESLShareButton} from '../../core/esl-share-button';
+
+describe('ESLShare: "copy" action import', () => {
+  test('"copy" action was registered when importing', () => {
+    expect(ESLShareActionRegistry.instance.has('copy')).toBe(true);
+  });
+});
+
+describe('ESLShare: "copy" action public API', () => {
+  const originalClipboard = {...navigator.clipboard};
+  const mockClipboard = {
+    writeText: jest.fn(),
+  };
+  const copyAction = ESLShareActionRegistry.instance.get('copy');
+  const $button = ESLShareButton.create();
+
+  beforeAll(() => {
+    (navigator as any).clipboard = mockClipboard;
+
+    ESLShareButton.register();
+    document.body.appendChild($button);
+    $button.setAttribute('share-title', 'Test button title');
+    $button.setAttribute('share-url', '/test/button/url');
+    $button.setAttribute('additional', '{"alertText": "Copied to clipboard"}');
+  });
+
+  afterEach(() => {
+    jest.clearAllMocks();
+  });
+
+  afterAll(() => {
+    document.body.innerHTML = '';
+    (navigator as any).clipboard = originalClipboard;
+  });
+
+  test('isAvailable should be true', () => {
+    expect(copyAction?.isAvailable).toBe(true);
+  });
+
+  test('should call navigation.clipboard.writeText() when share() calls', () => {
+    copyAction?.share($button);
+    expect(mockClipboard.writeText).toBeCalledWith('http://localhost/test/button/url');
+  });
+
+  test('should dispatch esl:alert:show with shareAdditional.alertText when share() calls', (done) => {
+    document.body.addEventListener('esl:alert:show', (e) => {
+      expect((e as CustomEvent).detail).toEqual({
+        cls: 'esl-share-alert',
+        html: '<span>Copied to clipboard</span>',
+      });
+      done();
+    }, {once: true});
+    copyAction?.share($button);
+  }, 10);
+});
diff --git a/src/modules/esl-share/test/actions/external-action.test.ts b/src/modules/esl-share/test/actions/external-action.test.ts
new file mode 100644
index 000000000..ea7dc71a7
--- /dev/null
+++ b/src/modules/esl-share/test/actions/external-action.test.ts
@@ -0,0 +1,55 @@
+import '../../actions/external-action';
+import {ESLShareActionRegistry} from '../../core/esl-share-action-registry';
+import {ESLShareButton} from '../../core/esl-share-button';
+
+describe('ESLShare: "external" action import', () => {
+  test('"external" action was registered when importing', () => {
+    expect(ESLShareActionRegistry.instance.has('external')).toBe(true);
+  });
+});
+
+describe('ESLShare: "external" action public API', () => {
+  const originalCreateElement = document.createElement;
+  const mockAnchorElement = {click: jest.fn()};
+  Object.defineProperty(mockAnchorElement, 'href', {
+    get: () => (this as any)._href,
+    set: (value) => (this as any)._href = value,
+  });
+  const mockCreateElement = (tag: string) => {
+    if (tag === 'a') return mockAnchorElement as any;
+    return originalCreateElement(tag);
+  };
+
+  const externalAction = ESLShareActionRegistry.instance.get('external');
+  const $button = ESLShareButton.create();
+
+  beforeAll(() => {
+    ESLShareButton.register();
+    document.body.appendChild($button);
+    $button.setAttribute('link', '//host/sharer?title={title}&url={url}&t={t}&u={u}');
+    $button.setAttribute('share-title', 'Test btn title');
+    $button.setAttribute('share-url', '/test/url');
+  });
+
+  afterEach(() => {
+    jest.clearAllMocks();
+  });
+
+  afterAll(() => {
+    document.body.innerHTML = '';
+    jest.restoreAllMocks();
+  });
+
+  test('should be available', () => {
+    expect(externalAction?.isAvailable).toBe(true);
+  });
+
+  test('should simulate click() on anchor to make external jump (also checks link placeholders replacement)', () => {
+    jest.spyOn(document, 'createElement').mockImplementation(mockCreateElement);
+    externalAction?.share($button);
+    expect(mockAnchorElement.click).toBeCalledTimes(1);
+    expect((mockAnchorElement as any).href).toBe(
+      '//host/sharer?title=Test%20btn%20title&url=http%3A%2F%2Flocalhost%2Ftest%2Furl&t=Test%20btn%20title&u=http%3A%2F%2Flocalhost%2Ftest%2Furl'
+    );
+  });
+});
diff --git a/src/modules/esl-share/test/actions/media-action.test.ts b/src/modules/esl-share/test/actions/media-action.test.ts
new file mode 100644
index 000000000..a808c6d47
--- /dev/null
+++ b/src/modules/esl-share/test/actions/media-action.test.ts
@@ -0,0 +1,45 @@
+import '../../actions/media-action';
+import {ESLShareActionRegistry} from '../../core/esl-share-action-registry';
+import {ESLShareButton} from '../../core/esl-share-button';
+
+describe('ESLShare: "media" action import', () => {
+  test('"media" action was registered when importing', () => {
+    expect(ESLShareActionRegistry.instance.has('media')).toBe(true);
+  });
+});
+
+describe('ESLShare: "media" action public API', () => {
+  const mediaAction = ESLShareActionRegistry.instance.get('media');
+  const $button = ESLShareButton.create();
+
+  beforeAll(() => {
+    ESLShareButton.register();
+    document.body.appendChild($button);
+    $button.setAttribute('link', '//host/sharer?title={title}&url={url}&t={t}&u={u}');
+    $button.setAttribute('share-title', 'Test btn title');
+    $button.setAttribute('share-url', '/test/url');
+  });
+
+  afterEach(() => {
+    jest.clearAllMocks();
+  });
+
+  afterAll(() => {
+    document.body.innerHTML = '';
+    jest.restoreAllMocks();
+  });
+
+  test('should be available', () => {
+    expect(mediaAction?.isAvailable).toBe(true);
+  });
+
+  test('should call window.open() with buttons link when share() (also checks link placeholders replacement)', () => {
+    const mockOpen = jest.spyOn(window, 'open').mockImplementation(() => null);
+    mediaAction?.share($button);
+    expect(mockOpen).toBeCalledWith(
+      '//host/sharer?title=Test%20btn%20title&url=http%3A%2F%2Flocalhost%2Ftest%2Furl&t=Test%20btn%20title&u=http%3A%2F%2Flocalhost%2Ftest%2Furl',
+      '_blank',
+      'scrollbars=0,resizable=1,menubar=0,left=100,top=100,width=750,height=500,toolbar=0,status=0'
+    );
+  });
+});
diff --git a/src/modules/esl-share/test/actions/native-action.test.ts b/src/modules/esl-share/test/actions/native-action.test.ts
new file mode 100644
index 000000000..aa932c4a7
--- /dev/null
+++ b/src/modules/esl-share/test/actions/native-action.test.ts
@@ -0,0 +1,46 @@
+import '../../actions/native-action';
+import {ESLShareActionRegistry} from '../../core/esl-share-action-registry';
+import {ESLShareButton} from '../../core/esl-share-button';
+
+describe('ESLShare: "native" action import', () => {
+  test('"native" action was registered when importing', () => {
+    expect(ESLShareActionRegistry.instance.has('native')).toBe(true);
+  });
+});
+
+describe('ESLShare: "native" action public API', () => {
+  const originalShare = navigator.share;
+  const mockShare = jest.fn();
+  const nativeAction = ESLShareActionRegistry.instance.get('native');
+  const $button = ESLShareButton.create();
+
+  beforeAll(() => {
+    (navigator as any).share = mockShare;
+
+    ESLShareButton.register();
+    document.body.appendChild($button);
+    $button.setAttribute('share-title', 'Test button title');
+    $button.setAttribute('share-url', '/test/button/url');
+  });
+
+  afterEach(() => {
+    jest.clearAllMocks();
+  });
+
+  afterAll(() => {
+    document.body.innerHTML = '';
+    (navigator as any).share = originalShare;
+  });
+
+  test('should be available', () => {
+    expect(nativeAction?.isAvailable).toBe(true);
+  });
+
+  test('should call navigator.share() when share() calls', () => {
+    nativeAction?.share($button);
+    expect(mockShare).toBeCalledWith({
+      title: 'Test button title',
+      url: 'http://localhost/test/button/url',
+    });
+  });
+});
diff --git a/src/modules/esl-share/test/actions/print-action.test.ts b/src/modules/esl-share/test/actions/print-action.test.ts
new file mode 100644
index 000000000..95df6e8e7
--- /dev/null
+++ b/src/modules/esl-share/test/actions/print-action.test.ts
@@ -0,0 +1,36 @@
+import '../../actions/print-action';
+import {ESLShareActionRegistry} from '../../core/esl-share-action-registry';
+import {ESLShareButton} from '../../core/esl-share-button';
+
+describe('ESLShare: "print" action import', () => {
+  test('"print" action was registered when importing', () => {
+    expect(ESLShareActionRegistry.instance.has('print')).toBe(true);
+  });
+});
+
+describe('ESLShare: "print" action public API', () => {
+  const printAction = ESLShareActionRegistry.instance.get('print');
+  const $button = ESLShareButton.create();
+
+  beforeAll(() => {
+    ESLShareButton.register();
+    document.body.appendChild($button);
+    $button.setAttribute('share-title', 'Test button title');
+    $button.setAttribute('share-url', '/test/button/url');
+  });
+
+  afterEach(() => {
+    jest.clearAllMocks();
+  });
+
+  afterAll(() => {
+    document.body.innerHTML = '';
+    jest.restoreAllMocks();
+  });
+
+  test('should call window.print() when share() calls', () => {
+    const mockPrint = jest.spyOn(window, 'print').mockImplementation(() => null);
+    printAction?.share($button);
+    expect(mockPrint).toBeCalled();
+  });
+});
diff --git a/src/modules/esl-share/test/esl-share-action-registry.test.ts b/src/modules/esl-share/test/esl-share-action-registry.test.ts
new file mode 100644
index 000000000..a20e7f9c6
--- /dev/null
+++ b/src/modules/esl-share/test/esl-share-action-registry.test.ts
@@ -0,0 +1,57 @@
+import {ESLShareActionRegistry} from '../core/esl-share-action-registry';
+import {ESLSharePrintAction} from '../actions/print-action';
+
+describe('ESLShareActionRegistry tests', () => {
+  describe('ESLShareActionRegistry static API', () => {
+    test('ESLShareActionRegistry instance is a singleton', () => {
+      expect(ESLShareActionRegistry.instance).toBeInstanceOf(ESLShareActionRegistry);
+      expect(ESLShareActionRegistry.instance).toBe(ESLShareActionRegistry.instance);
+    });
+  });
+
+  describe('ESLShareActionRegistry public API', () => {
+
+    afterEach(() => {
+      ESLShareActionRegistry.instance['actionsMap'].clear();
+    });
+
+    test('should throw an error when register invalid action', () => {
+      const {instance} = ESLShareActionRegistry;
+      expect(() => instance.register({} as any)).toThrowError();
+    });
+
+    test('should register action', () => {
+      const {instance} = ESLShareActionRegistry;
+      instance.register(ESLSharePrintAction);
+      expect(instance['actionsMap'].has('print')).toBe(true);
+    });
+
+    test('should register the same action just once', () => {
+      const {instance} = ESLShareActionRegistry;
+      instance.register(ESLSharePrintAction);
+      instance.register(ESLSharePrintAction);
+      instance.register(ESLSharePrintAction);
+      expect(instance['actionsMap'].size).toBe(1);
+    });
+
+    test('should check action availability', () => {
+      const {instance} = ESLShareActionRegistry;
+      instance.register(ESLSharePrintAction);
+      expect(instance.has('copy')).toBe(false);
+      expect(instance.has('print')).toBe(true);
+    });
+
+    test('should get action by name', () => {
+      const {instance} = ESLShareActionRegistry;
+      instance.register(ESLSharePrintAction);
+      const action = instance.get('print');
+      expect(action).toBeInstanceOf(ESLSharePrintAction);
+    });
+
+    test('should return null when trying to get an unregistered action', () => {
+      const {instance} = ESLShareActionRegistry;
+      const action = instance.get('print');
+      expect(action).toBe(null);
+    });
+  });
+});
diff --git a/src/modules/esl-share/test/esl-share-button.test.ts b/src/modules/esl-share/test/esl-share-button.test.ts
new file mode 100644
index 000000000..d9fc3210b
--- /dev/null
+++ b/src/modules/esl-share/test/esl-share-button.test.ts
@@ -0,0 +1,130 @@
+import {ESLShareButton} from '../core/esl-share-button';
+import {ESLShareCopyAction} from '../actions/copy-action';
+import '../buttons/copy';
+import {facebook} from '../buttons/facebook';
+
+// @ts-ignore
+import nestedElementsTemplate from './nested-elements-template.html';
+
+describe('ESLShareButton tests', () => {
+  beforeAll(() => ESLShareButton.register());
+
+  describe('Static class methods', () => {
+    test('has create() method that returns new instanse', () => {
+      const $button = ESLShareButton.create();
+      expect($button).toBeInstanceOf(ESLShareButton);
+    });
+
+    test('created instance has a name attribute in case when invoked with a passed button name', () => {
+      const $button = ESLShareButton.create('copy');
+      expect($button.getAttribute('name')).toBe('copy');
+    });
+
+    test('created instance has a default-icon attribute in case when invoked with a passed button name', () => {
+      const $button = ESLShareButton.create('copy');
+      expect($button.getAttribute('default-icon')).toBe('');
+    });
+  });
+
+  describe('Instance public API', () => {
+    const $copyButton = ESLShareButton.create('copy');
+    const $facebookButton = ESLShareButton.create('facebook');
+    $facebookButton.setAttribute('share-title', 'Test button title');
+    $facebookButton.setAttribute('share-url', '/test/button/url');
+
+    beforeAll(() => {
+      document.body.appendChild($copyButton);
+      document.body.appendChild($facebookButton);
+    });
+
+    afterAll(() => {
+      document.body.innerHTML = '';
+    });
+
+    test('config getter available', () => {
+      expect($facebookButton.config).toEqual(facebook);
+    });
+
+    test('shareAction getter available', () => {
+      expect($copyButton.shareAction).toBe('copy');
+    });
+
+    test('shareAdditional getter available', () => {
+      expect($copyButton.shareAdditional).toEqual({alertText: 'Copied to clipboard'});
+    });
+    test('shareLink getter available', () => {
+      expect($facebookButton.shareLink).toBe('//www.facebook.com/sharer.php?u={u}');
+    });
+
+    test('titleToShare getter available', () => {
+      expect($facebookButton.titleToShare).toBe('Test button title');
+    });
+
+    test('urlToShare getter available and always returns absolute URL', () => {
+      expect($facebookButton.urlToShare).toBe('http://localhost/test/button/url');
+    });
+
+    test('share() method calls share() method of button share action instance', () => {
+      const shareSpy = jest.spyOn(ESLShareCopyAction.prototype, 'share');
+      $copyButton.share();
+      expect(shareSpy).toHaveBeenCalled();
+      shareSpy.mockRestore();
+    });
+  });
+
+  describe('share-title and share-url attributes cascading', () => {
+    const $lvl: HTMLElement[] = [];
+    let $button: ESLShareButton;
+
+    const setShareAttributes = ($element: HTMLElement, title?: string, url?: string): void => {
+      title && $element.setAttribute('share-title', title);
+      url && $element.setAttribute('share-url', url);
+    };
+
+    const clearShareAttributes = ($element: HTMLElement): void => {
+      ['share-title', 'share-url'].forEach((attr) => $element.removeAttribute(attr));
+    };
+
+    beforeAll(() => {
+      document.title = 'Fallback title';
+      document.body.innerHTML = nestedElementsTemplate;
+      $lvl.push(document.body);
+      ['.lvl-1', '.lvl-2', '.lvl-3'].forEach((selector) => $lvl.push(document.querySelector(selector) as HTMLElement));
+      $button = ESLShareButton.create('copy');
+      $lvl[3].appendChild($button);
+    });
+
+    afterEach(() => {
+      $lvl.forEach(clearShareAttributes);
+      clearShareAttributes($button);
+    });
+
+    afterAll(() => {
+      document.body.innerHTML = '';
+    });
+
+    test('should use document.title as fallback value when not defined on button and its parents', () => {
+      expect($button.titleToShare).toBe(document.title);
+    });
+
+    test('should use window.location.href as fallback value when not defined on button and its parents', () => {
+      expect($button.urlToShare).toBe(window.location.href);
+    });
+
+    test('should use values from button when they are defined and ignore values from its parents', () => {
+      setShareAttributes($lvl[1], 'Title from level-1', 'https://host.com/level-1');
+      setShareAttributes($lvl[3], 'Title from level-3', 'https://host.com/level-3');
+      setShareAttributes($button, 'Title from button', 'https://host.com/button');
+      expect($button.titleToShare).toBe('Title from button');
+      expect($button.urlToShare).toBe('https://host.com/button');
+    });
+
+    test('should use values from closest parent when not defined on button but defined on its parents', () => {
+      setShareAttributes($lvl[1], 'Title from level-1', 'https://host.com/level-1');
+      setShareAttributes($lvl[2], 'Title from level-2', undefined);
+      setShareAttributes($lvl[3], undefined, 'https://host.com/level-3');
+      expect($button.titleToShare).toBe('Title from level-2');
+      expect($button.urlToShare).toBe('https://host.com/level-3');
+    });
+  });
+});
diff --git a/src/modules/esl-share/test/esl-share-config.test.ts b/src/modules/esl-share/test/esl-share-config.test.ts
index 78f0be572..92ee879ec 100644
--- a/src/modules/esl-share/test/esl-share-config.test.ts
+++ b/src/modules/esl-share/test/esl-share-config.test.ts
@@ -72,7 +72,7 @@ describe('ESLShareConfig tests', () => {
       ['sn1', ['sn1']],
       ['sn2', ['sn2']],
       ['', []]
-    ])('ESLShareConfig.prototype.get resolves %s query to list %s', (name: string, list: string[]) => {
+    ])('ESLShareConfig.prototype.get resolves "%s" query to list %s', (name: string, list: string[]) => {
       const result = instance.get(name);
       expect(result.map((cfg) => cfg.name)).toEqual(list);
       for (const res of result) {
@@ -84,6 +84,23 @@ describe('ESLShareConfig tests', () => {
         }));
       }
     });
+
+    describe('ESLShareConfig selects buttons from the list with deduplication behavior', () => {
+      test.each([
+        ['sn1 sn1 sn1', ['sn1']],
+        ['sn2 sn1 sn2', ['sn2', 'sn1']],
+        ['sn1 sn2 sn2 sn1', ['sn1', 'sn2']],
+        ['group:group1 group:group1', ['sn1', 'sn2']],
+        ['group:group2 group:group1 group:group2', ['sn2', 'sn3', 'sn4', 'sn1']],
+        ['sn4 group:group2 sn4 group:group2', ['sn4', 'sn2', 'sn3']],
+        ['all all all', ['sn1', 'sn2', 'sn3', 'sn4', 'sn-5']],
+        ['sn3 all', ['sn3', 'sn1', 'sn2', 'sn4', 'sn-5']],
+        ['group:group2 sn-5 all', ['sn2', 'sn3', 'sn4', 'sn-5', 'sn1']]
+      ])('ESLShareConfig.prototype.get resolves "%s" query to list %s', (name: string, list: string[]) => {
+        const result = instance.get(name);
+        expect(result.map((cfg) => cfg.name)).toEqual(list);
+      });
+    });
   });
 
   describe('ESLShareConfig resolve recursive config', () => {
diff --git a/src/modules/esl-share/test/esl-share-popup.test.ts b/src/modules/esl-share/test/esl-share-popup.test.ts
new file mode 100644
index 000000000..915bb6236
--- /dev/null
+++ b/src/modules/esl-share/test/esl-share-popup.test.ts
@@ -0,0 +1,84 @@
+import {IntersectionObserverMock} from '../../esl-utils/test/intersectionObserver.mock';
+import {ESLSharePopup} from '../core/esl-share-popup';
+import {ESLShare} from '../core/esl-share';
+import '../buttons/copy';
+
+// @ts-ignore
+import nestedElementsTemplate from './nested-elements-template.html';
+
+import type {ESLShareButton} from '../core/esl-share-button';
+
+
+describe('ESLSharePopup tests', () => {
+  beforeAll(() => {
+    IntersectionObserverMock.mock();
+    ESLShare.register();
+  });
+
+  describe('share-title and share-url attributes forwarding from activator', () => {
+    const $lvl: HTMLElement[] = [];
+    let $trigger: ESLShare;
+
+    const setShareAttributes = ($element: HTMLElement, title?: string, url?: string): void => {
+      title && $element.setAttribute('share-title', title);
+      url && $element.setAttribute('share-url', url);
+    };
+
+    const clearShareAttributes = ($element: HTMLElement): void => {
+      ['share-title', 'share-url'].forEach((attr) => $element.removeAttribute(attr));
+    };
+
+    const showPopupAndGetButton = (): ESLShareButton => {
+      $trigger.showTarget();
+      return document.querySelector('esl-share-button') as ESLShareButton;
+    };
+
+    beforeAll(() => {
+      document.title = 'Fallback title';
+      document.body.innerHTML = nestedElementsTemplate;
+      $lvl.push(document.body);
+      ['.lvl-1', '.lvl-2', '.lvl-3'].forEach((selector) => $lvl.push(document.querySelector(selector) as HTMLElement));
+      $trigger = ESLShare.create();
+      $trigger.list = 'copy';
+      $lvl[3].appendChild($trigger);
+    });
+
+    afterEach(() => {
+      $lvl.forEach(clearShareAttributes);
+      clearShareAttributes($trigger);
+      ESLSharePopup.sharedInstance.hide();
+    });
+
+    afterAll(() => {
+      document.body.innerHTML = '';
+    });
+
+    test('should use document.title as fallback value when not defined on trigger and its parents', () => {
+      const $button = showPopupAndGetButton();
+      expect($button.titleToShare).toBe(document.title);
+    });
+
+    test('should use window.location.href as fallback value when not defined on trigger and its parents', () => {
+      const $button = showPopupAndGetButton();
+      expect($button.urlToShare).toBe(window.location.href);
+    });
+
+    test('should use values from trigger when attributes are defined and ignore values from its parents', () => {
+      setShareAttributes($lvl[1], 'Title from level-1', 'https://host.com/level-1');
+      setShareAttributes($lvl[3], 'Title from level-3', 'https://host.com/level-3');
+      setShareAttributes($trigger, 'Title from trigger', 'https://host.com/trigger');
+      const $button = showPopupAndGetButton();
+      expect($button.titleToShare).toBe('Title from trigger');
+      expect($button.urlToShare).toBe('https://host.com/trigger');
+    });
+
+    test('should use values from closest parent when not defined on button but defined on its parents', () => {
+      setShareAttributes($lvl[1], 'Title from level-1', 'https://host.com/level-1');
+      setShareAttributes($lvl[2], 'Title from level-2', undefined);
+      setShareAttributes($lvl[3], undefined, 'https://host.com/level-3');
+      const $button = showPopupAndGetButton();
+      expect($button.titleToShare).toBe('Title from level-2');
+      expect($button.urlToShare).toBe('https://host.com/level-3');
+    });
+  });
+});
diff --git a/src/modules/esl-share/test/nested-elements-template.html b/src/modules/esl-share/test/nested-elements-template.html
new file mode 100644
index 000000000..54b69ae64
--- /dev/null
+++ b/src/modules/esl-share/test/nested-elements-template.html
@@ -0,0 +1,7 @@
+<div class="lvl-1">
+  <div class="lvl-2">
+    <div class="lvl-3">
+
+    </div>
+  </div>
+</div>