event-propagate-disabled.tentative.html (8694B)
1 <!DOCTYPE html> 2 <meta charset="utf8"> 3 <meta name="timeout" content="long"> 4 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 5 <title>Event propagation on disabled form elements</title> 6 <link rel="author" href="mailto:krosylight@mozilla.com"> 7 <link rel="help" href="https://github.com/whatwg/html/issues/2368"> 8 <link rel="help" href="https://github.com/whatwg/html/issues/5886"> 9 <script src="/resources/testharness.js"></script> 10 <script src="/resources/testharnessreport.js"></script> 11 <script src="/resources/testdriver.js"></script> 12 <script src="/resources/testdriver-vendor.js"></script> 13 <script src="/resources/testdriver-actions.js"></script> 14 15 <div id="cases"> 16 <input> <!-- Sanity check with non-disabled control --> 17 <select disabled></select> 18 <select disabled> 19 <!-- <option> can't be clicked as it doesn't have its own painting area --> 20 <option>foo</option> 21 </select> 22 <fieldset disabled>Text</fieldset> 23 <fieldset disabled><span class="target">Span</span></fieldset> 24 <button disabled>Text</button> 25 <button disabled><span class="target">Span</span></button> 26 <textarea disabled></textarea> 27 <input disabled type="button"> 28 <input disabled type="checkbox"> 29 <input disabled type="color" value="#000000"> 30 <input disabled type="date"> 31 <input disabled type="datetime-local"> 32 <input disabled type="email"> 33 <input disabled type="file"> 34 <input disabled type="image"> 35 <input disabled type="month"> 36 <input disabled type="number"> 37 <input disabled type="password"> 38 <input disabled type="radio"> 39 <!-- Native click will click the bar --> 40 <input disabled type="range" value="0"> 41 <!-- Native click will click the slider --> 42 <input disabled type="range" value="50"> 43 <input disabled type="reset"> 44 <input disabled type="search"> 45 <input disabled type="submit"> 46 <input disabled type="tel"> 47 <input disabled type="text"> 48 <input disabled type="time"> 49 <input disabled type="url"> 50 <input disabled type="week"> 51 <my-control disabled>Text</my-control> 52 </div> 53 54 <script> 55 customElements.define('my-control', class extends HTMLElement { 56 static get formAssociated() { return true; } 57 get disabled() { return this.hasAttribute("disabled"); } 58 }); 59 60 /** 61 * @param {Element} element 62 */ 63 function getEventFiringTarget(element) { 64 return element.querySelector(".target") || element; 65 } 66 67 const allEvents = ["pointermove", "mousemove", "pointerdown", "mousedown", "pointerup", "mouseup", "click"]; 68 69 /** 70 * @param {*} t 71 * @param {Element} element 72 * @param {Element} observingElement 73 */ 74 function setupTest(t, element, observingElement) { 75 /** @type {{type: string, composedPath: Node[]}[]} */ 76 const observedEvents = []; 77 const controller = new AbortController(); 78 const { signal } = controller; 79 const listenerFn = t.step_func(event => { 80 observedEvents.push({ 81 type: event.type, 82 target: event.target, 83 isTrusted: event.isTrusted, 84 composedPath: event.composedPath().map(n => n.constructor.name), 85 }); 86 }); 87 for (const event of allEvents) { 88 observingElement.addEventListener(event, listenerFn, { signal }); 89 } 90 t.add_cleanup(() => controller.abort()); 91 92 const target = getEventFiringTarget(element); 93 return { target, observedEvents }; 94 } 95 96 /** 97 * @param {Element} element 98 * @returns {boolean} 99 */ 100 function isFormControl(element) { 101 if (["button", "input", "select", "textarea"].includes(element.localName)) { 102 return true; 103 } 104 return element.constructor.formAssociated; 105 } 106 107 function isDisabledFormControl(element) { 108 return isFormControl(element) && element.disabled; 109 } 110 111 /** 112 * @param {Element} target 113 * @param {*} observedEvent 114 */ 115 function shouldNotBubble(target, observedEvent) { 116 return ( 117 isDisabledFormControl(target) && 118 observedEvent.isTrusted && 119 ["mousedown", "mouseup", "click"].includes(observedEvent.type) 120 ); 121 } 122 123 /** 124 * @param {Event} event 125 */ 126 function getExpectedComposedPath(event) { 127 let target = event.target; 128 const result = []; 129 while (target) { 130 if (shouldNotBubble(target, event)) { 131 return result; 132 } 133 result.push(target.constructor.name); 134 target = target.parentNode; 135 } 136 result.push("Window"); 137 return result; 138 } 139 140 /** 141 * @param {object} options 142 * @param {Element & { disabled: boolean }} options.element 143 * @param {Element} options.observingElement 144 * @param {string[]} options.expectedEvents 145 * @param {(target: Element) => (Promise<void> | void)} options.clickerFn 146 * @param {string} options.title 147 */ 148 function promise_event_test({ element, observingElement, expectedEvents, nonDisabledExpectedEvents, clickerFn, title }) { 149 promise_test(async t => { 150 const { target, observedEvents } = setupTest(t, element, observingElement); 151 152 await t.step_func(clickerFn)(target); 153 await new Promise(resolve => t.step_timeout(resolve, 0)); 154 155 const expected = isDisabledFormControl(element) ? expectedEvents : nonDisabledExpectedEvents; 156 157 t.step_wait_func_done(() => observedEvents.length > 0, 158 () => assert_array_equals(observedEvents.map(e => e.type), expected, "Observed events"), 159 undefined, 1000, 10); 160 ; 161 162 for (const observed of observedEvents) { 163 assert_equals(observed.target, target, `${observed.type}.target`) 164 assert_array_equals( 165 observed.composedPath, 166 getExpectedComposedPath(observed), 167 `${observed.type}.composedPath` 168 ); 169 } 170 171 }, `${title} on ${element.outerHTML}, observed from <${observingElement.localName}>`); 172 } 173 174 /** 175 * @param {object} options 176 * @param {Element & { disabled: boolean }} options.element 177 * @param {string[]} options.expectedEvents 178 * @param {(target: Element) => (Promise<void> | void)} options.clickerFn 179 * @param {string} options.title 180 */ 181 function promise_event_test_hierarchy({ element, expectedEvents, nonDisabledExpectedEvents, clickerFn, title }) { 182 const targets = [element, document.body]; 183 if (element.querySelector(".target")) { 184 targets.unshift(element.querySelector(".target")); 185 } 186 for (const observingElement of targets) { 187 promise_event_test({ element, observingElement, expectedEvents, nonDisabledExpectedEvents, clickerFn, title }); 188 } 189 } 190 191 function trusted_click(target) { 192 // To workaround type=file clicking issue 193 // https://github.com/w3c/webdriver/issues/1666 194 return new test_driver.Actions() 195 .pointerMove(0, 0, { origin: target }) 196 .pointerDown() 197 .pointerUp() 198 .send(); 199 } 200 201 const mouseEvents = ["mousemove", "mousedown", "mouseup", "click"]; 202 const pointerEvents = ["pointermove", "pointerdown", "pointerup"]; 203 204 // Events except mousedown/up/click 205 const allowedEvents = ["pointermove", "mousemove", "pointerdown", "pointerup"]; 206 207 const elements = document.getElementById("cases").children; 208 for (const element of elements) { 209 // Observe on a child element of the control, if exists 210 const target = element.querySelector(".target"); 211 if (target) { 212 promise_event_test({ 213 element, 214 observingElement: target, 215 expectedEvents: allEvents, 216 nonDisabledExpectedEvents: allEvents, 217 clickerFn: trusted_click, 218 title: "Trusted click" 219 }); 220 } 221 222 // Observe on the control itself 223 promise_event_test({ 224 element, 225 observingElement: element, 226 expectedEvents: allowedEvents, 227 nonDisabledExpectedEvents: allEvents, 228 clickerFn: trusted_click, 229 title: "Trusted click" 230 }); 231 232 // Observe on document.body 233 promise_event_test({ 234 element, 235 observingElement: document.body, 236 expectedEvents: allowedEvents, 237 nonDisabledExpectedEvents: allEvents, 238 clickerFn: trusted_click, 239 title: "Trusted click" 240 }); 241 242 const eventFirePair = [ 243 [MouseEvent, mouseEvents], 244 [PointerEvent, pointerEvents] 245 ]; 246 247 for (const [eventInterface, events] of eventFirePair) { 248 promise_event_test_hierarchy({ 249 element, 250 expectedEvents: events, 251 nonDisabledExpectedEvents: events, 252 clickerFn: target => { 253 for (const event of events) { 254 target.dispatchEvent(new eventInterface(event, { bubbles: true })) 255 } 256 }, 257 title: `Dispatch new ${eventInterface.name}()` 258 }) 259 } 260 261 promise_event_test_hierarchy({ 262 element, 263 expectedEvents: getEventFiringTarget(element) === element ? [] : ["click"], 264 nonDisabledExpectedEvents: ["click"], 265 clickerFn: target => target.click(), 266 title: `click()` 267 }) 268 } 269 </script>