{currentPostcode && (
-
- )}
-
- {phaseOptions.length > 0 && (
-
-
)}
-
-
-
+ {phaseOptions.length > 0 && phaseSelect("pill")}
+
+ {typeSelect("pill")}
diff --git a/nextjs-app/components/FilterSheet.module.css b/nextjs-app/components/FilterSheet.module.css
new file mode 100644
index 0000000..73491be
--- /dev/null
+++ b/nextjs-app/components/FilterSheet.module.css
@@ -0,0 +1,96 @@
+/* ── Phone filter sheet ───────────────────────────────────────────── */
+
+.body {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ padding: 1rem;
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+}
+
+.fieldLabel {
+ font-family: var(--font-ui);
+ font-size: var(--step--1);
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+/* Distance: five joined segments, a full-width row. At 360px each is about
+ 65px wide, clear of the 44px minimum. */
+.segments {
+ display: flex;
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-sm);
+ overflow: hidden;
+}
+
+.segment {
+ position: relative;
+ flex: 1 1 0;
+ display: flex;
+}
+
+.segment + .segment {
+ border-left: 1px solid var(--border);
+}
+
+/* The real radio stays in the tree for keyboard and screen readers; the span
+ is what is drawn. */
+.segment input {
+ position: absolute;
+ inset: 0;
+ margin: 0;
+ opacity: 0;
+ cursor: pointer;
+}
+
+.segment span {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 2.75rem;
+ font-family: var(--font-ui);
+ font-size: var(--step--1);
+ font-weight: 500;
+ color: var(--text-primary);
+ background: var(--bg-card);
+ white-space: nowrap;
+ transition: background-color var(--transition), color var(--transition);
+}
+
+.segment input:checked + span {
+ background: rgba(var(--sage-rgb), 0.38);
+ color: var(--brand-strong);
+ font-weight: 700;
+}
+
+.segment input:focus-visible + span {
+ box-shadow: inset 0 0 0 2px var(--brand);
+}
+
+.segment input:disabled {
+ cursor: progress;
+}
+
+.footer {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.clearAll {
+ min-height: 2.75rem;
+ padding: 0 1rem;
+}
+
+.show {
+ flex: 1;
+ min-height: 2.75rem;
+ justify-content: center;
+}
diff --git a/nextjs-app/components/FilterSheet.tsx b/nextjs-app/components/FilterSheet.tsx
new file mode 100644
index 0000000..6229a09
--- /dev/null
+++ b/nextjs-app/components/FilterSheet.tsx
@@ -0,0 +1,121 @@
+"use client";
+
+import { useId } from "react";
+import type { ReactNode } from "react";
+import { Modal } from "./Modal";
+import styles from "./FilterSheet.module.css";
+
+export const RADIUS_OPTIONS = ["0.25", "0.5", "1", "3", "5"] as const;
+
+export const radiusLabel = (r: string) => `${r} mile${r === "1" ? "" : "s"}`;
+
+interface FilterSheetProps {
+ open: boolean;
+ onClose: () => void;
+ /** The current distance, or null when the search is not by postcode. */
+ radius: string | null;
+ onRadiusChange: (radius: string) => void;
+ /** The filter selects, each already labelled; FilterBar owns their state. */
+ children: ReactNode;
+ /** Shown only when something is applied. */
+ onClearAll?: () => void;
+ resultCount?: number;
+ isPending: boolean;
+}
+
+/*
+ * Every filter in one place, for phones. A change applies at once, as it does
+ * in the desktop toolbar, so the results behind the sheet are already the
+ * filtered ones and the footer button only has to close it.
+ */
+export function FilterSheet({
+ open,
+ onClose,
+ radius,
+ onRadiusChange,
+ children,
+ onClearAll,
+ resultCount,
+ isPending,
+}: FilterSheetProps) {
+ const distanceId = useId();
+
+ const showLabel = isPending
+ ? "Updating…"
+ : resultCount === undefined
+ ? "Show schools"
+ : resultCount === 0
+ ? "No schools match"
+ : `Show ${resultCount.toLocaleString()} school${resultCount === 1 ? "" : "s"}`;
+
+ return (
+