Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions packages/coreui-react/src/components/chip-set/CChipSet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import React, {
ElementType,
HTMLAttributes,
KeyboardEvent,
ReactNode,
forwardRef,
useState,
} from 'react'
import PropTypes from 'prop-types'
import classNames from 'classnames'

import { useChipSet, type CChipSetItem } from './useChipSet'
import { PolymorphicRefForwardingComponent } from '../../helpers'
import { useForkedRef } from '../../hooks'

export type { CChipSetItem }

const itemValue = (item: string | CChipSetItem): string =>
typeof item === 'string' ? item : item.value

export interface CChipSetProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onSelect'> {
/**
* Provides an accessible label for the remove button of every chip rendered by the React Chip Set component.
*/
ariaRemoveLabel?: string
/**
* Specifies the root element or custom component used by the React Chip Set component.
*/
as?: ElementType
/**
* Renders chips from data instead of children. Each item is a string or an object with a `value`, an optional `label`, and any `CChip` props. Children are used when this is omitted.
*/
chips?: (string | CChipSetItem)[]
/**
* Adds custom classes to the React Chip Set root element.
*/
className?: string
/**
* Sets the initial uncontrolled chips. In this mode the React Chip Set component owns the list and removes chips itself; use `chips` (with `onRemove`) for a controlled list.
*/
defaultChips?: (string | CChipSetItem)[]
/**
* Sets the initial uncontrolled selection of the React Chip Set component.
*/
defaultSelected?: string[]
/**
* Disables every chip rendered by the React Chip Set component.
*/
disabled?: boolean
/**
* Turns the chips into filter chips, each showing a leading check icon while selected.
*/
filter?: boolean
/**
* Callback fired when a chip requests removal. The chips are controlled by their rendered children, so remove the chip from your data in response.
*/
onRemove?: (value: string) => void
/**
* Callback fired when the selected chip values of the React Chip Set component change.
*/
onSelect?: (selected: string[]) => void
/**
* Displays a remove button on every chip rendered by the React Chip Set component.
*/
removable?: boolean
/**
* Replaces the default remove icon on every chip with a custom icon node.
*/
removeIcon?: ReactNode
/**
* Enables selection behavior for the chips rendered by the React Chip Set component.
*/
selectable?: boolean
/**
* Controls the selected chip values of the React Chip Set component.
*
* @controllable onSelect
*/
selected?: string[]
/**
* Replaces the default selected icon shown by filter chips with a custom icon node.
*/
selectedIcon?: ReactNode
/**
* Sets how many chips can be selected at once in the React Chip Set component.
*/
selectionMode?: 'single' | 'multiple'
}

export const CChipSet: PolymorphicRefForwardingComponent<'div', CChipSetProps> = forwardRef<
HTMLDivElement,
CChipSetProps
>(
(
{
ariaRemoveLabel,
as: Component = 'div',
children,
chips,
className,
defaultChips,
defaultSelected,
disabled,
filter,
onKeyDown,
onRemove,
onSelect,
removable,
removeIcon,
selectable,
selected,
selectedIcon,
selectionMode,
...rest
},
ref
) => {
// The chip list is controlled by `chips` (or children) or, with `defaultChips`,
// uncontrolled — then the set owns the list and removes chips itself.
const isUncontrolledList = chips === undefined && defaultChips !== undefined
const [_chips, setChips] = useState<(string | CChipSetItem)[]>(defaultChips ?? [])

const { rootRef, handleKeyDown, renderChips, renderChipsFromData } = useChipSet({
ariaRemoveLabel,
defaultSelected,
disabled,
filter,
removable,
removeIcon,
selectable,
selected,
selectedIcon,
selectionMode,
onRemoveChip: (value) => {
if (isUncontrolledList) {
setChips((prev) => prev.filter((item) => itemValue(item) !== value))
}
onRemove?.(value)
},
onSelectionChange: onSelect,
})
const forkedRef = useForkedRef(ref, rootRef)

const items = chips ?? (isUncontrolledList ? _chips : undefined)

return (
<Component
className={classNames('chip-set', { disabled }, className)}
{...(disabled && { 'aria-disabled': true })}
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
handleKeyDown(event)
onKeyDown?.(event)
}}
{...rest}
ref={forkedRef}
>
{items ? renderChipsFromData(items) : renderChips(children)}
</Component>
)
}
)

CChipSet.propTypes = {
ariaRemoveLabel: PropTypes.string,
as: PropTypes.elementType,
children: PropTypes.node,
chips: PropTypes.array,
className: PropTypes.string,
defaultChips: PropTypes.array,
defaultSelected: PropTypes.array,
disabled: PropTypes.bool,
filter: PropTypes.bool,
onRemove: PropTypes.func,
onSelect: PropTypes.func,
removable: PropTypes.bool,
removeIcon: PropTypes.node,
selectable: PropTypes.bool,
selected: PropTypes.array,
selectedIcon: PropTypes.node,
selectionMode: PropTypes.oneOf(['single', 'multiple']),
}

CChipSet.displayName = 'CChipSet'
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import * as React from 'react'
import { fireEvent, render } from '@testing-library/react'
import '@testing-library/jest-dom'
import { CChip } from '../../chip'
import { CChipSet } from '../index'

test('loads and displays CChipSet component', async () => {
const { container } = render(
<CChipSet>
<CChip value="a">A</CChip>
<CChip value="b">B</CChip>
</CChipSet>,
)
expect(container).toMatchSnapshot()
expect(container.firstChild).toHaveClass('chip-set')
})

test('CChipSet passes selectable down to its chips', async () => {
const { getByText } = render(
<CChipSet selectable={true}>
<CChip value="a">A</CChip>
<CChip value="b">B</CChip>
</CChipSet>,
)

expect(getByText('A')).toHaveAttribute('aria-selected', 'false')
fireEvent.click(getByText('A'))
expect(getByText('A')).toHaveClass('active')
})

test('CChipSet allows multiple selected chips by default', async () => {
const onSelect = jest.fn()
const { getByText } = render(
<CChipSet selectable={true} onSelect={onSelect}>
<CChip value="a">A</CChip>
<CChip value="b">B</CChip>
</CChipSet>,
)

fireEvent.click(getByText('A'))
fireEvent.click(getByText('B'))

expect(getByText('A')).toHaveClass('active')
expect(getByText('B')).toHaveClass('active')
expect(onSelect).toHaveBeenLastCalledWith(['a', 'b'])
})

test('CChipSet deselects siblings in single selection mode', async () => {
const onSelect = jest.fn()
const { getByText } = render(
<CChipSet selectable={true} selectionMode="single" onSelect={onSelect}>
<CChip value="a">A</CChip>
<CChip value="b">B</CChip>
</CChipSet>,
)

fireEvent.click(getByText('A'))
fireEvent.click(getByText('B'))

expect(getByText('A')).not.toHaveClass('active')
expect(getByText('B')).toHaveClass('active')
expect(onSelect).toHaveBeenLastCalledWith(['b'])
})

test('CChipSet honors a controlled selected', async () => {
const { getByText } = render(
<CChipSet selectable={true} selected={['b']}>
<CChip value="a">A</CChip>
<CChip value="b">B</CChip>
</CChipSet>,
)

expect(getByText('A')).not.toHaveClass('active')
expect(getByText('B')).toHaveClass('active')
})

test('CChipSet forwards filter so a selected chip shows a check icon', async () => {
const { getByText, container } = render(
<CChipSet filter={true} selected={['a']}>
<CChip value="a">A</CChip>
</CChipSet>,
)

expect(getByText('A')).toHaveClass('active')
expect(container.querySelector('.chip-check')).toBeInTheDocument()
})

test('CChipSet moves focus between chips with the keyboard', async () => {
const { getByText } = render(
<CChipSet selectable>
<CChip value="first">First</CChip>
<CChip value="second">Second</CChip>
<CChip value="third">Third</CChip>
</CChipSet>,
)

const first = getByText('First')
const second = getByText('Second')
const third = getByText('Third')

first.focus()
fireEvent.keyDown(first, { key: 'ArrowRight' })
expect(second).toHaveFocus()

fireEvent.keyDown(second, { key: 'End' })
expect(third).toHaveFocus()

fireEvent.keyDown(third, { key: 'Home' })
expect(first).toHaveFocus()

// No cycling past the first chip.
fireEvent.keyDown(first, { key: 'ArrowLeft' })
expect(first).toHaveFocus()
})

test('CChipSet mirrors arrow keys in RTL', async () => {
document.documentElement.dir = 'rtl'

const { getByText } = render(
<CChipSet selectable>
<CChip value="first">First</CChip>
<CChip value="second">Second</CChip>
</CChipSet>,
)

const first = getByText('First')
const second = getByText('Second')

first.focus()
// In RTL, ArrowLeft moves to the next chip.
fireEvent.keyDown(first, { key: 'ArrowLeft' })
expect(second).toHaveFocus()

fireEvent.keyDown(second, { key: 'ArrowRight' })
expect(first).toHaveFocus()

document.documentElement.dir = ''
})

test('CChipSet fires onRemove so the parent can drop the chip', async () => {
const onRemove = jest.fn()

const Example = () => {
const [values, setValues] = React.useState(['a', 'b'])
return (
<CChipSet
removable
onRemove={(value) => {
onRemove(value)
setValues((prev) => prev.filter((item) => item !== value))
}}
>
{values.map((value) => (
<CChip key={value} value={value}>
{value.toUpperCase()}
</CChip>
))}
</CChipSet>
)
}

const { getByText, queryByText } = render(<Example />)

const removeButton = getByText('A').querySelector('.chip-remove')
fireEvent.click(removeButton as Element)

expect(onRemove).toHaveBeenCalledWith('a')
expect(queryByText('A')).not.toBeInTheDocument()
expect(getByText('B')).toBeInTheDocument()
})

test('CChipSet with defaultChips removes the chip itself (uncontrolled list)', async () => {
const onRemove = jest.fn()
const { getByText, queryByText } = render(
<CChipSet removable defaultChips={['a', { value: 'b', label: 'B' }]} onRemove={onRemove} />,
)

fireEvent.click(getByText('a').querySelector('.chip-remove') as Element)

expect(onRemove).toHaveBeenCalledWith('a')
expect(queryByText('a')).not.toBeInTheDocument()
expect(getByText('B')).toBeInTheDocument()
})

test('CChipSet renders chips from the chips prop (strings and objects)', async () => {
const { getByText, container } = render(
<CChipSet
selectable
chips={['react', { value: 'vue', label: 'Vue' }, { value: 'ng', label: 'Angular', selectable: false }]}
/>,
)

expect(container.querySelectorAll('.chip')).toHaveLength(3)
expect(getByText('react')).toBeInTheDocument()
expect(getByText('Vue')).toBeInTheDocument()

// Per-item override still works with the data-driven API.
fireEvent.click(getByText('Angular'))
expect(getByText('Angular')).not.toHaveClass('active')
})

test('CChipSet disables every chip', async () => {
const { getByText } = render(
<CChipSet disabled={true} removable={true}>
<CChip value="a">A</CChip>
</CChipSet>,
)

expect(getByText('A')).toHaveClass('disabled')
expect(getByText('A').querySelector('.chip-remove')).toBeNull()
})
Loading