manuel
Version:
A super customizable VDOM autocomplete with *production ready* defaults.
950 lines (756 loc) • 17.2 kB
JavaScript
/* eslint-disable fp/no-mutating-methods, fp/no-mutation */
const test = require('tape')
const manuel = require('../')
const m = require('mithril')
const mithrilQuery = require('mithril-query')
const UNUSED_KEY_CODE = 1
const KEY_UP = 38
const KEY_DOWN = 40
const KEY_ESC = 27
const KEY_ENTER = 13
const {
compose
, append
, prepend
, split
, join
} = require('ramda')
const render =
require('mithril-node-render')
const renderStdout =
compose(
console.log
, join('\n')
, prepend('--HTML-BEGIN--')
, append('--HTML-END--')
, split('\n')
, render
)
function query(app){
const context =
mithrilQuery(app)
const {
first: $
, find: $$
} = context
return { context, $, $$ }
}
function Model(){
return {
model: {
list: [[]]
,input: ['']
,chosen: [null]
,open: [false]
,highlighted: [null]
}
,modelKeys: {
list: 'list'
,input: 'input'
,chosen: 'chosen'
,open: 'open'
,highlighted: 'highlighted'
}
}
}
function mithrilAutocomplete({model, modelKeys}){
return {
autocomplete:
manuel({
hyperscript: m
,get: k => model[k][0]
,set: (k,v) => model[k].unshift(v)
})
,model
,modelKeys
}
}
test('Mithril v0.2.x', function(t){
const { model, modelKeys, autocomplete } =
mithrilAutocomplete( Model() )
var overrides = {}
const App = {
controller: function Controller(){
return function view(){
return autocomplete(modelKeys, overrides)
}
}
,view: f => f()
}
const { $, $$, context } = query(App)
t.comment('Basic Rendering'); {
t.equals(
render(
m('.manuel-complete'
,m('input', { value: "" })
,m('ul')
)
)
, render(context.rootEl)
, 'Basic structure renders'
)
model.input.unshift('Che'); context.redraw()
t.equals(
render(
m('.manuel-complete.not-empty'
,m('input', { value: model.input[0]})
,m('ul')
)
)
, render(context.rootEl)
, 'Autocomplete updates to reflect model, not-empty classes added'
)
$('input').attrs.oninput({
currentTarget: { value: 'Cher' }
})
context.redraw()
t.equals(
model.input[0]
, 'Cher'
, 'Input event updated model'
)
t.equals(
context.rootEl.attrs.className
,'manuel-complete not-empty'
,`Typing with an empty list did not show the drawer
but did add not-empty class to input
`
)
t.equals(
model.open[0]
, true
,'Typing set open to true, even though the list is empty'
)
model.list.unshift([
'Cher'
,'Cherry'
,'Cherries'
,'Chereth Cutestory'
])
context.redraw()
t.equals(
render(
m('.manuel-complete.open.not-empty.loaded'
,m('input', { value: model.input[0]})
,m('ul', model.list[0].map(
x => m(
'li', { class: ""}, [
m('mark', 'Cher')
, x.slice(4)
]
)
) )
)
)
, render(context.rootEl)
, `After adding items to the list:
open and loaded are both added to the root classnames
and matching text is highlighted
`
)
context.redraw()
}
t.comment('Keyboard Interaction'); {
const prevented = []
context.rootEl.attrs.onkeydown({
keyCode: UNUSED_KEY_CODE
,preventDefault(){
// istanbul ignore next
prevented.unshift(UNUSED_KEY_CODE)
}
})
t.equals(
prevented.length
, 0
, 'Pressing an unsed key does not e.preventDefault('
)
context.rootEl.attrs.onkeydown({
keyCode: KEY_DOWN
,preventDefault(){
prevented.unshift(KEY_DOWN)
}
})
t.deepEquals(
prevented
,[KEY_DOWN]
,'Pressing a used key (KEY_DOWN) did prevent default'
)
t.equals(
model.list[0][0]
,model.highlighted[0]
,'First item on a KEY DOWN led to the first item being highlighted'
)
context.redraw()
t.equals(
render($('.highlight'))
,render(
m('li.highlight'
,m('mark', model.highlighted[0] )
)
)
,'Highlight class added to element'
)
context.rootEl.attrs.onkeydown({
keyCode: KEY_DOWN
,preventDefault(){
prevented.unshift(KEY_DOWN)
}
})
t.equals(
model.list[0][1]
,model.highlighted[0]
,'on press KEY DOWN again led to the second item being highlighted'
)
model.highlighted.unshift(
model.list[0][0]
)
context.rootEl.attrs.onkeydown({
keyCode: KEY_UP
,preventDefault(){
prevented.unshift(KEY_UP)
}
})
t.equals(
model.list[0][model.list[0].length-1]
,model.highlighted[0]
,'KEY UP on first item leads to wrap around to last item highlight'
)
context.rootEl.attrs.onkeydown({
keyCode: KEY_DOWN
,preventDefault(){
prevented.unshift(KEY_DOWN)
}
})
t.equals(
model.list[0][0]
,model.highlighted[0]
,'KEY DOWN on last item wraps around to first item highlight'
)
model.highlighted.unshift(
model.list[0][model.list[0].length-1]
)
context.rootEl.attrs.onkeydown({
keyCode: KEY_UP
,preventDefault(){
prevented.unshift(KEY_UP)
}
})
t.equals(
model.list[0][model.list[0].length -2 ]
,model.highlighted[0]
,'KEY UP on last item steps backwards'
)
context.rootEl.attrs.onkeydown({
keyCode: KEY_DOWN
,preventDefault(){
prevented.unshift(KEY_DOWN)
}
})
t.equals(
model.list[0][model.list[0].length -1 ]
,model.highlighted[0]
,'KEY DOWN in middle of list steps forward'
)
context.rootEl.attrs.onkeydown({
keyCode: KEY_ESC
,preventDefault(){
prevented.unshift(KEY_ESC)
}
})
t.equals(
model.open[0]
,false
,'Escape Key closes the dialog'
)
t.equals(
prevented[0]
,KEY_ESC
,'Pressing escape prevents default'
)
model.input.unshift('XYZ')
context.redraw()
context.rootEl.attrs.onkeydown({
keyCode: KEY_ENTER
,preventDefault(){
// istanbul ignore next
prevented.unshift(KEY_ENTER)
}
})
t.equals(
model.chosen[0]
, null
, 'Pressing enter when nothing is highlighted does nothing'
)
model.input.unshift('Ch')
model.open.unshift(true)
context.redraw()
context.rootEl.attrs.onkeydown({
keyCode: KEY_DOWN
,preventDefault(){
prevented.unshift(KEY_DOWN)
}
})
context.rootEl.attrs.onkeydown({
keyCode: KEY_ENTER
,preventDefault(){
prevented.unshift(KEY_ENTER)
}
})
t.equals(
model.chosen[0]
,model.highlighted[0]
,'Pressing enter when highlighting chooses the highlighted item'
)
}
t.comment('Filtering and Sorting'); {
model.input.unshift('')
context.redraw()
t.equals(
$$('li').length
,model.list[0].length
,'When input is empty default filter always returns true'
)
model.list.unshift(
['bbbb', 'cccc', 'aaaa']
)
context.redraw()
t.deepEquals(
$$('li').map( x => x.children[0] )
,['aaaa', 'bbbb', 'cccc']
,'When terms are the same length, sort by alphabetical order'
)
model.list.shift()
}
t.comment('Overrides'); {
t.comment('null overrides'); {
overrides = null
model.input.unshift('')
context.redraw()
t.equals(
$$('li').length
, 4
, 'manuel runs fine with null overrides'
)
}
t.comment('minChars'); {
overrides = {}
overrides.minChars = 0
model.input.unshift('')
model.open.unshift(true)
context.redraw()
t.equals(
$('.manuel-complete')
.attrs.className.includes('open')
,true
,'Overriding minchars allows drawer to open even with "" input'
)
}
t.comment('maxItems'); {
overrides.maxItems = 0
context.redraw()
t.equals(
$$('li').length
, 0
, 'Setting maxItems=0 prevents open rendering of suggestions'
)
t.equals(
model.open[0]
,true
,'Despite model.open being set to true...'
)
t.equals(
$('.manuel-complete')
.attrs.className.includes('open')
,false
,'The open class is not set because there are no items rendered'
)
overrides.showingDrawer = true
context.redraw()
t.equals(
$('.manuel-complete')
.attrs.className.includes('open')
,true
,'Overriding showingDrawer forces suggestions list to render'
)
}
t.comment('sort'); {
model.list.unshift(['B','A','C'])
overrides = { sort: () => 0 }
context.redraw()
t.deepEquals(
$$('li')
.map( x => x.children[0] )
,model.list[0]
,'Overriding sort to a noop leaves list as is'
)
model.list.shift()
}
t.comment('filter'); {
overrides = { filter: () => false }
context.redraw()
t.deepEquals(
$$('li').length
,0
,'Overriding filter to K(false) forces the list to be empty'
)
}
t.comment('filteredList'); {
overrides = { filteredList: ['Not', 'even', 'in', 'the', 'model']}
context.redraw()
t.deepEquals(
$$('li')
.map( x => x.children[0] )
, overrides.filteredList
,'Overriding filteredList forces suggestions to be different'
)
}
t.comment('chosen'); {
overrides = { choose(){} }
model.chosen.unshift(null)
context.redraw()
$('li').attrs.onmousedown({
stopPropagation(){}
})
t.equals(
model.chosen[0]
,null
,'Overriding choose=noop prevents clickItem from setting chosen'
)
overrides = { clickItem(){} }
model.chosen.unshift(null)
context.redraw()
$('li').attrs.onmousedown({
stopPropagation(){}
})
t.equals(
model.chosen[0]
,null
,'Overriding clickItem = noop prevents setting chosen'
)
}
t.comment('PATTERN_INPUT'); {
overrides = { PATTERN_INPUT: null }
model.input.unshift('Ch')
context.redraw()
t.equals(
$$('mark').length
,0
,'Setting PATTERN_INPUT=null prevents mark highlighting'
)
overrides = { PATTERN_INPUT: /th/gi }
context.redraw()
t.equals(
render($$('li')
.find( x => mithrilQuery(x).find('mark').length > 0 )
.children
)
,render(['Chere',m('mark', 'th'), ' Cutestory'])
,'Setting PATTERN_INPUT=/th/ overrides input matching'
)
}
t.comment('mark'); {
overrides = { mark: x => m('blink', x) }
context.redraw()
t.equals(
render($('li'))
,render(
m('li', { class:"" }, [
m('blink', 'Ch')
,'er'
])
)
,'Overriding mark to m("blink", x) does what you expect'
)
overrides = { highlight: x => x }
context.redraw()
t.equals(
$$('mark').length
,0
,'Setting highlight to I prevents marking'
)
}
t.comment('oninput'); {
context.redraw()
overrides =
{ oninput: (e) => {
const value = e.currentTarget.value
model.input.unshift(value.split('').reverse().join(''))
}
}
context.redraw()
$('input').attrs.oninput({
currentTarget: {
value: 'OLLEH'
}
})
t.equals(
model.input[0]
,'HELLO'
,'Overriding oninput to reverse input updated the model'
)
}
t.comment('onfocus'); {
overrides = { onfocus() {} }
context.redraw()
model.open.unshift(false)
$('input').attrs.onfocus()
t.equals(
model.open[0]
,false
,'Overriding onfocus to a noop leaves model.open = false'
)
model.open.shift()
}
t.comment('onblur'); {
overrides = { onblur() {} }
context.redraw()
model.open.unshift(true)
$('input').attrs.onblur()
t.equals(
model.open[0]
,true
,'Overriding onblur to a noop leaves model.open = true'
)
model.open.shift()
}
t.comment('close'); {
overrides = { close() {} }
context.redraw()
model.open.unshift(true)
$('input').attrs.onblur()
t.equals(
model.open[0]
,true
,'Overriding close to a noop leaves model.open = false'
)
model.open.shift()
}
t.comment('onkeydown'); {
overrides = { onkeydown(){} }
context.redraw();
const prevents = []
;[KEY_DOWN, KEY_ENTER, KEY_ESC, KEY_UP]
.forEach(function(keyCode){
context.rootEl.attrs.onkeydown({
keyCode
,preventDefault(){
// istanbul ignore next
prevents.push(keyCode)
}
})
})
t.equals(
prevents.length
, 0
,'Overriding onkeydown disables every key interaction'
)
}
t.comment('classNames'); {
overrides = { classNames(){
return 'manuel-complete something else'
}}
context.redraw()
t.equals(
context.rootEl.attrs.className
,'manuel-complete something else'
,'Overriding classnames via classNames'
)
overrides = {}
}
t.comment('itemClassNames'); {
overrides = { itemClassNames(){
return 'wowee'
}}
model.input.unshift(
model.list[0][0]
)
context.redraw()
t.equals(
$('ul')
.children[0]
.attrs
.className
, 'wowee'
, 'item classname changed to override value'
)
model.input.shift()
overrides = {}
context.redraw()
}
t.comment('renderItem'); {
overrides = { renderItem(){
return 'jambalaya'
}}
model.input.unshift(
model.list[0][0]
)
context.redraw()
t.equals(
$('ul').children[0]
, 'jambalaya'
, 'item rendered differently after override'
)
model.input.shift()
overrides = {}
context.redraw()
}
t.comment('renderItems'); {
overrides = { renderItems(){
return 'jambalaya'
}}
context.redraw()
t.equals(
context.rootEl.children[1]
, 'jambalaya'
, 'ul replace with override value'
)
overrides = {}
context.redraw()
}
t.comment('renderRoot'); {
overrides = { renderRoot(){
return 'jambalaya'
}}
context.redraw()
t.equals(
context.rootEl
, 'jambalaya'
, 'root element replace with override value'
)
overrides = {}
context.redraw()
}
t.comment('renderInput'); {
overrides = { renderInput(){
return 'jambalaya'
}}
context.redraw()
t.equals(
context.rootEl.children[0]
, 'jambalaya'
, 'input element replace with override value'
)
overrides = {}
context.redraw()
}
t.comment('keyboard overrides'); {
overrides = {
keyboardSubmit(){
return []
}
,keyboardDismiss(){
return []
}
,keyboardNavigate(){
return []
}
}
context.redraw()
const prevents = []
;[KEY_DOWN, KEY_ENTER, KEY_ESC, KEY_UP]
.forEach(function(keyCode){
context.rootEl.attrs.onkeydown({
keyCode
,preventDefault(){
// istanbul ignore next
prevents.push(keyCode)
}
})
})
t.equals(
prevents.length
, 0
,'Overriding keyboard fns disables every key interaction'
)
overrides = {}
context.redraw()
}
renderStdout(context.rootEl)
overrides = {}
context.redraw()
}
t.comment('Focus and Blur - Open and Close'); {
model.open.unshift(false)
model.input.unshift(
model.list[0][0].slice(0,2)
)
$('input')
.attrs.onfocus()
t.equals(
model.open[0]
,true
,'Clicking on the input opens the suggestion list'
)
$('input')
.attrs.onblur()
t.equals(
model.open[0]
,false
,'Clicking anywhere else closes the suggestion list'
)
{
model.open.unshift(true)
const oldLength =
model.open.length
$('input')
.attrs.onfocus()
t.equals(
oldLength
,model.open.length
,'Open is not set if its already set.'
)
}
model.chosen.unshift('Hello')
model.open.unshift(false)
context.redraw()
$('input').attrs.oninput({
currentTarget: {
value: 'Something Else'
}
})
t.equals(
model.chosen[0]
,null
,'If inputted value does not match chosen, chosen is set to null'
)
t.equals(
model.open[0]
,true
,'If suggestions list is closed, typing will open it'
)
{
model.chosen.unshift( 'Same as input' )
model.open.unshift(true)
const oldLength = model.open.length
$('input').attrs.oninput({
currentTarget: {
value: 'Same as input'
}
})
t.equals(
model.chosen[0]
,'Same as input'
,'If inputted value doesmatch chosen, the value is retained'
)
t.equals(
model.open.length
,oldLength
,'If suggestions is already open, typing will leave open unset'
)
}
{
model.open.unshift(false)
const oldLength = model.open.length
$('input').attrs.onblur()
t.equals(
oldLength
,model.open.length
,'When closing, if open is already false, it is left unset'
)
}
}
t.end()
})
// istanbul ignore next
// eslint-disable-next-line
process.on('unhandledRejection', r => console.error(r));