|
1 | 1 | import FilterToggle from '@components/atoms/FilterToggle'; |
2 | 2 | import type { ReactNode } from 'react'; |
3 | | -import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; |
| 3 | +import { |
| 4 | + Fragment, |
| 5 | + useCallback, |
| 6 | + useEffect, |
| 7 | + useMemo, |
| 8 | + useRef, |
| 9 | + useState, |
| 10 | +} from 'react'; |
4 | 11 |
|
5 | 12 | export interface FilterOption { |
6 | 13 | slug: string; |
@@ -153,6 +160,33 @@ export default function ProjectsFilter({ |
153 | 160 |
|
154 | 161 | const clear = useCallback(() => setState(EMPTY_STATE), []); |
155 | 162 |
|
| 163 | + const containerRef = useRef<HTMLDivElement | null>(null); |
| 164 | + const [open, setOpen] = useState(false); |
| 165 | + const panelId = `${gridId}-filter-panel`; |
| 166 | + |
| 167 | + useEffect(() => { |
| 168 | + if (!open) return; |
| 169 | + const onPointerDown = (e: PointerEvent) => { |
| 170 | + const el = containerRef.current; |
| 171 | + if (el && e.target instanceof Node && !el.contains(e.target)) { |
| 172 | + setOpen(false); |
| 173 | + } |
| 174 | + }; |
| 175 | + const onKey = (e: KeyboardEvent) => { |
| 176 | + if (e.key === 'Escape') setOpen(false); |
| 177 | + }; |
| 178 | + document.addEventListener('pointerdown', onPointerDown); |
| 179 | + document.addEventListener('keydown', onKey); |
| 180 | + return () => { |
| 181 | + document.removeEventListener('pointerdown', onPointerDown); |
| 182 | + document.removeEventListener('keydown', onKey); |
| 183 | + }; |
| 184 | + }, [open]); |
| 185 | + |
| 186 | + const handleTriggerClick = useCallback(() => { |
| 187 | + setOpen((v) => !v); |
| 188 | + }, []); |
| 189 | + |
156 | 190 | const activeCount = |
157 | 191 | state.phases.length + state.modalities.length + state.skills.length; |
158 | 192 |
|
@@ -295,23 +329,65 @@ export default function ProjectsFilter({ |
295 | 329 | ); |
296 | 330 |
|
297 | 331 | return ( |
298 | | - <div className="mb-8 space-y-4 rounded-lg border border-gray-200 bg-cream/40 p-4 sm:p-5"> |
299 | | - <div className="flex flex-wrap items-start justify-between gap-4"> |
300 | | - <p className="text-sm leading-relaxed text-ink-soft">{summary}</p> |
301 | | - {activeCount > 0 && ( |
| 332 | + <div |
| 333 | + ref={containerRef} |
| 334 | + className="sticky top-24 z-30 mx-auto mb-8 w-9/10" |
| 335 | + > |
| 336 | + <div className="relative"> |
| 337 | + <div className="flex flex-wrap items-stretch gap-2 border border-gray-200 bg-white/60 shadow-sm backdrop-blur-md"> |
302 | 338 | <button |
303 | 339 | type="button" |
304 | | - onClick={clear} |
305 | | - className="shrink-0 text-xs font-medium text-ink-muted underline-offset-2 hover:text-ink hover:underline" |
| 340 | + aria-expanded={open} |
| 341 | + aria-controls={panelId} |
| 342 | + onClick={handleTriggerClick} |
| 343 | + className="flex flex-1 cursor-pointer items-center gap-3 px-4 py-3 text-left text-sm leading-relaxed text-ink-soft transition-colors hover:bg-white/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none" |
306 | 344 | > |
307 | | - Clear all |
| 345 | + <svg |
| 346 | + aria-hidden="true" |
| 347 | + viewBox="0 0 20 20" |
| 348 | + fill="none" |
| 349 | + stroke="currentColor" |
| 350 | + strokeWidth="1.75" |
| 351 | + strokeLinecap="round" |
| 352 | + strokeLinejoin="round" |
| 353 | + className={cx( |
| 354 | + 'h-4 w-4 shrink-0 text-ink-muted transition-transform duration-200', |
| 355 | + open ? 'rotate-180' : 'rotate-0' |
| 356 | + )} |
| 357 | + > |
| 358 | + <path d="M5 7.5l5 5 5-5" /> |
| 359 | + </svg> |
| 360 | + <span className="flex-1">{summary}</span> |
308 | 361 | </button> |
309 | | - )} |
| 362 | + {activeCount > 0 && ( |
| 363 | + <button |
| 364 | + type="button" |
| 365 | + onClick={clear} |
| 366 | + className="shrink-0 self-center pr-4 text-xs font-medium text-ink-muted underline-offset-2 hover:text-ink hover:underline" |
| 367 | + > |
| 368 | + Clear all |
| 369 | + </button> |
| 370 | + )} |
| 371 | + </div> |
| 372 | + |
| 373 | + <div |
| 374 | + id={panelId} |
| 375 | + aria-hidden={!open} |
| 376 | + className={cx( |
| 377 | + 'absolute top-full -right-px -left-px border border-t-0 border-gray-200 bg-white/60 shadow-sm backdrop-blur-md transition-opacity duration-200 ease-out', |
| 378 | + open ? 'opacity-100' : 'pointer-events-none opacity-0' |
| 379 | + )} |
| 380 | + > |
| 381 | + <div className="space-y-4 p-4 sm:p-5"> |
| 382 | + {renderRow('Modalities', 'modalities', modalities)} |
| 383 | + {renderRow('Phases', 'phases', phases)} |
| 384 | + {renderRow('Skills', 'skills', skills)} |
| 385 | + </div> |
| 386 | + </div> |
310 | 387 | </div> |
311 | | - |
312 | | - {renderRow('Modalities', 'modalities', modalities)} |
313 | | - {renderRow('Phases', 'phases', phases)} |
314 | | - {renderRow('Skills', 'skills', skills)} |
315 | 388 | </div> |
316 | 389 | ); |
317 | 390 | } |
| 391 | + |
| 392 | +const cx = (...parts: Array<string | false | null | undefined>) => |
| 393 | + parts.filter(Boolean).join(' '); |
0 commit comments