slim-select
Version:
Slim advanced select dropdown
1,457 lines (1,242 loc) • 58.3 kB
text/typescript
import { debounce } from './helpers'
import Settings from './settings'
import Store, { Optgroup, Option } from './store'
import CssClasses from './classes'
export interface Callbacks {
open: () => void
close: () => void
addable?: (
value: string
) => Promise<Partial<Option> | string> | Partial<Option> | 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
// Used to compute the range selection
private lastSelectedOption: Option | null
// Timeout tracking for cleanup
private closeAnimationTimeout: ReturnType<typeof setTimeout> | null = null
// 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.lastSelectedOption = null
this.main = this.mainDiv()
this.content = this.contentDiv()
// Add classes and styles to main/content
this.updateClassStyles()
this.updateAriaAttributes()
// Position content off-screen initially to prevent scrollbars
// This must be after updateClassStyles() since it removes all style attributes
// The main element isn't in the DOM yet, so getBoundingClientRect() would return zeros
// Once opened, moveContent() will position it correctly
if (this.settings.contentPosition !== 'relative') {
this.content.main.style.top = '-9999px'
this.content.main.style.left = '-9999px'
this.content.main.style.margin = '0'
this.content.main.style.width = 'auto'
}
// Add content to the content location settings
if (this.settings.contentLocation) {
this.settings.contentLocation.appendChild(this.content.main)
}
}
// Helper method to add classes that may contain spaces
// Splits by spaces and adds each class individually to avoid DOMException
private addClasses(element: HTMLElement | SVGElement, classValue: string): void {
if (!classValue || classValue.trim() === '') {
return
}
const classes = classValue.split(' ').filter((c) => c.trim() !== '')
for (const cls of classes) {
element.classList.add(cls.trim())
}
}
// Helper method to remove classes that may contain spaces
private removeClasses(element: HTMLElement | SVGElement, classValue: string): void {
if (!classValue || classValue.trim() === '') {
return
}
const classes = classValue.split(' ').filter((c) => c.trim() !== '')
for (const cls of classes) {
element.classList.remove(cls.trim())
}
}
// Remove disabled classes
public enable(): void {
// Remove disabled class
this.removeClasses(this.main.main, this.classes.disabled)
this.main.main.setAttribute('aria-disabled', 'false')
// Set search input to "enabled"
this.content.search.input.disabled = false
}
// Set disabled classes
public disable(): void {
// Add disabled class
this.addClasses(this.main.main, this.classes.disabled)
this.main.main.setAttribute('aria-disabled', 'true')
// Set search input to disabled
this.content.search.input.disabled = true
}
public open(): void {
this.main.arrow.path.setAttribute('d', this.classes.arrowOpen)
this.main.main.setAttribute('aria-expanded', 'true')
// Clear any pending close animation timeout to prevent race conditions
if (this.closeAnimationTimeout) {
clearTimeout(this.closeAnimationTimeout)
this.closeAnimationTimeout = null
}
// Set direction class on both main and content (persists, never removed)
const isAbove = this.settings.openPosition === 'up'
const dirClass = isAbove ? this.classes.dirAbove : this.classes.dirBelow
this.addClasses(this.main.main, dirClass)
this.addClasses(this.content.main, dirClass)
// Add open class to content to trigger open animation
this.addClasses(this.content.main, this.classes.contentOpen)
// Make search visible to screen readers when opened
this.content.search.input.removeAttribute('aria-hidden')
// 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.setAttribute('aria-expanded', 'false')
this.main.arrow.path.setAttribute('d', this.classes.arrowClose)
// Remove open class from content to trigger close animation
// Direction class (dirAbove/dirBelow) persists to maintain correct transform-origin
this.removeClasses(this.content.main, this.classes.contentOpen)
// Hide search from screen readers when closed
this.content.search.input.setAttribute('aria-hidden', 'true')
// Clear active descendant when closed
this.main.main.removeAttribute('aria-activedescendant')
// Remove direction class from main and content after animation is complete
const animationTiming = this.getAnimationTiming()
this.closeAnimationTimeout = setTimeout(() => {
this.removeClasses(this.main.main, this.classes.dirAbove)
this.removeClasses(this.main.main, this.classes.dirBelow)
this.removeClasses(this.content.main, this.classes.dirAbove)
this.removeClasses(this.content.main, this.classes.dirBelow)
this.closeAnimationTimeout = null
}, animationTiming)
}
private getAnimationTiming(): number {
const computedStyle = getComputedStyle(this.content.main)
const cssValue = computedStyle.getPropertyValue('--ss-animation-timing').trim()
if (cssValue) {
// Parse CSS time value (e.g., "0.2s" or "200ms")
if (cssValue.endsWith('ms')) {
return parseFloat(cssValue)
} else if (cssValue.endsWith('s')) {
return parseFloat(cssValue) * 1000
}
}
// Fall back to default 200ms
return 200
}
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.addClasses(this.main.main, this.classes.main)
this.addClasses(this.content.main, 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() {
const listboxId = this.content.list.id
// Main combobox
this.main.main.role = 'combobox'
this.main.main.setAttribute('aria-haspopup', 'listbox')
this.main.main.setAttribute('aria-controls', listboxId)
this.main.main.setAttribute('aria-expanded', 'false')
this.content.list.setAttribute('role', 'listbox')
this.content.list.setAttribute('aria-label', this.settings.ariaLabel + ' listbox')
// Add aria-multiselectable for multiple selects
if (this.settings.isMultiple) {
this.content.list.setAttribute('aria-multiselectable', 'true')
}
// Search input should also control the listbox
this.content.search.input.setAttribute('aria-controls', listboxId)
}
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.getFirst('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')
this.addClasses(values, this.classes.values)
main.appendChild(values)
// Add deselect
const deselect = document.createElement('div')
this.addClasses(deselect, 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)) {
this.addClasses(deselect, this.classes.hide)
} else {
this.removeClasses(deselect, 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')
this.addClasses(arrow, 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) {
this.addClasses(arrow, 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')
this.addClasses(placeholder, 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')
this.addClasses(singleValue, 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.addClasses(this.main.deselect.main, this.classes.hide)
} else {
this.removeClasses(this.main.deselect.main, 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.getFirst('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')
this.addClasses(singleValue, 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.getFirst('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) {
this.addClasses(n, 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')
this.addClasses(value, this.classes.value)
value.dataset.id = option.id
const text = document.createElement('div')
this.addClasses(text, 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')
this.addClasses(deleteDiv, this.classes.valueDelete)
deleteDiv.setAttribute('tabindex', '0') // Make the div focusable for tab navigation
// 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) {
if (c.id) {
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)
// Add keydown event listener for keyboard navigation (Enter key)
deleteDiv.onkeydown = (e) => {
if (e.key === 'Enter') {
deleteDiv.click() // Trigger the click event when Enter is pressed
}
}
}
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')
this.addClasses(main, 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) {
this.addClasses(main, 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('aria-autocomplete', 'list')
input.setAttribute('autocapitalize', 'off')
input.setAttribute('autocomplete', 'off')
input.setAttribute('autocorrect', 'off')
// Hide from screen readers by default (shown when opened)
input.setAttribute('aria-hidden', 'true')
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.getFirst('highlighted')
) as HTMLDivElement
if (highlighted) {
highlighted.click()
return false
}
return true
case 'Enter':
// Check if there's a highlighted option first
const highlightedEnter = this.content.list.querySelector(
'.' + this.classes.getFirst('highlighted')
) as HTMLDivElement
if (highlightedEnter) {
// If an option is highlighted, select it (even if addable is enabled)
highlightedEnter.click()
return false
} else if (this.callbacks.addable) {
// If no option is highlighted and addable is enabled, add new item
addable.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
this.addClasses(addable, 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: Partial<Option>) => {
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({ preventScroll: true })
}
public getOptions(notPlaceholder = false, notDisabled = false, notHidden = false): HTMLDivElement[] {
// Put together query string
let query = '.' + this.classes.getFirst('option')
if (notPlaceholder) {
query += ':not(.' + this.classes.getFirst('placeholder') + ')'
}
if (notDisabled) {
query += ':not(.' + this.classes.getFirst('disabled') + ')'
}
if (notHidden) {
query += ':not(.' + this.classes.getFirst('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.getFirst('highlighted'))) {
this.addClasses(options[0], 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.getFirst('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.getFirst('selected'))) {
this.addClasses(o, 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.getFirst('highlighted'))) {
const prevOption = options[i]
// Remove highlighted class from current one
this.removeClasses(prevOption, 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.getFirst('mainOpen'))) {
const optgroupLabel = prevParent.querySelector('.' + this.classes.getFirst('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]
this.addClasses(selectOption, this.classes.highlighted)
this.ensureElementInView(this.content.list, selectOption)
// Update aria-activedescendant for screen readers
if (selectOption.id) {
this.main.main.setAttribute('aria-activedescendant', selectOption.id)
}
// 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.getFirst('close'))) {
const optgroupLabel = selectParent.querySelector(
'.' + this.classes.getFirst('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
const firstHighlight = options[dir === 'down' ? 0 : options.length - 1]
this.addClasses(firstHighlight, this.classes.highlighted)
// Update aria-activedescendant for screen readers
if (firstHighlight.id) {
this.main.main.setAttribute('aria-activedescendant', firstHighlight.id)
}
// Scroll to highlighted one
this.ensureElementInView(this.content.list, firstHighlight)
}
// Create main container that options will reside
public listDiv(): HTMLDivElement {
const options = document.createElement('div')
this.addClasses(options, this.classes.list)
// Add id for ARIA controls reference
const listId = this.settings.id + '-list'
options.id = listId
options.dataset.id = listId
return options
}
public renderError(error: string) {
// Clear out innerHtml
this.content.list.innerHTML = ''
const errorDiv = document.createElement('div')
this.addClasses(errorDiv, 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')
this.addClasses(searchingDiv, this.classes.searching)
searchingDiv.textContent = this.settings.searchingText
this.content.list.appendChild(searchingDiv)
}
// Take in data and add options to
public renderOptions(data: (Option | Optgroup)[]): void {
// Clear out innerHtml
this.content.list.innerHTML = ''
// If no results show no results text
if (data.length === 0) {
const noResults = document.createElement('div')
this.addClasses(noResults, 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
const fragment = document.createDocumentFragment()
for (const d of data) {
// Create optgroup
if (d instanceof Optgroup) {
// Create optgroup
const optgroupEl = document.createElement('div')
this.addClasses(optgroupEl, this.classes.optgroup)
// Create label
const optgroupLabel = document.createElement('div')
this.addClasses(optgroupLabel, this.classes.optgroupLabel)
optgroupEl.appendChild(optgroupLabel)
// Create label text div element
const optgroupLabelText = document.createElement('div')
this.addClasses(optgroupLabelText, this.classes.optgroupLabelText)
optgroupLabelText.textContent = d.label
optgroupLabel.appendChild(optgroupLabelText)
// Create options container
const optgroupActions = document.createElement('div')
this.addClasses(optgroupActions, 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')
this.addClasses(selectAll, 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) {
this.addClasses(selectAll, 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
let optionIds = d.options.map((o) => o.id).filter((id) => id !== undefined)
const newSelected = currentSelected.concat(optionIds)
// Loop through options and if they don't exist in the store
// run addOption callback
for (const o of d.options) {
if (o.id && !this.store.getOptionByID(o.id)) {
this.callbacks.addOption(new Option(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')
this.addClasses(optgroupClosable, this.classes.optgroupClosable)
// Create svg arrow
const optgroupClosableSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
optgroupClosableSvg.setAttribute('viewBox', '0 0 100 100')
this.addClasses(optgroupClosableSvg, 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() !== '') {
this.addClasses(optgroupClosable, this.classes.mainOpen)
optgroupClosableArrow.setAttribute('d', this.classes.arrowOpen)
} else if (d.closable === 'open') {
this.addClasses(optgroupEl, this.classes.mainOpen)
optgroupClosableArrow.setAttribute('d', this.classes.arrowOpen)
} else if (d.closable === 'close') {
this.addClasses(optgroupEl, 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.getFirst('close'))) {
this.removeClasses(optgroupEl, this.classes.close)
this.addClasses(optgroupEl, this.classes.mainOpen)
optgroupClosableArrow.setAttribute('d', this.classes.arrowOpen)
} else {
this.removeClasses(optgroupEl, this.classes.mainOpen)
this.addClasses(optgroupEl, 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 option of d.options) {
optgroupEl.appendChild(this.option(new Option(option)))
fragment.appendChild(optgroupEl)
}
}
// Create option
if (d instanceof Option) {
fragment.appendChild(this.option(d as Option))
}
}
// Append fragment to list
this.content.list.appendChild(fragment)
}
// Create option div element
public option(option: Option): HTMLDivElement {
// Add hidden placeholder
if (option.placeholder) {
const placeholder = document.createElement('div')
this.addClasses(placeholder, this.classes.option)
this.addClasses(placeholder, 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 = this.settings.id + '-' + option.id // Unique ID for ARIA references
this.addClasses(optionEl, 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) {
this.addClasses(optionEl, this.classes.hide)
}
// If allowed to deselect, null onclick and add disabled
if (option.disabled) {
this.addClasses(optionEl, this.classes.disabled)
}
// If option is selected and hideSelectedOption is true, hide it
if (option.selected && this.settings.hideSelected) {
this.addClasses(optionEl, this.classes.hide)
}
// If option is selected
if (option.selected) {
this.addClasses(optionEl, this.classes.selected)
optionEl.setAttribute('aria-selected', 'true')
this.main.main.setAttribute('aria-activedescendant', optionEl.id)
} else {
this.removeClasses(optionEl, 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)
const isCmd = e.ctrlKey || e.metaKey // Cmd (Mac) or Ctrl (Windows/Linux)
// If the option is disabled, do nothing
if (option.disabled) {
return
}
// allowDeselect only applies to single-select mode
// In multi-select, you can always toggle options on/off
if (!this.settings.isMultiple && option.selected && !this.settings.allowDeselect) {
return
}
// Prevent deselection of mandatory options
if (option.selected && option.mandatory) {
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 (allow Cmd to bypass minSelected)
if (
(this.settings.isMultiple && this.settings.maxSelected <= selectedOptions.length && !option.selected) ||
(this.settings.isMultiple && this.settings.minSelected >= selectedOptions.length && option.selected && !isCmd)
) {
return
}
// Setup variables
let shouldUpdate = false
const before = this.store.getSelectedOptions()
let after = [] as Option[]
// If multiple - mimic native browser multi-select behavior
if (this.settings.isMultiple) {
const isCurrentlySelected = before.some((o: Option) => o.id === elementID)
const isShift = e.shiftKey
// Shift+Click: Select range from last clicked to current
if (isShift && this.lastSelectedOption) {
const options = this.store.getDataOptions()
const lastIndex = options.findIndex((o: Option) => o.id === this.lastSelectedOption!.id)
const currentIndex = options.findIndex((o: Option) => o.id === option.id)
if (lastIndex >= 0 && currentIndex >= 0) {
const startIndex = Math.min(lastIndex, currentIndex)
const endIndex = Math.max(lastIndex, currentIndex)
const rangeOptions = options.slice(startIndex, endIndex + 1)
// Check if range would exceed maxSelected
const newSelections = rangeOptions.filter((opt) => !before.find((b) => b.id === opt.id))
if (before.length + newSelections.length <= this.settings.maxSelected) {
// Add range to existing selections
after = before.concat(newSelections)
} else {
// Range too large, keep existing selections
after = before
}
} else {
after = before
}
}
// Cmd/Ctrl+Click: Toggle selection without affecting others (keeps dropdown open)
else if (isCmd) {
if (isCurrentlySelected) {
// Deselect this option
after = before.filter((o: Option) => o.id !== elementID)
} else {
// Add this option to selection
after = before.concat(option)
}
this.lastSelectedOption = option
}
// Regular Click: Toggle this option (add/remove), will close dropdown
else {
if (isCurrentlySe