/** * @fileOverview * @name component * * Fetures: * 1. options search/filter * 2. grouping * 3. show icons for unGroupedContent options * 4. `arrow-up` & `arrow-down` keys to navigate through options * 5. `return` key to select the current active option * 6. esc to cancel * * Option data structure: * { * label: string, * value: string, * group: string, // Optional, which group/category this option belong to. * imgUrl: string, // Optional, whether to display a image for this option, unGrouped options only. * } * **/ import Component from '@ember/component'; import { inject as service } from '@ember/service'; import { next } from '@ember/runloop'; import { get, set, computed, observer } from '@ember/object'; import C from 'ui/utils/constants'; import layout from './template'; import { htmlSafe } from '@ember/string'; import { on } from '@ember/object/evented'; import { escapeRegex } from 'ui/utils/util'; const MAX_HEIGHT = 285; export default Component.extend({ intl: service(), layout, classNames: ['searchable-select'], classNameBindings: ['class', 'showDropdownArrow'], // input class: null, value: null, prefix: null, suffix: null, prompt: null, // If need to catch the group changes, you can pass a group prop in. group: null, content: null, interContent: null, optionLabelPath: 'label', optionValuePath: 'value', optionGroupPath: 'group', localizedPrompt: false, localizedLabel: false, localizedHtmlLabel: false, customLabel: false, readOnly: null, showOptions: false, allowCustom: false, filter: null, clientSideFiltering: true, // the current highlighted option. $activeTarget: null, maxHeight: MAX_HEIGHT, showDropdownArrow: true, init() { this._super(...arguments); this.observeContent(); }, didInsertElement() { this.$('.input-search').on('click', () => { this.send('show'); }); }, willDestroyElement() { this.off(); }, actions: { search(/* term*/) { // placeholder is over written by extenders if you want }, selectUnGroupedItem(idx) { const found = get(this, 'unGroupedContent').objectAt(idx); this.setSelect(found); }, selectGroupedItem(items, idx) { const found = items.objectAt(idx); this.setSelect(found); }, selectPrompt() { set(this, 'value', null); this.send('hide'); }, mouseEnter(event) { this.$('.searchable-option').removeClass('searchable-option-active'); const $target = this.$(event.target); $target.addClass('searchable-option-active'); set(this, '$activeTarget', $target); }, mouseLeave(event) { this.$(event.target).removeClass('searchable-option-active'); set(this, '$activeTarget', null); }, show() { if (get(this, 'showOptions') === true) { return; } const toBottom = $('body').height() - $(this.$()[0]).offset().top - 60; // eslint-disable-line set(this, 'maxHeight', toBottom < MAX_HEIGHT ? toBottom : MAX_HEIGHT) set(this, 'filter', null); next(() => { const checked = this.$('.searchable-option .icon-check'); const options = this.$('.searchable-options'); if ( options.length && checked.length ) { options.animate({ scrollTop: `${ checked.parent().offset().top - options.offset().top }px` }); } }); set(this, 'showOptions', true); }, hide() { set(this, 'filter', get(this, 'displayLabel')); set(this, 'showOptions', false); set(this, '$activeTarget', null); }, }, observeContent: observer('content.[]', 'value', 'displayLabel', function(){ if (!get(this, 'content')) { set(this, 'content', []); } set(this, 'interContent', get(this, 'content').slice(0)); if (get(this, 'allowCustom')) { set(this, 'searchLabel', 'generic.searchOrCustomInput'); const value = get(this, 'value'); this.insertCustomValue(value, false); } set(this, 'filter', get(this, 'displayLabel')); }), optionsMaxHeightCss: computed('maxHeight', function() { return htmlSafe(`max-height: ${ get(this, 'maxHeight') }px`); }), // Show option image --> unGroupedContent only showOptionIcon: computed('unGroupedContent.@each.imgUrl', function() { return get(this, 'unGroupedContent').some((item) => !!item.imgUrl); }), displayLabel: computed('value', 'prompt', 'interContent.[]', 'intl.locale.[]', function() { const value = get(this, 'value'); const vp = get(this, 'optionValuePath'); const lp = get(this, 'optionLabelPath'); const selectedItem = get(this, 'interContent').filterBy(vp, value).get('firstObject'); if (selectedItem) { let label = get(selectedItem, lp); if (get(this, 'localizedLabel')) { label = get(this, 'intl').t(label); } else if ( get(this, 'localizedHtmlLabel') ) { label = value; } return label; } return null; }), filtered: computed('filter', 'interContent.[]', function() { const filter = (get(this, 'filter') || '').trim(); const options = get(this, 'interContent'); if (get(this, 'allowCustom')) { this.insertCustomValue(filter, true); } if ( get(this, 'clientSideFiltering') ) { const filteredOptionsA = []; const filteredOptionsB = []; options.forEach((option) => { const filterTerms = filter.split(/\s+/); const gp = get(this, 'optionGroupPath'); const lp = get(this, 'optionLabelPath'); const group = get(option, gp); const label = get(option, lp); let startsWithOneOfFilterTerm = false; let containsEveryFilterTerm = true; filterTerms.forEach((s) => { s = s.toLowerCase(); const startsWith = label.toLowerCase().startsWith(s) || (group && group.toLowerCase().startsWith(s)); if (startsWith) { startsWithOneOfFilterTerm = true; } const exp = escapeRegex(s); const pattern = new RegExp(exp, 'i'); const contains = pattern.test(label) || (group && pattern.test(group)); if (!contains) { containsEveryFilterTerm = false; } }); if (startsWithOneOfFilterTerm && containsEveryFilterTerm) { filteredOptionsA.push(option); return; } if (containsEveryFilterTerm) { filteredOptionsB.push(option); return; } }); return filteredOptionsA.concat(filteredOptionsB); } else { return options; } }), unGroupedContent: computed('filtered.[]', function() { const groupPath = get(this, 'optionGroupPath'); const out = []; get(this, 'filtered').forEach((opt) => { const key = get(opt, groupPath); if (!key) { out.push(opt); } }); return out; }), groupedContent: computed('filtered.[]', function() { const groupPath = get(this, 'optionGroupPath'); const out = []; get(this, 'filtered').forEach((opt) => { const key = get(opt, groupPath); if (key) { let group = out.filterBy('group', key)[0]; if (!group) { group = { group: key, options: [] }; out.push(group); } group.options.push(opt); } }); return out.sortBy(groupPath); }), showMessage: computed('filtered.[]', function() { return get(this, 'filtered.length') === 0; }), missingMessage: computed('content.[]', function() { let len = get(this, 'content.length') let out = 'searchableSelect.noOptions'; if (len) { out = 'searchableSelect.noMatch'; } return out; }), showOptionsChanged: on('init', observer('showOptions', function() { const show = get(this, 'showOptions'); if (show) { this.on(); } else { this.off(); } })), allContent() { const out = []; const grouped = get(this, 'groupedContent'); const unGrouped = get(this, 'unGroupedContent'); out.pushObjects(unGrouped); grouped.forEach((g) => out.pushObjects(g.options)); return out; }, on() { this.$().on('keydown.searchable-option', (event) => { const kc = event.keyCode; // Note: keyup event can't be prevented. if (!get(this, 'showOptions')) { return; } if (kc === C.KEY.UP) { this.stepThroughOptions(-1); } if (kc === C.KEY.DOWN) { this.stepThroughOptions(1); } // support using return key to select the current active option if (kc === C.KEY.CR || kc === C.KEY.LF) { event.preventDefault(); const $activeTarget = get(this, '$activeTarget'); if ($activeTarget) { // activeTarget is prompt if ($activeTarget.hasClass('searchable-prompt')) { this.send('selectPrompt'); } else { let idx = this.$('.searchable-option').index($activeTarget); idx = !!get(this, 'prompt') ? idx - 1 : idx; // set value const activeOption = this.allContent().objectAt(idx); this.setSelect(activeOption); } // hide options after value has been set this.send('hide'); } } // esc to hide if (kc === C.KEY.ESCAPE) { this.send('hide'); } }); }, off() { if (this.$()) { this.$().off('keydown.searchable-option'); } }, setSelect(item) { const gp = get(this, 'optionGroupPath'); const vp = get(this, 'optionValuePath'); set(this, 'value', get(item, vp)); if (gp && get(item, gp)) { set(this, 'group', get(item, gp)); } set(this, 'filter', get(this, 'displayLabel')); // https://stackoverflow.com/questions/39624902/new-input-placeholder-behavior-in-safari-10-no-longer-hides-on-change-via-java next(() => { const input = this.$('.input-search'); if ( input ) { input.focus(); input.blur(); } }) this.sendAction('change', item); this.send('hide'); }, stepThroughOptions(step) { const $activeTarget = get(this, '$activeTarget'); const $options = this.$('.searchable-option'); const len = $options.length; let currentIdx = -1; let nextIdx = 0; if (len === 0) { return; } if (!$activeTarget) { $options.removeClass('searchable-option-active'); $options.eq(0).addClass('searchable-option-active'); set(this, '$activeTarget', $options.eq(0)); return; } currentIdx = $options.index($activeTarget); if (currentIdx !== -1) { nextIdx = currentIdx + step; } if (nextIdx !== 0) { nextIdx = nextIdx < 0 ? len - 1 : nextIdx % len; } const $nextActiveTarget = $options.eq(nextIdx); set(this, '$activeTarget', $nextActiveTarget); $activeTarget.removeClass('searchable-option-active'); $nextActiveTarget.addClass('searchable-option-active') }, insertCustomValue(value, isFilter) { const vp = get(this, 'optionValuePath'); const lp = get(this, 'optionLabelPath'); value = value || ''; if (!isFilter) { const custom = { custom: true, }; custom[lp] = `${ value } (Custom)`; custom[vp] = value; get(this, 'interContent').pushObject(custom); } else { const found = get(this, 'interContent').filterBy('custom', true).get('firstObject'); if (found) { set(found, lp, `${ value } (Custom)`); set(found, vp, value) } } }, });