ui/lib/shared/addon/components/searchable-select/component.js

487 lines
12 KiB
JavaScript

/**
* @fileOverview
* @name component<searchable-select>
*
* 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)
}
}
},
});