├── README.md ├── bounds.js ├── example └── index.js ├── index.js ├── package.json ├── screenshot.png ├── suggester.js └── test └── bounds.js /README.md: -------------------------------------------------------------------------------- 1 | # Suggest Box 2 | 3 | Decorates a textarea with GitHub-style suggestion popups (eg for emojis and users) 4 | 5 | ![Screenshot](/screenshot.png?raw=true) 6 | 7 | ## Usage: SuggestBox(textarea, suggester, options) 8 | 9 | Suggest box is a decorator; it takes in a textarea element and binds to its events. The second param is object of options to suggest after an initial character has been typed. 10 | 11 | ```js 12 | var textarea = document.querySelector('textarea.my-text-area') 13 | suggestBox(textarea, suggester, { 14 | cls: 'my-suggest-box' // optional, extra class for the suggest-box popup 15 | }) 16 | ``` 17 | ### options 18 | * `cls` additional classes to set on the suggest box. appended to `'.suggestbox'+cls`. see syntax for [hyperscript classes](https://github.com/dominictarr/hyperscript#classes--id) 19 | * `stringify` a function called to map a selected value to a string. 20 | 21 | 22 | ## suggestor 23 | 24 | the `suggestor` can be a function that calls back on suggestions for each word. 25 | 26 | ``` js 27 | function suggester (word, cb) { 28 | 29 | //check first char for @ 30 | if(word[0] === '@') 31 | lookupUsers(word, cb) 32 | //else if it's an ordinary word, cb immediately. 33 | else cb() 34 | } 35 | 36 | ``` 37 | the signature of the callback is `cb(err, suggestions)` 38 | if there is an error, it will be logged. 39 | `suggestions` is an array of suggestions. Each suggestion is of the form 40 | 41 | ``` js 42 | { 43 | title: 'Bob', // title to render 44 | // image: '/img/user.png', // optional, renders the image instead of the title (title still required for matching) 45 | // cls: 'user-option', // optional, extra class for the option's li 46 | subtitle: 'Bob Roberts' // subtitle to render 47 | value: '@bob' // value to insert once selected 48 | } 49 | ``` 50 | 51 | when an item is selected. the value of the `value` property is inserted at the cursor. 52 | If `options.stringify` is provided, then `options.stringify(value)` is inserted (which means `.value` may be an object) 53 | otherwise `value` must be a string. 54 | 55 | can use `showBoth` to display image and title 56 | ``` js 57 | { 58 | title: 'Bob', // title to render 59 | image: '/img/user.png', // renders the image at left of the title 60 | // cls: 'user-option', // optional, extra class for the option's li 61 | subtitle: 'Bob Roberts' // subtitle to render 62 | value: '@bob' // value to insert once selected 63 | showBoth: true // display image + title 64 | } 65 | ``` 66 | 67 | `value` is what will be inserted if the user makes that selection. 68 | If you are using a sigil (say, @ at the start of user names, 69 | the value needs to include that at the start) 70 | 71 | Alternatively, if you already know all the possibilities, 72 | the suggestor can be a map of sigils (prefix characters) to arrays of suggestions. 73 | ``` js 74 | var suggester = { 75 | '@': [ // the initial character to watch for 76 | { 77 | title: 'Bob', // title to render 78 | // image: '/img/user.png', // optional, renders the image instead of the title (title still required for matching) 79 | // cls: 'user-option', // optional, extra class for the option's li 80 | subtitle: 'Bob Roberts' // subtitle to render 81 | value: '@bob' // value to insert once selected 82 | }, 83 | ... 84 | ] 85 | } 86 | 87 | ``` 88 | 89 | This example will watch for the '@' symbol and begin suggesting usernames (bob or alice). 90 | 91 | Alternatively, if you want all inputs to trigger the suggest-box, just pass the array directly. 92 | This is good for, for example, tag inputs: 93 | 94 | ```js 95 | var input = document.querySelector('input.my-tags-input') 96 | suggestBox(input, [ // trigger for any character 97 | { 98 | title: 'Bob', 99 | ... 100 | }, 101 | ... 102 | ] 103 | ) 104 | ``` 105 | (this also works as 106 | 107 | Also, the option may be provided as an async function, this should 108 | callback with an array of objects with this shape: `{title, subtitle?, value}` 109 | 110 | ``` js 111 | suggestBox(textarea, { 112 | '@': function (word, cb) { 113 | getSuggestion(word, cb) 114 | } 115 | }) 116 | ``` 117 | 118 | ## Event: suggestselect 119 | 120 | If you want to listen for a suggest-box selection, you can attach to the 'suggestselect' event on the element. It will include the option object in the `detail`. 121 | 122 | ```js 123 | textarea.addEventListener('suggestselect', function (e) { 124 | console.log(e) /* => { 125 | title: 'Bob', 126 | subtitle: 'Bob Roberts', 127 | value: '@bob' 128 | } */ 129 | }) 130 | ``` 131 | 132 | ## Styles 133 | 134 | You must add your own styles to the page. Here is a some recommended styling in less: 135 | 136 | ```css 137 | .suggest-box { 138 | position: fixed; 139 | border: 1px solid #ddd; 140 | z-index: 100; 141 | background: white; 142 | 143 | ul { 144 | margin: 0; 145 | padding: 0; 146 | list-style: none; 147 | li { 148 | padding: 4px 8px; 149 | font-size: 85%; 150 | border-bottom: 1px solid #ddd; 151 | &:last-child { 152 | border: 0; 153 | } 154 | &.selected { 155 | color: #fff; 156 | background-color: #428bca; 157 | border-color: darken(#428bca, 5%); 158 | } 159 | img { 160 | height: 20px; 161 | } 162 | } 163 | } 164 | } 165 | ``` 166 | 167 | ## License 168 | 169 | MIT Licensed, Copyright 2014 Paul Frazee 170 | 171 | 172 | 173 | 174 | 175 | 176 | -------------------------------------------------------------------------------- /bounds.js: -------------------------------------------------------------------------------- 1 | 2 | function whitespace (s) { 3 | return /\S/.test(s) 4 | } 5 | 6 | exports.START = exports.END = -1 7 | 8 | var start = exports.start = function (text, i, bound) { 9 | var s = i, S = -1 10 | while(s >= 0 && bound(text[s])) S = s-- 11 | return exports.START = S 12 | } 13 | 14 | var end = exports.end = function (text, i, bound) { 15 | var s = i, S = -1 16 | while(s < text.length && bound(text[s])) S = ++s 17 | return exports.END = S 18 | } 19 | 20 | var word = exports.word = function (text, i, bound) { 21 | bound = bound || whitespace 22 | return text.substring(start(text, i, bound), end(text, i, bound)) 23 | } 24 | 25 | exports.replace = function replace (value, text, i, bound) { 26 | bound = bound || whitespace 27 | 28 | var w = word(text, i, bound) 29 | if(!w) return text 30 | 31 | return ( 32 | text.substring(0, exports.START) 33 | + value 34 | + text.substring(exports.END) 35 | ) 36 | } 37 | 38 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | 2 | // to build the example, run `npm run build` then point your 3 | // browser at ./index.html 4 | 5 | var h = require('hyperscript') 6 | var suggest = require('../') 7 | 8 | var textarea = TA = h('textarea', {rows: 60, columns: 20, 9 | value: '\n'.repeat(55) 10 | }) 11 | 12 | document.body.appendChild(h('style', 13 | '.suggest-box .selected {color: red;}' 14 | )) 15 | 16 | document.body.appendChild(h('div', 17 | h('h1', 'suggestbox test'), 18 | h('input', {type: 'checkbox', onclick: function () { 19 | this.parentNode.style.textAlign = this.checked ? 'right' : 'left' 20 | }}), 21 | h('br'), 22 | textarea, 23 | h('p', 24 | 'type into the text area, and when you type "."', 25 | 'an autosuggest for DOM properties will be created' 26 | ) 27 | )) 28 | 29 | suggest(textarea, { 30 | '.': function (word, cb) { 31 | word = word.toLowerCase() 32 | var data = Object.keys(window) 33 | .filter(function (k) { 34 | return k.toLowerCase().indexOf(word) === 0 35 | }).map(function (k) { 36 | return {title: k, value: '.'+k} 37 | }) 38 | 39 | setTimeout(function () { 40 | cb(null, data) 41 | }, ~~(Math.random()*200)) 42 | } 43 | }) 44 | 45 | 46 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | var h = require('hyperscript') 3 | var wordBoundary = /\s/ 4 | var bounds = require('./bounds') 5 | 6 | var TextareaCaretPosition = require('textarea-caret-position') 7 | 8 | var Suggester = require('./suggester') 9 | 10 | module.exports = function(el, choices, options) { 11 | var tcp = new TextareaCaretPosition(el) 12 | 13 | var suggest = Suggester(choices) 14 | 15 | options = options || {} 16 | 17 | var stringify = options.stringify || String 18 | 19 | var box = { 20 | input: el, 21 | choices: choices, 22 | options: options, 23 | active: false, 24 | activate: activate, 25 | deactivate: deactivate, 26 | selection: 0, 27 | filtered: [], 28 | 29 | //get the current word 30 | get: function (i) { 31 | i = Number.isInteger(i) ? i : el.selectionStart - 1 32 | return bounds.word(el.value, i) 33 | }, 34 | 35 | //replace the current word 36 | set: function (w, i) { 37 | i = Number.isInteger(i) ? i : el.selectionStart - 1 38 | el.value = bounds.replace(w, el.value + ' ', i) 39 | el.selectionStart = el.selectionEnd = bounds.START + w.length + 1 40 | }, 41 | 42 | select: function (n) { 43 | this.selection = Math.max(0, Math.min(this.filtered.length, n)) 44 | this.update() 45 | }, 46 | next: function () { 47 | this.select(this.selection + 1) 48 | }, 49 | prev: function () { 50 | this.select(this.selection - 1) 51 | }, 52 | suggest: function (cb) { 53 | var choices, self = this 54 | // extract current word 55 | var word = this.get() 56 | if(!word) 57 | return this.deactivate(), cb() 58 | 59 | // filter and order the list by the current word 60 | this.selection = 0 61 | 62 | var r = this.request = (this.request || 0) + 1 63 | suggest(word, function (err, choices) { 64 | if(err) return console.error(err) 65 | if(r !== self.request) return cb() 66 | if(choices) cb(null, self.filtered = choices) 67 | }) 68 | 69 | }, 70 | reposition: function () { 71 | self = this 72 | if (self.filtered.length == 0) 73 | return self.deactivate() 74 | 75 | // create / update the element 76 | if (self.active) { 77 | self.update() 78 | } else { 79 | // calculate position 80 | var pos = tcp.get(el.selectionStart, el.selectionEnd) 81 | 82 | var bounds = el.getBoundingClientRect() 83 | // setup 84 | self.x = pos.left + bounds.left - el.scrollLeft 85 | self.y = pos.top + bounds.top - el.scrollTop + 20 86 | self.activate() 87 | } 88 | }, 89 | update: update, 90 | complete: function (n) { 91 | if(!isNaN(n)) this.select(n) 92 | if (this.filtered.length) { 93 | var choice = this.filtered[this.selection] 94 | if (choice && choice.value) { 95 | // update the text under the cursor to have the current selection's value var v = el.value 96 | this.set(stringify(choice.value)) 97 | // fire the suggestselect event 98 | el.dispatchEvent(new CustomEvent('suggestselect', { detail: choice })) 99 | } 100 | } 101 | this.deactivate() 102 | }, 103 | } 104 | el.addEventListener('input', oninput.bind(box)) 105 | el.addEventListener('keydown', onkeydown.bind(box)) 106 | el.addEventListener('blur', onblur.bind(box)) 107 | return box 108 | } 109 | 110 | function getItemIndex(e) { 111 | for (var el = e.target; el && el != this; el = el.parentNode) 112 | if (el._i != null) 113 | return el._i 114 | } 115 | 116 | function onListMouseMove(e) { 117 | this.isMouseActive = true 118 | } 119 | 120 | function onListMouseOver(e) { 121 | // ignore mouseover triggered by list redrawn under the cursor 122 | if (!this.isMouseActive) return 123 | 124 | var i = getItemIndex(e) 125 | if (i != null && i != this.selection) 126 | this.select(i) 127 | } 128 | 129 | function onListMouseDown(e) { 130 | var i = getItemIndex(e) 131 | if (i != null) { 132 | this.select(i) 133 | this.complete() 134 | // prevent blur 135 | e.preventDefault() 136 | } 137 | } 138 | 139 | function render(box) { 140 | var cls = (box.options.cls) ? ('.'+box.options.cls) : '' 141 | var style = { left: (box.x+'px'), position: 'fixed' } 142 | 143 | // hang the menu above or below the cursor, wherever there is more room 144 | if (box.y < window.innerHeight/2) { 145 | style.top = box.y + 'px' 146 | } else { 147 | style.bottom = (window.innerHeight - box.y + 20) + 'px' 148 | } 149 | 150 | return h('.suggest-box'+cls, { style: style }, [ 151 | h('ul', { 152 | onmousemove: onListMouseMove.bind(box), 153 | onmouseover: onListMouseOver.bind(box), 154 | onmousedown: onListMouseDown.bind(box) 155 | }, renderOpts(box)) 156 | ]) 157 | } 158 | 159 | function renderOpts(box) { 160 | var fragment = document.createDocumentFragment() 161 | for (var i=0; i < box.filtered.length; i++) { 162 | var opt = box.filtered[i] 163 | var tag = 'li' 164 | if (i === box.selection) tag += '.selected' 165 | if (opt.cls) tag += '.' + opt.cls 166 | var title = null 167 | var image = null 168 | if(opt.showBoth){ 169 | title = h('strong', opt.title) 170 | image = h('img', { src: opt.image }) 171 | } else title = opt.image ? h('img', { src: opt.image }) : h('strong', opt.title) 172 | fragment.appendChild(h(tag, {_i: i}, image, ' ', [title, ' ', opt.subtitle && h('small', opt.subtitle)])) 173 | } 174 | return fragment 175 | } 176 | 177 | function activate() { 178 | if (this.active) 179 | return 180 | this.active = true 181 | this.el = render(this) 182 | document.body.appendChild(this.el) 183 | adjustPosition.call(this) 184 | } 185 | 186 | function update() { 187 | if (!this.active) 188 | return 189 | var ul = this.el.querySelector('ul') 190 | ul.innerHTML = '' 191 | ul.appendChild(renderOpts(this)) 192 | adjustPosition.call(this) 193 | } 194 | 195 | function deactivate() { 196 | if (!this.active) 197 | return 198 | this.el.parentNode.removeChild(this.el) 199 | this.el = null 200 | this.active = false 201 | } 202 | 203 | function oninput(e) { 204 | var self = this 205 | var word = this.suggest(function (_, suggestions) { 206 | if(suggestions) self.reposition() 207 | }) 208 | } 209 | 210 | function onkeydown(e) { 211 | if (this.active) { 212 | // up 213 | if (e.keyCode == 38) this.prev() 214 | // down 215 | else if (e.keyCode == 40) this.next() 216 | // escape 217 | else if (e.keyCode == 27) this.deactivate() 218 | // enter or tab 219 | 220 | else if (e.keyCode == 13 || e.keyCode == 9) this.complete() 221 | else return //ordinary key, fall back. 222 | 223 | e.preventDefault() //movement key, as above. 224 | 225 | this.isMouseActive = false 226 | } 227 | } 228 | 229 | function onblur(e) { 230 | this.deactivate() 231 | } 232 | 233 | function adjustPosition() { 234 | // move the box left to fit in the viewport, if needed 235 | var width = this.el.getBoundingClientRect().width 236 | var rightOverflow = this.x + width - window.innerWidth 237 | var rightAdjust = Math.min(this.x, Math.max(0, rightOverflow)) 238 | this.el.style.left = (this.x - rightAdjust) + 'px' 239 | } 240 | 241 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "suggest-box", 3 | "description": "decorates a textarea with GitHub-style suggestion popups (eg for emojis and users)", 4 | "version": "2.2.3", 5 | "homepage": "https://github.com/pfraze/suggest-box", 6 | "repository": { 7 | "type": "git", 8 | "url": "git://github.com/pfraze/suggest-box.git" 9 | }, 10 | "dependencies": { 11 | "hyperscript": "~1.4.2", 12 | "textarea-caret-position": "^0.1.1" 13 | }, 14 | "author": "Paul Frazee ", 15 | "license": "MIT", 16 | "devDependencies": { 17 | "browserify": "~8.0.3", 18 | "indexhtmlify": "~1.2.0", 19 | "tape": "^4.4.0" 20 | }, 21 | "scripts": { 22 | "build": "browserify example/index.js | indexhtmlify > example/index.html" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pfrazee/suggest-box/b810713b54de15ee8bafb29a524832816e59596a/screenshot.png -------------------------------------------------------------------------------- /suggester.js: -------------------------------------------------------------------------------- 1 | 2 | function isObject (o) { 3 | return o && 'object' === typeof o 4 | } 5 | 6 | var isArray = Array.isArray 7 | 8 | function isFunction (f) { 9 | return 'function' === typeof f 10 | } 11 | 12 | function compare(a, b) { 13 | return compareval(a.rank, b.rank) || compareval(a.title, b.title) 14 | } 15 | 16 | function compareval(a, b) { 17 | return a === b ? 0 : a < b ? -1 : 1 18 | } 19 | 20 | function suggestWord (word, choices, cb) { 21 | if(isArray(choices)) { 22 | //remove any non word characters and make case insensitive. 23 | var wordRe = new RegExp(word.replace(/\W/g, ''), 'i') 24 | cb(null, choices.map(function (opt, i) { 25 | var title = wordRe.exec(opt.title) 26 | var subtitle = opt.subtitle ? wordRe.exec(opt.subtitle) : null 27 | var rank = (title === null ? (subtitle&&subtitle.index) : (subtitle === null ? (title&&title.index) : Math.min(title.index, subtitle.index))) 28 | if (rank !== null) { 29 | opt.rank = rank 30 | return opt 31 | } 32 | }).filter(Boolean).sort(compare).slice(0, 20)) 33 | } 34 | else if(isFunction(choices)) choices(word, cb) 35 | } 36 | 37 | module.exports = function (choices) { 38 | if(isFunction(choices)) return choices 39 | 40 | else if(isObject(choices) && (choices.any || isArray(choices))) 41 | return function (word, cb) { 42 | suggestWord(word, choices.any || choices, cb) 43 | } 44 | else if(isObject(choices)) { 45 | var _choices = choices 46 | //legacy 47 | return function (word, cb) { 48 | if(!choices[word[0]]) return cb() 49 | suggestWord(word.substring(1), choices[word[0]], cb) 50 | } 51 | } 52 | } 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /test/bounds.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | //find a word at a given index in a string. 4 | 5 | var bounds = require('../bounds') 6 | 7 | var tape = require('tape') 8 | 9 | var text = 'foo bar baz TestThing wordz' 10 | 11 | tape('select a word at a bound', function (t) { 12 | 13 | function test(i) { 14 | return bounds.word(text, i) 15 | } 16 | 17 | t.equal(test(0), 'foo') 18 | t.equal(test(1), 'foo') 19 | t.equal(test(2), 'foo') 20 | 21 | t.equal(test(4), 'bar') 22 | t.equal(test(5), 'bar') 23 | t.equal(test(6), 'bar') 24 | 25 | t.equal(test(15), 'TestThing') 26 | 27 | t.equal(test(22), 'wordz') 28 | 29 | t.equal(test(3), '') 30 | t.equal(test(7), '') 31 | 32 | t.end() 33 | }) 34 | 35 | tape('replace a word', function (t) { 36 | t.equal(bounds.replace('FOO', text, 1), 'FOO bar baz TestThing wordz') 37 | t.end() 38 | }) 39 | 40 | 41 | 42 | --------------------------------------------------------------------------------