All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 35s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m10s
Build and Push Docker Images / Build Integrator (push) Successful in 57s
Build and Push Docker Images / Build Kestra Init (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
- Add shared button system (.btn-primary/secondary/tertiary/active) in globals.css
- Replace 40+ hardcoded rgba() values with design tokens across all CSS modules
- Add skip link, :focus-visible indicators, and ARIA landmarks
- Standardise button labels ("+ Compare" / "✓ Comparing") across all components
- Add collapse/minimize toggle to ComparisonToast
- Fix heading hierarchy (h3→h2 in ComparisonView)
- Add aria-live on search results, aria-label on trend SVGs
- Add "Search" nav link, fix footer empty section, unify max-widths
- Darken --text-muted for WCAG AA compliance (4.6:1 contrast ratio)
- Net reduction of ~180 lines through button style deduplication
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
143 lines
4.2 KiB
TypeScript
143 lines
4.2 KiB
TypeScript
/**
|
|
* SchoolSearchModal Component
|
|
* Modal for searching and adding schools to comparison
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import { useState, useMemo } from 'react';
|
|
import { Modal } from './Modal';
|
|
import { useComparison } from '@/hooks/useComparison';
|
|
import { debounce } from '@/lib/utils';
|
|
import { fetchSchools } from '@/lib/api';
|
|
import type { School } from '@/lib/types';
|
|
import styles from './SchoolSearchModal.module.css';
|
|
|
|
interface SchoolSearchModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
|
|
const { addSchool, selectedSchools, canAddMore } = useComparison();
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [results, setResults] = useState<School[]>([]);
|
|
const [isSearching, setIsSearching] = useState(false);
|
|
const [hasSearched, setHasSearched] = useState(false);
|
|
|
|
// Debounced search function
|
|
const performSearch = useMemo(
|
|
() =>
|
|
debounce(async (term: string) => {
|
|
if (!term.trim()) {
|
|
setResults([]);
|
|
setHasSearched(false);
|
|
return;
|
|
}
|
|
|
|
setIsSearching(true);
|
|
try {
|
|
const data = await fetchSchools({ search: term, page_size: 10 }, { cache: 'no-store' });
|
|
setResults(data.schools || []);
|
|
setHasSearched(true);
|
|
} catch (error) {
|
|
console.error('Search failed:', error);
|
|
setResults([]);
|
|
} finally {
|
|
setIsSearching(false);
|
|
}
|
|
}, 300),
|
|
[]
|
|
);
|
|
|
|
const handleSearchChange = (value: string) => {
|
|
setSearchTerm(value);
|
|
performSearch(value);
|
|
};
|
|
|
|
const handleAddSchool = (school: School) => {
|
|
addSchool(school);
|
|
// Don't close modal, allow adding multiple schools
|
|
};
|
|
|
|
const isSchoolSelected = (urn: number) => {
|
|
return selectedSchools.some((s) => s.urn === urn);
|
|
};
|
|
|
|
const handleClose = () => {
|
|
setSearchTerm('');
|
|
setResults([]);
|
|
setHasSearched(false);
|
|
onClose();
|
|
};
|
|
|
|
return (
|
|
<Modal isOpen={isOpen} onClose={handleClose}>
|
|
<div className={styles.modalContent}>
|
|
<h2 className={styles.title}>Add School to Comparison</h2>
|
|
|
|
{!canAddMore && (
|
|
<div className={styles.warning}>
|
|
Maximum 5 schools can be compared. Remove a school to add another.
|
|
</div>
|
|
)}
|
|
|
|
{/* Search Input */}
|
|
<div className={styles.searchContainer}>
|
|
<input
|
|
type="text"
|
|
value={searchTerm}
|
|
onChange={(e) => handleSearchChange(e.target.value)}
|
|
placeholder="Search by school name or location..."
|
|
className={styles.searchInput}
|
|
autoFocus
|
|
/>
|
|
{isSearching && <div className={styles.searchSpinner} />}
|
|
</div>
|
|
|
|
{/* Results */}
|
|
<div className={styles.results}>
|
|
{hasSearched && results.length === 0 && (
|
|
<div className={styles.noResults}>
|
|
No schools found matching "{searchTerm}"
|
|
</div>
|
|
)}
|
|
|
|
{results.map((school) => {
|
|
const alreadySelected = isSchoolSelected(school.urn);
|
|
|
|
return (
|
|
<div key={school.urn} className={styles.resultItem}>
|
|
<div className={styles.schoolInfo}>
|
|
<div className={styles.schoolName}>{school.school_name}</div>
|
|
<div className={styles.schoolMeta}>
|
|
{school.local_authority && (
|
|
<span>{school.local_authority}</span>
|
|
)}
|
|
{school.school_type && (
|
|
<span>{school.school_type}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => handleAddSchool(school)}
|
|
disabled={alreadySelected || !canAddMore}
|
|
className={alreadySelected ? 'btn btn-active' : 'btn btn-secondary'}
|
|
>
|
|
{alreadySelected ? '✓ Comparing' : '+ Compare'}
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{!hasSearched && (
|
|
<div className={styles.hint}>
|
|
Start typing to search for schools...
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|