@cdc/map 4.23.4 → 4.23.6

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.
@@ -1,12 +1,12 @@
1
- import React, { useEffect, useState, useMemo, memo, useCallback } from 'react'
2
- import { useTable, useSortBy, useResizeColumns, useBlockLayout } from 'react-table'
1
+ import React, { useEffect, useState, memo } from 'react'
2
+
3
3
  import Papa from 'papaparse'
4
4
  import ExternalIcon from '../images/external-link.svg' // TODO: Move to Icon component
5
5
  import Icon from '@cdc/core/components/ui/Icon'
6
6
 
7
7
  import ErrorBoundary from '@cdc/core/components/ErrorBoundary'
8
8
  import LegendCircle from '@cdc/core/components/LegendCircle'
9
- import CoveMediaControls from '@cdc/core/components/CoveMediaControls'
9
+ import MediaControls from '@cdc/core/components/MediaControls'
10
10
 
11
11
  import Loading from '@cdc/core/components/Loading'
12
12
 
@@ -15,6 +15,7 @@ const DataTable = props => {
15
15
  const { state, tableTitle, indexTitle, mapTitle, rawData, runtimeData, headerColor, expandDataTable, columns, displayDataAsText, applyLegendToRow, displayGeoName, navigationHandler, viewport, formatLegendLocation, tabbingId, setFilteredCountryCode } = props
16
16
 
17
17
  const [expanded, setExpanded] = useState(expandDataTable)
18
+ const [sortBy, setSortBy] = useState({ column: 'geo', asc: false })
18
19
 
19
20
  const [accessibilityLabel, setAccessibilityLabel] = useState('')
20
21
 
@@ -22,110 +23,124 @@ const DataTable = props => {
22
23
 
23
24
  // Catch all sorting method used on load by default but also on user click
24
25
  // Having a custom method means we can add in any business logic we want going forward
25
- const customSort = useCallback(
26
- (a, b) => {
27
- const digitRegex = /\d+/
26
+ const customSort = (a, b) => {
27
+ const digitRegex = /\d+/
28
28
 
29
- const hasNumber = value => digitRegex.test(value)
29
+ const hasNumber = value => digitRegex.test(value)
30
30
 
31
- // force null and undefined to the bottom
32
- a = a === null || a === undefined ? '' : a
33
- b = b === null || b === undefined ? '' : b
31
+ // force null and undefined to the bottom
32
+ a = a === null || a === undefined ? '' : a
33
+ b = b === null || b === undefined ? '' : b
34
34
 
35
- // convert any strings that are actually numbers to proper data type
36
- const aNum = Number(a)
35
+ // convert any strings that are actually numbers to proper data type
36
+ const aNum = Number(a)
37
37
 
38
- if (!Number.isNaN(aNum)) {
39
- a = aNum
40
- }
38
+ if (!Number.isNaN(aNum)) {
39
+ a = aNum
40
+ }
41
41
 
42
- const bNum = Number(b)
42
+ const bNum = Number(b)
43
43
 
44
- if (!Number.isNaN(bNum)) {
45
- b = bNum
46
- }
44
+ if (!Number.isNaN(bNum)) {
45
+ b = bNum
46
+ }
47
47
 
48
- // remove iso code prefixes
49
- if (typeof a === 'string') {
50
- a = a.replace('us-', '')
51
- a = displayGeoName(a)
52
- }
48
+ // remove iso code prefixes
49
+ if (typeof a === 'string') {
50
+ a = a.replace('us-', '')
51
+ a = displayGeoName(a)
52
+ }
53
53
 
54
- if (typeof b === 'string') {
55
- b = b.replace('us-', '')
56
- b = displayGeoName(b)
57
- }
54
+ if (typeof b === 'string') {
55
+ b = b.replace('us-', '')
56
+ b = displayGeoName(b)
57
+ }
58
58
 
59
- // force any string values to lowercase
60
- a = typeof a === 'string' ? a.toLowerCase() : a
61
- b = typeof b === 'string' ? b.toLowerCase() : b
59
+ // force any string values to lowercase
60
+ a = typeof a === 'string' ? a.toLowerCase() : a
61
+ b = typeof b === 'string' ? b.toLowerCase() : b
62
62
 
63
- // If the string contains a number, remove the text from the value and only sort by the number. Only uses the first number it finds.
64
- if (typeof a === 'string' && hasNumber(a) === true) {
65
- a = a.match(digitRegex)[0]
63
+ // If the string contains a number, remove the text from the value and only sort by the number. Only uses the first number it finds.
64
+ if (typeof a === 'string' && hasNumber(a) === true) {
65
+ a = a.match(digitRegex)[0]
66
66
 
67
- a = Number(a)
68
- }
67
+ a = Number(a)
68
+ }
69
69
 
70
- if (typeof b === 'string' && hasNumber(b) === true) {
71
- b = b.match(digitRegex)[0]
70
+ if (typeof b === 'string' && hasNumber(b) === true) {
71
+ b = b.match(digitRegex)[0]
72
72
 
73
- b = Number(b)
74
- }
73
+ b = Number(b)
74
+ }
75
75
 
76
- // When comparing a number to a string, always send string to bottom
77
- if (typeof a === 'number' && typeof b === 'string') {
78
- return 1
79
- }
76
+ // When comparing a number to a string, always send string to bottom
77
+ if (typeof a === 'number' && typeof b === 'string') {
78
+ return 1
79
+ }
80
80
 
81
- if (typeof b === 'number' && typeof a === 'string') {
82
- return -1
83
- }
81
+ if (typeof b === 'number' && typeof a === 'string') {
82
+ return -1
83
+ }
84
84
 
85
- // Return either 1 or -1 to indicate a sort priority
86
- if (a > b) {
87
- return 1
88
- }
89
- if (a < b) {
90
- return -1
91
- }
92
- // returning 0, undefined or any falsey value will use subsequent sorts or
93
- // the index as a tiebreaker
94
- return 0
95
- },
96
- [displayGeoName]
97
- )
85
+ // Return either 1 or -1 to indicate a sort priority
86
+ if (a > b) {
87
+ return 1
88
+ }
89
+ if (a < b) {
90
+ return -1
91
+ }
92
+ // returning 0, undefined or any falsey value will use subsequent sorts or
93
+ // the index as a tiebreaker
94
+ return 0
95
+ }
98
96
 
99
97
  // Optionally wrap cell with anchor if config defines a navigation url
100
- const getCellAnchor = useCallback(
101
- (markup, row) => {
102
- if (columns.navigate && row[columns.navigate.name]) {
103
- markup = (
104
- <span
105
- onClick={() => navigationHandler(row[columns.navigate.name])}
106
- className='table-link'
107
- title='Click for more information (Opens in a new window)'
108
- role='link'
109
- tabIndex='0'
110
- onKeyDown={e => {
111
- if (e.keyCode === 13) {
112
- navigationHandler(row[columns.navigate.name])
113
- }
114
- }}
115
- >
116
- {markup}
117
- <ExternalIcon className='inline-icon' />
118
- </span>
119
- )
120
- }
98
+ const getCellAnchor = (markup, row) => {
99
+ if (columns.navigate && row[columns.navigate.name]) {
100
+ markup = (
101
+ <span
102
+ onClick={() => navigationHandler(row[columns.navigate.name])}
103
+ className='table-link'
104
+ title='Click for more information (Opens in a new window)'
105
+ role='link'
106
+ tabIndex='0'
107
+ onKeyDown={e => {
108
+ if (e.keyCode === 13) {
109
+ navigationHandler(row[columns.navigate.name])
110
+ }
111
+ }}
112
+ >
113
+ {markup}
114
+ <ExternalIcon className='inline-icon' />
115
+ </span>
116
+ )
117
+ }
121
118
 
122
- return markup
123
- },
124
- [columns.navigate, navigationHandler]
125
- )
119
+ return markup
120
+ }
121
+
122
+ const rand = Math.random().toString(16).substr(2, 8)
123
+ const skipId = `btn__${rand}`
124
+
125
+ const mapLookup = {
126
+ 'us-county': 'United States County Map',
127
+ 'single-state': 'State Map',
128
+ us: 'United States Map',
129
+ world: 'World Map'
130
+ }
126
131
 
127
132
  const DownloadButton = memo(() => {
128
- const csvData = Papa.unparse(rawData)
133
+ let csvData
134
+ if (state.general.type === 'bubble' || !state.table.showFullGeoNameInCSV) {
135
+ // Just Unparse
136
+ csvData = Papa.unparse(rawData)
137
+ } else if (state.general.geoType !== 'us-county' || state.general.type === 'us-geocode') {
138
+ // Unparse + Add column for full Geo name
139
+ csvData = Papa.unparse(rawData.map(row => ({ FullGeoName: displayGeoName(row[state.columns.geo.name]), ...row })))
140
+ } else {
141
+ // Unparse + Add column for full Geo name
142
+ csvData = Papa.unparse(rawData.map(row => ({ FullGeoName: formatLegendLocation(row[state.columns.geo.name]), ...row })))
143
+ }
129
144
 
130
145
  const blob = new Blob([csvData], { type: 'text/csv;charset=utf-8;' })
131
146
 
@@ -142,81 +157,7 @@ const DataTable = props => {
142
157
  Download Data (CSV)
143
158
  </a>
144
159
  )
145
- }, [rawData])
146
-
147
- // Creates columns structure for the table
148
- const tableColumns = useMemo(() => {
149
- const newTableColumns = []
150
-
151
- Object.keys(columns).forEach(column => {
152
- if (columns[column].dataTable === true && columns[column].name) {
153
- const newCol = {
154
- Header: columns[column].label ? columns[column].label : columns[column].name,
155
- id: column,
156
- accessor: row => {
157
- if (runtimeData) {
158
- if (state.legend.specialClasses && state.legend.specialClasses.length && typeof state.legend.specialClasses[0] === 'object') {
159
- for (let i = 0; i < state.legend.specialClasses.length; i++) {
160
- if (String(runtimeData[row][state.legend.specialClasses[i].key]) === state.legend.specialClasses[i].value) {
161
- return state.legend.specialClasses[i].label
162
- }
163
- }
164
- }
165
- return runtimeData[row][columns[column].name] ?? null
166
- }
167
-
168
- return null
169
- },
170
- sortType: (a, b) => customSort(a.values[column], b.values[column])
171
- }
172
-
173
- if (column === 'geo') {
174
- newCol.Header = indexTitle || 'Location'
175
- newCol.Cell = ({ row, value }) => {
176
- const rowObj = runtimeData[row.original]
177
-
178
- const legendColor = applyLegendToRow(rowObj)
179
-
180
- var labelValue
181
- if (state.general.geoType !== 'us-county' || state.general.type === 'us-geocode') {
182
- labelValue = displayGeoName(row.original)
183
- } else {
184
- labelValue = formatLegendLocation(row.original)
185
- }
186
-
187
- labelValue = getCellAnchor(labelValue, rowObj)
188
-
189
- const cellMarkup = (
190
- <>
191
- <LegendCircle fill={legendColor[0]} />
192
- {labelValue}
193
- </>
194
- )
195
-
196
- return cellMarkup
197
- }
198
- } else {
199
- newCol.Cell = ({ value }) => {
200
- const cellMarkup = displayDataAsText(value, column)
201
-
202
- return cellMarkup
203
- }
204
- }
205
-
206
- newTableColumns.push(newCol)
207
- }
208
- })
209
-
210
- return newTableColumns
211
- }, [indexTitle, columns, runtimeData, getCellAnchor, displayDataAsText, applyLegendToRow, customSort, displayGeoName, state.legend.specialClasses]) // eslint-disable-line
212
-
213
- const tableData = useMemo(
214
- () =>
215
- Object.keys(runtimeData)
216
- .filter(key => applyLegendToRow(runtimeData[key]))
217
- .sort((a, b) => customSort(a, b)),
218
- [runtimeData, applyLegendToRow, customSort]
219
- )
160
+ }, [rawData, state.table])
220
161
 
221
162
  // Change accessibility label depending on expanded status
222
163
  useEffect(() => {
@@ -231,36 +172,26 @@ const DataTable = props => {
231
172
  setAccessibilityLabel(collapsedLabel)
232
173
  }
233
174
  // eslint-disable-next-line react-hooks/exhaustive-deps
234
- }, [expanded, applyLegendToRow, customSort])
235
-
236
- const defaultColumn = useMemo(
237
- () => ({
238
- minWidth: 150,
239
- width: 200,
240
- maxWidth: 400
241
- }),
242
- []
243
- )
175
+ }, [expanded])
244
176
 
245
- const mapLookup = {
246
- 'us-county': 'United States County Map',
247
- 'single-state': 'State Map',
248
- us: 'United States Map',
249
- world: 'World Map'
250
- }
251
-
252
- const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = useTable({ columns: tableColumns, data: tableData, defaultColumn }, useSortBy, useBlockLayout, useResizeColumns)
177
+ if (!state.data) return <Loading />
253
178
 
254
- const rand = Math.random().toString(16).substr(2, 8)
255
- const skipId = `btn__${rand}`
179
+ const rows = Object.keys(runtimeData)
180
+ .filter(row => applyLegendToRow(runtimeData[row]))
181
+ .sort((a, b) => {
182
+ const sortVal = customSort(runtimeData[a][state.columns[sortBy.column].name], runtimeData[b][state.columns[sortBy.column].name])
183
+ if (!sortBy.asc) return sortVal
184
+ if (sortVal === 0) return 0
185
+ if (sortVal < 0) return 1
186
+ return -1
187
+ })
256
188
 
257
- if (!state.data) return <Loading />
258
189
  return (
259
190
  <ErrorBoundary component='DataTable'>
260
- <CoveMediaControls.Section classes={['download-links']}>
261
- <CoveMediaControls.Link config={state} />
191
+ <MediaControls.Section classes={['download-links']}>
192
+ <MediaControls.Link config={state} />
262
193
  {state.general.showDownloadButton && <DownloadButton />}
263
- </CoveMediaControls.Section>
194
+ </MediaControls.Section>
264
195
  <section id={tabbingId.replace('#', '')} className={`data-table-container ${viewport}`} aria-label={accessibilityLabel}>
265
196
  <a id='skip-nav' className='cdcdataviz-sr-only-focusable' href={`#${skipId}`}>
266
197
  Skip Navigation or Skip to Content
@@ -281,49 +212,85 @@ const DataTable = props => {
281
212
  {tableTitle}
282
213
  </div>
283
214
  <div className='table-container' style={{ maxHeight: state.dataTable.limitHeight && `${state.dataTable.height}px`, overflowY: 'scroll' }}>
284
- <table height={expanded ? null : 0} {...getTableProps()} aria-live='assertive' className={expanded ? 'data-table' : 'data-table cdcdataviz-sr-only'} hidden={!expanded} aria-rowcount={state?.data.length ? state.data.length : '-1'}>
215
+ <table height={expanded ? null : 0} role='table' aria-live='assertive' className={expanded ? 'data-table' : 'data-table cdcdataviz-sr-only'} hidden={!expanded} aria-rowcount={state?.data.length ? state.data.length : '-1'}>
285
216
  <caption className='cdcdataviz-sr-only'>{state.dataTable.caption ? state.dataTable.caption : `Datatable showing data for the ${mapLookup[state.general.geoType]} figure.`}</caption>
286
217
  <thead style={{ position: 'sticky', top: 0, zIndex: 999 }}>
287
- {headerGroups.map(headerGroup => (
288
- <tr {...headerGroup.getHeaderGroupProps()}>
289
- {headerGroup.headers.map(column => (
290
- <th
291
- tabIndex='0'
292
- title={column.Header}
293
- role='columnheader'
294
- scope='col'
295
- {...column.getHeaderProps(column.getSortByToggleProps())}
296
- className={column.isSorted ? (column.isSortedDesc ? 'sort sort-desc' : 'sort sort-asc') : 'sort'}
297
- onKeyDown={e => {
298
- if (e.keyCode === 13) {
299
- column.toggleSortBy()
300
- }
301
- }}
302
- //aria-sort={column.isSorted ? column.isSortedDesc ? 'descending' : 'ascending' : 'none' }
303
- {...(column.isSorted ? (column.isSortedDesc ? { 'aria-sort': 'descending' } : { 'aria-sort': 'ascending' }) : null)}
304
- >
305
- {column.render('Header')}
306
- <button>
307
- <span className='cdcdataviz-sr-only'>{`Sort by ${column.render('Header').toLowerCase()} in ${column.isSorted ? (column.isSortedDesc ? 'descending' : 'ascending') : 'no'} `} order</span>
308
- </button>
309
- <div {...column.getResizerProps()} className='resizer' />
310
- </th>
311
- ))}
312
- </tr>
313
- ))}
218
+ <tr>
219
+ {Object.keys(columns)
220
+ .filter(column => columns[column].dataTable === true && columns[column].name)
221
+ .map(column => {
222
+ let text
223
+ if (column !== 'geo') {
224
+ text = columns[column].label ? columns[column].label : columns[column].name
225
+ } else {
226
+ text = indexTitle || 'Location'
227
+ }
228
+
229
+ return (
230
+ <th
231
+ key={`col-header-${column}`}
232
+ tabIndex='0'
233
+ title={text}
234
+ role='columnheader'
235
+ scope='col'
236
+ onClick={() => {
237
+ setSortBy({ column, asc: sortBy.column === column ? !sortBy.asc : false })
238
+ }}
239
+ onKeyDown={e => {
240
+ if (e.keyCode === 13) {
241
+ setSortBy({ column, asc: sortBy.column === column ? !sortBy.asc : false })
242
+ }
243
+ }}
244
+ className={sortBy.column === column ? (sortBy.asc ? 'sort sort-asc' : 'sort sort-desc') : 'sort'}
245
+ {...(sortBy.column === column ? (sortBy.asc ? { 'aria-sort': 'ascending' } : { 'aria-sort': 'descending' }) : null)}
246
+ >
247
+ {text}
248
+ <button>
249
+ <span className='cdcdataviz-sr-only'>{`Sort by ${text} in ${sortBy.column === column ? (!sortBy.asc ? 'descending' : 'ascending') : 'descending'} `} order</span>
250
+ </button>
251
+ </th>
252
+ )
253
+ })}
254
+ </tr>
314
255
  </thead>
315
- <tbody {...getTableBodyProps()}>
256
+ <tbody>
316
257
  {rows.map(row => {
317
- prepareRow(row)
318
258
  return (
319
- <tr {...row.getRowProps()} role='row'>
320
- {row.cells.map(cell => {
321
- return (
322
- <td tabIndex='0' {...cell.getCellProps()} role='gridcell' onClick={e => (state.general.type === 'bubble' && state.general.allowMapZoom && state.general.geoType === 'world' ? setFilteredCountryCode(cell.row.original) : true)}>
323
- {cell.render('Cell')}
324
- </td>
325
- )
326
- })}
259
+ <tr role='row'>
260
+ {Object.keys(columns)
261
+ .filter(column => columns[column].dataTable === true && columns[column].name)
262
+ .map(column => {
263
+ let cellValue
264
+
265
+ if (column === 'geo') {
266
+ const rowObj = runtimeData[row]
267
+ const legendColor = applyLegendToRow(rowObj)
268
+
269
+ var labelValue
270
+ if (state.general.geoType !== 'us-county' || state.general.type === 'us-geocode') {
271
+ labelValue = displayGeoName(row)
272
+ } else {
273
+ labelValue = formatLegendLocation(row)
274
+ }
275
+
276
+ labelValue = getCellAnchor(labelValue, rowObj)
277
+
278
+ cellValue = (
279
+ <>
280
+ <LegendCircle fill={legendColor[0]} />
281
+ {labelValue}
282
+ </>
283
+ )
284
+ } else {
285
+ cellValue = displayDataAsText(runtimeData[row][state.columns[column].name], column)
286
+ }
287
+
288
+ return (
289
+ <td tabIndex='0' role='gridcell' onClick={e => (state.general.type === 'bubble' && state.general.allowMapZoom && state.general.geoType === 'world' ? setFilteredCountryCode(row) : true)}>
290
+ {cellValue}
291
+ </td>
292
+ )
293
+ })}
327
294
  </tr>
328
295
  )
329
296
  })}