diff --git a/Localization/Localizable.stringsdict b/Localization/Localizable.stringsdict index ce358b43..4b9a1276 100644 --- a/Localization/Localizable.stringsdict +++ b/Localization/Localizable.stringsdict @@ -156,6 +156,28 @@ %ld reblogs + plural.count.reply + + NSStringLocalizedFormatKey + %#@reply_count@ + reply_count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + zero + 0 replies + one + 1 reply + few + %ld replies + many + %ld replies + other + %ld replies + + plural.count.vote NSStringLocalizedFormatKey diff --git a/Localization/app.json b/Localization/app.json index ad99e178..fad7cc20 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -130,6 +130,7 @@ "show_user_profile": "Show user profile", "content_warning": "Content Warning", "media_content_warning": "Tap anywhere to reveal", + "tap_to_reveal": "Tap to reveal", "poll": { "vote": "Vote", "closed": "Closed" @@ -141,7 +142,11 @@ "favorite": "Favorite", "unfavorite": "Unfavorite", "menu": "Menu", - "hide": "Hide" + "hide": "Hide", + "show_image": "Show image", + "show_gif": "Show GIF", + "show_video_player": "Show video player", + "tap_then_hold_to_show_menu": "Tap then hold to show menu" }, "tag": { "url": "URL", @@ -440,6 +445,11 @@ "title": "Unblock Account", "message": "Confirm to unblock %s" } + }, + "accessibility": { + "show_avatar_image": "Show avatar image", + "edit_avatar_image": "Edit avatar image", + "show_banner_image": "Show banner image" } }, "follower": { diff --git a/Mastodon/Diffiable/Search/SearchSection.swift b/Mastodon/Diffiable/Search/SearchSection.swift index 38c87a76..21f1d479 100644 --- a/Mastodon/Diffiable/Search/SearchSection.swift +++ b/Mastodon/Diffiable/Search/SearchSection.swift @@ -21,8 +21,11 @@ extension SearchSection { ) -> UICollectionViewDiffableDataSource { let trendCellRegister = UICollectionView.CellRegistration { cell, indexPath, item in - cell.primaryLabel.text = "#" + item.name - cell.secondaryLabel.text = L10n.Scene.Search.Recommend.HashTag.peopleTalking(item.talkingPeopleCount ?? 0) + let primaryLabelText = "#" + item.name + let secondaryLabelText = L10n.Scene.Search.Recommend.HashTag.peopleTalking(item.talkingPeopleCount ?? 0) + + cell.primaryLabel.text = primaryLabelText + cell.secondaryLabel.text = secondaryLabelText cell.lineChartView.data = (item.history ?? []) .sorted(by: { $0.day < $1.day }) // latest last @@ -32,6 +35,12 @@ extension SearchSection { } return CGFloat(point) } + + cell.isAccessibilityElement = true + cell.accessibilityLabel = [ + primaryLabelText, + secondaryLabelText + ].joined(separator: ", ") } let dataSource = UICollectionViewDiffableDataSource( diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index 0c1b0423..aadb0d70 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -75,6 +75,7 @@ final class ProfileHeaderView: UIView { let avatarButton: AvatarButton = { let button = AvatarButton() button.avatarImageView.configure(cornerConfiguration: .init(corner: .fixed(radius: 0))) + button.accessibilityLabel = "Avatar image" // FIXME: i18n return button }() diff --git a/Mastodon/Scene/Search/Search/Cell/TrendCollectionViewCell.swift b/Mastodon/Scene/Search/Search/Cell/TrendCollectionViewCell.swift index 48a7606b..a43d65df 100644 --- a/Mastodon/Scene/Search/Search/Cell/TrendCollectionViewCell.swift +++ b/Mastodon/Scene/Search/Search/Cell/TrendCollectionViewCell.swift @@ -132,3 +132,4 @@ extension TrendCollectionViewCell { } } + diff --git a/Mastodon/Scene/Search/Search/Cell/TrendSectionHeaderCollectionReusableView.swift b/Mastodon/Scene/Search/Search/Cell/TrendSectionHeaderCollectionReusableView.swift index 4632f384..9d21ee28 100644 --- a/Mastodon/Scene/Search/Search/Cell/TrendSectionHeaderCollectionReusableView.swift +++ b/Mastodon/Scene/Search/Search/Cell/TrendSectionHeaderCollectionReusableView.swift @@ -23,6 +23,7 @@ final class TrendSectionHeaderCollectionReusableView: UICollectionReusableView { label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 22, weight: .bold)) label.textColor = Asset.Colors.Label.primary.color label.text = L10n.Scene.Search.Recommend.HashTag.title + label.numberOfLines = 0 return label }() diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell.swift index e16e4f28..d971cada 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell.swift @@ -79,8 +79,8 @@ extension StatusThreadRootTableViewCell { statusView.delegate = self // a11y - statusView.contentMetaText.textView.isSelectable = true statusView.contentMetaText.textView.isAccessibilityElement = false + statusView.contentMetaText.textView.isSelectable = true } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { @@ -91,6 +91,54 @@ extension StatusThreadRootTableViewCell { } +extension StatusThreadRootTableViewCell { + + override var accessibilityElements: [Any]? { + get { + var elements = [ + statusView.headerContainerView, + statusView.avatarButton, + statusView.authorNameLabel, + statusView.menuButton, + statusView.authorUsernameLabel, + statusView.dateLabel, + statusView.contentSensitiveeToggleButton, + statusView.spoilerOverlayView, + statusView.contentMetaText.textView, + statusView.mediaGridContainerView, + statusView.pollTableView, + statusView.pollStatusStackView, + statusView.statusVisibilityView, + statusView.actionToolbarContainer, + statusView.statusMetricView + ] + + if !statusView.viewModel.isSensitive { + elements.removeAll(where: { $0 === statusView.contentSensitiveeToggleButton }) + } + + if statusView.viewModel.isContentReveal { + elements.removeAll(where: { $0 === statusView.spoilerOverlayView }) + } else { + elements.removeAll(where: { $0 === statusView.contentMetaText.textView }) + } + + if statusView.statusVisibilityView.isHidden { + elements.removeAll(where: { $0 === statusView.statusVisibilityView }) + } + + if statusView.viewModel.pollItems.isEmpty { + elements.removeAll(where: { $0 === statusView.pollTableView }) + elements.removeAll(where: { $0 === statusView.pollStatusStackView }) + } + + return elements + } + set { } + } + +} + extension StatusThreadRootTableViewCell: AdaptiveContainerMarginTableViewCell { var containerView: StatusView { statusView diff --git a/MastodonSDK/Sources/MastodonUI/View/Button/AvatarButton.swift b/MastodonSDK/Sources/MastodonUI/View/Button/AvatarButton.swift index 3854c470..57257fd8 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Button/AvatarButton.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Button/AvatarButton.swift @@ -7,6 +7,7 @@ import os.log import UIKit +import MastodonLocalization open class AvatarButton: UIControl { @@ -37,6 +38,9 @@ open class AvatarButton: UIControl { avatarImageView.trailingAnchor.constraint(equalTo: trailingAnchor), avatarImageView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) + + isAccessibilityElement = true + accessibilityLabel = L10n.Common.Controls.Status.showUserProfile } public override func layoutSubviews() { diff --git a/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift index 41d97c0f..cb9c53f3 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift @@ -74,6 +74,13 @@ public final class MediaGridContainerView: UIView { super.init(coder: coder) _init() } + + public override var accessibilityElements: [Any]? { + get { + mediaViews + } + set { } + } } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift index 68847e74..f4231f02 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift @@ -104,6 +104,8 @@ extension MediaView { extension MediaView { private func _init() { // lazy load content later + + isAccessibilityElement = true } public func setup(configuration: Configuration) { @@ -115,13 +117,18 @@ extension MediaView { case .image(let info): layoutImage() bindImage(configuration: configuration, info: info) + accessibilityLabel = "Show image" // TODO: i18n case .gif(let info): layoutGIF() bindGIF(configuration: configuration, info: info) + accessibilityLabel = "Show GIF" // TODO: i18n case .video(let info): layoutVideo() bindVideo(configuration: configuration, info: info) + accessibilityLabel = "Show video player" // TODO: i18n } + + accessibilityHint = "Tap then hold to show menu" // TODO: i18n layoutBlurhash() bindBlurhash(configuration: configuration) diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/PollOptionView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/PollOptionView+ViewModel.swift index ff458e7a..e25e5d0a 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/PollOptionView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/PollOptionView+ViewModel.swift @@ -50,6 +50,8 @@ extension PollOptionView { @Published public var primaryStripProgressViewTintColor: UIColor = Asset.Colors.brandBlue.color @Published public var secondaryStripProgressViewTintColor: UIColor = Asset.Colors.brandBlue.color.withAlphaComponent(0.5) + @Published public var groupedAccessibilityLabel = "" + init() { // selectState Publishers.CombineLatest3( @@ -136,9 +138,11 @@ extension PollOptionView.ViewModel { .sink { metaContent in guard let metaContent = metaContent else { view.optionTextField.text = "" + view.optionTextField.accessibilityLabel = "" return } view.optionTextField.text = metaContent.string + view.optionTextField.accessibilityLabel = metaContent.string } .store(in: &disposeBag) // selectState @@ -175,6 +179,21 @@ extension PollOptionView.ViewModel { } } .store(in: &disposeBag) + + bindAccessibility(view: view) + } + + private func bindAccessibility(view: PollOptionView) { + $selectState + .sink { selectState in + switch selectState { + case .on: + view.accessibilityTraits.insert(.selected) + default: + view.accessibilityTraits.remove(.selected) + } + } + .store(in: &disposeBag) } } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/PollOptionView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/PollOptionView.swift index d56ac06e..df000233 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/PollOptionView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/PollOptionView.swift @@ -177,6 +177,26 @@ extension PollOptionView { plusCircleImageView.isHidden = true updateCornerRadius() + + isAccessibilityElement = true + } + + public override var accessibilityLabel: String? { + get { + switch viewModel.voteState { + case .reveal: + return [ + optionTextField, + optionPercentageLabel + ] + .compactMap { $0.accessibilityLabel } + .joined(separator: ", ") + + case .hidden: + return optionTextField.accessibilityLabel + } + } + set { } } public override func layoutSubviews() { diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index da529ea1..39a802db 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -698,6 +698,9 @@ extension StatusView.ViewModel { if let spoilerContent = spoilerContent, !spoilerContent.string.isEmpty { strings.append(L10n.Common.Controls.Status.contentWarning) strings.append(spoilerContent.string) + + // TODO: replace with "Tap to reveal" + strings.append(L10n.Common.Controls.Status.mediaContentWarning) } if isContentReveal { @@ -707,6 +710,21 @@ extension StatusView.ViewModel { return strings.compactMap { $0 }.joined(separator: ", ") } + $isContentReveal + .map { isContentReveal in + isContentReveal ? L10n.Scene.Compose.Accessibility.enableContentWarning : L10n.Scene.Compose.Accessibility.disableContentWarning + } + .sink { label in + statusView.contentSensitiveeToggleButton.accessibilityLabel = label + } + .store(in: &disposeBag) + + contentAccessibilityLabel + .sink { contentAccessibilityLabel in + statusView.spoilerOverlayView.accessibilityLabel = contentAccessibilityLabel + } + .store(in: &disposeBag) + let meidaAccessibilityLabel = $mediaViewConfigurations .map { configurations -> String? in let count = configurations.count diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift index d6b37656..8a1c02df 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift @@ -61,7 +61,7 @@ public final class StatusView: UIView { }() // header - let headerContainerView = UIView() + public let headerContainerView = UIView() // header icon let headerIconImageView: UIImageView = { @@ -106,6 +106,7 @@ public final class StatusView: UIView { button.tintColor = Asset.Colors.Label.secondary.color let image = UIImage(systemName: "ellipsis", withConfiguration: UIImage.SymbolConfiguration(font: .systemFont(ofSize: 15))) button.setImage(image, for: .normal) + button.accessibilityLabel = L10n.Common.Controls.Status.Actions.menu return button }() @@ -152,7 +153,7 @@ public final class StatusView: UIView { }() // content warning - let spoilerOverlayView = SpoilerOverlayView() + public let spoilerOverlayView = SpoilerOverlayView() // media public let mediaContainerView = UIView() @@ -173,7 +174,7 @@ public final class StatusView: UIView { public var pollTableViewHeightLayoutConstraint: NSLayoutConstraint! public var pollTableViewDiffableDataSource: UITableViewDiffableDataSource? - let pollStatusStackView = UIStackView() + public let pollStatusStackView = UIStackView() let pollVoteCountLabel: UILabel = { let label = UILabel() label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12, weight: .regular)) @@ -186,6 +187,7 @@ public final class StatusView: UIView { label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12, weight: .regular)) label.textColor = Asset.Colors.Label.secondary.color label.text = " ยท " + label.isAccessibilityElement = false return label }() let pollCountdownLabel: UILabel = { @@ -542,7 +544,7 @@ extension StatusView.Style { // pollTableView statusView.pollContainerView.addArrangedSubview(statusView.pollTableView) - // pollStatusStackView + // pollStatusStackView: H - [ pollVoteCountLabel | pollCountdownLabel | pollVoteButton ] statusView.pollStatusStackView.axis = .horizontal statusView.pollContainerView.addArrangedSubview(statusView.pollStatusStackView) diff --git a/MastodonSDK/Sources/MastodonUI/View/Control/ActionToolbarContainer.swift b/MastodonSDK/Sources/MastodonUI/View/Control/ActionToolbarContainer.swift index 3b4a9737..c3a7ba37 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Control/ActionToolbarContainer.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Control/ActionToolbarContainer.swift @@ -93,7 +93,7 @@ extension ActionToolbarContainer { replyButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.reply reblogButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.reblog // needs update to follow state favoriteButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.favorite // needs update to follow state - shareButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.menu + shareButton.accessibilityLabel = L10n.Common.Controls.Actions.share switch style { case .inline: @@ -219,6 +219,7 @@ extension ActionToolbarContainer { public func configureReply(count: Int, isEnabled: Bool) { let title = ActionToolbarContainer.title(from: count) replyButton.setTitle(title, for: .normal) + replyButton.accessibilityLabel = "\(count) reply" // TODO: i18n } public func configureReblog(count: Int, isEnabled: Bool, isHighlighted: Bool) { @@ -230,6 +231,13 @@ extension ActionToolbarContainer { reblogButton.tintColor = tintColor reblogButton.setTitleColor(tintColor, for: .normal) reblogButton.setTitleColor(tintColor, for: .highlighted) + + if isHighlighted { + reblogButton.accessibilityTraits.insert(.selected) + } else { + reblogButton.accessibilityTraits.remove(.selected) + } + reblogButton.accessibilityLabel = L10n.Plural.Count.reblog(count) } public func configureFavorite(count: Int, isEnabled: Bool, isHighlighted: Bool) { @@ -242,6 +250,13 @@ extension ActionToolbarContainer { favoriteButton.tintColor = tintColor favoriteButton.setTitleColor(tintColor, for: .normal) favoriteButton.setTitleColor(tintColor, for: .highlighted) + + if isHighlighted { + favoriteButton.accessibilityTraits.insert(.selected) + } else { + favoriteButton.accessibilityTraits.remove(.selected) + } + favoriteButton.accessibilityLabel = L10n.Plural.Count.favorite(count) } } diff --git a/MastodonSDK/Sources/MastodonUI/View/Control/SpoilerOverlayView.swift b/MastodonSDK/Sources/MastodonUI/View/Control/SpoilerOverlayView.swift index cf84bfa8..17360d54 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Control/SpoilerOverlayView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Control/SpoilerOverlayView.swift @@ -70,6 +70,8 @@ extension SpoilerOverlayView { bottomPaddingView.setContentCompressionResistancePriority(.defaultLow - 100, for: .vertical) spoilerMetaLabel.isUserInteractionEnabled = false + + isAccessibilityElement = true } public func setComponentHidden(_ isHidden: Bool) {