@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.
Files changed (77) hide show
  1. package/_stories/Gallery.Charts.stories.tsx +307 -0
  2. package/_stories/Gallery.DataBite.stories.tsx +72 -0
  3. package/_stories/Gallery.Maps.stories.tsx +230 -0
  4. package/_stories/Gallery.WaffleChart.stories.tsx +187 -0
  5. package/_stories/PageART.stories.tsx +192 -0
  6. package/_stories/PageBRFSS.stories.tsx +289 -0
  7. package/_stories/PageCancerRegistries.stories.tsx +199 -0
  8. package/_stories/PageEasternEquineEncephalitis.stories.tsx +202 -0
  9. package/_stories/PageExcessiveAlcoholUse.stories.tsx +196 -0
  10. package/_stories/PageMaternalMortality.stories.tsx +192 -0
  11. package/_stories/PageOralHealth.stories.tsx +196 -0
  12. package/_stories/PageRespiratory.stories.tsx +332 -0
  13. package/_stories/PageSmokingTobacco.stories.tsx +195 -0
  14. package/_stories/PageStateDiabetesProfiles.stories.tsx +196 -0
  15. package/_stories/PageWastewater.stories.tsx +463 -0
  16. package/assets/icon-magnifying-glass.svg +5 -0
  17. package/assets/icon-warming-stripes.svg +13 -0
  18. package/components/AdvancedEditor/AdvancedEditor.tsx +4 -0
  19. package/components/AdvancedEditor/EmbedEditor.tsx +281 -0
  20. package/components/ComboBox/ComboBox.tsx +345 -0
  21. package/components/ComboBox/combobox.styles.css +185 -0
  22. package/components/ComboBox/index.ts +1 -0
  23. package/components/DataTable/DataTable.tsx +132 -58
  24. package/components/DataTable/data-table.css +216 -215
  25. package/components/DataTable/helpers/mapCellMatrix.tsx +14 -6
  26. package/components/EditorPanel/ColumnsEditor.tsx +37 -19
  27. package/components/EditorPanel/DataTableEditor.tsx +51 -25
  28. package/components/EditorPanel/EditorPanel.styles.css +16 -0
  29. package/components/EditorPanel/EditorPanel.tsx +144 -0
  30. package/components/EditorPanel/EditorPanelDispatch.tsx +75 -0
  31. package/components/EditorPanel/FieldSetWrapper.tsx +66 -23
  32. package/components/EditorPanel/Inputs.tsx +33 -7
  33. package/components/EditorPanel/VizFilterEditor/VizFilterEditor.tsx +236 -175
  34. package/components/EditorPanel/sections/VisualSection.tsx +169 -0
  35. package/components/Filters/Filters.tsx +31 -5
  36. package/components/Filters/helpers/getNestedOptions.ts +2 -1
  37. package/components/Filters/helpers/handleSorting.ts +1 -1
  38. package/components/Layout/components/Sidebar/components/sidebar.styles.scss +82 -0
  39. package/components/Layout/components/Visualization/index.tsx +16 -1
  40. package/components/Layout/components/Visualization/visualizations.scss +7 -0
  41. package/components/Legend/Legend.Gradient.tsx +1 -1
  42. package/components/MediaControls.tsx +53 -27
  43. package/components/ui/Icon.tsx +3 -1
  44. package/components/ui/Title/index.tsx +30 -2
  45. package/components/ui/Title/title.styles.css +42 -0
  46. package/dist/cove-main.css +26 -3
  47. package/dist/cove-main.css.map +1 -1
  48. package/generateViteConfig.js +8 -1
  49. package/helpers/addValuesToFilters.ts +6 -1
  50. package/helpers/coveUpdateWorker.ts +19 -12
  51. package/helpers/embedCodeGenerator.ts +109 -0
  52. package/helpers/getUniqueValues.ts +19 -0
  53. package/helpers/hashObj.ts +25 -0
  54. package/helpers/isRightAlignedTableValue.js +5 -0
  55. package/helpers/metrics/helpers.ts +1 -0
  56. package/helpers/pivotData.ts +2 -2
  57. package/helpers/prepareScreenshot.ts +268 -0
  58. package/helpers/queryStringUtils.ts +29 -0
  59. package/helpers/tests/prepareScreenshot.test.ts +414 -0
  60. package/helpers/tests/queryStringUtils.test.ts +381 -0
  61. package/helpers/tests/testStandaloneBuild.ts +23 -5
  62. package/helpers/useDataVizClasses.ts +0 -1
  63. package/helpers/ver/4.26.1.ts +80 -0
  64. package/hooks/useDataColumns.ts +63 -0
  65. package/hooks/useFilterManagement.ts +94 -0
  66. package/hooks/useLegendSeparators.ts +26 -0
  67. package/hooks/useListManagement.ts +192 -0
  68. package/package.json +4 -3
  69. package/styles/_button-section.scss +0 -3
  70. package/types/Axis.ts +1 -0
  71. package/types/ForecastingSeriesKey.ts +1 -0
  72. package/types/MarkupInclude.ts +1 -0
  73. package/types/Series.ts +3 -0
  74. package/types/Table.ts +1 -0
  75. package/types/Visualization.ts +1 -0
  76. package/types/VizFilter.ts +1 -0
  77. 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