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 { 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 (
|
||||
<html lang={locale} className="dark">
|
||||
<body className="min-h-screen bg-surface-0 text-text-primary antialiased">
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<NavShell>{children}</NavShell>
|
||||
<NavShell session={session}>{children}</NavShell>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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<FormState>("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<HTMLInputElement>) => {
|
||||
@@ -146,8 +170,13 @@ export default function RegisterPage() {
|
||||
className="grid grid-cols-2 gap-3 mb-6 animate-in animate-in-delay-1"
|
||||
>
|
||||
<AccountTypeCard
|
||||
ref={(el) => {
|
||||
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() {
|
||||
}
|
||||
/>
|
||||
<AccountTypeCard
|
||||
ref={(el) => {
|
||||
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 (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
tabIndex={tabIndex}
|
||||
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 ${
|
||||
selected
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-border bg-surface-2 hover:border-accent/40 hover:bg-surface-3/30"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`mb-2 ${
|
||||
selected ? "text-accent" : "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
<div className={`mb-2 ${selected ? "text-accent" : "text-text-muted"}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div
|
||||
className={`text-sm font-semibold mb-0.5 ${
|
||||
selected ? "text-text-primary" : "text-text-primary"
|
||||
selected ? "text-text-primary" : "text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
@@ -347,4 +382,4 @@ function AccountTypeCard({
|
||||
<div className="text-xs text-text-muted leading-snug">{description}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user