feat(a11y): WCAG AA polish on the site onboarding flow
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
- index.css: add visible :focus-visible rings for .site-card-button and .site-button so keyboard users can see where focus lives - SiteCard: drop outline:none, add aria-pressed and aria-label so screen readers announce selection state - SiteButton: add the .site-button class for the shared focus ring - SiteDisclaimerScreen: keyboard skip via Enter / Space / Escape, a role="region" + aria-label wrapper and aria-live="polite" on the message; honour prefers-reduced-motion on the fade - IntroVideoPlayer: role="region" with a skip hint in aria-label, preload="auto", and aria-hidden on the decorative caption span
This commit is contained in:
@@ -21,6 +21,7 @@ export function SiteButton({
|
|||||||
onMouseDown={() => setIsPressed(true)}
|
onMouseDown={() => setIsPressed(true)}
|
||||||
onMouseUp={() => setIsPressed(false)}
|
onMouseUp={() => setIsPressed(false)}
|
||||||
onMouseLeave={() => setIsPressed(false)}
|
onMouseLeave={() => setIsPressed(false)}
|
||||||
|
className="site-button"
|
||||||
style={{
|
style={{
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
padding: "12px 20px",
|
padding: "12px 20px",
|
||||||
|
|||||||
@@ -22,23 +22,18 @@ export function SiteCard({
|
|||||||
return "#b8b8b8";
|
return "#b8b8b8";
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBorder = (): string => {
|
const borderColor = selected ? "#a8d5a2" : "rgba(255, 255, 255, 0.55)";
|
||||||
if (selected) return "3px solid #a8d5a2";
|
|
||||||
if (isSituation) return "3px solid rgba(255, 255, 255, 0.55)";
|
|
||||||
if (disabled) return "3px solid rgba(255, 255, 255, 0.55)";
|
|
||||||
return "3px solid rgba(255, 255, 255, 0.55)";
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTextColor = (): string => {
|
const textColor = disabled ? "rgba(77, 77, 77, 0.72)" : "#4d4d4d";
|
||||||
if (disabled) return "rgba(77, 77, 77, 0.72)";
|
|
||||||
return "#4d4d4d";
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
aria-pressed={selected}
|
||||||
|
aria-label={label}
|
||||||
|
className="site-card-button"
|
||||||
style={{
|
style={{
|
||||||
width: isSituation
|
width: isSituation
|
||||||
? "clamp(220px, 24vw, 300px)"
|
? "clamp(220px, 24vw, 300px)"
|
||||||
@@ -46,21 +41,20 @@ export function SiteCard({
|
|||||||
height: isSituation
|
height: isSituation
|
||||||
? "clamp(48px, 6vw, 60px)"
|
? "clamp(48px, 6vw, 60px)"
|
||||||
: "clamp(140px, 18vw, 180px)",
|
: "clamp(140px, 18vw, 180px)",
|
||||||
border: getBorder(),
|
border: `3px solid ${borderColor}`,
|
||||||
background: getBackground(),
|
background: getBackground(),
|
||||||
cursor: disabled ? "not-allowed" : "pointer",
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
transition: "all 0.15s ease",
|
transition: "all 0.15s ease",
|
||||||
outline: "none",
|
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!imagePath && (
|
{!imagePath && (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
color: getTextColor(),
|
color: textColor,
|
||||||
fontSize: isSituation
|
fontSize: isSituation
|
||||||
? "clamp(14px, 1.8vw, 22px)"
|
? "clamp(14px, 1.8vw, 22px)"
|
||||||
: "clamp(10px, 1.5vw, 14px)",
|
: "clamp(10px, 1.5vw, 14px)",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useSiteStore } from "@/managers/stores/useSiteStore";
|
import { useSiteStore } from "@/managers/stores/useSiteStore";
|
||||||
|
import { usePrefersReducedMotion } from "@/hooks/ui/usePrefersReducedMotion";
|
||||||
|
|
||||||
const DISCLAIMER_TEXT =
|
const DISCLAIMER_TEXT =
|
||||||
"Ce site a été conçu pour être utilisé sur ordinateur.\nPour une meilleure expérience, assurez-vous d'avoir une bonne connexion internet et une machine performante.";
|
"Ce site a été conçu pour être utilisé sur ordinateur.\nPour une meilleure expérience, assurez-vous d'avoir une bonne connexion internet et une machine performante.";
|
||||||
@@ -7,13 +8,15 @@ const DISCLAIMER_TEXT =
|
|||||||
const TEXT_DISPLAY_DURATION = 5000;
|
const TEXT_DISPLAY_DURATION = 5000;
|
||||||
const FADE_OUT_DURATION = 1000;
|
const FADE_OUT_DURATION = 1000;
|
||||||
const TRANSITION_DELAY = 250;
|
const TRANSITION_DELAY = 250;
|
||||||
|
const SKIP_KEYS = new Set(["Enter", " ", "Escape"]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Screen 0: Disclaimer
|
* Screen 0: Disclaimer
|
||||||
*/
|
*/
|
||||||
export function SiteDisclaimerScreen(): React.JSX.Element {
|
export function SiteDisclaimerScreen(): React.JSX.Element {
|
||||||
const setStep = useSiteStore((state) => state.setStep);
|
const setStep = useSiteStore((state) => state.setStep);
|
||||||
const [textOpacity, setTextOpacity] = useState(0);
|
const prefersReducedMotion = usePrefersReducedMotion();
|
||||||
|
const [textOpacity, setTextOpacity] = useState(prefersReducedMotion ? 1 : 0);
|
||||||
const hasSkipped = useRef(false);
|
const hasSkipped = useRef(false);
|
||||||
|
|
||||||
const handleSkip = useCallback(() => {
|
const handleSkip = useCallback(() => {
|
||||||
@@ -23,33 +26,40 @@ export function SiteDisclaimerScreen(): React.JSX.Element {
|
|||||||
}, [setStep]);
|
}, [setStep]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Fade in text
|
|
||||||
const fadeInTimeout = window.setTimeout(() => {
|
const fadeInTimeout = window.setTimeout(() => {
|
||||||
setTextOpacity(1);
|
setTextOpacity(1);
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
// Start fade out after display duration
|
|
||||||
const fadeOutTimeout = window.setTimeout(() => {
|
const fadeOutTimeout = window.setTimeout(() => {
|
||||||
setTextOpacity(0);
|
setTextOpacity(0);
|
||||||
}, TEXT_DISPLAY_DURATION);
|
}, TEXT_DISPLAY_DURATION);
|
||||||
|
|
||||||
// Transition to welcome after fade out + delay
|
|
||||||
const transitionTimeout = window.setTimeout(
|
const transitionTimeout = window.setTimeout(
|
||||||
() => {
|
handleSkip,
|
||||||
handleSkip();
|
|
||||||
},
|
|
||||||
TEXT_DISPLAY_DURATION + FADE_OUT_DURATION + TRANSITION_DELAY,
|
TEXT_DISPLAY_DURATION + FADE_OUT_DURATION + TRANSITION_DELAY,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||||
|
if (SKIP_KEYS.has(event.key)) {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSkip();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(fadeInTimeout);
|
window.clearTimeout(fadeInTimeout);
|
||||||
window.clearTimeout(fadeOutTimeout);
|
window.clearTimeout(fadeOutTimeout);
|
||||||
window.clearTimeout(transitionTimeout);
|
window.clearTimeout(transitionTimeout);
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [handleSkip]);
|
}, [handleSkip]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label="Avertissement"
|
||||||
onClick={handleSkip}
|
onClick={handleSkip}
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
@@ -63,6 +73,7 @@ export function SiteDisclaimerScreen(): React.JSX.Element {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
|
aria-live="polite"
|
||||||
style={{
|
style={{
|
||||||
color: "#F2F2F2",
|
color: "#F2F2F2",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
@@ -72,7 +83,9 @@ export function SiteDisclaimerScreen(): React.JSX.Element {
|
|||||||
lineHeight: 1.6,
|
lineHeight: 1.6,
|
||||||
maxWidth: 800,
|
maxWidth: 800,
|
||||||
opacity: textOpacity,
|
opacity: textOpacity,
|
||||||
transition: `opacity ${FADE_OUT_DURATION}ms ease-in-out`,
|
transition: prefersReducedMotion
|
||||||
|
? "none"
|
||||||
|
: `opacity ${FADE_OUT_DURATION}ms ease-in-out`,
|
||||||
whiteSpace: "pre-wrap",
|
whiteSpace: "pre-wrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { useCallback, useRef, useEffect } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
|
||||||
const INTRO_VIDEO_PATH = "/cinematics/intro.mp4";
|
const INTRO_VIDEO_PATH = "/cinematics/intro.mp4";
|
||||||
|
const SKIP_KEYS = new Set(["Enter", " "]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-screen video player for intro cinematic
|
* Full-screen video player for the intro cinematic.
|
||||||
* - Plays intro.mp4 in fullscreen
|
* Advances to the dialogue-intro step when the video ends or the user skips.
|
||||||
* - Automatically advances to dialogue-intro step when video ends
|
|
||||||
* - Allows skipping with Enter/Space/Click
|
|
||||||
*/
|
*/
|
||||||
export function IntroVideoPlayer(): React.JSX.Element {
|
export function IntroVideoPlayer(): React.JSX.Element {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
@@ -18,16 +17,13 @@ export function IntroVideoPlayer(): React.JSX.Element {
|
|||||||
}, [setIntroStep]);
|
}, [setIntroStep]);
|
||||||
|
|
||||||
const handleSkip = useCallback(() => {
|
const handleSkip = useCallback(() => {
|
||||||
if (videoRef.current) {
|
videoRef.current?.pause();
|
||||||
videoRef.current.pause();
|
|
||||||
}
|
|
||||||
setIntroStep("dialogue-intro");
|
setIntroStep("dialogue-intro");
|
||||||
}, [setIntroStep]);
|
}, [setIntroStep]);
|
||||||
|
|
||||||
// Handle keyboard skip (Enter/Space)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
if (SKIP_KEYS.has(event.key)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handleSkip();
|
handleSkip();
|
||||||
}
|
}
|
||||||
@@ -39,6 +35,8 @@ export function IntroVideoPlayer(): React.JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label="Vidéo d'introduction. Appuyez sur Entrée pour passer."
|
||||||
onClick={handleSkip}
|
onClick={handleSkip}
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
@@ -56,6 +54,7 @@ export function IntroVideoPlayer(): React.JSX.Element {
|
|||||||
src={INTRO_VIDEO_PATH}
|
src={INTRO_VIDEO_PATH}
|
||||||
autoPlay
|
autoPlay
|
||||||
playsInline
|
playsInline
|
||||||
|
preload="auto"
|
||||||
onEnded={handleVideoEnd}
|
onEnded={handleVideoEnd}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -64,6 +63,7 @@ export function IntroVideoPlayer(): React.JSX.Element {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
bottom: 32,
|
bottom: 32,
|
||||||
|
|||||||
@@ -44,6 +44,18 @@ select {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Site onboarding — accessible focus rings (WCAG 2.4.7) */
|
||||||
|
.site-card-button:focus-visible,
|
||||||
|
.site-button:focus-visible {
|
||||||
|
outline: 3px solid #ffffff;
|
||||||
|
outline-offset: 3px;
|
||||||
|
box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-card-button[aria-pressed="true"]:focus-visible {
|
||||||
|
outline-color: #a8d5a2;
|
||||||
|
}
|
||||||
|
|
||||||
canvas {
|
canvas {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user