feat(GRO-786): add ARIA label attributes to Modal dialog component
- Update Modal component to accept title and titleStyle props - Add role="dialog", aria-modal="true", and aria-labelledby attributes - Use useId() to generate stable ID for title heading association - Update all 4 Modal call sites (New/Edit Client, Add/Edit Pet, Log Grooming Visit, Permanently Delete Client) with title props - Delete modal passes titleStyle for red color on warning Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
import { useEffect, useState, useCallback, useRef, useId } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
|
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
|
||||||
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
|
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
|
||||||
@@ -647,8 +647,7 @@ export function ClientsPage() {
|
|||||||
|
|
||||||
{/* ── Client modal ── */}
|
{/* ── Client modal ── */}
|
||||||
{showClientForm && (
|
{showClientForm && (
|
||||||
<Modal onClose={() => setShowClientForm(false)}>
|
<Modal title={editingClient ? "Edit Client" : "New Client"} onClose={() => setShowClientForm(false)}>
|
||||||
<h2 style={{ marginTop: 0 }}>{editingClient ? "Edit Client" : "New Client"}</h2>
|
|
||||||
<form onSubmit={submitClient}>
|
<form onSubmit={submitClient}>
|
||||||
<Field label="Full name">
|
<Field label="Full name">
|
||||||
<input value={clientForm.name} onChange={(e) => setClientForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
<input value={clientForm.name} onChange={(e) => setClientForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
||||||
@@ -678,8 +677,7 @@ export function ClientsPage() {
|
|||||||
|
|
||||||
{/* ── Pet modal ── */}
|
{/* ── Pet modal ── */}
|
||||||
{showPetForm && (
|
{showPetForm && (
|
||||||
<Modal onClose={() => setShowPetForm(false)}>
|
<Modal title={editingPet ? "Edit Pet" : "Add Pet"} onClose={() => setShowPetForm(false)}>
|
||||||
<h2 style={{ marginTop: 0 }}>{editingPet ? "Edit Pet" : "Add Pet"}</h2>
|
|
||||||
<form onSubmit={submitPet}>
|
<form onSubmit={submitPet}>
|
||||||
<Field label="Pet name">
|
<Field label="Pet name">
|
||||||
<input value={petForm.name} onChange={(e) => setPetForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
<input value={petForm.name} onChange={(e) => setPetForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
||||||
@@ -753,8 +751,7 @@ export function ClientsPage() {
|
|||||||
|
|
||||||
{/* ── Visit log modal ── */}
|
{/* ── Visit log modal ── */}
|
||||||
{showLogForm && logPetId && (
|
{showLogForm && logPetId && (
|
||||||
<Modal onClose={() => setShowLogForm(false)}>
|
<Modal title="Log Grooming Visit" onClose={() => setShowLogForm(false)}>
|
||||||
<h2 style={{ marginTop: 0 }}>Log Grooming Visit</h2>
|
|
||||||
{logsLoading[logPetId] && <p style={{ fontSize: 13, color: "#6b7280" }}>Loading history…</p>}
|
{logsLoading[logPetId] && <p style={{ fontSize: 13, color: "#6b7280" }}>Loading history…</p>}
|
||||||
{visitLogs[logPetId] && visitLogs[logPetId].length > 0 && (
|
{visitLogs[logPetId] && visitLogs[logPetId].length > 0 && (
|
||||||
<div style={{ marginBottom: "1rem" }}>
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
@@ -817,8 +814,7 @@ export function ClientsPage() {
|
|||||||
|
|
||||||
{/* ── Delete confirmation modal ── */}
|
{/* ── Delete confirmation modal ── */}
|
||||||
{showDeleteConfirm && selectedClient && (
|
{showDeleteConfirm && selectedClient && (
|
||||||
<Modal onClose={() => setShowDeleteConfirm(false)}>
|
<Modal title="Permanently Delete Client" titleStyle={{ color: "#dc2626" }} onClose={() => setShowDeleteConfirm(false)}>
|
||||||
<h2 style={{ marginTop: 0, color: "#dc2626" }}>Permanently Delete Client</h2>
|
|
||||||
<p style={{ fontSize: 14, color: "#374151" }}>
|
<p style={{ fontSize: 14, color: "#374151" }}>
|
||||||
This will permanently delete <strong>{selectedClient.name}</strong> and all their pets. This action cannot be undone.
|
This will permanently delete <strong>{selectedClient.name}</strong> and all their pets. This action cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
@@ -856,46 +852,8 @@ export function ClientsPage() {
|
|||||||
|
|
||||||
// ─── Shared UI ───────────────────────────────────────────────────────────────
|
// ─── Shared UI ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
function Modal({ children, onClose, title, titleStyle }: { children: React.ReactNode; onClose: () => void; title: string; titleStyle?: React.CSSProperties }) {
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const titleId = useId();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const previouslyFocused = document.activeElement as HTMLElement;
|
|
||||||
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
|
||||||
const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(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<HTMLElement>(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 (
|
return (
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
@@ -904,9 +862,12 @@ function Modal({ children, onClose }: { children: React.ReactNode; onClose: () =
|
|||||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={modalRef}
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
style={{ background: "#fff", borderRadius: 8, padding: "1.5rem", maxWidth: 480, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.3)" }}
|
style={{ background: "#fff", borderRadius: 8, padding: "1.5rem", maxWidth: 480, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.3)" }}
|
||||||
>
|
>
|
||||||
|
<h2 id={titleId} style={{ marginTop: 0, ...titleStyle }}>{title}</h2>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user