Bug 1542277 - [devtools] Fix Inspector search autocomplete when using selector search with pseudo selectors. r=devtools-reviewers,jdescottes.
This patch adds support for the "pseudo" selector state in the inspector search, which is already handled in the server (in WalkerActor#getSuggestionsForQuery). Differential Revision: https://phabricator.services.mozilla.com/D250896
This commit is contained in:
committed by
nchevobbe@mozilla.com
parent
2ce6dfa245
commit
76957c0da0
@@ -202,6 +202,11 @@ class SelectorAutocompleter extends EventEmitter {
|
||||
ID: "id",
|
||||
TAG: "tag",
|
||||
ATTRIBUTE: "attribute",
|
||||
// This is for pseudo classes (e.g. `:active`, `:not()`). We keep it as "pseudo" as
|
||||
// the server handles both pseudo elements and pseudo classes under the same type
|
||||
PSEUDO_CLASS: "pseudo",
|
||||
// This is for pseudo element (e.g. `::selection`)
|
||||
PSEUDO_ELEMENT: "pseudo-element",
|
||||
};
|
||||
|
||||
// The current state of the query.
|
||||
@@ -263,6 +268,10 @@ class SelectorAutocompleter extends EventEmitter {
|
||||
this.#state = this.States.ID;
|
||||
} else if (lastChar === "[") {
|
||||
this.#state = this.States.ATTRIBUTE;
|
||||
} else if (lastChar === ":") {
|
||||
this.#state = this.States.PSEUDO_CLASS;
|
||||
} else if (lastChar === ")") {
|
||||
this.#state = null;
|
||||
} else {
|
||||
this.#state = this.States.TAG;
|
||||
}
|
||||
@@ -278,6 +287,10 @@ class SelectorAutocompleter extends EventEmitter {
|
||||
this.#state = this.States.ID;
|
||||
} else if (lastChar === "[") {
|
||||
this.#state = this.States.ATTRIBUTE;
|
||||
} else if (lastChar === ":") {
|
||||
this.#state = this.States.PSEUDO_CLASS;
|
||||
} else if (lastChar === ")") {
|
||||
this.#state = null;
|
||||
} else {
|
||||
this.#state = this.States.CLASS;
|
||||
}
|
||||
@@ -294,6 +307,10 @@ class SelectorAutocompleter extends EventEmitter {
|
||||
this.#state = this.States.CLASS;
|
||||
} else if (lastChar === "[") {
|
||||
this.#state = this.States.ATTRIBUTE;
|
||||
} else if (lastChar === ":") {
|
||||
this.#state = this.States.PSEUDO_CLASS;
|
||||
} else if (lastChar === ")") {
|
||||
this.#state = null;
|
||||
} else {
|
||||
this.#state = this.States.ID;
|
||||
}
|
||||
@@ -309,11 +326,34 @@ class SelectorAutocompleter extends EventEmitter {
|
||||
this.#state = this.States.CLASS;
|
||||
} else if (lastChar === "#") {
|
||||
this.#state = this.States.ID;
|
||||
} else if (lastChar === ":") {
|
||||
this.#state = this.States.PSEUDO_CLASS;
|
||||
} else if (lastChar === ")") {
|
||||
this.#state = null;
|
||||
} else {
|
||||
this.#state = this.States.ATTRIBUTE;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case this.States.PSEUDO_CLASS:
|
||||
if (lastChar === ":" && secondLastChar === ":") {
|
||||
// We don't support searching for pseudo elements, so bail out when we
|
||||
// see `::`
|
||||
this.#state = this.States.PSEUDO_ELEMENT;
|
||||
return this.#state;
|
||||
}
|
||||
|
||||
if (lastChar === "(") {
|
||||
this.#state = null;
|
||||
} else if (lastChar === ".") {
|
||||
this.#state = this.States.CLASS;
|
||||
} else if (lastChar === "#") {
|
||||
this.#state = this.States.ID;
|
||||
} else {
|
||||
this.#state = this.States.PSEUDO_CLASS;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return this.#state;
|
||||
@@ -417,7 +457,9 @@ class SelectorAutocompleter extends EventEmitter {
|
||||
const items = [];
|
||||
|
||||
for (let [value, state] of suggestions) {
|
||||
if (query.match(/[\s>+~]$/)) {
|
||||
if (popupState === this.States.PSEUDO_CLASS) {
|
||||
value = query.substring(0, query.lastIndexOf(":")) + value;
|
||||
} else if (query.match(/[\s>+~]$/)) {
|
||||
// for cases like 'div ', 'div >', 'div+' or 'div~'
|
||||
value = query + value;
|
||||
} else if (query.match(/[\s>+~][\.#a-zA-Z][^\s>+~\.#\[]*$/)) {
|
||||
@@ -487,12 +529,17 @@ class SelectorAutocompleter extends EventEmitter {
|
||||
const originalQuery = this.searchBox.value;
|
||||
|
||||
const state = this.state;
|
||||
let firstPart = "";
|
||||
let completing = "";
|
||||
|
||||
if (query.endsWith("*") || state === this.States.ATTRIBUTE) {
|
||||
// Hide the popup if the query ends with * (because we don't want to
|
||||
// suggest all nodes) or if it is an attribute selector (because
|
||||
// it would give a lot of useless results).
|
||||
if (
|
||||
// Hide the popup if:
|
||||
// - the query ends with * (because we don't want to suggest all nodes)
|
||||
query.endsWith("*") ||
|
||||
// - if it is an attribute selector (because it would give a lot of useless results).
|
||||
state === this.States.ATTRIBUTE ||
|
||||
// - if it is a pseudo element selector (we don't support it, see Bug 1097991)
|
||||
state === this.States.PSEUDO_ELEMENT
|
||||
) {
|
||||
this.hidePopup();
|
||||
this.emitForTests("processing-done", { query: originalQuery });
|
||||
return;
|
||||
@@ -507,16 +554,20 @@ class SelectorAutocompleter extends EventEmitter {
|
||||
// - 'div.foo ~ s' returns 's'
|
||||
// - 'div.foo x-el_1' returns 'x-el_1'
|
||||
const matches = query.match(/[\s>+~]?(?<tag>[a-zA-Z0-9_-]*)$/);
|
||||
firstPart = matches.groups.tag;
|
||||
query = query.slice(0, query.length - firstPart.length);
|
||||
completing = matches.groups.tag;
|
||||
query = query.slice(0, query.length - completing.length);
|
||||
} else if (state === this.States.CLASS) {
|
||||
// gets the class that is being completed. For ex. '.foo.b' returns 'b'
|
||||
firstPart = query.match(/\.([^\.]*)$/)[1];
|
||||
query = query.slice(0, query.length - firstPart.length - 1);
|
||||
completing = query.match(/\.([^\.]*)$/)[1];
|
||||
query = query.slice(0, query.length - completing.length - 1);
|
||||
} else if (state === this.States.ID) {
|
||||
// gets the id that is being completed. For ex. '.foo#b' returns 'b'
|
||||
firstPart = query.match(/#([^#]*)$/)[1];
|
||||
query = query.slice(0, query.length - firstPart.length - 1);
|
||||
completing = query.match(/#([^#]*)$/)[1];
|
||||
query = query.slice(0, query.length - completing.length - 1);
|
||||
} else if (state === this.States.PSEUDO_CLASS) {
|
||||
// The getSuggestionsForQuery expects a pseudo element without the : prefix
|
||||
completing = query.substring(query.lastIndexOf(":") + 1);
|
||||
query = "";
|
||||
}
|
||||
// TODO: implement some caching so that over the wire request is not made
|
||||
// everytime.
|
||||
@@ -527,19 +578,25 @@ class SelectorAutocompleter extends EventEmitter {
|
||||
let suggestions =
|
||||
await this.inspector.commands.inspectorCommand.getSuggestionsForQuery(
|
||||
query,
|
||||
firstPart,
|
||||
completing,
|
||||
state
|
||||
);
|
||||
|
||||
if (state === this.States.CLASS) {
|
||||
firstPart = "." + firstPart;
|
||||
completing = "." + completing;
|
||||
} else if (state === this.States.ID) {
|
||||
firstPart = "#" + firstPart;
|
||||
completing = "#" + completing;
|
||||
} else if (state === this.States.PSEUDO_CLASS) {
|
||||
completing = ":" + completing;
|
||||
// Remove pseudo-element suggestions, since the search does not work with them (Bug 1097991)
|
||||
suggestions = suggestions.filter(
|
||||
suggestion => !suggestion[0].startsWith("::")
|
||||
);
|
||||
}
|
||||
|
||||
// If there is a single tag match and it's what the user typed, then
|
||||
// don't need to show a popup.
|
||||
if (suggestions.length === 1 && suggestions[0][0] === firstPart) {
|
||||
if (suggestions.length === 1 && suggestions[0][0] === completing) {
|
||||
suggestions = [];
|
||||
}
|
||||
|
||||
|
||||
@@ -405,6 +405,8 @@ fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and
|
||||
|
||||
["browser_inspector_search-suggests-ids-and-classes.js"]
|
||||
|
||||
["browser_inspector_search-suggests-pseudo.js"]
|
||||
|
||||
["browser_inspector_search_keyboard_shortcut_conflict.js"]
|
||||
|
||||
["browser_inspector_search_keyboard_trap.js"]
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
"use strict";
|
||||
|
||||
// Test that the selector-search input proposes pseudo elements
|
||||
|
||||
const TEST_URL = `<h1>Hello</h1>`;
|
||||
|
||||
add_task(async function () {
|
||||
const { inspector } = await openInspectorForURL(
|
||||
"data:text/html;charset=utf-8," + encodeURI(TEST_URL)
|
||||
);
|
||||
|
||||
const TESTS = [
|
||||
{
|
||||
input: ":",
|
||||
// we don't want to test the exact items that are suggested, as the test would fail
|
||||
// when new pseudo are added in the platform.
|
||||
// Only includes some items that should be suggested (`included`),
|
||||
// and some that should not be (`notIncluded`)
|
||||
suggestions: {
|
||||
included: [":active", ":empty", ":focus"],
|
||||
notIncluded: ["::selection", "::marker"],
|
||||
},
|
||||
inputAfterAcceptingSuggestion: ":active",
|
||||
},
|
||||
{
|
||||
// For now we don't support searching for pseudo element (Bug 1097991),
|
||||
// so the list should be empty
|
||||
input: "::",
|
||||
suggestions: {
|
||||
included: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "h1:",
|
||||
suggestions: {
|
||||
included: ["h1:active", "h1:empty", "h1:focus"],
|
||||
notIncluded: ["h1::selection", "h1::marker"],
|
||||
},
|
||||
inputAfterAcceptingSuggestion: "h1:active",
|
||||
},
|
||||
{
|
||||
input: "h1:not",
|
||||
suggestions: {
|
||||
included: ["h1:not("],
|
||||
notIncluded: ["h1:nth-child("],
|
||||
},
|
||||
inputAfterAcceptingSuggestion: "h1:not(",
|
||||
},
|
||||
{
|
||||
input: "h1:empty:",
|
||||
suggestions: {
|
||||
included: ["h1:empty:active", "h1:empty:empty", "h1:empty:focus"],
|
||||
notIncluded: ["h1::selection", "h1::marker"],
|
||||
},
|
||||
inputAfterAcceptingSuggestion: "h1:empty:active",
|
||||
},
|
||||
{
|
||||
input: "h1:empty:no",
|
||||
suggestions: {
|
||||
included: ["h1:empty:not("],
|
||||
notIncluded: ["h1:empty:nth-child("],
|
||||
},
|
||||
inputAfterAcceptingSuggestion: "h1:empty:not(",
|
||||
},
|
||||
{
|
||||
input: "body > h1:",
|
||||
suggestions: {
|
||||
included: ["body > h1:active", "body > h1:empty", "body > h1:focus"],
|
||||
notIncluded: ["body > h1::selection", "body > h1::marker"],
|
||||
},
|
||||
inputAfterAcceptingSuggestion: "body > h1:active",
|
||||
},
|
||||
{
|
||||
input: "body > h1:no",
|
||||
suggestions: {
|
||||
included: ["body > h1:not("],
|
||||
notIncluded: ["body > h1:nth-child("],
|
||||
},
|
||||
inputAfterAcceptingSuggestion: "body > h1:not(",
|
||||
},
|
||||
];
|
||||
|
||||
info("Focus the search box");
|
||||
await focusSearchBoxUsingShortcut(inspector.panelWin);
|
||||
|
||||
const searchInputEl = inspector.panelWin.document.getElementById(
|
||||
"inspector-searchbox"
|
||||
);
|
||||
const { searchPopup } = inspector.searchSuggestions;
|
||||
|
||||
for (const { input, suggestions, inputAfterAcceptingSuggestion } of TESTS) {
|
||||
info(`Checking suggestions for "${input}"`);
|
||||
|
||||
const onPopupOpened = searchPopup.once("popup-opened");
|
||||
// the query for getting suggestions is not throttled and is fired for every char
|
||||
// being typed, so we avoid using EventUtils.sendString for the whole input to avoid
|
||||
// dealing with multiple events. Instead, put the value directly in the input, and only
|
||||
// type the last char.
|
||||
const onProcessingDone =
|
||||
inspector.searchSuggestions.once("processing-done");
|
||||
searchInputEl.value = input.substring(0, input.length - 1);
|
||||
EventUtils.sendChar(input.at(-1), inspector.panelWin);
|
||||
info("Wait for search query to complete");
|
||||
await onProcessingDone;
|
||||
|
||||
const actualSuggestions = Array.from(
|
||||
searchPopup.list.querySelectorAll("li")
|
||||
).map(li => li.textContent);
|
||||
|
||||
if (!suggestions.included.length) {
|
||||
const res = await Promise.race([
|
||||
onPopupOpened,
|
||||
wait(1000).then(() => "TIMEOUT"),
|
||||
]);
|
||||
is(res, "TIMEOUT", "popup did not open");
|
||||
} else {
|
||||
await onPopupOpened;
|
||||
ok(true, "suggestions popup opened");
|
||||
}
|
||||
|
||||
for (const expectedLabel of suggestions.included) {
|
||||
ok(
|
||||
actualSuggestions.some(s => s === expectedLabel),
|
||||
`"${expectedLabel}" is in the list of suggestions for "${input}" (full list: ${JSON.stringify(actualSuggestions)})`
|
||||
);
|
||||
}
|
||||
|
||||
for (const unexpectedLabel of suggestions.notIncluded || []) {
|
||||
ok(
|
||||
!actualSuggestions.some(s => s === unexpectedLabel),
|
||||
`"${unexpectedLabel}" is not in the list of suggestions for "${input}"`
|
||||
);
|
||||
}
|
||||
|
||||
if (inputAfterAcceptingSuggestion) {
|
||||
info("Press tab to fill the search input with the first suggestion");
|
||||
const onSuggestionAccepted =
|
||||
inspector.searchSuggestions.once("processing-done");
|
||||
const onPopupClosed = searchPopup.once("popup-closed");
|
||||
EventUtils.synthesizeKey("VK_TAB", {}, inspector.panelWin);
|
||||
await onSuggestionAccepted;
|
||||
await onPopupClosed;
|
||||
|
||||
is(
|
||||
searchInputEl.value,
|
||||
inputAfterAcceptingSuggestion,
|
||||
"input has expected value after accepting suggestion"
|
||||
);
|
||||
}
|
||||
|
||||
info("Clear the input");
|
||||
const onSearchCleared = inspector.search.once("search-cleared");
|
||||
const onEmptySearchSuggestionProcessingDone =
|
||||
inspector.searchSuggestions.once("processing-done");
|
||||
|
||||
// select the whole input and hit backspace to clear it
|
||||
searchInputEl.setSelectionRange(0, searchInputEl.value.length);
|
||||
EventUtils.synthesizeKey("VK_BACK_SPACE", {}, inspector.panelWin);
|
||||
await onSearchCleared;
|
||||
await onEmptySearchSuggestionProcessingDone;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user