slim-select
Version:
Slim advanced select dropdown
595 lines (495 loc) • 19 kB
text/typescript
import CssClasses from './classes'
import { debounce, hasClassInTree, isEqual } from './helpers'
import Render from './render'
import Select from './select'
import Settings from './settings'
import Store, { Option, Optgroup } from './store'
// Export classes
export { Settings, Option, Optgroup }
// Export interfaces from render
export type { Main, Content, Search } from './render'
export interface Config {
select: string | Element
data?: (Partial<Option> | Partial<Optgroup>)[]
settings?: Partial<Settings>
cssClasses?: Partial<CssClasses>
events?: Events
}
export interface Events {
search?: (
searchValue: string,
currentData: (Option | Optgroup)[]
) => Promise<(Partial<Option> | Partial<Optgroup>)[]> | (Partial<Option> | Partial<Optgroup>)[]
searchFilter?: (option: Option, search: string) => boolean
addable?: (
value: string
) => Promise<Partial<Option> | string> | Partial<Option> | string | false | null | undefined | Error
beforeChange?: (newVal: Option[], oldVal: Option[]) => boolean | void
afterChange?: (newVal: Option[]) => void
beforeOpen?: () => void
afterOpen?: () => void
beforeClose?: () => void
afterClose?: () => void
error?: (err: Error) => void
}
export default class SlimSelect {
public selectEl: HTMLSelectElement
// Classes
public settings!: Settings
public cssClasses!: CssClasses
public select!: Select
public store!: Store
public render!: Render
// Timeout tracking for cleanup
private openTimeout: ReturnType<typeof setTimeout> | null = null
private closeTimeout: ReturnType<typeof setTimeout> | null = null
// Events
public events = {
search: undefined,
searchFilter: (opt: Option, search: string) => {
return opt.text.toLowerCase().indexOf(search.toLowerCase()) !== -1
},
addable: undefined,
beforeChange: undefined,
afterChange: undefined,
beforeOpen: undefined,
afterOpen: undefined,
beforeClose: undefined,
afterClose: undefined
} as Events
constructor(config: Config) {
// Make sure you get the right element
this.selectEl = (
typeof config.select === 'string' ? document.querySelector(config.select) : config.select
) as HTMLSelectElement
if (!this.selectEl) {
if (config.events && config.events.error) {
config.events.error(new Error('Could not find select element'))
}
return
}
if (this.selectEl.tagName !== 'SELECT') {
if (config.events && config.events.error) {
config.events.error(new Error('Element isnt of type select'))
}
return
}
// If select already has a slim select id on it lets destroy it first
if (this.selectEl.dataset.ssid) {
this.destroy()
}
// Set settings
this.settings = new Settings(config.settings)
// Set CSS classes
this.cssClasses = new CssClasses(config.cssClasses)
// Set events
const debounceEvents = ['beforeOpen', 'afterOpen', 'beforeClose', 'afterClose']
for (const key in config.events) {
// Check if key exists in events
if (!config.events.hasOwnProperty(key)) {
continue
}
// Check if key is in debounceEvents
if (debounceEvents.indexOf(key) !== -1) {
;(this.events as { [key: string]: any })[key] = debounce((config.events as { [key: string]: any })[key], 100)
} else {
;(this.events as { [key: string]: any })[key] = (config.events as { [key: string]: any })[key]
}
}
// Upate settings with type, style and classname
this.settings.disabled = config.settings?.disabled ? config.settings.disabled : this.selectEl.disabled
this.settings.isMultiple = this.selectEl.multiple
this.settings.style = this.selectEl.style.cssText
this.settings.class = this.selectEl.className.split(' ')
// Set select class
this.select = new Select(this.selectEl)
// Ensure the select has an id for label associations
if (!this.selectEl.id) {
this.selectEl.id = this.settings.id
}
this.select.updateSelect(this.settings.id, this.settings.style, this.settings.class)
this.select.hideUI() // Hide the original select element
// Add select listeners
this.select.onValueChange = (options: Option[]) => {
// Run set selected from the values given
this.setSelected(options.map((option) => option.id))
}
this.select.onClassChange = (classes: string[]) => {
// Update settings with new class
this.settings.class = classes
// Run render updateClassStyles
this.render.updateClassStyles()
}
this.select.onDisabledChange = (disabled: boolean) => {
if (disabled) {
this.disable()
} else {
this.enable()
}
}
this.select.onOptionsChange = (data: (Option | Optgroup)[]) => {
// Process the data (including empty data for clearing all options)
// Empty data is safe to process here because if we were updating, the change would be queued
// and onOptionsChange wouldn't be called directly
this.setData(data || [])
}
// Set up label click handler to toggle SlimSelect
// This allows clicking the label to open/close, matching the main div behavior
this.select.onLabelClick = () => {
if (!this.settings.disabled) {
this.settings.isOpen ? this.close() : this.open()
}
}
// Set store class
const data = config.data ? config.data : this.select.getData()
this.store = new Store(this.settings.isMultiple ? 'multiple' : 'single', data)
// If data is passed update the original select element
if (config.data) {
this.select.updateOptions(this.store.getData())
}
// Set render renderCallbacks
const renderCallbacks = {
open: this.open.bind(this),
close: this.close.bind(this),
addable: this.events.addable ? this.events.addable : undefined,
setSelected: this.setSelected.bind(this),
addOption: this.addOption.bind(this),
search: this.search.bind(this),
beforeChange: this.events.beforeChange,
afterChange: this.events.afterChange
}
// Setup render class
this.render = new Render(this.settings, this.cssClasses, this.store, renderCallbacks)
this.render.renderValues()
this.render.renderOptions(this.store.getData())
// Add aria-label or aria-labelledby if exists
const selectAriaLabel = this.selectEl.getAttribute('aria-label')
const selectAriaLabelledBy = this.selectEl.getAttribute('aria-labelledby')
if (selectAriaLabel) {
this.render.main.main.setAttribute('aria-label', selectAriaLabel)
} else if (selectAriaLabelledBy) {
this.render.main.main.setAttribute('aria-labelledby', selectAriaLabelledBy)
}
// Add render after original select element
if (this.selectEl.parentNode) {
this.selectEl.parentNode.insertBefore(this.render.main.main, this.selectEl.nextSibling)
}
// Add window resize listener to moveContent if window size changes
window.addEventListener('resize', this.windowResize, false)
// If the user wants to show the content forcibly on a specific side,
// there is no need to listen for scroll events
if (this.settings.openPosition === 'auto') {
window.addEventListener('scroll', this.windowScroll, false)
}
// Add window visibility change listener to closeContent if window is hidden
document.addEventListener('visibilitychange', this.windowVisibilityChange)
// If disabled lets call it
if (this.settings.disabled) {
this.disable()
}
// If alwaysOpnen then open it
if (this.settings.alwaysOpen) {
this.open()
}
// Set up label handlers to open SlimSelect when label is clicked
this.select.setupLabelHandlers()
// Add SlimSelect to select element
;(this.selectEl as any).slim = this
}
// Set to enabled and remove disabled classes
public enable(): void {
this.settings.disabled = false
this.select.enable()
this.render.enable()
}
// Set to disabled and add disabled classes
public disable(): void {
this.settings.disabled = true
this.select.disable()
this.render.disable()
}
public getData(): Option[] | Optgroup[] {
return this.store.getData()
}
public setData(data: (Partial<Option> | Partial<Optgroup>)[]): void {
// Get original selected values
const selected = this.store.getSelected()
// Validate data
const err = this.store.validateDataArray(data)
if (err) {
if (this.events.error) {
this.events.error(err)
}
return
}
// Update the store
this.store.setData(data)
const dataClean = this.store.getData()
// Update original select element
this.select.updateOptions(dataClean)
// Update the render
this.render.renderValues()
this.render.renderOptions(dataClean)
// Trigger afterChange event, if it doesnt equal the original selected values
if (this.events.afterChange && !isEqual(selected, this.store.getSelected())) {
this.events.afterChange(this.store.getSelectedOptions())
}
}
public getSelected(): string[] {
let options = this.store.getSelectedOptions()
if (this.settings.keepOrder) {
options = this.store.selectedOrderOptions(options)
}
return options.map((option) => option.value)
}
// Will take in a string or array of strings and set the selected by either the id or value
public setSelected(values: string | string[], runAfterChange = true): void {
// Get original selected values
const selected = this.store.getSelected()
const options = this.store.getDataOptions()
values = Array.isArray(values) ? values : [values]
const ids = []
// for back-compatibility support both, set by id and set by value
for (const value of values) {
if (options.find((option) => option.id == value)) {
ids.push(value)
continue
}
// if option with given id is not found try to search by value
for (const option of options.filter((option) => option.value == value)) {
ids.push(option.id)
}
}
// Update the store
this.store.setSelectedBy('id', ids)
const data = this.store.getData()
// Update the select element
this.select.updateOptions(data)
// Update the render
this.render.renderValues()
// If there is a search input value lets run through the search again
// Otherwise we will just render the options from store data
if (this.render.content.search.input.value !== '') {
this.search(this.render.content.search.input.value)
} else {
this.render.renderOptions(data)
}
// Trigger afterChange event, if it doesnt equal the original selected values
if (runAfterChange && this.events.afterChange && !isEqual(selected, this.store.getSelected())) {
this.events.afterChange(this.store.getSelectedOptions())
}
}
public addOption(option: Partial<Option>): void {
// Get original selected values
const selected = this.store.getSelected()
// Add option to store if it does not already include the option
if (!this.store.getDataOptions().some((o) => o.value === (option.value ?? option.text))) {
this.store.addOption(option)
}
const data = this.store.getData()
// Update the select element
this.select.updateOptions(data)
// Update the render
this.render.renderValues()
this.render.renderOptions(data)
// Trigger afterChange event, if it doesnt equal the original selected values
if (this.events.afterChange && !isEqual(selected, this.store.getSelected())) {
this.events.afterChange(this.store.getSelectedOptions())
}
}
public open(): void {
// Dont open if disabled
// Dont do anything if the content is already open
if (this.settings.disabled || this.settings.isOpen) {
return
}
// Clear any pending close timeout to prevent race conditions
if (this.closeTimeout) {
clearTimeout(this.closeTimeout)
this.closeTimeout = null
}
// Run beforeOpen callback
if (this.events.beforeOpen) {
this.events.beforeOpen()
}
// Tell render to open
this.render.open()
// Focus on input field only if search is enabled
if (this.settings.showSearch && this.settings.focusSearch) {
this.render.searchFocus()
}
this.settings.isOpen = true
// setTimeout is for animation completion
this.openTimeout = setTimeout(() => {
// Run afterOpen callback
if (this.events.afterOpen) {
this.events.afterOpen()
}
// Update settings
// Prevent overide if user close fast without wait full open
// For detail see issue https://github.com/brianvoe/slim-select/issues/397
if (this.settings.isOpen) {
this.settings.isFullOpen = true
}
// Add onclick listener to document to closeContent if clicked outside
document.addEventListener('click', this.documentClick)
}, this.settings.timeoutDelay)
// Start an interval to check if main has moved
// in order to keep content close to main
if (this.settings.contentPosition === 'absolute') {
if (this.settings.intervalMove) {
clearInterval(this.settings.intervalMove)
}
this.settings.intervalMove = setInterval(this.render.moveContent.bind(this.render), 500)
}
}
public close(eventType: string | null = null): void {
// Dont do anything if the content is already closed
// Dont do anything if alwaysOpen is true
if (!this.settings.isOpen || this.settings.alwaysOpen) {
return
}
// Clear any pending open timeout to prevent race conditions
if (this.openTimeout) {
clearTimeout(this.openTimeout)
this.openTimeout = null
}
// Run beforeClose calback
if (this.events.beforeClose) {
this.events.beforeClose()
}
// Tell render to close
this.render.close()
// Clear search only if not empty and keepSearch is false
if (!this.settings.keepSearch && this.render.content.search.input.value !== '') {
this.search('') // Clear search
}
// If we arent tabbing focus back on the main element
this.render.mainFocus(eventType)
// Update settings
this.settings.isOpen = false
this.settings.isFullOpen = false
// Reset the content below
this.closeTimeout = setTimeout(() => {
// Run afterClose callback
if (this.events.afterClose) {
this.events.afterClose()
}
// Add onclick listener to document to closeContent if clicked outside
document.removeEventListener('click', this.documentClick)
}, this.settings.timeoutDelay)
if (this.settings.intervalMove) {
clearInterval(this.settings.intervalMove)
}
}
// Take in string value and search current options
public search(value: string): void {
// If the passed in value is not the same as the search input value
// then lets update the search input value
if (this.render.content.search.input.value !== value) {
this.render.content.search.input.value = value
}
// If value is empty then render all options
if (value === '') {
this.render.renderOptions(this.store.getData())
return
}
// If no search event run regular search
if (!this.events.search) {
// If value is empty then render all options
const searchResults = value === '' ? this.store.getData() : this.store.search(value, this.events.searchFilter!)
this.render.renderOptions(searchResults)
return
}
// Search event exists so lets render the searching text
this.render.renderSearching()
// Based upon the search event deal with the response
const searchResp = this.events.search(value, this.store.getSelectedOptions())
// If the search event returns a promise
if (searchResp instanceof Promise) {
searchResp
.then((data: (Partial<Option> | Partial<Optgroup>)[]) => {
// Update store data with search results, preserving selected options
this.store.setData(data, true)
// Update original select element
this.select.updateOptions(this.store.getData())
// Render the updated data
this.render.renderOptions(this.store.getData())
})
.catch((err: Error | string) => {
// Update the render with error
this.render.renderError(typeof err === 'string' ? err : err.message)
})
return
} else if (Array.isArray(searchResp)) {
// Update store data with search results, preserving selected options
this.store.setData(searchResp, true)
// Update original select element
this.select.updateOptions(this.store.getData())
// Render the updated data
this.render.renderOptions(this.store.getData())
} else {
// Update the render with error
this.render.renderError('Search event must return a promise or an array of data')
}
}
public destroy(): void {
// Clear any pending timeouts
if (this.openTimeout) {
clearTimeout(this.openTimeout)
this.openTimeout = null
}
if (this.closeTimeout) {
clearTimeout(this.closeTimeout)
this.closeTimeout = null
}
if (this.settings.intervalMove) {
clearInterval(this.settings.intervalMove)
this.settings.intervalMove = null
}
// Remove all event listeners
document.removeEventListener('click', this.documentClick)
window.removeEventListener('resize', this.windowResize, false)
if (this.settings.openPosition === 'auto') {
window.removeEventListener('scroll', this.windowScroll, false)
}
document.removeEventListener('visibilitychange', this.windowVisibilityChange)
// Delete the store data
this.store.setData([])
// Remove the render
this.render.destroy()
// Show the original select element
this.select.destroy()
}
private windowResize: (e: Event) => void = debounce(() => {
if (!this.settings.isOpen && !this.settings.isFullOpen) {
return
}
this.render.moveContent()
})
// Event listener for window scrolling
private windowScroll: (e: Event) => void = debounce(() => {
// If the content is not open, there is no need to move it
if (!this.settings.isOpen && !this.settings.isFullOpen) {
return
}
this.render.moveContent()
})
// Event listener for document click
private documentClick: (e: Event) => void = (e: Event) => {
// If the content is not open, there is no need to close it
if (!this.settings.isOpen) {
return
}
// Check if the click was on the content by looking at the parents
if (e.target && !hasClassInTree(e.target as HTMLElement, this.settings.id)) {
this.close(e.type)
}
}
// Event Listener for window visibility change
private windowVisibilityChange: (e: Event) => void = () => {
if (document.hidden) {
this.close()
}
}
}