PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 35s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m18s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m15s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 22s
On phones the results toolbar's filters were a sideways-scrolling row led by "More filters", so phase showed only in part and school type not at all, and the panel "More filters" opened held neither of them. Phones now get a single Filters button beside the folded search summary, counting every applied filter. It opens a bottom sheet with every filter: distance as five segments, then phase, school type, local authority and the secondary-only filters. Changes apply at once, as on desktop, so the footer's "Show N schools" only closes the sheet. Applied filters show as removable chips on a second line, which appears only when something is applied. Desktop and tablet are unchanged. Modal gains dialog semantics, a pinned footer and focus handling, and moves above the pinned toolbar, the floating List/Map button and the comparison toast, which its old z-index sat beneath. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
134 lines
4.0 KiB
TypeScript
134 lines
4.0 KiB
TypeScript
/**
|
|
* Modal Component
|
|
* Reusable modal overlay with animations
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import { useEffect, useCallback, useId, useRef } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import styles from './Modal.module.css';
|
|
|
|
interface ModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
children: React.ReactNode;
|
|
title?: string;
|
|
size?: 'small' | 'medium' | 'large';
|
|
/** Pinned below the scrolling content, so its actions stay in reach. */
|
|
footer?: React.ReactNode;
|
|
}
|
|
|
|
export function Modal({ isOpen, onClose, children, title, size = 'medium', footer }: ModalProps) {
|
|
const overlayRef = useRef<HTMLDivElement>(null);
|
|
const dialogRef = useRef<HTMLDivElement>(null);
|
|
const titleId = useId();
|
|
|
|
const handleEscape = useCallback((e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
onClose();
|
|
}
|
|
}, [onClose]);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
|
|
// Add event listener
|
|
document.addEventListener('keydown', handleEscape);
|
|
|
|
// Prevent body scroll
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
return () => {
|
|
document.removeEventListener('keydown', handleEscape);
|
|
document.body.style.overflow = 'unset';
|
|
};
|
|
}, [isOpen, handleEscape]);
|
|
|
|
// Focus moves into the dialog when it opens and back to whatever opened it
|
|
// when it closes. A child that has already taken focus (an autoFocus input)
|
|
// keeps it: children's effects run before this one.
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
const opener = document.activeElement as HTMLElement | null;
|
|
const dialog = dialogRef.current;
|
|
if (dialog && !dialog.contains(document.activeElement)) dialog.focus();
|
|
return () => opener?.focus?.();
|
|
}, [isOpen]);
|
|
|
|
// Pin the overlay to the VISUAL viewport, not the layout viewport. On mobile
|
|
// the on-screen keyboard shrinks the visual viewport but not the layout one,
|
|
// so a `position: fixed; inset: 0` overlay keeps full height — leaving the
|
|
// bottom-anchored sheet (and the dim backdrop's lower half) hidden behind
|
|
// the keyboard. Tracking visualViewport.height/offsetTop keeps the whole
|
|
// overlay — backdrop and sheet — inside the visible area, above the keyboard.
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
|
|
const el = overlayRef.current;
|
|
if (!vv || !el) return;
|
|
|
|
const sync = () => {
|
|
el.style.top = `${vv.offsetTop}px`;
|
|
el.style.height = `${vv.height}px`;
|
|
el.style.bottom = 'auto';
|
|
};
|
|
sync();
|
|
vv.addEventListener('resize', sync);
|
|
vv.addEventListener('scroll', sync);
|
|
return () => {
|
|
vv.removeEventListener('resize', sync);
|
|
vv.removeEventListener('scroll', sync);
|
|
};
|
|
}, [isOpen]);
|
|
|
|
if (!isOpen || typeof window === 'undefined') return null;
|
|
|
|
const handleOverlayClick = (e: React.MouseEvent) => {
|
|
if (e.target === e.currentTarget) {
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
return createPortal(
|
|
<div ref={overlayRef} className={styles.overlay} onClick={handleOverlayClick}>
|
|
<div
|
|
ref={dialogRef}
|
|
className={`${styles.modal} ${styles[size]}`}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby={title ? titleId : undefined}
|
|
tabIndex={-1}
|
|
>
|
|
<div className={styles.header}>
|
|
{title && <h2 id={titleId} className={styles.title}>{title}</h2>}
|
|
<button
|
|
className={styles.closeButton}
|
|
onClick={onClose}
|
|
aria-label="Close modal"
|
|
>
|
|
<svg
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<line x1="18" y1="6" x2="6" y2="18" />
|
|
<line x1="6" y1="6" x2="18" y2="18" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div className={styles.content}>
|
|
{children}
|
|
</div>
|
|
{footer && <div className={styles.footer}>{footer}</div>}
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|