88 DataTable ,
99 EmptyState ,
1010} from "@texturehq/edges" ;
11- import { useCallback , useMemo } from "react" ;
11+ import { useCallback , useMemo , useRef , useState } from "react" ;
1212import { useExplorer } from "../ExplorerContext" ;
1313import { getAllUtilities , searchEntities , sortByName } from "@/lib/data" ;
1414import {
@@ -41,8 +41,198 @@ const segmentFilterOptions = [
4141 } ) ) ,
4242] ;
4343
44+ // All US state/territory codes present in the data
45+ const ALL_STATE_CODES = [
46+ "AK" , "AL" , "AR" , "AZ" , "CA" , "CO" , "CT" , "DC" , "DE" , "FL" , "GA" , "HI" ,
47+ "IA" , "ID" , "IL" , "IN" , "KS" , "KY" , "LA" , "MA" , "MD" , "ME" , "MI" , "MN" ,
48+ "MO" , "MS" , "MT" , "NC" , "ND" , "NE" , "NH" , "NJ" , "NM" , "NV" , "NY" , "OH" ,
49+ "OK" , "OR" , "PA" , "RI" , "SC" , "SD" , "TN" , "TX" , "UT" , "VA" , "VT" , "WA" ,
50+ "WI" , "WV" , "WY" ,
51+ ] ;
52+
53+ /** Returns all individual state codes from a comma-separated jurisdiction string */
54+ function parseJurisdictionStates ( jurisdiction : string | null ) : string [ ] {
55+ if ( ! jurisdiction ) return [ ] ;
56+ return jurisdiction . split ( "," ) . map ( ( s ) => s . trim ( ) ) . filter ( Boolean ) ;
57+ }
58+
59+ /** True if the utility matches any of the selected jurisdictions */
60+ function matchesJurisdictions ( utility : Utility , selected : string [ ] ) : boolean {
61+ if ( selected . length === 0 ) return true ;
62+ const states = parseJurisdictionStates ( utility . jurisdiction ) ;
63+ return selected . some ( ( j ) => states . includes ( j ) ) ;
64+ }
65+
66+ // ---------------------------------------------------------------------------
67+ // Jurisdiction multi-select dropdown
68+ // ---------------------------------------------------------------------------
69+
70+ interface JurisdictionFilterProps {
71+ selected : string [ ] ;
72+ onChange : ( jurisdictions : string [ ] ) => void ;
73+ }
74+
75+ function JurisdictionFilter ( { selected, onChange } : JurisdictionFilterProps ) {
76+ const [ open , setOpen ] = useState ( false ) ;
77+ const [ search , setSearch ] = useState ( "" ) ;
78+ const ref = useRef < HTMLDivElement > ( null ) ;
79+
80+ // Close on outside click
81+ const handleBlur = useCallback ( ( e : React . FocusEvent < HTMLDivElement > ) => {
82+ if ( ! ref . current ?. contains ( e . relatedTarget as Node ) ) {
83+ setOpen ( false ) ;
84+ }
85+ } , [ ] ) ;
86+
87+ const filteredCodes = useMemo (
88+ ( ) =>
89+ search
90+ ? ALL_STATE_CODES . filter ( ( code ) =>
91+ code . toLowerCase ( ) . includes ( search . toLowerCase ( ) )
92+ )
93+ : ALL_STATE_CODES ,
94+ [ search ]
95+ ) ;
96+
97+ const toggle = useCallback (
98+ ( code : string ) => {
99+ if ( selected . includes ( code ) ) {
100+ onChange ( selected . filter ( ( s ) => s !== code ) ) ;
101+ } else {
102+ onChange ( [ ...selected , code ] ) ;
103+ }
104+ } ,
105+ [ selected , onChange ]
106+ ) ;
107+
108+ const clearAll = useCallback ( ( ) => {
109+ onChange ( [ ] ) ;
110+ setSearch ( "" ) ;
111+ } , [ onChange ] ) ;
112+
113+ const label =
114+ selected . length === 0
115+ ? "All Jurisdictions"
116+ : selected . length === 1
117+ ? selected [ 0 ]
118+ : `${ selected . length } States` ;
119+
120+ return (
121+ < div ref = { ref } className = "relative" onBlur = { handleBlur } >
122+ < button
123+ type = "button"
124+ onClick = { ( ) => setOpen ( ( o ) => ! o ) }
125+ className = { `h-10 sm:h-8 inline-flex items-center gap-1.5 rounded-md border px-2 text-base sm:text-sm transition-colors ${
126+ selected . length > 0
127+ ? "border-brand-primary bg-brand-primary/10 text-brand-primary font-medium"
128+ : "border-border-default bg-background-surface text-text-body"
129+ } `}
130+ aria-haspopup = "listbox"
131+ aria-expanded = { open }
132+ >
133+ { label }
134+ { selected . length > 0 && (
135+ < span
136+ role = "button"
137+ tabIndex = { 0 }
138+ aria-label = "Clear jurisdiction filter"
139+ className = "ml-1 text-brand-primary hover:text-brand-primary/70 leading-none"
140+ onClick = { ( e ) => {
141+ e . stopPropagation ( ) ;
142+ clearAll ( ) ;
143+ } }
144+ onKeyDown = { ( e ) => {
145+ if ( e . key === "Enter" || e . key === " " ) {
146+ e . stopPropagation ( ) ;
147+ clearAll ( ) ;
148+ }
149+ } }
150+ >
151+ ×
152+ </ span >
153+ ) }
154+ < svg
155+ className = { `w-3 h-3 ml-0.5 transition-transform ${ open ? "rotate-180" : "" } ` }
156+ fill = "none"
157+ viewBox = "0 0 24 24"
158+ stroke = "currentColor"
159+ strokeWidth = { 2 }
160+ aria-hidden = "true"
161+ >
162+ < path strokeLinecap = "round" strokeLinejoin = "round" d = "M19 9l-7 7-7-7" />
163+ </ svg >
164+ </ button >
165+
166+ { open && (
167+ < div
168+ className = "absolute right-0 top-full mt-1 z-50 w-52 rounded-lg border border-border-default bg-background-surface shadow-lg flex flex-col"
169+ role = "listbox"
170+ aria-multiselectable = "true"
171+ aria-label = "Filter by jurisdiction"
172+ >
173+ { /* Search */ }
174+ < div className = "p-2 border-b border-border-default" >
175+ < input
176+ autoFocus
177+ type = "text"
178+ value = { search }
179+ onChange = { ( e ) => setSearch ( e . target . value ) }
180+ placeholder = "Search states..."
181+ className = "w-full h-7 rounded-md border border-border-default bg-background-page px-2 text-sm text-text-body placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-brand-primary"
182+ />
183+ </ div >
184+
185+ { /* Options */ }
186+ < div className = "overflow-y-auto max-h-56 py-1" >
187+ { filteredCodes . length === 0 ? (
188+ < div className = "px-3 py-2 text-sm text-text-muted" > No states found</ div >
189+ ) : (
190+ filteredCodes . map ( ( code ) => {
191+ const isChecked = selected . includes ( code ) ;
192+ return (
193+ < label
194+ key = { code }
195+ className = "flex items-center gap-2 px-3 py-1.5 text-sm text-text-body cursor-pointer hover:bg-background-hover select-none"
196+ role = "option"
197+ aria-selected = { isChecked }
198+ >
199+ < input
200+ type = "checkbox"
201+ checked = { isChecked }
202+ onChange = { ( ) => toggle ( code ) }
203+ className = "rounded border-border-default text-brand-primary focus:ring-brand-primary"
204+ />
205+ { code }
206+ </ label >
207+ ) ;
208+ } )
209+ ) }
210+ </ div >
211+
212+ { /* Footer */ }
213+ { selected . length > 0 && (
214+ < div className = "px-3 py-2 border-t border-border-default" >
215+ < button
216+ type = "button"
217+ onClick = { clearAll }
218+ className = "text-xs text-text-muted hover:text-text-body transition-colors"
219+ >
220+ Clear all ({ selected . length } )
221+ </ button >
222+ </ div >
223+ ) }
224+ </ div >
225+ ) }
226+ </ div >
227+ ) ;
228+ }
229+
230+ // ---------------------------------------------------------------------------
231+ // Main panel
232+ // ---------------------------------------------------------------------------
233+
44234export function UtilityListPanel ( ) {
45- const { state, setSearch, setSegment, navigateToDetail, navigateToLanding } = useExplorer ( ) ;
235+ const { state, setSearch, setSegment, setJurisdictions , navigateToDetail, navigateToLanding } = useExplorer ( ) ;
46236
47237 const allUtilities = useMemo ( ( ) => getAllUtilities ( ) , [ ] ) ;
48238
@@ -54,9 +244,12 @@ export function UtilityListPanel() {
54244 if ( state . segment !== "all" ) {
55245 result = result . filter ( ( u ) => u . segment === state . segment ) ;
56246 }
247+ if ( state . jurisdictions . length > 0 ) {
248+ result = result . filter ( ( u ) => matchesJurisdictions ( u , state . jurisdictions ) ) ;
249+ }
57250 result = sortByName ( result , "asc" ) ;
58251 return result ;
59- } , [ allUtilities , state . q , state . segment ] ) ;
252+ } , [ allUtilities , state . q , state . segment , state . jurisdictions ] ) ;
60253
61254 const rows : UtilityRow [ ] = useMemo (
62255 ( ) =>
@@ -141,17 +334,23 @@ export function UtilityListPanel() {
141334 onChange : ( ) => { } ,
142335 } }
143336 customControls = {
144- < select
145- value = { state . segment }
146- onChange = { ( e ) => setSegment ( e . target . value ) }
147- className = "h-10 sm:h-8 rounded-md border border-border-default bg-background-surface px-2 text-base sm:text-sm text-text-body"
148- >
149- { segmentFilterOptions . map ( ( opt ) => (
150- < option key = { opt . id } value = { opt . value } >
151- { opt . label }
152- </ option >
153- ) ) }
154- </ select >
337+ < div className = "flex items-center gap-2" >
338+ < select
339+ value = { state . segment }
340+ onChange = { ( e ) => setSegment ( e . target . value ) }
341+ className = "h-10 sm:h-8 rounded-md border border-border-default bg-background-surface px-2 text-base sm:text-sm text-text-body"
342+ >
343+ { segmentFilterOptions . map ( ( opt ) => (
344+ < option key = { opt . id } value = { opt . value } >
345+ { opt . label }
346+ </ option >
347+ ) ) }
348+ </ select >
349+ < JurisdictionFilter
350+ selected = { state . jurisdictions }
351+ onChange = { setJurisdictions }
352+ />
353+ </ div >
155354 }
156355 sticky = { true }
157356 />
@@ -161,7 +360,7 @@ export function UtilityListPanel() {
161360 < EmptyState
162361 icon = "Lightning"
163362 title = "No utilities found"
164- description = { state . q ? "Try adjusting your search criteria." : "No utilities in the dataset ." }
363+ description = { state . q ? "Try adjusting your search criteria." : "No utilities match the selected filters ." }
165364 fullHeight = { true }
166365 />
167366 ) : (
0 commit comments