From 9a282bb8c0d23736c319dfaa34679f2a6e36d5bf Mon Sep 17 00:00:00 2001 From: mrholek Date: Wed, 10 Jun 2026 16:06:03 +0200 Subject: [PATCH 01/12] feat(Chip): add CChipSet component and filter chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CChipSet (own components/chip-set/ folder) for grouping chips, built on a shared useChipSet hook — the React equivalent of the vanilla ChipInput extends ChipSet. The hook owns selection coordination (single/multiple, controlled value), roving focus, focus-a-neighbor-after-removal, and per-chip prop injection; CChipSet is a thin .chip-set wrapper and CChipInput now builds on the same hook instead of duplicating that logic, adding only the text-input layer. Move roving focus out of CChip into the useChipSet engine; CChip handles only its own Enter/Space and Backspace/Delete. The focusable-chip selector is shared via chip/const.ts; chip identity in a set comes from the value prop (chip-set/utils.ts). Add a filter prop to CChip (implies selectable, leading check icon while selected, customizable via selectedIcon) and to CChipInput. Document CChipSet on a new /components/chip-set/ page (with styling) mirroring the vanilla docs. Includes tests, docs examples, and API tables. --- .../src/components/chip-set/CChipSet.tsx | 147 +++++++++++++ .../chip-set/__tests__/CChipSet.spec.tsx | 156 +++++++++++++ .../__snapshots__/CChipSet.spec.tsx.snap | 22 ++ .../src/components/chip-set/index.ts | 3 + .../src/components/chip-set/useChipSet.ts | 206 ++++++++++++++++++ .../src/components/chip-set/utils.ts | 19 ++ .../src/components/chip/CChip.tsx | 120 ++++------ .../components/chip/__tests__/CChip.spec.tsx | 41 ++-- .../coreui-react/src/components/chip/const.ts | 3 + .../src/components/form/CChipInput.tsx | 103 ++++----- .../__snapshots__/CChipInput.spec.tsx.snap | 2 + packages/coreui-react/src/components/index.ts | 1 + packages/docs/content/api/CChip.api.mdx | 32 ++- packages/docs/content/api/CChipInput.api.mdx | 10 + packages/docs/content/api/CChipSet.api.mdx | 160 ++++++++++++++ .../docs/content/components/chip-set/api.mdx | 12 + .../chip-set/examples/ChipSetExample.tsx | 13 ++ .../examples/ChipSetFilterExample.tsx | 13 ++ .../examples/ChipSetRemovableExample.tsx | 23 ++ .../examples/ChipSetSelectableExample.tsx | 13 ++ .../examples/ChipSetSingleExample.tsx | 12 + .../content/components/chip-set/index.mdx | 78 +++++++ .../content/components/chip-set/styling.mdx | 35 +++ .../docs/content/components/chip/index.mdx | 22 +- packages/docs/src/nav.tsx | 8 + 25 files changed, 1091 insertions(+), 163 deletions(-) create mode 100644 packages/coreui-react/src/components/chip-set/CChipSet.tsx create mode 100644 packages/coreui-react/src/components/chip-set/__tests__/CChipSet.spec.tsx create mode 100644 packages/coreui-react/src/components/chip-set/__tests__/__snapshots__/CChipSet.spec.tsx.snap create mode 100644 packages/coreui-react/src/components/chip-set/index.ts create mode 100644 packages/coreui-react/src/components/chip-set/useChipSet.ts create mode 100644 packages/coreui-react/src/components/chip-set/utils.ts create mode 100644 packages/coreui-react/src/components/chip/const.ts create mode 100644 packages/docs/content/api/CChipSet.api.mdx create mode 100644 packages/docs/content/components/chip-set/api.mdx create mode 100644 packages/docs/content/components/chip-set/examples/ChipSetExample.tsx create mode 100644 packages/docs/content/components/chip-set/examples/ChipSetFilterExample.tsx create mode 100644 packages/docs/content/components/chip-set/examples/ChipSetRemovableExample.tsx create mode 100644 packages/docs/content/components/chip-set/examples/ChipSetSelectableExample.tsx create mode 100644 packages/docs/content/components/chip-set/examples/ChipSetSingleExample.tsx create mode 100644 packages/docs/content/components/chip-set/index.mdx create mode 100644 packages/docs/content/components/chip-set/styling.mdx diff --git a/packages/coreui-react/src/components/chip-set/CChipSet.tsx b/packages/coreui-react/src/components/chip-set/CChipSet.tsx new file mode 100644 index 00000000..0f46a7bf --- /dev/null +++ b/packages/coreui-react/src/components/chip-set/CChipSet.tsx @@ -0,0 +1,147 @@ +import React, { ElementType, HTMLAttributes, KeyboardEvent, ReactNode, forwardRef } from 'react' +import PropTypes from 'prop-types' +import classNames from 'classnames' + +import { useChipSet } from './useChipSet' +import { PolymorphicRefForwardingComponent } from '../../helpers' +import { useForkedRef } from '../../hooks' + +export interface CChipSetProps extends Omit, 'onChange'> { + /** + * 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 + /** + * Adds custom classes to the React Chip Set root element. + */ + className?: string + /** + * Sets the initial uncontrolled selection of the React Chip Set component. + */ + defaultValue?: 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 the selected chip values of the React Chip Set component change. + */ + onChange?: (selected: string[]) => void + /** + * 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 + /** + * 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 + /** + * 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' + /** + * Controls the selected chip values rendered by the React Chip Set component. + * + * @controllable onChange + */ + value?: string[] +} + +export const CChipSet: PolymorphicRefForwardingComponent<'div', CChipSetProps> = forwardRef< + HTMLDivElement, + CChipSetProps +>( + ( + { + ariaRemoveLabel, + as: Component = 'div', + children, + className, + defaultValue, + disabled, + filter, + onChange, + onKeyDown, + onRemove, + removable, + removeIcon, + selectable, + selectedIcon, + selectionMode, + value, + ...rest + }, + ref + ) => { + const { rootRef, handleKeyDown, renderChips } = useChipSet({ + ariaRemoveLabel, + defaultValue, + disabled, + filter, + removable, + removeIcon, + selectable, + selectedIcon, + selectionMode, + value, + onRemoveChip: onRemove, + onSelectionChange: onChange, + }) + const forkedRef = useForkedRef(ref, rootRef) + + return ( + ) => { + handleKeyDown(event) + onKeyDown?.(event) + }} + {...rest} + ref={forkedRef} + > + {renderChips(children)} + + ) + } +) + +CChipSet.propTypes = { + ariaRemoveLabel: PropTypes.string, + as: PropTypes.elementType, + children: PropTypes.node, + className: PropTypes.string, + defaultValue: PropTypes.array, + disabled: PropTypes.bool, + filter: PropTypes.bool, + onChange: PropTypes.func, + onRemove: PropTypes.func, + removable: PropTypes.bool, + removeIcon: PropTypes.node, + selectable: PropTypes.bool, + selectedIcon: PropTypes.node, + selectionMode: PropTypes.oneOf(['single', 'multiple']), + value: PropTypes.array, +} + +CChipSet.displayName = 'CChipSet' diff --git a/packages/coreui-react/src/components/chip-set/__tests__/CChipSet.spec.tsx b/packages/coreui-react/src/components/chip-set/__tests__/CChipSet.spec.tsx new file mode 100644 index 00000000..cbe8e61b --- /dev/null +++ b/packages/coreui-react/src/components/chip-set/__tests__/CChipSet.spec.tsx @@ -0,0 +1,156 @@ +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( + + A + B + , + ) + expect(container).toMatchSnapshot() + expect(container.firstChild).toHaveClass('chip-set') +}) + +test('CChipSet passes selectable down to its chips', async () => { + const { getByText } = render( + + A + B + , + ) + + 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 onChange = jest.fn() + const { getByText } = render( + + A + B + , + ) + + fireEvent.click(getByText('A')) + fireEvent.click(getByText('B')) + + expect(getByText('A')).toHaveClass('active') + expect(getByText('B')).toHaveClass('active') + expect(onChange).toHaveBeenLastCalledWith(['a', 'b']) +}) + +test('CChipSet deselects siblings in single selection mode', async () => { + const onChange = jest.fn() + const { getByText } = render( + + A + B + , + ) + + fireEvent.click(getByText('A')) + fireEvent.click(getByText('B')) + + expect(getByText('A')).not.toHaveClass('active') + expect(getByText('B')).toHaveClass('active') + expect(onChange).toHaveBeenLastCalledWith(['b']) +}) + +test('CChipSet honors a controlled value', async () => { + const { getByText } = render( + + A + B + , + ) + + 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( + + A + , + ) + + expect(getByText('A')).toHaveClass('active') + expect(container.querySelector('.chip-check')).toBeInTheDocument() +}) + +test('CChipSet moves focus between chips with the keyboard', async () => { + const { getByText } = render( + + First + Second + Third + , + ) + + 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 fires onRemove so the parent can drop the chip', async () => { + const onRemove = jest.fn() + + const Example = () => { + const [values, setValues] = React.useState(['a', 'b']) + return ( + { + onRemove(value) + setValues((prev) => prev.filter((item) => item !== value)) + }} + > + {values.map((value) => ( + + {value.toUpperCase()} + + ))} + + ) + } + + const { getByText, queryByText } = render() + + 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 disables every chip', async () => { + const { getByText } = render( + + A + , + ) + + expect(getByText('A')).toHaveClass('disabled') +}) diff --git a/packages/coreui-react/src/components/chip-set/__tests__/__snapshots__/CChipSet.spec.tsx.snap b/packages/coreui-react/src/components/chip-set/__tests__/__snapshots__/CChipSet.spec.tsx.snap new file mode 100644 index 00000000..10a2e065 --- /dev/null +++ b/packages/coreui-react/src/components/chip-set/__tests__/__snapshots__/CChipSet.spec.tsx.snap @@ -0,0 +1,22 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`loads and displays CChipSet component 1`] = ` +
+
+ + A + + + B + +
+
+`; diff --git a/packages/coreui-react/src/components/chip-set/index.ts b/packages/coreui-react/src/components/chip-set/index.ts new file mode 100644 index 00000000..6fdb081e --- /dev/null +++ b/packages/coreui-react/src/components/chip-set/index.ts @@ -0,0 +1,3 @@ +import { CChipSet } from './CChipSet' + +export { CChipSet } diff --git a/packages/coreui-react/src/components/chip-set/useChipSet.ts b/packages/coreui-react/src/components/chip-set/useChipSet.ts new file mode 100644 index 00000000..297a1ca5 --- /dev/null +++ b/packages/coreui-react/src/components/chip-set/useChipSet.ts @@ -0,0 +1,206 @@ +import React, { + KeyboardEvent, + MouseEvent, + ReactNode, + isValidElement, + useEffect, + useRef, + useState, +} from 'react' + +import type { CChipProps } from '../chip/CChip' +import { SELECTOR_CHIP_FOCUSABLE } from '../chip/const' +import { resolveChipValue } from './utils' + +export interface UseChipSetOptions { + ariaRemoveLabel?: string + defaultValue?: string[] + disabled?: boolean + filter?: boolean + removable?: boolean + removeIcon?: ReactNode + /** + * Move focus to a neighboring chip after one is removed. Containers that own a + * different focus target after removal (e.g. CChipInput refocuses its input) + * set this to `false`. + */ + restoreFocusOnRemove?: boolean + selectable?: boolean + selectedIcon?: ReactNode + selectionMode?: 'single' | 'multiple' + value?: string[] + onRemoveChip?: (value: string) => void + onSelectionChange?: (selected: string[]) => void +} + +/** + * The shared chip-set engine. Both `CChipSet` and `CChipInput` build on it — the + * React equivalent of the vanilla `ChipInput extends ChipSet`. It owns selection + * coordination (single/multiple, controlled-or-uncontrolled), roving focus, the + * focus-a-neighbor-after-removal behavior, and the per-chip prop injection. + * Existence of the chips stays with the caller (its children / value list). + */ +export const useChipSet = (options: UseChipSetOptions) => { + const { + ariaRemoveLabel, + defaultValue = [], + disabled, + filter, + removable, + removeIcon, + restoreFocusOnRemove = true, + selectable, + selectedIcon, + selectionMode = 'multiple', + value, + onRemoveChip, + onSelectionChange, + } = options + + const rootRef = useRef(null) + const pendingFocusValue = useRef(null) + const isControlled = value !== undefined + const [_selected, setSelected] = useState(defaultValue) + // In controlled mode the value prop drives selection directly; _selected is + // only read when uncontrolled, so no syncing effect is needed. + const selectedValues = isControlled ? (value as string[]) : _selected + + // Focus the saved neighbor once the removed chip has left the DOM. + useEffect(() => { + if (pendingFocusValue.current === null) { + return + } + + const selector = `[data-coreui-chip-value="${pendingFocusValue.current}"]` + rootRef.current?.querySelector(selector)?.focus() + pendingFocusValue.current = null + }) + + const getFocusableChips = (): HTMLElement[] => [ + ...(rootRef.current?.querySelectorAll(SELECTOR_CHIP_FOCUSABLE) ?? []), + ] + + const emitSelection = (next: string[]) => { + if (!isControlled) { + setSelected(next) + } + onSelectionChange?.(next) + } + + const updateSelection = (chipValue: string, nextSelected: boolean) => { + const isSelected = selectedValues.includes(chipValue) + if (nextSelected === isSelected) { + return + } + + if (nextSelected) { + // Single selection mode keeps only the freshly selected chip. + emitSelection(selectionMode === 'single' ? [chipValue] : [...selectedValues, chipValue]) + return + } + + emitSelection(selectedValues.filter((item) => item !== chipValue)) + } + + const clearSelection = () => { + if (selectedValues.length > 0) { + emitSelection([]) + } + } + + const removeChip = (chipValue: string) => { + if (restoreFocusOnRemove) { + const chips = getFocusableChips() + const index = chips.findIndex((chip) => chip.dataset.coreuiChipValue === chipValue) + // Prefer the next chip, fall back to the previous one at the end. + const neighbor = chips[index + 1] ?? chips[index - 1] ?? null + pendingFocusValue.current = neighbor?.dataset.coreuiChipValue ?? null + } + + emitSelection(selectedValues.filter((item) => item !== chipValue)) + onRemoveChip?.(chipValue) + } + + // 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(SELECTOR_CHIP_FOCUSABLE) + if (!chip) { + return false + } + + const chips = getFocusableChips() + const index = chips.indexOf(chip) + + switch (event.key) { + case 'ArrowLeft': { + event.preventDefault() + chips[index - 1]?.focus() + return true + } + + case 'ArrowRight': { + event.preventDefault() + chips[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 + } + } + } + + const renderChips = (children: ReactNode): ReactNode => + React.Children.map(children, (child, index) => { + if (!isValidElement(child)) { + return child + } + + const chipValue = resolveChipValue(child, index) + const childSelectable = child.props.selectable ?? selectable + const childFilter = child.props.filter ?? filter + const childRemovable = child.props.removable ?? removable + const isCoordinated = Boolean(childSelectable || childFilter) + + return React.cloneElement(child, { + ariaRemoveLabel: child.props.ariaRemoveLabel ?? ariaRemoveLabel, + disabled: child.props.disabled ?? disabled, + filter: childFilter, + removable: childRemovable, + removeIcon: child.props.removeIcon ?? removeIcon, + selectable: childSelectable, + selectedIcon: child.props.selectedIcon ?? selectedIcon, + ...(isCoordinated && { + selected: selectedValues.includes(chipValue), + onSelectedChange: ( + selected: boolean, + event: MouseEvent | KeyboardEvent + ) => { + updateSelection(chipValue, selected) + child.props.onSelectedChange?.(selected, event) + }, + }), + ...(childRemovable && { + onRemove: (event: MouseEvent | KeyboardEvent) => { + removeChip(chipValue) + child.props.onRemove?.(event) + }, + }), + }) + }) + + return { rootRef, selectedValues, clearSelection, getFocusableChips, handleKeyDown, renderChips } +} diff --git a/packages/coreui-react/src/components/chip-set/utils.ts b/packages/coreui-react/src/components/chip-set/utils.ts new file mode 100644 index 00000000..03ae54b2 --- /dev/null +++ b/packages/coreui-react/src/components/chip-set/utils.ts @@ -0,0 +1,19 @@ +import { ReactElement } from 'react' + +import type { CChipProps } from '../chip/CChip' + +/** + * Resolves the value used to identify a chip in a `CChipSet`: its explicit + * `value` prop, its text children, or the render index as a last resort. + */ +export const resolveChipValue = (chip: ReactElement, index: number): string => { + if (chip.props.value !== undefined) { + return chip.props.value + } + + if (typeof chip.props.children === 'string') { + return chip.props.children + } + + return String(index) +} diff --git a/packages/coreui-react/src/components/chip/CChip.tsx b/packages/coreui-react/src/components/chip/CChip.tsx index 9dc32049..20ff7117 100644 --- a/packages/coreui-react/src/components/chip/CChip.tsx +++ b/packages/coreui-react/src/components/chip/CChip.tsx @@ -49,6 +49,10 @@ export interface CChipProps extends HTMLAttributes = forwardRef< HTMLSpanElement | HTMLButtonElement, CChipProps @@ -110,6 +120,7 @@ export const CChip: PolymorphicRefForwardingComponent<'span', CChipProps> = forw clickable, color, disabled, + filter, onClick, onDeselect, onKeyDown, @@ -120,13 +131,17 @@ export const CChip: PolymorphicRefForwardingComponent<'span', CChipProps> = forw removeIcon, selectable, selected, + selectedIcon, size, tabIndex, + value, variant, ...rest }, ref ) => { + // A filter chip is selectable by definition. + const isSelectable = Boolean(selectable || filter) const chipRef = useRef(null) const forkedRef = useForkedRef(ref, chipRef) const isSelectedControlled = selected !== undefined @@ -140,47 +155,15 @@ export const CChip: PolymorphicRefForwardingComponent<'span', CChipProps> = forw }, [isSelectedControlled, selected]) const isFocusable = useMemo( - () => Boolean(!disabled && (selectable || removable)), - [disabled, selectable, removable] + () => Boolean(!disabled && (isSelectable || removable)), + [disabled, isSelectable, 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) { + if (!isSelectable || disabled || nextSelected === selectedState) { return } @@ -219,7 +202,7 @@ export const CChip: PolymorphicRefForwardingComponent<'span', CChipProps> = forw return } - if (selectable) { + if (isSelectable) { toggleSelectedState(event) } @@ -236,7 +219,7 @@ export const CChip: PolymorphicRefForwardingComponent<'span', CChipProps> = forw case 'Enter': case ' ': case 'Spacebar': { - if (selectable) { + if (isSelectable) { event.preventDefault() toggleSelectedState(event) } @@ -247,45 +230,11 @@ export const CChip: PolymorphicRefForwardingComponent<'span', CChipProps> = forw 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 } @@ -297,18 +246,19 @@ export const CChip: PolymorphicRefForwardingComponent<'span', CChipProps> = forw className={classNames( 'chip', { - active: selectable ? selectedState : active, + active: isSelectable ? selectedState : active, disabled, [`chip-${color}`]: color, [`chip-${size}`]: size, - 'chip-clickable': clickable || selectable || Boolean(onClick), + 'chip-clickable': clickable || isSelectable || Boolean(onClick), 'chip-outline': variant === 'outline', }, className )} data-coreui-chip-focusable={isFocusable || undefined} + data-coreui-chip-value={value} {...(disabled && { 'aria-disabled': true })} - {...(selectable && { 'aria-selected': selectedState })} + {...(isSelectable && { 'aria-selected': selectedState })} {...(isFocusable && tabIndex === undefined && { tabIndex: 0 })} onClick={handleClick} onKeyDown={handleKeyDown} @@ -316,6 +266,21 @@ export const CChip: PolymorphicRefForwardingComponent<'span', CChipProps> = forw {...rest} ref={forkedRef} > + {filter && selectedState && ( + + )} {children} {removable && (