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

Configure Feed

Select the types of activity you want to include in your feed.

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 =