Skip to content

Commit

Permalink
Add Element.toggleAttribute() polyfill and support in Shady DOM (#541)
Browse files Browse the repository at this point in the history
  • Loading branch information
justinfagnani authored Mar 29, 2023
1 parent 2474d19 commit ee3a436
Show file tree
Hide file tree
Showing 19 changed files with 538 additions and 4 deletions.
4 changes: 3 additions & 1 deletion packages/custom-elements/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

<!-- ## Unreleased -->
## Unreleased

- Add support for `Element.toggleAttribute()` ([#541](https://github.com/webcomponents/polyfills/pull/541))

## [1.5.1] - 2022-10-20

Expand Down
31 changes: 31 additions & 0 deletions packages/custom-elements/ts_src/Patch/Element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,37 @@ export default function (internals: CustomElementInternals) {
}
};

if (Native.Element_toggleAttribute) {
Element.prototype.toggleAttribute = function (
this: Element,
name,
force?: boolean | undefined
) {
// Fast path for non-custom elements.
if (this.__CE_state !== CEState.custom) {
return Native.Element_toggleAttribute.call(this, name, force);
}

const oldValue = Native.Element_getAttribute.call(this, name);
const hadAttribute = oldValue !== null;
const hasAttribute = Native.Element_toggleAttribute.call(
this,
name,
force
);
if (hadAttribute !== hasAttribute) {
internals.attributeChangedCallback(
this,
name,
oldValue,
hasAttribute ? '' : null,
null
);
}
return hasAttribute;
};
}

Element.prototype.removeAttributeNS = function (
this: Element,
namespace,
Expand Down
1 change: 1 addition & 0 deletions packages/custom-elements/ts_src/Patch/Native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const Element_innerHTML = Object.getOwnPropertyDescriptor(
export const Element_getAttribute = window.Element.prototype.getAttribute;
export const Element_setAttribute = window.Element.prototype.setAttribute;
export const Element_removeAttribute = window.Element.prototype.removeAttribute;
export const Element_toggleAttribute = window.Element.prototype.toggleAttribute;
export const Element_getAttributeNS = window.Element.prototype.getAttributeNS;
export const Element_setAttributeNS = window.Element.prototype.setAttributeNS;
export const Element_removeAttributeNS =
Expand Down
4 changes: 3 additions & 1 deletion packages/shadydom/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

<!-- ## Unreleased -->
## Unreleased

- Add support for `Element.toggleAttribute()` ([#541](https://github.com/webcomponents/polyfills/pull/541))

## [1.10.0] - 2022-10-20

Expand Down
1 change: 1 addition & 0 deletions packages/shadydom/src/patch-native.js
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,7 @@ export const addNativePrefixedProperties = () => {
'getAttribute',
'hasAttribute',
'removeAttribute',
'toggleAttribute',
// on older Safari, these are on Element.
'focus',
'blur',
Expand Down
28 changes: 27 additions & 1 deletion packages/shadydom/src/patches/Element.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,36 @@ export const ElementPatches = utils.getOwnPropertyDescriptors({
this[utils.NATIVE_PREFIX + 'removeAttribute'](attr);
distributeAttributeChange(this, attr);
} else if (this.getAttribute(attr) === '') {
// ensure that "class" attribute is fully removed if ShadyCSS does not keep scoping
// When attr='class', scopeClassAttribute() will handle the change as a
// side-effect and return `true`, allowing us to get to this branch,
// which cleans up the 'class' attribute if it's empty and we were
// supposed to have removed it.
this[utils.NATIVE_PREFIX + 'removeAttribute'](attr);
}
},

/**
* @this {Element}
* @param {string} attr
* @param {boolean | undefined} force
*/
toggleAttribute(attr, force) {
if (this.ownerDocument !== doc) {
return this[utils.NATIVE_PREFIX + 'toggleAttribute'](attr, force);
} else if (!scopeClassAttribute(this, attr, '')) {
const result = this[utils.NATIVE_PREFIX + 'toggleAttribute'](attr, force);
distributeAttributeChange(this, attr);
return result;
} else if (this.getAttribute(attr) === '' && !force) {
// When attr='class', scopeClassAttribute() will handle the change as a
// side-effect and return `true`, allowing us to get to this branch,
// which cleans up the 'class' attribute if it's empty and we were
// supposed to have removed it.
// We check for `force` being falsey because we only want to clean up on
// removal of the attribute.
return this[utils.NATIVE_PREFIX + 'toggleAttribute'](attr, force);
}
},
});

if (!utils.settings.preferPerformance) {
Expand Down
4 changes: 4 additions & 0 deletions packages/shadydom/src/wrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ class Wrapper {
this.node[utils.SHADY_PREFIX + 'removeAttribute'](name);
}

toggleAttribute(name, force) {
return this.node[utils.SHADY_PREFIX + 'toggleAttribute'](name, force);
}

attachShadow(options) {
return this.node[utils.SHADY_PREFIX + 'attachShadow'](options);
}
Expand Down
266 changes: 266 additions & 0 deletions packages/tests/custom-elements/html/Element/toggleAttribute.test.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
<!DOCTYPE html>
<html>
<head>
<title>Element#toggleAttribute</title>
<script>
(window.customElements =
window.customElements || {}).forcePolyfill = true;
</script>
<script src="../../../node_modules/@webcomponents/webcomponentsjs/bundles/webcomponents-pf_js.js"></script>
<script src="../../../node_modules/@webcomponents/custom-elements/custom-elements.min.js"></script>
</head>
<body>
<script type="module">
import {runTests, assert} from '../../../environment.js';
import {safariGCBugWorkaround} from '../../safari-gc-bug-workaround.js';

runTests(async () => {
suiteSetup(() => safariGCBugWorkaround());

function generateLocalName() {
return 'test-element-' + Math.random().toString(32).substring(2);
}

function defineWithLocalName(localName, observedAttributes) {
customElements.define(
localName,
class extends HTMLElement {
static get observedAttributes() {
return observedAttributes;
}

constructor() {
super();
this.constructed = true;
this.connectedCallbackCount = 0;
this.disconnectedCallbackCount = 0;
this.attrCallbackArgs = [];
}

connectedCallback() {
this.connectedCallbackCount++;
}

disconnectedCallback() {
this.disconnectedCallbackCount++;
}

attributeChangedCallback(name, oldValue, newValue, namespace) {
this.attrCallbackArgs.push(
Array.prototype.slice.apply(arguments)
);
}
}
);
}

const hasToggleAttribute =
Element.prototype.toggleAttribute instanceof Function;
const testFn = hasToggleAttribute ? test : test.skip;

suite('Toggling an unset attribute.', () => {
let localName;

setup(() => {
localName = generateLocalName();
defineWithLocalName(localName, ['attr']);
});

testFn(
'Toggling an attribute with no value (null) adds the attribute and triggers a callback.',
() => {
const element = document.createElement(localName);

let result = element.toggleAttribute('attr');

assert.equal(result, true);
assert.equal(element.getAttribute('attr'), '');
assert.equal(element.attrCallbackArgs.length, 1);
assert.deepEqual(element.attrCallbackArgs[0], [
'attr',
null,
'',
null,
]);
}
);

testFn(
'Toggling (force: true) an attribute with no value (null) adds the attribute and triggers a callback.',
() => {
const element = document.createElement(localName);

let result = element.toggleAttribute('attr', true);

assert.equal(result, true);
assert.equal(element.getAttribute('attr'), '');
assert.equal(element.attrCallbackArgs.length, 1);
assert.deepEqual(element.attrCallbackArgs[0], [
'attr',
null,
'',
null,
]);
}
);

testFn(
'Toggling (force: false) an attribute with no value (null) does not trigger a callback.',
() => {
const element = document.createElement(localName);

let result = element.toggleAttribute('attr', false);

assert.equal(result, false);
assert.equal(element.getAttribute('attr'), null);
assert.equal(element.attrCallbackArgs.length, 0);
}
);
});

suite('Toggling a set attribute.', () => {
let localName1;
let localName2;

setup(() => {
localName1 = generateLocalName();
defineWithLocalName(localName1, []);
localName2 = generateLocalName();
defineWithLocalName(localName2, ['attr']);
});

testFn(
'Toggling an unobserved attribute removes the attribute but does not trigger a callback.',
() => {
const element = document.createElement(localName1);

assert.equal(element.attrCallbackArgs.length, 0);

element.setAttribute('attr', 'abc');

assert.equal(element.attrCallbackArgs.length, 0);

let result = element.toggleAttribute('attr');

assert.equal(result, false);
assert.equal(element.getAttribute('attr'), null);
assert.equal(element.attrCallbackArgs.length, 0);
}
);

testFn(
'Toggling (force: true) an unobserved attribute does not change the attribute and does not trigger a callback.',
() => {
const element = document.createElement(localName1);

assert.equal(element.attrCallbackArgs.length, 0);

element.setAttribute('attr', 'abc');

assert.equal(element.getAttribute('attr'), 'abc');
assert.equal(element.attrCallbackArgs.length, 0);

let result = element.toggleAttribute('attr', true);

assert.equal(result, true);
assert.equal(element.getAttribute('attr'), 'abc');
assert.equal(element.attrCallbackArgs.length, 0);
}
);

testFn(
'Toggling (force: false) an unobserved attribute removes the attribute but does not trigger a callback.',
() => {
const element = document.createElement(localName1);

assert.equal(element.attrCallbackArgs.length, 0);

element.setAttribute('attr', 'abc');

assert.equal(element.getAttribute('attr'), 'abc');
assert.equal(element.attrCallbackArgs.length, 0);

let result = element.toggleAttribute('attr', false);

assert.equal(result, false);
assert.equal(element.getAttribute('attr'), null);
assert.equal(element.attrCallbackArgs.length, 0);
}
);

testFn(
'Toggling an observed attribute removes the attribute and triggers a callback.',
() => {
const element = document.createElement(localName2);

assert.equal(element.attrCallbackArgs.length, 0);

element.setAttribute('attr', 'abc');

assert.equal(element.getAttribute('attr'), 'abc');
assert.equal(element.attrCallbackArgs.length, 1);

let result = element.toggleAttribute('attr');

assert.equal(result, false);
assert.equal(element.getAttribute('attr'), null);
assert.equal(element.attrCallbackArgs.length, 2);
assert.deepEqual(element.attrCallbackArgs[1], [
'attr',
'abc',
null,
null,
]);
}
);

testFn(
'Toggling (force: true) an observed attribute does not change the attribute and does not trigger a callback.',
() => {
const element = document.createElement(localName2);

assert.equal(element.attrCallbackArgs.length, 0);

element.setAttribute('attr', 'abc');

assert.equal(element.getAttribute('attr'), 'abc');
assert.equal(element.attrCallbackArgs.length, 1);

let result = element.toggleAttribute('attr', true);

assert.equal(result, true);
assert.equal(element.getAttribute('attr'), 'abc');
assert.equal(element.attrCallbackArgs.length, 1);
}
);

testFn(
'Toggling (force: false) an observed attribute removes the attribute and triggers a callback.',
() => {
const element = document.createElement(localName2);

assert.equal(element.attrCallbackArgs.length, 0);

element.setAttribute('attr', 'abc');

assert.equal(element.getAttribute('attr'), 'abc');
assert.equal(element.attrCallbackArgs.length, 1);

let result = element.toggleAttribute('attr', false);

assert.equal(result, false);
assert.equal(element.getAttribute('attr'), null);
assert.equal(element.attrCallbackArgs.length, 2);
assert.deepEqual(element.attrCallbackArgs[1], [
'attr',
'abc',
null,
null,
]);
}
);
});
});
</script>
</body>
</html>
Loading

0 comments on commit ee3a436

Please sign in to comment.