name-attribute.html (18074B)
1 <!DOCTYPE HTML> 2 <meta charset=UTF-8> 3 <title>Test for the name attribute creating exclusive accordions from details elements</title> 4 <link rel="author" title="L. David Baron" href="https://dbaron.org/"> 5 <link rel="author" title="Google" href="http://www.google.com/"> 6 <link rel="help" href="https://html.spec.whatwg.org/multipage/#the-details-element"> 7 <link rel="help" href="https://open-ui.org/components/accordion.explainer"> 8 <link rel="help" href="https://github.com/openui/open-ui/issues/725"> 9 <link rel="help" href="https://bugs.chromium.org/p/chromium/issues/detail?id=1444057"> 10 <script src="/resources/testharness.js"></script> 11 <script src="/resources/testharnessreport.js"></script> 12 13 <div id="container"> 14 </div> 15 16 <script> 17 18 function assert_element_states(elements, expectations, description) { 19 assert_array_equals(elements.map(e => Number(e.open)), expectations, description); 20 } 21 22 let container = document.getElementById("container"); 23 24 promise_test(async t => { 25 container.innerHTML = ` 26 <details name="a"> 27 <summary>1</summary> 28 This is the first item. 29 </details> 30 31 <details name="a"> 32 <summary>2</summary> 33 This is the second item. 34 </details> 35 `; 36 let first = container.firstElementChild; 37 let second = first.nextElementSibling; 38 assert_false(first.open); 39 assert_false(second.open); 40 first.open = true; 41 assert_true(first.open); 42 assert_false(second.open); 43 second.open = true; 44 assert_false(first.open); 45 assert_true(second.open); 46 second.open = true; 47 assert_false(first.open); 48 assert_true(second.open); 49 second.open = false; 50 assert_false(first.open); 51 assert_false(second.open); 52 }, "basic handling of mutually exclusive details"); 53 54 promise_test(async t => { 55 container.innerHTML = ` 56 <details name="a" open> 57 <summary>1</summary> 58 This is the first item. 59 </details> 60 61 <details name="a"> 62 <summary>2</summary> 63 This is the second item. 64 </details> 65 66 <details name="a" open> 67 <summary>3</summary> 68 This is the third item. 69 </details> 70 `; 71 let first = container.firstElementChild; 72 let second = first.nextElementSibling; 73 let third = second.nextElementSibling; 74 function assert_states(expected_first, expected_second, expected_third, description) { 75 assert_array_equals([first.open, second.open, third.open], [expected_first, expected_second, expected_third], description); 76 } 77 78 assert_states(true, false, false, "initial states from open attribute"); 79 first.open = true; 80 assert_states(true, false, false, "non-mutation doesn't change state"); 81 second.open = true; 82 assert_states(false, true, false, "mutation closes multiple open elements"); 83 third.setAttribute("open", ""); 84 assert_states(false, false, true, "setAttribute closes other open element"); 85 }, "more complex handling of mutually exclusive details"); 86 87 promise_test(async t => { 88 let details_elements_string = ` 89 <details name="a"></details> 90 <details name="a" open></details> 91 <details name="b"></details> 92 <details name="b"></details> 93 `; 94 container.innerHTML = ` 95 ${details_elements_string} 96 <div id="shadow_host"></div> 97 `; 98 let shadow_root = document.getElementById("shadow_host").attachShadow({ mode: "open" }); 99 shadow_root.innerHTML = details_elements_string; 100 let elements = Array.from(container.querySelectorAll("details")).concat(Array.from(shadow_root.querySelectorAll("details"))); 101 102 assert_element_states(elements, [0, 1, 0, 0, 0, 1, 0, 0], "initial states from open attribute"); 103 elements[4].open = true; 104 assert_element_states(elements, [0, 1, 0, 0, 1, 0, 0, 0], "after mutation in shadow tree"); 105 for (let i = 0; i < 8; ++i) { 106 elements[i].open = true; 107 } 108 assert_element_states(elements, [0, 1, 0, 1, 0, 1, 0, 1], "after setting all elements open"); 109 elements[0].open = true; 110 assert_element_states(elements, [1, 0, 0, 1, 0, 1, 0, 1], "after final mutation"); 111 }, "mutually exclusive details across multiple names and multiple tree scopes"); 112 113 promise_test(async t => { 114 container.innerHTML = ` 115 <details name="a" id="e0" open></details> 116 <details name="a" id="e1"></details> 117 <details name="a" id="e3" open></details> 118 `; 119 let e2 = document.createElement("details"); 120 e2.id = "e2"; 121 e2.name = "a"; 122 e2.open = true; 123 let elements = [ document.getElementById("e0"), 124 document.getElementById("e1"), 125 e2, 126 document.getElementById("e3") ]; 127 container.insertBefore(e2, elements[3]); 128 129 let mutation_event_received_ids = []; 130 let mutation_listener = event => { 131 assert_equals(event.type, "DOMSubtreeModified"); 132 assert_equals(event.target.nodeType, Node.ELEMENT_NODE); 133 let element = event.target; 134 assert_equals(element.localName, "details"); 135 mutation_event_received_ids.push(element.id); 136 }; 137 let toggle_event_received_ids = []; 138 let toggle_event_promises = []; 139 for (let element of elements) { 140 element.addEventListener("DOMSubtreeModified", mutation_listener); 141 toggle_event_promises.push(new Promise((resolve, reject) => { 142 element.addEventListener("toggle", event => { 143 assert_equals(event.type, "toggle"); 144 assert_equals(event.target, element); 145 toggle_event_received_ids.push(element.id); 146 resolve(undefined); 147 }); 148 })); 149 } 150 assert_array_equals(mutation_event_received_ids, []); 151 assert_element_states(elements, [1, 0, 0, 0], "states before mutation"); 152 elements[1].open = true; 153 if (mutation_event_received_ids.length == 0) { 154 // ok if mutation events are not supported 155 } else { 156 assert_array_equals(mutation_event_received_ids, ["e1"], 157 "mutation events received only for open attribute mutation and not for closing other element"); 158 } 159 assert_element_states(elements, [0, 1, 0, 0], "states after mutation"); 160 assert_array_equals(toggle_event_received_ids, [], "toggle events received before awaiting promises"); 161 await Promise.all(toggle_event_promises); 162 assert_array_equals(toggle_event_received_ids, ["e3", "e2", "e1", "e0"], "toggle events received after awaiting promises, including toggle events from parser insertion"); 163 }, "mutation event and toggle event order"); 164 165 // This function is used to guard tests that test behavior that is 166 // relevant only because of Mutation Events. If mutation events (for 167 // attribute addition/removal) are removed from the web, the tests using 168 // this function can be removed. 169 function mutation_events_for_attribute_removal_supported() { 170 if (!("MutationEvent" in window)) { 171 return false; 172 } 173 container.innerHTML = `<div id="event-removal-test"></div>`; 174 let element = container.firstChild; 175 let event_fired = false; 176 element.addEventListener("DOMSubtreeModified", event => event_fired = true); 177 element.removeAttribute("id"); 178 return event_fired; 179 } 180 181 promise_test(async t => { 182 if (!mutation_events_for_attribute_removal_supported()) { 183 return; 184 } 185 container.innerHTML = ` 186 <details name="a" id="e0" open></details> 187 <details name="a" id="e1"></details> 188 <details name="a" id="e2" open></details> 189 `; 190 let elements = [ document.getElementById("e0"), 191 document.getElementById("e1"), 192 document.getElementById("e2") ]; 193 194 let received_ids = []; 195 let listener = event => { 196 received_ids.push(event.target.id); 197 }; 198 for (let element of elements) { 199 element.addEventListener("DOMSubtreeModified", listener); 200 } 201 assert_array_equals(received_ids, []); 202 assert_element_states(elements, [1, 0, 0], "states before mutation"); 203 elements[1].open = true; 204 assert_array_equals(received_ids, ["e1"], 205 "mutation events received only for open attribute mutation and not for closing other element"); 206 assert_element_states(elements, [0, 1, 0], "states after mutation"); 207 }, "interaction of open attribute changes with mutation events"); 208 209 promise_test(async t => { 210 container.innerHTML = ` 211 <details></details> 212 <details></details> 213 <details name></details> 214 <details name></details> 215 <details name=""></details> 216 <details name=""></details> 217 `; 218 let elements = Array.from(container.querySelectorAll("details")); 219 220 assert_element_states(elements, [0, 0, 0, 0, 0, 0], "initial states from open attribute"); 221 for (let i = 0; i < 6; ++i) { 222 elements[i].open = true; 223 } 224 assert_element_states(elements, [1, 1, 1, 1, 1, 1], "after setting all elements open"); 225 }, "empty and missing name attributes do not create groups"); 226 227 const connected_scenarios = { 228 "connected": { 229 "create": data => container, 230 "cleanup": data => {}, 231 }, 232 "disconnected": { 233 "create": data => document.createElement("div"), 234 "cleanup": data => {}, 235 }, 236 "shadow": { 237 "create": data => { 238 let e = document.createElement("div"); 239 container.appendChild(e); 240 data.wrapper = e; 241 let shadowRoot = e.attachShadow({ mode: "open" }); 242 let d = document.createElement("div"); 243 shadowRoot.appendChild(d); 244 return d; 245 }, 246 "cleanup": data => { data.wrapper.remove(); }, 247 }, 248 "shadow-in-disconnected": { 249 "create": data => { 250 let e = document.createElement("div"); 251 let shadowRoot = e.attachShadow({ mode: "open" }); 252 let d = document.createElement("div"); 253 shadowRoot.appendChild(d); 254 return d; 255 }, 256 "cleanup": data => {}, 257 }, 258 "template-in-disconnected": { 259 "create": data => { 260 let e = document.createElement("div"); 261 e.innerHTML = ` 262 <template> 263 <div></div> 264 </template> 265 `; 266 return e.firstElementChild.content.firstElementChild; 267 }, 268 "cleanup": data => {}, 269 }, 270 "connected-in-xhr-response": { 271 "create": data => new Promise((resolve, reject) => { 272 let xhr = new XMLHttpRequest(); 273 xhr.open("GET", "support/empty-html-document.html"); 274 xhr.responseType = "document"; 275 xhr.send(); 276 xhr.addEventListener("load", event => { resolve(xhr.response.body); }); 277 let reject_with_type = 278 event => { reject(`${event.type} event received`); } 279 xhr.addEventListener("error", reject_with_type); 280 xhr.addEventListener("abort", reject_with_type); 281 }), 282 "cleanup": data => {}, 283 }, 284 "connected-in-implementation-create-document": { 285 "create": data => { 286 let doc = document.implementation.createHTMLDocument("impl-created"); 287 return doc.body; 288 }, 289 "cleanup": data => {}, 290 }, 291 "connected-in-template": { 292 "create": data => { 293 container.innerHTML = ` 294 <template> 295 <div></div> 296 </template> 297 `; 298 return container.firstElementChild.content.firstElementChild; 299 }, 300 "cleanup": data => { container.innerHTML = ""; }, 301 }, 302 }; 303 304 for (const [scenario, scenario_callbacks] of Object.entries(connected_scenarios)) { 305 promise_test(async t => { 306 let data = {}; 307 let container = await scenario_callbacks.create(data); 308 t.add_cleanup(async () => await scenario_callbacks.cleanup(data)); 309 assert_true(container instanceof HTMLDivElement || 310 container instanceof HTMLBodyElement, 311 "error in test setup"); 312 313 container.innerHTML = ` 314 <details name="scenariotest" open></details> 315 <details name="scenariotest"></details> 316 `; 317 318 let elements = Array.from(container.querySelectorAll("details[name='scenariotest']")); 319 assert_element_states(elements, [1, 0], "state before toggle"); 320 elements[1].open = true; 321 assert_element_states(elements, [0, 1], "state after toggle enforces exclusivity"); 322 }, `exclusivity enforcement with attachment scenario ${scenario}`); 323 } 324 325 promise_test(async t => { 326 container.innerHTML = ` 327 <details name="a" id="e0" open></details> 328 <details name="a" id="e1"></details> 329 <details name="b" id="e2" open></details> 330 `; 331 let elements = [ document.getElementById("e0"), 332 document.getElementById("e1"), 333 document.getElementById("e2") ]; 334 335 let mutation_received_ids = []; 336 let listener = event => { 337 mutation_received_ids.push(event.target.id); 338 }; 339 for (let element of elements) { 340 element.addEventListener("DOMSubtreeModified", listener); 341 } 342 343 assert_element_states(elements, [1, 0, 1], "states before first mutation"); 344 assert_array_equals(mutation_received_ids, [], "mutation events received before first mutation"); 345 elements[2].name = "a"; 346 assert_element_states(elements, [1, 0, 0], "states after first mutation"); 347 if (mutation_received_ids.length != 0) { 348 // OK to not support mutation events, or to send DOMSubtreeModified 349 // only for attribute addition/removal (open) but not for attribute 350 // change (name) 351 assert_array_equals(mutation_received_ids, ["e2"], "mutation events received after first mutation"); 352 } 353 elements[0].name = "c"; 354 elements[2].open = true; 355 assert_element_states(elements, [1, 0, 1], "states before second mutation"); 356 if (mutation_received_ids.length != 0) { // OK to not support mutation events 357 if (mutation_received_ids.length == 1) { 358 // OK to receive DOMSubtreeModified for attribute addition/removal 359 // (open) but not for attribute change (name) 360 assert_array_equals(mutation_received_ids, ["e2"], "mutation events received before second mutation"); 361 } else { 362 assert_array_equals(mutation_received_ids, ["e2", "e0", "e2"], "mutation events received before second mutation"); 363 } 364 } 365 elements[0].name = "a"; 366 assert_element_states(elements, [0, 0, 1], "states after second mutation"); 367 if (mutation_received_ids.length != 0) { // OK to not support mutation events 368 if (mutation_received_ids.length == 1) { 369 // OK to receive DOMSubtreeModified for attribute addition/removal 370 // (open) but not for attribute change (name) 371 assert_array_equals(mutation_received_ids, ["e2"], "mutation events received before second mutation"); 372 } else { 373 assert_array_equals(mutation_received_ids, ["e2", "e0", "e2", "e0"], "mutation events received after second mutation"); 374 } 375 } 376 }, "handling of name attribute changes"); 377 378 promise_test(async t => { 379 container.innerHTML = ` 380 <details name="a" id="e0" open></details> 381 <details name="a" id="e1" open></details> 382 <details open name="a" id="e2"></details> 383 `; 384 let elements = [ document.getElementById("e0"), 385 document.getElementById("e1"), 386 document.getElementById("e2") ]; 387 388 assert_element_states(elements, [1, 0, 0], "states after insertion by parser"); 389 }, "closing as a result of parsing doesn't depend on attribute order"); 390 391 promise_test(async t => { 392 container.innerHTML = ` 393 <details name="a" id="e0" open></details> 394 <details name="a" id="e1"></details> 395 `; 396 let elements = [ document.getElementById("e0"), 397 document.getElementById("e1") ]; 398 399 assert_element_states(elements, [1, 0], "states before first mutation"); 400 401 let make_details = () => { 402 let e = document.createElement("details"); 403 e.setAttribute("name", "a"); 404 return e; 405 }; 406 407 let watch_e0 = new EventWatcher(t, elements[0], ['toggle']); 408 let watch_e1 = new EventWatcher(t, elements[1], ['toggle']); 409 410 let expect_opening = async (watcher) => { 411 await watcher.wait_for(['toggle'], {record: 'all'}).then((events) => { 412 assert_equals(events[0].oldState, "closed"); 413 assert_equals(events[0].newState, "open"); 414 }); 415 }; 416 417 let expect_closing = async (watcher) => { 418 await watcher.wait_for(['toggle'], {record: 'all'}).then((events) => { 419 assert_equals(events[0].oldState, "open"); 420 assert_equals(events[0].newState, "closed"); 421 }); 422 }; 423 424 let track_mutations = (element) => { 425 let result = { count: 0 }; 426 let listener = event => { 427 ++result.count; 428 }; 429 element.addEventListener("DOMSubtreeModified", listener); 430 return result; 431 } 432 433 await expect_opening(watch_e0); 434 435 // Test appending an open element in the group. 436 let new1 = make_details(); 437 let mutations1 = track_mutations(new1); 438 let watch_new1 = new EventWatcher(t, new1, ['toggle']); 439 new1.open = true; 440 assert_in_array(mutations1.count, [0, 1], "mutation events count before inserting new1"); 441 await expect_opening(watch_new1); 442 container.appendChild(new1); 443 await expect_closing(watch_new1); 444 assert_in_array(mutations1.count, [0, 1], "mutation events count after inserting new1"); 445 446 // Test appending a closed element in the group. 447 let new2 = make_details(); 448 let mutations2 = track_mutations(new2); 449 let watch_new2 = new EventWatcher(t, new2, ['toggle']); 450 container.appendChild(new2); 451 assert_equals(mutations2.count, 0, "mutation events count after inserting new2"); 452 453 // Test inserting an open element at the start of the group. 454 let new3 = make_details(); 455 let mutations3 = track_mutations(new3); 456 new3.open = true; // this time do this before creating the EventWatcher 457 let watch_new3 = new EventWatcher(t, new3, ['toggle']); 458 assert_in_array(mutations3.count, [0, 1], "mutation events count before inserting new3"); 459 await expect_opening(watch_new3); 460 container.insertBefore(new3, elements[0]); 461 await expect_closing(watch_new3); 462 assert_in_array(mutations3.count, [0, 1], "mutation events count after inserting new3"); 463 }, "handling of insertion of elements into group"); 464 465 promise_test(async t => { 466 container.remove(); 467 container.innerHTML = ` 468 <details name="a"> 469 <summary>1</summary> 470 This is the first item. 471 </details> 472 473 <details name="a"> 474 <summary>2</summary> 475 This is the second item. 476 </details> 477 `; 478 let first = container.firstElementChild; 479 let second = first.nextElementSibling; 480 assert_false(first.open); 481 assert_false(second.open); 482 first.open = true; 483 assert_true(first.open); 484 assert_false(second.open); 485 second.open = true; 486 assert_false(first.open); 487 assert_true(second.open); 488 second.open = true; 489 assert_false(first.open); 490 assert_true(second.open); 491 second.open = false; 492 assert_false(first.open); 493 assert_false(second.open); 494 }, "basic handling of mutually exclusive details when the element isn't connected"); 495 496 </script>