diff --git a/js/index.esm.js b/js/index.esm.js index 01d298e05fce..2affa484d3e4 100644 --- a/js/index.esm.js +++ b/js/index.esm.js @@ -9,6 +9,7 @@ export { default as Alert } from './src/alert.js' export { default as Button } from './src/button.js' export { default as Carousel } from './src/carousel.js' export { default as Collapse } from './src/collapse.js' +export { default as CustomSelect } from './src/custom-select.js' export { default as Datepicker } from './src/datepicker.js' export { default as Dialog } from './src/dialog.js' export { default as Dropdown } from './src/dropdown.js' diff --git a/js/index.umd.js b/js/index.umd.js index 73f12b424edd..efec31f5bf18 100644 --- a/js/index.umd.js +++ b/js/index.umd.js @@ -9,6 +9,7 @@ import Alert from './src/alert.js' import Button from './src/button.js' import Carousel from './src/carousel.js' import Collapse from './src/collapse.js' +import CustomSelect from './src/custom-select.js' import Datepicker from './src/datepicker.js' import Dialog from './src/dialog.js' import Dropdown from './src/dropdown.js' @@ -27,6 +28,7 @@ export default { Button, Carousel, Collapse, + CustomSelect, Datepicker, Dialog, Dropdown, diff --git a/js/src/custom-select.js b/js/src/custom-select.js new file mode 100644 index 000000000000..0590cf340216 --- /dev/null +++ b/js/src/custom-select.js @@ -0,0 +1,830 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap custom-select.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import { + computePosition, + flip, + shift, + offset, + autoUpdate +} from '@floating-ui/dom' +import BaseComponent from './base-component.js' +import EventHandler from './dom/event-handler.js' +import Manipulator from './dom/manipulator.js' +import SelectorEngine from './dom/selector-engine.js' +import { DefaultAllowlist, sanitizeHtml } from './util/sanitizer.js' +import { + getNextActiveElement, + getUID, + isDisabled, + isRTL, + isVisible +} from './util/index.js' + +/** + * Constants + */ + +const NAME = 'customSelect' +const DATA_KEY = 'bs.custom-select' +const EVENT_KEY = `.${DATA_KEY}` +const DATA_API_KEY = '.data-api' + +const ESCAPE_KEY = 'Escape' +const TAB_KEY = 'Tab' +const ARROW_UP_KEY = 'ArrowUp' +const ARROW_DOWN_KEY = 'ArrowDown' +const HOME_KEY = 'Home' +const END_KEY = 'End' +const ENTER_KEY = 'Enter' +const SPACE_KEY = ' ' + +const EVENT_CHANGE = `change${EVENT_KEY}` +const EVENT_SHOW = `show${EVENT_KEY}` +const EVENT_SHOWN = `shown${EVENT_KEY}` +const EVENT_HIDE = `hide${EVENT_KEY}` +const EVENT_HIDDEN = `hidden${EVENT_KEY}` +const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}` +const EVENT_KEYDOWN_DATA_API = `keydown${EVENT_KEY}${DATA_API_KEY}` + +const CLASS_NAME_SHOW = 'show' +const CLASS_NAME_DISABLED = 'disabled' +const CLASS_NAME_SELECTED = 'selected' + +const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="custom-select"]' +const _SELECTOR_MENU = '.custom-select-menu' +const SELECTOR_ITEM = '.custom-select-item' +const SELECTOR_VISIBLE_ITEMS = '.custom-select-item:not(.disabled):not(:disabled)' +const _SELECTOR_SEARCH = '.custom-select-search-input' + +const Default = { + allowHtml: false, + allowList: DefaultAllowlist, + boundary: 'clippingParents', + hidePlaceholderOption: true, + liveSearch: false, + liveSearchNormalize: false, + liveSearchPlaceholder: 'Search...', + offset: [0, 2], + placement: 'bottom-start', + sanitize: true, + sanitizeFn: null, + showCheckmark: true +} + +const DefaultType = { + allowHtml: 'boolean', + allowList: 'object', + boundary: '(string|element)', + hidePlaceholderOption: 'boolean', + liveSearch: 'boolean', + liveSearchNormalize: 'boolean', + liveSearchPlaceholder: 'string', + offset: '(array|string|function)', + placement: 'string', + sanitize: 'boolean', + sanitizeFn: '(null|function)', + showCheckmark: 'boolean' +} + +/** + * Class definition + */ + +class CustomSelect extends BaseComponent { + constructor(element, config) { + super(element, config) + + this._select = this._element + this._isMultiple = this._select.multiple + this._toggle = null + this._menu = null + this._searchInput = null + this._items = [] + this._floatingCleanup = null + this._isShown = false + + this._init() + } + + // Getters + static get Default() { + return Default + } + + static get DefaultType() { + return DefaultType + } + + static get NAME() { + return NAME + } + + // Public + toggle() { + return this._isShown ? this.hide() : this.show() + } + + show() { + if (isDisabled(this._toggle) || this._isShown) { + return + } + + const showEvent = EventHandler.trigger(this._select, EVENT_SHOW) + if (showEvent.defaultPrevented) { + return + } + + this._isShown = true + this._createFloating() + + this._menu.classList.add(CLASS_NAME_SHOW) + this._toggle.classList.add(CLASS_NAME_SHOW) + this._toggle.setAttribute('aria-expanded', 'true') + + // Focus search input or first item + if (this._config.liveSearch && this._searchInput) { + this._searchInput.focus() + } else { + const selectedItem = SelectorEngine.findOne(`.${CLASS_NAME_SELECTED}`, this._menu) + if (selectedItem) { + selectedItem.focus() + } + } + + EventHandler.trigger(this._select, EVENT_SHOWN) + } + + hide() { + if (!this._isShown) { + return + } + + const hideEvent = EventHandler.trigger(this._select, EVENT_HIDE) + if (hideEvent.defaultPrevented) { + return + } + + this._isShown = false + this._disposeFloating() + + this._menu.classList.remove(CLASS_NAME_SHOW) + this._toggle.classList.remove(CLASS_NAME_SHOW) + this._toggle.setAttribute('aria-expanded', 'false') + + // Clear search + if (this._searchInput) { + this._searchInput.value = '' + this._filterItems('') + } + + EventHandler.trigger(this._select, EVENT_HIDDEN) + } + + refresh() { + this._buildMenu() + this._updateToggleText() + } + + dispose() { + this._disposeFloating() + + // Remove generated elements + if (this._toggle) { + this._toggle.remove() + } + + if (this._menu) { + this._menu.remove() + } + + // Show original select + this._select.classList.remove('visually-hidden') + this._select.removeAttribute('aria-hidden') + this._select.removeAttribute('tabindex') + + super.dispose() + } + + // Private + _init() { + // Hide original select + this._select.classList.add('visually-hidden') + this._select.setAttribute('aria-hidden', 'true') + this._select.setAttribute('tabindex', '-1') + + this._createToggle() + + this._createMenu() + + this._buildMenu() + + // If a placeholder option exists and no option is explicitly selected, select it so the toggle shows placeholder text on load + const placeholderOption = this._getPlaceholderOption() + const hasExplicitSelection = this._select.querySelector('option[selected]') + if (!this._isMultiple && placeholderOption && !hasExplicitSelection) { + for (const option of this._select.querySelectorAll('option')) { + option.selected = (option === placeholderOption) + } + } + + this._updateToggleText() + + this._setupEventListeners() + } + + _createToggle() { + this._toggle = document.createElement('button') + this._toggle.type = 'button' + this._toggle.setAttribute('aria-haspopup', 'listbox') + this._toggle.setAttribute('aria-expanded', 'false') + this._toggle.id = getUID('custom-select-toggle-') + + // Copy classes from select to toggle + const toggleClasses = this._getToggleClasses() + this._toggle.classList.add('custom-select-toggle', ...toggleClasses) + + if (this._select.disabled) { + this._toggle.disabled = true + this._toggle.classList.add(CLASS_NAME_DISABLED) + } + + // Create inner structure + this._toggle.innerHTML = ` + + + +` + + this._select.after(this._toggle) + } + + _getToggleClasses() { + const classes = [] + const selectClasses = this._select.classList + + // Check for button variant classes + const hasButtonClass = [...selectClasses].some(cls => + cls.startsWith('btn-') || cls.startsWith('theme-') + ) + + if (hasButtonClass) { + // Copy button and theme classes + for (const cls of selectClasses) { + if (cls.startsWith('btn-') || cls.startsWith('theme-')) { + classes.push(cls) + } + } + } else { + // Default to form-control style + classes.push('form-control') + + // Copy size classes + if (selectClasses.contains('form-control-sm')) { + classes.push('form-control-sm') + } else if (selectClasses.contains('form-control-lg')) { + classes.push('form-control-lg') + } + } + + return classes + } + + _createMenu() { + this._menu = document.createElement('div') + this._menu.classList.add('dropdown-menu', 'custom-select-menu') + this._menu.setAttribute('role', 'listbox') + this._menu.id = getUID('custom-select-menu-') + this._toggle.setAttribute('aria-controls', this._menu.id) + + // Add search input if enabled + if (this._config.liveSearch) { + const searchWrapper = document.createElement('div') + searchWrapper.classList.add('custom-select-search') + searchWrapper.innerHTML = ` + + ` + this._menu.append(searchWrapper) + this._searchInput = searchWrapper.querySelector('input') + } + + // No results message (hidden by default) + const noResults = document.createElement('div') + noResults.classList.add('custom-select-no-results', 'd-none') + noResults.textContent = 'No results found' + this._noResults = noResults + + this._toggle.after(this._menu) + } + + _buildMenu() { + // Remove existing items (keep search wrapper if present) + const existingItems = SelectorEngine.find('.custom-select-item, .custom-select-header, .custom-select-no-results', this._menu) + for (const item of existingItems) { + item.remove() + } + + this._items = [] + + const options = this._select.querySelectorAll('option, optgroup') + + for (const option of options) { + if (option.tagName === 'OPTGROUP') { + this._createOptgroupHeader(option, this._menu) + } else { + this._createItem(option, this._menu) + } + } + + // Add no results message at the end + this._menu.append(this._noResults) + } + + _createOptgroupHeader(optgroup, container) { + const header = document.createElement('div') + header.classList.add('custom-select-header', 'dropdown-header') + header.textContent = optgroup.label + + if (optgroup.disabled) { + header.classList.add(CLASS_NAME_DISABLED) + } + + container.append(header) + + // Add options within this optgroup + for (const option of optgroup.querySelectorAll('option')) { + this._createItem(option, container, optgroup.disabled) + } + } + + _createItem(option, container, parentDisabled = false) { + // Skip placeholder options: empty value when hidePlaceholderOption, or data-bs-placeholder on option + const isPlaceholder = (option.value === '' && this._config.hidePlaceholderOption) || + option.getAttribute('data-bs-placeholder') === 'true' + if (isPlaceholder) { + return + } + + const item = document.createElement('button') + item.type = 'button' + item.classList.add('dropdown-item', 'custom-select-item') + item.setAttribute('role', 'option') + item.dataset.value = option.value + + // Store search tokens + const tokens = option.dataset.bsTokens || '' + const description = option.dataset.bsDescription || '' + item.dataset.searchText = `${option.textContent} ${description} ${tokens}`.toLowerCase() + + if (option.disabled || parentDisabled) { + item.disabled = true + item.classList.add(CLASS_NAME_DISABLED) + item.setAttribute('aria-disabled', 'true') + } + + if (option.selected) { + item.classList.add(CLASS_NAME_SELECTED) + item.setAttribute('aria-selected', 'true') + } + + // Build item content + item.innerHTML = this._buildItemContent(option) + + container.append(item) + this._items.push({ item, option }) + } + + _buildItemContent(option) { + // Check for full custom content override + const customContent = option.dataset.bsContent + if (customContent) { + return this._sanitize(` + + ${customContent} + + ${this._config.showCheckmark ? this._getCheckmarkHtml() : ''} + `) + } + + // Build from individual data attributes + const icon = option.dataset.bsIcon // Inline SVG - trusted developer content, not sanitized + const image = option.dataset.bsImage + const description = option.dataset.bsDescription + const text = option.textContent + + let html = '' + + // Icon (inline SVG) or image - icon is trusted developer content + if (icon) { + html += `${icon}` + } else if (image) { + html += `` + } + + html += '' + html += `${this._sanitize(text)}` + + if (description) { + html += `${this._sanitize(description)}` + } + + html += '' + + if (this._config.showCheckmark) { + html += this._getCheckmarkHtml() + } + + return html + } + + _getCheckmarkHtml() { + return '' + } + + _sanitize(content) { + if (!this._config.sanitize) { + return content + } + + return sanitizeHtml(String(content), this._config.allowList, this._config.sanitizeFn) + } + + _getPlaceholderOption() { + // Option explicitly marked as placeholder, or first empty-value option + const marked = this._select.querySelector('option[data-bs-placeholder="true"]') + if (marked) { + return marked + } + + return this._select.querySelector('option[value=""]') + } + + _updateToggleText() { + const valueElement = SelectorEngine.findOne('.custom-select-value', this._toggle) + const selectedOptions = [...this._select.selectedOptions] + + const onlyPlaceholderSelected = selectedOptions.length === 1 && + (selectedOptions[0].value === '' || selectedOptions[0].getAttribute('data-bs-placeholder') === 'true') + const noneSelected = selectedOptions.length === 0 || onlyPlaceholderSelected + + if (noneSelected) { + // Show placeholder: data-bs-placeholder on select, or placeholder option's text + const explicitPlaceholder = this._select.getAttribute('data-bs-placeholder') + const placeholderOption = this._getPlaceholderOption() + valueElement.textContent = explicitPlaceholder || (placeholderOption ? placeholderOption.textContent.trim() : '') + valueElement.classList.add('custom-select-placeholder') + return + } + + valueElement.classList.remove('custom-select-placeholder') + + if (this._isMultiple) { + valueElement.textContent = selectedOptions.length === 1 ? selectedOptions[0].textContent : `${selectedOptions.length} selected` + } else { + const selected = selectedOptions[0] + // Use custom content for toggle if available, otherwise text + const icon = selected.dataset.bsIcon // Trusted developer content + const text = selected.textContent + + if (icon) { + valueElement.innerHTML = `${icon} ${this._sanitize(text)}` + } else { + valueElement.textContent = text + } + } + } + + _setupEventListeners() { + // Toggle click + EventHandler.on(this._toggle, 'click', event => { + event.preventDefault() + this.toggle() + }) + + // Item clicks + EventHandler.on(this._menu, 'click', SELECTOR_ITEM, event => { + event.preventDefault() + const item = event.target.closest(SELECTOR_ITEM) + if (item && !item.disabled) { + this._selectItem(item) + } + }) + + // Search input + if (this._searchInput) { + EventHandler.on(this._searchInput, 'input', () => { + this._filterItems(this._searchInput.value) + }) + + EventHandler.on(this._searchInput, 'keydown', event => { + if (event.key === ARROW_DOWN_KEY) { + event.preventDefault() + const firstVisible = SelectorEngine.findOne(`${SELECTOR_VISIBLE_ITEMS}:not(.d-none)`, this._menu) + if (firstVisible) { + firstVisible.focus() + } + } + }) + } + + // Keyboard navigation + EventHandler.on(this._toggle, 'keydown', event => { + this._handleToggleKeydown(event) + }) + + EventHandler.on(this._menu, 'keydown', event => { + this._handleMenuKeydown(event) + }) + + // Close on outside click + EventHandler.on(document, EVENT_CLICK_DATA_API, event => { + if (this._isShown && !this._menu.contains(event.target) && !this._toggle.contains(event.target)) { + this.hide() + } + }) + + // Sync with native select changes + EventHandler.on(this._select, 'change', () => { + this._syncFromSelect() + }) + } + + _selectItem(item) { + const { value } = item.dataset + const option = this._select.querySelector(`option[value="${CSS.escape(value)}"]`) + + if (!option) { + return + } + + if (this._isMultiple) { + // Toggle selection for multiple + option.selected = !option.selected + item.classList.toggle(CLASS_NAME_SELECTED) + item.setAttribute('aria-selected', option.selected) + } else { + // Single select: clear previous selection + for (const { item: i, option: o } of this._items) { + i.classList.remove(CLASS_NAME_SELECTED) + i.setAttribute('aria-selected', 'false') + o.selected = false + } + + option.selected = true + item.classList.add(CLASS_NAME_SELECTED) + item.setAttribute('aria-selected', 'true') + + this.hide() + } + + this._updateToggleText() + + // Dispatch change event on original select + EventHandler.trigger(this._select, 'change') + EventHandler.trigger(this._select, EVENT_CHANGE, { value, option }) + } + + _syncFromSelect() { + // Sync visual state from native select + for (const { item, option } of this._items) { + if (option.selected) { + item.classList.add(CLASS_NAME_SELECTED) + item.setAttribute('aria-selected', 'true') + } else { + item.classList.remove(CLASS_NAME_SELECTED) + item.setAttribute('aria-selected', 'false') + } + } + + this._updateToggleText() + } + + _filterItems(query) { + const normalizedQuery = this._normalizeText(query.toLowerCase()) + let visibleCount = 0 + + for (const { item } of this._items) { + const searchText = this._normalizeText(item.dataset.searchText) + + if (searchText.includes(normalizedQuery)) { + item.classList.remove('d-none') + visibleCount++ + } else { + item.classList.add('d-none') + } + } + + // Handle optgroup headers visibility + const headers = SelectorEngine.find('.custom-select-header', this._menu) + for (const header of headers) { + // Show header if any following items (until next header) are visible + let nextSibling = header.nextElementSibling + let hasVisibleItems = false + + while (nextSibling && !nextSibling.classList.contains('custom-select-header')) { + if (nextSibling.classList.contains('custom-select-item') && !nextSibling.classList.contains('d-none')) { + hasVisibleItems = true + break + } + + nextSibling = nextSibling.nextElementSibling + } + + header.classList.toggle('d-none', !hasVisibleItems) + } + + // Show/hide no results message + this._noResults.classList.toggle('d-none', visibleCount > 0 || query === '') + } + + _normalizeText(text) { + if (!this._config.liveSearchNormalize) { + return text + } + + // Normalize accents + return text.normalize('NFD').replace(/[\u0300-\u036F]/g, '') + } + + _handleToggleKeydown(event) { + const { key } = event + + if ([ARROW_DOWN_KEY, ARROW_UP_KEY, ENTER_KEY, SPACE_KEY].includes(key)) { + event.preventDefault() + + if (this._isShown) { + const items = SelectorEngine.find(`${SELECTOR_VISIBLE_ITEMS}:not(.d-none)`, this._menu) + if (items.length) { + items[key === ARROW_UP_KEY ? items.length - 1 : 0].focus() + } + } else { + this.show() + } + } + + if (key === ESCAPE_KEY && this._isShown) { + event.preventDefault() + this.hide() + this._toggle.focus() + } + } + + _handleMenuKeydown(event) { + const { key, target } = event + const items = SelectorEngine.find(`${SELECTOR_VISIBLE_ITEMS}:not(.d-none)`, this._menu) + .filter(item => isVisible(item)) + + if ([ARROW_DOWN_KEY, ARROW_UP_KEY].includes(key)) { + event.preventDefault() + const { activeElement } = document + const isDown = key === ARROW_DOWN_KEY + getNextActiveElement(items, activeElement, isDown, true).focus() + } + + if ([HOME_KEY, END_KEY].includes(key)) { + event.preventDefault() + const targetItem = key === HOME_KEY ? items[0] : items[items.length - 1] + if (targetItem) { + targetItem.focus() + } + } + + if ((key === ENTER_KEY || key === SPACE_KEY) && target.classList.contains('custom-select-item')) { + event.preventDefault() + this._selectItem(target) + } + + if (key === ESCAPE_KEY) { + event.preventDefault() + this.hide() + this._toggle.focus() + } + + if (key === TAB_KEY) { + this.hide() + } + } + + _createFloating() { + const placement = isRTL() ? + this._config.placement.replace('start', 'temp').replace('end', 'start').replace('temp', 'end') : + this._config.placement + + const middleware = [ + offset({ mainAxis: this._config.offset[1] || 0, crossAxis: this._config.offset[0] || 0 }), + flip({ + boundary: this._config.boundary === 'clippingParents' ? 'clippingAncestors' : this._config.boundary + }), + shift({ padding: 8 }) + ] + + const updatePosition = async () => { + const { x, y, placement: finalPlacement } = await computePosition( + this._toggle, + this._menu, + { placement, middleware } + ) + + Object.assign(this._menu.style, { + position: 'absolute', + left: `${x}px`, + top: `${y}px`, + margin: '0' + }) + + Manipulator.setDataAttribute(this._menu, 'placement', finalPlacement) + } + + updatePosition() + this._floatingCleanup = autoUpdate(this._toggle, this._menu, updatePosition) + } + + _disposeFloating() { + if (this._floatingCleanup) { + this._floatingCleanup() + this._floatingCleanup = null + } + } + + // Static + static jQueryInterface(config) { + return this.each(function () { + const data = CustomSelect.getOrCreateInstance(this, config) + + if (typeof config !== 'string') { + return + } + + if (typeof data[config] === 'undefined') { + throw new TypeError(`No method named "${config}"`) + } + + data[config]() + }) + } + + static clearMenus(event) { + if (event.button === 2) { + return + } + + const openSelects = SelectorEngine.find(`${SELECTOR_DATA_TOGGLE}.${CLASS_NAME_SHOW}`) + + for (const toggle of openSelects) { + const selectElement = toggle.previousElementSibling + if (selectElement) { + const instance = CustomSelect.getInstance(selectElement) + if (instance) { + instance.hide() + } + } + } + } +} + +/** + * Data API implementation + */ + +EventHandler.on(document, EVENT_CLICK_DATA_API, event => { + // Close any open custom selects when clicking elsewhere + const { target } = event + const openToggles = SelectorEngine.find('.custom-select-toggle.show') + + for (const toggle of openToggles) { + const menu = toggle.nextElementSibling + if (!toggle.contains(target) && !menu?.contains(target)) { + const select = toggle.previousElementSibling + const instance = CustomSelect.getInstance(select) + if (instance) { + instance.hide() + } + } + } +}) + +EventHandler.on(document, EVENT_KEYDOWN_DATA_API, SELECTOR_DATA_TOGGLE, function (event) { + const instance = CustomSelect.getOrCreateInstance(this) + + if ([ARROW_DOWN_KEY, ARROW_UP_KEY, ENTER_KEY, SPACE_KEY].includes(event.key)) { + event.preventDefault() + instance.show() + } +}) + +// Auto-initialize on DOM ready +EventHandler.on(window, 'DOMContentLoaded', () => { + for (const select of SelectorEngine.find(SELECTOR_DATA_TOGGLE)) { + CustomSelect.getOrCreateInstance(select) + } +}) + +export default CustomSelect diff --git a/scss/_dropdown.scss b/scss/_dropdown.scss index cf42ec9f0cd8..10aab9c2840c 100644 --- a/scss/_dropdown.scss +++ b/scss/_dropdown.scss @@ -35,10 +35,10 @@ $dropdown-link-active-bg: $component-active-bg !default; $dropdown-link-disabled-color: var(--fg-3) !default; +$dropdown-item-gap: $spacer * .5 !default; $dropdown-item-padding-y: $spacer * .25 !default; $dropdown-item-padding-x: $spacer * .75 !default; $dropdown-item-border-radius: var(--border-radius) !default; -$dropdown-item-gap: $spacer * .5 !default; $dropdown-header-color: var(--gray-600) !default; $dropdown-header-padding-x: $dropdown-item-padding-x !default; @@ -150,7 +150,7 @@ $dropdown-dark-header-color: var(--gray-500) !default; .dropdown-item { display: flex; gap: var(--dropdown-item-gap); - align-items: center; + align-items: var(--dropdown-item-align, center); width: 100%; // For `