UNPKG

slim-select

Version:

Slim advanced select dropdown

1,457 lines (1,242 loc) 58.3 kB
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