Merge "Add keyboard nav to gr-dropdown"
This commit is contained in:
commit
87a27d86f8
@ -15,9 +15,11 @@ limitations under the License.
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
|
<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
|
||||||
|
<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
|
||||||
<link rel="import" href="../../../bower_components/polymer/polymer.html">
|
<link rel="import" href="../../../bower_components/polymer/polymer.html">
|
||||||
<link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
|
<link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
|
||||||
<link rel="import" href="../../shared/gr-button/gr-button.html">
|
<link rel="import" href="../../shared/gr-button/gr-button.html">
|
||||||
|
<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
|
||||||
<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
|
<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
|
||||||
<link rel="import" href="../../../styles/shared-styles.html">
|
<link rel="import" href="../../../styles/shared-styles.html">
|
||||||
|
|
||||||
@ -80,6 +82,11 @@ limitations under the License.
|
|||||||
background-color: #6B82D6;
|
background-color: #6B82D6;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
li:focus,
|
||||||
|
li.selected {
|
||||||
|
background-color: #EBF5FB;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
.topContent {
|
.topContent {
|
||||||
display: block;
|
display: block;
|
||||||
padding: .85em 1em;
|
padding: .85em 1em;
|
||||||
@ -123,7 +130,9 @@ limitations under the License.
|
|||||||
items="[[topContent]]"
|
items="[[topContent]]"
|
||||||
as="item"
|
as="item"
|
||||||
initial-count="75">
|
initial-count="75">
|
||||||
<div class$="[[_getClassIfBold(item.bold)]] top-item">
|
<div
|
||||||
|
class$="[[_getClassIfBold(item.bold)]] top-item"
|
||||||
|
tabindex="-1">
|
||||||
[[item.text]]
|
[[item.text]]
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -134,23 +143,31 @@ limitations under the License.
|
|||||||
items="[[items]]"
|
items="[[items]]"
|
||||||
as="link"
|
as="link"
|
||||||
initial-count="75">
|
initial-count="75">
|
||||||
<li>
|
<li tabindex="-1">
|
||||||
<span
|
<span
|
||||||
class$="itemAction [[_computeDisabledClass(link.id, disabledIds.*)]]"
|
class$="itemAction [[_computeDisabledClass(link.id, disabledIds.*)]]"
|
||||||
data-id$="[[link.id]]"
|
data-id$="[[link.id]]"
|
||||||
on-tap="_handleItemTap"
|
on-tap="_handleItemTap"
|
||||||
hidden$="[[link.url]]">[[link.name]]</span>
|
hidden$="[[link.url]]"
|
||||||
|
tabindex="-1">[[link.name]]</span>
|
||||||
<a
|
<a
|
||||||
class="itemAction"
|
class="itemAction"
|
||||||
href$="[[_computeLinkURL(link)]]"
|
href$="[[_computeLinkURL(link)]]"
|
||||||
rel$="[[_computeLinkRel(link)]]"
|
rel$="[[_computeLinkRel(link)]]"
|
||||||
target$="[[link.target]]"
|
target$="[[link.target]]"
|
||||||
hidden$="[[!link.url]]">[[link.name]]</a>
|
hidden$="[[!link.url]]"
|
||||||
|
tabindex="-1">[[link.name]]</a>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</iron-dropdown>
|
</iron-dropdown>
|
||||||
|
<gr-cursor-manager
|
||||||
|
id="cursor"
|
||||||
|
cursor-target-class="selected"
|
||||||
|
scroll-behavior="never"
|
||||||
|
focus-on-move
|
||||||
|
stops="[[_els]]"</gr-cursor-manager>
|
||||||
<gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
|
<gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
|
||||||
</template>
|
</template>
|
||||||
<script src="gr-dropdown.js"></script>
|
<script src="gr-dropdown.js"></script>
|
||||||
|
@ -30,7 +30,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
properties: {
|
properties: {
|
||||||
items: Array,
|
items: {
|
||||||
|
type: Array,
|
||||||
|
observer: '_resetCursorStops',
|
||||||
|
},
|
||||||
topContent: Object,
|
topContent: Object,
|
||||||
horizontalAlign: {
|
horizontalAlign: {
|
||||||
type: String,
|
type: String,
|
||||||
@ -60,18 +63,76 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
_hasAvatars: String,
|
_hasAvatars: String,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The elements of the list.
|
||||||
|
*/
|
||||||
|
_listElements: {
|
||||||
|
type: Array,
|
||||||
|
value() { return []; },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
behaviors: [
|
behaviors: [
|
||||||
Gerrit.BaseUrlBehavior,
|
Gerrit.BaseUrlBehavior,
|
||||||
|
Gerrit.KeyboardShortcutBehavior,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
keyBindings: {
|
||||||
|
'down': '_handleDown',
|
||||||
|
'enter space': '_handleEnter',
|
||||||
|
'tab': '_handleTab',
|
||||||
|
'up': '_handleUp',
|
||||||
|
},
|
||||||
|
|
||||||
attached() {
|
attached() {
|
||||||
this.$.restAPI.getConfig().then(cfg => {
|
this.$.restAPI.getConfig().then(cfg => {
|
||||||
this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
|
this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_handleUp(e) {
|
||||||
|
if (this.$.dropdown.opened) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this.$.cursor.previous();
|
||||||
|
} else {
|
||||||
|
this._open();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_handleDown(e) {
|
||||||
|
if (this.$.dropdown.opened) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this.$.cursor.next();
|
||||||
|
} else {
|
||||||
|
this._open();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_handleTab(e) {
|
||||||
|
if (this.$.dropdown.opened) {
|
||||||
|
// Tab in a native select is a no-op. Emulate this.
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_handleEnter(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (this.$.dropdown.opened) {
|
||||||
|
// TODO(kaspern): This solution will not work in Shadow DOM, and
|
||||||
|
// is not particularly robust in general. Find a better solution
|
||||||
|
// when page.js has been abstracted away from components.
|
||||||
|
const el = this.$.cursor.target.querySelector(':not([hidden])');
|
||||||
|
if (el) { el.click(); }
|
||||||
|
} else {
|
||||||
|
this._open();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
_handleDropdownTap(e) {
|
_handleDropdownTap(e) {
|
||||||
// async is needed so that that the click event is fired before the
|
// async is needed so that that the click event is fired before the
|
||||||
// dropdown closes (This was a bug for touch devices).
|
// dropdown closes (This was a bug for touch devices).
|
||||||
@ -81,7 +142,14 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
_showDropdownTapHandler(e) {
|
_showDropdownTapHandler(e) {
|
||||||
|
this._open();
|
||||||
|
},
|
||||||
|
|
||||||
|
_open() {
|
||||||
this.$.dropdown.open();
|
this.$.dropdown.open();
|
||||||
|
this.$.cursor.setCursorAtIndex(0);
|
||||||
|
Polymer.dom.flush();
|
||||||
|
this.$.cursor.target.focus();
|
||||||
},
|
},
|
||||||
|
|
||||||
_getClassIfBold(bold) {
|
_getClassIfBold(bold) {
|
||||||
@ -113,9 +181,7 @@
|
|||||||
|
|
||||||
_handleItemTap(e) {
|
_handleItemTap(e) {
|
||||||
const id = e.target.getAttribute('data-id');
|
const id = e.target.getAttribute('data-id');
|
||||||
const item = this.items.find(item => {
|
const item = this.items.find(item => item.id === id);
|
||||||
return item.id === id;
|
|
||||||
});
|
|
||||||
if (id && !this.disabledIds.includes(id)) {
|
if (id && !this.disabledIds.includes(id)) {
|
||||||
if (item) {
|
if (item) {
|
||||||
this.dispatchEvent(new CustomEvent('tap-item', {detail: item}));
|
this.dispatchEvent(new CustomEvent('tap-item', {detail: item}));
|
||||||
@ -127,5 +193,10 @@
|
|||||||
_computeDisabledClass(id, disabledIdsRecord) {
|
_computeDisabledClass(id, disabledIdsRecord) {
|
||||||
return disabledIdsRecord.base.includes(id) ? 'disabled' : '';
|
return disabledIdsRecord.base.includes(id) ? 'disabled' : '';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_resetCursorStops() {
|
||||||
|
Polymer.dom.flush();
|
||||||
|
this._els = this.querySelectorAll('li');
|
||||||
|
},
|
||||||
});
|
});
|
||||||
})();
|
})();
|
@ -34,15 +34,22 @@ limitations under the License.
|
|||||||
<script>
|
<script>
|
||||||
suite('gr-dropdown tests', () => {
|
suite('gr-dropdown tests', () => {
|
||||||
let element;
|
let element;
|
||||||
|
let sandbox;
|
||||||
|
|
||||||
setup(() => {
|
setup(() => {
|
||||||
stub('gr-rest-api-interface', {
|
stub('gr-rest-api-interface', {
|
||||||
getConfig() { return Promise.resolve({}); },
|
getConfig() { return Promise.resolve({}); },
|
||||||
});
|
});
|
||||||
element = fixture('basic');
|
element = fixture('basic');
|
||||||
|
sandbox = sinon.sandbox.create();
|
||||||
|
});
|
||||||
|
|
||||||
|
teardown(() => {
|
||||||
|
sandbox.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tap on trigger opens menu', () => {
|
test('tap on trigger opens menu', () => {
|
||||||
|
sandbox.stub(element, '_open', () => {element.$.dropdown.open();});
|
||||||
assert.isFalse(element.$.dropdown.opened);
|
assert.isFalse(element.$.dropdown.opened);
|
||||||
MockInteractions.tap(element.$.trigger);
|
MockInteractions.tap(element.$.trigger);
|
||||||
assert.isTrue(element.$.dropdown.opened);
|
assert.isTrue(element.$.dropdown.opened);
|
||||||
@ -91,8 +98,8 @@ limitations under the License.
|
|||||||
test('non link items', () => {
|
test('non link items', () => {
|
||||||
const item0 = {name: 'item one', id: 'foo'};
|
const item0 = {name: 'item one', id: 'foo'};
|
||||||
element.items = [item0, {name: 'item two', id: 'bar'}];
|
element.items = [item0, {name: 'item two', id: 'bar'}];
|
||||||
const fooTapped = sinon.stub();
|
const fooTapped = sandbox.stub();
|
||||||
const tapped = sinon.stub();
|
const tapped = sandbox.stub();
|
||||||
element.addEventListener('tap-item-foo', fooTapped);
|
element.addEventListener('tap-item-foo', fooTapped);
|
||||||
element.addEventListener('tap-item', tapped);
|
element.addEventListener('tap-item', tapped);
|
||||||
flushAsynchronousOperations();
|
flushAsynchronousOperations();
|
||||||
@ -106,8 +113,8 @@ limitations under the License.
|
|||||||
element.items = [{name: 'item one', id: 'foo'}];
|
element.items = [{name: 'item one', id: 'foo'}];
|
||||||
element.disabledIds = ['foo'];
|
element.disabledIds = ['foo'];
|
||||||
|
|
||||||
const stub = sinon.stub();
|
const stub = sandbox.stub();
|
||||||
const tapped = sinon.stub();
|
const tapped = sandbox.stub();
|
||||||
element.addEventListener('tap-item-foo', stub);
|
element.addEventListener('tap-item-foo', stub);
|
||||||
element.addEventListener('tap-item', tapped);
|
element.addEventListener('tap-item', tapped);
|
||||||
flushAsynchronousOperations();
|
flushAsynchronousOperations();
|
||||||
@ -115,5 +122,46 @@ limitations under the License.
|
|||||||
assert.isFalse(stub.called);
|
assert.isFalse(stub.called);
|
||||||
assert.isFalse(tapped.called);
|
assert.isFalse(tapped.called);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
suite('keyboard navigation', () => {
|
||||||
|
setup(() => {
|
||||||
|
element.items = [
|
||||||
|
{name: 'item one', id: 'foo'},
|
||||||
|
{name: 'item two', id: 'bar'},
|
||||||
|
];
|
||||||
|
flushAsynchronousOperations();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('down', () => {
|
||||||
|
const stub = sandbox.stub(element.$.cursor, 'next');
|
||||||
|
assert.isFalse(element.$.dropdown.opened);
|
||||||
|
MockInteractions.pressAndReleaseKeyOn(element, 40);
|
||||||
|
assert.isTrue(element.$.dropdown.opened);
|
||||||
|
MockInteractions.pressAndReleaseKeyOn(element, 40);
|
||||||
|
assert.isTrue(stub.called);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('up', () => {
|
||||||
|
const stub = sandbox.stub(element.$.cursor, 'previous');
|
||||||
|
assert.isFalse(element.$.dropdown.opened);
|
||||||
|
MockInteractions.pressAndReleaseKeyOn(element, 38);
|
||||||
|
assert.isTrue(element.$.dropdown.opened);
|
||||||
|
MockInteractions.pressAndReleaseKeyOn(element, 38);
|
||||||
|
assert.isTrue(stub.called);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enter/space', () => {
|
||||||
|
// Because enter and space are handled by the same fn, we need only to
|
||||||
|
// test one.
|
||||||
|
assert.isFalse(element.$.dropdown.opened);
|
||||||
|
MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
|
||||||
|
assert.isTrue(element.$.dropdown.opened);
|
||||||
|
|
||||||
|
const el = element.$.cursor.target.querySelector(':not([hidden])');
|
||||||
|
const stub = sandbox.stub(el, 'click');
|
||||||
|
MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
|
||||||
|
assert.isTrue(stub.called);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user