Files
letro-ios/ElementX/Sources/Screens/CreatePollScreen/View/PollFormScreen.swift
Mauro Romito 4f1c10b555 improved the accessibility labels for the view
also added copies for the section headers, and fixes an issue where the move action voice over was not reading the option
2025-06-16 11:31:17 +02:00

236 lines
9.1 KiB
Swift

//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Compound
import SwiftUI
struct PollFormScreen: View {
@ObservedObject var context: PollFormScreenViewModel.Context
@FocusState var focus: Focus?
enum Focus: Hashable {
case question
case option(index: Int)
}
var body: some View {
Form {
questionSection
optionsSection
showResultsSection
deletePollSection
}
.trackAnalyticsIfNeeded(context: context)
.compoundList()
.scrollDismissesKeyboard(.immediately)
.environment(\.editMode, .constant(.active))
.navigationTitle(context.viewState.navigationTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar }
.animation(.elementDefault, value: context.options)
.interactiveDismissDisabled(context.viewState.formContentHasChanged)
.alert(item: $context.alertInfo)
}
// MARK: - Private
private var questionSection: some View {
Section {
ListRow(label: .plain(title: L10n.screenCreatePollQuestionHint),
kind: .textField(text: $context.question, axis: .vertical))
.focused($focus, equals: .question)
.accessibilityIdentifier(A11yIdentifiers.pollFormScreen.question)
} header: {
Text(L10n.screenCreatePollQuestionDesc)
.compoundListSectionHeader()
}
}
private var optionsSection: some View {
Section {
ForEach(context.options) { option in
if let index = context.options.firstIndex(of: option) {
PollFormOptionRow(text: $context.options[index].text.limited(to: 240),
placeholder: L10n.screenCreatePollAnswerHint(index + 1),
canDeleteItem: context.options.count > 2) {
if case .option(let focusedIndex) = focus, focusedIndex == index {
focus = nil
}
context.send(viewAction: .deleteOption(index: index))
}
.focused($focus, equals: .option(index: index))
.accessibilityIdentifier(A11yIdentifiers.pollFormScreen.optionID(index))
.onChange(of: context.options[index].text) { _, newOptionText in
guard let lastCharacter = newOptionText.last, lastCharacter.isNewline else {
return
}
context.options[index].text.removeLast()
submitOption(at: index)
}
.onSubmit { // onSubmit is still called when using the return key on a hardware keyboard
submitOption(at: index)
}
.submitLabel(index == context.options.endIndex - 1 ? .done : .next)
}
}
.onMove { offsets, toOffset in
context.options.move(fromOffsets: offsets, toOffset: toOffset)
}
if context.options.count < context.viewState.maxNumberOfOptions {
ListRow(label: .plain(title: L10n.screenCreatePollAddOptionBtn),
kind: .button {
context.send(viewAction: .addOption)
focus = context.options.indices.last.map { .option(index: $0) }
})
.accessibilityIdentifier(A11yIdentifiers.pollFormScreen.addOption)
}
} header: {
Text(L10n.screenCreatePollOptionsSectionTitle)
.compoundListSectionHeader()
}
// Disables animations when the text view resizes for multiline
.animation(.noAnimation, value: UUID())
}
private func submitOption(at index: Array<PollFormScreenViewStateBindings.Option>.Index) {
let nextOptionIndex = index == context.options.endIndex - 1 ? nil : index + 1
focus = nextOptionIndex.map { .option(index: $0) }
}
private var showResultsSection: some View {
Section {
ListRow(label: .plain(title: L10n.screenCreatePollAnonymousDesc),
kind: .toggle($context.isUndisclosed))
.accessibilityIdentifier(A11yIdentifiers.pollFormScreen.pollKind)
} header: {
Text(L10n.screenCreatePollSettingsSectionTitle)
.compoundListSectionHeader()
}
}
@ViewBuilder
private var deletePollSection: some View {
switch context.viewState.mode {
case .edit:
Section {
ListRow(label: .plain(title: L10n.actionDeletePoll, role: .destructive),
kind: .button { context.send(viewAction: .delete) })
}
case .new:
EmptyView()
}
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) {
Button(L10n.actionCancel) {
context.send(viewAction: .cancel)
}
}
ToolbarItem(placement: .confirmationAction) {
Button(context.viewState.submitButtonTitle) {
context.send(viewAction: .submit)
}
.disabled(context.viewState.isSubmitButtonDisabled)
.accessibilityIdentifier(A11yIdentifiers.pollFormScreen.submit)
}
}
}
private extension View {
@MainActor @ViewBuilder
func trackAnalyticsIfNeeded(context: PollFormScreenViewModel.Context) -> some View {
switch context.viewState.mode {
case .edit:
self
case .new:
track(screen: .CreatePollView)
}
}
}
private struct PollFormOptionRow: View {
@Environment(\.editMode) var editMode
@Binding var text: String
let placeholder: String
let canDeleteItem: Bool
let deleteAction: () -> Void
var body: some View {
ListRow(kind: .custom {
HStack(spacing: 16) {
if editMode?.wrappedValue == .active {
Button(role: .destructive, action: deleteAction) {
CompoundIcon(\.delete)
}
.disabled(!canDeleteItem)
.buttonStyle(.compound(.textLink))
.accessibilityLabel(L10n.screenCreatePollRemoveAccessibilityLabel(L10n.screenCreatePollOptionAccessibilityLabel(placeholder, text)))
}
TextField(text: $text, axis: .vertical) {
Text(placeholder)
.compoundTextFieldPlaceholder()
}
// For some reason the placeholder is always read by voice over even if I disable or override the label, so if the text is empty we use an empy accessibility label
.accessibilityLabel(text.isEmpty ? "" : L10n.screenCreatePollOptionAccessibilityLabel(placeholder, text))
// Allows the move action voice over to give priority to this field over the remove button
.accessibilitySortPriority(1)
.tint(.compound.iconAccentTertiary)
.alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
}
.padding(.horizontal, ListRowPadding.horizontal)
.padding(.vertical, ListRowPadding.vertical)
})
}
}
// MARK: - Previews
struct PollFormScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = PollFormScreenViewModel(mode: .new)
static let editViewModel = PollFormScreenViewModel(mode: .edit(eventID: "1234", poll: poll))
static let poll = Poll(question: "Cats or Dogs?",
kind: .disclosed,
maxSelections: 1,
options: [
.init(id: "0", text: "Cats", votes: 0, allVotes: 0, isSelected: false, isWinning: false),
.init(id: "0", text: "Dogs", votes: 0, allVotes: 0, isSelected: false, isWinning: false),
.init(id: "0", text: "Fish", votes: 0, allVotes: 0, isSelected: false, isWinning: false)
],
votes: [:],
endDate: nil,
createdByAccountOwner: true)
static var previews: some View {
NavigationStack {
PollFormScreen(context: viewModel.context)
}
.previewDisplayName("New")
NavigationStack {
PollFormScreen(context: editViewModel.context)
}
.previewDisplayName("Edit")
}
}
private extension Binding where Value == String {
func limited(to limit: Int) -> Self {
.init {
wrappedValue
} set: { newValue in
wrappedValue = String(newValue.prefix(limit))
}
}
}