diff --git a/VDS.xcodeproj/project.pbxproj b/VDS.xcodeproj/project.pbxproj index ee0265b4..3e5d50bc 100644 --- a/VDS.xcodeproj/project.pbxproj +++ b/VDS.xcodeproj/project.pbxproj @@ -7,8 +7,12 @@ objects = { /* Begin PBXBuildFile section */ + 180636C72C29B0A400C92D86 /* InputStepper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180636C62C29B0A400C92D86 /* InputStepper.swift */; }; + 180636C92C29B0DF00C92D86 /* InputStepperLog.txt in Resources */ = {isa = PBXBuildFile; fileRef = 180636C82C29B0DF00C92D86 /* InputStepperLog.txt */; }; 1808BEBC2BA41C3200129230 /* CarouselScrollbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1808BEBB2BA41C3200129230 /* CarouselScrollbar.swift */; }; 1832AC572BA0791D008AE476 /* BreadcrumbCellItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1832AC562BA0791D008AE476 /* BreadcrumbCellItem.swift */; }; + 184023452C61E7AD00A412C8 /* PriceLockup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 184023442C61E7AD00A412C8 /* PriceLockup.swift */; }; + 184023472C61E7EC00A412C8 /* PriceLockupChangeLog.txt in Resources */ = {isa = PBXBuildFile; fileRef = 184023462C61E7EC00A412C8 /* PriceLockupChangeLog.txt */; }; 1842B1DF2BECE28B0021AFCA /* CalendarDateViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1842B1DE2BECE28B0021AFCA /* CalendarDateViewCell.swift */; }; 1842B1E12BECE7B70021AFCA /* CalendarHeaderReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1842B1E02BECE7B70021AFCA /* CalendarHeaderReusableView.swift */; }; 1842B1E32BECF0A20021AFCA /* CalendarFooterReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1842B1E22BECF0A10021AFCA /* CalendarFooterReusableView.swift */; }; @@ -18,7 +22,10 @@ 18A3F12A2BD9298900498E4A /* Calendar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18A3F1292BD9298900498E4A /* Calendar.swift */; }; 18A65A022B96E848006602CC /* Breadcrumbs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18A65A012B96E848006602CC /* Breadcrumbs.swift */; }; 18A65A042B96F050006602CC /* BreadcrumbItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18A65A032B96F050006602CC /* BreadcrumbItem.swift */; }; + 18AE87502C06FDA60075F181 /* Carousel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18AE874F2C06FDA60075F181 /* Carousel.swift */; }; + 18B42AC62C09D197008D6262 /* CarouselSlotAlignmentModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18B42AC52C09D197008D6262 /* CarouselSlotAlignmentModel.swift */; }; 18B463A42BBD3C46005C4528 /* DropdownOptionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18B463A32BBD3C46005C4528 /* DropdownOptionModel.swift */; }; + 18B9763F2C11BA4A009271DF /* CarouselPaginationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18B9763E2C11BA4A009271DF /* CarouselPaginationModel.swift */; }; 18FEA1AD2BDD137500A56439 /* CalendarIndicatorModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18FEA1AC2BDD137500A56439 /* CalendarIndicatorModel.swift */; }; 18FEA1B52BE0E63600A56439 /* Date+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18FEA1B42BE0E63600A56439 /* Date+Extension.swift */; }; 445BA07829C07B3D0036A7C5 /* Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 445BA07729C07B3D0036A7C5 /* Notification.swift */; }; @@ -204,9 +211,13 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 180636C62C29B0A400C92D86 /* InputStepper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputStepper.swift; sourceTree = ""; }; + 180636C82C29B0DF00C92D86 /* InputStepperLog.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = InputStepperLog.txt; sourceTree = ""; }; 1808BEBB2BA41C3200129230 /* CarouselScrollbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarouselScrollbar.swift; sourceTree = ""; }; 1808BEBF2BA456B700129230 /* CarouselScrollbarChangeLog.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = CarouselScrollbarChangeLog.txt; sourceTree = ""; }; 1832AC562BA0791D008AE476 /* BreadcrumbCellItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreadcrumbCellItem.swift; sourceTree = ""; }; + 184023442C61E7AD00A412C8 /* PriceLockup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceLockup.swift; sourceTree = ""; }; + 184023462C61E7EC00A412C8 /* PriceLockupChangeLog.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = PriceLockupChangeLog.txt; sourceTree = ""; }; 1842B1DE2BECE28B0021AFCA /* CalendarDateViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDateViewCell.swift; sourceTree = ""; }; 1842B1E02BECE7B70021AFCA /* CalendarHeaderReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarHeaderReusableView.swift; sourceTree = ""; }; 1842B1E22BECF0A10021AFCA /* CalendarFooterReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarFooterReusableView.swift; sourceTree = ""; }; @@ -219,7 +230,11 @@ 18A3F1292BD9298900498E4A /* Calendar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Calendar.swift; sourceTree = ""; }; 18A65A012B96E848006602CC /* Breadcrumbs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Breadcrumbs.swift; sourceTree = ""; }; 18A65A032B96F050006602CC /* BreadcrumbItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreadcrumbItem.swift; sourceTree = ""; }; + 18AE874F2C06FDA60075F181 /* Carousel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Carousel.swift; sourceTree = ""; }; + 18AE87532C06FE610075F181 /* CarouselChangeLog.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = CarouselChangeLog.txt; sourceTree = ""; }; + 18B42AC52C09D197008D6262 /* CarouselSlotAlignmentModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarouselSlotAlignmentModel.swift; sourceTree = ""; }; 18B463A32BBD3C46005C4528 /* DropdownOptionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropdownOptionModel.swift; sourceTree = ""; }; + 18B9763E2C11BA4A009271DF /* CarouselPaginationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarouselPaginationModel.swift; sourceTree = ""; }; 18BDEE812B75316E00452358 /* ButtonIconChangeLog.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = ButtonIconChangeLog.txt; sourceTree = ""; }; 18FEA1AC2BDD137500A56439 /* CalendarIndicatorModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarIndicatorModel.swift; sourceTree = ""; }; 18FEA1B42BE0E63600A56439 /* Date+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extension.swift"; sourceTree = ""; }; @@ -442,6 +457,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 180636C52C29B06200C92D86 /* InputStepper */ = { + isa = PBXGroup; + children = ( + 180636C62C29B0A400C92D86 /* InputStepper.swift */, + 180636C82C29B0DF00C92D86 /* InputStepperLog.txt */, + ); + path = InputStepper; + sourceTree = ""; + }; 1808BEBA2BA41B1D00129230 /* CarouselScrollbar */ = { isa = PBXGroup; children = ( @@ -451,6 +475,15 @@ path = CarouselScrollbar; sourceTree = ""; }; + 184023432C61E78D00A412C8 /* PriceLockup */ = { + isa = PBXGroup; + children = ( + 184023442C61E7AD00A412C8 /* PriceLockup.swift */, + 184023462C61E7EC00A412C8 /* PriceLockupChangeLog.txt */, + ); + path = PriceLockup; + sourceTree = ""; + }; 186D13C92BBA8A3500986B53 /* DropdownSelect */ = { isa = PBXGroup; children = ( @@ -487,6 +520,17 @@ path = Breadcrumbs; sourceTree = ""; }; + 18AE874E2C06FD610075F181 /* Carousel */ = { + isa = PBXGroup; + children = ( + 18AE874F2C06FDA60075F181 /* Carousel.swift */, + 18B9763E2C11BA4A009271DF /* CarouselPaginationModel.swift */, + 18B42AC52C09D197008D6262 /* CarouselSlotAlignmentModel.swift */, + 18AE87532C06FE610075F181 /* CarouselChangeLog.txt */, + ); + path = Carousel; + sourceTree = ""; + }; 440B84C82BD8E0CE004A732A /* Table */ = { isa = PBXGroup; children = ( @@ -657,16 +701,19 @@ 18A65A002B96E7E1006602CC /* Breadcrumbs */, EA0FC2BE2912D18200DF80B4 /* Buttons */, 18A3F1202BD8F5DE00498E4A /* Calendar */, + 18AE874E2C06FD610075F181 /* Carousel */, 1808BEBA2BA41B1D00129230 /* CarouselScrollbar */, EAF7F092289985E200B287F5 /* Checkbox */, EAC58C1F2BF127F000BA39FA /* DatePicker */, 186D13C92BBA8A3500986B53 /* DropdownSelect */, EA985BF3296C609E00F2FF2E /* Icon */, + 180636C52C29B06200C92D86 /* InputStepper */, EA3362412892EF700071C351 /* Label */, 44604AD529CE195300E62B51 /* Line */, EAD0688C2A55F801002E3A2D /* Loader */, 445BA07629C07ABA0036A7C5 /* Notification */, 71B23C2B2B91FA510027F7D9 /* Pagination */, + 184023432C61E78D00A412C8 /* PriceLockup */, EA89200B28B530F0006B9984 /* RadioBox */, EAF7F11428A1470D00B287F5 /* RadioButton */, 440B84C82BD8E0CE004A732A /* Table */, @@ -1166,9 +1213,11 @@ buildActionMask = 2147483647; files = ( EA3362042891E14D0071C351 /* VerizonNHGeTX-Bold.otf in Resources */, + 184023472C61E7EC00A412C8 /* PriceLockupChangeLog.txt in Resources */, EA3362072891E14D0071C351 /* VerizonNHGeDS-Regular.otf in Resources */, EA3362062891E14D0071C351 /* VerizonNHGeTX-Regular.otf in Resources */, EA3362052891E14D0071C351 /* VerizonNHGeDS-Bold.otf in Resources */, + 180636C92C29B0DF00C92D86 /* InputStepperLog.txt in Resources */, EAA5EEB928ECD24B003B3210 /* Icons.xcassets in Resources */, EAA5EEE428F5B855003B3210 /* VerizonNHGDS-Light.otf in Resources */, ); @@ -1212,6 +1261,7 @@ files = ( 445BA07829C07B3D0036A7C5 /* Notification.swift in Sources */, EA5E304C294CBDD00082B959 /* TileContainer.swift in Sources */, + 180636C72C29B0A400C92D86 /* InputStepper.swift in Sources */, EAF7F0A6289B0CE000B287F5 /* Resetable.swift in Sources */, EA985C2D296F03FE00F2FF2E /* TileletIconModels.swift in Sources */, EA89200428AECF4B006B9984 /* UITextField+Publisher.swift in Sources */, @@ -1290,6 +1340,7 @@ EA5F86D02A1F936100BC83E4 /* TabsContainer.swift in Sources */, EAF7F0B1289B177F00B287F5 /* ColorLabelAttribute.swift in Sources */, EAC9258F2911C9DE00091998 /* EntryFieldBase.swift in Sources */, + 18B9763F2C11BA4A009271DF /* CarouselPaginationModel.swift in Sources */, EAB1D2EA28AE84AA00DAE764 /* UIControlPublisher.swift in Sources */, EAD068922A560B65002E3A2D /* LoaderViewController.swift in Sources */, 44BD43B62C04866600644F87 /* TableRowModel.swift in Sources */, @@ -1301,6 +1352,7 @@ EA8E40932A82889500934ED3 /* TooltipDialog.swift in Sources */, 44604AD429CE186A00E62B51 /* NotificationButtonModel.swift in Sources */, EAD8D2C128BFDE8B006EB6A6 /* UIGestureRecognizer+Publisher.swift in Sources */, + 18B42AC62C09D197008D6262 /* CarouselSlotAlignmentModel.swift in Sources */, 71B23C2D2B91FA690027F7D9 /* Pagination.swift in Sources */, EA0D1C372A681CCE00E5C127 /* ToggleView.swift in Sources */, EAF7F0B9289C139800B287F5 /* ColorConfiguration.swift in Sources */, @@ -1339,6 +1391,7 @@ EA0B18052A9E2D2D00F2D0CD /* SelectorBase.swift in Sources */, EAC71A1D2A2E155A00E47A9F /* Checkbox.swift in Sources */, EAF7F0AB289B13FD00B287F5 /* TextStyleLabelAttribute.swift in Sources */, + 18AE87502C06FDA60075F181 /* Carousel.swift in Sources */, EAB1D29C28A5618900DAE764 /* RadioButtonGroup.swift in Sources */, EA81410B2A0E8E3C004F60D2 /* ButtonIcon.swift in Sources */, EA985BE629688F6A00F2FF2E /* TileletBadgeModel.swift in Sources */, @@ -1372,6 +1425,7 @@ EAC58C0E2BED021600BA39FA /* Password.swift in Sources */, EAF7F0AD289B142900B287F5 /* StrikeThroughLabelAttribute.swift in Sources */, EAB5FEF12927F4AA00998C17 /* SelfSizingCollectionView.swift in Sources */, + 184023452C61E7AD00A412C8 /* PriceLockup.swift in Sources */, EA3361B8288B2AAA0071C351 /* ViewProtocol.swift in Sources */, EA3361A8288B23300071C351 /* UIColor.swift in Sources */, EA2DC9B42BE2C6FE004F58C5 /* TextField.swift in Sources */, @@ -1535,7 +1589,7 @@ BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 70; + CURRENT_PROJECT_VERSION = 72; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1573,7 +1627,7 @@ BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 70; + CURRENT_PROJECT_VERSION = 72; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; diff --git a/VDS/BaseClasses/Control.swift b/VDS/BaseClasses/Control.swift index 7aa08717..7ebe3824 100644 --- a/VDS/BaseClasses/Control.swift +++ b/VDS/BaseClasses/Control.swift @@ -10,6 +10,7 @@ import UIKit import Combine /// Base Class use to build Controls. +@objcMembers @objc(VDSControl) open class Control: UIControl, ViewProtocol, UserInfoable, Clickable { //-------------------------------------------------- @@ -77,20 +78,30 @@ open class Control: UIControl, ViewProtocol, UserInfoable, Clickable { //-------------------------------------------------- // MARK: - Lifecycle //-------------------------------------------------- - open func initialSetup() { + private func initialSetup() { if !initialSetupPerformed { initialSetupPerformed = true + shouldUpdateView = false setup() + setDefaults() + shouldUpdateView = true setNeedsUpdate() } } open func setup() { - backgroundColor = .clear translatesAutoresizingMaskIntoConstraints = false insetsLayoutMarginsFromSafeArea = false } - + + open func setDefaults() { + backgroundColor = .clear + surface = .light + isEnabled = true + onClick = nil + userInfo.removeAll() + } + open func updateView() { } open func updateAccessibility() { @@ -107,13 +118,12 @@ open class Control: UIControl, ViewProtocol, UserInfoable, Clickable { } } - + open func reset() { - backgroundColor = .clear - surface = .light - isEnabled = true - onClick = nil - userInfo.removeAll() + shouldUpdateView = false + setDefaults() + shouldUpdateView = true + setNeedsUpdate() } //-------------------------------------------------- diff --git a/VDS/BaseClasses/Selector/SelectorBase.swift b/VDS/BaseClasses/Selector/SelectorBase.swift index fb8d771e..6af003fd 100644 --- a/VDS/BaseClasses/Selector/SelectorBase.swift +++ b/VDS/BaseClasses/Selector/SelectorBase.swift @@ -28,6 +28,8 @@ public protocol SelectorControlable: Control, Changeable { } /// Base Class used to build out a Selector control. +@objcMembers +@objc(VDSSelectorBase) open class SelectorBase: Control, SelectorControlable { //-------------------------------------------------- // MARK: - Initializers @@ -98,13 +100,25 @@ open class SelectorBase: Control, SelectorControlable { //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- - /// Executed on initialization for this View. - open override func initialSetup() { - super.initialSetup() + + /// 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 = true + accessibilityTraits = .button + } + + open override func setDefaults() { + super.setDefaults() + + showError = false + onClick = { control in control.toggle() } + onChange = nil + bridge_accessibilityLabelBlock = { [weak self] in guard let self else { return "" } return "\(Self.self)\(showError ? ", error" : "")" @@ -116,14 +130,6 @@ open class SelectorBase: Control, SelectorControlable { } } - /// 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 = true - accessibilityTraits = .button - } - open override func updateView() { super.updateView() setNeedsLayout() diff --git a/VDS/BaseClasses/Selector/SelectorGroupBase.swift b/VDS/BaseClasses/Selector/SelectorGroupBase.swift index 4df1ca5c..e04b5e5a 100644 --- a/VDS/BaseClasses/Selector/SelectorGroupBase.swift +++ b/VDS/BaseClasses/Selector/SelectorGroupBase.swift @@ -65,25 +65,30 @@ open class SelectorGroupBase: Control, SelectorGrou } didSet { + setItemsActions() for selector in items { - selector.onClick = { [weak self] handler in - self?.didSelect(handler) - self?.setNeedsUpdate() - } - - selector.accessibilityAction = { [weak self] handler in - guard let handler = handler as? SelectorItemType else { return } - self?.didSelect(handler) - self?.setNeedsUpdate() - } - mainStackView.addArrangedSubview(selector) } } } open var onChangeSubscriber: AnyCancellable? - + + private func setItemsActions() { + for selector in items { + selector.onClick = { [weak self] handler in + self?.didSelect(handler) + self?.setNeedsUpdate() + } + + selector.accessibilityAction = { [weak self] handler in + guard let handler = handler as? SelectorItemType else { return } + self?.didSelect(handler) + self?.setNeedsUpdate() + } + } + } + /// Whether the Control is enabled or not. override open var isEnabled: Bool { didSet { @@ -115,6 +120,12 @@ open class SelectorGroupBase: Control, SelectorGrou .pinBottom(0, .defaultHigh) } + open override func setDefaults() { + super.setDefaults() + onChange = nil + items = [] + } + /// Handler for the Group to override on a select event. /// - Parameter selectedControl: Selected Control the user interacted. open func didSelect(_ selectedControl: SelectorItemType) { @@ -127,13 +138,6 @@ open class SelectorGroupBase: Control, SelectorGrou self?.sendActions(for: .valueChanged) } } - - /// Resets to default settings. - open override func reset() { - super.reset() - onChange = nil - items.forEach{ $0.reset() } - } } diff --git a/VDS/BaseClasses/Selector/SelectorItemBase.swift b/VDS/BaseClasses/Selector/SelectorItemBase.swift index 44ed01b1..ea037771 100644 --- a/VDS/BaseClasses/Selector/SelectorItemBase.swift +++ b/VDS/BaseClasses/Selector/SelectorItemBase.swift @@ -66,18 +66,21 @@ open class SelectorItemBase: Control, Errorable, Changea /// Label used to render labelText. open var label = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) + $0.setContentCompressionResistancePriority(.required, for: .horizontal) $0.textStyle = .boldBodyLarge } /// Label used to render childText. open var childLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) + $0.setContentCompressionResistancePriority(.required, for: .horizontal) $0.textStyle = .bodyLarge } /// Label used to render errorText. open var errorLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) + $0.setContentCompressionResistancePriority(.required, for: .horizontal) $0.textStyle = .bodyMedium } @@ -157,9 +160,32 @@ open class SelectorItemBase: Control, Errorable, Changea //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- - /// Executed on initialization for this View. - open override func initialSetup() { - super.initialSetup() + + /// 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() + + selectorView.isAccessibilityElement = true + isAccessibilityElement = false + addSubview(mainStackView) + + mainStackView.isUserInteractionEnabled = false + mainStackView.addArrangedSubview(selectorStackView) + mainStackView.addArrangedSubview(errorLabel) + selectorStackView.addArrangedSubview(selectorView) + selectorStackView.addArrangedSubview(selectorLabelStackView) + selectorLabelStackView.addArrangedSubview(label) + selectorLabelStackView.addArrangedSubview(childLabel) + mainStackView + .pinTop() + .pinLeading() + .pinTrailing() + .pinBottom(0, .defaultHigh) + } + + open override func setDefaults() { + super.setDefaults() + onClick = { [weak self] control in guard let self, isEnabled else { return } toggle() @@ -203,29 +229,23 @@ open class SelectorItemBase: Control, Errorable, Changea guard let self else { return "" } return !isEnabled ? "" : "Double tap to activate." } - - } - /// 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() + label.textStyle = .boldBodyLarge + childLabel.textStyle = .bodyLarge + errorLabel.textStyle = .bodyMedium - selectorView.isAccessibilityElement = true - isAccessibilityElement = false - addSubview(mainStackView) + labelText = nil + labelTextAttributes = nil + labelAttributedText = nil + childText = nil + childTextAttributes = nil + childAttributedText = nil + showError = false + errorText = nil + inputId = nil + isSelected = false - mainStackView.isUserInteractionEnabled = false - mainStackView.addArrangedSubview(selectorStackView) - mainStackView.addArrangedSubview(errorLabel) - selectorStackView.addArrangedSubview(selectorView) - selectorStackView.addArrangedSubview(selectorLabelStackView) - selectorLabelStackView.addArrangedSubview(label) - selectorLabelStackView.addArrangedSubview(childLabel) - mainStackView - .pinTop() - .pinLeading() - .pinTrailing() - .pinBottom(0, .defaultHigh) + onChange = nil } /// Used to make changes to the View based off a change events or from local properties. @@ -281,30 +301,10 @@ open class SelectorItemBase: Control, Errorable, Changea /// Resets to default settings. open override func reset() { - super.reset() - shouldUpdateView = false label.reset() childLabel.reset() errorLabel.reset() - - label.textStyle = .boldBodyLarge - childLabel.textStyle = .bodyLarge - errorLabel.textStyle = .bodyMedium - - labelText = nil - labelTextAttributes = nil - labelAttributedText = nil - childText = nil - childTextAttributes = nil - childAttributedText = nil - showError = false - errorText = nil - inputId = nil - isSelected = false - - onChange = nil - shouldUpdateView = true - setNeedsUpdate() + super.reset() } //-------------------------------------------------- diff --git a/VDS/BaseClasses/View.swift b/VDS/BaseClasses/View.swift index 7e88df8e..a78905ba 100644 --- a/VDS/BaseClasses/View.swift +++ b/VDS/BaseClasses/View.swift @@ -10,8 +10,9 @@ import UIKit import Combine /// Base Class used to build Views. +@objcMembers @objc(VDSView) -open class View: UIView, ViewProtocol, UserInfoable { +open class View: UIView, ViewProtocol, UserInfoable, Clickable { //-------------------------------------------------- // MARK: - Initializers @@ -36,6 +37,7 @@ open class View: UIView, ViewProtocol, UserInfoable { //-------------------------------------------------- open var subscribers = Set() + open var onClickSubscriber: AnyCancellable? //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- @@ -56,20 +58,30 @@ open class View: UIView, ViewProtocol, UserInfoable { //-------------------------------------------------- // MARK: - Lifecycle //-------------------------------------------------- - open func initialSetup() { + private func initialSetup() { if !initialSetupPerformed { initialSetupPerformed = true + shouldUpdateView = false setup() + setDefaults() + shouldUpdateView = true setNeedsUpdate() } } open func setup() { - backgroundColor = .clear translatesAutoresizingMaskIntoConstraints = false insetsLayoutMarginsFromSafeArea = false } + open func setDefaults() { + backgroundColor = .clear + surface = .light + isEnabled = true + onClick = nil + userInfo.removeAll() + } + open func updateView() { } open func updateAccessibility() { @@ -81,9 +93,10 @@ open class View: UIView, ViewProtocol, UserInfoable { } open func reset() { - backgroundColor = .clear - surface = .light - isEnabled = true + shouldUpdateView = false + setDefaults() + shouldUpdateView = true + setNeedsUpdate() } open override func layoutSubviews() { diff --git a/VDS/Classes/AlertViewController.swift b/VDS/Classes/AlertViewController.swift index 30a5d5c6..5472fab8 100644 --- a/VDS/Classes/AlertViewController.swift +++ b/VDS/Classes/AlertViewController.swift @@ -10,6 +10,8 @@ import UIKit import Combine import VDSCoreTokens +@objcMembers +@objc(VDSAlertViewController) open class AlertViewController: UIViewController, Surfaceable { /// Set of Subscribers for any Publishers for this Control. diff --git a/VDS/Classes/ClearPopoverViewController.swift b/VDS/Classes/ClearPopoverViewController.swift index 6f9bcb67..d2137b62 100644 --- a/VDS/Classes/ClearPopoverViewController.swift +++ b/VDS/Classes/ClearPopoverViewController.swift @@ -8,6 +8,8 @@ import Foundation import UIKit +@objcMembers +@objc(VDSClearPopoverViewController) open class ClearPopoverViewController: UIViewController, UIPopoverPresentationControllerDelegate { /// The view to be inserted inside the popover diff --git a/VDS/Components/Badge/Badge.swift b/VDS/Components/Badge/Badge.swift index 5753428e..1c47133f 100644 --- a/VDS/Components/Badge/Badge.swift +++ b/VDS/Components/Badge/Badge.swift @@ -15,6 +15,7 @@ import Combine /// If you are using AutoLayoutConstraints you have a combination of Leading/Left and Trailing/Right NSLayoutConstraints, /// you need to ensure that one of these Horizontal Contraints is not constraint of "equatTo". If you are to pin the left/right edges /// to its parent this object will stretch to the parent's width. +@objcMembers @objc(VDSBadge) open class Badge: View { @@ -148,25 +149,28 @@ open class Badge: View { maxWidthConstraint = label.widthLessThanEqualTo(constant: 0).with { $0.isActive = false } clipsToBounds = true + } + + open override func setDefaults() { + super.setDefaults() + bridge_accessibilityLabelBlock = { [weak self] in guard let self else { return "" } return text } - } - - /// Resets to default settings. - open override func reset() { - super.reset() - shouldUpdateView = false - label.reset() + label.lineBreakMode = .byTruncatingTail label.textStyle = .boldBodySmall fillColor = .red text = "" maxWidth = nil numberOfLines = 1 - shouldUpdateView = true - setNeedsUpdate() + } + + /// Resets to default settings. + open override func reset() { + label.reset() + super.reset() } /// Used to make changes to the View based off a change events or from local properties. diff --git a/VDS/Components/BadgeIndicator/BadgeIndicator.swift b/VDS/Components/BadgeIndicator/BadgeIndicator.swift index 60025390..daecddf4 100644 --- a/VDS/Components/BadgeIndicator/BadgeIndicator.swift +++ b/VDS/Components/BadgeIndicator/BadgeIndicator.swift @@ -11,6 +11,7 @@ import VDSCoreTokens import Combine /// A badge indicator is a visual label used to convey status or highlight supplemental information. +@objcMembers @objc(VDSBadgeIndicator) open class BadgeIndicator: View { @@ -292,6 +293,28 @@ open class BadgeIndicator: View { label.centerYAnchor.constraint(equalTo: badgeView.centerYAnchor).isActive = true labelContraints.isActive = true + } + + open override func setDefaults() { + super.setDefaults() + label.lineBreakMode = .byTruncatingTail + label.textAlignment = .center + fillColor = .red + number = nil + kind = .simple + leadingCharacter = nil + trailingText = nil + size = .xxlarge + dotSize = nil + verticalPadding = nil + horizontalPadding = nil + hideDot = false + hideBorder = false + width = nil + height = nil + accessibilityText = nil + maximumDigits = .two + bridge_accessibilityLabelBlock = { [weak self] in guard let self else { return "" } if let accessibilityText { @@ -306,15 +329,8 @@ open class BadgeIndicator: View { /// Resets to default settings. open override func reset() { - super.reset() - shouldUpdateView = false label.reset() - label.lineBreakMode = .byTruncatingTail - label.textAlignment = .center - fillColor = .red - number = nil - shouldUpdateView = true - setNeedsUpdate() + super.reset() } /// Used to make changes to the View based off a change events or from local properties. diff --git a/VDS/Components/Breadcrumbs/BreadcrumbItem.swift b/VDS/Components/Breadcrumbs/BreadcrumbItem.swift index bf6d4ed1..cdb6011e 100644 --- a/VDS/Components/Breadcrumbs/BreadcrumbItem.swift +++ b/VDS/Components/Breadcrumbs/BreadcrumbItem.swift @@ -13,6 +13,7 @@ import Combine /// A Breadcrumb Item contains href(link) and selected flag. /// Breadcrumb links to its respective page if it is not disabled. /// Breadcrumb contains text with a separator by default, highlights text in bold without a separator if selected. +@objcMembers @objc (VDSBreadcrumbItem) open class BreadcrumbItem: ButtonBase { @@ -71,17 +72,15 @@ open class BreadcrumbItem: ButtonBase { //-------------------------------------------------- // 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() - + open override func setDefaults() { + super.setDefaults() + isAccessibilityElement = true + accessibilityTraits = .link + titleLabel?.numberOfLines = 0 titleLabel?.lineBreakMode = .byWordWrapping contentHorizontalAlignment = .left - isAccessibilityElement = true - accessibilityTraits = .link - bridge_accessibilityHintBlock = { [weak self] in guard let self else { return "" } return !isEnabled ? "" : "Double tap to open." @@ -130,17 +129,4 @@ open class BreadcrumbItem: ButtonBase { } } - - /// Resets to default settings. - open override func reset() { - super.reset() - shouldUpdateView = false - text = nil - accessibilityCustomActions = [] - isAccessibilityElement = true - accessibilityTraits = .button - shouldUpdateView = true - setNeedsUpdate() - } - } diff --git a/VDS/Components/Breadcrumbs/Breadcrumbs.swift b/VDS/Components/Breadcrumbs/Breadcrumbs.swift index b5256497..808b5548 100644 --- a/VDS/Components/Breadcrumbs/Breadcrumbs.swift +++ b/VDS/Components/Breadcrumbs/Breadcrumbs.swift @@ -13,6 +13,7 @@ import Combine /// A Breadcrumbs contains BreadcrumbItems. /// It contains Breadcrumb Item Default, Breadcrumb Item Selected, Separator. /// Breadcrumbs are secondary navigation that use a hierarchy of internal links to tell customers where they are in an experience. Each breadcrumb links to its respective page, except for that of current page. +@objcMembers @objc(VDSBreadcrumbs) open class Breadcrumbs: View { @@ -108,21 +109,19 @@ open class Breadcrumbs: View { // MARK: - Overrides //-------------------------------------------------- /// Executed on initialization for this View. - open override func initialSetup() { - super.initialSetup() + open override func setup() { + super.setup() containerView.addSubview(collectionView) collectionView.pinToSuperView() addSubview(containerView) containerView.pinToSuperView() } - - /// Resets to default settings. - open override func reset() { - super.reset() - shouldUpdateView = false - breadcrumbs.forEach { $0.reset() } - shouldUpdateView = true - setNeedsUpdate() + + open override func setDefaults() { + super.setDefaults() + breadcrumbs = [] + breadcrumbModels = [] + isEnabled = true } /// Used to make changes to the View based off a change events or from local properties. diff --git a/VDS/Components/Buttons/Button/Button.swift b/VDS/Components/Buttons/Button/Button.swift index f4147f54..9569e3e7 100644 --- a/VDS/Components/Buttons/Button/Button.swift +++ b/VDS/Components/Buttons/Button/Button.swift @@ -15,6 +15,7 @@ import Combine /// If you are using AutoLayoutConstraints you have a combination of Leading/Left and Trailing/Right NSLayoutConstraints, /// you need to ensure that one of these Horizontal Contraints is not constraint of "equatTo". If you are to pin the left/right edges /// to its parent this object will stretch to the parent's width. +@objcMembers @objc(VDSButton) open class Button: ButtonBase, Useable { @@ -222,16 +223,12 @@ open class Button: ButtonBase, Useable { isAccessibilityElement = true accessibilityTraits = .button } - - /// Resets to default settings. - open override func reset() { - super.reset() - shouldUpdateView = false + + open override func setDefaults() { + super.setDefaults() use = .primary width = nil size = .large - shouldUpdateView = true - setNeedsUpdate() } /// Used to make changes to the View based off a change events or from local properties. diff --git a/VDS/Components/Buttons/ButtonBase.swift b/VDS/Components/Buttons/ButtonBase.swift index eebf3a68..b555218d 100644 --- a/VDS/Components/Buttons/ButtonBase.swift +++ b/VDS/Components/Buttons/ButtonBase.swift @@ -11,6 +11,7 @@ import VDSCoreTokens import Combine /// Base class used for UIButton type classes. +@objcMembers @objc(VDSButtonBase) open class ButtonBase: UIButton, ViewProtocol, UserInfoable, Clickable { @@ -96,13 +97,13 @@ open class ButtonBase: UIButton, ViewProtocol, UserInfoable, Clickable { //-------------------------------------------------- // MARK: - Lifecycle //-------------------------------------------------- - open func initialSetup() { + private func initialSetup() { if !initialSetupPerformed { initialSetupPerformed = true - backgroundColor = .clear - translatesAutoresizingMaskIntoConstraints = false - accessibilityCustomActions = [] + shouldUpdateView = false setup() + setDefaults() + shouldUpdateView = true setNeedsUpdate() } } @@ -110,10 +111,19 @@ open class ButtonBase: UIButton, ViewProtocol, UserInfoable, Clickable { open func setup() { translatesAutoresizingMaskIntoConstraints = false - + } + + open func setDefaults() { + backgroundColor = .clear + accessibilityCustomActions = [] titleLabel?.adjustsFontSizeToFitWidth = false titleLabel?.lineBreakMode = .byTruncatingTail titleLabel?.numberOfLines = 1 + surface = .light + isEnabled = true + text = nil + onClick = nil + userInfo.removeAll() } open func updateView() { @@ -130,12 +140,7 @@ open class ButtonBase: UIButton, ViewProtocol, UserInfoable, Clickable { open func reset() { shouldUpdateView = false - surface = .light - isEnabled = true - text = nil - accessibilityCustomActions = [] - onClick = nil - userInfo.removeAll() + setDefaults() shouldUpdateView = true setNeedsUpdate() } diff --git a/VDS/Components/Buttons/ButtonGroup/ButtonGroup.swift b/VDS/Components/Buttons/ButtonGroup/ButtonGroup.swift index 124a7fba..520c8d1f 100644 --- a/VDS/Components/Buttons/ButtonGroup/ButtonGroup.swift +++ b/VDS/Components/Buttons/ButtonGroup/ButtonGroup.swift @@ -11,6 +11,7 @@ import VDSCoreTokens import Combine /// A button group contains combinations of related CTAs including ``Button``, ``TextLink``, and ``TextLinkCaret``. This group component controls a combination's orientation, spacing, size and allowable size pairings. +@objcMembers @objc(VDSButtonGroup) open class ButtonGroup: View { @@ -166,15 +167,18 @@ open class ButtonGroup: View { collectionView.reloadData() } - open override func reset() { - super.reset() - shouldUpdateView = false + open override func setDefaults() { + super.setDefaults() rowQuantityPhone = 0 rowQuantityTablet = 0 alignment = .center + childWidth = nil + buttons = [] + } + + open override func reset() { buttons.forEach { $0.reset() } - shouldUpdateView = true - setNeedsUpdate() + super.reset() } open override func layoutSubviews() { diff --git a/VDS/Components/Buttons/ButtonGroup/ButtonGroupPositionLayout.swift b/VDS/Components/Buttons/ButtonGroup/ButtonGroupPositionLayout.swift index 5d3a339f..433fa58f 100644 --- a/VDS/Components/Buttons/ButtonGroup/ButtonGroupPositionLayout.swift +++ b/VDS/Components/Buttons/ButtonGroup/ButtonGroupPositionLayout.swift @@ -49,56 +49,53 @@ class ButtonCollectionViewRow { func layout(for position: ButtonGroup.Alignment, with collectionViewWidth: CGFloat){ var offset = 0.0 let height = rowHeight + attributes.last?.spacing = 0 + + //filter only the buttons since this is the only + //object we can change the frames for. + let buttonAttributes = attributes.filter{$0.isButton} - //check to see if you have buttons and there is a percentage - if let buttonPercentage, hasButtons, buttonPercentage > 0 { + if !buttonAttributes.isEmpty { + let buttonCount = CGFloat(buttonAttributes.count) - var usedSpace = 0.0 - //get the width for the buttons - for attribute in attributes { - if !attribute.isButton { - usedSpace += attribute.frame.width - } - usedSpace += attribute.spacing - } - let buttonAvailableSpace = collectionViewWidth - usedSpace - let realPercentage = (buttonPercentage / 100) - let buttonWidth = realPercentage * buttonAvailableSpace -// print("buttonPercentage :\(realPercentage)") -// print("collectionView width:\(collectionViewWidth)") -// print("usedSpace width:\(usedSpace)") -// print("button available width:\(buttonAvailableSpace)") -// print("each button width:\(buttonWidth)\n") -// print("minimum widht:\(ButtonSize.large.minimumWidth)") - // test sizing - var testSize = 0.0 - var buttonCount = 0.0 - for attribute in attributes { - if attribute.isButton { - testSize += buttonWidth - buttonCount += 1 - } + ///Calculate the spaces between items in the row + let totalSpacingBetweenAttributes = attributes.reduce(0.0) { $0 + $1.spacing } + + //see how much of the rows width is used for + //non-buttons that are BaseButton Subclasses that are not "Button" + let nonButtonSpace = attributes.filter { !$0.isButton }.reduce(0.0) { $0 + $1.frame.width } + + //getting available button space since textlinks need their space + let buttonsAvailableSpace = collectionViewWidth - nonButtonSpace - totalSpacingBetweenAttributes + let buttonEqualSpacing = buttonsAvailableSpace / buttonCount + var maxButtonWidth = buttonEqualSpacing //default to equal spacing + var buttonCalculatedPercentage: CGFloat = 0.0 + + //check to see if you have buttons and there is a percentage + if let buttonPercentage, hasButtons, buttonPercentage > 0 { + buttonCalculatedPercentage = CGFloat(buttonPercentage / 100.0) + let buttonPercentageWidth = buttonCalculatedPercentage * buttonsAvailableSpace + maxButtonWidth = min(max(buttonPercentageWidth, Button.Size.large.minimumWidth), maxButtonWidth) } - if buttonWidth >= Button.Size.large.minimumWidth { - if testSize <= buttonAvailableSpace { - for attribute in attributes { - if attribute.isButton { - attribute.frame.size.width = buttonWidth - } + //resize the buttonAttributes + if maxButtonWidth >= Button.Size.large.minimumWidth { + //if there is enough room for all buttons + if maxButtonWidth * buttonCount <= buttonsAvailableSpace { + for attribute in buttonAttributes { + attribute.frame.size.width = buttonCalculatedPercentage.isZero ? min(attribute.frame.size.width, maxButtonWidth) : maxButtonWidth } } else { - let distributedSize = buttonAvailableSpace / buttonCount - for attribute in attributes { - if attribute.isButton { - attribute.frame.size.width = distributedSize - } + //if not enough room, give all buttons the same width + for attribute in buttonAttributes { + attribute.frame.size.width = buttonEqualSpacing } } } } + //update the offset based on position switch position { case .left: break @@ -181,7 +178,7 @@ class ButtonGroupPositionLayout: UICollectionViewLayout { var rows = [ButtonCollectionViewRow]() rows.append(ButtonCollectionViewRow()) - let collectionViewWidth = collectionView.frame.width + let collectionViewWidth = collectionView.horizontalPinnedWidth() ?? collectionView.frame.width for item in 0.. collectionViewWidth || (rowQuantity > 0 && rowItemCount == rowQuantity) { + if (layoutWidthIterator + itemSize.width) > collectionViewWidth && rowQuantity == 0 + || (rowQuantity > 0 && rowItemCount == rowQuantity) { // If the current row width (after this item being laid out) is exceeding // the width of the collection view content, put it in the next line @@ -318,3 +316,5 @@ class ButtonGroupPositionLayout: UICollectionViewLayout { } } + + diff --git a/VDS/Components/Buttons/TextLink/TextLink.swift b/VDS/Components/Buttons/TextLink/TextLink.swift index f77dfa79..b002c712 100644 --- a/VDS/Components/Buttons/TextLink/TextLink.swift +++ b/VDS/Components/Buttons/TextLink/TextLink.swift @@ -16,6 +16,7 @@ import Combine /// If you are using AutoLayoutConstraints you have a combination of Leading/Left and Trailing/Right NSLayoutConstraints, /// you need to ensure that one of these Horizontal Contraints is not constraint of "equatTo". If you are to pin the left/right edges /// to its parent this object will stretch to the parent's width. +@objcMembers @objc(VDSTextLink) open class TextLink: ButtonBase { //-------------------------------------------------- @@ -90,12 +91,7 @@ open class TextLink: ButtonBase { open override func setup() { super.setup() isAccessibilityElement = true - accessibilityTraits = .link - - //left align titleLabel in case this is pinned leading/trailing - //default is always set to center - contentHorizontalAlignment = .left - + if let titleLabel { addSubview(line) line.pinLeading(titleLabel.leadingAnchor) @@ -105,12 +101,21 @@ open class TextLink: ButtonBase { lineHeightConstraint = line.height(constant: 1) lineHeightConstraint?.isActive = true } + } + + open override func setDefaults() { + super.setDefaults() + size = .large + accessibilityTraits = .link + //left align titleLabel in case this is pinned leading/trailing + //default is always set to center + contentHorizontalAlignment = .left + bridge_accessibilityHintBlock = { [weak self] in guard let self else { return "" } return !isEnabled ? "" : "Double tap to open." } - } /// Used to make changes to the View based off a change events or from local properties. @@ -122,18 +127,5 @@ open class TextLink: ButtonBase { //always call last so the label is rendered super.updateView() } - - /// Resets to default settings. - open override func reset() { - super.reset() - shouldUpdateView = false - text = nil - size = .large - accessibilityCustomActions = [] - isAccessibilityElement = true - accessibilityTraits = .link - shouldUpdateView = true - setNeedsUpdate() - } - + } diff --git a/VDS/Components/Buttons/TextLinkCaret/TextLinkCaret.swift b/VDS/Components/Buttons/TextLinkCaret/TextLinkCaret.swift index d1522d02..e8806daa 100644 --- a/VDS/Components/Buttons/TextLinkCaret/TextLinkCaret.swift +++ b/VDS/Components/Buttons/TextLinkCaret/TextLinkCaret.swift @@ -16,6 +16,7 @@ import Combine /// If you are using AutoLayoutConstraints you have a combination of Leading/Left and Trailing/Right NSLayoutConstraints, /// you need to ensure that one of these Horizontal Contraints is not constraint of "equatTo". If you are to pin the left/right edges /// to its parent this object will stretch to the parent's width. +@objcMembers @objc(VDSTextLinkCaret) open class TextLinkCaret: ButtonBase { //-------------------------------------------------- @@ -75,10 +76,8 @@ open class TextLinkCaret: ButtonBase { //-------------------------------------------------- // 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() - + open override func setDefaults() { + super.setDefaults() //left align titleLabel in case this is pinned leading/trailing //default is always set to center contentHorizontalAlignment = .left @@ -87,11 +86,12 @@ open class TextLinkCaret: ButtonBase { titleLabel?.numberOfLines = 0 titleLabel?.lineBreakMode = .byWordWrapping + iconPosition = .right + bridge_accessibilityHintBlock = { [weak self] in guard let self else { return "" } return !isEnabled ? "" : "Double tap to open." } - } /// Used to make changes to the View based off a change events or from local properties. @@ -99,14 +99,7 @@ open class TextLinkCaret: ButtonBase { imageAttribute = CaretLabelAttribute(tintColor: textColor, position: iconPosition) super.updateView() } - - /// Resets to default settings. - open override func reset() { - super.reset() - iconPosition = .right - text = nil - } - + /// The natural size for the receiving view, considering only properties of the view itself. open override var intrinsicContentSize: CGSize { guard let titleLabel else { return super.intrinsicContentSize } diff --git a/VDS/Components/Calendar/Calendar.swift b/VDS/Components/Calendar/Calendar.swift index cd1c783b..0a6ba18c 100644 --- a/VDS/Components/Calendar/Calendar.swift +++ b/VDS/Components/Calendar/Calendar.swift @@ -11,6 +11,7 @@ import VDSCoreTokens import Combine /// A calendar is a monthly view that lets customers select a single date. +@objcMembers @objc(VDSCalendar) open class CalendarBase: Control, Changeable { @@ -124,10 +125,6 @@ open class CalendarBase: Control, Changeable { //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- - open override func initialSetup() { - super.initialSetup() - } - /// 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() @@ -153,6 +150,19 @@ open class CalendarBase: Control, Changeable { collectionView.pinCenterX(anchor: containerView.centerXAnchor) } + + open override func setDefaults() { + super.setDefaults() + hideContainerBorder = false + hideCurrentDateIndicator = false + transparentBackground = false + activeDates = [] + inactiveDates = [] + indicators = [] + minDate = Date() + maxDate = Date() + selectedDate = Date() + } open override func updateView() { super.updateView() @@ -173,17 +183,6 @@ open class CalendarBase: Control, Changeable { containerView.layer.borderWidth = VDSFormControls.borderWidth } } - - /// Resets to default settings. - open override func reset() { - super.reset() - hideContainerBorder = false - hideCurrentDateIndicator = false - transparentBackground = false - activeDates = [] - inactiveDates = [] - indicators = [] - } //-------------------------------------------------- // MARK: - Private Methods diff --git a/VDS/Components/Carousel/Carousel.swift b/VDS/Components/Carousel/Carousel.swift new file mode 100644 index 00000000..9e14d3d3 --- /dev/null +++ b/VDS/Components/Carousel/Carousel.swift @@ -0,0 +1,561 @@ +// +// Carousel.swift +// VDS +// +// Created by Kanamarlapudi, Vasavi on 29/05/24. +// + +import Foundation +import UIKit +import VDSCoreTokens +import Combine + +/// A carousel is a collection of related content in a row that a customer can navigate through horizontally. +/// Use this component to show content that is supplementary, not essential for task completion. +@objcMembers +@objc(VDSCarousel) +open class Carousel: View { + + //-------------------------------------------------- + // 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: - Enums + //-------------------------------------------------- + /// Enum used to describe the pagination display for this component. + public enum PaginationDisplay: String, CaseIterable { + case persistent, none + } + + /// Enum used to describe the peek for this component. + /// This is how much a tile is partially visible. It is measured by the distance between the edge of + /// the tile and the edge of the viewport or carousel container. A peek can appear on the left and/or + /// right edge of the carousel container or viewport, depending on the carousel’s scroll position. + public enum Peek: String, CaseIterable { + case standard, minimum, none + } + + /// Enum used to describe the vertical of slotAlignment. + public enum Vertical: String, CaseIterable { + case top, middle, bottom + } + + /// Enum used to describe the horizontal of slotAlignment. + public enum Horizontal: String, CaseIterable { + case left, center, right + } + + /// Space between each tile. The default value will be 6X in tablet and 3X in mobile. + public enum Gutter: String, CaseIterable , DefaultValuing, Valuing { + case gutter3X = "3X" + case gutter6X = "6X" + + public static var defaultValue: Self { UIDevice.isIPad ? .gutter6X : .gutter3X } + + public var value: CGFloat { + switch self { + case .gutter3X: + VDSLayout.space3X + case .gutter6X: + VDSLayout.space6X + } + } + } + + //-------------------------------------------------- + // MARK: - Public Properties + //-------------------------------------------------- + /// views used to render view in the carousel slots. + open var views: [UIView] = [] { didSet { setNeedsUpdate() } } + + /// Space between each tile. The default value will be 6X in tablet and 3X in mobile. + open var gutter: Gutter = Gutter.defaultValue { didSet { setNeedsUpdate() } } + + /// The amount of slides visible in the carousel container at one time. + /// The default value will be 3UP in tablet and 1UP in mobile. + open var layout: CarouselScrollbar.Layout = UIDevice.isIPad ? .threeUP : .oneUP { + didSet { + carouselScrollBar.position = 0 + setNeedsUpdate() + } + } + + /// A callback when moving the carousel. Returns selectedGroupIndex. + open var onChange: ((Int) -> Void)? { + get { nil } + set { + onChangeCancellable?.cancel() + if let newValue { + onChangeCancellable = onChangePublisher + .sink { c in + newValue(c) + } + } + } + } + + /// Config object for pagination. + open var pagination: CarouselPaginationModel = .init(kind: .lowContrast, floating: true) { didSet {setNeedsUpdate() } } + + /// If provided, will determine the conditions to render the pagination arrows. + open var paginationDisplay: PaginationDisplay = .none { didSet {setNeedsUpdate() } } + + /// If provided, will apply margin to pagination arrows. Can be set to either positive or negative values. + /// The default value will be 3X in tablet and 2X in mobile. These values are the default in order to avoid overlapping content within the carousel. + open var paginationInset: CGFloat = UIDevice.isIPad ? VDSLayout.space3X : VDSLayout.space2X { didSet { updatePaginationInset() } } + + /// Options for user to configure the partially-visible tile in group. + /// Setting peek to 'none' will display arrow navigation icons on mobile devices. + open var peek: Peek = .standard { didSet { setNeedsUpdate() } } + + /// The initial visible slide's index in the carousel. + open var groupIndex: Int = 0 { didSet { setNeedsUpdate() } } + + /// If provided, will set the alignment for slot content when the slots has different heights. + open var slotAlignment: CarouselSlotAlignmentModel? = .init(vertical: .top, horizontal: .left) { didSet { setNeedsUpdate() } } + + //-------------------------------------------------- + // MARK: - Private Properties + //-------------------------------------------------- + internal var containerSize: CGSize { CGSize(width: frame.size.width, height: 44) } + private let contentStackView = UIStackView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.axis = .vertical + $0.distribution = .fill + $0.spacing = UIDevice.isIPad ? VDSLayout.space3X : VDSLayout.space1X + $0.backgroundColor = .clear + } + + internal var carouselScrollBar = CarouselScrollbar().with { + $0.layout = UIDevice.isIPad ? .threeUP : .oneUP + $0.position = 0 + $0.backgroundColor = .clear + } + + internal var containerView = View().with { + $0.clipsToBounds = true + $0.backgroundColor = .clear + } + + internal var scrollContainerView = View().with { + $0.clipsToBounds = true + $0.backgroundColor = .clear + } + + private var scrollView = UIScrollView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.backgroundColor = .clear + } + + /// Previous button to show previous slide. + private var previousButton = ButtonIcon().with { + $0.kind = .lowContrast + $0.iconName = .leftCaret + $0.iconOffset = .init(x: -2, y: 0) + $0.customContainerSize = UIDevice.isIPad ? 40 : 28 + $0.icon.customSize = UIDevice.isIPad ? 16 : 12 + } + + /// Next button to show next slide. + private var nextButton = ButtonIcon().with { + $0.kind = .lowContrast + $0.iconName = .rightCaret + $0.iconOffset = .init(x: 2, y: 0) + $0.customContainerSize = UIDevice.isIPad ? 40 : 28 + $0.icon.customSize = UIDevice.isIPad ? 16 : 12 + } + + /// A publisher for when moving the carousel. Passes parameters selectedGroupIndex (position). + open var onChangePublisher = PassthroughSubject() + private var onChangeCancellable: AnyCancellable? + + private var containerStackHeightConstraint: NSLayoutConstraint? + private var containerViewHeightConstraint: NSLayoutConstraint? + private var prevButtonLeadingConstraint: NSLayoutConstraint? + private var nextButtonTrailingConstraint: NSLayoutConstraint? + + // The scrollbar has top 5X space. So the expected top space is adjusted for tablet and mobile. + let scrollbarTopSpace = UIDevice.isIPad ? VDSLayout.space3X : VDSLayout.space1X + + var slotDefaultHeight = 50.0 + var peekMinimum = 24.0 + var minimumSlotWidth = 0.0 + + //-------------------------------------------------- + // MARK: - Lifecycle + //-------------------------------------------------- + /// 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 + + // Add containerView + addSubview(containerView) + containerView + .pinTop() + .pinBottom() + .pinLeading() + .pinTrailing() + .heightGreaterThanEqualTo(containerSize.height) + containerView.centerYAnchor.constraint(equalTo: centerYAnchor).activate() + + // Add content stackview + containerView.addSubview(contentStackView) + + // Add scrollview + scrollContainerView.addSubview(scrollView) + scrollView.pinToSuperView() + + // Add pagination button icons + scrollContainerView.addSubview(previousButton) + previousButton + .pinLeadingGreaterThanOrEqualTo() + .pinCenterY() + + scrollContainerView.addSubview(nextButton) + nextButton + .pinTrailingLessThanOrEqualTo() + .pinCenterY() + + // Add scroll container view & carousel scrollbar + contentStackView.addArrangedSubview(scrollContainerView) + contentStackView.addArrangedSubview(carouselScrollBar) + contentStackView.setCustomSpacing(scrollbarTopSpace, after: scrollContainerView) + contentStackView + .pinTop() + .pinBottom() + .pinLeading() + .pinTrailing() + .heightGreaterThanEqualTo(containerSize.height) + + addlisteners() + updatePaginationInset() + } + + open override func setDefaults() { + super.setDefaults() + gutter = UIDevice.isIPad ? .gutter6X : .gutter3X + layout = UIDevice.isIPad ? .threeUP : .oneUP + onChange = nil + pagination = .init(kind: .lowContrast, floating: true) + paginationDisplay = .none + paginationInset = UIDevice.isIPad ? VDSLayout.space3X : VDSLayout.space2X + peek = .standard + groupIndex = 0 + } + + /// Used to make changes to the View based off a change events or from local properties. + open override func updateView() { + super.updateView() + + carouselScrollBar.numberOfSlides = views.count + carouselScrollBar.layout = layout + if (carouselScrollBar.position == 0 || carouselScrollBar.position > carouselScrollBar.numberOfSlides) { + carouselScrollBar.position = 1 + } + carouselScrollBar.isHidden = (totalPositions() <= 1) ? true : false + + // Mobile/Tablet layouts without peek - must show pagination controls. + // If peek is ‘none’, pagination controls should show. So set to persistent. + if peek == .none { + paginationDisplay = .persistent + } + + // Minimum (Mobile only) Supported only on Mobile viewports. If a user passes Minimum for tablet carousel, the peek reverts to Standard. + if UIDevice.isIPad && peek == .minimum { + peek = .standard + } + + // Standard(Default) Peek - Supported for all Tablet viewports and layouts. Supported only for 1up layouts on Mobile viewports. + if peek == .standard && !UIDevice.isIPad && layout != CarouselScrollbar.Layout.oneUP { + peek = .minimum + } + + updatePaginationControls() + addCarouselSlots() + } + + //-------------------------------------------------- + // MARK: - Private Methods + //-------------------------------------------------- + private func addlisteners() { + nextButton.onClick = { _ in self.nextButtonClick() } + previousButton.onClick = { _ in self.previousButtonClick() } + + /// Will be called when the scrubber position changes. + carouselScrollBar.onScrubberDrag = { [weak self] scrubberId in + guard let self else { return } + updateScrollPosition(position: scrubberId, callbackText:"onThumbPositionChange") + } + + /// Will be called when the scrollbar thumb move forward. + carouselScrollBar.onMoveForward = { [weak self] scrubberId in + guard let self else { return } + updateScrollPosition(position: scrubberId, callbackText:"onMoveForward") + } + + /// Will be called when the scrollbar thumb move backward. + carouselScrollBar.onMoveBackward = { [weak self] scrubberId in + guard let self else { return } + updateScrollPosition(position: scrubberId, callbackText:"onMoveBackward") + } + + /// Will be called when the scrollbar thumb touch start. + carouselScrollBar.onThumbTouchStart = { [weak self] scrubberId in + guard let self else { return } + updateScrollPosition(position: scrubberId, callbackText:"onThumbTouchStart") + } + + /// Will be called when the scrollbar thumb touch end. + carouselScrollBar.onThumbTouchEnd = { [weak self] scrubberId in + guard let self else { return } + updateScrollPosition(position: scrubberId, callbackText:"onThumbTouchEnd") + } + } + + // Update pagination buttons with selected surface, kind, floating values + private func updatePaginationControls() { + containerView.surface = surface + showPaginationControls() + previousButton.kind = pagination.kind + previousButton.floating = pagination.floating + nextButton.kind = pagination.kind + nextButton.floating = pagination.floating + previousButton.surface = surface + nextButton.surface = surface + } + + // Show/Hide pagination buttons of Carousel based on First or Middle or Last + private func showPaginationControls() { + if carouselScrollBar.numberOfSlides == layout.value { + previousButton.isHidden = true + nextButton.isHidden = true + } else { + previousButton.isHidden = (carouselScrollBar.position == 1) || (paginationDisplay == .none) + nextButton.isHidden = (carouselScrollBar.position == totalPositions()) || (paginationDisplay == .none) + } + } + + private func estimateHeightFor(component: UIView, with itemWidth: CGFloat) -> CGFloat { + let maxSize = CGSize(width: itemWidth, height: CGFloat.greatestFiniteMagnitude) + let estItemSize = component.systemLayoutSizeFitting(maxSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel) + return estItemSize.height + } + + private func fetchCarouselHeight() -> CGFloat { + var height = slotDefaultHeight + if views.count > 0 { + for index in 0...views.count - 1 { + let estHeight = estimateHeightFor(component: views[index], with: minimumSlotWidth) + height = estHeight > height ? estHeight : height + } + } + return height + } + + // Add carousel slots and load data if any + private func addCarouselSlots() { + getSlotWidth() + if containerView.frame.size.width > 0 { + containerViewHeightConstraint?.isActive = false + containerStackHeightConstraint?.isActive = false + let slotHeight = fetchCarouselHeight() + + // Perform a loop to iterate each subView + scrollView.subviews.forEach { subView in + // Removing subView from its parent view + subView.removeFromSuperview() + } + + // Add carousel items + if views.count > 0 { + var xPos = 0.0 + for index in 0...views.count - 1 { + + // Add Carousel Slot + let carouselSlot = View().with { + $0.clipsToBounds = true + } + scrollView.addSubview(carouselSlot) + scrollView.delegate = self + + carouselSlot + .pinTop() + .pinBottom() + .pinLeading(xPos) + .width(minimumSlotWidth) + .height(slotHeight) + xPos = xPos + minimumSlotWidth + gutter.value + + let component = views[index] + carouselSlot.addSubview(component) + setSlotAlignment(contentView: component) + } + scrollView.contentSize = CGSize(width: xPos - gutter.value, height: slotHeight) + } + + let containerHeight = slotHeight + scrollbarTopSpace + containerSize.height + if carouselScrollBar.isHidden { + containerStackHeightConstraint = contentStackView.heightAnchor.constraint(equalToConstant: slotHeight) + containerViewHeightConstraint = containerView.heightAnchor.constraint(equalToConstant: slotHeight) + } else { + containerStackHeightConstraint = contentStackView.heightAnchor.constraint(equalToConstant: containerHeight) + containerViewHeightConstraint = containerView.heightAnchor.constraint(equalToConstant: containerHeight) + } + containerViewHeightConstraint?.isActive = true + containerStackHeightConstraint?.isActive = true + } + } + + // Set slot alignment if provided. Used only when slot content have different heights or widths. + private func setSlotAlignment(contentView: UIView) { + switch slotAlignment?.vertical { + case .top: + contentView + .pinTop() + .pinBottomLessThanOrEqualTo() + case .middle: + contentView + .pinTopGreaterThanOrEqualTo() + .pinBottomLessThanOrEqualTo() + .pinCenterY() + case .bottom: + contentView + .pinTopGreaterThanOrEqualTo() + .pinBottom() + default: break + } + + switch slotAlignment?.horizontal { + case .left: + contentView + .pinLeading() + .pinTrailingLessThanOrEqualTo() + case .center: + contentView + .pinLeadingGreaterThanOrEqualTo() + .pinTrailingLessThanOrEqualTo() + .pinCenterX() + case .right: + contentView + .pinLeadingGreaterThanOrEqualTo() + .pinTrailing() + default: break + } + } + + // Get the slot width relative to the peak + private func getSlotWidth() { + let actualWidth = containerView.frame.size.width + let isScrollbarSuppressed = views.count > 0 && layout.value == views.count + let isPeekMinimumOnTablet = UIDevice.isIPad && peek == .minimum + let isPeekNone: Bool = peek == .none + minimumSlotWidth = isScrollbarSuppressed || isPeekMinimumOnTablet || isPeekNone ? actualWidth - ((CGFloat(layout.value)-1) * gutter.value): actualWidth - (CGFloat(layout.value) * gutter.value) + if !isScrollbarSuppressed { + switch peek { + case .standard: + // Standard(Default) Peek - Supported for all Tablet viewports and layouts. Supported only for 1up layouts on Mobile viewports. + if UIDevice.isIPad { + minimumSlotWidth = minimumSlotWidth - (minimumSlotWidth/(CGFloat(layout.value) + 3)) + } else if layout == .oneUP { + minimumSlotWidth = minimumSlotWidth - (minimumSlotWidth/4) + } + case .minimum: + // Peek Mimumum Width: 24px from edge of container (at the default view of the carousel with one peek visible) + // Minimum (Mobile only) Supported only on Mobile viewports. If a user passes Minimum for tablet carousel, the peek reverts to Standard. + minimumSlotWidth = isPeekMinimumOnTablet ? minimumSlotWidth : minimumSlotWidth - peekMinimum - gutter.value + case .none: + break + } + } + minimumSlotWidth = ceil(minimumSlotWidth / CGFloat(layout.value)) + } + + private func nextButtonClick() { + carouselScrollBar.position = carouselScrollBar.position+1 + showPaginationControls() + updateScrollPosition(position: carouselScrollBar.position, callbackText:"pageControlClicks") + } + + private func previousButtonClick() { + carouselScrollBar.position = carouselScrollBar.position-1 + showPaginationControls() + updateScrollPosition(position: carouselScrollBar.position, callbackText:"pageControlClicks") + } + + private func updatePaginationInset() { + prevButtonLeadingConstraint?.isActive = false + nextButtonTrailingConstraint?.isActive = false + prevButtonLeadingConstraint = previousButton.leadingAnchor.constraint(equalTo: scrollContainerView.leadingAnchor, constant: paginationInset) + nextButtonTrailingConstraint = nextButton.trailingAnchor.constraint(equalTo: scrollContainerView.trailingAnchor, constant: -paginationInset) + prevButtonLeadingConstraint?.isActive = true + nextButtonTrailingConstraint?.isActive = true + } + + private func updateScrollbarPosition(targetContentOffsetXPos:CGFloat) { + let scrollContentSizeWidth = scrollView.contentSize.width + let totalPositions = totalPositions() + let layoutSpace = Int (floor( Double(scrollContentSizeWidth / Double(totalPositions)))) + let remindSpace = Int(targetContentOffsetXPos) % layoutSpace + var contentPos = (Int(targetContentOffsetXPos) / layoutSpace) + 1 + contentPos = remindSpace > layoutSpace/2 ? contentPos+1 : contentPos + carouselScrollBar.position = contentPos + updateScrollPosition(position: contentPos, callbackText: "ScrollViewMoved") + } + + // Update scrollview offset relative to scrollbar thumb position + private func updateScrollPosition(position: Int, callbackText: String) { + if carouselScrollBar.numberOfSlides > 0 { + let scrollContentSizeWidth = scrollView.contentSize.width + let totalPositions = totalPositions() + var xPos = 0.0 + if position == 1 { + xPos = 0.0 + } else if position == totalPositions { + xPos = scrollContentSizeWidth - containerView.frame.size.width + } else { + let isScrollbarSuppressed = views.count > 0 && layout.value == views.count + let isPeekMinimumOnTablet = UIDevice.isIPad && peek == .minimum + if !isScrollbarSuppressed { + let slotWidthWithGutter = minimumSlotWidth + gutter.value + let xPosition = CGFloat( Float(position-1) * Float(layout.value) * Float(slotWidthWithGutter)) + let peekWidth = (containerView.frame.size.width - gutter.value - (Double(layout.value) * (minimumSlotWidth + gutter.value)))/2 + xPos = (peek == .none || isPeekMinimumOnTablet) ? xPosition : xPosition - gutter.value - peekWidth + } + } + carouselScrollBar.scrubberId = position+1 + let yPos = scrollView.contentOffset.y + scrollView.setContentOffset(CGPoint(x: xPos, y: yPos), animated: true) + showPaginationControls() + groupIndex = position-1 + onChangePublisher.send(groupIndex) + } + } + + // Get the overall positions of the carousel scrollbar relative to the slides and selected layout + private func totalPositions() -> Int { + return Int (ceil (Double(carouselScrollBar.numberOfSlides) / Double(layout.value))) + } +} + +extension Carousel: UIScrollViewDelegate { + //-------------------------------------------------- + // MARK: - UIScrollView Delegate + //-------------------------------------------------- + public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { + updateScrollbarPosition(targetContentOffsetXPos: targetContentOffset.pointee.x) + } + +} diff --git a/VDS/Components/Carousel/CarouselChangeLog.txt b/VDS/Components/Carousel/CarouselChangeLog.txt new file mode 100644 index 00000000..a1fd0f4f --- /dev/null +++ b/VDS/Components/Carousel/CarouselChangeLog.txt @@ -0,0 +1,15 @@ +MM/DD/YYYY +---------------- + +06/22/2023 +---------------- +- Initial Beta Release + +10/02/2023 +---------------- +- Removed (Beta) from header. Removed deprecated sections and “New” badge from Kind section. + +11/20/2023 +---------------- +- Updated visuals to reflect new corner radius value - 12px +- Updated focus border corner radius to 14px diff --git a/VDS/Components/Carousel/CarouselPaginationModel.swift b/VDS/Components/Carousel/CarouselPaginationModel.swift new file mode 100644 index 00000000..ab85d6a4 --- /dev/null +++ b/VDS/Components/Carousel/CarouselPaginationModel.swift @@ -0,0 +1,26 @@ +// +// CarouselPaginationModel.swift +// VDS +// +// Created by Kanamarlapudi, Vasavi on 06/06/24. +// + +import Foundation +import UIKit + +/// Custom data type for pagination prop for 'Carousel' component. +extension Carousel { + public struct CarouselPaginationModel { + + /// Pagination supports Button icon property 'kind'. + public var kind: ButtonIcon.Kind + + /// Pagination supports Button icon property 'floating'. + public var floating: Bool + + public init(kind: ButtonIcon.Kind, floating: Bool) { + self.kind = kind + self.floating = floating + } + } +} diff --git a/VDS/Components/Carousel/CarouselSlotAlignmentModel.swift b/VDS/Components/Carousel/CarouselSlotAlignmentModel.swift new file mode 100644 index 00000000..ee1d491b --- /dev/null +++ b/VDS/Components/Carousel/CarouselSlotAlignmentModel.swift @@ -0,0 +1,27 @@ +// +// CarouselSlotAlignmentModel.swift +// VDS +// +// Created by Kanamarlapudi, Vasavi on 31/05/24. +// + +import Foundation + +/// Custom data type for the SlotAlignment prop for the 'carousel' component. +extension Carousel { + + /// Used only when slot content have different heights or widths. + public struct CarouselSlotAlignmentModel { + + /// Used for vertical alignment of slot alignment. + public var vertical: Carousel.Vertical + + /// Used for horizontal alignment of slot alignment. + public var horizontal: Carousel.Horizontal + + public init(vertical: Carousel.Vertical, horizontal: Carousel.Horizontal) { + self.vertical = vertical + self.horizontal = horizontal + } + } +} diff --git a/VDS/Components/CarouselScrollbar/CarouselScrollbar.swift b/VDS/Components/CarouselScrollbar/CarouselScrollbar.swift index b4213f32..5b91acb7 100644 --- a/VDS/Components/CarouselScrollbar/CarouselScrollbar.swift +++ b/VDS/Components/CarouselScrollbar/CarouselScrollbar.swift @@ -12,6 +12,7 @@ import Combine /// A carousel scrollbar is a control that allows to navigate between items in a carousel. /// It's also a status indicator that conveys the relative amount of content in a carousel and a location within it. +@objcMembers @objc(VDSCarouselScrollbar) open class CarouselScrollbar: View { @@ -45,13 +46,13 @@ open class CarouselScrollbar: View { } /// The number of slides that can appear at once in a set in a carousel container. - open var selectedLayout: Layout? { - get { return _selectedLayout } + open var layout: Layout? { + get { return _layout } set { if let newValue { - _selectedLayout = newValue + _layout = newValue } else { - _selectedLayout = .oneUP + _layout = .oneUP } setThumbWidth() scrollThumbToPosition(position) @@ -105,14 +106,15 @@ open class CarouselScrollbar: View { /// A callback when the scrubber position changes. Passes parameters (position). open var onScrubberDrag: ((Int) -> Void)? { - get { nil } - set { + didSet { onScrubberDragCancellable?.cancel() - if let newValue { + if let onScrubberDrag { onScrubberDragCancellable = onScrubberDragPublisher .sink { c in - newValue(c) + onScrubberDrag(c) } + } else { + onScrubberDragCancellable = nil } } } @@ -123,14 +125,15 @@ open class CarouselScrollbar: View { /// A callback when the thumb move forward. Passes parameters (position). open var onMoveForward: ((Int) -> Void)? { - get { nil } - set { + didSet { onMoveForwardCancellable?.cancel() - if let newValue { + if let onMoveForward { onMoveForwardCancellable = onMoveForwardPublisher .sink { c in - newValue(c) + onMoveForward(c) } + } else { + onMoveForwardCancellable = nil } } } @@ -141,14 +144,15 @@ open class CarouselScrollbar: View { /// A callback when the thumb move backward. Passes parameters (position). open var onMoveBackward: ((Int) -> Void)? { - get { nil } - set { + didSet { onMoveBackwardCancellable?.cancel() - if let newValue { + if let onMoveBackward { onMoveBackwardCancellable = onMoveBackwardPublisher .sink { c in - newValue(c) + onMoveBackward(c) } + } else { + onMoveBackwardCancellable = nil } } } @@ -159,14 +163,15 @@ open class CarouselScrollbar: View { /// A callback when the thumb touch start. Passes parameters (position). open var onThumbTouchStart: ((Int) -> Void)? { - get { nil } - set { + didSet { onThumbTouchStartCancellable?.cancel() - if let newValue { + if let onThumbTouchStart { onThumbTouchStartCancellable = onThumbTouchStartPublisher .sink { c in - newValue(c) + onThumbTouchStart(c) } + } else { + onThumbTouchStartCancellable = nil } } } @@ -177,14 +182,15 @@ open class CarouselScrollbar: View { /// A callback when the thumb touch end. Passes parameters (position). open var onThumbTouchEnd: ((Int) -> Void)? { - get { nil } - set { + didSet { onThumbTouchEndCancellable?.cancel() - if let newValue { + if let onThumbTouchEnd { onThumbTouchEndCancellable = onThumbTouchEndPublisher .sink { c in - newValue(c) + onThumbTouchEnd(c) } + } else { + onThumbTouchEndCancellable = nil } } } @@ -198,7 +204,7 @@ open class CarouselScrollbar: View { //-------------------------------------------------- // Sizes are from InVision design specs. internal var containerSize: CGSize { CGSize(width: 45, height: 44) } - internal var _selectedLayout: Layout = .oneUP + internal var _layout: Layout = .oneUP internal var _numberOfSlides: Int = 1 internal var totalPositions: Int = 1 internal var _position: Int = 1 @@ -234,10 +240,6 @@ open class CarouselScrollbar: View { //-------------------------------------------------- // MARK: - Lifecycle //-------------------------------------------------- - open override func initialSetup() { - super.initialSetup() - } - open override func setup() { super.setup() isAccessibilityElement = false @@ -297,6 +299,19 @@ open class CarouselScrollbar: View { thumbView.layer.addSublayer(thumbViewLayer) } + open override func setDefaults() { + super.setDefaults() + onMoveForward = nil + onMoveBackward = nil + onScrubberDrag = nil + onThumbTouchEnd = nil + onThumbTouchStart = nil + layout = .oneUP + numberOfSlides = 1 + totalPositions = 1 + position = 1 + } + open override func updateView() { super.updateView() trackView.backgroundColor = trackColorConfiguration.getColor(surface) @@ -329,7 +344,7 @@ open class CarouselScrollbar: View { // Compute track width and should maintain minimum thumb width if needed private func setThumbWidth() { - let width = (Float(trackViewWidth) / Float(numberOfSlides)) * Float(_selectedLayout.value) + let width = (Float(trackViewWidth) / Float(numberOfSlides)) * Float(_layout.value) computedWidth = (width > Float(trackViewWidth)) ? Float(trackViewWidth) : width thumbWidth = (width <= Float(trackViewWidth) && width > minThumbWidth) ? width : ((width > Float(trackViewWidth)) ? Float(trackViewWidth) : minThumbWidth) thumbView.frame.size.width = CGFloat(thumbWidth) @@ -362,7 +377,7 @@ open class CarouselScrollbar: View { } private func checkPositions() { - totalPositions = Int (ceil (Double(numberOfSlides) / Double(_selectedLayout.value))) + totalPositions = Int (ceil (Double(numberOfSlides) / Double(_layout.value))) } private func scrollThumbToPosition(_ position: Int) { diff --git a/VDS/Components/Checkbox/Checkbox.swift b/VDS/Components/Checkbox/Checkbox.swift index 3369ebcb..f1c99691 100644 --- a/VDS/Components/Checkbox/Checkbox.swift +++ b/VDS/Components/Checkbox/Checkbox.swift @@ -12,6 +12,7 @@ import VDSCoreTokens /// Checkboxes are a multi-select component through which a customer indicates a choice. This is also used within /// ``CheckboxItem`` and ``CheckboxGroup`` +@objcMembers @objc(VDSCheckbox) open class Checkbox: SelectorBase { @@ -61,6 +62,11 @@ open class Checkbox: SelectorBase { selectorColorConfiguration.setSurfaceColors(VDSColor.elementsPrimaryOndark, VDSColor.elementsPrimaryOnlight, forState: .selected) } + open override func setDefaults() { + super.setDefaults() + isAnimated = false + } + /// This will change the state of the Selector and execute the actionBlock if provided. open override func toggle() { guard isEnabled else { return } diff --git a/VDS/Components/Checkbox/CheckboxGroup.swift b/VDS/Components/Checkbox/CheckboxGroup.swift index b450d4d5..226083fe 100644 --- a/VDS/Components/Checkbox/CheckboxGroup.swift +++ b/VDS/Components/Checkbox/CheckboxGroup.swift @@ -12,6 +12,7 @@ import VDSCoreTokens /// When the choice has multiple options, use a checkbox group. For example, use a checkbox group when /// asking a customer which attributes they would like to filter their search by. This uses ``CheckboxItem`` /// to allow user selection. +@objcMembers @objc(VDSCheckboxGroup) open class CheckboxGroup: SelectorGroupBase, SelectorGroupMultiSelect { @@ -86,6 +87,12 @@ open class CheckboxGroup: SelectorGroupBase, SelectorGroupMultiSel mainStackView.spacing = VDSLayout.space6X } + open override func setDefaults() { + super.setDefaults() + showError = false + inputId = nil + } + public override func didSelect(_ selectedControl: CheckboxItem) { selectedControl.toggle() if selectedControl.isSelected, showError{ diff --git a/VDS/Components/Checkbox/CheckboxItem.swift b/VDS/Components/Checkbox/CheckboxItem.swift index 21943636..e489edea 100644 --- a/VDS/Components/Checkbox/CheckboxItem.swift +++ b/VDS/Components/Checkbox/CheckboxItem.swift @@ -9,6 +9,7 @@ import Foundation import UIKit /// Checkboxes are a multi-select component through which a customer indicates a choice. If a binary choice, the component is a checkbox. If the choice has multiple options, the component is a ``CheckboxGroup``. +@objcMembers @objc(VDSCheckboxItem) open class CheckboxItem: SelectorItemBase { @@ -47,6 +48,11 @@ open class CheckboxItem: SelectorItemBase { isSelected.toggle() sendActions(for: .valueChanged) } + + open override func setDefaults() { + super.setDefaults() + isAnimated = false + } /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { diff --git a/VDS/Components/DatePicker/DatePicker.swift b/VDS/Components/DatePicker/DatePicker.swift index 83450403..e8802c69 100644 --- a/VDS/Components/DatePicker/DatePicker.swift +++ b/VDS/Components/DatePicker/DatePicker.swift @@ -4,8 +4,9 @@ import VDSCoreTokens import Combine /// A dropdown select is an expandable menu of predefined options that allows a customer to make a single selection. +@objcMembers @objc(VDSDatePicker) -open class DatePicker: EntryFieldBase { +open class DatePicker: EntryFieldBase { //-------------------------------------------------- // MARK: - Initializers //-------------------------------------------------- @@ -27,6 +28,19 @@ open class DatePicker: EntryFieldBase { /// A callback when the selected option changes. Passes parameters (option). open var onDateSelected: ((Date, DatePicker) -> Void)? + /// Override UIControl state to add the .error state if showError is true. + open override var state: UIControl.State { + get { + var state = super.state + if isEnabled { + if isCalendarShowing { + state.insert(.focused) + } + } + return state + } + } + //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- @@ -35,8 +49,9 @@ open class DatePicker: EntryFieldBase { true } } - + internal override var responder: UIResponder? { hiddenView } + internal var isCalendarShowing: Bool = false { didSet { setNeedsUpdate() } } internal var hiddenView = Responder().with { $0.width(0) } internal var minWidthDefault = 186.0 internal var bottomStackView: UIStackView = { @@ -137,18 +152,7 @@ open class DatePicker: EntryFieldBase { // setting color config selectedDateLabel.textColorConfiguration = primaryColorConfiguration.eraseToAnyColorable() - - // tap gesture - containerView - .publisher(for: UITapGestureRecognizer()) - .sink { [weak self] _ in - guard let self else { return } - if isEnabled && !isReadOnly { - showPopover() - } - } - .store(in: &subscribers) - + NotificationCenter.default .publisher(for: UIDevice.orientationDidChangeNotification).sink { [weak self] _ in guard let self else { return } @@ -159,6 +163,22 @@ open class DatePicker: EntryFieldBase { popoverOverlayView.isHidden = true } + open override func setDefaults() { + super.setDefaults() + selectedDate = nil + calendarModel = .init() + dateFormat = .shortNumeric + selectedDateLabel.textStyle = .bodyLarge + + // tap gesture + containerView.onClick = { [weak self] _ in + guard let self else { return } + if isEnabled && !isReadOnly { + showPopover() + } + } + } + open override func getFieldContainer() -> UIView { // stackview for controls in EntryFieldBase.controlContainerView let controlStackView = UIStackView().with { @@ -185,12 +205,6 @@ open class DatePicker: EntryFieldBase { calendarIcon.color = iconColorConfiguration.getColor(self) } - /// Resets to default settings. - open override func reset() { - super.reset() - selectedDateLabel.textStyle = .bodyLarge - } - internal func formatDate(_ date: Date) { let formatter = DateFormatter() formatter.dateFormat = dateFormat.format @@ -315,6 +329,7 @@ extension DatePicker { } } + isCalendarShowing = true } private func hidePopoverView() { @@ -346,6 +361,7 @@ extension DatePicker { UIAccessibility.post(notification: .layoutChanged, argument: containerView) } } + isCalendarShowing = false } private func calculatePopoverPosition(relativeTo sourceView: UIView, in parentView: UIView, size: CGSize, with spacing: CGFloat) -> CGPoint? { diff --git a/VDS/Components/DropdownSelect/DropdownSelect.swift b/VDS/Components/DropdownSelect/DropdownSelect.swift index 832349d2..2c243a26 100644 --- a/VDS/Components/DropdownSelect/DropdownSelect.swift +++ b/VDS/Components/DropdownSelect/DropdownSelect.swift @@ -11,8 +11,9 @@ import VDSCoreTokens import Combine /// A dropdown select is an expandable menu of predefined options that allows a customer to make a single selection. +@objcMembers @objc(VDSDropdownSelect) -open class DropdownSelect: EntryFieldBase { +open class DropdownSelect: EntryFieldBase { //-------------------------------------------------- // MARK: - Initializers //-------------------------------------------------- @@ -81,24 +82,15 @@ open class DropdownSelect: EntryFieldBase { open var inlineDisplayLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.setContentCompressionResistancePriority(.required, for: .horizontal) - $0.textAlignment = .left - $0.textStyle = .boldBodyLarge - $0.numberOfLines = 1 - $0.sizeToFit() } open var selectedOptionLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.setContentCompressionResistancePriority(.required, for: .horizontal) - $0.textAlignment = .left - $0.textStyle = .bodyLarge - $0.numberOfLines = 1 } open var dropdownField = UITextField().with { $0.translatesAutoresizingMaskIntoConstraints = false - $0.tintColor = UIColor.clear - $0.font = TextStyle.bodyLarge.font } open var optionsPicker = UIPickerView() @@ -152,15 +144,35 @@ open class DropdownSelect: EntryFieldBase { }() // tap gesture - containerView - .publisher(for: UITapGestureRecognizer()) - .sink { [weak self] _ in - self?.launchPicker() - } - .store(in: &subscribers) + containerView.onClick = { [weak self] _ in + self?.launchPicker() + } containerView.height(44) } + open override func setDefaults() { + super.setDefaults() + showInlineLabel = false + selectId = nil + inlineDisplayLabel.textAlignment = .left + inlineDisplayLabel.textStyle = .boldBodyLarge + inlineDisplayLabel.numberOfLines = 1 + selectedOptionLabel.textAlignment = .left + selectedOptionLabel.textStyle = .bodyLarge + selectedOptionLabel.numberOfLines = 1 + dropdownField.tintColor = UIColor.clear + dropdownField.font = TextStyle.bodyLarge.font + showInlineLabel = false + options = [] + selectId = nil + } + + open override func reset() { + inlineDisplayLabel.reset() + selectedOptionLabel.reset() + super.reset() + } + open override func getFieldContainer() -> UIView { let controlStackView = UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false @@ -187,17 +199,6 @@ open class DropdownSelect: EntryFieldBase { selectedOptionLabel.surface = surface selectedOptionLabel.isEnabled = isEnabled } - - /// Resets to default settings. - open override func reset() { - super.reset() - - inlineDisplayLabel.textStyle = .boldBodyLarge - selectedOptionLabel.textStyle = .bodyLarge - showInlineLabel = false - options = [] - selectId = nil - } //-------------------------------------------------- // MARK: - Public Methods diff --git a/VDS/Components/Icon/ButtonIcon/ButtonIcon.swift b/VDS/Components/Icon/ButtonIcon/ButtonIcon.swift index bf8a50f7..b6b51601 100644 --- a/VDS/Components/Icon/ButtonIcon/ButtonIcon.swift +++ b/VDS/Components/Icon/ButtonIcon/ButtonIcon.swift @@ -402,17 +402,36 @@ open class ButtonIcon: Control, Changeable { centerXConstraint?.activate() centerYConstraint = icon.centerYAnchor.constraint(equalTo: iconLayoutGuide.centerYAnchor, constant: 0) centerYConstraint?.activate() + + publisher(for: .touchUpInside) + .sink(receiveValue: { [weak self] _ in + guard let self, isEnabled, + selectedIconName != nil, + selectable else { return } + toggle() + }) + .store(in: &subscribers) } - /// Executed on initialization for this View. - open override func initialSetup() { - super.initialSetup() - onClick = { control in - guard control.isEnabled else { return } - if control.selectedIconName != nil && control.selectable { - control.toggle() - } - } + open override func setDefaults() { + super.setDefaults() + badgeIndicatorModel = nil + kind = .ghost + surfaceType = .colorFill + iconName = nil + selectedIconName = nil + selectedIconColorConfiguration = nil + size = .large + floating = false + fitToIcon = false + hideBorder = true + showBadgeIndicator = false + selectable = false + iconOffset = .init(x: 0, y: 0) + customContainerSize = nil + customIconSize = nil + customBadgeIndicatorOffset = nil + onChange = nil } /// This will change the state of the Selector and execute the actionBlock if provided. @@ -422,26 +441,6 @@ open class ButtonIcon: Control, Changeable { sendActions(for: .valueChanged) } - /// Resets to default settings. - open override func reset() { - super.reset() - shouldUpdateView = false - kind = .ghost - surfaceType = .colorFill - size = .large - floating = false - hideBorder = true - iconOffset = .init(x: 0, y: 0) - iconName = nil - selectedIconName = nil - showBadgeIndicator = false - selectable = false - badgeIndicatorModel = nil - onChange = nil - shouldUpdateView = true - setNeedsUpdate() - } - /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { super.updateView() diff --git a/VDS/Components/Icon/Icon.swift b/VDS/Components/Icon/Icon.swift index 140f7b87..8e19bf5c 100644 --- a/VDS/Components/Icon/Icon.swift +++ b/VDS/Components/Icon/Icon.swift @@ -13,6 +13,7 @@ import Combine /// An icon is a graphical element that conveys information at a glance. It helps orient /// a customer, explain functionality and draw attention to interactive elements. Icons /// should have a functional purpose and should never be used for decoration. +@objcMembers @objc(VDSIcon) open class Icon: View { @@ -89,22 +90,29 @@ open class Icon: View { addSubview(imageView) imageView.pinToSuperView() - - backgroundColor = .clear - + isAccessibilityElement = true accessibilityTraits = .none - accessibilityHint = "image" + + } + + open override func setDefaults() { + super.setDefaults() + backgroundColor = .clear + color = VDSColor.paletteBlack + size = .medium + name = nil + customSize = nil + imageView.image = nil + accessibilityHint = "image" bridge_accessibilityLabelBlock = { [weak self] in guard let self else { return "" } return name?.rawValue ?? "icon" } - - - } + /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { super.updateView() @@ -122,12 +130,6 @@ open class Icon: View { invalidateIntrinsicContentSize() } - /// Resets to default settings. - open override func reset() { - super.reset() - color = VDSColor.paletteBlack - imageView.image = nil - } } extension UIImage { diff --git a/VDS/Components/InputStepper/InputStepper.swift b/VDS/Components/InputStepper/InputStepper.swift new file mode 100644 index 00000000..6c44b58f --- /dev/null +++ b/VDS/Components/InputStepper/InputStepper.swift @@ -0,0 +1,422 @@ +// +// InputStepper.swift +// VDS +// +// Created by Kanamarlapudi, Vasavi on 24/06/24. +// + +import Foundation +import UIKit +import VDSCoreTokens +import Combine + +/// A stepper is a two-segment control that people use to increase or decrease an incremental value.' +@objcMembers +@objc(VDSInputStepper) +open class InputStepper: 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: - Enums + //-------------------------------------------------- + /// Enum used to describe the size of Input Stepper. + public enum Size: String, CaseIterable { + case large, small + + var minWidth: CGFloat { + self == .large ? 121 : 90 + } + + var minHeight: CGFloat { + self == .large ? 44 : 32 + } + + var space: CGFloat { + self == .large ? VDSLayout.space3X : VDSLayout.space2X + } + var padding: CGFloat { + self == .large ? 6.0 : VDSLayout.space1X + } + + var buttonContainerSize: Int { + self == .large ? 32 : 24 + } + + var textStyle: TextStyle { + self == .large ? .boldBodyLarge : .boldBodySmall + } + } + + /// Enum used to describe the width of a fixed value or percentage of the input stepper control. + public enum ControlWidth { + case percentage(CGFloat) + case value(CGFloat) + } + + //-------------------------------------------------- + // MARK: - Public Properties + //-------------------------------------------------- + /// If there is a width that is larger than this size's minimumWidth, the input stepper will resize to this width. + open var controlWidth: ControlWidth? { + get { _controlWidth } + set { + if let newValue { + switch newValue { + case .percentage(let percentage): + if percentage <= 100.0 { + _controlWidth = newValue + } + case .value(let value): + if value > 0 && value > containerSize.width { + _controlWidth = newValue + } + } + } else { + _controlWidth = nil + } + setNeedsUpdate() + } + } + + /// Accepts percentage value to width of parent container. + open var widthPercentage: CGFloat? { + didSet { + if let percentage = widthPercentage, percentage > 100 { + widthPercentage = 100 + } + setNeedsUpdate() + } + } + + private var _defaultValue: Int = 0 + open override var defaultValue: Int? { + get { _defaultValue } + set { + if let newValue { + _defaultValue = newValue > maxValue ? maxValue : newValue < minValue ? minValue : newValue + } else { + _defaultValue = 0 + } + setNeedsUpdate() + } + } + + open override var value: Int? { return defaultValue } + + /// Maximum value of the input stepper, defaults to '99'. + lazy open var maxValue: Int = { _defaultMaxValue }() { + didSet { + if maxValue > _defaultMaxValue || maxValue < _defaultMinValue && maxValue > minValue { + maxValue = _defaultMaxValue + } + setNeedsUpdate() + } + } + + /// Minimum value of the input stepper, defaults to '0'. + lazy open var minValue: Int = { _defaultMinValue }() { + didSet { + if minValue < _defaultMinValue && minValue >= _defaultMaxValue && minValue < maxValue { + minValue = _defaultMinValue + } + setNeedsUpdate() + } + } + + /// The size of the input stepper. Defaults to 'large'. + open var size: Size = .large { didSet { setNeedsUpdate() } } + + /// Accepts any text or character to appear next to input stepper value. + open var trailingText: String? { didSet { setNeedsUpdate() } } + + //-------------------------------------------------- + // MARK: - Private Properties + //-------------------------------------------------- + private var _controlWidth: ControlWidth? = nil + private var _defaultMinValue: Int = 0 + private var _defaultMaxValue: Int = 99 + + /// This is the view that will be wrapped with the border for userInteraction. + /// The only subview of this view is the stepperStackView. + internal var stepperContainerView = View().with { + $0.isAccessibilityElement = true + $0.accessibilityLabel = "Input Stepper" + } + + internal var stepperStackView = UIStackView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.axis = .horizontal + $0.distribution = .fill + $0.alignment = .fill + } + + internal var decrementButton = ButtonIcon().with { + $0.kind = .ghost + $0.iconName = Icon.Name(name: "minus") + $0.iconOffset = .init(x: -2, y: 0) + $0.customContainerSize = 32 + $0.icon.customSize = 16 + $0.backgroundColor = .clear + } + + internal var incrementButton = ButtonIcon().with { + $0.kind = .ghost + $0.iconName = Icon.Name(name: "plus") + $0.iconOffset = .init(x: 2, y: 0) + $0.customContainerSize = 32 + $0.icon.customSize = 16 + $0.backgroundColor = .clear + } + + internal var textLabel = Label().with { + $0.setContentCompressionResistancePriority(.required, for: .vertical) + $0.textStyle = .boldBodyLarge + $0.numberOfLines = 1 + $0.lineBreakMode = .byTruncatingTail + $0.textAlignment = .center + } + + //-------------------------------------------------- + // MARK: - Constraints + //-------------------------------------------------- + internal var stepperWidthConstraint: NSLayoutConstraint? + internal var stepperHeightConstraint: NSLayoutConstraint? + + //-------------------------------------------------- + // MARK: - Configuration Properties + //-------------------------------------------------- + internal override var containerSize: CGSize { CGSize(width: size.minWidth, height: size.minHeight) } + + //-------------------------------------------------- + // MARK: - Lifecycle + //-------------------------------------------------- + + /// 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() + // Set initial states + defaultValue = 0 + containerView.isEnabled = false + statusIcon.isHidden = true + + //override the default settings since the containerView + //fieldStackView relationShip needs to be updated + //we are not applying spacing either in the edges since this + //is the view that will take place of the containerView for the + //design of the original "containerView". This will get refactored at + //some point. + fieldStackView.applyAlignment(.leading) + + // Add listeners + decrementButton.onClick = { _ in self.decrementButtonClick() } + incrementButton.onClick = { _ in self.incrementButtonClick() } + + // setting color config + textLabel.textColorConfiguration = primaryColorConfiguration.eraseToAnyColorable() + } + + open override func getFieldContainer() -> UIView { + stepperStackView.addArrangedSubview(decrementButton) + stepperStackView.addArrangedSubview(textLabel) + stepperStackView.addArrangedSubview(incrementButton) + + // Set space between decrement button, label, and increment button relative to input Stepper size. + stepperStackView.setCustomSpacing(size.space, after: decrementButton) + stepperStackView.setCustomSpacing(size.space, after: textLabel) + + // stepperContainerView for controls in EntryFieldBase.controlContainerView + stepperContainerView.addSubview(stepperStackView) + + // Update Edge insets relative to input Stepper size. + stepperStackView.pinToSuperView(.uniform(size.padding)) + + stepperWidthConstraint = stepperContainerView.width(constant: containerSize.width, priority: .required) + stepperHeightConstraint = stepperContainerView.height(constant: containerSize.height, priority: .required) + + return stepperContainerView + } + + /// Used to make changes to the View based off a change events or from local properties. + open override func updateView() { + super.updateView() + statusIcon.isHidden = true + + // Update label + textLabel.isEnabled = isEnabled + textLabel.surface = surface + textLabel.text = "\(_defaultValue) " + (trailingText ?? "") + textLabel.textStyle = size.textStyle + + updateButtonStates() + } + + open override var accessibilityElements: [Any]? { + get { + var elements = [Any]() + + if !isReadOnly || isEnabled { + elements.append(contentsOf: [titleLabel, containerView, decrementButton, textLabel, incrementButton]) + } else { + elements.append(contentsOf: [titleLabel, containerView, textLabel]) + } + + if showError { + if let errorText, !errorText.isEmpty { + elements.append(errorLabel) + } + } + if let helperText, !helperText.isEmpty { + elements.append(helperLabel) + } + return elements + } + + set { super.accessibilityElements = newValue } + } + + /// Resets to default settings. + open override func reset() { + super.reset() + textLabel.reset() + controlWidth = nil + widthPercentage = nil + defaultValue = 0 + minValue = _defaultMinValue + maxValue = _defaultMaxValue + trailingText = nil + size = .large + } + + //-------------------------------------------------- + // MARK: - Overrides + //-------------------------------------------------- + override func updateContainerView() { + //we are not calling super since we + //are using the fieldStackView as the "containerView" + //which will get the look/feel of the containerView. + //this will get refactored in the future. + fieldStackView.backgroundColor = containerBackgroundColor + fieldStackView.layer.borderColor = borderColorConfiguration.getColor(self).cgColor + fieldStackView.layer.borderWidth = VDSFormControls.borderWidth + } + + internal override func updateContainerWidth() { + //we are not calling super here since + //we are changing how the widths are getting calculated + //now by including a percentage. + + defer { + fieldStackView.layer.cornerRadius = containerSize.height / 2 + } + + stepperWidthConstraint?.deactivate() + widthConstraint?.deactivate() + trailingLessThanEqualsConstraint?.deactivate() + trailingEqualsConstraint?.deactivate() + + var widthConstraintConstant: CGFloat? + + if let widthPercentage, let superWidth = horizontalPinnedWidth() { + // test value vs minimum width and take the greater value + widthConstraintConstant = max(superWidth * (widthPercentage / 100), minWidth) + } else if let width, width >= minWidth, width <= maxWidth { + widthConstraintConstant = width + } else if let parentWidth = width, parentWidth >= maxWidth { + widthConstraintConstant = maxWidth + } else if let parentWidth = width, parentWidth <= minWidth { + widthConstraintConstant = minWidth + } + + if let widthConstraintConstant { + widthConstraint?.constant = widthConstraintConstant + widthConstraint?.activate() + trailingLessThanEqualsConstraint?.activate() + } else { + trailingEqualsConstraint?.activate() + } + + // Update Edge insets if size changes applied. + stepperStackView.applyAlignment(.fill, edges: .uniform(size.padding)) + + // Update height if size changes applied. + stepperHeightConstraint?.constant = containerSize.height + + //update the stepper's widthConstraint if + //controlWidth was set + guard let controlWidth else { + return + } + + // Set the inputStepper's controlWidth based on percentage received relative to its parentView's frame. + let containerWidth: CGFloat = widthConstraintConstant ?? containerView.frame.size.width + var stepperWidthConstant: CGFloat? + var stepperWidth: CGFloat + + switch controlWidth { + case .percentage(let percentage): + stepperWidth = max(containerWidth * ((percentage) / 100), minWidth) + + case .value(let value): + stepperWidth = value + } + + //get the value of the stepperWidthConstant + if stepperWidth >= containerSize.width && stepperWidth <= containerWidth { + stepperWidthConstant = stepperWidth + } else if stepperWidth >= containerWidth { + stepperWidthConstant = containerWidth + } + + if let stepperWidthConstant { + stepperWidthConstraint?.constant = stepperWidthConstant + stepperWidthConstraint?.activate() + } + } + + //-------------------------------------------------- + // MARK: - Private Methods + //-------------------------------------------------- + internal func decrementButtonClick() { + if _defaultValue > minValue { + defaultValue = _defaultValue - 1 + sendActions(for: .valueChanged) + } + } + + internal func incrementButtonClick() { + if _defaultValue < maxValue { + defaultValue = _defaultValue + 1 + sendActions(for: .valueChanged) + } + } + + internal func updateButtonStates() { + decrementButton.customContainerSize = size.buttonContainerSize + incrementButton.customContainerSize = size.buttonContainerSize + decrementButton.surface = surface + incrementButton.surface = surface + + if isReadOnly || !isEnabled { + decrementButton.isEnabled = false + incrementButton.isEnabled = false + } else { + decrementButton.isEnabled = (defaultValue ?? _defaultMaxValue ) > minValue ? true : false + incrementButton.isEnabled = (defaultValue ?? _defaultMinValue) < maxValue ? true : false + } + } + +} diff --git a/VDS/Components/InputStepper/InputStepperLog.txt b/VDS/Components/InputStepper/InputStepperLog.txt new file mode 100644 index 00000000..e8cd816b --- /dev/null +++ b/VDS/Components/InputStepper/InputStepperLog.txt @@ -0,0 +1,44 @@ + +MM/DD/YYYY +---------------- + +02/2024 +---------------- +- New component + +02/15/2024 +---------------- +- Added Border align: Inside to Anatomy +- Removed leadingText property values from States. +- Added Read-only to States. +- Created a section for Minimum width in Layout and spacing and updated the minWidth to 145px. +- Added trailingText spacing to Layout and spacing. +- Reduced space between Button Icons and text to 12px in Layout and spacing. +- Added top/bottom padding values to Layout and spacing. + +03/01/2024 +---------------- +- Removed Leading Text from “Content and other properties” section. + +03/20/2024 +---------------- +- Updated Anatomy artwork and items +- Added width and controlWidth to Configurations +- Updated Width under Layout and spacing to show layout examples + +04/12/2024 +---------------- +- Added a new configuration property (size) that includes large and small +- Reduced details from Anatomy page and added them to Configurations/Size +- Added Hit area, Small Input Stepper spacing properties to Layout and spacing +- Updated the Behavior page to display disabled button icon when value is at max and min + +04/29/2024 +---------------- +- Updated the Behavior page to display disabled button icon when value is at max and min + +05/10/2024 +---------------- +- Added helperTextPlacement property to Configurations +- Added Layout examples for right Helper Text placement in Layout and Spacing +- Added overflow examples in Overflow section of Layout and Spacing diff --git a/VDS/Components/Label/Label.swift b/VDS/Components/Label/Label.swift index e408953b..245dcf81 100644 --- a/VDS/Components/Label/Label.swift +++ b/VDS/Components/Label/Label.swift @@ -12,6 +12,7 @@ import Combine /// Label is a standard view used to draw text with applying Typography through ``TextStyle`` as well /// as other attributes using any implemetation of ``LabelAttributeModel``. +@objcMembers @objc(VDSLabel) open class Label: UILabel, ViewProtocol, UserInfoable { @@ -191,42 +192,46 @@ open class Label: UILabel, ViewProtocol, UserInfoable { //-------------------------------------------------- // MARK: - Lifecycle //-------------------------------------------------- - open func initialSetup() { + private func initialSetup() { if !initialSetupPerformed { initialSetupPerformed = true - //register for ContentSizeChanges - NotificationCenter - .Publisher(center: .default, name: UIContentSizeCategory.didChangeNotification) - .sink { [weak self] notification in - self?.setNeedsUpdate() - }.store(in: &subscribers) - backgroundColor = .clear - numberOfLines = 0 - lineBreakMode = .byTruncatingTail - translatesAutoresizingMaskIntoConstraints = false - accessibilityCustomActions = [] - isAccessibilityElement = true - accessibilityTraits = .staticText - textAlignment = .left + shouldUpdateView = false setup() + setDefaults() + shouldUpdateView = true setNeedsUpdate() } } open func setup() { + //register for ContentSizeChanges + NotificationCenter + .Publisher(center: .default, name: UIContentSizeCategory.didChangeNotification) + .sink { [weak self] notification in + self?.setNeedsUpdate() + }.store(in: &subscribers) + translatesAutoresizingMaskIntoConstraints = false + isAccessibilityElement = true } - open func reset() { - shouldUpdateView = false + open func setDefaults() { + backgroundColor = .clear + accessibilityTraits = .staticText + accessibilityCustomActions = [] surface = .light isEnabled = true attributes = nil textStyle = .defaultStyle + lineBreakMode = .byTruncatingTail textAlignment = .left text = nil attributedText = nil numberOfLines = 0 - backgroundColor = .clear + } + + open func reset() { + shouldUpdateView = false + setDefaults() shouldUpdateView = true setNeedsUpdate() } diff --git a/VDS/Components/Line/Line.swift b/VDS/Components/Line/Line.swift index e6edae70..06accf07 100644 --- a/VDS/Components/Line/Line.swift +++ b/VDS/Components/Line/Line.swift @@ -10,6 +10,7 @@ import UIKit import VDSCoreTokens /// A line visually separates content sections or elements in lists, tables and layouts to indicate content hierarchy. +@objcMembers @objc(VDSLine) open class Line: View { @@ -80,11 +81,6 @@ open class Line: View { //-------------------------------------------------- // 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() - } - /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { super.updateView() @@ -93,8 +89,8 @@ open class Line: View { } /// Resets to default settings. - open override func reset() { - super.reset() + open override func setDefaults() { + super.setDefaults() style = .primary orientation = .horizontal } diff --git a/VDS/Components/Loader/Loader.swift b/VDS/Components/Loader/Loader.swift index 13ceeb43..5b7f1b59 100644 --- a/VDS/Components/Loader/Loader.swift +++ b/VDS/Components/Loader/Loader.swift @@ -11,6 +11,7 @@ import VDSCoreTokens /// A loader is an indicator that uses animation to show customers that there is an indefinite amount of wait time while a task is ongoing, e.g. a page is loading, a form is being submitted. The component disappears when the task is complete. +@objcMembers @objc(VDSLoader) open class Loader: View { diff --git a/VDS/Components/Loader/LoaderViewController.swift b/VDS/Components/Loader/LoaderViewController.swift index 65639c18..408422fb 100644 --- a/VDS/Components/Loader/LoaderViewController.swift +++ b/VDS/Components/Loader/LoaderViewController.swift @@ -10,6 +10,8 @@ import UIKit import VDSCoreTokens /// ViewController to show the Loader, this will be presented using the LoaderLaunchable Protocl. +@objcMembers +@objc(VDSLoaderViewController) open class LoaderViewController: UIViewController, Surfaceable { //-------------------------------------------------- // MARK: - Private Properties diff --git a/VDS/Components/Notification/Notification.swift b/VDS/Components/Notification/Notification.swift index 66be33c4..59d2b78f 100644 --- a/VDS/Components/Notification/Notification.swift +++ b/VDS/Components/Notification/Notification.swift @@ -14,6 +14,7 @@ import Combine /// in context. There are four types: information, success, warning and error; each /// with different color and content. They may be screen-specific, flow-specific or /// experience-wide. +@objcMembers @objc(VDSNotification) open class Notification: View { @@ -267,26 +268,15 @@ open class Notification: View { closeButton.accessibilityTraits = [.button] closeButton.accessibilityLabel = "Close Notification" - typeIcon.bridge_accessibilityLabelBlock = { [weak self] in - guard let self else { return "" } - return style.accessibleText - } } - /// Resets to default settings. - open override func reset() { - super.reset() - - shouldUpdateView = false - - titleLabel.reset() + open override func setDefaults() { + super.setDefaults() titleLabel.text = "" titleLabel.textStyle = UIDevice.isIPad ? .boldBodyLarge : .boldBodySmall - subTitleLabel.reset() subTitleLabel.textStyle = UIDevice.isIPad ? .bodyLarge : .bodySmall - buttonGroup.reset() buttonGroup.alignment = .left primaryButtonModel = nil @@ -301,9 +291,19 @@ open class Notification: View { closeButton.name = .close hideCloseButton = false - - shouldUpdateView = true - setNeedsUpdate() + + typeIcon.bridge_accessibilityLabelBlock = { [weak self] in + guard let self else { return "" } + return style.accessibleText + } + } + + /// Resets to default settings. + open override func reset() { + titleLabel.reset() + subTitleLabel.reset() + buttonGroup.reset() + super.reset() } /// Used to make changes to the View based off a change events or from local properties. diff --git a/VDS/Components/Pagination/Pagination.swift b/VDS/Components/Pagination/Pagination.swift index 8988aaa4..cf4f9183 100644 --- a/VDS/Components/Pagination/Pagination.swift +++ b/VDS/Components/Pagination/Pagination.swift @@ -11,6 +11,7 @@ import VDSCoreTokens import Combine ///Pagination is a control that enables customers to navigate multiple pages of content by selecting either a specific page or the next or previous set of four pages. +@objcMembers @objc(VDSPagination) open class Pagination: View { @@ -56,7 +57,7 @@ open class Pagination: View { ///Next button to select next page public let nextButton: PaginationButton = .init(type: .next) /// A callback when the page changes. Passes parameters (selectedPage). - public var onPageDidSelect: ((Int) -> Void)? + open var onPageDidSelect: ((Int) -> Void)? /// Total number of pages, allows limit ranging from 0 to 9999. @Clamping(range: 0...9999) public var total: Int { @@ -69,7 +70,7 @@ open class Pagination: View { } } ///Selected active page number and clips to total pages if selected index is greater than the total pages. - public var selectedPage: Int { + open var selectedPage: Int { set { if newValue >= total { _selectedPageIndex = total - 1 @@ -94,8 +95,8 @@ open class Pagination: View { // MARK: - Overrides //-------------------------------------------------- /// Executed on initialization for this View. - open override func initialSetup() { - super.initialSetup() + open override func setup() { + super.setup() collectionContainerView.addSubview(collectionView) containerView.addSubview(previousButton) @@ -144,6 +145,10 @@ open class Pagination: View { .sink { [weak self] value in self?.collectionViewWidthAnchor?.constant = value //As cell width is dynamic i.e cell may contain 2 or 3 or 4 charcters. Make sure that all the visible cells are displayed. }.store(in: &subscribers) + } + + open override func setDefaults() { + super.setDefaults() collectionContainerView.onAccessibilityIncrement = { [weak self] in guard let self else { return } self.selectedPage = max(0, self.selectedPage + 1) diff --git a/VDS/Components/Pagination/PaginationButton.swift b/VDS/Components/Pagination/PaginationButton.swift index d7aaf8e2..14f399b9 100644 --- a/VDS/Components/Pagination/PaginationButton.swift +++ b/VDS/Components/Pagination/PaginationButton.swift @@ -9,6 +9,7 @@ import UIKit import VDSCoreTokens ///This is customised button for Pagination view +@objcMembers @objc(PaginationButton) open class PaginationButton: ButtonBase { //-------------------------------------------------- @@ -59,8 +60,8 @@ open class PaginationButton: ButtonBase { // MARK: - Overrides //-------------------------------------------------- /// Executed on initialization for this View. - open override func initialSetup() { - super.initialSetup() + open override func setup() { + super.setup() if #available(iOS 15.0, *) { configuration = buttonConfiguration } else { diff --git a/VDS/Components/PriceLockup/PriceLockup.swift b/VDS/Components/PriceLockup/PriceLockup.swift new file mode 100644 index 00000000..990e5b76 --- /dev/null +++ b/VDS/Components/PriceLockup/PriceLockup.swift @@ -0,0 +1,333 @@ +// +// PriceLockup.swift +// VDS +// +// Created by Kanamarlapudi, Vasavi on 06/08/24. +// + +import Foundation +import UIKit +import VDSCoreTokens + +@objcMembers +@objc(VDSPriceLockup) +open class PriceLockup: View { + + //-------------------------------------------------- + // 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: - Enums + //-------------------------------------------------- + /// Enum used to describe the term of PriceLockup. + public enum Term: String, DefaultValuing, CaseIterable { + case month, year, biennial, none + + /// The default term is 'month'. + public static var defaultValue : Self { .month } + + /// Text for this term of PriceLockup. + public var type: String { + switch self { + case .month: + return "mo" + case .year: + return "yr" + case .biennial: + return "biennial" + case .none: + return "" + } + } + } + + /// Enum that represents the size availble for PriceLockup. + public enum Size: String, DefaultValuing, CaseIterable { + case xxxsmall = "3XSmall" + case xxsmall = "2XSmall" + case xsmall = "XSmall" + case small + case medium + case large + case xlarge = "XLarge" + case xxlarge = "2XLarge" + + public static var defaultValue: Self { .medium } + } + + /// Enum used to describe the kind of PriceLockup. + public enum Kind: String, DefaultValuing, CaseIterable { + case primary, secondary, savings + + /// The default kind is 'primary'. + public static var defaultValue : Self { .primary } + + /// Color configuation relative to kind. + public var colorConfiguration: SurfaceColorConfiguration { + switch self { + case .primary: + return SurfaceColorConfiguration(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark) + case .secondary: + return SurfaceColorConfiguration(VDSColor.elementsSecondaryOnlight, VDSColor.elementsSecondaryOndark) + case .savings: + return SurfaceColorConfiguration(VDSColor.paletteGreen26, VDSColor.paletteGreen36) + } + } + } + + //-------------------------------------------------- + // MARK: - Public Properties + //-------------------------------------------------- + + /// If true, the component will render as bold. + open var bold: Bool = false { didSet { setNeedsUpdate() } } + + /// Currency - If hideCurrency true, the component will render without currency. + open var hideCurrency: Bool = false { didSet { setNeedsUpdate() } } + + /// Leading text for the component. + open var leadingText: String? { didSet { setNeedsUpdate() } } + + /// Value rendered for the component. + open var price: Float? { didSet { setNeedsUpdate() } } + + /// Color to the component. The default kind is primary. + open var kind: Kind = .defaultValue { didSet { setNeedsUpdate() } } + + /// Size of the component. It varies by size and viewport(mobile/Tablet). + /// The default size is medium with viewport mobile. + open var size: Size = .defaultValue { didSet { setNeedsUpdate() } } + + /// If true, the component with a strikethrough. It applies only when uniformSize is true. + /// Does not apply a strikethrough format to leading and trailing text. + open var strikethrough: Bool = false { didSet { setNeedsUpdate() } } + + /// Term text for the component. The default term is 'month'. + /// Superscript placement can vary when term and delimeter are "none". + open var term: Term = .defaultValue { didSet { setNeedsUpdate() } } + + /// Trailing text for the component. + open var trailingText: String? { didSet { setNeedsUpdate() } } + + /// Superscript text for the component. + open var superscript: String? { didSet { setNeedsUpdate() } } + + /// If true, currency and value have the same font text style as delimeter, term label and superscript. + /// This will render the pricing and term sections as a uniform size. + open var uniformSize: Bool = false { didSet { setNeedsUpdate() } } + + //-------------------------------------------------- + // MARK: - Private Properties + //-------------------------------------------------- + internal var priceLockupLabel = Label().with { + $0.isAccessibilityElement = true + $0.lineBreakMode = .byWordWrapping + } + + internal var delimiterIndex = 0 + internal var strikethroughLocation = 0 + internal var strikethroughLength = 0 + + internal var textPosition:TextPosition = .preDelimiter + enum TextPosition: String, CaseIterable { + case preDelimiter, postDelimiter + } + + //-------------------------------------------------- + // MARK: - Configuration Properties + //-------------------------------------------------- + internal var containerSize: CGSize { CGSize(width: 45, height: 44) } + + // TextStyle for the size. + private var textStyle: TextStyle.StandardStyle { + switch (size, textPosition) { + case (.xxxsmall, .preDelimiter), (.xxxsmall, .postDelimiter): + return .micro + + case (.xxsmall, .preDelimiter), (.xxsmall, .postDelimiter): + return .bodySmall + + case (.xsmall, .preDelimiter), (.xsmall, .postDelimiter): + return .bodyMedium + + case (.small, .preDelimiter), (.small, .postDelimiter): + return .bodyLarge + + case (.medium, .preDelimiter): + return UIDevice.isIPad ? .titleSmall : .titleMedium + + case (.medium, .postDelimiter): + return .bodyLarge + + case (.large, .preDelimiter): + return UIDevice.isIPad ? .titleMedium : .titleLarge + + case (.large, .postDelimiter): + return UIDevice.isIPad ? .titleSmall : .titleMedium + + case (.xlarge, .preDelimiter): + return UIDevice.isIPad ? .titleLarge : .titleXLarge + + case (.xlarge, .postDelimiter): + return UIDevice.isIPad ? .titleMedium : .titleLarge + + case (.xxlarge, .preDelimiter): + return UIDevice.isIPad ? .titleXLarge : .featureSmall + + case (.xxlarge, .postDelimiter): + return UIDevice.isIPad ? .titleLarge : .titleXLarge + } + } + + //-------------------------------------------------- + // 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() + + // Price lockup label + addSubview(priceLockupLabel) + priceLockupLabel.pinToSuperView() + } + + /// Used to make changes to the View based off a change events or from local properties. + open override func updateView() { + super.updateView() + + priceLockupLabel.text = formatText() + priceLockupLabel.surface = surface + + // Set the attributed text + updateLabelAttributes() + } + + open override func setDefaults() { + super.setDefaults() + bold = false + hideCurrency = false + leadingText = nil + price = nil + kind = .defaultValue + size = .defaultValue + strikethrough = false + term = .defaultValue + trailingText = nil + superscript = nil + uniformSize = false + } + + /// Resets to default settings. + open override func reset() { + priceLockupLabel.reset() + super.reset() + } + + //-------------------------------------------------- + // MARK: - Private Methods + //-------------------------------------------------- + // Update PriceLockup text attributes + func updateLabelAttributes() { + var attributes: [any LabelAttributeModel] = [] + attributes.append(ColorLabelAttribute(location: 0, + length: priceLockupLabel.text.count, + color: kind.colorConfiguration.getColor(self))) + textPosition = .postDelimiter + if strikethrough { + + // strike applies only when uniformSize true. Does not apply a strikethrough format to leading, trailing, and superscript text. + attributes.append(TextStyleLabelAttribute(location: 0, + length: priceLockupLabel.text.count, + textStyle: bold ? textStyle.bold : textStyle.regular, + textPosition: .left)) + attributes.append(StrikeThroughLabelAttribute(location:strikethroughLocation, length: strikethroughLength)) + + } else if uniformSize { + + // currency and value have the same font text style as delimeter, term, trailing text and superscript. + attributes.append(TextStyleLabelAttribute(location: 0, + length: priceLockupLabel.text.count, + textStyle: bold ? textStyle.bold : textStyle.regular, + textPosition: .left)) + + } else { + + // size updates relative to predelimiter, postdelimiter + if delimiterIndex > 0 { + textPosition = .preDelimiter + attributes.append(TextStyleLabelAttribute(location: 0, + length: delimiterIndex, + textStyle: bold ? textStyle.bold : textStyle.regular, + textPosition: .left)) + + textPosition = .postDelimiter + attributes.append(TextStyleLabelAttribute(location: delimiterIndex, + length: priceLockupLabel.text.count-delimiterIndex, + textStyle: bold ? textStyle.bold : textStyle.regular, + textPosition: .left)) + } + } + priceLockupLabel.attributes = attributes + } + + // Get text for PriceLockup. + private func formatText() -> String { + var text : String = "" + let space = " " + let delimiter = "/" + delimiterIndex = 0 + strikethroughLength = 0 + let currency: String = hideCurrency ? "" : "$" + + if let leadingText { + text.append(leadingText) + text.append(space) + delimiterIndex = delimiterIndex + leadingText.count + space.count + } + + strikethroughLocation = delimiterIndex + + if let price = price?.clean { + text.append(currency) + text.append(price) + delimiterIndex = delimiterIndex + price.count + currency.count + strikethroughLength = price.count + currency.count + } + + if term != .none { + text.append(delimiter) + text.append(term.type) + strikethroughLength = strikethroughLength + delimiter.count + term.type.count + } + + if let trailingText { + text.append(space) + text.append(trailingText) + } + + if let superscript { + text.append(superscript) + } + + return text + } +} + +extension Float { + // remove a decimal from a float if the decimal is equal to 0 + var clean: String { + return self.truncatingRemainder(dividingBy: 1) == 0 ? String(format: "%.0f", self) : String(describing: self) + } +} diff --git a/VDS/Components/PriceLockup/PriceLockupChangeLog.txt b/VDS/Components/PriceLockup/PriceLockupChangeLog.txt new file mode 100644 index 00000000..e06ee9f8 --- /dev/null +++ b/VDS/Components/PriceLockup/PriceLockupChangeLog.txt @@ -0,0 +1,28 @@ +MM/DD/YYYY +---------------- + +11/16/2023 +---------------- +- Added leadingText and trailingText to anatomy +- Added leadingText and trailingText props to configurations +- Added term prop to configurations +- Removed Suspended orange color and corresponding color tokens + +11/27/2023 +---------------- +- Removed “Delimiter” from Anatomy as “Term” includes both delimiter and term +- Added “Figma only” badge to leadingText and trailingText in Configurations +- Added superscript to “none” under term in Configurations +- Added Overflow section to Layout and spacing +- Updated Spacing to allow for leading and trailing text + +12/18/23 +---------------- +- Updated all pages with spec template updates from Doc Utility Expansion Pack +- Added Content props section to Config page + +1/15/24 +---------------- +- Clarified strikethrough does not apply to leading or trailing text +- Clarified and added to text overflow examples +- Correct Success to Savings in the configuration seciton diff --git a/VDS/Components/RadioBox/RadioBoxGroup.swift b/VDS/Components/RadioBox/RadioBoxGroup.swift index c58802c0..000e3433 100644 --- a/VDS/Components/RadioBox/RadioBoxGroup.swift +++ b/VDS/Components/RadioBox/RadioBoxGroup.swift @@ -11,6 +11,7 @@ import UIKit /// Radio boxes are single-select components through which a customer indicates a choice. /// They're stylized ``RadioButtons`` that must always be paired with one or more ``RadioBoxItem`` /// in a radio box group. Use radio boxes to display choices like device storage. +@objcMembers @objc(VDSRadioBoxGroup) open class RadioBoxGroup: SelectorGroupBase, SelectorGroupSingleSelect { @@ -66,17 +67,26 @@ open class RadioBoxGroup: SelectorGroupBase, SelectorGroupSingleSe // MARK: - Overrides //-------------------------------------------------- private func ensureDevice() { + var axis: NSLayoutConstraint.Axis = .vertical + var distribution: UIStackView.Distribution = .fill + + defer { + mainStackView.axis = axis + mainStackView.distribution = distribution + } + if UIDevice.isIPad { - mainStackView.axis = .horizontal - mainStackView.distribution = .fillEqually + axis = .horizontal + distribution = .fillEqually } else { - if UIDevice.current.orientation.isPortrait || UIDevice.current.orientation == .unknown { - mainStackView.axis = .vertical - mainStackView.distribution = .fill - - } else { - mainStackView.axis = .horizontal - mainStackView.distribution = .fillEqually + guard let supportedOrientations = UIApplication.shared.windows.first?.rootViewController?.supportedInterfaceOrientations else { + return + } + + let orientation = UIDevice.current.orientation + if supportedOrientations.contains(.landscape) && (orientation == .landscapeLeft || orientation == .landscapeRight) { + axis = .horizontal + distribution = .fillEqually } } } diff --git a/VDS/Components/RadioBox/RadioBoxItem.swift b/VDS/Components/RadioBox/RadioBoxItem.swift index 872a4d05..a5ca1d8b 100644 --- a/VDS/Components/RadioBox/RadioBoxItem.swift +++ b/VDS/Components/RadioBox/RadioBoxItem.swift @@ -12,6 +12,7 @@ import VDSCoreTokens /// Radio boxes are single-select components through which a customer indicates a choice /// that are used within a ``RadioBoxGroup``. +@objcMembers @objc(VDSRadioBoxItem) open class RadioBoxItem: Control, Changeable, FormFieldable, Groupable { @@ -164,11 +165,38 @@ open class RadioBoxItem: Control, Changeable, FormFieldable, Groupable { //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- - /// Executed on initialization for this View. - open override func initialSetup() { - super.initialSetup() - onClick = { control in - control.toggle() + /// 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 + selectorView.isAccessibilityElement = true + selectorView.accessibilityTraits = .button + addSubview(selectorView) + selectorView.isUserInteractionEnabled = false + + selectorView.addSubview(selectorStackView) + + selectorStackView.addArrangedSubview(selectorLeftLabelStackView) + selectorStackView.addArrangedSubview(subTextRightLabel) + selectorLeftLabelStackView.addArrangedSubview(textLabel) + selectorLeftLabelStackView.addArrangedSubview(subTextLabel) + + selectorView + .pinTop() + .pinLeading() + .pinTrailing(0, .defaultHigh) + .pinBottom(0, .defaultHigh) + + selectorStackView.pinToSuperView(.uniform(VDSLayout.space3X)) + } + + open override func setDefaults() { + super.setDefaults() + + onClick = { [weak self] _ in + guard let self, isEnabled else { return } + toggle() } selectorView.bridge_accessibilityLabelBlock = { [weak self] in @@ -203,43 +231,7 @@ open class RadioBoxItem: Control, Changeable, FormFieldable, Groupable { return accessibilityLabels.joined(separator: ", ") } - - } - /// 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 - selectorView.isAccessibilityElement = true - selectorView.accessibilityTraits = .button - addSubview(selectorView) - selectorView.isUserInteractionEnabled = false - - selectorView.addSubview(selectorStackView) - - selectorStackView.addArrangedSubview(selectorLeftLabelStackView) - selectorStackView.addArrangedSubview(subTextRightLabel) - selectorLeftLabelStackView.addArrangedSubview(textLabel) - selectorLeftLabelStackView.addArrangedSubview(subTextLabel) - - selectorView - .pinTop() - .pinLeading() - .pinTrailing(0, .defaultHigh) - .pinBottom(0, .defaultHigh) - - selectorStackView.pinToSuperView(.uniform(VDSLayout.space3X)) - } - - /// Resets to default settings. - open override func reset() { - super.reset() - shouldUpdateView = false - textLabel.reset() - subTextLabel.reset() - subTextRightLabel.reset() - textLabel.textStyle = .boldBodyLarge subTextLabel.textStyle = .bodyLarge subTextRightLabel.textStyle = .bodyLarge @@ -259,9 +251,14 @@ open class RadioBoxItem: Control, Changeable, FormFieldable, Groupable { isSelected = false onChange = nil + } - shouldUpdateView = true - setNeedsUpdate() + /// Resets to default settings. + open override func reset() { + textLabel.reset() + subTextLabel.reset() + subTextRightLabel.reset() + super.reset() } /// This will change the state of the Selector and execute the actionBlock if provided. diff --git a/VDS/Components/RadioButton/RadioButton.swift b/VDS/Components/RadioButton/RadioButton.swift index f19e7b2f..7241fddf 100644 --- a/VDS/Components/RadioButton/RadioButton.swift +++ b/VDS/Components/RadioButton/RadioButton.swift @@ -13,6 +13,7 @@ import VDSCoreTokens /// Radio buttons are single-select components through which a customer indicates a choice. /// They must always be paired with one or more ``RadioButtonItem`` within a ``RadioButtonGroup``. /// Use radio buttons to display choices like delivery method. +@objcMembers @objc(VDSRadioButton) open class RadioButton: SelectorBase { diff --git a/VDS/Components/RadioButton/RadioButtonGroup.swift b/VDS/Components/RadioButton/RadioButtonGroup.swift index f44f5587..5d323997 100644 --- a/VDS/Components/RadioButton/RadioButtonGroup.swift +++ b/VDS/Components/RadioButton/RadioButtonGroup.swift @@ -11,6 +11,7 @@ import UIKit /// Radio buttons items are single-select components through which a customer indicates a choice. /// They must always be paired with one or more other ``RadioButtonItem`` within a radio button group. /// Use radio buttons to display choices like delivery method. +@objcMembers @objc(VDSRadioButtonGroup) open class RadioButtonGroup: SelectorGroupBase, SelectorGroupSingleSelect { @@ -76,12 +77,13 @@ open class RadioButtonGroup: SelectorGroupBase, SelectorGroupSi } } - open override func reset() { - super.reset() + open override func setDefaults() { + super.setDefaults() + inputId = nil showError = false } - - public override func didSelect(_ selectedControl: RadioButtonItem) { + + open override func didSelect(_ selectedControl: RadioButtonItem) { if let selectedItem { updateToggle(selectedItem) } diff --git a/VDS/Components/RadioButton/RadioButtonItem.swift b/VDS/Components/RadioButton/RadioButtonItem.swift index bc15531d..af84ac89 100644 --- a/VDS/Components/RadioButton/RadioButtonItem.swift +++ b/VDS/Components/RadioButton/RadioButtonItem.swift @@ -11,6 +11,7 @@ import UIKit /// Radio buttons items are single-select components through which a customer indicates a choice. /// They must always be paired with one or more other radio button items within a ``RadioButtonGroup``. /// Use radio buttons to display choices like delivery method. +@objcMembers @objc(VDSRadioButtonItem) open class RadioButtonItem: SelectorItemBase { diff --git a/VDS/Components/Table/Table.swift b/VDS/Components/Table/Table.swift index 39cc6698..21fe00a4 100644 --- a/VDS/Components/Table/Table.swift +++ b/VDS/Components/Table/Table.swift @@ -10,6 +10,7 @@ import UIKit import VDSCoreTokens ///Table is view composed of rows and columns, which takes any view into each cell and resizes based on the highest cell height. +@objcMembers @objc(VDSTable) open class Table: View { @@ -91,8 +92,8 @@ open class Table: View { //-------------------------------------------------- ///Called upon initializing the table view - open override func initialSetup() { - super.initialSetup() + open override func setup() { + super.setup() addSubview(matrixView) matrixView.pinToSuperView() } @@ -108,18 +109,16 @@ open class Table: View { matrixView.collectionViewLayout.invalidateLayout() } - /// Resets to default settings. - open override func reset() { - super.reset() + open override func setDefaults() { + super.setDefaults() striped = false padding = .standard tableHeader = [] tableRows = [] fillContainer = true columnWidths = nil - setNeedsUpdate() } - + func calculateColumnWidths() -> [CGFloat] { guard let noOfColumns = tableData.first?.columnsCount else { return [] } let itemWidth = floor(matrixView.safeAreaLayoutGuide.layoutFrame.width / CGFloat(noOfColumns)) diff --git a/VDS/Components/Tabs/Tab.swift b/VDS/Components/Tabs/Tab.swift index 4c0b55cd..0b97568b 100644 --- a/VDS/Components/Tabs/Tab.swift +++ b/VDS/Components/Tabs/Tab.swift @@ -11,7 +11,7 @@ import VDSCoreTokens import Combine extension Tabs { - + @objcMembers @objc(VDSTab) open class Tab: Control, Groupable { @@ -150,6 +150,10 @@ extension Tabs { labelLeadingConstraint = label.pinLeading(anchor: layoutGuide.leadingAnchor) labelBottomConstraint = label.pinBottom(anchor: layoutGuide.bottomAnchor, priority: .defaultHigh) + } + + open override func setDefaults() { + super.setDefaults() bridge_accessibilityLabelBlock = { [weak self] in guard let self else { return "" } return text diff --git a/VDS/Components/Tabs/Tabs.swift b/VDS/Components/Tabs/Tabs.swift index 88ae02e7..ac9e7269 100644 --- a/VDS/Components/Tabs/Tabs.swift +++ b/VDS/Components/Tabs/Tabs.swift @@ -10,6 +10,7 @@ import UIKit import VDSCoreTokens /// Tabs are organizational components that group content and allow customers to navigate its display. Use them to separate content when the content is related but doesn’t need to be compared. +@objcMembers @objc(VDSTabs) open class Tabs: View { @@ -220,10 +221,11 @@ open class Tabs: View { super.layoutSubviews() updateContentView() } - - open override func reset() { - super.reset() - shouldUpdateView = false + + open override func setDefaults() { + super.setDefaults() + onTabDidSelect = nil + onTabShouldSelect = nil orientation = .horizontal borderLine = true fillContainer = false @@ -234,11 +236,9 @@ open class Tabs: View { selectedIndex = 0 size = .medium sticky = false - tabViews.forEach{ $0.reset() } - shouldUpdateView = true - setNeedsUpdate() + tabModels = [] } - + //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- diff --git a/VDS/Components/Tabs/TabsContainer.swift b/VDS/Components/Tabs/TabsContainer.swift index d44b877f..945ecbaf 100644 --- a/VDS/Components/Tabs/TabsContainer.swift +++ b/VDS/Components/Tabs/TabsContainer.swift @@ -8,6 +8,7 @@ import Foundation import UIKit +@objcMembers @objc(VDSTabsContainer) open class TabsContainer: View { diff --git a/VDS/Components/TextFields/EntryFieldBase.swift b/VDS/Components/TextFields/EntryFieldBase.swift index cf2dffa2..0a815e08 100644 --- a/VDS/Components/TextFields/EntryFieldBase.swift +++ b/VDS/Components/TextFields/EntryFieldBase.swift @@ -11,9 +11,7 @@ import VDSCoreTokens import Combine /// Base Class used to build out a Input controls. -@objc(VDSEntryField) -open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { - +open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { //-------------------------------------------------- // MARK: - Initializers //-------------------------------------------------- @@ -137,8 +135,8 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { 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.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .focused) + $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: [.focused, .error]) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: .disabled) $0.setSurfaceColors(VDSColor.feedbackErrorOnlight, VDSColor.feedbackErrorOndark, forState: .error) $0.setSurfaceColors(VDSFormControlsColor.borderReadonlyOnlight, VDSFormControlsColor.borderReadonlyOndark, forState: .readonly) @@ -228,11 +226,11 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { open var inputId: String? { didSet { setNeedsUpdate() } } /// The text of this textField. - open var value: String? { + open var value: ValueType? { get { fatalError("must be read from subclass")} } - open var defaultValue: AnyHashable? { didSet { setNeedsUpdate() } } + open var defaultValue: ValueType? { didSet { setNeedsUpdate() } } open var isRequired: Bool = false { didSet { setNeedsUpdate() } } @@ -244,7 +242,7 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { } } - open var rules = [AnyRule]() + open var rules = [AnyRule]() open var accessibilityHintText: String = "Double tap to open" @@ -315,6 +313,41 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { errorLabel.textColorConfiguration = primaryColorConfiguration.eraseToAnyColorable() helperLabel.textColorConfiguration = secondaryColorConfiguration.eraseToAnyColorable() + } + + /// Updates the UI + open override func updateView() { + super.updateView() + updateRules() + updateContainerView() + updateContainerWidth() + updateTitleLabel() + updateErrorLabel() + updateHelperLabel() + } + + open override func setDefaults() { + super.setDefaults() + + titleLabel.textStyle = .bodySmall + errorLabel.textStyle = .bodySmall + helperLabel.textStyle = .bodySmall + + labelText = nil + helperText = nil + showError = false + errorText = nil + tooltipModel = nil + transparentBackground = false + width = nil + inputId = nil + defaultValue = nil + isRequired = false + isReadOnly = false + helperTextPlacement = .bottom + rules = [] + onChange = nil + containerView.bridge_accessibilityLabelBlock = { [weak self] in guard let self else { return "" } var accessibilityLabels = [String]() @@ -331,9 +364,7 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { if let errorText, showError { accessibilityLabels.append("error, \(errorText)") } - - accessibilityLabels.append("\(Self.self)") - + return accessibilityLabels.joined(separator: ", ") } @@ -343,49 +374,23 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { } containerView.bridge_accessibilityValueBlock = { [weak self] in - guard let self else { return "" } - return value + guard let self, let value else { return "" } + return "\(value)" } statusIcon.bridge_accessibilityLabelBlock = { [weak self] in guard let self else { return "" } return showError || hasInternalError ? "error" : nil } - } - - /// Updates the UI - open override func updateView() { - super.updateView() - updateContainerView() - updateContainerWidth() - updateTitleLabel() - updateErrorLabel() - updateHelperLabel() + } /// Resets to default settings. open override func reset() { - super.reset() titleLabel.reset() errorLabel.reset() helperLabel.reset() - - titleLabel.textStyle = .bodySmall - errorLabel.textStyle = .bodySmall - helperLabel.textStyle = .bodySmall - - labelText = nil - helperText = nil - showError = false - errorText = nil - tooltipModel = nil - transparentBackground = false - width = nil - inputId = nil - defaultValue = nil - isRequired = false - isReadOnly = false - onChange = nil + super.reset() } open override var canBecomeFirstResponder: Bool { @@ -418,7 +423,6 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { } open func validate(){ - updateRules() validator = FormFieldValidator(field: self, rules: rules) validator?.validate() setNeedsUpdate() @@ -524,8 +528,8 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { //-------------------------------------------------- internal func updateRules() { rules.removeAll() - if isRequired && useRequiredRule { - let rule = RequiredRule() + if isRequired && useRequiredRule && ValueType.self == String.self { + let rule = RequiredRule() if let errorText, !errorText.isEmpty { rule.errorMessage = errorText } else if let labelText{ @@ -543,7 +547,7 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { containerView.layer.borderWidth = VDSFormControls.borderWidth containerView.layer.cornerRadius = VDSFormControls.borderRadius } - + internal func updateContainerWidth() { widthConstraint?.deactivate() trailingLessThanEqualsConstraint?.deactivate() diff --git a/VDS/Components/TextFields/InputField/FieldTypes/Telephone.swift b/VDS/Components/TextFields/InputField/FieldTypes/Telephone.swift index 89417c19..10255be6 100644 --- a/VDS/Components/TextFields/InputField/FieldTypes/Telephone.swift +++ b/VDS/Components/TextFields/InputField/FieldTypes/Telephone.swift @@ -10,6 +10,35 @@ import UIKit extension InputField { + public class TelephoneNumberValidator: Rule, Withable { + public var format: String + public var errorMessage: String = "Please enter a valid telephone number" + + public init(format: String) { + self.format = format + } + + public func isValid(value: String?) -> Bool { + guard let value, !value.isEmpty else { return true } + let regex = createRegex(from: format) + let predicate = NSPredicate(format: "SELF MATCHES %@", regex) + let valid = predicate.evaluate(with: value) + return valid + } + + private func createRegex(from format: String) -> String { + // Escape special regex characters in the format string + let escapedFormat = NSRegularExpression.escapedPattern(for: format) + + // Replace placeholder characters with regex patterns + let regex = escapedFormat + .replacingOccurrences(of: "X", with: "\\d") + + return "^" + regex + "$" + } + } + + class TelephoneHandler: FieldTypeHandler { static let shared = TelephoneHandler() @@ -25,14 +54,7 @@ extension InputField { } override func appendRules(_ inputField: InputField) { - if let text = inputField.textField.text, text.count > 0 { - let rule = CharacterCountRule().copyWith { - $0.maxLength = "XXX-XXX-XXXX".count - $0.compareType = .equals - $0.errorMessage = "Enter a valid telephone." - } - inputField.rules.append(.init(rule)) - } + inputField.rules.append(.init(TelephoneNumberValidator(format: "XXX-XXX-XXXX"))) } override func textField(_ inputField: InputField, textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { @@ -49,7 +71,7 @@ extension InputField { let rawNumber = newText.filter { $0.isNumber } // Format the number with dashes - let formattedNumber = formatUSNumber(rawNumber) + let formattedNumber = rawNumber.formatUSNumber() // Set the formatted text textField.text = formattedNumber @@ -62,6 +84,8 @@ extension InputField { textField.selectedTextRange = textField.textRange(from: newPosition, to: newPosition) } + value = formattedNumber + // Prevent the default behavior return false @@ -69,43 +93,45 @@ extension InputField { override func textFieldDidEndEditing(_ inputField: InputField, textField: UITextField) { if let text = inputField.text { - let rawNumber = text.filter { $0.isNumber } - textField.text = formatUSNumber(rawNumber) + textField.text = text.formatUSNumber() + value = textField.text } } - - func formatUSNumber(_ number: String) -> String { - // Format the number in the style XXX-XXX-XXXX - let areaCodeLength = 3 - let centralOfficeCodeLength = 3 - let lineNumberLength = 4 - - var formattedNumber = "" - - if number.count > 0 { - formattedNumber.append(contentsOf: number.prefix(areaCodeLength)) - } - - if number.count > areaCodeLength { - let startIndex = number.index(number.startIndex, offsetBy: areaCodeLength) - let endIndex = number.index(startIndex, offsetBy: min(centralOfficeCodeLength, number.count - areaCodeLength)) - let centralOfficeCode = number[startIndex.. areaCodeLength + centralOfficeCodeLength { - let startIndex = number.index(number.startIndex, offsetBy: areaCodeLength + centralOfficeCodeLength) - let endIndex = number.index(startIndex, offsetBy: min(lineNumberLength, number.count - areaCodeLength - centralOfficeCodeLength)) - let lineNumber = number[startIndex.. String { + // Format the number in the style XXX-XXX-XXXX + let areaCodeLength = 3 + let centralOfficeCodeLength = 3 + let lineNumberLength = 4 + + var formattedNumber = "" + let number = filter { $0.isNumber } + + if number.count > 0 { + formattedNumber.append(contentsOf: number.prefix(areaCodeLength)) + } + + if number.count > areaCodeLength { + let startIndex = number.index(number.startIndex, offsetBy: areaCodeLength) + let endIndex = number.index(startIndex, offsetBy: min(centralOfficeCodeLength, number.count - areaCodeLength)) + let centralOfficeCode = number[startIndex.. areaCodeLength + centralOfficeCodeLength { + let startIndex = number.index(number.startIndex, offsetBy: areaCodeLength + centralOfficeCodeLength) + let endIndex = number.index(startIndex, offsetBy: min(lineNumberLength, number.count - areaCodeLength - centralOfficeCodeLength)) + let lineNumber = number[startIndex.. { //-------------------------------------------------- // MARK: - Initializers @@ -204,6 +205,22 @@ open class InputField: EntryFieldBase { textField.textColorConfiguration = textFieldTextColorConfiguration + } + + open override func getFieldContainer() -> UIView { + return textField + } + + open override func setDefaults() { + super.setDefaults() + textField.text = "" + + successLabel.textStyle = .bodySmall + + fieldType = .text + showSuccess = false + successText = nil + containerView.bridge_accessibilityLabelBlock = { [weak self] in guard let self else { return "" } var accessibilityLabels = [String]() @@ -258,22 +275,10 @@ open class InputField: EntryFieldBase { } } - 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 + super.reset() } /// Used to make changes to the View based off a change events or from local properties. @@ -311,6 +316,24 @@ open class InputField: EntryFieldBase { } } + open var widthPercentage: CGFloat? { didSet { setNeedsUpdate() } } + + internal override func updateContainerWidth() { + widthConstraint?.deactivate() + trailingLessThanEqualsConstraint?.deactivate() + trailingEqualsConstraint?.deactivate() + + //see if there is a widthPercentage and follow the same pattern as done for "width" + let currentWidth = (horizontalPinnedWidth() ?? 0) * (widthPercentage ?? 0) + if currentWidth >= minWidth, currentWidth <= maxWidth { + widthConstraint?.constant = currentWidth + widthConstraint?.activate() + trailingLessThanEqualsConstraint?.activate() + } else { + super.updateContainerWidth() + } + } + override func updateRules() { super.updateRules() fieldType.handler().appendRules(self) @@ -364,11 +387,11 @@ extension InputField: UITextFieldDelegate { open func textFieldDidChangeSelection(_ textField: UITextField) { fieldType.handler().textFieldDidChangeSelection(self, textField: textField) + text = textField.text + sendActions(for: .valueChanged) if fieldType.handler().validateOnChange { validate() } - sendActions(for: .valueChanged) - setNeedsUpdate() } open func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { diff --git a/VDS/Components/TextFields/InputField/TextField.swift b/VDS/Components/TextFields/InputField/TextField.swift index b56f3423..9fb8d7f6 100644 --- a/VDS/Components/TextFields/InputField/TextField.swift +++ b/VDS/Components/TextFields/InputField/TextField.swift @@ -10,6 +10,7 @@ import UIKit import Combine import VDSCoreTokens +@objcMembers @objc(VDSTextField) open class TextField: UITextField, ViewProtocol, Errorable { @@ -97,19 +98,22 @@ open class TextField: UITextField, ViewProtocol, Errorable { //-------------------------------------------------- // MARK: - Lifecycle //-------------------------------------------------- - open func initialSetup() { + private func initialSetup() { if !initialSetupPerformed { initialSetupPerformed = true - backgroundColor = .clear - translatesAutoresizingMaskIntoConstraints = false - setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - clipsToBounds = true + shouldUpdateView = false setup() + setDefaults() + shouldUpdateView = true setNeedsUpdate() } } open func setup() { + translatesAutoresizingMaskIntoConstraints = false + setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + clipsToBounds = true + let accessView = UIView(frame: .init(origin: .zero, size: .init(width: UIScreen.main.bounds.width, height: 44))) accessView.backgroundColor = .white accessView.addBorder(side: .top, width: 1, color: .lightGray) @@ -123,6 +127,17 @@ open class TextField: UITextField, ViewProtocol, Errorable { inputAccessoryView = accessView } + open func setDefaults() { + backgroundColor = .clear + surface = .light + text = nil + formatText = nil + useScaledFont = false + showError = false + errorText = nil + textStyle = .defaultStyle + } + @objc func doneButtonAction() { // Resigns the first responder status when 'Done' is tapped let _ = resignFirstResponder() @@ -173,8 +188,7 @@ open class TextField: UITextField, ViewProtocol, Errorable { open func reset() { shouldUpdateView = false - surface = .light - text = nil + setDefaults() shouldUpdateView = true setNeedsUpdate() } diff --git a/VDS/Components/TextFields/Rules/RequiredRule.swift b/VDS/Components/TextFields/Rules/RequiredRule.swift index df00bc59..d4296036 100644 --- a/VDS/Components/TextFields/Rules/RequiredRule.swift +++ b/VDS/Components/TextFields/Rules/RequiredRule.swift @@ -7,12 +7,14 @@ import Foundation -class RequiredRule: Rule { +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 } + func isValid(value: ValueType?) -> 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 f8a9a6b3..fc246e2d 100644 --- a/VDS/Components/TextFields/TextArea/TextArea.swift +++ b/VDS/Components/TextFields/TextArea/TextArea.swift @@ -12,8 +12,9 @@ import Combine /// A text area is an input wherein a customer enters long-form information. /// Use a text area when you want customers to enter text that’s longer than a single line. +@objcMembers @objc(VDSTextArea) -open class TextArea: EntryFieldBase { +open class TextArea: EntryFieldBase { //-------------------------------------------------- // MARK: - Initializers //-------------------------------------------------- @@ -111,6 +112,8 @@ open class TextArea: EntryFieldBase { } didSet { + setNeedsUpdate() + if textView.isFirstResponder { validate() } @@ -163,14 +166,19 @@ open class TextArea: EntryFieldBase { bottomContainerStackView.spacing = VDSLayout.space2X } + + open override func setDefaults() { + super.setDefaults() + minHeight = .twoX + maxLength = nil + textView.text = "" + characterCounterLabel.textStyle = .bodySmall + } /// Resets to default settings. open override func reset() { - super.reset() - textView.text = "" characterCounterLabel.reset() - characterCounterLabel.textStyle = .bodySmall - setNeedsUpdate() + super.reset() } /// Used to make changes to the View based off a change events or from local properties. @@ -191,8 +199,9 @@ open class TextArea: EntryFieldBase { override func updateRules() { super.updateRules() - - rules.append(.init(countRule)) + if let maxLength, maxLength > 0 { + rules.append(.init(countRule)) + } } open override func getFieldContainer() -> UIView { diff --git a/VDS/Components/TextFields/TextArea/TextView.swift b/VDS/Components/TextFields/TextArea/TextView.swift index 3c5dc5ee..b8098a49 100644 --- a/VDS/Components/TextFields/TextArea/TextView.swift +++ b/VDS/Components/TextFields/TextArea/TextView.swift @@ -10,6 +10,7 @@ import UIKit import Combine import VDSCoreTokens +@objcMembers @objc(VDSTextView) open class TextView: UITextView, ViewProtocol, Errorable { @@ -106,17 +107,20 @@ open class TextView: UITextView, ViewProtocol, Errorable { //-------------------------------------------------- // MARK: - Lifecycle //-------------------------------------------------- - open func initialSetup() { + private func initialSetup() { if !initialSetupPerformed { initialSetupPerformed = true - backgroundColor = .clear - translatesAutoresizingMaskIntoConstraints = false + shouldUpdateView = false setup() + setDefaults() + shouldUpdateView = true setNeedsUpdate() } } open func setup() { + translatesAutoresizingMaskIntoConstraints = false + let accessView = UIView(frame: .init(origin: .zero, size: .init(width: UIScreen.main.bounds.width, height: 44))) accessView.backgroundColor = .white accessView.addBorder(side: .top, width: 1, color: .lightGray) @@ -133,6 +137,15 @@ open class TextView: UITextView, ViewProtocol, Errorable { placeholderLabel.pinToSuperView() } + open func setDefaults() { + backgroundColor = .clear + surface = .light + text = nil + placeholder = nil + errorText = nil + showError = false + } + @objc func doneButtonAction() { // Resigns the first responder status when 'Done' is tapped resignFirstResponder() @@ -152,8 +165,7 @@ open class TextView: UITextView, ViewProtocol, Errorable { open func reset() { shouldUpdateView = false - surface = .light - text = nil + setDefaults() shouldUpdateView = true setNeedsUpdate() } diff --git a/VDS/Components/TileContainer/TileContainer.swift b/VDS/Components/TileContainer/TileContainer.swift index 6eeaf316..b00d0388 100644 --- a/VDS/Components/TileContainer/TileContainer.swift +++ b/VDS/Components/TileContainer/TileContainer.swift @@ -10,11 +10,12 @@ import VDSCoreTokens import UIKit import Combine +@objcMembers @objc(VDSTileContainer) open class TileContainer: TileContainerBase { /// Enum used to describe the padding choices used for this component. - public enum Padding: DefaultValuing { + public enum Padding: DefaultValuing, Valuing { case padding3X case padding4X case padding6X @@ -43,7 +44,7 @@ open class TileContainer: TileContainerBase { } } -open class TileContainerBase: Control where PaddingType.ValueType == CGFloat { +open class TileContainerBase: View where PaddingType.ValueType == CGFloat { //-------------------------------------------------- // MARK: - Initializers @@ -74,7 +75,7 @@ open class TileContainerBase: Control where Padding case custom(UIColor) private var reflectedValue: String { String(reflecting: self) } - + public static func == (lhs: Self, rhs: Self) -> Bool { lhs.reflectedValue == rhs.reflectedValue } @@ -86,7 +87,7 @@ open class TileContainerBase: Control where Padding case gradient(UIColor, UIColor) case none } - + /// Enum used to describe the aspect ratios used for this component. public enum AspectRatio: String, CaseIterable { case ratio1x1 = "1:1" @@ -109,7 +110,7 @@ open class TileContainerBase: Control where Padding $0.contentMode = .scaleAspectFill $0.clipsToBounds = true } - + open var containerView = View().with { $0.setContentHuggingPriority(.defaultLow, for: .horizontal) $0.setContentHuggingPriority(.defaultLow, for: .vertical) @@ -117,6 +118,8 @@ open class TileContainerBase: Control where Padding $0.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) } + private var isHighlighted: Bool = false { didSet { setNeedsUpdate() } } + //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- @@ -125,27 +128,27 @@ open class TileContainerBase: Control where Padding /// This is the container in which views will be pinned. open var contentView = View() - + /// This is the view used to show the high light color for a onClick. open var highlightView = View().with { $0.isUserInteractionEnabled = false } - + /// This controls the aspect ratio for the component. open var aspectRatio: AspectRatio = .ratio1x1 { didSet { setNeedsUpdate() } } - + /// Sets the background color for the component. open var color: BackgroundColor? { didSet { setNeedsUpdate() } } /// Sets the background effect for the component. open var backgroundEffect: BackgroundEffect = .none { didSet { setNeedsUpdate() } } - + /// Sets the inside padding for the component open var padding: PaddingType = PaddingType.defaultValue { didSet { setNeedsUpdate() } } /// Applies a background color if backgroundImage prop fails or has trouble loading. open var imageFallbackColor: Surface = .light { didSet { setNeedsUpdate() } } - + private var _width: CGFloat? /// Sets the width for the component. Accepts a pixel value. open var width: CGFloat? { @@ -159,7 +162,7 @@ open class TileContainerBase: Control where Padding setNeedsUpdate() } } - + private var _height: CGFloat? /// Sets the height for the component. Accepts a pixel value. open var height: CGFloat? { @@ -179,13 +182,14 @@ open class TileContainerBase: Control where Padding /// Determines if there is a drop shadow or not. open var showDropShadow: Bool = false { didSet { setNeedsUpdate() } } - + //-------------------------------------------------- // MARK: - Constraints //-------------------------------------------------- internal var widthConstraint: NSLayoutConstraint? internal var heightConstraint: NSLayoutConstraint? - + internal var aspectRatioConstraint: NSLayoutConstraint? + //-------------------------------------------------- // MARK: - Configuration //-------------------------------------------------- @@ -228,13 +232,13 @@ open class TileContainerBase: Control where Padding containerView.addSubview(backgroundImageView) backgroundImageView.pinToSuperView() - + containerView.addSubview(contentView) contentView.pinToSuperView() - + containerView.addSubview(highlightView) highlightView.pinToSuperView() - + widthConstraint = widthAnchor.constraint(equalToConstant: 0).deactivate() heightConstraint = heightAnchor.constraint(equalToConstant: 0).deactivate() @@ -266,7 +270,7 @@ open class TileContainerBase: Control where Padding setNeedsUpdate() } }.store(in: &subscribers) - + } /// Overriden to take the hit if there is an onClickSubscriber and the view is not a UIControl @@ -277,21 +281,20 @@ open class TileContainerBase: Control where Padding return view } - /// Resets to default settings. - open override func reset() { - super.reset() - shouldUpdateView = false + open override func setDefaults() { + super.setDefaults() + backgroundImage = nil color = .white - aspectRatio = .none + backgroundEffect = .none + padding = .defaultValue + aspectRatio = .ratio1x1 imageFallbackColor = .light width = nil height = nil showBorder = false showDropShadow = false - shouldUpdateView = true - setNeedsUpdate() } - + /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { super.updateView() @@ -301,13 +304,14 @@ open class TileContainerBase: Control where Padding containerView.layer.borderColor = borderColorConfiguration.getColor(self).cgColor containerView.layer.borderWidth = showBorder ? VDSFormControls.borderWidth : 0 - + contentView.removeConstraints() contentView.pinToSuperView(.uniform(padding.value)) updateContainerView() + } - + open override var accessibilityElements: [Any]? { get { var items = [Any]() @@ -328,16 +332,37 @@ open class TileContainerBase: Control where Padding //append all children that are accessible items.append(contentsOf: elements) - + return items } set {} } + open override func touchesBegan(_ touches: Set, with event: UIEvent?) { + super.touchesBegan(touches, with: event) + if let onClickSubscriber { + isHighlighted = true + } + } + + open override func touchesEnded(_ touches: Set, with event: UIEvent?) { + super.touchesEnded(touches, with: event) + if let onClickSubscriber { + isHighlighted = false + } + } + + open override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + super.touchesCancelled(touches, with: event) + if let onClickSubscriber { + isHighlighted = false + } + } + //-------------------------------------------------- // MARK: - Public Methods //-------------------------------------------------- - + /// This will place a view within the contentView of this component. public func addContentView(_ view: UIView, shouldPin: Bool = true) { view.removeFromSuperview() @@ -346,7 +371,7 @@ open class TileContainerBase: Control where Padding view.pinToSuperView() } } - + //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- @@ -379,55 +404,10 @@ open class TileContainerBase: Control where Padding containerView.backgroundColor = color.withAlphaComponent(alphaConfiguration) } } - - private func ratioSize(for width: CGFloat) -> CGSize { - var height: CGFloat = width - switch aspectRatio { - case .ratio1x1: - break; - case .ratio3x4: - height = (4 / 3) * width - case .ratio4x3: - height = (3 / 4) * width - case .ratio2x3: - height = (3 / 2) * width - case .ratio3x2: - height = (2 / 3) * width - case .ratio9x16: - height = (16 / 9) * width - case .ratio16x9: - height = (9 / 16) * width - case .ratio1x2: - height = (2 / 1) * width - case .ratio2x1: - height = (1 / 2) * width - - default: - break - } - - return CGSize(width: width, height: height) - } - - private func sizeContainerView(width: CGFloat? = nil, height: CGFloat? = nil) { - if let width, width > 0 { - widthConstraint?.constant = width - widthConstraint?.activate() - } - - if let height, height > 0 { - heightConstraint?.constant = height - heightConstraint?.activate() - } - } - private func updateContainerView() { applyBackgroundEffects() - - widthConstraint?.deactivate() - heightConstraint?.deactivate() - + if showDropShadow, surface == .light { containerView.addDropShadow(dropShadowConfiguration) } else { @@ -436,50 +416,101 @@ open class TileContainerBase: Control where Padding containerView.dropShadowLayers?.forEach { $0.frame = containerView.bounds } containerView.gradientLayers?.forEach { $0.frame = containerView.bounds } + + //sizing the container with constraints + + //Set local vars + var containerViewWidth: CGFloat? = width + let containerViewHeight: CGFloat? = height + let multiplier = aspectRatio.multiplier - if width != nil || height != nil { - var containerViewWidth: CGFloat? - var containerViewHeight: CGFloat? - //run logic to determine which to activate - if let width, aspectRatio == .none && height == nil{ - containerViewWidth = width - - } else if let height, aspectRatio == .none && width == nil{ - containerViewHeight = height - - } else if let height, let width { - containerViewWidth = width - containerViewHeight = height - - } else if let width { - let size = ratioSize(for: width) - containerViewWidth = size.width - containerViewHeight = size.height + //turn off the constraints + aspectRatioConstraint?.deactivate() + widthConstraint?.deactivate() + heightConstraint?.deactivate() - } else if let height { - let size = ratioSize(for: height) - containerViewWidth = size.width - containerViewHeight = size.height - } - sizeContainerView(width: containerViewWidth, height: containerViewHeight) + //------------------------------------------------------------------------- + //Overriding Nil Width Rules + //------------------------------------------------------------------------- + //Rule 1: + //In the scenario where we only have a height but the multiplie is nil, we + //want to set the width with the parent's width which will more or less "fill" + //the container horizontally + //- height is set + //- width is not set + //- aspectRatio is not set + if let superviewWidth, superviewWidth > 0, + containerViewHeight != nil, + containerViewWidth == nil, + multiplier == nil { + containerViewWidth = superviewWidth + } + + //Rule 2: + //In the scenario where no width and height is set, want to set the width with the + //parent's width which will more or less "fill" the container horizontally + //- height is not set + //- width is not set + else if let superviewWidth, superviewWidth > 0, + containerViewWidth == nil, + containerViewHeight == nil { + containerViewWidth = superviewWidth + } + //------------------------------------------------------------------------- + + + //------------------------------------------------------------------------- + //Width + AspectRatio Constraint + //------------------------------------------------------------------------- + if let containerViewWidth, + let multiplier, + containerViewWidth > 0, + containerViewHeight == nil { + widthConstraint?.constant = containerViewWidth + widthConstraint?.activate() + aspectRatioConstraint = heightAnchor.constraint(equalTo: widthAnchor, multiplier: multiplier) + aspectRatioConstraint?.activate() + + } + //------------------------------------------------------------------------- + //Height + AspectRatio Constraint + //------------------------------------------------------------------------- + else if let containerViewHeight, + let multiplier, + containerViewHeight > 0, + containerViewWidth == nil { + heightConstraint?.constant = containerViewHeight + heightConstraint?.activate() + aspectRatioConstraint = widthAnchor.constraint(equalTo: heightAnchor, multiplier: multiplier) + aspectRatioConstraint?.activate() + } else { - if let parentSize = horizontalPinnedSize() { - - var containerViewWidth: CGFloat? - var containerViewHeight: CGFloat? - - let size = ratioSize(for: parentSize.width) - if aspectRatio == .none { - containerViewWidth = size.width - } else { - containerViewWidth = size.width - containerViewHeight = size.height - } - - sizeContainerView(width: containerViewWidth, height: containerViewHeight) + + //------------------------------------------------------------------------- + //Width Constraint + //------------------------------------------------------------------------- + if let containerViewWidth, + containerViewWidth > 0 { + widthConstraint?.constant = containerViewWidth + widthConstraint?.activate() + } + + //------------------------------------------------------------------------- + //Height Constraint + //------------------------------------------------------------------------- + if let containerViewHeight, + containerViewHeight > 0 { + heightConstraint?.constant = containerViewHeight + heightConstraint?.activate() } } } + + /// This is the size of the superview's allowed space for this container first by constrained size which would include padding/inset values an + private var superviewWidth: CGFloat? { + horizontalPinnedWidth() ?? superview?.frame.size.width + } + } extension TileContainerBase { @@ -519,3 +550,30 @@ extension TileContainerBase { } } } + +extension TileContainerBase.AspectRatio { + var multiplier: CGFloat? { + switch self { + case .ratio1x1: + return 1 + case .ratio3x4: + return 4 / 3 + case .ratio4x3: + return 3 / 4 + case .ratio2x3: + return 3 / 2 + case .ratio3x2: + return 2 / 3 + case .ratio9x16: + return 16 / 9 + case .ratio16x9: + return 9 / 16 + case .ratio1x2: + return 2 / 1 + case .ratio2x1: + return 1 / 2 + case .none: + return nil + } + } +} diff --git a/VDS/Components/Tilelet/Tilelet.swift b/VDS/Components/Tilelet/Tilelet.swift index e52332ff..cb154056 100644 --- a/VDS/Components/Tilelet/Tilelet.swift +++ b/VDS/Components/Tilelet/Tilelet.swift @@ -15,11 +15,12 @@ import Combine /// support quick scanning and engagement. A Tilelet is fully clickable and /// while it can include an arrow CTA, it does not require one in order to /// function. +@objcMembers @objc(VDSTilelet) open class Tilelet: TileContainerBase { /// Enum used to describe the padding choices used for this component. - public enum Padding: String, DefaultValuing, CaseIterable { + public enum Padding: String, DefaultValuing, Valuing, CaseIterable { case small case large @@ -378,6 +379,22 @@ open class Tilelet: TileContainerBase { titleLockupSubTitleLabelHeightGreaterThanConstraint?.priority = .defaultHigh titleLockupSubTitleLabelHeightGreaterThanConstraint?.activate() + } + + open override func setDefaults() { + super.setDefaults() + aspectRatio = .none + color = .black + textWidth = nil + textPostion = .top + + //models + badgeModel = nil + titleModel = nil + subTitleModel = nil + descriptiveIconModel = nil + directionalIconModel = nil + directionalIcon.bridge_accessibilityLabelBlock = { [weak self] in guard let self, let directionalIconModel else { return nil } return directionalIconModel.accessibleText @@ -389,22 +406,6 @@ open class Tilelet: TileContainerBase { } } - /// Resets to default settings. - open override func reset() { - shouldUpdateView = false - super.reset() - aspectRatio = .none - color = .black - //models - badgeModel = nil - titleModel = nil - subTitleModel = nil - descriptiveIconModel = nil - directionalIconModel = nil - shouldUpdateView = true - setNeedsUpdate() - } - /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { super.updateView() diff --git a/VDS/Components/TitleLockup/TitleLockup.swift b/VDS/Components/TitleLockup/TitleLockup.swift index bc5c4c3a..1711939c 100644 --- a/VDS/Components/TitleLockup/TitleLockup.swift +++ b/VDS/Components/TitleLockup/TitleLockup.swift @@ -12,6 +12,7 @@ import Combine /// Title Lockup ensures the readability of words on the screen /// with approved built in text size configurations. +@objcMembers @objc(VDSTitleLockup) open class TitleLockup: View { @@ -279,18 +280,14 @@ open class TitleLockup: View { set {} } - /// Resets to default settings. - open override func reset() { - super.reset() - shouldUpdateView = false + open override func setDefaults() { + super.setDefaults() + textAlignment = .left eyebrowModel = nil titleModel = nil subTitleModel = nil - shouldUpdateView = true - setNeedsUpdate() } - var labelViews = [UIView]() /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { super.updateView() diff --git a/VDS/Components/Toggle/Toggle.swift b/VDS/Components/Toggle/Toggle.swift index aaa411de..304b18ce 100644 --- a/VDS/Components/Toggle/Toggle.swift +++ b/VDS/Components/Toggle/Toggle.swift @@ -12,6 +12,7 @@ import Combine /// A toggle is a control that lets customers instantly turn on /// or turn off a single option, setting or function. +@objcMembers @objc(VDSToggle) open class Toggle: Control, Changeable, FormFieldable { @@ -54,6 +55,7 @@ open class Toggle: Control, Changeable, FormFieldable { private var leftConstraints: [NSLayoutConstraint] = [] private var rightConstraints: [NSLayoutConstraint] = [] private var labelConstraints: [NSLayoutConstraint] = [] + private var toggleConstraints: [NSLayoutConstraint] = [] //-------------------------------------------------- // MARK: - Configuration @@ -94,7 +96,7 @@ open class Toggle: Control, Changeable, FormFieldable { open var toggleView = ToggleView().with { $0.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) $0.isUserInteractionEnabled = false - $0.isAccessibilityElement = false + $0.isAccessibilityElement = false } /// Used in showing the on/off text. @@ -147,35 +149,15 @@ open class Toggle: Control, Changeable, FormFieldable { open var value: AnyHashable? { isOn } - /// The natural size for the receiving view, considering only properties of the view itself. - open override var intrinsicContentSize: CGSize { - if showLabel { - label.sizeToFit() - let size = CGSize(width: label.frame.width + spacingBetween + toggleContainerSize.width, - height: max(toggleContainerSize.height, label.frame.height)) - return size - } else { - return toggleContainerSize - } - } - open override var shouldHighlight: Bool { false } //-------------------------------------------------- // MARK: - Overrides - //-------------------------------------------------- - /// Executed on initialization for this View. - open override func initialSetup() { - super.initialSetup() - onClick = { control in - control.toggle() - } - } - + //-------------------------------------------------- /// 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 = true if #available(iOS 17.0, *) { accessibilityTraits = .toggleButton @@ -207,6 +189,59 @@ open class Toggle: Control, Changeable, FormFieldable { label.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor) ] + // Set content hugging priority + setContentHuggingPriority(.required, for: .horizontal) + + isAccessibilityElement = true + if #available(iOS 17.0, *) { + accessibilityTraits = .toggleButton + } else { + accessibilityTraits = .button + } + addSubview(label) + addSubview(toggleView) + + // Set up initial constraints for label and switch + toggleView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true + + //toggle + toggleConstraints = [ + toggleView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor), + toggleView.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor) + ] + + //toggle and label variants + labelConstraints = [ + height(constant: toggleContainerSize.height, priority: .defaultLow), + heightGreaterThanEqualTo(constant: toggleContainerSize.height, priority: .defaultHigh), + label.topAnchor.constraint(equalTo: topAnchor), + label.bottomAnchor.constraint(equalTo: bottomAnchor), + ] + + //label-toggle + leftConstraints = [ + label.leadingAnchor.constraint(equalTo: leadingAnchor), + toggleView.leadingAnchor.constraint(equalTo: label.trailingAnchor, constant: spacingBetween), + toggleView.trailingAnchor.constraint(equalTo: trailingAnchor) + ] + + //toggle-label + rightConstraints = [ + toggleView.leadingAnchor.constraint(equalTo: leadingAnchor), + label.leadingAnchor.constraint(equalTo: toggleView.trailingAnchor, constant: spacingBetween), + label.trailingAnchor.constraint(equalTo: trailingAnchor) + ] + + } + + open override func setDefaults() { + super.setDefaults() + + onClick = { [weak self] _ in + guard let self else { return } + toggle() + } + bridge_accessibilityValueBlock = { [weak self] in guard let self else { return "" } if showText { @@ -215,13 +250,7 @@ open class Toggle: Control, Changeable, FormFieldable { return isSelected ? "On" : "Off" } } - } - - /// Resets to default settings. - open override func reset() { - super.reset() - shouldUpdateView = false - label.reset() + isEnabled = true isOn = false isAnimated = true @@ -233,8 +262,12 @@ open class Toggle: Control, Changeable, FormFieldable { textPosition = .left inputId = nil onChange = nil - shouldUpdateView = true - setNeedsUpdate() + } + + /// Resets to default settings. + open override func reset() { + label.reset() + super.reset() } /// Used to make changes to the View based off a change events or from local properties. @@ -260,6 +293,8 @@ open class Toggle: Control, Changeable, FormFieldable { label.isHidden = !showLabel if showLabel { + NSLayoutConstraint.deactivate(toggleConstraints) + label.textAlignment = textPosition == .left ? .right : .left label.textStyle = textStyle label.text = statusText @@ -278,6 +313,7 @@ open class Toggle: Control, Changeable, FormFieldable { NSLayoutConstraint.deactivate(leftConstraints) NSLayoutConstraint.deactivate(rightConstraints) NSLayoutConstraint.deactivate(labelConstraints) + NSLayoutConstraint.activate(toggleConstraints) } } } diff --git a/VDS/Components/Toggle/ToggleView.swift b/VDS/Components/Toggle/ToggleView.swift index dc5e9570..2ecfd44a 100644 --- a/VDS/Components/Toggle/ToggleView.swift +++ b/VDS/Components/Toggle/ToggleView.swift @@ -12,6 +12,7 @@ import Combine /// A toggle is a control that lets customers instantly turn on /// or turn off a single option, setting or function. +@objcMembers @objc(VDSToggleView) open class ToggleView: Control, Changeable, FormFieldable { @@ -104,18 +105,10 @@ open class ToggleView: Control, Changeable, FormFieldable { //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- - /// Executed on initialization for this View. - open override func initialSetup() { - super.initialSetup() - onClick = { control in - control.toggle() - } - } - /// 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 = true if #available(iOS 17.0, *) { accessibilityTraits = .toggleButton @@ -156,20 +149,21 @@ open class ToggleView: Control, Changeable, FormFieldable { accessibilityLabel = "Toggle" } - /// Resets to default settings. - open override func reset() { - super.reset() - shouldUpdateView = false + open override func setDefaults() { + super.setDefaults() isOn = false isAnimated = true inputId = nil toggleView.backgroundColor = toggleColorConfiguration.getColor(self) knobView.backgroundColor = knobColorConfiguration.getColor(self) - onChange = nil - shouldUpdateView = true - setNeedsUpdate() + onChange = nil + + onClick = { [weak self] _ in + guard let self else { return } + toggle() + } } - + /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { super.updateView() diff --git a/VDS/Components/Tooltip/Tooltip.swift b/VDS/Components/Tooltip/Tooltip.swift index f07fb1be..1c2e2061 100644 --- a/VDS/Components/Tooltip/Tooltip.swift +++ b/VDS/Components/Tooltip/Tooltip.swift @@ -13,6 +13,7 @@ import Combine /// A tooltip is an overlay that clarifies another component or content /// element. It is triggered when a customer hovers, clicks or taps /// the tooltip icon. +@objcMembers @objc(VDSTooltip) open class Tooltip: Control, TooltipLaunchable { @@ -128,6 +129,16 @@ open class Tooltip: Control, TooltipLaunchable { isAccessibilityElement = true accessibilityTraits = .button + } + + open override func setDefaults() { + super.setDefaults() + closeButtonText = "Close" + fillColor = .primary + size = .medium + title = nil + content = nil + contentView = nil onClick = { [weak self] tooltip in guard let self else { return} @@ -157,19 +168,7 @@ open class Tooltip: Control, TooltipLaunchable { return isEnabled ? "Double tap to open." : "" } } - - /// Resets to default settings. - open override func reset() { - super.reset() - shouldUpdateView = false - size = .medium - title = "" - content = "" - fillColor = .primary - closeButtonText = "Close" - shouldUpdateView = true - setNeedsUpdate() - } + /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { diff --git a/VDS/Components/Tooltip/TooltipAlertViewController.swift b/VDS/Components/Tooltip/TooltipAlertViewController.swift index 043f07f4..0fb3d69c 100644 --- a/VDS/Components/Tooltip/TooltipAlertViewController.swift +++ b/VDS/Components/Tooltip/TooltipAlertViewController.swift @@ -10,6 +10,8 @@ import UIKit import Combine import VDSCoreTokens +@objcMembers +@objc(VDSTooltipAlertViewController) open class TooltipAlertViewController: UIViewController, Surfaceable { /// Set of Subscribers for any Publishers for this Control. diff --git a/VDS/Components/Tooltip/TooltipDialog.swift b/VDS/Components/Tooltip/TooltipDialog.swift index 0650a808..bb6b5653 100644 --- a/VDS/Components/Tooltip/TooltipDialog.swift +++ b/VDS/Components/Tooltip/TooltipDialog.swift @@ -9,6 +9,8 @@ import Foundation import UIKit import VDSCoreTokens +@objcMembers +@objc(VDSTooltipDialog) open class TooltipDialog: View, UIScrollViewDelegate { //-------------------------------------------------- diff --git a/VDS/Components/Tooltip/TrailingTooltipLabel.swift b/VDS/Components/Tooltip/TrailingTooltipLabel.swift index f1f26abc..8b5011ef 100644 --- a/VDS/Components/Tooltip/TrailingTooltipLabel.swift +++ b/VDS/Components/Tooltip/TrailingTooltipLabel.swift @@ -84,17 +84,13 @@ open class TrailingTooltipLabel: View, TooltipLaunchable { } } - /// Resets to default settings. - open override func reset() { - super.reset() - shouldUpdateView = false + open override func setDefaults() { + super.setDefaults() labelText = nil labelAttributes = nil labelTextStyle = .defaultStyle labelTextAlignment = .left tooltipModel = nil - shouldUpdateView = true - setNeedsUpdate() } } diff --git a/VDS/Protocols/Clickable.swift b/VDS/Protocols/Clickable.swift index c2fc9056..fe6ff10d 100644 --- a/VDS/Protocols/Clickable.swift +++ b/VDS/Protocols/Clickable.swift @@ -9,12 +9,12 @@ import Foundation import UIKit import Combine -public protocol Clickable: ViewProtocol where Self: UIControl { +public protocol Clickable: ViewProtocol { /// Sets the primary Subscriber used for the UIControl event .touchUpInside. var onClickSubscriber: AnyCancellable? { get set } } -extension Clickable { +extension Clickable where Self: UIControl { /// Allows the setting of a completion block against the onClickSubscriber cancellable. This will /// completion block will get executed against the UIControl publisher for the 'touchUpInside' action. public var onClick: ((Self) -> ())? { @@ -23,7 +23,7 @@ extension Clickable { onClickSubscriber?.cancel() if let newValue { onClickSubscriber = publisher(for: .touchUpInside) - .sink { [weak self] c in + .sink { [weak self] c in guard let self, self.isEnabled else { return } newValue(c) } @@ -34,3 +34,24 @@ extension Clickable { } } } + +extension Clickable where Self: UIView { + /// Allows the setting of a completion block against the onClickSubscriber cancellable. This will + /// completion block will get executed against the UIControl publisher for the 'touchUpInside' action. + public var onClick: ((Self) -> ())? { + get { return nil } + set { + onClickSubscriber?.cancel() + if let newValue { + onClickSubscriber = publisher(for: UITapGestureRecognizer()) + .sink { [weak self] _ in + guard let self, self.isEnabled else { return } + newValue(self) + } + } else { + onClickSubscriber = nil + } + setNeedsUpdate() + } + } +} diff --git a/VDS/Protocols/DefaultValuing.swift b/VDS/Protocols/DefaultValuing.swift index ffeaf0eb..7d156053 100644 --- a/VDS/Protocols/DefaultValuing.swift +++ b/VDS/Protocols/DefaultValuing.swift @@ -7,6 +7,6 @@ import Foundation -public protocol DefaultValuing: Valuing { +public protocol DefaultValuing { static var defaultValue: Self { get } } diff --git a/VDS/Protocols/FormFieldable.swift b/VDS/Protocols/FormFieldable.swift index 8a620765..d2067782 100644 --- a/VDS/Protocols/FormFieldable.swift +++ b/VDS/Protocols/FormFieldable.swift @@ -8,7 +8,7 @@ import Foundation /// Protocol used for a FormField object. -public protocol FormFieldable { +public protocol FormFieldable { associatedtype ValueType = AnyHashable /// Unique Id for the Form Field object within a Form. @@ -19,7 +19,10 @@ public protocol FormFieldable { } /// Protocol for FormFieldable that require internal validation. -public protocol FormFieldInternalValidatable: FormFieldable, Errorable { +public protocol FormFieldInternalValidatable: FormFieldable, Errorable { + /// Rules that drive the validator + var rules: [AnyRule] { get set } + /// Is there an internalError var hasInternalError: Bool { get } /// Internal Error Message that will show. @@ -69,16 +72,23 @@ public protocol Rule { func isValid(value: ValueType?) -> Bool /// Error Message to be show if the value is invalid. var errorMessage: String { get } + /// type of rule + var ruleType: String { get } +} + +extension Rule { + public var ruleType: String { "\(Self.self)" } } /// Type Erased Rule for a specific ValueType. public struct AnyRule: Rule { private let _isValid: (ValueType?) -> Bool - + public var ruleType: String public let errorMessage: String public init(_ rule: R) where R.ValueType == ValueType { self._isValid = rule.isValid + self.ruleType = rule.ruleType self.errorMessage = rule.errorMessage } diff --git a/VDS/Protocols/LayoutConstraintable.swift b/VDS/Protocols/LayoutConstraintable.swift index ba08e3c2..bee145ad 100644 --- a/VDS/Protocols/LayoutConstraintable.swift +++ b/VDS/Protocols/LayoutConstraintable.swift @@ -705,11 +705,11 @@ extension LayoutConstraintable { } // Method to check if the view is pinned to its superview - public func isPinnedToSuperview() -> Bool { - isPinnedVerticallyToSuperview() && isPinnedHorizontallyToSuperview() + public func isPinnedEqual() -> Bool { + isPinnedEqualVertically() && isPinnedEqualHorizontally() } - public func horizontalPinnedSize() -> CGSize? { + public func horizontalPinnedWidth() -> CGFloat? { guard let view = self as? UIView, let superview = view.superview else { return nil } let constraints = superview.constraints @@ -735,44 +735,106 @@ extension LayoutConstraintable { if let leadingView = leadingObject as? UIView, let trailingView = trailingObject as? UIView { let leadingPosition = leadingView.convert(leadingView.bounds.origin, to: superview).x let trailingPosition = trailingView.convert(trailingView.bounds.origin, to: superview).x + trailingView.bounds.width - return CGSize(width: trailingPosition - leadingPosition, height: view.bounds.size.height) + return trailingPosition - leadingPosition } else if let leadingGuide = leadingObject as? UILayoutGuide, let trailingGuide = trailingObject as? UILayoutGuide { let leadingPosition = leadingGuide.layoutFrame.minX let trailingPosition = trailingGuide.layoutFrame.maxX - return CGSize(width: trailingPosition - leadingPosition, height: view.bounds.size.height) + return trailingPosition - leadingPosition } else if let leadingView = leadingObject as? UIView, let trailingGuide = trailingObject as? UILayoutGuide { let leadingPosition = leadingView.convert(leadingView.bounds.origin, to: superview).x let trailingPosition = trailingGuide.layoutFrame.maxX - return CGSize(width: trailingPosition - leadingPosition, height: view.bounds.size.height) + return trailingPosition - leadingPosition } else if let leadingGuide = leadingObject as? UILayoutGuide, let trailingView = trailingObject as? UIView { let leadingPosition = leadingGuide.layoutFrame.minX let trailingPosition = trailingView.convert(trailingView.bounds.origin, to: superview).x + trailingView.bounds.width - return CGSize(width: trailingPosition - leadingPosition, height: view.bounds.size.height) + return trailingPosition - leadingPosition } } else if let pinnedObject = leadingPinnedObject { if let view = pinnedObject as? UIView { - return view.bounds.size + return view.bounds.size.width } else if let layoutGuide = pinnedObject as? UILayoutGuide { - return layoutGuide.layoutFrame.size + return layoutGuide.layoutFrame.size.width } } else if let pinnedObject = trailingPinnedObject { if let view = pinnedObject as? UIView { - return view.bounds.size + return view.bounds.size.width } else if let layoutGuide = pinnedObject as? UILayoutGuide { - return layoutGuide.layoutFrame.size + return layoutGuide.layoutFrame.size.width } } return nil } + + public func verticalPinnedHeight() -> CGFloat? { + guard let view = self as? UIView, let superview = view.superview else { return nil } + let constraints = superview.constraints + + var topPinnedObject: AnyObject? + var bottomPinnedObject: AnyObject? + + for constraint in constraints { + if (constraint.firstItem === view && (constraint.firstAttribute == .top || constraint.firstAttribute == .topMargin)) { + topPinnedObject = constraint.secondItem as AnyObject? + } else if (constraint.secondItem === view && (constraint.secondAttribute == .top || constraint.secondAttribute == .topMargin)) { + topPinnedObject = constraint.firstItem as AnyObject? + } else if (constraint.firstItem === view && (constraint.firstAttribute == .bottom || constraint.firstAttribute == .bottomMargin)) { + bottomPinnedObject = constraint.secondItem as AnyObject? + } else if (constraint.secondItem === view && (constraint.secondAttribute == .bottom || constraint.secondAttribute == .bottomMargin)) { + bottomPinnedObject = constraint.firstItem as AnyObject? + } + } + + // Ensure both top and bottom pinned objects are identified + if let topObject = topPinnedObject, let bottomObject = bottomPinnedObject { + + // Calculate the size based on the pinned objects + if let topView = topObject as? UIView, let bottomView = bottomObject as? UIView { + let topPosition = topView.convert(topView.bounds.origin, to: superview).y + let bottomPosition = bottomView.convert(bottomView.bounds.origin, to: superview).y + bottomView.bounds.height + return bottomPosition - topPosition + + } else if let topGuide = topObject as? UILayoutGuide, let bottomGuide = bottomObject as? UILayoutGuide { + let topPosition = topGuide.layoutFrame.minY + let bottomPosition = bottomGuide.layoutFrame.maxY + return bottomPosition - topPosition + + } else if let topView = topObject as? UIView, let bottomGuide = bottomObject as? UILayoutGuide { + let topPosition = topView.convert(topView.bounds.origin, to: superview).y + let bottomPosition = bottomGuide.layoutFrame.maxY + return bottomPosition - topPosition + + } else if let topGuide = topObject as? UILayoutGuide, let bottomView = bottomObject as? UIView { + let topPosition = topGuide.layoutFrame.minY + let bottomPosition = bottomView.convert(bottomView.bounds.origin, to: superview).y + bottomView.bounds.height + return bottomPosition - topPosition + } + + } else if let pinnedObject = topPinnedObject { + if let view = pinnedObject as? UIView { + return view.bounds.size.height + } else if let layoutGuide = pinnedObject as? UILayoutGuide { + return layoutGuide.layoutFrame.size.height + } + + } else if let pinnedObject = bottomPinnedObject { + if let view = pinnedObject as? UIView { + return view.bounds.size.height + } else if let layoutGuide = pinnedObject as? UILayoutGuide { + return layoutGuide.layoutFrame.size.height + } + } + + return nil + } - public func isPinnedHorizontallyToSuperview() -> Bool { + public func isPinnedEqualHorizontally() -> Bool { guard let view = self as? UIView, let superview = view.superview else { return false } let constraints = superview.constraints var leadingPinned = false @@ -796,7 +858,7 @@ extension LayoutConstraintable { return leadingPinned && trailingPinned } - public func isPinnedVerticallyToSuperview() -> Bool { + public func isPinnedEqualVertically() -> Bool { guard let view = self as? UIView, let superview = view.superview else { return false } let constraints = superview.constraints var topPinned = false diff --git a/VDS/Protocols/ViewProtocol.swift b/VDS/Protocols/ViewProtocol.swift index c7cba091..9a509dc1 100644 --- a/VDS/Protocols/ViewProtocol.swift +++ b/VDS/Protocols/ViewProtocol.swift @@ -19,12 +19,12 @@ public protocol ViewProtocol: AnyObject, Initable, Resettable, Enabling, Surface /// Used for setting an implementation for the default Accessible Action var accessibilityAction: ((Self) -> Void)? { get set } - /// Executed on initialization for this View. - func initialSetup() - /// Called once when a view is initialized and is used to Setup additional UI or other constants and configurations. func setup() - + + /// Default configurations for values and properties. This is called in the setup() and reset(). + func setDefaults() + /// Used to make changes to the View based off a change events or from local properties. func updateView() diff --git a/VDS/SupportingFiles/ReleaseNotes.txt b/VDS/SupportingFiles/ReleaseNotes.txt index 414bd6c3..ec14a5f7 100644 --- a/VDS/SupportingFiles/ReleaseNotes.txt +++ b/VDS/SupportingFiles/ReleaseNotes.txt @@ -1,9 +1,18 @@ +1.0.72 +---------------- +- ONEAPP-9311 - InputStepper - Finished +- ONEAPP-9314 - PriceLockup - Finished +- CXTDT-599736 - All classes refactored workflow setup(), reset(), setDefaults() + 1.0.71 ---------------- +- CXTDT-581800 - DatePicker - Selected Error state icon +- CXTDT-581801 - DatePicker - border disappears for on dark focus state - CXTDT-581803 - DatePicker - Calendar does not switch to Dark Mode - CXTDT-584278 – InputField - Accessibility -- CXTDT-586375 - Table - Default to compact padding, when striped is selected. -- CXTDT-586497 - Table - Contents alignment based on the row/header +- CXTDT-586375 - Table - Issue With Stripe +- CXTDT-577463 - InputField - Accessibility - #7 +- CXTDT-565796 - DropdownSelect – Removed the "Type" from the VoiceOver 1.0.70 ---------------- diff --git a/VDS/VDS.docc/VDS.md b/VDS/VDS.docc/VDS.md index d145ea0a..0a494234 100755 --- a/VDS/VDS.docc/VDS.md +++ b/VDS/VDS.docc/VDS.md @@ -26,18 +26,21 @@ Using the system allows designers and developers to collaborate more easily and - ``ButtonIcon`` - ``ButtonGroup`` - ``CalendarBase`` +- ``Carousel`` - ``CarouselScrollbar`` - ``Checkbox`` - ``CheckboxItem`` - ``CheckboxGroup`` - ``DropdownSelect`` - ``Icon`` +- ``InputStepper`` - ``InputField`` - ``Label`` - ``Line`` - ``Loader`` - ``Notification`` - ``Pagination`` +- ``PriceLockup`` - ``RadioBoxItem`` - ``RadioBoxGroup`` - ``RadioButton``