mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
Refactor typeahead for reuse (#14165)
* First stab at refactoring typeahead * Don't double submit on enter * Add item templating * Remove unnecessary test * Don't submit on enter * Add clarifying comment * Fix history log key * Fix key handling * Don't always hide on submit * Check items exists * Simplify select next/prev and reset selected on hide/backspace * Add test * Put persistedLog on scope so it can be tested * Update typeahead items when language changes * Finish that work I didn't do in the last commit * Order matters
This commit is contained in:
parent
323ff3678f
commit
a856e42a8a
11 changed files with 334 additions and 469 deletions
|
@ -134,12 +134,11 @@ describe('queryBar directive', function () {
|
|||
|
||||
it('should use a unique typeahead key for each appName/language combo', function () {
|
||||
init({ query: 'foo', language: 'lucene' }, 'discover', true);
|
||||
const typeahead = $elem.find('.typeahead');
|
||||
expect(typeahead.isolateScope().historyKey).to.be('discover-lucene');
|
||||
expect($elem.isolateScope().queryBar.persistedLog.name).to.be('typeahead:discover-lucene');
|
||||
|
||||
$parentScope.query = { query: 'foo', language: 'kuery' };
|
||||
$parentScope.$digest();
|
||||
expect(typeahead.isolateScope().historyKey).to.be('discover-kuery');
|
||||
expect($elem.isolateScope().queryBar.persistedLog.name).to.be('typeahead:discover-kuery');
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
@ -3,11 +3,20 @@
|
|||
name="queryBarForm"
|
||||
ng-submit="queryBar.submit()"
|
||||
>
|
||||
<div class="typeahead" kbn-typeahead="{{queryBar.typeaheadKey()}}" on-select="queryBar.submit()" role="search">
|
||||
<div class="kuiLocalSearch">
|
||||
<kbn-typeahead
|
||||
items="queryBar.typeaheadItems"
|
||||
on-select="queryBar.onTypeaheadSelect(item)"
|
||||
>
|
||||
<div
|
||||
class="kuiLocalSearch"
|
||||
role="search"
|
||||
>
|
||||
|
||||
<!-- Lucene input -->
|
||||
<div class="kuiLocalSearchAssistedInput" ng-if="queryBar.localQuery.language === 'lucene'">
|
||||
<div
|
||||
class="kuiLocalSearchAssistedInput"
|
||||
ng-if="queryBar.localQuery.language === 'lucene'"
|
||||
>
|
||||
<input
|
||||
parse-query
|
||||
input-focus
|
||||
|
@ -38,7 +47,10 @@
|
|||
</div>
|
||||
|
||||
<!-- kuery input -->
|
||||
<div class="kuiLocalSearchAssistedInput" ng-if="queryBar.localQuery.language === 'kuery'">
|
||||
<div
|
||||
class="kuiLocalSearchAssistedInput"
|
||||
ng-if="queryBar.localQuery.language === 'kuery'"
|
||||
>
|
||||
<input
|
||||
ng-model="queryBar.localQuery.query"
|
||||
input-focus
|
||||
|
@ -104,6 +116,5 @@
|
|||
<span class="fa fa-search" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<kbn-typeahead-items></kbn-typeahead-items>
|
||||
</div>
|
||||
</kbn-typeahead>
|
||||
</form>
|
||||
|
|
|
@ -19,13 +19,15 @@ module.directive('queryBar', function () {
|
|||
},
|
||||
controllerAs: 'queryBar',
|
||||
bindToController: true,
|
||||
controller: callAfterBindingsWorkaround(function ($scope, config) {
|
||||
controller: callAfterBindingsWorkaround(function ($scope, config, PersistedLog) {
|
||||
this.appName = this.appName || 'global';
|
||||
this.availableQueryLanguages = queryLanguages;
|
||||
this.showLanguageSwitcher = config.get('search:queryLanguage:switcher:enable');
|
||||
this.typeaheadKey = () => `${this.appName}-${this.query.language}`;
|
||||
|
||||
this.submit = () => {
|
||||
if (this.localQuery.query) {
|
||||
this.persistedLog.add(this.localQuery.query);
|
||||
}
|
||||
this.onSubmit({ $query: this.localQuery });
|
||||
};
|
||||
|
||||
|
@ -34,11 +36,33 @@ module.directive('queryBar', function () {
|
|||
this.submit();
|
||||
};
|
||||
|
||||
this.onTypeaheadSelect = (item) => {
|
||||
this.localQuery.query = item;
|
||||
this.submit();
|
||||
};
|
||||
|
||||
this.updateTypeaheadItems = () => {
|
||||
const { persistedLog, localQuery: { query } } = this;
|
||||
this.typeaheadItems = persistedLog.get().filter(recentSearch => {
|
||||
return recentSearch.includes(query) && recentSearch !== query;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.$watch('queryBar.query', (newQuery) => {
|
||||
this.localQuery = {
|
||||
...newQuery
|
||||
};
|
||||
}, true);
|
||||
|
||||
$scope.$watch('queryBar.localQuery.language', (language) => {
|
||||
this.persistedLog = new PersistedLog(`typeahead:${this.appName}-${language}`, {
|
||||
maxLength: config.get('history:limit'),
|
||||
filterDuplicates: true
|
||||
});
|
||||
this.updateTypeaheadItems();
|
||||
});
|
||||
|
||||
$scope.$watch('queryBar.localQuery.query', this.updateTypeaheadItems);
|
||||
})
|
||||
};
|
||||
|
||||
|
|
|
@ -1,230 +1,199 @@
|
|||
import angular from 'angular';
|
||||
import sinon from 'sinon';
|
||||
import expect from 'expect.js';
|
||||
import sinon from 'sinon';
|
||||
import ngMock from 'ng_mock';
|
||||
import 'ui/typeahead';
|
||||
import 'plugins/kibana/discover/index';
|
||||
import '../typeahead';
|
||||
import { comboBoxKeyCodes } from '@elastic/eui';
|
||||
const { UP, DOWN, ENTER, TAB, ESCAPE } = comboBoxKeyCodes;
|
||||
|
||||
// TODO: This should not be needed, timefilter is only included here, it should move
|
||||
describe('Typeahead directive', function () {
|
||||
let $compile;
|
||||
let scope;
|
||||
let element;
|
||||
|
||||
const typeaheadHistoryCount = 10;
|
||||
const typeaheadName = 'unittest';
|
||||
let $parentScope;
|
||||
let $typeaheadScope;
|
||||
let $elem;
|
||||
let typeaheadCtrl;
|
||||
let onSelectStub;
|
||||
beforeEach(ngMock.module('kibana'));
|
||||
|
||||
let markup = `<div class="typeahead" kbn-typeahead="${typeaheadName}" on-select="selectItem()">
|
||||
<input type="text" placeholder="Filter..." class="form-control" ng-model="query" kbn-typeahead-input>
|
||||
<kbn-typeahead-items></kbn-typeahead-items>
|
||||
</div>`;
|
||||
const typeaheadItems = ['abc', 'def', 'ghi'];
|
||||
beforeEach(ngMock.inject(function (_$compile_, _$rootScope_) {
|
||||
$compile = _$compile_;
|
||||
scope = _$rootScope_.$new();
|
||||
const html = `
|
||||
<kbn-typeahead
|
||||
items="items"
|
||||
item-template="itemTemplate"
|
||||
on-select="onSelect(item)"
|
||||
>
|
||||
<input
|
||||
kbn-typeahead-input
|
||||
ng-model="value"
|
||||
type="text"
|
||||
/>
|
||||
</kbn-typeahead>
|
||||
`;
|
||||
element = $compile(html)(scope);
|
||||
scope.items = ['foo', 'bar', 'baz'];
|
||||
scope.onSelect = sinon.spy();
|
||||
scope.$digest();
|
||||
}));
|
||||
|
||||
const init = function () {
|
||||
// Load the application
|
||||
ngMock.module('kibana');
|
||||
|
||||
ngMock.module('kibana/typeahead', function ($provide) {
|
||||
$provide.factory('PersistedLog', function () {
|
||||
function PersistedLogMock(name, options) {
|
||||
this.name = name;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
PersistedLogMock.prototype.add = sinon.stub().returns(typeaheadItems);
|
||||
PersistedLogMock.prototype.get = sinon.stub().returns(typeaheadItems);
|
||||
|
||||
return PersistedLogMock;
|
||||
});
|
||||
|
||||
$provide.service('config', function () {
|
||||
this.get = sinon.stub().returns(typeaheadHistoryCount);
|
||||
describe('before focus', function () {
|
||||
it('should be hidden', function () {
|
||||
scope.$digest();
|
||||
expect(element.find('.typeahead-items').hasClass('ng-hide')).to.be(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Create the scope
|
||||
ngMock.inject(function ($injector, $controller, $rootScope, $compile) {
|
||||
// Give us a scope
|
||||
$parentScope = $rootScope;
|
||||
|
||||
$parentScope.selectItem = onSelectStub = sinon.stub();
|
||||
$elem = angular.element(markup);
|
||||
|
||||
$compile($elem)($parentScope);
|
||||
$elem.scope().$digest();
|
||||
$typeaheadScope = $elem.isolateScope();
|
||||
typeaheadCtrl = $elem.controller('kbnTypeahead');
|
||||
});
|
||||
};
|
||||
|
||||
describe('typeahead directive', function () {
|
||||
describe('typeahead requirements', function () {
|
||||
describe('missing on-select attribute', function () {
|
||||
const goodMarkup = markup;
|
||||
|
||||
before(function () {
|
||||
markup = `<div class="typeahead" kbn-typeahead="${typeaheadName}">
|
||||
<input type="text" placeholder="Filter..." class="form-control" ng-model="query" kbn-typeahead-input />
|
||||
<kbn-typeahead-items></kbn-typeahead-items>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
after(function () {
|
||||
markup = goodMarkup;
|
||||
});
|
||||
|
||||
it('should throw with message', function () {
|
||||
expect(init).to.throwException(/on-select must be defined/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('internal functionality', function () {
|
||||
describe('after focus', function () {
|
||||
beforeEach(function () {
|
||||
init();
|
||||
element.find('input').triggerHandler('focus');
|
||||
});
|
||||
|
||||
describe('PersistedLog', function () {
|
||||
it('should instantiate PersistedLog', function () {
|
||||
expect(typeaheadCtrl.history.name).to.equal('typeahead:' + typeaheadName);
|
||||
expect(typeaheadCtrl.history.options.maxLength).to.equal(typeaheadHistoryCount);
|
||||
expect(typeaheadCtrl.history.options.filterDuplicates).to.equal(true);
|
||||
});
|
||||
|
||||
it('should read data when directive is instantiated', function () {
|
||||
expect(typeaheadCtrl.history.get.callCount).to.be(1);
|
||||
});
|
||||
|
||||
it('should not save empty entries', function () {
|
||||
const entries = typeaheadItems.slice(0);
|
||||
entries.push('', 'jkl');
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
$typeaheadScope.inputModel.$setViewValue(entries[i]);
|
||||
typeaheadCtrl.persistEntry();
|
||||
}
|
||||
expect(typeaheadCtrl.history.add.callCount).to.be(4);
|
||||
});
|
||||
|
||||
it('should still be hidden', function () {
|
||||
scope.$digest();
|
||||
expect(element.find('.typeahead-items').hasClass('ng-hide')).to.be(true);
|
||||
});
|
||||
|
||||
describe('controller scope', function () {
|
||||
it('should contain the input model', function () {
|
||||
expect($typeaheadScope.inputModel).to.be.an('object');
|
||||
expect($typeaheadScope.inputModel)
|
||||
.to.have.property('$viewValue')
|
||||
.and.have.property('$modelValue')
|
||||
.and.have.property('$setViewValue').a('function');
|
||||
it('should show when a key other than escape is pressed unless there are no items', function () {
|
||||
element.find('.typeahead').triggerHandler({
|
||||
type: 'keydown',
|
||||
keyCode: 'A'.charCodeAt(0)
|
||||
});
|
||||
|
||||
it('should save data to the scope', function () {
|
||||
// $scope.items is set via history.add, so mock the output
|
||||
typeaheadCtrl.history.add.returns(typeaheadItems);
|
||||
scope.$digest();
|
||||
|
||||
// a single call will call history.add, which will respond with the mocked data
|
||||
$typeaheadScope.inputModel.$setViewValue(typeaheadItems[0]);
|
||||
typeaheadCtrl.persistEntry();
|
||||
expect(element.find('.typeahead-items').hasClass('ng-hide')).to.be(false);
|
||||
|
||||
expect($typeaheadScope.items).to.be.an('array');
|
||||
expect($typeaheadScope.items.length).to.be(typeaheadItems.length);
|
||||
scope.items = [];
|
||||
scope.$digest();
|
||||
|
||||
expect(element.find('.typeahead-items').hasClass('ng-hide')).to.be(true);
|
||||
});
|
||||
|
||||
it('should hide when escape is pressed', function () {
|
||||
element.find('.typeahead').triggerHandler({
|
||||
type: 'keydown',
|
||||
keyCode: ESCAPE
|
||||
});
|
||||
|
||||
it('should order filtered results', function () {
|
||||
const entries = ['ac/dc', 'anthrax', 'abba', 'phantogram', 'skrillex'];
|
||||
const allEntries = typeaheadItems.concat(entries);
|
||||
const startMatches = allEntries.filter(function (item) {
|
||||
return /^a/.test(item);
|
||||
});
|
||||
typeaheadCtrl.history.add.returns(allEntries);
|
||||
scope.$digest();
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
$typeaheadScope.inputModel.$setViewValue(entries[i]);
|
||||
typeaheadCtrl.persistEntry();
|
||||
}
|
||||
expect(element.find('.typeahead-items').hasClass('ng-hide')).to.be(true);
|
||||
});
|
||||
|
||||
typeaheadCtrl.filterItemsByQuery('a');
|
||||
it('should select the next option on arrow down', function () {
|
||||
let expectedActiveIndex = -1;
|
||||
for (let i = 0; i < scope.items.length + 1; i++) {
|
||||
expectedActiveIndex++;
|
||||
if (expectedActiveIndex > scope.items.length - 1) expectedActiveIndex = 0;
|
||||
|
||||
expect($typeaheadScope.filteredItems).to.contain('phantogram');
|
||||
const nonStarterIndex = $typeaheadScope.filteredItems.indexOf('phantogram');
|
||||
|
||||
startMatches.forEach(function (item) {
|
||||
expect($typeaheadScope.filteredItems).to.contain(item);
|
||||
expect($typeaheadScope.filteredItems.indexOf(item)).to.be.below(nonStarterIndex);
|
||||
element.find('.typeahead').triggerHandler({
|
||||
type: 'keydown',
|
||||
keyCode: DOWN
|
||||
});
|
||||
|
||||
expect($typeaheadScope.filteredItems).not.to.contain('skrillex');
|
||||
scope.$digest();
|
||||
|
||||
expect(element.find('.typeahead-item.active').length).to.be(1);
|
||||
expect(element.find('.typeahead-item').eq(expectedActiveIndex).hasClass('active')).to.be(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should select the previous option on arrow up', function () {
|
||||
let expectedActiveIndex = scope.items.length;
|
||||
for (let i = 0; i < scope.items.length + 1; i++) {
|
||||
expectedActiveIndex--;
|
||||
if (expectedActiveIndex < 0) expectedActiveIndex = scope.items.length - 1;
|
||||
|
||||
element.find('.typeahead').triggerHandler({
|
||||
type: 'keydown',
|
||||
keyCode: UP
|
||||
});
|
||||
|
||||
scope.$digest();
|
||||
|
||||
expect(element.find('.typeahead-item.active').length).to.be(1);
|
||||
expect(element.find('.typeahead-item').eq(expectedActiveIndex).hasClass('active')).to.be(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should fire the onSelect handler with the selected item on enter', function () {
|
||||
const typeaheadEl = element.find('.typeahead');
|
||||
|
||||
typeaheadEl.triggerHandler({
|
||||
type: 'keydown',
|
||||
keyCode: DOWN
|
||||
});
|
||||
|
||||
it('should call the on-select method on mouse click of an item', function () {
|
||||
// $scope.items is set via history.add, so mock the output
|
||||
typeaheadCtrl.history.add.returns(typeaheadItems);
|
||||
typeaheadEl.triggerHandler({
|
||||
type: 'keydown',
|
||||
keyCode: ENTER
|
||||
});
|
||||
|
||||
// a single call will call history.add, which will respond with the mocked data
|
||||
$typeaheadScope.inputModel.$setViewValue(typeaheadItems[0]);
|
||||
typeaheadCtrl.persistEntry();
|
||||
scope.$digest();
|
||||
|
||||
$parentScope.$digest();
|
||||
sinon.assert.calledOnce(scope.onSelect);
|
||||
sinon.assert.calledWith(scope.onSelect, scope.items[0]);
|
||||
});
|
||||
|
||||
$elem.find('.typeahead-item').click();
|
||||
sinon.assert.called(onSelectStub);
|
||||
it('should fire the onSelect handler with the selected item on tab', function () {
|
||||
const typeaheadEl = element.find('.typeahead');
|
||||
|
||||
typeaheadEl.triggerHandler({
|
||||
type: 'keydown',
|
||||
keyCode: DOWN
|
||||
});
|
||||
|
||||
typeaheadEl.triggerHandler({
|
||||
type: 'keydown',
|
||||
keyCode: TAB
|
||||
});
|
||||
|
||||
scope.$digest();
|
||||
|
||||
sinon.assert.calledOnce(scope.onSelect);
|
||||
sinon.assert.calledWith(scope.onSelect, scope.items[0]);
|
||||
});
|
||||
|
||||
it('should select the option on hover', function () {
|
||||
const hoverIndex = 0;
|
||||
element.find('.typeahead-item').eq(hoverIndex).triggerHandler('mouseenter');
|
||||
|
||||
scope.$digest();
|
||||
|
||||
expect(element.find('.typeahead-item.active').length).to.be(1);
|
||||
expect(element.find('.typeahead-item').eq(hoverIndex).hasClass('active')).to.be(true);
|
||||
});
|
||||
|
||||
it('should fire the onSelect handler with the selected item on click', function () {
|
||||
const clickIndex = 1;
|
||||
const clickEl = element.find('.typeahead-item').eq(clickIndex);
|
||||
clickEl.triggerHandler('mouseenter');
|
||||
clickEl.triggerHandler('click');
|
||||
|
||||
scope.$digest();
|
||||
|
||||
sinon.assert.calledOnce(scope.onSelect);
|
||||
sinon.assert.calledWith(scope.onSelect, scope.items[clickIndex]);
|
||||
});
|
||||
|
||||
it('should update the list when the items change', function () {
|
||||
scope.items = ['qux'];
|
||||
scope.$digest();
|
||||
expect(expect(element.find('.typeahead-item').length).to.be(scope.items.length));
|
||||
});
|
||||
|
||||
it('should default to showing the item itself in the list', function () {
|
||||
scope.items.forEach((item, i) => {
|
||||
expect(element.find('kbn-typeahead-item').eq(i).html()).to.be(item);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list appearance', function () {
|
||||
beforeEach(function () {
|
||||
typeaheadCtrl.history.add.returns(typeaheadItems);
|
||||
$typeaheadScope.inputModel.$setViewValue(typeaheadItems[0]);
|
||||
typeaheadCtrl.persistEntry();
|
||||
|
||||
// make sure the data looks how we expect
|
||||
expect($typeaheadScope.items.length).to.be(3);
|
||||
});
|
||||
|
||||
it('should default to hidden', function () {
|
||||
expect(typeaheadCtrl.isVisible()).to.be(false);
|
||||
});
|
||||
|
||||
it('should appear when not hidden, has matches input and focused', function () {
|
||||
typeaheadCtrl.setHidden(false);
|
||||
expect(typeaheadCtrl.isVisible()).to.be(false);
|
||||
|
||||
typeaheadCtrl.filterItemsByQuery(typeaheadItems[0]);
|
||||
expect(typeaheadCtrl.isVisible()).to.be(false);
|
||||
|
||||
// only visible when all conditions match
|
||||
typeaheadCtrl.setFocused(true);
|
||||
expect(typeaheadCtrl.isVisible()).to.be(true);
|
||||
|
||||
typeaheadCtrl.setFocused(false);
|
||||
expect(typeaheadCtrl.isVisible()).to.be(false);
|
||||
});
|
||||
|
||||
it('should appear when not hidden, has matches input and moused over', function () {
|
||||
typeaheadCtrl.setHidden(false);
|
||||
expect(typeaheadCtrl.isVisible()).to.be(false);
|
||||
|
||||
typeaheadCtrl.filterItemsByQuery(typeaheadItems[0]);
|
||||
expect(typeaheadCtrl.isVisible()).to.be(false);
|
||||
|
||||
// only visible when all conditions match
|
||||
typeaheadCtrl.setMouseover(true);
|
||||
expect(typeaheadCtrl.isVisible()).to.be(true);
|
||||
|
||||
typeaheadCtrl.setMouseover(false);
|
||||
expect(typeaheadCtrl.isVisible()).to.be(false);
|
||||
});
|
||||
|
||||
it('should hide when no matches', function () {
|
||||
typeaheadCtrl.setHidden(false);
|
||||
typeaheadCtrl.setFocused(true);
|
||||
|
||||
typeaheadCtrl.filterItemsByQuery(typeaheadItems[0]);
|
||||
expect(typeaheadCtrl.isVisible()).to.be(true);
|
||||
|
||||
typeaheadCtrl.filterItemsByQuery('a8h4o8ah48thal4i7rlia4ujru4glia47gf');
|
||||
expect(typeaheadCtrl.isVisible()).to.be(false);
|
||||
});
|
||||
it('should use a custom template if specified to show the item in the list', function () {
|
||||
scope.items = [{
|
||||
label: 'foo',
|
||||
value: 1
|
||||
}];
|
||||
scope.itemTemplate = '<div class="label">{{item.label}}</div>';
|
||||
scope.$digest();
|
||||
expect(element.find('.label').html()).to.be(scope.items[0].label);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,39 +0,0 @@
|
|||
import { uiModules } from 'ui/modules';
|
||||
const typeahead = uiModules.get('kibana/typeahead');
|
||||
|
||||
typeahead.directive('kbnTypeaheadInput', function () {
|
||||
|
||||
return {
|
||||
restrict: 'A',
|
||||
require: ['^ngModel', '^kbnTypeahead'],
|
||||
|
||||
link: function ($scope, $el, $attr, deps) {
|
||||
const model = deps[0];
|
||||
const typeaheadCtrl = deps[1];
|
||||
|
||||
typeaheadCtrl.setInputModel(model);
|
||||
|
||||
// disable browser autocomplete
|
||||
$el.attr('autocomplete', 'off');
|
||||
|
||||
// handle keypresses
|
||||
$el.on('keydown', function (ev) {
|
||||
$scope.$evalAsync(() => typeaheadCtrl.keypressHandler(ev));
|
||||
});
|
||||
|
||||
// update focus state based on the input focus state
|
||||
$el.on('focus', function () {
|
||||
$scope.$evalAsync(() => typeaheadCtrl.setFocused(true));
|
||||
});
|
||||
|
||||
$el.on('blur', function () {
|
||||
$scope.$evalAsync(() => typeaheadCtrl.setFocused(false));
|
||||
});
|
||||
|
||||
// unbind event listeners
|
||||
$scope.$on('$destroy', function () {
|
||||
$el.off();
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
|
@ -1,16 +0,0 @@
|
|||
import listTemplate from 'ui/typeahead/partials/typeahead-items.html';
|
||||
import { uiModules } from 'ui/modules';
|
||||
const typeahead = uiModules.get('kibana/typeahead');
|
||||
|
||||
typeahead.directive('kbnTypeaheadItems', function () {
|
||||
return {
|
||||
restrict: 'E',
|
||||
require: '^kbnTypeahead',
|
||||
replace: true,
|
||||
template: listTemplate,
|
||||
|
||||
link: function ($scope, $el, attr, typeaheadCtrl) {
|
||||
$scope.typeahead = typeaheadCtrl;
|
||||
}
|
||||
};
|
||||
});
|
|
@ -1,16 +0,0 @@
|
|||
<div
|
||||
ng-show="typeahead.isVisible()"
|
||||
ng-mouseenter="typeahead.setMouseover(true);"
|
||||
ng-mouseleave="typeahead.setMouseover(false);"
|
||||
class="typeahead-items"
|
||||
>
|
||||
<div
|
||||
ng-repeat="item in typeahead.getItems()"
|
||||
ng-class="{active: item === typeahead.active}"
|
||||
ng-click="typeahead.selectItem(item, $event);"
|
||||
ng-mouseenter="typeahead.activateItem(item);"
|
||||
class="typeahead-item"
|
||||
>
|
||||
{{item}}
|
||||
</div>
|
||||
</div>
|
26
src/ui/public/typeahead/typeahead.html
Normal file
26
src/ui/public/typeahead/typeahead.html
Normal file
|
@ -0,0 +1,26 @@
|
|||
<div
|
||||
class="typeahead"
|
||||
ng-keydown="typeahead.onKeyDown($event)"
|
||||
>
|
||||
<ng-transclude></ng-transclude>
|
||||
<div
|
||||
class="typeahead-items"
|
||||
ng-show="typeahead.isVisible()"
|
||||
ng-mouseenter="typeahead.onMouseEnter()"
|
||||
ng-mouseleave="typeahead.onMouseLeave()"
|
||||
>
|
||||
<div
|
||||
class="typeahead-item"
|
||||
ng-repeat="item in typeahead.items"
|
||||
ng-class="{active: $index === typeahead.selectedIndex}"
|
||||
ng-click="typeahead.submit()"
|
||||
ng-mouseenter="typeahead.selectedIndex = $index"
|
||||
>
|
||||
<kbn-typeahead-item
|
||||
item="item"
|
||||
template="typeahead.itemTemplate"
|
||||
>
|
||||
</kbn-typeahead-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -1,225 +1,91 @@
|
|||
import _ from 'lodash';
|
||||
import 'ui/typeahead/typeahead.less';
|
||||
import 'ui/typeahead/_input';
|
||||
import 'ui/typeahead/_items';
|
||||
import template from './typeahead.html';
|
||||
import { uiModules } from 'ui/modules';
|
||||
import { comboBoxKeyCodes } from '@elastic/eui';
|
||||
import './typeahead.less';
|
||||
import './typeahead_input';
|
||||
import './typeahead_item';
|
||||
|
||||
const { UP, DOWN, ENTER, TAB, ESCAPE } = comboBoxKeyCodes;
|
||||
const typeahead = uiModules.get('kibana/typeahead');
|
||||
|
||||
typeahead.directive('kbnTypeahead', function () {
|
||||
return {
|
||||
restrict: 'A',
|
||||
template,
|
||||
transclude: true,
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
historyKey: '@kbnTypeahead',
|
||||
items: '=',
|
||||
itemTemplate: '=',
|
||||
onSelect: '&'
|
||||
},
|
||||
bindToController: true,
|
||||
controllerAs: 'typeahead',
|
||||
controller: function () {
|
||||
this.isHidden = true;
|
||||
this.selectedIndex = null;
|
||||
|
||||
controller: function ($scope, PersistedLog, config) {
|
||||
const self = this;
|
||||
self.query = '';
|
||||
self.hidden = true;
|
||||
self.focused = false;
|
||||
self.mousedOver = false;
|
||||
|
||||
self.setInputModel = function (model) {
|
||||
$scope.inputModel = model;
|
||||
|
||||
// watch for changes to the query parameter, delegate to typeaheadCtrl
|
||||
$scope.$watch('inputModel.$viewValue', self.filterItemsByQuery);
|
||||
this.submit = () => {
|
||||
const item = this.items[this.selectedIndex];
|
||||
this.onSelect({ item });
|
||||
this.selectedIndex = null;
|
||||
};
|
||||
|
||||
self.setHidden = function (hidden) {
|
||||
self.hidden = !!(hidden);
|
||||
};
|
||||
|
||||
self.setFocused = function (focused) {
|
||||
self.focused = !!(focused);
|
||||
};
|
||||
|
||||
self.setMouseover = function (mousedOver) {
|
||||
self.mousedOver = !!(mousedOver);
|
||||
};
|
||||
|
||||
// activation methods
|
||||
self.activateItem = function (item) {
|
||||
self.active = item;
|
||||
};
|
||||
|
||||
self.getActiveIndex = function () {
|
||||
if (!self.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
return $scope.filteredItems.indexOf(self.active);
|
||||
};
|
||||
|
||||
self.getItems = function () {
|
||||
return $scope.filteredItems;
|
||||
};
|
||||
|
||||
self.activateNext = function () {
|
||||
let index = self.getActiveIndex();
|
||||
if (index == null) {
|
||||
index = 0;
|
||||
} else if (index < $scope.filteredItems.length - 1) {
|
||||
++index;
|
||||
}
|
||||
|
||||
self.activateItem($scope.filteredItems[index]);
|
||||
};
|
||||
|
||||
self.activatePrev = function () {
|
||||
let index = self.getActiveIndex();
|
||||
|
||||
if (index > 0 && index != null) {
|
||||
--index;
|
||||
} else if (index === 0) {
|
||||
self.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
self.activateItem($scope.filteredItems[index]);
|
||||
};
|
||||
|
||||
self.isActive = function (item) {
|
||||
return item === self.active;
|
||||
};
|
||||
|
||||
// selection methods
|
||||
self.selectItem = function (item, ev) {
|
||||
self.hidden = true;
|
||||
self.active = false;
|
||||
$scope.inputModel.$setViewValue(item);
|
||||
$scope.inputModel.$render();
|
||||
self.persistEntry();
|
||||
|
||||
if (ev && ev.type === 'click') {
|
||||
$scope.onSelect();
|
||||
this.selectPrevious = () => {
|
||||
if (this.selectedIndex !== null && this.selectedIndex > 0) {
|
||||
this.selectedIndex--;
|
||||
} else {
|
||||
this.selectedIndex = this.items.length - 1;
|
||||
}
|
||||
};
|
||||
|
||||
self.persistEntry = function () {
|
||||
if ($scope.inputModel.$viewValue.length) {
|
||||
// push selection into the history
|
||||
$scope.items = self.history.add($scope.inputModel.$viewValue);
|
||||
this.selectNext = () => {
|
||||
if (this.selectedIndex !== null && this.selectedIndex < this.items.length - 1) {
|
||||
this.selectedIndex++;
|
||||
} else {
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
};
|
||||
|
||||
self.selectActive = function () {
|
||||
if (self.active) {
|
||||
self.selectItem(self.active);
|
||||
this.isVisible = () => {
|
||||
// Blur fires before click. If we only checked isFocused, then click events would never fire.
|
||||
const isFocusedOrMousedOver = this.isFocused || this.isMousedOver;
|
||||
return !this.isHidden && this.items && this.items.length > 0 && isFocusedOrMousedOver;
|
||||
};
|
||||
|
||||
this.onKeyDown = (event) => {
|
||||
const { keyCode } = event;
|
||||
|
||||
this.isHidden = keyCode === ESCAPE;
|
||||
|
||||
if ([TAB, ENTER].includes(keyCode) && !this.hidden && this.selectedIndex !== null) {
|
||||
event.preventDefault();
|
||||
this.submit();
|
||||
} else if (keyCode === UP) {
|
||||
event.preventDefault();
|
||||
this.selectPrevious();
|
||||
} else if (keyCode === DOWN) {
|
||||
event.preventDefault();
|
||||
this.selectNext();
|
||||
} else {
|
||||
this.selectedIndex = null;
|
||||
}
|
||||
};
|
||||
|
||||
self.keypressHandler = function (ev) {
|
||||
const keyCode = ev.which || ev.keyCode;
|
||||
|
||||
if (self.focused) {
|
||||
self.hidden = false;
|
||||
}
|
||||
|
||||
// hide on escape
|
||||
if (_.contains([comboBoxKeyCodes.ESCAPE], keyCode)) {
|
||||
self.hidden = true;
|
||||
self.active = false;
|
||||
}
|
||||
|
||||
// change selection with arrow up/down
|
||||
// on down key, attempt to load all items if none are loaded
|
||||
if (_.contains([comboBoxKeyCodes.DOWN], keyCode) && $scope.filteredItems.length === 0) {
|
||||
$scope.filteredItems = $scope.items;
|
||||
$scope.$digest();
|
||||
} else if (_.contains([comboBoxKeyCodes.UP, comboBoxKeyCodes.DOWN], keyCode)) {
|
||||
if (self.isVisible() && $scope.filteredItems.length) {
|
||||
ev.preventDefault();
|
||||
|
||||
if (keyCode === comboBoxKeyCodes.DOWN) {
|
||||
self.activateNext();
|
||||
} else {
|
||||
self.activatePrev();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// persist selection on enter, when not selecting from the list
|
||||
if (_.contains([comboBoxKeyCodes.ENTER], keyCode)) {
|
||||
if (!self.active) {
|
||||
self.persistEntry();
|
||||
}
|
||||
}
|
||||
|
||||
// select on enter or tab
|
||||
if (_.contains([comboBoxKeyCodes.ENTER, comboBoxKeyCodes.TAB], keyCode)) {
|
||||
self.selectActive();
|
||||
self.hidden = true;
|
||||
}
|
||||
this.onFocus = () => {
|
||||
this.isFocused = true;
|
||||
};
|
||||
|
||||
self.filterItemsByQuery = function (query) {
|
||||
// cache query so we can call it again if needed
|
||||
if (query) {
|
||||
self.query = query;
|
||||
}
|
||||
|
||||
// if the query is empty, clear the list items
|
||||
if (!self.query.length) {
|
||||
$scope.filteredItems = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// update the filteredItems using the query
|
||||
const beginningMatches = $scope.items.filter(function (item) {
|
||||
return item.indexOf(query) === 0;
|
||||
});
|
||||
|
||||
const otherMatches = $scope.items.filter(function (item) {
|
||||
return item.indexOf(query) > 0;
|
||||
});
|
||||
|
||||
$scope.filteredItems = beginningMatches.concat(otherMatches);
|
||||
this.onBlur = () => {
|
||||
this.isFocused = false;
|
||||
};
|
||||
|
||||
self.isVisible = function () {
|
||||
return !self.hidden && ($scope.filteredItems.length > 0) && (self.focused || self.mousedOver);
|
||||
this.onMouseEnter = () => {
|
||||
this.isMousedOver = true;
|
||||
};
|
||||
|
||||
$scope.$watch('historyKey', () => {
|
||||
self.history = new PersistedLog('typeahead:' + $scope.historyKey, {
|
||||
maxLength: config.get('history:limit'),
|
||||
filterDuplicates: true
|
||||
});
|
||||
|
||||
$scope.items = self.history.get();
|
||||
$scope.filteredItems = [];
|
||||
});
|
||||
|
||||
// handle updates to parent scope history
|
||||
$scope.$watch('items', function () {
|
||||
if (self.query) {
|
||||
self.filterItemsByQuery(self.query);
|
||||
}
|
||||
});
|
||||
|
||||
// watch for changes to the filtered item list
|
||||
$scope.$watch('filteredItems', function (filteredItems) {
|
||||
|
||||
// if list is empty, or active item is missing, unset active item
|
||||
if (!filteredItems.length || !_.contains(filteredItems, self.active)) {
|
||||
self.active = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
link: function ($scope, $el, attrs) {
|
||||
if (!_.has(attrs, 'onSelect')) {
|
||||
throw new Error('on-select must be defined');
|
||||
}
|
||||
|
||||
$scope.$watch('typeahead.isVisible()', function (vis) {
|
||||
$el.toggleClass('visible', vis);
|
||||
});
|
||||
this.onMouseLeave = () => {
|
||||
this.isMousedOver = false;
|
||||
};
|
||||
}
|
||||
};
|
||||
});
|
||||
|
|
25
src/ui/public/typeahead/typeahead_input.js
Normal file
25
src/ui/public/typeahead/typeahead_input.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { uiModules } from 'ui/modules';
|
||||
const typeahead = uiModules.get('kibana/typeahead');
|
||||
|
||||
typeahead.directive('kbnTypeaheadInput', function () {
|
||||
return {
|
||||
restrict: 'A',
|
||||
require: '^kbnTypeahead',
|
||||
link: function ($scope, $el, $attr, typeahead) {
|
||||
// disable browser autocomplete
|
||||
$el.attr('autocomplete', 'off');
|
||||
|
||||
$el.on('focus', () => {
|
||||
$scope.$evalAsync(() => typeahead.onFocus());
|
||||
});
|
||||
|
||||
$el.on('blur', () => {
|
||||
$scope.$evalAsync(() => typeahead.onBlur());
|
||||
});
|
||||
|
||||
$scope.$on('$destroy', () => {
|
||||
$el.off();
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
16
src/ui/public/typeahead/typeahead_item.js
Normal file
16
src/ui/public/typeahead/typeahead_item.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { uiModules } from 'ui/modules';
|
||||
const typeahead = uiModules.get('kibana/typeahead');
|
||||
|
||||
typeahead.directive('kbnTypeaheadItem', function ($compile) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
item: '=',
|
||||
template: '='
|
||||
},
|
||||
link: (scope, element) => {
|
||||
element.html(scope.template || '{{item}}');
|
||||
$compile(element.contents())(scope);
|
||||
}
|
||||
};
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue