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"> <dom-module id="gr-autocomplete-dropdown">
<template> <template>
<style include="shared-styles"> <style include="shared-styles">
/* This must be set here vs. the container component because in some cases :host {
the element is moved in the DOM to a base element and is no longer a z-index: 100;
child of its original parent. */ }
:host(.fixed){ :host([is-hidden]) {
position: fixed; display: none;
} }
ul { ul {
list-style: none; list-style: none;
@@ -49,12 +49,6 @@ limitations under the License.
box-shadow: rgba(0, 0, 0, 0.3) 0 1px 3px; box-shadow: rgba(0, 0, 0, 0.3) 0 1px 3px;
} }
</style> </style>
<iron-dropdown
id="dropdown"
allow-outside-scroll="true"
vertical-align="top"
horizontal-align="auto"
vertical-offset="[[verticalOffset]]">
<div <div
class="dropdown-content" class="dropdown-content"
slot="dropdown-content" slot="dropdown-content"
@@ -71,7 +65,6 @@ limitations under the License.
</template> </template>
</ul> </ul>
</div> </div>
</iron-dropdown>
<gr-cursor-manager <gr-cursor-manager
id="cursor" id="cursor"
index="{{index}}" index="{{index}}"

View File

@@ -14,9 +14,6 @@
(function() { (function() {
'use strict'; 'use strict';
const AWAIT_MAX_ITERS = 10;
const AWAIT_STEP = 5;
Polymer({ Polymer({
is: 'gr-autocomplete-dropdown', is: 'gr-autocomplete-dropdown',
@@ -34,6 +31,10 @@
properties: { properties: {
index: Number, index: Number,
isHidden: {
value: true,
reflectToAttribute: true,
},
verticalOffset: { verticalOffset: {
type: Number, type: Number,
value: null, value: null,
@@ -53,6 +54,7 @@
}, },
behaviors: [ behaviors: [
Polymer.IronFitBehavior,
Gerrit.KeyboardShortcutBehavior, Gerrit.KeyboardShortcutBehavior,
], ],
@@ -65,46 +67,14 @@
}, },
close() { close() {
this.$.dropdown.close(); this.isHidden = true;
}, },
open() { open() {
this._open().then(() => { this.isHidden = false;
this.refit();
this._resetCursorStops(); this._resetCursorStops();
this._resetCursorIndex(); 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;
}, },
getCurrentText() { getCurrentText() {
@@ -112,7 +82,7 @@
}, },
_handleUp(e) { _handleUp(e) {
if (!this.hidden) { if (!this.isHidden) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.cursorUp(); this.cursorUp();
@@ -120,7 +90,7 @@
}, },
_handleDown(e) { _handleDown(e) {
if (!this.hidden) { if (!this.isHidden) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.cursorDown(); this.cursorDown();
@@ -128,13 +98,13 @@
}, },
cursorDown() { cursorDown() {
if (!this.hidden) { if (!this.isHidden) {
this.$.cursor.next(); this.$.cursor.next();
} }
}, },
cursorUp() { cursorUp() {
if (!this.hidden) { if (!this.isHidden) {
this.$.cursor.previous(); this.$.cursor.previous();
} }
}, },

View File

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

View File

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

View File

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

View File

@@ -67,12 +67,19 @@ limitations under the License.
} }
</style> </style>
<div id="hiddenText"></div> <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 <gr-autocomplete-dropdown
vertical-align="top"
horizontal-align="left"
dynamic-align
id="emojiSuggestions" id="emojiSuggestions"
suggestions="[[_suggestions]]" suggestions="[[_suggestions]]"
index="[[_index]]" index="[[_index]]"
vertical-offset="[[_verticalOffset]]" vertical-offset="[[_verticalOffset]]"
on-dropdown-closed="_resetAndFocus" on-dropdown-closed="_resetEmojiDropdown"
on-item-selected="_handleEmojiSelect"> on-item-selected="_handleEmojiSelect">
</gr-autocomplete-dropdown> </gr-autocomplete-dropdown>
<iron-autogrow-textarea <iron-autogrow-textarea

View File

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

View File

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