diff --git a/addon/components/paper-autocomplete-highlight.js b/addon/components/paper-autocomplete-highlight.js new file mode 100644 index 000000000..eb595e490 --- /dev/null +++ b/addon/components/paper-autocomplete-highlight.js @@ -0,0 +1,35 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + tagName: 'span', + flags: '', + + highlight: Ember.computed('searchText', 'label', 'flags', function () { + var unsafeText = Ember.Handlebars.Utils.escapeExpression(this.get('label')), + text = unsafeText, + flags = this.get('flags'), + regex = this.getRegExp(this.get('searchText'), flags), + html = text.replace(regex, '$&'); + return new Ember.Handlebars.SafeString(html); + }), + + sanitize (term) { + if (!term) { + return term; + } + return term.replace(/[\\\^\$\*\+\?\.\(\)\|\{}\[\]]/g, '\\$&'); + }, + + getRegExp (text, flags) { + var str = ''; + if (flags.indexOf('^') >= 1) { + str += '^'; + } + str += text; + if (flags.indexOf('$') >= 1) { + str += '$'; + } + return new RegExp(this.sanitize(str), flags.replace(/[\$\^]/g, '')); + } + +}); diff --git a/addon/components/paper-autocomplete-item.js b/addon/components/paper-autocomplete-item.js new file mode 100644 index 000000000..d698d9cc6 --- /dev/null +++ b/addon/components/paper-autocomplete-item.js @@ -0,0 +1,34 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + tagName: 'li', + attributeBindings: ['tabindex', 'role'], + classNameBindings: ['isSelected:selected'], + tabindex: 0, + role: 'option', + + + label: Ember.computed('item',function () { + return this.lookupLabelOfItem(this.get('item')); + }), + + isSelected: Ember.computed('selectedIndex', function () { + return this.get('selectedIndex') === this.get('index'); + }), + + lookupLabelOfItem (model) { + var value; + if (this.get('lookupKey')) { + value = model[this.get('lookupKey')]; + } else { + value = model; + } + return value; + }, + + + + click () { + this.sendAction('pick', this.get('item')); + } +}); diff --git a/addon/components/paper-autocomplete-list.js b/addon/components/paper-autocomplete-list.js new file mode 100644 index 000000000..db43318e2 --- /dev/null +++ b/addon/components/paper-autocomplete-list.js @@ -0,0 +1,124 @@ +import Ember from 'ember'; + +var ITEM_HEIGHT = 41, + MAX_HEIGHT = 5.5 * ITEM_HEIGHT, + MENU_PADDING = 8; + +export default Ember.Component.extend({ + util: Ember.inject.service(), + + tagName: 'ul', + classNames: ['md-default-theme', 'md-autocomplete-suggestions', 'md-whiteframe-z1'], + attributeNameBindings: ['role'], + role: 'presentation', + stickToElement: null, + + hidden: true, + + + isVisible: Ember.computed.not('hidden'), + + mouseEnter () { + this.sendAction('mouse-enter'); + }, + mouseLeave () { + this.sendAction('mouse-leave'); + }, + mouseUp () { + this.sendAction('mouse-up'); + }, + + + hideSuggestionObserver: Ember.observer('hidden', function () { + if (this.get('hidden') === true) { + this.get('util').enableScrolling(); + } else { + this.get('util').disableScrollAround(this.$()); + this.positionDropdown(); + } + }), + + + positionDropdown () { + var hrect = Ember.$('#' + this.get('wrapToElementId'))[0].getBoundingClientRect(), + vrect = hrect, + root = document.body.getBoundingClientRect(), + top = vrect.bottom - root.top, + bot = root.bottom - vrect.top, + left = hrect.left - root.left, + width = hrect.width, + styles = { + left: left + 'px', + minWidth: width + 'px', + maxWidth: Math.max(hrect.right - root.left, root.right - hrect.left) - MENU_PADDING + 'px' + }, + ul = this.$(); + + if (top > bot && root.height - hrect.bottom - MENU_PADDING < MAX_HEIGHT) { + styles.top = 'auto'; + styles.bottom = bot + 'px'; + styles.maxHeight = Math.min(MAX_HEIGHT, hrect.top - root.top - MENU_PADDING) + 'px'; + } else { + styles.top = top + 'px'; + styles.bottom = 'auto'; + styles.maxHeight = Math.min(MAX_HEIGHT, root.bottom - hrect.bottom - MENU_PADDING) + 'px'; + } + ul.css(styles); + correctHorizontalAlignment(); + + /** + * Makes sure that the menu doesn't go off of the screen on either side. + */ + function correctHorizontalAlignment () { + var dropdown = ul[0].getBoundingClientRect(), + styles = {}; + if (dropdown.right > root.right - MENU_PADDING) { + styles.left = (hrect.right - dropdown.width) + 'px'; + } + ul.css(styles); + } + }, + + + observeIndex: Ember.observer('selectedIndex', function () { + var suggestions = this.get('suggestions'); + if (!suggestions[this.get('selectedIndex')]) { + return; + } + + var ul = this.$(), + li = ul.find('li:eq('+this.get('selectedIndex')+')')[0], + top = li.offsetTop, + bot = top + li.offsetHeight, + hgt = ul[0].clientHeight; + if (top < ul[0].scrollTop) { + ul[0].scrollTop = top; + } else if (bot > ul[0].scrollTop + hgt) { + ul[0].scrollTop = bot - hgt; + } + }), + + resizeWindowEvent () { + this.positionDropdown(); + }, + + + didInsertElement () { + var _self = this; + var ul = this.$().detach(); + Ember.$('body').append(ul); + + this.set('___resizeFunction', function () { + _self.positionDropdown(); + }); + Ember.$(window).on('resize', this.get('___resizeFunction')); + }, + willDestroyElement () { + Ember.$(window).off('resize',this.get('___resizeFunction')); + this.get('util').enableScrolling(); + } + + + + +}); diff --git a/addon/components/paper-autocomplete.js b/addon/components/paper-autocomplete.js new file mode 100644 index 000000000..837562d16 --- /dev/null +++ b/addon/components/paper-autocomplete.js @@ -0,0 +1,313 @@ +import Ember from 'ember'; +import HasBlockMixin from '../mixins/hasblock-mixin'; + +function isString (item) { + return typeof item === 'string' || item instanceof String; +} + +/** + * @name paper-autocomplete + * + * @description + * Provides material design autocomplete. + * + * + * ## Dependencies + * - paper-autocomplete-item + * - paper-autocomplete-list + * - paper-input + * - paper-button + * - input + */ +export default Ember.Component.extend(HasBlockMixin, { + util: Ember.inject.service(), + constants: Ember.inject.service(), + + tagName: 'md-autocomplete', + classNameBindings: ['notFloating:md-default-theme'], + attributeBindings: ['floating:md-floating-label', 'showDisabled:disabled'], + + + // Internal + suggestions: Ember.A([]), + loading: false, + hidden: true, + selectedIndex: null, + messages: [], + noBlur: false, + hasFocus: false, + + // Public + disabled: null, + required: null, + lookupKey: null, + placeholder: '', + delay: 0, + minLength: 1, + allowNonExisting: false, + noCache: false, + notFoundMessage: 'No matches found for "%@".', + + init:function(){ + this._super(...arguments); + this.set('itemCache', {}); + if (this.get('model')) { + this.set('searchText', this.lookupLabelOfItem(this.get('model'))); + } else if (typeof this.get('searchText') === 'undefined') { + this.set('searchText', ''); + } + }, + + + notFloating: Ember.computed.not('floating'), + notHidden: Ember.computed.not('hidden'), + + + + notFoundMsg: Ember.computed('searchText', 'notFoundMessage', function () { + return Ember.String.fmt(this.get('notFoundMessage'), [this.get('searchText')]); + }), + + /** + * Needed because of false = disabled="false". + */ + showDisabled: Ember.computed('disabled', function () { + if (this.get('disabled')) { + return true; + } + }), + + showLoadingBar: Ember.computed('loading', 'allowNonExisting', 'debouncingState', function () { + return !this.get('loading') && !this.get('allowNonExisting') && !this.get('debouncingState'); + }), + + enableClearButton: Ember.computed('searchText', 'disabled', function () { + return this.get('searchText') && !this.get('disabled'); + }), + + wrapperClasses: Ember.computed('notFloating', 'notHidden', function () { + var classes = ''; + if (this.get('notFloating')) { + classes += ' md-whiteframe-z1'; + } + if (this.get('notHidden')) { + classes += ' md-menu-showing'; + } + return classes; + }), + + + observeSearchText: Ember.observer('searchText', function () { + if (this.get('searchText') === this.get('previousSearchText')) { + return; + } + if (!this.get('allowNonExisting')) { + this.set('model', null); + } else { + this.set('model', this.get('searchText')); + } + + var wait = parseInt(this.get('delay'), 10) || 0; + this.set('debouncingState', true); + Ember.run.debounce(this, this.handleSearchText, wait); + this.set('previousSearchText', this.get('searchText')); + }), + + + + shouldHide () { + if (!this.isMinLengthMet()) { + return true; + } + return false; + }, + + isMinLengthMet () { + return this.get('searchText').length >= this.get('minLength'); + }, + + + + handleSearchText () { + this.set('selectedIndex', this.get('defaultIndex')); + this.set('debouncingState', false); + if (!this.isMinLengthMet()) { + this.send('clear'); + } else { + this.handleQuery(); + } + }, + + handleQuery () { + var suggestions, + _self = this, + source = this.get('source'), + lookupKey = this.get('lookupKey'), + text = this.get('searchText').toLowerCase(), + cached = this.itemsFromCache(text); + if (cached) { + suggestions = cached; + this.set('selectedIndex', _self.get('defaultIndex')); + this.set('suggestions', suggestions); + this.set('hidden', this.shouldHide()); + } else if (typeof source !== 'function') { + if (text) { + suggestions = source.filter(function (item) { + var search; + if (isString(item)) { + search = item; + } else { + if (lookupKey === null) { + console.error("You have not defined 'lookupKey' on paper-autocomplete, when source contained " + + "items that are not of type String. To fix this error provide a " + + "lookupKey='key to lookup from source item'."); + } + search = item[lookupKey]; + } + search = search.toLowerCase(); + return search.indexOf(text) === 0; + }); + } else { + suggestions = source; + } + this.set('selectedIndex', _self.get('defaultIndex')); + this.set('suggestions', suggestions); + this.set('hidden', this.shouldHide()); + } else { + this.set('loading', true); + + var promise = source.call(this, text); + promise.then(function (items) { + _self.get('itemCache')[text] = items; + if (_self.get('lastPromise') === promise) { + suggestions = items; + _self.set('suggestions', suggestions); + _self.set('hidden', _self.shouldHide()); + _self.set('selectedIndex', _self.get('defaultIndex')); // Reset index of list position. + _self.set('loading', false); + } + }); + this.set('lastPromise', promise); + } + }, + + itemsFromCache (text) { + if (this.get('noCache') === true) { + return; + } + if (this.get('itemCache')[text]) { + return this.get('itemCache')[text]; + } + return null; + }, + + + + lookupLabelOfItem (model) { + var value; + if (this.get('lookupKey')) { + value = model[this.get('lookupKey')]; + } else { + value = model; + } + return value; + }, + + autocompleteWrapperId: Ember.computed('elementId', function () { + return 'autocomplete-wrapper-' + this.get('elementId'); + }), + + actions: { + clear: function () { + this.set('searchText', ''); + this.set('selectedIndex', -1); + this.set('loading', false); + this.set('model', null); + this.set('hidden', true); + }, + + pickModel: function (model) { + this.set('model', model); + var value = this.lookupLabelOfItem(model); + // First set previousSearchText then searchText ( do not trigger observer only update value! ). + this.set('previousSearchText', value); + this.set('searchText', value); + this.set('hidden', true); + }, + + inputFocusOut () { + this.set('hasFocus', false); + if (this.get('noBlur') === false) { + this.set('hidden', true); + } + }, + + inputFocusIn () { + this.set('hasFocus', true); + this.set('hidden', this.shouldHide()); + if (!this.get('hidden')) { + this.handleSearchText(); + } + }, + + inputKeyDown (value, event) { + switch (event.keyCode) { + case this.get('constants').KEYCODE.DOWN_ARROW: + if (this.get('loading')) { + return; + } + event.stopPropagation(); + event.preventDefault(); + this.set('selectedIndex', Math.min(this.get('selectedIndex') + 1, this.get('suggestions').length - 1)); + break; + case this.get('constants').KEYCODE.UP_ARROW: + if (this.get('loading')) { + return; + } + event.stopPropagation(); + event.preventDefault(); + this.set('selectedIndex', this.get('selectedIndex') < 0 ? this.get('suggestions').length - 1 : Math.max(0, this.get('selectedIndex') - 1)); + break; + case this.get('constants').KEYCODE.TAB: + case this.get('constants').KEYCODE.ENTER: + if (this.get('hidden') || this.get('loading') || this.get('selectedIndex') < 0 || this.get('suggestions').length < 1) { + return; + } + event.stopPropagation(); + event.preventDefault(); + this.send('pickModel', this.get('suggestions')[this.get('selectedIndex')]); + break; + case this.get('constants').KEYCODE.ESCAPE: + event.stopPropagation(); + event.preventDefault(); + this.set('suggestions', Ember.A([])); + this.set('hidden', true); + this.set('selectedIndex', this.get('defaultIndex')); + break; + default: + break; + } + }, + listMouseEnter () { + this.set('noBlur', true); + }, + listMouseLeave () { + this.set('noBlur', false); + if (this.get('hasFocus') === false) { + this.set('hidden', true); + } + }, + listMouseUp () { + this.$().find('input').focus(); + } + }, + + /** + * Returns the default index based on whether or not autoselect is enabled. + * @returns {number} + */ + defaultIndex: Ember.computed('autoselect', function () { + return this.get('autoselect') ? 0 : -1; + }) + +}); diff --git a/addon/components/paper-button.js b/addon/components/paper-button.js index 92e558b40..b07b8fadd 100644 --- a/addon/components/paper-button.js +++ b/addon/components/paper-button.js @@ -7,8 +7,8 @@ import ColorMixin from 'ember-paper/mixins/color-mixin'; export default BaseFocusable.extend(RippleMixin, ProxiableMixin, ColorMixin, { attributeBindings: ['target', 'action'], tagName: 'button', - classNames: ['md-button','md-default-theme'], - classNameBindings: ['raised:md-raised', 'icon-button:md-icon-button'], + themed: true, + classNameBindings: ['raised:md-raised', 'icon-button:md-icon-button', 'themed:md-default-theme', 'themed:md-button'], /* RippleMixin overrides */ isIconButton: Ember.computed(function() { diff --git a/addon/components/paper-input.js b/addon/components/paper-input.js index 8ab3280f5..3c56e10c4 100644 --- a/addon/components/paper-input.js +++ b/addon/components/paper-input.js @@ -1,8 +1,9 @@ import Ember from 'ember'; import BaseFocusable from './base-focusable'; import ColorMixin from 'ember-paper/mixins/color-mixin'; +import FlexMixin from 'ember-paper/mixins/flex-mixin'; -export default BaseFocusable.extend(ColorMixin, { +export default BaseFocusable.extend(ColorMixin, FlexMixin, { tagName: 'md-input-container', classNames: ['md-default-theme'], classNameBindings: ['hasValue:md-input-has-value', 'focus:md-input-focused', 'isInvalid:md-input-invalid'], @@ -63,11 +64,18 @@ export default BaseFocusable.extend(ColorMixin, { }, actions: { - focusIn() { + focusIn(value) { + // We resend action so other components can take use of the actions also ( if they want ). + // Actions must be sent before focusing. + this.sendAction('focus-in', value); this.set('focus',true); }, - focusOut() { + focusOut(value) { + this.sendAction('focus-out', value); this.set('focus',false); + }, + keyDown(value, event) { + this.sendAction('key-down', value, event); } } }); diff --git a/app/components/paper-autocomplete-highlight.js b/app/components/paper-autocomplete-highlight.js new file mode 100644 index 000000000..0de8d8183 --- /dev/null +++ b/app/components/paper-autocomplete-highlight.js @@ -0,0 +1 @@ +export { default } from 'ember-paper/components/paper-autocomplete-highlight'; \ No newline at end of file diff --git a/app/components/paper-autocomplete-item.js b/app/components/paper-autocomplete-item.js new file mode 100644 index 000000000..7641a4be4 --- /dev/null +++ b/app/components/paper-autocomplete-item.js @@ -0,0 +1 @@ +export { default } from 'ember-paper/components/paper-autocomplete-item'; diff --git a/app/components/paper-autocomplete-list.js b/app/components/paper-autocomplete-list.js new file mode 100644 index 000000000..5ff0cfea6 --- /dev/null +++ b/app/components/paper-autocomplete-list.js @@ -0,0 +1 @@ +export { default } from 'ember-paper/components/paper-autocomplete-list'; \ No newline at end of file diff --git a/app/components/paper-autocomplete.js b/app/components/paper-autocomplete.js new file mode 100644 index 000000000..b88975292 --- /dev/null +++ b/app/components/paper-autocomplete.js @@ -0,0 +1 @@ +export { default } from 'ember-paper/components/paper-autocomplete'; \ No newline at end of file diff --git a/app/services/util.js b/app/services/util.js new file mode 100644 index 000000000..4206687fb --- /dev/null +++ b/app/services/util.js @@ -0,0 +1,108 @@ +import Ember from 'ember'; + +/* global jQuery */ + +var Util = Ember.Service.extend({ + + // Disables scroll around the passed element. + disableScrollAround: function (element) { + var $mdUtil = this, + $document = jQuery(window.document); + + $mdUtil.disableScrollAround._count = $mdUtil.disableScrollAround._count || 0; + ++$mdUtil.disableScrollAround._count; + if ($mdUtil.disableScrollAround._enableScrolling) return $mdUtil.disableScrollAround._enableScrolling; + var body = $document[0].body, + restoreBody = disableBodyScroll(), + restoreElement = disableElementScroll(); + + return $mdUtil.disableScrollAround._enableScrolling = function () { + if (!--$mdUtil.disableScrollAround._count) { + restoreBody(); + restoreElement(); + delete $mdUtil.disableScrollAround._enableScrolling; + } + }; + + // Creates a virtual scrolling mask to absorb touchmove, keyboard, scrollbar clicking, and wheel events + function disableElementScroll() { + var zIndex = 50; + var scrollMask = jQuery( + '
{{message}}
+ {{/if}} + {{/each}} +Use \{{paper-autocomplete}}
to search for matches from local or remote data sources.
+
+ {{paper-autocomplete
+ disabled=firstDisabled
+ placeholder="Select a Country ..."
+ notFoundMessage='Oops country: "%@" doesn\'t exist here.'
+ source=items lookupKey="name"
+ model=myModel}}
+ Selected country is + {{#if myModel}} + {{myModel.name}} + ({{myModel.code}}) + {{else}} + Nothing selected... + {{/if}} +
+ {{#paper-checkbox checked=firstDisabled}}Disable input{{/paper-checkbox}} + + + +Use attribute allowNonExisting=true
for the autocomplete to allow setting model to non existing items in the autocomplete. This is useful for search boxes.
+
+ {{paper-autocomplete minLength=0 allowNonExisting=true placeholder="Type e.g. ember, paper, one, two etc." source=arrayOfItems model=sixthModel}}
+ Selected thing was: + {{#if sixthModel}} + {{sixthModel}} + {{else}} + Nothing ... + {{/if}} +
+ +{{/paper-card-content}} +{{/paper-card}} + + +{{#paper-card}} +{{#paper-card-content}} +You may pass a callback to the source attribute, this callback takes the searchText as an argument and must
+ return a promise. This means that it works with e.g. jQuery's "$.getJSON" or Ember Data. When dealing with AJAX the delay
attribute
+ is recommended to use to set a delay for the search to start.
+
+
+ {{paper-autocomplete minLength=0 delay=300 placeholder="Type e.g. Ram, Test, etc." source=dataFromPromise lookupKey="name" model=otherModel}}
+ You have selected: + {{#if otherModel}} + {{otherModel.name}} + ({{otherModel.id}}) + {{else}} + No Item Selected. + {{/if}} +
+ +
+ In the above template we use the variable dataFromPromise
, in your controller you would want to
+ define a function that returns a promise. Here is a sample configuration that would work if you have a
+ country model in the store with a name attribute:
+
Use+ + {{#paper-autocomplete searchText=mySearchText minLength=0 placeholder="Type e.g. ember, paper, one, two etc." source=arrayOfItems model=fourthModel as |item index|}} + + {{paper-icon icon="star"}} + {{paper-autocomplete-highlight searchText=mySearchText label=item}} (index {{index}} ) + + {{else}} + Whoops! Could not find "{{mySearchText}}". + {{/paper-autocomplete}} + +\{{paper-autocomplete}}
with custom templates to show styled autocomplete results. In this example we also useminLength=0
which allow to see all results if input is empty.
Selected thing was: + {{#if fourthModel}} + {{fourthModel}} + {{else}} + Nothing selected... + {{/if}} +
+ + + +The custom template receives 2 block parameters (item, index). +
+ +section.isItemTemplate
and section.isNotFoundTemplate
.
+ \{{item.name}}
. If isNotFoundTemplate
is true, item is always null
.
+ isNotFoundTemplate
is true.
+ The following example demonstrates floating labels being used as a normal form element.+ {{paper-autocomplete floating=true placeholder="Select a Country ..." source=items lookupKey="name" model=thirdModel}} +
Selected country is + {{#if thirdModel}} + {{thirdModel.name}} + ({{thirdModel.code}}) + {{else}} + Nothing selected... + {{/if}} +
+ + +Attribute | +Type | +Description | +
---|---|---|
source | +mixed | +The source attribute is used to look up possible suggestions for the autocomplete.
+ The source attribute accepts:
+
|
+
model | +mixed | +When a user selects item from the suggestions, model will be set and updated. Provide a model so you can do something about the value when user clicks the item. | +
placeholder | +string | +Sets a placeholder for the autocomplete input field. | +
minLength | +integer | +Sets how many characters the user must type before the autocomplete gives suggestions. Default is 1 . |
+
delay | +integer | +The delay attribute lets you configure how many milliseconds to wait before we trigger a search, this is + useful to avoid mass sending HTTP requests to your backend if you are using Function based source with + AJAX calls. Somewhere around 300 ms is good. | +
noCache | +boolean | +Only effective if you use promise as source. This disables the cache of promise loaded suggestions. By default they are cached when loaded the first time. | +
floating | +boolean | +Makes the autocomplete field a normal input field with floating labels. | +
autoselect | +boolean | +When suggestions is being displayed, by default when autoselect is true it will select the first element as selected. Default is false. | +
disabled | +boolean | +Disables the autocomplete. | +
required | +boolean | +Makes the autocomplete a required field. | +
allowNonExisting | +boolean | +allowNonExisting is useful for search boxes. It allows to use items that are not in the autocomplete selection. If you type e.g. "Chees" the model will also be set to "Chees". | +
notFoundMessage | +string | +The message to display if no items was found. Default is: No matches found for "%@". . The %@ part will be replaced by the users input. |
+