| <!DOCTYPE html> |
| <meta charset="utf8"> |
| <meta name="timeout" content="long"> |
| <title>Event propagation on disabled form elements</title> |
| <link rel="author" href="mailto:krosylight@mozilla.com"> |
| <link rel="help" href="https://github.com/whatwg/html/issues/2368"> |
| <link rel="help" href="https://github.com/whatwg/html/issues/5886"> |
| <script src="/resources/testharness.js"></script> |
| <script src="/resources/testharnessreport.js"></script> |
| <script src="/resources/testdriver.js"></script> |
| <script src="/resources/testdriver-vendor.js"></script> |
| <script src="/resources/testdriver-actions.js"></script> |
| |
| <div id="cases"> |
| <input> <!-- Sanity check with non-disabled control --> |
| <select disabled></select> |
| <select disabled> |
| <!-- <option> can't be clicked as it doesn't have its own painting area --> |
| <option>foo</option> |
| </select> |
| <fieldset disabled>Text</fieldset> |
| <fieldset disabled><span class="target">Span</span></fieldset> |
| <button disabled>Text</button> |
| <button disabled><span class="target">Span</span></button> |
| <textarea disabled></textarea> |
| <input disabled type="button"> |
| <input disabled type="checkbox"> |
| <input disabled type="color" value="#000000"> |
| <input disabled type="date"> |
| <input disabled type="datetime-local"> |
| <input disabled type="email"> |
| <input disabled type="file"> |
| <input disabled type="image"> |
| <input disabled type="month"> |
| <input disabled type="number"> |
| <input disabled type="password"> |
| <input disabled type="radio"> |
| <!-- Native click will click the bar --> |
| <input disabled type="range" value="0"> |
| <!-- Native click will click the slider --> |
| <input disabled type="range" value="50"> |
| <input disabled type="reset"> |
| <input disabled type="search"> |
| <input disabled type="submit"> |
| <input disabled type="tel"> |
| <input disabled type="text"> |
| <input disabled type="time"> |
| <input disabled type="url"> |
| <input disabled type="week"> |
| <my-control disabled>Text</my-control> |
| </div> |
| |
| <script> |
| customElements.define('my-control', class extends HTMLElement { |
| static get formAssociated() { return true; } |
| get disabled() { return this.hasAttribute("disabled"); } |
| }); |
| |
| /** |
| * @param {Element} element |
| */ |
| function getEventFiringTarget(element) { |
| return element.querySelector(".target") || element; |
| } |
| |
| const allEvents = ["pointermove", "mousemove", "pointerdown", "mousedown", "pointerup", "mouseup", "click"]; |
| |
| /** |
| * @param {*} t |
| * @param {Element} element |
| * @param {Element} observingElement |
| */ |
| function setupTest(t, element, observingElement) { |
| /** @type {{type: string, composedPath: Node[]}[]} */ |
| const observedEvents = []; |
| const controller = new AbortController(); |
| const { signal } = controller; |
| const listenerFn = t.step_func(event => { |
| observedEvents.push({ |
| type: event.type, |
| target: event.target, |
| isTrusted: event.isTrusted, |
| composedPath: event.composedPath().map(n => n.constructor.name), |
| }); |
| }); |
| for (const event of allEvents) { |
| observingElement.addEventListener(event, listenerFn, { signal }); |
| } |
| t.add_cleanup(() => controller.abort()); |
| |
| const target = getEventFiringTarget(element); |
| return { target, observedEvents }; |
| } |
| |
| /** |
| * @param {Element} target |
| * @param {*} observedEvent |
| */ |
| function shouldNotBubble(target, observedEvent) { |
| return ( |
| target.disabled && |
| observedEvent.isTrusted && |
| ["mousedown", "mouseup", "click"].includes(observedEvent.type) |
| ); |
| } |
| |
| /** |
| * @param {Event} event |
| */ |
| function getExpectedComposedPath(event) { |
| let target = event.target; |
| const result = []; |
| while (target) { |
| if (shouldNotBubble(target, event)) { |
| return result; |
| } |
| result.push(target.constructor.name); |
| target = target.parentNode; |
| } |
| result.push("Window"); |
| return result; |
| } |
| |
| /** |
| * @param {object} options |
| * @param {Element & { disabled: boolean }} options.element |
| * @param {Element} options.observingElement |
| * @param {string[]} options.expectedEvents |
| * @param {(target: Element) => (Promise<void> | void)} options.clickerFn |
| * @param {string} options.title |
| */ |
| function promise_event_test({ element, observingElement, expectedEvents, nonDisabledExpectedEvents, clickerFn, title }) { |
| promise_test(async t => { |
| const { target, observedEvents } = setupTest(t, element, observingElement); |
| |
| await t.step_func(clickerFn)(target); |
| await new Promise(resolve => t.step_timeout(resolve, 0)); |
| |
| const expected = element.disabled ? expectedEvents : nonDisabledExpectedEvents; |
| assert_array_equals(observedEvents.map(e => e.type), expected, "Observed events"); |
| |
| for (const observed of observedEvents) { |
| assert_equals(observed.target, target, `${observed.type}.target`) |
| assert_array_equals( |
| observed.composedPath, |
| getExpectedComposedPath(observed), |
| `${observed.type}.composedPath` |
| ); |
| } |
| |
| }, `${title} on ${element.outerHTML}, observed from <${observingElement.localName}>`); |
| } |
| |
| /** |
| * @param {object} options |
| * @param {Element & { disabled: boolean }} options.element |
| * @param {string[]} options.expectedEvents |
| * @param {(target: Element) => (Promise<void> | void)} options.clickerFn |
| * @param {string} options.title |
| */ |
| function promise_event_test_hierarchy({ element, expectedEvents, nonDisabledExpectedEvents, clickerFn, title }) { |
| const targets = [element, document.body]; |
| if (element.querySelector(".target")) { |
| targets.unshift(element.querySelector(".target")); |
| } |
| for (const observingElement of targets) { |
| promise_event_test({ element, observingElement, expectedEvents, nonDisabledExpectedEvents, clickerFn, title }); |
| } |
| } |
| |
| function trusted_click(target) { |
| // To workaround type=file clicking issue |
| // https://github.com/w3c/webdriver/issues/1666 |
| return new test_driver.Actions() |
| .pointerMove(0, 0, { origin: target }) |
| .pointerDown() |
| .pointerUp() |
| .send(); |
| } |
| |
| const mouseEvents = ["mousemove", "mousedown", "mouseup", "click"]; |
| const pointerEvents = ["pointermove", "pointerdown", "pointerup"]; |
| |
| // Events except mousedown/up/click |
| const allowedEvents = ["pointermove", "mousemove", "pointerdown", "pointerup"]; |
| |
| const elements = document.getElementById("cases").children; |
| for (const element of elements) { |
| // Observe on a child element of the control, if exists |
| const target = element.querySelector(".target"); |
| if (target) { |
| promise_event_test({ |
| element, |
| observingElement: target, |
| expectedEvents: allEvents, |
| nonDisabledExpectedEvents: allEvents, |
| clickerFn: trusted_click, |
| title: "Trusted click" |
| }); |
| } |
| |
| // Observe on the control itself |
| promise_event_test({ |
| element, |
| observingElement: element, |
| expectedEvents: allowedEvents, |
| nonDisabledExpectedEvents: allEvents, |
| clickerFn: trusted_click, |
| title: "Trusted click" |
| }); |
| |
| // Observe on document.body |
| promise_event_test({ |
| element, |
| observingElement: document.body, |
| expectedEvents: allowedEvents, |
| nonDisabledExpectedEvents: allEvents, |
| clickerFn: trusted_click, |
| title: "Trusted click" |
| }); |
| |
| const eventFirePair = [ |
| [MouseEvent, mouseEvents], |
| [PointerEvent, pointerEvents] |
| ]; |
| |
| for (const [eventInterface, events] of eventFirePair) { |
| promise_event_test_hierarchy({ |
| element, |
| expectedEvents: events, |
| nonDisabledExpectedEvents: events, |
| clickerFn: target => { |
| for (const event of events) { |
| target.dispatchEvent(new eventInterface(event, { bubbles: true })) |
| } |
| }, |
| title: `Dispatch new ${eventInterface.name}()` |
| }) |
| } |
| |
| promise_event_test_hierarchy({ |
| element, |
| expectedEvents: getEventFiringTarget(element) === element ? [] : ["click"], |
| nonDisabledExpectedEvents: ["click"], |
| clickerFn: target => target.click(), |
| title: `click()` |
| }) |
| } |
| </script> |