@coreui/react-pro 5.23.5 → 5.24.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.
- package/README.md +1 -1
- package/dist/cjs/components/autocomplete/CAutocomplete.js +19 -7
- package/dist/cjs/components/autocomplete/CAutocomplete.js.map +1 -1
- package/dist/cjs/components/autocomplete/CAutocompleteOptions.js +9 -6
- package/dist/cjs/components/autocomplete/CAutocompleteOptions.js.map +1 -1
- package/dist/cjs/components/calendar/CCalendar.d.ts +58 -2
- package/dist/cjs/components/calendar/CCalendar.js +16 -6
- package/dist/cjs/components/calendar/CCalendar.js.map +1 -1
- package/dist/cjs/components/calendar/CCalendarPanel.d.ts +25 -1
- package/dist/cjs/components/calendar/CCalendarPanel.js +55 -7
- package/dist/cjs/components/calendar/CCalendarPanel.js.map +1 -1
- package/dist/cjs/components/calendar/types.d.ts +2 -2
- package/dist/cjs/components/calendar/utils.d.ts +28 -3
- package/dist/cjs/components/calendar/utils.js +122 -5
- package/dist/cjs/components/calendar/utils.js.map +1 -1
- package/dist/cjs/components/chip/CChip.d.ts +76 -0
- package/dist/cjs/components/chip/CChip.js +178 -0
- package/dist/cjs/components/chip/CChip.js.map +1 -0
- package/dist/cjs/components/chip/index.d.ts +2 -0
- package/dist/cjs/components/form/CChipInput.d.ts +92 -0
- package/dist/cjs/components/form/CChipInput.js +253 -0
- package/dist/cjs/components/form/CChipInput.js.map +1 -0
- package/dist/cjs/components/form/index.d.ts +2 -1
- package/dist/cjs/components/index.d.ts +1 -0
- package/dist/cjs/components/multi-select/CMultiSelectOptions.js +1 -1
- package/dist/cjs/components/multi-select/CMultiSelectOptions.js.map +1 -1
- package/dist/cjs/components/smart-table/CSmartTable.js +1 -0
- package/dist/cjs/components/smart-table/CSmartTable.js.map +1 -1
- package/dist/cjs/components/smart-table/CSmartTableBody.js +1 -0
- package/dist/cjs/components/smart-table/CSmartTableBody.js.map +1 -1
- package/dist/cjs/components/smart-table/CSmartTableHead.js +1 -0
- package/dist/cjs/components/smart-table/CSmartTableHead.js.map +1 -1
- package/dist/cjs/index.js +4 -0
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/components/autocomplete/CAutocomplete.js +20 -8
- package/dist/esm/components/autocomplete/CAutocomplete.js.map +1 -1
- package/dist/esm/components/autocomplete/CAutocompleteOptions.js +10 -7
- package/dist/esm/components/autocomplete/CAutocompleteOptions.js.map +1 -1
- package/dist/esm/components/calendar/CCalendar.d.ts +58 -2
- package/dist/esm/components/calendar/CCalendar.js +16 -6
- package/dist/esm/components/calendar/CCalendar.js.map +1 -1
- package/dist/esm/components/calendar/CCalendarPanel.d.ts +25 -1
- package/dist/esm/components/calendar/CCalendarPanel.js +56 -8
- package/dist/esm/components/calendar/CCalendarPanel.js.map +1 -1
- package/dist/esm/components/calendar/types.d.ts +2 -2
- package/dist/esm/components/calendar/utils.d.ts +28 -3
- package/dist/esm/components/calendar/utils.js +120 -6
- package/dist/esm/components/calendar/utils.js.map +1 -1
- package/dist/esm/components/chip/CChip.d.ts +76 -0
- package/dist/esm/components/chip/CChip.js +176 -0
- package/dist/esm/components/chip/CChip.js.map +1 -0
- package/dist/esm/components/chip/index.d.ts +2 -0
- package/dist/esm/components/form/CChipInput.d.ts +92 -0
- package/dist/esm/components/form/CChipInput.js +251 -0
- package/dist/esm/components/form/CChipInput.js.map +1 -0
- package/dist/esm/components/form/index.d.ts +2 -1
- package/dist/esm/components/index.d.ts +1 -0
- package/dist/esm/components/multi-select/CMultiSelectOptions.js +1 -1
- package/dist/esm/components/smart-table/CSmartTable.js +1 -0
- package/dist/esm/components/smart-table/CSmartTable.js.map +1 -1
- package/dist/esm/components/smart-table/CSmartTableBody.js +1 -0
- package/dist/esm/components/smart-table/CSmartTableBody.js.map +1 -1
- package/dist/esm/components/smart-table/CSmartTableHead.js +1 -0
- package/dist/esm/components/smart-table/CSmartTableHead.js.map +1 -1
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -1
- package/package.json +5 -5
- package/src/components/autocomplete/CAutocomplete.tsx +29 -8
- package/src/components/autocomplete/CAutocompleteOptions.tsx +1 -1
- package/src/components/calendar/CCalendar.tsx +105 -6
- package/src/components/calendar/CCalendarPanel.tsx +119 -8
- package/src/components/calendar/types.ts +2 -2
- package/src/components/calendar/utils.ts +152 -6
- package/src/components/chip/CChip.tsx +372 -0
- package/src/components/chip/__tests__/CChip.spec.tsx +113 -0
- package/src/components/chip/__tests__/__snapshots__/CChip.spec.tsx.snap +65 -0
- package/src/components/chip/index.ts +3 -0
- package/src/components/form/CChipInput.tsx +477 -0
- package/src/components/form/__tests__/CChipInput.spec.tsx +62 -0
- package/src/components/form/__tests__/__snapshots__/CChipInput.spec.tsx.snap +91 -0
- package/src/components/form/index.ts +2 -0
- package/src/components/index.ts +1 -0
|
@@ -60,8 +60,17 @@ const getMondayOfISOWeek = (year: number, week: number): Date => {
|
|
|
60
60
|
* @returns A number representing year*12 + month for easy comparison.
|
|
61
61
|
*/
|
|
62
62
|
const dateToMonthNumber = (date: Date): number => {
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
return date.getFullYear() * 12 + date.getMonth()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Helper function to convert a date to a quarter number for comparison.
|
|
68
|
+
* @param date - The date to convert.
|
|
69
|
+
* @returns A number representing year*4 + quarter for easy comparison.
|
|
70
|
+
*/
|
|
71
|
+
const dateToQuarterNumber = (date: Date): number => {
|
|
72
|
+
const quarter = Math.floor(date.getMonth() / 3)
|
|
73
|
+
return date.getFullYear() * 4 + quarter
|
|
65
74
|
}
|
|
66
75
|
|
|
67
76
|
/**
|
|
@@ -122,6 +131,36 @@ const parseWeekString = (dateString: string): Date | null => {
|
|
|
122
131
|
return convertIsoWeekToDate(dateString)
|
|
123
132
|
}
|
|
124
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Parses a quarter string and returns a Date object for the first day of that quarter.
|
|
136
|
+
* @param dateString - The quarter string to parse.
|
|
137
|
+
* @returns The Date object for the first day of the quarter, or null if invalid.
|
|
138
|
+
*/
|
|
139
|
+
const parseQuarterString = (dateString: string): Date | null => {
|
|
140
|
+
const quarterPatterns = [
|
|
141
|
+
/^(\d{4})-Q(\d{1})$/, // 2023-Q1, 2023-Q4
|
|
142
|
+
/^(\d{4})Q(\d{1})$/, // 2023Q1, 2023Q4
|
|
143
|
+
/^(\d{4})\s+Q(\d{1})$/, // 2023 Q1, 2023 Q4
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
for (const pattern of quarterPatterns) {
|
|
147
|
+
const match = dateString.trim().match(pattern)
|
|
148
|
+
if (match) {
|
|
149
|
+
const parsedYear = parseYearSmart(match[1])
|
|
150
|
+
const parsedQuarter = Number.parseInt(match[2], 10)
|
|
151
|
+
|
|
152
|
+
// Validate quarter (1-4)
|
|
153
|
+
if (parsedQuarter >= 1 && parsedQuarter <= 4) {
|
|
154
|
+
// Calculate the first month of the quarter (Q1=0, Q2=3, Q3=6, Q4=9)
|
|
155
|
+
const monthIndex = (parsedQuarter - 1) * 3
|
|
156
|
+
return new Date(parsedYear, monthIndex, 1)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
|
|
125
164
|
/**
|
|
126
165
|
* Parses a month string and returns a Date object for the first day of that month.
|
|
127
166
|
* @param dateString - The month string to parse.
|
|
@@ -508,6 +547,9 @@ export const convertToDateObject = (
|
|
|
508
547
|
case 'month': {
|
|
509
548
|
return parseMonthString(dateString)
|
|
510
549
|
}
|
|
550
|
+
case 'quarter': {
|
|
551
|
+
return parseQuarterString(dateString)
|
|
552
|
+
}
|
|
511
553
|
case 'year': {
|
|
512
554
|
return parseYearString(dateString)
|
|
513
555
|
}
|
|
@@ -523,7 +565,7 @@ export const convertToDateObject = (
|
|
|
523
565
|
* @param dateString - The date string to parse.
|
|
524
566
|
* @param locale - The locale to use for date format patterns.
|
|
525
567
|
* @param includeTime - Whether to include time parsing.
|
|
526
|
-
* @param selectionType - The selection type ('day', 'week', 'month', 'year').
|
|
568
|
+
* @param selectionType - The selection type ('day', 'week', 'month', 'quarter', 'year').
|
|
527
569
|
* @returns A Date object if parsing succeeds, null if parsing fails.
|
|
528
570
|
*/
|
|
529
571
|
export const getLocalDateFromString = (
|
|
@@ -565,7 +607,7 @@ export const getCalendarDate = (calendarDate: Date, order: number, view: ViewTyp
|
|
|
565
607
|
return new Date(calendarDate.getFullYear(), calendarDate.getMonth() + order, 1)
|
|
566
608
|
}
|
|
567
609
|
|
|
568
|
-
if (order !== 0 && view === 'months') {
|
|
610
|
+
if (order !== 0 && (view === 'months' || view === 'quarters')) {
|
|
569
611
|
return new Date(calendarDate.getFullYear() + order, calendarDate.getMonth(), 1)
|
|
570
612
|
}
|
|
571
613
|
|
|
@@ -580,7 +622,7 @@ export const getCalendarDate = (calendarDate: Date, order: number, view: ViewTyp
|
|
|
580
622
|
/**
|
|
581
623
|
* Formats a date based on the selection type.
|
|
582
624
|
* @param date - The date to format.
|
|
583
|
-
* @param selectionType - The type of selection ('day', 'week', 'month', 'year').
|
|
625
|
+
* @param selectionType - The type of selection ('day', 'week', 'month', 'quarter', 'year').
|
|
584
626
|
* @returns A formatted date string or the original Date object.
|
|
585
627
|
*/
|
|
586
628
|
export const getDateBySelectionType = (
|
|
@@ -601,6 +643,11 @@ export const getDateBySelectionType = (
|
|
|
601
643
|
return `${date.getFullYear()}-${monthNumber}`
|
|
602
644
|
}
|
|
603
645
|
|
|
646
|
+
if (selectionType === 'quarter') {
|
|
647
|
+
const quarter = Math.floor(date.getMonth() / 3) + 1
|
|
648
|
+
return `${date.getFullYear()}Q${quarter}`
|
|
649
|
+
}
|
|
650
|
+
|
|
604
651
|
if (selectionType === 'year') {
|
|
605
652
|
return `${date.getFullYear()}`
|
|
606
653
|
}
|
|
@@ -650,7 +697,10 @@ export const getFirstAvailableDateInRange = (
|
|
|
650
697
|
* @param format - The format of the month names ('short' or 'long').
|
|
651
698
|
* @returns An array of month names.
|
|
652
699
|
*/
|
|
653
|
-
export const getMonthsNames = (
|
|
700
|
+
export const getMonthsNames = (
|
|
701
|
+
locale: string,
|
|
702
|
+
format: 'long' | 'narrow' | 'short' | 'numeric' | '2-digit' = 'short'
|
|
703
|
+
): string[] => {
|
|
654
704
|
return Array.from({ length: 12 }, (_, i) => {
|
|
655
705
|
return new Date(2000, i, 1).toLocaleString(locale, { month: format })
|
|
656
706
|
})
|
|
@@ -1031,6 +1081,102 @@ export const isMonthInRange = (date: Date, start: Date | null, end: Date | null)
|
|
|
1031
1081
|
return Boolean(_start && _end && _start <= _date && _date <= _end)
|
|
1032
1082
|
}
|
|
1033
1083
|
|
|
1084
|
+
/**
|
|
1085
|
+
* Checks if a quarter is disabled based on the 'quarter' period type.
|
|
1086
|
+
* @param date - The date representing the quarter to check.
|
|
1087
|
+
* @param min - Minimum allowed date.
|
|
1088
|
+
* @param max - Maximum allowed date.
|
|
1089
|
+
* @param disabledDates - Criteria for disabled dates.
|
|
1090
|
+
* @returns True if the quarter is disabled, false otherwise.
|
|
1091
|
+
*/
|
|
1092
|
+
export const isQuarterDisabled = (
|
|
1093
|
+
date: Date,
|
|
1094
|
+
min?: Date | null,
|
|
1095
|
+
max?: Date | null,
|
|
1096
|
+
disabledDates?: DisabledDate | DisabledDate[]
|
|
1097
|
+
) => {
|
|
1098
|
+
const current = dateToQuarterNumber(date)
|
|
1099
|
+
const _min = min ? dateToQuarterNumber(min) : null
|
|
1100
|
+
const _max = max ? dateToQuarterNumber(max) : null
|
|
1101
|
+
|
|
1102
|
+
if (isOutsideRange(current, _min, _max)) {
|
|
1103
|
+
return true
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
if (disabledDates === undefined) {
|
|
1107
|
+
return false
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// Get the start and end of the quarter
|
|
1111
|
+
const quarter = Math.floor(date.getMonth() / 3)
|
|
1112
|
+
const quarterStartMonth = quarter * 3
|
|
1113
|
+
const quarterEndMonth = quarterStartMonth + 2
|
|
1114
|
+
const year = date.getFullYear()
|
|
1115
|
+
|
|
1116
|
+
const quarterStart = new Date(year, quarterStartMonth, 1)
|
|
1117
|
+
const quarterEnd = new Date(year, quarterEndMonth + 1, 0) // Last day of the quarter
|
|
1118
|
+
|
|
1119
|
+
const startTime = min ? Math.max(quarterStart.getTime(), min.getTime()) : quarterStart.getTime()
|
|
1120
|
+
const endTime = max ? Math.min(quarterEnd.getTime(), max.getTime()) : quarterEnd.getTime()
|
|
1121
|
+
|
|
1122
|
+
for (
|
|
1123
|
+
const currentDate = new Date(startTime);
|
|
1124
|
+
currentDate.getTime() <= endTime;
|
|
1125
|
+
currentDate.setDate(currentDate.getDate() + 1)
|
|
1126
|
+
) {
|
|
1127
|
+
if (!isDateDisabled(currentDate, min, max, disabledDates)) {
|
|
1128
|
+
return false
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
return false
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* Checks if a quarter is selected based on start and end dates.
|
|
1137
|
+
* @param date - The date representing the quarter.
|
|
1138
|
+
* @param start - Start date.
|
|
1139
|
+
* @param end - End date.
|
|
1140
|
+
* @returns True if the quarter is selected, false otherwise.
|
|
1141
|
+
*/
|
|
1142
|
+
export const isQuarterSelected = (date: Date, start: Date | null, end: Date | null): boolean => {
|
|
1143
|
+
const year = date.getFullYear()
|
|
1144
|
+
const quarter = Math.floor(date.getMonth() / 3)
|
|
1145
|
+
|
|
1146
|
+
if (start !== null) {
|
|
1147
|
+
const startYear = start.getFullYear()
|
|
1148
|
+
const startQuarter = Math.floor(start.getMonth() / 3)
|
|
1149
|
+
if (year === startYear && quarter === startQuarter) {
|
|
1150
|
+
return true
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
if (end !== null) {
|
|
1155
|
+
const endYear = end.getFullYear()
|
|
1156
|
+
const endQuarter = Math.floor(end.getMonth() / 3)
|
|
1157
|
+
if (year === endYear && quarter === endQuarter) {
|
|
1158
|
+
return true
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
return false
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* Checks if a quarter is within a specified range.
|
|
1167
|
+
* @param date - The date representing the quarter.
|
|
1168
|
+
* @param start - Start date.
|
|
1169
|
+
* @param end - End date.
|
|
1170
|
+
* @returns True if the quarter is within the range, false otherwise.
|
|
1171
|
+
*/
|
|
1172
|
+
export const isQuarterInRange = (date: Date, start: Date | null, end: Date | null): boolean => {
|
|
1173
|
+
const _start = start ? dateToQuarterNumber(start) : null
|
|
1174
|
+
const _end = end ? dateToQuarterNumber(end) : null
|
|
1175
|
+
const _date = dateToQuarterNumber(date)
|
|
1176
|
+
|
|
1177
|
+
return Boolean(_start && _end && _start <= _date && _date <= _end)
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1034
1180
|
/**
|
|
1035
1181
|
* Checks if two dates are the same calendar date.
|
|
1036
1182
|
* @param date - First date.
|
|
@@ -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
|
+
})
|