UNPKG

slim-select

Version:

Slim advanced select dropdown

595 lines (495 loc) 19 kB
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() } } }