mirror of https://github.com/rancher/ui.git
336 lines
9.0 KiB
JavaScript
336 lines
9.0 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 } from '@ember/object';
|
|
import C from 'ui/utils/constants';
|
|
import layout from './template';
|
|
|
|
export default Component.extend({
|
|
layout,
|
|
intl: service(),
|
|
|
|
classNames: ['searchable-select'],
|
|
classNameBindings: ['class'],
|
|
|
|
// 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,
|
|
optionLabelPath: 'label',
|
|
optionValuePath: 'value',
|
|
optionGroupPath: 'group',
|
|
localizedPrompt: false,
|
|
localizedLabel: false,
|
|
readOnly: null,
|
|
|
|
showOptions: false,
|
|
allowCustom: false,
|
|
filter: null,
|
|
// the current highlighted option.
|
|
$activeTarget: null,
|
|
|
|
// Show option image --> unGroupedContent only
|
|
showOptionIcon: function () {
|
|
return this.get('unGroupedContent').some(item => !!item.imgUrl);
|
|
}.property('unGroupedContent.@each.imgUrl'),
|
|
|
|
init() {
|
|
this._super();
|
|
if (!this.get('content')) {
|
|
this.set('content', []);
|
|
}
|
|
if (this.get('allowCustom')) {
|
|
this.set('searchLabel', 'generic.searchOrCustomInput');
|
|
const value = this.get('value');
|
|
this.insertCustomValue(value, false);
|
|
}
|
|
this.set('filter', this.get('displayLabel'));
|
|
},
|
|
|
|
insertCustomValue: function (value, isFilter) {
|
|
const vp = this.get('optionValuePath');
|
|
const lp = this.get('optionLabelPath');
|
|
value = value || '';
|
|
if (!isFilter) {
|
|
const custom = {
|
|
custom: true,
|
|
};
|
|
custom[lp] = `${value} (Custom)`;
|
|
custom[vp] = value;
|
|
this.get('content').pushObject(custom);
|
|
} else {
|
|
const found = this.get('content').filterBy('custom', true).get('firstObject');
|
|
if (found) {
|
|
set(found, lp, `${value} (Custom)`);
|
|
set(found, vp, value)
|
|
}
|
|
}
|
|
},
|
|
|
|
displayLabel: function () {
|
|
const value = this.get('value');
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
|
|
const vp = this.get('optionValuePath');
|
|
const lp = this.get('optionLabelPath');
|
|
const selectedItem = this.get('content').filterBy(vp, value).get('firstObject');
|
|
|
|
if (selectedItem) {
|
|
let label = get(selectedItem, lp);
|
|
if (this.get('localizedLabel')) {
|
|
label = this.get('intl').t(label);
|
|
}
|
|
return label;
|
|
}
|
|
return null;
|
|
}.property('value', 'prompt', 'content.[]'),
|
|
|
|
didInsertElement() {
|
|
this.$('.input-search').on('click', () => {
|
|
this.send('show');
|
|
})
|
|
},
|
|
|
|
filtered: function() {
|
|
const filter = (this.get('filter') || '').trim();
|
|
const options = this.get('content');
|
|
if (this.get('allowCustom')) {
|
|
this.insertCustomValue(filter, true);
|
|
}
|
|
return options.filter(option => {
|
|
const segments = filter.split(' ');
|
|
const gp = this.get('optionGroupPath');
|
|
const lp = this.get('optionLabelPath');
|
|
const group = get(option, gp);
|
|
const label = get(option, lp);
|
|
return segments.every(s => {
|
|
const pattern = new RegExp(s, 'i');
|
|
const labelMatched = pattern.test(label);
|
|
let groupMatched = false;
|
|
if (group && group.trim()) {
|
|
groupMatched = pattern.test(group);
|
|
return groupMatched || labelMatched;
|
|
}
|
|
return labelMatched;
|
|
});
|
|
});
|
|
}.property('filter', 'content.[]'),
|
|
|
|
unGroupedContent: function () {
|
|
const groupPath = this.get('optionGroupPath');
|
|
const out = [];
|
|
this.get('filtered').forEach((opt) => {
|
|
const key = get(opt, groupPath);
|
|
if (!key) {
|
|
out.push(opt);
|
|
}
|
|
});
|
|
return out;
|
|
}.property('filtered.[]'),
|
|
|
|
groupedContent: function () {
|
|
const groupPath = this.get('optionGroupPath');
|
|
const out = [];
|
|
this.get('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);
|
|
}.property('filtered.[]'),
|
|
|
|
allContent() {
|
|
const out = [];
|
|
const grouped = this.get('groupedContent');
|
|
const unGrouped = this.get('unGroupedContent');
|
|
out.pushObjects(unGrouped);
|
|
grouped.forEach(g => out.pushObjects(g.options));
|
|
return out;
|
|
},
|
|
|
|
on() {
|
|
this.$().on('keydown.searchable-keydown', event => {
|
|
const kc = event.keyCode;
|
|
// Note: keyup event can't be prevented.
|
|
if (!this.get('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 = this.get('$activeTarget');
|
|
if ($activeTarget) {
|
|
// activeTarget is prompt
|
|
if ($activeTarget.hasClass('searchable-prompt')) {
|
|
this.send('selectPrompt');
|
|
} else {
|
|
let idx = this.$('.searchable-option').index($activeTarget);
|
|
idx = !!this.get('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() {
|
|
this.$().off('keydown.searchable-keydown');
|
|
},
|
|
|
|
setSelect(item) {
|
|
const gp = this.get('optionGroupPath');
|
|
const vp = this.get('optionValuePath');
|
|
|
|
this.set('value', get(item, vp));
|
|
if (gp && get(item, gp)) {
|
|
this.set('group', get(item, gp));
|
|
}
|
|
this.set('filter', this.get('displayLabel'));
|
|
// https://stackoverflow.com/questions/39624902/new-input-placeholder-behavior-in-safari-10-no-longer-hides-on-change-via-java
|
|
next(() => {
|
|
this.$('.input-search').focus();
|
|
this.$('.input-search').blur();
|
|
})
|
|
this.sendAction('change', item);
|
|
this.send('hide');
|
|
},
|
|
|
|
showOptionsChanged: function () {
|
|
const show = this.get('showOptions');
|
|
if (show) {
|
|
this.on();
|
|
} else {
|
|
this.off();
|
|
}
|
|
}.observes('showOptions'),
|
|
|
|
stepThroughOptions(step) {
|
|
const $activeTarget = this.get('$activeTarget');
|
|
const $options = this.$('.searchable-option');
|
|
let currentIdx = -1;
|
|
let nextIdx = 0;
|
|
|
|
const len = $options.length;
|
|
|
|
if (len === 0) {
|
|
return;
|
|
}
|
|
|
|
if (!$activeTarget) {
|
|
$options.removeClass('searchable-option-active');
|
|
$options.eq(0).addClass('searchable-option-active');
|
|
this.set('$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);
|
|
this.set('$activeTarget', $nextActiveTarget);
|
|
|
|
$activeTarget.removeClass('searchable-option-active');
|
|
$nextActiveTarget.addClass('searchable-option-active')
|
|
},
|
|
|
|
actions: {
|
|
selectUnGroupedItem(idx) {
|
|
const found = this.get('unGroupedContent').objectAt(idx);
|
|
this.setSelect(found);
|
|
},
|
|
selectGroupedItem(items, idx) {
|
|
const found = items.objectAt(idx);
|
|
this.setSelect(found);
|
|
},
|
|
selectPrompt() {
|
|
this.set('value', null);
|
|
this.send('hide');
|
|
},
|
|
mouseEnter(event) {
|
|
this.$('.searchable-option').removeClass('searchable-option-active');
|
|
const $target = this.$(event.target);
|
|
$target.addClass('searchable-option-active');
|
|
this.set('$activeTarget', $target);
|
|
},
|
|
mouseLeave(event) {
|
|
this.$(event.target).removeClass('searchable-option-active');
|
|
this.set('$activeTarget', null);
|
|
},
|
|
show() {
|
|
if (this.get('showOptions') === true) {
|
|
return;
|
|
}
|
|
this.set('filter', null);
|
|
// select text inside input search box, which will let users easey to clear the inputed text.
|
|
// this.$('.input-search').select();
|
|
this.set('showOptions', true);
|
|
},
|
|
hide() {
|
|
this.set('filter', this.get('displayLabel'));
|
|
this.set('showOptions', false);
|
|
this.set('$activeTarget', null);
|
|
},
|
|
},
|
|
willDestoryElement() {
|
|
this.off();
|
|
}
|
|
});
|