@coreui/react 5.9.2 → 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 (34) 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/form/CChipInput.d.ts +92 -0
  8. package/dist/cjs/components/form/CChipInput.js +253 -0
  9. package/dist/cjs/components/form/CChipInput.js.map +1 -0
  10. package/dist/cjs/components/form/index.d.ts +2 -1
  11. package/dist/cjs/components/index.d.ts +1 -0
  12. package/dist/cjs/index.js +4 -0
  13. package/dist/cjs/index.js.map +1 -1
  14. package/dist/esm/components/chip/CChip.d.ts +76 -0
  15. package/dist/esm/components/chip/CChip.js +176 -0
  16. package/dist/esm/components/chip/CChip.js.map +1 -0
  17. package/dist/esm/components/chip/index.d.ts +2 -0
  18. package/dist/esm/components/form/CChipInput.d.ts +92 -0
  19. package/dist/esm/components/form/CChipInput.js +251 -0
  20. package/dist/esm/components/form/CChipInput.js.map +1 -0
  21. package/dist/esm/components/form/index.d.ts +2 -1
  22. package/dist/esm/components/index.d.ts +1 -0
  23. package/dist/esm/index.js +2 -0
  24. package/dist/esm/index.js.map +1 -1
  25. package/package.json +5 -5
  26. package/src/components/chip/CChip.tsx +372 -0
  27. package/src/components/chip/__tests__/CChip.spec.tsx +113 -0
  28. package/src/components/chip/__tests__/__snapshots__/CChip.spec.tsx.snap +65 -0
  29. package/src/components/chip/index.ts +3 -0
  30. package/src/components/form/CChipInput.tsx +477 -0
  31. package/src/components/form/__tests__/CChipInput.spec.tsx +62 -0
  32. package/src/components/form/__tests__/__snapshots__/CChipInput.spec.tsx.snap +91 -0
  33. package/src/components/form/index.ts +2 -0
  34. package/src/components/index.ts +1 -0
@@ -0,0 +1,477 @@
1
+ import React, {
2
+ ClipboardEvent,
3
+ FocusEvent,
4
+ HTMLAttributes,
5
+ KeyboardEvent,
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 { CChip } from '../chip/CChip'
17
+ import { useForkedRef } from '../../hooks'
18
+
19
+ type ChipClassName = string | ((value: string) => string)
20
+
21
+ export interface CChipInputProps extends Omit<
22
+ HTMLAttributes<HTMLDivElement>,
23
+ 'onChange' | 'onInput' | 'onSelect'
24
+ > {
25
+ /**
26
+ * Adds custom classes to the React Chip Input component root element.
27
+ */
28
+ className?: string
29
+ /**
30
+ * Adds custom classes to chips rendered by the React Chip Input component. Accepts a static className or a resolver function based on chip value.
31
+ */
32
+ chipClassName?: ChipClassName
33
+ /**
34
+ * Creates a new chip when the React Chip Input component loses focus with a pending value.
35
+ */
36
+ createOnBlur?: boolean
37
+ /**
38
+ * Sets the initial uncontrolled values rendered by the React Chip Input component.
39
+ */
40
+ defaultValue?: string[]
41
+ /**
42
+ * Disables the React Chip Input component and prevents adding, removing, or selecting chips.
43
+ */
44
+ disabled?: boolean
45
+ /**
46
+ * Sets the `id` of the internal text input rendered by the React Chip Input component.
47
+ */
48
+ id?: string
49
+ /**
50
+ * Renders an inline label inside the React Chip Input component container.
51
+ */
52
+ label?: ReactNode
53
+ /**
54
+ * Sets the maximum number of chips that can be created in the React Chip Input component.
55
+ */
56
+ maxChips?: number | null
57
+ /**
58
+ * Sets the name of the hidden input used by the React Chip Input component for form submission.
59
+ */
60
+ name?: string
61
+ /**
62
+ * Callback fired when the React Chip Input component adds a new chip.
63
+ */
64
+ onAdd?: (value: string) => void
65
+ /**
66
+ * Callback fired when the value list of the React Chip Input component changes.
67
+ */
68
+ onChange?: (values: string[]) => void
69
+ /**
70
+ * Callback fired when the internal text input value changes in the React Chip Input component.
71
+ */
72
+ onInput?: (value: string) => void
73
+ /**
74
+ * Callback fired when the React Chip Input component removes a chip.
75
+ */
76
+ onRemove?: (value: string) => void
77
+ /**
78
+ * Callback fired when the selected chip values change in the React Chip Input component.
79
+ */
80
+ onSelect?: (selected: string[]) => void
81
+ /**
82
+ * Sets placeholder text for the internal input of the React Chip Input component.
83
+ */
84
+ placeholder?: string
85
+ /**
86
+ * Makes the React Chip Input component readonly while keeping values visible.
87
+ */
88
+ readOnly?: boolean
89
+ /**
90
+ * Displays remove buttons on chips managed by the React Chip Input component.
91
+ */
92
+ removable?: boolean
93
+ /**
94
+ * Enables chip selection behavior in the React Chip Input component.
95
+ */
96
+ selectable?: boolean
97
+ /**
98
+ * Sets the separator character used to create chips while typing or pasting in the React Chip Input component.
99
+ */
100
+ separator?: string | null
101
+ /**
102
+ * Sets the size of the React Chip Input component to small or large.
103
+ */
104
+ size?: 'sm' | 'lg'
105
+ /**
106
+ * Controls the values rendered by the React Chip Input component.
107
+ *
108
+ * @controllable onChange
109
+ */
110
+ value?: string[]
111
+ }
112
+
113
+ const resolveChipClassName = (chipClassName: ChipClassName | undefined, value: string) => {
114
+ if (!chipClassName) {
115
+ return
116
+ }
117
+
118
+ if (typeof chipClassName === 'function') {
119
+ const resolvedClassName = chipClassName(value)
120
+ return typeof resolvedClassName === 'string' ? resolvedClassName : undefined
121
+ }
122
+
123
+ return chipClassName
124
+ }
125
+
126
+ const uniqueValues = (values: string[]) => [
127
+ ...new Set(values.map((value) => value.trim()).filter(Boolean)),
128
+ ]
129
+
130
+ export const CChipInput = forwardRef<HTMLDivElement, CChipInputProps>(
131
+ (
132
+ {
133
+ className,
134
+ chipClassName,
135
+ createOnBlur = true,
136
+ defaultValue = [],
137
+ disabled,
138
+ id,
139
+ label,
140
+ maxChips = null,
141
+ name,
142
+ onAdd,
143
+ onChange,
144
+ onInput,
145
+ onRemove,
146
+ onSelect,
147
+ placeholder = '',
148
+ readOnly,
149
+ removable = true,
150
+ selectable,
151
+ separator = ',',
152
+ size,
153
+ value,
154
+ ...rest
155
+ },
156
+ ref
157
+ ) => {
158
+ const isControlled = value !== undefined
159
+ const [_values, setValues] = useState<string[]>(() => uniqueValues(defaultValue))
160
+ const [inputValue, setInputValue] = useState('')
161
+ const [selectedValues, setSelectedValues] = useState<string[]>([])
162
+ const rootRef = useRef<HTMLDivElement>(null)
163
+ const inputRef = useRef<HTMLInputElement>(null)
164
+ const forkedRef = useForkedRef(ref, rootRef)
165
+
166
+ const values = useMemo(
167
+ () => uniqueValues(isControlled ? (value as string[]) : _values),
168
+ [isControlled, value, _values]
169
+ )
170
+
171
+ useEffect(() => {
172
+ setSelectedValues((prev) => prev.filter((item) => values.includes(item)))
173
+ }, [values])
174
+
175
+ const emitValuesChange = (nextValues: string[]) => {
176
+ if (!isControlled) {
177
+ setValues(nextValues)
178
+ }
179
+ onChange?.(nextValues)
180
+ }
181
+
182
+ const canAddMore = maxChips === null || values.length < maxChips
183
+
184
+ const add = (rawValue: string) => {
185
+ if (disabled || readOnly) {
186
+ return false
187
+ }
188
+
189
+ const normalizedValue = String(rawValue).trim()
190
+ if (!normalizedValue || values.includes(normalizedValue) || !canAddMore) {
191
+ return false
192
+ }
193
+
194
+ const nextValues = [...values, normalizedValue]
195
+ emitValuesChange(nextValues)
196
+ onAdd?.(normalizedValue)
197
+ return true
198
+ }
199
+
200
+ const remove = (valueToRemove: string) => {
201
+ if (disabled || readOnly) {
202
+ return false
203
+ }
204
+
205
+ if (!values.includes(valueToRemove)) {
206
+ return false
207
+ }
208
+
209
+ const nextValues = values.filter((item) => item !== valueToRemove)
210
+ emitValuesChange(nextValues)
211
+ setSelectedValues((prev) => {
212
+ const nextSelected = prev.filter((item) => item !== valueToRemove)
213
+ if (nextSelected.length !== prev.length) {
214
+ onSelect?.(nextSelected)
215
+ }
216
+ return nextSelected
217
+ })
218
+ onRemove?.(valueToRemove)
219
+ return true
220
+ }
221
+
222
+ const createFromInput = () => {
223
+ if (add(inputValue)) {
224
+ setInputValue('')
225
+ }
226
+ }
227
+
228
+ const focusLastChip = () => {
229
+ if (!rootRef.current) {
230
+ return
231
+ }
232
+
233
+ const focusableChips = [
234
+ ...rootRef.current.querySelectorAll<HTMLElement>(
235
+ '[data-coreui-chip-focusable="true"]:not(.disabled)'
236
+ ),
237
+ ]
238
+ if (focusableChips.length === 0) {
239
+ return
240
+ }
241
+ focusableChips[focusableChips.length - 1].focus()
242
+ }
243
+
244
+ const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
245
+ switch (event.key) {
246
+ case 'Enter': {
247
+ event.preventDefault()
248
+ createFromInput()
249
+ break
250
+ }
251
+
252
+ case 'Backspace':
253
+ case 'Delete': {
254
+ if (inputValue === '') {
255
+ event.preventDefault()
256
+ focusLastChip()
257
+ }
258
+ break
259
+ }
260
+
261
+ case 'ArrowLeft': {
262
+ if (event.currentTarget.selectionStart === 0 && event.currentTarget.selectionEnd === 0) {
263
+ event.preventDefault()
264
+ focusLastChip()
265
+ }
266
+ break
267
+ }
268
+
269
+ case 'Escape': {
270
+ setInputValue('')
271
+ event.currentTarget.blur()
272
+ break
273
+ }
274
+
275
+ // No default
276
+ }
277
+ }
278
+
279
+ const handleInputChange = (value: string) => {
280
+ if (disabled || readOnly) {
281
+ return
282
+ }
283
+
284
+ if (separator && value.includes(separator)) {
285
+ const parts = value.split(separator)
286
+ const chipsToAdd = uniqueValues(parts.slice(0, -1))
287
+ const nextValues = [...values]
288
+
289
+ for (const chipValue of chipsToAdd) {
290
+ if (maxChips !== null && nextValues.length >= maxChips) {
291
+ break
292
+ }
293
+ if (!nextValues.includes(chipValue)) {
294
+ nextValues.push(chipValue)
295
+ onAdd?.(chipValue)
296
+ }
297
+ }
298
+
299
+ if (nextValues.length !== values.length) {
300
+ emitValuesChange(nextValues)
301
+ }
302
+
303
+ const tail = parts[parts.length - 1] || ''
304
+ setInputValue(tail)
305
+ onInput?.(tail)
306
+ return
307
+ }
308
+
309
+ setInputValue(value)
310
+ onInput?.(value)
311
+ }
312
+
313
+ const handlePaste = (event: ClipboardEvent<HTMLInputElement>) => {
314
+ if (disabled || readOnly || !separator) {
315
+ return
316
+ }
317
+
318
+ const pastedData = event.clipboardData.getData('text')
319
+ if (!pastedData.includes(separator)) {
320
+ return
321
+ }
322
+
323
+ event.preventDefault()
324
+ const chipsToAdd = uniqueValues(pastedData.split(separator))
325
+ const nextValues = [...values]
326
+
327
+ for (const chipValue of chipsToAdd) {
328
+ if (maxChips !== null && nextValues.length >= maxChips) {
329
+ break
330
+ }
331
+ if (!nextValues.includes(chipValue)) {
332
+ nextValues.push(chipValue)
333
+ onAdd?.(chipValue)
334
+ }
335
+ }
336
+
337
+ if (nextValues.length !== values.length) {
338
+ emitValuesChange(nextValues)
339
+ }
340
+
341
+ setInputValue('')
342
+ onInput?.('')
343
+ }
344
+
345
+ const handleInputBlur = (event: FocusEvent<HTMLInputElement>) => {
346
+ if (!createOnBlur) {
347
+ return
348
+ }
349
+
350
+ if ((event.relatedTarget as HTMLElement | null)?.closest('.chip')) {
351
+ return
352
+ }
353
+
354
+ createFromInput()
355
+ }
356
+
357
+ const handleContainerKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
358
+ if (event.target === inputRef.current) {
359
+ return
360
+ }
361
+
362
+ if (event.key.length === 1) {
363
+ inputRef.current?.focus()
364
+ }
365
+ }
366
+
367
+ const handleContainerClick = (event: React.MouseEvent<HTMLDivElement>) => {
368
+ if (event.target === rootRef.current) {
369
+ inputRef.current?.focus()
370
+ }
371
+ }
372
+
373
+ const handleSelectedChange = (chipValue: string, selected: boolean) => {
374
+ setSelectedValues((prev) => {
375
+ const nextSelected = selected
376
+ ? uniqueValues([...prev, chipValue])
377
+ : prev.filter((value) => value !== chipValue)
378
+ onSelect?.(nextSelected)
379
+ return nextSelected
380
+ })
381
+ }
382
+
383
+ const inputSize = Math.max(placeholder.length, inputValue.length, 1)
384
+
385
+ return (
386
+ <div
387
+ className={classNames(
388
+ 'chip-input',
389
+ {
390
+ 'chip-input-sm': size === 'sm',
391
+ 'chip-input-lg': size === 'lg',
392
+ disabled,
393
+ },
394
+ className
395
+ )}
396
+ aria-disabled={disabled ? true : undefined}
397
+ aria-readonly={readOnly ? true : undefined}
398
+ onClick={handleContainerClick}
399
+ onKeyDown={handleContainerKeyDown}
400
+ {...rest}
401
+ ref={forkedRef}
402
+ >
403
+ {label && (
404
+ <label className="chip-input-label" htmlFor={id}>
405
+ {label}
406
+ </label>
407
+ )}
408
+
409
+ {values.map((chipValue) => (
410
+ <CChip
411
+ key={chipValue}
412
+ className={resolveChipClassName(chipClassName, chipValue)}
413
+ removable={Boolean(removable && !disabled && !readOnly)}
414
+ ariaRemoveLabel={`Remove ${chipValue}`}
415
+ disabled={disabled}
416
+ onRemove={() => remove(chipValue)}
417
+ selectable={selectable}
418
+ selected={selectedValues.includes(chipValue)}
419
+ onSelectedChange={(selected) => handleSelectedChange(chipValue, selected)}
420
+ >
421
+ {chipValue}
422
+ </CChip>
423
+ ))}
424
+
425
+ <input
426
+ type="text"
427
+ id={id}
428
+ className="chip-input-field"
429
+ disabled={disabled}
430
+ readOnly={Boolean(!disabled && readOnly)}
431
+ placeholder={placeholder}
432
+ size={inputSize}
433
+ value={inputValue}
434
+ onBlur={handleInputBlur}
435
+ onChange={(event) => handleInputChange(event.target.value)}
436
+ onKeyDown={handleInputKeyDown}
437
+ onPaste={handlePaste}
438
+ onFocus={() => {
439
+ if (selectedValues.length > 0) {
440
+ setSelectedValues([])
441
+ onSelect?.([])
442
+ }
443
+ }}
444
+ ref={inputRef}
445
+ />
446
+
447
+ {name && <input type="hidden" name={name} value={values.join(',')} />}
448
+ </div>
449
+ )
450
+ }
451
+ )
452
+
453
+ CChipInput.propTypes = {
454
+ chipClassName: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
455
+ className: PropTypes.string,
456
+ createOnBlur: PropTypes.bool,
457
+ defaultValue: PropTypes.array,
458
+ disabled: PropTypes.bool,
459
+ id: PropTypes.string,
460
+ label: PropTypes.node,
461
+ maxChips: PropTypes.number,
462
+ name: PropTypes.string,
463
+ onAdd: PropTypes.func,
464
+ onChange: PropTypes.func,
465
+ onInput: PropTypes.func,
466
+ onRemove: PropTypes.func,
467
+ onSelect: PropTypes.func,
468
+ placeholder: PropTypes.string,
469
+ readOnly: PropTypes.bool,
470
+ removable: PropTypes.bool,
471
+ selectable: PropTypes.bool,
472
+ separator: PropTypes.string,
473
+ size: PropTypes.oneOf(['sm', 'lg']),
474
+ value: PropTypes.array,
475
+ }
476
+
477
+ CChipInput.displayName = 'CChipInput'
@@ -0,0 +1,62 @@
1
+ import * as React from 'react'
2
+ import { fireEvent, render } from '@testing-library/react'
3
+ import '@testing-library/jest-dom'
4
+ import { CChipInput } from '../index'
5
+
6
+ test('loads and displays CChipInput component', async () => {
7
+ const { container } = render(<CChipInput defaultValue={['React', 'TypeScript']} />)
8
+ expect(container).toMatchSnapshot()
9
+ })
10
+
11
+ test('CChipInput renders internal label', async () => {
12
+ const { getByText } = render(<CChipInput id="skillsInput" label="Skills:" />)
13
+
14
+ const label = getByText('Skills:')
15
+ expect(label).toHaveClass('chip-input-label')
16
+ expect(label).toHaveAttribute('for', 'skillsInput')
17
+ })
18
+
19
+ test('CChipInput adds chip on enter', async () => {
20
+ const onChange = jest.fn()
21
+ const { container } = render(<CChipInput onChange={onChange} />)
22
+
23
+ const input = container.querySelector('.chip-input-field') as HTMLInputElement
24
+ fireEvent.change(input, { target: { value: 'CoreUI' } })
25
+ fireEvent.keyDown(input, { key: 'Enter' })
26
+
27
+ expect(container.querySelectorAll('.chip')).toHaveLength(1)
28
+ expect(container.querySelector('.chip')?.textContent).toContain('CoreUI')
29
+ expect(onChange).toHaveBeenCalledWith(['CoreUI'])
30
+ })
31
+
32
+ test('CChipInput handles separators', async () => {
33
+ const { container } = render(<CChipInput separator="," />)
34
+ const input = container.querySelector('.chip-input-field') as HTMLInputElement
35
+
36
+ fireEvent.change(input, { target: { value: 'React,TypeScript,' } })
37
+
38
+ expect(container.querySelectorAll('.chip')).toHaveLength(2)
39
+ expect(input.value).toBe('')
40
+ })
41
+
42
+ test('CChipInput removes chip via remove button', async () => {
43
+ const onRemove = jest.fn()
44
+ const { container } = render(<CChipInput defaultValue={['React']} onRemove={onRemove} />)
45
+
46
+ const removeButton = container.querySelector('.chip-remove') as HTMLButtonElement
47
+ fireEvent.click(removeButton)
48
+
49
+ expect(container.querySelectorAll('.chip')).toHaveLength(0)
50
+ expect(onRemove).toHaveBeenCalledWith('React')
51
+ })
52
+
53
+ test('CChipInput selectable chips', async () => {
54
+ const onSelect = jest.fn()
55
+ const { container } = render(<CChipInput defaultValue={['React']} selectable onSelect={onSelect} />)
56
+
57
+ const chip = container.querySelector('.chip') as HTMLElement
58
+ fireEvent.click(chip)
59
+
60
+ expect(chip).toHaveClass('active')
61
+ expect(onSelect).toHaveBeenCalledWith(['React'])
62
+ })
@@ -0,0 +1,91 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`loads and displays CChipInput component 1`] = `
4
+ <div>
5
+ <div
6
+ class="chip-input"
7
+ >
8
+ <span
9
+ class="chip"
10
+ data-coreui-chip-focusable="true"
11
+ tabindex="0"
12
+ >
13
+ React
14
+ <button
15
+ aria-label="Remove React"
16
+ class="chip-remove"
17
+ tabindex="-1"
18
+ type="button"
19
+ >
20
+ <svg
21
+ fill="none"
22
+ height="16"
23
+ stroke="currentColor"
24
+ stroke-linecap="round"
25
+ stroke-width="2"
26
+ viewBox="0 0 16 16"
27
+ width="16"
28
+ xmlns="http://www.w3.org/2000/svg"
29
+ >
30
+ <line
31
+ x1="4"
32
+ x2="12"
33
+ y1="4"
34
+ y2="12"
35
+ />
36
+ <line
37
+ x1="12"
38
+ x2="4"
39
+ y1="4"
40
+ y2="12"
41
+ />
42
+ </svg>
43
+ </button>
44
+ </span>
45
+ <span
46
+ class="chip"
47
+ data-coreui-chip-focusable="true"
48
+ tabindex="0"
49
+ >
50
+ TypeScript
51
+ <button
52
+ aria-label="Remove TypeScript"
53
+ class="chip-remove"
54
+ tabindex="-1"
55
+ type="button"
56
+ >
57
+ <svg
58
+ fill="none"
59
+ height="16"
60
+ stroke="currentColor"
61
+ stroke-linecap="round"
62
+ stroke-width="2"
63
+ viewBox="0 0 16 16"
64
+ width="16"
65
+ xmlns="http://www.w3.org/2000/svg"
66
+ >
67
+ <line
68
+ x1="4"
69
+ x2="12"
70
+ y1="4"
71
+ y2="12"
72
+ />
73
+ <line
74
+ x1="12"
75
+ x2="4"
76
+ y1="4"
77
+ y2="12"
78
+ />
79
+ </svg>
80
+ </button>
81
+ </span>
82
+ <input
83
+ class="chip-input-field"
84
+ placeholder=""
85
+ size="1"
86
+ type="text"
87
+ value=""
88
+ />
89
+ </div>
90
+ </div>
91
+ `;
@@ -1,5 +1,6 @@
1
1
  import { CForm } from './CForm'
2
2
  import { CFormCheck } from './CFormCheck'
3
+ import { CChipInput } from './CChipInput'
3
4
  import { CFormControlValidation } from './CFormControlValidation'
4
5
  import { CFormControlWrapper } from './CFormControlWrapper'
5
6
  import { CFormFeedback } from './CFormFeedback'
@@ -17,6 +18,7 @@ import { CInputGroupText } from './CInputGroupText'
17
18
  export {
18
19
  CForm,
19
20
  CFormCheck,
21
+ CChipInput,
20
22
  CFormControlValidation,
21
23
  CFormControlWrapper,
22
24
  CFormFeedback,
@@ -9,6 +9,7 @@ export * from './button-group'
9
9
  export * from './callout'
10
10
  export * from './card'
11
11
  export * from './carousel'
12
+ export * from './chip'
12
13
  export * from './close-button'
13
14
  export * from './collapse'
14
15
  export * from './conditional-portal'