tangled
alpha
login
or
join now
nekomimi.pet
/
atproto-ui
41
fork
atom
A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
41
fork
atom
overview
issues
2
pulls
pipelines
aria labels
@nekomimi.pet
4 months ago
7faa2c9a
0748f639
+53
-28
7 changed files
expand all
collapse all
unified
split
lib
components
BlueskyPostList.tsx
GrainGallery.tsx
renderers
BlueskyPostRenderer.tsx
BlueskyProfileRenderer.tsx
CurrentlyPlayingRenderer.tsx
GrainGalleryRenderer.tsx
TangledRepoRenderer.tsx
+7
-1
lib/components/BlueskyPostList.tsx
···
73
73
74
74
if (error)
75
75
return (
76
76
-
<div style={{ padding: 8, color: "crimson" }}>
76
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
245
+
const postPreview = text.slice(0, 100);
246
246
+
const ariaLabel = text
247
247
+
? `Post by ${did}: ${postPreview}${text.length > 100 ? '...' : ''}`
248
248
+
: `Post by ${did}`;
249
249
+
245
250
return (
246
251
<a
247
252
href={href}
248
253
target="_blank"
249
254
rel="noopener noreferrer"
255
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
286
-
return loadingIndicator || <div style={{ padding: 8 }}>Resolving handle…</div>;
286
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
59
-
<div style={{ padding: 8, color: "crimson" }}>
59
59
+
<div role="alert" style={{ padding: 8, color: "crimson" }}>
60
60
Failed to load post.
61
61
</div>
62
62
);
63
63
}
64
64
-
if (loading && !record) return <div style={{ padding: 8 }}>Loading…</div>;
64
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
184
-
const Avatar: React.FC<{ avatarUrl?: string }> = ({ avatarUrl }) =>
184
184
+
const Avatar: React.FC<{ avatarUrl?: string; name?: string }> = ({ avatarUrl, name }) =>
185
185
avatarUrl ? (
186
186
-
<img src={avatarUrl} alt="avatar" style={baseStyles.avatarImg} />
186
186
+
<img src={avatarUrl} alt={`${name || 'User'}'s profile picture`} style={baseStyles.avatarImg} />
187
187
) : (
188
188
-
<div style={baseStyles.avatarPlaceholder} aria-hidden />
188
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
281
-
<Avatar avatarUrl={props.avatarUrl} />
281
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
329
-
<Avatar avatarUrl={props.avatarUrl} />
329
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
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
27
-
<div style={{ padding: 8, color: "crimson" }}>
27
27
+
<div role="alert" style={{ padding: 8, color: "crimson" }}>
28
28
Failed to load profile.
29
29
</div>
30
30
);
31
31
-
if (loading && !record) return <div style={{ padding: 8 }}>Loading…</div>;
31
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
48
-
<img src={avatarUrl} alt="avatar" style={base.avatarImg} />
48
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
52
-
aria-label="avatar"
52
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
222
-
<div style={{ padding: 8, color: "var(--atproto-color-error)" }}>
222
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
228
-
<div style={{ padding: 8, color: "var(--atproto-color-text-secondary)" }}>
228
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
389
-
<div style={styles.modalContent} onClick={(e) => e.stopPropagation()}>
389
389
+
<div
390
390
+
role="dialog"
391
391
+
aria-modal="true"
392
392
+
aria-labelledby="platform-modal-title"
393
393
+
style={styles.modalContent}
394
394
+
onClick={(e) => e.stopPropagation()}
395
395
+
>
390
396
<div style={styles.modalHeader}>
391
391
-
<h3 style={styles.modalTitle}>Choose your streaming service</h3>
397
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
124
-
<div style={{ padding: 8, color: "crimson" }}>
124
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
131
-
return <div style={{ padding: 8 }}>Loading gallery…</div>;
131
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
158
-
<img src={avatarUrl} alt="avatar" style={styles.avatarImg} />
158
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
514
+
role="dialog"
515
515
+
aria-modal="true"
516
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
583
-
aria-label="Previous photo"
586
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
616
-
aria-label="Next photo"
619
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
708
-
<div
711
711
+
<button
712
712
+
onClick={onClick}
713
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
720
+
border: "none",
721
721
+
padding: 0,
722
722
+
display: "block",
723
723
+
width: "100%",
715
724
}}
716
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
736
-
onClick={() => setShowAltText(!showAltText)}
744
744
+
onClick={(e) => {
745
745
+
e.stopPropagation();
746
746
+
setShowAltText(!showAltText);
747
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
759
+
aria-pressed={showAltText}
748
760
>
749
761
ALT
750
762
</button>
751
763
)}
752
752
-
</div>
764
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
87
-
<div style={{ padding: 8, color: "crimson" }}>
87
87
+
<div role="alert" style={{ padding: 8, color: "crimson" }}>
88
88
Failed to load repository.
89
89
</div>
90
90
);
91
91
-
if (loading && !record) return <div style={{ padding: 8 }}>Loading…</div>;
91
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 =