Skip to content

Commit 7a88bc4

Browse files
committed
filter into floating toggle
1 parent 5f2412c commit 7a88bc4

3 files changed

Lines changed: 90 additions & 16 deletions

File tree

src/components/atoms/FilterToggle.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export default function FilterToggle({
3333
'group relative inline-flex cursor-pointer items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-all duration-150 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
3434
active
3535
? 'border-transparent gradient-phase-light text-ink shadow-sm'
36-
: 'border-gray-200 bg-white/60 text-ink-muted opacity-60 grayscale hover:border-gray-300 hover:opacity-100 hover:grayscale-0'
36+
: 'border-gray-200 bg-white text-ink-muted/70 grayscale hover:border-gray-300 hover:text-ink hover:grayscale-0'
3737
)}
3838
>
3939
{iconSvg && (

src/components/organisms/ProjectsFilter.tsx

Lines changed: 89 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import FilterToggle from '@components/atoms/FilterToggle';
22
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';
411

512
export interface FilterOption {
613
slug: string;
@@ -153,6 +160,33 @@ export default function ProjectsFilter({
153160

154161
const clear = useCallback(() => setState(EMPTY_STATE), []);
155162

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+
156190
const activeCount =
157191
state.phases.length + state.modalities.length + state.skills.length;
158192

@@ -295,23 +329,65 @@ export default function ProjectsFilter({
295329
);
296330

297331
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">
302338
<button
303339
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"
306344
>
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>
308361
</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>
310387
</div>
311-
312-
{renderRow('Modalities', 'modalities', modalities)}
313-
{renderRow('Phases', 'phases', phases)}
314-
{renderRow('Skills', 'skills', skills)}
315388
</div>
316389
);
317390
}
391+
392+
const cx = (...parts: Array<string | false | null | undefined>) =>
393+
parts.filter(Boolean).join(' ');

src/content/pages/work.yaml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ status: published
33
description: Projects and partnerships across civil society and open technology
44
sections:
55
- type: projectsFilterGrid
6-
title: Our projects
7-
description: "A selection of the work we do across civil society and open technology. Toggle modalities, phases, or skills to narrow the view."
86
- type: richText
97
content: >-
108
We've worked with investigative journalism platforms, human rights research centres, open-source collaboration tools, multi-stakeholder cybersecurity initiatives, and civil society coordination platforms. Every project is different, but they share a commitment to transparency, sovereignty, and serving people who are too often underserved by technology.

0 commit comments

Comments
 (0)