import { clicked } from 'clicked'
import Events from 'eventemitter3'
import STYLES from './styles.json'
class FallDown extends Events
{
/**
* @param {object} options
* @param {(HTMLElement|string)} [options.element] use preexisting element for FallDown with optional data in attributes (provide either options.element or options.parent)
* @param {HTMLElement} [options.parent] use thsi parent to create the FallDown (provide either options.element or options.parent)
* @param {string[]|FallDownElement[]} [options.options] list of values for FallDown box
* @param {string} [options.separatorOptions=","] separator used to split attribute data-options from options.element
* @param {(string|string[]|FallDownElement[]|FallDownElement)} [options.selected=''] default value (may also be in the form of "item1,item2,item3" where ","=options.separatorOptions)
* @param {string} [options.label] label for FallDown box
* @param {string} [options.size] set this to force the editbox to have a certain size regardless of content--this size does not include the arrow size, if any (e.g., '5rem')
* @param {string} [options.minSize=longest] longest=size to largest option; otherwise use this as minWidth (e.g., '5rem')
* @param {boolean} [options.allowEdit] can type entry
* @param {string} [options.multipleName] when set and more than 1 item is selected then text changes to "n items" where n is the number selected and items=multipleName
* @param {string} [options.multipleSeparator=", "] when showing multiple options on the selector, use this to separate the options
* @param {(object|boolean)} [options.arrow=true] change open and close arrows; set to false to remove
* @param {string} [options.arrow.up=▴]
* @param {string} [options.arrow.down=▾]
* @param {boolean} [options.addCSS] append styles directly to DOM instead of using stylesheet
* @param {boolean} [options.addCSSClassName=falldown] change class names of added CSS styles (useful if you want multiple falldown boxes on same page with different styles)
* @param {object} [options.styles] changes default styles if options.addCSS=true
* @param {object} [options.styles.main]
* @param {object} [options.styles.label]
* @param {object} [options.styles.selection]
* @param {object} [options.styles.selected]
* @param {object} [options.styles.arrow]
* @param {object} [options.styles.box]
* @param {object} [options.styles.select]
* @param {object} [options.styles.option]
* @param {object} [options.styles.cursor]
* @param {object} [options.styles.focus]
* @param {object} [options.classNames] change default class names of elements; don't use if options.addCSS=true
* @param {string} [options.classNames.main=falldown-main]
* @param {string} [options.classNames.label=falldown-label]
* @param {string} [options.classNames.selection=falldown-selection]
* @param {string} [options.classNames.selected=falldown-selected]
* @param {string} [options.classNames.arrow=falldown-arrow]
* @param {string} [options.classNames.box=falldown-box]
* @param {string} [options.classNames.select=falldown-select]
* @param {string} [options.classNames.option=falldown-option]
* @param {string} [options.classNames.cursor=falldown-cursor]
* @param {string} [options.classNames.focus=falldown-focus]
* @fires select
*/
constructor(options = {})
{
super()
if (!FallDown.setup)
{
window.addEventListener('resize', FallDown.resize)
window.addEventListener('keydown', FallDown.keydown)
FallDown.setup = true
}
/**
* Main element
* @type HTMLElement
*/
if (options.element)
{
if (typeof options.element === 'string')
{
this.element = document.querySelector(options.element)
if (!this.element)
{
console.warn(`Falldown could not find document.querySelector(${options.element})`)
return
}
}
else
{
this.element = options.element
}
}
else
{
this.element = document.createElement('div')
}
if (options.addCSS || this.element.getAttribute('data-add-css'))
{
this.addStyles(options)
}
this.options = options
this.setupOptions()
this.element.classList.add(options.classNames.main)
let s = `<div class="${options.classNames.label}">${options.label}</div>` +
`<div class="${options.classNames.selection}" tabindex="0">` +
`<div class="${options.classNames.selected}"></div>` +
(options.arrow ? `<div class="${options.classNames.arrow}">${options.arrow.down}</div>` : '') +
`<div class="${options.classNames.box}">`
this.optionsToFallDownOptions(options)
for (let option of this.falldown)
{
s += `<div class="${options.classNames.option}${option.selected ? ` ${options.classNames.select}` : ''}">${option.html}</div>`
}
s += '</div></div>'
this.element.innerHTML = s
/**
* Whether dropdown box is showing
* @type boolean
*/
this.showing = false
this.label = this.element.querySelector('.' + options.classNames.label)
this.selection = this.element.querySelector('.' + options.classNames.selection)
this.selection.setAttribute('tabindex', this.selection.getAttribute('tabindex') || 0)
this.selected = this.selection.querySelector('.' + options.classNames.selected)
this.arrow = this.element.querySelector('.' + options.classNames.arrow)
this.box = this.selection.querySelector('.' + options.classNames.box)
if (options.parent)
{
options.parent.appendChild(this.element)
}
const elements = this.box.querySelectorAll('.' + options.classNames.option)
for (let i = 0; i < elements.length; i++)
{
clicked(elements[i], () =>
{
this.cursorActive = false
this.emit('select', this.select(i))
if (this.options.multiple)
{
this.clearCursor()
}
else
{
this.close()
}
})
}
this.cursor = null
this.options = options
clicked(this.label, () => this.toggle(), { clicked: false, clickDown: true })
if (this.arrow)
{
clicked(this.arrow, () => this.toggle(), { clicked: false, clickDown: true })
}
clicked(this.selected, () => this.toggle(), { clicked: false, clickDown: true })
this.selection.addEventListener('focus', () =>
{
this.focused = true
this.selection.classList.add(this.options.classNames.focus)
FallDown.active = this
})
this.selection.addEventListener('blur', () => this.blur())
this.box.style.display = 'block'
if (this.options.size)
{
this.selected.style.width = this.options.size
this.selected.style.overflow = 'hidden'
}
else if (this.options.minSize === 'longest')
{
let longest = 0
for (let i = 0; i < this.box.childNodes.length; i++)
{
const width = this.box.childNodes[i].offsetWidth
longest = width > longest ? width : longest
this.selected.style.minWidth = longest + 'px'
}
}
else
{
this.selected.style.minWidth = this.options.minSize
}
if (!options.allowEdit)
{
this.selected.style.userSelect = 'none'
}
this.showSelection()
this.box.style.display = 'none'
}
blur()
{
if (this.showing)
{
this.focused = false
this.close()
this.selection.classList.remove(this.options.classNames.focus)
if (FallDown.active === this)
{
FallDown.active = null
}
}
}
/** toggle the falldown box open or closed */
toggle()
{
if (this.showing)
{
this.close()
}
else
{
this.open()
}
}
optionsToFallDownOptions(options)
{
/**
* list of items in falldown box
* @type {FallDownElement[]}
*/
this.falldown = []
let selected = []
if (options.selected)
{
if (Array.isArray(options.selected))
{
if (typeof options.selected[0] === 'string' || !isNaN(options.selected[0]))
{
selected = options.selected
}
else
{
for (let item of options.selected)
{
selected.push(item.value)
}
}
}
else if (typeof options.selected === 'string' || !isNaN(options.selected))
{
if (options.selected.indexOf(options.separatorOptions) !== -1)
{
selected = options.selected.split(options.separatorOptions)
}
else
{
selected.push(options.selected)
}
}
else
{
selected.push(options.selected.value)
}
}
for (let i = 0; i < options.options.length; i++)
{
const option = options.options[i]
if (typeof option === 'string' || !isNaN(option))
{
this.falldown.push({ value: option, html: option, selected: selected.indexOf(option) !== -1 })
}
else
{
this.falldown.push(option)
option.selected = option.selected || selected.indexOf(option.value) !== -1
}
}
}
/**
* returns current value (or array of values)
* @return (string|string[])
*/
get value()
{
const list = []
for (let item of this.falldown)
{
if (item.selected)
{
list.push(item.value)
}
}
if (this.options.multiple)
{
return list
}
else
{
return list[0]
}
}
setupOptions()
{
const options = this.options
const element = this.element
options.selected = options.selected || element.getAttribute('data-selected') || ''
const dataOptions = element.getAttribute('data-options')
options.options = options.options || (dataOptions ? dataOptions.split(options.separatorOptions || ',') : [])
options.label = options.label || element.getAttribute('data-label') || ''
options.multiple = options.multiple || element.getAttribute('data-multiple')
options.multipleName = options.multipleName || element.getAttribute('data-multiple-name')
options.arrow = typeof options.arrow === 'undefined' ? { up: '▴', down: '▾' } : options.arrows
options.minSize = options.minSize || element.getAttribute('data-minsize') || 'longest'
if (!options.classNames)
{
options.classNames = {}
}
options.classNames.main = options.classNames.main || 'falldown-main'
options.classNames.label = options.classNames.label || 'falldown-label'
options.classNames.selection = options.classNames.selection || 'falldown-selection'
options.classNames.box = options.classNames.box || 'falldown-box'
options.classNames.select = options.classNames.select || 'falldown-select'
options.classNames.selected = options.classNames.selected || 'falldown-selected'
options.classNames.arrow = options.classNames.arrow || 'falldown-arrow'
options.classNames.option = options.classNames.option || 'falldown-option'
options.classNames.cursor = options.classNames.cursor || 'falldown-cursor'
options.classNames.focus = options.classNames.focus || 'falldown-focus'
if (options.addCSSClassName)
{
for (let key in options.classNames)
{
options.classNames[key] = options.classNames[key].replace('falldown', options.addCSSClassName)
}
}
}
addStyles(options)
{
options.styles = options.styles || {}
let s = ''
for (let selector in STYLES)
{
const selectorName = options.addCSSClassName ? selector.replace('falldown', options.addCSSClassName) : selector
const simple = selector.replace('.falldown-', '')
s += selectorName + '{'
for (let label in STYLES[selector])
{
const replace = options.styles[simple]
if (!replace || !replace[label])
{
s += label + ':' + STYLES[selector][label] + ';'
}
}
for (let label in options.styles[simple])
{
s += label + ':' + options.styles[simple][label] + ';'
}
s += '}'
}
const style = document.createElement('style')
style.innerText = s
document.head.appendChild(style)
}
/**
* open falldown box
*/
open()
{
if (!this.showing)
{
this.box.style.display = 'block'
if (this.options.arrow)
{
this.arrow.innerHTML = this.options.arrow.up
}
this.resizeBox()
FallDown.active = this
this.cursor = null
this.showing = true
this.selection.focus()
}
}
resizeBox()
{
const width = window.innerWidth
const height = window.innerHeight
let box = this.box.getBoundingClientRect()
const selection = this.selected.getBoundingClientRect()
this.box.style.maxHeight = 'unset'
if (selection.top + selection.height / 2 > height / 2)
{
this.box.style.bottom = this.selection.offsetHeight + 'px'
this.box.style.top = 'unset'
const box = this.box.getBoundingClientRect()
if (box.top < 0)
{
const style = window.getComputedStyle(this.box)
const spacing = parseFloat(style.marginLeft) + parseFloat(style.marginRight) + parseFloat(style.paddingLeft) + parseFloat(style.paddingRight) + parseFloat(style.borderLeftWidth) + parseFloat(style.borderRightWidth)
this.box.style.maxHeight = box.height + box.top - spacing + 'px'
}
}
else
{
this.box.style.top = this.selection.offsetHeight + 'px'
this.box.style.bottom = 'unset'
const box = this.box.getBoundingClientRect()
if (box.bottom > height)
{
const style = window.getComputedStyle(this.box)
const spacing = parseFloat(style.marginTop) + parseFloat(style.marginBottom) + parseFloat(style.paddingTop) + parseFloat(style.paddingBottom) + parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth)
this.box.style.maxHeight = height - box.top - spacing + 'px'
}
}
this.box.style.width = 'fit-content'
if (selection.left >= width / 2)
{
this.box.style.right = 0
this.box.style.left = 'unset'
box = this.box.getBoundingClientRect()
if (box.left < 0)
{
this.box.style.right = box.left + 'px'
this.box.style.left = 'unset'
}
}
else
{
this.box.style.left = 0
this.box.style.right = 'unset'
box = this.box.getBoundingClientRect()
if (box.right >= width)
{
this.box.style.left = width - box.right + 'px'
this.box.style.right = 'unset'
}
}
const lastCheck = this.box.getBoundingClientRect()
if (lastCheck.left < 0 || lastCheck.right > window.innerWidth)
{
const check = this.element.getBoundingClientRect()
this.box.style.width = 'calc(100vw - 2rem - 4px)'
this.box.style.left = -check.left + 'px'
this.box.style.right = 'auto'
}
}
/**
* close falldown box
*/
close()
{
if (this.showing)
{
this.clearCursor()
this.box.style.display = 'none'
this.selection.classList.remove(this.options.classNames.focus)
if (this.options.arrow)
{
this.arrow.innerHTML = this.options.arrow.down
}
this.showing = false
}
}
setCursor(i)
{
if (this.cursorActive)
{
this.clearCursor()
if (i === this.box.childNodes.length)
{
i = 0
}
else if (i === -1)
{
i = this.box.childNodes.length - 1
}
this.cursor = i
this.box.childNodes[this.cursor].classList.add(this.options.classNames.cursor)
this.scrollIntoBoxView(this.box.childNodes[this.cursor])
}
}
// TODO: does not work properly when scrolling up :(
scrollIntoBoxView(element)
{
const bounding = element.getBoundingClientRect()
if (bounding.top < 0)
{
this.box.scrollTop -= bounding.top
}
else if (bounding.bottom > window.innerHeight)
{
this.box.scrollTop -= window.innerHeight - bounding.bottom
}
}
clearCursor()
{
if (this.cursor !== null)
{
this.box.childNodes[this.cursor].classList.remove(this.options.classNames.cursor)
}
}
/**
* force selection of options based on value (clearing the remaining options)
* @param {*} input or array of values to select
*/
force(input)
{
if (this.options.multiple)
{
for (let i = 0; i < this.falldown.length; i++)
{
if (input.indexOf(i) !== -1)
{
this.box.childNodes[i].classList.add(this.options.classNames.select)
this.falldown[i].selected = true
}
else
{
this.box.childNodes[i].classList.remove(this.options.classNames.select)
this.falldown[i].selected = false
}
}
this.showSelection()
}
else
{
for (let i = 0; i < this.falldown.length; i++)
{
if (this.falldown[i].value === input)
{
this.box.childNodes[i].classList.add(this.options.classNames.select)
this.falldown[i].selected = true
this.selection.innerHTML = input
}
else
{
this.box.childNodes[i].classList.remove(this.options.classNames.select)
this.falldown[i].selected = false
}
}
this.showSelection()
}
}
/**
* remove selected option
* @param {number} index
*/
remove(index)
{
this.box.childNodes[index].classList.remove(this.options.classNames.select)
this.falldown[index].selected = false
return this.showSelection()
}
showSelection()
{
const list = []
for (let i = 0; i < this.falldown.length; i++)
{
if (this.falldown[i].selected)
{
list.push(this.falldown[i].html)
}
}
if (list.length > 1)
{
if (this.options.multipleName)
{
this.selected.innerHTML = list.length + this.options.multipleName
}
else
{
let s = ''
for (let i = 0; i < list.length - 1; i++)
{
s += list[i] + (this.options.multipleSeparator || ', ')
}
this.selected.innerHTML = s + list[list.length - 1]
}
}
else if (list.length === 1)
{
this.selected.innerHTML = list[0]
}
else
{
this.selected.innerHTML = ''
}
return list
}
/**
* clear all selections
*/
clear()
{
for (let i = 0; i < this.falldown.length; i++)
{
this.box.childNodes[i].classList.remove(this.options.classNames.select)
this.falldown[i].selected = false
}
this.showSelection()
}
/**
* select option by HTML
* @param {string} name
*/
selectByHTML(html)
{
for (let i = 0; i < this.falldown.length; i++)
{
if (this.falldown.html === html)
{
return this.select(i)
}
}
}
/**
* select option by index
* @param {number} index
*/
select(index)
{
if (this.cursor)
{
this.clearCursor()
}
if (this.options.multiple)
{
const changed = this.falldown[index]
changed.selected = !changed.selected
this.box.childNodes[index].classList.toggle(this.options.classNames.select)
this.showSelection()
this.setCursor(index)
return { changed, value: this.value, falldown: this }
}
else
{
for (let i = 0; i < this.falldown.length; i++)
{
if (i === index)
{
this.box.childNodes[i].classList.add(this.options.classNames.select)
this.falldown[i].selected = true
}
else
{
this.box.childNodes[i].classList.remove(this.options.classNames.select)
this.falldown[i].selected = false
}
}
this.showSelection()
return { changed: this.falldown[index], value: this.value, falldown: this }
}
}
/**
* get index (or list of indices) for selected value(s)
* @return (number|number[])
*/
getIndex()
{
const list = []
for (let i = 0; i < this.falldown.length; i++)
{
if (this.falldown[i].selected)
{
list.push(i)
}
}
if (this.options.multiple)
{
return list
}
else
{
return list.length ? list[0] : null
}
}
selectDelta(delta, unset)
{
if (this.options.multiple)
{
this.setCursor(this.cursor === null ? 0 : this.cursor + delta)
}
else
{
let index = this.getIndex()
if (index === null)
{
index = unset
}
else
{
index += delta
index = index < 0 ? this.falldown.length + index : index
index = index >= this.falldown.length ? index - this.falldown.length : index
}
this.box.childNodes[index].scrollIntoView()
this.emit('select', this.select(index))
}
}
static cancel()
{
const active = FallDown.active
if (active && active.showing)
{
active.close()
}
}
static keydown(e)
{
const active = FallDown.active
if (active)
{
switch (e.key)
{
case 'ArrowDown':
active.cursorActive = true
if (!active.showing)
{
active.open()
}
else
{
active.selectDelta(1, 0)
}
e.preventDefault()
break
case 'ArrowUp':
active.cursorActive = true
if (!active.showing)
{
active.open()
}
else
{
active.selectDelta(-1, active.box.childNodes.length - 1)
}
e.preventDefault()
break
case 'Space':
case 'Enter':
case ' ':
active.cursorActive = true
if (!active.showing)
{
active.open()
}
else
{
if (active.options.multiple)
{
active.emit('select', active.select(active.cursor))
if (!active.options.multiple)
{
active.close()
}
}
else
{
active.close()
}
}
e.preventDefault()
break
case 'Escape':
active.cursorActive = false
active.close()
e.preventDefault()
break
}
}
}
/**
* load falldown on all elements with the proper className
* @param {string} className=falldown type convert
*/
static load(className = 'falldown')
{
const divs = document.querySelectorAll('.' + className)
for (let i = 0; i < divs.length; i++)
{
new FallDown({ element: divs[i] })
}
}
static resize()
{
const active = FallDown.active
if (active && active.showing)
{
active.resizeBox()
}
}
}
/**
* @typedef {Object} FallDown#FallDownElement
* @property {*} value
* @property {string} html to display
*/
/**
* fires when the selection of the falldown changes
* @event FallDown#select
* @type {object}
* @property {FallDownElement} changed
* @property {*} value - array of values (for option.multiple) or value of selected item
* @property {FallDown} falldown - FallDown element
*/
export { FallDown }