keyboard radiogroup, modal focus trap, nav session hydration
All checks were successful
Build and Push / build (push) Successful in 1m53s
All checks were successful
Build and Push / build (push) Successful in 1m53s
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
|
||||||
description,
|
|
||||||
icon,
|
|
||||||
}: {
|
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
}) {
|
tabIndex: number;
|
||||||
|
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||||
|
}
|
||||||
|
>(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>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
Reference in New Issue
Block a user