diff --git a/package.json b/package.json
index 3831fbac..103c7878 100644
--- a/package.json
+++ b/package.json
@@ -38,6 +38,7 @@
"angular": ">=1.6.x",
"angular-aria": ">=1.6.x",
"angular-sanitize": ">=1.6.x",
+ "bloodhound-js": "^1.2.3",
"clipboard": "^2.0.1",
"escape-string-regexp": "^1.0.5",
"flatpickr": "^4.5.2",
diff --git a/packages/oui-angular/src/index.js b/packages/oui-angular/src/index.js
index 745f69b4..c264c4eb 100644
--- a/packages/oui-angular/src/index.js
+++ b/packages/oui-angular/src/index.js
@@ -1,4 +1,5 @@
import ActionMenu from "@ovh-ui/oui-action-menu";
+import Autocomplete from "@ovh-ui/oui-autocomplete";
import BackButton from "@ovh-ui/oui-back-button";
import Button from "@ovh-ui/oui-button";
import Calendar from "@ovh-ui/oui-calendar";
@@ -40,6 +41,7 @@ import Tooltip from "@ovh-ui/oui-tooltip";
export default angular
.module("oui", [
ActionMenu,
+ Autocomplete,
BackButton,
Button,
Calendar,
diff --git a/packages/oui-angular/src/index.spec.js b/packages/oui-angular/src/index.spec.js
index 42ff6b48..5ae82c44 100644
--- a/packages/oui-angular/src/index.spec.js
+++ b/packages/oui-angular/src/index.spec.js
@@ -1,6 +1,7 @@
import "@ovh-ui/common/test-utils";
loadTests(require.context("../../oui-action-menu/src/", true, /.*((\.spec)|(index))$/));
+loadTests(require.context("../../oui-autocomplete/src/", true, /.*((\.spec)|(index))$/));
loadTests(require.context("../../oui-back-button/src/", true, /.*((\.spec)|(index))$/));
loadTests(require.context("../../oui-button/src/", true, /.*((\.spec)|(index))$/));
loadTests(require.context("../../oui-calendar/src/", true, /.*((\.spec)|(index))$/));
diff --git a/packages/oui-autocomplete/README.md b/packages/oui-autocomplete/README.md
new file mode 100644
index 00000000..b158c16c
--- /dev/null
+++ b/packages/oui-autocomplete/README.md
@@ -0,0 +1,83 @@
+# Autocomplete
+
+
+
+## Usage
+
+### Basic
+
+#### Array of strings
+
+```html:preview
+
+```
+
+#### Array of objects
+
+```html:preview
+
+```
+
+### Events
+
+**Note**: If you want to access the parameters inside `on-select` callback, you need to use `value` variable as below.
+It will return the corresponding value from your array of suggestions.
+
+```html:preview
+
+
+
model value: {{$ctrl.modelOnSelect | json}}
+
onSelect 'value' value: {{$ctrl.selectedValue | json}}
+
+```
+
+## Variants
+
+### Search
+
+See [Search](#!/oui-angular/search) component.
+
+```html:preview
+
+
+```
+
+## API
+
+| Attribute | Type | Binding | One-time binding | Values | Default | Description
+| ---- | ---- | ---- | ---- | ---- | ---- | ----
+| `oui-autocomplete` | array | < | no | n/a | n/a | array of suggestions
+| `oui-autocomplete-options` | object | | yes | n/a | n/a | options of autocomplete
+| `oui-autocomplete-property` | string | @? | no | n/a | n/a | property path used to get value from suggestion
+| `oui-autocomplete-on-select` | function | & | no | n/a | n/a | handler triggered when suggestion is selected
+
+## Configuration
+
+The autocomplete can be globally configured with a provider.
+
+```js
+angular.module("myModule", [
+ "oui.autocomplete"
+]).config(ouiAutocompleteConfigurationProvider => {
+ ouiAutocompleteConfigurationProvider.setOptions({ // default options
+ debounceDelay: 500,
+ minLength: 2
+ });
+});
+```
diff --git a/packages/oui-autocomplete/package.json b/packages/oui-autocomplete/package.json
new file mode 100644
index 00000000..07ca9082
--- /dev/null
+++ b/packages/oui-autocomplete/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@ovh-ui/oui-autocomplete",
+ "version": "1.0.0",
+ "main": "./src/index.js",
+ "license": "BSD-3-Clause",
+ "author": "OVH SAS",
+ "dependencies": {
+ "bloodhound-js": "^1.2.3",
+ "popper.js": "^1.14.4"
+ }
+}
diff --git a/packages/oui-autocomplete/src/autocomplete.controller.js b/packages/oui-autocomplete/src/autocomplete.controller.js
new file mode 100644
index 00000000..7e5b7641
--- /dev/null
+++ b/packages/oui-autocomplete/src/autocomplete.controller.js
@@ -0,0 +1,251 @@
+import Bloodhound from "bloodhound-js";
+import debounce from "lodash/debounce";
+import get from "lodash/get";
+import merge from "lodash/merge";
+import Popper from "popper.js";
+import template from "./autocomplete.html";
+
+const KEYBOARD_KEYS = {
+ TAB: 9,
+ SHIFT: 16,
+ ESC: 27,
+ UP: 38,
+ DOWN: 40
+};
+
+export default class {
+ constructor ($compile, $document, $element, $scope, $timeout, ouiAutocompleteConfiguration) {
+ "ngInject";
+
+ this.$compile = $compile;
+ this.$document = $document;
+ this.$element = $element;
+ this.$timeout = $timeout;
+ this.$scope = $scope;
+ this.providerOptions = ouiAutocompleteConfiguration.options;
+ }
+
+ createDatalist () {
+ const input = this.$element[0];
+ const autocomplete = this.$element.next()[0];
+
+ // Let Popper.js position the datalist
+ this.popper = new Popper(input, autocomplete, {
+ placement: "bottom-start"
+ });
+
+ this.triggerWidth = `${this.popper.reference.clientWidth}px`;
+ }
+
+ openDatalist (datum) {
+ if (!datum.length) {
+ this.closeDatalist();
+ return;
+ }
+
+ this.$timeout(() => {
+ // Refresh keyboard navigation
+ this.navItems = undefined;
+ this.navIndex = undefined;
+
+ this.datalist = datum;
+ this.isOpen = true;
+
+ // Init keyboard navigation
+ if (!this.isNavigable) {
+ this.isNavigable = true;
+ this.$document
+ .one("click", () => this.closeDatalist())
+ .on("keydown", (e) => this.triggerKeyHandler(e))
+ .on("keyup", (e) => delete this.keys[e.which]);
+ }
+ });
+ }
+
+ closeDatalist () {
+ this.$timeout(() => {
+ this.datalist = [];
+ this.isOpen = false;
+
+ // Clear keyboard navigation
+ if (this.isNavigable) {
+ this.isNavigable = false;
+ this.keys = {};
+
+ this.$document
+ .off("click")
+ .off("keydown")
+ .off("keyup");
+ }
+ });
+ }
+
+ initSearchEngine (suggestions) {
+ if (!this.engine && angular.isArray(suggestions)) {
+ this.engine = new Bloodhound({
+ local: suggestions,
+ datumTokenizer: (datum) => {
+ const value = this.getProperty(datum);
+ return Bloodhound.tokenizers.whitespace(value);
+ },
+ queryTokenizer: Bloodhound.tokenizers.whitespace
+ });
+
+ this.engine.initialize();
+
+ // Watch model value for search engine
+ this.$scope.$watch(
+ () => this.model.$modelValue,
+ debounce(
+ (value) => this.searchQuery(value),
+ this.options.debounceDelay
+ )
+ );
+ }
+ }
+
+ updateSearchEngine (suggestions) {
+ if (this.engine && angular.isArray(suggestions)) {
+ this.engine.clear();
+ this.engine.local = suggestions;
+ this.engine.initialize(true);
+ }
+ }
+
+ searchQuery (query) {
+ if (angular.isString(query) && query !== this.selectedValue) {
+ this.$timeout(() => {
+ if (query.length >= this.options.minLength) {
+ this.engine.search(
+ query,
+ (datum) => this.openDatalist(datum), // Sync
+ (datum) => this.openDatalist(datum) // Async
+ );
+ } else if (this.isOpen) {
+ this.closeDatalist();
+ }
+
+ // Needed for highlight filter
+ this.query = query;
+ });
+ }
+ }
+
+ updateValue (value) {
+ this.$element[0].focus();
+ this.selectedValue = this.getProperty(value);
+ this.closeDatalist();
+
+ // Update value and notify model change
+ this.model.$setViewValue(this.selectedValue);
+ this.model.$render();
+
+ // Callback
+ this.onSelect({
+ value: angular.copy(value) // Clean $$hashKey
+ });
+ }
+
+ getProperty (item) {
+ return get(item, this.property, item);
+ }
+
+ focusNavItem (direction) {
+ if (angular.isUndefined(this.navItems)) {
+ this.navItems = this.autocomplete.find("button");
+ this.navItems.push(this.$element[0]);
+ this.navLastIndex = this.navItems.length - 1;
+ }
+
+ // Set index of trigger input if undefined
+ if (angular.isUndefined(this.navIndex)) {
+ this.navIndex = this.navLastIndex;
+ }
+
+ if (direction === "next") {
+ this.navIndex = this.navIndex >= this.navLastIndex ? 0 : this.navIndex + 1;
+ } else if (direction === "prev") {
+ this.navIndex = this.navIndex <= 0 ? this.navLastIndex : this.navIndex - 1;
+ }
+
+ this.navItems[this.navIndex].focus();
+ }
+
+ triggerKeyHandler (e) {
+ if (this.isNavigable) {
+ const key = e.which;
+
+ if ([
+ KEYBOARD_KEYS.TAB,
+ KEYBOARD_KEYS.SHIFT,
+ KEYBOARD_KEYS.UP,
+ KEYBOARD_KEYS.DOWN,
+ KEYBOARD_KEYS.ESC
+ ].indexOf(key) > -1) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ // Add key in array for key combination
+ this.keys[key] = true;
+
+ if (
+ (this.keys[KEYBOARD_KEYS.TAB] && !this.keys[KEYBOARD_KEYS.SHIFT]) ||
+ this.keys[KEYBOARD_KEYS.DOWN]
+ ) {
+ // Move Down
+ this.focusNavItem("next");
+ } else if (
+ (this.keys[KEYBOARD_KEYS.TAB] && this.keys[KEYBOARD_KEYS.SHIFT]) ||
+ this.keys[KEYBOARD_KEYS.UP]
+ ) {
+ // Move Up
+ this.focusNavItem("prev");
+ } else if (this.keys[KEYBOARD_KEYS.ESC]) {
+ // Escape
+ this.closeDatalist();
+ }
+ }
+ }
+ }
+
+ $onChanges (changes) {
+ if (changes.suggestions && changes.suggestions.currentValue) {
+ this.updateSearchEngine(changes.suggestions.currentValue);
+ }
+ }
+
+ $onInit () {
+ this.id = `ouiAutocomplete${this.$scope.$id}`;
+ this.options = merge(this.providerOptions, this.options);
+ this.keys = {};
+
+ this.initSearchEngine(this.suggestions);
+ }
+
+ $postLink () {
+ this.$timeout(() => {
+ // Create a new scope to compile the autocomplete next to the input
+ const autocompleteScope = angular.extend(this.$scope.$new(true), { $autocompleteCtrl: this });
+ this.autocomplete = this.$compile(template)(autocompleteScope);
+
+ this.$element
+ .attr("autocomplete", "off")
+ .attr("list", this.id)
+ .one("focus", () => this.createDatalist()) // One time bind to create the popper helper
+ .on("click", (e) => e.stopPropagation()) // Avoid click propagation on $element
+ .after(this.autocomplete); // Add compiled template after $element
+ });
+ }
+
+ $onDestroy () {
+ if (this.engine) {
+ this.engine.clear();
+ }
+
+ if (this.isNavigable) {
+ this.$element
+ .off("click")
+ .off("keydown");
+ }
+ }
+}
diff --git a/packages/oui-autocomplete/src/autocomplete.directive.js b/packages/oui-autocomplete/src/autocomplete.directive.js
new file mode 100644
index 00000000..6f0a6341
--- /dev/null
+++ b/packages/oui-autocomplete/src/autocomplete.directive.js
@@ -0,0 +1,21 @@
+import controller from "./autocomplete.controller";
+
+export default () => {
+ "ngInject";
+
+ return {
+ restrict: "A",
+ require: {
+ model: "ngModel"
+ },
+ scope: true,
+ bindToController: {
+ suggestions: "
+
+
+
+
diff --git a/packages/oui-autocomplete/src/autocomplete.provider.js b/packages/oui-autocomplete/src/autocomplete.provider.js
new file mode 100644
index 00000000..d804b7e7
--- /dev/null
+++ b/packages/oui-autocomplete/src/autocomplete.provider.js
@@ -0,0 +1,25 @@
+import merge from "lodash/merge";
+
+export default class {
+ constructor () {
+ this.options = {
+ debounceDelay: 250,
+ minLength: 2
+ };
+ }
+
+ /**
+ * Set the options of the flatpickr calendar
+ * @param {Object} options the configuration of the calendar
+ */
+ setOptions (options) {
+ this.options = merge(this.options, options);
+ return this;
+ }
+
+ $get () {
+ return angular.copy({
+ options: this.options
+ });
+ }
+}
diff --git a/packages/oui-autocomplete/src/filter/autocomplete-highlight.filter.js b/packages/oui-autocomplete/src/filter/autocomplete-highlight.filter.js
new file mode 100644
index 00000000..aff5f293
--- /dev/null
+++ b/packages/oui-autocomplete/src/filter/autocomplete-highlight.filter.js
@@ -0,0 +1,16 @@
+export default ($sce) => {
+ "ngInject";
+
+ const highlight = '$&';
+ const escapeRegexp = (query) =>
+ query.replace(/([.?*+^$[\]\\(){}|-])/g, "\\$1");
+
+ // Highlight query in the beginning of each matching words
+ return (matchItem, query) => {
+ const regexp = `\\b${escapeRegexp(query)}`;
+ return $sce.trustAsHtml(query ?
+ matchItem.replace(new RegExp(regexp, "gi"), highlight) :
+ matchItem
+ );
+ };
+};
diff --git a/packages/oui-autocomplete/src/index.js b/packages/oui-autocomplete/src/index.js
new file mode 100644
index 00000000..ad0f52d3
--- /dev/null
+++ b/packages/oui-autocomplete/src/index.js
@@ -0,0 +1,10 @@
+import Autocomplete from "./autocomplete.directive";
+import AutocompleteHightlight from "./filter/autocomplete-highlight.filter";
+import AutocompleteProvider from "./autocomplete.provider";
+
+export default angular
+ .module("oui.autocomplete", [])
+ .directive("ouiAutocomplete", Autocomplete)
+ .filter("ouiAutocompleteHightlight", AutocompleteHightlight)
+ .provider("ouiAutocompleteConfiguration", AutocompleteProvider)
+ .name;
diff --git a/packages/oui-autocomplete/src/index.spec.data.json b/packages/oui-autocomplete/src/index.spec.data.json
new file mode 100644
index 00000000..6d06b505
--- /dev/null
+++ b/packages/oui-autocomplete/src/index.spec.data.json
@@ -0,0 +1,492 @@
+{
+ "strings": [
+ "Afghanistan",
+ "Åland Islands",
+ "Albania",
+ "Algeria",
+ "American Samoa",
+ "AndorrA",
+ "Angola",
+ "Anguilla",
+ "Antarctica",
+ "Antigua and Barbuda",
+ "Argentina",
+ "Armenia",
+ "Aruba",
+ "Australia",
+ "Austria",
+ "Azerbaijan",
+ "Bahamas",
+ "Bahrain",
+ "Bangladesh",
+ "Barbados",
+ "Belarus",
+ "Belgium",
+ "Belize",
+ "Benin",
+ "Bermuda",
+ "Bhutan",
+ "Bolivia",
+ "Bosnia and Herzegovina",
+ "Botswana",
+ "Bouvet Island",
+ "Brazil",
+ "British Indian Ocean Territory",
+ "Brunei Darussalam",
+ "Bulgaria",
+ "Burkina Faso",
+ "Burundi",
+ "Cambodia",
+ "Cameroon",
+ "Canada",
+ "Cape Verde",
+ "Cayman Islands",
+ "Central African Republic",
+ "Chad",
+ "Chile",
+ "China",
+ "Christmas Island",
+ "Cocos (Keeling) Islands",
+ "Colombia",
+ "Comoros",
+ "Congo",
+ "Congo, The Democratic Republic of the",
+ "Cook Islands",
+ "Costa Rica",
+ "Cote D'Ivoire",
+ "Croatia",
+ "Cuba",
+ "Cyprus",
+ "Czech Republic",
+ "Denmark",
+ "Djibouti",
+ "Dominica",
+ "Dominican Republic",
+ "Ecuador",
+ "Egypt",
+ "El Salvador",
+ "Equatorial Guinea",
+ "Eritrea",
+ "Estonia",
+ "Ethiopia",
+ "Falkland Islands (Malvinas",
+ "Faroe Islands",
+ "Fiji",
+ "Finland",
+ "France",
+ "French Guiana",
+ "French Polynesia",
+ "French Southern Territories",
+ "Gabon",
+ "Gambia",
+ "Georgia",
+ "Germany",
+ "Ghana",
+ "Gibraltar",
+ "Greece",
+ "Greenland",
+ "Grenada",
+ "Guadeloupe",
+ "Guam",
+ "Guatemala",
+ "Guernsey",
+ "Guinea",
+ "Guinea-Bissau",
+ "Guyana",
+ "Haiti",
+ "Heard Island and Mcdonald Islands",
+ "Holy See (Vatican City State",
+ "Honduras",
+ "Hong Kong",
+ "Hungary",
+ "Iceland",
+ "India",
+ "Indonesia",
+ "Iran, Islamic Republic Of",
+ "Iraq",
+ "Ireland",
+ "Isle of Man",
+ "Israel",
+ "Italy",
+ "Jamaica",
+ "Japan",
+ "Jersey",
+ "Jordan",
+ "Kazakhstan",
+ "Kenya",
+ "Kiribati",
+ "Korea, Democratic People's Republic of",
+ "Korea, Republic of",
+ "Kuwait",
+ "Kyrgyzstan",
+ "Lao People's Democratic Republic",
+ "Latvia",
+ "Lebanon",
+ "Lesotho",
+ "Liberia",
+ "Libyan Arab Jamahiriya",
+ "Liechtenstein",
+ "Lithuania",
+ "Luxembourg",
+ "Macao",
+ "Macedonia, The Former Yugoslav Republic of",
+ "Madagascar",
+ "Malawi",
+ "Malaysia",
+ "Maldives",
+ "Mali",
+ "Malta",
+ "Marshall Islands",
+ "Martinique",
+ "Mauritania",
+ "Mauritius",
+ "Mayotte",
+ "Mexico",
+ "Micronesia, Federated States of",
+ "Moldova, Republic of",
+ "Monaco",
+ "Mongolia",
+ "Montserrat",
+ "Morocco",
+ "Mozambique",
+ "Myanmar",
+ "Namibia",
+ "Nauru",
+ "Nepal",
+ "Netherlands",
+ "Netherlands Antilles",
+ "New Caledonia",
+ "New Zealand",
+ "Nicaragua",
+ "Niger",
+ "Nigeria",
+ "Niue",
+ "Norfolk Island",
+ "Northern Mariana Islands",
+ "Norway",
+ "Oman",
+ "Pakistan",
+ "Palau",
+ "Palestinian Territory, Occupied",
+ "Panama",
+ "Papua New Guinea",
+ "Paraguay",
+ "Peru",
+ "Philippines",
+ "Pitcairn",
+ "Poland",
+ "Portugal",
+ "Puerto Rico",
+ "Qatar",
+ "Reunion",
+ "Romania",
+ "Russian Federation",
+ "RWANDA",
+ "Saint Helena",
+ "Saint Kitts and Nevis",
+ "Saint Lucia",
+ "Saint Pierre and Miquelon",
+ "Saint Vincent and the Grenadines",
+ "Samoa",
+ "San Marino",
+ "Sao Tome and Principe",
+ "Saudi Arabia",
+ "Senegal",
+ "Serbia and Montenegro",
+ "Seychelles",
+ "Sierra Leone",
+ "Singapore",
+ "Slovakia",
+ "Slovenia",
+ "Solomon Islands",
+ "Somalia",
+ "South Africa",
+ "South Georgia and the South Sandwich Islands",
+ "Spain",
+ "Sri Lanka",
+ "Sudan",
+ "Suriname",
+ "Svalbard and Jan Mayen",
+ "Swaziland",
+ "Sweden",
+ "Switzerland",
+ "Syrian Arab Republic",
+ "Taiwan, Province of China",
+ "Tajikistan",
+ "Tanzania, United Republic of",
+ "Thailand",
+ "Timor-Leste",
+ "Togo",
+ "Tokelau",
+ "Tonga",
+ "Trinidad and Tobago",
+ "Tunisia",
+ "Turkey",
+ "Turkmenistan",
+ "Turks and Caicos Islands",
+ "Tuvalu",
+ "Uganda",
+ "Ukraine",
+ "United Arab Emirates",
+ "United Kingdom",
+ "United States",
+ "United States Minor Outlying Islands",
+ "Uruguay",
+ "Uzbekistan",
+ "Vanuatu",
+ "Venezuela",
+ "Viet Nam",
+ "Virgin Islands, British",
+ "Virgin Islands, U.S",
+ "Wallis and Futuna",
+ "Western Sahara",
+ "Yemen",
+ "Zambia",
+ "Zimbabwe"
+ ],
+ "objects": [
+ { "country": { "name": "Afghanistan", "code": "AF" } },
+ { "country": { "name": "Åland Islands", "code": "AX" } },
+ { "country": { "name": "Albania", "code": "AL" } },
+ { "country": { "name": "Algeria", "code": "DZ" } },
+ { "country": { "name": "American Samoa", "code": "AS" } },
+ { "country": { "name": "AndorrA", "code": "AD" } },
+ { "country": { "name": "Angola", "code": "AO" } },
+ { "country": { "name": "Anguilla", "code": "AI" } },
+ { "country": { "name": "Antarctica", "code": "AQ" } },
+ { "country": { "name": "Antigua and Barbuda", "code": "AG" } },
+ { "country": { "name": "Argentina", "code": "AR" } },
+ { "country": { "name": "Armenia", "code": "AM" } },
+ { "country": { "name": "Aruba", "code": "AW" } },
+ { "country": { "name": "Australia", "code": "AU" } },
+ { "country": { "name": "Austria", "code": "AT" } },
+ { "country": { "name": "Azerbaijan", "code": "AZ" } },
+ { "country": { "name": "Bahamas", "code": "BS" } },
+ { "country": { "name": "Bahrain", "code": "BH" } },
+ { "country": { "name": "Bangladesh", "code": "BD" } },
+ { "country": { "name": "Barbados", "code": "BB" } },
+ { "country": { "name": "Belarus", "code": "BY" } },
+ { "country": { "name": "Belgium", "code": "BE" } },
+ { "country": { "name": "Belize", "code": "BZ" } },
+ { "country": { "name": "Benin", "code": "BJ" } },
+ { "country": { "name": "Bermuda", "code": "BM" } },
+ { "country": { "name": "Bhutan", "code": "BT" } },
+ { "country": { "name": "Bolivia", "code": "BO" } },
+ { "country": { "name": "Bosnia and Herzegovina", "code": "BA" } },
+ { "country": { "name": "Botswana", "code": "BW" } },
+ { "country": { "name": "Bouvet Island", "code": "BV" } },
+ { "country": { "name": "Brazil", "code": "BR" } },
+ { "country": { "name": "British Indian Ocean Territory", "code": "IO" } },
+ { "country": { "name": "Brunei Darussalam", "code": "BN" } },
+ { "country": { "name": "Bulgaria", "code": "BG" } },
+ { "country": { "name": "Burkina Faso", "code": "BF" } },
+ { "country": { "name": "Burundi", "code": "BI" } },
+ { "country": { "name": "Cambodia", "code": "KH" } },
+ { "country": { "name": "Cameroon", "code": "CM" } },
+ { "country": { "name": "Canada", "code": "CA" } },
+ { "country": { "name": "Cape Verde", "code": "CV" } },
+ { "country": { "name": "Cayman Islands", "code": "KY" } },
+ { "country": { "name": "Central African Republic", "code": "CF" } },
+ { "country": { "name": "Chad", "code": "TD" } },
+ { "country": { "name": "Chile", "code": "CL" } },
+ { "country": { "name": "China", "code": "CN" } },
+ { "country": { "name": "Christmas Island", "code": "CX" } },
+ { "country": { "name": "Cocos (Keeling) Islands", "code": "CC" } },
+ { "country": { "name": "Colombia", "code": "CO" } },
+ { "country": { "name": "Comoros", "code": "KM" } },
+ { "country": { "name": "Congo", "code": "CG" } },
+ { "country": { "name": "Congo, The Democratic Republic of the", "code": "CD" } },
+ { "country": { "name": "Cook Islands", "code": "CK" } },
+ { "country": { "name": "Costa Rica", "code": "CR" } },
+ { "country": { "name": "Cote D'Ivoire", "code": "CI" } },
+ { "country": { "name": "Croatia", "code": "HR" } },
+ { "country": { "name": "Cuba", "code": "CU" } },
+ { "country": { "name": "Cyprus", "code": "CY" } },
+ { "country": { "name": "Czech Republic", "code": "CZ" } },
+ { "country": { "name": "Denmark", "code": "DK" } },
+ { "country": { "name": "Djibouti", "code": "DJ" } },
+ { "country": { "name": "Dominica", "code": "DM" } },
+ { "country": { "name": "Dominican Republic", "code": "DO" } },
+ { "country": { "name": "Ecuador", "code": "EC" } },
+ { "country": { "name": "Egypt", "code": "EG" } },
+ { "country": { "name": "El Salvador", "code": "SV" } },
+ { "country": { "name": "Equatorial Guinea", "code": "GQ" } },
+ { "country": { "name": "Eritrea", "code": "ER" } },
+ { "country": { "name": "Estonia", "code": "EE" } },
+ { "country": { "name": "Ethiopia", "code": "ET" } },
+ { "country": { "name": "Falkland Islands (Malvinas", "code": "FK" } },
+ { "country": { "name": "Faroe Islands", "code": "FO" } },
+ { "country": { "name": "Fiji", "code": "FJ" } },
+ { "country": { "name": "Finland", "code": "FI" } },
+ { "country": { "name": "France", "code": "FR" } },
+ { "country": { "name": "French Guiana", "code": "GF" } },
+ { "country": { "name": "French Polynesia", "code": "PF" } },
+ { "country": { "name": "French Southern Territories", "code": "TF" } },
+ { "country": { "name": "Gabon", "code": "GA" } },
+ { "country": { "name": "Gambia", "code": "GM" } },
+ { "country": { "name": "Georgia", "code": "GE" } },
+ { "country": { "name": "Germany", "code": "DE" } },
+ { "country": { "name": "Ghana", "code": "GH" } },
+ { "country": { "name": "Gibraltar", "code": "GI" } },
+ { "country": { "name": "Greece", "code": "GR" } },
+ { "country": { "name": "Greenland", "code": "GL" } },
+ { "country": { "name": "Grenada", "code": "GD" } },
+ { "country": { "name": "Guadeloupe", "code": "GP" } },
+ { "country": { "name": "Guam", "code": "GU" } },
+ { "country": { "name": "Guatemala", "code": "GT" } },
+ { "country": { "name": "Guernsey", "code": "GG" } },
+ { "country": { "name": "Guinea", "code": "GN" } },
+ { "country": { "name": "Guinea-Bissau", "code": "GW" } },
+ { "country": { "name": "Guyana", "code": "GY" } },
+ { "country": { "name": "Haiti", "code": "HT" } },
+ { "country": { "name": "Heard Island and Mcdonald Islands", "code": "HM" } },
+ { "country": { "name": "Holy See (Vatican City State", "code": "VA" } },
+ { "country": { "name": "Honduras", "code": "HN" } },
+ { "country": { "name": "Hong Kong", "code": "HK" } },
+ { "country": { "name": "Hungary", "code": "HU" } },
+ { "country": { "name": "Iceland", "code": "IS" } },
+ { "country": { "name": "India", "code": "IN" } },
+ { "country": { "name": "Indonesia", "code": "ID" } },
+ { "country": { "name": "Iran, Islamic Republic Of", "code": "IR" } },
+ { "country": { "name": "Iraq", "code": "IQ" } },
+ { "country": { "name": "Ireland", "code": "IE" } },
+ { "country": { "name": "Isle of Man", "code": "IM" } },
+ { "country": { "name": "Israel", "code": "IL" } },
+ { "country": { "name": "Italy", "code": "IT" } },
+ { "country": { "name": "Jamaica", "code": "JM" } },
+ { "country": { "name": "Japan", "code": "JP" } },
+ { "country": { "name": "Jersey", "code": "JE" } },
+ { "country": { "name": "Jordan", "code": "JO" } },
+ { "country": { "name": "Kazakhstan", "code": "KZ" } },
+ { "country": { "name": "Kenya", "code": "KE" } },
+ { "country": { "name": "Kiribati", "code": "KI" } },
+ { "country": { "name": "Korea, Democratic People's Republic of", "code": "KP" } },
+ { "country": { "name": "Korea, Republic of", "code": "KR" } },
+ { "country": { "name": "Kuwait", "code": "KW" } },
+ { "country": { "name": "Kyrgyzstan", "code": "KG" } },
+ { "country": { "name": "Lao People's Democratic Republic", "code": "LA" } },
+ { "country": { "name": "Latvia", "code": "LV" } },
+ { "country": { "name": "Lebanon", "code": "LB" } },
+ { "country": { "name": "Lesotho", "code": "LS" } },
+ { "country": { "name": "Liberia", "code": "LR" } },
+ { "country": { "name": "Libyan Arab Jamahiriya", "code": "LY" } },
+ { "country": { "name": "Liechtenstein", "code": "LI" } },
+ { "country": { "name": "Lithuania", "code": "LT" } },
+ { "country": { "name": "Luxembourg", "code": "LU" } },
+ { "country": { "name": "Macao", "code": "MO" } },
+ { "country": { "name": "Macedonia, The Former Yugoslav Republic of", "code": "MK" } },
+ { "country": { "name": "Madagascar", "code": "MG" } },
+ { "country": { "name": "Malawi", "code": "MW" } },
+ { "country": { "name": "Malaysia", "code": "MY" } },
+ { "country": { "name": "Maldives", "code": "MV" } },
+ { "country": { "name": "Mali", "code": "ML" } },
+ { "country": { "name": "Malta", "code": "MT" } },
+ { "country": { "name": "Marshall Islands", "code": "MH" } },
+ { "country": { "name": "Martinique", "code": "MQ" } },
+ { "country": { "name": "Mauritania", "code": "MR" } },
+ { "country": { "name": "Mauritius", "code": "MU" } },
+ { "country": { "name": "Mayotte", "code": "YT" } },
+ { "country": { "name": "Mexico", "code": "MX" } },
+ { "country": { "name": "Micronesia, Federated States of", "code": "FM" } },
+ { "country": { "name": "Moldova, Republic of", "code": "MD" } },
+ { "country": { "name": "Monaco", "code": "MC" } },
+ { "country": { "name": "Mongolia", "code": "MN" } },
+ { "country": { "name": "Montserrat", "code": "MS" } },
+ { "country": { "name": "Morocco", "code": "MA" } },
+ { "country": { "name": "Mozambique", "code": "MZ" } },
+ { "country": { "name": "Myanmar", "code": "MM" } },
+ { "country": { "name": "Namibia", "code": "NA" } },
+ { "country": { "name": "Nauru", "code": "NR" } },
+ { "country": { "name": "Nepal", "code": "NP" } },
+ { "country": { "name": "Netherlands", "code": "NL" } },
+ { "country": { "name": "Netherlands Antilles", "code": "AN" } },
+ { "country": { "name": "New Caledonia", "code": "NC" } },
+ { "country": { "name": "New Zealand", "code": "NZ" } },
+ { "country": { "name": "Nicaragua", "code": "NI" } },
+ { "country": { "name": "Niger", "code": "NE" } },
+ { "country": { "name": "Nigeria", "code": "NG" } },
+ { "country": { "name": "Niue", "code": "NU" } },
+ { "country": { "name": "Norfolk Island", "code": "NF" } },
+ { "country": { "name": "Northern Mariana Islands", "code": "MP" } },
+ { "country": { "name": "Norway", "code": "NO" } },
+ { "country": { "name": "Oman", "code": "OM" } },
+ { "country": { "name": "Pakistan", "code": "PK" } },
+ { "country": { "name": "Palau", "code": "PW" } },
+ { "country": { "name": "Palestinian Territory, Occupied", "code": "PS" } },
+ { "country": { "name": "Panama", "code": "PA" } },
+ { "country": { "name": "Papua New Guinea", "code": "PG" } },
+ { "country": { "name": "Paraguay", "code": "PY" } },
+ { "country": { "name": "Peru", "code": "PE" } },
+ { "country": { "name": "Philippines", "code": "PH" } },
+ { "country": { "name": "Pitcairn", "code": "PN" } },
+ { "country": { "name": "Poland", "code": "PL" } },
+ { "country": { "name": "Portugal", "code": "PT" } },
+ { "country": { "name": "Puerto Rico", "code": "PR" } },
+ { "country": { "name": "Qatar", "code": "QA" } },
+ { "country": { "name": "Reunion", "code": "RE" } },
+ { "country": { "name": "Romania", "code": "RO" } },
+ { "country": { "name": "Russian Federation", "code": "RU" } },
+ { "country": { "name": "RWANDA", "code": "RW" } },
+ { "country": { "name": "Saint Helena", "code": "SH" } },
+ { "country": { "name": "Saint Kitts and Nevis", "code": "KN" } },
+ { "country": { "name": "Saint Lucia", "code": "LC" } },
+ { "country": { "name": "Saint Pierre and Miquelon", "code": "PM" } },
+ { "country": { "name": "Saint Vincent and the Grenadines", "code": "VC" } },
+ { "country": { "name": "Samoa", "code": "WS" } },
+ { "country": { "name": "San Marino", "code": "SM" } },
+ { "country": { "name": "Sao Tome and Principe", "code": "ST" } },
+ { "country": { "name": "Saudi Arabia", "code": "SA" } },
+ { "country": { "name": "Senegal", "code": "SN" } },
+ { "country": { "name": "Serbia and Montenegro", "code": "CS" } },
+ { "country": { "name": "Seychelles", "code": "SC" } },
+ { "country": { "name": "Sierra Leone", "code": "SL" } },
+ { "country": { "name": "Singapore", "code": "SG" } },
+ { "country": { "name": "Slovakia", "code": "SK" } },
+ { "country": { "name": "Slovenia", "code": "SI" } },
+ { "country": { "name": "Solomon Islands", "code": "SB" } },
+ { "country": { "name": "Somalia", "code": "SO" } },
+ { "country": { "name": "South Africa", "code": "ZA" } },
+ { "country": { "name": "South Georgia and the South Sandwich Islands", "code": "GS" } },
+ { "country": { "name": "Spain", "code": "ES" } },
+ { "country": { "name": "Sri Lanka", "code": "LK" } },
+ { "country": { "name": "Sudan", "code": "SD" } },
+ { "country": { "name": "Suriname", "code": "SR" } },
+ { "country": { "name": "Svalbard and Jan Mayen", "code": "SJ" } },
+ { "country": { "name": "Swaziland", "code": "SZ" } },
+ { "country": { "name": "Sweden", "code": "SE" } },
+ { "country": { "name": "Switzerland", "code": "CH" } },
+ { "country": { "name": "Syrian Arab Republic", "code": "SY" } },
+ { "country": { "name": "Taiwan, Province of China", "code": "TW" } },
+ { "country": { "name": "Tajikistan", "code": "TJ" } },
+ { "country": { "name": "Tanzania, United Republic of", "code": "TZ" } },
+ { "country": { "name": "Thailand", "code": "TH" } },
+ { "country": { "name": "Timor-Leste", "code": "TL" } },
+ { "country": { "name": "Togo", "code": "TG" } },
+ { "country": { "name": "Tokelau", "code": "TK" } },
+ { "country": { "name": "Tonga", "code": "TO" } },
+ { "country": { "name": "Trinidad and Tobago", "code": "TT" } },
+ { "country": { "name": "Tunisia", "code": "TN" } },
+ { "country": { "name": "Turkey", "code": "TR" } },
+ { "country": { "name": "Turkmenistan", "code": "TM" } },
+ { "country": { "name": "Turks and Caicos Islands", "code": "TC" } },
+ { "country": { "name": "Tuvalu", "code": "TV" } },
+ { "country": { "name": "Uganda", "code": "UG" } },
+ { "country": { "name": "Ukraine", "code": "UA" } },
+ { "country": { "name": "United Arab Emirates", "code": "AE" } },
+ { "country": { "name": "United Kingdom", "code": "GB" } },
+ { "country": { "name": "United States", "code": "US" } },
+ { "country": { "name": "United States Minor Outlying Islands", "code": "UM" } },
+ { "country": { "name": "Uruguay", "code": "UY" } },
+ { "country": { "name": "Uzbekistan", "code": "UZ" } },
+ { "country": { "name": "Vanuatu", "code": "VU" } },
+ { "country": { "name": "Venezuela", "code": "VE" } },
+ { "country": { "name": "Viet Nam", "code": "VN" } },
+ { "country": { "name": "Virgin Islands, British", "code": "VG" } },
+ { "country": { "name": "Virgin Islands, U.S", "code": "VI" } },
+ { "country": { "name": "Wallis and Futuna", "code": "WF" } },
+ { "country": { "name": "Western Sahara", "code": "EH" } },
+ { "country": { "name": "Yemen", "code": "YE" } },
+ { "country": { "name": "Zambia", "code": "ZM" } },
+ { "country": { "name": "Zimbabwe", "code": "ZW" } }
+ ]
+}
diff --git a/packages/oui-autocomplete/src/index.spec.js b/packages/oui-autocomplete/src/index.spec.js
new file mode 100644
index 00000000..c0e337ae
--- /dev/null
+++ b/packages/oui-autocomplete/src/index.spec.js
@@ -0,0 +1,197 @@
+import data from "./index.spec.data.json";
+
+describe("ouiAutocomplete", () => {
+ let $document;
+ let $timeout;
+ let testUtils;
+
+ beforeEach(angular.mock.module("oui.autocomplete"));
+ beforeEach(angular.mock.module("oui.autocomplete.configuration"));
+ beforeEach(angular.mock.module("oui.test-utils"));
+
+ beforeEach(inject((_$document_, _$timeout_, _TestUtils_) => {
+ $document = _$document_;
+ $timeout = _$timeout_;
+ testUtils = _TestUtils_;
+ }));
+
+ describe("Provider", () => {
+ let configuration;
+
+ angular.module("oui.autocomplete.configuration", [
+ "oui.autocomplete"
+ ]).config(ouiAutocompleteConfigurationProvider => {
+ ouiAutocompleteConfigurationProvider.setOptions({
+ foo: "bar"
+ });
+ });
+
+ beforeEach(inject(_ouiAutocompleteConfiguration_ => {
+ configuration = _ouiAutocompleteConfiguration_;
+ }));
+
+ it("should have custom options", () => {
+ expect(configuration.options.foo).toEqual("bar");
+ });
+ });
+
+ describe("Directive", () => {
+ describe("Init", () => {
+ let component;
+ let input;
+ let autocomplete;
+ let autocompleteCtrl;
+ let controller;
+
+ beforeEach(() => {
+ component = testUtils.compileTemplate(``, {
+ suggestions: data.strings
+ });
+
+ $timeout.flush();
+
+ input = angular.element(component[0].querySelector(".trigger"));
+ autocomplete = input.next();
+ autocompleteCtrl = component.scope().$$childHead.$autocompleteCtrl;
+ controller = component.scope().$ctrl;
+ });
+
+ it("should prepare trigger input and create a datalist, next to it", () => {
+ expect(input.attr("list")).toBeDefined();
+ expect(input.attr("autocomplete")).toBe("off");
+
+ expect(autocomplete.length).toBe(1);
+ expect(autocomplete.attr("id")).toBeDefined();
+ expect(autocomplete.hasClass("oui-autocomplete")).toBeTruthy();
+ });
+
+ it("should create popper helper when input is focused", () => {
+ expect(autocompleteCtrl.popper).toBeUndefined();
+
+ input.triggerHandler("focus");
+ expect(autocompleteCtrl.popper).toBeDefined();
+ });
+
+ it("should initialize search engine", () => {
+ const engine = autocompleteCtrl.engine;
+ expect(engine.local.length).toBe(controller.suggestions.length);
+ });
+
+ it("should update search engine", () => {
+ controller.suggestions = [];
+ component.scope().$apply();
+
+ const engine = autocompleteCtrl.engine;
+ expect(engine.local.length).toBe(controller.suggestions.length);
+ });
+ });
+
+ describe("Search", () => {
+ const debounceDelay = 500;
+ const searchValue = "belgium";
+ const KEYBOARD_KEYS = {
+ TAB: 9,
+ SHIFT: 16,
+ ESC: 27,
+ UP: 38,
+ DOWN: 40
+ };
+
+ let component;
+ let input;
+ let autocomplete;
+ let autocompleteCtrl;
+
+ beforeEach((done) => {
+ component = testUtils.compileTemplate(``, {
+ suggestions: data.strings
+ });
+
+ $timeout.flush();
+
+ input = angular.element(component[0].querySelector(".trigger"));
+ autocomplete = input.next();
+ autocompleteCtrl = component.scope().$$childHead.$autocompleteCtrl;
+
+ // Update input value and notify change to angularjs
+ input.val(searchValue);
+ input.triggerHandler("input");
+
+ // Autocomplete has debounce delay before showing up
+ setTimeout(() => {
+ $timeout.flush();
+ done();
+ }, debounceDelay);
+ });
+
+ it("should update input value", () => {
+ const suggestion = autocomplete.find("button");
+
+ expect(autocomplete.find("button").length).toBe(1);
+ expect(suggestion.text().toLowerCase()).toBe(searchValue);
+
+ suggestion.triggerHandler("click");
+ expect(input.val()).toBe(suggestion.text());
+ });
+
+ it("should navigate through suggestions with keyboard", () => {
+ // Navigation start at last index of array (trigger input)
+ // And is initialized on first keydown
+ expect(autocompleteCtrl.navIndex).toBeUndefined();
+
+ // Should be first item (loop)
+ $document.triggerHandler({ type: "keydown", which: KEYBOARD_KEYS.DOWN });
+ $document.triggerHandler({ type: "keyup", which: KEYBOARD_KEYS.DOWN });
+ expect(autocompleteCtrl.navIndex).toBe(0);
+
+ // Should be next item
+ $document.triggerHandler({ type: "keydown", which: KEYBOARD_KEYS.TAB });
+ $document.triggerHandler({ type: "keyup", which: KEYBOARD_KEYS.TAB });
+ expect(autocompleteCtrl.navIndex).toBe(autocompleteCtrl.navLastIndex);
+
+ // Should be prev item
+ $document.triggerHandler({ type: "keydown", which: KEYBOARD_KEYS.UP });
+ $document.triggerHandler({ type: "keyup", which: KEYBOARD_KEYS.UP });
+ expect(autocompleteCtrl.navIndex).toBe(0);
+
+ // Should be last item (loop)
+ $document.triggerHandler({ type: "keydown", which: KEYBOARD_KEYS.SHIFT });
+ $document.triggerHandler({ type: "keydown", which: KEYBOARD_KEYS.TAB });
+ $document.triggerHandler({ type: "keyup", which: KEYBOARD_KEYS.SHIFT });
+ $document.triggerHandler({ type: "keyup", which: KEYBOARD_KEYS.TAB });
+ expect(autocompleteCtrl.navIndex).toBe(autocompleteCtrl.navLastIndex);
+
+ // Should close datalist
+ $document.triggerHandler({ type: "keydown", which: KEYBOARD_KEYS.ESC });
+ $document.triggerHandler({ type: "keyup", which: KEYBOARD_KEYS.ESC });
+
+ $timeout.flush();
+
+ expect(autocomplete.hasClass("ng-hide")).toBeTruthy();
+ });
+
+ it("should close datalist when below minimum length", (done) => {
+ input.val("");
+ input.triggerHandler("input");
+
+ setTimeout(() => {
+ $timeout.flush();
+ expect(autocomplete.hasClass("ng-hide")).toBeTruthy();
+ done();
+ }, debounceDelay);
+ });
+
+ it("should close datalist when clicked outside", () => {
+ $document.triggerHandler("click");
+
+ $timeout.flush();
+
+ expect(autocomplete.hasClass("ng-hide")).toBeTruthy();
+ });
+ });
+ });
+});
diff --git a/packages/oui-search/README.md b/packages/oui-search/README.md
index 350569e0..bffe1916 100644
--- a/packages/oui-search/README.md
+++ b/packages/oui-search/README.md
@@ -32,6 +32,8 @@
### Accessibility
+**Note**: `aria-label` add an attribute `aria-label` on the input.
+
```html:preview
```
-- `aria-label` add an attribute `aria-label` on the input.
+### Autocomplete
+
+See [Autocomplete](#!/oui-angular/autocomplete) directive for more informations.
+
+```html:preview
+
+
+```
### Events
@@ -62,14 +75,26 @@
## API
-| Attribute | Type | Binding | One-time Binding | Values | Default | Description
-| ---- | ---- | ---- | ---- | ---- | ---- | ----
-| `model` | object | = | no | n/a | n/a | model bound to component
-| `id` | string | @? | yes | n/a | n/a | id attribute of the button
-| `name` | string | @? | yes | n/a | n/a | name attribute of the button
-| `placeholder` | string | @? | yes | n/a | n/a | placeholder text
-| `aria-label` | string | @? | yes | n/a | n/a | accessibility label
-| `disabled` | boolean | | no | `true`, `false` | `false` | disabled flag
-| `on-change` | function | & | no | n/a | n/a | handler triggered when model has changed
-| `on-reset` | function | & | no | n/a | n/a | handler triggered when form is reseted
-| `on-submit` | function | & | no | n/a | n/a | handler triggered when form is submitted
+| Attribute | Type | Binding | One-time Binding | Values | Default | Description
+| ---- | ---- | ---- | ---- | ---- | ---- | ----
+| `model` | object | = | no | n/a | n/a | model bound to component
+| `id` | string | @? | yes | n/a | n/a | id attribute of the button
+| `name` | string | @? | yes | n/a | n/a | name attribute of the button
+| `placeholder` | string | @? | yes | n/a | n/a | placeholder text
+| `aria-label` | string | @? | yes | n/a | n/a | accessibility label
+| `disabled` | boolean | | no | `true`, `false` | `false` | disabled flag
+| `on-change` | function | & | no | n/a | n/a | handler triggered when model has changed
+| `on-reset` | function | & | no | n/a | n/a | handler triggered when form is reseted
+| `on-submit` | function | & | no | n/a | n/a | handler triggered when form is submitted
+
+
+### `oui-autocomplete` attributes
+
+See [Autocomplete](#!/oui-angular/autocomplete) directive for more informations.
+
+| Attribute | Type | Binding | One-time Binding | Values | Default | Description
+| ---- | ---- | ---- | ---- | ---- | ---- | ----
+| `autocomplete` | array | < | no | n/a | n/a | array of suggestions
+| `autocomplete-options` | object | | yes | n/a | n/a | options of autocomplete
+| `autocomplete-property` | string | @? | no | n/a | n/a | property path used to get value from suggestion
+| `autocomplete-on-select` | function | & | no | n/a | n/a | handler triggered when suggestion is selected
diff --git a/packages/oui-search/src/search.component.js b/packages/oui-search/src/search.component.js
index 0acd5f82..c1fe7f3e 100644
--- a/packages/oui-search/src/search.component.js
+++ b/packages/oui-search/src/search.component.js
@@ -11,12 +11,15 @@ export default {
name: "@?",
placeholder: "@?",
ariaLabel: "@?",
-
disabled: "",
onChange: "&",
onReset: "&",
- onSubmit: "&"
+ onSubmit: "&",
+
+ autocomplete: "",
+ autocompleteProperty: "@?",
+ autocompleteOnSelect: "&"
},
controller,
template
diff --git a/packages/oui-search/src/search.html b/packages/oui-search/src/search.html
index 2f1e5ba8..3f522cab 100644
--- a/packages/oui-search/src/search.html
+++ b/packages/oui-search/src/search.html
@@ -1,6 +1,10 @@