mirror of
https://github.com/hoornet/vega.git
synced 2026-05-07 12:49:13 -07:00
Harden accessibility: ARIA roles, semantic buttons, alt text, form labels
- Replace clickable divs with semantic <button> elements (NoteCard, SearchView) - Add role="dialog", aria-modal, aria-labelledby on all modals (Login, Quote, Zap, Lightbox) - Add role="presentation" on overlay backdrops (NoteCard menu, emoji pickers) - Add aria-label to all icon-only buttons (close, nav arrows, context menu) - Add aria-expanded to context menu toggle - Add meaningful alt text to all 35+ images (avatars, covers, thumbnails, media) - Add aria-label to form inputs (search, onboarding login)
This commit is contained in:
@@ -71,7 +71,7 @@ export function ArticleCard({ event }: { event: NDKEvent }) {
|
||||
{profile?.picture ? (
|
||||
<img
|
||||
src={profile.picture}
|
||||
alt=""
|
||||
alt={`${authorName}'s avatar`}
|
||||
className="w-5 h-5 rounded-sm object-cover"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
@@ -108,7 +108,7 @@ export function ArticleCard({ event }: { event: NDKEvent }) {
|
||||
<div className="shrink-0 w-24 h-20 rounded-sm overflow-hidden bg-bg-raised">
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
alt={`Cover image for ${title || "article"}`}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
|
||||
@@ -43,7 +43,7 @@ function AuthorRow({ pubkey, publishedAt, readingTime }: { pubkey: string; publi
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<button className="shrink-0" onClick={() => openProfile(pubkey)}>
|
||||
{profile?.picture ? (
|
||||
<img src={profile.picture} alt="" className="w-9 h-9 rounded-sm object-cover hover:opacity-80 transition-opacity"
|
||||
<img src={profile.picture} alt={`${name}'s avatar`} className="w-9 h-9 rounded-sm object-cover hover:opacity-80 transition-opacity"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-sm bg-bg-raised border border-border flex items-center justify-center text-text-dim text-sm">
|
||||
@@ -361,7 +361,7 @@ export function ArticleView() {
|
||||
<div className="mb-6 -mx-2">
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
alt={`Cover image for ${title || "article"}`}
|
||||
className="w-full aspect-video object-cover rounded-sm"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
|
||||
@@ -56,7 +56,7 @@ function ConvRow({
|
||||
}`}
|
||||
>
|
||||
{profile?.picture ? (
|
||||
<img src={profile.picture} alt="" className="w-8 h-8 rounded-sm object-cover shrink-0"
|
||||
<img src={profile.picture} alt={`${name}'s avatar`} className="w-8 h-8 rounded-sm object-cover shrink-0"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-sm bg-bg-raised border border-border flex items-center justify-center text-text-dim text-xs shrink-0">
|
||||
@@ -177,7 +177,7 @@ function ThreadPanel({
|
||||
{/* Header */}
|
||||
<div className="border-b border-border px-4 py-2.5 flex items-center gap-3 shrink-0">
|
||||
{profile?.picture && (
|
||||
<img src={profile.picture} alt="" className="w-7 h-7 rounded-sm object-cover" />
|
||||
<img src={profile.picture} alt={`${name}'s avatar`} className="w-7 h-7 rounded-sm object-cover" />
|
||||
)}
|
||||
<button
|
||||
onClick={() => openProfile(partnerPubkey)}
|
||||
|
||||
@@ -213,7 +213,7 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
|
||||
{avatar ? (
|
||||
<img
|
||||
src={avatar}
|
||||
alt=""
|
||||
alt="Your avatar"
|
||||
className="w-9 h-9 rounded-sm object-cover bg-bg-raised"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
@@ -252,7 +252,7 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
|
||||
) : (
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
alt="Attachment preview"
|
||||
className="h-16 w-auto rounded-sm border border-border object-cover"
|
||||
onError={(e) => { (e.target as HTMLImageElement).className = "h-16 w-20 rounded-sm border border-border bg-bg-raised"; }}
|
||||
/>
|
||||
|
||||
@@ -68,7 +68,7 @@ export function FountainCard({ seg }: { seg: ContentSegment }) {
|
||||
{episode.artworkUrl ? (
|
||||
<img
|
||||
src={episode.artworkUrl}
|
||||
alt=""
|
||||
alt={`${episode.title || "Episode"} artwork`}
|
||||
className="w-12 h-12 rounded-sm object-cover shrink-0"
|
||||
loading="lazy"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
|
||||
@@ -175,7 +175,7 @@ export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps)
|
||||
) : (
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
alt="Attachment preview"
|
||||
className="h-12 w-auto rounded-sm border border-border object-cover"
|
||||
onError={(e) => { (e.target as HTMLImageElement).className = "h-12 w-16 rounded-sm border border-border bg-bg-raised"; }}
|
||||
/>
|
||||
|
||||
@@ -83,7 +83,7 @@ export function YouTubeCard({ seg }: { seg: ContentSegment }) {
|
||||
>
|
||||
<img
|
||||
src={`https://img.youtube.com/vi/${seg.mediaId}/hqdefault.jpg`}
|
||||
alt=""
|
||||
alt="Video thumbnail"
|
||||
className="w-28 h-16 rounded-sm object-cover shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
@@ -120,7 +120,7 @@ export function NoteActions({ event, onReplyToggle, showReply }: NoteActionsProp
|
||||
{/* Emoji picker popover */}
|
||||
{showEmojiPicker && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9]" onClick={() => setShowEmojiPicker(false)} />
|
||||
<div className="fixed inset-0 z-[9]" role="presentation" onClick={() => setShowEmojiPicker(false)} />
|
||||
<div className="absolute bottom-6 left-0 bg-bg-raised border border-border shadow-lg z-10 flex gap-0.5 px-1.5 py-1">
|
||||
{REACTION_EMOJIS.map((emoji) => (
|
||||
<button
|
||||
|
||||
@@ -67,11 +67,11 @@ export function NoteCard({ event, focused, onReplyInThread }: NoteCardProps) {
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
{/* Avatar */}
|
||||
<div className="shrink-0 cursor-pointer" onClick={() => openProfile(event.pubkey)}>
|
||||
<button className="shrink-0 cursor-pointer" aria-label={`View profile of ${name}`} onClick={() => openProfile(event.pubkey)}>
|
||||
{avatar ? (
|
||||
<img
|
||||
src={avatar}
|
||||
alt=""
|
||||
alt={`${name}'s avatar`}
|
||||
className="w-9 h-9 rounded-sm object-cover bg-bg-raised hover:opacity-80 transition-opacity"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
@@ -83,7 +83,7 @@ export function NoteCard({ event, focused, onReplyInThread }: NoteCardProps) {
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -103,14 +103,16 @@ export function NoteCard({ event, focused, onReplyInThread }: NoteCardProps) {
|
||||
<div className="relative ml-auto">
|
||||
<button
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
aria-label="More actions"
|
||||
aria-expanded={menuOpen}
|
||||
className="text-text-dim hover:text-text text-[14px] px-1 leading-none opacity-0 group-hover/card:opacity-100 transition-opacity"
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9]" onClick={() => setMenuOpen(false)} />
|
||||
<div className="absolute right-0 top-5 bg-bg-raised border border-border shadow-lg z-10 w-32">
|
||||
<div className="fixed inset-0 z-[9]" role="presentation" onClick={() => setMenuOpen(false)} />
|
||||
<div role="menu" className="absolute right-0 top-5 bg-bg-raised border border-border shadow-lg z-10 w-32">
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); follows.includes(event.pubkey) ? unfollow(event.pubkey) : follow(event.pubkey); }}
|
||||
className="w-full text-left px-3 py-2 text-[11px] text-text-muted hover:text-accent hover:bg-bg-hover transition-colors"
|
||||
|
||||
@@ -23,7 +23,7 @@ function ImageGrid({ images, onImageClick }: { images: string[]; onImageClick: (
|
||||
<div className="mt-2">
|
||||
<img
|
||||
src={images[0]}
|
||||
alt=""
|
||||
alt="Posted image"
|
||||
loading="lazy"
|
||||
className="max-w-full max-h-80 rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
|
||||
onClick={(e) => { e.stopPropagation(); onImageClick(0); }}
|
||||
@@ -40,7 +40,7 @@ function ImageGrid({ images, onImageClick }: { images: string[]; onImageClick: (
|
||||
<img
|
||||
key={idx}
|
||||
src={src}
|
||||
alt=""
|
||||
alt="Posted image"
|
||||
loading="lazy"
|
||||
className="w-full aspect-[4/3] rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
|
||||
onClick={(e) => { e.stopPropagation(); onImageClick(idx); }}
|
||||
@@ -56,7 +56,7 @@ function ImageGrid({ images, onImageClick }: { images: string[]; onImageClick: (
|
||||
<div className="mt-2 grid grid-cols-2 grid-rows-2 gap-1" style={{ gridTemplateRows: "1fr 1fr" }}>
|
||||
<img
|
||||
src={visible[0]}
|
||||
alt=""
|
||||
alt="Posted image"
|
||||
loading="lazy"
|
||||
className="w-full h-full rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in row-span-2"
|
||||
style={{ aspectRatio: "3/4" }}
|
||||
@@ -65,7 +65,7 @@ function ImageGrid({ images, onImageClick }: { images: string[]; onImageClick: (
|
||||
/>
|
||||
<img
|
||||
src={visible[1]}
|
||||
alt=""
|
||||
alt="Posted image"
|
||||
loading="lazy"
|
||||
className="w-full aspect-[4/3] rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
|
||||
onClick={(e) => { e.stopPropagation(); onImageClick(1); }}
|
||||
@@ -73,7 +73,7 @@ function ImageGrid({ images, onImageClick }: { images: string[]; onImageClick: (
|
||||
/>
|
||||
<img
|
||||
src={visible[2]}
|
||||
alt=""
|
||||
alt="Posted image"
|
||||
loading="lazy"
|
||||
className="w-full aspect-[4/3] rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
|
||||
onClick={(e) => { e.stopPropagation(); onImageClick(2); }}
|
||||
@@ -90,7 +90,7 @@ function ImageGrid({ images, onImageClick }: { images: string[]; onImageClick: (
|
||||
<div key={idx} className="relative">
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
alt="Posted image"
|
||||
loading="lazy"
|
||||
className="w-full aspect-[4/3] rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
|
||||
onClick={(e) => { e.stopPropagation(); onImageClick(idx); }}
|
||||
@@ -133,7 +133,7 @@ function QuotePreview({ eventId }: { eventId: string }) {
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{profile?.picture && (
|
||||
<img src={profile.picture} alt="" className="w-4 h-4 rounded-sm object-cover shrink-0"
|
||||
<img src={profile.picture} alt={`${name}'s avatar`} className="w-4 h-4 rounded-sm object-cover shrink-0"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
)}
|
||||
<span className="text-text-muted text-[11px] font-medium truncate">{name}</span>
|
||||
|
||||
@@ -46,13 +46,16 @@ export function QuoteModal({ event, authorName, authorAvatar, onClose, onPublish
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="quote-modal-title"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div className="bg-bg border border-border w-full max-w-md mx-4 shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h2 className="text-text text-sm font-medium">Quote note</h2>
|
||||
<button onClick={onClose} className="text-text-dim hover:text-text text-lg leading-none">×</button>
|
||||
<h2 id="quote-modal-title" className="text-text text-sm font-medium">Quote note</h2>
|
||||
<button onClick={onClose} aria-label="Close" className="text-text-dim hover:text-text text-lg leading-none">×</button>
|
||||
</div>
|
||||
|
||||
{/* Compose */}
|
||||
@@ -72,7 +75,7 @@ export function QuoteModal({ event, authorName, authorAvatar, onClose, onPublish
|
||||
<div className="border border-border px-3 py-2.5 bg-bg-raised rounded-sm">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
{authorAvatar ? (
|
||||
<img src={authorAvatar} alt="" className="w-4 h-4 rounded-sm object-cover" />
|
||||
<img src={authorAvatar} alt={`${authorName}'s avatar`} className="w-4 h-4 rounded-sm object-cover" />
|
||||
) : (
|
||||
<div className="w-4 h-4 rounded-sm bg-accent/20 flex items-center justify-center text-accent text-[8px]">
|
||||
{authorName.charAt(0).toUpperCase()}
|
||||
|
||||
@@ -34,7 +34,7 @@ function FollowRow({
|
||||
{avatar ? (
|
||||
<img
|
||||
src={avatar}
|
||||
alt=""
|
||||
alt={`${name}'s avatar`}
|
||||
className="w-9 h-9 rounded-sm object-cover bg-bg-raised hover:opacity-80 transition-opacity"
|
||||
loading="lazy"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
|
||||
@@ -248,6 +248,7 @@ function LoginStep({ onBack, onComplete }: { onBack: () => void; onComplete: ()
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={mode === "nsec" ? "nsec1…" : mode === "npub" ? "npub1…" : "bunker://…"}
|
||||
aria-label={mode === "nsec" ? "Secret key" : mode === "npub" ? "Public key" : "Bunker URI"}
|
||||
autoFocus
|
||||
className="w-full bg-bg border border-border px-3 py-2 text-text text-[12px] font-mono focus:outline-none focus:border-accent/50 placeholder:text-text-dim mb-2"
|
||||
style={{ WebkitUserSelect: "text", userSelect: "text" } as React.CSSProperties}
|
||||
|
||||
@@ -72,7 +72,7 @@ export function EpisodeList({ show, onBack }: EpisodeListProps) {
|
||||
{show.artworkUrl && (
|
||||
<img
|
||||
src={show.artworkUrl}
|
||||
alt=""
|
||||
alt={`${show.title || "Podcast"} artwork`}
|
||||
className="w-20 h-20 rounded-sm object-cover shrink-0"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,7 @@ export function PodcastCard({ show, onClick }: PodcastCardProps) {
|
||||
{show.artworkUrl ? (
|
||||
<img
|
||||
src={show.artworkUrl}
|
||||
alt=""
|
||||
alt={`${show.title || "Podcast"} artwork`}
|
||||
className="w-16 h-16 rounded-sm object-cover bg-bg"
|
||||
loading="lazy"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
|
||||
@@ -236,7 +236,7 @@ export function PodcastPlayerBar() {
|
||||
{artwork && (
|
||||
<img
|
||||
src={artwork}
|
||||
alt=""
|
||||
alt="Now playing artwork"
|
||||
className="w-10 h-10 rounded-sm object-cover shrink-0 bg-bg-raised"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
|
||||
@@ -70,7 +70,7 @@ export function ProfileMediaGallery({ notes, loading }: { notes: NDKEvent[]; loa
|
||||
>
|
||||
<img
|
||||
src={item.url}
|
||||
alt=""
|
||||
alt="Media content"
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
@@ -88,7 +88,7 @@ export function ProfileMediaGallery({ notes, loading }: { notes: NDKEvent[]; loa
|
||||
{item.thumbnailId ? (
|
||||
<img
|
||||
src={`https://img.youtube.com/vi/${item.thumbnailId}/mqdefault.jpg`}
|
||||
alt=""
|
||||
alt="Video thumbnail"
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
@@ -28,7 +28,7 @@ function TopFollowerAvatar({ pubkey }: { pubkey: string }) {
|
||||
{profile?.picture ? (
|
||||
<img
|
||||
src={profile.picture}
|
||||
alt=""
|
||||
alt={`${name}'s avatar`}
|
||||
className="w-5 h-5 rounded-sm object-cover bg-bg-raised"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
@@ -218,7 +218,7 @@ export function ProfileView() {
|
||||
)}
|
||||
<img
|
||||
src={profile.banner}
|
||||
alt=""
|
||||
alt={`${name}'s banner`}
|
||||
className="w-full h-full object-cover object-[center_30%] cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => setBannerLightbox(true)}
|
||||
onLoad={() => setBannerLoaded(true)}
|
||||
@@ -233,7 +233,7 @@ export function ProfileView() {
|
||||
{avatar ? (
|
||||
<img
|
||||
src={avatar}
|
||||
alt=""
|
||||
alt={`${name}'s avatar`}
|
||||
style={{ width: 64, height: 64 }}
|
||||
className="rounded-sm object-cover bg-bg-raised"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
|
||||
@@ -52,21 +52,21 @@ function UserRow({ user }: { user: ParsedUser }) {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-border hover:bg-bg-hover transition-colors">
|
||||
<div className="shrink-0 cursor-pointer" onClick={() => navToProfile(user.pubkey)}>
|
||||
<button className="shrink-0 cursor-pointer" aria-label={`View profile of ${displayName}`} onClick={() => navToProfile(user.pubkey)}>
|
||||
{user.picture ? (
|
||||
<img src={user.picture} alt="" className="w-9 h-9 rounded-sm object-cover bg-bg-raised"
|
||||
<img src={user.picture} alt={`${displayName}'s avatar`} className="w-9 h-9 rounded-sm object-cover bg-bg-raised"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-sm bg-bg-raised border border-border flex items-center justify-center text-text-dim text-xs">
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 cursor-pointer" onClick={() => navToProfile(user.pubkey)}>
|
||||
</button>
|
||||
<button className="min-w-0 flex-1 cursor-pointer text-left" onClick={() => navToProfile(user.pubkey)}>
|
||||
<div className="text-text text-[13px] font-medium truncate">{displayName}</div>
|
||||
{user.nip05 && <div className="text-text-dim text-[10px] truncate">{user.nip05}</div>}
|
||||
{user.about && <div className="text-text-dim text-[11px] truncate mt-0.5">{user.about}</div>}
|
||||
</div>
|
||||
</button>
|
||||
{loggedIn && !isOwn && (
|
||||
<button
|
||||
onClick={handleFollowToggle}
|
||||
@@ -279,6 +279,7 @@ export function SearchView() {
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="search… try by:name, #tag, has:image, is:article, since:2026-01-01"
|
||||
aria-label="Search Nostr"
|
||||
autoFocus
|
||||
className="flex-1 bg-transparent text-text text-[13px] placeholder:text-text-dim focus:outline-none"
|
||||
/>
|
||||
@@ -413,17 +414,17 @@ export function SearchView() {
|
||||
)}
|
||||
{visibleSuggestions.map((s) => s.profile && (
|
||||
<div key={s.pubkey} className="flex items-center gap-3 px-4 py-2.5 border-b border-border hover:bg-bg-hover transition-colors group/suggestion">
|
||||
<div className="shrink-0 cursor-pointer" onClick={() => useUIStore.getState().openProfile(s.pubkey)}>
|
||||
<button className="shrink-0 cursor-pointer" aria-label={`View profile of ${s.profile.displayName || s.profile.name || "user"}`} onClick={() => useUIStore.getState().openProfile(s.pubkey)}>
|
||||
{s.profile.picture ? (
|
||||
<img src={s.profile.picture} alt="" className="w-9 h-9 rounded-sm object-cover bg-bg-raised"
|
||||
<img src={s.profile.picture} alt={`${s.profile.displayName || s.profile.name || "User"}'s avatar`} className="w-9 h-9 rounded-sm object-cover bg-bg-raised"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-sm bg-bg-raised border border-border flex items-center justify-center text-text-dim text-xs">
|
||||
{(s.profile.displayName || s.profile.name || "?").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 cursor-pointer" onClick={() => useUIStore.getState().openProfile(s.pubkey)}>
|
||||
</button>
|
||||
<button className="min-w-0 flex-1 cursor-pointer text-left" onClick={() => useUIStore.getState().openProfile(s.pubkey)}>
|
||||
<div className="text-text text-[13px] font-medium truncate">
|
||||
{s.profile.displayName || s.profile.name || shortenPubkey(s.pubkey)}
|
||||
</div>
|
||||
@@ -434,7 +435,7 @@ export function SearchView() {
|
||||
{s.profile.about && (
|
||||
<div className="text-text-dim text-[11px] truncate mt-0.5">{s.profile.about}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<SuggestionFollowButton pubkey={s.pubkey} />
|
||||
<button
|
||||
onClick={() => dismiss(s.pubkey)}
|
||||
|
||||
@@ -18,7 +18,7 @@ export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9]" onClick={onClose} />
|
||||
<div className="fixed inset-0 z-[9]" role="presentation" onClick={onClose} />
|
||||
<div className="absolute bottom-7 right-0 bg-bg-raised border border-border shadow-lg z-10 w-64">
|
||||
{/* Group tabs */}
|
||||
<div className="flex border-b border-border">
|
||||
|
||||
@@ -28,11 +28,15 @@ export function ImageLightbox({ images, index, onClose, onNavigate }: ImageLight
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Image viewer"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close image viewer"
|
||||
className="absolute top-4 right-4 text-white/70 hover:text-white text-[20px] w-8 h-8 flex items-center justify-center transition-colors z-10"
|
||||
>
|
||||
✕
|
||||
@@ -49,6 +53,7 @@ export function ImageLightbox({ images, index, onClose, onNavigate }: ImageLight
|
||||
{hasPrev && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onNavigate(index - 1); }}
|
||||
aria-label="Previous image"
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/50 hover:text-white text-[28px] w-10 h-10 flex items-center justify-center transition-colors z-10"
|
||||
>
|
||||
‹
|
||||
@@ -58,7 +63,7 @@ export function ImageLightbox({ images, index, onClose, onNavigate }: ImageLight
|
||||
{/* Image */}
|
||||
<img
|
||||
src={images[index]}
|
||||
alt=""
|
||||
alt={`Image ${index + 1} of ${images.length}`}
|
||||
className="max-w-[90vw] max-h-[90vh] object-contain select-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
draggable={false}
|
||||
@@ -68,6 +73,7 @@ export function ImageLightbox({ images, index, onClose, onNavigate }: ImageLight
|
||||
{hasNext && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onNavigate(index + 1); }}
|
||||
aria-label="Next image"
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/50 hover:text-white text-[28px] w-10 h-10 flex items-center justify-center transition-colors z-10"
|
||||
>
|
||||
›
|
||||
|
||||
@@ -110,7 +110,11 @@ export function LoginModal({ onClose }: LoginModalProps) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="login-modal-title"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||
>
|
||||
<div
|
||||
className="bg-bg-raised border border-border w-full max-w-md mx-4"
|
||||
@@ -118,9 +122,10 @@ export function LoginModal({ onClose }: LoginModalProps) {
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h2 className="text-text text-sm font-medium">Login</h2>
|
||||
<h2 id="login-modal-title" className="text-text text-sm font-medium">Login</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close login dialog"
|
||||
className="text-text-dim hover:text-text text-lg leading-none"
|
||||
>
|
||||
×
|
||||
|
||||
@@ -27,7 +27,7 @@ function MutedRow({ pubkey, onUnmute }: { pubkey: string; onUnmute: () => void }
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-2 border border-border text-[12px] group">
|
||||
{profile?.picture && (
|
||||
<img src={profile.picture} alt="" className="w-5 h-5 rounded-sm object-cover shrink-0" />
|
||||
<img src={profile.picture} alt={`${name}'s avatar`} className="w-5 h-5 rounded-sm object-cover shrink-0" />
|
||||
)}
|
||||
<span className="text-text truncate flex-1">{name}</span>
|
||||
<button
|
||||
|
||||
@@ -10,7 +10,7 @@ function Avatar({ account, size = "w-6 h-6", textSize = "text-[10px]" }: { accou
|
||||
return (
|
||||
<img
|
||||
src={account.picture}
|
||||
alt=""
|
||||
alt={`${account.name || "Account"} avatar`}
|
||||
className={`${size} rounded-sm object-cover shrink-0`}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
|
||||
@@ -21,7 +21,7 @@ function AncestorCard({ event }: { event: NDKEvent }) {
|
||||
>
|
||||
<div className="shrink-0 mt-0.5">
|
||||
{avatar ? (
|
||||
<img src={avatar} alt="" className="w-6 h-6 rounded-sm object-cover bg-bg-raised" loading="lazy" />
|
||||
<img src={avatar} alt={`${name}'s avatar`} className="w-6 h-6 rounded-sm object-cover bg-bg-raised" loading="lazy" />
|
||||
) : (
|
||||
<div className="w-6 h-6 rounded-sm bg-bg-raised border border-border flex items-center justify-center text-text-dim text-[9px]">
|
||||
{name.charAt(0).toUpperCase()}
|
||||
|
||||
@@ -95,7 +95,7 @@ function ZapRow({
|
||||
onClick={() => pubkey && openProfile(pubkey)}
|
||||
>
|
||||
{avatar ? (
|
||||
<img src={avatar} alt="" className="w-8 h-8 rounded-sm object-cover" loading="lazy"
|
||||
<img src={avatar} alt={`${name}'s avatar`} className="w-8 h-8 rounded-sm object-cover" loading="lazy"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-sm bg-bg-raised border border-border flex items-center justify-center text-text-dim text-xs">
|
||||
|
||||
@@ -45,6 +45,9 @@ export function ZapModal({ target, recipientName, onClose }: ZapModalProps) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Zap ${recipientName}`}
|
||||
onClick={handleBackdrop}
|
||||
>
|
||||
<div className="bg-bg border border-border w-80 shadow-2xl">
|
||||
@@ -54,7 +57,7 @@ export function ZapModal({ target, recipientName, onClose }: ZapModalProps) {
|
||||
<div className="text-text text-[13px] font-medium">⚡ Zap {recipientName}</div>
|
||||
{!nwcUri && <div className="text-danger text-[10px] mt-0.5">No wallet connected</div>}
|
||||
</div>
|
||||
<button onClick={onClose} className="text-text-dim hover:text-text text-[11px] transition-colors">✕</button>
|
||||
<button onClick={onClose} aria-label="Close" className="text-text-dim hover:text-text text-[11px] transition-colors">✕</button>
|
||||
</div>
|
||||
|
||||
{/* No wallet state */}
|
||||
|
||||
Reference in New Issue
Block a user