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

aria labels

+53 -28
+7 -1
lib/components/BlueskyPostList.tsx
··· 73 73 74 74 if (error) 75 75 return ( 76 - <div style={{ padding: 8, color: "crimson" }}> 76 + <div role="alert" style={{ padding: 8, color: "crimson" }}> 77 77 Failed to load posts. 78 78 </div> 79 79 ); ··· 242 242 resolvedReplyHandle, 243 243 ); 244 244 245 + const postPreview = text.slice(0, 100); 246 + const ariaLabel = text 247 + ? `Post by ${did}: ${postPreview}${text.length > 100 ? '...' : ''}` 248 + : `Post by ${did}`; 249 + 245 250 return ( 246 251 <a 247 252 href={href} 248 253 target="_blank" 249 254 rel="noopener noreferrer" 255 + aria-label={ariaLabel} 250 256 style={{ 251 257 ...listStyles.row, 252 258 color: `var(--atproto-color-text)`,
+1 -1
lib/components/GrainGallery.tsx
··· 283 283 displayHandle ?? formatDidForLabel(resolvedDid ?? handleOrDid); 284 284 285 285 if (!displayHandle && resolvingIdentity) { 286 - return loadingIndicator || <div style={{ padding: 8 }}>Resolving handle…</div>; 286 + return loadingIndicator || <div role="status" aria-live="polite" style={{ padding: 8 }}>Resolving handle…</div>; 287 287 } 288 288 if (!displayHandle && resolutionError) { 289 289 return (
+8 -7
lib/renderers/BlueskyPostRenderer.tsx
··· 56 56 57 57 if (error) { 58 58 return ( 59 - <div style={{ padding: 8, color: "crimson" }}> 59 + <div role="alert" style={{ padding: 8, color: "crimson" }}> 60 60 Failed to load post. 61 61 </div> 62 62 ); 63 63 } 64 - if (loading && !record) return <div style={{ padding: 8 }}>Loading…</div>; 64 + if (loading && !record) return <div role="status" aria-live="polite" style={{ padding: 8 }}>Loading…</div>; 65 65 66 66 const text = record.text; 67 67 const createdDate = new Date(record.createdAt); ··· 181 181 </div> 182 182 ); 183 183 184 - const Avatar: React.FC<{ avatarUrl?: string }> = ({ avatarUrl }) => 184 + const Avatar: React.FC<{ avatarUrl?: string; name?: string }> = ({ avatarUrl, name }) => 185 185 avatarUrl ? ( 186 - <img src={avatarUrl} alt="avatar" style={baseStyles.avatarImg} /> 186 + <img src={avatarUrl} alt={`${name || 'User'}'s profile picture`} style={baseStyles.avatarImg} /> 187 187 ) : ( 188 - <div style={baseStyles.avatarPlaceholder} aria-hidden /> 188 + <div style={baseStyles.avatarPlaceholder} aria-hidden="true" /> 189 189 ); 190 190 191 191 const ReplyInfo: React.FC<{ ··· 278 278 279 279 const ThreadLayout: React.FC<LayoutProps> = (props) => ( 280 280 <div style={{ display: "flex", gap: 8, alignItems: "flex-start" }}> 281 - <Avatar avatarUrl={props.avatarUrl} /> 281 + <Avatar avatarUrl={props.avatarUrl} name={props.authorDisplayName || props.authorHandle} /> 282 282 <div style={{ flex: 1, minWidth: 0 }}> 283 283 <div 284 284 style={{ ··· 326 326 const DefaultLayout: React.FC<LayoutProps> = (props) => ( 327 327 <> 328 328 <header style={baseStyles.header}> 329 - <Avatar avatarUrl={props.avatarUrl} /> 329 + <Avatar avatarUrl={props.avatarUrl} name={props.authorDisplayName || props.authorHandle} /> 330 330 <AuthorInfo 331 331 primaryName={props.primaryName} 332 332 authorDisplayName={props.authorDisplayName} ··· 563 563 <img src={url} alt={alt} style={imagesBase.img} /> 564 564 ) : ( 565 565 <div 566 + role={error ? "alert" : "status"} 566 567 style={{ 567 568 ...imagesBase.placeholder, 568 569 color: `var(--atproto-color-text-muted)`,
+4 -4
lib/renderers/BlueskyProfileRenderer.tsx
··· 24 24 25 25 if (error) 26 26 return ( 27 - <div style={{ padding: 8, color: "crimson" }}> 27 + <div role="alert" style={{ padding: 8, color: "crimson" }}> 28 28 Failed to load profile. 29 29 </div> 30 30 ); 31 - if (loading && !record) return <div style={{ padding: 8 }}>Loading…</div>; 31 + if (loading && !record) return <div role="status" aria-live="polite" style={{ padding: 8 }}>Loading…</div>; 32 32 33 33 const profileUrl = `${blueskyAppBaseUrl}/profile/${did}`; 34 34 const rawWebsite = record.website?.trim(); ··· 45 45 <div style={{ ...base.card, background: `var(--atproto-color-bg)`, borderColor: `var(--atproto-color-border)`, color: `var(--atproto-color-text)` }}> 46 46 <div style={base.header}> 47 47 {avatarUrl ? ( 48 - <img src={avatarUrl} alt="avatar" style={base.avatarImg} /> 48 + <img src={avatarUrl} alt={`${record.displayName || handle || did}'s profile picture`} style={base.avatarImg} /> 49 49 ) : ( 50 50 <div 51 51 style={{ ...base.avatar, background: `var(--atproto-color-bg-elevated)` }} 52 - aria-label="avatar" 52 + aria-hidden="true" 53 53 /> 54 54 )} 55 55 <div style={{ flex: 1 }}>
+10 -4
lib/renderers/CurrentlyPlayingRenderer.tsx
··· 219 219 220 220 if (error) 221 221 return ( 222 - <div style={{ padding: 8, color: "var(--atproto-color-error)" }}> 222 + <div role="alert" style={{ padding: 8, color: "var(--atproto-color-error)" }}> 223 223 Failed to load status. 224 224 </div> 225 225 ); 226 226 if (loading && !record) 227 227 return ( 228 - <div style={{ padding: 8, color: "var(--atproto-color-text-secondary)" }}> 228 + <div role="status" aria-live="polite" style={{ padding: 8, color: "var(--atproto-color-text-secondary)" }}> 229 229 Loading… 230 230 </div> 231 231 ); ··· 386 386 {/* Platform Selection Modal */} 387 387 {showPlatformModal && songlinkData && ( 388 388 <div style={styles.modalOverlay} onClick={() => setShowPlatformModal(false)}> 389 - <div style={styles.modalContent} onClick={(e) => e.stopPropagation()}> 389 + <div 390 + role="dialog" 391 + aria-modal="true" 392 + aria-labelledby="platform-modal-title" 393 + style={styles.modalContent} 394 + onClick={(e) => e.stopPropagation()} 395 + > 390 396 <div style={styles.modalHeader}> 391 - <h3 style={styles.modalTitle}>Choose your streaming service</h3> 397 + <h3 id="platform-modal-title" style={styles.modalTitle}>Choose your streaming service</h3> 392 398 <button 393 399 style={styles.closeButton} 394 400 onClick={() => setShowPlatformModal(false)}
+21 -9
lib/renderers/GrainGalleryRenderer.tsx
··· 121 121 122 122 if (error) { 123 123 return ( 124 - <div style={{ padding: 8, color: "crimson" }}> 124 + <div role="alert" style={{ padding: 8, color: "crimson" }}> 125 125 Failed to load gallery. 126 126 </div> 127 127 ); 128 128 } 129 129 130 130 if (loading && photos.length === 0) { 131 - return <div style={{ padding: 8 }}>Loading gallery…</div>; 131 + return <div role="status" aria-live="polite" style={{ padding: 8 }}>Loading gallery…</div>; 132 132 } 133 133 134 134 return ( ··· 155 155 <article style={styles.card}> 156 156 <header style={styles.header}> 157 157 {avatarUrl ? ( 158 - <img src={avatarUrl} alt="avatar" style={styles.avatarImg} /> 158 + <img src={avatarUrl} alt={`${authorDisplayName || authorHandle || 'User'}'s profile picture`} style={styles.avatarImg} /> 159 159 ) : ( 160 160 <div style={styles.avatarPlaceholder} aria-hidden /> 161 161 )} ··· 511 511 512 512 return ( 513 513 <div 514 + role="dialog" 515 + aria-modal="true" 516 + aria-label={`Photo ${photoIndex + 1} of ${totalPhotos}`} 514 517 style={{ 515 518 position: "fixed", 516 519 top: 0, ··· 580 583 }} 581 584 onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.2)")} 582 585 onMouseLeave={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.1)")} 583 - aria-label="Previous photo" 586 + aria-label={`Previous photo (${photoIndex} of ${totalPhotos})`} 584 587 > 585 588 586 589 </button> ··· 613 616 }} 614 617 onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.2)")} 615 618 onMouseLeave={(e) => (e.currentTarget.style.background = "rgba(255, 255, 255, 0.1)")} 616 - aria-label="Next photo" 619 + aria-label={`Next photo (${photoIndex + 2} of ${totalPhotos})`} 617 620 > 618 621 619 622 </button> ··· 705 708 706 709 return ( 707 710 <figure style={{ ...(isSingle ? styles.singlePhotoItem : styles.photoItem), ...gridItemStyle }}> 708 - <div 711 + <button 712 + onClick={onClick} 713 + aria-label={hasAlt ? `View photo: ${alt}` : "View photo"} 709 714 style={{ 710 715 ...(isSingle ? styles.singlePhotoMedia : styles.photoContainer), 711 716 background: `var(--atproto-color-image-bg)`, 712 717 // Only apply aspect ratio for single photos; grid photos fill their cells 713 718 ...(isSingle && aspect ? { aspectRatio: aspect } : {}), 714 719 cursor: onClick ? "pointer" : "default", 720 + border: "none", 721 + padding: 0, 722 + display: "block", 723 + width: "100%", 715 724 }} 716 - onClick={onClick} 717 725 > 718 726 {url ? ( 719 727 <img src={url} alt={alt} style={isSingle ? styles.photo : styles.photoGrid} /> ··· 733 741 )} 734 742 {hasAlt && ( 735 743 <button 736 - onClick={() => setShowAltText(!showAltText)} 744 + onClick={(e) => { 745 + e.stopPropagation(); 746 + setShowAltText(!showAltText); 747 + }} 737 748 style={{ 738 749 ...styles.altBadge, 739 750 background: showAltText ··· 745 756 }} 746 757 title="Toggle alt text" 747 758 aria-label="Toggle alt text" 759 + aria-pressed={showAltText} 748 760 > 749 761 ALT 750 762 </button> 751 763 )} 752 - </div> 764 + </button> 753 765 {hasAlt && showAltText && ( 754 766 <figcaption 755 767 style={{
+2 -2
lib/renderers/TangledRepoRenderer.tsx
··· 84 84 85 85 if (error) 86 86 return ( 87 - <div style={{ padding: 8, color: "crimson" }}> 87 + <div role="alert" style={{ padding: 8, color: "crimson" }}> 88 88 Failed to load repository. 89 89 </div> 90 90 ); 91 - if (loading && !record) return <div style={{ padding: 8 }}>Loading…</div>; 91 + if (loading && !record) return <div role="status" aria-live="polite" style={{ padding: 8 }}>Loading…</div>; 92 92 93 93 // Construct the canonical URL: tangled.org/@[did]/[repo-name] 94 94 const viewUrl =