UNPKG

bim-select

Version:

A dropdown/select solution for Angular.js that handles millions of items without lag.

566 lines (510 loc) 17.8 kB
// .outerWidth requires jQuery. require('jquery'); require('./bim-select.less'); const templateUrl = require('./bim-select.template.html'); const itemTemplateUrl = require('./bim-select-item.template.html'); const selectedItemTemplateUrl = itemTemplateUrl; exports.name = 'bimSelect'; /** * A combo box/searchable dropdown list with support for millions of * items. It is using a virtual scroll to handle the amount of items. * It works just as fine with a smaller amount of items. * * Supports [`ngRequired`][ngRequired] and [`ngDisabled`][ngDisabled]. * Both defaults to false. * * [ngRequired]: https://docs.angularjs.org/api/ng/directive/ngRequired * [ngDisabled]: https://docs.angularjs.org/api/ng/directive/ngDisabled * * @param {Expression<Array<?>} items * An angular expression that evaluates to an array of items in it that * should be available to select from in the list by the user. * * Each item should have a `name` property that will be used as the * text representing the item in the dropdown. * @param {Expression} ngModel * Should evaluate to a property that will contain the currently * selected item in the `items` array. * @param {Expression<Function>} [onChange] * A function that will be notified when the user selects a new item * in the list. Use `selected` in the expression to get hold of the * selected item. The value of `selected` will be the value in the * `items` array that was selected by the user. * @param {Expression<Function>} [adapter] * If each object in `items` does not have a `text` and `id` property * you can use an adapter to transform each item into an object bimSelect * can work with. * * This function is invoked once for each item in the `items` list and must * return an object with a `text` (string) and an `id` (string, numeric) * property. * @param {Expression<String>} [itemTemplateUrl] * If you need to specify your own template to be rendered for each match * in the list, set the url to the template here. `item` is available on * the scope and is an object with `id`, `text` and `model` property, and * the `model` property has the item in the `items` array as a value. * @param {Expression<String>} [diacritics] * If set to `'strip'` then all filtering in the dropdown will compare * items using the normalized values stripped of any diacritic marks. * @param {Expression<Function>} [sorter] * It allows the consumer to have a custom order of the matching items. * * An expression evaulating to a function reference. This function will * be used as a sorter simliar to the one used when sorting with * `Array.prototype.sort`. Parameters will be `match1`, `match2`, `query` * and the return value should equal that needed for the array sorter. * * E.g. if the matches beginning with the search string should be * prioritized, a custom sorter handling this could be added. * * Please note: This function is only affects a filtered list. * @param {Expression<String>} [selectedItemTemplateUrl] * The selected item template is rendered in place of the input when the input * is not focused. This allows you to render html inside a 'fake' input. * For instance, if you want to render an icon beside the text of the selected * dropdown item you can do so by using your own custom template. `item` is * available on the scope and it is an object with `id`, `text` and `model` property, * and the `model` property has the item in the `items` array as a value. * @param {Expression<Function>} [validator] * This function allows the consumer to specify if the selected item is valid or not. * The function is optional and the selected item will always be valid if not supplied. * The validator function should return a boolean. It receives the selected item * as it's first parameter (which may be falsy if nothing is selected). * Returning true makes it valid, false makes it invalid. * @example * Simple example * * ```html * <bim-select items="vm.items" * ng-model="vm.selected" * on-change="vm.update({ item: selected })"> * ``` * * @example * When using an adapter * * ```js * vm.items = [ * { age: 19, name: 'Nineteen' }, * { age: 20, name: 'Twenty' } * ]; * vm.adapter = function(item) { * // Convert to a text/id object * return { * id: item.age, * text: item.name * }; * } * ``` * ```html * <bim-select items="vm.items" * ng-model="vm.selected" * adapter="vm.adapter"> * ``` * * @example * When using an custom sorter * * ```js * vm.items = [ * { id: 1, name: 'Augustus' }, * { id: 3, name: 'Caligula' } * ]; * vm.sorter = function reverse(a, b, query) { * return -a.text.localeCompare(b.text); * } * ``` * ```html * <bim-select items="vm.items" * ng-model="vm.selected" * sorter="vm.sorter"> * ``` * * @example * Custom template where the text should be "encrypted". * * ```html * <script type="text/ng-template" id="item.html"> * <span class="bim-select-item">{{ item.text | rot13 }}</span> * </script> * * <bim-select items="vm.items" * ng-model="vm.selected" * item-template-url="'item.html'"></bim-select> * @example * Using validator to make component invalid when user is not allowed to * choose an item from the list. Adds the invalid classes to the component. * * ```js * vm.items = [ * { id: 1, text: 'Publish' }, * { id: 2, text: 'Unpublish' } * ]; * const isAdmin = false; * vm.validator = function(selectedItem) { * if (selectedItem) { * if (selectedItem.text === 'Publish') { * if (!isAdmin) { * return false; * } * } * } * return true; * } * ``` * ```html * <bim-select class="bim-select-spec" * ng-model="vm.selected" * items="vm.items" * validator="vm.validator" * name="status" * ng-class="{ * 'has-error': form.status.$invalid * }"></bim-select> * ``` * @example * Disabling elements (options) * You have to add the isDisabled attribute to disable elements * Add isDisabled: true to disable element, add isDisabled: false OR do not add anything to enable element * ```js * vm.items = [ * { id: 1, text: 'Published', isDisabled: true}, * { id: 2, text: 'Unpublished', isDisabled: false}, * { id: 3, text: 'Archived' } * ] * ``` * ```html * <bim-select items="vm.items" ...> * ``` */ exports.impl = { bindings: { adapter: '<', diacritics: '<?', itemTemplateUrl: '<?', items: '<', onChange: '&', selectedItemTemplateUrl: '<?', sorter: '<?', validator: '<' }, require: { model: 'ngModel' }, templateUrl, controller: BimSelectController }; BimSelectController.$inject = [ '$document', '$element', '$timeout', '$scope', '$attrs', 'bimSelectConfig' ]; function BimSelectController( $document, $element, $timeout, $scope, $attrs, bimSelectConfig ) { const $ctrl = this; const defaultItemTemplateUrl = itemTemplateUrl; const defaultSelectedItemTemplateUrl = selectedItemTemplateUrl; const ul = $element[0].querySelector('ul'); let isFocused = false; const Keys = { Escape: 27, Up: 38, Down: 40, Enter: 13 }; let currentJoinedInternalIds = null; $ctrl.internalItems = []; $ctrl.defaultPlaceholder = 'No selection'; $scope.$on('$destroy', function() { $document.off('mousedown touchstart pointerdown', outsideClick); }); // ANGULAR METHODS $ctrl.$onInit = function() { $ctrl.validator = $ctrl.validator || function() { return true; }; $ctrl.internalItemTemplateUrl = $ctrl.itemTemplateUrl || bimSelectConfig.itemTemplateUrl || defaultItemTemplateUrl; $ctrl.internalSelectedItemTemplateUrl = $ctrl.selectedItemTemplateUrl || bimSelectConfig.selectedItemTemplateUrl || $ctrl.itemTemplateUrl || bimSelectConfig.itemTemplateUrl || defaultSelectedItemTemplateUrl; renderSelection(); $ctrl.model.$render = renderSelection; $ctrl.adapter = $ctrl.adapter || function(item) { return { text: item.text, id: item.id }; }; setWidth(); }; $ctrl.$doCheck = function() { const adaptedItems = adaptItems(); const ids = adaptedItems.map(item => item.id).join('$'); if (ids !== currentJoinedInternalIds) { currentJoinedInternalIds = ids; $ctrl.internalItems = adaptedItems; updateMatches(); } }; // TEMPLATE METHODS $ctrl.activateHandler = function(event) { event && event.stopPropagation(); $ctrl.inputValue = ''; isFocused = true; /* * Fixes delayed selected item change glitch. * If the `item` scope is not set to null when * bim-select is opened the old value for the selected * item will be shown for a few milliseconds when the * user selects a new item from the dropdown. */ setSelected(null); open(); }; $ctrl.deactivateHandler = function(event) { isFocused = false; }; $ctrl.toggleHandler = function() { if ($ctrl.active) { $ctrl.close(); } else { // For some reason the .focus below does not trigger activateHandler // when running in a normal browser window, so invoke it manually. $ctrl.activateHandler(); $element.find('input').focus(); } }; $ctrl.close = function() { $document.off('mousedown touchstart pointerdown', outsideClick); $ctrl.active = false; renderSelection(); }; $ctrl.select = function(event, match) { event && event.preventDefault(); // check if models are existing // isDisabled attribute is existing var isDisabled = (typeof match.model !== 'undefined' && typeof match.model.isDisabled !== 'undefined') ? match.model.isDisabled : false; if (!isDisabled && match.id !== 'bim-select-message') { setSelection(match); $ctrl.onChange({ selected: match.model }); $ctrl.close(); } }; $ctrl.clear = function() { $ctrl.model.$setViewValue(null); $ctrl.onChange({ selected: null }); $ctrl.close(); setSelected(null); }; $ctrl.keydownHandler = function(event) { if (event.which === Keys.Escape) { $ctrl.close(); } if (event.which === Keys.Down) { event.preventDefault(); const newIndex = Math.min( $ctrl.activeIndex + 1, $ctrl.matches.length - 1 ); if ($ctrl.matches[newIndex].id !== 'bim-select-message') { $ctrl.activeIndex = newIndex; ensureVisibleItem(); } } if (event.which === Keys.Up) { event.preventDefault(); if ($ctrl.activeIndex > -1) { $ctrl.activeIndex = Math.max($ctrl.activeIndex - 1, 0); ensureVisibleItem(); } } if (event.which === Keys.Enter) { event.preventDefault(); if ($ctrl.activeIndex >= 0) { const item = $ctrl.matches[$ctrl.activeIndex]; $ctrl.select(null, item); } } }; $ctrl.inputValueChangeHandler = function() { updateMatches(); ul.scrollTop = 0; if (!$ctrl.active) { open(); } }; $ctrl.isRequired = function() { return !!$attrs.required; }; $ctrl.isDisabled = function() { return !!$attrs.disabled; }; $ctrl.isClearable = function() { return $ctrl.model.$modelValue !== undefined && $ctrl.model.$modelValue !== null && !$ctrl.isRequired(); }; $ctrl.placeholderText = () => $attrs.placeholder || bimSelectConfig.placeholder || $ctrl.defaultPlaceholder; $ctrl.shouldDisplayInput = function() { const isDisabled = $ctrl.isDisabled(); const modelIsFalsy = !$ctrl.model.$modelValue; return modelIsFalsy || ((isFocused) && !isDisabled); }; $ctrl.isDisabledItem = function(item) { if (item.model) { return item.model.isDisabled === true; } else { return false; } }; // INTERNAL HELPERS function ensureVisibleItem() { $timeout(function() { const li = ul.querySelector('li.active'); if (li) { const itemHeight = li.clientHeight; const listHeight = ul.clientHeight; const offsetTop = li.offsetTop; // below viewport if (offsetTop + itemHeight > ul.scrollTop + listHeight) { ul.scrollTop = offsetTop - listHeight + 2 * itemHeight; } // above viewport if (offsetTop - 5 < ul.scrollTop) { ul.scrollTop = offsetTop - itemHeight; } } }); } function open() { if (!$ctrl.active) { $ctrl.active = true; $document.on('mousedown touchstart pointerdown', outsideClick); updateMatches(); setWidth(); $timeout(function() { // Force rerender of virtual scroll. Needed for at least IE11. $scope.$broadcast('vsRepeatResize'); }); } }; function updateMatches() { $ctrl.activeIndex = -1; const query = $ctrl.inputValue || ''; $ctrl.matches = $ctrl.internalItems.filter(function(item) { const text = normalize(item.text); return text.indexOf(normalize(query)) >= 0; }); const sorter = 'sorter' in $ctrl ? $ctrl.sorter : bimSelectConfig.sorter; if (query && sorter) { $ctrl.matches.sort(function(a, b) { return sorter(a.model, b.model, query); }); } // Workaround to expose real index for each item since // vs-repeat modifies it. $ctrl.matches.forEach(function(match, index) { match.index = index; }); if ($ctrl.inputValue && $ctrl.matches.length === 0) { $ctrl.matches.push({ id: 'bim-select-message', text: 'No matches' }); } else if ($ctrl.internalItems.length === 0) { $ctrl.matches.push({ id: 'bim-select-message', text: 'No options' }); } } function adaptItem(item) { const adapted = $ctrl.adapter(item); if (typeof adapted.text !== 'string') { throw new Error('Adapter did not generate an object with a valid text string property'); } if (typeof adapted.id !== 'string' && typeof adapted.id !== 'number') { throw new Error('Adapter did not generate an object with a valid id string or numeric property'); } adapted.model = item; return adapted; } function adaptItems() { const externalItems = $ctrl.items || []; return externalItems.map(adaptItem); } function setWidth() { $ctrl.width = $element.find('.input-group').outerWidth(); } function setSelection(match) { $ctrl.model.$setViewValue(match.model); } function renderSelection() { if ($ctrl.model.$modelValue === undefined || $ctrl.model.$modelValue === null) { $ctrl.inputValue = ''; } else { $ctrl.model.$modelValue && setSelected(adaptItem($ctrl.model.$modelValue)); $ctrl.inputValue = $ctrl.model.$modelValue && $ctrl.adapter($ctrl.model.$modelValue).text; } validateSelected(); } function outsideClick(event) { let elm = event.target; while (elm && elm !== $element[0]) { elm = elm.parentNode; } if (!elm) { // We hit document, and not any element within the directive $scope.$apply(function() { ul.scrollTop = 0; $ctrl.close(); }); } } var NORMALIZE_MAP = { 'å': 'a', 'ä': 'a', 'ë': 'e', 'é': 'e', 'è': 'e', 'ö': 'o', 'ø': 'o', 'ü': 'u' }; function normalize(str) { const localPresent = 'diacritics' in $ctrl; let out = str.toLowerCase(); if ((localPresent && $ctrl.diacritics === 'strip') || (!localPresent && bimSelectConfig.diacritics === 'strip')) { if (out.normalize) { // Most browsers out = out.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); } else { // IE11 out = out.split('').map(function(char) { return NORMALIZE_MAP[char] || char; }).join(''); } } return out; } function setSelected(item) { $scope.item = item; } function validateSelected() { const item = $ctrl.model.$modelValue && adaptItem($ctrl.model.$modelValue); const isValid = $ctrl.validator(item); $ctrl.model.$setValidity('selection', isValid); } };