Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Meteor AutoForm 7.1.0 - Autocomplete #1701

Open
wants to merge 16 commits into
base: devel
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions autoform-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions dynamic.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
7 changes: 7 additions & 0 deletions inputTypes/autocomplete/autocomplete.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<template name="afAutocomplete">
<div class="autocomplete dropdown">
<input type="text" value="{{this.label}}" {{this.visibleAtts}} />
<div class="dropdown-menu"></div>
</div>
<input type="hidden" value="{{this.value}}" {{this.atts}} />
</template>
238 changes: 238 additions & 0 deletions inputTypes/autocomplete/autocomplete.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
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 })
const keys = ['data-schema-key', 'id', 'name']
keys.forEach(key => {
delete visibleAtts[key]
})
// add form-control to remaining classes
context.visibleAtts = AutoForm.Utility.addClass({ ...visibleAtts }, 'form-control')
context.atts = AutoForm.Utility.addClass({ ...itemAtts }, 'form-control')
// 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([])
me.autorun(() => {
const data = Template.currentData()
items.set(data.items)
})

// 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/.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)
}
})

// clear on blur?
// TODO: Figure out how blur won't block "click"
// $input.blur((e)=>{ clearDropdown(e) })

const callback = function (e) {
// only populate when typing characters or deleting
// otherwise, we are navigating
if (/ArrowDown|ArrowUp|ArrowLeft|ArrowRight|Enter|Escape/.test(e.originalEvent.key) === false) {
// we're typing
// ensure hidden and visible values match for validation
$hidden.val($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 = `<div class="dropdown-item" data-value="${result[i].value}" data-label="${result[i].label}">${result[i].label}</div>`
$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')
currIndex = -1
})

// choose an answer on click
$suggestions.children().click((e) => {
const dataValue = me.$(e.target).attr('data-value')
const dataLabel = me.$(e.target).attr('data-label')
$input.val(dataLabel)
$hidden.val(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)
$hidden.val(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/.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)
$hidden.val(enterValue)
clearDropdown(e, false)
$input.focus()
}
}
}

// detect keystrokes
$input.keyup((e) => {
callback(e)
})

// show on double click
$input.dblclick((e) => {
callback(e)
})

// show on double click
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

change comment to show on touch
Good catch!

$input.on('touchstart', (e) => {
callback(e)
})
})
2 changes: 1 addition & 1 deletion package.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Package.describe({
summary:
'Easily create forms with automatic insert and update, and automatic reactive validation.',
git: 'https://github.com/aldeed/meteor-autoform.git',
version: '7.0.0'
version: '7.1.0'
})

Package.onUse(function (api) {
Expand Down
2 changes: 2 additions & 0 deletions static.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down