@coreui/vue-pro
Version:
UI Components Library for Vue.js
169 lines (142 loc) • 5.27 kB
text/typescript
import { computed, nextTick, provide, ref, type ComputedRef, type InjectionKey } from 'vue'
import { SELECTOR_CHIP_FOCUSABLE } from '../chip/const'
import { isRTL } from '../../utils'
export interface ChipSetConfig {
disabled?: boolean
filter?: boolean
removable?: boolean
removeIcon?: string | object
selectable?: boolean
selectedIcon?: string | object
}
export interface ChipSetContext {
/** Config forwarded to every chip in the set; the chip's own props win. */
config: ComputedRef<ChipSetConfig>
isSelected: (value: string) => boolean
toggleSelected: (value: string, next: boolean, event: MouseEvent | KeyboardEvent) => void
removeChip: (value: string, event: MouseEvent | KeyboardEvent) => void
}
export const chipSetContextKey: InjectionKey<ChipSetContext> = Symbol('CChipSet')
export interface UseChipSetOptions {
config: ComputedRef<ChipSetConfig>
selectionMode?: () => 'single' | 'multiple'
/** Controlled selected values (e.g. v-model:selected). Undefined keeps it uncontrolled. */
selected?: () => string[] | undefined
defaultSelected?: string[]
/** Move focus to a neighbor after removal. Containers with their own target (CChipInput → input) pass false. */
restoreFocusOnRemove?: boolean
onSelectionChange?: (selected: string[]) => void
onRemove?: (value: string) => void
}
/**
* Shared chip-set engine for CChipSet and CChipInput — the Vue equivalent of the
* vanilla `ChipInput extends ChipSet`. It owns selection coordination
* (single/multiple, controlled-or-uncontrolled), roving focus, and the
* focus-a-neighbor-after-removal behavior, and `provide`s a context that CChip
* injects. Existence of the chips stays with the caller.
*/
export const useChipSet = (options: UseChipSetOptions) => {
const {
config,
selectionMode = () => 'multiple',
selected = () => undefined,
defaultSelected = [],
restoreFocusOnRemove = true,
onSelectionChange,
onRemove,
} = options
const rootRef = ref<HTMLElement>()
const internalSelected = ref<string[]>(defaultSelected)
const selectedValues = computed(() => {
const controlled = selected()
return controlled === undefined ? internalSelected.value : controlled
})
const getFocusableChips = (): HTMLElement[] => [
...(rootRef.value?.querySelectorAll<HTMLElement>(SELECTOR_CHIP_FOCUSABLE) ?? []),
]
const emitSelection = (next: string[]): void => {
if (selected() === undefined) {
internalSelected.value = next
}
onSelectionChange?.(next)
}
const toggleSelected = (value: string, next: boolean): void => {
const isSelected = selectedValues.value.includes(value)
if (next === isSelected) {
return
}
if (next) {
// Single selection mode keeps only the freshly selected chip.
emitSelection(selectionMode() === 'single' ? [value] : [...selectedValues.value, value])
return
}
emitSelection(selectedValues.value.filter((item) => item !== value))
}
const clearSelection = (): void => {
if (selectedValues.value.length > 0) {
emitSelection([])
}
}
const removeChip = (value: string): void => {
if (restoreFocusOnRemove) {
const chips = getFocusableChips()
const index = chips.findIndex((chip) => chip.dataset.coreuiChipValue === value)
// Prefer the next chip, fall back to the previous one at the end.
const neighbor = chips[index + 1] ?? chips[index - 1] ?? null
const neighborValue = neighbor?.dataset.coreuiChipValue ?? null
if (neighborValue !== null) {
nextTick(() => {
rootRef.value
?.querySelector<HTMLElement>(`[data-coreui-chip-value="${neighborValue}"]`)
?.focus()
})
}
}
emitSelection(selectedValues.value.filter((item) => item !== value))
onRemove?.(value)
}
// Roving focus: arrow keys move between chips and Home/End jump to the edges,
// with no cycling. Returns true when the key was handled.
const handleKeydown = (event: KeyboardEvent): boolean => {
const chip = (event.target as HTMLElement).closest<HTMLElement>(SELECTOR_CHIP_FOCUSABLE)
if (!chip) {
return false
}
const chips = getFocusableChips()
const index = chips.indexOf(chip)
// In RTL the visual direction is mirrored, so left/right swap.
const rtl = isRTL(rootRef.value)
switch (event.key) {
case 'ArrowLeft': {
event.preventDefault()
chips[rtl ? index + 1 : index - 1]?.focus()
return true
}
case 'ArrowRight': {
event.preventDefault()
chips[rtl ? index - 1 : index + 1]?.focus()
return true
}
case 'Home': {
event.preventDefault()
chips[0]?.focus()
return true
}
case 'End': {
event.preventDefault()
chips[chips.length - 1]?.focus()
return true
}
default: {
return false
}
}
}
provide(chipSetContextKey, {
config,
isSelected: (value: string) => selectedValues.value.includes(value),
toggleSelected: (value, next) => toggleSelected(value, next),
removeChip: (value) => removeChip(value),
})
return { rootRef, selectedValues, clearSelection, getFocusableChips, handleKeydown }
}