Skip to content

Commit ee3a436

Browse files
Add Element.toggleAttribute() polyfill and support in Shady DOM (webcomponents#541)
1 parent 2474d19 commit ee3a436

19 files changed

+538
-4
lines changed

packages/custom-elements/CHANGELOG.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
<!-- ## Unreleased -->
8+
## Unreleased
9+
10+
- Add support for `Element.toggleAttribute()` ([#541](https://github.com/webcomponents/polyfills/pull/541))
911

1012
## [1.5.1] - 2022-10-20
1113

packages/custom-elements/ts_src/Patch/Element.ts

+31
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,37 @@ export default function (internals: CustomElementInternals) {
179179
}
180180
};
181181

182+
if (Native.Element_toggleAttribute) {
183+
Element.prototype.toggleAttribute = function (
184+
this: Element,
185+
name,
186+
force?: boolean | undefined
187+
) {
188+
// Fast path for non-custom elements.
189+
if (this.__CE_state !== CEState.custom) {
190+
return Native.Element_toggleAttribute.call(this, name, force);
191+
}
192+
193+
const oldValue = Native.Element_getAttribute.call(this, name);
194+
const hadAttribute = oldValue !== null;
195+
const hasAttribute = Native.Element_toggleAttribute.call(
196+
this,
197+
name,
198+
force
199+
);
200+
if (hadAttribute !== hasAttribute) {
201+
internals.attributeChangedCallback(
202+
this,
203+
name,
204+
oldValue,
205+
hasAttribute ? '' : null,
206+
null
207+
);
208+
}
209+
return hasAttribute;
210+
};
211+
}
212+
182213
Element.prototype.removeAttributeNS = function (
183214
this: Element,
184215
namespace,

packages/custom-elements/ts_src/Patch/Native.ts

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export const Element_innerHTML = Object.getOwnPropertyDescriptor(
3737
export const Element_getAttribute = window.Element.prototype.getAttribute;
3838
export const Element_setAttribute = window.Element.prototype.setAttribute;
3939
export const Element_removeAttribute = window.Element.prototype.removeAttribute;
40+
export const Element_toggleAttribute = window.Element.prototype.toggleAttribute;
4041
export const Element_getAttributeNS = window.Element.prototype.getAttributeNS;
4142
export const Element_setAttributeNS = window.Element.prototype.setAttributeNS;
4243
export const Element_removeAttributeNS =

packages/shadydom/CHANGELOG.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
<!-- ## Unreleased -->
8+
## Unreleased
9+
10+
- Add support for `Element.toggleAttribute()` ([#541](https://github.com/webcomponents/polyfills/pull/541))
911

1012
## [1.10.0] - 2022-10-20
1113

packages/shadydom/src/patch-native.js

+1
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,7 @@ export const addNativePrefixedProperties = () => {
397397
'getAttribute',
398398
'hasAttribute',
399399
'removeAttribute',
400+
'toggleAttribute',
400401
// on older Safari, these are on Element.
401402
'focus',
402403
'blur',

packages/shadydom/src/patches/Element.js

+27-1
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,36 @@ export const ElementPatches = utils.getOwnPropertyDescriptors({
116116
this[utils.NATIVE_PREFIX + 'removeAttribute'](attr);
117117
distributeAttributeChange(this, attr);
118118
} else if (this.getAttribute(attr) === '') {
119-
// ensure that "class" attribute is fully removed if ShadyCSS does not keep scoping
119+
// When attr='class', scopeClassAttribute() will handle the change as a
120+
// side-effect and return `true`, allowing us to get to this branch,
121+
// which cleans up the 'class' attribute if it's empty and we were
122+
// supposed to have removed it.
120123
this[utils.NATIVE_PREFIX + 'removeAttribute'](attr);
121124
}
122125
},
126+
127+
/**
128+
* @this {Element}
129+
* @param {string} attr
130+
* @param {boolean | undefined} force
131+
*/
132+
toggleAttribute(attr, force) {
133+
if (this.ownerDocument !== doc) {
134+
return this[utils.NATIVE_PREFIX + 'toggleAttribute'](attr, force);
135+
} else if (!scopeClassAttribute(this, attr, '')) {
136+
const result = this[utils.NATIVE_PREFIX + 'toggleAttribute'](attr, force);
137+
distributeAttributeChange(this, attr);
138+
return result;
139+
} else if (this.getAttribute(attr) === '' && !force) {
140+
// When attr='class', scopeClassAttribute() will handle the change as a
141+
// side-effect and return `true`, allowing us to get to this branch,
142+
// which cleans up the 'class' attribute if it's empty and we were
143+
// supposed to have removed it.
144+
// We check for `force` being falsey because we only want to clean up on
145+
// removal of the attribute.
146+
return this[utils.NATIVE_PREFIX + 'toggleAttribute'](attr, force);
147+
}
148+
},
123149
});
124150

125151
if (!utils.settings.preferPerformance) {

packages/shadydom/src/wrapper.js

+4
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ class Wrapper {
8787
this.node[utils.SHADY_PREFIX + 'removeAttribute'](name);
8888
}
8989

90+
toggleAttribute(name, force) {
91+
return this.node[utils.SHADY_PREFIX + 'toggleAttribute'](name, force);
92+
}
93+
9094
attachShadow(options) {
9195
return this.node[utils.SHADY_PREFIX + 'attachShadow'](options);
9296
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<title>Element#toggleAttribute</title>
5+
<script>
6+
(window.customElements =
7+
window.customElements || {}).forcePolyfill = true;
8+
</script>
9+
<script src="../../../node_modules/@webcomponents/webcomponentsjs/bundles/webcomponents-pf_js.js"></script>
10+
<script src="../../../node_modules/@webcomponents/custom-elements/custom-elements.min.js"></script>
11+
</head>
12+
<body>
13+
<script type="module">
14+
import {runTests, assert} from '../../../environment.js';
15+
import {safariGCBugWorkaround} from '../../safari-gc-bug-workaround.js';
16+
17+
runTests(async () => {
18+
suiteSetup(() => safariGCBugWorkaround());
19+
20+
function generateLocalName() {
21+
return 'test-element-' + Math.random().toString(32).substring(2);
22+
}
23+
24+
function defineWithLocalName(localName, observedAttributes) {
25+
customElements.define(
26+
localName,
27+
class extends HTMLElement {
28+
static get observedAttributes() {
29+
return observedAttributes;
30+
}
31+
32+
constructor() {
33+
super();
34+
this.constructed = true;
35+
this.connectedCallbackCount = 0;
36+
this.disconnectedCallbackCount = 0;
37+
this.attrCallbackArgs = [];
38+
}
39+
40+
connectedCallback() {
41+
this.connectedCallbackCount++;
42+
}
43+
44+
disconnectedCallback() {
45+
this.disconnectedCallbackCount++;
46+
}
47+
48+
attributeChangedCallback(name, oldValue, newValue, namespace) {
49+
this.attrCallbackArgs.push(
50+
Array.prototype.slice.apply(arguments)
51+
);
52+
}
53+
}
54+
);
55+
}
56+
57+
const hasToggleAttribute =
58+
Element.prototype.toggleAttribute instanceof Function;
59+
const testFn = hasToggleAttribute ? test : test.skip;
60+
61+
suite('Toggling an unset attribute.', () => {
62+
let localName;
63+
64+
setup(() => {
65+
localName = generateLocalName();
66+
defineWithLocalName(localName, ['attr']);
67+
});
68+
69+
testFn(
70+
'Toggling an attribute with no value (null) adds the attribute and triggers a callback.',
71+
() => {
72+
const element = document.createElement(localName);
73+
74+
let result = element.toggleAttribute('attr');
75+
76+
assert.equal(result, true);
77+
assert.equal(element.getAttribute('attr'), '');
78+
assert.equal(element.attrCallbackArgs.length, 1);
79+
assert.deepEqual(element.attrCallbackArgs[0], [
80+
'attr',
81+
null,
82+
'',
83+
null,
84+
]);
85+
}
86+
);
87+
88+
testFn(
89+
'Toggling (force: true) an attribute with no value (null) adds the attribute and triggers a callback.',
90+
() => {
91+
const element = document.createElement(localName);
92+
93+
let result = element.toggleAttribute('attr', true);
94+
95+
assert.equal(result, true);
96+
assert.equal(element.getAttribute('attr'), '');
97+
assert.equal(element.attrCallbackArgs.length, 1);
98+
assert.deepEqual(element.attrCallbackArgs[0], [
99+
'attr',
100+
null,
101+
'',
102+
null,
103+
]);
104+
}
105+
);
106+
107+
testFn(
108+
'Toggling (force: false) an attribute with no value (null) does not trigger a callback.',
109+
() => {
110+
const element = document.createElement(localName);
111+
112+
let result = element.toggleAttribute('attr', false);
113+
114+
assert.equal(result, false);
115+
assert.equal(element.getAttribute('attr'), null);
116+
assert.equal(element.attrCallbackArgs.length, 0);
117+
}
118+
);
119+
});
120+
121+
suite('Toggling a set attribute.', () => {
122+
let localName1;
123+
let localName2;
124+
125+
setup(() => {
126+
localName1 = generateLocalName();
127+
defineWithLocalName(localName1, []);
128+
localName2 = generateLocalName();
129+
defineWithLocalName(localName2, ['attr']);
130+
});
131+
132+
testFn(
133+
'Toggling an unobserved attribute removes the attribute but does not trigger a callback.',
134+
() => {
135+
const element = document.createElement(localName1);
136+
137+
assert.equal(element.attrCallbackArgs.length, 0);
138+
139+
element.setAttribute('attr', 'abc');
140+
141+
assert.equal(element.attrCallbackArgs.length, 0);
142+
143+
let result = element.toggleAttribute('attr');
144+
145+
assert.equal(result, false);
146+
assert.equal(element.getAttribute('attr'), null);
147+
assert.equal(element.attrCallbackArgs.length, 0);
148+
}
149+
);
150+
151+
testFn(
152+
'Toggling (force: true) an unobserved attribute does not change the attribute and does not trigger a callback.',
153+
() => {
154+
const element = document.createElement(localName1);
155+
156+
assert.equal(element.attrCallbackArgs.length, 0);
157+
158+
element.setAttribute('attr', 'abc');
159+
160+
assert.equal(element.getAttribute('attr'), 'abc');
161+
assert.equal(element.attrCallbackArgs.length, 0);
162+
163+
let result = element.toggleAttribute('attr', true);
164+
165+
assert.equal(result, true);
166+
assert.equal(element.getAttribute('attr'), 'abc');
167+
assert.equal(element.attrCallbackArgs.length, 0);
168+
}
169+
);
170+
171+
testFn(
172+
'Toggling (force: false) an unobserved attribute removes the attribute but does not trigger a callback.',
173+
() => {
174+
const element = document.createElement(localName1);
175+
176+
assert.equal(element.attrCallbackArgs.length, 0);
177+
178+
element.setAttribute('attr', 'abc');
179+
180+
assert.equal(element.getAttribute('attr'), 'abc');
181+
assert.equal(element.attrCallbackArgs.length, 0);
182+
183+
let result = element.toggleAttribute('attr', false);
184+
185+
assert.equal(result, false);
186+
assert.equal(element.getAttribute('attr'), null);
187+
assert.equal(element.attrCallbackArgs.length, 0);
188+
}
189+
);
190+
191+
testFn(
192+
'Toggling an observed attribute removes the attribute and triggers a callback.',
193+
() => {
194+
const element = document.createElement(localName2);
195+
196+
assert.equal(element.attrCallbackArgs.length, 0);
197+
198+
element.setAttribute('attr', 'abc');
199+
200+
assert.equal(element.getAttribute('attr'), 'abc');
201+
assert.equal(element.attrCallbackArgs.length, 1);
202+
203+
let result = element.toggleAttribute('attr');
204+
205+
assert.equal(result, false);
206+
assert.equal(element.getAttribute('attr'), null);
207+
assert.equal(element.attrCallbackArgs.length, 2);
208+
assert.deepEqual(element.attrCallbackArgs[1], [
209+
'attr',
210+
'abc',
211+
null,
212+
null,
213+
]);
214+
}
215+
);
216+
217+
testFn(
218+
'Toggling (force: true) an observed attribute does not change the attribute and does not trigger a callback.',
219+
() => {
220+
const element = document.createElement(localName2);
221+
222+
assert.equal(element.attrCallbackArgs.length, 0);
223+
224+
element.setAttribute('attr', 'abc');
225+
226+
assert.equal(element.getAttribute('attr'), 'abc');
227+
assert.equal(element.attrCallbackArgs.length, 1);
228+
229+
let result = element.toggleAttribute('attr', true);
230+
231+
assert.equal(result, true);
232+
assert.equal(element.getAttribute('attr'), 'abc');
233+
assert.equal(element.attrCallbackArgs.length, 1);
234+
}
235+
);
236+
237+
testFn(
238+
'Toggling (force: false) an observed attribute removes the attribute and triggers a callback.',
239+
() => {
240+
const element = document.createElement(localName2);
241+
242+
assert.equal(element.attrCallbackArgs.length, 0);
243+
244+
element.setAttribute('attr', 'abc');
245+
246+
assert.equal(element.getAttribute('attr'), 'abc');
247+
assert.equal(element.attrCallbackArgs.length, 1);
248+
249+
let result = element.toggleAttribute('attr', false);
250+
251+
assert.equal(result, false);
252+
assert.equal(element.getAttribute('attr'), null);
253+
assert.equal(element.attrCallbackArgs.length, 2);
254+
assert.deepEqual(element.attrCallbackArgs[1], [
255+
'attr',
256+
'abc',
257+
null,
258+
null,
259+
]);
260+
}
261+
);
262+
});
263+
});
264+
</script>
265+
</body>
266+
</html>

0 commit comments

Comments
 (0)