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:
Lukas Olson 2018-02-26 10:10:42 -07:00
parent 323ff3678f
commit a856e42a8a
11 changed files with 334 additions and 469 deletions

View file

@ -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');
});
});

View file

@ -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>

View file

@ -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);
})
};

View file

@ -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);
});
});
});

View file

@ -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();
});
}
};
});

View file

@ -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;
}
};
});

View file

@ -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>

View 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>

View file

@ -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;
};
}
};
});

View 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();
});
}
};
});

View 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);
}
};
});