@cdc/core 4.24.10 → 4.24.12-2
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/components/AdvancedEditor/AdvancedEditor.tsx +17 -13
- package/components/Alert/components/Alert.tsx +39 -8
- package/components/DataTable/DataTable.tsx +31 -10
- package/components/DataTable/DataTableStandAlone.tsx +3 -3
- package/components/DataTable/components/ExpandCollapse.tsx +1 -1
- package/components/DataTable/components/SortIcon/sort-icon.css +15 -0
- package/components/DataTable/data-table.css +4 -22
- package/components/DataTable/helpers/boxplotCellMatrix.tsx +19 -14
- package/components/DataTable/helpers/getChartCellValue.ts +25 -7
- package/components/EditorPanel/ColumnsEditor.tsx +81 -36
- package/components/EditorPanel/DataTableEditor.tsx +62 -56
- package/components/EditorPanel/FieldSetWrapper.tsx +2 -2
- package/components/EditorPanel/Inputs.tsx +26 -16
- package/components/EditorPanel/VizFilterEditor/VizFilterEditor.tsx +55 -56
- package/components/Filters/Filters.tsx +42 -38
- package/components/Filters/helpers/handleSorting.ts +5 -0
- package/components/Footnotes/FootnotesStandAlone.tsx +17 -4
- package/components/Layout/components/Sidebar/components/sidebar.styles.scss +0 -4
- package/components/Layout/components/Visualization/visualizations.scss +1 -1
- package/components/Legend/Legend.Gradient.tsx +50 -35
- package/components/Loader/Loader.tsx +10 -5
- package/components/MultiSelect/MultiSelect.tsx +56 -33
- package/components/MultiSelect/multiselect.styles.css +20 -7
- package/components/NestedDropdown/NestedDropdown.tsx +55 -32
- package/components/NestedDropdown/nesteddropdown.styles.css +26 -13
- package/components/Table/Table.tsx +102 -34
- package/components/Table/components/Row.tsx +1 -1
- package/components/_stories/DataTable.stories.tsx +14 -0
- package/components/_stories/Filters.stories.tsx +57 -0
- package/components/_stories/_mocks/DataTable/no-data.json +108 -0
- package/components/inputs/{InputToggle.jsx → InputToggle.tsx} +35 -29
- package/components/ui/Icon.tsx +19 -6
- package/dist/cove-main.css +26 -57
- package/dist/cove-main.css.map +1 -1
- package/helpers/DataTransform.ts +2 -1
- package/helpers/addValuesToFilters.ts +22 -8
- package/helpers/cove/{number.js → number.ts} +25 -11
- package/helpers/coveUpdateWorker.ts +1 -1
- package/helpers/fetchRemoteData.js +32 -37
- package/helpers/filterVizData.ts +2 -2
- package/helpers/formatConfigBeforeSave.ts +16 -0
- package/helpers/gatherQueryParams.ts +2 -3
- package/helpers/queryStringUtils.ts +16 -1
- package/helpers/tests/addValuesToFilters.test.ts +6 -1
- package/helpers/useDataVizClasses.ts +44 -21
- package/helpers/ver/4.24.10.ts +12 -0
- package/helpers/ver/versionNeedsUpdate.ts +2 -0
- package/helpers/viewports.ts +8 -7
- package/package.json +2 -2
- package/styles/_button-section.scss +1 -1
- package/styles/_global-variables.scss +9 -4
- package/styles/_global.scss +21 -22
- package/styles/_reset.scss +0 -12
- package/styles/filters.scss +0 -22
- package/styles/v2/base/_reset.scss +0 -7
- package/styles/v2/components/editor.scss +0 -4
- package/styles/v2/components/icon.scss +1 -1
- package/types/Axis.ts +2 -0
- package/types/BoxPlot.ts +5 -3
- package/types/Color.ts +1 -1
- package/types/Legend.ts +1 -2
- package/types/MarkupInclude.ts +1 -0
- package/types/Runtime.ts +3 -1
- package/types/Series.ts +8 -1
- package/types/Table.ts +1 -1
- package/types/Version.ts +1 -0
- package/types/Visualization.ts +7 -8
- package/types/VizFilter.ts +2 -1
- package/components/ui/Select.jsx +0 -30
- package/helpers/getGradientLegendWidth.ts +0 -15
|
@@ -15,20 +15,33 @@ type StandAloneProps = {
|
|
|
15
15
|
viewport?: ViewPort
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
const FootnotesStandAlone: React.FC<StandAloneProps> = ({
|
|
18
|
+
const FootnotesStandAlone: React.FC<StandAloneProps> = ({
|
|
19
|
+
visualizationKey,
|
|
20
|
+
config,
|
|
21
|
+
viewport,
|
|
22
|
+
isEditor,
|
|
23
|
+
updateConfig
|
|
24
|
+
}) => {
|
|
19
25
|
const updateField = updateFieldFactory<Footnote[]>(config, updateConfig)
|
|
20
26
|
if (isEditor)
|
|
21
27
|
return (
|
|
22
|
-
<EditorWrapper
|
|
28
|
+
<EditorWrapper
|
|
29
|
+
component={FootnotesStandAlone}
|
|
30
|
+
visualizationKey={visualizationKey}
|
|
31
|
+
visualizationConfig={config}
|
|
32
|
+
updateConfig={updateConfig}
|
|
33
|
+
type={'Footnotes'}
|
|
34
|
+
viewport={viewport}
|
|
35
|
+
>
|
|
23
36
|
<FootnotesEditor key={visualizationKey} config={config} updateField={updateField} />
|
|
24
37
|
</EditorWrapper>
|
|
25
38
|
)
|
|
26
39
|
|
|
27
40
|
// get the api footnotes from the config
|
|
28
41
|
const apiFootnotes = useMemo(() => {
|
|
29
|
-
|
|
42
|
+
const configData = config.formattedData || config.data
|
|
43
|
+
if (configData && config.dataKey && config.dynamicFootnotes) {
|
|
30
44
|
const { symbolColumn, textColumn, orderColumn } = config.dynamicFootnotes
|
|
31
|
-
const configData = config.formattedData || config.data
|
|
32
45
|
const _data = configData.map(row => _.pick(row, [symbolColumn, textColumn, orderColumn]))
|
|
33
46
|
_data.sort((a, b) => a[orderColumn] - b[orderColumn])
|
|
34
47
|
return _data.map(row => ({ symbol: row[symbolColumn], text: row[textColumn] }))
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { Group } from '@visx/group'
|
|
2
2
|
import { Text } from '@visx/text'
|
|
3
|
-
import { type
|
|
3
|
+
import { type MapConfig } from '@cdc/map/src/types/MapConfig'
|
|
4
4
|
import { type ChartConfig } from '@cdc/chart/src/types/ChartConfig'
|
|
5
|
-
import { getGradientLegendWidth } from '@cdc/core/helpers/getGradientLegendWidth'
|
|
6
5
|
import { getTextWidth } from '../../helpers/getTextWidth'
|
|
7
6
|
import { DimensionsType } from '../../types/Dimensions'
|
|
8
7
|
|
|
8
|
+
const MARGIN = 1
|
|
9
|
+
const BORDER_SIZE = 1
|
|
10
|
+
const MOBILE_BREAKPOINT = 576
|
|
11
|
+
|
|
9
12
|
type CombinedConfig = MapConfig | ChartConfig
|
|
10
13
|
|
|
11
14
|
interface GradientProps {
|
|
@@ -13,30 +16,40 @@ interface GradientProps {
|
|
|
13
16
|
colors: string[]
|
|
14
17
|
config: CombinedConfig
|
|
15
18
|
dimensions: DimensionsType
|
|
16
|
-
|
|
19
|
+
parentPaddingToSubtract?: number
|
|
17
20
|
}
|
|
18
21
|
|
|
19
|
-
const LegendGradient = ({
|
|
22
|
+
const LegendGradient = ({
|
|
23
|
+
labels,
|
|
24
|
+
colors,
|
|
25
|
+
config,
|
|
26
|
+
dimensions,
|
|
27
|
+
parentPaddingToSubtract = 0
|
|
28
|
+
}: GradientProps): JSX.Element => {
|
|
29
|
+
const { uid, legend, type } = config
|
|
30
|
+
const { tickRotation, position, style, subStyle, hideBorder } = legend
|
|
31
|
+
|
|
32
|
+
const isLinearBlocks = subStyle === 'linear blocks'
|
|
20
33
|
let [width] = dimensions
|
|
21
34
|
|
|
22
|
-
const
|
|
23
|
-
const
|
|
35
|
+
const smallScreen = width <= MOBILE_BREAKPOINT
|
|
36
|
+
const legendWidth = Number(width) - parentPaddingToSubtract - MARGIN * 2 - BORDER_SIZE * 2
|
|
37
|
+
const uniqueID = `${uid}-${Date.now()}`
|
|
24
38
|
|
|
25
39
|
const numTicks = colors?.length
|
|
26
40
|
|
|
27
|
-
const longestLabel = labels
|
|
41
|
+
const longestLabel = (labels || []).reduce((a: string, b) => (a.length > String(b).length ? a : b), '')
|
|
28
42
|
const boxHeight = 20
|
|
29
43
|
let height = 50
|
|
30
|
-
const margin = 1
|
|
31
44
|
|
|
32
45
|
// configure tick witch and angle
|
|
33
46
|
const textWidth = getTextWidth(longestLabel, `normal 14px sans-serif`)
|
|
34
|
-
const rotationAngle = Number(
|
|
47
|
+
const rotationAngle = Number(tickRotation) || 0
|
|
35
48
|
// Convert the angle from degrees to radians
|
|
36
49
|
const angleInRadians = rotationAngle * (Math.PI / 180)
|
|
37
50
|
const newHeight = height + Number(textWidth) * Math.sin(angleInRadians)
|
|
38
51
|
|
|
39
|
-
//
|
|
52
|
+
// configure gradient colors
|
|
40
53
|
const stops = colors.map((color, index) => {
|
|
41
54
|
const offset = (index / (colors.length - 1)) * 100
|
|
42
55
|
return <stop key={index} offset={`${offset}%`} style={{ stopColor: color, stopOpacity: 1 }} />
|
|
@@ -45,69 +58,71 @@ const LegendGradient = ({ labels, colors, config, dimensions, currentViewport }:
|
|
|
45
58
|
// render ticks and labels
|
|
46
59
|
const ticks = labels.map((key, index) => {
|
|
47
60
|
const segmentWidth = legendWidth / numTicks
|
|
48
|
-
const xPositionX = index * segmentWidth + segmentWidth
|
|
61
|
+
const xPositionX = index * segmentWidth + segmentWidth + MARGIN
|
|
49
62
|
const textAnchor = rotationAngle ? 'end' : 'middle'
|
|
50
63
|
const verticalAnchor = rotationAngle ? 'middle' : 'start'
|
|
64
|
+
const lastTick = index === labels.length - 1
|
|
51
65
|
|
|
52
66
|
return (
|
|
53
|
-
<Group top={
|
|
54
|
-
<line x1={xPositionX} x2={xPositionX} y1={30} y2={boxHeight} stroke='black' />
|
|
67
|
+
<Group top={MARGIN}>
|
|
68
|
+
{!lastTick && !isLinearBlocks && <line x1={xPositionX} x2={xPositionX} y1={30} y2={boxHeight} stroke='black' />}
|
|
55
69
|
<Text
|
|
56
|
-
angle={-
|
|
70
|
+
angle={-tickRotation}
|
|
57
71
|
x={xPositionX}
|
|
58
72
|
y={boxHeight}
|
|
59
73
|
dy={10}
|
|
60
74
|
dx={-segmentWidth / 2}
|
|
61
|
-
fontSize='14'
|
|
75
|
+
fontSize={smallScreen ? '12' : '14'}
|
|
62
76
|
textAnchor={textAnchor}
|
|
63
77
|
verticalAnchor={verticalAnchor}
|
|
78
|
+
width={segmentWidth}
|
|
79
|
+
lineHeight={'14'}
|
|
64
80
|
>
|
|
65
81
|
{key}
|
|
66
82
|
</Text>
|
|
67
83
|
</Group>
|
|
68
84
|
)
|
|
69
85
|
})
|
|
70
|
-
if ((
|
|
86
|
+
if ((type === 'map' && position === 'side') || !position) {
|
|
71
87
|
return
|
|
72
88
|
}
|
|
73
|
-
if (
|
|
74
|
-
config.type === 'chart' &&
|
|
75
|
-
(config.legend.position === 'left' || config.legend.position === 'right' || !config.legend.position)
|
|
76
|
-
) {
|
|
89
|
+
if (type === 'chart' && (position === 'left' || position === 'right' || !position)) {
|
|
77
90
|
return
|
|
78
91
|
}
|
|
79
92
|
|
|
80
|
-
if (
|
|
93
|
+
if (style === 'gradient') {
|
|
81
94
|
return (
|
|
82
|
-
<svg
|
|
95
|
+
<svg
|
|
96
|
+
style={{ overflow: 'visible', width: '100%', marginTop: 10, marginBottom: hideBorder ? 10 : 0 }}
|
|
97
|
+
height={newHeight}
|
|
98
|
+
>
|
|
83
99
|
{/* background border*/}
|
|
84
|
-
<rect
|
|
85
|
-
x={0}
|
|
86
|
-
y={0}
|
|
87
|
-
width={legendWidth + margin * 2}
|
|
88
|
-
height={boxHeight + margin * 2}
|
|
89
|
-
fill='#d3d3d3'
|
|
90
|
-
strokeWidth='0.5'
|
|
91
|
-
/>
|
|
100
|
+
<rect x={0} y={0} width={legendWidth + MARGIN * 2} height={boxHeight + MARGIN * 2} fill='#d3d3d3' />
|
|
92
101
|
{/* Define the gradient */}
|
|
93
102
|
<linearGradient id={`gradient-smooth-${uniqueID}`} x1='0%' y1='0%' x2='100%' y2='0%'>
|
|
94
103
|
{stops}
|
|
95
104
|
</linearGradient>
|
|
96
105
|
|
|
97
|
-
{
|
|
98
|
-
<rect
|
|
106
|
+
{subStyle === 'smooth' && (
|
|
107
|
+
<rect
|
|
108
|
+
x={MARGIN}
|
|
109
|
+
y={MARGIN}
|
|
110
|
+
width={legendWidth}
|
|
111
|
+
height={boxHeight}
|
|
112
|
+
fill={`url(#gradient-smooth-${uniqueID})`}
|
|
113
|
+
/>
|
|
99
114
|
)}
|
|
100
115
|
|
|
101
|
-
{
|
|
116
|
+
{subStyle === 'linear blocks' &&
|
|
102
117
|
colors.map((color, index) => {
|
|
103
118
|
const segmentWidth = legendWidth / numTicks
|
|
104
|
-
const xPosition = index * segmentWidth
|
|
119
|
+
const xPosition = index * segmentWidth + MARGIN
|
|
105
120
|
return (
|
|
106
121
|
<Group>
|
|
107
122
|
<rect
|
|
108
123
|
key={index}
|
|
109
124
|
x={xPosition}
|
|
110
|
-
y={
|
|
125
|
+
y={MARGIN}
|
|
111
126
|
width={segmentWidth}
|
|
112
127
|
height={boxHeight}
|
|
113
128
|
fill={color}
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import React, { useEffect, useRef } from 'react'
|
|
2
2
|
import './loader.styles.css'
|
|
3
3
|
|
|
4
|
+
// these coorespond to bootstrap classes
|
|
5
|
+
// https://getbootstrap.com/docs/4.2/components/spinners/
|
|
6
|
+
type SpinnerType = 'text-primary' | 'text-secondary'
|
|
7
|
+
|
|
4
8
|
type LoaderProps = {
|
|
5
9
|
fullScreen?: boolean
|
|
10
|
+
spinnerType?: SpinnerType
|
|
6
11
|
}
|
|
7
12
|
|
|
8
|
-
const Spinner = () => (
|
|
9
|
-
<div className=
|
|
13
|
+
const Spinner = ({ spinnerType }: { spinnerType: SpinnerType }) => (
|
|
14
|
+
<div className={`spinner-border ${spinnerType}`} role='status'>
|
|
10
15
|
<span className='sr-only'>Loading...</span>
|
|
11
16
|
</div>
|
|
12
17
|
)
|
|
13
18
|
|
|
14
|
-
const Loader: React.FC<LoaderProps> = ({ fullScreen = false }) => {
|
|
19
|
+
const Loader: React.FC<LoaderProps> = ({ fullScreen = false, spinnerType }) => {
|
|
15
20
|
const backgroundRef = useRef(null)
|
|
16
21
|
|
|
17
22
|
useEffect(() => {
|
|
@@ -23,10 +28,10 @@ const Loader: React.FC<LoaderProps> = ({ fullScreen = false }) => {
|
|
|
23
28
|
|
|
24
29
|
return fullScreen ? (
|
|
25
30
|
<div ref={backgroundRef} className='cove-loader fullscreen'>
|
|
26
|
-
<Spinner />
|
|
31
|
+
<Spinner spinnerType={spinnerType || 'text-primary'} />
|
|
27
32
|
</div>
|
|
28
33
|
) : (
|
|
29
|
-
<Spinner />
|
|
34
|
+
<Spinner spinnerType={spinnerType || 'text-primary'} />
|
|
30
35
|
)
|
|
31
36
|
}
|
|
32
37
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import React, { useEffect, useRef, useState } from 'react'
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import Tooltip from '../ui/Tooltip'
|
|
3
3
|
import Icon from '../ui/Icon'
|
|
4
4
|
|
|
5
5
|
import './multiselect.styles.css'
|
|
6
6
|
import { UpdateFieldFunc } from '../../types/UpdateFieldFunc'
|
|
7
|
+
import Loader from '../Loader'
|
|
7
8
|
|
|
8
9
|
interface Option {
|
|
9
10
|
value: string | number
|
|
@@ -20,11 +21,24 @@ interface MultiSelectProps {
|
|
|
20
21
|
selected?: (string | number)[]
|
|
21
22
|
limit?: number
|
|
22
23
|
tooltip?: React.ReactNode
|
|
24
|
+
loading?: boolean
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
const MultiSelect: React.FC<MultiSelectProps> = ({
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
const MultiSelect: React.FC<MultiSelectProps> = ({
|
|
28
|
+
section = null,
|
|
29
|
+
subsection = null,
|
|
30
|
+
fieldName,
|
|
31
|
+
label,
|
|
32
|
+
options,
|
|
33
|
+
updateField,
|
|
34
|
+
selected = [],
|
|
35
|
+
limit,
|
|
36
|
+
tooltip,
|
|
37
|
+
loading
|
|
38
|
+
}) => {
|
|
39
|
+
const preselectedItems = useMemo(() => options.filter(opt => selected.includes(opt.value)).slice(0, limit), [options])
|
|
40
|
+
const [selectedItems, setSelectedItems] = useState<Option[]>()
|
|
41
|
+
const items = selectedItems || preselectedItems
|
|
28
42
|
const [expanded, setExpanded] = useState(false)
|
|
29
43
|
const multiSelectRef = useRef(null)
|
|
30
44
|
|
|
@@ -52,15 +66,15 @@ const MultiSelect: React.FC<MultiSelectProps> = ({ section = null, subsection =
|
|
|
52
66
|
|
|
53
67
|
const handleItemSelect = (option: Option, e = null) => {
|
|
54
68
|
if (e && e.type === 'keyup' && e.key !== 'Enter') return
|
|
55
|
-
if (limit &&
|
|
56
|
-
const newItems = [...
|
|
69
|
+
if (limit && items?.length >= limit) return
|
|
70
|
+
const newItems = [...items, option]
|
|
57
71
|
setSelectedItems(newItems)
|
|
58
72
|
update(newItems)
|
|
59
73
|
}
|
|
60
74
|
|
|
61
75
|
const handleItemRemove = (option: Option, e = null) => {
|
|
62
76
|
if (e && e.type === 'keyup' && e.key !== 'Enter') return
|
|
63
|
-
const newItems =
|
|
77
|
+
const newItems = items.filter(item => item.value !== option.value)
|
|
64
78
|
setSelectedItems(newItems)
|
|
65
79
|
update(newItems)
|
|
66
80
|
}
|
|
@@ -68,33 +82,40 @@ const MultiSelect: React.FC<MultiSelectProps> = ({ section = null, subsection =
|
|
|
68
82
|
const multiID = 'multiSelect_' + label
|
|
69
83
|
return (
|
|
70
84
|
<div ref={multiSelectRef} className='cove-multiselect'>
|
|
71
|
-
{label && (
|
|
72
|
-
<span id={multiID} className='edit-label column-heading'>
|
|
73
|
-
{label}
|
|
74
|
-
</span>
|
|
75
|
-
)}
|
|
76
|
-
|
|
77
85
|
{tooltip && tooltip}
|
|
78
86
|
|
|
79
87
|
<div className='wrapper'>
|
|
80
|
-
<div
|
|
81
|
-
{
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
88
|
+
<div
|
|
89
|
+
id={multiID}
|
|
90
|
+
onClick={() => {
|
|
91
|
+
if (!items.length && !loading) {
|
|
92
|
+
setExpanded(true)
|
|
93
|
+
}
|
|
94
|
+
}}
|
|
95
|
+
className='selected'
|
|
96
|
+
aria-disabled={loading}
|
|
97
|
+
>
|
|
98
|
+
{items.length ? (
|
|
99
|
+
items.map(item => (
|
|
100
|
+
<div key={item.value} aria-labelledby={label ? multiID + label : undefined}>
|
|
101
|
+
{item.label}
|
|
102
|
+
<button
|
|
103
|
+
aria-label='Remove'
|
|
104
|
+
onClick={e => {
|
|
105
|
+
e.preventDefault()
|
|
106
|
+
handleItemRemove(item)
|
|
107
|
+
}}
|
|
108
|
+
onKeyUp={e => {
|
|
109
|
+
handleItemRemove(item, e)
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
x
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
))
|
|
116
|
+
) : (
|
|
117
|
+
<span className='pl-1 pt-1'>{loading ? 'Loading...' : '- Select -'}</span>
|
|
118
|
+
)}
|
|
98
119
|
<button
|
|
99
120
|
aria-label={expanded ? 'Collapse' : 'Expand'}
|
|
100
121
|
aria-labelledby={label ? multiID : undefined}
|
|
@@ -104,9 +125,10 @@ const MultiSelect: React.FC<MultiSelectProps> = ({ section = null, subsection =
|
|
|
104
125
|
setExpanded(!expanded)
|
|
105
126
|
}}
|
|
106
127
|
>
|
|
107
|
-
<Icon display={
|
|
128
|
+
<Icon display={'caretDown'} style={{ cursor: 'pointer' }} />
|
|
108
129
|
</button>
|
|
109
130
|
</div>
|
|
131
|
+
{loading && <Loader spinnerType={'text-secondary'} />}
|
|
110
132
|
{!!limit && (
|
|
111
133
|
<Tooltip style={{ textTransform: 'none' }}>
|
|
112
134
|
<Tooltip.Target>
|
|
@@ -118,9 +140,10 @@ const MultiSelect: React.FC<MultiSelectProps> = ({ section = null, subsection =
|
|
|
118
140
|
</Tooltip>
|
|
119
141
|
)}
|
|
120
142
|
</div>
|
|
143
|
+
|
|
121
144
|
<ul className={'dropdown' + (expanded ? '' : ' d-none')}>
|
|
122
145
|
{options
|
|
123
|
-
.filter(option => !
|
|
146
|
+
.filter(option => !items.find(item => item.value === option.value))
|
|
124
147
|
.map(option => (
|
|
125
148
|
<li
|
|
126
149
|
className='cove-multiselect-li'
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
.cove-multiselect {
|
|
2
|
-
position: relative;
|
|
3
2
|
.cove-input__label {
|
|
4
3
|
display: block;
|
|
5
4
|
}
|
|
@@ -7,9 +6,11 @@
|
|
|
7
6
|
display: inline-flex;
|
|
8
7
|
align-items: center;
|
|
9
8
|
.selected {
|
|
9
|
+
&[aria-disabled='true'] {
|
|
10
|
+
background: var(--lightestGray);
|
|
11
|
+
}
|
|
10
12
|
border: 1px solid var(--lightGray);
|
|
11
|
-
padding:
|
|
12
|
-
min-height: 40px;
|
|
13
|
+
padding: 7px;
|
|
13
14
|
min-width: 200px;
|
|
14
15
|
display: inline-block;
|
|
15
16
|
:is(button) {
|
|
@@ -25,10 +26,14 @@
|
|
|
25
26
|
border-radius: 5px;
|
|
26
27
|
}
|
|
27
28
|
.expand {
|
|
28
|
-
padding:
|
|
29
|
-
|
|
30
|
-
background: var(--lightGray);
|
|
29
|
+
padding: 2px 0px;
|
|
30
|
+
margin-right: -6px;
|
|
31
31
|
float: right;
|
|
32
|
+
margin-bottom: -3px;
|
|
33
|
+
color: var(--mediumGray);
|
|
34
|
+
&:focus {
|
|
35
|
+
outline: none;
|
|
36
|
+
}
|
|
32
37
|
}
|
|
33
38
|
border-radius: 5px;
|
|
34
39
|
}
|
|
@@ -39,11 +44,15 @@
|
|
|
39
44
|
margin-bottom: 0;
|
|
40
45
|
}
|
|
41
46
|
}
|
|
47
|
+
.spinner-border {
|
|
48
|
+
right: 20% !important;
|
|
49
|
+
}
|
|
42
50
|
}
|
|
43
51
|
.dropdown {
|
|
44
52
|
background: white;
|
|
45
53
|
position: absolute;
|
|
46
|
-
|
|
54
|
+
top: var(--select-height);
|
|
55
|
+
margin-top: 0px;
|
|
47
56
|
border: 1px solid var(--lightGray);
|
|
48
57
|
padding-left: 0;
|
|
49
58
|
min-height: 40px;
|
|
@@ -62,3 +71,7 @@
|
|
|
62
71
|
}
|
|
63
72
|
}
|
|
64
73
|
}
|
|
74
|
+
|
|
75
|
+
.accordion__panel .cove-multiselect .dropdown {
|
|
76
|
+
position: relative;
|
|
77
|
+
}
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
import { useState, useEffect, useRef, useMemo } from 'react'
|
|
1
|
+
import { useState, useEffect, useRef, useMemo, useId } from 'react'
|
|
2
2
|
import './nesteddropdown.styles.css'
|
|
3
3
|
import Icon from '@cdc/core/components/ui/Icon'
|
|
4
4
|
import { filterSearchTerm, NestedOptions, ValueTextPair } from './nestedDropdownHelpers'
|
|
5
|
+
import Loader from '../Loader'
|
|
5
6
|
|
|
6
7
|
const Options: React.FC<{
|
|
7
8
|
subOptions: ValueTextPair[]
|
|
9
|
+
filterIndex: number
|
|
8
10
|
label: string
|
|
9
11
|
handleSubGroupSelect: Function
|
|
10
12
|
userSelectedLabel: string
|
|
11
13
|
userSearchTerm: string
|
|
12
|
-
}> = ({ subOptions, label, handleSubGroupSelect, userSelectedLabel, userSearchTerm }) => {
|
|
14
|
+
}> = ({ subOptions, filterIndex, label, handleSubGroupSelect, userSelectedLabel, userSearchTerm }) => {
|
|
13
15
|
const [isTierOneExpanded, setIsTierOneExpanded] = useState(true)
|
|
14
16
|
const checkMark = <>✔</>
|
|
15
17
|
|
|
@@ -18,7 +20,7 @@ const Options: React.FC<{
|
|
|
18
20
|
}, [userSearchTerm])
|
|
19
21
|
|
|
20
22
|
const handleGroupClick = e => {
|
|
21
|
-
const leaveExpanded = e.target.className ===
|
|
23
|
+
const leaveExpanded = e.target.className === `selectable-item-${filterIndex}` ? true : !isTierOneExpanded
|
|
22
24
|
setIsTierOneExpanded(leaveExpanded)
|
|
23
25
|
}
|
|
24
26
|
|
|
@@ -26,10 +28,10 @@ const Options: React.FC<{
|
|
|
26
28
|
const currentItem = e.target
|
|
27
29
|
if (e.key === 'ArrowRight') setIsTierOneExpanded(true)
|
|
28
30
|
else if (e.key === 'ArrowLeft') {
|
|
29
|
-
if (currentItem.className ===
|
|
31
|
+
if (currentItem.className === `selectable-item-${filterIndex}`) currentItem.parentNode.parentNode.focus()
|
|
30
32
|
setIsTierOneExpanded(false)
|
|
31
33
|
} else if (e.key === 'Enter') {
|
|
32
|
-
currentItem.className ===
|
|
34
|
+
currentItem.className === `selectable-item-${filterIndex}`
|
|
33
35
|
? handleSubGroupSelect(currentItem.dataset.value)
|
|
34
36
|
: setIsTierOneExpanded(!isTierOneExpanded)
|
|
35
37
|
}
|
|
@@ -44,7 +46,7 @@ const Options: React.FC<{
|
|
|
44
46
|
aria-label={label}
|
|
45
47
|
onClick={handleGroupClick}
|
|
46
48
|
onKeyUp={handleKeyUp}
|
|
47
|
-
className=
|
|
49
|
+
className={`nested-dropdown-group-${filterIndex}`}
|
|
48
50
|
>
|
|
49
51
|
<span className={'font-weight-bold'}>{label} </span>
|
|
50
52
|
{
|
|
@@ -73,7 +75,7 @@ const Options: React.FC<{
|
|
|
73
75
|
return (
|
|
74
76
|
<li
|
|
75
77
|
key={regionID}
|
|
76
|
-
className=
|
|
78
|
+
className={`selectable-item-${filterIndex}`}
|
|
77
79
|
tabIndex={0}
|
|
78
80
|
role='treeitem'
|
|
79
81
|
aria-label={regionID}
|
|
@@ -104,28 +106,31 @@ const Options: React.FC<{
|
|
|
104
106
|
type NestedDropdownProps = {
|
|
105
107
|
activeGroup: string
|
|
106
108
|
activeSubGroup?: string
|
|
107
|
-
|
|
108
|
-
isUrlFilter?: boolean
|
|
109
|
+
filterIndex: number
|
|
109
110
|
listLabel: string
|
|
110
111
|
handleSelectedItems: ([group, subgroup]: [string, string]) => void
|
|
111
112
|
options: NestedOptions
|
|
112
|
-
|
|
113
|
+
loading?: boolean
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
const NestedDropdown: React.FC<NestedDropdownProps> = ({
|
|
116
117
|
options,
|
|
117
118
|
activeGroup,
|
|
118
119
|
activeSubGroup,
|
|
120
|
+
filterIndex,
|
|
119
121
|
listLabel,
|
|
120
|
-
handleSelectedItems
|
|
122
|
+
handleSelectedItems,
|
|
123
|
+
loading
|
|
121
124
|
}) => {
|
|
122
|
-
const
|
|
123
|
-
const subGroupFilterActive = activeSubGroup || ''
|
|
125
|
+
const dropdownId = useId()
|
|
124
126
|
|
|
125
127
|
const [userSearchTerm, setUserSearchTerm] = useState('')
|
|
126
|
-
const [inputValue, setInputValue] = useState(
|
|
127
|
-
|
|
128
|
-
)
|
|
128
|
+
const [inputValue, setInputValue] = useState('')
|
|
129
|
+
|
|
130
|
+
const initialInputValue = useMemo(() => {
|
|
131
|
+
// value from props
|
|
132
|
+
return activeSubGroup ? `${activeGroup} - ${activeSubGroup}` : ''
|
|
133
|
+
}, [activeSubGroup])
|
|
129
134
|
const [inputHasFocus, setInputHasFocus] = useState(false)
|
|
130
135
|
const [isListOpened, setIsListOpened] = useState(false)
|
|
131
136
|
|
|
@@ -150,7 +155,7 @@ const NestedDropdown: React.FC<NestedDropdownProps> = ({
|
|
|
150
155
|
setIsListOpened(true)
|
|
151
156
|
// Move focus from Input to top of dropdown
|
|
152
157
|
Dropdown.firstChild.focus()
|
|
153
|
-
} else if (className ===
|
|
158
|
+
} else if (className === `selectable-item-${filterIndex}`) {
|
|
154
159
|
// Move focus to next item on list: next Tier Two item or the next Tier One or SearchInput
|
|
155
160
|
const itemToFocusOnAfterKeyUp = nextSibling ?? parentNode.parentNode.nextSibling ?? searchInput.current
|
|
156
161
|
itemToFocusOnAfterKeyUp.focus()
|
|
@@ -175,7 +180,7 @@ const NestedDropdown: React.FC<NestedDropdownProps> = ({
|
|
|
175
180
|
// Move focus to last item of the last collapsed Tier Two in dropdown
|
|
176
181
|
Dropdown.lastChild.lastChild.lastChild.focus()
|
|
177
182
|
}
|
|
178
|
-
} else if (className ===
|
|
183
|
+
} else if (className === `selectable-item-${filterIndex}`) {
|
|
179
184
|
// Move focus to previous Tier Two or Move focus to current Tier One
|
|
180
185
|
const itemToFocusOnAfterKeyUp = previousSibling ?? parentNode.parentNode
|
|
181
186
|
itemToFocusOnAfterKeyUp.focus()
|
|
@@ -214,7 +219,7 @@ const NestedDropdown: React.FC<NestedDropdownProps> = ({
|
|
|
214
219
|
}
|
|
215
220
|
}
|
|
216
221
|
|
|
217
|
-
const filterOptions
|
|
222
|
+
const filterOptions = useMemo(() => {
|
|
218
223
|
return filterSearchTerm(userSearchTerm, options)
|
|
219
224
|
}, [userSearchTerm, options])
|
|
220
225
|
|
|
@@ -225,39 +230,56 @@ const NestedDropdown: React.FC<NestedDropdownProps> = ({
|
|
|
225
230
|
setInputValue(newSearchTerm)
|
|
226
231
|
}
|
|
227
232
|
|
|
233
|
+
const handleOnBlur = e => {
|
|
234
|
+
if (
|
|
235
|
+
e.relatedTarget === null ||
|
|
236
|
+
![
|
|
237
|
+
`nested-dropdown-${filterIndex}`,
|
|
238
|
+
`nested-dropdown-group-${filterIndex}`,
|
|
239
|
+
`selectable-item-${filterIndex}`
|
|
240
|
+
].includes(e.relatedTarget.className)
|
|
241
|
+
) {
|
|
242
|
+
setInputHasFocus(false)
|
|
243
|
+
setIsListOpened(false)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
228
247
|
return (
|
|
229
248
|
<>
|
|
230
|
-
{listLabel && <span className='edit-label column-heading'>{listLabel}</span>}
|
|
231
249
|
<div
|
|
232
|
-
id=
|
|
233
|
-
className={`nested-dropdown ${isListOpened ? 'open-filter' : ''}`}
|
|
250
|
+
id={dropdownId}
|
|
251
|
+
className={`nested-dropdown nested-dropdown-${filterIndex} ${isListOpened ? 'open-filter' : ''}`}
|
|
234
252
|
onKeyUp={handleKeyUp}
|
|
235
253
|
>
|
|
236
|
-
<div
|
|
254
|
+
<div
|
|
255
|
+
className={`nested-dropdown-input-container${loading || !options.length ? ' disabled' : ''}`}
|
|
256
|
+
aria-label='searchInput'
|
|
257
|
+
aria-disabled={loading}
|
|
258
|
+
role='textbox'
|
|
259
|
+
>
|
|
237
260
|
<input
|
|
261
|
+
id={`nested-dropdown-${filterIndex}`}
|
|
238
262
|
className='search-input'
|
|
239
263
|
ref={searchInput}
|
|
240
264
|
aria-label='searchInput'
|
|
241
265
|
aria-haspopup='true'
|
|
242
266
|
aria-hidden='false'
|
|
243
267
|
tabIndex={0}
|
|
244
|
-
value={inputValue}
|
|
268
|
+
value={inputValue || initialInputValue}
|
|
245
269
|
onChange={handleSearchTermChange}
|
|
246
|
-
placeholder={'
|
|
270
|
+
placeholder={loading ? 'Loading...' : '- Select -'}
|
|
271
|
+
disabled={loading || !options.length}
|
|
247
272
|
onClick={() => {
|
|
248
273
|
if (inputHasFocus) setIsListOpened(!isListOpened)
|
|
249
274
|
}}
|
|
250
275
|
onFocus={() => setInputHasFocus(true)}
|
|
251
|
-
onBlur={
|
|
276
|
+
onBlur={e => handleOnBlur(e)}
|
|
252
277
|
/>
|
|
253
278
|
<span className='list-arrow' aria-hidden={true}>
|
|
254
|
-
|
|
255
|
-
<Icon display='caretFilledUp' alt='arrow pointing up' />
|
|
256
|
-
) : (
|
|
257
|
-
<Icon display='caretFilledDown' alt='arrow pointing down' />
|
|
258
|
-
)}
|
|
279
|
+
<Icon display='caretDown' />
|
|
259
280
|
</span>
|
|
260
281
|
</div>
|
|
282
|
+
{loading && <Loader spinnerType={'text-secondary'} />}
|
|
261
283
|
<ul
|
|
262
284
|
role='tree'
|
|
263
285
|
key={listLabel}
|
|
@@ -275,11 +297,12 @@ const NestedDropdown: React.FC<NestedDropdownProps> = ({
|
|
|
275
297
|
<Options
|
|
276
298
|
key={groupTextValue + '_' + index}
|
|
277
299
|
subOptions={subgroup}
|
|
300
|
+
filterIndex={filterIndex}
|
|
278
301
|
label={groupTextValue}
|
|
279
302
|
handleSubGroupSelect={subGroupValue => {
|
|
280
303
|
chooseSelectedSubGroup(groupValue, subGroupValue)
|
|
281
304
|
}}
|
|
282
|
-
userSelectedLabel={
|
|
305
|
+
userSelectedLabel={activeGroup + activeSubGroup}
|
|
283
306
|
userSearchTerm={userSearchTerm}
|
|
284
307
|
/>
|
|
285
308
|
)
|