Skip to content

Commit e7e1365

Browse files
committed
Add comboboxes to filters
1 parent 8fc938f commit e7e1365

File tree

7 files changed

+255
-126
lines changed

7 files changed

+255
-126
lines changed
Lines changed: 3 additions & 0 deletions
Loading

src/app/conf/2025/schedule/[id]/page.tsx

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import { ScheduleSession } from "../../../2023/types"
1515
import { SessionVideo } from "./session-video"
1616
import { NavbarPlaceholder } from "../../components/navbar"
1717
import { BackLink } from "../_components/back-link"
18+
import { Tag } from "@/app/conf/_design-system/tag"
19+
import { eventsColors } from "../../utils"
1820

1921
function getEventTitle(event: ScheduleSession, speakers: string[]): string {
2022
let { name } = event
@@ -59,24 +61,6 @@ export function generateStaticParams() {
5961
return schedule.filter(s => s.id).map(s => ({ id: s.id }))
6062
}
6163

62-
const Tag = ({
63-
text,
64-
featured = false,
65-
}: {
66-
text: string
67-
featured?: boolean
68-
}) =>
69-
!text ? null : (
70-
<span
71-
className={clsx(
72-
"h-max whitespace-nowrap rounded-full border border-solid px-3 py-1 typography-tagline",
73-
featured && "border-2 border-pri-darker bg-pri-darker text-white",
74-
)}
75-
>
76-
{text}
77-
</span>
78-
)
79-
8064
export default function SessionPage({ params }: SessionProps) {
8165
const event = schedule.find(s => s.id === params.id)
8266
if (!event) {
@@ -109,9 +93,31 @@ export default function SessionPage({ params }: SessionProps) {
10993
<div className="mx-auto mt-10 flex flex-col self-center sm:space-y-4">
11094
<div className="space-y-5">
11195
<div className="flex flex-wrap gap-3">
112-
<Tag text={eventType} featured />
113-
<Tag text={event.audience} />
114-
<Tag text={event.event_subtype} />
96+
{eventType && (
97+
<Tag color={eventsColors[event.event_type]}>
98+
{eventType}
99+
</Tag>
100+
)}
101+
{event.audience && (
102+
<Tag
103+
color={
104+
eventsColors[event.audience] ||
105+
"hsl(var(--color-neu-700))"
106+
}
107+
>
108+
{event.audience}
109+
</Tag>
110+
)}
111+
{event.event_subtype && (
112+
<Tag
113+
color={
114+
eventsColors[event.event_subtype] ||
115+
"hsl(var(--color-sec-base))"
116+
}
117+
>
118+
{event.event_subtype}
119+
</Tag>
120+
)}
115121
</div>
116122
<h1 className="mt-0 typography-h1">{eventTitle}</h1>
117123
<time dateTime={event.event_start} className="mt-4">
@@ -129,7 +135,7 @@ export default function SessionPage({ params }: SessionProps) {
129135
key={speaker.username}
130136
>
131137
<Avatar
132-
className="size-[100px] rounded-full lg:size-[120px]"
138+
className="size-[100px] lg:size-[120px]"
133139
avatar={speaker.avatar}
134140
name={speaker.name}
135141
/>
Lines changed: 193 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
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"
49

510
type FiltersProps = {
611
categories: { name: string; options: string[] }[]
712
filterState: Record<string, string[]>
8-
onFilterChange: (category: string, option: string, checked: boolean) => void
13+
onFilterChange: (category: string, newSelectedOptions: string[]) => void
914
onReset: () => void
1015
}
1116

@@ -16,111 +21,209 @@ export function Filters({
1621
onReset,
1722
}: FiltersProps) {
1823
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+
))}
7138
{Object.values(filterState).flat().length > 0 && (
72-
<ResetButton onReset={onReset} />
39+
<div className="relative">
40+
<ResetButton onReset={onReset} className="absolute top-[18px]" />
41+
</div>
7342
)}
7443
</div>
7544
)
7645
}
7746

78-
function ResetButton({ onReset }: { onReset: () => void }) {
47+
function ResetButton({
48+
onReset,
49+
className,
50+
}: {
51+
onReset: () => void
52+
className?: string
53+
}) {
7954
return (
8055
<button
56+
title="Reset filters"
8157
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+
)}
8362
>
84-
Reset filters <X className="inline-block size-4" />
63+
<CloseIcon className="inline-block size-4" />
8564
</button>
8665
)
8766
}
8867

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
9375
}
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+
)
9492

95-
function FilterOptions({
96-
category,
97-
filterState,
98-
onFilterChange,
99-
}: FilterOptionsProps) {
10093
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"
115110
/>
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+
)}
119142
>
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>
122155
</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>
125228
)
126229
}

0 commit comments

Comments
 (0)