Quote Block accessibility and error handling

This commit is contained in:
adilallo
2025-08-26 10:14:05 -06:00
parent 98b46e7a9d
commit 28b788c584
+120 -21
View File
@@ -1,6 +1,6 @@
"use client";
import React from "react";
import React, { useState } from "react";
import Image from "next/image";
import QuoteDecor from "./QuoteDecor";
@@ -11,7 +11,13 @@ const QuoteBlock = ({
author = "Jo Freeman",
source = "The Tyranny of Structurelessness",
avatarSrc = "assets/Quote_Avatar.svg",
id,
fallbackAvatarSrc = "assets/Quote_Avatar.svg", // Fallback avatar
onError, // Error callback
}) => {
const [imageError, setImageError] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
// Variant configurations
const variants = {
compact: {
@@ -63,8 +69,60 @@ const QuoteBlock = ({
const config = variants[variant] || variants.standard;
// Use provided ID or generate a stable one based on content
const baseId = id || `quote-${author.toLowerCase().replace(/\s+/g, "-")}`;
const quoteId = `${baseId}-content`;
const authorId = `${baseId}-author`;
// Error handling functions
const handleImageError = (error) => {
console.warn(
`QuoteBlock: Failed to load avatar image for ${author}:`,
error
);
setImageError(true);
setImageLoading(false);
// Call error callback if provided
if (onError) {
onError({
type: "image_load_error",
message: `Failed to load avatar for ${author}`,
author,
avatarSrc,
error,
});
}
};
const handleImageLoad = () => {
setImageLoading(false);
setImageError(false);
};
// Validate required props
if (!quote || !author) {
console.error("QuoteBlock: Missing required props (quote or author)");
if (onError) {
onError({
type: "missing_props",
message: "QuoteBlock requires quote and author props",
quote: !!quote,
author: !!author,
});
}
return null; // Don't render if missing required props
}
// Determine which avatar to use
const currentAvatarSrc = imageError ? fallbackAvatarSrc : avatarSrc;
return (
<div className={`${config.container} ${className}`}>
<section
className={`${config.container} ${className}`}
aria-labelledby={quoteId}
role="region"
>
<div
className={`${config.card} bg-[var(--color-surface-default-brand-darker-accent)] relative overflow-hidden`}
>
@@ -74,19 +132,57 @@ const QuoteBlock = ({
className="pointer-events-none absolute z-0
left-0 top-0
w-full h-full"
aria-hidden="true"
/>
)}
<div className={`flex flex-col ${config.gap} relative z-10`}>
<div className={`flex flex-col ${config.avatarGap}`}>
<Image
src={avatarSrc}
alt={`${author} Avatar`}
width={64}
height={64}
className={`filter sepia ${config.avatar}`}
/>
<blockquote>
{/* Avatar with error handling */}
<div className="relative">
<Image
src={currentAvatarSrc}
alt={`Portrait of ${author}`}
width={64}
height={64}
className={`filter sepia ${
config.avatar
} transition-opacity duration-300 ${
imageLoading ? "opacity-0" : "opacity-100"
}`}
loading="lazy"
onError={handleImageError}
onLoad={handleImageLoad}
/>
{/* Loading state */}
{imageLoading && (
<div
className={`absolute inset-0 bg-gray-200 animate-pulse rounded-full ${config.avatar}`}
/>
)}
{/* Error state - show initials */}
{imageError && !imageLoading && (
<div
className={`flex items-center justify-center bg-gray-300 rounded-full ${config.avatar} text-gray-600 font-bold`}
>
<span className="text-sm md:text-base lg:text-lg xl:text-xl">
{author
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()}
</span>
</div>
)}
</div>
<blockquote
id={quoteId}
aria-labelledby={authorId}
className="relative"
>
<p
className={`font-bricolage-grotesque font-normal ${config.quote} text-[var(--color-content-inverse-primary)] -indent-[0.5em] relative before:content-['\\201C'] after:content-['\\201D'] before:mr-[0.05em] after:ml-[0.05em] before:[font-family:var(--font-bricolage-grotesque)] after:[font-family:var(--font-bricolage-grotesque)]`}
>
@@ -94,21 +190,24 @@ const QuoteBlock = ({
</p>
</blockquote>
</div>
<div className="flex flex-col gap-[var(--spacing-scale-008)] md:gap-[var(--spacing-scale-012)] xl:gap-[var(--spacing-scale-020)]">
<p
className={`font-inter font-normal ${config.author} text-[var(--color-content-inverse-primary)] uppercase`}
<footer className="flex flex-col gap-[var(--spacing-scale-008)] md:gap-[var(--spacing-scale-012)] xl:gap-[var(--spacing-scale-020)]">
<cite
id={authorId}
className={`font-inter font-normal ${config.author} text-[var(--color-content-inverse-primary)] uppercase not-italic`}
>
{author}
</p>
<p
className={`font-inter font-normal ${config.source} text-[var(--color-content-inverse-primary)] uppercase -indent-[0.5em] relative before:content-['\\201C'] after:content-['\\201D'] before:mr-[0.05em] after:ml-[0.05em] before:[font-family:var(--font-inter)] after:[font-family:var(--font-inter)]`}
>
{source}
</p>
</div>
</cite>
{source && (
<p
className={`font-inter font-normal ${config.source} text-[var(--color-content-inverse-primary)] uppercase -indent-[0.5em] relative before:content-['\\201C'] after:content-['\\201D'] before:mr-[0.05em] after:ml-[0.05em] before:[font-family:var(--font-inter)] after:[font-family:var(--font-inter)]`}
>
{source}
</p>
)}
</footer>
</div>
</div>
</div>
</section>
);
};