diff --git a/src/.gitignore b/src/.gitignore new file mode 100644 index 0000000..06b34da --- /dev/null +++ b/src/.gitignore @@ -0,0 +1,2 @@ +*.css +*.map diff --git a/src/scss/_variables.scss b/src/scss/_variables.scss new file mode 100644 index 0000000..6c9beaf --- /dev/null +++ b/src/scss/_variables.scss @@ -0,0 +1,22 @@ +$spacer: 1em !default; + +$black: #111111 !default; +$white: #FFFFFF !default; +$gray: #DDDDDD !default; +$light-gray: #EFEFEF !default; +$yellow: #FFFE47 !default; + +$primary: #FF64B1 !default; +$secondary: #EFEFEF !default; + +$text: $black !default; +$text-secondary: #BBBBBB !default; + +$default-shadow: rgba(0,0,0,.3); + +$border-default: solid 0.125 * $spacer $default-shadow !default; +$border-default-transparent: solid 0.0625 * $spacer transparent !default; +$border-radius: .5em !default; + +$padding: $spacer * .5 $spacer * .75 !default; + diff --git a/src/scss/simplecomplete.scss b/src/scss/simplecomplete.scss new file mode 100644 index 0000000..d2ed7d3 --- /dev/null +++ b/src/scss/simplecomplete.scss @@ -0,0 +1,354 @@ +@use "sass:math"; +@import 'variables'; + +@keyframes pulsingAnim { + 0% { + opacity: .6; + } + + 50% { + opacity: 1; + } + + 100% { + opacity: .6; + } +} + +@keyframes scrollingBg { + 0% { + background-position: 0 0; + } + + 100% { + background-position: -100% 0; + } +} + +@keyframes highlightAnim { + 0% { + background: $white; + } + + 15% { + background: $primary; + } + + 30% { + background: $white; + } + + 50% { + background: $primary; + } + + 100% { + background: inherit; + } +} + +.simplecomplete { + position: relative; + border: $border-default; + border-radius: $border-radius; + background: linear-gradient(90deg, $white 0, $white 100%); + box-sizing: border-box; + transition: background 3s; + + * { + box-sizing: border-box; + } + + &__input { + display: flex; + flex-flow: row nowrap; + &-field { + flex: 1; + background: white; + padding: $padding; + overflow-x: auto; + white-space: nowrap; + border: $border-default-transparent; + border-radius: $border-radius * .8; + outline: none; + margin: .125em; + + @media screen and (max-width: 992px) { + border-radius: 0; + } + } + + &-cancel { + position: absolute; + top: 50%; + right: 0; + width: 2em; + transform: translateY(-50%); + text-align: center; + font-size: 2em; + cursor: pointer; + + &:after { + display: inline-block; + content: "\00D7"; + color: darken($gray, 20); + } + + @media screen and (max-width: 992px) { + position: relative; + display: flex; + flex-flow: column; + justify-content: center; + align-items: center; + transform: none; + top: auto; + right: auto; + + background: $white; + + &:after { + display: block; + padding: 0 $spacer; + color: $gray; + } + } + } + } + + &__value { + @extend .simplecomplete__input-field; + &:not(.hide) { + & ~ .simplecomplete__input { + display: none; + } + } + + > .selected-option, > .empty-item { + display: inline-block; + cursor: pointer; + padding: $padding; + margin: 0 .125em; + background: $light-gray; + color: $text-secondary; + font-weight: 500; + border-radius: $border-radius; + border: $border-default-transparent; + background: $gray; + color: $black; + + &:first-child { + margin-left: -.25em; + } + + &:last-child { + margin-right: -.25em; + } + } + + > .empty-item { + background: $white; + color: $text-secondary; + font-weight: 400; + } + + &.is-empty { + color: $text-secondary; + } + } + + &.is-multiple, &.is-blankable { + .simplecomplete__value > .selected-option { + &:after { + content: "\00D7"; + display: inline; + margin-left: .25em; + color: darken($text-secondary, 20); + } + } + + .simplecomplete__input-cancel { + display: block; + } + } + + &__results { + position: absolute; + top: 100%; + left: 0; + max-width: 100%; + background: $white; + box-shadow: 0 .125em .25em 0 lighten($black, 60); + z-index: 255; + + border-radius: $border-radius; + + @media screen and (max-width: 992px) { + position: relative; + top: 0; + flex: 1; + overflow-y: auto; + background: $white; + box-shadow: none; + border-radius: 0; + } + + &-list { + border-radius: $border-radius; + overflow: hidden; + overflow-y: auto; + max-height: 30vh; + padding: $spacer * .5; + &-item, &-empty { + cursor: pointer; + display: block; + padding: $spacer * .5 $spacer; + color: $text-secondary; + font-weight: 500; + &:not(:last-child) { + border-bottom: solid 1px lighten($black, 90); + } + + &:hover { + color: $black; + } + + > .highlight { + display: inline; + color: $primary; + border-radius: $border-radius; + } + + &.selected { + font-weight: 500; + background: $primary; + color: $white; + border-radius: $border-radius; + + &:hover { + background: darken($primary, 10); + color: $white; + } + + > .highlight { + color: $text; + background: $yellow; + padding: $spacer * .125 $spacer * .25; + margin: -$spacer * .125 0; + } + } + + &.disabled { + border-radius: $border-radius; + background: $light-gray; + font-style: italic; + } + } + + @media screen and (max-width: 992px) { + max-height: none; + } + } + } + + .hide { + display: none; + } + + &.is-blankable:not(.active) { + .simplecomplete__value > .selected-option { + &:after { + display: none; + } + } + } + + &.active:not(.is-select) { + .simplecomplete__results { + display: block; + } + } + + &.active.is-select, &:not(.is-select) { + .simplecomplete__input-field { + padding: $spacer * 1.05; + } + } + + &.inline { + display: inline-block; + } + + &.active { + border-color: $primary; + @media screen and (max-width: 992px) { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + border-color: transparent; + display: flex; + flex-flow: column nowrap; + background: $gray; + z-index: 255; + border-radius: 0; + border: none; + } + + .simplecomplete__input { + @media screen and (max-width: 992px) { + animation-name: highlightAnim; + animation-duration: 1.3s; + animation-timing-function: ease-in-out; + animation-iteration-count: 1; + } + } + } + + &.is-busy { + border-color: rgba(255,255,255,.6); + background: $primary; + background: linear-gradient(90deg, lighten(red, 30) 0%, lighten(yellow, 30) 25%, lighten(green, 30) 50%, lighten(blue, 30) 75%, lighten(red, 30) 100%); + background-size: 10vw 10vw; + animation-name: scrollingBg; + animation-iteration-count: infinite; + animation-duration: 3s; + animation-timing-function: linear; + + .simplecomplete__input-field { + border-color: $primary; + } + + .simplecomplete__results-list { + padding-top: 1em; + &-empty { + display: none; + } + + &:before, &:after { + display: block; + content: ""; + border-radius: $border-radius; + height: 1.2em; + background: $light-gray; + width: 80%; + margin: 0 1em; + animation-name: pulsingAnim; + animation-iteration-count: infinite; + animation-duration: .8s; + animation-timing-function: linear; + } + + &:after { + width: 60%; + margin-top: .6em; + } + } + } + + &:not(.has-results) { + .simplecomplete__results { + display: none; + } + } +} diff --git a/src/simplecomplete/index.js b/src/simplecomplete/index.js new file mode 100644 index 0000000..22cc982 --- /dev/null +++ b/src/simplecomplete/index.js @@ -0,0 +1,7 @@ +import SimpleComplete from "./simplecomplete.js"; + +/// Make SimpleComplete a global variable. +window.SimpleComplete = SimpleComplete; + +export default SimpleComplete; +export { SimpleComplete }; diff --git a/src/simplecomplete/options.js b/src/simplecomplete/options.js new file mode 100644 index 0000000..8b7f44c --- /dev/null +++ b/src/simplecomplete/options.js @@ -0,0 +1,37 @@ +const defaultOptions = { + url: null, + method: 'GET', + headers: null, + delay: 300, + timeout: 30, + multiple: false, + paramName: 'q', + debug: false, + minLength: null, + placeholder: 'Search', + blankable: false, + + // ui stuff and options/settings + params () {}, + init () {}, + error () {}, + + // user interaction + focus () {}, // user clicks input + blur () {}, // user leaves input + onInput () {}, // user starts input + keydown () {}, // user presses a key + keyup () {}, // user releases a key + select () {}, // user clicks an element + reset () {}, // user resets the element to its initial state + + // data handling + onBeforeRequest () {}, // before the request to the url is sent + onRequest () {}, + onResponse () {}, // server sends a response + onErrorResponse () {}, // server error + onParseItem () {}, // parses an item from response + onRenderItem (out, data) { return out }, // renders an item from response +} + +export default defaultOptions diff --git a/src/simplecomplete/simplecomplete.js b/src/simplecomplete/simplecomplete.js new file mode 100644 index 0000000..a3b4068 --- /dev/null +++ b/src/simplecomplete/simplecomplete.js @@ -0,0 +1,515 @@ +import contentloaded from './utils/contentloaded.js' +import _defaults from './options.js' + +export default class SimpleComplete { + static KEYS = { + ENTER: 13, + TAB: 9 + } + static initClass () { + this.prototype.events = [] + + window.contentloaded = contentloaded + } + + constructor (el, options = null) { + this.results = null + this.query = null + this.launchedRequest = null + this.options = { + ..._defaults, + ...(options || {}) + } + + if ('string' === typeof el) { + let els = document.querySelectorAll(el) + if (els && els.length > 0) { + els.forEach(el => { + if (!SimpleComplete.instances) { + SimpleComplete.instances = [] + } + SimpleComplete.instances.push(new SimpleComplete(el, options)) + }) + } + } else { + this.element = el + + if (this.element.simplecomplete) { + throw new Error('nope') + } + + this.element.simplecomplete = this + this.hideOrigin () + this.initDOM () + this.initEvents () + + this.updateLabel () + } + } + + hideOrigin () { + if(!this.options.debug) { + this.element.style.width = '1px'; + this.element.style.height = '1px'; + this.element.style.position = 'absolute' + this.element.style.left = `${-window.innerWidth * 99}px` + this.element.style.top = '0' + } else { + this.element.style.border = 'solid 3px red' + this.element.style.marginTop = '1em' + this.element.style.marginBottom = '1em' + } + } + + initDOM () { + this.parentNode = this.element.parentNode + + this.wrapper = SimpleComplete.createElement('DIV', { + classes: 'simplecomplete' + }) + + if (this.options.blankable) { + this.wrapper.classList.add('is-blankable') + } + + if (this.isMultiple()) { + this.wrapper.classList.add('is-multiple') + } + + if (this.isSelectElement()) { + this.wrapper.classList.add('is-select') + } + + let inputWrapper = SimpleComplete.createElement('DIV', { + classes: 'simplecomplete__input' + }) + + this.fakeInput = SimpleComplete.createElement('DIV', { + classes: 'simplecomplete__input-field' + }) + this.cancelInputBtn = SimpleComplete.createElement('A', { + classes: 'simplecomplete__input-cancel' + }) + + this.cancelInputBtn.addEventListener('click', e => { + e.preventDefault () + e.stopPropagation () + document.body.dispatchEvent(new Event('click')) + }) + + inputWrapper.appendChild(this.fakeInput) + inputWrapper.appendChild(this.cancelInputBtn) + + this.currentValue = SimpleComplete.createElement('DIV', { + classes: 'simplecomplete__value' + }) + + this.wrapper.appendChild(this.currentValue) + this.wrapper.appendChild(inputWrapper) + + // render results + let resultsWrapper = SimpleComplete.createElement('DIV', { + classes: 'simplecomplete__results' + }) + + this.resultsList = SimpleComplete.createElement('DIV', { + classes: 'simplecomplete__results-list' + }) + + resultsWrapper.appendChild(this.resultsList) + this.wrapper.appendChild(resultsWrapper) + + // insert wrapper into parentnode of element + this.element.parentNode.insertBefore(this.wrapper, this.element.nextSibling) + + this.onResults(Array.from(this.element.options || [])) + } + + initEvents () { + this.element.addEventListener('focus', e => { + e.preventDefault () + this.onFocus () + }) + this.wrapper.addEventListener('click', this.onFocus.bind(this)) + this.currentValue.addEventListener('click', this.onFocus.bind(this)) + + this.fakeInput.addEventListener('keydown', e => { + switch (e.keyCode) { + case SimpleComplete.KEYS.TAB: + case SimpleComplete.KEYS.ENTER: + e.preventDefault() + return false + } + }) + this.fakeInput.addEventListener('keyup', this.onInput.bind(this)) + } + + onInput (e) { + if (this.launchedRequest) { + window.clearTimeout(this.launchedRequest) + } + + switch (e.keyCode) { + case SimpleComplete.KEYS.ENTER: + this.onSend() + case SimpleComplete.KEYS.TAB: + e.preventDefault() + return false + } + + this.query = e.target.textContent.trim() + this.launchedRequest = window.setTimeout(this.onSend.bind(this), this.options.delay || 300) + this.onResults() + } + + onSend (e = null) { + if (!this.query || (this.options.minLength > 0 && this.query.length < this.options.minLength)) { + this.onError('simplecomplete.errors.min-length') + } else { + if (this.wrapper.classList.contains('is-busy')) { + this.queued = true + } else { + console.log('TODO: send request') + this.wrapper.classList.add('is-busy') + window.setTimeout(() => { + this.wrapper.classList.remove('is-busy') + if(this.queued) { + this.queued = false + this.onSend () + } + }, 3000) + } + } + } + + isMultiple () { + return this.element.multiple || false + } + + deselectOption (_val) { + let _opts = Array.from(this.element.selectedOptions) + + for (let _key in _opts) { + let o = _opts[_key] + if (o.value == _val) { + o.selected = false + break + } + } + } + + selectOption (_val) { + let _opts = Array.from(this.element.options) + + for (let _key in _opts) { + let o = _opts[_key] + if (o.value == _val) { + o.selected = true + break + } + } + } + + updateValue (_val = null) { + if (_val !== null || this.options.blankable) { + if (this.isMultiple()) { + if (this.isSelected(_val)) { + this.deselectOption (_val) + } else { + this.selectOption (_val) + } + } else { + this.element.value = _val + } + + } + this.updateLabel () + this.onResults () + } + + isSelectElement () { + return (this.element.tagName || false) && 'SELECT' == this.element.tagName + } + + updateLabel () { + this.currentValue.classList.remove('is-empty') + this.currentValue.innerHTML = '' + if (this.isMultiple() || this.isSelectElement()) { + let _opts = Array.from(this.element.selectedOptions) + for (let _key in _opts) { + let o = _opts[_key] + + let badge = SimpleComplete.createElement('SPAN', { + classes: 'selected-option' + }) + + badge.textContent = o.textContent + badge.dataset.value = o.value + if (this.isMultiple() || this.options.blankable) { + badge.addEventListener('click', e => { + e.preventDefault () + e.stopPropagation () + this.deselectOption(e.target.dataset.value) + this.updateValue() + }) + } + + this.currentValue.appendChild(badge) + } + } + + if(!this.currentValue.childNodes.length) { + let placeholder = this.element.value || this.element.getAttribute('placeholder') || this.element.dataset.placeholder || this.options.placeholder || 'Search' + if (this.isSelectElement()) { + let badge = SimpleComplete.createElement('SPAN', { + classes: 'empty-item' + }) + + badge.textContent = placeholder + + this.currentValue.appendChild(badge) + } else { + this.currentValue.textContent = placeholder + } + + if (placeholder == this.options.placeholder) { + this.currentValue.classList.add('is-empty') + } + } + } + + onError (key) { + console.log(key) + } + + onFocus (e) { + if (e) { + e.stopPropagation() + } + document.body.dispatchEvent(new Event('click')) + this.wrapper.classList.add('active') + if (this.results.length > 0) { + this.wrapper.classList.add('has-results') + } + this.currentValue.classList.add('hide') + this.fakeInput.setAttribute('contenteditable', true) + this.fakeInput.focus() + + this.options.focus() + + document.body.addEventListener('click', e => { + this.onBlur() + }, {once:true}) + } + + onBlur () { + this.query = null + this.fakeInput.textContent = '' + this.wrapper.classList.remove('active') + this.currentValue.classList.remove('hide') + this.fakeInput.setAttribute('contenteditable', false) + + this.wrapper.classList.remove('has-results') + + this.onResults () + + this.options.blur() + } + + onSelect (_val) { + this.updateValue (_val) + document.body.dispatchEvent(new Event('click')) + } + + onResults (data = null) { + if(data !== null) { + this.results = data + } + + if(this.results && this.resultsList) { + this.resultsList.innerHTML = '' + + try { + this.results.filter(res => { + return !this.query || res.value.toString().toLowerCase().indexOf(this.query.toLowerCase()) >= 0 || res.label.toLowerCase().indexOf(this.query.toLowerCase()) >= 0 + }).forEach(res => { + this.resultsList.appendChild(this.renderOption(res)) + }) + } catch(err) { + console.log(err) + } + + if (!this.resultsList.childNodes.length) { + let emptyNode = SimpleComplete.createElement('DIV', { + classes: 'simplecomplete__results-list-empty' + }) + + emptyNode.textContent = this.options.emptyText || 'No results' + + this.resultsList.appendChild(emptyNode) + } + } + } + + isSelected (_val) { + let opts = Array.from(this.element.selectedOptions || []) + for (let _index = 0; _index < opts.length; _index++) { + let option = opts[_index] + if (option.value == _val) { + return true + } + } + return this.element.value == _val || false + } + + renderOption (data) { + let _out = SimpleComplete.createElement('A', { + classes: 'simplecomplete__results-list-item' + }) + + if (this.isSelected(data.value)) { + _out.classList.add('selected') + } + + if (data.disabled) { + _out.classList.add('disabled') + } + + if (!this.isSelected(data.value) || this.isMultiple()) { + _out.addEventListener('click', e => { + e.preventDefault () + e.stopPropagation () + if (!data.disabled) { + this.onSelect(data.value) + } + }) + } + + // cut the label and mark the search term in the label if the search term is set + if (!!this.query) { + let regexp = new RegExp(this.query,'gi') + let label = data.label + + let check = label.matchAll(regexp) + + if(check) { + let hightlightSpan = SimpleComplete.createElement('SPAN', { + classes: 'highlight' + }); + let offset = 0; + [...check].forEach(res => { + hightlightSpan.textContent = res[0] + let parts = [ + label.substr(0, res.index), + hightlightSpan.outerHTML, + label.substr(offset + res.index + res[0].length, label.length + offset) + ] + + offset += hightlightSpan.outerHTML.length + + label = parts.join('') + }) + } + _out.innerHTML = label + } else { + _out.textContent = data.label + } + + try { + return this.options.onRenderItem(_out, data) + } catch(err) { + return _out + } + } +} + +SimpleComplete.initClass () + +SimpleComplete.version = 'dev' + +SimpleComplete.options = {} + +SimpleComplete.instances = [] + +SimpleComplete.autoDiscover = true + +SimpleComplete.discover = () => { + let inputs = document.querySelectorAll('[data-sc]') + let res = [] + + if (inputs && inputs.length > 0) { + inputs.forEach(_in => { + console.log(_in, _in.dataset.blankable || false) + let options = { + minLength: _in.dataset.minLength || null, + blankable: _in.dataset.blankable || false + } + res.push(new SimpleComplete(_in, options)); + }) + } + return res; +} + +SimpleComplete.createElement = function (tag) { + let options = arguments[1] || {} + options = { + id: null, + classes: null, + attributes: null, + styles: null, + data: null, + ...options + } + + let el = document.createElement(tag) + + if (el) { + if (options.styles) { + el.styles = options.styles + } + if (options.classes) { + if ('string' == typeof options.classes) { + options.classes = options.classes.split(' ') + } + options.classes.forEach(_class => el.classList.add(_class)) + } + + if (options.attributes) { + Object.keys(options.attributes).forEach(_key => el.setAttribute(_key, options.attributes[_key])) + } + + if (options.data) { + Object.keys(options.data).forEach(_key => { + el.dataset[_key] = options.data[_key] + }) + } + } + + return el +} + +SimpleComplete.getElement = (el, name) => { + let $el = null + + if (document.querySelectorAll) { + $el = document.querySelector(el) + } else if(!!el.nodeType) { + $el = el + } + + if (!$el) { + throw new Error('') + } + + return $el +} + +SimpleComplete.__autoDiscoverFunc = () => { + if (SimpleComplete.autoDiscover) { + return SimpleComplete.discover() + } +} + +contentloaded(window, SimpleComplete.__autoDiscoverFunc) + +export { SimpleComplete } diff --git a/src/simplecomplete/utils/contentloaded.js b/src/simplecomplete/utils/contentloaded.js new file mode 100644 index 0000000..9cd7c2c --- /dev/null +++ b/src/simplecomplete/utils/contentloaded.js @@ -0,0 +1,56 @@ +/* +* contentloaded.js +* +* Author: Diego Perini (diego.perini at gmail.com) +* Summary: cross-browser wrapper for DOMContentLoaded +* Updated: 20101020 +* License: MIT +* Version: 1.2 +* +* URL: +* http://javascript.nwbox.com/ContentLoaded/ +* http://javascript.nwbox.com/ContentLoaded/MIT-LICENSE +*/ + +export default function contentLoaded (win, fn) { + let done = false; + let top = true; + let doc = win.document; + let root = doc.documentElement; + let add = doc.addEventListener ? "addEventListener" : "attachEvent"; + let rem = doc.addEventListener ? "removeEventListener" : "detachEvent"; + let pre = doc.addEventListener ? "" : "on"; + var init = function (e) { + if (e.type === "readystatechange" && doc.readyState !== "complete") { + return; + } + (e.type === "load" ? win : doc)[rem](pre + e.type, init, false); + if (!done && (done = true)) { + return fn.call(win, e.type || e); + } + }; + + var poll = function () { + try { + root.doScroll("left"); + } catch (e) { + setTimeout(poll, 50); + return; + } + return init("poll"); + }; + + if (doc.readyState !== "complete") { + if (doc.createEventObject && root.doScroll) { + try { + top = !win.frameElement; + } catch (error) {} + if (top) { + poll(); + } + } + doc[add](pre + "DOMContentLoaded", init, false); + doc[add](pre + "readystatechange", init, false); + return win[add](pre + "load", init, false); + } +}