| <!doctype html> |
| <html> |
| <head> |
| <meta charset="utf-8"> |
| <title>Testing editable state and focus in shadow DOM in design mode</title> |
| <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> |
| <script src="../include/editor-test-utils.js"></script> |
| </head> |
| <body> |
| <h3>open</h3> |
| <my-shadow data-mode="open"></my-shadow> |
| <h3>closed</h3> |
| <my-shadow data-mode="closed"></my-shadow> |
| |
| <script> |
| "use strict"; |
| |
| document.designMode = "on"; |
| const utils = new EditorTestUtils(document.body); |
| |
| class MyShadow extends HTMLElement { |
| #defaultInnerHTML = |
| "<style>:focus { outline: 3px red solid; }</style>" + |
| "<div>text" + |
| "<div contenteditable=\"\">editable</div>" + |
| "<object tabindex=\"0\">object</object>" + |
| "<p tabindex=\"0\">paragraph</p>" + |
| "</div>"; |
| #shadowRoot; |
| |
| constructor() { |
| super(); |
| this.#shadowRoot = this.attachShadow({mode: this.getAttribute("data-mode")}); |
| this.#shadowRoot.innerHTML = this.#defaultInnerHTML; |
| } |
| |
| reset() { |
| this.#shadowRoot.innerHTML = this.#defaultInnerHTML; |
| this.#shadowRoot.querySelector("div").getBoundingClientRect(); |
| } |
| |
| focusText() { |
| this.focus(); |
| const div = this.#shadowRoot.querySelector("div"); |
| getSelection().collapse(div.firstChild || div, 0); |
| } |
| |
| focusContentEditable() { |
| this.focus(); |
| const contenteditable = this.#shadowRoot.querySelector("div[contenteditable]"); |
| contenteditable.focus(); |
| getSelection().collapse(contenteditable.firstChild || contenteditable, 0); |
| } |
| |
| focusObject() { |
| this.focus(); |
| this.#shadowRoot.querySelector("object[tabindex]").focus(); |
| } |
| |
| focusParagraph() { |
| this.focus(); |
| const tabbableP = this.#shadowRoot.querySelector("p[tabindex]"); |
| tabbableP.focus(); |
| getSelection().collapse(tabbableP.firstChild || tabbableP, 0); |
| } |
| |
| getInnerHTML() { |
| return this.#shadowRoot.innerHTML; |
| } |
| |
| getDefaultInnerHTML() { |
| return this.#defaultInnerHTML; |
| } |
| |
| getFocusedElementName() { |
| return this.#shadowRoot.querySelector(":focus")?.tagName.toLocaleLowerCase() || ""; |
| } |
| |
| getSelectedRange() { |
| // XXX There is no standardized way to retrieve selected ranges in |
| // shadow trees, therefore, we use non-standardized API for now |
| // since the main purpose of this test is checking the behavior of |
| // selection changes in shadow trees, not checking the selection API. |
| const selection = |
| this.#shadowRoot.getSelection !== undefined |
| ? this.#shadowRoot.getSelection() |
| : getSelection(); |
| return selection.getRangeAt(0); |
| } |
| } |
| |
| customElements.define("my-shadow", MyShadow); |
| |
| function getRangeDescription(range) { |
| function getNodeDescription(node) { |
| if (!node) { |
| return "null"; |
| } |
| switch (node.nodeType) { |
| case Node.TEXT_NODE: |
| case Node.COMMENT_NODE: |
| case Node.CDATA_SECTION_NODE: |
| return `${node.nodeName} "${node.data}"`; |
| case Node.ELEMENT_NODE: |
| return `<${node.nodeName.toLowerCase()}>`; |
| default: |
| return `${node.nodeName}`; |
| } |
| } |
| if (range === null) { |
| return "null"; |
| } |
| if (range === undefined) { |
| return "undefined"; |
| } |
| return range.startContainer == range.endContainer && |
| range.startOffset == range.endOffset |
| ? `(${getNodeDescription(range.startContainer)}, ${range.startOffset})` |
| : `(${getNodeDescription(range.startContainer)}, ${ |
| range.startOffset |
| }) - (${getNodeDescription(range.endContainer)}, ${range.endOffset})`; |
| } |
| |
| promise_test(async () => { |
| await new Promise(resolve => addEventListener("load", resolve, {once: true})); |
| assert_true(true, "Load event is fired"); |
| }, "Waiting for load"); |
| |
| /** |
| * The expected result of this test is based on Blink and Gecko's behavior. |
| */ |
| |
| for (const mode of ["open", "closed"]) { |
| const host = document.querySelector(`my-shadow[data-mode=${mode}]`); |
| promise_test(async (t) => { |
| host.reset(); |
| host.focusText(); |
| test(() => { |
| assert_equals( |
| host.getFocusedElementName(), |
| "", |
| `No element should have focus after ${t.name}` |
| ); |
| }, `Focus after ${t.name}`); |
| await utils.sendKey("A"); |
| test(() => { |
| assert_equals( |
| host.getInnerHTML(), |
| host.getDefaultInnerHTML(), |
| `The shadow DOM shouldn't be modified after ${t.name}` |
| ); |
| }, `Typing "A" after ${t.name}`); |
| }, `Collapse selection into text in the ${mode} shadow DOM`); |
| |
| promise_test(async (t) => { |
| host.reset(); |
| host.focusContentEditable(); |
| test(() => { |
| assert_equals( |
| host.getFocusedElementName(), |
| "div", |
| `<div contenteditable> should have focus after ${t.name}` |
| ); |
| }, `Focus after ${t.name}`); |
| await utils.sendKey("A"); |
| test(() => { |
| assert_equals( |
| host.getInnerHTML(), |
| host.getDefaultInnerHTML().replace("<div contenteditable=\"\">", "<div contenteditable=\"\">A"), |
| `The shadow DOM shouldn't be modified after ${t.name}` |
| ); |
| }, `Typing "A" after ${t.name}`); |
| }, `Collapse selection into text in <div contenteditable> in the ${mode} shadow DOM`); |
| |
| promise_test(async (t) => { |
| host.reset(); |
| host.focusObject(); |
| test(() => { |
| assert_equals( |
| host.getFocusedElementName(), |
| "object", |
| `The <object> element should have focus after ${t.name}` |
| ); |
| }, `Focus after ${t.name}`); |
| await utils.sendKey("A"); |
| test(() => { |
| assert_equals( |
| host.getInnerHTML(), |
| host.getDefaultInnerHTML(), |
| `The shadow DOM shouldn't be modified after ${t.name}` |
| ); |
| }, `Typing "A" after ${t.name}`); |
| }, `Set focus to <object> in the ${mode} shadow DOM`); |
| |
| promise_test(async (t) => { |
| host.reset(); |
| host.focusParagraph(); |
| test(() => { |
| assert_equals( |
| host.getFocusedElementName(), |
| "p", |
| `The <p tabindex="0"> element should have focus after ${t.name}` |
| ); |
| }, `Focus after ${t.name}`); |
| await utils.sendKey("A"); |
| test(() => { |
| assert_equals( |
| host.getInnerHTML(), |
| host.getDefaultInnerHTML(), |
| `The shadow DOM shouldn't be modified after ${t.name}` |
| ); |
| }, `Typing "A" after ${t.name}`); |
| }, `Set focus to <p tabindex="0"> in the ${mode} shadow DOM`); |
| |
| promise_test(async (t) => { |
| host.reset(); |
| host.focusParagraph(); |
| await utils.sendSelectAllShortcutKey(); |
| assert_in_array( |
| getRangeDescription(host.getSelectedRange()), |
| [ |
| // Feel free to add reasonable select all result in the <my-shadow>. |
| "(#document-fragment, 0) - (#document-fragment, 2)", |
| "(#text \"text\", 0) - (#text \"paragraph\", 9)", |
| ], |
| `Only all children of the ${mode} shadow DOM should be selected` |
| ); |
| getSelection().collapse(document.body, 0); |
| }, `SelectAll in the ${mode} shadow DOM`); |
| |
| promise_test(async (t) => { |
| host.reset(); |
| host.focusContentEditable(); |
| await utils.sendSelectAllShortcutKey(); |
| assert_in_array( |
| getRangeDescription(host.getSelectedRange()), |
| [ |
| // Feel free to add reasonable select all result in the <div contenteditable>. |
| "(<div>, 0) - (<div>, 1)", |
| "(#text \"editable\", 0) - (#text \"editable\", 8)", |
| ] |
| ); |
| getSelection().collapse(document.body, 0); |
| }, `SelectAll in the <div contenteditable> in the ${mode} shadow DOM`); |
| } |
| </script> |
| </body> |
| </html> |