Skip to content

Commit

Permalink
Merge pull request #2228 from exadel-inc/feat/attr-closest
Browse files Browse the repository at this point in the history
feat(esl-utils): extend `attr` decorator with inherit option
  • Loading branch information
ala-n authored Feb 26, 2024
2 parents 2a25fb2 + 6c3f9c2 commit 1a6fa0a
Show file tree
Hide file tree
Showing 4 changed files with 164 additions and 6 deletions.
21 changes: 16 additions & 5 deletions src/modules/esl-utils/decorators/attr.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {identity, resolveProperty} from '../misc/functions';
import {parseString, toKebabCase} from '../misc/format';
import {getAttr, setAttr} from '../dom/attr';
import {getAttr, getClosestAttr, setAttr} from '../dom/attr';

import type {PropertyProvider} from '../misc/functions';
import type {ESLAttributeDecorator} from '../dom/attr';
Expand All @@ -15,6 +15,17 @@ type AttrDescriptor<T = string> = {
name?: string;
/** Create getter only */
readonly?: boolean;
/**
* Specifies the attribute inheritance behavior.
* If `inherit` is set to `true`, the attribute will inherit the value from the same-named attribute of the closest parent element in the DOM tree.
* For instance, `@attr({inherit: true}) ignore;` will look for an `ignore` attribute in the parent elements if it's not defined in the current element.
* If `dataAttr` is also true, it will search for `data-ignore` instead.
*
* If `inherit` is set to a string, it will use this string as the attribute name to search for in the parent elements.
* For example, `@attr({inherit: 'alt-ignore'}) ignore;` will first look for its own `ignore` attribute (or 'data-ignore' if `dataAttr` is true),
* and if not found, it will look for an `alt-ignore` attribute in the parent elements.
*/
inherit?: boolean | string;
/** Use data-* attribute */
dataAttr?: boolean;
/** Default property value. Used if no attribute is present on the element. Empty string by default. Supports provider function. */
Expand All @@ -36,14 +47,14 @@ const buildAttrName =
export const attr = <T = string>(config: AttrDescriptor<T> = {}): ESLAttributeDecorator => {
return (target: ESLDomElementTarget, propName: string): any => {
const attrName = buildAttrName(config.name || propName, !!config.dataAttr);
const inheritAttrName = typeof config.inherit === 'string' ? config.inherit : attrName;

function get(): T | null {
const val = getAttr(this, attrName);
if (val === null && 'defaultValue' in config) {
return resolveProperty(config.defaultValue, this) as T;
}
const val = config.inherit ? getAttr(this, attrName) || getClosestAttr(this, inheritAttrName) : getAttr(this, attrName);
if (val === null && 'defaultValue' in config) return resolveProperty(config.defaultValue, this) as T;
return (config.parser || parseString as AttrParser<any>)(val);
}

function set(value: T): void {
setAttr(this, attrName, (config.serializer as AttrSerializer<any> || identity)(value));
}
Expand Down
116 changes: 116 additions & 0 deletions src/modules/esl-utils/decorators/test/attr.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import '../../../../polyfills/es5-target-shim';
import {attr} from '../attr';
import {ESLTestTemplate} from '../../test/template';

describe('Decorator: attr', () => {

Expand Down Expand Up @@ -106,6 +107,121 @@ describe('Decorator: attr', () => {
expect(el.defProvider).toBe('');
});

describe('Inherit parameter', () => {

class ThirdElement extends HTMLElement {
@attr({inherit: 'box'})
public container: string;
@attr({inherit: 'parent', dataAttr: true})
public ignore: string;
@attr({inherit: true})
public disallow: string;
@attr({inherit: true, dataAttr: true})
public allow: string;
}
customElements.define('third-el', ThirdElement);

class SecondElement extends HTMLElement {}
customElements.define('second-el', SecondElement);

const SCOPE: any = {
firstEl: '#first-el',
secondEl: '#second-el',
thirdEl: '#third-el',
};
const TEMPLATE = ESLTestTemplate.create(`
<div id="first-el">
<second-el id="second-el">
<third-el id="third-el"></third-el>
</second-el>
</div>
`, SCOPE).bind('beforeeach');

describe('Inherit searches for the closest element with explicitly declared name', () => {
// @attr({inherit: 'box'}) public container: string;
test('The attribute inherit the value from own same-named attribute', () => {
TEMPLATE.$thirdEl.setAttribute('container', 'value');
expect(TEMPLATE.$thirdEl.container).toBe('value');
});
test('Own attribute setter changes the own value', () => {
TEMPLATE.$thirdEl.container = 'container';
expect(TEMPLATE.$thirdEl.container).toBe('container');
});
test('The value should be resolved from the closest DOM element (custom-element)', () => {
TEMPLATE.$secondEl.setAttribute('box', 'carousel');
expect(TEMPLATE.$thirdEl.container).toBe('carousel');
});
test('The value resolves from the closest element (non-custom-element) in DOM', () => {
TEMPLATE.$firstEl.setAttribute('box', 'slide');
expect(TEMPLATE.$thirdEl.container).toBe('slide');
});
test('Elements with declared inherit are absent in DOM and returns empty string', () => {
expect(TEMPLATE.$thirdEl.container).toBe('');
});
});

describe('Inherit searches for the closest element with explicitly declared data attribute name', () => {
// @attr({inherit: 'parent', dataAttr: true}) public ignore: string;
test('The attribute inherit the value from the same-named data-attribute', () => {
TEMPLATE.$thirdEl.setAttribute('data-ignore', 'swipe');
expect(TEMPLATE.$thirdEl.ignore).toBe('swipe');
});
test('Own attribute setter changes the own value', () => {
TEMPLATE.$thirdEl.ignore = 'touch';
expect(TEMPLATE.$thirdEl.ignore).toBe('touch');
});
test('The value resolves from the closest element (custom-element) in DOM', () => {
TEMPLATE.$secondEl.setAttribute('parent', 'close');
expect(TEMPLATE.$thirdEl.ignore).toBe('close');
});
test('The value resolves from the closest element (non-custom-element) in DOM', () => {
TEMPLATE.$firstEl.setAttribute('parent', 'open');
expect(TEMPLATE.$thirdEl.ignore).toBe('open');
});
test('Elements with declared inherit are absent in DOM and returns empty string', () => {
expect(TEMPLATE.$thirdEl.ignore).toBe('');
});
});

describe('Inherit searches for the closest element with the same attribute name in DOM', () => {
// @attr({inherit: true}) public disallow: string;
test('The attribute inherit the value from the same attribute name', () => {
TEMPLATE.$thirdEl.disallow = 'scroll';
expect(TEMPLATE.$thirdEl.disallow).toBe('scroll');
});
test('The value resolves from the closest element (custom-element) in DOM with the same attribute name', () => {
TEMPLATE.$secondEl.setAttribute('disallow', 'activator');
expect(TEMPLATE.$thirdEl.disallow).toBe('activator');
});
test('The value resolves from the closest element (non-custom-element) in DOM with the same attribute name', () => {
TEMPLATE.$firstEl.setAttribute('disallow', 'deactivator');
expect(TEMPLATE.$thirdEl.disallow).toBe('deactivator');
});
test('Elements with declared inherit are absent in DOM and returns empty string', () => {
expect(TEMPLATE.$thirdEl.disallow).toBe('');
});
});

describe('Inherit searches for the closest element with the same data-attribute name in DOM', () => {
// @attr({inherit: true, dataAttr: true}) public allow: string;
test('The attribute inherit the value from the same attribute name', () => {
TEMPLATE.$thirdEl.allow = 'option';
expect(TEMPLATE.$thirdEl.allow).toBe('option');
});
test('The value resolves from the closest element (custom-element) in DOM with the same data-attribute name', () => {
TEMPLATE.$secondEl.setAttribute('data-allow', 'scroll');
expect(TEMPLATE.$thirdEl.allow).toBe('scroll');
});
test('The value resolves from the closest element (non-custom-element) in DOM with the same data-attribute name', () => {
TEMPLATE.$firstEl.setAttribute('data-allow', 'swipe');
expect(TEMPLATE.$thirdEl.allow).toBe('swipe');
});
test('Elements with declared inherit are absent in DOM and returns empty string', () => {
expect(TEMPLATE.$thirdEl.allow).toBe('');
});
});
});

afterAll(() => {
document.body.removeChild(el);
});
Expand Down
7 changes: 7 additions & 0 deletions src/modules/esl-utils/dom/attr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,10 @@ export function setAttr($el: ESLAttributeTarget, name: string, value: undefined
$el.setAttribute(name, value === true ? '' : value);
}
}

/** Gets attribute value from the closest element with group behavior settings */
export function getClosestAttr($el: ESLAttributeTarget, attrName: string): string | null {
if (!($el = resolveDomTarget($el))) return null;
const $closest = $el.closest(`[${attrName}]`);
return $closest ? $closest.getAttribute(attrName) : null;
}
26 changes: 25 additions & 1 deletion src/modules/esl-utils/dom/test/attr.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {hasAttr, getAttr, setAttr} from '../attr';
import {hasAttr, getAttr, setAttr, getClosestAttr} from '../attr';

describe('Attribute', () => {
const attrName = 'test-attr';
Expand Down Expand Up @@ -118,4 +118,28 @@ describe('Attribute', () => {
expect($el3.getAttribute(attrName)).toBe(attrValue);
});
});


describe('getClosestAttr', () => {
const $el = document.createElement('div');
$el.setAttribute(attrName, attrValue);

const $parent = document.createElement('div');
const $parentName = 'parent-attr';
const $parentValue = 'parent-value';
$parent.setAttribute($parentName, $parentValue);

$parent.append($el);
document.body.append($parent);

test('should return attribute from the current element', () => {
expect(getClosestAttr($el, attrName)).toBe(attrValue);
});
test('should return an attribute from the parent DOM element', () => {
expect(getClosestAttr($el, $parentName)).toBe($parentValue);
});
test('should return null in case the specified attribute is absent at the current element and its parents', () => {
expect(getClosestAttr($el, 'name')).toBe(null);
});
});
});

0 comments on commit 1a6fa0a

Please sign in to comment.