import React, { ElementType, HTMLAttributes, KeyboardEvent, MouseEvent, ReactNode, forwardRef, useEffect, useMemo, useRef, useState, } from 'react' import PropTypes from 'prop-types' import classNames from 'classnames' import { PolymorphicRefForwardingComponent } from '../../helpers' import { useForkedRef } from '../../hooks' import { colorPropType } from '../../props' import type { Colors } from '../../types' export interface CChipProps extends HTMLAttributes { /** * Toggles the active state of the React Chip component for non-selectable usage. */ active?: boolean /** * Provides an accessible label for the remove button in the React Chip component. */ ariaRemoveLabel?: string /** * Specifies the root element or custom component used by the React Chip component. */ as?: ElementType /** * Adds custom classes to the React Chip root element. */ className?: string /** * Enables interactive hover styling and pointer cursor for the React Chip component. */ clickable?: boolean /** * Sets the contextual color of the React Chip component using CoreUI theme colors. * * @type 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'dark' | 'light' | string */ color?: Colors /** * Disables the React Chip component and removes interactive behavior. */ disabled?: boolean /** * Callback fired when the React Chip component becomes deselected. */ onDeselect?: (event: MouseEvent | KeyboardEvent) => void /** * Callback fired when the React Chip component requests removal by button click or keyboard action. */ onRemove?: (event: MouseEvent | KeyboardEvent) => void /** * Callback fired when the React Chip component becomes selected. */ onSelect?: (event: MouseEvent | KeyboardEvent) => void /** * Callback fired when the selected state of the React Chip component changes. */ onSelectedChange?: ( selected: boolean, event: MouseEvent | KeyboardEvent ) => void /** * Displays a remove button inside the React Chip component. */ removable?: boolean /** * Replaces the default remove icon with a custom icon node in the React Chip component. */ removeIcon?: ReactNode /** * Enables selectable behavior and keyboard toggle support for the React Chip component. */ selectable?: boolean /** * Controls the selected state of a selectable React Chip component. */ selected?: boolean /** * Sets the size of the React Chip component to small or large. */ size?: 'sm' | 'lg' /** * Sets the visual variant of the React Chip component to outline style. */ variant?: 'outline' } const SELECTOR_FOCUSABLE_ITEMS = '[data-coreui-chip-focusable="true"]:not(.disabled)' export const CChip: PolymorphicRefForwardingComponent<'span', CChipProps> = forwardRef< HTMLSpanElement | HTMLButtonElement, CChipProps >( ( { active, ariaRemoveLabel = 'Remove', children, as: Component = 'span', className, clickable, color, disabled, onClick, onDeselect, onKeyDown, onRemove, onSelect, onSelectedChange, removable, removeIcon, selectable, selected, size, tabIndex, variant, ...rest }, ref ) => { const chipRef = useRef(null) const forkedRef = useForkedRef(ref, chipRef) const isSelectedControlled = selected !== undefined const [_selected, setSelected] = useState(Boolean(selected)) const selectedState = isSelectedControlled ? Boolean(selected) : _selected useEffect(() => { if (isSelectedControlled) { setSelected(Boolean(selected)) } }, [isSelectedControlled, selected]) const isFocusable = useMemo( () => Boolean(!disabled && (selectable || removable)), [disabled, selectable, removable] ) const getFocusableSibling = (shouldGetNext: boolean) => { const currentElement = chipRef.current if (!currentElement?.parentElement) { return null } const chips = Array.from( currentElement.parentElement.querySelectorAll(SELECTOR_FOCUSABLE_ITEMS) ) const index = chips.indexOf(currentElement as unknown as HTMLElement) if (index === -1 || chips.length <= 1) { return null } const targetIndex = shouldGetNext ? index + 1 : index - 1 return chips[targetIndex] ?? null } const navigateToEdge = (targetIndex: 0 | -1) => { const currentElement = chipRef.current if (!currentElement?.parentElement) { return } const chips = Array.from( currentElement.parentElement.querySelectorAll(SELECTOR_FOCUSABLE_ITEMS) ) const edgeChip = targetIndex === -1 ? chips[chips.length - 1] : chips[0] edgeChip?.focus() } const setSelectableState = ( nextSelected: boolean, event: MouseEvent | KeyboardEvent ) => { if (!selectable || disabled || nextSelected === selectedState) { return } if (!isSelectedControlled) { setSelected(nextSelected) } if (nextSelected) { onSelect?.(event) } else { onDeselect?.(event) } onSelectedChange?.(nextSelected, event) } const toggleSelectedState = (event: MouseEvent | KeyboardEvent) => { setSelectableState(!selectedState, event) } const handleRemove = (event: MouseEvent | KeyboardEvent) => { onRemove?.(event) } const handleRemoveClick = (event: MouseEvent) => { event.stopPropagation() handleRemove(event) } const handleClick = (event: MouseEvent) => { if (disabled) { return } if ((event.target as HTMLElement).closest('.chip-remove')) { return } if (selectable) { toggleSelectedState(event) } onClick?.(event) } const handleKeyDown = (event: KeyboardEvent) => { if (disabled) { onKeyDown?.(event) return } switch (event.key) { case 'Enter': case ' ': case 'Spacebar': { if (selectable) { event.preventDefault() toggleSelectedState(event) } break } case 'Backspace': case 'Delete': { if (removable) { event.preventDefault() const sibling = getFocusableSibling(false) || getFocusableSibling(true) sibling?.focus() handleRemove(event) } break } case 'ArrowLeft': { event.preventDefault() const sibling = getFocusableSibling(false) sibling?.focus() if (selectedState && event.shiftKey) { sibling?.dispatchEvent(new CustomEvent('coreui-chip-select')) } break } case 'ArrowRight': { event.preventDefault() const sibling = getFocusableSibling(true) sibling?.focus() if (selectedState && event.shiftKey) { sibling?.dispatchEvent(new CustomEvent('coreui-chip-select')) } break } case 'Home': { event.preventDefault() navigateToEdge(0) break } case 'End': { event.preventDefault() navigateToEdge(-1) break } // No default } onKeyDown?.(event) } return ( {children} {removable && ( )} ) } ) CChip.propTypes = { active: PropTypes.bool, ariaRemoveLabel: PropTypes.string, as: PropTypes.elementType, children: PropTypes.node, className: PropTypes.string, clickable: PropTypes.bool, color: colorPropType, disabled: PropTypes.bool, onDeselect: PropTypes.func, onRemove: PropTypes.func, onSelect: PropTypes.func, onSelectedChange: PropTypes.func, removable: PropTypes.bool, removeIcon: PropTypes.node, selectable: PropTypes.bool, selected: PropTypes.bool, size: PropTypes.oneOf(['sm', 'lg']), variant: PropTypes.oneOf(['outline']), } CChip.displayName = 'CChip'