Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(esl-utils): extend attr decorator with inherit option #2228

Merged
merged 9 commits into from
Feb 26, 2024
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', () => {
ala-n marked this conversation as resolved.
Show resolved Hide resolved

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', () => {
NastaLeo marked this conversation as resolved.
Show resolved Hide resolved
// @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 resolves from the closest element (custom-element) in DOM', () => {
ala-n marked this conversation as resolved.
Show resolved Hide resolved
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', () => {
NastaLeo marked this conversation as resolved.
Show resolved Hide resolved
// @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', () => {
NastaLeo marked this conversation as resolved.
Show resolved Hide resolved
// @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('finds indicated attribute on this element', () => {
expect(getClosestAttr($el, attrName)).toBe(attrValue);
ala-n marked this conversation as resolved.
Show resolved Hide resolved
});
test('finds indicated attribute on closest parent in DOM', () => {
expect(getClosestAttr($el, $parentName)).toBe($parentValue);
ala-n marked this conversation as resolved.
Show resolved Hide resolved
});
test('returns null in case indicated attribute is absent in DOM', () => {
expect(getClosestAttr($el, 'name')).toBe(null);
ala-n marked this conversation as resolved.
Show resolved Hide resolved
});
});
});
Loading