diff --git a/apps/web/src/pages/Clients.tsx b/apps/web/src/pages/Clients.tsx index 0e40212..8f89d52 100644 --- a/apps/web/src/pages/Clients.tsx +++ b/apps/web/src/pages/Clients.tsx @@ -857,12 +857,56 @@ export function ClientsPage() { // ─── Shared UI ─────────────────────────────────────────────────────────────── function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) { + const modalRef = useRef(null); + + useEffect(() => { + const previouslyFocused = document.activeElement as HTMLElement; + const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + const focusableElements = modalRef.current?.querySelectorAll(focusableSelectors); + const firstFocusable = focusableElements?.[0]; + firstFocusable?.focus(); + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + onClose(); + return; + } + if (e.key !== "Tab") return; + if (!modalRef.current) return; + const focusables = modalRef.current.querySelectorAll(focusableSelectors); + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last?.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first?.focus(); + } + } + } + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + previouslyFocused?.focus(); + }; + }, [onClose]); + return (
{ if (e.target === e.currentTarget) onClose(); }} > -
+
{children}
diff --git a/apps/web/src/pages/Invoices.tsx b/apps/web/src/pages/Invoices.tsx index 0712b9c..04fc7ea 100644 --- a/apps/web/src/pages/Invoices.tsx +++ b/apps/web/src/pages/Invoices.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit } from "@groombook/types"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -682,19 +682,63 @@ export function InvoicesPage() { // ─── Shared UI helpers ──────────────────────────────────────────────────────── function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) { + const modalRef = useRef(null); + + useEffect(() => { + const previouslyFocused = document.activeElement as HTMLElement; + const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + const focusableElements = modalRef.current?.querySelectorAll(focusableSelectors); + const firstFocusable = focusableElements?.[0]; + firstFocusable?.focus(); + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + onClose(); + return; + } + if (e.key !== "Tab") return; + if (!modalRef.current) return; + const focusables = modalRef.current.querySelectorAll(focusableSelectors); + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last?.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first?.focus(); + } + } + } + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + previouslyFocused?.focus(); + }; + }, [onClose]); + return (
{ if (e.target === e.currentTarget) onClose(); }} > -
+
{children}
diff --git a/apps/web/src/portal/sections/BillingPayments.tsx b/apps/web/src/portal/sections/BillingPayments.tsx index 6bcfb17..d47bea4 100644 --- a/apps/web/src/portal/sections/BillingPayments.tsx +++ b/apps/web/src/portal/sections/BillingPayments.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { loadStripe } from "@stripe/stripe-js"; import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js"; import { CreditCard, DollarSign, Package, Zap } from "lucide-react"; @@ -356,6 +356,48 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr const [isProcessing, setIsProcessing] = useState(false); const [isComplete, setIsComplete] = useState(false); const [error, setError] = useState(null); + const completeModalRef = useRef(null); + const paymentModalRef = useRef(null); + + // Focus trap + Escape-to-close for both inline modals + useEffect(() => { + const modalRef = isComplete ? completeModalRef.current : paymentModalRef.current; + if (!modalRef) return; + + const previouslyFocused = document.activeElement as HTMLElement; + const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + const focusableElements = modalRef.querySelectorAll(focusableSelectors); + const firstFocusable = focusableElements[0]; + firstFocusable?.focus(); + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + onClose(); + return; + } + if (e.key !== "Tab" || !modalRef) return; + const focusables = modalRef.querySelectorAll(focusableSelectors); + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last?.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first?.focus(); + } + } + } + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + previouslyFocused?.focus(); + }; + }, [isComplete, onClose]); const formatCents = (cents: number) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100); @@ -420,8 +462,8 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr if (isComplete) { return ( -
-
+
+
@@ -440,8 +482,8 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr } return ( -
-
+
+

Pay Outstanding Balance