diff --git a/.ember-cli b/.ember-cli index ee64cfe..96bd287 100644 --- a/.ember-cli +++ b/.ember-cli @@ -5,5 +5,7 @@ Setting `disableAnalytics` to true will prevent any data from being sent. */ - "disableAnalytics": false + "disableAnalytics": false, + "liveReload": true, + "watcher": "polling" } diff --git a/addon/.gitkeep b/addon/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/addon/components/auto-complete-option.js b/addon/components/auto-complete-option.js new file mode 100644 index 0000000..54123ec --- /dev/null +++ b/addon/components/auto-complete-option.js @@ -0,0 +1,69 @@ +import Ember from 'ember'; +import layout from '../templates/components/auto-complete-option'; + +const computed = Ember.computed; +const alias = computed.alias; + +export default Ember.Component.extend({ + layout: layout, + + template: alias('parentView.template'), + + tagName: 'li', + + autoComplete: alias('parentView'), + + attributeBindings: ['role', 'selected', 'tabindex'], + + classNames: ['auto-complete__option'], + + role: 'option', + + selectOption: 'selectOption', + + tabindex: -1, + + /** + * The resolved label of the option. If the option is an object then the label + * is provided by the property at `optionLabelPath`. Otherwise the label is + * the option itself. Must resolve to a string. + * + * @property _label + * @private + */ + label: alias('content._label'), + + /** + * The resolved value of the option. If the option is an object then the value + * is provided by the property at `optionLabelPath`. Otherwise the value is + * the option itself. + * + * @property _label + * @private + */ + value: alias('content._value'), + + /** + * Label formatted to highlight the matched parts of the option. + * + * @property _label + * @private + */ + formattedLabel: alias('content._formattedLabel'), + + /** + * Whether or not the option is selected. + * + * @property _label + * @private + */ + selected: alias('content._selected'), + + click() { + this.sendAction('selectOption', this.get('value'), this.get('label')); + }, + + mouseEnter() { + this.$().focus(); + } +}); diff --git a/addon/components/auto-complete.js b/addon/components/auto-complete.js new file mode 100644 index 0000000..8244234 --- /dev/null +++ b/addon/components/auto-complete.js @@ -0,0 +1,315 @@ +import Ember from 'ember'; +import layout from '../templates/components/auto-complete'; +import AutoCompleteOptionProxy from '../proxies/auto-complete-option'; + +const observer = Ember.observer; +const computed = Ember.computed; +const map = computed.map; + +/** + @module AutoComplete + + AutoComplete as a component is similar to a Select. The user types some input, + suggestions appear in a dropdown list and can be clicked, and the value of the + selected option is selected. +*/ +export default Ember.Component.extend({ + layout: layout, + + classNames: ['auto-complete'], + + /** + * Two-way bound property representing the current value of the search input. + * + * @property value + * @public + */ + value: '', + + /** + * Two-way bound property representing the current value of the selection. + * + * @property selection + * @public + */ + selection: null, + + /** + * Two-way bound property representing the path to an option's value. This + * value is what will be mapped to `selection` when an object is selected. + * + * @property optionValuePath + * @public + */ + optionValuePath: '', + + /** + * Two-way bound property representing the path to an option's label. This + * value is what will be mapped to `value` when an option is selected. + * + * @property optionLabelPath + * @public + */ + optionLabelPath: '', + + /** + * Determines whether or not the content of this AutoComplete is provided + * asynchronously. If it is, it is assumed that the user is filtering the list + * on their own and no filtering is done by the component. + * + * @property async + * @private + */ + async: false, + + /** + * Determines whether or not the matching segment of option labels will be + * highlighted. + * + * @property highlightMatches + * @public + */ + highlightMatches: true, + + /** + * Determines whether or not the matching segment of option labels will be + * highlighted. + * + * @property highlightMatches + * @public + */ + isOpen: false, + + /** + * Internal representation of the option list for the AutoComplete. Wraps + * each option in an AutoCompleteOptionProxy object which has various + * computed properties for determining an objects value, label, etc. + * + * @property options + * @private + */ + options: map('content', function(option) { + // Wrap standard JS objects in Ember objects + if (Ember.typeOf(option) === 'object' ) { + option = Ember.Object.create(option); + } + + return AutoCompleteOptionProxy.create({ + _autoComplete: this, + content: option + }); + }), + + /** + * A regular expression of the current value of the input, used in a few + * locations (null if no value is blank) + * + * @property regexValue + * @private + */ + regexValue: computed('value', function() { + const value = this.get('value'); + + if (value) { + return new RegExp(value, 'g'); + } + }), + + /** + * The filtered list of options. Filtered by regex comparison of value to + * label if non-async, otherwise not filtered. + * + * @property filteredOptions + * @private + */ + filteredOptions: computed('options.@each', 'regexValue', function() { + if (this.get('async')) { return this.get('options'); } + + const regexValue = this.get('regexValue'); + + return Ember.A(this.get('options').filter(function(option) { + return option.get('_label').match(regexValue); + }) + ); + }), + + + + /** + * Sets the selection property when one of the options is selected. This can + * happen either when the user clicks on an option, or when they type the full + * label into the input. + * + * @property setSelection + * @private + */ + setSelection: observer('options.@each._selected', function() { + const selection = this.get('options').findBy('_selected'); + + if (selection) { + this.set('selection', selection.get('_value')); + } else { + this.set('selection', null); + } + }), + + focusIn() { + this.open(); + }, + + focusOut() { + this.close(); + }, + + closedKeydownMap: { + 13/*enter*/: 'open', + 40/*down*/: 'open', + }, + + openKeydownMap: { + 27/*esc*/: 'closeAndFocus', + 32/*space*/: 'selectFocusedOption', + 13/*enter*/: 'selectFocusedOption', + 40/*down*/: 'focusNext', + 38/*up*/: 'focusPrevious', + 8/*backspace*/: 'startBackspacing' + }, + + /** + * Handles keyboard interactions from all elements in the component. + * + * @method handleKeydown + * @private + */ + + keyDown(event) { + const map = this.get('isOpen') ? this.get('openKeydownMap') : this.get('closedKeydownMap'); + const method = map[event.keyCode]; + + if (this[method]) { + return this[method](event); + } + + const input = this.$('input')[0]; + // After this we focus the input, but if they are using shift, we don't + // want to actually do it (they are probably shift+tabbing away). This is a + // blacklist of one, which makes me really nervous. We want to allow any + // valid input character, but that's a huge whitelist, or maybe use the + // run loop and wait for focus to settle on the new element and then decide + // what to do. + if (event.shiftKey) { + return; + } + if (document.activeElement !== input) { + input.focus(); + // if its not backspace, then we want to select the input, since its + // keyDown, then on keyUp the contents will be replaced, but with + // backspace, we dont' want to do that. + if (event.keyCode !== 8/*backspace*/) { + input.select(); + } + } + }, + + focusNext(event) { + event.preventDefault(); + + const input = this.$('input'); + const focusedOption = this.$('.auto-complete__option:focus').first(); + const firstOption = this.$('.auto-complete__option:first').first(); + const lastOption = this.$('.auto-complete__option:last').first(); + + if (focusedOption[0] === lastOption[0]) { + input[0].focus(); + } else if (focusedOption.length) { + focusedOption.next()[0].focus(); + } else { + firstOption.focus(); + } + }, + + /** + * Focuses the previous option in the popover. + * + * @method focusPrevious + * @private + */ + + focusPrevious(event) { + event.preventDefault(); + + const input = this.$('input'); + const focusedOption = this.$('.auto-complete__option:focus').first(); + const firstOption = this.$('.auto-complete__option:first').first(); + const lastOption = this.$('.auto-complete__option:last').first(); + + if (focusedOption[0] === firstOption[0]) { + input.focus(); + } else if (focusedOption.length) { + focusedOption.prev().focus(); + } else { + lastOption.focus(); + } + }, + + /** + * Focuses an option given an index in the options cache. + * + * @method focusOptionAtIndex + * @private + */ + + selectFocusedOption(event) { + const focusedOption = this.$('.auto-complete__option:focus'); + + if (focusedOption.length) { + event.preventDefault(); + focusedOption.click(); + } + }, + + /** + * Sets the option as the `focusedOption` + * + * @method focusOption + * @private + */ + + open() { + this.set('isOpen', true); + }, + + close() { + this.set('isOpen', false); + }, + + closeAndFocus() { + this.$('input').focus(); + this.close(); + }, + + click(event) { + if (this.$(event.target).is('input')) { + this.open(); + } + }, + + actions: { + /** + * Selects a clicked option, sets the component's value and selection to the + * option's label and value, respectively. + * + * @property selectOption + * @private + */ + selectOption(value, label) { + this.set('value', label); + + this.closeAndFocus(); + + if (this.get('onSelect')) { + this.sendAction('onSelect', value); + } + } + } +}); diff --git a/addon/proxies/auto-complete-option.js b/addon/proxies/auto-complete-option.js new file mode 100644 index 0000000..e3b23d8 --- /dev/null +++ b/addon/proxies/auto-complete-option.js @@ -0,0 +1,93 @@ +import Ember from 'ember'; + +const computed = Ember.computed; + +/** + @module AutoCompleteOptionProxy + + Provides a simple object proxy with some computed properties for use in the + AutoComplete component. The AutoComplete component requires access to it's + options in order to set its selection and filter out suggestions - Using + another component here would be better semantically would require some fugly + hacking to register the components together. + + Possibly consider using CollectionView? + + Notes: + - Computed properties are underscored so that they will not conflict with + potential objects being passed (`label` and `value` are fairly common + property names, the others are underscored for consistency) + - `_autoComplete` is a reference to the parent AutoComplete component. This + would typically be done with `parentView` if this were a component. + Aliasing could be used to clean up the code here, but some of the items + are arguably sole properties of the AutoComplete component (`_selected`) + whereas others have some right to be here (`optionLabelPath`, + `optionValuePath`). +*/ +export default Ember.ObjectProxy.extend({ + /** + * The resolved label of the option. If the option is an object then the label + * is provided by the property at `optionLabelPath`. Otherwise the label is + * the option itself. Must resolve to a string. + * + * @property _label + * @private + */ + _label: computed('content', '_autoComplete.optionLabelPath', function() { + const content = this.get('content'); + const optionLabelPath = this.get('_autoComplete.optionLabelPath'); + + if (optionLabelPath && content && typeof content === 'object') { + return content.get(optionLabelPath); + } else { + return content; + } + }), + + /** + * The resolved value of the option. If the option is an object then the value + * is provided by the property at `optionLabelPath`. Otherwise the value is + * the option itself. + * + * @property _label + * @private + */ + _value: computed('content', '_autoComplete.optionValuePath', function() { + const content = this.get('content'); + const optionValuePath = this.get('_autoComplete.optionValuePath'); + + if (optionValuePath && content && typeof content === 'object') { + return content.get(optionValuePath); + } else { + return content; + } + }), + + /** + * Label formatted to highlight the matched parts of the option. + * + * @property _label + * @private + */ + _formattedLabel: computed('label', '_autoComplete.regexValue', function() { + const label = this.get('_label'); + const highlightMatches = this.get('_autoComplete.highlightMatches'); + const regexValue = this.get('_autoComplete.regexValue'); + + if (regexValue && highlightMatches) { + return label.replace(regexValue, "$&"); + } + + return label; + }), + + /** + * Whether or not the option is selected. + * + * @property _label + * @private + */ + _selected: computed('_label', '_autoComplete.value', function() { + return this.get('_autoComplete.value') === this.get('_label'); + }) +}); diff --git a/addon/templates/components/auto-complete-option.hbs b/addon/templates/components/auto-complete-option.hbs new file mode 100644 index 0000000..5581501 --- /dev/null +++ b/addon/templates/components/auto-complete-option.hbs @@ -0,0 +1,5 @@ +{{#if template}} + {{yield content}} +{{else}} + {{{formattedLabel}}} +{{/if}} diff --git a/addon/templates/components/auto-complete.hbs b/addon/templates/components/auto-complete.hbs new file mode 100644 index 0000000..358c330 --- /dev/null +++ b/addon/templates/components/auto-complete.hbs @@ -0,0 +1,11 @@ +{{input value=value placeholder=placeholder}} + +
diff --git a/app/.gitkeep b/app/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/components/auto-complete-option.js b/app/components/auto-complete-option.js new file mode 100644 index 0000000..d41fd73 --- /dev/null +++ b/app/components/auto-complete-option.js @@ -0,0 +1,3 @@ +import autoCompleteOption from 'auto-complete/components/auto-complete-option'; + +export default autoCompleteOption; diff --git a/app/components/auto-complete.js b/app/components/auto-complete.js new file mode 100644 index 0000000..40f9976 --- /dev/null +++ b/app/components/auto-complete.js @@ -0,0 +1,3 @@ +import autoComplete from 'auto-complete/components/auto-complete'; + +export default autoComplete; diff --git a/package.json b/package.json index 10e9778..cf7a7f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "auto-complete", - "version": "0.0.0", + "name": "ember-auto-complete", + "version": "0.1.0", "description": "The default blueprint for ember-cli addons.", "directories": { "doc": "doc", @@ -11,7 +11,7 @@ "build": "ember build", "test": "ember try:testall" }, - "repository": "", + "repository": "https://github.com/Ticketfly/ember-auto-complete", "engines": { "node": ">= 0.10.0" }, @@ -23,7 +23,6 @@ "ember-cli-app-version": "0.3.3", "ember-cli-content-security-policy": "0.4.0", "ember-cli-dependency-checker": "0.0.8", - "ember-cli-htmlbars": "0.7.4", "ember-cli-ic-ajax": "0.1.1", "ember-cli-inject-live-reload": "^1.3.0", "ember-cli-qunit": "0.3.10", @@ -37,9 +36,10 @@ "ember-addon" ], "dependencies": { - "ember-cli-babel": "^5.0.0" + "ember-cli-babel": "^5.0.0", + "ember-cli-htmlbars": "0.7.4" }, "ember-addon": { "configPath": "tests/dummy/config" } -} \ No newline at end of file +} diff --git a/tests/dummy/app/controllers/application.js b/tests/dummy/app/controllers/application.js new file mode 100644 index 0000000..1c4990c --- /dev/null +++ b/tests/dummy/app/controllers/application.js @@ -0,0 +1,48 @@ +import Ember from 'ember'; + +const computed = Ember.computed; + +export default Ember.Controller.extend({ + testValue: '', + + testOptions: Ember.A([ + { + value: 'Lennon', + label: 'John Lennon', + }, + { + value: 'McCartney', + label: 'Paul McCartney', + }, + { + value: 'Starr', + label: 'Ringo Starr', + }, + { + value: 'Harrison', + label: 'George Harrison', + } + ]), + + testPromise: computed('testValue', function() { + const testValue = this.get('testValue'); + + return Ember.A(this.get('testOptions').filter(function(option) { + return option.label.match(new RegExp(testValue, 'g')); + }) + ); + }), + + testStrings: Ember.A([ + 'John Lennon', + 'Paul McCartney', + 'Ringo Starr', + 'George Harrison' + ]), + + actions: { + testSelectHandler: function(option) { + console.log(`${option} selected`); + } + } +}); diff --git a/tests/dummy/app/styles/app.css b/tests/dummy/app/styles/app.css index e69de29..aaa3d95 100644 --- a/tests/dummy/app/styles/app.css +++ b/tests/dummy/app/styles/app.css @@ -0,0 +1,3 @@ +.hide { + display: none; +} diff --git a/tests/dummy/app/templates/application.hbs b/tests/dummy/app/templates/application.hbs index 05eb936..f71e5c2 100644 --- a/tests/dummy/app/templates/application.hbs +++ b/tests/dummy/app/templates/application.hbs @@ -1,3 +1,20 @@