Update gr-autocomplete-dropdown to use iron-fit-behavior

This is instead of iron-dropdown, because there were some focus issues
with iron-dropdown in which the iron-dropdown controlled focus when we
did not want it to.

Bug: Issue 7334
Change-Id: I2da8e0892eecb950f35d7d2b7664a1c7ff5f111a
This commit is contained in:
Becky Siegel
2017-10-03 14:24:53 +01:00
parent 1598e0e5ba
commit 21d48fc69f
8 changed files with 103 additions and 140 deletions

View File

@@ -25,11 +25,11 @@ limitations under the License.
<dom-module id="gr-autocomplete-dropdown">
<template>
<style include="shared-styles">
/* This must be set here vs. the container component because in some cases
the element is moved in the DOM to a base element and is no longer a
child of its original parent. */
:host(.fixed){
position: fixed;
:host {
z-index: 100;
}
:host([is-hidden]) {
display: none;
}
ul {
list-style: none;
@@ -49,29 +49,22 @@ limitations under the License.
box-shadow: rgba(0, 0, 0, 0.3) 0 1px 3px;
}
</style>
<iron-dropdown
id="dropdown"
allow-outside-scroll="true"
vertical-align="top"
horizontal-align="auto"
vertical-offset="[[verticalOffset]]">
<div
class="dropdown-content"
slot="dropdown-content"
id="suggestions"
role="listbox">
<ul>
<template is="dom-repeat" items="[[suggestions]]">
<li data-index$="[[index]]"
data-value$="[[item.dataValue]]"
tabindex="-1"
aria-label$="[[item.name]]"
role="option"
on-tap="_handleTapItem">[[item.text]]</li>
</template>
</ul>
</div>
</iron-dropdown>
<div
class="dropdown-content"
slot="dropdown-content"
id="suggestions"
role="listbox">
<ul>
<template is="dom-repeat" items="[[suggestions]]">
<li data-index$="[[index]]"
data-value$="[[item.dataValue]]"
tabindex="-1"
aria-label$="[[item.name]]"
role="option"
on-tap="_handleTapItem">[[item.text]]</li>
</template>
</ul>
</div>
<gr-cursor-manager
id="cursor"
index="{{index}}"

View File

@@ -14,9 +14,6 @@
(function() {
'use strict';
const AWAIT_MAX_ITERS = 10;
const AWAIT_STEP = 5;
Polymer({
is: 'gr-autocomplete-dropdown',
@@ -34,6 +31,10 @@
properties: {
index: Number,
isHidden: {
value: true,
reflectToAttribute: true,
},
verticalOffset: {
type: Number,
value: null,
@@ -53,6 +54,7 @@
},
behaviors: [
Polymer.IronFitBehavior,
Gerrit.KeyboardShortcutBehavior,
],
@@ -65,46 +67,14 @@
},
close() {
this.$.dropdown.close();
this.isHidden = true;
},
open() {
this._open().then(() => {
this._resetCursorStops();
this._resetCursorIndex();
this.fire('open-complete');
});
},
// TODO (beckysiegel) look into making this a behavior since it's used
// 3 times now.
_open(...args) {
return new Promise(resolve => {
Polymer.IronOverlayBehaviorImpl.open.apply(this.$.dropdown, args);
this._awaitOpen(resolve);
});
},
/**
* NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
* opening. Eventually replace with a direct way to listen to the overlay.
*/
_awaitOpen(fn) {
let iters = 0;
const step = () => {
this.async(() => {
if (this.style.display !== 'none') {
fn.call(this);
} else if (iters++ < AWAIT_MAX_ITERS) {
step.call(this);
}
}, AWAIT_STEP);
};
step.call(this);
},
get isHidden() {
return !this.$.dropdown.opened;
this.isHidden = false;
this.refit();
this._resetCursorStops();
this._resetCursorIndex();
},
getCurrentText() {
@@ -112,7 +82,7 @@
},
_handleUp(e) {
if (!this.hidden) {
if (!this.isHidden) {
e.preventDefault();
e.stopPropagation();
this.cursorUp();
@@ -120,7 +90,7 @@
},
_handleDown(e) {
if (!this.hidden) {
if (!this.isHidden) {
e.preventDefault();
e.stopPropagation();
this.cursorDown();
@@ -128,13 +98,13 @@
},
cursorDown() {
if (!this.hidden) {
if (!this.isHidden) {
this.$.cursor.next();
}
},
cursorUp() {
if (!this.hidden) {
if (!this.isHidden) {
this.$.cursor.previous();
}
},

View File

@@ -52,7 +52,7 @@ limitations under the License.
});
test('escape key', done => {
const closeSpy = sandbox.spy(element.$.dropdown, 'close');
const closeSpy = sandbox.spy(element, 'close');
MockInteractions.pressAndReleaseKeyOn(element, 27);
flushAsynchronousOperations();
assert.isTrue(closeSpy.called);
@@ -87,24 +87,24 @@ limitations under the License.
});
test('down key', () => {
element.hidden = true;
element.isHidden = true;
const nextSpy = sandbox.spy(element.$.cursor, 'next');
MockInteractions.pressAndReleaseKeyOn(element, 40);
assert.isFalse(nextSpy.called);
assert.equal(element.$.cursor.index, 0);
element.hidden = false;
element.isHidden = false;
MockInteractions.pressAndReleaseKeyOn(element, 40);
assert.isTrue(nextSpy.called);
assert.equal(element.$.cursor.index, 1);
});
test('up key', () => {
element.hidden = true;
element.isHidden = true;
const prevSpy = sandbox.spy(element.$.cursor, 'previous');
MockInteractions.pressAndReleaseKeyOn(element, 38);
assert.isFalse(prevSpy.called);
assert.equal(element.$.cursor.index, 0);
element.hidden = false;
element.isHidden = false;
element.$.cursor.setCursorAtIndex(1);
assert.equal(element.$.cursor.index, 1);
MockInteractions.pressAndReleaseKeyOn(element, 38);

View File

@@ -38,26 +38,29 @@ limitations under the License.
color: red;
}
</style>
<input
id="input"
class$="[[_computeClass(borderless)]]"
is="iron-input"
disabled$="[[disabled]]"
bind-value="{{text}}"
placeholder="[[placeholder]]"
on-keydown="_handleKeydown"
on-focus="_onInputFocus"
on-blur="_onInputBlur"
autocomplete="off"/>
<!-- This container is needed for Safari and Firefox -->
<div id="suggestionContainer">
<div>
<input
id="input"
class$="[[_computeClass(borderless)]]"
is="iron-input"
disabled$="[[disabled]]"
bind-value="{{text}}"
placeholder="[[placeholder]]"
on-keydown="_handleKeydown"
on-focus="_onInputFocus"
on-blur="_onInputBlur"
autocomplete="off"/>
<gr-autocomplete-dropdown
vertical-align="top"
vertical-offset="20"
horizontal-align="auto"
id="suggestions"
on-item-selected="_handleItemSelect"
on-keydown="_handleKeydown"
suggestions="[[_suggestions]]"
role="listbox"
index="[[_index]]">
index="[[_index]]"
position-target="[[_inputElement]]">
</gr-autocomplete-dropdown>
</div>
</template>

View File

@@ -185,6 +185,10 @@
this._commit();
},
get _inputElement() {
return this.$.input;
},
/**
* Set the text of the input without triggering the suggestion dropdown.
* @param {string} text The new text for the input.

View File

@@ -67,12 +67,19 @@ limitations under the License.
}
</style>
<div id="hiddenText"></div>
<!-- When the autocomplete is open, the span is moved at the end of
hiddenText in order to correctly position the dropdown. After being moved,
it is set as the positionTarget for the emojiSuggestions dropdown. -->
<span id="caratSpan"></span>
<gr-autocomplete-dropdown
vertical-align="top"
horizontal-align="left"
dynamic-align
id="emojiSuggestions"
suggestions="[[_suggestions]]"
index="[[_index]]"
vertical-offset="[[_verticalOffset]]"
on-dropdown-closed="_resetAndFocus"
on-dropdown-closed="_resetEmojiDropdown"
on-item-selected="_handleEmojiSelect">
</gr-autocomplete-dropdown>
<iron-autogrow-textarea

View File

@@ -125,7 +125,6 @@
}
},
closeDropdown() {
return this.$.emojiSuggestions.close();
},
@@ -148,10 +147,6 @@
if (this._hideAutocomplete) { return; }
e.preventDefault();
e.stopPropagation();
this._resetAndFocus();
},
_resetAndFocus() {
this._resetEmojiDropdown();
},
@@ -190,15 +185,19 @@
},
/**
* Uses a hidden element with the same width and styling of the textarea and
* the text up until the point of interest. Then the emoji selection
* element is added to the end so that they are correctly positioned by the
* end of the last character entered.
* the text up until the point of interest. Then caratSpan element is added
* to the end and is set to be the positionTarget for the dropdown. Together
* this allows the dropdown to appear near where the user is typing.
*/
_updateCaratPosition() {
this._hideAutocomplete = false;
this.$.hiddenText.textContent = this.$.textarea.value.substr(0,
this.$.textarea.selectionStart);
this.$.hiddenText.appendChild(this.$.emojiSuggestions);
const caratSpan = this.$.caratSpan;
this.$.hiddenText.appendChild(caratSpan);
this.$.emojiSuggestions.positionTarget = caratSpan;
this._openEmojiDropdown();
},
_getFontSize() {
@@ -250,8 +249,6 @@
// Otherwise open the dropdown and set the position to be just below the
// cursor.
} else if (this.$.emojiSuggestions.isHidden) {
this._hideAutocomplete = false;
this._openEmojiDropdown();
this._updateCaratPosition();
}
this.$.textarea.textarea.focus();
@@ -268,7 +265,7 @@
suggestion.text = suggestion.value + ' ' + suggestion.match;
suggestions.push(suggestion);
}
this._suggestions = suggestions;
this.set('_suggestions', suggestions);
},
_determineSuggestions(emojiText) {

View File

@@ -171,11 +171,11 @@ limitations under the License.
element.text = 'test';
element._updateCaratPosition();
assert.deepEqual(element.$.hiddenText.innerHTML, element.text +
element.$.emojiSuggestions.outerHTML);
element.$.caratSpan.outerHTML);
});
test('emoji dropdown is closed when iron-overlay-closed is fired', () => {
const resetSpy = sandbox.spy(element, '_resetAndFocus');
const resetSpy = sandbox.spy(element, '_resetEmojiDropdown');
element.$.emojiSuggestions.fire('dropdown-closed');
assert.isTrue(resetSpy.called);
});
@@ -190,10 +190,6 @@ limitations under the License.
suite('keyboard shortcuts', () => {
function setupDropdown(callback) {
element.$.emojiSuggestions.addEventListener('open-complete', () => {
callback();
});
flushAsynchronousOperations();
MockInteractions.focus(element.$.textarea);
element.$.textarea.selectionStart = 1;
element.$.textarea.selectionEnd = 1;
@@ -201,55 +197,48 @@ limitations under the License.
element.$.textarea.selectionStart = 1;
element.$.textarea.selectionEnd = 2;
element.text = ':1';
flushAsynchronousOperations();
}
test('escape key', done => {
const resestSpy = sandbox.spy(element, '_resetAndFocus');
test('escape key', () => {
const resetSpy = sandbox.spy(element, '_resetEmojiDropdown');
MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
assert.isFalse(resestSpy.called);
setupDropdown(() => {
MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
assert.isTrue(resestSpy.called);
assert.isFalse(!element.$.emojiSuggestions.isHidden);
done();
});
assert.isFalse(resetSpy.called);
setupDropdown();
MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
assert.isTrue(resetSpy.called);
assert.isFalse(!element.$.emojiSuggestions.isHidden);
});
test('up key', done => {
test('up key', () => {
const upSpy = sandbox.spy(element.$.emojiSuggestions, 'cursorUp');
MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
assert.isFalse(upSpy.called);
setupDropdown(() => {
MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
assert.isTrue(upSpy.called);
done();
});
setupDropdown();
MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
assert.isTrue(upSpy.called);
});
test('down key', done => {
test('down key', () => {
const downSpy = sandbox.spy(element.$.emojiSuggestions, 'cursorDown');
MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
assert.isFalse(downSpy.called);
setupDropdown(() => {
MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
assert.isTrue(downSpy.called);
done();
});
setupDropdown();
MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
assert.isTrue(downSpy.called);
});
test('enter key', done => {
test('enter key', () => {
const enterSpy = sandbox.spy(element.$.emojiSuggestions,
'getCursorTarget');
MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
assert.isFalse(enterSpy.called);
setupDropdown(() => {
MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
assert.isTrue(enterSpy.called);
flushAsynchronousOperations();
// A space is automatically added at the end.
assert.equal(element.text, '💯 ');
done();
});
setupDropdown();
MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
assert.isTrue(enterSpy.called);
flushAsynchronousOperations();
// A space is automatically added at the end.
assert.equal(element.text, '💯 ');
});
});
});