Commit b9f402c5 authored by manuk's avatar manuk Committed by Commit Bot

[chrome:omnibox] Add input box and a filter/highlight toggle to search results.

When the filter option is selected, matches (rows) which do not match the search query are hidden; when the highlight option is selected, matches which do match the search query are highlighted (light blue).

Bug: 891303
Change-Id: I4c34dae1a1cef2c7baeffdd6de0355ef7ea53f0a
Reviewed-on: https://chromium-review.googlesource.com/c/1340904Reviewed-by: default avatarDemetrios Papadopoulos <dpapad@chromium.org>
Reviewed-by: default avatarTommy Li <tommycli@chromium.org>
Commit-Queue: manuk hovanesian <manukh@chromium.org>
Cr-Commit-Position: refs/heads/master@{#610969}
parent dcdb2ecc
......@@ -10,7 +10,7 @@
background-color: #C0C0C0;
}
.autocomplete-results-table td {
.autocomplete-results-table tr {
background-color: #F0F0F0;
}
......@@ -19,6 +19,10 @@
text-decoration: underline;
}
.left-20 {
margin-left: 20px
}
.check-mark,
.x-mark {
background-position: center;
......@@ -53,3 +57,32 @@ p {
.additional-info-value {
white-space: nowrap;
}
.filtered-hidden {
display: none;
}
.autocomplete-results-table .filtered-highlighted {
background-color: lightskyblue;
/* TODO(manukh) This is a placholder color until other ui changes occur. */
}
.toggle input,
.toggle input:not(:checked) ~ .toggle-on,
.toggle input:checked ~ .toggle-off {
display: none;
}
.toggle span {
user-select: none;
display: inline-block;
padding: 3px 5px;
min-width: 60px;
text-align: center;
cursor: pointer;
border: 1px solid;
}
.toggle span:hover {
background-color: #f0f0f0;
}
......@@ -72,8 +72,18 @@
Show results per provider, not just merged results
</label>
</p>
<button id="copy-text" title="Copy visible table in text format. This is affected by the visibility of ouput; i.e. toggling `Show all details` affects what will be copied.">Copy as text</button>
<button id="copy-json" title="Copy responses in JSON format. This is not affected by the visibility of output and will copy responses in their entirety.">Copy as JSON</button>
<div class="section">
<button id="copy-text" title="Copy visible table in text format. This is affected by the visibility of ouput; i.e. toggling `Show all details` affects what will be copied.">Copy as text</button>
<button id="copy-json" title="Copy responses in JSON format. This is not affected by the visibility of output and will copy responses in their entirety.">Copy as JSON</button>
</div>
<div class="section">
<input id="filter-text" type="text" size="60" placeholder="Filter output">
<label class="toggle left-20">
<input id="filter-hide" type="checkbox">
<span class="toggle-off">Highlight</span>
<span class="toggle-on">Filter</span>
</label>
</div>
</template>
<template id="omnibox-output-template">
......@@ -107,7 +117,11 @@
</table>
</template>
<omnibox-inputs id="omnibox-inputs" class="section"></omnibox-inputs>
<omnibox-output id="omnibox-output" class="section"></omnibox-output>
<div class="section">
<omnibox-inputs id="omnibox-inputs"></omnibox-inputs>
</div>
<div class="section">
<omnibox-output id="omnibox-output"></omnibox-output>
</div>
</body>
</html>
......@@ -65,11 +65,11 @@
}
}
/** @type {BrowserProxy} */
/** @type {!BrowserProxy} */
const browserProxy = new BrowserProxy();
/** @type {OmniboxInputs} */
/** @type {!OmniboxInputs} */
let omniboxInputs;
/** @type {omnibox_output.OmniboxOutput} */
/** @type {!omnibox_output.OmniboxOutput} */
let omniboxOutput;
document.addEventListener('DOMContentLoaded', () => {
......@@ -95,5 +95,9 @@
event => event.detail === 'text' ?
omniboxOutput.copyDelegate.copyTextOutput() :
omniboxOutput.copyDelegate.copyJsonOutput());
omniboxInputs.addEventListener(
'filter-input-changed',
event => omniboxOutput.filterDelegate.filter(
event.detail.filterText, event.detail.filterHide));
});
})();
......@@ -14,22 +14,41 @@ class OmniboxElement extends HTMLElement {
this.attachShadow({mode: 'open'});
const template = OmniboxElement.getTemplate(templateId);
this.shadowRoot.appendChild(template);
/** @type {!ShadowRoot} */
this.shadowRoot;
}
/**
* Searches local shadow root for element by id
* Get an element that's known to exist within this OmniboxElement.
* Searches local shadow root for element by id.
* @param {string} id
* @return {Element}
* @return {!Element}
*/
$$(id) {
return this.shadowRoot.getElementById(id);
return OmniboxElement.getById_(id, (this.shadowRoot));
}
/**
* Get a template that's known to exist within the DOM document.
* @param {string} templateId
* @return {Element}
* @return {!Element}
*/
static getTemplate(templateId) {
return $(templateId).content.cloneNode(true);
return OmniboxElement.getById_(templateId).content.cloneNode(true);
}
/**
* Get an element that's known to exist by its ID. We use this instead of just
* calling getElementById because this lets us satisfy the JSCompiler type
* system.
* @private
* @param {string} id
* @param {!Node=} context
* @return {!Element}
*/
static getById_(id, context) {
return assertInstanceof(
(context || document).getElementById(id), Element,
`Missing required element: ${id}`);
}
}
......@@ -41,6 +41,7 @@ class OmniboxInputs extends OmniboxElement {
setupElementListeners_() {
const onQueryInputsChanged = this.onQueryInputsChanged_.bind(this);
const onDisplayInputsChanged = this.onDisplayInputsChanged_.bind(this);
const onFilterInputsChange = this.onFilterInputChange_.bind(this);
this.$$('input-text').addEventListener('input', onQueryInputsChanged);
this.$$('lock-cursor-position')
......@@ -59,11 +60,16 @@ class OmniboxInputs extends OmniboxElement {
.addEventListener('click', () => this.onCopyOutput_('text'));
this.$$('copy-json')
.addEventListener('click', () => this.onCopyOutput_('json'));
this.$$('filter-text').addEventListener('input', onFilterInputsChange);
this.$$('filter-hide').addEventListener('change', onFilterInputsChange);
}
// TODO (manukh) rename below on*InputsChanged methods to on*Changed to reduce
// verbosity.
/** @private */
onQueryInputsChanged_() {
/** @type {QueryInputs} */
/** @type {!QueryInputs} */
const queryInputs = {
inputText: this.$$('input-text').value,
cursorPosition: this.cursorPosition_,
......@@ -77,7 +83,7 @@ class OmniboxInputs extends OmniboxElement {
/** @private */
onDisplayInputsChanged_() {
/** @type {DisplayInputs} */
/** @type {!DisplayInputs} */
const displayInputs = {
showIncompleteResults: this.$$('show-incomplete-results').checked,
showDetails: this.$$('show-details').checked,
......@@ -104,6 +110,16 @@ class OmniboxInputs extends OmniboxElement {
onCopyOutput_(format) {
this.dispatchEvent(new CustomEvent('copy-request', {detail: format}));
}
/** @private */
onFilterInputChange_() {
this.dispatchEvent(new CustomEvent('filter-input-changed', {
detail: {
filterText: this.$$('filter-text').value,
filterHide: this.$$('filter-hide').checked,
}
}));
}
}
window.customElements.define(OmniboxInputs.is, OmniboxInputs);
......@@ -175,7 +175,7 @@ cr.define('omnibox_output', function() {
* provides a single public interface to interact with the output:
* 1. Render tables from responses (RenderDelegate)
* 2. Control visibility based on display options (TODO)
* 3. Control visibility and coloring based on search text (TODO)
* 3. Control visibility and coloring based on search text (FilterDelegate)
* 4. Export and copy output (CopyDelegate)
* 5. Preserve inputs and reset inputs to default (TODO)
* 6. Export and import inputs (TODO)
......@@ -195,26 +195,28 @@ cr.define('omnibox_output', function() {
constructor() {
super('omnibox-output-template');
/** @type {RenderDelegate} */
/** @type {!RenderDelegate} */
this.renderDelegate = new RenderDelegate(this.$$('contents'));
/** @type {CopyDelegate} */
/** @type {!CopyDelegate} */
this.copyDelegate = new CopyDelegate(this);
/** @type {!FilterDelegate} */
this.filterDelegate = new FilterDelegate(this);
/** @type {!Array<!mojom.OmniboxResult>} */
this.responses = [];
/** @private {QueryInputs} */
this.queryInputs_ = /** @type {QueryInputs} */ ({});
/** @private {DisplayInputs} */
this.displayInputs_ = /** @type {DisplayInputs} */ ({});
/** @private {!QueryInputs} */
this.queryInputs_ = /** @type {!QueryInputs} */ ({});
/** @private {!DisplayInputs} */
this.displayInputs_ = /** @type {!DisplayInputs} */ ({});
}
/** @param {QueryInputs} queryInputs */
/** @param {!QueryInputs} queryInputs */
updateQueryInputs(queryInputs) {
this.queryInputs_ = queryInputs;
this.refresh_();
}
/** @param {DisplayInputs} displayInputs */
/** @param {!DisplayInputs} displayInputs */
updateDisplayInputs(displayInputs) {
this.displayInputs_ = displayInputs;
this.refresh_();
......@@ -236,52 +238,55 @@ cr.define('omnibox_output', function() {
this.renderDelegate.refresh(
this.queryInputs_, this.responses, this.displayInputs_);
}
/** @return {!Array<!OutputMatch>} */
get matches() {
return this.renderDelegate.matches;
}
}
// Responsible for rendering the output HTML.
class RenderDelegate {
/** @param {Element} containerElement */
/** @param {!Element} containerElement */
constructor(containerElement) {
this.containerElement = containerElement;
/** @private {!Element} */
this.containerElement_ = containerElement;
}
/**
* @param {QueryInputs} queryInputs
* @param {!QueryInputs} queryInputs
* @param {!Array<!mojom.OmniboxResult>} responses
* @param {DisplayInputs} displayInputs
* @param {!DisplayInputs} displayInputs
*/
refresh(queryInputs, responses, displayInputs) {
this.clearOutput_();
if (responses.length) {
if (displayInputs.showIncompleteResults) {
responses.forEach(
response => this.addOutputResultsGroup_(
response, queryInputs, displayInputs));
} else {
this.addOutputResultsGroup_(
responses[responses.length - 1], queryInputs, displayInputs);
}
if (!responses.length)
return;
/** @private {!Array<OutputResultsGroup>} */
this.resultsGroup_;
if (displayInputs.showIncompleteResults) {
this.resultsGroup_ = responses.map(
response =>
new OutputResultsGroup(response, queryInputs.cursorPosition));
} else {
const lastResponse = responses[responses.length - 1];
this.resultsGroup_ =
[new OutputResultsGroup(lastResponse, queryInputs.cursorPosition)];
}
}
/**
* @private
* @param {!mojom.OmniboxResult} response
* @param {QueryInputs} queryInputs
* @param {DisplayInputs} displayInputs
*/
addOutputResultsGroup_(response, queryInputs, displayInputs) {
this.containerElement.appendChild(
new OutputResultsGroup(response, queryInputs.cursorPosition)
.render(
this.clearOutput_();
this.resultsGroup_.forEach(
resultsGroup =>
this.containerElement_.appendChild(resultsGroup.render(
displayInputs.showDetails,
displayInputs.showIncompleteResults,
displayInputs.showAllProviders));
displayInputs.showAllProviders)));
}
/** @private */
clearOutput_() {
let contents = this.containerElement;
const contents = this.containerElement_;
// Clears all children.
while (contents.firstChild)
contents.removeChild(contents.firstChild);
......@@ -289,7 +294,12 @@ cr.define('omnibox_output', function() {
/** @return {string} */
get visibletableText() {
return this.containerElement.innerText;
return this.containerElement_.innerText;
}
/** @return {!Array<!OutputMatch>} */
get matches() {
return this.resultsGroup_.flatMap(resultsGroup => resultsGroup.matches);
}
}
......@@ -316,10 +326,10 @@ cr.define('omnibox_output', function() {
host: resultsGroup.host,
isTypedHost: resultsGroup.isTypedHost
};
/** @type {OutputResultsTable} */
/** @type {!OutputResultsTable} */
this.combinedResults =
new OutputResultsTable(resultsGroup.combinedResults);
/** @type {Array<OutputResultsTable>} */
/** @type {!Array<!OutputResultsTable>} */
this.individualResultsList =
resultsGroup.resultsByProvider
.map(resultsWrapper => resultsWrapper.results)
......@@ -332,7 +342,7 @@ cr.define('omnibox_output', function() {
* @param {boolean} showDetails
* @param {boolean} showIncompleteResults
* @param {boolean} showAllProviders
* @return {Element}
* @return {!Element}
*/
render(showDetails, showIncompleteResults, showAllProviders) {
const resultsGroupNode =
......@@ -352,7 +362,7 @@ cr.define('omnibox_output', function() {
/**
* @private
* @return {Element}
* @return {!Element}
*/
renderDetails_() {
const details =
......@@ -370,7 +380,7 @@ cr.define('omnibox_output', function() {
/**
* @private
* @param {boolean} showDetails
* @return {Element}
* @return {!Element}
*/
renderIndividualResults_(showDetails) {
const individualResultsNode = OmniboxElement.getTemplate(
......@@ -380,6 +390,13 @@ cr.define('omnibox_output', function() {
individualResults.render(showDetails)));
return individualResultsNode;
}
/** @return {!Array<!OutputMatch>} */
get matches() {
return [this.combinedResults]
.concat(this.individualResultsList)
.flatMap(results => results.matches);
}
}
/**
......@@ -387,16 +404,16 @@ cr.define('omnibox_output', function() {
* rendered by OutputMatch below.
*/
class OutputResultsTable {
/** @param {Array<!mojom.AutocompleteMatch>} results */
/** @param {!Array<!mojom.AutocompleteMatch>} results */
constructor(results) {
/** @type {Array<OutputMatch>} */
/** @type {!Array<!OutputMatch>} */
this.matches = results.map(match => new OutputMatch(match));
}
/**
* Creates a HTML Node representing this data.
* @param {boolean} showDetails
* @return {Element}
* @return {!Element}
*/
render(showDetails) {
const resultsTable = OmniboxElement.getTemplate('results-table-template');
......@@ -437,12 +454,15 @@ cr.define('omnibox_output', function() {
this.additionalProperties[propertyName] = propertyValue;
}
});
/** @type {!Element} */
this.associatedElement;
}
/**
* Creates a HTML Node representing this data.
* @param {boolean} showDetails
* @return {Element}
* @return {!Element}
*/
render(showDetails) {
const row = document.createElement('tr');
......@@ -464,7 +484,8 @@ cr.define('omnibox_output', function() {
row.appendChild(
OutputMatch.renderJsonProperty_(this.additionalProperties));
}
return row;
this.associatedElement = row;
return this.associatedElement;
}
/**
......@@ -472,7 +493,7 @@ cr.define('omnibox_output', function() {
* rendering becomes more substantial
* @private
* @param {string} propertyValue
* @return {Element}
* @return {!Element}
*/
static renderTextProperty_(propertyValue) {
const cell = document.createElement('td');
......@@ -483,7 +504,7 @@ cr.define('omnibox_output', function() {
/**
* @private
* @param {Object} propertyValue
* @return {Element}
* @return {!Element}
*/
static renderJsonProperty_(propertyValue) {
const cell = document.createElement('td');
......@@ -496,7 +517,7 @@ cr.define('omnibox_output', function() {
/**
* @private
* @param {boolean} propertyValue
* @return {Element}
* @return {!Element}
*/
static renderBooleanProperty_(propertyValue) {
const cell = document.createElement('td');
......@@ -510,11 +531,11 @@ cr.define('omnibox_output', function() {
/**
* @private
* @param {string} propertyValue
* @return {Element}
* @return {!Element}
*/
static renderLinkProperty_(propertyValue) {
let cell = document.createElement('td');
let link = document.createElement('a');
const cell = document.createElement('td');
const link = document.createElement('a');
link.textContent = propertyValue;
link.href = propertyValue;
cell.appendChild(link);
......@@ -525,7 +546,7 @@ cr.define('omnibox_output', function() {
* @private
* @param {boolean} showDetails
* @param {boolean} showAdditionalHeader
* @return {Element}
* @return {!Element}
*/
static renderHeader_(showDetails, showAdditionalHeader) {
const row = document.createElement('tr');
......@@ -548,7 +569,7 @@ cr.define('omnibox_output', function() {
* @param {string} name
* @param {string=} url
* @param {string=} tooltip
* @return {Element}
* @return {!Element}
*/
static renderHeaderCell_(name, url, tooltip) {
const cell = document.createElement('th');
......@@ -565,7 +586,7 @@ cr.define('omnibox_output', function() {
}
/**
* @return {Array<PresentationInfoRecord>} Array representing which columns
* @return {!Array<!PresentationInfoRecord>} Array representing which columns
* need to be displayed.
*/
static displayedProperties(showDetails) {
......@@ -586,18 +607,18 @@ cr.define('omnibox_output', function() {
/** Responsible for setting clipboard contents. */
class CopyDelegate {
/** @param {omnibox_output.OmniboxOutput} omniboxOutput */
/** @param {!omnibox_output.OmniboxOutput} omniboxOutput */
constructor(omniboxOutput) {
/** @type {omnibox_output.OmniboxOutput} */
this.omniboxOutput = omniboxOutput;
/** @private {!omnibox_output.OmniboxOutput} */
this.omniboxOutput_ = omniboxOutput;
}
copyTextOutput() {
this.copy_(this.omniboxOutput.renderDelegate.visibletableText);
this.copy_(this.omniboxOutput_.renderDelegate.visibletableText);
}
copyJsonOutput() {
this.copy_(JSON.stringify(this.omniboxOutput.responses, null, 2));
this.copy_(JSON.stringify(this.omniboxOutput_.responses, null, 2));
}
/**
......@@ -610,6 +631,74 @@ cr.define('omnibox_output', function() {
}
}
/** Responsible for highlighting and hiding rows using filter text. */
class FilterDelegate {
/** @param {!omnibox_output.OmniboxOutput} omniboxOutput */
constructor(omniboxOutput) {
/** @private {!omnibox_output.OmniboxOutput} */
this.omniboxOutput_ = omniboxOutput;
}
/**
* @param {string} filterText
* @param {boolean} filterHide
*/
filter(filterText, filterHide) {
this.omniboxOutput_.matches.filter(match => match.associatedElement)
.forEach(match => {
const row = match.associatedElement;
row.classList.remove('filtered-hidden');
row.classList.remove('filtered-highlighted');
if (!filterText)
return;
const isMatch = FilterDelegate.filterMatch_(match, filterText);
row.classList.toggle('filtered-hidden', filterHide && !isMatch);
row.classList.toggle(
'filtered-highlighted', !filterHide && isMatch);
});
}
/**
* Checks if a omnibox match fuzzy-matches a filter string. Each character
* of filterText must be present in the match text, either adjacent to the
* previous matched character, or at the start of a new word (see
* textToWords_).
* E.g. `abc` matches `abc`, `a big cat`, `a-bigCat`, `a very big cat`, and
* `an amBer cat`; but does not match `abigcat` or `an amber cat`.
* `green rainbow` is matched by `gre rain`, but not by `gre bow`.
* One exception is the first character, which may be matched mid-word.
* E.g. `een rain` can also match `green rainbow`.
* @private
* @param {!OutputMatch} match
* @param {string} filterText
* @return {boolean}
*/
static filterMatch_(match, filterText) {
const row = match.associatedElement;
const cells = Array.from(row.querySelectorAll('td'));
const regexFilter = Array.from(filterText).join('(.*\\.)?');
return cells
.map(cell => FilterDelegate.textToWords_(cell.textContent).join('.'))
.some(text => text.match(regexFilter));
}
/**
* Splits a string into words, delimited by either capital letters, groups
* of digits, or non alpha characters.
* E.g., `https://google.com/the-dog-ate-134pies` will be split to:
* https, :, /, /, google, ., com, /, the, -, dog, -, ate, -, 134, pies
* We don't use `Array.split`, because we want to group digits, e.g. 134.
* @private
* @param {string} text
* @return {!Array<string>}
*/
static textToWords_(text) {
return text.match(/[a-z]+|[A-Z][a-z]*|\d+|./g) || [];
}
}
window.customElements.define(OmniboxOutput.is, OmniboxOutput);
return {OmniboxOutput: OmniboxOutput};
......
......@@ -112,3 +112,15 @@ Clipboard.prototype.writeText = function(text) {};
/** @const {!Clipboard} */
Navigator.prototype.clipboard;
/**
* TODO(manukh): remove this once it is added to Closure Compiler itself.
* @see https://tc39.github.io/proposal-flatMap/#sec-Array.prototype.flatMap
* @param {?function(this:S, T, number, !Array<T>): R} callback
* @param {S=} opt_this
* @return {!Array<R>}
* @this {IArrayLike<T>|string}
* @template T,S,R
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/flatMap
*/
Array.prototype.flatMap = function(callback, opt_this) {};
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment