@cdc/core 4.25.11 → 4.26.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/_stories/Gallery.Charts.stories.tsx +307 -0
- package/_stories/Gallery.DataBite.stories.tsx +72 -0
- package/_stories/Gallery.Maps.stories.tsx +230 -0
- package/_stories/Gallery.WaffleChart.stories.tsx +187 -0
- package/_stories/PageART.stories.tsx +192 -0
- package/_stories/PageBRFSS.stories.tsx +289 -0
- package/_stories/PageCancerRegistries.stories.tsx +199 -0
- package/_stories/PageEasternEquineEncephalitis.stories.tsx +202 -0
- package/_stories/PageExcessiveAlcoholUse.stories.tsx +196 -0
- package/_stories/PageMaternalMortality.stories.tsx +192 -0
- package/_stories/PageOralHealth.stories.tsx +196 -0
- package/_stories/PageRespiratory.stories.tsx +332 -0
- package/_stories/PageSmokingTobacco.stories.tsx +195 -0
- package/_stories/PageStateDiabetesProfiles.stories.tsx +196 -0
- package/_stories/PageWastewater.stories.tsx +463 -0
- package/assets/icon-magnifying-glass.svg +5 -0
- package/assets/icon-warming-stripes.svg +13 -0
- package/components/AdvancedEditor/AdvancedEditor.tsx +4 -0
- package/components/AdvancedEditor/EmbedEditor.tsx +281 -0
- package/components/ComboBox/ComboBox.tsx +345 -0
- package/components/ComboBox/combobox.styles.css +185 -0
- package/components/ComboBox/index.ts +1 -0
- package/components/DataTable/DataTable.tsx +132 -58
- package/components/DataTable/data-table.css +216 -215
- package/components/DataTable/helpers/mapCellMatrix.tsx +14 -6
- package/components/EditorPanel/ColumnsEditor.tsx +37 -19
- package/components/EditorPanel/DataTableEditor.tsx +51 -25
- package/components/EditorPanel/EditorPanel.styles.css +16 -0
- package/components/EditorPanel/EditorPanel.tsx +144 -0
- package/components/EditorPanel/EditorPanelDispatch.tsx +75 -0
- package/components/EditorPanel/FieldSetWrapper.tsx +66 -23
- package/components/EditorPanel/Inputs.tsx +33 -7
- package/components/EditorPanel/VizFilterEditor/VizFilterEditor.tsx +236 -175
- package/components/EditorPanel/sections/VisualSection.tsx +169 -0
- package/components/Filters/Filters.tsx +31 -5
- package/components/Filters/helpers/getNestedOptions.ts +2 -1
- package/components/Filters/helpers/handleSorting.ts +1 -1
- package/components/Layout/components/Sidebar/components/sidebar.styles.scss +82 -0
- package/components/Layout/components/Visualization/index.tsx +16 -1
- package/components/Layout/components/Visualization/visualizations.scss +7 -0
- package/components/Legend/Legend.Gradient.tsx +1 -1
- package/components/MediaControls.tsx +53 -27
- package/components/ui/Icon.tsx +3 -1
- package/components/ui/Title/index.tsx +30 -2
- package/components/ui/Title/title.styles.css +42 -0
- package/dist/cove-main.css +26 -3
- package/dist/cove-main.css.map +1 -1
- package/generateViteConfig.js +8 -1
- package/helpers/addValuesToFilters.ts +6 -1
- package/helpers/coveUpdateWorker.ts +19 -12
- package/helpers/embedCodeGenerator.ts +109 -0
- package/helpers/getUniqueValues.ts +19 -0
- package/helpers/hashObj.ts +25 -0
- package/helpers/isRightAlignedTableValue.js +5 -0
- package/helpers/metrics/helpers.ts +1 -0
- package/helpers/pivotData.ts +2 -2
- package/helpers/prepareScreenshot.ts +268 -0
- package/helpers/queryStringUtils.ts +29 -0
- package/helpers/tests/prepareScreenshot.test.ts +414 -0
- package/helpers/tests/queryStringUtils.test.ts +381 -0
- package/helpers/tests/testStandaloneBuild.ts +23 -5
- package/helpers/useDataVizClasses.ts +0 -1
- package/helpers/ver/4.26.1.ts +80 -0
- package/hooks/useDataColumns.ts +63 -0
- package/hooks/useFilterManagement.ts +94 -0
- package/hooks/useLegendSeparators.ts +26 -0
- package/hooks/useListManagement.ts +192 -0
- package/package.json +4 -3
- package/styles/_button-section.scss +0 -3
- package/types/Axis.ts +1 -0
- package/types/ForecastingSeriesKey.ts +1 -0
- package/types/MarkupInclude.ts +1 -0
- package/types/Series.ts +3 -0
- package/types/Table.ts +1 -0
- package/types/Visualization.ts +1 -0
- package/types/VizFilter.ts +1 -0
- package/LICENSE +0 -201
|
@@ -6,6 +6,7 @@ import { FilterFunction, JsonEditor, UpdateFunction } from 'json-edit-react'
|
|
|
6
6
|
import './advanced-editor-styles.css'
|
|
7
7
|
import _ from 'lodash'
|
|
8
8
|
import Tooltip from '../ui/Tooltip'
|
|
9
|
+
import EmbedEditor from './EmbedEditor'
|
|
9
10
|
|
|
10
11
|
export const AdvancedEditor = ({
|
|
11
12
|
loadConfig,
|
|
@@ -115,6 +116,9 @@ export const AdvancedEditor = ({
|
|
|
115
116
|
</React.Fragment>
|
|
116
117
|
)}
|
|
117
118
|
</div>
|
|
119
|
+
|
|
120
|
+
{/* Share with Partners Section */}
|
|
121
|
+
<EmbedEditor config={config} />
|
|
118
122
|
</>
|
|
119
123
|
)
|
|
120
124
|
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import React, { useState, useEffect, useMemo } from 'react'
|
|
2
|
+
import { generateEmbedCode } from '../../helpers/embedCodeGenerator'
|
|
3
|
+
|
|
4
|
+
type EmbedEditorProps = {
|
|
5
|
+
config?: any // Current visualization config
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* EmbedEditor - Provides "Share with Partners" functionality
|
|
10
|
+
* Generates embed codes for iframe embedding of visualizations
|
|
11
|
+
*/
|
|
12
|
+
export const EmbedEditor: React.FC<EmbedEditorProps> = ({ config }) => {
|
|
13
|
+
const [configUrl, setConfigUrl] = useState<string | null>(null)
|
|
14
|
+
const [showEmbedModal, setShowEmbedModal] = useState(false)
|
|
15
|
+
const [embedCode, setEmbedCode] = useState('')
|
|
16
|
+
const [embedCodeCopied, setEmbedCodeCopied] = useState(false)
|
|
17
|
+
const [isExpanded, setIsExpanded] = useState(false)
|
|
18
|
+
|
|
19
|
+
// Check if all filters have setByQueryParameter
|
|
20
|
+
const filtersAreValid = useMemo(() => {
|
|
21
|
+
if (!config) return true
|
|
22
|
+
|
|
23
|
+
// Check regular filters
|
|
24
|
+
const filters = config.filters || []
|
|
25
|
+
// Check dashboard shared filters
|
|
26
|
+
const sharedFilters = config.dashboard?.sharedFilters || []
|
|
27
|
+
|
|
28
|
+
const allFilters = [...filters, ...sharedFilters]
|
|
29
|
+
|
|
30
|
+
// If no filters, valid
|
|
31
|
+
if (allFilters.length === 0) return true
|
|
32
|
+
|
|
33
|
+
// All filters must have setByQueryParameter
|
|
34
|
+
return allFilters.every((filter: any) => !!filter.setByQueryParameter)
|
|
35
|
+
}, [config])
|
|
36
|
+
|
|
37
|
+
// Detect configUrl from WCMS permalink or use dev fallback
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
// Try to get config URL from WCMS permalink element
|
|
40
|
+
const permalinkElement = document.querySelector('#sample-permalink') as HTMLAnchorElement
|
|
41
|
+
|
|
42
|
+
if (permalinkElement?.href) {
|
|
43
|
+
try {
|
|
44
|
+
// Parse the URL and extract just the pathname (strip host)
|
|
45
|
+
const url = new URL(permalinkElement.href)
|
|
46
|
+
const pathname = url.pathname
|
|
47
|
+
setConfigUrl(pathname)
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.warn('[EmbedEditor] Failed to parse permalink URL:', err)
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
// Check if we're in development mode
|
|
53
|
+
const isDevelopment =
|
|
54
|
+
process.env.NODE_ENV === 'development' ||
|
|
55
|
+
window.location.hostname === 'localhost' ||
|
|
56
|
+
window.location.hostname === '127.0.0.1'
|
|
57
|
+
|
|
58
|
+
if (isDevelopment) {
|
|
59
|
+
// Use fallback only in development
|
|
60
|
+
const fallbackUrl = '/examples/line-chart-states.json'
|
|
61
|
+
setConfigUrl(fallbackUrl)
|
|
62
|
+
} else {
|
|
63
|
+
// In production without permalink, don't show embed section
|
|
64
|
+
console.warn('[EmbedEditor] No permalink found and not in development mode')
|
|
65
|
+
setConfigUrl(null)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}, [])
|
|
69
|
+
|
|
70
|
+
// Handle showing embed code modal
|
|
71
|
+
const handleShowEmbedCode = () => {
|
|
72
|
+
if (!configUrl) {
|
|
73
|
+
alert('This visualization must be published before generating embed code.')
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const code = generateEmbedCode({ configUrl })
|
|
78
|
+
setEmbedCode(code)
|
|
79
|
+
setShowEmbedModal(true)
|
|
80
|
+
setEmbedCodeCopied(false)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Handle copying embed code from modal
|
|
84
|
+
const handleCopyFromModal = async () => {
|
|
85
|
+
try {
|
|
86
|
+
await navigator.clipboard.writeText(embedCode)
|
|
87
|
+
setEmbedCodeCopied(true)
|
|
88
|
+
setTimeout(() => setEmbedCodeCopied(false), 3000)
|
|
89
|
+
} catch (err) {
|
|
90
|
+
console.error('Failed to copy embed code:', err)
|
|
91
|
+
alert('Failed to copy to clipboard. Please copy manually.')
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Handle closing modal
|
|
96
|
+
const handleCloseModal = () => {
|
|
97
|
+
setShowEmbedModal(false)
|
|
98
|
+
setEmbedCodeCopied(false)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Hide embed section until released
|
|
102
|
+
return null
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<>
|
|
106
|
+
{/* Collapsible Share with Partners Section */}
|
|
107
|
+
<div className='share-partners' style={{ padding: '0 1em 1em', textAlign: 'left' }}>
|
|
108
|
+
<span
|
|
109
|
+
className='advanced-toggle-link'
|
|
110
|
+
onClick={() => setIsExpanded(!isExpanded)}
|
|
111
|
+
style={{ paddingTop: '1em', display: 'block', cursor: 'pointer', textDecoration: 'underline' }}
|
|
112
|
+
>
|
|
113
|
+
<span
|
|
114
|
+
style={{ textDecoration: 'none', display: 'inline-block', fontFamily: 'monospace', paddingRight: '5px' }}
|
|
115
|
+
>
|
|
116
|
+
{isExpanded ? `— ` : `+ `}
|
|
117
|
+
</span>
|
|
118
|
+
Share with Partners
|
|
119
|
+
</span>
|
|
120
|
+
|
|
121
|
+
{isExpanded && (
|
|
122
|
+
<div style={{ paddingTop: '1em' }}>
|
|
123
|
+
{!configUrl ? (
|
|
124
|
+
<div
|
|
125
|
+
style={{
|
|
126
|
+
padding: '0.75em',
|
|
127
|
+
background: '#fff3cd',
|
|
128
|
+
border: '1px solid #ffc107',
|
|
129
|
+
borderRadius: '4px',
|
|
130
|
+
marginBottom: '0.5em'
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
<p style={{ fontSize: '0.85em', margin: 0, color: '#856404' }}>
|
|
134
|
+
⚠️ An embed code cannot be generated until this visualization has been saved.
|
|
135
|
+
</p>
|
|
136
|
+
</div>
|
|
137
|
+
) : !filtersAreValid ? (
|
|
138
|
+
<div
|
|
139
|
+
style={{
|
|
140
|
+
padding: '0.75em',
|
|
141
|
+
background: '#fff3cd',
|
|
142
|
+
border: '1px solid #ffc107',
|
|
143
|
+
borderRadius: '4px',
|
|
144
|
+
marginBottom: '0.5em'
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
<p style={{ fontSize: '0.85em', margin: '0 0 0.5em 0', fontWeight: 'bold', color: '#856404' }}>
|
|
148
|
+
⚠️ Embed Code Not Available
|
|
149
|
+
</p>
|
|
150
|
+
<p style={{ fontSize: '0.85em', margin: 0, color: '#856404' }}>
|
|
151
|
+
To enable embedding, all filters must have the "Query String Parameter" field set. Some filters are
|
|
152
|
+
missing this field. After setting the field, make sure to save your visualization.
|
|
153
|
+
</p>
|
|
154
|
+
</div>
|
|
155
|
+
) : (
|
|
156
|
+
<>
|
|
157
|
+
<p style={{ fontSize: '0.85em', marginBottom: '1em', color: '#666' }}>
|
|
158
|
+
Generate embed codes for partners to add this visualization to their websites. Your visualization will
|
|
159
|
+
need to be published to Link (www.cdc.gov) before it can be embedded by a partner.
|
|
160
|
+
</p>
|
|
161
|
+
|
|
162
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5em' }}>
|
|
163
|
+
<button
|
|
164
|
+
className='btn btn-primary'
|
|
165
|
+
onClick={handleShowEmbedCode}
|
|
166
|
+
style={{ width: '100%', textAlign: 'left' }}
|
|
167
|
+
>
|
|
168
|
+
Get Embed Code
|
|
169
|
+
</button>
|
|
170
|
+
</div>
|
|
171
|
+
</>
|
|
172
|
+
)}
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
{/* Embed Code Modal */}
|
|
178
|
+
{showEmbedModal && (
|
|
179
|
+
<div
|
|
180
|
+
className='modal-overlay'
|
|
181
|
+
style={{
|
|
182
|
+
position: 'fixed',
|
|
183
|
+
top: 0,
|
|
184
|
+
left: 0,
|
|
185
|
+
right: 0,
|
|
186
|
+
bottom: 0,
|
|
187
|
+
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
188
|
+
display: 'flex',
|
|
189
|
+
alignItems: 'center',
|
|
190
|
+
justifyContent: 'center',
|
|
191
|
+
zIndex: 9999
|
|
192
|
+
}}
|
|
193
|
+
onClick={handleCloseModal}
|
|
194
|
+
>
|
|
195
|
+
<div
|
|
196
|
+
className='modal-content'
|
|
197
|
+
style={{
|
|
198
|
+
backgroundColor: 'white',
|
|
199
|
+
borderRadius: '8px',
|
|
200
|
+
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
|
201
|
+
maxWidth: '600px',
|
|
202
|
+
width: '90%',
|
|
203
|
+
margin: '20px'
|
|
204
|
+
}}
|
|
205
|
+
onClick={e => e.stopPropagation()}
|
|
206
|
+
>
|
|
207
|
+
<div
|
|
208
|
+
className='modal-header'
|
|
209
|
+
style={{
|
|
210
|
+
padding: '15px 20px',
|
|
211
|
+
borderBottom: '1px solid #e0e0e0',
|
|
212
|
+
backgroundColor: '#005eaa',
|
|
213
|
+
display: 'flex',
|
|
214
|
+
justifyContent: 'space-between',
|
|
215
|
+
alignItems: 'center',
|
|
216
|
+
borderRadius: '8px 8px 0 0'
|
|
217
|
+
}}
|
|
218
|
+
>
|
|
219
|
+
<h3 style={{ color: 'white', margin: 0 }}>Embed Code</h3>
|
|
220
|
+
<button
|
|
221
|
+
onClick={handleCloseModal}
|
|
222
|
+
style={{
|
|
223
|
+
background: 'transparent',
|
|
224
|
+
border: 'none',
|
|
225
|
+
color: 'white',
|
|
226
|
+
fontSize: '1.5em',
|
|
227
|
+
cursor: 'pointer',
|
|
228
|
+
padding: '0 5px',
|
|
229
|
+
lineHeight: 1
|
|
230
|
+
}}
|
|
231
|
+
aria-label='Close'
|
|
232
|
+
>
|
|
233
|
+
×
|
|
234
|
+
</button>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
<div className='modal-body' style={{ padding: '20px' }}>
|
|
238
|
+
<p style={{ marginBottom: '10px', color: '#666' }}>Copy this code and paste it into your website:</p>
|
|
239
|
+
<textarea
|
|
240
|
+
readOnly
|
|
241
|
+
value={embedCode}
|
|
242
|
+
style={{
|
|
243
|
+
width: '100%',
|
|
244
|
+
height: '180px',
|
|
245
|
+
fontFamily: 'monospace',
|
|
246
|
+
fontSize: '0.85em',
|
|
247
|
+
padding: '10px',
|
|
248
|
+
border: '1px solid #ddd',
|
|
249
|
+
borderRadius: '4px',
|
|
250
|
+
resize: 'vertical',
|
|
251
|
+
boxSizing: 'border-box'
|
|
252
|
+
}}
|
|
253
|
+
onFocus={e => e.target.select()}
|
|
254
|
+
/>
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
<div
|
|
258
|
+
className='modal-footer'
|
|
259
|
+
style={{
|
|
260
|
+
padding: '15px 20px',
|
|
261
|
+
borderTop: '1px solid #e0e0e0',
|
|
262
|
+
display: 'flex',
|
|
263
|
+
justifyContent: 'flex-end',
|
|
264
|
+
gap: '10px'
|
|
265
|
+
}}
|
|
266
|
+
>
|
|
267
|
+
<button className='btn btn-secondary' onClick={handleCloseModal}>
|
|
268
|
+
Close
|
|
269
|
+
</button>
|
|
270
|
+
<button className='btn btn-primary' onClick={handleCopyFromModal} style={{ minWidth: '120px' }}>
|
|
271
|
+
{embedCodeCopied ? '✓ Copied!' : 'Copy to Clipboard'}
|
|
272
|
+
</button>
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
)}
|
|
277
|
+
</>
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export default EmbedEditor
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState, useId } from 'react'
|
|
2
|
+
import './combobox.styles.css'
|
|
3
|
+
import { UpdateFieldFunc } from '../../types/UpdateFieldFunc'
|
|
4
|
+
import MagnifyingGlassIcon from '../../assets/icon-magnifying-glass.svg'
|
|
5
|
+
|
|
6
|
+
interface Option {
|
|
7
|
+
value: string | number
|
|
8
|
+
label: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ComboBoxProps {
|
|
12
|
+
section?: string
|
|
13
|
+
subsection?: string
|
|
14
|
+
fieldName: string | number
|
|
15
|
+
options: Option[]
|
|
16
|
+
updateField: UpdateFieldFunc<string>
|
|
17
|
+
label?: string
|
|
18
|
+
selected?: string | number
|
|
19
|
+
placeholder?: string
|
|
20
|
+
loading?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ComboBox: React.FC<ComboBoxProps> = ({
|
|
24
|
+
section = null,
|
|
25
|
+
subsection = null,
|
|
26
|
+
fieldName,
|
|
27
|
+
options,
|
|
28
|
+
updateField,
|
|
29
|
+
label,
|
|
30
|
+
selected = '',
|
|
31
|
+
placeholder = '- Select -',
|
|
32
|
+
loading = false
|
|
33
|
+
}) => {
|
|
34
|
+
const [query, setQuery] = useState('')
|
|
35
|
+
const [focused, setFocused] = useState(false)
|
|
36
|
+
const [activeIndex, setActiveIndex] = useState(-1)
|
|
37
|
+
|
|
38
|
+
const isDisabled = loading || !options?.length
|
|
39
|
+
|
|
40
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
41
|
+
const listboxRef = useRef<HTMLUListElement>(null)
|
|
42
|
+
const comboboxId = useId()
|
|
43
|
+
|
|
44
|
+
// Get selected option
|
|
45
|
+
const selectedOption = options.find(opt => opt.value === selected)
|
|
46
|
+
|
|
47
|
+
// Token-based filtering: all tokens must match (AND logic)
|
|
48
|
+
const filteredOptions = query
|
|
49
|
+
? options.filter(opt => {
|
|
50
|
+
const tokens = query
|
|
51
|
+
.toLowerCase()
|
|
52
|
+
.split(/\s+/)
|
|
53
|
+
.filter(t => t.length > 0)
|
|
54
|
+
const label = opt.label.toLowerCase()
|
|
55
|
+
return tokens.every(token => label.includes(token))
|
|
56
|
+
})
|
|
57
|
+
: options
|
|
58
|
+
|
|
59
|
+
// Highlight matched tokens in option labels
|
|
60
|
+
const highlightMatches = (label: string, query: string): React.ReactNode => {
|
|
61
|
+
if (!query) return label
|
|
62
|
+
|
|
63
|
+
const tokens = query
|
|
64
|
+
.toLowerCase()
|
|
65
|
+
.split(/\s+/)
|
|
66
|
+
.filter(t => t.length > 0)
|
|
67
|
+
if (tokens.length === 0) return label
|
|
68
|
+
|
|
69
|
+
// Find all match positions for all tokens
|
|
70
|
+
const matches: { start: number; end: number }[] = []
|
|
71
|
+
tokens.forEach(token => {
|
|
72
|
+
let pos = 0
|
|
73
|
+
const lowerLabel = label.toLowerCase()
|
|
74
|
+
while ((pos = lowerLabel.indexOf(token, pos)) !== -1) {
|
|
75
|
+
matches.push({ start: pos, end: pos + token.length })
|
|
76
|
+
pos += token.length
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// Sort and merge overlapping matches
|
|
81
|
+
matches.sort((a, b) => a.start - b.start)
|
|
82
|
+
const merged: { start: number; end: number }[] = []
|
|
83
|
+
matches.forEach(match => {
|
|
84
|
+
if (merged.length === 0 || match.start > merged[merged.length - 1].end) {
|
|
85
|
+
merged.push(match)
|
|
86
|
+
} else {
|
|
87
|
+
merged[merged.length - 1].end = Math.max(merged[merged.length - 1].end, match.end)
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// Build the highlighted result
|
|
92
|
+
const parts: React.ReactNode[] = []
|
|
93
|
+
let lastIndex = 0
|
|
94
|
+
merged.forEach((match, i) => {
|
|
95
|
+
if (match.start > lastIndex) {
|
|
96
|
+
parts.push(label.substring(lastIndex, match.start))
|
|
97
|
+
}
|
|
98
|
+
parts.push(
|
|
99
|
+
<span className='cove-combobox-option-highlight' key={i}>
|
|
100
|
+
{label.substring(match.start, match.end)}
|
|
101
|
+
</span>
|
|
102
|
+
)
|
|
103
|
+
lastIndex = match.end
|
|
104
|
+
})
|
|
105
|
+
if (lastIndex < label.length) {
|
|
106
|
+
parts.push(label.substring(lastIndex))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return <>{parts}</>
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const noResults = focused && query.length > 0 && !filteredOptions.length
|
|
113
|
+
const isListOpen = focused && !isDisabled
|
|
114
|
+
|
|
115
|
+
const listboxId = `${comboboxId}-listbox`
|
|
116
|
+
const labelId = label ? `${comboboxId}-label` : undefined
|
|
117
|
+
|
|
118
|
+
// Handle option selection
|
|
119
|
+
const handleSelect = (option: Option) => {
|
|
120
|
+
updateField(section, subsection, fieldName, String(option.value))
|
|
121
|
+
setQuery('')
|
|
122
|
+
setFocused(false)
|
|
123
|
+
setActiveIndex(-1)
|
|
124
|
+
inputRef.current?.blur()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Handle input change (typing)
|
|
128
|
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
129
|
+
const value = e.target.value
|
|
130
|
+
setQuery(value)
|
|
131
|
+
setActiveIndex(-1)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Handle keyboard navigation
|
|
135
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
136
|
+
if (isDisabled) return
|
|
137
|
+
|
|
138
|
+
switch (e.key) {
|
|
139
|
+
case 'ArrowDown':
|
|
140
|
+
e.preventDefault()
|
|
141
|
+
if (!focused) {
|
|
142
|
+
setFocused(true)
|
|
143
|
+
setActiveIndex(0)
|
|
144
|
+
} else {
|
|
145
|
+
setActiveIndex(prev => (prev < filteredOptions.length - 1 ? prev + 1 : 0))
|
|
146
|
+
}
|
|
147
|
+
break
|
|
148
|
+
|
|
149
|
+
case 'ArrowUp':
|
|
150
|
+
e.preventDefault()
|
|
151
|
+
if (focused) {
|
|
152
|
+
setActiveIndex(prev => (prev > 0 ? prev - 1 : filteredOptions.length - 1))
|
|
153
|
+
}
|
|
154
|
+
break
|
|
155
|
+
|
|
156
|
+
case 'Home':
|
|
157
|
+
if (focused && filteredOptions.length > 0) {
|
|
158
|
+
e.preventDefault()
|
|
159
|
+
setActiveIndex(0)
|
|
160
|
+
}
|
|
161
|
+
break
|
|
162
|
+
|
|
163
|
+
case 'End':
|
|
164
|
+
if (focused && filteredOptions.length > 0) {
|
|
165
|
+
e.preventDefault()
|
|
166
|
+
setActiveIndex(filteredOptions.length - 1)
|
|
167
|
+
}
|
|
168
|
+
break
|
|
169
|
+
|
|
170
|
+
case 'Enter':
|
|
171
|
+
e.preventDefault()
|
|
172
|
+
if (focused && activeIndex >= 0 && filteredOptions[activeIndex]) {
|
|
173
|
+
handleSelect(filteredOptions[activeIndex])
|
|
174
|
+
} else if (!focused) {
|
|
175
|
+
setFocused(true)
|
|
176
|
+
}
|
|
177
|
+
break
|
|
178
|
+
|
|
179
|
+
case 'Escape':
|
|
180
|
+
e.preventDefault()
|
|
181
|
+
setQuery('')
|
|
182
|
+
setFocused(false)
|
|
183
|
+
setActiveIndex(-1)
|
|
184
|
+
inputRef.current?.blur()
|
|
185
|
+
break
|
|
186
|
+
|
|
187
|
+
case 'Tab':
|
|
188
|
+
setQuery('')
|
|
189
|
+
setFocused(false)
|
|
190
|
+
setActiveIndex(-1)
|
|
191
|
+
break
|
|
192
|
+
|
|
193
|
+
default:
|
|
194
|
+
// Any other key opens the dropdown
|
|
195
|
+
if (!focused && e.key.length === 1) {
|
|
196
|
+
setFocused(true)
|
|
197
|
+
}
|
|
198
|
+
break
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Handle input focus
|
|
203
|
+
const handleFocus = () => {
|
|
204
|
+
if (isDisabled) return
|
|
205
|
+
inputRef.current?.select()
|
|
206
|
+
setFocused(true)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Handle input blur
|
|
210
|
+
const handleBlur = (e: React.FocusEvent) => {
|
|
211
|
+
const relatedTarget = e.relatedTarget as Node
|
|
212
|
+
const clickedInListbox = listboxRef.current && listboxRef.current.contains(relatedTarget)
|
|
213
|
+
|
|
214
|
+
if (!clickedInListbox) {
|
|
215
|
+
setQuery('')
|
|
216
|
+
setFocused(false)
|
|
217
|
+
setActiveIndex(-1)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Handle button toggle
|
|
222
|
+
const handleButtonClick = (e: React.MouseEvent) => {
|
|
223
|
+
if (isDisabled) return
|
|
224
|
+
|
|
225
|
+
e.preventDefault()
|
|
226
|
+
if (focused) {
|
|
227
|
+
inputRef.current?.blur()
|
|
228
|
+
setFocused(false)
|
|
229
|
+
} else {
|
|
230
|
+
inputRef.current?.focus()
|
|
231
|
+
setFocused(true)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Handle click outside
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
238
|
+
const target = event.target as Node
|
|
239
|
+
if (
|
|
240
|
+
inputRef.current &&
|
|
241
|
+
!inputRef.current.contains(target) &&
|
|
242
|
+
listboxRef.current &&
|
|
243
|
+
!listboxRef.current.contains(target)
|
|
244
|
+
) {
|
|
245
|
+
setQuery('')
|
|
246
|
+
setFocused(false)
|
|
247
|
+
setActiveIndex(-1)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
document.addEventListener('mousedown', handleClickOutside)
|
|
252
|
+
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
253
|
+
}, [])
|
|
254
|
+
|
|
255
|
+
const activeDescendantId = activeIndex >= 0 ? `${comboboxId}-option-${activeIndex}` : undefined
|
|
256
|
+
const displayValue = isDisabled ? '' : focused ? query : selectedOption?.label || ''
|
|
257
|
+
const displayPlaceholder = isDisabled ? (loading ? 'Loading...' : '- Select -') : selectedOption?.label || placeholder
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<div className='cove-combobox'>
|
|
261
|
+
{/* SR-only instructions */}
|
|
262
|
+
<span className='sr-only'>Use ↑ or ↓ to navigate options, Enter to select, Escape to close</span>
|
|
263
|
+
|
|
264
|
+
<div className='cove-combobox-wrapper'>
|
|
265
|
+
<input
|
|
266
|
+
ref={inputRef}
|
|
267
|
+
id={`${comboboxId}-input`}
|
|
268
|
+
type='text'
|
|
269
|
+
role='combobox'
|
|
270
|
+
aria-autocomplete='list'
|
|
271
|
+
aria-expanded={isListOpen}
|
|
272
|
+
aria-controls={isListOpen ? listboxId : undefined}
|
|
273
|
+
aria-activedescendant={activeDescendantId}
|
|
274
|
+
aria-labelledby={labelId}
|
|
275
|
+
aria-label={label ? undefined : 'Filter selection'}
|
|
276
|
+
aria-disabled={isDisabled}
|
|
277
|
+
autoComplete='off'
|
|
278
|
+
className='cove-combobox-input'
|
|
279
|
+
value={displayValue}
|
|
280
|
+
onChange={handleInputChange}
|
|
281
|
+
onFocus={handleFocus}
|
|
282
|
+
onBlur={handleBlur}
|
|
283
|
+
onKeyDown={handleKeyDown}
|
|
284
|
+
placeholder={displayPlaceholder}
|
|
285
|
+
disabled={isDisabled}
|
|
286
|
+
/>
|
|
287
|
+
|
|
288
|
+
<button
|
|
289
|
+
type='button'
|
|
290
|
+
tabIndex={-1}
|
|
291
|
+
aria-label={isListOpen ? 'Close dropdown' : 'Open dropdown'}
|
|
292
|
+
aria-controls={listboxId}
|
|
293
|
+
aria-expanded={isListOpen}
|
|
294
|
+
className='cove-combobox-button'
|
|
295
|
+
onMouseDown={handleButtonClick}
|
|
296
|
+
disabled={isDisabled}
|
|
297
|
+
>
|
|
298
|
+
<MagnifyingGlassIcon aria-hidden='true' />
|
|
299
|
+
</button>
|
|
300
|
+
</div>
|
|
301
|
+
|
|
302
|
+
{isListOpen && (
|
|
303
|
+
<ul
|
|
304
|
+
ref={listboxRef}
|
|
305
|
+
id={listboxId}
|
|
306
|
+
role='listbox'
|
|
307
|
+
aria-labelledby={labelId}
|
|
308
|
+
aria-label={label ? undefined : 'Filter options'}
|
|
309
|
+
className='cove-combobox-listbox'
|
|
310
|
+
tabIndex={-1}
|
|
311
|
+
>
|
|
312
|
+
{noResults ? (
|
|
313
|
+
<li className='cove-combobox-option no-results' aria-disabled='true'>
|
|
314
|
+
There are no items matching this search.
|
|
315
|
+
</li>
|
|
316
|
+
) : (
|
|
317
|
+
filteredOptions.map((option, index) => {
|
|
318
|
+
const isSelected = option.value === selected
|
|
319
|
+
const isActive = index === activeIndex
|
|
320
|
+
|
|
321
|
+
return (
|
|
322
|
+
<li
|
|
323
|
+
key={option.value}
|
|
324
|
+
id={`${comboboxId}-option-${index}`}
|
|
325
|
+
role='option'
|
|
326
|
+
aria-selected={isSelected}
|
|
327
|
+
className={`cove-combobox-option${isSelected ? ' selected' : ''}${isActive ? ' active' : ''}`}
|
|
328
|
+
onMouseDown={e => {
|
|
329
|
+
e.preventDefault()
|
|
330
|
+
handleSelect(option)
|
|
331
|
+
}}
|
|
332
|
+
onMouseEnter={() => setActiveIndex(index)}
|
|
333
|
+
>
|
|
334
|
+
{highlightMatches(option.label, query)}
|
|
335
|
+
</li>
|
|
336
|
+
)
|
|
337
|
+
})
|
|
338
|
+
)}
|
|
339
|
+
</ul>
|
|
340
|
+
)}
|
|
341
|
+
</div>
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export default ComboBox
|