From 322125716d15e1a9a898a0674301d3236c3ecca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Carrier?= Date: Wed, 7 Nov 2018 08:56:24 -0500 Subject: [PATCH] Polymer 3 support and fixes Give the option to overlap or not. Don't feel the search input when selecting a choice. --- bower.json | 15 +- demo/dinosaur-card.js | 55 +++ demo/index.html | 151 ++++---- index.html | 11 +- package-lock.json | 324 ++++++++++++++++ package.json | 34 ++ paper-dropdown-input.js | 796 ++++++++++++++++++++++++++++++++++++++++ test/basic-test.html | 73 ++-- test/index.html | 2 +- 9 files changed, 1330 insertions(+), 131 deletions(-) create mode 100644 demo/dinosaur-card.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 paper-dropdown-input.js diff --git a/bower.json b/bower.json index d55668f..cb274b5 100644 --- a/bower.json +++ b/bower.json @@ -19,17 +19,6 @@ "bower_components", "test", "tests" - ], - "dependencies": { - "polymer": "Polymer/polymer#1.9 - 2", - "iron-icon": "#1 - 2", - "iron-icons": "#1 - 2", - "paper-item": "#1 - 2", - "paper-listbox": "#1 - 2", - "paper-input": "#1 - 2", - "paper-dropdown-menu": "#1 - 2", - "iron-input": "#1 - 2", - "paper-icon-button": "#1 - 2", - "web-animations-js": "web-animations/web-animations-js#^2.3.1" - } + ] } + diff --git a/demo/dinosaur-card.js b/demo/dinosaur-card.js new file mode 100644 index 0000000..4f784c4 --- /dev/null +++ b/demo/dinosaur-card.js @@ -0,0 +1,55 @@ +import { Polymer } from '@polymer/polymer/lib/legacy/polymer-fn.js'; +import { html } from '@polymer/polymer/lib/utils/html-tag.js'; +Polymer({ + _template: html` + + +
+ [[data.name]] +
+

[[data.value]]

+

[[data.description]]

+
+
+`, + + is: 'dinosaur-card', + + properties: { + data: Object, + label: { + type: String, + computed: "_getLabel(data)" + } + }, + + listeners: { + "tap": "dinotap" + }, + + dinotap: function(event) { + console.log("dinotap", this); + }, + + _getLabel: function(data) { + return data.dinoName; + } +}); diff --git a/demo/index.html b/demo/index.html index 8589765..94a441b 100755 --- a/demo/index.html +++ b/demo/index.html @@ -13,12 +13,12 @@ window.Polymer = window.Polymer || {}; window.Polymer.dom = 'shadow'; - + - - - - + + + + + + + + + + + + + + +
+ + + + + +
+ + + + +
+
+ + +`, + + is: 'paper-dropdown-input', + + behaviors: [ + IronButtonState, + IronControlState, + IronFormElementBehavior, + Templatizer, + IronValidatableBehavior + ], + + properties: { + /** + * The derived "label" of the currently selected item. This value + * is the `label` property on the selected item if set, or else the + * trimmed text content of the selected item. + */ + selectedItemLabel: { + type: String, + notify: true, + readOnly: true + }, + + /** + * The last selected item. An item is selected if the dropdown menu has + * a child with slot `dropdown-content`, and that child triggers an + * `iron-select` event with the selected `item` in the `detail`. + * + * @type {?Object} + */ + selectedItem: { + type: Object, + notify: true, + readOnly: true + }, + + /** + * The value for this element that will be used when submitting in + * a form. It is read only, and will always have the same value + * as `selectedItemLabel`. + */ + value: { + type: String, + notify: true, + readOnly: true + }, + + /** + * The label for the dropdown. + */ + label: { + type: String + }, + + /** + * The placeholder for the dropdown. + */ + placeholder: { + type: String + }, + + /** + * The error message to display when invalid. + */ + errorMessage: { + type: String + }, + + /** + * True if the dropdown is open. Otherwise, false. + */ + opened: { + type: Boolean, + notify: true, + value: false, + observer: '_openedChanged' + }, + + /** + * By default, the dropdown will constrain scrolling on the page + * to itself when opened. + * Set to true in order to prevent scroll from being constrained + * to the dropdown when it opens. + */ + allowOutsideScroll: { + type: Boolean, + value: false + }, + + /** + * Set to true to disable the floating label. Bind this to the + * ``'s `noLabelFloat` property. + */ + noLabelFloat: { + type: Boolean, + value: false, + reflectToAttribute: true + }, + + /** + * Set to true to always float the label. Bind this to the + * ``'s `alwaysFloatLabel` property. + */ + alwaysFloatLabel: { + type: Boolean, + value: false + }, + + /** + * Set to true to disable animations when opening and closing the + * dropdown. + */ + noAnimations: { + type: Boolean, + value: false + }, + + /** + * The orientation against which to align the menu dropdown + * horizontally relative to the dropdown trigger. + */ + horizontalAlign: { + type: String, + value: 'left' + }, + + /** + * The orientation against which to align the menu dropdown + * vertically relative to the dropdown trigger. + */ + verticalAlign: { + type: String, + value: 'top' + }, + + /** + * If true, the `horizontalAlign` and `verticalAlign` properties will + * be considered preferences instead of strict requirements when + * positioning the dropdown and may be changed if doing so reduces + * the area of the dropdown falling outside of `fitInto`. + */ + dynamicAlign: { + type: Boolean + }, + + /** + * If true, the `horizontalAlign`, `verticalAlign` and dynamicAlign + * properties will be used, placing the dropdown around the input + * else the dropdow will be placed over the input. + */ + noOverlap: { + type: Boolean, + value: false + }, + + /** + * Whether focus should be restored to the dropdown when the menu closes. + */ + restoreFocusOnClose: { + type: Boolean, + value: true + }, + + /** + * The maximum amount of items the dropdown will render + * User will be asked to enter a more specific query if this + * threshold is exceeded + */ + maxSize: { + type: Number, + value: 50 + }, + /** + * Set to true if the maxSize threshold is exceeded + */ + tooBig: { + type: Boolean, + notify: true, + readOnly: true + }, + + /** + * If set, the user can choose to use the entered search query + * as the value. + * This makes the element behave more like an autocompletion element. + */ + freedom: { + type: Boolean, + value: false + }, + + /** + * The items that this element will filter on, and present to the + * user if it matches the user's search query + */ + items: { + type: Array, + value: function() {return []} + }, + + /** + * The current search query entered by the user + */ + searchValue: { + type: String, + notify: true, + value: "" + }, + + /** + * The property name that items will have that paper-dropdown-input + * can filter on + */ + filterProperty: { + type: String, + value: "value" + }, + + /** + * The filter function, executed each time 'items' or 'searchValue' changes + * The default function expects an array of strings or an array of + * objects containing the 'value' property + */ + filter: { + type: Function, + value: function() { + return function(items, searchValue, filterProperty) { + // older version of filter did not have filterProperty as an argument + // fallback for backwards compatibility + if (!filterProperty) { + filterProperty = this.filterProperty; + } + if (!searchValue) { + return items; + } else { + var _searchValue = searchValue.toLowerCase(); + return items.filter( function(item) { + if (!item[filterProperty] && typeof item != "string") { + console.error("paper-dropdown-input: item in `items`:", item, " is not a string or does not contain `" + filterProperty + "` property"); + return true; // everything goes through + } else { + return (item[filterProperty] || item).toLowerCase().indexOf(_searchValue) > -1; + } + }); + } + } + } + }, + + /** + * The remaining items after filtering. + * These are shown to the user in the dropdown. + * This list is truncated if 'maxSize' is exceeded + */ + _filtereditems: { + type: Array, + computed: "_filterItems(items, searchValue, filterProperty)" + }, + + /** + * Makes the element read-only. The dropdown will not open. + */ + readonly: { + type: Boolean, + value: false, + reflectToAttribute: true + }, + + /** + * passed to paper-listbox. + * https://www.webcomponents.org/element/PolymerElements/paper-listbox/paper-listbox#property-selectable + */ + selectable: { + type: String + }, + + /** + * disables search, making it more like a regular dropdown. + */ + noSearch: { + type: Boolean, + value: false, + reflectToAttribute: true + } + }, + + listeners: { + 'tap': '_onTap', + 'dom-change': '_refit' + // 'neon-animation-finish': '_focusSearch' + }, + + keyBindings: { + 'up down': 'open', + 'esc': 'close' + }, + + hostAttributes: { + role: 'combobox', + 'aria-autocomplete': 'none', + 'aria-haspopup': 'true' + }, + + observers: [ + '_selectedItemChanged(selectedItem)', + '_updateTemplate(_filtereditems)', + '_updateSelectedItem(items.length, selectedItemLabel, filterProperty)', + '_updateSelectedItemLabel(items, value, filterProperty)' + ], + + attached: function() { + // NOTE(cdata): Due to timing, a preselected value in a `IronSelectable` + // child will cause an `iron-select` event to fire while the element is + // still in a `DocumentFragment`. This has the effect of causing + // handlers not to fire. So, we double check this value on attached: + var contentElement = this.contentElement; + if (contentElement && contentElement.selectedItem) { + this._setSelectedItem(contentElement.selectedItem); + } + if (!this._isSetupTemplate){ + this._isSetupTemplate = true; + this._setupTemplate(); + } + }, + + /** + * The content element that is contained by the dropdown menu, if any. + */ + get contentElement() { + return this.$.menu; + }, + + /** + * Show the dropdown content. + */ + open: function() { + this.$.menuButton.open(); + }, + + /** + * Hide the dropdown content. + */ + close: function() { + this.$.menuButton.close(); + }, + + /** + * A handler that is called when `iron-select` is fired. + * + * @param {CustomEvent} event An `iron-select` event. + */ + _onIronSelect: function(event) { + this._setSelectedItem(event.detail.item); + }, + + /** + * A handler that is called when `iron-deselect` is fired. + * + * @param {CustomEvent} event An `iron-deselect` event. + */ + _onIronDeselect: function(event) { + this._setSelectedItem(null); + }, + + /** + * A handler that is called when the dropdown is tapped. + * + * @param {CustomEvent} event A tap event. + */ + _onTap: function(event) { + if (gestures.findOriginalTarget(event) === this) { + this.open(); + } + }, + + /** + * Compute the label for the dropdown given a selected item. + * + * @param {Element} selectedItem A selected Element item, with an + * optional `label` property. + */ + _selectedItemChanged: function(selectedItem) { + if (this.opened) { // user is searching + return; + } + var value = ''; + var displayValue = ''; + if (!selectedItem) { + value = ''; + } else { + displayValue = selectedItem.textContent.trim() || selectedItem.label; + value = selectedItem.label || selectedItem.getAttribute('label') || selectedItem.textContent.trim(); + } + if (!value && this.selectedItemLabel) { // selected item re-appeared in _filteredItems + return; + } + this._setValue(value); + // this._setSelectedItemLabel(value); + this._setSelectedItemLabel(displayValue); + }, + + /** + * Compute the vertical offset of the menu based on the value of + * `noLabelFloat`. + * + * @param {boolean} noLabelFloat True if the label should not float + * above the input, otherwise false. + */ + _computeMenuVerticalOffset: function(noLabelFloat) { + // NOTE(cdata): These numbers are somewhat magical because they are + // derived from the metrics of elements internal to `paper-input`'s + // template. The metrics will change depending on whether or not the + // input has a floating label. + return noLabelFloat ? -4 : 8; + }, + + /** + * Returns false if the element is required and does not have a selection, + * and true otherwise. + * @param {*=} _value Ignored. + * @return {boolean} true if `required` is false, or if `required` is true + * and the element has a valid selection. + */ + _getValidity: function(_value) { + return this.disabled || !this.required || (this.required && !!this.value); + }, + + _openedChanged: function() { + var openState = this.opened ? 'true' : 'false'; + var e = this.contentElement; + if (e) { + e.setAttribute('aria-expanded', openState); + } + if (openState) { + setTimeout(this._focusSearch.bind(this), 100); + } + }, + + _focusSearch: function() { + this.$.searchInput.focus(); + this.$.searchInput.select(); + }, + + /** + * Sets up the template + */ + _setupTemplate: function() { + // when the user clears the search field, the selectedItem is null + // this causes an iron-select event causing the dropdown to close + this.$.menuButton._onIronSelect = function(event) { + var value = this.selectedItem && (this.selectedItem.label || this.selectedItem.getAttribute('label') || this.selectedItem.textContent.trim()); + if (!value && this.selectedItemLabel) { + return; + } + if (!this.ignoreSelect) { + this.$.menuButton.close(); + } + }.bind(this); + + // normally a user can select an option by typign the first letter + // this clashes with the search field + this.$.menu._focusWithKeyboardEvent = function() {}; + + var template = dom$0(this).querySelector('template') || this.$.menuTemplate; + this.templatize(template); + this._templateInstance = this.stamp({ + items: this._filtereditems + }); + var dom = dom$0(this); + dom.appendChild(this._templateInstance.root); + // dom.insertBefore(this._templateInstance.root, dom.firstChild); + }, + + /** + * Updates the stamped template's data + * + * @param {Array.} filtereditems the new list to display in the dropdown + */ + _updateTemplate: function(filtereditems) { + if (this._templateInstance) { + this._templateInstance.items = filtereditems; + } + }, + + _refit: function() { + if (this.opened) { + this.$.menuButton.$.dropdown.refit(); + } + }, + + /** + * Returns a filtered version of items, based on if the array object matched 'searchValue' + * + * @param {Array.} items the array of items to filter + * @param {String} searchValue value to filter with + * @return {Array.} the filtered array + */ + _filterItems: function(items, searchValue, filterProperty) { + var result = this.filter ? this.filter(items, searchValue, filterProperty) : items; + if (this.maxSize > 0) { + this._setTooBig(result.length > this.maxSize); + return result.slice(0,this.maxSize); + } + else { + this._setTooBig(false); + return result; + } + }, + + _stopEvent: function(event) { + event.stopPropagation(); + }, + + /** + * Clears 'searchValue' and focuses the search input + */ + clearSearch: function(event) { + event && this._stopEvent(event); + this.searchValue = ""; + this._focusSearch(); + }, + + _updateSelectedItem: function(itemsLength, selectedItemLabel, filterProperty) { + var displayValue = this.selectedItem && (this.selectedItem.textContent.trim() || this.selectedItem.label); + var value = this.selectedItem && (this.selectedItem.label || this.selectedItem.getAttribute('label') || displayValue); + if (selectedItemLabel && value != selectedItemLabel) { + var index = this.$.menu.items.findIndex( function(item) { + return item.label == selectedItemLabel || + item[filterProperty] == selectedItemLabel || + item.value == selectedItemLabel || + item == selectedItemLabel; + } ); + index > -1 && this.$.menu.select(index); + } + }, + + _updateSelectedItemLabel: function(items, value, filterProperty) { + if (value === undefined) { + return; + } + if (value === null || value == "") { + this._setSelectedItemLabel(null); + this._setSelectedItem(null); + this.$.menu.selected = null; + return; + } + var selectedItem = items.find( function(item) { + return item[filterProperty] == value || item.label == value || item == value + }); + if (selectedItem) { + var displayValue = selectedItem[filterProperty] || selectedItem.label || selectedItem; + this._setSelectedItemLabel(displayValue); + } + else { + this.$.menu.selected = null; + if (this.freedom) { + this._setSelectedItemLabel(value); + } + else { + this._setSelectedItemLabel(null); + } + } + }, + + /* + * selectedItem can get screwed up. + * say you selected the first item in the list, and you start searching. + * You click the first item in the filtered list, the change will not + * get picked up. This fixed this edge case. + */ + _checkSelectChange: function(event) { + // execute ~after~ the normal update flow + // otherwise we would always detect the edge case + setTimeout(function () { + if (!this.selectedItemLabel) { return } + + // 'Polymer.dom(event.detail.sourceEvent).localTarget' is erroring out with Firefox + // when running in a full Polymer 2.x context + var selectedItem; + if (event.composedPath && event.composedPath()[0]) { + selectedItem = event.composedPath()[0]; + } + else { + selectedItem = dom$0(event.detail.sourceEvent).localTarget; + } + + var selectedItemLabel = selectedItem.textContent.trim() || selectedItem.label; + + if (selectedItemLabel !== this.selectedItemLabel) { + this.opened = false; + this._selectedItemChanged(this.selectedItem); + } + }.bind(this), 0); + }, + + _disabledOrReadonly: function(disabled, readonly) { + return disabled || readonly; + }, + + _showFreeInput: function(freedom, searchValue) { + return freedom && searchValue != ""; + } +}); diff --git a/test/basic-test.html b/test/basic-test.html index f8b83ed..e12768a 100755 --- a/test/basic-test.html +++ b/test/basic-test.html @@ -5,9 +5,7 @@ - - - + @@ -24,37 +22,40 @@

paper-dropdown-input

suite('', function() { var element; setup(function() { - element = fixture('paper-dropdown-input-fixture'); - }); - - // TODO - - // test('author bothered to write its own tests', function() { - // assert.equal(element.tagName, "TEMPLATE" + "-ELEMENT", "this test should only exist in template-" + "element"); - // }); - // - // test('defines the "author" property', function() { - // assert.equal(element.someObject.name, 'deinonychus'); - // }); - // test('says hello', function() { - // assert.equal(element.sayHello(), 'paper-dropdown-input says, Hello World!'); - // var greetings = element.sayHello('greetings Earthlings'); - // assert.equal(greetings, 'paper-dropdown-input says, greetings Earthlings'); - // }); - // test('fires lasers', function(done) { - // element.addEventListener('paper-dropdown-input-lasers', function(event) { - // assert.equal(event.detail.sound, 'Pew pew!'); - // done(); - // }); - // element.fireLasers(); - // }); - // test('distributed children', function() { - // var els = element.getContentChildren(); - // assert.equal(els.length, 1, 'one distributed node'); - // assert.equal(els[0], element.querySelector('h2'), 'content distributed correctly'); - // }); - }); - + import 'paper-dropdown-input.js'; + suite('', function() { + var element; + setup(function() { + element = fixture('paper-dropdown-input-fixture'); + }); - - + // TODO + + // test('author bothered to write its own tests', function() { + // assert.equal(element.tagName, "TEMPLATE" + "-ELEMENT", "this test should only exist in template-" + "element"); + // }); + // + // test('defines the "author" property', function() { + // assert.equal(element.someObject.name, 'deinonychus'); + // }); + // test('says hello', function() { + // assert.equal(element.sayHello(), 'paper-dropdown-input says, Hello World!'); + // var greetings = element.sayHello('greetings Earthlings'); + // assert.equal(greetings, 'paper-dropdown-input says, greetings Earthlings'); + // }); + // test('fires lasers', function(done) { + // element.addEventListener('paper-dropdown-input-lasers', function(event) { + // assert.equal(event.detail.sound, 'Pew pew!'); + // done(); + // }); + // element.fireLasers(); + // }); + // test('distributed children', function() { + // var els = element.getContentChildren(); + // assert.equal(els.length, 1, 'one distributed node'); + // assert.equal(els[0], element.querySelector('h2'), 'content distributed correctly'); + // }); + }); + }); + }); + \ No newline at end of file diff --git a/test/index.html b/test/index.html index 491378d..1d732c7 100644 --- a/test/index.html +++ b/test/index.html @@ -2,7 +2,7 @@ - +