A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.

aria labels

+7 -1
lib/components/BlueskyPostList.tsx
···
if (error)
return (
-
<div style={{ padding: 8, color: "crimson" }}>
+
<div role="alert" style={{ padding: 8, color: "crimson" }}>
Failed to load posts.
</div>
);
···
resolvedReplyHandle,
);
+
const postPreview = text.slice(0, 100);
+
const ariaLabel = text
+
? `Post by ${did}: ${postPreview}${text.length > 100 ? '...' : ''}`
+
: `Post by ${did}`;
+
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
+
aria-label={ariaLabel}
style={{
...listStyles.row,
color: `var(--atproto-color-text)`,
+1 -1
lib/components/GrainGallery.tsx
···
displayHandle ?? formatDidForLabel(resolvedDid ?? handleOrDid);
if (!displayHandle && resolvingIdentity) {
-
return loadingIndicator || <div style={{ padding: 8 }}>Resolving handle…</div>;
+
return loadingIndicator || <div role="status" aria-live="polite" style={{ padding: 8 }}>Resolving handle…</div>;
}
if (!displayHandle && resolutionError) {
return (
+8 -7
lib/renderers/BlueskyPostRenderer.tsx
···
if (error) {
return (
-
<div style={{ padding: 8, color: "crimson" }}>
+
<div role="alert" style={{ padding: 8, color: "crimson" }}>
Failed to load post.
</div>
);
}
-
if (loading && !record) return <div style={{ padding: 8 }}>Loading…</div>;
+
if (loading && !record) return <div role="status" aria-live="polite" style={{ padding: 8 }}>Loading…</div>;
const text = record.text;
const createdDate = new Date(record.createdAt);
···
</div>
);
-
const Avatar: React.FC<{ avatarUrl?: string }> = ({ avatarUrl }) =>
+
const Avatar: React.FC<{ avatarUrl?: string; name?: string }> = ({ avatarUrl, name }) =>
avatarUrl ? (
-
<img src={avatarUrl} alt="avatar" style={baseStyles.avatarImg} />
+
<img src={avatarUrl} alt={`${name || 'User'}'s profile picture`} style={baseStyles.avatarImg} />
) : (
-
<div style={baseStyles.avatarPlaceholder} aria-hidden />
+
<div style={baseStyles.avatarPlaceholder} aria-hidden="true" />
);
const ReplyInfo: React.FC<{
···
const ThreadLayout: React.FC<LayoutProps> = (props) => (
<div style={{ display: "flex", gap: 8, alignItems: "flex-start" }}>
-
<Avatar avatarUrl={props.avatarUrl} />
+
<Avatar avatarUrl={props.avatarUrl} name={props.authorDisplayName || props.authorHandle} />
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
···
const DefaultLayout: React.FC<LayoutProps> = (props) => (
<>
<header style={baseStyles.header}>
-
<Avatar avatarUrl={props.avatarUrl} />
+
<Avatar avatarUrl={props.avatarUrl} name={props.authorDisplayName || props.authorHandle} />
<AuthorInfo
primaryName={props.primaryName}
authorDisplayName={props.authorDisplayName}
···
<img src={url} alt={alt} style={imagesBase.img} />
) : (
<div
+
role={error ? "alert" : "status"}
style={{
...imagesBase.placeholder,
color: `var(--atproto-color-text-muted)`,
+4 -4
lib/renderers/BlueskyProfileRenderer.tsx
···
if (error)
return (
-
<div style={{ padding: 8, color: "crimson" }}>
+
<div role="alert" style={{ padding: 8, color: "crimson" }}>
Failed to load profile.
</div>
);
-
if (loading && !record) return <div style={{ padding: 8 }}>Loading…</div>;
+
if (loading && !record) return <div role="status" aria-live="polite" style={{ padding: 8 }}>Loading…</div>;
const profileUrl = `${blueskyAppBaseUrl}/profile/${did}`;
const rawWebsite = record.website?.trim();
···
<div style={{ ...base.card, background: `var(--atproto-color-bg)`, borderColor: `var(--atproto-color-border)`, color: `var(--atproto-color-text)` }}>
<div style={base.header}>
{avatarUrl ? (
-
<img src={avatarUrl} alt="avatar" style={base.avatarImg} />
+
<img src={avatarUrl} alt={`${record.displayName || handle || did}'s profile picture`} style={base.avatarImg} />
) : (
<div
style={{ ...base.avatar, background: `var(--atproto-color-bg-elevated)` }}
-
aria-label="avatar"
+
aria-hidden="true"
/>
)}
<div style={{ flex: 1 }}>
+10 -4
lib/renderers/CurrentlyPlayingRenderer.tsx
···
if (error)
return (
-
<div style={{ padding: 8, color: "var(--atproto-color-error)" }}>
+
<div role="alert" style={{ padding: 8, color: "var(--atproto-color-error)" }}>
Failed to load status.
</div>
);
if (loading && !record)
return (
-
<div style={{ padding: 8, color: "var(--atproto-color-text-secondary)" }}>
+
<div role="status" aria-live="polite" style={{ padding: 8, color: "var(--atproto-color-text-secondary)" }}>
Loading…
</div>
);
···
{/* Platform Selection Modal */}
{showPlatformModal && songlinkData && (
<div style={styles.modalOverlay} onClick={() => setShowPlatformModal(false)}>
-
<div style={styles.modalContent} onClick={(e) => e.stopPropagation()}>
+
<div
+
role="dialog"
+
aria-modal="true"
+
aria-labelledby="platform-modal-title"
+
style={styles.modalContent}
+
onClick={(e) => e.stopPropagation()}
+
>
<div style={styles.modalHeader}>
-
<h3 style={styles.modalTitle}>Choose your streaming service</h3>
+
<h3 id="platform-modal-title" style={styles.modalTitle}>Choose your streaming service</h3>
<button
style={styles.closeButton}
onClick={() => setShowPlatformModal(false)}
+21 -9
lib/renderers/GrainGalleryRenderer.tsx
···
if (error) {
return (
-
<div style={{ padding: 8, color: "crimson" }}>
+
<div role="alert" style={{ padding: 8, color: "crimson" }}>
Failed to load gallery.
</div>
);
}
if (loading && photos.length === 0) {
-
return <div style={{ padding: 8 }}>Loading gallery…</div>;
+
return <div role="status" aria-live="polite" style={{ padding: 8 }}>Loading gallery…</div>;
}
return (
···
<article style={styles.card}>
<header style={styles.header}>
{avatarUrl ? (
-
<img src={avatarUrl} alt="avatar" style={styles.avatarImg} />
+
<img src={avatarUrl} alt={`${authorDisplayName || authorHandle || 'User'}'s profile picture`} style={styles.avatarImg} />
) : (
<div style={styles.avatarPlaceholder} aria-hidden />
)}
···
return (
<div
+
role="dialog"
+
aria-modal="true"
+
aria-label={`Photo ${photoIndex + 1} of ${totalPhotos}`}
style={{
position: "fixed",
top: 0,
···
}}
onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.2)")}
onMouseLeave={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.1)")}
-
aria-label="Previous photo"
+
aria-label={`Previous photo (${photoIndex} of ${totalPhotos})`}
>
</button>
···
}}
onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.2)")}
onMouseLeave={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.1)")}
-
aria-label="Next photo"
+
aria-label={`Next photo (${photoIndex + 2} of ${totalPhotos})`}
>
</button>
···
return (
<figure style={{ ...(isSingle ? styles.singlePhotoItem : styles.photoItem), ...gridItemStyle }}>
-
<div
+
<button
+
onClick={onClick}
+
aria-label={hasAlt ? `View photo: ${alt}` : "View photo"}
style={{
...(isSingle ? styles.singlePhotoMedia : styles.photoContainer),
background: `var(--atproto-color-image-bg)`,
// Only apply aspect ratio for single photos; grid photos fill their cells
...(isSingle && aspect ? { aspectRatio: aspect } : {}),
cursor: onClick ? "pointer" : "default",
+
border: "none",
+
padding: 0,
+
display: "block",
+
width: "100%",
}}
-
onClick={onClick}
>
{url ? (
<img src={url} alt={alt} style={isSingle ? styles.photo : styles.photoGrid} />
···
)}
{hasAlt && (
<button
-
onClick={() => setShowAltText(!showAltText)}
+
onClick={(e) => {
+
e.stopPropagation();
+
setShowAltText(!showAltText);
+
}}
style={{
...styles.altBadge,
background: showAltText
···
}}
title="Toggle alt text"
aria-label="Toggle alt text"
+
aria-pressed={showAltText}
>
ALT
</button>
)}
-
</div>
+
</button>
{hasAlt && showAltText && (
<figcaption
style={{
+2 -2
lib/renderers/TangledRepoRenderer.tsx
···
if (error)
return (
-
<div style={{ padding: 8, color: "crimson" }}>
+
<div role="alert" style={{ padding: 8, color: "crimson" }}>
Failed to load repository.
</div>
);
-
if (loading && !record) return <div style={{ padding: 8 }}>Loading…</div>;
+
if (loading && !record) return <div role="status" aria-live="polite" style={{ padding: 8 }}>Loading…</div>;
// Construct the canonical URL: tangled.org/@[did]/[repo-name]
const viewUrl =