diff --git a/src/modules/esl-utils/decorators/attr.ts b/src/modules/esl-utils/decorators/attr.ts index 683fb5a1a..96f11d360 100644 --- a/src/modules/esl-utils/decorators/attr.ts +++ b/src/modules/esl-utils/decorators/attr.ts @@ -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'; @@ -15,6 +15,17 @@ type AttrDescriptor = { 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. */ @@ -36,14 +47,14 @@ const buildAttrName = export const attr = (config: AttrDescriptor = {}): 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)(val); } + function set(value: T): void { setAttr(this, attrName, (config.serializer as AttrSerializer || identity)(value)); } diff --git a/src/modules/esl-utils/decorators/test/attr.test.ts b/src/modules/esl-utils/decorators/test/attr.test.ts index da959c6bf..4651b2900 100644 --- a/src/modules/esl-utils/decorators/test/attr.test.ts +++ b/src/modules/esl-utils/decorators/test/attr.test.ts @@ -1,5 +1,6 @@ import '../../../../polyfills/es5-target-shim'; import {attr} from '../attr'; +import {ESLTestTemplate} from '../../test/template'; describe('Decorator: attr', () => { @@ -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(` +
+ + + +
+ `, 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); }); diff --git a/src/modules/esl-utils/dom/attr.ts b/src/modules/esl-utils/dom/attr.ts index ab9765fdd..93c85c1d8 100644 --- a/src/modules/esl-utils/dom/attr.ts +++ b/src/modules/esl-utils/dom/attr.ts @@ -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; +} diff --git a/src/modules/esl-utils/dom/test/attr.test.ts b/src/modules/esl-utils/dom/test/attr.test.ts index 0212148f3..47db0a9b6 100644 --- a/src/modules/esl-utils/dom/test/attr.test.ts +++ b/src/modules/esl-utils/dom/test/attr.test.ts @@ -1,4 +1,4 @@ -import {hasAttr, getAttr, setAttr} from '../attr'; +import {hasAttr, getAttr, setAttr, getClosestAttr} from '../attr'; describe('Attribute', () => { const attrName = 'test-attr'; @@ -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); + }); + }); });