Skip to content

Commit a603147

Browse files
authored
Merge pull request Stellar-IndigoPay#349 from AugistineCreates/feature/keyboard-navigation
Feature/keyboard navigation
2 parents 4e3cd9f + 919ef8d commit a603147

12 files changed

Lines changed: 480 additions & 26 deletions

File tree

.github/workflows/frontend.yml

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -112,16 +112,23 @@ jobs:
112112
cache: npm
113113
cache-dependency-path: frontend/package-lock.json
114114
- name: Install frontend dependencies
115-
working-directory: frontend
116-
run: npm ci --legacy-peer-deps
117-
- name: Cache Playwright browsers
118-
uses: actions/cache@v4
119-
id: playwright-cache
120-
with:
121-
path: ~/.cache/ms-playwright
122-
key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/package-lock.json') }}
123-
restore-keys: |
124-
${{ runner.os }}-playwright-
115+
working-directory: frontend
116+
run: npm ci --legacy-peer-deps
117+
- name: Install json-server for mock API
118+
run: npm install -g json-server
119+
- name: Start mock API server
120+
working-directory: frontend
121+
run: npx json-server --watch tests/e2e/fixtures/db.json --port 4000 &
122+
env:
123+
NODE_ENV: test
124+
- name: Cache Playwright browsers
125+
uses: actions/cache@v4
126+
id: playwright-cache
127+
with:
128+
path: ~/.cache/ms-playwright
129+
key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/package-lock.json') }}
130+
restore-keys: |
131+
${{ runner.os }}-playwright-
125132
- name: Install Playwright browsers (with dependencies)
126133
if: steps.playwright-cache.outputs.cache-hit != 'true'
127134
run: npx playwright install --with-deps chromium
@@ -130,6 +137,13 @@ jobs:
130137
if: steps.playwright-cache.outputs.cache-hit == 'true'
131138
run: npx playwright install-deps chromium
132139
working-directory: frontend
140+
- name: Build frontend for E2E
141+
working-directory: frontend
142+
run: npm run build
143+
env:
144+
NEXT_PUBLIC_STELLAR_NETWORK: testnet
145+
NEXT_PUBLIC_HORIZON_URL: https://horizon-testnet.stellar.org
146+
NEXT_PUBLIC_API_URL: http://localhost:4000
133147
- name: Run E2E tests
134148
working-directory: frontend
135149
run: npx playwright test
@@ -258,6 +272,13 @@ jobs:
258272
working-directory: frontend
259273
run: npx playwright install --with-deps chromium firefox webkit
260274

275+
- name: Build frontend for E2E
276+
working-directory: frontend
277+
run: npm run build
278+
env:
279+
NEXT_PUBLIC_STELLAR_NETWORK: testnet
280+
NEXT_PUBLIC_HORIZON_URL: https://horizon-testnet.stellar.org
281+
NEXT_PUBLIC_API_URL: http://localhost:4000
261282
- name: Run Playwright tests
262283
working-directory: frontend
263284
# Visual regression snapshots are OS/font-render dependent — skip in CI,

.github/workflows/sbom.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ jobs:
2929
- uses: actions/checkout@v4
3030

3131
- name: Generate SBOM for ${{ matrix.service }}
32-
uses: anchore/sbom-action@v0
32+
uses: anchore/sbom-action@v0.16.0
3333
with:
3434
format: cyclonedx-json
3535
artifact-name: sbom-${{ matrix.service }}.cdx.json
@@ -50,7 +50,7 @@ jobs:
5050
# token lacks permission (e.g., on forks).
5151
- name: Submit SBOM to GitHub dependency graph
5252
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
53-
uses: anchore/sbom-action@v0
53+
uses: anchore/sbom-action@v0.16.0
5454
continue-on-error: true
5555
with:
5656
format: cyclonedx-json
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { useState, useEffect, useRef, useCallback } from "react";
2+
import { useRouter } from "next/router";
3+
import Link from "next/link";
4+
import { fetchProjects } from "@/lib/api";
5+
import type { ClimateProject } from "@/utils/types";
6+
7+
interface GlobalSearchModalProps {
8+
onClose: () => void;
9+
}
10+
11+
export default function GlobalSearchModal({ onClose }: GlobalSearchModalProps) {
12+
const router = useRouter();
13+
const [query, setQuery] = useState("");
14+
const [results, setResults] = useState<ClimateProject[]>([]);
15+
const [loading, setLoading] = useState(false);
16+
const [selectedIndex, setSelectedIndex] = useState(0);
17+
18+
const modalRef = useRef<HTMLDivElement>(null);
19+
const inputRef = useRef<HTMLInputElement>(null);
20+
const previousFocusRef = useRef<HTMLElement | null>(null);
21+
22+
useEffect(() => {
23+
previousFocusRef.current = document.activeElement as HTMLElement;
24+
setTimeout(() => {
25+
inputRef.current?.focus();
26+
}, 50);
27+
}, []);
28+
29+
useEffect(() => {
30+
return () => {
31+
if (previousFocusRef.current) {
32+
previousFocusRef.current.focus();
33+
}
34+
};
35+
}, []);
36+
37+
useEffect(() => {
38+
if (!query.trim()) {
39+
setResults([]);
40+
return;
41+
}
42+
const delayDebounceFn = setTimeout(async () => {
43+
setLoading(true);
44+
try {
45+
const projects = await fetchProjects({ limit: 10 });
46+
const filtered = projects.filter((p) =>
47+
p.name.toLowerCase().includes(query.toLowerCase()) ||
48+
p.description.toLowerCase().includes(query.toLowerCase()) ||
49+
p.category.toLowerCase().includes(query.toLowerCase())
50+
);
51+
setResults(filtered);
52+
setSelectedIndex(0);
53+
} catch (err) {
54+
console.error("Search failed:", err);
55+
} finally {
56+
setLoading(false);
57+
}
58+
}, 250);
59+
60+
return () => clearTimeout(delayDebounceFn);
61+
}, [query]);
62+
63+
const handleKeyDown = useCallback(
64+
(e: React.KeyboardEvent) => {
65+
if (e.key === "Escape") {
66+
e.preventDefault();
67+
onClose();
68+
return;
69+
}
70+
71+
if (e.key === "ArrowDown") {
72+
e.preventDefault();
73+
setSelectedIndex((prev) =>
74+
results.length > 0 ? (prev + 1) % results.length : 0
75+
);
76+
} else if (e.key === "ArrowUp") {
77+
e.preventDefault();
78+
setSelectedIndex((prev) =>
79+
results.length > 0 ? (prev - 1 + results.length) % results.length : 0
80+
);
81+
} else if (e.key === "Enter") {
82+
if (results.length > 0 && selectedIndex >= 0 && selectedIndex < results.length) {
83+
e.preventDefault();
84+
router.push(`/projects/${results[selectedIndex].id}`);
85+
onClose();
86+
}
87+
}
88+
},
89+
[results, selectedIndex, router, onClose]
90+
);
91+
92+
const handleFocusTrap = useCallback((e: KeyboardEvent) => {
93+
if (e.key !== "Tab" || !modalRef.current) return;
94+
const focusableElements = modalRef.current.querySelectorAll(
95+
'input, button, a, [tabindex]:not([tabindex="-1"])'
96+
);
97+
if (focusableElements.length === 0) return;
98+
const firstElement = focusableElements[0] as HTMLElement;
99+
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
100+
101+
if (e.shiftKey) {
102+
if (document.activeElement === firstElement) {
103+
e.preventDefault();
104+
lastElement.focus();
105+
}
106+
} else {
107+
if (document.activeElement === lastElement) {
108+
e.preventDefault();
109+
firstElement.focus();
110+
}
111+
}
112+
}, []);
113+
114+
useEffect(() => {
115+
document.addEventListener("keydown", handleFocusTrap);
116+
return () => document.removeEventListener("keydown", handleFocusTrap);
117+
}, [handleFocusTrap]);
118+
119+
return (
120+
<div
121+
className="fixed inset-0 z-[100] flex items-start justify-center pt-[10vh] bg-black/60 backdrop-blur-md animate-fade-in"
122+
onClick={onClose}
123+
>
124+
<div
125+
ref={modalRef}
126+
className="w-full max-w-lg mx-4 bg-[#0A0A1A]/95 dark:bg-[#050510]/95 border border-[rgba(99,102,241,0.20)] rounded-2xl shadow-2xl overflow-hidden animate-slide-up"
127+
onClick={(e) => e.stopPropagation()}
128+
onKeyDown={handleKeyDown}
129+
>
130+
<div className="p-4 border-b border-[rgba(99,102,241,0.15)] flex items-center gap-3">
131+
<svg
132+
className="w-5 h-5 text-[#818CF8]"
133+
fill="none"
134+
viewBox="0 0 24 24"
135+
stroke="currentColor"
136+
strokeWidth={2}
137+
>
138+
<path
139+
strokeLinecap="round"
140+
strokeLinejoin="round"
141+
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
142+
/>
143+
</svg>
144+
<input
145+
ref={inputRef}
146+
type="text"
147+
placeholder="Search verified climate projects..."
148+
value={query}
149+
onChange={(e) => setQuery(e.target.value)}
150+
className="w-full bg-transparent text-white font-body text-base outline-none placeholder-[#94A3B8]"
151+
aria-label="Search projects"
152+
/>
153+
<button
154+
onClick={onClose}
155+
className="px-2 py-1 text-xs font-semibold text-[#818CF8] bg-[rgba(99,102,241,0.12)] rounded-lg hover:bg-[rgba(99,102,241,0.20)] transition-colors"
156+
>
157+
ESC
158+
</button>
159+
</div>
160+
161+
<div className="max-h-[300px] overflow-y-auto p-2">
162+
{loading ? (
163+
<div className="py-8 text-center text-[#94A3B8] font-body text-sm">
164+
Searching...
165+
</div>
166+
) : results.length > 0 ? (
167+
<ul role="listbox" aria-label="Search results" className="space-y-1">
168+
{results.map((project, index) => {
169+
const isSelected = index === selectedIndex;
170+
return (
171+
<li
172+
key={project.id}
173+
role="option"
174+
aria-selected={isSelected}
175+
className={`rounded-xl transition-all ${
176+
isSelected
177+
? "bg-[rgba(99,102,241,0.15)] text-white border border-[rgba(99,102,241,0.30)]"
178+
: "text-[#94A3B8] hover:text-white hover:bg-[rgba(99,102,241,0.06)]"
179+
}`}
180+
>
181+
<Link
182+
href={`/projects/${project.id}`}
183+
onClick={onClose}
184+
className="block px-4 py-3 outline-none"
185+
>
186+
<div className="flex justify-between items-center gap-2">
187+
<span className="font-display font-medium text-sm">
188+
{project.name}
189+
</span>
190+
<span className="text-xs px-2 py-0.5 rounded-full bg-[rgba(99,102,241,0.08)] border border-[rgba(99,102,241,0.12)] text-[#818CF8]">
191+
{project.category}
192+
</span>
193+
</div>
194+
<p className="text-xs text-[#64748B] line-clamp-1 mt-1 font-body">
195+
{project.description}
196+
</p>
197+
</Link>
198+
</li>
199+
);
200+
})}
201+
</ul>
202+
) : query.trim() ? (
203+
<div className="py-8 text-center text-[#94A3B8] font-body text-sm">
204+
No projects found for &ldquo;{query}&rdquo;
205+
</div>
206+
) : (
207+
<div className="py-6 text-center text-[#64748B] font-body text-xs">
208+
Type to search by name or category...
209+
</div>
210+
)}
211+
</div>
212+
</div>
213+
</div>
214+
);
215+
}

0 commit comments

Comments
 (0)