UNPKG

manuel

Version:

A super customizable VDOM autocomplete with *production ready* defaults.

950 lines (756 loc) 17.2 kB
/* 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));