Repository: exadel-inc/esl
Pull request #151 (2021-04-19 09:00:37)

Class Utils Extended (concept)
Concept review PR 
Waits documentation update in case it will be approved during the discussion


Patch:
diff --git a/pages/src/common/page.less b/pages/src/common/page.less
index e3389b250..e26e1f54a 100644
--- a/pages/src/common/page.less
+++ b/pages/src/common/page.less
@@ -81,3 +81,11 @@ test-media-controls {
     }
   }
 }
+
+.logo-light svg {
+  fill: #ffffff;
+}
+
+.logo-primary svg {
+  fill: #0082CA;
+}
diff --git a/pages/src/header/header.html b/pages/src/header/header.html
index 5496f201e..3fa30da92 100644
--- a/pages/src/header/header.html
+++ b/pages/src/header/header.html
@@ -1,6 +1,6 @@
 <nav class="navbar navbar-expand-md navbar-dark bg-dark esl-demo-header">
   <div class="container">
-    <a class="navbar-brand mr-5" href="{{= page.resolveURL('/' + SERVER_CONFIG.defaultPageUrl)}}">
+    <a class="navbar-brand logo-light mr-5" href="{{= page.resolveURL('/' + SERVER_CONFIG.defaultPageUrl)}}">
       {{= page.getFile('static/assets/logo.svg').toString() }}
       <span class="ml-2">Exadel Smart Library</span>
     </a>
diff --git a/pages/static/assets/logo.svg b/pages/static/assets/logo.svg
index 70ff7fa1d..6c81964df 100644
--- a/pages/static/assets/logo.svg
+++ b/pages/static/assets/logo.svg
@@ -2,6 +2,6 @@
 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
   <path fill="#C6361A" d="M200 292c-.3 0-.6 0-.9-.1-.7-.2-73.1-17-73.1-96.9v-56.3c0-1.8 1.1-3.5 2.8-4.1l69.6-26.3c1-.4 2.1-.4 3.1 0l69.6 26.3c1.7.6 2.8 2.3 2.8 4.1V195c0 79.9-72.3 96.8-73.1 96.9-.2.1-.5.1-.8.1z"/>
   <path fill="#FF5D24" fill-rule="evenodd" d="M200 108v184c.3 0 .6 0 .9-.1.7-.2 73.1-17 73.1-96.9v-56.3c0-1.8-1.1-3.5-2.8-4.1l-69.6-26.3c-.6-.2-1.1-.3-1.6-.3z" clip-rule="evenodd"/>
-  <path fill="#0082CA" fill-rule="evenodd" d="M207.7 207.3l-15.8 15.8-23.2-6.2-6.2-23.2 15.8-15.8c-9-.7-18.2 2.4-25.1 9.3-9.5 9.5-11.8 23.5-6.8 35.1L108.6 260c-4.7 4.7-4.7 12.3 0 17s12.3 4.7 17 0l37.7-37.7c11.6 4.9 25.6 2.7 35.1-6.8 6.9-6.9 10-16.2 9.3-25.2zM292 122.6c-4.7-4.7-12.3-4.7-17 0l-37.7 37.7c-11.6-4.9-25.6-2.7-35.1 6.8-6.9 6.9-10 16.1-9.3 25.1l15.8-15.8 23.2 6.2 6.2 23.2-15.8 15.8c9 .7 18.2-2.4 25.1-9.3 9.5-9.5 11.8-23.5 6.8-35.1l37.7-37.7c4.8-4.6 4.8-12.2.1-16.9z" clip-rule="evenodd"/>
-  <path fill="#0082CA" d="M199.5 39.5c-88.4 0-160 71.6-160 160s71.6 160 160 160 160-71.6 160-160-71.6-160-160-160zm0 308c-81.7 0-148-66.3-148-148s66.3-148 148-148 148 66.3 148 148-66.3 148-148 148z"/>
+  <path fill-rule="evenodd" d="M207.7 207.3l-15.8 15.8-23.2-6.2-6.2-23.2 15.8-15.8c-9-.7-18.2 2.4-25.1 9.3-9.5 9.5-11.8 23.5-6.8 35.1L108.6 260c-4.7 4.7-4.7 12.3 0 17s12.3 4.7 17 0l37.7-37.7c11.6 4.9 25.6 2.7 35.1-6.8 6.9-6.9 10-16.2 9.3-25.2zM292 122.6c-4.7-4.7-12.3-4.7-17 0l-37.7 37.7c-11.6-4.9-25.6-2.7-35.1 6.8-6.9 6.9-10 16.1-9.3 25.1l15.8-15.8 23.2 6.2 6.2 23.2-15.8 15.8c9 .7 18.2-2.4 25.1-9.3 9.5-9.5 11.8-23.5 6.8-35.1l37.7-37.7c4.8-4.6 4.8-12.2.1-16.9z" clip-rule="evenodd"/>
+  <path d="M199.5 39.5c-88.4 0-160 71.6-160 160s71.6 160 160 160 160-71.6 160-160-71.6-160-160-160zm0 308c-81.7 0-148-66.3-148-148s66.3-148 148-148 148 66.3 148 148-66.3 148-148 148z"/>
 </svg>
diff --git a/pages/views/index.html b/pages/views/index.html
index 3e05ff657..132849369 100644
--- a/pages/views/index.html
+++ b/pages/views/index.html
@@ -7,7 +7,7 @@ containerCls: container
 {{##content:
 
 <div class="d-sm-flex align-items-center">
-  <div class="brand-section mr-2">
+  <div class="brand-section logo-primary mr-2">
     {{= page.getFile('static/assets/logo.svg').toString() }}
   </div>
   <p class="m-0 lead description">
diff --git a/pages/views/pages/basic/esl-toggleable.html b/pages/views/pages/basic/esl-toggleable.html
index 1496727f1..82523edd8 100644
--- a/pages/views/pages/basic/esl-toggleable.html
+++ b/pages/views/pages/basic/esl-toggleable.html
@@ -69,6 +69,23 @@ title: ESL Toggleable and ESL Trigger components
   </div>
 </section>
 <hr/>
+
+<section class="row">
+  <div class="col-12">
+    <h4>Class toggle</h4>
+    <div style="height: 70px;">
+      <esl-popup id="popup3" class="d-block alert" active-class="alert-success !alert-danger">
+          <strong>Toggleable can be used to switch state.</strong> You can toggle it using the trigger bellow.
+      </esl-popup>
+    </div>
+    <p>Triggers</p>
+    <div>
+      <esl-trigger target="#popup3" class="btn btn-primary">Toggle type</esl-trigger>
+    </div>
+  </div>
+</section>
+<hr/>
+
 <section class="row">
   <div class="col-12">
     <h4>Close On Body Click</h4>
@@ -111,11 +128,19 @@ title: ESL Toggleable and ESL Trigger components
 <hr/>
 
 <section class="row">
+  <style>
+    body:not(.body-marker) .b-marker {
+      display: none
+    }
+  </style>
   <div class="col-12">
     <h4>Group of popups (click event)</h4>
+    <div class="b-marker alert alert-info">Body class marker active</div>
+
     <div style="height: 70px;">
       <esl-popup id="popup3"
                  group="group1"
+                 body-class="body-marker"
                  close-on=".close-button"
                  close-on-esc
                  open>
@@ -130,6 +155,7 @@ title: ESL Toggleable and ESL Trigger components
     <div style="height: 70px;">
       <esl-popup id="popup4"
                  group="group1"
+                 body-class="body-marker"
                  close-on=".close-button"
                  close-on-esc>
         <div class="alert alert-warning" role="alert">
@@ -143,6 +169,7 @@ title: ESL Toggleable and ESL Trigger components
     <div style="height: 70px;">
       <esl-popup id="popup5"
                  group="group1"
+                 body-class="body-marker"
                  close-on=".close-button"
                  close-on-esc>
         <div class="alert alert-warning" role="alert">
diff --git a/src/modules/esl-alert/core/esl-alert.ts b/src/modules/esl-alert/core/esl-alert.ts
index 820f5afc5..d68a0a828 100644
--- a/src/modules/esl-alert/core/esl-alert.ts
+++ b/src/modules/esl-alert/core/esl-alert.ts
@@ -3,7 +3,7 @@ import {bind} from '../../esl-utils/decorators/bind';
 import {attr, jsonAttr} from '../../esl-base-element/core';
 import {ESLToggleable, ToggleableActionParams} from '../../esl-toggleable/core';
 import {DeviceDetector} from '../../esl-utils/environment/device-detector';
-import {CSSUtil} from '../../esl-utils/dom/styles';
+import {CSSClassUtils} from '../../esl-utils/dom/class';
 import {createZIndexIframe} from '../../esl-utils/fixes/ie-fixes';
 import {TraversingQuery} from '../../esl-traversing-query/core';
 
@@ -125,14 +125,14 @@ export class ESLAlert extends ESLToggleable {
   }
 
   protected render({text, html, cls}: AlertActionParams) {
-    CSSUtil.removeCls(this, this.activeCls);
-    CSSUtil.addCls(this, this.activeCls = cls);
+    CSSClassUtils.remove(this, this.activeCls);
+    CSSClassUtils.add(this, this.activeCls = cls);
     if (html) this.$content.innerHTML = html;
     if (text) this.$content.textContent = text;
   }
   protected clear() {
     this.$content.innerHTML = '';
-    CSSUtil.removeCls(this, this.activeCls);
+    CSSClassUtils.remove(this, this.activeCls);
   }
 
   @bind
diff --git a/src/modules/esl-forms/esl-select/core/esl-select.ts b/src/modules/esl-forms/esl-select/core/esl-select.ts
index 2839630ce..241223dd2 100644
--- a/src/modules/esl-forms/esl-select/core/esl-select.ts
+++ b/src/modules/esl-forms/esl-select/core/esl-select.ts
@@ -1,6 +1,6 @@
 import {attr, boolAttr} from '../../../esl-base-element/core';
 import {bind} from '../../../esl-utils/decorators/bind';
-import {CSSUtil} from '../../../esl-utils/dom/styles';
+import {CSSClassUtils} from '../../../esl-utils/dom/class';
 import {ENTER, SPACE} from '../../../esl-utils/dom/keys';
 import {ExportNs} from '../../../esl-utils/environment/export-ns';
 import {EventUtils} from '../../../esl-utils/dom/events';
@@ -137,11 +137,11 @@ export class ESLSelect extends ESLSelectWrapper {
   protected _onUpdate() {
     const hasValue = this.hasSelected();
     this.toggleAttribute('has-value', hasValue);
-    CSSUtil.toggleClsTo(this, this.hasValueClass, hasValue);
+    CSSClassUtils.toggle(this, this.hasValueClass, hasValue);
 
     const focusEl = document.activeElement;
     const hasFocus = this.open || (focusEl && this.contains(focusEl));
-    CSSUtil.toggleClsTo(this, this.hasFocusClass, !!hasFocus);
+    CSSClassUtils.toggle(this, this.hasFocusClass, !!hasFocus);
   }
 
   @bind
diff --git a/src/modules/esl-image/core/esl-image.ts b/src/modules/esl-image/core/esl-image.ts
index 4015f8879..c90c0f458 100644
--- a/src/modules/esl-image/core/esl-image.ts
+++ b/src/modules/esl-image/core/esl-image.ts
@@ -1,6 +1,6 @@
 import {ExportNs} from '../../esl-utils/environment/export-ns';
 import {bind} from '../../esl-utils/decorators/bind';
-import {CSSUtil} from '../../esl-utils/dom/styles';
+import {CSSClassUtils} from '../../esl-utils/dom/class';
 import {ESLBaseElement, attr, boolAttr} from '../../esl-base-element/core';
 import {EventUtils} from '../../esl-utils/dom/events';
 import {ESLMediaRuleList} from '../../esl-media-query/core';
@@ -294,8 +294,9 @@ export class ESLImage extends ESLBaseElement {
     if (this.containerClass === null) return;
     const cls = this.containerClass || (this.constructor as typeof ESLImage).DEFAULT_CONTAINER_CLS;
     const state = isLoadState(this.containerClassState) && this[this.containerClassState];
+
     const targetEl = TraversingQuery.first(this.containerClassTarget, this) as HTMLElement;
-    targetEl && CSSUtil.toggleClsTo(targetEl, cls, state);
+    targetEl && CSSClassUtils.toggle(targetEl, cls, state);
   }
 
   public $$fire(eventName: string, eventInit: CustomEventInit = {bubbles: false}): boolean {
diff --git a/src/modules/esl-media/core/esl-media.ts b/src/modules/esl-media/core/esl-media.ts
index 214ba42b9..52f4b606c 100644
--- a/src/modules/esl-media/core/esl-media.ts
+++ b/src/modules/esl-media/core/esl-media.ts
@@ -1,7 +1,7 @@
 import {ExportNs} from '../../esl-utils/environment/export-ns';
 import {ESLBaseElement, attr, boolAttr} from '../../esl-base-element/core';
 import {bind} from '../../esl-utils/decorators/bind';
-import {CSSUtil} from '../../esl-utils/dom/styles';
+import {CSSClassUtils} from '../../esl-utils/dom/class';
 import {rafDecorator} from '../../esl-utils/async/raf';
 import {debounce} from '../../esl-utils/async/debounce';
 import {EventUtils} from '../../esl-utils/dom/events';
@@ -202,8 +202,8 @@ export class ESLMedia extends ESLBaseElement {
     if (!targetEl) return;
 
     const active = this.canActivate();
-    CSSUtil.toggleClsTo(targetEl, this.loadClsAccepted, active);
-    CSSUtil.toggleClsTo(targetEl, this.loadClsDeclined, !active);
+    CSSClassUtils.toggle(targetEl, this.loadClsAccepted, active);
+    CSSClassUtils.toggle(targetEl, this.loadClsDeclined, !active);
   }
 
   /**
@@ -334,7 +334,7 @@ export class ESLMedia extends ESLBaseElement {
   /** Update ready class state */
   protected updateReadyClass() {
     const target = TraversingQuery.first(this.readyClassTarget, this) as HTMLElement;
-    target && CSSUtil.toggleClsTo(target, this.readyClass, this.ready);
+    target && CSSClassUtils.toggle(target, this.readyClass, this.ready);
   }
 
   /** Applied provider */
diff --git a/src/modules/esl-panel/core/esl-panel-group.ts b/src/modules/esl-panel/core/esl-panel-group.ts
index da6b77b30..8f709df32 100644
--- a/src/modules/esl-panel/core/esl-panel-group.ts
+++ b/src/modules/esl-panel/core/esl-panel-group.ts
@@ -2,7 +2,7 @@ import {ExportNs} from '../../esl-utils/environment/export-ns';
 import {attr, ESLBaseElement} from '../../esl-base-element/core';
 import {afterNextRender} from '../../esl-utils/async/raf';
 import {bind} from '../../esl-utils/decorators/bind';
-import {CSSUtil} from '../../esl-utils/dom/styles';
+import {CSSClassUtils} from '../../esl-utils/dom/class';
 import {ESLMediaRuleList} from '../../esl-media-query/core';
 import {ESLPanel, PanelActionParams} from './esl-panel';
 import {TraversingQuery} from '../../esl-traversing-query/core';
@@ -140,13 +140,13 @@ export class ESLPanelGroup extends ESLBaseElement {
 
   /** Pre-processing animation action */
   protected beforeAnimate() {
-    CSSUtil.addCls(this, this.animationClass);
+    CSSClassUtils.add(this, this.animationClass);
   }
 
   /** Post-processing animation action */
   protected afterAnimate() {
     this.style.removeProperty('height');
-    CSSUtil.removeCls(this, this.animationClass);
+    CSSClassUtils.remove(this, this.animationClass);
   }
 
   /** Init a fallback timer to call post-animate action */
diff --git a/src/modules/esl-panel/core/esl-panel.ts b/src/modules/esl-panel/core/esl-panel.ts
index 40401217e..0f4455649 100644
--- a/src/modules/esl-panel/core/esl-panel.ts
+++ b/src/modules/esl-panel/core/esl-panel.ts
@@ -1,5 +1,5 @@
 import {ExportNs} from '../../esl-utils/environment/export-ns';
-import {CSSUtil} from '../../esl-utils/dom/styles';
+import {CSSClassUtils} from '../../esl-utils/dom/class';
 import {bind} from '../../esl-utils/decorators/bind';
 import {afterNextRender} from '../../esl-utils/async/raf';
 import {attr, jsonAttr} from '../../esl-base-element/core';
@@ -93,8 +93,8 @@ export class ESLPanel extends ESLToggleable {
 
   /** Pre-processing animation action */
   protected beforeAnimate() {
-    CSSUtil.addCls(this, this.animateClass);
-    this.postAnimateClass && afterNextRender(() => CSSUtil.addCls(this, this.postAnimateClass));
+    CSSClassUtils.add(this, this.animateClass);
+    this.postAnimateClass && afterNextRender(() => CSSClassUtils.add(this, this.postAnimateClass));
   }
 
   /** Process animation */
@@ -116,8 +116,8 @@ export class ESLPanel extends ESLToggleable {
   /** Clear animation properties */
   protected clearAnimation() {
     this.style.removeProperty('max-height');
-    CSSUtil.removeCls(this, this.animateClass);
-    CSSUtil.removeCls(this, this.postAnimateClass);
+    CSSClassUtils.remove(this, this.animateClass);
+    CSSClassUtils.remove(this, this.postAnimateClass);
   }
 
   /** Init a fallback timer to call post-animate action */
diff --git a/src/modules/esl-popup/core/esl-popup.ts b/src/modules/esl-popup/core/esl-popup.ts
index 11955b138..9c26a21a7 100644
--- a/src/modules/esl-popup/core/esl-popup.ts
+++ b/src/modules/esl-popup/core/esl-popup.ts
@@ -1,7 +1,7 @@
 import {ExportNs} from '../../esl-utils/environment/export-ns';
 import {attr} from '../../esl-base-element/core';
 import {ESLToggleable, ToggleableActionParams} from '../../esl-toggleable/core';
-import {ScrollStrategy, ScrollUtility} from '../../esl-utils/dom/scroll';
+import {ScrollStrategy, ScrollUtils} from '../../esl-utils/dom/scroll';
 
 @ExportNs('Popup')
 export class ESLPopup extends ESLToggleable {
@@ -12,12 +12,12 @@ export class ESLPopup extends ESLToggleable {
   protected onShow(params: ToggleableActionParams) {
     super.onShow(params);
 
-    this.disableScroll !== null && ScrollUtility.requestLock(this, this.disableScroll);
+    this.disableScroll !== null && ScrollUtils.requestLock(this, this.disableScroll);
   }
 
   protected onHide(params: ToggleableActionParams) {
     super.onHide(params);
 
-    this.disableScroll !== null && ScrollUtility.requestUnlock(this, this.disableScroll);
+    this.disableScroll !== null && ScrollUtils.requestUnlock(this, this.disableScroll);
   }
 }
diff --git a/src/modules/esl-toggleable/core/esl-toggleable.ts b/src/modules/esl-toggleable/core/esl-toggleable.ts
index 6471ab6fb..0258e59ea 100644
--- a/src/modules/esl-toggleable/core/esl-toggleable.ts
+++ b/src/modules/esl-toggleable/core/esl-toggleable.ts
@@ -1,6 +1,6 @@
 import {ExportNs} from '../../esl-utils/environment/export-ns';
 import {ESC} from '../../esl-utils/dom/keys';
-import {CSSUtil} from '../../esl-utils/dom/styles';
+import {CSSClassUtils} from '../../esl-utils/dom/class';
 import {bind} from '../../esl-utils/decorators/bind';
 import {defined} from '../../esl-utils/misc/object';
 import {DeviceDetector} from '../../esl-utils/environment/device-detector';
@@ -209,8 +209,8 @@ export class ESLToggleable extends ESLBaseElement {
   protected onShow(params: ToggleableActionParams) {
     activators.set(this, params.activator);
     this.open = this._open = true;
-    CSSUtil.addCls(this, this.activeClass);
-    CSSUtil.addCls(document.body, this.bodyClass);
+    CSSClassUtils.add(this, this.activeClass);
+    CSSClassUtils.add(document.body, this.bodyClass, this);
     this.updateA11y();
     this.$$fire('esl:refresh');
   }
@@ -219,8 +219,8 @@ export class ESLToggleable extends ESLBaseElement {
   protected onHide(params: ToggleableActionParams) {
     activators.delete(this);
     this.open = this._open = false;
-    CSSUtil.removeCls(this, this.activeClass);
-    CSSUtil.removeCls(document.body, this.bodyClass);
+    CSSClassUtils.remove(this, this.activeClass);
+    CSSClassUtils.remove(document.body, this.bodyClass, this);
     this.updateA11y();
   }
 
diff --git a/src/modules/esl-trigger/core/esl-trigger.ts b/src/modules/esl-trigger/core/esl-trigger.ts
index fcd8b5625..d701b1932 100644
--- a/src/modules/esl-trigger/core/esl-trigger.ts
+++ b/src/modules/esl-trigger/core/esl-trigger.ts
@@ -5,7 +5,7 @@ import {ready} from '../../esl-utils/decorators/ready';
 import {TraversingQuery} from '../../esl-traversing-query/core';
 import {NoopFnSignature} from '../../esl-utils/misc/functions';
 import {DeviceDetector} from '../../esl-utils/environment/device-detector';
-import {CSSUtil} from '../../esl-utils/dom/styles';
+import {CSSClassUtils} from '../../esl-utils/dom/class';
 import {ENTER, SPACE} from '../../esl-utils/dom/keys';
 
 import type {ESLToggleable} from '../../esl-toggleable/core/esl-toggleable';
@@ -193,7 +193,7 @@ export class ESLTrigger extends ESLBaseElement {
     this.toggleAttribute('active', this.$target.open);
 
     const clsTarget = TraversingQuery.first(this.activeClassTarget, this) as HTMLElement;
-    clsTarget && CSSUtil.toggleClsTo(clsTarget, this.activeClass, this.active);
+    clsTarget && CSSClassUtils.toggle(clsTarget, this.activeClass, this.active);
 
     this.updateA11y();
 
diff --git a/src/modules/esl-utils/README.md b/src/modules/esl-utils/README.md
index aa362af23..cd11a8fb2 100644
--- a/src/modules/esl-utils/README.md
+++ b/src/modules/esl-utils/README.md
@@ -53,7 +53,7 @@ Even if you use the whole utils module, it is still tiny.
     - #### [Scroll](./dom/scroll.ts) - scroll locking methods. 
       *Note: uses [scroll.less](./dom/scroll.less) styles.*
   
-    - #### [Styles](./dom/styles.ts) - small utility to work with CSS classes in a JQuery manner.
+    - #### [CSSClass](./dom/class.ts) - is a utility to work with CSS classes. Supports JQuery-like enumeration, inversion syntax and locks.
   
     - #### [DOM Traversing](./dom/traversing.ts) - a set of utils to find DOM elements or check their relations.
   
diff --git a/src/modules/esl-utils/all.ts b/src/modules/esl-utils/all.ts
index 5b7c35595..873d9de3e 100644
--- a/src/modules/esl-utils/all.ts
+++ b/src/modules/esl-utils/all.ts
@@ -24,6 +24,7 @@ export * from './async/throttle';
 // DOM
 export * from './dom/script';
 export * from './dom/styles';
+export * from './dom/class';
 export * from './dom/events';
 export * from './dom/traversing';
 
diff --git a/src/modules/esl-utils/dom/class.ts b/src/modules/esl-utils/dom/class.ts
new file mode 100644
index 000000000..20ab325b0
--- /dev/null
+++ b/src/modules/esl-utils/dom/class.ts
@@ -0,0 +1,97 @@
+/** Describe locker elements collection per class name */
+type Locks = Map<string, Set<HTMLElement>>;
+/** Store locks for key element classes*/
+const lockStore = new WeakMap<HTMLElement, Locks>();
+
+/** Mange className lock for the element */
+const lock = (el: HTMLElement, className: string, locker: HTMLElement) => {
+  const elLocks: Locks = lockStore.get(el) || new Map();
+  const classLocks: Set<HTMLElement> = elLocks.get(className) || new Set();
+  classLocks.add(locker);
+  elLocks.set(className, classLocks);
+  lockStore.set(el, elLocks);
+};
+/**
+ * Manage className unlock for the element
+ * @returns true if className have no lockes
+ */
+const unlock = (el: HTMLElement, className: string, locker: HTMLElement) => {
+  const elLocks = lockStore.get(el);
+  if (!elLocks) return true;
+  const classLocks = elLocks.get(className);
+  if (!classLocks) return true;
+  classLocks.delete(locker);
+  return !classLocks.size;
+};
+
+/**
+ * Add single class to the element.
+ * Supports inversion and locker management.
+ */
+const add = (el: HTMLElement, className: string, locker?: HTMLElement): void => {
+  if (className[0] === '!') return CSSClassUtils.remove(el, className.substr(1), locker);
+  if (locker) lock(el, className, locker);
+  el.classList.add(className);
+};
+
+/**
+ * Remove single class from the element.
+ * Supports inversion and locker management.
+ */
+const remove = (el: HTMLElement, className: string, locker?: HTMLElement): void => {
+  if (className[0] === '!') return CSSClassUtils.add(el, className.substr(1), locker);
+  if (locker && !unlock(el, className, locker)) return;
+  if (!locker) CSSClassUtils.unlock(el, className);
+  el.classList.remove(className);
+};
+
+/**
+ * CSS class manipulation utilities.
+ *
+ * Allows to manipulate with CSS classes with the following set of sub-features:
+ * - JQuery-like enumeration - you can pass multiple tokens separated by space
+ * - safe checks - empty or falsy token sting will be ignored without trowing an error
+ * - inversion syntax - tokens that start from '!' will be processed with inverted action
+ * (e.g. addCls(el, '!class') - will remove 'class' from the element, while removeCls(el, '!class') adds 'class' to the element)
+ * - class locks - you can manipulate with classes using `locker` option that takes into account modification initiator.
+ * That means the class added in 'locker' mode will not be removed until all initiators that requested add class have requested its removal.
+ * */
+export abstract class CSSClassUtils {
+  /** Splitting passed token string into CSS class names array. */
+  public static splitTokens(tokenString: string | null | undefined): string[] {
+    return (tokenString || '').split(' ').filter((str) => !!str);
+  }
+
+  /**
+   * Add all classes from the class token string to the element.
+   * @see CSSClassUtils
+   * */
+  public static add(el: HTMLElement, cls: string | null | undefined, locker?: HTMLElement) {
+    CSSClassUtils.splitTokens(cls).forEach((className) => add(el, className, locker));
+  }
+
+  /**
+   * Remove all classes from the class token string to the element.
+   * @see CSSClassUtils
+   * */
+  public static remove(el: HTMLElement, cls: string | null | undefined, locker?: HTMLElement) {
+    CSSClassUtils.splitTokens(cls).forEach((className) => remove(el, className, locker));
+  }
+
+  /**
+   * Toggle all classes from the class token string on the element to the passed state.
+   * @see CSSClassUtils
+   * */
+  public static toggle(el: HTMLElement, cls: string | null | undefined, state: boolean, locker?: HTMLElement) {
+    (state ? CSSClassUtils.add : CSSClassUtils.remove)(el, cls, locker);
+  }
+
+  /** Remove all lockers for the element or passed element className */
+  public static unlock(el: HTMLElement, className?: string) {
+    if (className) {
+      lockStore.get(el)?.delete(className);
+    } else {
+      lockStore.delete(el);
+    }
+  }
+}
diff --git a/src/modules/esl-utils/dom/scroll.ts b/src/modules/esl-utils/dom/scroll.ts
index c622ce8aa..583b0e2b0 100644
--- a/src/modules/esl-utils/dom/scroll.ts
+++ b/src/modules/esl-utils/dom/scroll.ts
@@ -3,7 +3,7 @@ export type ScrollStrategy = 'none' | 'native' | 'pseudo';
 const $html = document.documentElement;
 const initiatorSet = new Set();
 
-export abstract class ScrollUtility {
+export abstract class ScrollUtils {
   /**
    * Check vertical scroll based on content height
    * */
@@ -16,7 +16,7 @@ export abstract class ScrollUtility {
    * @param [strategy] - to make scroll visually disabled
    * */
   public static lock(strategy?: ScrollStrategy) {
-    const hasScroll = ScrollUtility.hasVerticalScroll();
+    const hasScroll = ScrollUtils.hasVerticalScroll();
     if (strategy && strategy !== 'none' && hasScroll) {
       $html.classList.add(`esl-${strategy}-scroll`);
     }
@@ -39,7 +39,7 @@ export abstract class ScrollUtility {
    * */
   public static requestLock(initiator: any, strategy?: ScrollStrategy) {
     initiator && initiatorSet.add(initiator);
-    (initiatorSet.size > 0) && ScrollUtility.lock(strategy);
+    (initiatorSet.size > 0) && ScrollUtils.lock(strategy);
   }
 
   /**
@@ -49,6 +49,9 @@ export abstract class ScrollUtility {
    * */
   public static requestUnlock(initiator: any, strategy?: ScrollStrategy) {
     initiator && initiatorSet.delete(initiator);
-    (initiatorSet.size === 0) && ScrollUtility.unlock();
+    (initiatorSet.size === 0) && ScrollUtils.unlock();
   }
 }
+
+/** @deprecated Use ScrollUtils alias */
+export const ScrollUtility = ScrollUtils;
diff --git a/src/modules/esl-utils/dom/styles.ts b/src/modules/esl-utils/dom/styles.ts
index 421e0913c..a95b60d97 100644
--- a/src/modules/esl-utils/dom/styles.ts
+++ b/src/modules/esl-utils/dom/styles.ts
@@ -1,4 +1,9 @@
-/** CSS manipulation utilities. */
+/**
+ * @deprecated
+ * CSS manipulation utilities.
+ *
+ * Use {@link CSSClassUtils} instead
+ * */
 export abstract class CSSUtil {
   /** Splitting passed token string into CSS class names array. */
   public static splitTokens(tokenString: string | null | undefined): string[] {


Test patch:
diff --git a/src/modules/esl-utils/dom/test/class.test.ts b/src/modules/esl-utils/dom/test/class.test.ts
new file mode 100644
index 000000000..cdccc0990
--- /dev/null
+++ b/src/modules/esl-utils/dom/test/class.test.ts
@@ -0,0 +1,124 @@
+import {CSSClassUtils} from '../class';
+
+describe('CSSClassUtils tests:', () => {
+  test('styles: crud simple', () => {
+    const el = document.createElement('div');
+
+    expect(el.classList.length).toBe(0);
+    CSSClassUtils.add(el, 'abc');
+    expect(el.classList.contains('abc')).toBe(true);
+    CSSClassUtils.remove(el, 'abc');
+    expect(el.classList.contains('abc')).toBe(false);
+    CSSClassUtils.toggle(el, 'abc', true);
+    expect(el.classList.contains('abc')).toBe(true);
+    CSSClassUtils.toggle(el, 'abc', false);
+    expect(el.classList.contains('abc')).toBe(false);
+  });
+
+  test('styles: crud multiple', () => {
+    const el = document.createElement('div');
+
+    expect(el.classList.length).toBe(0);
+    CSSClassUtils.add(el, 'a b');
+    expect(el.classList.contains('a')).toBe(true);
+    expect(el.classList.contains('b')).toBe(true);
+    CSSClassUtils.remove(el, 'a b ');
+    expect(el.classList.contains('a')).toBe(false);
+    expect(el.classList.contains('b')).toBe(false);
+
+    CSSClassUtils.add(el, 'a a');
+    expect(el.classList.contains('a')).toBe(true);
+    expect(el.classList.length).toBe(1);
+    CSSClassUtils.remove(el, 'b c');
+    expect(el.classList.contains('a')).toBe(true);
+  });
+
+  describe('class locks:', () => {
+    const el = document.createElement('div');
+    const lock1 = document.createElement('div');
+    const lock2 = document.createElement('div');
+
+    afterEach(() => el.className = '');
+
+    test('lock case', () => {
+      CSSClassUtils.add(el, 'a', lock1);
+      expect(el.classList.contains('a')).toBeTruthy();
+      CSSClassUtils.add(el, 'a', lock2);
+      expect(el.classList.contains('a')).toBeTruthy();
+
+      CSSClassUtils.remove(el, 'a', lock1);
+      expect(el.classList.contains('a')).toBeTruthy();
+      CSSClassUtils.remove(el, 'a', lock2);
+      expect(el.classList.contains('a')).toBeFalsy();
+    });
+
+    test('double lock case', () => {
+      CSSClassUtils.toggle(el, 'a', true, lock2);
+      expect(el.classList.contains('a')).toBeTruthy();
+      CSSClassUtils.toggle(el, 'a', true, lock2);
+      expect(el.classList.contains('a')).toBeTruthy();
+
+      CSSClassUtils.toggle(el, 'a', false, lock2);
+      expect(el.classList.contains('a')).toBeFalsy();
+    });
+
+    test('unlock via force action', () => {
+      CSSClassUtils.add(el, 'a', lock1);
+      expect(el.classList.contains('a')).toBeTruthy();
+      CSSClassUtils.add(el, 'a', lock2);
+      expect(el.classList.contains('a')).toBeTruthy();
+
+      CSSClassUtils.remove(el, 'a');
+      expect(el.classList.contains('a')).toBeFalsy();
+    });
+
+    const payloadSet = (new Array(100))
+      .fill('!a !b c !d !e !f !g !h !i !j !k !l !m !n !o !p !q !r !s !t !u !v !w !x !y !z')
+      .join(' ');
+
+    test('payload test case', () => {
+      const start = performance.now();
+      CSSClassUtils.remove(el, payloadSet, lock1);
+      expect(el.classList.contains('a')).toBeTruthy();
+      expect(el.classList.length).toBe(25);
+      CSSClassUtils.add(el, payloadSet, lock1);
+      expect(el.classList.contains('a')).toBeFalsy();
+      expect(el.classList.length).toBe(1);
+      const end = performance.now();
+      expect(end - start).toBeLessThan(200);
+    }, 200);
+  });
+
+  describe('reverse adding:', () => {
+    test('add reverse', () => {
+      const el = document.createElement('div');
+      el.className = 'a b';
+      CSSClassUtils.add(el, '!a');
+      expect(el.classList.length).toBe(1);
+      CSSClassUtils.add(el, '!b');
+      expect(el.classList.length).toBe(0);
+    });
+    test('remove reverse', () => {
+      const el = document.createElement('div');
+      el.className = 'a b';
+      CSSClassUtils.remove(el, '!a');
+      expect(el.classList.contains('a')).toBeTruthy();
+      CSSClassUtils.remove(el, '!b');
+      expect(el.classList.contains('b')).toBeTruthy();
+      expect(el.classList.length).toBe(2);
+    });
+  });
+
+  describe('edge cases:', () => {
+    test.each([
+      [''], [' '], [null], [undefined]
+    ])('%p safe check', (val) => {
+      const el = document.createElement('div');
+      expect(el.classList.length).toBe(0);
+      CSSClassUtils.add(el, val);
+      expect(el.classList.length).toBe(0);
+      CSSClassUtils.remove(el, val);
+      expect(el.classList.length).toBe(0);
+    });
+  });
+});
diff --git a/src/modules/esl-utils/dom/test/styles.test.ts b/src/modules/esl-utils/dom/test/styles.test.ts
deleted file mode 100644
index 677c0c233..000000000
--- a/src/modules/esl-utils/dom/test/styles.test.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import {CSSUtil} from '../styles';
-
-describe('dom/styles helper tests', () => {
-  test('styles: crud simple', () => {
-    const el = document.createElement('div');
-
-    expect(el.classList.length).toBe(0);
-    CSSUtil.addCls(el, 'abc');
-    expect(el.classList.contains('abc')).toBe(true);
-    CSSUtil.removeCls(el, 'abc');
-    expect(el.classList.contains('abc')).toBe(false);
-    CSSUtil.toggleClsTo(el, 'abc', true);
-    expect(el.classList.contains('abc')).toBe(true);
-    CSSUtil.toggleClsTo(el, 'abc', false);
-    expect(el.classList.contains('abc')).toBe(false);
-  });
-
-  test('styles: crud multiple', () => {
-    const el = document.createElement('div');
-
-    expect(el.classList.length).toBe(0);
-    CSSUtil.addCls(el, 'a b');
-    expect(el.classList.contains('a')).toBe(true);
-    expect(el.classList.contains('b')).toBe(true);
-    CSSUtil.removeCls(el, 'a b ');
-    expect(el.classList.contains('a')).toBe(false);
-    expect(el.classList.contains('b')).toBe(false);
-
-    CSSUtil.addCls(el, 'a a');
-    expect(el.classList.contains('a')).toBe(true);
-    expect(el.classList.length).toBe(1);
-    CSSUtil.removeCls(el, 'b c');
-    expect(el.classList.contains('a')).toBe(true);
-  });
-
-  test.each([
-    [''], [' '], [null], [undefined]
-  ])('styles: safe check', (val) => {
-    const el = document.createElement('div');
-    expect(el.classList.length).toBe(0);
-    CSSUtil.addCls(el, val);
-    expect(el.classList.length).toBe(0);
-    CSSUtil.removeCls(el, val);
-    expect(el.classList.length).toBe(0);
-  });
-});