feat(prototype): the first real 💩 prototype version

this is the first real working interface with almost what i imagined it to be like.

Next: xhr implementation with requesting sources to update the possible choices
This commit is contained in:
Benjamin Wegener
2021-10-09 12:49:29 +02:00
parent 4bbc5cc832
commit d4bb20146c
7 changed files with 993 additions and 0 deletions

2
src/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*.css
*.map

22
src/scss/_variables.scss Normal file
View File

@@ -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;

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,7 @@
import SimpleComplete from "./simplecomplete.js";
/// Make SimpleComplete a global variable.
window.SimpleComplete = SimpleComplete;
export default SimpleComplete;
export { SimpleComplete };

View File

@@ -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

View File

@@ -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 }

View File

@@ -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);
}
}