// // TextEntryField.swift // VDS // // Created by Matt Bruce on 10/3/22. // import Foundation import UIKit import VDSCoreTokens import Combine /// An input field is an input wherein a customer enters information. They typically appear in forms. /// Specialized input fields capture credit card numbers, inline actions, passwords, phone numbers, /// dates and security codes in their correct formats. @objc(VDSInputField) open class InputField: EntryFieldBase { //-------------------------------------------------- // MARK: - Initializers //-------------------------------------------------- required public init() { super.init(frame: .zero) } public override init(frame: CGRect) { super.init(frame: .zero) } public required init?(coder: NSCoder) { super.init(coder: coder) } //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- internal override var responder: UIResponder? { textField } internal override var minWidth: CGFloat { fieldType.handler().minWidth } internal override var maxWidth: CGFloat { let frameWidth = frame.size.width return helperTextPlacement == .right ? (frameWidth - horizontalStackView.spacing) / 2 : frameWidth } /// The is used for the for adding the helperLabel to the right of the containerView. internal var horizontalStackView: UIStackView = { return UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.axis = .horizontal $0.distribution = .fillEqually $0.spacing = VDSLayout.space3X $0.alignment = .top } }() //-------------------------------------------------- // MARK: - Public FieldType Properties //-------------------------------------------------- /// Representing the type of input. open var fieldType: FieldType = .text { didSet { setNeedsUpdate() } } //-------------------------------------------------- // MARK: - CreditCard/SecurityCode //-------------------------------------------------- open var cardType: CreditCardType = .placeholder { didSet { setNeedsUpdate() } } //-------------------------------------------------- // MARK: - Password //-------------------------------------------------- /// This is the text that will be displayed when the password is unmasked. open var hidePasswordButtonText: String = "Hide" { didSet { setNeedsUpdate() } } /// This is the text that will be displayed when the password is masked. open var showPasswordButtonText: String = "Show" { didSet { setNeedsUpdate() } } //-------------------------------------------------- // MARK: - Date //-------------------------------------------------- /// Date Format used when using the FieldType 'Date'. open var dateFormat: DateFormat = .mmddyy { didSet { setNeedsUpdate() } } //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- /// Label to render the successText. open var successLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.textStyle = .bodySmall } /// UITextField shown in the InputField. open var textField = TextField().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.textStyle = TextStyle.bodyLarge $0.isAccessibilityElement = false } /// Color configuration for the textField. open var textFieldTextColorConfiguration: AnyColorable = ViewColorConfiguration().with { $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forDisabled: true) $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forDisabled: false) }.eraseToAnyColorable() open var actionTextLink = TextLink().with { $0.contentEdgeInsets = .top(-2) } open var actionTextLinkModel: TextLinkModel? { didSet { setNeedsUpdate() } } /// The text of this TextField. open var text: String? { get { textField.text } set { textField.text = newValue setNeedsUpdate() } } /// Value for the textField open override var value: String? { if let value = fieldType.handler().value { return value } else { return textField.text } } var _showError: Bool = false /// Whether not to show the error. open override var showError: Bool { get { _showError } set { if !showSuccess && _showError != newValue { _showError = newValue setNeedsUpdate() } } } var _showSuccess: Bool = false /// Whether not to show the success. open var showSuccess: Bool { get { _showSuccess } set { if !showError && _showSuccess != newValue { _showSuccess = newValue setNeedsUpdate() } } } /// Override UIControl state to add the .error state if showSuccess is true and if showError is true. open override var state: UIControl.State { get { var state = super.state if showSuccess { state.insert(.success) } return state } } /// If given, this will be shown if showSuccess if true. open var successText: String? { didSet { setNeedsUpdate() } } //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- /// Called once when a view is initialized and is used to Setup additional UI or other constants and configurations. open override func setup() { super.setup() accessibilityHintText = "Double tap to edit" textField.heightAnchor.constraint(equalToConstant: 20).isActive = true textField.delegate = self bottomContainerStackView.insertArrangedSubview(successLabel, at: 0) containerView.fieldStackView.addArrangedSubview(actionTextLink) successLabel.textColorConfiguration = primaryColorConfiguration.eraseToAnyColorable() containerView.backgroundColorConfiguration.setSurfaceColors(VDSColor.feedbackSuccessBackgroundOnlight, VDSColor.feedbackSuccessBackgroundOndark, forState: .success) containerView.backgroundColorConfiguration.setSurfaceColors(VDSColor.feedbackSuccessBackgroundOnlight, VDSColor.feedbackSuccessBackgroundOndark, forState: [.success, .focused]) containerView.borderColorConfiguration.setSurfaceColors(VDSColor.feedbackSuccessOnlight, VDSColor.feedbackSuccessOndark, forState: .success) textField.textColorConfiguration = textFieldTextColorConfiguration containerView.bridge_accessibilityLabelBlock = { [weak self] in guard let self else { return "" } var accessibilityLabels = [String]() if let text = titleLabel.text?.trimmingCharacters(in: .whitespaces) { accessibilityLabels.append(text) } if let formatText = textField.formatText, !formatText.isEmpty { accessibilityLabels.append("format, \(formatText)") } if let placeholderText = textField.placeholder, !placeholderText.isEmpty { accessibilityLabels.append("placeholder, \(placeholderText)") } if isReadOnly { accessibilityLabels.append("read only") } if !isEnabled { accessibilityLabels.append("dimmed") } if let errorText, showError { accessibilityLabels.append("error, \(errorText)") } if let successText, showSuccess { accessibilityLabels.append("success, \(successText)") } accessibilityLabels.append("\(Self.self)") return accessibilityLabels.joined(separator: ", ") } containerView.statusIcon.bridge_accessibilityLabelBlock = { [weak self] in guard let self else { return "" } if showError { return "error" } else if showSuccess { return "success" } else { return nil } } } open override func getFieldContainer() -> UIView { return textField } /// Resets to default settings. open override func reset() { super.reset() textField.text = "" successLabel.reset() successLabel.textStyle = .bodySmall fieldType = .text showSuccess = false successText = nil helperTextPlacement = .bottom } /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { //update fieldType first fieldType.handler().updateView(self) super.updateView() textField.surface = surface textField.isEnabled = isEnabled textField.isUserInteractionEnabled = isEnabled && !isReadOnly } open override func updateErrorLabel() { super.updateErrorLabel() //show error or success if showError, let _ = errorText { successLabel.isHidden = true } else if showSuccess, let successText { successLabel.text = successText successLabel.surface = surface successLabel.isEnabled = isEnabled successLabel.isHidden = false errorLabel.isHidden = true containerView.statusIcon.name = .checkmarkAlt containerView.statusIcon.color = containerView.iconColorConfiguration.getColor(self) containerView.statusIcon.isHidden = !isEnabled || state.contains(.focused) } else { successLabel.isHidden = true } } override func updateRules() { super.updateRules() fieldType.handler().appendRules(self) } open override var accessibilityElements: [Any]? { get { var elements = [Any]() elements.append(contentsOf: [titleLabel, containerView]) if let leftView = textField.leftView { elements.append(leftView) } if !containerView.statusIcon.isHidden{ elements.append(containerView.statusIcon) } if !actionTextLink.isHidden { elements.append(actionTextLink) } if let errorText, !errorText.isEmpty, showError || hasInternalError { elements.append(errorLabel) } else if showSuccess, let successText, !successText.isEmpty { elements.append(successLabel) } if let helperText, !helperText.isEmpty { elements.append(helperLabel) } return elements } set { super.accessibilityElements = newValue } } } extension InputField: UITextFieldDelegate { public func textFieldDidBeginEditing(_ textField: UITextField) { fieldType.handler().textFieldDidBeginEditing(self, textField: textField) updateContainer() updateErrorLabel() } public func textFieldDidEndEditing(_ textField: UITextField) { fieldType.handler().textFieldDidEndEditing(self, textField: textField) validate() UIAccessibility.post(notification: .layoutChanged, argument: self.containerView) } public func textFieldDidChangeSelection(_ textField: UITextField) { fieldType.handler().textFieldDidChangeSelection(self, textField: textField) if fieldType.handler().validateOnChange { validate() } sendActions(for: .valueChanged) setNeedsUpdate() } public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { return fieldType.handler().textField(self, textField: textField, shouldChangeCharactersIn: range, replacementString: string) } } extension String { internal static func format(_ value: String, indices: [Int], with separator: String) -> String { var formattedString = "" var currentIndex = value.startIndex for index in 0..