1
- import { Menu , Popover , Transition } from "@headlessui/react"
2
- import { clsx } from "clsx"
3
- import { ChevronDown , X } from "lucide-react"
1
+ import clsx from "clsx"
2
+ import { useState } from "react"
3
+
4
+ import CloseIcon from "@/app/conf/2025/pixelarticons/close.svg?svgr"
5
+ import CaretDownIcon from "@/app/conf/2025/pixelarticons/caret-down.svg?svgr"
6
+ import { Combobox } from "@headlessui/react"
7
+ import { Tag } from "@/app/conf/_design-system/tag"
8
+ import { eventsColors } from "../../utils"
4
9
5
10
type FiltersProps = {
6
11
categories : { name : string ; options : string [ ] } [ ]
7
12
filterState : Record < string , string [ ] >
8
- onFilterChange : ( category : string , option : string , checked : boolean ) => void
13
+ onFilterChange : ( category : string , newSelectedOptions : string [ ] ) => void
9
14
onReset : ( ) => void
10
15
}
11
16
@@ -16,111 +21,209 @@ export function Filters({
16
21
onReset,
17
22
} : FiltersProps ) {
18
23
return (
19
- < div className = "flex justify-center gap-3 pb-10" >
20
- < Menu as = "div" className = "relative inline-block text-left" >
21
- < Transition
22
- enter = "transition ease-out duration-100"
23
- enterFrom = "transform opacity-0 scale-95"
24
- enterTo = "transform opacity-100 scale-100"
25
- leave = "transition ease-in duration-75"
26
- leaveFrom = "transform opacity-100 scale-100"
27
- leaveTo = "transform opacity-0 scale-95"
28
- >
29
- < Menu . Items className = "absolute left-0 z-10 mt-2 w-40 origin-top-left rounded-md shadow-2xl ring-1 ring-blk/5 focus:outline-none" >
30
- < div className = "py-1" >
31
- { categories . map ( option => (
32
- < Menu . Item key = { option . name } >
33
- < span > { option . name } </ span >
34
- </ Menu . Item >
35
- ) ) }
36
- </ div >
37
- </ Menu . Items >
38
- </ Transition >
39
- </ Menu >
40
- < Popover . Group className = "flex items-baseline space-x-8" >
41
- { categories . map ( ( category , sectionIdx ) => (
42
- < Popover
43
- as = "div"
44
- key = { category . name }
45
- id = { `desktop-menu-${ sectionIdx } ` }
46
- className = "relative inline-block text-left"
47
- >
48
- < Popover . Button className = "group inline-flex cursor-pointer items-center justify-center bg-inherit p-1 px-2 text-neu-700 hover:text-neu-900" >
49
- < span > { category . name } </ span >
50
- { filterState [ category . name ] . length ? (
51
- < span className = "ml-1.5 bg-neu-200 px-1.5 py-0.5 tabular-nums text-neu-700" >
52
- { filterState [ category . name ] . length }
53
- </ span >
54
- ) : null }
55
- < ChevronDown
56
- className = "-mr-1 ml-1 size-5 shrink-0 text-neu-400 group-hover:text-neu-500"
57
- aria-hidden = "true"
58
- />
59
- </ Popover . Button >
60
-
61
- < Popover . Panel className = "absolute right-0 z-10 mt-2 origin-top-right rounded-md bg-neu-0 p-4 shadow-lg focus:outline-none" >
62
- < FilterOptions
63
- category = { category }
64
- filterState = { filterState }
65
- onFilterChange = { onFilterChange }
66
- />
67
- </ Popover . Panel >
68
- </ Popover >
69
- ) ) }
70
- </ Popover . Group >
24
+ < div className = "flex flex-wrap justify-stretch gap-x-2 gap-y-4 pb-10" >
25
+ { categories . map ( category => (
26
+ < FiltersCombobox
27
+ key = { category . name }
28
+ label = { category . name }
29
+ options = { category . options }
30
+ value = { filterState [ category . name ] || [ ] }
31
+ onChange = { newSelectedOptions => {
32
+ onFilterChange ( category . name , newSelectedOptions )
33
+ } }
34
+ placeholder = { `Any ${ category . name . toLowerCase ( ) } ` }
35
+ className = "flex-1"
36
+ />
37
+ ) ) }
71
38
{ Object . values ( filterState ) . flat ( ) . length > 0 && (
72
- < ResetButton onReset = { onReset } />
39
+ < div className = "relative" >
40
+ < ResetButton onReset = { onReset } className = "absolute top-[18px]" />
41
+ </ div >
73
42
) }
74
43
</ div >
75
44
)
76
45
}
77
46
78
- function ResetButton ( { onReset } : { onReset : ( ) => void } ) {
47
+ function ResetButton ( {
48
+ onReset,
49
+ className,
50
+ } : {
51
+ onReset : ( ) => void
52
+ className ?: string
53
+ } ) {
79
54
return (
80
55
< button
56
+ title = "Reset filters"
81
57
onClick = { onReset }
82
- className = "flex cursor-pointer items-center gap-x-2 bg-neu-100 px-2 py-1 text-neu-700 hover:bg-neu-200/80 hover:text-neu-900"
58
+ className = { clsx (
59
+ "flex h-fit cursor-pointer items-center gap-x-2 bg-neu-100 p-2 text-neu-700 hover:bg-neu-200/80 hover:text-neu-900" ,
60
+ className ,
61
+ ) }
83
62
>
84
- Reset filters < X className = "inline-block size-4" />
63
+ < CloseIcon className = "inline-block size-4" />
85
64
</ button >
86
65
)
87
66
}
88
67
89
- interface FilterOptionsProps {
90
- category : { name : string ; options : string [ ] }
91
- filterState : Record < string , string [ ] >
92
- onFilterChange : ( category : string , option : string , checked : boolean ) => void
68
+ interface FiltersComboboxProps {
69
+ label : string
70
+ options : string [ ]
71
+ value : string [ ]
72
+ onChange : ( newSelectedOptions : string [ ] ) => void
73
+ placeholder : string
74
+ className ?: string
93
75
}
76
+ function FiltersCombobox ( {
77
+ label,
78
+ options,
79
+ value,
80
+ onChange,
81
+ placeholder,
82
+ className,
83
+ } : FiltersComboboxProps ) {
84
+ const [ query , setQuery ] = useState ( "" )
85
+
86
+ const filteredOptions =
87
+ query === ""
88
+ ? options
89
+ : options . filter ( option =>
90
+ option . toLowerCase ( ) . includes ( query . toLowerCase ( ) ) ,
91
+ )
94
92
95
- function FilterOptions ( {
96
- category,
97
- filterState,
98
- onFilterChange,
99
- } : FilterOptionsProps ) {
100
93
return (
101
- < form className = "space-y-4" >
102
- { category . options . map ( ( option , optionIdx ) => (
103
- < div key = { option } className = "flex items-center gap-3" >
104
- < input
105
- id = { `filter-${ category . name } -${ optionIdx } ` }
106
- name = { `${ category . name } []` }
107
- defaultValue = { option }
108
- onChange = { e => {
109
- const { checked, value } = e . target
110
- onFilterChange ( category . name , value , checked )
111
- } }
112
- checked = { filterState [ category . name ] . includes ( option ) }
113
- type = "checkbox"
114
- className = "size-4 cursor-pointer rounded border-neu-300"
94
+ < Combobox multiple nullable value = { value } onChange = { onChange } >
95
+ < div className = { clsx ( "flex flex-col" , className ) } >
96
+ { label && (
97
+ < Combobox . Label className = "mb-1 block font-mono font-medium uppercase text-neu-900 typography-menu" >
98
+ { label }
99
+ </ Combobox . Label >
100
+ ) }
101
+ < label className = "relative w-full border border-neu-500 bg-neu-0 p-2 leading-normal focus-within:outline-none focus-within:ring focus-within:ring-neu-300" >
102
+ < Combobox . Input
103
+ value = { query }
104
+ onChange = { e => setQuery ( e . target . value ) }
105
+ className = { clsx (
106
+ "text-neu-800 !outline-offset-0 typography-body-sm placeholder:text-neu-600 focus:outline-none max-lg:typography-body-md" ,
107
+ ) }
108
+ placeholder = { placeholder }
109
+ autoComplete = "true"
115
110
/>
116
- < label
117
- htmlFor = { `filter-${ category . name } -${ optionIdx } ` }
118
- className = "cursor-pointer whitespace-nowrap pr-6 text-neu-900"
111
+ < Combobox . Button
112
+ className = { clsx (
113
+ "absolute inset-y-0 right-0 flex items-center px-2 focus:outline-none" ,
114
+ ) }
115
+ >
116
+ < CaretDownIcon
117
+ className = "ui-open:rotate-180 size-5 text-neu-400 transition-transform duration-150 group-hover:text-neu-500"
118
+ aria-hidden = "true"
119
+ />
120
+ </ Combobox . Button >
121
+ { value . length > 0 && (
122
+ < div className = "inset-y-0 left-0 z-[1] mt-1 flex items-center overflow-x-auto pr-8" >
123
+ < div className = "flex flex-wrap items-center gap-1" >
124
+ { value . map ( item => (
125
+ < Tag
126
+ key = { item }
127
+ color = { eventsColors [ item ] || "hsl(var(--color-neu-400))" }
128
+ >
129
+ { item }
130
+ </ Tag >
131
+ ) ) }
132
+ </ div >
133
+ </ div >
134
+ ) }
135
+ </ label >
136
+
137
+ < div className = "relative" >
138
+ < Combobox . Options
139
+ className = { clsx (
140
+ "absolute z-10 -mt-px max-h-60 w-full overflow-auto border border-neu-500 bg-neu-0 p-1 text-base" ,
141
+ ) }
119
142
>
120
- { option }
121
- </ label >
143
+ { filteredOptions . map ( option => (
144
+ < Combobox . Option key = { option } value = { option } >
145
+ { ( { active, selected } ) => (
146
+ < FilterComboboxOption
147
+ active = { active }
148
+ selected = { selected }
149
+ option = { option }
150
+ />
151
+ ) }
152
+ </ Combobox . Option >
153
+ ) ) }
154
+ </ Combobox . Options >
122
155
</ div >
123
- ) ) }
124
- </ form >
156
+ </ div >
157
+ </ Combobox >
158
+ )
159
+ }
160
+
161
+ interface CheckboxIconProps extends React . SVGProps < SVGSVGElement > {
162
+ checked : boolean
163
+ }
164
+ function CheckboxIcon ( { checked, ...rest } : CheckboxIconProps ) {
165
+ return (
166
+ < svg
167
+ xmlns = "http://www.w3.org/2000/svg"
168
+ width = "20"
169
+ height = "20"
170
+ viewBox = "0 0 20 20"
171
+ fill = "currentColor"
172
+ { ...rest }
173
+ >
174
+ { ! checked ? (
175
+ < >
176
+ < path
177
+ fill-rule = "evenodd"
178
+ clip-rule = "evenodd"
179
+ d = "M2.5 2.5H4.16667H15.8333H17.5V17.5H15.8333H4.16667H2.5V2.5ZM15.8333 15.8333V4.16667H4.16667V15.8333H15.8333Z"
180
+ />
181
+ </ >
182
+ ) : (
183
+ < >
184
+ < rect x = "2" y = "3" width = "15" height = "15" />
185
+ < path d = "M6 10.3333H7.66667V12H6V10.3333Z" fill = "white" />
186
+ < path d = "M7.66667 12H9.33333V13.6667H7.66667V12Z" fill = "white" />
187
+ < path d = "M9.33333 10.3333H11V12H9.33333V10.3333Z" fill = "white" />
188
+ < path d = "M11 8.66667H12.6667V10.3333H11V8.66667Z" fill = "white" />
189
+ < path d = "M12.6667 7H14.3333V8.66667H12.6667V7Z" fill = "white" />
190
+ </ >
191
+ ) }
192
+ </ svg >
193
+ )
194
+ }
195
+
196
+ function FilterComboboxOption ( {
197
+ active,
198
+ selected,
199
+ option,
200
+ } : {
201
+ active : boolean
202
+ selected : boolean
203
+ option : string
204
+ } ) {
205
+ return (
206
+ < div
207
+ className = { clsx (
208
+ "relative flex cursor-default select-none items-center p-1 font-sans typography-body-sm" ,
209
+ active && "bg-neu-100" ,
210
+ ) }
211
+ >
212
+ < CheckboxIcon
213
+ className = { clsx ( "size-5 shrink-0" , active && "text-neu-700" ) }
214
+ checked = { selected }
215
+ />
216
+ < div className = "min-w-0 flex-1 overflow-hidden pl-1 [container-type:inline-size]" >
217
+ < span
218
+ // eslint-disable-next-line tailwindcss/no-contradicting-classname
219
+ className = { clsx (
220
+ "relative block w-fit min-w-full whitespace-nowrap pt-px transition-all [--delta-x:calc(-100%+100cqi)]" ,
221
+ active && "animate-show-overflow" ,
222
+ ) }
223
+ >
224
+ { option }
225
+ </ span >
226
+ </ div >
227
+ </ div >
125
228
)
126
229
}
0 commit comments