diff --git a/VDS/Components/DatePicker/DatePicker.swift b/VDS/Components/DatePicker/DatePicker.swift index 7a158ff6..7645cce5 100644 --- a/VDS/Components/DatePicker/DatePicker.swift +++ b/VDS/Components/DatePicker/DatePicker.swift @@ -147,12 +147,7 @@ open class DatePicker: EntryFieldBase, DatePickerViewControllerDelegate, UIPopov open override func updateAccessibility() { super.updateAccessibility() - let label = "Date Picker, \(isReadOnly ? ", read only" : "")" - if let errorText, showError { - fieldStackView.accessibilityLabel = "\(label) ,error, \(errorText)" - } else { - fieldStackView.accessibilityLabel = label - } + fieldStackView.accessibilityLabel = "Date Picker, \(accessibilityLabelText)" fieldStackView.accessibilityHint = isReadOnly || !isEnabled ? "" : "Double tap to open." fieldStackView.accessibilityValue = value } diff --git a/VDS/Components/DropdownSelect/DropdownSelect.swift b/VDS/Components/DropdownSelect/DropdownSelect.swift index bad62536..e001d130 100644 --- a/VDS/Components/DropdownSelect/DropdownSelect.swift +++ b/VDS/Components/DropdownSelect/DropdownSelect.swift @@ -278,12 +278,7 @@ open class DropdownSelect: EntryFieldBase { open override func updateAccessibility() { super.updateAccessibility() - let label = "Dropdown Select, \(isReadOnly ? ", read only" : "")" - if let errorText, showError { - fieldStackView.accessibilityLabel = "\(label) ,error, \(errorText)" - } else { - fieldStackView.accessibilityLabel = label - } + fieldStackView.accessibilityLabel = "Dropdown Select, \(accessibilityLabelText)" fieldStackView.accessibilityHint = isReadOnly || !isEnabled ? "" : "Double tap to open." fieldStackView.accessibilityValue = value } diff --git a/VDS/Components/Label/Label.swift b/VDS/Components/Label/Label.swift index 9e4dd18e..7d14a85f 100644 --- a/VDS/Components/Label/Label.swift +++ b/VDS/Components/Label/Label.swift @@ -314,8 +314,11 @@ open class Label: UILabel, ViewProtocol, UserInfoable { super.text = newValue return } - + + //clear out accessibility + accessibilityElements?.removeAll() accessibilityCustomActions = [] + //create the primary string let mutableText = NSMutableAttributedString.mutableText(for: newValue, textStyle: textStyle, @@ -337,6 +340,10 @@ open class Label: UILabel, ViewProtocol, UserInfoable { return } + //clear out accessibility + accessibilityElements?.removeAll() + accessibilityCustomActions = [] + let mutableText = NSMutableAttributedString(attributedString: newValue) applyAttributes(mutableText) @@ -348,7 +355,7 @@ open class Label: UILabel, ViewProtocol, UserInfoable { private func applyAttributes(_ mutableAttributedString: NSMutableAttributedString) { actions = [] - if let attributes = attributes { + if let attributes { mutableAttributedString.apply(attributes: attributes) } } @@ -359,7 +366,7 @@ open class Label: UILabel, ViewProtocol, UserInfoable { let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText) - if let attributes = attributes { + if let attributes { //loop through the models attributes for attribute in attributes { diff --git a/VDS/Components/TextFields/EntryFieldBase.swift b/VDS/Components/TextFields/EntryFieldBase.swift index ea023736..f56f78a2 100644 --- a/VDS/Components/TextFields/EntryFieldBase.swift +++ b/VDS/Components/TextFields/EntryFieldBase.swift @@ -241,6 +241,23 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { open var rules = [AnyRule]() + open var accessibilityLabelText: String { + var accessibilityLabels = [String]() + if let text = titleLabel.text { + accessibilityLabels.append(text) + } + if isReadOnly { + accessibilityLabels.append("read only") + } + if !isEnabled { + accessibilityLabels.append("dimmed") + } + if let errorText, showError { + accessibilityLabels.append("error, \(errorText)") + } + return accessibilityLabels.joined(separator: ", ") + } + //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- diff --git a/VDS/Components/TextFields/InputField/FieldTypes/CreditCard.swift b/VDS/Components/TextFields/InputField/FieldTypes/CreditCard.swift index bd512b50..bc3a289a 100644 --- a/VDS/Components/TextFields/InputField/FieldTypes/CreditCard.swift +++ b/VDS/Components/TextFields/InputField/FieldTypes/CreditCard.swift @@ -9,7 +9,20 @@ import Foundation import UIKit extension InputField { - + public class CreditCardNumberRule: Rule, Withable { + public var cardType: CreditCardType? + public var errorMessage: String = "You have exceeded the character limit." + + public func isValid(value: String?) -> Bool { + guard let count = value?.count, let min = cardType?.minLength, let max = cardType?.maxLength else { return true } + if min == max { + return count == max + } else { + return count >= min && count <= max + } + } + } + public enum CreditCardType: String, CaseIterable { case generic case visa @@ -38,15 +51,22 @@ extension InputField { } } - var separatorIndices: [Int] { + func separatorIndices(_ length: Int) -> [Int] { + var indices: [Int] = [4, 8, 12] switch self { - case .dinersClub: - return [4, 10] + case .amex, .dinersClub: + indices = [4, 10] + case .unionPay: + if length == 19 { + indices = [5] + } default: - return [4, 8, 12] + break } + + return indices } - + var securityCodeLength: Int { if self == .amex { return 4 @@ -55,9 +75,21 @@ extension InputField { } } + var minLength: Int { + switch self { + case .visa: return 13 + case .amex: return 15 + case .dinersClub: return 14 + default: return 16 + } + } + var maxLength: Int { switch self { + case .visa: return 19 + case .amex: return 15 case .dinersClub: return 14 + case .unionPay: return 19 default: return 16 } } @@ -131,9 +163,8 @@ extension InputField { override func appendRules(_ inputField: InputField) { if let text = inputField.textField.text, text.count > 0 { - let rule = CharacterCountRule().copyWith { - $0.maxLength = inputField.cardType.maxLength - $0.compareType = .equals + let rule = CreditCardNumberRule().copyWith { + $0.cardType = inputField.cardType $0.errorMessage = "Enter a valid credit card." } inputField.rules.append(.init(rule)) @@ -205,8 +236,8 @@ extension InputField { /// Private internal func formatCreditCardNumber(_ cardType: CreditCardType, number: String) -> String { - let formattedInput = number.filter { $0.isNumber } // Remove any existing slashes - return String.format(formattedInput, indices: cardType.separatorIndices, with: " ") + let rawNumber = number.filter { $0.isNumber } // Remove any existing slashes + return String.format(rawNumber, indices: cardType.separatorIndices(rawNumber.count), with: " ") } internal func updateCardTypeIcon(_ inputField: InputField, rawNumber: String) { @@ -224,9 +255,8 @@ extension InputField { guard rawNumber.count == cardType.maxLength else { return formatCreditCardNumber(cardType, number: number) } let lastFourDigits = rawNumber.suffix(4) let maskedSection = String(repeating: "•", count: 12) - let formattedMaskSection = String.format(maskedSection, indices: cardType.separatorIndices, with: " ") + let formattedMaskSection = String.format(maskedSection, indices: cardType.separatorIndices(rawNumber.count), with: " ") return formattedMaskSection + " " + lastFourDigits } } - } diff --git a/VDS/Components/TextFields/InputField/FieldTypes/Date.swift b/VDS/Components/TextFields/InputField/FieldTypes/Date.swift index 19656745..17decc96 100644 --- a/VDS/Components/TextFields/InputField/FieldTypes/Date.swift +++ b/VDS/Components/TextFields/InputField/FieldTypes/Date.swift @@ -10,6 +10,18 @@ import UIKit extension InputField { + public class DateRule: Rule, Withable { + public var dateFormat: DateFormat? + public var errorMessage: String = "Enter a valid date" + private let dateFormatter = DateFormatter() + + public func isValid(value: String?) -> Bool { + guard let value, let dateFormat, !value.isEmpty else { return true } + dateFormatter.dateFormat = dateFormat.formatString + return dateFormatter.date(from: value) != nil + } + } + public enum DateFormat: String, CaseIterable { case mmyy case mmddyy @@ -46,6 +58,102 @@ extension InputField { case .mmddyyyy: [2,4] } } + + public func isValid(_ date: String) -> Bool { + let allowedCharacters = CharacterSet(charactersIn: "0123456789/") + + // Check if the input contains only allowed characters + if date.rangeOfCharacter(from: allowedCharacters.inverted) != nil || date.isEmpty { + return false + } + + let components = date.split(separator: "/") + + + func isMonth(_ month: String) -> Bool { + switch month.count { + case 1: + guard let month = Int(month), (0...1).contains(month) else { return false } + return true + case 2: + guard let month = Int(month), (1...12).contains(month) else { return false } + return true + default: + return false + } + } + + func isDay(_ day: String) -> Bool { + switch day.count { + case 1: + guard let day = Int(day),(1...3).contains(day) else { return false } + return true + case 2: + guard let day = Int(day), (1...31).contains(day) else { return false } + return true + default: + return false + } + } + + func isYear(_ year: String, max: Int) -> Bool { + guard year.count <= max else { + return false + } + return true + } + + switch self { + case .mmyy: + if components.count > 2 { + return false + } + + // Validate month part + if components.count > 0, let monthPart = components.first { + if !isMonth(String(monthPart)) { + return false + } + } + + // Validate year part + if components.count > 1, let yearPart = components.last { + if !isYear(String(yearPart), max: 2) { + return false + } + } + + case .mmddyy, .mmddyyyy: + if components.count > 3 { + return false + } + + // Validate month part + if components.count > 0, let monthPart = components.first { + if !isMonth(String(monthPart)) { + return false + } + } + + // Validate day part + if components.count > 1 { + let dayPart = components[1] + if !isDay(String(dayPart)) { + return false + } + } + + // Validate year part + if components.count > 2, let yearPart = components.last { + if !isYear(String(yearPart), max: self == .mmddyy ? 2 : 4) { + return false + } + } + } + + return true + } + } class DateHandler: FieldTypeHandler { @@ -58,16 +166,15 @@ extension InputField { override func updateView(_ inputField: InputField) { minWidth = 114.0 - placeholderText = inputField.dateFormat.placeholderText - + //placeholderText = inputField.dateFormat.placeholderText + inputField.textField.formatText = inputField.dateFormat.placeholderText super.updateView(inputField) } override func appendRules(_ inputField: InputField) { if let text = inputField.textField.text, text.count > 0 { - let rule = CharacterCountRule().copyWith { - $0.maxLength = inputField.dateFormat.maxLength - $0.compareType = .equals + let rule = DateRule().copyWith { + $0.dateFormat = inputField.dateFormat $0.errorMessage = "Enter a valid date." } inputField.rules.append(.init(rule)) @@ -86,9 +193,13 @@ extension InputField { if newText.count > inputField.dateFormat.maxLength { return false } - + if newText.count <= inputField.dateFormat.maxLength { - textField.text = String.format(newText, indices: inputField.dateFormat.separatorIndices, with: "/") + let rawNumber = newText.filter { $0.isNumber } + let formatted = String.format(rawNumber, indices: inputField.dateFormat.separatorIndices, with: "/") + if inputField.dateFormat.isValid(formatted) || formatted.isEmpty { + textField.text = formatted + } return false } else { return true diff --git a/VDS/Components/TextFields/InputField/InputField.swift b/VDS/Components/TextFields/InputField/InputField.swift index 516954cd..a160420a 100644 --- a/VDS/Components/TextFields/InputField/InputField.swift +++ b/VDS/Components/TextFields/InputField/InputField.swift @@ -190,9 +190,11 @@ open class InputField: EntryFieldBase { successLabel.textColorConfiguration = primaryColorConfiguration.eraseToAnyColorable() backgroundColorConfiguration.setSurfaceColors(VDSColor.feedbackSuccessBackgroundOnlight, VDSColor.feedbackSuccessBackgroundOndark, forState: .success) - - borderColorConfiguration.setSurfaceColors(VDSColor.feedbackSuccessOnlight, VDSColor.feedbackSuccessOndark, forState: .success) + backgroundColorConfiguration.setSurfaceColors(VDSColor.feedbackSuccessBackgroundOnlight, VDSColor.feedbackSuccessBackgroundOndark, forState: [.success, .focused]) + borderColorConfiguration.setSurfaceColors(VDSColor.feedbackSuccessOnlight, VDSColor.feedbackSuccessOndark, forState: .success) + + textField.textColorConfiguration = textFieldTextColorConfiguration } open override func getFieldContainer() -> UIView { @@ -221,19 +223,14 @@ open class InputField: EntryFieldBase { super.updateView() + textField.surface = surface textField.isEnabled = isEnabled textField.isUserInteractionEnabled = isEnabled && !isReadOnly - textField.textColor = textFieldTextColorConfiguration.getColor(self) } - + open override func updateAccessibility() { super.updateAccessibility() - let label = "\(isReadOnly ? "read only" : "")" - if let errorText, showError { - textField.accessibilityLabel = "\(label) ,error, \(errorText)" - } else { - textField.accessibilityLabel = label - } + textField.accessibilityLabel = accessibilityLabelText textField.accessibilityHint = isReadOnly || !isEnabled ? "" : "Double tap to open." } @@ -253,7 +250,7 @@ open class InputField: EntryFieldBase { statusIcon.name = .checkmarkAlt statusIcon.color = iconColorConfiguration.getColor(self) statusIcon.surface = surface - statusIcon.isHidden = !isEnabled + statusIcon.isHidden = !isEnabled || state.contains(.focused) } else { successLabel.isHidden = true } @@ -308,6 +305,7 @@ extension InputField: UITextFieldDelegate { public func textFieldDidBeginEditing(_ textField: UITextField) { fieldType.handler().textFieldDidBeginEditing(self, textField: textField) updateContainerView() + updateErrorLabel() } public func textFieldDidEndEditing(_ textField: UITextField) { diff --git a/VDS/Components/TextFields/InputField/TextField.swift b/VDS/Components/TextFields/InputField/TextField.swift index b054741d..a2980b3b 100644 --- a/VDS/Components/TextFields/InputField/TextField.swift +++ b/VDS/Components/TextFields/InputField/TextField.swift @@ -47,6 +47,17 @@ open class TextField: UITextField, ViewProtocol, Errorable { //-------------------------------------------------- // MARK: - Properties //-------------------------------------------------- + private var formatLabel = Label().with { + $0.tag = 999 + $0.textColorConfiguration = ViewColorConfiguration().with { + $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forDisabled: true) + $0.setSurfaceColors(VDSColor.elementsSecondaryOnlight, VDSColor.elementsSecondaryOndark, forDisabled: false) + }.eraseToAnyColorable() + } + + /// Format String similar to placeholder + open var formatText: String? + /// TextStyle used on the titleLabel. open var textStyle: TextStyle = .defaultStyle { didSet { setNeedsUpdate() } } @@ -114,6 +125,37 @@ open class TextField: UITextField, ViewProtocol, Errorable { open func updateView() { updateLabel() + updateFormat() + } + + open func updateFormat() { + guard let formatText else { + formatLabel.text = "" + return + } + + if viewWithTag(999) == nil { + addSubview(formatLabel) + formatLabel.pinToSuperView() + } + + var attributes: [any LabelAttributeModel]? + var finalFormatText = formatText + + if let text, !text.isEmpty { + //make the color of the matching text clear + attributes = [ColorLabelAttribute(location: 0, length: text.count, color: .clear)] + + let startIndex = formatText.index(formatText.startIndex, offsetBy: text.count) + if startIndex < formatText.endIndex { + finalFormatText = text + formatText[startIndex...] + } + } + + //set the label + formatLabel.surface = surface + formatLabel.text = finalFormatText + formatLabel.attributes = attributes } open func updateAccessibility() { diff --git a/VDS/Components/TextFields/TextArea/TextArea.swift b/VDS/Components/TextFields/TextArea/TextArea.swift index 870cf6b6..b0d858df 100644 --- a/VDS/Components/TextFields/TextArea/TextArea.swift +++ b/VDS/Components/TextFields/TextArea/TextArea.swift @@ -195,12 +195,7 @@ open class TextArea: EntryFieldBase { open override func updateAccessibility() { super.updateAccessibility() - let label = "\(isReadOnly ? "read only" : "")" - if let errorText, showError { - textView.accessibilityLabel = "\(label) ,error, \(errorText)" - } else { - textView.accessibilityLabel = label - } + textView.accessibilityLabel = accessibilityLabelText textView.accessibilityHint = isReadOnly || !isEnabled ? "" : "Double tap to open." } diff --git a/VDS/SupportingFiles/ReleaseNotes.txt b/VDS/SupportingFiles/ReleaseNotes.txt index e39df158..de8c62de 100644 --- a/VDS/SupportingFiles/ReleaseNotes.txt +++ b/VDS/SupportingFiles/ReleaseNotes.txt @@ -3,6 +3,11 @@ - CXTDT-565087 - Input Field - Text - OnDark colors - CXTDT-565112 - Input Field - Credit Card icons - CXTDT-565117 - Input Field - Overflow not clipped +- CXTDT-560823 – TextArea – Accessibility Labels/Error/ReadyOnly/Disabled +- CXTDT-553663 - DropdownSelect – Accessibility +- CXTDT-544662 - Breadcrumbs - Text Wrapping +- CXTDT-565105 - InputField - Date - Typeover text not working +- CXTDT-565115 - InputField - CreditCard - China UnionPay does not allow longer numbers 1.0.65 ----------------