diff --git a/package.json b/package.json index 699f687f..7b25166b 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,6 @@ "repository": "ovh-ux/ovh-ui-angular", "main": "./dist/oui-angular.min.js", "browser": "./dist/oui-angular.min.js", - "module": "./packages/oui-angular/src/index.js", "scripts": { "commit": "npm-scripts-config commit", "commitmsg": "npm-scripts-config commitmsg", @@ -35,18 +34,25 @@ } }, "dependencies": { + "bloodhound-js": "^1.2.3", + "clipboard": "^2.0.1", + "escape-string-regexp": "^1.0.5", + "flatpickr": "^4.5.2", + "popper.js": "^1.14.4", + "ui-select": "^0.19.8" + }, + "peerDependencies": { "angular": ">=1.6.x", "angular-aria": ">=1.6.x", "angular-sanitize": ">=1.6.x", - "clipboard": "^2.0.1", - "escape-string-regexp": "^1.0.5", "flatpickr": "^4.5.2", - "ovh-ui-kit": "^2.20.0", - "popper.js": "^1.14.4" + "ovh-ui-kit": "^2.20.0" }, "devDependencies": { + "angular": ">=1.6.x", + "angular-aria": ">=1.6.x", + "angular-sanitize": ">=1.6.x", "angular-mocks": ">=1.6.x", - "autoprefixer": "^9.1.5", "babel-cli": "^6.26.0", "babel-core": "^6.26.3", "babel-eslint": "^9.0.0", @@ -59,15 +65,11 @@ "babel-preset-stage-2": "^6.24.1", "babel-register": "^6.26.0", "cross-env": "^5.2.0", - "css-loader": "^1.0.0", "eslint": "^4.3.0", "eslint-config-ovh": "^0.1.1", "eslint-friendly-formatter": "^4.0.1", "eslint-loader": "^2.1.1", - "eventsource-polyfill": "^0.9.6", - "express": "^4.16.3", "html-loader": "^0.5.5", - "html-webpack-plugin": "^3.2.0", "istanbul-instrumenter-loader": "^3.0.1", "jasmine-core": "^3.2.1", "karma": "^3.0.0", @@ -80,29 +82,17 @@ "karma-sourcemap-loader": "^0.3.7", "karma-spec-reporter": "^0.0.32", "karma-webpack": "^4.0.0-rc.2", - "less": "^3.8.1", - "less-loader": "^4.1.0", - "loader-utils": "^1.1.0", "lodash": "^4.17.11", "lodash-webpack-plugin": "^0.11.5", - "marked": "^0.5.0", - "minimist": "^1.2.0", "npm-scripts-config": "^0.0.2", "npm-scripts-conventional-changelog": "^0.1.0", - "opn": "^5.3.0", - "portscanner": "^2.2.0", - "postcss-loader": "^3.0.0", "rimraf": "^2.6.2", - "style-loader": "^0.23.0", "webpack": "^4.19.1", "webpack-cli": "^3.1.0", "webpack-dev-middleware": "^3.3.0", "webpack-hot-middleware": "^2.24.0", "webpack-merge": "^4.1.4" }, - "peerDependencies": { - "ovh-ui-kit": "*" - }, "engines": { "node": ">= 6.9.0", "npm": ">= 3.10.0", diff --git a/packages/oui-angular/src/index.js b/packages/oui-angular/src/index.js index c32f6756..62b6ec9d 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"; @@ -10,7 +11,9 @@ import CriteriaAdder from "@ovh-ui/oui-criteria-adder"; import CriteriaContainer from "@ovh-ui/oui-criteria-container"; import Datagrid from "@ovh-ui/oui-datagrid"; import Dropdown from "@ovh-ui/oui-dropdown"; +import DualList from "@ovh-ui/oui-dual-list"; import Field from "@ovh-ui/oui-field"; +import File from "@ovh-ui/oui-file"; import FormActions from "@ovh-ui/oui-form-actions"; import GuideMenu from "@ovh-ui/oui-guide-menu"; import HeaderTabs from "@ovh-ui/oui-header-tabs"; @@ -32,6 +35,7 @@ import Slideshow from "@ovh-ui/oui-slideshow"; import Spinner from "@ovh-ui/oui-spinner"; import Stepper from "@ovh-ui/oui-stepper"; import Switch from "@ovh-ui/oui-switch"; +import Tabs from "@ovh-ui/oui-tabs"; import Textarea from "@ovh-ui/oui-textarea"; import Tile from "@ovh-ui/oui-tile"; import Tooltip from "@ovh-ui/oui-tooltip"; @@ -39,6 +43,7 @@ import Tooltip from "@ovh-ui/oui-tooltip"; export default angular .module("oui", [ ActionMenu, + Autocomplete, BackButton, Button, Calendar, @@ -50,7 +55,9 @@ export default angular CriteriaContainer, Datagrid, Dropdown, + DualList, Field, + File, FormActions, GuideMenu, HeaderTabs, @@ -72,6 +79,7 @@ export default angular Spinner, Stepper, Switch, + Tabs, Textarea, Tile, Tooltip diff --git a/packages/oui-angular/src/index.spec.js b/packages/oui-angular/src/index.spec.js index 67b46a72..d62f271c 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))$/)); @@ -12,7 +13,9 @@ loadTests(require.context("../../oui-criteria-adder/src/", true, /.*((\.spec)|(i loadTests(require.context("../../oui-criteria-container/src/", true, /.*((\.spec)|(index))$/)); loadTests(require.context("../../oui-datagrid/src/", true, /.*((\.spec)|(index))$/)); loadTests(require.context("../../oui-dropdown/src/", true, /.*((\.spec)|(index))$/)); +loadTests(require.context("../../oui-dual-list/src/", true, /.*((\.spec)|(index))$/)); loadTests(require.context("../../oui-field/src/", true, /.*((\.spec)|(index))$/)); +loadTests(require.context("../../oui-file/src/", true, /.*((\.spec)|(index))$/)); loadTests(require.context("../../oui-form-actions/src/", true, /.*((\.spec)|(index))$/)); loadTests(require.context("../../oui-guide-menu/src/", true, /.*((\.spec)|(index))$/)); loadTests(require.context("../../oui-header-tabs/src/", true, /.*((\.spec)|(index))$/)); @@ -34,6 +37,7 @@ loadTests(require.context("../../oui-slideshow/src/", true, /.*((\.spec)|(index) loadTests(require.context("../../oui-spinner/src/", true, /.*((\.spec)|(index))$/)); loadTests(require.context("../../oui-stepper/src/", true, /.*((\.spec)|(index))$/)); loadTests(require.context("../../oui-switch/src/", true, /.*((\.spec)|(index))$/)); +loadTests(require.context("../../oui-tabs/src/", true, /.*((\.spec)|(index))$/)); loadTests(require.context("../../oui-tile/src/", true, /.*((\.spec)|(index))$/)); loadTests(require.context("../../oui-textarea/src/", true, /.*((\.spec)|(index))$/)); loadTests(require.context("../../oui-tooltip/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 | { + 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-button/src/button.html b/packages/oui-button/src/button.html index 1106a01d..ef95d5c2 100644 --- a/packages/oui-button/src/button.html +++ b/packages/oui-button/src/button.html @@ -16,7 +16,7 @@ > - {{::$ctrl.text}} + {{$ctrl.text}} diff --git a/packages/oui-checkbox/src/checkbox.component.js b/packages/oui-checkbox/src/checkbox.component.js index 3663b59a..9e789c78 100644 --- a/packages/oui-checkbox/src/checkbox.component.js +++ b/packages/oui-checkbox/src/checkbox.component.js @@ -4,6 +4,9 @@ import template from "./checkbox.html"; export default { template, controller, + require: { + form: "?^^form" + }, bindings: { model: "=?", id: "@?", diff --git a/packages/oui-checkbox/src/checkbox.controller.js b/packages/oui-checkbox/src/checkbox.controller.js index bdf5101e..053b27da 100644 --- a/packages/oui-checkbox/src/checkbox.controller.js +++ b/packages/oui-checkbox/src/checkbox.controller.js @@ -17,7 +17,6 @@ export default class { // So we use $timeout to force the $apply this.$timeout(() => this.$element - .addClass("oui-checkbox") .removeAttr("id") .removeAttr("name") ); @@ -38,6 +37,13 @@ export default class { addDefaultParameter(this, "id", `ouiCheckbox${this.$scope.$id}`); } + hasError () { + if (!this.form || !this.form[this.name]) { + return false; + } + return (this.form[this.name].$dirty || this.form.$submitted) && !this.focused && this.form[this.name].$invalid; + } + _updateIndeterminateState (model) { this.checkboxElement.prop("indeterminate", model === null); } diff --git a/packages/oui-checkbox/src/checkbox.html b/packages/oui-checkbox/src/checkbox.html index fc1249ef..4caca23f 100644 --- a/packages/oui-checkbox/src/checkbox.html +++ b/packages/oui-checkbox/src/checkbox.html @@ -1,28 +1,33 @@ - -