From 1407a1d5b42e7801d40c3f282a0726af254ff83c Mon Sep 17 00:00:00 2001 From: Mauro Romito Date: Mon, 3 Aug 2026 13:43:54 +0200 Subject: [PATCH] implemented a11y for galleries --- .../en-US.lproj/Localizable.strings | 5 ++++ .../en.lproj/Localizable.strings | 5 ++++ .../en.lproj/Localizable.stringsdict | 16 +++++++++++++ ElementX/Sources/Generated/Strings.swift | 24 +++++++++++++++++++ .../TimelineItemAccessibilityModifier.swift | 18 ++++++++++++++ .../Style/TimelineItemSendInfoLabel.swift | 4 ++++ .../TimelineItemViews/GalleryGridView.swift | 1 + .../GalleryItemTileView.swift | 24 ++++++++++++++++++- .../TimelineItemViews/GalleryListView.swift | 10 ++++++++ .../GalleryRoomTimelineView.swift | 1 + 10 files changed, 107 insertions(+), 1 deletion(-) diff --git a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings index 7d573d495..50db1f35b 100644 --- a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings @@ -12,6 +12,11 @@ "a11y_edit_avatar" = "Edit avatar"; "a11y_edit_room_address_hint" = "The full address will be %1$@"; "a11y_expand_message_text_field" = "Expand message text field"; +"a11y_gallery_attachment" = "Gallery, attachment %1$d"; +"a11y_gallery_failed_scan" = "Gallery, failed scan %1$d"; +"a11y_gallery_image" = "Gallery, image %1$d"; +"a11y_gallery_scanning" = "Gallery, scanning %1$d"; +"a11y_gallery_video" = "Gallery, video %1$d"; "a11y_hide_password" = "Hide password"; "a11y_info" = "Info"; "a11y_join_call" = "Join call"; diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index c004691d4..b2e30a9ff 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -12,6 +12,11 @@ "a11y_edit_avatar" = "Edit avatar"; "a11y_edit_room_address_hint" = "The full address will be %1$@"; "a11y_expand_message_text_field" = "Expand message text field"; +"a11y_gallery_attachment" = "Gallery, attachment %1$d"; +"a11y_gallery_failed_scan" = "Gallery, failed scan %1$d"; +"a11y_gallery_image" = "Gallery, image %1$d"; +"a11y_gallery_scanning" = "Gallery, scanning %1$d"; +"a11y_gallery_video" = "Gallery, video %1$d"; "a11y_hide_password" = "Hide password"; "a11y_info" = "Info"; "a11y_join_call" = "Join call"; diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict b/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict index a9537fc8f..fb32c8c60 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict @@ -18,6 +18,22 @@ NSStringLocalizedFormatKey %#@COUNT@ + a11y_gallery_more_media + + COUNT + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Gallery, %1$d more media + other + Gallery, %1$d more medias + + NSStringLocalizedFormatKey + %#@COUNT@ + a11y_polls_percent_of_total COUNT diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index d885fd7c7..569260f52 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -48,6 +48,30 @@ internal nonisolated enum L10n { } /// Expand message text field internal static var a11yExpandMessageTextField: String { return L10n.tr("Localizable", "a11y_expand_message_text_field") } + /// Gallery, attachment %1$d + internal static func a11yGalleryAttachment(_ p1: Int) -> String { + return L10n.tr("Localizable", "a11y_gallery_attachment", p1) + } + /// Gallery, failed scan %1$d + internal static func a11yGalleryFailedScan(_ p1: Int) -> String { + return L10n.tr("Localizable", "a11y_gallery_failed_scan", p1) + } + /// Gallery, image %1$d + internal static func a11yGalleryImage(_ p1: Int) -> String { + return L10n.tr("Localizable", "a11y_gallery_image", p1) + } + /// Plural format key: "%#@COUNT@" + internal static func a11yGalleryMoreMedia(_ p1: Int) -> String { + return L10n.tr("Localizable", "a11y_gallery_more_media", p1) + } + /// Gallery, scanning %1$d + internal static func a11yGalleryScanning(_ p1: Int) -> String { + return L10n.tr("Localizable", "a11y_gallery_scanning", p1) + } + /// Gallery, video %1$d + internal static func a11yGalleryVideo(_ p1: Int) -> String { + return L10n.tr("Localizable", "a11y_gallery_video", p1) + } /// Hide password internal static var a11yHidePassword: String { return L10n.tr("Localizable", "a11y_hide_password") } /// Info diff --git a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemAccessibilityModifier.swift b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemAccessibilityModifier.swift index 3a7d8224c..8507c7cb0 100644 --- a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemAccessibilityModifier.swift +++ b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemAccessibilityModifier.swift @@ -21,6 +21,24 @@ private struct TimelineItemAccessibilityModifier: ViewModifier { action() } } + // A gallery is a container so that each of its attachments can be focussed on its own. + // Everything that isn't an attachment is announced when entering it, as the caption and + // the send info are hidden where they're shown to avoid being read twice. + case let timelineItem as GalleryRoomTimelineItem: + content + .accessibilityElement(children: .contain) + .accessibilityLabel { _ in + Text(timelineItem.sender.displayName ?? timelineItem.sender.id) + if let caption = timelineItem.content.caption, !caption.isBlank { + Text(caption) + } + Text(timelineItem.localizedSendInfo) + } + .accessibilityActions { + Button(L10n.commonMessageActions) { + action() + } + } case let timelineItem as EventBasedTimelineItemProtocol: content .accessibilityRepresentation { diff --git a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemSendInfoLabel.swift b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemSendInfoLabel.swift index b529ac815..ba8daca54 100644 --- a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemSendInfoLabel.swift +++ b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemSendInfoLabel.swift @@ -18,6 +18,8 @@ extension View { modifier(TimelineItemSendInfoModifier(sendInfo: .init(timelineItem: timelineItem, adjustedDeliveryStatus: adjustedDeliveryStatus, hasContentScanningFailure: hasContentScanningFailure), + // A gallery announces this when entering it instead. + isAccessibilityHidden: timelineItem is GalleryRoomTimelineItem, context: context)) } } @@ -25,6 +27,7 @@ extension View { /// Adds the send info to a view with the correct layout. private struct TimelineItemSendInfoModifier: ViewModifier { let sendInfo: TimelineItemSendInfo + let isAccessibilityHidden: Bool let context: TimelineViewModel.Context var layout: AnyLayout { @@ -43,6 +46,7 @@ private struct TimelineItemSendInfoModifier: ViewModifier { content TimelineItemSendInfoLabel(sendInfo: sendInfo) + .accessibilityHidden(isAccessibilityHidden) .contentShape(.rect) // Tap gesture to avoid the message being detected as a button by VoiceOver // (and the action shows a description that is already read to the user). diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryGridView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryGridView.swift index d9d8a111a..cf6f275af 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryGridView.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryGridView.swift @@ -72,6 +72,7 @@ struct GalleryGridView: View { let overflow = (overflowCount > 0 && isLastVisible) ? overflowCount : 0 GalleryItemTileView(item: items[index], + position: index + 1, mediaProvider: mediaProvider, contentScannerService: contentScannerService, overflowCount: overflow) { diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryItemTileView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryItemTileView.swift index 86f5304f7..a5d8ea89d 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryItemTileView.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryItemTileView.swift @@ -10,11 +10,25 @@ import SwiftUI struct GalleryItemTileView: View { let item: GalleryItem + /// The item's position within the gallery, announced instead of its filename. + let position: Int let mediaProvider: MediaProviderProtocol? let contentScannerService: ContentScannerServiceProtocol? let overflowCount: Int let onTap: () -> Void + private var accessibilityLabel: String { + if overflowCount > 0 { + L10n.a11yGalleryMoreMedia(overflowCount) + } else if item.isVideo { + L10n.a11yGalleryVideo(position) + } else if item.isImage { + L10n.a11yGalleryImage(position) + } else { + L10n.a11yGalleryAttachment(position) + } + } + var body: some View { // Overflow above the scanner: shows on any scan state, survives content updates. ZStack { @@ -33,11 +47,15 @@ struct GalleryItemTileView: View { } } .accessibilityElement(children: .ignore) - .accessibilityLabel(item.filename) + .accessibilityLabel(accessibilityLabel) } scanningContent: { ScanningMediaEventsTimelineView() + .accessibilityElement(children: .ignore) + .accessibilityLabel(L10n.a11yGalleryScanning(position)) } unsafeContent: { failure in UnsafeMediaEventsTimelineView(failure: failure) + .accessibilityElement(children: .ignore) + .accessibilityLabel(L10n.a11yGalleryFailedScan(position)) } if overflowCount > 0 { @@ -47,6 +65,9 @@ struct GalleryItemTileView: View { // Tappable in every scan state, so that an unsafe item can't hide the rest of the gallery. .contentShape(Rectangle()) .onTapGesture { onTap() } + // One element per tile, labelled by whichever state is on show. + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) } @ViewBuilder @@ -109,6 +130,7 @@ struct GalleryItemTileView: View { .foregroundStyle(.compound.textPrimary) } .allowsHitTesting(false) // Let the tap fall through to the tile below. + .accessibilityHidden(true) // The count is already announced by the tile's label. } private func formatted(duration: TimeInterval) -> String { diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryListView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryListView.swift index e88021929..30e823a74 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryListView.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryListView.swift @@ -24,6 +24,7 @@ struct GalleryListView: View { } GalleryListRow(item: item, + position: index + 1, mediaProvider: mediaProvider, contentScannerService: contentScannerService) { onItemTap(index) @@ -46,6 +47,8 @@ struct GalleryDivider: View { private struct GalleryListRow: View { let item: GalleryItem + /// The item's position within the gallery, prepended to the row's own description. + let position: Int let mediaProvider: MediaProviderProtocol? let contentScannerService: ContentScannerServiceProtocol? let onTap: () -> Void @@ -71,6 +74,13 @@ private struct GalleryListRow: View { } unsafeContent: { failure in failureRow(failure) } + // One element per row, so that each attachment is focussed on its own, with its position + // prepended to the description that the row already provides. + .accessibilityElement(children: .combine) + .accessibilityLabel { label in + Text(L10n.a11yGalleryAttachment(position)) + label + } } private func row(icon: some View) -> some View { diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryRoomTimelineView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryRoomTimelineView.swift index e455d7f74..c68d00ad0 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryRoomTimelineView.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryRoomTimelineView.swift @@ -41,6 +41,7 @@ struct GalleryRoomTimelineView: View { GalleryDivider() } caption + .accessibilityHidden(true) // Announced when entering the gallery, so not a stop of its own. } } .frame(width: GalleryGridView.groupWidth, alignment: .leading)