diff --git a/VDS.xcodeproj/project.pbxproj b/VDS.xcodeproj/project.pbxproj index e34e7772..d6c8c32c 100644 --- a/VDS.xcodeproj/project.pbxproj +++ b/VDS.xcodeproj/project.pbxproj @@ -62,6 +62,10 @@ EA21C5DB2B600EDE00CFC139 /* VDSTokens.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = EA21C5DA2B600EDD00CFC139 /* VDSTokens.xcframework */; }; EA297A5529FB07760031ED56 /* TooltipLabelAttribute.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA297A5429FB07760031ED56 /* TooltipLabelAttribute.swift */; }; EA297A5729FB0A360031ED56 /* AppleGuidelinesTouchable.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA297A5629FB0A360031ED56 /* AppleGuidelinesTouchable.swift */; }; + EA2DC9B02BE175BA004F58C5 /* RequiredRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2DC9AF2BE175BA004F58C5 /* RequiredRule.swift */; }; + EA2DC9B22BE175E6004F58C5 /* CharacterCountRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2DC9B12BE175E6004F58C5 /* CharacterCountRule.swift */; }; + EA2DC9B42BE2C6FE004F58C5 /* TextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2DC9B32BE2C6FE004F58C5 /* TextField.swift */; }; + EA2DC9B62BE2F4A1004F58C5 /* UITextView+Publisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2DC9B52BE2F4A1004F58C5 /* UITextView+Publisher.swift */; }; EA336171288B19200071C351 /* VDS.docc in Sources */ = {isa = PBXBuildFile; fileRef = EA336170288B19200071C351 /* VDS.docc */; }; EA336177288B19210071C351 /* VDS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EA33616C288B19200071C351 /* VDS.framework */; }; EA33617C288B19210071C351 /* VDSTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA33617B288B19210071C351 /* VDSTests.swift */; }; @@ -144,6 +148,7 @@ EAB5FEF829393A7200998C17 /* ButtonGroupConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB5FEF729393A7200998C17 /* ButtonGroupConstants.swift */; }; EAB5FF0129424ACB00998C17 /* UIControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB5FF0029424ACB00998C17 /* UIControl.swift */; }; EABFEB642A26473700C4C106 /* NSAttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = EABFEB632A26473700C4C106 /* NSAttributedString.swift */; }; + EAC58BFD2BE935C300BA39FA /* TitleLockupTextColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC58BFC2BE935C300BA39FA /* TitleLockupTextColor.swift */; }; EAC71A1D2A2E155A00E47A9F /* Checkbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC71A1C2A2E155A00E47A9F /* Checkbox.swift */; }; EAC71A1F2A2E173D00E47A9F /* RadioButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC71A1E2A2E173D00E47A9F /* RadioButton.swift */; }; EAC846F3294B95CE00F685BA /* ButtonGroupCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC846F2294B95CE00F685BA /* ButtonGroupCollectionViewCell.swift */; }; @@ -259,6 +264,10 @@ EA21C5DA2B600EDD00CFC139 /* VDSTokens.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = VDSTokens.xcframework; path = ../SharedFrameworks/VDSTokens.xcframework; sourceTree = ""; }; EA297A5429FB07760031ED56 /* TooltipLabelAttribute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TooltipLabelAttribute.swift; sourceTree = ""; }; EA297A5629FB0A360031ED56 /* AppleGuidelinesTouchable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleGuidelinesTouchable.swift; sourceTree = ""; }; + EA2DC9AF2BE175BA004F58C5 /* RequiredRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequiredRule.swift; sourceTree = ""; }; + EA2DC9B12BE175E6004F58C5 /* CharacterCountRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterCountRule.swift; sourceTree = ""; }; + EA2DC9B32BE2C6FE004F58C5 /* TextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextField.swift; sourceTree = ""; }; + EA2DC9B52BE2F4A1004F58C5 /* UITextView+Publisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITextView+Publisher.swift"; sourceTree = ""; }; EA33616C288B19200071C351 /* VDS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = VDS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; EA33616F288B19200071C351 /* VDS.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = VDS.h; sourceTree = ""; }; EA336170288B19200071C351 /* VDS.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = VDS.docc; sourceTree = ""; }; @@ -343,6 +352,7 @@ EAB5FEF729393A7200998C17 /* ButtonGroupConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonGroupConstants.swift; sourceTree = ""; }; EAB5FF0029424ACB00998C17 /* UIControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIControl.swift; sourceTree = ""; }; EABFEB632A26473700C4C106 /* NSAttributedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSAttributedString.swift; sourceTree = ""; }; + EAC58BFC2BE935C300BA39FA /* TitleLockupTextColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitleLockupTextColor.swift; sourceTree = ""; }; EAC71A1C2A2E155A00E47A9F /* Checkbox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Checkbox.swift; sourceTree = ""; }; EAC71A1E2A2E173D00E47A9F /* RadioButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioButton.swift; sourceTree = ""; }; EAC846F2294B95CE00F685BA /* ButtonGroupCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonGroupCollectionViewCell.swift; sourceTree = ""; }; @@ -543,6 +553,15 @@ path = ButtonGroup; sourceTree = ""; }; + EA2DC9AE2BE175A6004F58C5 /* Rules */ = { + isa = PBXGroup; + children = ( + EA2DC9AF2BE175BA004F58C5 /* RequiredRule.swift */, + EA2DC9B12BE175E6004F58C5 /* CharacterCountRule.swift */, + ); + path = Rules; + sourceTree = ""; + }; EA336162288B19200071C351 = { isa = PBXGroup; children = ( @@ -784,12 +803,13 @@ isa = PBXGroup; children = ( EA5E30522950DDA60082B959 /* TitleLockup.swift */, + EAEEECAC2B1FC1A600531FC2 /* TitleLockupChangeLog.txt */, EA985BEF2968A93600F2FF2E /* TitleLockupEyebrowModel.swift */, EA985BED2968A92400F2FF2E /* TitleLockupSubTitleModel.swift */, EA985BEB2968A91200F2FF2E /* TitleLockupTitleModel.swift */, + EAC58BFC2BE935C300BA39FA /* TitleLockupTextColor.swift */, EA985BF12968B5BB00F2FF2E /* TitleLockupTextStyle.swift */, EA513A942A4E1F82002A4DFF /* TitleLockupStyleConfiguration.swift */, - EAEEECAC2B1FC1A600531FC2 /* TitleLockupChangeLog.txt */, ); path = TitleLockup; sourceTree = ""; @@ -867,6 +887,7 @@ children = ( EAB1D2E928AE84AA00DAE764 /* UIControlPublisher.swift */, EA89200328AECF4B006B9984 /* UITextField+Publisher.swift */, + EA2DC9B52BE2F4A1004F58C5 /* UITextView+Publisher.swift */, EAD8D2C028BFDE8B006EB6A6 /* UIGestureRecognizer+Publisher.swift */, ); path = Publishers; @@ -907,6 +928,7 @@ EAC925852911C9DE00091998 /* TextFields */ = { isa = PBXGroup; children = ( + EA2DC9AE2BE175A6004F58C5 /* Rules */, EAC9258B2911C9DE00091998 /* EntryFieldBase.swift */, EAC925862911C9DE00091998 /* InputField */, EA985C21296E032000F2FF2E /* TextArea */, @@ -918,6 +940,7 @@ isa = PBXGroup; children = ( EAC925872911C9DE00091998 /* InputField.swift */, + EA2DC9B32BE2C6FE004F58C5 /* TextField.swift */, EA6642942BCEBF9500D81DC4 /* TextLinkModel.swift */, ); path = InputField; @@ -1159,6 +1182,7 @@ EA0D1C412A6AD61C00E5C127 /* Typography+Additional.swift in Sources */, EAC925842911C63100091998 /* Colorable.swift in Sources */, 18B463A42BBD3C46005C4528 /* DropdownOptionModel.swift in Sources */, + EAC58BFD2BE935C300BA39FA /* TitleLockupTextColor.swift in Sources */, EAACB89A2B927108006A3869 /* Valuing.swift in Sources */, EAE785312BA0A438009428EA /* UIImage+Helper.swift in Sources */, EAB5FEF5292D371F00998C17 /* ButtonBase.swift in Sources */, @@ -1171,6 +1195,7 @@ 71ACE89E2BA1CC1700FB6ADC /* TiletEyebrowModel.swift in Sources */, EAF7F11728A1475A00B287F5 /* RadioButtonItem.swift in Sources */, EA985BEE2968A92400F2FF2E /* TitleLockupSubTitleModel.swift in Sources */, + EA2DC9B22BE175E6004F58C5 /* CharacterCountRule.swift in Sources */, EA985BF22968B5BB00F2FF2E /* TitleLockupTextStyle.swift in Sources */, EAB1D2CD28ABE76100DAE764 /* Withable.swift in Sources */, 71FC86DC2B96F4C800700965 /* PaginationCellItem.swift in Sources */, @@ -1180,11 +1205,13 @@ 71FC86DE2B9738B900700965 /* SurfaceConfigurationValue.swift in Sources */, EA297A5529FB07760031ED56 /* TooltipLabelAttribute.swift in Sources */, EA985BEA29689B6D00F2FF2E /* TileletSubTitleModel.swift in Sources */, + EA2DC9B02BE175BA004F58C5 /* RequiredRule.swift in Sources */, 18FEA1B72BE0EBFE00A56439 /* CalendarHeaderView.swift in Sources */, EA3361C9289054C50071C351 /* Surfaceable.swift in Sources */, EAB5FEED2927E1B200998C17 /* ButtonGroupPositionLayout.swift in Sources */, EA4DB30228DCBCA500103EE3 /* Badge.swift in Sources */, EA33624728931B050071C351 /* Initable.swift in Sources */, + EA2DC9B62BE2F4A1004F58C5 /* UITextView+Publisher.swift in Sources */, EAF7F0A4289B017C00B287F5 /* LabelAttributeModel.swift in Sources */, EA0B18022A9E236900F2D0CD /* SelectorGroupBase.swift in Sources */, EA5F86D02A1F936100BC83E4 /* TabsContainer.swift in Sources */, @@ -1262,6 +1289,7 @@ EAB5FEF12927F4AA00998C17 /* SelfSizingCollectionView.swift in Sources */, EA3361B8288B2AAA0071C351 /* ViewProtocol.swift in Sources */, EA3361A8288B23300071C351 /* UIColor.swift in Sources */, + EA2DC9B42BE2C6FE004F58C5 /* TextField.swift in Sources */, EAC9257D29119B5400091998 /* TextLink.swift in Sources */, EA596ABF2A16B4F500300C4B /* Tabs.swift in Sources */, EAD062A72A3B67770015965D /* UIView+CALayer.swift in Sources */, @@ -1417,7 +1445,7 @@ BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 62; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1454,7 +1482,7 @@ BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 62; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; diff --git a/VDS/BaseClasses/Selector/SelectorGroupBase.swift b/VDS/BaseClasses/Selector/SelectorGroupBase.swift index 997a71b2..1639ab86 100644 --- a/VDS/BaseClasses/Selector/SelectorGroupBase.swift +++ b/VDS/BaseClasses/Selector/SelectorGroupBase.swift @@ -20,7 +20,7 @@ extension SelectorGroup { public var hasSelectedItem: Bool { items.filter { $0.isSelected == true }.count > 0 } } -public protocol SelectorGroupMultiSelect: SelectorGroup {} +public protocol SelectorGroupMultiSelect: SelectorGroup, FormFieldable {} extension SelectorGroupMultiSelect { /// Current Selected Control for this group. public var selectedItems: [SelectorItemType]? { @@ -30,7 +30,7 @@ extension SelectorGroupMultiSelect { } } -public protocol SelectorGroupSingleSelect: SelectorGroup {} +public protocol SelectorGroupSingleSelect: SelectorGroup, FormFieldable {} extension SelectorGroupSingleSelect { /// Current Selected Control for this group. public var selectedItem: SelectorItemType? { @@ -39,7 +39,7 @@ extension SelectorGroupSingleSelect { } /// Base Class used for any Grouped Form Control of a Selector Type. -open class SelectorGroupBase: Control, SelectorGroup, Changeable { +open class SelectorGroupBase: Control, SelectorGroup, Changeable { //-------------------------------------------------- // MARK: - Private Properties diff --git a/VDS/BaseClasses/Selector/SelectorItemBase.swift b/VDS/BaseClasses/Selector/SelectorItemBase.swift index 495ffaf3..3dd31dda 100644 --- a/VDS/BaseClasses/Selector/SelectorItemBase.swift +++ b/VDS/BaseClasses/Selector/SelectorItemBase.swift @@ -11,7 +11,7 @@ import Combine import VDSTokens /// Base Class used to build out a SelectorControlable control. -open class SelectorItemBase: Control, Errorable, Changeable, FormFieldable { +open class SelectorItemBase: Control, Errorable, Changeable { //-------------------------------------------------- // MARK: - Initializers @@ -141,8 +141,10 @@ open class SelectorItemBase: Control, Errorable, open var inputId: String? { didSet { setNeedsUpdate() } } - open var value: AnyHashable? { didSet { setNeedsUpdate() } } + open var value: AnyHashable? { hiddenValue } + open var hiddenValue: AnyHashable? { didSet { setNeedsUpdate() } } + //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- @@ -214,7 +216,6 @@ open class SelectorItemBase: Control, Errorable, showError = false errorText = nil inputId = nil - value = nil isSelected = false onChange = nil diff --git a/VDS/Classes/SelfSizingCollectionView.swift b/VDS/Classes/SelfSizingCollectionView.swift index ad5d5661..ff1f9cb8 100644 --- a/VDS/Classes/SelfSizingCollectionView.swift +++ b/VDS/Classes/SelfSizingCollectionView.swift @@ -23,12 +23,12 @@ public final class SelfSizingCollectionView: UICollectionView { /// - layout: Layout used for this CollectionView public override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) { super.init(frame: frame, collectionViewLayout: layout) - self.setupContentSizeObservation() + self.initialSetup() } public required init?(coder: NSCoder) { super.init(coder: coder) - self.setupContentSizeObservation() + self.initialSetup() } //-------------------------------------------------- @@ -69,22 +69,31 @@ public final class SelfSizingCollectionView: UICollectionView { //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- - private func setupContentSizeObservation() { + private func initialSetup() { + //ensure this hasn't run before + guard anyCancellable == nil else { return } + //ensure autoLayout uses intrinsic height setContentHuggingPriority(.required, for: .vertical) setContentCompressionResistancePriority(.required, for: .vertical) collectionViewHeight = height(constant: 0, priority: .defaultHigh) - anyCancellable = self.publisher(for: \.contentSize, options: [.new]) + anyCancellable = self.publisher(for: \.contentSize, options: [.new, .old]) .sink { [weak self] compare in - guard let self else { return } - if compare.height != self.collectionViewHeight?.constant { - self.invalidateIntrinsicContentSize() - self.collectionViewHeight?.constant = compare.height - self.contentSizeSubject.send(compare) - } + + guard let self, + let currentHeight = self.collectionViewHeight?.constant, + compare.height != currentHeight else { return } + + self.invalidateIntrinsicContentSize() + self.collectionViewHeight?.constant = compare.height + self.contentSizeSubject.send(compare) } } + + deinit { + anyCancellable?.cancel() + } } extension UITraitCollection { diff --git a/VDS/Components/BadgeIndicator/BadgeIndicator.swift b/VDS/Components/BadgeIndicator/BadgeIndicator.swift index a327270d..aae927a8 100644 --- a/VDS/Components/BadgeIndicator/BadgeIndicator.swift +++ b/VDS/Components/BadgeIndicator/BadgeIndicator.swift @@ -210,6 +210,7 @@ open class BadgeIndicator: View { /// The Container's height. open var height: CGFloat? { didSet { setNeedsUpdate() } } + open var accessibilityText: String? { didSet { setNeedsUpdate() } } //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- @@ -348,7 +349,9 @@ open class BadgeIndicator: View { open override func updateAccessibility() { super.updateAccessibility() - if kind == .numbered { + if let accessibilityText { + accessibilityLabel = accessibilityText + } else if kind == .numbered { accessibilityLabel = label.text } else { accessibilityLabel = "Simple" diff --git a/VDS/Components/Breadcrumbs/BreadcrumbItem.swift b/VDS/Components/Breadcrumbs/BreadcrumbItem.swift index a7b9bef4..f12d9646 100644 --- a/VDS/Components/Breadcrumbs/BreadcrumbItem.swift +++ b/VDS/Components/Breadcrumbs/BreadcrumbItem.swift @@ -96,7 +96,7 @@ open class BreadcrumbItem: ButtonBase { /// Used to update any Accessibility properties. open override func updateAccessibility() { super.updateAccessibility() - accessibilityLabel = "Breadcrumb \(text ?? "")" + accessibilityLabel = text } } diff --git a/VDS/Components/Breadcrumbs/Breadcrumbs.swift b/VDS/Components/Breadcrumbs/Breadcrumbs.swift index 63084192..7c88e18a 100644 --- a/VDS/Components/Breadcrumbs/Breadcrumbs.swift +++ b/VDS/Components/Breadcrumbs/Breadcrumbs.swift @@ -39,6 +39,13 @@ open class Breadcrumbs: View { } } + open override var accessibilityElements: [Any]? { + get { + return [containerView, breadcrumbs] + } + set {} + } + /// A callback when the selected item changes. Passes parameters (crumb). open var onBreadcrumbDidSelect: ((BreadcrumbItem) -> Void)? @@ -73,6 +80,11 @@ open class Breadcrumbs: View { return collectionView }() + private let containerView = View().with { + $0.isAccessibilityElement = true + $0.accessibilityLabel = "Breadcrumbs" + } + //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- @@ -106,8 +118,10 @@ open class Breadcrumbs: View { /// Executed on initialization for this View. open override func initialSetup() { super.initialSetup() - addSubview(collectionView) + containerView.addSubview(collectionView) collectionView.pinToSuperView() + addSubview(containerView) + containerView.pinToSuperView() } /// Resets to default settings. @@ -124,7 +138,7 @@ open class Breadcrumbs: View { super.updateView() collectionView.reloadData() } - + open override func layoutSubviews() { //Turn off the ability to execute updateView() in the super //since we don't want an infinite loop @@ -139,6 +153,7 @@ open class Breadcrumbs: View { } } private var separatorWidth = Label().with { $0.text = "/"; $0.sizeToFit() }.intrinsicContentSize.width + } extension Breadcrumbs: UICollectionViewDelegate, UICollectionViewDataSource, ButtongGroupPositionLayoutDelegate { diff --git a/VDS/Components/Checkbox/CheckboxGroup.swift b/VDS/Components/Checkbox/CheckboxGroup.swift index c7e424a1..1df8d9cd 100644 --- a/VDS/Components/Checkbox/CheckboxGroup.swift +++ b/VDS/Components/Checkbox/CheckboxGroup.swift @@ -14,6 +14,7 @@ import VDSTokens /// to allow user selection. @objc(VDSCheckboxGroup) open class CheckboxGroup: SelectorGroupBase, SelectorGroupMultiSelect { + //-------------------------------------------------- // MARK: - Initializers //-------------------------------------------------- @@ -32,6 +33,10 @@ open class CheckboxGroup: SelectorGroupBase, SelectorGroupMultiSel //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- + public var inputId: String? + + public var value: [SelectorItemType]? { selectedItems } + /// Array of ``CheckboxItemModel`` that will be used to build the selectorViews of type ``CheckboxItem``. open var selectorModels: [CheckboxItemModel]? { didSet { @@ -41,7 +46,7 @@ open class CheckboxGroup: SelectorGroupBase, SelectorGroupMultiSel $0.isEnabled = !model.disabled $0.surface = model.surface $0.inputId = model.inputId - $0.value = model.value + $0.hiddenValue = model.value $0.accessibilityLabel = model.accessibileText $0.accessibilityValue = "item \(index+1) of \(selectorModels.count)" $0.labelText = model.labelText @@ -97,7 +102,7 @@ open class CheckboxGroup: SelectorGroupBase, SelectorGroupMultiSel } extension CheckboxGroup { - public struct CheckboxItemModel : Surfaceable, Initable, FormFieldable, Errorable { + public struct CheckboxItemModel : Surfaceable, Initable, Errorable { /// Whether this object is disabled or not public var disabled: Bool diff --git a/VDS/Components/DropdownSelect/DropdownSelect.swift b/VDS/Components/DropdownSelect/DropdownSelect.swift index 11550937..80f94988 100644 --- a/VDS/Components/DropdownSelect/DropdownSelect.swift +++ b/VDS/Components/DropdownSelect/DropdownSelect.swift @@ -37,6 +37,11 @@ open class DropdownSelect: EntryFieldBase { /// Allows unique ID to be passed to the element. open var selectId: Int? { didSet { setNeedsUpdate() }} + /// Current SelectedItem Value + open override var value: String? { + selectedItem?.value + } + /// Current SelectedItem open var selectedItem: DropdownOptionModel? { guard let selectId else { return nil } @@ -89,12 +94,6 @@ open class DropdownSelect: EntryFieldBase { // MARK: - Configuration Properties //-------------------------------------------------- internal override var containerSize: CGSize { CGSize(width: showInlineLabel ? minWidthInlineLabel : width ?? minWidthDefault, height: 44) } - - internal let iconColorConfiguration = ControlColorConfiguration().with { - $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .normal) - $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: .disabled) - $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .error) - } //-------------------------------------------------- // MARK: - Overrides @@ -103,8 +102,6 @@ open class DropdownSelect: EntryFieldBase { /// 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() - - accessibilityLabel = "Dropdown Select" // stackview for controls in EntryFieldBase.controlContainerView let controlStackView = UIStackView().with { @@ -119,6 +116,10 @@ open class DropdownSelect: EntryFieldBase { controlStackView.addArrangedSubview(inlineDisplayLabel) controlStackView.addArrangedSubview(selectedOptionLabel) + containerStackView.isAccessibilityElement = true + containerStackView.accessibilityLabel = "Dropdown Select" + inlineDisplayLabel.isAccessibilityElement = true + controlStackView.setCustomSpacing(0, after: dropdownField) controlStackView.setCustomSpacing(VDSLayout.space1X, after: inlineDisplayLabel) controlStackView.setCustomSpacing(VDSLayout.space3X, after: selectedOptionLabel) @@ -163,7 +164,7 @@ open class DropdownSelect: EntryFieldBase { updateInlineLabel() - dropdownField.isUserInteractionEnabled = readOnly ? false : true + dropdownField.isUserInteractionEnabled = isReadOnly ? false : true selectedOptionLabel.surface = surface selectedOptionLabel.isEnabled = isEnabled } @@ -191,7 +192,7 @@ open class DropdownSelect: EntryFieldBase { updatedLabelText = showInlineLabel ? "" : updatedLabelText - if let oldText = updatedLabelText, !required, !oldText.hasSuffix("Optional") { + if let oldText = updatedLabelText, !isRequired, !oldText.hasSuffix("Optional") { let optionColorAttr = ColorLabelAttribute(location: oldText.count + 2, length: 8, color: secondaryColorConfiguration.getColor(self)) @@ -234,22 +235,53 @@ open class DropdownSelect: EntryFieldBase { open func updateSelectedOptionLabel(option: DropdownOptionModel? = nil) { selectedOptionLabel.text = option?.text ?? "" - value = option?.value } open override func updateErrorLabel() { super.updateErrorLabel() if !showError && !hasInternalError { - icon.name = .downCaret + statusIcon.name = .downCaret } - icon.surface = surface - icon.isHidden = readOnly ? true : false - icon.color = iconColorConfiguration.getColor(self) + statusIcon.surface = surface + statusIcon.isHidden = isReadOnly ? true : false + statusIcon.color = iconColorConfiguration.getColor(self) } + open override func updateAccessibility() { + super.updateAccessibility() + var selectedOption = selectedOptionLabel.text ?? "" + containerStackView.accessibilityLabel = "Dropdown Select, \(selectedOption) \(isReadOnly ? ", read only" : "")" + containerStackView.accessibilityHint = isReadOnly || !isEnabled ? "" : "Double tap to open." + } + + open override var accessibilityElements: [Any]? { + get { + var elements = [Any]() + elements.append(contentsOf: [titleLabel, containerStackView]) + + if showError { + elements.append(statusIcon) + if let errorText, !errorText.isEmpty { + elements.append(errorLabel) + } + } + + if let helperText, !helperText.isEmpty { + elements.append(helperLabel) + } + + return elements + } + + set { super.accessibilityElements = newValue } + } + + @objc open func pickerDoneClicked() { optionsPicker.isHidden = true dropdownField.resignFirstResponder() + setNeedsUpdate() + UIAccessibility.post(notification: .layoutChanged, argument: containerStackView) } } @@ -260,6 +292,7 @@ extension DropdownSelect: UIPickerViewDelegate, UIPickerViewDataSource { internal func launchPicker() { if optionsPicker.isHidden { + UIAccessibility.post(notification: .layoutChanged, argument: optionsPicker) dropdownField.becomeFirstResponder() } else { dropdownField.resignFirstResponder() @@ -284,6 +317,7 @@ extension DropdownSelect: UIPickerViewDelegate, UIPickerViewDataSource { guard options.count > row else { return } selectId = row updateSelectedOptionLabel(option: options[row]) + sendActions(for: .valueChanged) self.onItemSelected?(row, options[row]) } } diff --git a/VDS/Components/Icon/ButtonIcon/ButtonIcon.swift b/VDS/Components/Icon/ButtonIcon/ButtonIcon.swift index 658cb05e..5ba6a9fe 100644 --- a/VDS/Components/Icon/ButtonIcon/ButtonIcon.swift +++ b/VDS/Components/Icon/ButtonIcon/ButtonIcon.swift @@ -14,7 +14,7 @@ import Combine /// It usually represents a supplementary or utilitarian action. A button icon can stand alone, but often /// exists in a group when there are several actions that can be performed. @objc(VDSButtonIcon) -open class ButtonIcon: Control, Changeable, FormFieldable { +open class ButtonIcon: Control, Changeable { //-------------------------------------------------- // MARK: - Initializers @@ -173,10 +173,6 @@ open class ButtonIcon: Control, Changeable, FormFieldable { /// Used to move the icon inside the button in both x and y axis. open var iconOffset: CGPoint = .init(x: 0, y: 0) { didSet { setNeedsUpdate() } } - open var inputId: String? { didSet { setNeedsUpdate() } } - - open var value: AnyHashable? { didSet { setNeedsUpdate() } } - //-------------------------------------------------- // MARK: - Configuration //-------------------------------------------------- @@ -548,6 +544,7 @@ open class ButtonIcon: Control, Changeable, FormFieldable { badgeIndicator.horizontalPadding = badgeIndicatorModel.horizontalPadding badgeIndicator.hideDot = badgeIndicatorModel.hideDot badgeIndicator.hideBorder = badgeIndicatorModel.hideBorder + badgeIndicator.accessibilityText = badgeIndicatorModel.accessibilityText } private func updateExpandDirectionalConstraints() { diff --git a/VDS/Components/Icon/ButtonIcon/ButtonIconBadgeIndicatorModel.swift b/VDS/Components/Icon/ButtonIcon/ButtonIconBadgeIndicatorModel.swift index e1c04b23..28d731b0 100644 --- a/VDS/Components/Icon/ButtonIcon/ButtonIconBadgeIndicatorModel.swift +++ b/VDS/Components/Icon/ButtonIcon/ButtonIconBadgeIndicatorModel.swift @@ -46,6 +46,9 @@ extension ButtonIcon { /// Trailing Text height that will be used for the badge indicator. public var trailingText: String? + /// Accessibliity Text + public var accessibilityText: String? + /// Dot Size that will be used for the badge indicator. public var dotSize: CGFloat? @@ -61,7 +64,7 @@ extension ButtonIcon { /// Hide Border that will be used for the badge indicator. public var hideBorder: Bool = false - public init(kind: BadgeIndicator.Kind = .simple, fillColor: BadgeIndicator.FillColor = .red, expandDirection: ExpandDirection = .right, size: BadgeIndicator.Size = .xxlarge, maximumDigits: BadgeIndicator.MaximumDigits = .two, width: CGFloat? = nil, height: CGFloat? = nil, number: Int? = nil, leadingCharacter: String? = "", trailingText: String? = "", dotSize: CGFloat? = nil, verticalPadding: CGFloat? = nil, horizontalPadding: CGFloat? = nil, hideDot: Bool = false, hideBorder: Bool = false) { + public init(kind: BadgeIndicator.Kind = .simple, fillColor: BadgeIndicator.FillColor = .red, expandDirection: ExpandDirection = .right, size: BadgeIndicator.Size = .xxlarge, maximumDigits: BadgeIndicator.MaximumDigits = .two, width: CGFloat? = nil, height: CGFloat? = nil, number: Int? = nil, leadingCharacter: String? = "", trailingText: String? = "", accessibilityText: String? = nil, dotSize: CGFloat? = nil, verticalPadding: CGFloat? = nil, horizontalPadding: CGFloat? = nil, hideDot: Bool = false, hideBorder: Bool = false) { self.kind = kind self.fillColor = fillColor self.expandDirection = expandDirection @@ -70,6 +73,7 @@ extension ButtonIcon { self.width = width self.height = height self.number = number + self.accessibilityText = accessibilityText self.leadingCharacter = leadingCharacter self.trailingText = trailingText self.dotSize = dotSize diff --git a/VDS/Components/Label/Attributes/TooltipLabelAttribute.swift b/VDS/Components/Label/Attributes/TooltipLabelAttribute.swift index 187d4eda..6b03d103 100644 --- a/VDS/Components/Label/Attributes/TooltipLabelAttribute.swift +++ b/VDS/Components/Label/Attributes/TooltipLabelAttribute.swift @@ -37,17 +37,19 @@ public class TooltipLabelAttribute: ActionLabelAttributeModel, TooltipLaunchable } var frame = CGRect.zero + let ratio: Double = 1.0 //0.80 + let yPosition: Double = -3 if let font = attributedString.attribute(.font, at: 0, effectiveRange: &originalRange) as? UIFont { switch font.pointSize { case 15..<25: size = .medium - frame = CGRect(x: 0, y: -1, width: size.value.dimensions.width * 0.80, height: size.value.dimensions.height * 0.80) + frame = CGRect(x: 0, y: yPosition, width: size.value.dimensions.width * ratio, height: size.value.dimensions.height * ratio) case 0..<14: size = .small - frame = CGRect(x: 0, y: -1, width: size.value.dimensions.width * 0.80 , height: size.value.dimensions.height * 0.80) + frame = CGRect(x: 0, y: yPosition, width: size.value.dimensions.width * ratio , height: size.value.dimensions.height * ratio) default: size = .medium - frame = CGRect(x: 0, y: -1, width: size.value.dimensions.width, height: size.value.dimensions.height) + frame = CGRect(x: 0, y: yPosition, width: size.value.dimensions.width, height: size.value.dimensions.height) } } @@ -75,7 +77,7 @@ public class TooltipLabelAttribute: ActionLabelAttributeModel, TooltipLaunchable self.subscriber = subscriber self.surface = surface self.model = model - self.accessibleText = accessibleText + self.accessibleText = accessibleText ?? model.accessibleText self.presenter = presenter //create the tooltip click event diff --git a/VDS/Components/Notification/Notification.swift b/VDS/Components/Notification/Notification.swift index 241efb34..b1010176 100644 --- a/VDS/Components/Notification/Notification.swift +++ b/VDS/Components/Notification/Notification.swift @@ -52,7 +52,7 @@ open class Notification: View { } } - var accessibilityText: String { + var accessibleText: String { switch self { case .info: "Information Message" @@ -104,6 +104,7 @@ open class Notification: View { open var typeIcon = Icon().with { $0.name = .infoBold $0.size = UIDevice.isIPad ? .medium : .small + $0.accessibilityTraits.remove(.image) } /// Icon used for the close. @@ -374,7 +375,7 @@ open class Notification: View { open override func updateAccessibility() { super.updateAccessibility() closeButton.accessibilityLabel = "Close Notification" - typeIcon.accessibilityLabel = style.accessibilityText + typeIcon.accessibilityLabel = style.accessibleText } private func setConstraints() { diff --git a/VDS/Components/Pagination/Pagination.swift b/VDS/Components/Pagination/Pagination.swift index f5a4e2c7..d3c0298a 100644 --- a/VDS/Components/Pagination/Pagination.swift +++ b/VDS/Components/Pagination/Pagination.swift @@ -189,6 +189,7 @@ open class Pagination: View { nextButton.isHidden = _selectedPageIndex == total - 1 collectionView.reloadData() verifyIfMaxDigitChanged() + setNeedsUpdate() } ///Identifying if there is any change in the digits of upcoming page diff --git a/VDS/Components/RadioBox/RadioBoxGroup.swift b/VDS/Components/RadioBox/RadioBoxGroup.swift index 18d3c26b..e284840b 100644 --- a/VDS/Components/RadioBox/RadioBoxGroup.swift +++ b/VDS/Components/RadioBox/RadioBoxGroup.swift @@ -32,6 +32,10 @@ open class RadioBoxGroup: SelectorGroupBase, SelectorGroupSingleSe //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- + public var inputId: String? + + public var value: SelectorItemType? { selectedItem } + /// Array of ``RadioBoxItemModel`` that will be used to build the selectorViews of type ``RadioBoxItem``. open var selectorModels: [RadioBoxItemModel]? { didSet { @@ -48,6 +52,7 @@ open class RadioBoxGroup: SelectorGroupBase, SelectorGroupSingleSe $0.subTextRightAttributes = model.subTextRightAttributes $0.isEnabled = !model.disabled $0.inputId = model.inputId + $0.hiddenValue = model.value $0.isSelected = model.selected $0.strikethrough = model.strikethrough $0.strikethroughAccessibilityText = model.strikethroughAccessibileText diff --git a/VDS/Components/RadioBox/RadioBoxItem.swift b/VDS/Components/RadioBox/RadioBoxItem.swift index 23dd2b41..1a9d613f 100644 --- a/VDS/Components/RadioBox/RadioBoxItem.swift +++ b/VDS/Components/RadioBox/RadioBoxItem.swift @@ -127,7 +127,9 @@ open class RadioBoxItem: Control, Changeable, FormFieldable { open var inputId: String? { didSet { setNeedsUpdate() } } - open var value: AnyHashable? { didSet { setNeedsUpdate() } } + open var value: AnyHashable? { hiddenValue } + + open var hiddenValue: AnyHashable? { didSet { setNeedsUpdate() } } //-------------------------------------------------- // MARK: - Configuration Properties @@ -211,7 +213,7 @@ open class RadioBoxItem: Control, Changeable, FormFieldable { subTextRightAttributedText = nil strikethrough = false inputId = nil - value = nil + hiddenValue = nil isSelected = false onChange = nil diff --git a/VDS/Components/RadioButton/RadioButtonGroup.swift b/VDS/Components/RadioButton/RadioButtonGroup.swift index 6b12f60a..aecb8034 100644 --- a/VDS/Components/RadioButton/RadioButtonGroup.swift +++ b/VDS/Components/RadioButton/RadioButtonGroup.swift @@ -32,6 +32,10 @@ open class RadioButtonGroup: SelectorGroupBase, SelectorGroupSi //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- + public var inputId: String? + + public var value: SelectorItemType? { selectedItem } + /// Array of ``RadioButtonItemModel`` that will be used to build the selectorViews of type ``RadioButtonItem``. open var selectorModels: [RadioButtonItemModel]? { didSet { @@ -41,7 +45,7 @@ open class RadioButtonGroup: SelectorGroupBase, SelectorGroupSi $0.isEnabled = !model.disabled $0.surface = model.surface $0.inputId = model.inputId - $0.value = model.value + $0.hiddenValue = model.value $0.accessibilityLabel = model.accessibileText $0.accessibilityValue = "item \(index+1) of \(selectorModels.count)" $0.labelText = model.labelText @@ -57,7 +61,7 @@ open class RadioButtonGroup: SelectorGroupBase, SelectorGroupSi setNeedsUpdate() } } - + private var _showError: Bool = false /// Whether not to show the error. diff --git a/VDS/Components/TextFields/EntryFieldBase.swift b/VDS/Components/TextFields/EntryFieldBase.swift index a8ad917a..5c549564 100644 --- a/VDS/Components/TextFields/EntryFieldBase.swift +++ b/VDS/Components/TextFields/EntryFieldBase.swift @@ -83,6 +83,8 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { } }() + open var rules = [AnyRule]() + //-------------------------------------------------- // MARK: - Configuration Properties //-------------------------------------------------- @@ -103,15 +105,24 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { $0.setSurfaceColors(VDSFormControlsColor.backgroundOnlight, VDSFormControlsColor.backgroundOndark, forState: .normal) $0.setSurfaceColors(VDSFormControlsColor.backgroundOnlight, VDSFormControlsColor.backgroundOndark, forState: .disabled) $0.setSurfaceColors(VDSColor.feedbackErrorBackgroundOnlight, VDSColor.feedbackErrorBackgroundOndark, forState: .error) + $0.setSurfaceColors(VDSColor.feedbackErrorBackgroundOnlight, VDSColor.feedbackErrorBackgroundOndark, forState: [.error, .focused]) } internal var borderColorConfiguration = ControlColorConfiguration().with { $0.setSurfaceColors(VDSFormControlsColor.borderOnlight, VDSFormControlsColor.borderOndark, forState: .normal) + $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOnlight, forState: .focused) + $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOnlight, forState: [.focused, .error]) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: .disabled) $0.setSurfaceColors(VDSColor.feedbackErrorOnlight, VDSColor.feedbackErrorOndark, forState: .error) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: [.disabled,.error]) } + internal let iconColorConfiguration = ControlColorConfiguration().with { + $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .normal) + $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: .disabled) + $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .error) + } + internal var readOnlyBorderColorConfiguration = ControlColorConfiguration().with { $0.setSurfaceColors(VDSFormControlsColor.borderReadonlyOnlight, VDSFormControlsColor.borderReadonlyOndark, forState: .normal) } @@ -137,7 +148,7 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { $0.textStyle = .bodySmall } - open var icon: Icon = Icon().with { + open var statusIcon: Icon = Icon().with { $0.size = .medium } @@ -149,10 +160,7 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { open var showError: Bool = false { didSet { setNeedsUpdate() } } /// FormFieldValidator - internal var validator: (any FormFieldValidatorable)? - - /// Whether or not to show the internal error - open var hasInternalError: Bool { !(validator?.isValid ?? true) } + open var validator: (any FormFieldValidatorable)? /// Override UIControl state to add the .error state if showError is true. open override var state: UIControl.State { @@ -165,22 +173,8 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { } } - open var errorText: String? { - didSet { - updateContainerView() - updateErrorLabel() - setNeedsUpdate() - } - } - - open var internalErrorText: String? { - didSet { - updateContainerView() - updateErrorLabel() - setNeedsUpdate() - } - } - + open var errorText: String? { didSet { setNeedsUpdate() } } + open var tooltipModel: Tooltip.TooltipModel? { didSet { setNeedsUpdate() } } open var transparentBackground: Bool = false { didSet { setNeedsUpdate() } } @@ -190,22 +184,15 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { open var inputId: String? { didSet { setNeedsUpdate() } } /// The text of this textField. - internal var _value: String? open var value: String? { - get { _value } - set { - if let newValue, newValue != _value { - _value = newValue - sendActions(for: .valueChanged) - } - } + get { fatalError("must be read from subclass")} } open var defaultValue: AnyHashable? { didSet { setNeedsUpdate() } } - open var required: Bool = false { didSet { setNeedsUpdate() } } + open var isRequired: Bool = false { didSet { setNeedsUpdate() } } - open var readOnly: Bool = false { didSet { setNeedsUpdate() } } + open var isReadOnly: Bool = false { didSet { setNeedsUpdate() } } //-------------------------------------------------- // MARK: - Constraints @@ -220,7 +207,7 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { open override func setup() { super.setup() - isAccessibilityElement = true + isAccessibilityElement = false addSubview(stackView) //create the wrapping view @@ -239,11 +226,11 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { //this is the horizontal stack that contains //the left, InputContainer, Icons, Buttons container.addSubview(containerStackView) - containerStackView.pinToSuperView(.uniform(12)) + containerStackView.pinToSuperView(.uniform(VDSLayout.space3X)) //add the view to add input fields containerStackView.addArrangedSubview(controlContainerView) - containerStackView.addArrangedSubview(icon) + containerStackView.addArrangedSubview(statusIcon) containerStackView.setCustomSpacing(VDSLayout.space3X, after: controlContainerView) //get the container this is what show helper text, error text @@ -295,25 +282,26 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { transparentBackground = false width = nil inputId = nil - value = nil defaultValue = nil - required = false - readOnly = false + isRequired = false + isReadOnly = false onChange = nil } /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { super.updateView() - updateContainerView() updateTitleLabel() updateErrorLabel() updateHelperLabel() - - backgroundColor = surface.color + } + + open func validate(){ + updateRules() + validator = FormFieldValidator(field: self, rules: rules) validator?.validate() - internalErrorText = validator?.errorMessage + setNeedsUpdate() } //-------------------------------------------------- @@ -321,7 +309,7 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { //-------------------------------------------------- private func updateContainerView() { containerView.backgroundColor = backgroundColorConfiguration.getColor(self) - containerView.layer.borderColor = readOnly ? readOnlyBorderColorConfiguration.getColor(self).cgColor : borderColorConfiguration.getColor(self).cgColor + containerView.layer.borderColor = isReadOnly ? readOnlyBorderColorConfiguration.getColor(self).cgColor : borderColorConfiguration.getColor(self).cgColor containerView.layer.borderWidth = VDSFormControls.borderWidth containerView.layer.cornerRadius = VDSFormControls.borderRadius } @@ -339,6 +327,21 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { return bottomContainerView } + internal func updateRules() { + rules.removeAll() + if self.isRequired { + let rule = RequiredRule() + if let errorText, !errorText.isEmpty { + rule.errorMessage = errorText + } else if let labelText{ + rule.errorMessage = "You must enter a \(labelText)" + } else { + rule.errorMessage = "You must enter a value" + } + rules.append(.init(rule)) + } + } + open func updateTitleLabel() { //update the local vars for the label since we no @@ -347,7 +350,7 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { var updatedLabelText = labelText //dealing with the "Optional" addition to the text - if let oldText = updatedLabelText, !required, !oldText.hasSuffix("Optional") { + if let oldText = updatedLabelText, !isRequired, !oldText.hasSuffix("Optional") { if isEnabled { let optionColorAttr = ColorLabelAttribute(location: oldText.count + 2, length: 8, @@ -370,37 +373,27 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { } open func updateErrorLabel(){ - if showError, hasInternalError, let errorText, let internalErrorText { - errorLabel.text = [internalErrorText, errorText].joined(separator: "\n") - errorLabel.surface = surface - errorLabel.isEnabled = isEnabled - errorLabel.isHidden = false - icon.name = .error - icon.color = VDSColor.paletteBlack - icon.surface = surface - icon.isHidden = !isEnabled - } else if showError, let errorText { + if showError, let errorText { errorLabel.text = errorText errorLabel.surface = surface errorLabel.isEnabled = isEnabled errorLabel.isHidden = false - icon.name = .error - icon.color = VDSColor.paletteBlack - icon.surface = surface - icon.isHidden = !isEnabled + statusIcon.name = .error + statusIcon.surface = surface + statusIcon.isHidden = !isEnabled || state.contains(.focused) } else if hasInternalError, let internalErrorText { errorLabel.text = internalErrorText errorLabel.surface = surface errorLabel.isEnabled = isEnabled errorLabel.isHidden = false - icon.name = .error - icon.color = VDSColor.paletteBlack - icon.surface = surface - icon.isHidden = !isEnabled + statusIcon.name = .error + statusIcon.surface = surface + statusIcon.isHidden = !isEnabled || state.contains(.focused) } else { - icon.isHidden = true + statusIcon.isHidden = true errorLabel.isHidden = true } + statusIcon.color = iconColorConfiguration.getColor(self) } open func updateHelperLabel(){ diff --git a/VDS/Components/TextFields/InputField/InputField.swift b/VDS/Components/TextFields/InputField/InputField.swift index e34ee01c..00e7404f 100644 --- a/VDS/Components/TextFields/InputField/InputField.swift +++ b/VDS/Components/TextFields/InputField/InputField.swift @@ -14,7 +14,7 @@ import Combine /// 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, UITextFieldDelegate { +open class InputField: EntryFieldBase { //-------------------------------------------------- // MARK: - Initializers @@ -30,19 +30,19 @@ open class InputField: EntryFieldBase, UITextFieldDelegate { public required init?(coder: NSCoder) { super.init(coder: coder) } - + //-------------------------------------------------- // MARK: - Enums //-------------------------------------------------- /// Enum used to describe the input type. public enum FieldType: String, CaseIterable { - case text, number, calendar, inlineAction, password, creditCard, tel, date, securityCode + case text, number, inlineAction, password, creditCard, tel, date, securityCode } - + //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- - internal var inputFieldStackView: UIStackView = { + internal var inputFieldStackView: UIStackView = { return UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.axis = .horizontal @@ -50,9 +50,9 @@ open class InputField: EntryFieldBase, UITextFieldDelegate { $0.spacing = 12 } }() - + internal var minWidthConstraint: NSLayoutConstraint? - + //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- @@ -63,7 +63,7 @@ open class InputField: EntryFieldBase, UITextFieldDelegate { } /// UITextField shown in the InputField. - open var textField = UITextField().with { + open var textField = TextField().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.font = TextStyle.bodyLarge.font } @@ -73,37 +73,30 @@ open class InputField: EntryFieldBase, UITextFieldDelegate { $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forDisabled: true) $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forDisabled: false) }.eraseToAnyColorable() - + /// Representing the type of input. open var fieldType: FieldType = .text { didSet { setNeedsUpdate() } } - - internal var actionTextLink = TextLink().with { $0.contentEdgeInsets = .top(-2) } - internal var actionTextLinkModel: TextLinkModel? { didSet { setNeedsUpdate() } } - + open var leftIcon: Icon = Icon().with { $0.size = .medium } + + open var actionTextLink = TextLink().with { $0.contentEdgeInsets = .top(-2) } + + open var actionTextLinkModel: TextLinkModel? { didSet { setNeedsUpdate() } } + /// The text of this TextField. - private var _text: String? open var text: String? { - get { _text } + get { textField.text } set { - if let newValue, newValue != _text { - _text = newValue - textField.text = newValue - value = newValue - } + textField.text = newValue setNeedsUpdate() } } - /// The value of this textField. + /// Value for the textField open override var value: String? { - didSet { - if text != value { - text = value - } - } + textField.text } - + var _showError: Bool = false /// Whether not to show the error. open override var showError: Bool { @@ -134,7 +127,11 @@ open class InputField: EntryFieldBase, UITextFieldDelegate { var state = super.state if showSuccess { state.insert(.success) + + } else if textField.isFirstResponder { + state.insert(.focused) } + return state } } @@ -151,27 +148,45 @@ open class InputField: EntryFieldBase, UITextFieldDelegate { /// 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() - isAccessibilityElement = false minWidthConstraint = containerView.widthAnchor.constraint(greaterThanOrEqualToConstant: 0) minWidthConstraint?.isActive = true - controlContainerView.addSubview(textField) - textField - .pinTop() - .pinLeading() - .pinTrailingLessThanOrEqualTo(nil, 0, .defaultHigh) - .pinBottom(0, .defaultHigh) + // stackview for controls in EntryFieldBase.controlContainerView + let controlStackView = UIStackView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.axis = .horizontal + $0.spacing = VDSLayout.space3X + } + controlContainerView.addSubview(controlStackView) + controlStackView.pinToSuperView() + + controlStackView.addArrangedSubview(leftIcon) + controlStackView.addArrangedSubview(textField) textField.heightAnchor.constraint(equalToConstant: 20).isActive = true + textField.delegate = self textField .textPublisher - .sink { [weak self] text in - self?.value = text + .sink { [weak self] newText in + print("textPublisher newText: \(newText)") + self?.process(text: newText) + self?.validate() self?.sendActions(for: .valueChanged) - }.store(in: &subscribers) + textField + .publisher(for: .editingDidBegin) + .sink { [weak self] _ in + self?.setNeedsUpdate() + }.store(in: &subscribers) + + textField + .publisher(for: .editingDidEnd) + .sink { [weak self] _ in + self?.validate() + }.store(in: &subscribers) + stackView.addArrangedSubview(successLabel) stackView.setCustomSpacing(8, after: successLabel) @@ -188,7 +203,6 @@ open class InputField: EntryFieldBase, UITextFieldDelegate { open override func reset() { super.reset() textField.text = "" - textField.delegate = self successLabel.reset() successLabel.textStyle = .bodySmall @@ -207,20 +221,18 @@ open class InputField: EntryFieldBase, UITextFieldDelegate { /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { + + //update fieldType first + updateFieldType() + super.updateView() textField.isEnabled = isEnabled textField.textColor = textFieldTextColorConfiguration.getColor(self) - - if let actionTextLinkModel { - actionTextLink.text = actionTextLinkModel.text - actionTextLink.onClick = actionTextLinkModel.onClick - actionTextLink.isHidden = false - containerStackView.setCustomSpacing(VDSLayout.space2X, after: icon) - } else { - actionTextLink.isHidden = true - containerStackView.setCustomSpacing(0, after: icon) - } + } + + open override func updateErrorLabel() { + super.updateErrorLabel() //show error or success if showError, let _ = errorText { @@ -232,27 +244,15 @@ open class InputField: EntryFieldBase, UITextFieldDelegate { successLabel.isEnabled = isEnabled successLabel.isHidden = false errorLabel.isHidden = true - icon.name = .checkmarkAlt - icon.color = VDSColor.paletteBlack - icon.surface = surface - icon.isHidden = !isEnabled + statusIcon.name = .checkmarkAlt + statusIcon.color = VDSColor.paletteBlack + statusIcon.surface = surface + statusIcon.isHidden = !isEnabled } else { - icon.isHidden = true successLabel.isHidden = true } - - //set the width constraints - if let width, width > fieldType.width { - widthConstraint?.constant = width - widthConstraint?.isActive = true - minWidthConstraint?.isActive = false - } else { - minWidthConstraint?.constant = fieldType.width - widthConstraint?.isActive = false - minWidthConstraint?.isActive = true - } + } - open override func updateHelperLabel(){ //remove first helperLabel.removeFromSuperview() @@ -272,16 +272,135 @@ open class InputField: EntryFieldBase, UITextFieldDelegate { } } } + + open func updateFieldType() { + + var minWidth: CGFloat = 40.0 + var leftIconName: Icon.Name? + var actionModel: InputField.TextLinkModel? + var toolTipModel: Tooltip.TooltipModel? = tooltipModel + var isSecureTextEntry = false + var rules = [AnyRule]() + var placeholderText: String? + + if self.isRequired { + let rule = RequiredRule() + if let errorText { + rule.errorMessage = errorText + } + rules.append(.init(rule)) + } + + switch fieldType { + case .text: + break + + case .number: + break + + case .inlineAction: + minWidth = 102.0 + + case .password: + let isHide = passwordActionType == .hide + let buttonText = isHide ? + hidePasswordButtonText.isEmpty ? "Hide" : hidePasswordButtonText : + showPasswordButtonText.isEmpty ? "Show" : showPasswordButtonText + + isSecureTextEntry = !isHide + let nextPasswordActionType = passwordActionType.toggle() + if let text, !text.isEmpty { + actionModel = .init(text: buttonText, + onClick: { [weak self] _ in + guard let self else { return } + self.passwordActionType = nextPasswordActionType + }) + } else { + passwordActionType = .show + } + minWidth = 62.0 + + case .creditCard: + minWidth = 288.0 + + case .tel: + minWidth = 176.0 + + case .date: + minWidth = 114.0 + placeholderText = dateFormat.placeholderText + + case .securityCode: + minWidth = 88.0 + + } + + //textField + textField.isSecureTextEntry = isSecureTextEntry + + //leftIcon + leftIcon.surface = surface + leftIcon.color = iconColorConfiguration.getColor(self) + leftIcon.name = leftIconName + leftIcon.isHidden = leftIconName == nil + + //actionLink + actionTextLink.surface = surface + if let actionModel { + actionTextLink.text = actionModel.text + actionTextLink.onClick = actionModel.onClick + actionTextLink.isHidden = false + containerStackView.setCustomSpacing(VDSLayout.space2X, after: statusIcon) + } else { + actionTextLink.isHidden = true + containerStackView.setCustomSpacing(0, after: statusIcon) + } + + //set the width constraints + if let width, width > minWidth { + widthConstraint?.constant = width + widthConstraint?.isActive = true + minWidthConstraint?.isActive = false + } else { + minWidthConstraint?.constant = minWidth + widthConstraint?.isActive = false + minWidthConstraint?.isActive = true + } + + //placeholder + textField.placeholder = placeholderText + + //tooltip + tooltipModel = toolTipModel + } /// Used to update any Accessibility properties. open override func updateAccessibility() { super.updateAccessibility() textField.accessibilityLabel = showError ? "error" : nil - if showError { - accessibilityElements = [titleLabel, textField, icon, errorLabel, helperLabel] - } else { - accessibilityElements = [titleLabel, textField, helperLabel] + } + + open override var accessibilityElements: [Any]? { + get { + var elements = [Any]() + elements.append(contentsOf: [titleLabel, textField]) + if showError { + elements.append(statusIcon) + if let errorText, !errorText.isEmpty { + 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 } } open override var canBecomeFirstResponder: Bool { true } @@ -292,25 +411,114 @@ open class InputField: EntryFieldBase, UITextFieldDelegate { } return super.resignFirstResponder() } + + //-------------------------------------------------- + // MARK: - Password + //-------------------------------------------------- + enum PasswordAction { + case show, hide + + func toggle() -> PasswordAction { + self == .hide ? .show : .hide + } + } + + internal var passwordActionType: PasswordAction = .show { didSet { setNeedsUpdate() } } + + open var hidePasswordButtonText: String = "Hide" { didSet { setNeedsUpdate() } } + open var showPasswordButtonText: String = "Show" { didSet { setNeedsUpdate() } } + + //-------------------------------------------------- + // MARK: - Date + //-------------------------------------------------- + open var dateFormat: DateFormat = .mmddyy { didSet { setNeedsUpdate() } } + } -extension InputField.FieldType { - var width: CGFloat { - switch self { - case .inlineAction: - return 102 - case .password: - return 62.0 - case .creditCard: - return 288.0 - case .tel: - return 176.0 +extension InputField: UITextFieldDelegate { + public func process(text changedText: String) { + var newText: String = changedText + switch fieldType { case .date: - return 114.0 - case .securityCode: - return 88.0 + guard newText.count <= dateFormat.maxLength else { return } + let numericText = newText.compactMap { $0.isNumber ? $0 : nil } + var formattedText = "" + + for (index, char) in numericText.enumerated() { + if (index == 2 || (index == 4 && (dateFormat != .mmyy))) && index < dateFormat.maxLength { + formattedText += "/" + } + formattedText.append(char) + } + newText = formattedText + + default: break + } + } + + public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + switch fieldType { + case .date: + // Allow only numbers and limit the length of text. + let allowedCharacters = CharacterSet.decimalDigits + let characterSet = CharacterSet(charactersIn: string) + return allowedCharacters.isSuperset(of: characterSet) && ((textField.text?.count ?? 0) + string.count - range.length) <= dateFormat.maxLength + default: - return 40.0 + return true } } } + +extension InputField.FieldType { + + public var keyboardType: UIKeyboardType { + switch self { + case .number: + .numberPad + case .tel: + .phonePad + case .creditCard: + .numberPad + case .date: + .numberPad + case .securityCode: + .numberPad + default: + .default + } + } +} + +extension InputField { + public enum DateFormat: String, CaseIterable { + case mmyy + case mmddyy + case mmddyyyy + + public var placeholderText: String { + switch self { + case .mmyy: "MM/YY" + case .mmddyy: "MM/DD/YY" + case .mmddyyyy: "MM/DD/YYYY" + } + } + + public var formatString: String { + switch self { + case .mmyy: "MM/yy" + case .mmddyy: "MM/dd/yy" + case .mmddyyyy: "MM/dd/yyyy" + } + } + + public var maxLength: Int { + switch self { + case .mmyy: 5 + case .mmddyy: 8 + case .mmddyyyy: 10 + } + } + + } +} diff --git a/VDS/Components/TextFields/InputField/TextField.swift b/VDS/Components/TextFields/InputField/TextField.swift new file mode 100644 index 00000000..9a96829e --- /dev/null +++ b/VDS/Components/TextFields/InputField/TextField.swift @@ -0,0 +1,84 @@ +// +// TextField.swift +// VDS +// +// Created by Matt Bruce on 5/1/24. +// + +import Foundation +import UIKit + +@objc(VDSTextField) +open class TextField: UITextField { + + //-------------------------------------------------- + // MARK: - Initializers + //-------------------------------------------------- + required public init() { + super.init(frame: .zero) + initialSetup() + } + + public override init(frame: CGRect) { + super.init(frame: .zero) + initialSetup() + } + + public required init?(coder: NSCoder) { + super.init(coder: coder) + initialSetup() + } + + var horizontalPadding: CGFloat = 0 + + open override func textRect(forBounds bounds: CGRect) -> CGRect { + let rect = super.textRect(forBounds: bounds) + return rect.insetBy(dx: -horizontalPadding, dy: 0) + } + + open override func editingRect(forBounds bounds: CGRect) -> CGRect { + let rect = super.editingRect(forBounds: bounds) + return rect.insetBy(dx: -horizontalPadding, dy: 0) + } + + open override func placeholderRect(forBounds bounds: CGRect) -> CGRect { + let rect = super.placeholderRect(forBounds: bounds) + return rect.insetBy(dx: -horizontalPadding, dy: 0) + } + + open override var isSecureTextEntry: Bool { + didSet { + if isFirstResponder { + _ = becomeFirstResponder() + } + } + } + + open func initialSetup() { + let doneToolbar: UIToolbar = UIToolbar() + doneToolbar.translatesAutoresizingMaskIntoConstraints = false + doneToolbar.barStyle = .default + + let flexSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) + let done: UIBarButtonItem = UIBarButtonItem(title: "Done", style: .done, target: self, action: #selector(self.doneButtonAction)) + done.accessibilityHint = "Double tap to finish editing." + doneToolbar.items = [flexSpace, done] + doneToolbar.sizeToFit() + + inputAccessoryView = doneToolbar + } + + @objc func doneButtonAction() { + // Resigns the first responder status when 'Done' is tapped + resignFirstResponder() + } + + open override func becomeFirstResponder() -> Bool { + let success = super.becomeFirstResponder() + if isSecureTextEntry, let text { + self.text?.removeAll() + insertText(text) + } + return success + } +} diff --git a/VDS/Components/TextFields/InputField/TextLinkModel.swift b/VDS/Components/TextFields/InputField/TextLinkModel.swift index d5d8c7ba..89235e5e 100644 --- a/VDS/Components/TextFields/InputField/TextLinkModel.swift +++ b/VDS/Components/TextFields/InputField/TextLinkModel.swift @@ -12,7 +12,7 @@ extension InputField { ///Text that goes in the Tab public var text: String - + ///Click event when you click on a tab public var onClick: ((TextLink) -> Void)? diff --git a/VDS/Components/TextFields/Rules/CharacterCountRule.swift b/VDS/Components/TextFields/Rules/CharacterCountRule.swift new file mode 100644 index 00000000..f26ccafc --- /dev/null +++ b/VDS/Components/TextFields/Rules/CharacterCountRule.swift @@ -0,0 +1,18 @@ +// +// CharacterCountRule.swift +// VDS +// +// Created by Matt Bruce on 4/30/24. +// + +import Foundation + +class CharacterCountRule: Rule { + var maxLength: Int? + var errorMessage: String = "You have exceeded the character limit." + + func isValid(value: String?) -> Bool { + guard let text = value, let maxLength, maxLength > 0 else { return true } + return text.count <= maxLength + } +} diff --git a/VDS/Components/TextFields/Rules/RequiredRule.swift b/VDS/Components/TextFields/Rules/RequiredRule.swift new file mode 100644 index 00000000..df00bc59 --- /dev/null +++ b/VDS/Components/TextFields/Rules/RequiredRule.swift @@ -0,0 +1,19 @@ +// +// RequiredRule.swift +// VDS +// +// Created by Matt Bruce on 4/30/24. +// + +import Foundation + +class RequiredRule: Rule { + var maxLength: Int? + var errorMessage: String = "This field is required." + + func isValid(value: String?) -> Bool { + guard let value, !value.isEmpty, value.count > 0 else { return false } + return true + } +} + diff --git a/VDS/Components/TextFields/TextArea/TextArea.swift b/VDS/Components/TextFields/TextArea/TextArea.swift index d057333b..5ea944a3 100644 --- a/VDS/Components/TextFields/TextArea/TextArea.swift +++ b/VDS/Components/TextFields/TextArea/TextArea.swift @@ -60,7 +60,7 @@ open class TextArea: EntryFieldBase { $0.textAlignment = .right $0.numberOfLines = 1 } - + private var _minHeight: Height = .twoX open var minHeight: Height? { @@ -79,54 +79,60 @@ open class TextArea: EntryFieldBase { //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- - override var containerSize: CGSize { CGSize(width: 182, height: 88) } + /// 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 textView.isFirstResponder { + state.insert(.focused) + } + return state + } + } + + override var containerSize: CGSize { CGSize(width: 182, height: Height.twoX.value) } /// Enum used to describe the the height of TextArea. public enum Height: String, CaseIterable { case twoX = "2X" case fourX = "4X" case eightX = "8X" + var containerVerticalPadding: CGFloat { VDSLayout.space3X * 2 } var value: CGFloat { switch self { case .twoX: - 88 + 88 - containerVerticalPadding case .fourX: - 176 + 176 - containerVerticalPadding case .eightX: - 352 + 352 - containerVerticalPadding } } } - + /// The text of this TextArea. private var _text: String? open var text: String? { - get { _text } + get { textView.text } set { - if let newValue, newValue != _text { - _text = newValue - textView.text = newValue - value = newValue - } + textView.text = newValue setNeedsUpdate() } } /// The value of this textField. open override var value: String? { - didSet { - if text != value { - text = value - } - } + return textView.text } - + /// UITextView shown in the TextArea. open var textView = TextView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.sizeToFit() $0.isScrollEnabled = false + $0.textContainerInset = .zero + $0.textContainer.lineFragmentPadding = 0 } open var maxLength: Int? { @@ -135,20 +141,15 @@ open class TextArea: EntryFieldBase { } didSet { - setNeedsUpdate() + validate() } } - /// Color configuration for error icon. - internal var iconColorConfiguration = ControlColorConfiguration().with { - $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .normal) - } - /// Color configuration for character counter's highlight background color internal var highlightBackgroundColor = ControlColorConfiguration().with { $0.setSurfaceColors(VDSColor.backgroundPrimaryDark, VDSColor.backgroundPrimaryLight, forState: .normal) } - + /// Color configuration for character counter's highlight text color internal var highlightTextColor = ControlColorConfiguration().with { $0.setSurfaceColors(VDSColor.elementsPrimaryOndark, VDSColor.elementsPrimaryOnlight, forState: .normal) @@ -160,9 +161,6 @@ open class TextArea: EntryFieldBase { /// 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() - isAccessibilityElement = false - validator = FormFieldValidator