fix(onboarding): 44px touch targets across wizard CTAs + carousel pager

Bring every back / skip / next / chevron / pager button on the
v1.4.25 onboarding wizard up to the 44x44 px WCAG 2.5.8 floor. The
previous `size="sm"` buttons measured 32 px tall, the chevron icons
were 32 px square, and each dot pager target was 10 px square — all
well under the target-size minimum for thumb-driven flows.

Carousel slides now derive their accessible name from the slide
title via `aria-labelledby`, so the polite live region (which
mirrors "Slide N of M") is the single source for position
announcements. The previous markup announced position twice on every
swipe: once from the slide's own `aria-label`, again from the
live region.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 22:26:46 +02:00
1 parent a400e5407b
commit 4a7195a1bc
5 files changed
+50 -27

No files matched your search

+3 -2
View File
@@ -197,16 +197,16 @@ export function BaselineForm() {
</fieldset>
<div className="flex items-center justify-between gap-2 pt-2">
<Button asChild variant="ghost" size="sm">
<Button asChild variant="ghost" className="min-h-11 min-w-11">
<Link href="/onboarding/2">{t("onboarding.shell.back")}</Link>
</Button>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => advance({ saveProfile: false })}
disabled={saving}
className="min-h-11 min-w-11"
>
{t("onboarding.shell.skip")}
</Button>
@@ -214,6 +214,7 @@ export function BaselineForm() {
type="button"
onClick={() => advance({ saveProfile: true })}
disabled={saving}
className="min-h-11 min-w-11"
>
{saving ? <Loader2 className="size-4 animate-spin" /> : null}
{t("onboarding.baseline.saveCta")}
@@ -249,20 +249,25 @@ export function GoalsChipPicker({ userId }: GoalsChipPickerProps) {
</fieldset>
<div className="flex items-center justify-between gap-2 pt-2">
<Button asChild variant="ghost" size="sm">
<Button asChild variant="ghost" className="min-h-11 min-w-11">
<Link href="/onboarding/0">{t("onboarding.shell.back")}</Link>
</Button>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={advance}
disabled={advancing}
className="min-h-11 min-w-11"
>
{t("onboarding.shell.skip")}
</Button>
<Button type="button" onClick={advance} disabled={advancing}>
<Button
type="button"
onClick={advance}
disabled={advancing}
className="min-h-11 min-w-11"
>
{t("onboarding.shell.next")}
</Button>
</div>
@@ -135,7 +135,7 @@ export async function OnboardingShell({
<footer className="mt-8 flex items-center justify-between gap-2">
{backHref ? (
<Button asChild variant="ghost" size="sm">
<Button asChild variant="ghost" className="min-h-11 min-w-11">
<Link href={backHref}>{t("onboarding.shell.back")}</Link>
</Button>
) : (
@@ -144,12 +144,12 @@ export async function OnboardingShell({
<div className="flex items-center gap-2">
{skipHref ? (
<Button asChild variant="ghost" size="sm">
<Button asChild variant="ghost" className="min-h-11 min-w-11">
<Link href={skipHref}>{t("onboarding.shell.skip")}</Link>
</Button>
) : null}
{nextHref ? (
<Button asChild size="sm">
<Button asChild className="min-h-11 min-w-11">
<Link href={nextHref}>{primaryLabel}</Link>
</Button>
) : null}
+8 -3
View File
@@ -152,20 +152,25 @@ export function SourceCardGrid() {
</div>
<div className="flex items-center justify-between gap-2 pt-2">
<Button asChild variant="ghost" size="sm">
<Button asChild variant="ghost" className="min-h-11 min-w-11">
<Link href="/onboarding/1">{t("onboarding.shell.back")}</Link>
</Button>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={advance}
disabled={advancing}
className="min-h-11 min-w-11"
>
{t("onboarding.shell.skip")}
</Button>
<Button type="button" onClick={advance} disabled={advancing}>
<Button
type="button"
onClick={advance}
disabled={advancing}
className="min-h-11 min-w-11"
>
{t("onboarding.shell.next")}
</Button>
</div>
+28 -16
View File
@@ -181,10 +181,7 @@ export function WelcomeCarousel() {
data-slide-index={idx}
role="group"
aria-roledescription="slide"
aria-label={t("onboarding.welcome.slideOf", {
current: idx + 1,
total: SLIDES.length,
})}
aria-labelledby={`onboarding-welcome-slide-${slide.id}-title`}
className={cn(
"bg-card border-border flex w-full shrink-0 snap-start flex-col items-center gap-4",
"rounded-xl border p-6 text-center sm:p-8",
@@ -196,7 +193,10 @@ export function WelcomeCarousel() {
>
<slide.Icon className="size-8" />
</span>
<h2 className="text-lg font-semibold tracking-tight">
<h2
id={`onboarding-welcome-slide-${slide.id}-title`}
className="text-lg font-semibold tracking-tight"
>
{t(slide.titleKey)}
</h2>
<p className="text-muted-foreground text-sm leading-relaxed">
@@ -206,9 +206,11 @@ export function WelcomeCarousel() {
))}
</div>
{/* Live region announces slide changes to AT users — matches the
tour's pattern (tour.tsx:374) so the focus contract is
consistent across the onboarding surface. */}
{/* Single live region announces slide changes to AT users —
slides themselves carry `aria-label="slide N of total"` so a
second sr-only mirror would double-announce on every move.
The carousel-level region wins because it's tied to the
chevron + dot pager via `aria-controls`. */}
<p id={liveRegionId} aria-live="polite" className="sr-only">
{t("onboarding.welcome.slideOf", {
current: active + 1,
@@ -221,11 +223,12 @@ export function WelcomeCarousel() {
<Button
type="button"
variant="ghost"
size="icon-sm"
size="icon"
onClick={() => scrollToSlide(Math.max(0, active - 1))}
disabled={active === 0}
aria-label={t("onboarding.welcome.prevSlide")}
aria-controls={liveRegionId}
className="min-h-11 min-w-11"
>
<ChevronLeft className="size-4" />
</Button>
@@ -233,7 +236,7 @@ export function WelcomeCarousel() {
<div
role="tablist"
aria-label={t("onboarding.welcome.carouselLabel")}
className="flex items-center gap-2"
className="flex items-center"
>
{SLIDES.map((slide, idx) => (
<button
@@ -245,24 +248,32 @@ export function WelcomeCarousel() {
index: idx + 1,
})}
onClick={() => scrollToSlide(idx)}
className={cn(
"size-2.5 rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none",
active === idx ? "bg-primary" : "bg-muted hover:bg-muted-foreground/30",
)}
/>
className="inline-flex size-11 items-center justify-center"
>
<span
aria-hidden="true"
className={cn(
"size-2.5 rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none",
active === idx
? "bg-primary"
: "bg-muted hover:bg-muted-foreground/30",
)}
/>
</button>
))}
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
size="icon"
onClick={() =>
scrollToSlide(Math.min(SLIDES.length - 1, active + 1))
}
disabled={active === SLIDES.length - 1}
aria-label={t("onboarding.welcome.nextSlide")}
aria-controls={liveRegionId}
className="min-h-11 min-w-11"
>
<ChevronRight className="size-4" />
</Button>
@@ -274,6 +285,7 @@ export function WelcomeCarousel() {
size="lg"
onClick={handleGetStarted}
disabled={advancing}
className="min-h-11"
>
{t("onboarding.welcome.cta")}
</Button>