@coreui/coreui
Version:
The most popular front-end framework for developing responsive, mobile-first projects on the web rewritten and maintained by the CoreUI Team
528 lines (424 loc) • 13.2 kB
JavaScript
/**
* --------------------------------------------------------------------------
* CoreUI chip-set.js
* Licensed under MIT (https://github.com/coreui/coreui/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import BaseComponent from './base-component.js'
import Chip from './chip.js'
import EventHandler from './dom/event-handler.js'
import Manipulator from './dom/manipulator.js'
import SelectorEngine from './dom/selector-engine.js'
import { defineJQueryPlugin, getNextActiveElement, isRTL } from './util/index.js'
/**
* Constants
*/
const NAME = 'chip-set'
const DATA_KEY = 'coreui.chip-set'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
const EVENT_ADD = 'add'
const EVENT_REMOVE = 'remove'
const EVENT_CHANGE = 'change'
const EVENT_SELECT = 'select'
const EVENT_KEYDOWN = 'keydown'
const EVENT_CHIP_SELECTED = 'selected.coreui.chip'
const EVENT_CHIP_DESELECTED = 'deselected.coreui.chip'
const EVENT_CHIP_REMOVE = 'remove.coreui.chip'
const EVENT_CHIP_REMOVED = 'removed.coreui.chip'
const SELECTOR_DATA_CHIP_SET = '[data-coreui-chip-set]'
const SELECTOR_CHIP = '.chip'
const SELECTOR_CHIP_ACTIVE = `${SELECTOR_CHIP}.active`
const SELECTOR_CHIP_REMOVE = '.chip-remove'
const SELECTOR_FOCUSABLE_ITEMS = '.chip:not(.disabled)'
const CLASS_NAME_CHIP = 'chip'
const CLASS_NAME_DISABLED = 'disabled'
const SELECTION_MODE_SINGLE = 'single'
const DEFAULT_REMOVE_ICON = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" y1="4" x2="12" y2="12"/><line x1="12" y1="4" x2="4" y2="12"/></svg>'
const DEFAULT_SELECTED_ICON = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 512 512" fill="currentColor"><path d="M425.373 89.373 196 318.745 86.627 209.373l-45.254 45.254L196 409.255l274.627-274.628z"/></svg>'
const Default = {
ariaRemoveLabel: 'Remove',
chipClassName: null,
disabled: false,
filter: false,
maxChips: null,
removable: false,
removeIcon: DEFAULT_REMOVE_ICON,
selectable: false,
selectedIcon: DEFAULT_SELECTED_ICON,
selectionMode: 'multiple',
unique: false
}
const DefaultType = {
ariaRemoveLabel: 'string',
chipClassName: '(string|function|null)',
disabled: 'boolean',
filter: 'boolean',
maxChips: '(number|null)',
removable: 'boolean',
removeIcon: 'string',
selectable: 'boolean',
selectedIcon: 'string',
selectionMode: 'string',
unique: 'boolean'
}
/**
* Class definition
*/
class ChipSet extends BaseComponent {
constructor(element, config) {
super(element, config)
this._disabled = this._config.disabled || this._element.classList.contains(CLASS_NAME_DISABLED)
this._pendingFocus = null
this._chips = []
this._initChips()
this._addEventListeners()
}
// Getters
static get Default() {
return Default
}
static get DefaultType() {
return DefaultType
}
static get NAME() {
return NAME
}
// Public
add(chip) {
if (!this._canModify()) {
return null
}
const isElement = typeof chip !== 'string'
const value = isElement ? this._getChipValue(chip) : String(chip).trim()
if (!value) {
return null
}
if (this._config.unique && this._chips.includes(value)) {
return null
}
if (this._config.maxChips !== null && this._chips.length >= this._config.maxChips) {
return null
}
const addEvent = EventHandler.trigger(this._element, this.constructor.eventName(EVENT_ADD), {
value,
relatedTarget: this._input ?? null
})
if (addEvent.defaultPrevented) {
return null
}
const element = isElement ? chip : this._createChip(value)
this._appendChip(element)
this._setupChip(element)
this._chips.push(value)
EventHandler.trigger(this._element, this.constructor.eventName(EVENT_CHANGE), {
values: this.getValues()
})
return element
}
remove(chipOrValue) {
if (!this._canModify()) {
return false
}
let chip
let value
if (typeof chipOrValue === 'string') {
value = chipOrValue
chip = this._findChipByValue(value)
} else {
chip = chipOrValue
value = this._getChipValue(chip)
}
if (!chip || !value) {
return false
}
const removeEvent = EventHandler.trigger(this._element, this.constructor.eventName(EVENT_REMOVE), {
value,
chip,
relatedTarget: this._input ?? null
})
if (removeEvent.defaultPrevented) {
return false
}
const instance = Chip.getInstance(chip)
if (instance) {
instance.remove()
} else {
chip.remove()
this._handleChipRemoval(chip, value)
}
return !chip.isConnected
}
removeSelected() {
for (const chip of this.getSelected()) {
this.remove(chip)
}
}
clear() {
for (const chip of this._getChipElements()) {
this.remove(chip)
}
}
selectChip(chip) {
if (!this._getChipElements().includes(chip)) {
return
}
Chip.getInstance(chip)?.select()
}
selectAll() {
if (!this._config.selectable) {
return
}
for (const chip of this._getChipElements()) {
Chip.getInstance(chip)?.select()
}
}
deselectAll() {
for (const chip of this.getSelected()) {
Chip.getInstance(chip)?.deselect()
}
}
clearSelection() {
this.deselectAll()
EventHandler.trigger(this._element, this.constructor.eventName(EVENT_SELECT), {
selected: this.getSelectedValues()
})
}
getSelected() {
return SelectorEngine.find(SELECTOR_CHIP_ACTIVE, this._element)
}
getValues() {
return [...this._chips]
}
getSelectedValues() {
return this.getSelected().map(chip => this._getChipValue(chip))
}
dispose() {
EventHandler.off(this._element, Chip.EVENT_KEY)
super.dispose()
}
// Private
_configAfterMerge(config) {
// Filter chips are selectable by definition.
if (config.filter) {
config.selectable = true
}
return config
}
_canModify() {
return !this._disabled
}
_appendChip(chip) {
this._element.append(chip)
}
_getChipElements() {
return SelectorEngine.find(SELECTOR_CHIP, this._element)
}
_findChipByValue(value) {
return this._getChipElements().find(chip => this._getChipValue(chip) === value)
}
_getChipValue(chip) {
if (chip.dataset.coreuiChipValue) {
return chip.dataset.coreuiChipValue
}
const clone = chip.cloneNode(true)
const remove = SelectorEngine.findOne(SELECTOR_CHIP_REMOVE, clone)
if (remove) {
remove.remove()
}
return clone.textContent?.trim() || ''
}
_getFocusableChips() {
return SelectorEngine.find(SELECTOR_FOCUSABLE_ITEMS, this._element)
}
_initChips() {
for (const chip of this._getChipElements()) {
const value = this._getChipValue(chip)
if (value) {
this._chips.push(value)
this._applyChipClassName(chip, value)
}
this._setupChip(chip)
}
}
_setupChip(chip) {
Chip.getOrCreateInstance(chip, this._getChipConfig(chip))
}
_getChipConfig(chip) {
// A chip's own data attributes override the set's options.
return {
ariaRemoveLabel: this._config.ariaRemoveLabel,
disabled: this._disabled,
filter: this._config.filter,
removable: this._config.removable,
removeIcon: this._config.removeIcon,
selectable: this._config.selectable,
selectedIcon: this._config.selectedIcon,
...Manipulator.getDataAttributes(chip)
}
}
_createChip(value) {
const chip = document.createElement('span')
chip.className = CLASS_NAME_CHIP
chip.dataset.coreuiChipValue = value
chip.append(document.createTextNode(value))
this._applyChipClassName(chip, value)
return chip
}
_applyChipClassName(chip, value) {
const className = this._resolveChipClassName(value)
if (!className) {
return
}
chip.classList.add(...className.split(/\s+/).filter(Boolean))
}
_resolveChipClassName(value) {
const { chipClassName } = this._config
if (!chipClassName) {
return ''
}
if (typeof chipClassName === 'function') {
const resolvedClassName = chipClassName(value)
return typeof resolvedClassName === 'string' ? resolvedClassName : ''
}
return typeof chipClassName === 'string' ? chipClassName : ''
}
_addEventListeners() {
EventHandler.on(this._element, this.constructor.eventName(EVENT_KEYDOWN), SELECTOR_CHIP, event => this._handleKeydown(event))
EventHandler.on(this._element, EVENT_CHIP_SELECTED, SELECTOR_CHIP, event => this._handleSelectionChange(event))
EventHandler.on(this._element, EVENT_CHIP_DESELECTED, SELECTOR_CHIP, event => this._handleSelectionChange(event))
EventHandler.on(this._element, EVENT_CHIP_REMOVE, SELECTOR_CHIP, event => this._handleChipRemove(event))
EventHandler.on(this._element, EVENT_CHIP_REMOVED, SELECTOR_CHIP, event => this._handleChipRemoved(event))
}
_handleKeydown(event) {
const chip = event.target.closest(SELECTOR_CHIP)
if (!chip || chip.classList.contains(CLASS_NAME_DISABLED)) {
return
}
switch (event.key) {
case 'ArrowLeft': {
event.preventDefault()
// In RTL the visual direction is mirrored, so ArrowLeft moves to the next chip.
this._focusSibling(chip, isRTL())
break
}
case 'ArrowRight': {
event.preventDefault()
this._focusSibling(chip, !isRTL())
break
}
case 'Home': {
event.preventDefault()
this._navigateToEdge(0)
break
}
case 'End': {
event.preventDefault()
this._navigateToEdge(-1)
break
}
// No default
}
}
_focusSibling(chip, shouldGetNext) {
const chips = this._getFocusableChips()
if (chips.length === 0) {
return
}
// No cycling: navigation stops at the edges.
const sibling = getNextActiveElement(chips, chip, shouldGetNext, false)
if (sibling && sibling !== chip) {
sibling.focus()
}
}
_getRemovalNeighbor(chip) {
const chips = this._getFocusableChips()
if (chips.length === 0) {
return null
}
// Prefer the next chip, fall back to the previous one at the end.
const next = getNextActiveElement(chips, chip, true, false)
if (next && next !== chip) {
return next
}
const previous = getNextActiveElement(chips, chip, false, false)
return previous && previous !== chip ? previous : null
}
_navigateToEdge(targetIndex) {
const chips = this._getFocusableChips()
chips[targetIndex < 0 ? chips.length + targetIndex : targetIndex]?.focus()
}
_handleSelectionChange(event) {
const chip = event.target.closest(SELECTOR_CHIP)
if (this._config.selectionMode === SELECTION_MODE_SINGLE && chip?.matches(SELECTOR_CHIP_ACTIVE)) {
this._enforceSingleSelection(chip)
}
EventHandler.trigger(this._element, this.constructor.eventName(EVENT_SELECT), {
selected: this.getSelectedValues()
})
}
_enforceSingleSelection(selectedChip) {
for (const chip of this.getSelected()) {
if (chip !== selectedChip) {
Chip.getInstance(chip)?.deselect()
}
}
}
_handleChipRemove(event) {
const chip = event.target.closest(SELECTOR_CHIP)
this._pendingFocus = chip ? this._getRemovalNeighbor(chip) : null
}
_handleChipRemoved(event) {
const chip = event.target.closest(SELECTOR_CHIP)
this._pendingFocus?.focus()
this._pendingFocus = null
this._handleChipRemoval(chip, this._getChipValue(chip))
}
_handleChipRemoval(chip, value) {
const index = this._chips.indexOf(value)
if (index !== -1) {
this._chips.splice(index, 1)
}
EventHandler.trigger(this._element, this.constructor.eventName(EVENT_CHANGE), {
values: this.getValues()
})
EventHandler.trigger(this._element, this.constructor.eventName(EVENT_SELECT), {
selected: this.getSelectedValues()
})
}
// Static
static chipSetInterface(element, config) {
const data = ChipSet.getOrCreateInstance(element, config)
if (typeof config === 'string') {
if (typeof data[config] === 'undefined') {
throw new TypeError(`No method named "${config}"`)
}
data[config]()
}
}
static jQueryInterface(config) {
return this.each(function () {
const data = ChipSet.getOrCreateInstance(this)
if (typeof config !== 'string') {
return
}
if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {
throw new TypeError(`No method named "${config}"`)
}
data[config](this)
})
}
}
/**
* Data API implementation
*/
EventHandler.on(document, `DOMContentLoaded${EVENT_KEY}${DATA_API_KEY}`, () => {
for (const element of SelectorEngine.find(SELECTOR_DATA_CHIP_SET)) {
ChipSet.chipSetInterface(element)
}
})
/**
* jQuery
*/
defineJQueryPlugin(ChipSet)
export default ChipSet