@thecb/components 10.12.0 → 10.12.1
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/dist/index.cjs.js +554 -350
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +4 -5
- package/dist/index.esm.js +554 -350
- package/dist/index.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/components/molecules/multiple-select-filter/MultipleSelectFilter.js +61 -295
- package/src/components/molecules/multiple-select-filter/MultipleSelectFilter.stories.js +1 -1
- package/src/components/molecules/multiple-select-filter/MultipleSelectFilter.styled.js +4 -4
- package/src/components/molecules/multiple-select-filter/__private__/ActionLinkButton.js +24 -0
- package/src/components/molecules/multiple-select-filter/__private__/FilterButton.js +85 -0
- package/src/components/molecules/multiple-select-filter/__private__/FilterDropdown.js +23 -0
- package/src/components/molecules/multiple-select-filter/__private__/FilterableList.js +144 -0
- package/src/components/molecules/multiple-select-filter/__private__/FilterableListItem.js +67 -0
- package/src/components/molecules/multiple-select-filter/__private__/SearchBox.js +38 -0
- package/src/components/molecules/multiple-select-filter/__private__/useKeyboardNavigation.js +84 -0
- package/src/components/molecules/multiple-select-filter/__private__/util.js +31 -0
- package/src/hooks/use-outside-click/index.js +4 -5
- package/src/components/atoms/.DS_Store +0 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { Box } from "../../../atoms";
|
|
3
|
+
import FilterableListItem from "./FilterableListItem";
|
|
4
|
+
import useKeyboardNavigation from "./useKeyboardNavigation";
|
|
5
|
+
import {
|
|
6
|
+
sortItemsList,
|
|
7
|
+
filterItemsList,
|
|
8
|
+
selectOption,
|
|
9
|
+
isChecked,
|
|
10
|
+
isMaxSelectionReached
|
|
11
|
+
} from "./util";
|
|
12
|
+
import { GHOST_GREY } from "../../../../constants/colors";
|
|
13
|
+
|
|
14
|
+
const FilterableList = ({
|
|
15
|
+
id,
|
|
16
|
+
options,
|
|
17
|
+
appliedOptions,
|
|
18
|
+
selectedOptions,
|
|
19
|
+
maxSelections,
|
|
20
|
+
name,
|
|
21
|
+
setSelectedOptions,
|
|
22
|
+
searchTerm,
|
|
23
|
+
themeValues
|
|
24
|
+
}) => {
|
|
25
|
+
const [filteredOptions, setFilteredOptions] = useState([]);
|
|
26
|
+
const [filteredAppliedOptions, setFilteredAppliedOptions] = useState([]);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
setFilteredOptions(options);
|
|
30
|
+
setFilteredAppliedOptions(appliedOptions);
|
|
31
|
+
}, [options, appliedOptions]);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
const filteredOptionItems = filterItemsList(options, searchTerm?.rawValue);
|
|
35
|
+
const filteredAppliedItems = filterItemsList(
|
|
36
|
+
appliedOptions,
|
|
37
|
+
searchTerm?.rawValue
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
setFilteredOptions(
|
|
41
|
+
filteredOptionItems.length ? filteredOptionItems : filteredOptions
|
|
42
|
+
);
|
|
43
|
+
setFilteredAppliedOptions(filteredAppliedItems);
|
|
44
|
+
}, [searchTerm.rawValue]);
|
|
45
|
+
|
|
46
|
+
const handleSelectOption = option =>
|
|
47
|
+
selectOption(option, selectedOptions, setSelectedOptions);
|
|
48
|
+
|
|
49
|
+
const isAppliedOption = option =>
|
|
50
|
+
filteredAppliedOptions?.some(
|
|
51
|
+
appliedItem => appliedItem?.name === option?.name
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const currentFilteredOptions = filteredOptions.filter(
|
|
55
|
+
option => !isAppliedOption(option)
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const sortedOptions = sortItemsList(currentFilteredOptions);
|
|
59
|
+
const sortedAppliedOptions = sortItemsList(filteredAppliedOptions);
|
|
60
|
+
|
|
61
|
+
const { itemRefs, focusedIndex, handleKeyDown } = useKeyboardNavigation({
|
|
62
|
+
options: sortedOptions,
|
|
63
|
+
appliedOptions: sortedAppliedOptions,
|
|
64
|
+
selectedOptions,
|
|
65
|
+
maxSelections
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Box
|
|
70
|
+
id={id}
|
|
71
|
+
role="listbox"
|
|
72
|
+
padding="0"
|
|
73
|
+
extraStyles={`
|
|
74
|
+
overflow-y: auto;
|
|
75
|
+
max-height: 250px;
|
|
76
|
+
display: flex;
|
|
77
|
+
flex-flow: column;
|
|
78
|
+
`}
|
|
79
|
+
onKeyDown={handleKeyDown}
|
|
80
|
+
>
|
|
81
|
+
{sortedAppliedOptions?.length > 0 && (
|
|
82
|
+
<Box
|
|
83
|
+
padding="0"
|
|
84
|
+
extraStyles={
|
|
85
|
+
sortedOptions.length > 0 && `border-bottom: 1px solid ${GHOST_GREY}`
|
|
86
|
+
}
|
|
87
|
+
>
|
|
88
|
+
{sortedAppliedOptions.map((option, index) => {
|
|
89
|
+
const checked = isChecked(option, selectedOptions);
|
|
90
|
+
const tabIndex =
|
|
91
|
+
index === focusedIndex || (index === 0 && focusedIndex === -1)
|
|
92
|
+
? "0"
|
|
93
|
+
: "-1";
|
|
94
|
+
return (
|
|
95
|
+
<FilterableListItem
|
|
96
|
+
key={index}
|
|
97
|
+
ref={el => (itemRefs.current[index] = el)}
|
|
98
|
+
index={index}
|
|
99
|
+
option={option}
|
|
100
|
+
checked={checked}
|
|
101
|
+
selectOption={handleSelectOption}
|
|
102
|
+
tabIndex={tabIndex}
|
|
103
|
+
name={name}
|
|
104
|
+
themeValues={themeValues}
|
|
105
|
+
></FilterableListItem>
|
|
106
|
+
);
|
|
107
|
+
})}
|
|
108
|
+
</Box>
|
|
109
|
+
)}
|
|
110
|
+
{sortedOptions.map((option, index) => {
|
|
111
|
+
const checked = isChecked(option, selectedOptions);
|
|
112
|
+
const isDisabled =
|
|
113
|
+
isMaxSelectionReached(maxSelections, selectedOptions) && !checked;
|
|
114
|
+
const indexOffset = sortedAppliedOptions?.length
|
|
115
|
+
? sortedAppliedOptions?.length
|
|
116
|
+
: 0;
|
|
117
|
+
const currentIndex = index === 0 ? indexOffset : index + indexOffset;
|
|
118
|
+
const tabIndex =
|
|
119
|
+
currentIndex === focusedIndex ||
|
|
120
|
+
(indexOffset === 0 &&
|
|
121
|
+
currentIndex === indexOffset &&
|
|
122
|
+
focusedIndex === -1)
|
|
123
|
+
? "0"
|
|
124
|
+
: "-1";
|
|
125
|
+
return (
|
|
126
|
+
<FilterableListItem
|
|
127
|
+
key={currentIndex}
|
|
128
|
+
ref={el => (itemRefs.current[currentIndex] = el)}
|
|
129
|
+
index={currentIndex}
|
|
130
|
+
option={option}
|
|
131
|
+
checked={checked}
|
|
132
|
+
selectOption={isDisabled ? noop : handleSelectOption}
|
|
133
|
+
disabled={isDisabled}
|
|
134
|
+
tabIndex={tabIndex}
|
|
135
|
+
name={name}
|
|
136
|
+
themeValues={themeValues}
|
|
137
|
+
></FilterableListItem>
|
|
138
|
+
);
|
|
139
|
+
})}
|
|
140
|
+
</Box>
|
|
141
|
+
);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export default React.memo(FilterableList);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import React, { forwardRef } from "react";
|
|
2
|
+
import Checkbox from "../../../atoms/checkbox";
|
|
3
|
+
import { Box } from "../../../atoms";
|
|
4
|
+
|
|
5
|
+
const FilterableListItem = forwardRef(
|
|
6
|
+
(
|
|
7
|
+
{
|
|
8
|
+
index,
|
|
9
|
+
option,
|
|
10
|
+
checked,
|
|
11
|
+
selectOption,
|
|
12
|
+
disabled,
|
|
13
|
+
tabIndex,
|
|
14
|
+
name,
|
|
15
|
+
themeValues
|
|
16
|
+
},
|
|
17
|
+
ref
|
|
18
|
+
) => {
|
|
19
|
+
return (
|
|
20
|
+
<Box
|
|
21
|
+
padding="0"
|
|
22
|
+
key={index}
|
|
23
|
+
extraStyles={`
|
|
24
|
+
:hover,
|
|
25
|
+
:active,
|
|
26
|
+
:focus {
|
|
27
|
+
background-color: ${themeValues.primaryColor};
|
|
28
|
+
}
|
|
29
|
+
`}
|
|
30
|
+
>
|
|
31
|
+
<Checkbox
|
|
32
|
+
ref={ref}
|
|
33
|
+
title={option.name}
|
|
34
|
+
name={option.name}
|
|
35
|
+
checked={checked}
|
|
36
|
+
onChange={() => selectOption(option)}
|
|
37
|
+
textExtraStyles={`font-size: 0.875rem; margin: 0;`}
|
|
38
|
+
disabled={disabled}
|
|
39
|
+
extraStyles={`
|
|
40
|
+
padding: 0.075rem 0.325rem;
|
|
41
|
+
margin: 0;
|
|
42
|
+
:hover,
|
|
43
|
+
:active,
|
|
44
|
+
:focus {
|
|
45
|
+
background-color: ${themeValues.primaryColor};
|
|
46
|
+
}
|
|
47
|
+
`}
|
|
48
|
+
checkboxMargin="0.3rem"
|
|
49
|
+
role="option"
|
|
50
|
+
checkboxExtraStyles={`
|
|
51
|
+
width: 1.375rem;
|
|
52
|
+
height: 1.375rem;
|
|
53
|
+
${
|
|
54
|
+
checked && !disabled
|
|
55
|
+
? `background: ` + themeValues.secondaryColor + `;`
|
|
56
|
+
: ""
|
|
57
|
+
}
|
|
58
|
+
`}
|
|
59
|
+
tabIndex={tabIndex}
|
|
60
|
+
dataQa={`${name}-option-${index}`}
|
|
61
|
+
/>
|
|
62
|
+
</Box>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
export default FilterableListItem;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, FormInput } from "../../../atoms";
|
|
3
|
+
import { GHOST_GREY } from "../../../../constants/colors";
|
|
4
|
+
|
|
5
|
+
const SearchBox = ({
|
|
6
|
+
autocompleteValue,
|
|
7
|
+
fields,
|
|
8
|
+
actions,
|
|
9
|
+
placeholder,
|
|
10
|
+
disabled,
|
|
11
|
+
showSearchBox
|
|
12
|
+
}) => {
|
|
13
|
+
return (
|
|
14
|
+
<Box padding="0 0 0.5rem">
|
|
15
|
+
{showSearchBox && (
|
|
16
|
+
<FormInput
|
|
17
|
+
autocompleteValue={autocompleteValue}
|
|
18
|
+
showFieldErrorRow={false}
|
|
19
|
+
errorMessages={{}}
|
|
20
|
+
field={fields.searchTerm}
|
|
21
|
+
fieldActions={actions.fields.searchTerm}
|
|
22
|
+
placeholder={placeholder}
|
|
23
|
+
disabled={disabled}
|
|
24
|
+
extraStyles={`
|
|
25
|
+
height: 2.875rem;
|
|
26
|
+
border: 0;
|
|
27
|
+
border-radius: 0;
|
|
28
|
+
padding: 0.45rem;
|
|
29
|
+
font-size: 0.875rem;
|
|
30
|
+
border-bottom: 1px solid ${GHOST_GREY};
|
|
31
|
+
`}
|
|
32
|
+
/>
|
|
33
|
+
)}
|
|
34
|
+
</Box>
|
|
35
|
+
);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export default SearchBox;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import React, { useRef, useState, useEffect } from "react";
|
|
2
|
+
import { isMaxSelectionReached } from "./util";
|
|
3
|
+
|
|
4
|
+
const useKeyboardNavigation = ({
|
|
5
|
+
options,
|
|
6
|
+
appliedOptions,
|
|
7
|
+
selectedOptions,
|
|
8
|
+
maxSelections
|
|
9
|
+
}) => {
|
|
10
|
+
const [focusedIndex, setFocusedIndex] = useState(-1);
|
|
11
|
+
const itemRefs = useRef([]);
|
|
12
|
+
const totalItemsLength = options.length + appliedOptions.length;
|
|
13
|
+
|
|
14
|
+
const handleArrowUp = event => {
|
|
15
|
+
event.preventDefault();
|
|
16
|
+
setFocusedIndex(prevIndex =>
|
|
17
|
+
prevIndex > 0 ? prevIndex - 1 : totalItemsLength - 1
|
|
18
|
+
);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const handleArrowDown = event => {
|
|
22
|
+
event.preventDefault();
|
|
23
|
+
setFocusedIndex(prevIndex =>
|
|
24
|
+
prevIndex < totalItemsLength - 1 ? prevIndex + 1 : 0
|
|
25
|
+
);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const handleSpacebar = event => {
|
|
29
|
+
event.preventDefault();
|
|
30
|
+
const validFocusedIndex = focusedIndex < 0 ? 0 : focusedIndex;
|
|
31
|
+
// Select option on spacebar press if the maximum selection hasn't been reached.
|
|
32
|
+
if (
|
|
33
|
+
!isMaxSelectionReached(maxSelections, selectedOptions) &&
|
|
34
|
+
itemRefs.current &&
|
|
35
|
+
itemRefs.current[validFocusedIndex]
|
|
36
|
+
) {
|
|
37
|
+
const nestedInput = itemRefs.current[validFocusedIndex].querySelector(
|
|
38
|
+
"input"
|
|
39
|
+
);
|
|
40
|
+
if (nestedInput) {
|
|
41
|
+
nestedInput.click();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const handleTab = event => {
|
|
47
|
+
// Reset focus when tabbing out of the list.
|
|
48
|
+
setFocusedIndex(-1);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const keyActions = {
|
|
52
|
+
" ": event => handleSpacebar(event),
|
|
53
|
+
Space: event => handleSpacebar(event),
|
|
54
|
+
Tab: event => handleTab(event),
|
|
55
|
+
ArrowUp: event => handleArrowUp(event),
|
|
56
|
+
ArrowDown: event => handleArrowDown(event)
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const handleKeyDown = event => {
|
|
60
|
+
const eventKey = event.code || event.key;
|
|
61
|
+
const action = keyActions[eventKey];
|
|
62
|
+
if (action) {
|
|
63
|
+
action(event);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (
|
|
69
|
+
focusedIndex !== -1 &&
|
|
70
|
+
itemRefs.current &&
|
|
71
|
+
itemRefs.current[focusedIndex]
|
|
72
|
+
) {
|
|
73
|
+
itemRefs.current[focusedIndex].focus(); // move focus to the active option
|
|
74
|
+
}
|
|
75
|
+
}, [focusedIndex]);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
itemRefs,
|
|
79
|
+
focusedIndex,
|
|
80
|
+
handleKeyDown
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export default useKeyboardNavigation;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const filterItemsList = (list, searchTerm) =>
|
|
2
|
+
list.filter(item =>
|
|
3
|
+
item?.name?.toLowerCase().includes(searchTerm?.toLowerCase())
|
|
4
|
+
);
|
|
5
|
+
|
|
6
|
+
export const sortItemsList = list =>
|
|
7
|
+
list
|
|
8
|
+
.slice()
|
|
9
|
+
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
|
10
|
+
|
|
11
|
+
export const isMaxSelectionReached = (maxSelection, selectedOptions) =>
|
|
12
|
+
maxSelection && maxSelection === selectedOptions?.length;
|
|
13
|
+
|
|
14
|
+
export const isChecked = (option, selectedOptions) =>
|
|
15
|
+
selectedOptions?.some(
|
|
16
|
+
selectedOption => selectedOption?.name === option?.name
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
export const selectValues = items => items.map(item => item.value);
|
|
20
|
+
|
|
21
|
+
export const selectOption = (option, selectedOptions, setSelectedOptions) => {
|
|
22
|
+
if (selectValues(selectedOptions).includes(option.value)) {
|
|
23
|
+
const fewerOptions = selectedOptions.filter(
|
|
24
|
+
selectedOption => selectedOption.value !== option.value
|
|
25
|
+
);
|
|
26
|
+
setSelectedOptions(fewerOptions);
|
|
27
|
+
} else {
|
|
28
|
+
const moreOptions = selectedOptions.concat(option);
|
|
29
|
+
setSelectedOptions(moreOptions);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
@@ -18,12 +18,11 @@ const useOutsideClickHook = handler => {
|
|
|
18
18
|
if (ref.current && !ref.current.contains(e.target)) {
|
|
19
19
|
handler();
|
|
20
20
|
}
|
|
21
|
+
};
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
document.removeEventListener("click", handleOutsideClick, true);
|
|
26
|
-
};
|
|
23
|
+
document.addEventListener("click", handleOutsideClick, true);
|
|
24
|
+
return () => {
|
|
25
|
+
document.removeEventListener("click", handleOutsideClick, true);
|
|
27
26
|
};
|
|
28
27
|
}, [ref]);
|
|
29
28
|
|
|
Binary file
|