slim-select
Version:
Slim advanced select dropdown
1,455 lines (1,239 loc) • 48.4 kB
text/typescript
import { debounce } from './helpers'
import Settings from './settings'
import Store, { DataArray, Optgroup, Option, OptionOptional } from './store'
import CssClasses from './classes'
export interface Callbacks {
open: () => void
close: () => void
addable?: (
value: string
) => Promise<OptionOptional | string> | OptionOptional | string | false | undefined | null | Error
setSelected: (value: string | string[], runAfterChange: boolean) => void
addOption: (option: Option) => void
search: (search: string) => void
beforeChange?: (newVal: Option[], oldVal: Option[]) => boolean | void
afterChange?: (newVal: Option[]) => void
}
export interface Main {
main: HTMLDivElement
values: HTMLDivElement
deselect: {
main: HTMLDivElement
svg: SVGSVGElement
path: SVGPathElement
}
arrow: {
main: SVGSVGElement
path: SVGPathElement
}
}
export interface Content {
main: HTMLDivElement
search: Search
list: HTMLDivElement
}
export interface Search {
main: HTMLDivElement
input: HTMLInputElement
addable?: {
main: HTMLDivElement
svg: SVGSVGElement
path: SVGPathElement
}
}
export default class Render {
public settings: Settings
public store: Store
public callbacks: Callbacks
// Elements
public main: Main
public content: Content
// Classes
public classes: CssClasses
constructor(settings: Required<Settings>, classes: Required<CssClasses>, store: Store, callbacks: Callbacks) {
this.store = store
this.settings = settings
this.classes = classes
this.callbacks = callbacks
this.main = this.mainDiv()
this.content = this.contentDiv()
// Add classes and styles to main/content
this.updateClassStyles()
this.updateAriaAttributes()
// Add content to the content location settings
if (this.settings.contentLocation) {
this.settings.contentLocation.appendChild(this.content.main)
}
}
// Remove disabled classes
public enable(): void {
// Remove disabled class
this.main.main.classList.remove(this.classes.disabled)
// Set search input to "enabled"
this.content.search.input.disabled = false
}
// Set disabled classes
public disable(): void {
// Add disabled class
this.main.main.classList.add(this.classes.disabled)
// Set search input to disabled
this.content.search.input.disabled = true
}
public open(): void {
this.main.arrow.path.setAttribute('d', this.classes.arrowOpen)
// Add class to main container
this.main.main.classList.add(this.settings.openPosition === 'up' ? this.classes.openAbove : this.classes.openBelow)
this.main.main.setAttribute('aria-expanded', 'true')
// move the content in to the right location
this.moveContent()
// Move to last selected option
const selectedOptions = this.store.getSelectedOptions()
if (selectedOptions.length) {
const selectedId = selectedOptions[selectedOptions.length - 1].id
const selectedOption = this.content.list.querySelector('[data-id="' + selectedId + '"]') as HTMLElement
if (selectedOption) {
this.ensureElementInView(this.content.list, selectedOption)
}
}
}
public close(): void {
this.main.main.classList.remove(this.classes.openAbove)
this.main.main.classList.remove(this.classes.openBelow)
this.main.main.setAttribute('aria-expanded', 'false')
this.content.main.classList.remove(this.classes.openAbove)
this.content.main.classList.remove(this.classes.openBelow)
this.main.arrow.path.setAttribute('d', this.classes.arrowClose)
}
public updateClassStyles(): void {
// Clear all classes and styles
this.main.main.className = ''
this.main.main.removeAttribute('style')
this.content.main.className = ''
this.content.main.removeAttribute('style')
// Make sure main/content has its base class
this.main.main.classList.add(this.classes.main)
this.content.main.classList.add(this.classes.content)
// Add styles
if (this.settings.style !== '') {
this.main.main.style.cssText = this.settings.style
this.content.main.style.cssText = this.settings.style
}
// Add classes
if (this.settings.class.length) {
for (const c of this.settings.class) {
if (c.trim() !== '') {
this.main.main.classList.add(c.trim())
this.content.main.classList.add(c.trim())
}
}
}
// Misc classes
// Add content position class
if (this.settings.contentPosition === 'relative' || this.settings.contentPosition === 'fixed') {
this.content.main.classList.add('ss-' + this.settings.contentPosition)
}
}
public updateAriaAttributes() {
this.main.main.role = 'combobox'
this.main.main.setAttribute('aria-haspopup', 'listbox')
this.main.main.setAttribute('aria-controls', this.content.main.id)
this.main.main.setAttribute('aria-expanded', 'false')
this.content.main.setAttribute('role', 'listbox')
// do an aria-labelledby here maybe
}
public mainDiv(): Main {
// Create main container
const main = document.createElement('div')
// Add id to data-id
main.dataset.id = this.settings.id
// main.id = this.settings.id+'-main' // Remove for now as it is not needed and add duplicate id errors
// Add label
main.setAttribute('aria-label', this.settings.ariaLabel)
// Set tabable to allow tabbing to the element
main.tabIndex = 0
// Deal with keyboard events on the main div
// This is to allow for normal selecting
// when you may not have a search bar
main.onkeydown = (e: KeyboardEvent): boolean => {
// Convert above if else statemets to switch
switch (e.key) {
case 'ArrowUp':
case 'ArrowDown':
this.callbacks.open()
e.key === 'ArrowDown' ? this.highlight('down') : this.highlight('up')
return false
case 'Tab':
this.callbacks.close()
return true // Continue doing normal tabbing
case 'Enter':
case ' ':
this.callbacks.open()
const highlighted = this.content.list.querySelector('.' + this.classes.highlighted) as HTMLDivElement
if (highlighted) {
highlighted.click()
}
return false
case 'Escape':
this.callbacks.close()
return false
}
// Check if they type a-z, A-Z and 0-9
if (e.key.length === 1) {
this.callbacks.open()
}
return true
}
// Add onclick for main div
main.onclick = (e: Event): void => {
// Dont do anything if disabled
if (this.settings.disabled) {
return
}
this.settings.isOpen ? this.callbacks.close() : this.callbacks.open()
}
// Add values
const values = document.createElement('div')
values.classList.add(this.classes.values)
main.appendChild(values)
// Add deselect
const deselect = document.createElement('div')
deselect.classList.add(this.classes.deselect)
// Check if deselect is to be shown or not
const selectedOptions = this.store?.getSelectedOptions()
if (!this.settings.allowDeselect || (this.settings.isMultiple && selectedOptions && selectedOptions.length <= 0)) {
deselect.classList.add(this.classes.hide)
} else {
deselect.classList.remove(this.classes.hide)
}
// Add deselect onclick event
deselect.onclick = (e: Event) => {
e.stopPropagation()
// Dont do anything if disabled
if (this.settings.disabled) {
return
}
// By Default we will delete
let shouldDelete = true
const before = this.store.getSelectedOptions()
const after = [] as Option[]
// Add beforeChange callback
if (this.callbacks.beforeChange) {
shouldDelete = this.callbacks.beforeChange(after, before) === true
}
if (shouldDelete) {
if (this.settings.isMultiple) {
this.callbacks.setSelected([], false)
this.updateDeselectAll()
} else {
// Get first option and set it as selected
const firstOption = this.store.getFirstOption()
const id = firstOption ? firstOption.id : ''
this.callbacks.setSelected(id, false)
}
// Check if we need to close the dropdown
if (this.settings.closeOnSelect) {
this.callbacks.close()
}
// Run afterChange callback
if (this.callbacks.afterChange) {
this.callbacks.afterChange(this.store.getSelectedOptions())
}
}
}
// Add deselect svg
const deselectSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
deselectSvg.setAttribute('viewBox', '0 0 100 100')
const deselectPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
deselectPath.setAttribute('d', this.classes.deselectPath)
deselectSvg.appendChild(deselectPath)
deselect.appendChild(deselectSvg)
main.appendChild(deselect)
// Add arrow
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
arrow.classList.add(this.classes.arrow)
arrow.setAttribute('viewBox', '0 0 100 100')
const arrowPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
arrowPath.setAttribute('d', this.classes.arrowClose)
if (this.settings.alwaysOpen) {
arrow.classList.add(this.classes.hide)
}
arrow.appendChild(arrowPath)
main.appendChild(arrow)
return {
main: main,
values: values,
deselect: {
main: deselect,
svg: deselectSvg,
path: deselectPath
},
arrow: {
main: arrow,
path: arrowPath
}
}
}
public mainFocus(eventType: string | null): void {
// Trigger focus but dont scroll to it
// Need for prevent refocus the element if event is not keyboard event.
// For example if event is mouse click or tachpad click this condition prevent refocus on element
// because click by mouse change focus position and not need return focus to element.
if (eventType !== 'click') {
this.main.main.focus({ preventScroll: true })
}
}
public placeholder(): HTMLDivElement {
// Figure out if there is a placeholder option
const placeholderOption = this.store.filter((o) => o.placeholder, false) as Option[]
// If there is a placeholder option use that
// If placeholder has an html value, use that
// If placeholder has a text, use that
// If nothing is set, use the placeholder text
let placeholderText = this.settings.placeholderText
if (placeholderOption.length) {
if (placeholderOption[0].html !== '') {
placeholderText = placeholderOption[0].html
} else if (placeholderOption[0].text !== '') {
placeholderText = placeholderOption[0].text
}
}
// Create placeholder div
const placeholder = document.createElement('div')
placeholder.classList.add(this.classes.placeholder)
placeholder.innerHTML = placeholderText
return placeholder
}
// Get selected values and append to multiSelected values container
// and remove those who shouldnt exist
public renderValues(): void {
// If single select set placeholder or selected value
if (!this.settings.isMultiple) {
this.renderSingleValue()
return
}
this.renderMultipleValues()
this.updateDeselectAll()
}
private renderSingleValue(): void {
const selected = this.store.filter((o: Option): boolean => {
return o.selected && !o.placeholder
}, false) as Option[]
const selectedSingle = selected.length > 0 ? selected[0] : null
// If nothing is seleected use settings placeholder text
if (!selectedSingle) {
this.main.values.innerHTML = this.placeholder().outerHTML
} else {
// Create single value container
const singleValue = document.createElement('div')
singleValue.classList.add(this.classes.single)
if (selectedSingle.html) {
singleValue.innerHTML = selectedSingle.html
} else {
singleValue.innerText = selectedSingle.text
}
// If there is a selected value, set a single div
this.main.values.innerHTML = singleValue.outerHTML
}
// If allowDeselect is false or selected value is empty just hide deselect
if (!this.settings.allowDeselect || !selected.length) {
this.main.deselect.main.classList.add(this.classes.hide)
} else {
this.main.deselect.main.classList.remove(this.classes.hide)
}
}
private renderMultipleValues(): void {
// Get various pieces of data
let currentNodes = this.main.values.childNodes as NodeListOf<HTMLDivElement>
let selectedOptions = this.store.filter((opt: Option) => {
// Only grab options that are selected and display is true
return opt.selected && opt.display
}, false) as Option[]
// If selectedOptions is empty set placeholder
if (selectedOptions.length === 0) {
this.main.values.innerHTML = this.placeholder().outerHTML
return
} else {
// If there is a placeholder, remove it
const placeholder = this.main.values.querySelector('.' + this.classes.placeholder)
if (placeholder) {
placeholder.remove()
}
}
// If selectedOptions is greater than maxItems, set maxValuesMessage
if (selectedOptions.length > this.settings.maxValuesShown) {
// Creating the element that shows the number of selected items
const singleValue = document.createElement('div')
singleValue.classList.add(this.classes.max)
singleValue.textContent = this.settings.maxValuesMessage.replace('{number}', selectedOptions.length.toString())
// If there is a selected value, set a single div
this.main.values.innerHTML = singleValue.outerHTML
return
} else {
// If there is a message, remove it
const maxValuesMessage = this.main.values.querySelector('.' + this.classes.max)
if (maxValuesMessage) {
maxValuesMessage.remove()
}
}
// Lets check for data selected order
if (this.settings.keepOrder) {
selectedOptions = this.store.selectedOrderOptions(selectedOptions)
}
// Loop through currentNodes and only include ones that are not in selectedIDs
let removeNodes: HTMLDivElement[] = []
for (let i = 0; i < currentNodes.length; i++) {
const node = currentNodes[i]
const id = node.getAttribute('data-id')
if (id) {
// Check if id is in selectedOptions
const found = selectedOptions.filter((opt: Option) => {
return opt.id === id
}, false)
// If not found, add to removeNodes
if (!found.length) {
removeNodes.push(node)
}
}
}
// Loop through and remove
for (const n of removeNodes) {
n.classList.add(this.classes.valueOut)
setTimeout(() => {
if (this.main.values.hasChildNodes() && this.main.values.contains(n)) {
this.main.values.removeChild(n)
}
}, 100)
}
// Add values that dont currently exist
currentNodes = this.main.values.childNodes as NodeListOf<HTMLDivElement>
for (let d = 0; d < selectedOptions.length; d++) {
let shouldAdd = true
for (let i = 0; i < currentNodes.length; i++) {
if (selectedOptions[d].id === String(currentNodes[i].dataset.id)) {
shouldAdd = false
}
}
// If shouldAdd, insertAdjacentElement it to the values container in the order of the selectedOptions
if (shouldAdd) {
// If keepOrder is true, we will just append it to the end
if (this.settings.keepOrder) {
this.main.values.appendChild(this.multipleValue(selectedOptions[d]))
} else {
// else we will insert it in the order of the selectedOptions
if (currentNodes.length === 0) {
this.main.values.appendChild(this.multipleValue(selectedOptions[d]))
} else if (d === 0) {
this.main.values.insertBefore(this.multipleValue(selectedOptions[d]), currentNodes[d])
} else {
currentNodes[d - 1].insertAdjacentElement('afterend', this.multipleValue(selectedOptions[d]))
}
}
}
}
}
public multipleValue(option: Option): HTMLDivElement {
const value = document.createElement('div')
value.classList.add(this.classes.value)
value.dataset.id = option.id
const text = document.createElement('div')
text.classList.add(this.classes.valueText)
text.textContent = option.text // For multiple values always use text
value.appendChild(text)
// Only add deletion if the option is not mandatory
if (!option.mandatory) {
// Create delete div element
const deleteDiv = document.createElement('div')
deleteDiv.classList.add(this.classes.valueDelete)
// Add delete onclick event
deleteDiv.onclick = (e: Event) => {
e.preventDefault()
e.stopPropagation()
// Dont do anything if disabled
if (this.settings.disabled) {
return
}
// By Default we will delete
let shouldDelete = true
const before = this.store.getSelectedOptions()
const after = before.filter((o) => {
return o.selected && o.id !== option.id
}, true)
// Check if minSelected is set and if after length so, return
if (this.settings.minSelected && after.length < this.settings.minSelected) {
return
}
// If there is a beforeDeselect function run it
if (this.callbacks.beforeChange) {
shouldDelete = this.callbacks.beforeChange(after, before) === true
}
if (shouldDelete) {
// Loop through after and append ids to a variable called selected
let selectedIds: string[] = []
for (const o of after) {
if (o instanceof Optgroup) {
for (const c of o.options) {
selectedIds.push(c.id)
}
}
if (o instanceof Option) {
selectedIds.push(o.id)
}
}
this.callbacks.setSelected(selectedIds, false)
// Check if we need to close the dropdown
if (this.settings.closeOnSelect) {
this.callbacks.close()
}
// Run afterChange callback
if (this.callbacks.afterChange) {
this.callbacks.afterChange(after)
}
this.updateDeselectAll()
}
}
// Add delete svg
const deleteSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
deleteSvg.setAttribute('viewBox', '0 0 100 100')
const deletePath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
deletePath.setAttribute('d', this.classes.optionDelete)
deleteSvg.appendChild(deletePath)
deleteDiv.appendChild(deleteSvg)
value.appendChild(deleteDiv)
}
return value
}
public contentDiv(): Content {
const main = document.createElement('div')
// Add id to data-id
main.dataset.id = this.settings.id
// main.id = this.settings.id + '-content' // Remove for now as it is not needed and add duplicate id errors
// Add search
const search = this.searchDiv()
main.appendChild(search.main)
// Add list
const list = this.listDiv()
main.appendChild(list)
return {
main: main,
search: search,
list: list
}
}
public moveContent(): void {
// If contentPosition is relative, dont move the content anywhere other than below
if (this.settings.contentPosition === 'relative') {
this.moveContentBelow()
return
}
// If openContent is not auto set content
if (this.settings.openPosition === 'down') {
this.moveContentBelow()
return
} else if (this.settings.openPosition === 'up') {
this.moveContentAbove()
return
}
// Auto - Determine where to put the content
if (this.putContent() === 'up') {
this.moveContentAbove()
} else {
this.moveContentBelow()
}
}
public searchDiv(): Search {
const main = document.createElement('div')
const input = document.createElement('input')
const addable = document.createElement('div')
main.classList.add(this.classes.search)
// Setup search return object
const searchReturn: Search = {
main,
input
}
// We still want the search to be tabable but not shown
if (!this.settings.showSearch) {
main.classList.add(this.classes.hide)
input.readOnly = true
}
input.type = 'search'
input.placeholder = this.settings.searchPlaceholder
input.tabIndex = -1
input.setAttribute('aria-label', this.settings.searchPlaceholder)
input.setAttribute('autocapitalize', 'off')
input.setAttribute('autocomplete', 'off')
input.setAttribute('autocorrect', 'off')
input.oninput = debounce((e: Event) => {
this.callbacks.search((e.target as HTMLInputElement).value)
}, 100)
// Deal with keyboard events on search input field
input.onkeydown = (e: KeyboardEvent): boolean => {
// Convert above if else statemets to switch
switch (e.key) {
case 'ArrowUp':
case 'ArrowDown':
e.key === 'ArrowDown' ? this.highlight('down') : this.highlight('up')
return false
case 'Tab':
// When tabbing close the dropdown
// which will also focus on main div
// and then continuing normal tabbing
this.callbacks.close()
return true // Continue doing normal tabbing
case 'Escape':
this.callbacks.close()
return false
case ' ':
const highlighted = this.content.list.querySelector('.' + this.classes.highlighted) as HTMLDivElement
if (highlighted) {
highlighted.click()
return false
}
return true
case 'Enter':
if (this.callbacks.addable) {
addable.click()
return false
} else {
const highlighted = this.content.list.querySelector('.' + this.classes.highlighted) as HTMLDivElement
if (highlighted) {
highlighted.click()
return false
}
}
return true
}
return true // Allow normal typing
}
main.appendChild(input)
// If addable is enabled, add the addable div
if (this.callbacks.addable) {
// Add main class
addable.classList.add(this.classes.addable)
// Add svg icon
const plus = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
plus.setAttribute('viewBox', '0 0 100 100')
const plusPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
plusPath.setAttribute('d', this.classes.addablePath)
plus.appendChild(plusPath)
addable.appendChild(plus)
// Add click event to addable div
addable.onclick = (e: Event) => {
e.preventDefault()
e.stopPropagation()
// Do nothing if addable is not set
if (!this.callbacks.addable) {
return
}
// Grab input value
const inputValue = this.content.search.input.value.trim()
if (inputValue === '') {
this.content.search.input.focus()
return
}
// Run finish will be ran at the end of the addable function.
// Reason its in a function is so we can run it after the
// addable function is done for promise based addables
const runFinish = (oo: OptionOptional) => {
let newOption = new Option(oo)
// Call addOption to add the new option
this.callbacks.addOption(newOption)
// set selected value for single and multiple
if (this.settings.isMultiple) {
let ids = this.store.getSelected()
ids.push(newOption.id)
this.callbacks.setSelected(ids, true)
} else {
this.callbacks.setSelected([newOption.id], true)
}
// Clear search
this.callbacks.search('')
// Close it only if closeOnSelect = true
if (this.settings.closeOnSelect) {
setTimeout(() => {
// Give it a little padding for a better looking animation
this.callbacks.close()
}, 100)
}
}
// Call addable callback
const addableValue = this.callbacks.addable(inputValue)
// If addableValue is false, undefined or null, do nothing
if (addableValue === false || addableValue === undefined || addableValue === null) {
return
}
// If addableValue is a promise, wait for it to resolve
if (addableValue instanceof Promise) {
addableValue.then((value) => {
if (typeof value === 'string') {
runFinish({
text: value,
value: value
})
} else if (addableValue instanceof Error) {
this.renderError(addableValue.message)
} else {
runFinish(value)
}
})
} else if (typeof addableValue === 'string') {
runFinish({
text: addableValue,
value: addableValue
})
} else if (addableValue instanceof Error) {
this.renderError(addableValue.message)
} else {
runFinish(addableValue)
}
return
}
main.appendChild(addable)
// Add the addable to the search return
searchReturn.addable = {
main: addable,
svg: plus,
path: plusPath
}
}
return searchReturn
}
public searchFocus(): void {
this.content.search.input.focus()
}
public getOptions(notPlaceholder = false, notDisabled = false, notHidden = false): HTMLDivElement[] {
// Put together query string
let query = '.' + this.classes.option
if (notPlaceholder) {
query += ':not(.' + this.classes.placeholder + ')'
}
if (notDisabled) {
query += ':not(.' + this.classes.disabled + ')'
}
if (notHidden) {
query += ':not(.' + this.classes.hide + ')'
}
return Array.from(this.content.list.querySelectorAll(query))
}
// highlightUp is used to highlight the previous option in the list
public highlight(dir: 'up' | 'down'): void {
// Get full list of options in list
const options = this.getOptions(true, true, true)
// If there are no options, do nothing
if (options.length === 0) {
return
}
// If length is 1, highlight it
if (options.length === 1) {
// Check if option doesnt already have highlighted class
if (!options[0].classList.contains(this.classes.highlighted)) {
options[0].classList.add(this.classes.highlighted)
return
}
}
// Loop through options and see if there are no highlighted ones
let highlighted = false
for (const o of options) {
if (o.classList.contains(this.classes.highlighted)) {
highlighted = true
}
}
// If no highlighted, see if any are selected and if so highlight selected first one
if (!highlighted) {
for (const o of options) {
if (o.classList.contains(this.classes.selected)) {
o.classList.add(this.classes.highlighted)
break
}
}
}
// Loop through options and find the highlighted one
for (let i = 0; i < options.length; i++) {
// Found highlighted option
if (options[i].classList.contains(this.classes.highlighted)) {
const prevOption = options[i]
// Remove highlighted class from current one
prevOption.classList.remove(this.classes.highlighted)
// If previous option has parent classes ss-optgroup with ss-open then click it
const prevParent = prevOption.parentElement
if (prevParent && prevParent.classList.contains(this.classes.open)) {
const optgroupLabel = prevParent.querySelector('.' + this.classes.optgroupLabel) as HTMLDivElement
if (optgroupLabel) {
optgroupLabel.click()
}
}
// Highlight the next one
let selectOption =
options[dir === 'down' ? (i + 1 < options.length ? i + 1 : 0) : i - 1 >= 0 ? i - 1 : options.length - 1]
selectOption.classList.add(this.classes.highlighted)
this.ensureElementInView(this.content.list, selectOption)
// If selected option has parent classes ss-optgroup with ss-close then click it
const selectParent = selectOption.parentElement
if (selectParent && selectParent.classList.contains(this.classes.close)) {
const optgroupLabel = selectParent.querySelector('.' + this.classes.optgroupLabel) as HTMLDivElement
if (optgroupLabel) {
optgroupLabel.click()
}
}
return
}
}
// If we get here, there is no highlighted option
// So we will highlight the first or last based upon direction
options[dir === 'down' ? 0 : options.length - 1].classList.add(this.classes.highlighted)
// Scroll to highlighted one
this.ensureElementInView(this.content.list, options[dir === 'down' ? 0 : options.length - 1])
}
// Create main container that options will reside
public listDiv(): HTMLDivElement {
const options = document.createElement('div')
options.classList.add(this.classes.list)
return options
}
public renderError(error: string) {
// Clear out innerHtml
this.content.list.innerHTML = ''
const errorDiv = document.createElement('div')
errorDiv.classList.add(this.classes.error)
errorDiv.textContent = error
this.content.list.appendChild(errorDiv)
}
public renderSearching() {
// Clear out innerHtml
this.content.list.innerHTML = ''
const searchingDiv = document.createElement('div')
searchingDiv.classList.add(this.classes.searching)
searchingDiv.textContent = this.settings.searchingText
this.content.list.appendChild(searchingDiv)
}
// Take in data and add options to
public renderOptions(data: DataArray): void {
// Clear out innerHtml
this.content.list.innerHTML = ''
// If no results show no results text
if (data.length === 0) {
const noResults = document.createElement('div')
noResults.classList.add(this.classes.search)
//
if (this.callbacks.addable) {
noResults.innerHTML = this.settings.addableText.replace('{value}', this.content.search.input.value)
} else {
noResults.innerHTML = this.settings.searchText
}
this.content.list.appendChild(noResults)
return
}
// If settings has allowDeselect and isSingle, add empty placeholder in the event they want to deselect
if (this.settings.allowDeselect && !this.settings.isMultiple) {
// Check if store options have a placeholder
const placeholderOption = this.store.filter((o) => o.placeholder, false) as Option[]
if (!placeholderOption.length) {
this.store.addOption(
new Option({
text: '',
value: '',
selected: false,
placeholder: true
}),
true
)
}
}
// Append individual options to div container
for (const d of data) {
// Create optgroup
if (d instanceof Optgroup) {
// Create optgroup
const optgroupEl = document.createElement('div')
optgroupEl.classList.add(this.classes.optgroup)
// Create label
const optgroupLabel = document.createElement('div')
optgroupLabel.classList.add(this.classes.optgroupLabel)
optgroupEl.appendChild(optgroupLabel)
// Create label text div element
const optgroupLabelText = document.createElement('div')
optgroupLabelText.classList.add(this.classes.optgroupLabelText)
optgroupLabelText.textContent = d.label
optgroupLabel.appendChild(optgroupLabelText)
// Create options container
const optgroupActions = document.createElement('div')
optgroupActions.classList.add(this.classes.optgroupActions)
optgroupLabel.appendChild(optgroupActions)
// If selectByGroup is true and isMultiple then add click event to label
if (this.settings.isMultiple && d.selectAll) {
// Create new div to hold a checkbox svg
const selectAll = document.createElement('div')
selectAll.classList.add(this.classes.optgroupSelectAll)
// Check options and if all are selected, if so add class selected
let allSelected = true
for (const o of d.options) {
if (!o.selected) {
allSelected = false
break
}
}
// Add class if all selected
if (allSelected) {
selectAll.classList.add(this.classes.selected)
}
// Add select all text span
const selectAllText = document.createElement('span')
selectAllText.textContent = d.selectAllText
selectAll.appendChild(selectAllText)
// Create new svg for checkbox
const selectAllSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
selectAllSvg.setAttribute('viewBox', '0 0 100 100')
selectAll.appendChild(selectAllSvg)
// Create new path for box
const selectAllBox = document.createElementNS('http://www.w3.org/2000/svg', 'path')
selectAllBox.setAttribute('d', this.classes.optgroupSelectAllBox)
selectAllSvg.appendChild(selectAllBox)
// Create new path for check
const selectAllCheck = document.createElementNS('http://www.w3.org/2000/svg', 'path')
selectAllCheck.setAttribute('d', this.classes.optgroupSelectAllCheck)
selectAllSvg.appendChild(selectAllCheck)
// Add click event listener to select all
selectAll.addEventListener('click', (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
// Get the store current selected values
const currentSelected = this.store.getSelected()
// If all selected, remove all options from selected
// call setSelected and return
if (allSelected) {
// Put together new list minus all options in this optgroup
const newSelected = currentSelected.filter((s) => {
for (const o of d.options) {
if (s === o.id) {
return false
}
}
return true
})
this.callbacks.setSelected(newSelected, true)
return
} else {
// Put together new list with all options in this optgroup
const newSelected = currentSelected.concat(d.options.map((o) => o.id))
// Loop through options and if they don't exist in the store
// run addOption callback
for (const o of d.options) {
if (!this.store.getOptionByID(o.id)) {
this.callbacks.addOption(o)
}
}
this.callbacks.setSelected(newSelected, true)
return
}
})
// Append select all to label
optgroupActions.appendChild(selectAll)
}
// If optgroup has collapsable
if (d.closable !== 'off') {
// Create new div to hold a checkbox svg
const optgroupClosable = document.createElement('div')
optgroupClosable.classList.add(this.classes.optgroupClosable)
// Create svg arrow
const optgroupClosableSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
optgroupClosableSvg.setAttribute('viewBox', '0 0 100 100')
optgroupClosableSvg.classList.add(this.classes.arrow)
optgroupClosable.appendChild(optgroupClosableSvg)
// Create new path for arrow
const optgroupClosableArrow = document.createElementNS('http://www.w3.org/2000/svg', 'path')
optgroupClosableSvg.appendChild(optgroupClosableArrow)
// If any options are selected or someone is searching, set optgroup to open
if (d.options.some((o) => o.selected) || this.content.search.input.value.trim() !== '') {
optgroupClosable.classList.add(this.classes.open)
optgroupClosableArrow.setAttribute('d', this.classes.arrowOpen)
} else if (d.closable === 'open') {
optgroupEl.classList.add(this.classes.open)
optgroupClosableArrow.setAttribute('d', this.classes.arrowOpen)
} else if (d.closable === 'close') {
optgroupEl.classList.add(this.classes.close)
optgroupClosableArrow.setAttribute('d', this.classes.arrowClose)
}
// Add click event listener to close
optgroupLabel.addEventListener('click', (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
// If optgroup is closed, open it
if (optgroupEl.classList.contains(this.classes.close)) {
optgroupEl.classList.remove(this.classes.close)
optgroupEl.classList.add(this.classes.open)
optgroupClosableArrow.setAttribute('d', this.classes.arrowOpen)
} else {
optgroupEl.classList.remove(this.classes.open)
optgroupEl.classList.add(this.classes.close)
optgroupClosableArrow.setAttribute('d', this.classes.arrowClose)
}
})
// Append close to label
optgroupActions.appendChild(optgroupClosable)
}
// Add optgroup label
optgroupEl.appendChild(optgroupLabel)
// Loop through options
for (const o of d.options) {
optgroupEl.appendChild(this.option(o))
}
// Add optgroup to list
this.content.list.appendChild(optgroupEl)
}
// Create option
if (d instanceof Option) {
this.content.list.appendChild(this.option(d as Option))
}
}
}
// Create option div element
public option(option: Option): HTMLDivElement {
// Add hidden placeholder
if (option.placeholder) {
const placeholder = document.createElement('div')
placeholder.classList.add(this.classes.option)
placeholder.classList.add(this.classes.hide)
return placeholder
}
// Create option
const optionEl = document.createElement('div')
optionEl.dataset.id = option.id // Dataset id for identifying an option
// optionEl.id = option.id // Remove for now as it is not needed and add duplicate id errors
optionEl.classList.add(this.classes.option)
optionEl.setAttribute('role', 'option') // WCAG attribute
if (option.class) {
option.class.split(' ').forEach((dataClass: string) => {
optionEl.classList.add(dataClass)
})
}
if (option.style) {
optionEl.style.cssText = option.style
}
// Set option content
if (this.settings.searchHighlight && this.content.search.input.value.trim() !== '') {
optionEl.innerHTML = this.highlightText(
option.html !== '' ? option.html : option.text,
this.content.search.input.value,
this.classes.searchHighlighter
)
} else if (option.html !== '') {
optionEl.innerHTML = option.html
} else {
optionEl.textContent = option.text
}
// Set title attribute
if (this.settings.showOptionTooltips && optionEl.textContent) {
optionEl.setAttribute('title', optionEl.textContent)
}
// If option is disabled
if (!option.display) {
optionEl.classList.add(this.classes.hide)
}
// If allowed to deselect, null onclick and add disabled
if (option.disabled) {
optionEl.classList.add(this.classes.disabled)
}
// If option is selected and hideSelectedOption is true, hide it
if (option.selected && this.settings.hideSelected) {
optionEl.classList.add(this.classes.hide)
}
// If option is selected
if (option.selected) {
optionEl.classList.add(this.classes.selected)
optionEl.setAttribute('aria-selected', 'true')
this.main.main.setAttribute('aria-activedescendant', optionEl.id)
} else {
optionEl.classList.remove(this.classes.selected)
optionEl.setAttribute('aria-selected', 'false')
}
// Add click event listener
optionEl.addEventListener('click', (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
// Setup variables
const selectedOptions = this.store.getSelected()
const element = e.currentTarget as HTMLDivElement
const elementID = String(element.dataset.id)
// If the option is disabled or selected and the user isnt allowed to deselect
if (option.disabled || (option.selected && !this.settings.allowDeselect)) {
return
}
// Check limit and do nothing if limit is reached and the option is not selected
// Also check reverse for min limit and is selected
if (
(this.settings.isMultiple && this.settings.maxSelected <= selectedOptions.length && !option.selected) ||
(this.settings.isMultiple && this.settings.minSelected >= selectedOptions.length && option.selected)
) {
return
}
// Setup variables
let shouldUpdate = false
const before = this.store.getSelectedOptions()
let after = [] as Option[]
// If multiple
if (this.settings.isMultiple) {
if (option.selected) {
// If selected after would remove
after = before.filter((o: Option) => o.id !== elementID)
} else {
// If not selected after would add
after = before.concat(option)
}
}
// If single
if (!this.settings.isMultiple) {
if (option.selected) {
// If selected after would remove
after = []
} else {
// If not selected after would add
after = [option]
}
}
// If no beforeOnChange is set automatically update at end
if (!this.callbacks.beforeChange) {
shouldUpdate = true
}
if (this.callbacks.beforeChange) {
// Check if beforeChange returns false
if (this.callbacks.beforeChange(after, before) === false) {
shouldUpdate = false
} else {
shouldUpdate = true
}
}
if (shouldUpdate) {
// Check if the option exists in the store
// if not run addOption callback
if (!this.store.getOptionByID(elementID)) {
this.callbacks.addOption(option)
}
// Get values from after and set as selected
this.callbacks.setSelected(
after.map((o: Option) => o.id),
false
)
// If closeOnSelect is true
if (this.settings.closeOnSelect) {
this.callbacks.close()
}
// callback that the value has changed
if (this.callbacks.afterChange) {
this.callbacks.afterChange(after)
}
}
})
return optionEl
}
public destroy(): void {
// Remove main
this.main.main.remove()
// Remove content
this.content.main.remove()
}
private highlightText(str: string, search: any, className: string) {
// the completed string will be itself if already set, otherwise, the string that was passed in
let completedString: any = str
const regex = new RegExp('(?![^<]*>)(' + search.trim() + ')(?![^<]*>[^<>]*</)', 'i')
// If the regex doesn't match the string just exit
if (!str.match(regex)) {
return str
}
// Otherwise, get to highlighting
const matchStartPosition = (str.match(regex) as any).index
const matchEndPosition = matchStartPosition + (str.match(regex) as any)[0].toString().length
const originalTextFoundByRegex = str.substring(matchStartPosition, matchEndPosition)
completedString = completedString.replace(regex, `<mark class="${className}">${originalTextFoundByRegex}</mark>`)
return completedString
}
public moveContentAbove(): void {
// Get main and content height
const mainHeight = this.main.main.offsetHeight
const contentHeight = this.content.main.offsetHeight
// Set classes
this.main.main.classList.remove(this.classes.openBelow)
this.main.main.classList.add(this.classes.openAbove)
this.content.main.classList.remove(this.classes.openBelow)
this.content.main.classList.add(this.classes.openAbove)
// Set the content position
const containerRect = this.main.main.getBoundingClientRect()
this.content.main.style.margin = '-' + (mainHeight + contentHeight - 1) + 'px 0px 0px 0px'
this.content.main.style.top =
containerRect.top + containerRect.height + (this.settings.contentPosition === 'fixed' ? 0 : window.scrollY) + 'px'
this.content.main.style.left =
containerRect.left + (this.settings.contentPosition === 'fixed' ? 0 : window.scrollX) + 'px'
this.content.main.style.width = containerRect.width + 'px'
}
public moveContentBelow(): void {
// Set classes
this.main.main.classList.remove(this.classes.openAbove)
this.main.main.classList.add(this.classes.openBelow)
this.content.main.classList.remove(this.classes.openAbove)
this.content.main.classList.add(this.classes.openBelow)
// Set the content position
const containerRect = this.main.main.getBoundingClientRect()
this.content.main.style.margin = '-1px 0px 0px 0px'
// Dont do anything if the content is relative
if (this.settings.contentPosition !== 'relative') {
this.content.main.style.top =
containerRect.top +
containerRect.height +
(this.settings.contentPosition === 'fixed' ? 0 : window.scrollY) +
'px'
this.content.main.style.left =
containerRect.left + (this.settings.contentPosition === 'fixed' ? 0 : window.scrollX) + 'px'
this.content.main.style.width = containerRect.width + 'px'
}
}
public ensureElementInView(container: HTMLElement, element: HTMLElement): void {
// Determine container top and bottom
const cTop = container.scrollTop + container.offsetTop // Make sure to have offsetTop
const cBottom = cTop + container.clientHeight
// Determine element top and bottom
const eTop = element.offsetTop
const eBottom = eTop + element.clientHeight
// Check if out of view
if (eTop < cTop) {
container.scrollTop -= cTop - eTop
} else if (eBottom > cBottom) {
container.scrollTop += eBottom - cBottom
}
}
public putContent(): 'up' | 'down' {
// Get main and content height
const mainHeight = this.main.main.offsetHeight
const mainRect = this.main.main.getBoundingClientRect()
const contentHeight = this.content.main.offsetHeight
// From bottom of mainHeight figure out if content will fit below without going below the window
const spaceBelow = window.innerHeight - (mainRect.top + mainHeight)
// If space below is less than content height
if (spaceBelow <= contentHeight) {
// If space above is more than content height
if (mainRect.top > contentHeight) {
// Move content above
return 'up'
} else {
// Move content below
return 'down'
}
}
// Move content below
return 'down'
}
// Updates deselect based on item count and allowDeselect setting
public updateDeselectAll(): void {
if (!this.store || !this.settings) {
return
}
const selected = this.store.getSelectedOptions()
const hasSelectedItems = selected && selected.length > 0
const isMultiple = this.settings.isMultiple
const allowDeselect = this.settings.allowDeselect
const deselectButton = this.main.deselect.main
const hideClass = this.classes.hide
if (allowDeselect && !(isMultiple && !hasSelectedItems)) {
deselectButton.classList.remove(hideClass)
} else {
deselectButton.classList.add(hideClass)
}
}
}