@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.
Files changed (70) hide show
  1. package/components/AdvancedEditor/AdvancedEditor.tsx +17 -13
  2. package/components/Alert/components/Alert.tsx +39 -8
  3. package/components/DataTable/DataTable.tsx +31 -10
  4. package/components/DataTable/DataTableStandAlone.tsx +3 -3
  5. package/components/DataTable/components/ExpandCollapse.tsx +1 -1
  6. package/components/DataTable/components/SortIcon/sort-icon.css +15 -0
  7. package/components/DataTable/data-table.css +4 -22
  8. package/components/DataTable/helpers/boxplotCellMatrix.tsx +19 -14
  9. package/components/DataTable/helpers/getChartCellValue.ts +25 -7
  10. package/components/EditorPanel/ColumnsEditor.tsx +81 -36
  11. package/components/EditorPanel/DataTableEditor.tsx +62 -56
  12. package/components/EditorPanel/FieldSetWrapper.tsx +2 -2
  13. package/components/EditorPanel/Inputs.tsx +26 -16
  14. package/components/EditorPanel/VizFilterEditor/VizFilterEditor.tsx +55 -56
  15. package/components/Filters/Filters.tsx +42 -38
  16. package/components/Filters/helpers/handleSorting.ts +5 -0
  17. package/components/Footnotes/FootnotesStandAlone.tsx +17 -4
  18. package/components/Layout/components/Sidebar/components/sidebar.styles.scss +0 -4
  19. package/components/Layout/components/Visualization/visualizations.scss +1 -1
  20. package/components/Legend/Legend.Gradient.tsx +50 -35
  21. package/components/Loader/Loader.tsx +10 -5
  22. package/components/MultiSelect/MultiSelect.tsx +56 -33
  23. package/components/MultiSelect/multiselect.styles.css +20 -7
  24. package/components/NestedDropdown/NestedDropdown.tsx +55 -32
  25. package/components/NestedDropdown/nesteddropdown.styles.css +26 -13
  26. package/components/Table/Table.tsx +102 -34
  27. package/components/Table/components/Row.tsx +1 -1
  28. package/components/_stories/DataTable.stories.tsx +14 -0
  29. package/components/_stories/Filters.stories.tsx +57 -0
  30. package/components/_stories/_mocks/DataTable/no-data.json +108 -0
  31. package/components/inputs/{InputToggle.jsx → InputToggle.tsx} +35 -29
  32. package/components/ui/Icon.tsx +19 -6
  33. package/dist/cove-main.css +26 -57
  34. package/dist/cove-main.css.map +1 -1
  35. package/helpers/DataTransform.ts +2 -1
  36. package/helpers/addValuesToFilters.ts +22 -8
  37. package/helpers/cove/{number.js → number.ts} +25 -11
  38. package/helpers/coveUpdateWorker.ts +1 -1
  39. package/helpers/fetchRemoteData.js +32 -37
  40. package/helpers/filterVizData.ts +2 -2
  41. package/helpers/formatConfigBeforeSave.ts +16 -0
  42. package/helpers/gatherQueryParams.ts +2 -3
  43. package/helpers/queryStringUtils.ts +16 -1
  44. package/helpers/tests/addValuesToFilters.test.ts +6 -1
  45. package/helpers/useDataVizClasses.ts +44 -21
  46. package/helpers/ver/4.24.10.ts +12 -0
  47. package/helpers/ver/versionNeedsUpdate.ts +2 -0
  48. package/helpers/viewports.ts +8 -7
  49. package/package.json +2 -2
  50. package/styles/_button-section.scss +1 -1
  51. package/styles/_global-variables.scss +9 -4
  52. package/styles/_global.scss +21 -22
  53. package/styles/_reset.scss +0 -12
  54. package/styles/filters.scss +0 -22
  55. package/styles/v2/base/_reset.scss +0 -7
  56. package/styles/v2/components/editor.scss +0 -4
  57. package/styles/v2/components/icon.scss +1 -1
  58. package/types/Axis.ts +2 -0
  59. package/types/BoxPlot.ts +5 -3
  60. package/types/Color.ts +1 -1
  61. package/types/Legend.ts +1 -2
  62. package/types/MarkupInclude.ts +1 -0
  63. package/types/Runtime.ts +3 -1
  64. package/types/Series.ts +8 -1
  65. package/types/Table.ts +1 -1
  66. package/types/Version.ts +1 -0
  67. package/types/Visualization.ts +7 -8
  68. package/types/VizFilter.ts +2 -1
  69. package/components/ui/Select.jsx +0 -30
  70. 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> = ({ visualizationKey, config, viewport, isEditor, updateConfig }) => {
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 component={FootnotesStandAlone} visualizationKey={visualizationKey} visualizationConfig={config} updateConfig={updateConfig} type={'Footnotes'} viewport={viewport}>
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
- if (config.dataKey && config.dynamicFootnotes) {
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] }))
@@ -542,10 +542,6 @@
542
542
  font-weight: normal;
543
543
  }
544
544
 
545
- .btn {
546
- margin-top: 1em;
547
- }
548
-
549
545
  .sort-list {
550
546
  list-style: none;
551
547
 
@@ -1,6 +1,6 @@
1
1
  .cdc-open-viz-module {
2
2
  .cdc-chart-inner-container .cove-component__content {
3
- padding: 25px 15px !important;
3
+ padding: 25px 15px 25px 0 !important;
4
4
  }
5
5
  &.isEditor {
6
6
  overflow: auto;
@@ -1,11 +1,14 @@
1
1
  import { Group } from '@visx/group'
2
2
  import { Text } from '@visx/text'
3
- import { type ViewportSize, type MapConfig } from '@cdc/map/src/types/MapConfig'
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
- currentViewport: ViewportSize
19
+ parentPaddingToSubtract?: number
17
20
  }
18
21
 
19
- const LegendGradient = ({ labels, colors, config, dimensions, currentViewport }: GradientProps): JSX.Element => {
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 legendWidth = getGradientLegendWidth(width, currentViewport)
23
- const uniqueID = `${config.uid}-${Date.now()}`
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 && labels.length > 0 ? labels.reduce((a, b) => (a.length > b.length ? a : b)) : ''
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(config.legend.tickRotation) || 0
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
- // configre gradient colors
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={margin}>
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={-config.legend.tickRotation}
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 ((config.type === 'map' && config.legend.position === 'side') || !config.legend.position) {
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 (config.legend.style === 'gradient') {
93
+ if (style === 'gradient') {
81
94
  return (
82
- <svg style={{ overflow: 'visible', width: '100%', marginTop: 10 }} height={newHeight}>
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
- {config.legend.subStyle === 'smooth' && (
98
- <rect x={1} y={1} width={legendWidth} height={boxHeight} fill={`url(#gradient-smooth-${uniqueID})`} />
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
- {config.legend.subStyle === 'linear blocks' &&
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={0}
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='spinner-border text-primary' role='status'>
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> = ({ section = null, subsection = null, fieldName, label, options, updateField, selected = [], limit, tooltip }) => {
26
- const preselectedItems = options.filter(opt => selected.includes(opt.value)).slice(0, limit)
27
- const [selectedItems, setSelectedItems] = useState<Option[]>(preselectedItems)
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 && selectedItems.length >= limit) return
56
- const newItems = [...selectedItems, option]
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 = selectedItems.filter(item => item.value !== option.value)
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 className='selected'>
81
- {selectedItems.map(item => (
82
- <div key={item.value} aria-labelledby={label ? multiID : undefined}>
83
- {item.label}
84
- <button
85
- aria-label='Remove'
86
- onClick={e => {
87
- e.preventDefault()
88
- handleItemRemove(item)
89
- }}
90
- onKeyUp={e => {
91
- handleItemRemove(item, e)
92
- }}
93
- >
94
- x
95
- </button>
96
- </div>
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={expanded ? 'caretDown' : 'caretUp'} style={{ cursor: 'pointer' }} />
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 => !selectedItems.find(item => item.value === option.value))
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: 5px;
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: 0 5px;
29
- border-radius: 5px;
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
- margin-top: 5px;
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 = <>&#10004;</>
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 === 'selectable-item' ? true : !isTierOneExpanded
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 === 'selectable-item') currentItem.parentNode.parentNode.focus()
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 === 'selectable-item'
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='nested-dropdown-group'
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='selectable-item'
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
- isEditor?: boolean
108
- isUrlFilter?: boolean
109
+ filterIndex: number
109
110
  listLabel: string
110
111
  handleSelectedItems: ([group, subgroup]: [string, string]) => void
111
112
  options: NestedOptions
112
- subGroupingActive?: string
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 groupFilterActive = activeGroup
123
- const subGroupFilterActive = activeSubGroup || ''
125
+ const dropdownId = useId()
124
126
 
125
127
  const [userSearchTerm, setUserSearchTerm] = useState('')
126
- const [inputValue, setInputValue] = useState(
127
- subGroupFilterActive !== '' ? `${groupFilterActive} - ${subGroupFilterActive}` : ''
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 === 'selectable-item') {
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 === 'selectable-item') {
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: OptionsMemo = useMemo(() => {
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='nested-dropdown-container'
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 className='nested-dropdown-input-container' aria-label='searchInput' role='textbox'>
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={'Select an Option'}
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={() => setInputHasFocus(false)}
276
+ onBlur={e => handleOnBlur(e)}
252
277
  />
253
278
  <span className='list-arrow' aria-hidden={true}>
254
- {isListOpened ? (
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={groupFilterActive + subGroupFilterActive}
305
+ userSelectedLabel={activeGroup + activeSubGroup}
283
306
  userSearchTerm={userSearchTerm}
284
307
  />
285
308
  )