diff --git a/autoform-helpers.js b/autoform-helpers.js index c7d5577c..25f0791a 100644 --- a/autoform-helpers.js +++ b/autoform-helpers.js @@ -425,6 +425,27 @@ export const afSelectOptionAtts = function afSelectOptionAtts () { */ Template.registerHelper('afSelectOptionAtts', afSelectOptionAtts) +/** + * @name afAutocompleteSuggestionAtts + * @return {*} + */ +export const afAutocompleteSuggestionAtts = function afAutocompleteSuggestionAtts () { + if (this.value === false) this.value = 'false' + const atts = 'value' in this ? { value: this.value } : {} + if (this.selected) { + atts.selected = '' + } + if (this.htmlAtts) { + Object.assign(atts, this.htmlAtts) + } + return atts +} + +/* + * afAutocompleteSuggetionAtts + */ +Template.registerHelper('afAutocompleteSuggestionAtts', afAutocompleteSuggestionAtts) + // Expects to be called with this.name available Template.registerHelper('afOptionsFromSchema', function afOptionsFromSchema () { return AutoForm._getOptionsForField(this.name) diff --git a/dynamic.js b/dynamic.js index d9bde7a5..d9d227de 100644 --- a/dynamic.js +++ b/dynamic.js @@ -23,6 +23,8 @@ function init () { import('./formTypes/disabled.js'), // input types import('./inputTypes/value-converters.js'), + import('./inputTypes/autocomplete/autocomplete.html'), + import('./inputTypes/autocomplete/autocomplete.js'), import('./inputTypes/boolean-checkbox/boolean-checkbox.html'), import('./inputTypes/boolean-checkbox/boolean-checkbox.js'), import('./inputTypes/boolean-radios/boolean-radios.html'), diff --git a/inputTypes/autocomplete/autocomplete.html b/inputTypes/autocomplete/autocomplete.html new file mode 100644 index 00000000..4d352d13 --- /dev/null +++ b/inputTypes/autocomplete/autocomplete.html @@ -0,0 +1,7 @@ + diff --git a/inputTypes/autocomplete/autocomplete.js b/inputTypes/autocomplete/autocomplete.js new file mode 100644 index 00000000..3940436d --- /dev/null +++ b/inputTypes/autocomplete/autocomplete.js @@ -0,0 +1,292 @@ +import { Template } from 'meteor/templating' +import { ReactiveVar } from 'meteor/reactive-var' + +AutoForm.addInputType('autocomplete', { + template: 'afAutocomplete', + valueOut: function () { + return this.val() + }, + valueConverters: { + stringArray: AutoForm.valueConverters.stringToStringArray, + number: AutoForm.valueConverters.stringToNumber, + numberArray: AutoForm.valueConverters.stringToNumberArray, + boolean: AutoForm.valueConverters.stringToBoolean, + booleanArray: AutoForm.valueConverters.stringToBooleanArray, + date: AutoForm.valueConverters.stringToDate, + dateArray: AutoForm.valueConverters.stringToDateArray + }, + contextAdjust: function (context) { + context.atts.autocomplete = 'off' + const { ...itemAtts } = context.atts + // remove non-essential atts from visible input + const visibleAtts = Object.assign({}, context.atts) + + ;['data-schema-key', 'id', 'name'].forEach(key => { + delete visibleAtts[key] + }) + + // add form-control to remaining classes + context.visibleAtts = visibleAtts + + // build items list + context.items = [] + + // re-use selectOptions to keep it DRY + // Add all defined options or default + if (context.selectOptions) { + context.selectOptions.forEach(function (opt) { + // there are no subgroups here + const { label, value, ...htmlAtts } = opt + context.items.push({ + name: context.name, + label, + value, + htmlAtts, + _id: opt.value.toString(), + selected: (opt.value === context.value), + atts: itemAtts + }) + }) + } + else { + console.warn('autocomplete requires options for suggestions.') + } + return context + } +}) + +Template.afAutocomplete.onRendered(function () { + /* AUTOCOMPLETE + *************** + * This uses the same datums as select types, which + * means that 'options' come from simple-schema. + * + * It allows selection by arrows up/down/enter; mouse click; + * and when enough characters entered make a positive match. + * Arrow navigation is circlular; top to bottom & vice versa. + * + * It uses the 'dropdown' classes in bootstrap 4 for styling. + */ + + // get the instance items + // defined in several ways + const me = Template.instance() + const items = new ReactiveVar([]) + let isOption + + me.autorun(() => { + const data = Template.currentData() + items.set(data.items) + isOption = value => data.selectOptions.find(option => option.value === value) + }) + + // secure the dom so multiple autocompletes don't clash + const $input = me.$('input[type="text"]') + const $hidden = me.$('input[type="hidden"]') + const $container = me.$('.dropdown') + const $suggestions = me.$('.dropdown-menu') + + // prepare for arrow navigation + let currIndex = -1 + let totalItems = 0 + let showing = false + + const clearDropdown = function (e, haltEvents = false) { + if (showing === true) { + // hide the menu and reset the params + $suggestions.empty().removeClass('show') + $container.removeClass('show') + currIndex = -1 + totalItems = 0 + showing = false + if (haltEvents === true) { + e.preventDefault() + e.stopPropagation() + } + } + } + + // keydown catches escape + $input.keydown((e) => { + // prevent form submit from "Enter/Return" if showing + if ( + /Enter|Tab/.test(e.originalEvent.key) === true && + showing === true + ) { + e.preventDefault() + e.stopPropagation() + } + // allow Escape to close the dropdown + else if ( + /Escape/.test(e.originalEvent.key) === true && + showing === true + ) { + clearDropdown(e, true) + } + }) + + /** + * Ensure reactivity when changing the hidden value to a valid option or a + * falsy value (= deleting the value / clearing the field) + */ + const updateValue = value => { + $hidden.val(value) + + if (!value || isOption(value)) { + $hidden.trigger('change') + } + } + + const callback = function (e) { + // only populate when typing characters or deleting + // otherwise, we are navigating + if (/ArrowDown|ArrowUp|ArrowLeft|ArrowRight|Enter|Escape|Tab/.test(e.originalEvent.key) === false) { + // we're typing + // ensure hidden and visible values match for validation + updateValue($input.val()) + // filter results from visible input value + const result = items.get().filter((i) => { + const reg = new RegExp(e.target.value, 'gi') + return reg.test(i.label) + }) + + // display results in 'suggestions' div + $suggestions.empty() + let html + const len = result.length + totalItems = result.length + + if (len > 1) { + currIndex = -1 + for (let i = 0; i < len; i++) { + // populate suggestions + html = `` + $suggestions.append(html) + } + $suggestions.addClass('show') + $container.addClass('show') + showing = true + + // clear any manual navigated selections on hover + $suggestions.children().hover((e) => { + $suggestions.children().removeClass('active') + const $target = me.$(e.target) + $target.addClass('active') + currIndex = Number.parseInt($target.data('index'), 10) + + // make sure showing remains true + showing = true + }) + + // prevent blur when clicking on a suggestion! + $suggestions.children().on('mousedown', e => { + e.preventDefault() + e.stopPropagation() + }) + + // choose an answer on click + $suggestions.children().on('click', (e) => { + const dataValue = me.$(e.target).attr('data-value') + const dataLabel = me.$(e.target).attr('data-label') + $input.val(dataLabel) + updateValue(dataValue) + clearDropdown(e, false) + $input.focus() + }) + } + else if (e.originalEvent.key !== 'Backspace') { + // only force populate if not deleting + // bc we all make mistakes + if (result.length === 1) { + $input.val(result[0].label) + updateValue(result[0].value) + clearDropdown(e, false) + $input.focus() + } + else { + // no results, hide + clearDropdown(e, false) + } + } + } + else if (showing === true) { // we're navigating suggestions + // start highlighting at the 0 index + if (/ArrowDown/.test(e.originalEvent.key) === true) { + // navigating down + if (currIndex === totalItems - 1) { + currIndex = -1 + } + // remove all classes from the children + $suggestions.children().removeClass('active') + $suggestions.children('div').eq(++currIndex).addClass('active') + } + else if (/ArrowUp/.test(e.originalEvent.key) === true) { + if (currIndex <= 0) { + currIndex = totalItems + } + // navigating up + // remove all classes from the children + $suggestions.children().removeClass('active') + $suggestions.children('div').eq(--currIndex).addClass('active') + } + else if (/Enter|Tab/.test(e.originalEvent.key) === true) { + // we're selecting + if (currIndex === -1) { + currIndex = 0 + } + const enterValue = $suggestions.children('div').eq(currIndex).attr('data-value') + const enterLabel = $suggestions.children('div').eq(currIndex).attr('data-label') + $input.val(enterLabel) + updateValue(enterValue) + clearDropdown(e, false) + $input.focus() + } + } + } + + // mousedown triggers before blur, so we can check if mousedown is connected + // to a suggestion element and this prevent further bubbling to blur: + // https://stackoverflow.com/a/12092486 + $input.on('mousedown', e => { + if (me.$(e.currentTarget).data('suggestion')) { + e.preventDefault() + e.stopPropagation() + } + }) + + $input.on('blur', e => { + $hidden.trigger('blur') // triggers re-validation + clearDropdown(e, false) + }) + + // detect keystrokes + $input.on('keyup', e => { + callback(e) + }) + + // show on double click + $input.on('dblclick', (e) => { + callback(e) + }) + + // show on double click + $input.on('touchstart', (e) => { + $hidden.trigger('touchstart') + callback(e) + }) +}) + +Template.afAutocomplete.onDestroyed(function () { + const instance = this + const $input = instance.$('input[type="text"]') + $input.off() + + const $hidden = instance.$('input[type="hidden"]') + $hidden.off() + + const $container = instance.$('.dropdown') + $container.off() + + const $suggestions = instance.$('.dropdown-menu') + $suggestions.off() +}) diff --git a/static.js b/static.js index 2d42755f..eec728c0 100644 --- a/static.js +++ b/static.js @@ -10,6 +10,8 @@ import './formTypes/readonly.js' import './formTypes/disabled.js' // input types import './inputTypes/value-converters.js' +import './inputTypes/autocomplete/autocomplete.html' +import './inputTypes/autocomplete/autocomplete.js' import './inputTypes/boolean-checkbox/boolean-checkbox.html' import './inputTypes/boolean-checkbox/boolean-checkbox.js' import './inputTypes/boolean-radios/boolean-radios.html'