Skip to content

Instantly share code, notes, and snippets.

@lennyburdette
Last active August 24, 2016 21:57
Show Gist options
  • Save lennyburdette/3a65587a3029ad2526a7236141f00a84 to your computer and use it in GitHub Desktop.
Save lennyburdette/3a65587a3029ad2526a7236141f00a84 to your computer and use it in GitHub Desktop.
filtered select
import Ember from 'ember';
/*
This component manages:
* Converting an arbitrary array of objects in options (objects with a value and label)
for inner select component.
* The filter text and the filtered list of options.
* The proposed option, held temporarily until the selection is committed or discarded.
* Converting the option back into the original value in the action up to the calling comment.
*/
export default Ember.Component.extend({
classNames: [
'filtered-select-container'
],
label: Ember.computed('filter', 'proposedOption', 'selectedOption', function() {
/*const proposedOption = this.get('proposedOption');
if (proposedOption) {
return proposedOption.label;
}*/
const filter = this.get('filter');
if (filter && filter.length) {
return filter;
}
const selectedOption = this.get('selectedOption');
if (selectedOption) {
return selectedOption.label;
}
}),
optionsWithLabels: Ember.computed('options', 'labelPath', function() {
const labelPath = this.get('labelPath')
return this.get('options').map(value => ({
label: Ember.get(value, labelPath),
value
}));
}),
selectedOption: Ember.computed('optionsWithLabels', 'value', function() {
return this.get('optionsWithLabels').findBy('value', this.get('value'));
}),
filteredOptions: Ember.computed('optionsWithLabels', 'filter', function() {
let filtered = this.get('optionsWithLabels');
const filter = this.get('filter');
if (filter && filter.length) {
const pattern = new RegExp(`^${filter.toLowerCase()}`);
filtered = filtered.filter(value => pattern.test(value.label.toLowerCase()));
if (this.get('allowNew')) {
const newOption = { additive: true, label: filter, value: filter };
filtered.unshift(newOption);
this.set('proposedOption', newOption);
}
}
return filtered;
}),
actions: {
filterChanged(event) {
const lowerCase = event.target.value.trim().toLowerCase();
const exactMatch = this.get('optionsWithLabels').find(value =>
value.label.toLowerCase() === lowerCase
);
if (exactMatch && !this.get('allowNew')) {
this.set('proposedOption', exactMatch);
return;
}
this.set('filter', event.target.value);
},
filterCanceled() {
this.set('filter', null);
this.set('proposedOption', null);
},
optionProposed(option) {
this.set('proposedOption', option);
},
optionSelected(option) {
this.set('filter', null);
this.set('proposedOption', null);
this.sendAction('action', option.value);
}
}
});
import Ember from 'ember';
export default Ember.Controller.extend({
states: Ember.inject.service(),
log: Ember.computed(() => Ember.ArrayProxy.create({ content: [] })),
init(...args) {
this._super(...args);
this.set('selectedState', this.get('states.all')[20]);
},
actions: {
change(value) {
this.set('selectedState', value);
}
}
});
import Ember from 'ember';
import truthConvert from '../utils/truth-convert';
export function and(params) {
for (var i=0, len=params.length; i<len; i++) {
if (truthConvert(params[i]) === false) {
return params[i];
}
}
return params[params.length-1];
}
export default Ember.Helper.helper(and);
import Ember from 'ember';
export function eq(params) {
return params[0] === params[1];
}
export default Ember.Helper.helper(eq);
import Ember from 'ember';
import truthConvert from '../utils/truth-convert';
export function not(params) {
for (var i=0, len=params.length; i<len; i++) {
if (truthConvert(params[i]) === true) {
return false;
}
}
return true;
}
export default Ember.Helper.helper(not);
import Ember from 'ember';
import truthConvert from '../utils/truth-convert';
export function or(params) {
for (var i=0, len=params.length; i<len; i++) {
if (truthConvert(params[i]) === true) {
return params[i];
}
}
return params[params.length-1];
}
export default Ember.Helper.helper(or);
import Ember from 'ember';
export default Ember.Service.extend({
all: [
{
"name": "Alabama",
"abbreviation": "AL"
},
{
"name": "Alaska",
"abbreviation": "AK"
},
{
"name": "American Samoa",
"abbreviation": "AS"
},
{
"name": "Arizona",
"abbreviation": "AZ"
},
{
"name": "Arkansas",
"abbreviation": "AR"
},
{
"name": "California",
"abbreviation": "CA"
},
{
"name": "Colorado",
"abbreviation": "CO"
},
{
"name": "Connecticut",
"abbreviation": "CT"
},
{
"name": "Delaware",
"abbreviation": "DE"
},
{
"name": "District Of Columbia",
"abbreviation": "DC"
},
{
"name": "Federated States Of Micronesia",
"abbreviation": "FM"
},
{
"name": "Florida",
"abbreviation": "FL"
},
{
"name": "Georgia",
"abbreviation": "GA"
},
{
"name": "Guam",
"abbreviation": "GU"
},
{
"name": "Hawaii",
"abbreviation": "HI"
},
{
"name": "Idaho",
"abbreviation": "ID"
},
{
"name": "Illinois",
"abbreviation": "IL"
},
{
"name": "Indiana",
"abbreviation": "IN"
},
{
"name": "Iowa",
"abbreviation": "IA"
},
{
"name": "Kansas",
"abbreviation": "KS"
},
{
"name": "Kentucky",
"abbreviation": "KY"
},
{
"name": "Louisiana",
"abbreviation": "LA"
},
{
"name": "Maine",
"abbreviation": "ME"
},
{
"name": "Marshall Islands",
"abbreviation": "MH"
},
{
"name": "Maryland",
"abbreviation": "MD"
},
{
"name": "Massachusetts",
"abbreviation": "MA"
},
{
"name": "Michigan",
"abbreviation": "MI"
},
{
"name": "Minnesota",
"abbreviation": "MN"
},
{
"name": "Mississippi",
"abbreviation": "MS"
},
{
"name": "Missouri",
"abbreviation": "MO"
},
{
"name": "Montana",
"abbreviation": "MT"
},
{
"name": "Nebraska",
"abbreviation": "NE"
},
{
"name": "Nevada",
"abbreviation": "NV"
},
{
"name": "New Hampshire",
"abbreviation": "NH"
},
{
"name": "New Jersey",
"abbreviation": "NJ"
},
{
"name": "New Mexico",
"abbreviation": "NM"
},
{
"name": "New York",
"abbreviation": "NY"
},
{
"name": "North Carolina",
"abbreviation": "NC"
},
{
"name": "North Dakota",
"abbreviation": "ND"
},
{
"name": "Northern Mariana Islands",
"abbreviation": "MP"
},
{
"name": "Ohio",
"abbreviation": "OH"
},
{
"name": "Oklahoma",
"abbreviation": "OK"
},
{
"name": "Oregon",
"abbreviation": "OR"
},
{
"name": "Palau",
"abbreviation": "PW"
},
{
"name": "Pennsylvania",
"abbreviation": "PA"
},
{
"name": "Puerto Rico",
"abbreviation": "PR"
},
{
"name": "Rhode Island",
"abbreviation": "RI"
},
{
"name": "South Carolina",
"abbreviation": "SC"
},
{
"name": "South Dakota",
"abbreviation": "SD"
},
{
"name": "Tennessee",
"abbreviation": "TN"
},
{
"name": "Texas",
"abbreviation": "TX"
},
{
"name": "Utah",
"abbreviation": "UT"
},
{
"name": "Vermont",
"abbreviation": "VT"
},
{
"name": "Virgin Islands",
"abbreviation": "VI"
},
{
"name": "Virginia",
"abbreviation": "VA"
},
{
"name": "Washington",
"abbreviation": "WA"
},
{
"name": "West Virginia",
"abbreviation": "WV"
},
{
"name": "Wisconsin",
"abbreviation": "WI"
},
{
"name": "Wyoming",
"abbreviation": "WY"
}
]
});
import Ember from 'ember';
const { computed } = Ember;
const KEY_CODE_TAB = 9;
const KEY_CODE_ENTER = 13;
const KEY_CODE_ESC = 27;
const KEY_CODE_UP = 38;
const KEY_CODE_DOWN = 40;
/*
This component manages:
* The dropdown state: open or closed.
* Focus, click, and keyboard events.
Fun note! This component never calls this.set() except for isOpen.
*/
export default Ember.Component.extend({
classNames: ['filtered-select'],
classNameBindings: [
'isOpen:filtered-select--is-active',
'dropdownMode:filtered-select--dropdown'
],
// passed in
possibleOptions: null,
proposedOption: null,
selectedOption: null,
autoFocus: false,
dropdownMode: true,
label: null,
// internal state
isOpen: false,
// lifecycle methods
didInsertElement(...args) {
this._super(...args);
if (this.get('autoFocus')) {
this.$('input:text').focus();
}
},
click() {
this.openDropdown();
},
// TODO should we do this?
mouseLeave() {
this.highlight(null);
},
// event handlers
keyDown(e) {
switch (e.keyCode) {
case KEY_CODE_DOWN:
this.highlightNext(e);
Ember.run.scheduleOnce('afterRender', this, this.trackHighlighted);
return;
case KEY_CODE_UP:
this.highlightPrevious(e);
Ember.run.scheduleOnce('afterRender', this, this.trackHighlighted);
return;
case KEY_CODE_ENTER:
return this.commitHightlightedSelection();
case KEY_CODE_TAB:
return this.commitHightlightedSelection();
case KEY_CODE_ESC:
return this.revert();
default:
this.openDropdown();
}
},
// dropdown management
openDropdown() {
if (this.get('isOpen')) {
return;
}
this.set('isOpen', true);
this.$(document).on(`click.filteredSelect${this.get('elementId')}`, event => {
const target = this.$(event.target);
if (target && !target.closest(this.$()).length) {
return this.commitHightlightedSelection();
}
});
Ember.run.scheduleOnce('afterRender', this, this.trackHighlighted);
},
closeDropdown() {
this.set('isOpen', false);
this.$(document).off(`click.filteredSelect${this.get('elementId')}`);
},
// selection management
commitSelection(option) {
if (option && option !== this.get('selectedOption')) {
this.select(option);
}
this.closeDropdown();
this.$('input:text').blur();
},
commitHightlightedSelection() {
this.commitSelection(this.get('proposedOption'));
},
// proposed selection management
highlightedIndex: Ember.computed('possibleOptions', 'proposedOption', 'selectedOption', function() {
const proposed = this.get('proposedOption');
if (proposed) {
return this.get('possibleOptions').indexOf(proposed);
}
const selected = this.get('selectedOption');
if (selected) {
return this.get('possibleOptions').indexOf(selected);
}
return -1;
}),
highlight(value) {
this.propose(value);
if (value) {
Ember.run.scheduleOnce('afterRender', () => this.$('input:text').get(0).select());
}
},
highlightIndex(index) {
this.highlight(this.get('possibleOptions')[index]);
},
highlightNext(e) {
this.openDropdown();
if (this.get('highlightedIndex') < 0) {
this.highlightIndex(0);
e.stopPropagation();
e.preventDefault();
return
}
const nextIndex = this.get('highlightedIndex') + 1;
if (nextIndex < this.get('possibleOptions.length')) {
this.highlightIndex(nextIndex);
e.stopPropagation();
e.preventDefault();
Ember.run.scheduleOnce('afterRender', this, this.trackHighlighted);
}
},
highlightPrevious(e) {
const previousIndex = this.get('highlightedIndex') - 1;
if (previousIndex > -1) {
this.highlightIndex(previousIndex);
e.stopPropagation();
e.preventDefault();
Ember.run.scheduleOnce('afterRender', this, this.trackHighlighted);
}
},
trackHighlighted() {
let index = this.get('highlightedIndex');
if (!this.get('isOpen') || index < 0) {
return;
}
const $highlighted = this.$(`.filtered-select__option:eq(${index})`);
if ($highlighted.length) {
$highlighted.get(0).scrollIntoView(false);
}
},
revert() {
this.closeDropdown();
this.cancel();
},
actions: {
highlightOption(option) {
if (option && option !== this.get('proposedOption')) {
this.highlight(option);
}
},
optionClicked(option) {
this.commitSelection(option);
},
open() {
this.openDropdown();
this.$('input:text').get(0).select();
}
}
});
<input type="text"
value={{label}}
disabled={{disabled}}
class="filtered-select__input
{{if dropdownMode 'filtered-select--dropdown__input'}}"
placeholder={{placeholder}}
{{action (action 'open') on='focusIn'}} />
<div class="filtered-select__popover
{{if isOpen 'filtered-select__popover--is-active'}}
{{if dropdownMode 'filtered-select--dropdown__popover'}}">
{{#each possibleOptions as |option index|}}
<div class="filtered-select__option
{{if (or (eq option proposedOption) (eq option selectedOption)) 'filtered-select__option--is-highlighted'}}"
{{action 'optionClicked' option on='click' stopPropagation=true}}
{{action (action 'highlightOption') option on='mouseEnter'}}>
{{#if option.additive}}
Create new "{{option.label}}"
{{else}}
{{option.label}}
{{/if}}
</div>
{{else}}
<div class="filtered-select__null-state">
No results
</div>
{{/each}}
</div>
* {
box-sizing: border-box;
}
body {
font-family: helvetica;
}
.filtered-select-container {
position: relative;
}
.filtered-select__input,
.filtered-select__prompt {
position: relative;
min-height: 47px;
/*width: 100%;*/
padding: 13px 24px;
border: 1px solid #333;
border-radius: 0;
background: transparent;
font-size: 14px;
}
.filtered-select__input:focus {
outline: 0
}
.filtered-select__prompt {
display: none;
position: absolute;
top: 0;
cursor: pointer;
color: #333;
line-height: 1.5;
}
.filtered-select--has-prompt > .filtered-select__prompt {
display: block;
}
.filtered-select--is-active .form-field__caret,
.filtered-select--is-active .filtered-select__prompt {
display: none;
}
.filtered-select__input {
z-index: 5;
}
.filtered-select--dropdown > .filtered-select__input {
cursor: pointer;
}
.filtered-select--has-prompt > .filtered-select__input {
opacity: 0;
}
.filtered-select--is-active > .filtered-select__input {
cursor: inherit;
}
.filtered-select--dropdown__input {
/*border-color: transparent;*/
color: #333;
}
.filtered-select--is-active > .filtered-select--dropdown__input {
position: absolute;
/*width: 345px;*/
border: 1px solid lightblue;
border-width: 1px 1px 0 1px;
color: gray;
cursor: inherit;
min-height: 48px;
margin-top: -1px;
opacity: 1;
transition: border-color 0.2s ease-in;
}
.filtered-select--is-fluid-width > .filtered-select--dropdown__input {
/*width: 100%;*/
}
.filtered-select__popover {
position: absolute;
z-index: 600;
max-height: 376px;
/*width: 345px;*/
margin-top: -5px;
margin-left: 5px;
border: 1px solid gray;
border-width: 0 1px;
background-color: white;
opacity: 0;
overflow: auto;
visibility: hidden;
}
.filtered-select--is-fluid-width > .filtered-select__popover {
width: 100%;
}
.filtered-select--dropdown__popover {
top: 48px;
margin: 0;
border: 1px solid lightblue;
border-width: 0 1px 1px 1px;
}
.filtered-select__popover--is-active {
opacity: 1;
visibility: visible;
transition: visibility 0s linear, opacity 0.2s ease-in;
}
.filtered-select__null-state,
.filtered-select__option {
min-height: 47px;
padding: 13px 24px;
line-height: 1.5;
}
.filtered-select__null-state:hover,
.filtered-select__option:hover {
cursor: pointer;
}
.filtered-select__option {
color: #ddd;
}
.filtered-select__option:first-of-type {
border-top: 1px solid gray;
}
.filtered-select__option:last-of-type {
border-bottom: 1px solid gray;
}
.filtered-select--dropdown__popover > .filtered-select__option {
color: gray;
border: 0;
}
.filtered-select__option--is-highlighted {
background-color: lightblue;
}
<div>{{selectedState.name}}</div>
{{filter-manager options=states.all
value=selectedState
allowNew=true
labelPath='name'
action="change"}}
<!--<select>
<option>A</option>
<option>B</option>
<option>C</option>
<option>D</option>
<option>E</option>
<option>F</option>
<option>G</option>
</select>-->
<div tabindex="0"></div>
{{sq-filtered-select possibleOptions=filteredOptions
selectedOption=selectedOption
proposedOption=proposedOption
label=label
input=(action 'filterChanged')
propose=(action 'optionProposed')
select=(action 'optionSelected')
cancel=(action 'filterCanceled')}}
{
"version": "0.10.1",
"EmberENV": {
"FEATURES": {}
},
"options": {
"use_pods": false,
"enable-testing": false
},
"dependencies": {
"jquery": "https://cdnjs.cloudflare.com/ajax/libs/jquery/1.11.3/jquery.js",
"ember": "1.13.13",
"ember-data": "1.13.15",
"ember-template-compiler": "1.13.13"
},
"addons": {}
}
import Ember from 'ember';
export default function truthConvert(result) {
var truthy = result && Ember.get(result, 'isTruthy');
if (typeof truthy === 'boolean') { return truthy; }
if (Ember.isArray(result)) {
return Ember.get(result, 'length') !== 0;
} else {
return !!result;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment