keyboard radiogroup, modal focus trap, nav session hydration

This commit is contained in:
2026-05-29 22:46:03 +02:00
parent bff3aad1ca
commit 93842bec8e
4 changed files with 144 additions and 48 deletions

View File

@@ -3,6 +3,7 @@ import { NextIntlClientProvider } from "next-intl";
import { getMessages, getTranslations } from "next-intl/server"; import { getMessages, getTranslations } from "next-intl/server";
import { routing } from "@/i18n/routing"; import { routing } from "@/i18n/routing";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { auth } from "@/lib/auth";
import { NavShell } from "@/components/layout/nav-shell"; import { NavShell } from "@/components/layout/nav-shell";
export function generateStaticParams() { export function generateStaticParams() {
@@ -44,12 +45,13 @@ export default async function LocaleLayout({
} }
const messages = await getMessages(); const messages = await getMessages();
const session = await auth();
return ( return (
<html lang={locale} className="dark"> <html lang={locale} className="dark">
<body className="min-h-screen bg-surface-0 text-text-primary antialiased"> <body className="min-h-screen bg-surface-0 text-text-primary antialiased">
<NextIntlClientProvider messages={messages}> <NextIntlClientProvider messages={messages}>
<NavShell>{children}</NavShell> <NavShell session={session}>{children}</NavShell>
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </body>
</html> </html>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useRef, forwardRef } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter, Link } from "@/i18n/navigation"; import { useRouter, Link } from "@/i18n/navigation";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
@@ -50,6 +50,30 @@ export default function RegisterPage() {
const [state, setState] = useState<FormState>("idle"); const [state, setState] = useState<FormState>("idle");
const [error, setError] = useState(""); 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 isPersonal = accountType === "personal";
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -146,8 +170,13 @@ export default function RegisterPage() {
className="grid grid-cols-2 gap-3 mb-6 animate-in animate-in-delay-1" className="grid grid-cols-2 gap-3 mb-6 animate-in animate-in-delay-1"
> >
<AccountTypeCard <AccountTypeCard
ref={(el) => {
cardRefs.current[0] = el;
}}
selected={accountType === "personal"} selected={accountType === "personal"}
onClick={() => setAccountType("personal")} onClick={() => setAccountType("personal")}
tabIndex={rovingTabIndex("personal", 0)}
onKeyDown={(e) => handleCardKeyDown(e, 0)}
label={t("personalCardTitle")} label={t("personalCardTitle")}
description={t("personalCardDescription")} description={t("personalCardDescription")}
icon={ icon={
@@ -168,8 +197,13 @@ export default function RegisterPage() {
} }
/> />
<AccountTypeCard <AccountTypeCard
ref={(el) => {
cardRefs.current[1] = el;
}}
selected={accountType === "company"} selected={accountType === "company"}
onClick={() => setAccountType("company")} onClick={() => setAccountType("company")}
tabIndex={rovingTabIndex("company", 1)}
onKeyDown={(e) => handleCardKeyDown(e, 1)}
label={t("companyCardTitle")} label={t("companyCardTitle")}
description={t("companyCardDescription")} description={t("companyCardDescription")}
icon={ icon={
@@ -305,41 +339,42 @@ export default function RegisterPage() {
* and text colours intensify when selected to give a clear "this one * and text colours intensify when selected to give a clear "this one
* is on" signal beyond just the border colour. * is on" signal beyond just the border colour.
*/ */
function AccountTypeCard({ const AccountTypeCard = forwardRef<
selected, HTMLButtonElement,
onClick, {
label, selected: boolean;
description, onClick: () => void;
icon, label: string;
}: { description: string;
selected: boolean; icon: React.ReactNode;
onClick: () => void; tabIndex: number;
label: string; onKeyDown: (e: React.KeyboardEvent) => void;
description: string; }
icon: React.ReactNode; >(function AccountTypeCard(
}) { { selected, onClick, label, description, icon, tabIndex, onKeyDown },
ref
) {
return ( return (
<button <button
ref={ref}
type="button" type="button"
role="radio" role="radio"
aria-checked={selected} aria-checked={selected}
tabIndex={tabIndex}
onClick={onClick} onClick={onClick}
onKeyDown={onKeyDown}
className={`text-left rounded-xl border p-4 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/40 ${ className={`text-left rounded-xl border p-4 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/40 ${
selected selected
? "border-accent bg-accent/10" ? "border-accent bg-accent/10"
: "border-border bg-surface-2 hover:border-accent/40 hover:bg-surface-3/30" : "border-border bg-surface-2 hover:border-accent/40 hover:bg-surface-3/30"
}`} }`}
> >
<div <div className={`mb-2 ${selected ? "text-accent" : "text-text-muted"}`}>
className={`mb-2 ${
selected ? "text-accent" : "text-text-muted"
}`}
>
{icon} {icon}
</div> </div>
<div <div
className={`text-sm font-semibold mb-0.5 ${ className={`text-sm font-semibold mb-0.5 ${
selected ? "text-text-primary" : "text-text-primary" selected ? "text-text-primary" : "text-text-secondary"
}`} }`}
> >
{label} {label}
@@ -347,4 +382,4 @@ function AccountTypeCard({
<div className="text-xs text-text-muted leading-snug">{description}</div> <div className="text-xs text-text-muted leading-snug">{description}</div>
</button> </button>
); );
} });

View File

@@ -6,6 +6,7 @@ import { signOut, useSession } from "next-auth/react";
import { usePathname } from "@/i18n/navigation"; import { usePathname } from "@/i18n/navigation";
import { Link } from "@/i18n/navigation"; import { Link } from "@/i18n/navigation";
import { SessionProvider } from "next-auth/react"; import { SessionProvider } from "next-auth/react";
import type { Session } from "next-auth";
import { LanguageSwitcher } from "@/components/ui/language-switcher"; import { LanguageSwitcher } from "@/components/ui/language-switcher";
function NavBar() { 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 ( return (
<SessionProvider> <SessionProvider session={session}>
<NavBar /> <NavBar />
<main className="mx-auto max-w-6xl px-5 py-8">{children}</main> <main className="mx-auto max-w-6xl px-5 py-8">{children}</main>
</SessionProvider> </SessionProvider>

View File

@@ -16,6 +16,9 @@ interface Props {
ariaLabel?: string; 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. * Portal-based modal.
* *
@@ -25,45 +28,86 @@ interface Props {
* ancestor's containing block, not the viewport, when ANY ancestor * ancestor's containing block, not the viewport, when ANY ancestor
* has a `transform`, `perspective`, or `filter` applied. Our * has a `transform`, `perspective`, or `filter` applied. Our
* `animate-in` utility sets `transform: translateY(0)` on a lot of * `animate-in` utility sets `transform: translateY(0)` on a lot of
* dashboard/tenant-detail containers (because of the fade-up * dashboard/tenant-detail containers, which broke modals rendered as
* animation, which uses `animation-fill-mode: both` to keep the * in-place children — they centred to the panel they lived in, not to
* transform on after the animation finishes). That broke modals * the page. Rendering at `document.body` via `createPortal` escapes
* rendered as in-place children — they centred to the panel they * every containing-block ancestor and gives us true viewport coords.
* lived in, not to the page.
* *
* Rendering at `document.body` via `createPortal` escapes every * UX / a11y details
* containing-block ancestor and gives us true viewport coordinates. * -----------------
* * - Backdrop click triggers `onClose` (only when the click target IS
* UX details * the backdrop, not the panel inside).
* ---------- * - Escape triggers `onClose`.
* - Backdrop click triggers `onClose`. (Bubbling check: only fires * - `body` overflow is locked while open so background content doesn't
* when the click target IS the backdrop, not the panel inside.) * scroll behind the modal.
* - Escape key triggers `onClose`. Standard modal expectation. * - Focus is moved into the panel on open, trapped within it while open
* - `body` overflow is locked while open so background content * (Tab / Shift+Tab cycle), and restored to the previously focused
* doesn't scroll behind the modal. * element on close — so keyboard and screen-reader users can't tab
* - Renders nothing on first paint server-side, then mounts on * out to the inert page behind the dialog.
* client. `useEffect` gating ensures `document.body` is available;
* without it Next.js SSR would throw on `document` reference.
*/ */
export function Modal({ open, onClose, children, ariaLabel }: Props) { export function Modal({ open, onClose, children, ariaLabel }: Props) {
const closeRef = useRef(onClose); const closeRef = useRef(onClose);
closeRef.current = onClose; closeRef.current = onClose;
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (!open) return; 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; const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden"; 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<HTMLElement>(FOCUSABLE))
: [];
(focusables[0] ?? panel)?.focus();
const onKey = (e: KeyboardEvent) => { 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<HTMLElement>(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); window.addEventListener("keydown", onKey);
return () => { return () => {
document.body.style.overflow = previousOverflow; document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", onKey); window.removeEventListener("keydown", onKey);
// Restore focus to the trigger (if it's still in the document).
if (previouslyFocused && document.contains(previouslyFocused)) {
previouslyFocused.focus();
}
}; };
}, [open]); }, [open]);
@@ -72,15 +116,19 @@ export function Modal({ open, onClose, children, ariaLabel }: Props) {
return createPortal( return createPortal(
<div <div
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={(e) => { onClick={(e) => {
if (e.target === e.currentTarget) onClose(); if (e.target === e.currentTarget) onClose();
}} }}
> >
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full max-h-[90vh] overflow-y-auto"> <div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
tabIndex={-1}
className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full max-h-[90vh] overflow-y-auto focus:outline-none"
>
{children} {children}
</div> </div>
</div>, </div>,