diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 229d4a2..de9df47 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -3,6 +3,7 @@ import { NextIntlClientProvider } from "next-intl"; import { getMessages, getTranslations } from "next-intl/server"; import { routing } from "@/i18n/routing"; import { notFound } from "next/navigation"; +import { auth } from "@/lib/auth"; import { NavShell } from "@/components/layout/nav-shell"; export function generateStaticParams() { @@ -44,12 +45,13 @@ export default async function LocaleLayout({ } const messages = await getMessages(); + const session = await auth(); return ( - {children} + {children} diff --git a/src/app/[locale]/register/page.tsx b/src/app/[locale]/register/page.tsx index 6c3c818..fcc5832 100644 --- a/src/app/[locale]/register/page.tsx +++ b/src/app/[locale]/register/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useRef, forwardRef } from "react"; import { useTranslations } from "next-intl"; import { useRouter, Link } from "@/i18n/navigation"; import { Card } from "@/components/ui/card"; @@ -50,6 +50,30 @@ export default function RegisterPage() { const [state, setState] = useState("idle"); const [error, setError] = useState(""); + // Radiogroup keyboard support. `role="radio"` requires roving + // tabindex (one tab stop) + arrow-key navigation between options — + // native buttons don't move focus on arrows. The selected card is + // the tab stop; when nothing is selected yet the first card is + // focusable so keyboard users can enter the group. + const TYPES: AccountType[] = ["personal", "company"]; + const cardRefs = useRef<(HTMLButtonElement | null)[]>([]); + + const rovingTabIndex = (type: AccountType, index: number) => + accountType === type || (accountType === null && index === 0) ? 0 : -1; + + const handleCardKeyDown = (e: React.KeyboardEvent, index: number) => { + let next: number | null = null; + if (e.key === "ArrowRight" || e.key === "ArrowDown") { + next = (index + 1) % TYPES.length; + } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + next = (index - 1 + TYPES.length) % TYPES.length; + } + if (next === null) return; + e.preventDefault(); + setAccountType(TYPES[next]); + cardRefs.current[next]?.focus(); + }; + const isPersonal = accountType === "personal"; const handleChange = (e: React.ChangeEvent) => { @@ -146,8 +170,13 @@ export default function RegisterPage() { className="grid grid-cols-2 gap-3 mb-6 animate-in animate-in-delay-1" > { + cardRefs.current[0] = el; + }} selected={accountType === "personal"} onClick={() => setAccountType("personal")} + tabIndex={rovingTabIndex("personal", 0)} + onKeyDown={(e) => handleCardKeyDown(e, 0)} label={t("personalCardTitle")} description={t("personalCardDescription")} icon={ @@ -168,8 +197,13 @@ export default function RegisterPage() { } /> { + cardRefs.current[1] = el; + }} selected={accountType === "company"} onClick={() => setAccountType("company")} + tabIndex={rovingTabIndex("company", 1)} + onKeyDown={(e) => handleCardKeyDown(e, 1)} label={t("companyCardTitle")} description={t("companyCardDescription")} icon={ @@ -305,41 +339,42 @@ export default function RegisterPage() { * and text colours intensify when selected to give a clear "this one * is on" signal beyond just the border colour. */ -function AccountTypeCard({ - selected, - onClick, - label, - description, - icon, -}: { - selected: boolean; - onClick: () => void; - label: string; - description: string; - icon: React.ReactNode; -}) { +const AccountTypeCard = forwardRef< + HTMLButtonElement, + { + selected: boolean; + onClick: () => void; + label: string; + description: string; + icon: React.ReactNode; + tabIndex: number; + onKeyDown: (e: React.KeyboardEvent) => void; + } +>(function AccountTypeCard( + { selected, onClick, label, description, icon, tabIndex, onKeyDown }, + ref +) { return ( ); -} +}); diff --git a/src/components/layout/nav-shell.tsx b/src/components/layout/nav-shell.tsx index 3f465a9..dcc6374 100644 --- a/src/components/layout/nav-shell.tsx +++ b/src/components/layout/nav-shell.tsx @@ -6,6 +6,7 @@ import { signOut, useSession } from "next-auth/react"; import { usePathname } from "@/i18n/navigation"; import { Link } from "@/i18n/navigation"; import { SessionProvider } from "next-auth/react"; +import type { Session } from "next-auth"; import { LanguageSwitcher } from "@/components/ui/language-switcher"; function NavBar() { @@ -211,9 +212,19 @@ function NavLink({ ); } -export function NavShell({ children }: { children: React.ReactNode }) { +export function NavShell({ + children, + session, +}: { + children: React.ReactNode; + // Server-resolved session passed down from the locale layout. Seeding + // SessionProvider with it means useSession() is populated on the first + // client render, so the nav links render immediately instead of + // popping in after the client-side session fetch (CLS / flash). + session: Session | null; +}) { return ( - +
{children}
diff --git a/src/components/ui/modal.tsx b/src/components/ui/modal.tsx index b83af70..c9a89c0 100644 --- a/src/components/ui/modal.tsx +++ b/src/components/ui/modal.tsx @@ -16,6 +16,9 @@ interface Props { ariaLabel?: string; } +const FOCUSABLE = + 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])'; + /** * Portal-based modal. * @@ -25,45 +28,86 @@ interface Props { * ancestor's containing block, not the viewport, when ANY ancestor * has a `transform`, `perspective`, or `filter` applied. Our * `animate-in` utility sets `transform: translateY(0)` on a lot of - * dashboard/tenant-detail containers (because of the fade-up - * animation, which uses `animation-fill-mode: both` to keep the - * transform on after the animation finishes). That broke modals - * rendered as in-place children — they centred to the panel they - * lived in, not to the page. + * dashboard/tenant-detail containers, which broke modals rendered as + * in-place children — they centred to the panel they lived in, not to + * the page. Rendering at `document.body` via `createPortal` escapes + * every containing-block ancestor and gives us true viewport coords. * - * Rendering at `document.body` via `createPortal` escapes every - * containing-block ancestor and gives us true viewport coordinates. - * - * UX details - * ---------- - * - Backdrop click triggers `onClose`. (Bubbling check: only fires - * when the click target IS the backdrop, not the panel inside.) - * - Escape key triggers `onClose`. Standard modal expectation. - * - `body` overflow is locked while open so background content - * doesn't scroll behind the modal. - * - Renders nothing on first paint server-side, then mounts on - * client. `useEffect` gating ensures `document.body` is available; - * without it Next.js SSR would throw on `document` reference. + * UX / a11y details + * ----------------- + * - Backdrop click triggers `onClose` (only when the click target IS + * the backdrop, not the panel inside). + * - Escape triggers `onClose`. + * - `body` overflow is locked while open so background content doesn't + * scroll behind the modal. + * - Focus is moved into the panel on open, trapped within it while open + * (Tab / Shift+Tab cycle), and restored to the previously focused + * element on close — so keyboard and screen-reader users can't tab + * out to the inert page behind the dialog. */ export function Modal({ open, onClose, children, ariaLabel }: Props) { const closeRef = useRef(onClose); closeRef.current = onClose; + const panelRef = useRef(null); useEffect(() => { if (!open) return; - // Lock background scroll. Restore on unmount/close. + // Remember what had focus so we can restore it on close. + const previouslyFocused = document.activeElement as HTMLElement | null; + + // Lock background scroll. const previousOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; + // Move focus into the dialog — first focusable element, else the + // panel itself (it carries tabIndex={-1}). + const panel = panelRef.current; + const focusables = panel + ? Array.from(panel.querySelectorAll(FOCUSABLE)) + : []; + (focusables[0] ?? panel)?.focus(); + const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") closeRef.current(); + if (e.key === "Escape") { + closeRef.current(); + return; + } + if (e.key !== "Tab" || !panel) return; + + // Re-query each time — modal content can change between tabs. + const items = Array.from( + panel.querySelectorAll(FOCUSABLE) + ).filter((el) => el.offsetParent !== null || el === document.activeElement); + if (items.length === 0) { + e.preventDefault(); + panel.focus(); + return; + } + const first = items[0]; + const last = items[items.length - 1]; + const active = document.activeElement; + + if (e.shiftKey) { + if (active === first || active === panel) { + e.preventDefault(); + last.focus(); + } + } else if (active === last) { + e.preventDefault(); + first.focus(); + } }; + window.addEventListener("keydown", onKey); return () => { document.body.style.overflow = previousOverflow; window.removeEventListener("keydown", onKey); + // Restore focus to the trigger (if it's still in the document). + if (previouslyFocused && document.contains(previouslyFocused)) { + previouslyFocused.focus(); + } }; }, [open]); @@ -72,15 +116,19 @@ export function Modal({ open, onClose, children, ariaLabel }: Props) { return createPortal(
{ if (e.target === e.currentTarget) onClose(); }} > -
+
{children}
,