@coreui/react 5.9.1 → 5.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +2 -2
  3. package/dist/cjs/components/chip/CChip.d.ts +76 -0
  4. package/dist/cjs/components/chip/CChip.js +178 -0
  5. package/dist/cjs/components/chip/CChip.js.map +1 -0
  6. package/dist/cjs/components/chip/index.d.ts +2 -0
  7. package/dist/cjs/components/dropdown/CDropdown.js +1 -1
  8. package/dist/cjs/components/dropdown/CDropdown.js.map +1 -1
  9. package/dist/cjs/components/form/CChipInput.d.ts +92 -0
  10. package/dist/cjs/components/form/CChipInput.js +253 -0
  11. package/dist/cjs/components/form/CChipInput.js.map +1 -0
  12. package/dist/cjs/components/form/CFormSelect.js +1 -0
  13. package/dist/cjs/components/form/CFormSelect.js.map +1 -1
  14. package/dist/cjs/components/form/index.d.ts +2 -1
  15. package/dist/cjs/components/index.d.ts +1 -0
  16. package/dist/cjs/index.js +4 -0
  17. package/dist/cjs/index.js.map +1 -1
  18. package/dist/esm/components/chip/CChip.d.ts +76 -0
  19. package/dist/esm/components/chip/CChip.js +176 -0
  20. package/dist/esm/components/chip/CChip.js.map +1 -0
  21. package/dist/esm/components/chip/index.d.ts +2 -0
  22. package/dist/esm/components/dropdown/CDropdown.js +1 -1
  23. package/dist/esm/components/dropdown/CDropdown.js.map +1 -1
  24. package/dist/esm/components/form/CChipInput.d.ts +92 -0
  25. package/dist/esm/components/form/CChipInput.js +251 -0
  26. package/dist/esm/components/form/CChipInput.js.map +1 -0
  27. package/dist/esm/components/form/CFormSelect.js +1 -0
  28. package/dist/esm/components/form/CFormSelect.js.map +1 -1
  29. package/dist/esm/components/form/index.d.ts +2 -1
  30. package/dist/esm/components/index.d.ts +1 -0
  31. package/dist/esm/index.js +2 -0
  32. package/dist/esm/index.js.map +1 -1
  33. package/package.json +11 -11
  34. package/src/components/chip/CChip.tsx +372 -0
  35. package/src/components/chip/__tests__/CChip.spec.tsx +113 -0
  36. package/src/components/chip/__tests__/__snapshots__/CChip.spec.tsx.snap +65 -0
  37. package/src/components/chip/index.ts +3 -0
  38. package/src/components/dropdown/CDropdown.tsx +1 -1
  39. package/src/components/form/CChipInput.tsx +477 -0
  40. package/src/components/form/CFormSelect.tsx +2 -0
  41. package/src/components/form/__tests__/CChipInput.spec.tsx +62 -0
  42. package/src/components/form/__tests__/__snapshots__/CChipInput.spec.tsx.snap +91 -0
  43. package/src/components/form/index.ts +2 -0
  44. package/src/components/index.ts +1 -0
@@ -0,0 +1,372 @@
1
+ import React, {
2
+ ElementType,
3
+ HTMLAttributes,
4
+ KeyboardEvent,
5
+ MouseEvent,
6
+ ReactNode,
7
+ forwardRef,
8
+ useEffect,
9
+ useMemo,
10
+ useRef,
11
+ useState,
12
+ } from 'react'
13
+ import PropTypes from 'prop-types'
14
+ import classNames from 'classnames'
15
+
16
+ import { PolymorphicRefForwardingComponent } from '../../helpers'
17
+ import { useForkedRef } from '../../hooks'
18
+ import { colorPropType } from '../../props'
19
+ import type { Colors } from '../../types'
20
+
21
+ export interface CChipProps extends HTMLAttributes<HTMLSpanElement | HTMLButtonElement> {
22
+ /**
23
+ * Toggles the active state of the React Chip component for non-selectable usage.
24
+ */
25
+ active?: boolean
26
+ /**
27
+ * Provides an accessible label for the remove button in the React Chip component.
28
+ */
29
+ ariaRemoveLabel?: string
30
+ /**
31
+ * Specifies the root element or custom component used by the React Chip component.
32
+ */
33
+ as?: ElementType
34
+ /**
35
+ * Adds custom classes to the React Chip root element.
36
+ */
37
+ className?: string
38
+ /**
39
+ * Enables interactive hover styling and pointer cursor for the React Chip component.
40
+ */
41
+ clickable?: boolean
42
+ /**
43
+ * Sets the contextual color of the React Chip component using CoreUI theme colors.
44
+ *
45
+ * @type 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'dark' | 'light' | string
46
+ */
47
+ color?: Colors
48
+ /**
49
+ * Disables the React Chip component and removes interactive behavior.
50
+ */
51
+ disabled?: boolean
52
+ /**
53
+ * Callback fired when the React Chip component becomes deselected.
54
+ */
55
+ onDeselect?: (event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => void
56
+ /**
57
+ * Callback fired when the React Chip component requests removal by button click or keyboard action.
58
+ */
59
+ onRemove?: (event: MouseEvent<HTMLButtonElement> | KeyboardEvent<HTMLElement>) => void
60
+ /**
61
+ * Callback fired when the React Chip component becomes selected.
62
+ */
63
+ onSelect?: (event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => void
64
+ /**
65
+ * Callback fired when the selected state of the React Chip component changes.
66
+ */
67
+ onSelectedChange?: (
68
+ selected: boolean,
69
+ event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>
70
+ ) => void
71
+ /**
72
+ * Displays a remove button inside the React Chip component.
73
+ */
74
+ removable?: boolean
75
+ /**
76
+ * Replaces the default remove icon with a custom icon node in the React Chip component.
77
+ */
78
+ removeIcon?: ReactNode
79
+ /**
80
+ * Enables selectable behavior and keyboard toggle support for the React Chip component.
81
+ */
82
+ selectable?: boolean
83
+ /**
84
+ * Controls the selected state of a selectable React Chip component.
85
+ */
86
+ selected?: boolean
87
+ /**
88
+ * Sets the size of the React Chip component to small or large.
89
+ */
90
+ size?: 'sm' | 'lg'
91
+ /**
92
+ * Sets the visual variant of the React Chip component to outline style.
93
+ */
94
+ variant?: 'outline'
95
+ }
96
+
97
+ const SELECTOR_FOCUSABLE_ITEMS = '[data-coreui-chip-focusable="true"]:not(.disabled)'
98
+
99
+ export const CChip: PolymorphicRefForwardingComponent<'span', CChipProps> = forwardRef<
100
+ HTMLSpanElement | HTMLButtonElement,
101
+ CChipProps
102
+ >(
103
+ (
104
+ {
105
+ active,
106
+ ariaRemoveLabel = 'Remove',
107
+ children,
108
+ as: Component = 'span',
109
+ className,
110
+ clickable,
111
+ color,
112
+ disabled,
113
+ onClick,
114
+ onDeselect,
115
+ onKeyDown,
116
+ onRemove,
117
+ onSelect,
118
+ onSelectedChange,
119
+ removable,
120
+ removeIcon,
121
+ selectable,
122
+ selected,
123
+ size,
124
+ tabIndex,
125
+ variant,
126
+ ...rest
127
+ },
128
+ ref
129
+ ) => {
130
+ const chipRef = useRef<HTMLSpanElement | HTMLButtonElement>(null)
131
+ const forkedRef = useForkedRef(ref, chipRef)
132
+ const isSelectedControlled = selected !== undefined
133
+ const [_selected, setSelected] = useState(Boolean(selected))
134
+ const selectedState = isSelectedControlled ? Boolean(selected) : _selected
135
+
136
+ useEffect(() => {
137
+ if (isSelectedControlled) {
138
+ setSelected(Boolean(selected))
139
+ }
140
+ }, [isSelectedControlled, selected])
141
+
142
+ const isFocusable = useMemo(
143
+ () => Boolean(!disabled && (selectable || removable)),
144
+ [disabled, selectable, removable]
145
+ )
146
+
147
+ const getFocusableSibling = (shouldGetNext: boolean) => {
148
+ const currentElement = chipRef.current
149
+ if (!currentElement?.parentElement) {
150
+ return null
151
+ }
152
+
153
+ const chips = Array.from(
154
+ currentElement.parentElement.querySelectorAll<HTMLElement>(SELECTOR_FOCUSABLE_ITEMS)
155
+ )
156
+
157
+ const index = chips.indexOf(currentElement as unknown as HTMLElement)
158
+ if (index === -1 || chips.length <= 1) {
159
+ return null
160
+ }
161
+
162
+ const targetIndex = shouldGetNext ? index + 1 : index - 1
163
+ return chips[targetIndex] ?? null
164
+ }
165
+
166
+ const navigateToEdge = (targetIndex: 0 | -1) => {
167
+ const currentElement = chipRef.current
168
+ if (!currentElement?.parentElement) {
169
+ return
170
+ }
171
+
172
+ const chips = Array.from(
173
+ currentElement.parentElement.querySelectorAll<HTMLElement>(SELECTOR_FOCUSABLE_ITEMS)
174
+ )
175
+ const edgeChip = targetIndex === -1 ? chips[chips.length - 1] : chips[0]
176
+ edgeChip?.focus()
177
+ }
178
+
179
+ const setSelectableState = (
180
+ nextSelected: boolean,
181
+ event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>
182
+ ) => {
183
+ if (!selectable || disabled || nextSelected === selectedState) {
184
+ return
185
+ }
186
+
187
+ if (!isSelectedControlled) {
188
+ setSelected(nextSelected)
189
+ }
190
+
191
+ if (nextSelected) {
192
+ onSelect?.(event)
193
+ } else {
194
+ onDeselect?.(event)
195
+ }
196
+
197
+ onSelectedChange?.(nextSelected, event)
198
+ }
199
+
200
+ const toggleSelectedState = (event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => {
201
+ setSelectableState(!selectedState, event)
202
+ }
203
+
204
+ const handleRemove = (event: MouseEvent<HTMLButtonElement> | KeyboardEvent<HTMLElement>) => {
205
+ onRemove?.(event)
206
+ }
207
+
208
+ const handleRemoveClick = (event: MouseEvent<HTMLButtonElement>) => {
209
+ event.stopPropagation()
210
+ handleRemove(event)
211
+ }
212
+
213
+ const handleClick = (event: MouseEvent<HTMLElement>) => {
214
+ if (disabled) {
215
+ return
216
+ }
217
+
218
+ if ((event.target as HTMLElement).closest('.chip-remove')) {
219
+ return
220
+ }
221
+
222
+ if (selectable) {
223
+ toggleSelectedState(event)
224
+ }
225
+
226
+ onClick?.(event)
227
+ }
228
+
229
+ const handleKeyDown = (event: KeyboardEvent<HTMLElement>) => {
230
+ if (disabled) {
231
+ onKeyDown?.(event)
232
+ return
233
+ }
234
+
235
+ switch (event.key) {
236
+ case 'Enter':
237
+ case ' ':
238
+ case 'Spacebar': {
239
+ if (selectable) {
240
+ event.preventDefault()
241
+ toggleSelectedState(event)
242
+ }
243
+ break
244
+ }
245
+
246
+ case 'Backspace':
247
+ case 'Delete': {
248
+ if (removable) {
249
+ event.preventDefault()
250
+ const sibling = getFocusableSibling(false) || getFocusableSibling(true)
251
+ sibling?.focus()
252
+ handleRemove(event)
253
+ }
254
+ break
255
+ }
256
+
257
+ case 'ArrowLeft': {
258
+ event.preventDefault()
259
+ const sibling = getFocusableSibling(false)
260
+ sibling?.focus()
261
+ if (selectedState && event.shiftKey) {
262
+ sibling?.dispatchEvent(new CustomEvent('coreui-chip-select'))
263
+ }
264
+ break
265
+ }
266
+
267
+ case 'ArrowRight': {
268
+ event.preventDefault()
269
+ const sibling = getFocusableSibling(true)
270
+ sibling?.focus()
271
+ if (selectedState && event.shiftKey) {
272
+ sibling?.dispatchEvent(new CustomEvent('coreui-chip-select'))
273
+ }
274
+ break
275
+ }
276
+
277
+ case 'Home': {
278
+ event.preventDefault()
279
+ navigateToEdge(0)
280
+ break
281
+ }
282
+
283
+ case 'End': {
284
+ event.preventDefault()
285
+ navigateToEdge(-1)
286
+ break
287
+ }
288
+
289
+ // No default
290
+ }
291
+
292
+ onKeyDown?.(event)
293
+ }
294
+
295
+ return (
296
+ <Component
297
+ className={classNames(
298
+ 'chip',
299
+ {
300
+ active: selectable ? selectedState : active,
301
+ disabled,
302
+ [`chip-${color}`]: color,
303
+ [`chip-${size}`]: size,
304
+ 'chip-clickable': clickable || selectable || Boolean(onClick),
305
+ 'chip-outline': variant === 'outline',
306
+ },
307
+ className
308
+ )}
309
+ data-coreui-chip-focusable={isFocusable || undefined}
310
+ {...(disabled && { 'aria-disabled': true })}
311
+ {...(selectable && { 'aria-selected': selectedState })}
312
+ {...(isFocusable && tabIndex === undefined && { tabIndex: 0 })}
313
+ onClick={handleClick}
314
+ onKeyDown={handleKeyDown}
315
+ {...(Component === 'button' && { disabled })}
316
+ {...rest}
317
+ ref={forkedRef}
318
+ >
319
+ {children}
320
+ {removable && (
321
+ <button
322
+ type="button"
323
+ className="chip-remove"
324
+ aria-label={ariaRemoveLabel}
325
+ onClick={handleRemoveClick}
326
+ tabIndex={-1}
327
+ disabled={disabled}
328
+ >
329
+ {removeIcon ?? (
330
+ <svg
331
+ xmlns="http://www.w3.org/2000/svg"
332
+ width="16"
333
+ height="16"
334
+ viewBox="0 0 16 16"
335
+ fill="none"
336
+ stroke="currentColor"
337
+ strokeWidth="2"
338
+ strokeLinecap="round"
339
+ >
340
+ <line x1="4" y1="4" x2="12" y2="12" />
341
+ <line x1="12" y1="4" x2="4" y2="12" />
342
+ </svg>
343
+ )}
344
+ </button>
345
+ )}
346
+ </Component>
347
+ )
348
+ }
349
+ )
350
+
351
+ CChip.propTypes = {
352
+ active: PropTypes.bool,
353
+ ariaRemoveLabel: PropTypes.string,
354
+ as: PropTypes.elementType,
355
+ children: PropTypes.node,
356
+ className: PropTypes.string,
357
+ clickable: PropTypes.bool,
358
+ color: colorPropType,
359
+ disabled: PropTypes.bool,
360
+ onDeselect: PropTypes.func,
361
+ onRemove: PropTypes.func,
362
+ onSelect: PropTypes.func,
363
+ onSelectedChange: PropTypes.func,
364
+ removable: PropTypes.bool,
365
+ removeIcon: PropTypes.node,
366
+ selectable: PropTypes.bool,
367
+ selected: PropTypes.bool,
368
+ size: PropTypes.oneOf(['sm', 'lg']),
369
+ variant: PropTypes.oneOf(['outline']),
370
+ }
371
+
372
+ CChip.displayName = 'CChip'
@@ -0,0 +1,113 @@
1
+ import * as React from 'react'
2
+ import { fireEvent, render } from '@testing-library/react'
3
+ import '@testing-library/jest-dom'
4
+ import { CChip } from '../index'
5
+
6
+ test('loads and displays CChip component', async () => {
7
+ const { container } = render(<CChip>Test</CChip>)
8
+ expect(container).toMatchSnapshot()
9
+ })
10
+
11
+ test('CChip customize', async () => {
12
+ const { container } = render(
13
+ <CChip
14
+ active={true}
15
+ className="bazinga"
16
+ clickable={true}
17
+ color="warning"
18
+ as="button"
19
+ disabled={true}
20
+ size="lg"
21
+ variant="outline"
22
+ >
23
+ Test
24
+ </CChip>
25
+ )
26
+ expect(container).toMatchSnapshot()
27
+ expect(container.firstChild).toHaveClass('bazinga')
28
+ expect(container.firstChild).toHaveClass('chip')
29
+ expect(container.firstChild).toHaveClass('active')
30
+ expect(container.firstChild).toHaveClass('chip-clickable')
31
+ expect(container.firstChild).toHaveClass('chip-warning')
32
+ expect(container.firstChild).toHaveClass('chip-lg')
33
+ expect(container.firstChild).toHaveClass('chip-outline')
34
+ expect(container.firstChild).toHaveClass('disabled')
35
+ })
36
+
37
+ test('CChip removable', async () => {
38
+ const onRemove = jest.fn()
39
+ const { container } = render(
40
+ <CChip removable={true} ariaRemoveLabel="Remove test" onRemove={onRemove}>
41
+ Test
42
+ </CChip>
43
+ )
44
+
45
+ const removeButton = container.querySelector('.chip-remove')
46
+ expect(container).toMatchSnapshot()
47
+ expect(removeButton).toBeInTheDocument()
48
+ removeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
49
+ expect(onRemove).toHaveBeenCalled()
50
+ })
51
+
52
+ test('CChip selectable', async () => {
53
+ const onSelect = jest.fn()
54
+ const onDeselect = jest.fn()
55
+ const { getByText } = render(
56
+ <CChip selectable={true} onSelect={onSelect} onDeselect={onDeselect}>
57
+ Selectable
58
+ </CChip>,
59
+ )
60
+ const chip = getByText('Selectable')
61
+
62
+ expect(chip).toHaveAttribute('aria-selected', 'false')
63
+ fireEvent.click(chip)
64
+ expect(chip).toHaveClass('active')
65
+ expect(chip).toHaveAttribute('aria-selected', 'true')
66
+ expect(onSelect).toHaveBeenCalledTimes(1)
67
+
68
+ fireEvent.keyDown(chip, { key: 'Enter' })
69
+ expect(chip).not.toHaveClass('active')
70
+ expect(chip).toHaveAttribute('aria-selected', 'false')
71
+ expect(onDeselect).toHaveBeenCalledTimes(1)
72
+ })
73
+
74
+ test('CChip keyboard navigation', async () => {
75
+ const { getByText } = render(
76
+ <div>
77
+ <CChip selectable={true}>First</CChip>
78
+ <CChip selectable={true}>Second</CChip>
79
+ <CChip selectable={true}>Third</CChip>
80
+ </div>,
81
+ )
82
+
83
+ const first = getByText('First')
84
+ const second = getByText('Second')
85
+ const third = getByText('Third')
86
+
87
+ first.focus()
88
+ fireEvent.keyDown(first, { key: 'ArrowRight' })
89
+ expect(second).toHaveFocus()
90
+
91
+ fireEvent.keyDown(second, { key: 'End' })
92
+ expect(third).toHaveFocus()
93
+
94
+ fireEvent.keyDown(third, { key: 'Home' })
95
+ expect(first).toHaveFocus()
96
+ })
97
+
98
+ test('CChip delete triggers remove callback', async () => {
99
+ const onRemove = jest.fn()
100
+ const { getByText } = render(
101
+ <div>
102
+ <CChip removable={true} onRemove={onRemove}>
103
+ First
104
+ </CChip>
105
+ <CChip removable={true}>Second</CChip>
106
+ </div>,
107
+ )
108
+
109
+ const first = getByText('First')
110
+ first.focus()
111
+ fireEvent.keyDown(first, { key: 'Delete' })
112
+ expect(onRemove).toHaveBeenCalledTimes(1)
113
+ })
@@ -0,0 +1,65 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`CChip customize 1`] = `
4
+ <div>
5
+ <button
6
+ aria-disabled="true"
7
+ class="chip active disabled chip-warning chip-lg chip-clickable chip-outline bazinga"
8
+ disabled=""
9
+ >
10
+ Test
11
+ </button>
12
+ </div>
13
+ `;
14
+
15
+ exports[`CChip removable 1`] = `
16
+ <div>
17
+ <span
18
+ class="chip"
19
+ data-coreui-chip-focusable="true"
20
+ tabindex="0"
21
+ >
22
+ Test
23
+ <button
24
+ aria-label="Remove test"
25
+ class="chip-remove"
26
+ tabindex="-1"
27
+ type="button"
28
+ >
29
+ <svg
30
+ fill="none"
31
+ height="16"
32
+ stroke="currentColor"
33
+ stroke-linecap="round"
34
+ stroke-width="2"
35
+ viewBox="0 0 16 16"
36
+ width="16"
37
+ xmlns="http://www.w3.org/2000/svg"
38
+ >
39
+ <line
40
+ x1="4"
41
+ x2="12"
42
+ y1="4"
43
+ y2="12"
44
+ />
45
+ <line
46
+ x1="12"
47
+ x2="4"
48
+ y1="4"
49
+ y2="12"
50
+ />
51
+ </svg>
52
+ </button>
53
+ </span>
54
+ </div>
55
+ `;
56
+
57
+ exports[`loads and displays CChip component 1`] = `
58
+ <div>
59
+ <span
60
+ class="chip"
61
+ >
62
+ Test
63
+ </span>
64
+ </div>
65
+ `;
@@ -0,0 +1,3 @@
1
+ import { CChip } from './CChip'
2
+
3
+ export { CChip }
@@ -368,7 +368,7 @@ export const CDropdown: PolymorphicRefForwardingComponent<'div', CDropdownProps>
368
368
  }
369
369
 
370
370
  const target = event.target as HTMLElement | null
371
- const FORM_TAG_RE = /^(input|select|option|textarea|form|button|label)$/i
371
+ const FORM_TAG_RE = /^(input|select|option|textarea|form)$/i
372
372
 
373
373
  if (isOnMenu && target && FORM_TAG_RE.test(target.tagName)) {
374
374
  return