diff --git a/VDS.xcodeproj/project.pbxproj b/VDS.xcodeproj/project.pbxproj index d0a6b550..301c60d0 100644 --- a/VDS.xcodeproj/project.pbxproj +++ b/VDS.xcodeproj/project.pbxproj @@ -10,16 +10,23 @@ 1808BEBC2BA41C3200129230 /* CarouselScrollbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1808BEBB2BA41C3200129230 /* CarouselScrollbar.swift */; }; 1808BEC02BA456B700129230 /* CarouselScrollbarChangeLog.txt in Resources */ = {isa = PBXBuildFile; fileRef = 1808BEBF2BA456B700129230 /* CarouselScrollbarChangeLog.txt */; }; 1832AC572BA0791D008AE476 /* BreadcrumbCellItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1832AC562BA0791D008AE476 /* BreadcrumbCellItem.swift */; }; + 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 */; }; 18450CF12BA1B19C009FDF2A /* BreadcrumbsChangeLog.txt in Resources */ = {isa = PBXBuildFile; fileRef = 18450CF02BA1B19C009FDF2A /* BreadcrumbsChangeLog.txt */; }; 1855EC662BAABF2A002ACAC2 /* BreadcrumbItemModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1855EC652BAABF2A002ACAC2 /* BreadcrumbItemModel.swift */; }; 186B2A8A2B88DA7F001AB71F /* TextAreaChangeLog.txt in Resources */ = {isa = PBXBuildFile; fileRef = 186B2A892B88DA7F001AB71F /* TextAreaChangeLog.txt */; }; 186D13CB2BBA8B1500986B53 /* DropdownSelect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 186D13CA2BBA8B1500986B53 /* DropdownSelect.swift */; }; 186D13CF2BBC36EF00986B53 /* DropdownSelectChangeLog.txt in Resources */ = {isa = PBXBuildFile; fileRef = 186D13CE2BBC36EE00986B53 /* DropdownSelectChangeLog.txt */; }; 18792A902B7431F2008C0D29 /* ButtonIconBadgeIndicatorModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18792A8F2B7431F2008C0D29 /* ButtonIconBadgeIndicatorModel.swift */; }; + 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 */; }; 18B463A42BBD3C46005C4528 /* DropdownOptionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18B463A32BBD3C46005C4528 /* DropdownOptionModel.swift */; }; 18BDEE822B75316E00452358 /* ButtonIconChangeLog.txt in Resources */ = {isa = PBXBuildFile; fileRef = 18BDEE812B75316E00452358 /* ButtonIconChangeLog.txt */; }; + 18FEA1AD2BDD137500A56439 /* CalendarIndicatorModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18FEA1AC2BDD137500A56439 /* CalendarIndicatorModel.swift */; }; + 18FEA1B52BE0E63600A56439 /* Date+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18FEA1B42BE0E63600A56439 /* Date+Extension.swift */; }; + 18FEA1B92BE1301700A56439 /* CalendarChangeLog.txt in Resources */ = {isa = PBXBuildFile; fileRef = 18FEA1B82BE1301700A56439 /* CalendarChangeLog.txt */; }; 440B84CA2BD8E0E9004A732A /* Table.swift in Sources */ = {isa = PBXBuildFile; fileRef = 440B84C92BD8E0E9004A732A /* Table.swift */; }; 443DBAFA2BDA303F0021497E /* TableCellItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 443DBAF92BDA303F0021497E /* TableCellItem.swift */; }; 445BA07829C07B3D0036A7C5 /* Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 445BA07729C07B3D0036A7C5 /* Notification.swift */; }; @@ -59,6 +66,10 @@ EA21C5DB2B600EDE00CFC139 /* VDSTokens.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = EA21C5DA2B600EDD00CFC139 /* VDSTokens.xcframework */; }; EA297A5529FB07760031ED56 /* TooltipLabelAttribute.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA297A5429FB07760031ED56 /* TooltipLabelAttribute.swift */; }; EA297A5729FB0A360031ED56 /* AppleGuidelinesTouchable.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA297A5629FB0A360031ED56 /* AppleGuidelinesTouchable.swift */; }; + EA2DC9B02BE175BA004F58C5 /* RequiredRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2DC9AF2BE175BA004F58C5 /* RequiredRule.swift */; }; + EA2DC9B22BE175E6004F58C5 /* CharacterCountRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2DC9B12BE175E6004F58C5 /* CharacterCountRule.swift */; }; + EA2DC9B42BE2C6FE004F58C5 /* TextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2DC9B32BE2C6FE004F58C5 /* TextField.swift */; }; + EA2DC9B62BE2F4A1004F58C5 /* UITextView+Publisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2DC9B52BE2F4A1004F58C5 /* UITextView+Publisher.swift */; }; EA336171288B19200071C351 /* VDS.docc in Sources */ = {isa = PBXBuildFile; fileRef = EA336170288B19200071C351 /* VDS.docc */; }; EA336177288B19210071C351 /* VDS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EA33616C288B19200071C351 /* VDS.framework */; }; EA33617C288B19210071C351 /* VDSTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA33617B288B19210071C351 /* VDSTests.swift */; }; @@ -96,6 +107,7 @@ EA5F86D02A1F936100BC83E4 /* TabsContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5F86CF2A1F936100BC83E4 /* TabsContainer.swift */; }; EA6642952BCEBF9500D81DC4 /* TextLinkModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA6642942BCEBF9500D81DC4 /* TextLinkModel.swift */; }; EA6F330E2B911E9000BACAB9 /* TextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA6F330D2B911E9000BACAB9 /* TextView.swift */; }; + EA78C7962C00CAC200430AD1 /* Groupable.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA78C7952C00CAC200430AD1 /* Groupable.swift */; }; EA81410B2A0E8E3C004F60D2 /* ButtonIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA81410A2A0E8E3C004F60D2 /* ButtonIcon.swift */; }; EA8141102A127066004F60D2 /* UIColor+VDSColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA81410F2A127066004F60D2 /* UIColor+VDSColor.swift */; }; EA89200428AECF4B006B9984 /* UITextField+Publisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA89200328AECF4B006B9984 /* UITextField+Publisher.swift */; }; @@ -141,6 +153,20 @@ EAB5FEF829393A7200998C17 /* ButtonGroupConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB5FEF729393A7200998C17 /* ButtonGroupConstants.swift */; }; EAB5FF0129424ACB00998C17 /* UIControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB5FF0029424ACB00998C17 /* UIControl.swift */; }; EABFEB642A26473700C4C106 /* NSAttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = EABFEB632A26473700C4C106 /* NSAttributedString.swift */; }; + EAC58BFD2BE935C300BA39FA /* TitleLockupTextColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC58BFC2BE935C300BA39FA /* TitleLockupTextColor.swift */; }; + EAC58C062BED000200BA39FA /* CreditCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC58C052BED000200BA39FA /* CreditCard.swift */; }; + EAC58C082BED002D00BA39FA /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC58C072BED002D00BA39FA /* Date.swift */; }; + EAC58C0A2BED004E00BA39FA /* FieldType.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC58C092BED004E00BA39FA /* FieldType.swift */; }; + EAC58C0C2BED01D500BA39FA /* Telephone.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC58C0B2BED01D500BA39FA /* Telephone.swift */; }; + EAC58C0E2BED021600BA39FA /* Password.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC58C0D2BED021600BA39FA /* Password.swift */; }; + EAC58C122BED0DDD00BA39FA /* Text.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC58C112BED0DDD00BA39FA /* Text.swift */; }; + EAC58C142BED0DEC00BA39FA /* Number.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC58C132BED0DEC00BA39FA /* Number.swift */; }; + EAC58C162BED0E0300BA39FA /* InlineAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC58C152BED0E0300BA39FA /* InlineAction.swift */; }; + EAC58C182BED0E2300BA39FA /* SecurityCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC58C172BED0E2300BA39FA /* SecurityCode.swift */; }; + EAC58C232BF2824200BA39FA /* DatePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC58C222BF2824200BA39FA /* DatePicker.swift */; }; + EAC58C252BF2A7FB00BA39FA /* DatePickerChangeLog.txt in Resources */ = {isa = PBXBuildFile; fileRef = EAC58C242BF2A7FB00BA39FA /* DatePickerChangeLog.txt */; }; + EAC58C272BF4116200BA39FA /* DatePickerCalendarModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC58C262BF4116200BA39FA /* DatePickerCalendarModel.swift */; }; + EAC58C292BF4118C00BA39FA /* DatePickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC58C282BF4118C00BA39FA /* DatePickerViewController.swift */; }; EAC71A1D2A2E155A00E47A9F /* Checkbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC71A1C2A2E155A00E47A9F /* Checkbox.swift */; }; EAC71A1F2A2E173D00E47A9F /* RadioButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC71A1E2A2E173D00E47A9F /* RadioButton.swift */; }; EAC846F3294B95CE00F685BA /* ButtonGroupCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC846F2294B95CE00F685BA /* ButtonGroupCollectionViewCell.swift */; }; @@ -204,16 +230,23 @@ 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 = ""; }; + 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 = ""; }; 18450CF02BA1B19C009FDF2A /* BreadcrumbsChangeLog.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = BreadcrumbsChangeLog.txt; sourceTree = ""; }; 1855EC652BAABF2A002ACAC2 /* BreadcrumbItemModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreadcrumbItemModel.swift; sourceTree = ""; }; 186B2A892B88DA7F001AB71F /* TextAreaChangeLog.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = TextAreaChangeLog.txt; sourceTree = ""; }; 186D13CA2BBA8B1500986B53 /* DropdownSelect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropdownSelect.swift; sourceTree = ""; }; 186D13CE2BBC36EE00986B53 /* DropdownSelectChangeLog.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = DropdownSelectChangeLog.txt; sourceTree = ""; }; 18792A8F2B7431F2008C0D29 /* ButtonIconBadgeIndicatorModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonIconBadgeIndicatorModel.swift; sourceTree = ""; }; + 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 = ""; }; 18B463A32BBD3C46005C4528 /* DropdownOptionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropdownOptionModel.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 = ""; }; + 18FEA1B82BE1301700A56439 /* CalendarChangeLog.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = CalendarChangeLog.txt; sourceTree = ""; }; 440B84C92BD8E0E9004A732A /* Table.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Table.swift; sourceTree = ""; }; 443DBAF92BDA303F0021497E /* TableCellItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableCellItem.swift; sourceTree = ""; }; 445BA07729C07B3D0036A7C5 /* Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notification.swift; sourceTree = ""; }; @@ -253,6 +286,10 @@ EA21C5DA2B600EDD00CFC139 /* VDSTokens.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = VDSTokens.xcframework; path = ../SharedFrameworks/VDSTokens.xcframework; sourceTree = ""; }; EA297A5429FB07760031ED56 /* TooltipLabelAttribute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TooltipLabelAttribute.swift; sourceTree = ""; }; EA297A5629FB0A360031ED56 /* AppleGuidelinesTouchable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleGuidelinesTouchable.swift; sourceTree = ""; }; + EA2DC9AF2BE175BA004F58C5 /* RequiredRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequiredRule.swift; sourceTree = ""; }; + EA2DC9B12BE175E6004F58C5 /* CharacterCountRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterCountRule.swift; sourceTree = ""; }; + EA2DC9B32BE2C6FE004F58C5 /* TextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextField.swift; sourceTree = ""; }; + EA2DC9B52BE2F4A1004F58C5 /* UITextView+Publisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITextView+Publisher.swift"; sourceTree = ""; }; EA33616C288B19200071C351 /* VDS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = VDS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; EA33616F288B19200071C351 /* VDS.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = VDS.h; sourceTree = ""; }; EA336170288B19200071C351 /* VDS.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = VDS.docc; sourceTree = ""; }; @@ -291,6 +328,7 @@ EA5F86CF2A1F936100BC83E4 /* TabsContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabsContainer.swift; sourceTree = ""; }; EA6642942BCEBF9500D81DC4 /* TextLinkModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextLinkModel.swift; sourceTree = ""; }; EA6F330D2B911E9000BACAB9 /* TextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextView.swift; sourceTree = ""; }; + EA78C7952C00CAC200430AD1 /* Groupable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Groupable.swift; sourceTree = ""; }; EA81410A2A0E8E3C004F60D2 /* ButtonIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonIcon.swift; sourceTree = ""; }; EA81410F2A127066004F60D2 /* UIColor+VDSColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+VDSColor.swift"; sourceTree = ""; }; EA89200328AECF4B006B9984 /* UITextField+Publisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITextField+Publisher.swift"; sourceTree = ""; }; @@ -337,6 +375,20 @@ EAB5FEF729393A7200998C17 /* ButtonGroupConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonGroupConstants.swift; sourceTree = ""; }; EAB5FF0029424ACB00998C17 /* UIControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIControl.swift; sourceTree = ""; }; EABFEB632A26473700C4C106 /* NSAttributedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSAttributedString.swift; sourceTree = ""; }; + EAC58BFC2BE935C300BA39FA /* TitleLockupTextColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitleLockupTextColor.swift; sourceTree = ""; }; + EAC58C052BED000200BA39FA /* CreditCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreditCard.swift; sourceTree = ""; }; + EAC58C072BED002D00BA39FA /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = ""; }; + EAC58C092BED004E00BA39FA /* FieldType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldType.swift; sourceTree = ""; }; + EAC58C0B2BED01D500BA39FA /* Telephone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Telephone.swift; sourceTree = ""; }; + EAC58C0D2BED021600BA39FA /* Password.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Password.swift; sourceTree = ""; }; + EAC58C112BED0DDD00BA39FA /* Text.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Text.swift; sourceTree = ""; }; + EAC58C132BED0DEC00BA39FA /* Number.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Number.swift; sourceTree = ""; }; + EAC58C152BED0E0300BA39FA /* InlineAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineAction.swift; sourceTree = ""; }; + EAC58C172BED0E2300BA39FA /* SecurityCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityCode.swift; sourceTree = ""; }; + EAC58C222BF2824200BA39FA /* DatePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatePicker.swift; sourceTree = ""; }; + EAC58C242BF2A7FB00BA39FA /* DatePickerChangeLog.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = DatePickerChangeLog.txt; sourceTree = ""; }; + EAC58C262BF4116200BA39FA /* DatePickerCalendarModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatePickerCalendarModel.swift; sourceTree = ""; }; + EAC58C282BF4118C00BA39FA /* DatePickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatePickerViewController.swift; sourceTree = ""; }; EAC71A1C2A2E155A00E47A9F /* Checkbox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Checkbox.swift; sourceTree = ""; }; EAC71A1E2A2E173D00E47A9F /* RadioButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioButton.swift; sourceTree = ""; }; EAC846F2294B95CE00F685BA /* ButtonGroupCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonGroupCollectionViewCell.swift; sourceTree = ""; }; @@ -425,6 +477,20 @@ path = DropdownSelect; sourceTree = ""; }; + 18A3F1202BD8F5DE00498E4A /* Calendar */ = { + isa = PBXGroup; + children = ( + 18A3F1292BD9298900498E4A /* Calendar.swift */, + 1842B1DE2BECE28B0021AFCA /* CalendarDateViewCell.swift */, + 1842B1E22BECF0A10021AFCA /* CalendarFooterReusableView.swift */, + 1842B1E02BECE7B70021AFCA /* CalendarHeaderReusableView.swift */, + 18FEA1AC2BDD137500A56439 /* CalendarIndicatorModel.swift */, + 18FEA1B42BE0E63600A56439 /* Date+Extension.swift */, + 18FEA1B82BE1301700A56439 /* CalendarChangeLog.txt */, + ); + path = Calendar; + sourceTree = ""; + }; 18A65A002B96E7E1006602CC /* Breadcrumbs */ = { isa = PBXGroup; children = ( @@ -534,6 +600,15 @@ path = ButtonGroup; sourceTree = ""; }; + EA2DC9AE2BE175A6004F58C5 /* Rules */ = { + isa = PBXGroup; + children = ( + EA2DC9AF2BE175BA004F58C5 /* RequiredRule.swift */, + EA2DC9B12BE175E6004F58C5 /* CharacterCountRule.swift */, + ); + path = Rules; + sourceTree = ""; + }; EA336162288B19200071C351 = { isa = PBXGroup; children = ( @@ -595,8 +670,10 @@ EAD062AE2A3B87210015965D /* BadgeIndicator */, 18A65A002B96E7E1006602CC /* Breadcrumbs */, EA0FC2BE2912D18200DF80B4 /* Buttons */, + 18A3F1202BD8F5DE00498E4A /* Calendar */, 1808BEBA2BA41B1D00129230 /* CarouselScrollbar */, EAF7F092289985E200B287F5 /* Checkbox */, + EAC58C1F2BF127F000BA39FA /* DatePicker */, 186D13C92BBA8A3500986B53 /* DropdownSelect */, EA985BF3296C609E00F2FF2E /* Icon */, EA3362412892EF700071C351 /* Label */, @@ -666,6 +743,7 @@ EA5E305929510F8B0082B959 /* EnumSubset.swift */, EAF7F0A1289AFB3900B287F5 /* Errorable.swift */, EA3361AE288B26310071C351 /* FormFieldable.swift */, + EA78C7952C00CAC200430AD1 /* Groupable.swift */, EA33624628931B050071C351 /* Initable.swift */, EA471F392A95587500CE9E58 /* LayoutConstraintable.swift */, EA985C7C297DAED300F2FF2E /* Primitive.swift */, @@ -775,12 +853,13 @@ isa = PBXGroup; children = ( EA5E30522950DDA60082B959 /* TitleLockup.swift */, + EAEEECAC2B1FC1A600531FC2 /* TitleLockupChangeLog.txt */, EA985BEF2968A93600F2FF2E /* TitleLockupEyebrowModel.swift */, EA985BED2968A92400F2FF2E /* TitleLockupSubTitleModel.swift */, EA985BEB2968A91200F2FF2E /* TitleLockupTitleModel.swift */, + EAC58BFC2BE935C300BA39FA /* TitleLockupTextColor.swift */, EA985BF12968B5BB00F2FF2E /* TitleLockupTextStyle.swift */, EA513A942A4E1F82002A4DFF /* TitleLockupStyleConfiguration.swift */, - EAEEECAC2B1FC1A600531FC2 /* TitleLockupChangeLog.txt */, ); path = TitleLockup; sourceTree = ""; @@ -858,6 +937,7 @@ children = ( EAB1D2E928AE84AA00DAE764 /* UIControlPublisher.swift */, EA89200328AECF4B006B9984 /* UITextField+Publisher.swift */, + EA2DC9B52BE2F4A1004F58C5 /* UITextView+Publisher.swift */, EAD8D2C028BFDE8B006EB6A6 /* UIGestureRecognizer+Publisher.swift */, ); path = Publishers; @@ -877,6 +957,33 @@ path = Tooltip; sourceTree = ""; }; + EAC58C042BECFFEA00BA39FA /* FieldTypes */ = { + isa = PBXGroup; + children = ( + EAC58C092BED004E00BA39FA /* FieldType.swift */, + EAC58C052BED000200BA39FA /* CreditCard.swift */, + EAC58C072BED002D00BA39FA /* Date.swift */, + EAC58C152BED0E0300BA39FA /* InlineAction.swift */, + EAC58C132BED0DEC00BA39FA /* Number.swift */, + EAC58C0D2BED021600BA39FA /* Password.swift */, + EAC58C172BED0E2300BA39FA /* SecurityCode.swift */, + EAC58C0B2BED01D500BA39FA /* Telephone.swift */, + EAC58C112BED0DDD00BA39FA /* Text.swift */, + ); + path = FieldTypes; + sourceTree = ""; + }; + EAC58C1F2BF127F000BA39FA /* DatePicker */ = { + isa = PBXGroup; + children = ( + EAC58C222BF2824200BA39FA /* DatePicker.swift */, + EAC58C262BF4116200BA39FA /* DatePickerCalendarModel.swift */, + EAC58C282BF4118C00BA39FA /* DatePickerViewController.swift */, + EAC58C242BF2A7FB00BA39FA /* DatePickerChangeLog.txt */, + ); + path = DatePicker; + sourceTree = ""; + }; EAC9257E29119B5D00091998 /* TextLink */ = { isa = PBXGroup; children = ( @@ -898,6 +1005,7 @@ EAC925852911C9DE00091998 /* TextFields */ = { isa = PBXGroup; children = ( + EA2DC9AE2BE175A6004F58C5 /* Rules */, EAC9258B2911C9DE00091998 /* EntryFieldBase.swift */, EAC925862911C9DE00091998 /* InputField */, EA985C21296E032000F2FF2E /* TextArea */, @@ -908,7 +1016,9 @@ EAC925862911C9DE00091998 /* InputField */ = { isa = PBXGroup; children = ( + EAC58C042BECFFEA00BA39FA /* FieldTypes */, EAC925872911C9DE00091998 /* InputField.swift */, + EA2DC9B32BE2C6FE004F58C5 /* TextField.swift */, EA6642942BCEBF9500D81DC4 /* TextLinkModel.swift */, ); path = InputField; @@ -1088,8 +1198,10 @@ EAA5EEB928ECD24B003B3210 /* Icons.xcassets in Resources */, EAEEECA92B1F969700531FC2 /* TooltipChangeLog.txt in Resources */, 186D13CF2BBC36EF00986B53 /* DropdownSelectChangeLog.txt in Resources */, + 18FEA1B92BE1301700A56439 /* CalendarChangeLog.txt in Resources */, EAEEEC9C2B1F8F0700531FC2 /* TextLinkCaretChangeLog.txt in Resources */, EAA5EEE428F5B855003B3210 /* VerizonNHGDS-Light.otf in Resources */, + EAC58C252BF2A7FB00BA39FA /* DatePickerChangeLog.txt in Resources */, 71B5FCBB2B95A0CA00269BCC /* PaginationChangeLog.txt in Resources */, EAEEECAD2B1FC1A600531FC2 /* TitleLockupChangeLog.txt in Resources */, EAEEECAB2B1FBF2A00531FC2 /* ToggleChangeLog.txt in Resources */, @@ -1116,12 +1228,15 @@ EA985C2D296F03FE00F2FF2E /* TileletIconModels.swift in Sources */, EA89200428AECF4B006B9984 /* UITextField+Publisher.swift in Sources */, 18A65A022B96E848006602CC /* Breadcrumbs.swift in Sources */, + 1842B1E12BECE7B70021AFCA /* CalendarHeaderReusableView.swift in Sources */, + EA78C7962C00CAC200430AD1 /* Groupable.swift in Sources */, EA0D1C3F2A6AD5E200E5C127 /* Typography+ContentSizeCategory.swift in Sources */, EA5F86C82A1BD99100BC83E4 /* TabModel.swift in Sources */, EA297A5729FB0A360031ED56 /* AppleGuidelinesTouchable.swift in Sources */, 1832AC572BA0791D008AE476 /* BreadcrumbCellItem.swift in Sources */, EA3361C328902D960071C351 /* Toggle.swift in Sources */, EAF7F0A0289AB7EC00B287F5 /* View.swift in Sources */, + EAC58C232BF2824200BA39FA /* DatePicker.swift in Sources */, EA89201328B568D8006B9984 /* RadioBoxItem.swift in Sources */, 71FC86E42B9841AC00700965 /* PaginationFlowLayout.swift in Sources */, EAC9258C2911C9DE00091998 /* InputField.swift in Sources */, @@ -1129,6 +1244,7 @@ EAB2376229E9880400AABE9A /* TrailingTooltipLabel.swift in Sources */, EAACB8982B92706F006A3869 /* DefaultValuing.swift in Sources */, EAB2376A29E9E59100AABE9A /* TooltipLaunchable.swift in Sources */, + 18A3F12A2BD9298900498E4A /* Calendar.swift in Sources */, 18A65A042B96F050006602CC /* BreadcrumbItem.swift in Sources */, EAB2375D29E8789100AABE9A /* Tooltip.swift in Sources */, 71BFA70A2B7F70E6000DCE33 /* DropShadowable.swift in Sources */, @@ -1142,12 +1258,14 @@ EA33622E2891EA3C0071C351 /* DispatchQueue+Once.swift in Sources */, EA4DB2FD28D3D0CA00103EE3 /* AnyEquatable.swift in Sources */, EA5E305A29510F8B0082B959 /* EnumSubset.swift in Sources */, + EAC58C082BED002D00BA39FA /* Date.swift in Sources */, EA985BF7296C665E00F2FF2E /* IconName.swift in Sources */, EA8141102A127066004F60D2 /* UIColor+VDSColor.swift in Sources */, EAF7F0AF289B144C00B287F5 /* UnderlineLabelAttribute.swift in Sources */, EA0D1C412A6AD61C00E5C127 /* Typography+Additional.swift in Sources */, EAC925842911C63100091998 /* Colorable.swift in Sources */, 18B463A42BBD3C46005C4528 /* DropdownOptionModel.swift in Sources */, + EAC58BFD2BE935C300BA39FA /* TitleLockupTextColor.swift in Sources */, EAACB89A2B927108006A3869 /* Valuing.swift in Sources */, EAE785312BA0A438009428EA /* UIImage+Helper.swift in Sources */, EAB5FEF5292D371F00998C17 /* ButtonBase.swift in Sources */, @@ -1160,6 +1278,7 @@ 71ACE89E2BA1CC1700FB6ADC /* TiletEyebrowModel.swift in Sources */, EAF7F11728A1475A00B287F5 /* RadioButtonItem.swift in Sources */, EA985BEE2968A92400F2FF2E /* TitleLockupSubTitleModel.swift in Sources */, + EA2DC9B22BE175E6004F58C5 /* CharacterCountRule.swift in Sources */, EA985BF22968B5BB00F2FF2E /* TitleLockupTextStyle.swift in Sources */, EAB1D2CD28ABE76100DAE764 /* Withable.swift in Sources */, 71FC86DC2B96F4C800700965 /* PaginationCellItem.swift in Sources */, @@ -1169,10 +1288,12 @@ 71FC86DE2B9738B900700965 /* SurfaceConfigurationValue.swift in Sources */, EA297A5529FB07760031ED56 /* TooltipLabelAttribute.swift in Sources */, EA985BEA29689B6D00F2FF2E /* TileletSubTitleModel.swift in Sources */, + EA2DC9B02BE175BA004F58C5 /* RequiredRule.swift in Sources */, EA3361C9289054C50071C351 /* Surfaceable.swift in Sources */, EAB5FEED2927E1B200998C17 /* ButtonGroupPositionLayout.swift in Sources */, EA4DB30228DCBCA500103EE3 /* Badge.swift in Sources */, EA33624728931B050071C351 /* Initable.swift in Sources */, + EA2DC9B62BE2F4A1004F58C5 /* UITextView+Publisher.swift in Sources */, EAF7F0A4289B017C00B287F5 /* LabelAttributeModel.swift in Sources */, EA0B18022A9E236900F2D0CD /* SelectorGroupBase.swift in Sources */, EA5F86D02A1F936100BC83E4 /* TabsContainer.swift in Sources */, @@ -1194,13 +1315,17 @@ EA0D1C372A681CCE00E5C127 /* ToggleView.swift in Sources */, EAF7F0B9289C139800B287F5 /* ColorConfiguration.swift in Sources */, EA3361BD288B2C760071C351 /* TypeAlias.swift in Sources */, + EAC58C0A2BED004E00BA39FA /* FieldType.swift in Sources */, EA471F3A2A95587500CE9E58 /* LayoutConstraintable.swift in Sources */, + EAC58C292BF4118C00BA39FA /* DatePickerViewController.swift in Sources */, EAB1D2CF28ABEF2B00DAE764 /* Typography+Base.swift in Sources */, EA0D1C3B2A6AD51B00E5C127 /* Typogprahy+Styles.swift in Sources */, EAF7F09A2899B17200B287F5 /* CATransaction.swift in Sources */, + EAC58C162BED0E0300BA39FA /* InlineAction.swift in Sources */, EA0D1C3D2A6AD57600E5C127 /* Typography+Enums.swift in Sources */, EAF1FE9B29DB1A6000101452 /* Changeable.swift in Sources */, 440B84CA2BD8E0E9004A732A /* Table.swift in Sources */, + EAC58C0C2BED01D500BA39FA /* Telephone.swift in Sources */, EAF7F0A2289AFB3900B287F5 /* Errorable.swift in Sources */, EA8E40912A7D3F6300934ED3 /* UIView+Accessibility.swift in Sources */, EA6F330E2B911E9000BACAB9 /* TextView.swift in Sources */, @@ -1214,6 +1339,7 @@ 44604AD729CE196600E62B51 /* Line.swift in Sources */, 1808BEBC2BA41C3200129230 /* CarouselScrollbar.swift in Sources */, EAF978212A99035B00C2FEA9 /* Enabling.swift in Sources */, + EAC58C062BED000200BA39FA /* CreditCard.swift in Sources */, EA5E3058295105A40082B959 /* Tilelet.swift in Sources */, 186D13CB2BBA8B1500986B53 /* DropdownSelect.swift in Sources */, EA89201528B56CF4006B9984 /* RadioBoxGroup.swift in Sources */, @@ -1235,11 +1361,16 @@ EA0B18062A9E2D2D00F2D0CD /* SelectorItemBase.swift in Sources */, EAB5FF0129424ACB00998C17 /* UIControl.swift in Sources */, EA985BF5296C60C000F2FF2E /* Icon.swift in Sources */, + 1842B1E32BECF0A20021AFCA /* CalendarFooterReusableView.swift in Sources */, EA3361AA288B25E40071C351 /* Disabling.swift in Sources */, EA3361B6288B2A410071C351 /* Control.swift in Sources */, + EAC58C122BED0DDD00BA39FA /* Text.swift in Sources */, 5F21D7BF28DCEB3D003E7CD6 /* Useable.swift in Sources */, + EAC58C272BF4116200BA39FA /* DatePickerCalendarModel.swift in Sources */, EAF7F0B7289C12A600B287F5 /* UITapGestureRecognizer.swift in Sources */, + 1842B1DF2BECE28B0021AFCA /* CalendarDateViewCell.swift in Sources */, EA0D1C392A6AD4DF00E5C127 /* Typography+SpacingConfig.swift in Sources */, + 18FEA1B52BE0E63600A56439 /* Date+Extension.swift in Sources */, EAB2376629E9952D00AABE9A /* UIApplication.swift in Sources */, EAB5FED429267EB300998C17 /* UIView+NSLayoutConstraint.swift in Sources */, EAB2376829E9992800AABE9A /* TooltipAlertViewController.swift in Sources */, @@ -1247,14 +1378,19 @@ EA985C692971B90B00F2FF2E /* IconSize.swift in Sources */, 71FC86E02B973AE500700965 /* DropShadowConfiguration.swift in Sources */, EA3362302891EB4A0071C351 /* Font.swift in Sources */, + EAC58C0E2BED021600BA39FA /* Password.swift in Sources */, EAF7F0AD289B142900B287F5 /* StrikeThroughLabelAttribute.swift in Sources */, EAB5FEF12927F4AA00998C17 /* SelfSizingCollectionView.swift in Sources */, EA3361B8288B2AAA0071C351 /* ViewProtocol.swift in Sources */, EA3361A8288B23300071C351 /* UIColor.swift in Sources */, + EA2DC9B42BE2C6FE004F58C5 /* TextField.swift in Sources */, + EAC58C182BED0E2300BA39FA /* SecurityCode.swift in Sources */, EAC9257D29119B5400091998 /* TextLink.swift in Sources */, + EAC58C142BED0DEC00BA39FA /* Number.swift in Sources */, EA596ABF2A16B4F500300C4B /* Tabs.swift in Sources */, EAD062A72A3B67770015965D /* UIView+CALayer.swift in Sources */, EAD068942A560C13002E3A2D /* LoaderLaunchable.swift in Sources */, + 18FEA1AD2BDD137500A56439 /* CalendarIndicatorModel.swift in Sources */, EA985BEC2968A91200F2FF2E /* TitleLockupTitleModel.swift in Sources */, 5FC35BE328D51405004EBEAC /* Button.swift in Sources */, ); @@ -1405,7 +1541,7 @@ BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 64; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1442,7 +1578,7 @@ BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 64; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; diff --git a/VDS/BaseClasses/Selector/SelectorGroupBase.swift b/VDS/BaseClasses/Selector/SelectorGroupBase.swift index 997a71b2..d2632a9d 100644 --- a/VDS/BaseClasses/Selector/SelectorGroupBase.swift +++ b/VDS/BaseClasses/Selector/SelectorGroupBase.swift @@ -20,7 +20,7 @@ extension SelectorGroup { public var hasSelectedItem: Bool { items.filter { $0.isSelected == true }.count > 0 } } -public protocol SelectorGroupMultiSelect: SelectorGroup {} +public protocol SelectorGroupMultiSelect: SelectorGroup, FormFieldable {} extension SelectorGroupMultiSelect { /// Current Selected Control for this group. public var selectedItems: [SelectorItemType]? { @@ -30,7 +30,7 @@ extension SelectorGroupMultiSelect { } } -public protocol SelectorGroupSingleSelect: SelectorGroup {} +public protocol SelectorGroupSingleSelect: SelectorGroup, FormFieldable {} extension SelectorGroupSingleSelect { /// Current Selected Control for this group. public var selectedItem: SelectorItemType? { @@ -39,7 +39,7 @@ extension SelectorGroupSingleSelect { } /// Base Class used for any Grouped Form Control of a Selector Type. -open class SelectorGroupBase: Control, SelectorGroup, Changeable { +open class SelectorGroupBase: Control, SelectorGroup, Changeable { //-------------------------------------------------- // MARK: - Private Properties diff --git a/VDS/BaseClasses/Selector/SelectorItemBase.swift b/VDS/BaseClasses/Selector/SelectorItemBase.swift index 495ffaf3..75f832ed 100644 --- a/VDS/BaseClasses/Selector/SelectorItemBase.swift +++ b/VDS/BaseClasses/Selector/SelectorItemBase.swift @@ -11,7 +11,7 @@ import Combine import VDSTokens /// Base Class used to build out a SelectorControlable control. -open class SelectorItemBase: Control, Errorable, Changeable, FormFieldable { +open class SelectorItemBase: Control, Errorable, Changeable, Groupable { //-------------------------------------------------- // MARK: - Initializers @@ -141,8 +141,12 @@ open class SelectorItemBase: Control, Errorable, open var inputId: String? { didSet { setNeedsUpdate() } } - open var value: AnyHashable? { didSet { setNeedsUpdate() } } + open var value: AnyHashable? { hiddenValue } + open var hiddenValue: AnyHashable? { didSet { setNeedsUpdate() } } + + open var accessibilityValueText: String? + //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- @@ -191,6 +195,7 @@ open class SelectorItemBase: Control, Errorable, open override func updateAccessibility() { super.updateAccessibility() setAccessibilityLabel(for: [selectorView, label, childLabel, errorLabel]) + accessibilityValue = accessibilityValueText } /// Resets to default settings. @@ -214,7 +219,6 @@ open class SelectorItemBase: Control, Errorable, showError = false errorText = nil inputId = nil - value = nil isSelected = false onChange = nil diff --git a/VDS/Classes/SelfSizingCollectionView.swift b/VDS/Classes/SelfSizingCollectionView.swift index ad5d5661..ff1f9cb8 100644 --- a/VDS/Classes/SelfSizingCollectionView.swift +++ b/VDS/Classes/SelfSizingCollectionView.swift @@ -23,12 +23,12 @@ public final class SelfSizingCollectionView: UICollectionView { /// - layout: Layout used for this CollectionView public override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) { super.init(frame: frame, collectionViewLayout: layout) - self.setupContentSizeObservation() + self.initialSetup() } public required init?(coder: NSCoder) { super.init(coder: coder) - self.setupContentSizeObservation() + self.initialSetup() } //-------------------------------------------------- @@ -69,22 +69,31 @@ public final class SelfSizingCollectionView: UICollectionView { //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- - private func setupContentSizeObservation() { + private func initialSetup() { + //ensure this hasn't run before + guard anyCancellable == nil else { return } + //ensure autoLayout uses intrinsic height setContentHuggingPriority(.required, for: .vertical) setContentCompressionResistancePriority(.required, for: .vertical) collectionViewHeight = height(constant: 0, priority: .defaultHigh) - anyCancellable = self.publisher(for: \.contentSize, options: [.new]) + anyCancellable = self.publisher(for: \.contentSize, options: [.new, .old]) .sink { [weak self] compare in - guard let self else { return } - if compare.height != self.collectionViewHeight?.constant { - self.invalidateIntrinsicContentSize() - self.collectionViewHeight?.constant = compare.height - self.contentSizeSubject.send(compare) - } + + guard let self, + let currentHeight = self.collectionViewHeight?.constant, + compare.height != currentHeight else { return } + + self.invalidateIntrinsicContentSize() + self.collectionViewHeight?.constant = compare.height + self.contentSizeSubject.send(compare) } } + + deinit { + anyCancellable?.cancel() + } } extension UITraitCollection { diff --git a/VDS/Components/BadgeIndicator/BadgeIndicator.swift b/VDS/Components/BadgeIndicator/BadgeIndicator.swift index a327270d..463e5df8 100644 --- a/VDS/Components/BadgeIndicator/BadgeIndicator.swift +++ b/VDS/Components/BadgeIndicator/BadgeIndicator.swift @@ -210,6 +210,7 @@ open class BadgeIndicator: View { /// The Container's height. open var height: CGFloat? { didSet { setNeedsUpdate() } } + open var accessibilityText: String? { didSet { setNeedsUpdate() } } //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- @@ -348,7 +349,9 @@ open class BadgeIndicator: View { open override func updateAccessibility() { super.updateAccessibility() - if kind == .numbered { + if let accessibilityText { + accessibilityLabel = kind == .numbered ? label.text + " " + accessibilityText : accessibilityText + } else if kind == .numbered { accessibilityLabel = label.text } else { accessibilityLabel = "Simple" diff --git a/VDS/Components/Breadcrumbs/BreadcrumbItem.swift b/VDS/Components/Breadcrumbs/BreadcrumbItem.swift index a7b9bef4..f12d9646 100644 --- a/VDS/Components/Breadcrumbs/BreadcrumbItem.swift +++ b/VDS/Components/Breadcrumbs/BreadcrumbItem.swift @@ -96,7 +96,7 @@ open class BreadcrumbItem: ButtonBase { /// Used to update any Accessibility properties. open override func updateAccessibility() { super.updateAccessibility() - accessibilityLabel = "Breadcrumb \(text ?? "")" + accessibilityLabel = text } } diff --git a/VDS/Components/Breadcrumbs/Breadcrumbs.swift b/VDS/Components/Breadcrumbs/Breadcrumbs.swift index 63084192..7c88e18a 100644 --- a/VDS/Components/Breadcrumbs/Breadcrumbs.swift +++ b/VDS/Components/Breadcrumbs/Breadcrumbs.swift @@ -39,6 +39,13 @@ open class Breadcrumbs: View { } } + open override var accessibilityElements: [Any]? { + get { + return [containerView, breadcrumbs] + } + set {} + } + /// A callback when the selected item changes. Passes parameters (crumb). open var onBreadcrumbDidSelect: ((BreadcrumbItem) -> Void)? @@ -73,6 +80,11 @@ open class Breadcrumbs: View { return collectionView }() + private let containerView = View().with { + $0.isAccessibilityElement = true + $0.accessibilityLabel = "Breadcrumbs" + } + //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- @@ -106,8 +118,10 @@ open class Breadcrumbs: View { /// Executed on initialization for this View. open override func initialSetup() { super.initialSetup() - addSubview(collectionView) + containerView.addSubview(collectionView) collectionView.pinToSuperView() + addSubview(containerView) + containerView.pinToSuperView() } /// Resets to default settings. @@ -124,7 +138,7 @@ open class Breadcrumbs: View { super.updateView() collectionView.reloadData() } - + open override func layoutSubviews() { //Turn off the ability to execute updateView() in the super //since we don't want an infinite loop @@ -139,6 +153,7 @@ open class Breadcrumbs: View { } } private var separatorWidth = Label().with { $0.text = "/"; $0.sizeToFit() }.intrinsicContentSize.width + } extension Breadcrumbs: UICollectionViewDelegate, UICollectionViewDataSource, ButtongGroupPositionLayoutDelegate { diff --git a/VDS/Components/Calendar/Calendar.swift b/VDS/Components/Calendar/Calendar.swift new file mode 100644 index 00000000..fd3e3452 --- /dev/null +++ b/VDS/Components/Calendar/Calendar.swift @@ -0,0 +1,375 @@ +// +// Calendar.swift +// VDS +// +// Created by Kanamarlapudi, Vasavi on 19/04/24. +// + +import Foundation +import UIKit +import VDSTokens +import Combine + +/// A calendar is a monthly view that lets customers select a single date. +@objc(VDSCalendar) +open class CalendarBase: Control, Changeable { + + //-------------------------------------------------- + // 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: - Public Properties + //-------------------------------------------------- + open var onChangeSubscriber: AnyCancellable? + + /// If set to true, the calendar will not have a border. + open var hideContainerBorder: Bool = false { didSet { setNeedsUpdate() } } + + /// If set to true, the calendar will not have current date indication. + open var hideCurrentDateIndicator: Bool = false { didSet { setNeedsUpdate() } } + + /// Enable specific days. Pass an array of string value in date format e.g. ['07/21/2024', '07/24/2024', 07/28/2024']. + /// All other dates will be inactive. + open var activeDates: [Date] = [] { didSet { setNeedsUpdate() } } + + /// Disable specific days. Pass an array of string value in date format e.g. ['07/21/2024', '07/24/2024', 07/28/2024']. + /// All other dates will be active. + open var inactiveDates: [Date] = [] { didSet { setNeedsUpdate() } } + + /// If provided, the calendar will allow a selection to be made from this date forward. Defaults to today. + open var minDate: Date = Date() { didSet { setNeedsUpdate() } } + + /// If provided, the calendar will allow a selection to be made up to this date. + open var maxDate: Date = Date() { didSet { setNeedsUpdate() } } + + /// If provided, this is the date that will show as selected by the Calendar. + /// If no value is provided, the current date will be used. If null is provided, no date will be selected. + open var selectedDate: Date = Date() { didSet { setNeedsUpdate() } } + + /// If provided, the calendar will be rendered with transparent background. + open var transparentBackground: Bool = false { didSet { setNeedsUpdate() } } + + /// Array of ``CalendarIndicatorModel`` you are wanting to show on legend. + open var indicators: [CalendarIndicatorModel] = [] { didSet { setNeedsUpdate() } } + + //-------------------------------------------------- + // MARK: - Private Properties + //-------------------------------------------------- + internal var containerSize: CGSize { CGSize(width: 328, height: 336) } + internal var calendar = Calendar.current + + private let cellItemSize = CGSize(width: 40, height: 40) + private let headerHeight = 88.0 + private let footerHeight = 40.0 + private let calendarWidth = 304.0 + + private var heightConstraint: NSLayoutConstraint? + private var containerHeightConstraint: NSLayoutConstraint? + private var selectedIndexPath : IndexPath? + private var dates: [Date] = [] + private var days: [String] = [] + private var displayDate: Date = Date() + + internal var containerView = View().with { + $0.clipsToBounds = true + } + + /// Collectionview to load calendar month view + private lazy var collectionView: UICollectionView = { + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) + collectionView.isScrollEnabled = false + collectionView.translatesAutoresizingMaskIntoConstraints = false + collectionView.delegate = self + collectionView.dataSource = self + collectionView.showsHorizontalScrollIndicator = false + collectionView.showsVerticalScrollIndicator = false + collectionView.backgroundColor = .clear + + collectionView.register(CalendarDateViewCell.self, + forCellWithReuseIdentifier: CalendarDateViewCell.identifier) + + collectionView.register(CalendarHeaderReusableView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: CalendarHeaderReusableView.identifier) + + collectionView.register(CalendarFooterReusableView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, + withReuseIdentifier: CalendarFooterReusableView.identifier) + return collectionView + }() + + //-------------------------------------------------- + // MARK: - Configuration + //-------------------------------------------------- + internal var containerBorderColorConfiguration = SurfaceColorConfiguration(VDSColor.elementsPrimaryOnlight , VDSColor.elementsPrimaryOndark) + internal var backgroundColorConfiguration = SurfaceColorConfiguration(VDSFormControlsColor.backgroundOnlight, VDSFormControlsColor.backgroundOndark) + + //-------------------------------------------------- + // 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() + isAccessibilityElement = true + accessibilityLabel = "Calendar" + addSubview(containerView) + containerView + .pinTop() + .pinBottom() + .pinLeadingGreaterThanOrEqualTo() + .pinTrailingLessThanOrEqualTo() + .width(containerSize.width) + .heightGreaterThanEqualTo(containerSize.height) + containerView.centerXAnchor.constraint(equalTo: centerXAnchor).activate() + + // Calendar View + containerView.addSubview(collectionView) + let calendarHeight = containerSize.height - (2 * VDSLayout.space4X) + let spacing = (containerSize.width - calendarWidth) / 2 + + collectionView + .pinTop(VDSLayout.space4X) + .pinBottom(VDSLayout.space4X) + .pinLeading(spacing) + .pinTrailing(spacing) + .width(calendarWidth) + .heightGreaterThanEqualTo(calendarHeight) + + collectionView.pinCenterX(anchor: containerView.centerXAnchor) + } + + open override func updateView() { + super.updateView() + // range check between min & max dates + if (minDate <= maxDate) { + // Check if current date falls between min & max dates. + let fallsBetween = displayDate.isBetweeen(date: minDate, andDate: maxDate) + displayDate = fallsBetween ? displayDate : minDate + fetchDates(with: displayDate) + } + + containerView.layer.backgroundColor = backgroundColorConfiguration.getColor(self).cgColor + if hideContainerBorder { + containerView.layer.borderColor = nil + containerView.layer.borderWidth = 0 + containerView.layer.cornerRadius = 0 + } else { + containerView.layer.borderColor = containerBorderColorConfiguration.getColor(self).cgColor + containerView.layer.borderWidth = VDSFormControls.borderWidth + containerView.layer.cornerRadius = VDSFormControls.borderRadius + } + } + + /// Resets to default settings. + open override func reset() { + super.reset() + hideContainerBorder = false + hideCurrentDateIndicator = false + transparentBackground = false + activeDates = [] + inactiveDates = [] + indicators = [] + } + + //-------------------------------------------------- + // MARK: - Private Methods + //-------------------------------------------------- + func fetchDates(with aDate: Date) { + heightConstraint?.isActive = false + containerHeightConstraint?.isActive = false + days.removeAll() + dates = aDate.calendarDisplayDays + + for date in dates { + // code to be executed + if date.monthInt != aDate.monthInt { + days.append("") + } else { + days.append(date.getDay()) + } + } + + collectionView.reloadData() + + var height = collectionView.collectionViewLayout.collectionViewContentSize.height + height = height > 0 ? height : containerSize.height + heightConstraint = collectionView.heightAnchor.constraint(equalToConstant: height) + containerHeightConstraint = containerView.heightAnchor.constraint(equalToConstant: height + (2 * VDSLayout.space4X)) + heightConstraint?.isActive = true + containerHeightConstraint?.isActive = true + layoutIfNeeded() + } +} + +extension CalendarBase: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { + //-------------------------------------------------- + // MARK: - UICollectionView Delegate & Datasource + //-------------------------------------------------- + public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + days.count + } + + public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CalendarDateViewCell.identifier, for: indexPath) as? CalendarDateViewCell else { return UICollectionViewCell() } + + var indicatorCount = 0 + + if indicators.count > 0 { + for x in 0...indicators.count - 1 { + if days[indexPath.row] == indicators[x].date.getDay() { + indicatorCount += 1 + } + } + } + + cell.update(with: surface, + indicators: indicators, + text: days[indexPath.row], + indicatorCount: indicatorCount, + selectedDate: selectedDate, + displayDate: displayDate, + hideDate: hideCurrentDateIndicator, + minDate: minDate, + maxDate: maxDate, + activeDates: activeDates, + inactiveDates: inactiveDates) + + if days[indexPath.row] == selectedDate.getDay() { + selectedIndexPath = indexPath + } + + return cell + } + + public func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { + + switch kind { + case UICollectionView.elementKindSectionHeader: + + // Header + guard let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: CalendarHeaderReusableView.identifier, for: indexPath) as? CalendarHeaderReusableView else { + return UICollectionReusableView() + } + var nextEnabled = false + var prevEnabled = false + + // check the interval between min date, max date.. set enable/disable flag for next / previous buttons. + if displayDate.monthInt < maxDate.monthInt && displayDate.yearInt == maxDate.yearInt + || displayDate.yearInt < maxDate.yearInt { + nextEnabled = true + } + if minDate.monthInt < displayDate.monthInt && minDate.yearInt == displayDate.yearInt + || minDate.yearInt < displayDate.yearInt { + prevEnabled = true + } + + header.nextClicked = { [weak self] in + guard let self = self else { return } + let date = calendar.date(byAdding: .month, value: 1, to:displayDate)! + if date.monthInt <= maxDate.monthInt && date.yearInt == maxDate.yearInt + || date.yearInt < maxDate.yearInt { + displayDate = date + fetchDates(with: displayDate) + } + } + + header.previousClicked = { [weak self] in + guard let self = self else { return } + let date = calendar.date(byAdding: .month, value: -1, to:displayDate)! + if minDate.monthInt <= date.monthInt && minDate.yearInt == date.yearInt || (minDate.yearInt < date.yearInt) { + displayDate = date + fetchDates(with: displayDate) + } + } + header.update(with: surface, date: displayDate, nextEnabled: nextEnabled, previousEnabled: prevEnabled) + + return header + + case UICollectionView.elementKindSectionFooter: + + guard let footer = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: CalendarFooterReusableView.identifier, for: indexPath) as? CalendarFooterReusableView else { + return UICollectionReusableView() + } + footer.update(with: surface, indicators: indicators) + + return footer + + default: + + return UICollectionReusableView() + + } + } + + + public func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool { + if let cell = collectionView.cellForItem(at: indexPath) as? CalendarDateViewCell { + let isEnabled: Bool = cell.isDateEnabled() + if isEnabled { + cell.activeModeStart() + } + } + return true + } + + public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + // reload selected index, if it is in enabled state. + if let cell = collectionView.cellForItem(at: indexPath) as? CalendarDateViewCell { + let isEnabled: Bool = cell.isDateEnabled() + if isEnabled { + cell.activeModeEnd() + + // Callback to pass selected date if it is enabled only. + selectedDate = dates[indexPath.row] + sendActions(for: .valueChanged) + displayDate = selectedDate + + var reloadIndexPaths = [indexPath] + + // If an cell is already selected, then it needs to be deselected. + // Add its index path to the array of index paths to be reloaded. + if let deselectIndexPath = selectedIndexPath { + reloadIndexPaths.append(deselectIndexPath) + } + + collectionView.reloadItems(at: reloadIndexPaths) + } + } + } + + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize { + return CGSize(width: collectionView.frame.size.width, height: headerHeight) + } + + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize { + return CGSize(width: collectionView.frame.size.width, height: indicators.count > 0 ? footerHeight : 0) + } + + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { + return VDSLayout.space1X + } + + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat { + return VDSLayout.space1X + } + + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + return cellItemSize + } +} diff --git a/VDS/Components/Calendar/CalendarChangeLog.txt b/VDS/Components/Calendar/CalendarChangeLog.txt new file mode 100644 index 00000000..ee6928a3 --- /dev/null +++ b/VDS/Components/Calendar/CalendarChangeLog.txt @@ -0,0 +1,84 @@ +MM/DD/YYYY +---------------- +- Initial Brand 3.0 handoff + +12/24/2021 +---------------- +- Replaced focusring colors (previously interactive/onlight/ondark) with accessibility/onlight/ondark colors +- Updated focus border name (previously interactive.focusring.onlight) with focusring.onlight/ondark +- Updated the SPECS with FormControl tokens and corner radius tokens + +02/24/2022 +---------------- +- Replaced Caret Left and Right icons with bold assets. + +02/28/2022 +---------------- +- Removed dev note from Hover state and added a dev note to Active state. + +03/11/2022 +---------------- +- Update Hover and Active states triggers for carets. Icon swap and color change for mouse-only, and color change for touch. + +05/25/2022 +---------------- +- Added date indicator feature (including legend), today’s date indicator, and size/spacing adjustments to support this. + +07/20/2022 +---------------- +- Added configuration page. +- To configuration added transparent background and border suppression. + +08/10/2022 +---------------- +- Updated inverted and default to light and dark surface. Also, updated dark to selected. + +08/16/2022 +---------------- +- Updated default date background to be transparent. Updated border configuration prop name to hideContainerBorder. +- Moved Width from Configurations to Layout and Spacing, and Calendar Indicator specs under Elements. + +11/30/2022 +---------------- +- Added "(web only)" to any instance of "keyboard focus" + +12/13/2022 +---------------- +- Replaced form border and focus border pixel values, styles & spacing with tokens. + +01/09/2023 +---------------- +- Updated Specs to use new SPEC Templates and SPEC DOC Components. + +01/10/2023 +---------------- +- Updated Anatomy item #8 to “Current Date” to match design doc (originally “Today’s Date) + +04/12/2023 +---------------- +- Updated palette colors for Current date. + +05/01/2023 +---------------- +- Updated Day Header Text Style from Bold to Regular +- Updated Date Disabled Text Style from Bold to Regular +- Updated Date Text Style from Bold to Regular +- Updated Current Date font color from blue to black on light and white on dark. +- Updated all frames to reflect new designs. + +05/09/2023 +---------------- +- Replaced Previous and Next carets with Button Icon in: +Anatomy +Configurations +States + +11/09/2023 +---------------- +- Added component tokens +- Applied component tokens to selected states on light and dark surfaces +- Removed redundant color specifications from other sections + +11/30/2023 +---------------- +- Revised selected container background inverse tokens from onlight/ondark to light/dark diff --git a/VDS/Components/Calendar/CalendarDateViewCell.swift b/VDS/Components/Calendar/CalendarDateViewCell.swift new file mode 100644 index 00000000..d66522d0 --- /dev/null +++ b/VDS/Components/Calendar/CalendarDateViewCell.swift @@ -0,0 +1,327 @@ +// +// CalendarDateViewCell.swift +// VDS +// +// Created by Kanamarlapudi, Vasavi on 24/04/24. +// + +import UIKit +import VDSTokens + +final class CalendarDateViewCell: UICollectionViewCell { + + ///Identifier for the Calendar Date Cell. + static let identifier: String = String(describing: CalendarDateViewCell.self) + + //-------------------------------------------------- + // MARK: - Private Properties + //-------------------------------------------------- + internal var containerSize: CGSize { CGSize(width: 40, height: 40) } + + internal var containerView = View().with { + $0.clipsToBounds = true + } + + private lazy var stackView: UIStackView = { + return UIStackView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.axis = .horizontal + $0.distribution = .fill + $0.alignment = .center + $0.spacing = VDSLayout.space1X + $0.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + $0.setContentHuggingPriority(.defaultHigh, for: .horizontal) + $0.backgroundColor = .clear + } + }() + + private var numberLabel = Label().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.textAlignment = .center + $0.textStyle = .bodySmall + } + + private lazy var shapeLayer = CAShapeLayer() + private var surface: Surface = .light + private let selectedTextColorConfiguration = SurfaceColorConfiguration(VDSColor.elementsPrimaryInverseOnlight, VDSColor.elementsPrimaryInverseOndark) + private let selectedBackgroundColor = SurfaceColorConfiguration(VDSColor.backgroundPrimaryInverseLight, VDSColor.backgroundPrimaryInverseDark) + private let selectedCellIndicatorColorConfiguration = SurfaceColorConfiguration(VDSColor.paletteGray65, VDSColor.paletteGray44) + private let unselectedTextColorConfiguration = SurfaceColorConfiguration(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark) + private let unselectedCellIndicatorColorConfiguration = SurfaceColorConfiguration(VDSColor.elementsSecondaryOnlight, VDSColor.elementsSecondaryOndark) + private let disabledTextColorConfiguration = SurfaceColorConfiguration(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark) + private let disabledBackgroundColor = SurfaceColorConfiguration(VDSFormControlsColor.backgroundOnlight, VDSFormControlsColor.backgroundOndark) + private var activeBorderColorConfiguration = SurfaceColorConfiguration(VDSFormControlsColor.borderHoverOnlight , VDSFormControlsColor.borderHoverOndark) + private let currentDate = Date() + + //-------------------------------------------------- + // MARK: - Initializers + //-------------------------------------------------- + override init(frame: CGRect) { + super.init(frame: frame) + setUp() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setUp() + } + + //-------------------------------------------------- + // MARK: - Private Methods + //-------------------------------------------------- + /// Configuring the cell with default setup. + private func setUp() { + isAccessibilityElement = false + contentView.addSubview(containerView) + containerView + .pinTop() + .pinBottom() + .pinLeadingGreaterThanOrEqualTo() + .pinTrailingLessThanOrEqualTo() + .height(containerSize.height) + .width(containerSize.width) + .pinCenterX() + + // Number label + containerView.addSubview(numberLabel) + numberLabel.pinToSuperView() + + // Indicators + containerView.addSubview(stackView) + let topPos = containerSize.height * 0.7 + stackView + .pinTop(topPos) + .pinBottom() + .pinTopGreaterThanOrEqualTo() + .pinTrailingLessThanOrEqualTo() + .pinCenterX() + } + + /// Updating UI based on selected date, modified indicators data along with surface. + /// Enable/disable cell based on min date, max date, active dates, inactive dates. + func update(with surface: Surface, indicators: [CalendarBase.CalendarIndicatorModel], text: String, indicatorCount: Int, selectedDate: Date, displayDate: Date, hideDate: Bool, minDate: Date, maxDate: Date, activeDates: [Date], inactiveDates: [Date]) { + + stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + numberLabel.surface = surface + numberLabel.text = text + self.surface = surface + + // enable/disable cells based on min date, max date and active/inactive dates. + updateLabel(with:surface, displayDate: displayDate, minDate: minDate, maxDate: maxDate, activeDates: activeDates, inactiveDates: inactiveDates) + + // handling inactive dates. + if inactiveDates.count > 0 { + for x in 0...inactiveDates.count-1 { + if inactiveDates[x].monthInt == displayDate.monthInt && inactiveDates[x].yearInt == displayDate.yearInt { + if let day:Int = Int(numberLabel.text), day == inactiveDates[x].dayInt { + disableLabel(with: surface) + } + } + } + } + + // update text color, bg color, corner radius. + if numberLabel.text == selectedDate.getDay() + && selectedDate.monthInt == displayDate.monthInt + && selectedDate.yearInt == displayDate.yearInt + && numberLabel.isEnabled { + + numberLabel.textColor = selectedTextColorConfiguration.getColor(surface) + layer.backgroundColor = selectedBackgroundColor.getColor(surface).cgColor + layer.cornerRadius = VDSFormControls.borderRadius + + } else { + numberLabel.textColor = unselectedTextColorConfiguration.getColor(surface) + layer.backgroundColor = nil + layer.cornerRadius = 0 + } + + // add indicators. + if indicatorCount > 0 { + for x in 0...indicators.count-1 { + if numberLabel.text == indicators[x].date.getDay() { + let color = numberLabel.text == selectedDate.getDay() ? selectedCellIndicatorColorConfiguration.getColor(surface) : unselectedCellIndicatorColorConfiguration.getColor(surface) + addIndicator(with: color, surface: surface, clearFullCircle: x == 1, drawSemiCircle: x == 2) + } + } + } + + // update text style for current date. + if numberLabel.text == currentDate.getDay() + && currentDate.monthInt == displayDate.monthInt + && currentDate.yearInt == displayDate.yearInt { + numberLabel.textStyle = hideDate ? .bodySmall : .boldBodySmall + } else { + numberLabel.textStyle = .bodySmall + } + } + + // returns cell enabled state. + func isDateEnabled() -> Bool { + return numberLabel.isEnabled + } + + func activeModeStart() { + numberLabel.layer.borderColor = activeBorderColorConfiguration.getColor(surface).cgColor + numberLabel.layer.borderWidth = VDSFormControls.borderWidth + numberLabel.layer.cornerRadius = VDSFormControls.borderRadius + } + + func activeModeEnd() { + numberLabel.layer.borderColor = nil + numberLabel.layer.borderWidth = 0 + numberLabel.layer.cornerRadius = 0 + } + + func disableLabel(with surface: Surface) { + numberLabel.isEnabled = false + numberLabel.textColor = disabledTextColorConfiguration.getColor(surface) + layer.backgroundColor = disabledBackgroundColor.getColor(surface).cgColor + } + + func showActiveDates(with displayDate: Date, activeDates: [Date], inactiveDates: [Date]) { + for x in 0...activeDates.count-1 { + if activeDates[x].monthInt == displayDate.monthInt && activeDates[x].yearInt == displayDate.yearInt { + if let day:Int = Int(numberLabel.text), day == activeDates[x].dayInt { + numberLabel.isEnabled = true + } + } + } + } + + // handing active dates if exist, else enable numberLabel to display day. + func handleActiveDates(with displayDate: Date, activeDates: [Date], inactiveDates: [Date]) { + if activeDates.count > 0 && inactiveDates.count == 0 { + showActiveDates(with: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + } else { + numberLabel.isEnabled = true + } + } + + // enable all days if no active dates, handing active dates if exist. + func enableAllDaysAndCheckActiveDates(with surface:Surface, displayDate: Date, activeDates: [Date], inactiveDates: [Date]) { + if activeDates.count > 0 && inactiveDates.count == 0 { + disableLabel(with: surface) + showActiveDates(with: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + } else { + numberLabel.isEnabled = true + } + } + + func minDateValidation(with surface:Surface, minDate: Date, displayDate: Date, activeDates: [Date], inactiveDates: [Date]) { + // validate days to enable/disable with min date only. + if let day = Int(numberLabel.text), day < minDate.dayInt { + disableLabel(with: surface) + } else { + numberLabel.isEnabled = false + handleActiveDates(with: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + } + } + + func maxDateValidation(with surface:Surface, maxDate: Date, displayDate: Date, activeDates: [Date], inactiveDates: [Date]) { + // validate days to enable/disable with max date only. + if let day = Int(numberLabel.text), day > maxDate.dayInt { + disableLabel(with: surface) + } else { + numberLabel.isEnabled = false + handleActiveDates(with: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + } + } + + func minAndMaxDateValidation(with surface:Surface, minDate: Date, maxDate: Date, displayDate: Date, activeDates: [Date], inactiveDates: [Date]) { + // validate days to enable/disable with min and max date. + if let day = Int(numberLabel.text), day < minDate.dayInt || day > maxDate.dayInt { + disableLabel(with: surface) + } else { + numberLabel.isEnabled = false + handleActiveDates(with: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + } + } + + // enable/disable cells based on min date, max date and active/inactive dates. + func updateLabel(with surface: Surface, displayDate: Date, minDate: Date, maxDate: Date, activeDates: [Date], inactiveDates: [Date]) { + + if minDate.yearInt == displayDate.yearInt && !(maxDate.yearInt == displayDate.yearInt) { + // min year and max year are different, and matched to min year. + if minDate.monthInt == displayDate.monthInt { + // min year and max year are different, and matched to min year and min month. + minDateValidation(with: surface, minDate: minDate, displayDate: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + } else { + // handing active dates - enable all days if no active dates. + enableAllDaysAndCheckActiveDates(with: surface, displayDate: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + } + } else if maxDate.yearInt == displayDate.yearInt && !(minDate.yearInt == displayDate.yearInt) { + // min year and max year are different, and matched to max year. + if maxDate.monthInt == displayDate.monthInt { + // min year and max year are different, and matched to max year and max month. + maxDateValidation(with: surface, maxDate: maxDate, displayDate: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + } else { + // handing active dates - enable all days if no active dates. + enableAllDaysAndCheckActiveDates(with: surface, displayDate: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + } + } else if minDate.yearInt == displayDate.yearInt && maxDate.yearInt == displayDate.yearInt { + // min year and max year same + if minDate.monthInt == displayDate.monthInt && maxDate.monthInt == displayDate.monthInt { + // min year and max year same, when choose dates in same month. + minAndMaxDateValidation(with: surface, minDate: minDate, maxDate: maxDate, displayDate: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + + } else if minDate.monthInt == displayDate.monthInt || maxDate.monthInt == displayDate.monthInt { + // min year and max year same, and choose dates in different months. + if minDate.monthInt == displayDate.monthInt { + // min year and max year same, and matched to min month. + minDateValidation(with: surface, minDate: minDate, displayDate: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + } else if (maxDate.monthInt == displayDate.monthInt) { + // min year and max year same, and matched to max month. + maxDateValidation(with: surface, maxDate: maxDate, displayDate: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + } + } else { + // min year and max year same, and not matched to min or max month. + // handing active dates - enable all days if no active dates. + enableAllDaysAndCheckActiveDates(with: surface, displayDate: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + } + } else { + // min year and max year are different, and not matched to min or max year. + // handing active dates - enable all days if no active dates. + enableAllDaysAndCheckActiveDates(with: surface, displayDate: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + } + } + + func addIndicator(with color: UIColor, surface: Surface, clearFullCircle: Bool, drawSemiCircle: Bool) { + // add indicator + let indicatorView: View = View().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.backgroundColor = .clear + $0.layer.borderWidth = 1.0 + } + + indicatorView + .pinLeading() + .pinTrailing() + .width(8) + .height(8) + .pinCenterY() + + stackView.addArrangedSubview(indicatorView) + + // update indicator + indicatorView.backgroundColor = drawSemiCircle ? .clear : (clearFullCircle ? .clear : color) + indicatorView.layer.borderColor = color.cgColor + + layoutIfNeeded() + + indicatorView.layer.cornerRadius = indicatorView.frame.size.height / 2.0 + + guard drawSemiCircle else { return } + + let center = CGPoint(x: indicatorView.frame.size.width/2, y: indicatorView.frame.size.height/2) + let path = UIBezierPath() + path.move(to: center) + path.addArc(withCenter: center, radius: center.x, startAngle: 2 * .pi, endAngle: .pi, clockwise: true) + path.close() + shapeLayer.path = path.cgPath + shapeLayer.fillColor = color.cgColor + + guard indicatorView.layer.sublayers?.contains(shapeLayer) ?? true else { return } + indicatorView.layer.addSublayer(shapeLayer) + } +} diff --git a/VDS/Components/Calendar/CalendarFooterReusableView.swift b/VDS/Components/Calendar/CalendarFooterReusableView.swift new file mode 100644 index 00000000..9547084f --- /dev/null +++ b/VDS/Components/Calendar/CalendarFooterReusableView.swift @@ -0,0 +1,257 @@ +// +// CalendarFooterReusableView.swift +// VDS +// +// Created by Kanamarlapudi, Vasavi on 29/04/24. +// + +import UIKit +import VDSTokens + +/// Footer view to show indicators data. +class CalendarFooterReusableView: UICollectionReusableView { + + ///Identifier for the Calendar Footer Reusable View. + static let identifier: String = String(describing: CalendarFooterReusableView.self) + + //-------------------------------------------------- + // MARK: - Private Properties + //-------------------------------------------------- + private var surface: Surface = .light + private var items: [CalendarBase.CalendarIndicatorModel] = [] + internal var containerSize: CGSize { CGSize(width: 304, height: 40) } + internal var indicatorWidth = 8.0 + + var textLabel: Label = Label().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.textAlignment = .left + $0.textStyle = .bodySmall + $0.numberOfLines = 1 + } + + internal var containerView = View().with { + $0.clipsToBounds = true + } + + private let flowLayout = LeftAlignedCollectionViewFlowLayout().with { + $0.estimatedItemSize = UICollectionViewFlowLayout.automaticSize + $0.minimumLineSpacing = VDSLayout.space1X + $0.minimumInteritemSpacing = VDSLayout.space4X + $0.scrollDirection = .vertical + } + + open lazy var legendCollectionView = UICollectionView(frame: .zero, collectionViewLayout: flowLayout).with { + $0.isScrollEnabled = false + $0.translatesAutoresizingMaskIntoConstraints = false + $0.showsVerticalScrollIndicator = false + $0.showsHorizontalScrollIndicator = false + $0.isAccessibilityElement = true + $0.backgroundColor = .clear + $0.delegate = self + $0.dataSource = self + + $0.register(LegendCollectionViewCell.self, + forCellWithReuseIdentifier: LegendCollectionViewCell.identifier) + } + + private var topConstraint: NSLayoutConstraint? + + //-------------------------------------------------- + // MARK: - Initializers + //-------------------------------------------------- + override init(frame: CGRect) { + super.init(frame: frame) + setUp() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setUp() + } + + //-------------------------------------------------- + // MARK: - Private Methods + //-------------------------------------------------- + /// Configuring the cell with default setup. + private func setUp() { + isAccessibilityElement = false + + addSubview(containerView) + containerView.pinToSuperView() + + // legend Collection View + containerView.addSubview(legendCollectionView) + legendCollectionView + .pinTopLessThanOrEqualTo(topAnchor, VDSLayout.space6X, .defaultLow) + .pinBottom() + .pinLeading(VDSLayout.space3X) + .pinTrailing(VDSLayout.space3X) + .width(containerSize.width - (2 * VDSLayout.space3X)) + .heightGreaterThanEqualTo(16) + } + + /// Updating UI to show legend with titles. + func update(with surface: Surface, indicators: [CalendarBase.CalendarIndicatorModel]) { + self.items = indicators + self.surface = surface + legendCollectionView.reloadData() + + var height = legendCollectionView.collectionViewLayout.collectionViewContentSize.height + if height > 0 { + topConstraint?.isActive = false + height = height > containerSize.height ? containerSize.height : height + let top = containerSize.height - height + topConstraint = legendCollectionView.topAnchor.constraint(equalTo: topAnchor, constant: top) + topConstraint?.isActive = true + } + } + +} + +extension CalendarFooterReusableView: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { + + public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return items.count + } + + public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard collectionView == legendCollectionView, + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: LegendCollectionViewCell.identifier, for: indexPath) as? LegendCollectionViewCell, + indexPath.row <= items.count else { return UICollectionViewCell() } + let text = items[indexPath.row].label + cell.updateTitle(text: text, + color: VDSColor.elementsSecondaryOnlight, + surface: self.surface, + clearFullcircle: indexPath.row == 1, + drawSemiCircle: indexPath.row == 2) + return cell + } + + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + textLabel.text = items[indexPath.row].label + let intrinsicSize = textLabel.intrinsicContentSize + let cellwidth = intrinsicSize.width + indicatorWidth + VDSLayout.space2X + return .init(width: min(cellwidth, collectionView.frame.width), height: intrinsicSize.height) + } + +} + +private class LegendCollectionViewCell: UICollectionViewCell { + + static let identifier: String = String(describing: LegendCollectionViewCell.self) + + private let textColorConfiguration = SurfaceColorConfiguration(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark) + + private let indicatorColorConfiguration = SurfaceColorConfiguration(VDSColor.elementsSecondaryOnlight, VDSColor.elementsSecondaryOndark) + + private var title: Label = Label().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.textAlignment = .left + $0.numberOfLines = 1 + $0.textStyle = .bodySmall + $0.isAccessibilityElement = false + $0.backgroundColor = .clear + } + + private var legendIndicatorWrapper: View = View().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.backgroundColor = .clear + } + + private var legendIndicator: View = View().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.backgroundColor = .clear + $0.layer.borderWidth = 1.0 + } + + private lazy var stackView = UIStackView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.alignment = .fill + $0.distribution = .fill + $0.spacing = VDSLayout.space2X + $0.axis = .horizontal + $0.backgroundColor = .clear + } + + private lazy var shapeLayer = CAShapeLayer() + + internal var indicatorSize: CGSize { CGSize(width: 8, height: 8) } + + //-------------------------------------------------- + // MARK: - Initializers + //-------------------------------------------------- + override init(frame: CGRect) { + super.init(frame: frame) + setupCell() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupCell() + } + + func setupCell() { + addSubview(stackView) + stackView.pinToSuperView() + } + + func updateView() { + stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + legendIndicator.layer.sublayers?.forEach { $0.removeFromSuperlayer() } + + legendIndicatorWrapper.addSubview(legendIndicator) + legendIndicator.pinLeading().pinTrailing().width(indicatorSize.width).height(indicatorSize.height).pinCenterY() + + stackView.addArrangedSubview(legendIndicatorWrapper) + stackView.addArrangedSubview(title) + } + + func updateTitle(text: String, color: UIColor, surface: Surface, clearFullcircle: Bool, drawSemiCircle: Bool) { + updateView() + title.surface = surface + title.text = text + title.textColor = textColorConfiguration.getColor(surface) + + legendIndicator.backgroundColor = drawSemiCircle ? .clear : (clearFullcircle ? .clear : color) + legendIndicator.layer.borderColor = indicatorColorConfiguration.getColor(surface).cgColor + + self.layoutIfNeeded() + + legendIndicator.layer.cornerRadius = legendIndicator.frame.size.height / 2.0 + + guard drawSemiCircle else { return } + + let center = CGPoint(x: legendIndicator.frame.size.width/2, y: legendIndicator.frame.size.height/2) + let path = UIBezierPath() + path.move(to: center) + path.addArc(withCenter: center, radius: center.x, startAngle: 2 * .pi, endAngle: .pi, clockwise: true) + path.close() + shapeLayer.path = path.cgPath + shapeLayer.fillColor = color.cgColor + + guard legendIndicator.layer.sublayers?.contains(shapeLayer) ?? true else { return } + legendIndicator.layer.addSublayer(shapeLayer) + } +} + +class LeftAlignedCollectionViewFlowLayout: UICollectionViewFlowLayout { + + override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { + let attributes = super.layoutAttributesForElements(in: rect) + + var leftMargin = sectionInset.left + var maxY: CGFloat = -1.0 + attributes?.forEach { layoutAttribute in + if layoutAttribute.frame.origin.y >= maxY { + leftMargin = sectionInset.left + } + + layoutAttribute.frame.origin.x = leftMargin + + leftMargin += layoutAttribute.frame.width + minimumInteritemSpacing + maxY = max(layoutAttribute.frame.maxY , maxY) + } + + return attributes + } +} diff --git a/VDS/Components/Calendar/CalendarHeaderReusableView.swift b/VDS/Components/Calendar/CalendarHeaderReusableView.swift new file mode 100644 index 00000000..d0f7f247 --- /dev/null +++ b/VDS/Components/Calendar/CalendarHeaderReusableView.swift @@ -0,0 +1,241 @@ +// +// CalendarHeaderReusableView.swift +// VDS +// +// Created by Kanamarlapudi, Vasavi on 30/04/24. +// + +import UIKit +import VDSTokens + +/// Header view to display month and year along with days of week. +class CalendarHeaderReusableView: UICollectionReusableView { + + ///Identifier for the Calendar Header Reusable View. + static let identifier: String = String(describing: CalendarHeaderReusableView.self) + + //-------------------------------------------------- + // MARK: - Public Properties + //-------------------------------------------------- + /// A callback when the next button clicked. + public var nextClicked: (() -> (Void))? + + /// A callback when the previous button clicked. + public var previousClicked: (() -> (Void))? + + //-------------------------------------------------- + // MARK: - Private Properties + //-------------------------------------------------- + internal var containerSize: CGSize { CGSize(width: 304, height: 88) } + + private lazy var stackView = UIStackView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.distribution = .fill + $0.spacing = VDSLayout.space1X + $0.axis = .vertical + $0.backgroundColor = .clear + } + + private lazy var topHeaderView = UIStackView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.distribution = .fill + $0.spacing = VDSLayout.space1X + $0.axis = .horizontal + $0.backgroundColor = .clear + } + + private lazy var daysCollectionView: UICollectionView = { + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) + collectionView.isScrollEnabled = false + collectionView.translatesAutoresizingMaskIntoConstraints = false + collectionView.delegate = self + collectionView.dataSource = self + collectionView.showsHorizontalScrollIndicator = false + collectionView.showsVerticalScrollIndicator = false + collectionView.backgroundColor = .clear + collectionView.register(collectionViewCell.self, forCellWithReuseIdentifier: collectionViewCell.identifier) + return collectionView + }() + + private var surface: Surface = .light + private var displayDate: Date = Date() + + internal var previousMonthView = View() + internal var nextMonthView = View() + let viewSize = 40.0 + + internal var previousButton = ButtonIcon().with { + $0.kind = .ghost + $0.iconName = .leftCaret + $0.iconOffset = .init(x: -2, y: 0) + $0.icon.size = .small + $0.size = .small + } + + internal var nextButton = ButtonIcon().with { + $0.kind = .ghost + $0.iconName = .rightCaret + $0.iconOffset = .init(x: 2, y: 0) + $0.icon.size = .small + $0.size = .small + } + + internal var headerTitle = Label().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.textAlignment = .center + $0.numberOfLines = 1 + $0.textStyle = .boldBodySmall + $0.backgroundColor = .clear + $0.isAccessibilityElement = false + } + + internal let daysOfWeek = Date.capitalizedFirstLettersOfWeekdays + internal let headerTitleTextColorConfiguration = SurfaceColorConfiguration(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark) + + //-------------------------------------------------- + // MARK: - Initializers + //-------------------------------------------------- + override init(frame: CGRect) { + super.init(frame: frame) + setUp() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setUp() + } + + //-------------------------------------------------- + // MARK: - Private Methods + //-------------------------------------------------- + /// Configuring the cell with default setup. + private func setUp() { + isAccessibilityElement = false + + // stackview + addSubview(stackView) + stackView + .pinTop() + .pinBottom(VDSLayout.space1X) + .pinLeading() + .pinTrailing() + .height(containerSize.height - VDSLayout.space1X) + .width(containerSize.width) + stackView.centerXAnchor.constraint(equalTo: centerXAnchor).activate() + + // top header stack view + stackView.addArrangedSubview(topHeaderView) + topHeaderView.heightAnchor.constraint(equalToConstant: viewSize).isActive = true + topHeaderView.centerXAnchor.constraint(equalTo: centerXAnchor).activate() + + // previous button + topHeaderView.addArrangedSubview(previousMonthView) + previousMonthView.widthAnchor.constraint(equalToConstant: viewSize).activate() + previousMonthView.addSubview(previousButton) + previousButton.pinCenterY().pinCenterX() + previousButton.onClick = { _ in self.previousButtonClick() } + + // month year label + topHeaderView.addArrangedSubview(headerTitle) + + // next button + topHeaderView.addArrangedSubview(nextMonthView) + nextMonthView.widthAnchor.constraint(equalToConstant: viewSize).activate() + nextMonthView.addSubview(nextButton) + nextButton.pinCenterY().pinCenterX() + nextButton.onClick = { _ in self.nextButtonClick() } + + // days Collection View + stackView.addArrangedSubview(daysCollectionView) + topHeaderView.heightAnchor.constraint(equalToConstant: viewSize).isActive = true + daysCollectionView.centerXAnchor.constraint(equalTo: centerXAnchor).activate() + } + + /// Updating UI based on next/previous clicks along with surface. + /// Updating UI to enable/disable the next & previous buttons, updating header title. + func update(with surface: Surface, date: Date, nextEnabled: Bool, previousEnabled: Bool) { + self.surface = surface + headerTitle.surface = surface + previousButton.surface = surface + nextButton.surface = surface + nextButton.isEnabled = nextEnabled + previousButton.isEnabled = previousEnabled + daysCollectionView.reloadData() + let labelText = date.getMonthName() + " \(date.yearInt)" + headerTitle.text = labelText + headerTitle.textColor = headerTitleTextColorConfiguration.getColor(surface) + } + + func nextButtonClick() { + nextClicked?() + } + + func previousButtonClick() { + previousClicked?() + } +} + +extension CalendarHeaderReusableView: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { + + public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return daysOfWeek.count + } + + public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: collectionViewCell.identifier, for: indexPath) as? collectionViewCell else { return UICollectionViewCell() } + cell.updateTitle(text: daysOfWeek[indexPath.row], surface: self.surface) + return cell + } + + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + return CGSize(width: viewSize, height: viewSize) + } + + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat { + return VDSLayout.space1X + } + + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { + return VDSLayout.space1X + } +} + +private class collectionViewCell: UICollectionViewCell { + + static let identifier: String = String(describing: collectionViewCell.self) + + private let textColorConfiguration = SurfaceColorConfiguration(VDSColor.elementsSecondaryOnlight, VDSColor.elementsSecondaryOndark) + + private var title = Label().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.textAlignment = .center + $0.numberOfLines = 1 + $0.textStyle = .bodySmall + $0.backgroundColor = .clear + $0.isAccessibilityElement = false + } + + //-------------------------------------------------- + // MARK: - Initializers + //-------------------------------------------------- + override init(frame: CGRect) { + super.init(frame: frame) + setupCell() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupCell() + } + + func setupCell() { + addSubview(title) + title.pinToSuperView() + } + + func updateTitle(text: String, surface: Surface) { + title.surface = surface + title.text = text + title.textColor = textColorConfiguration.getColor(surface) + } +} diff --git a/VDS/Components/Calendar/CalendarIndicatorModel.swift b/VDS/Components/Calendar/CalendarIndicatorModel.swift new file mode 100644 index 00000000..04ad9714 --- /dev/null +++ b/VDS/Components/Calendar/CalendarIndicatorModel.swift @@ -0,0 +1,25 @@ +// +// CalendarIndicatorModel.swift +// VDS +// +// Created by Kanamarlapudi, Vasavi on 26/04/24. +// + +import Foundation + +/// Custom data type for indicators prop +extension CalendarBase { + public struct CalendarIndicatorModel { + + /// Text that shown to an indicator for legend + public var label: String + + /// Date to an indicator + public var date: Date + + public init(label: String, date: Date) { + self.label = label + self.date = date + } + } +} diff --git a/VDS/Components/Calendar/Date+Extension.swift b/VDS/Components/Calendar/Date+Extension.swift new file mode 100644 index 00000000..25d8c9de --- /dev/null +++ b/VDS/Components/Calendar/Date+Extension.swift @@ -0,0 +1,107 @@ +// +// Date+Extension.swift +// VDS +// +// Created by Kanamarlapudi, Vasavi on 26/04/24. +// + +import Foundation + +public extension Date { + + static var firstDayOfWeek = Calendar.current.firstWeekday + + /// Capitalizes the first letter of the day of the week + static var capitalizedFirstLettersOfWeekdays: [String] { + let calendar = Calendar.current + let weekdays = calendar.shortWeekdaySymbols + + return weekdays.map { weekday in + guard let firstLetter = weekday.first else { return "" } + return String(firstLetter).capitalized + } + } + + var startOfMonth: Date { + Calendar.current.dateInterval(of: .month, for: self)!.start + } + + var endOfMonth: Date { + let lastDay = Calendar.current.dateInterval(of: .month, for: self)!.end + return Calendar.current.date(byAdding: .day, value: -1, to: lastDay)! + } + + /// Get the number of days of the month + var numberOfDaysInMonth: Int { + Calendar.current.range(of: .day, in: .month, for: self)!.count + } + + var firstWeekDayBeforeStart: Date { + let startOfMonthWeekday = Calendar.current.component(.weekday, from: startOfMonth) + var numberFromPreviousMonth = startOfMonthWeekday - Self.firstDayOfWeek + if numberFromPreviousMonth < 0 { + numberFromPreviousMonth += 7 // Adjust to a 0-6 range if negative + } + return Calendar.current.date(byAdding: .day, value: -numberFromPreviousMonth, to: startOfMonth)! + } + + /// Get the days of the month to display + var calendarDisplayDays: [Date] { + var days: [Date] = [] + // Start with days from the previous month to fill the grid + let firstDisplayDay = firstWeekDayBeforeStart + var day = firstDisplayDay + while day < startOfMonth { + days.append(day) + day = Calendar.current.date(byAdding: .day, value: 1, to: day)! + } + // Add days of the current month + for dayOffset in 0.. Bool { + let from = Calendar.current.date(byAdding: .day, value: -1, to: date1)! + let to = Calendar.current.date(byAdding: .day, value: 1, to: date2)! + return from.compare(self) == self.compare(to) + } + + /// Returns the month name of the given date + func getMonthName() -> String { + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale.current + dateFormatter.setLocalizedDateFormatFromTemplate("MMMM") + return dateFormatter.string(from: self) + } + + func getDay() -> String { + if #available(iOS 15.0, *) { + return formatted(.dateTime.day()) + } else { + // Fallback on earlier versions + let dateFormatter: DateFormatter = DateFormatter() + dateFormatter.dateFormat = "d" + let day: String = dateFormatter.string(from: self) + return day + } + } +} diff --git a/VDS/Components/Checkbox/CheckboxGroup.swift b/VDS/Components/Checkbox/CheckboxGroup.swift index c7e424a1..43b8890d 100644 --- a/VDS/Components/Checkbox/CheckboxGroup.swift +++ b/VDS/Components/Checkbox/CheckboxGroup.swift @@ -14,6 +14,7 @@ import VDSTokens /// to allow user selection. @objc(VDSCheckboxGroup) open class CheckboxGroup: SelectorGroupBase, SelectorGroupMultiSelect { + //-------------------------------------------------- // MARK: - Initializers //-------------------------------------------------- @@ -32,6 +33,10 @@ open class CheckboxGroup: SelectorGroupBase, SelectorGroupMultiSel //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- + public var inputId: String? + + public var value: [SelectorItemType]? { selectedItems } + /// Array of ``CheckboxItemModel`` that will be used to build the selectorViews of type ``CheckboxItem``. open var selectorModels: [CheckboxItemModel]? { didSet { @@ -41,9 +46,9 @@ open class CheckboxGroup: SelectorGroupBase, SelectorGroupMultiSel $0.isEnabled = !model.disabled $0.surface = model.surface $0.inputId = model.inputId - $0.value = model.value + $0.hiddenValue = model.value $0.accessibilityLabel = model.accessibileText - $0.accessibilityValue = "item \(index+1) of \(selectorModels.count)" + $0.accessibilityValueText = "item \(index+1) of \(selectorModels.count)" $0.labelText = model.labelText $0.labelTextAttributes = model.labelTextAttributes $0.childText = model.childText @@ -97,7 +102,7 @@ open class CheckboxGroup: SelectorGroupBase, SelectorGroupMultiSel } extension CheckboxGroup { - public struct CheckboxItemModel : Surfaceable, Initable, FormFieldable, Errorable { + public struct CheckboxItemModel : Surfaceable, Initable, Errorable { /// Whether this object is disabled or not public var disabled: Bool diff --git a/VDS/Components/DatePicker/DatePicker.swift b/VDS/Components/DatePicker/DatePicker.swift new file mode 100644 index 00000000..e169cccf --- /dev/null +++ b/VDS/Components/DatePicker/DatePicker.swift @@ -0,0 +1,196 @@ +import Foundation +import UIKit +import VDSTokens +import Combine + +/// A dropdown select is an expandable menu of predefined options that allows a customer to make a single selection. +@objc(VDSDatePicker) +open class DatePicker: EntryFieldBase, DatePickerViewControllerDelegate, UIPopoverPresentationControllerDelegate { + //-------------------------------------------------- + // 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: - Public Properties + //-------------------------------------------------- + /// A callback when the selected option changes. Passes parameters (option). + open var onDateSelected: ((Date, DatePicker) -> Void)? + + //-------------------------------------------------- + // MARK: - Private Properties + //-------------------------------------------------- + internal var minWidthDefault = 186.0 + + internal var bottomStackView: UIStackView = { + return UIStackView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.axis = .vertical + $0.distribution = .fill + $0.alignment = .top + $0.spacing = VDSLayout.space2X + } + }() + + //-------------------------------------------------- + // MARK: - Public Properties + //-------------------------------------------------- + open var calendarIcon = Icon().with { + $0.name = .calendar + $0.size = .medium + } + + open var selectedDate: Date? { didSet { setNeedsUpdate() } } + + open var calendarModel: CalendarModel = .init() { didSet { setNeedsUpdate() } } + + open override var value: String? { + get { selectedDateLabel.text } + set { } + } + + open var selectedDateLabel = Label().with { + $0.setContentCompressionResistancePriority(.required, for: .vertical) + $0.textAlignment = .left + $0.textStyle = .bodyLarge + $0.lineBreakMode = .byCharWrapping + } + + public enum DateFormat: String, CaseIterable, CustomStringConvertible { + case shortNumeric + case longAlphabetic + case mediumNumeric + case consiseNumeric + + public var format: String { + switch self { + case .shortNumeric: "MM/dd/yy" + case .longAlphabetic: "MMMM d, yyyy" + case .mediumNumeric: "MM/dd/yyyy" + case .consiseNumeric: "M/d/yyyy" + } + } + + public var description: String { + return format + } + } + + open var dateFormat: DateFormat = .shortNumeric { didSet{ setNeedsUpdate() } } + + //-------------------------------------------------- + // MARK: - Configuration Properties + //-------------------------------------------------- + internal override var containerSize: CGSize { CGSize(width: minWidthDefault, height: 44) } + + //-------------------------------------------------- + // 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() + + fieldStackView.isAccessibilityElement = true + fieldStackView.accessibilityLabel = "Date Picker" + fieldStackView.accessibilityHint = "Double Tap to open" + + // setting color config + selectedDateLabel.textColorConfiguration = primaryColorConfiguration.eraseToAnyColorable() + + // tap gesture + fieldStackView + .publisher(for: UITapGestureRecognizer()) + .sink { [weak self] _ in + guard let self else { return } + if self.isEnabled && !self.isReadOnly { + self.togglePicker() + } + } + .store(in: &subscribers) + } + + open override func getFieldContainer() -> UIView { + // stackview for controls in EntryFieldBase.controlContainerView + let controlStackView = UIStackView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.axis = .horizontal + $0.spacing = VDSLayout.space3X + } + controlStackView.addArrangedSubview(calendarIcon) + controlStackView.addArrangedSubview(selectedDateLabel) + return controlStackView + } + + /// Used to make changes to the View based off a change events or from local properties. + open override func updateView() { + super.updateView() + + if let selectedDate { + formatDate(selectedDate) + } + + selectedDateLabel.surface = surface + selectedDateLabel.isEnabled = isEnabled + calendarIcon.color = iconColorConfiguration.getColor(self) + } + + open override func updateAccessibility() { + super.updateAccessibility() + let label = "Date Picker, \(isReadOnly ? ", read only" : "")" + if let errorText, showError { + fieldStackView.accessibilityLabel = "\(label) ,error, \(errorText)" + } else { + fieldStackView.accessibilityLabel = label + } + fieldStackView.accessibilityHint = isReadOnly || !isEnabled ? "" : "Double tap to open." + fieldStackView.accessibilityValue = value + } + + /// 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 + selectedDateLabel.text = formatter.string(from: date) + } + + internal func togglePicker() { + let calendarVC = DatePickerViewController(calendarModel, delegate: self) + calendarVC.modalPresentationStyle = .popover + calendarVC.selectedDate = selectedDate ?? Date() + if let popoverController = calendarVC.popoverPresentationController { + popoverController.delegate = self + popoverController.sourceView = containerView + popoverController.sourceRect = containerView.bounds + popoverController.permittedArrowDirections = .up + } + if let viewController = UIApplication.topViewController() { + viewController.present(calendarVC, animated: true, completion: nil) + } + } + + internal func didSelectDate(_ controller: DatePickerViewController, date: Date) { + selectedDate = date + controller.dismiss(animated: true) + sendActions(for: .valueChanged) + } + + public func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle { + return .none + } +} diff --git a/VDS/Components/DatePicker/DatePickerCalendarModel.swift b/VDS/Components/DatePicker/DatePickerCalendarModel.swift new file mode 100644 index 00000000..ccceb5c9 --- /dev/null +++ b/VDS/Components/DatePicker/DatePickerCalendarModel.swift @@ -0,0 +1,56 @@ +// +// DatePicker-CalendarModel.swift +// VDS +// +// Created by Matt Bruce on 5/14/24. +// + +import Foundation +import UIKit + +extension DatePicker { + public struct CalendarModel { + public let surface: Surface + + /// If set to true, the calendar will not have a border. + public let hideContainerBorder: Bool + + /// If set to true, the calendar will not have current date indication. + public let hideCurrentDateIndicator: Bool + + /// Enable specific days. Pass an array of string value in date format e.g. ['07/21/2024', '07/24/2024', 07/28/2024']. + /// All other dates will be inactive. + public let activeDates: [Date] + + /// Disable specific days. Pass an array of string value in date format e.g. ['07/21/2024', '07/24/2024', 07/28/2024']. + /// All other dates will be active. + public let inactiveDates: [Date] + + /// If provided, the calendar will allow a selection to be made from this date forward. Defaults to today. + public let minDate: Date + + /// If provided, the calendar will allow a selection to be made up to this date. + public let maxDate: Date + + /// Array of ``CalendarIndicatorModel`` you are wanting to show on legend. + public let indicators: [CalendarBase.CalendarIndicatorModel] + + public init(surface: Surface = .light, + hideContainerBorder: Bool = false, + hideCurrentDateIndicator: Bool = false, + activeDates: [Date] = [], + inactiveDates: [Date] = [], + minDate: Date = Date().startOfMonth, + maxDate: Date = Date().endOfMonth, + indicators: [CalendarBase.CalendarIndicatorModel] = []) { + self.surface = surface + self.hideContainerBorder = hideContainerBorder + self.hideCurrentDateIndicator = hideCurrentDateIndicator + self.activeDates = activeDates + self.inactiveDates = inactiveDates + self.minDate = minDate + self.maxDate = maxDate + self.indicators = indicators + } + } +} diff --git a/VDS/Components/DatePicker/DatePickerChangeLog.txt b/VDS/Components/DatePicker/DatePickerChangeLog.txt new file mode 100644 index 00000000..e588b709 --- /dev/null +++ b/VDS/Components/DatePicker/DatePickerChangeLog.txt @@ -0,0 +1,48 @@ +MM/DD/YYYY +---------------- +Initial Brand 3.0 handoff + +01/06/2021 +---------------- +Removed Max Width, Increased Min width. +Updated the tokens with FormControl tokens + +02/24/2022 +---------------- +Replaced Calendar, Info, Error, Caret Left, and Caret Right Non-Scaling icons with VDS Icon. + +06/22/2022 +---------------- +Updated Calendar instances with new Calendar (with Indicators) + +07/27/2022 +---------------- +Updated Calendar Date Picker Configurations to include Background Transparent Boolean. +Moved Default Selection Date, Always opened and Date formats to configurartions page. + +08/04/2022 +---------------- +Updated default and inverted prop to light and dark surface. + +09/09/2022 +---------------- +Added missing helper text from disabled state visuals. + +12/13/2022 +---------------- +Updated supported date formats +Added "(web only)" to any instance of "keyboard focus" +Replaced form border and focus border pixel values, styles & spacing with tokens. + +01/20/2023 +---------------- +Updated Specs to use new SPEC Templates and SPEC DOC Components. +Tweaked written out date format so Mo becomes Mon + +04/12/2023 +---------------- +Updated hex colors for updated feedback tokens in error states. + +02/08/2024 +---------------- +Added Calendar position section to Behaviors. diff --git a/VDS/Components/DatePicker/DatePickerViewController.swift b/VDS/Components/DatePicker/DatePickerViewController.swift new file mode 100644 index 00000000..f8a6e2f0 --- /dev/null +++ b/VDS/Components/DatePicker/DatePickerViewController.swift @@ -0,0 +1,71 @@ +// +// DatePickerPopoverViewController.swift +// VDS +// +// Created by Matt Bruce on 5/14/24. +// + +import Foundation +import UIKit + +protocol DatePickerViewControllerDelegate: NSObject { + func didSelectDate(_ controller: DatePicker.DatePickerViewController, date: Date) +} + +extension DatePicker { + class DatePickerViewController: UIViewController { + private var padding: CGFloat = 15 + private var topPadding: CGFloat { 10 + padding } + private var calendarModel: CalendarModel + private let picker = CalendarBase() + weak var delegate: DatePickerViewControllerDelegate? + + init(_ calendarModel: CalendarModel, delegate: DatePickerViewControllerDelegate?) { + self.delegate = delegate + self.calendarModel = calendarModel + super.init(nibName: nil, bundle: nil) + self.picker.onChange = { [weak self] control in + guard let self else { return } + self.delegate?.didSelectDate(self, date: control.selectedDate) + } + } + + var selectedDate: Date = Date() { + didSet { + picker.selectedDate = selectedDate + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.addSubview(picker) + picker.surface = calendarModel.surface + picker.hideContainerBorder = calendarModel.hideContainerBorder + picker.hideCurrentDateIndicator = calendarModel.hideCurrentDateIndicator + picker.indicators = calendarModel.indicators + picker.activeDates = calendarModel.activeDates + picker.inactiveDates = calendarModel.inactiveDates + picker.selectedDate = selectedDate + picker.minDate = calendarModel.minDate + picker.maxDate = calendarModel.maxDate + picker.pinToSuperView(.init(top: topPadding, left: padding, bottom: padding, right: padding)) + view.backgroundColor = picker.backgroundColor + } + + override var preferredContentSize: CGSize { + get { + var size = picker.frame.size + size.height += 40 + size.width += 30 + return size + } + set { + super.preferredContentSize = newValue + } + } + } +} diff --git a/VDS/Components/DropdownSelect/DropdownSelect.swift b/VDS/Components/DropdownSelect/DropdownSelect.swift index 11550937..54007c5a 100644 --- a/VDS/Components/DropdownSelect/DropdownSelect.swift +++ b/VDS/Components/DropdownSelect/DropdownSelect.swift @@ -31,12 +31,29 @@ open class DropdownSelect: EntryFieldBase { //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- + /// Override UIControl state to add the .error state if showSuccess is true and if showError is true. + open override var state: UIControl.State { + get { + var state = super.state + if dropdownField.isFirstResponder { + state.insert(.focused) + } + + return state + } + } + /// If true, the label will be displayed inside the dropdown containerView. Otherwise, the label will be above the dropdown containerView like a normal text input. open var showInlineLabel: Bool = false { didSet { setNeedsUpdate() }} /// Allows unique ID to be passed to the element. open var selectId: Int? { didSet { setNeedsUpdate() }} + /// Current SelectedItem Value + open override var value: String? { + selectedItem?.value + } + /// Current SelectedItem open var selectedItem: DropdownOptionModel? { guard let selectId else { return nil } @@ -46,30 +63,46 @@ open class DropdownSelect: EntryFieldBase { /// Array of options to show open var options: [DropdownOptionModel] = [] { didSet { setNeedsUpdate() }} - /// A callback when the selected option changes. Passes parameters (option). - open var onItemSelected: ((Int, DropdownOptionModel) -> Void)? - //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- internal var minWidthDefault = 66.0 internal var minWidthInlineLabel = 102.0 + internal override var minWidth: CGFloat { showInlineLabel ? minWidthInlineLabel : minWidthDefault } + internal override var maxWidth: CGFloat { + let frameWidth = frame.size.width + return helperTextPlacement == .right ? (frameWidth - horizontalStackView.spacing) / 2 : frameWidth + } + + /// The is used for the for adding the helperLabel to the right of the containerView. + internal var horizontalStackView: UIStackView = { + return UIStackView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.axis = .horizontal + $0.distribution = .fillEqually + $0.spacing = VDSLayout.space3X + $0.alignment = .top + } + }() //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- open var inlineDisplayLabel = Label().with { + $0.setContentCompressionResistancePriority(.required, for: .vertical) + $0.setContentCompressionResistancePriority(.required, for: .horizontal) $0.textAlignment = .left $0.textStyle = .boldBodyLarge - $0.lineBreakMode = .byCharWrapping + $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.lineBreakMode = .byCharWrapping + $0.numberOfLines = 1 } open var dropdownField = UITextField().with { @@ -88,13 +121,7 @@ open class DropdownSelect: EntryFieldBase { //-------------------------------------------------- // MARK: - Configuration Properties //-------------------------------------------------- - internal override var containerSize: CGSize { CGSize(width: showInlineLabel ? minWidthInlineLabel : width ?? minWidthDefault, height: 44) } - - internal let iconColorConfiguration = ControlColorConfiguration().with { - $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .normal) - $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: .disabled) - $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .error) - } + internal override var containerSize: CGSize { .init(width: minWidthDefault, height: 44) } //-------------------------------------------------- // MARK: - Overrides @@ -102,26 +129,11 @@ open class DropdownSelect: EntryFieldBase { /// Called once when a view is initialized and is used to Setup additional UI or other constants and configurations. open override func setup() { - super.setup() + super.setup() + + fieldStackView.isAccessibilityElement = true + inlineDisplayLabel.isAccessibilityElement = true - accessibilityLabel = "Dropdown Select" - - // stackview for controls in EntryFieldBase.controlContainerView - let controlStackView = UIStackView().with { - $0.translatesAutoresizingMaskIntoConstraints = false - $0.axis = .horizontal - $0.spacing = VDSFormControls.spaceInset - } - controlContainerView.addSubview(controlStackView) - controlStackView.pinToSuperView() - - controlStackView.addArrangedSubview(dropdownField) - controlStackView.addArrangedSubview(inlineDisplayLabel) - controlStackView.addArrangedSubview(selectedOptionLabel) - - controlStackView.setCustomSpacing(0, after: dropdownField) - controlStackView.setCustomSpacing(VDSLayout.space1X, after: inlineDisplayLabel) - controlStackView.setCustomSpacing(VDSLayout.space3X, after: selectedOptionLabel) dropdownField.width(0) inlineWidthConstraint = inlineDisplayLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 0) inlineWidthConstraint?.isActive = true @@ -136,20 +148,21 @@ open class DropdownSelect: EntryFieldBase { optionsPicker.isHidden = true dropdownField.inputView = optionsPicker dropdownField.inputAccessoryView = { - let inputToolbar = UIToolbar().with { - $0.barStyle = .default - $0.isTranslucent = true - $0.items=[ - UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.flexibleSpace, target: self, action: nil), - UIBarButtonItem(title: "Done", style: UIBarButtonItem.Style.done, target: self, action: #selector(pickerDoneClicked)) - ] - } - inputToolbar.sizeToFit() - return inputToolbar + 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) + let done = UIButton(type: .system) + done.setTitle("Done", for: .normal) + done.translatesAutoresizingMaskIntoConstraints = false + done.addTarget(self, action: #selector(pickerDoneClicked), for: .touchUpInside) + accessView.addSubview(done) + done.pinCenterY() + .pinTrailing(16) + return accessView }() // tap gesture - containerStackView + containerView .publisher(for: UITapGestureRecognizer()) .sink { [weak self] _ in self?.launchPicker() @@ -157,13 +170,29 @@ open class DropdownSelect: EntryFieldBase { .store(in: &subscribers) } + open override func getFieldContainer() -> UIView { + let controlStackView = UIStackView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.axis = .horizontal + $0.distribution = .fill + $0.spacing = VDSFormControls.spaceInset + } + controlStackView.addArrangedSubview(dropdownField) + controlStackView.addArrangedSubview(inlineDisplayLabel) + controlStackView.addArrangedSubview(selectedOptionLabel) + controlStackView.setCustomSpacing(0, after: dropdownField) + controlStackView.setCustomSpacing(VDSLayout.space1X, after: inlineDisplayLabel) + controlStackView.setCustomSpacing(VDSLayout.space3X, after: selectedOptionLabel) + return controlStackView + } + /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { super.updateView() updateInlineLabel() - dropdownField.isUserInteractionEnabled = readOnly ? false : true + dropdownField.isUserInteractionEnabled = isReadOnly ? false : true selectedOptionLabel.surface = surface selectedOptionLabel.isEnabled = isEnabled } @@ -191,12 +220,12 @@ open class DropdownSelect: EntryFieldBase { updatedLabelText = showInlineLabel ? "" : updatedLabelText - if let oldText = updatedLabelText, !required, !oldText.hasSuffix("Optional") { - let optionColorAttr = ColorLabelAttribute(location: oldText.count + 2, + if let oldText = updatedLabelText, !isRequired, !oldText.hasSuffix("Optional") { + let optionColorAttr = ColorLabelAttribute(location: oldText.count + 1, length: 8, color: secondaryColorConfiguration.getColor(self)) - updatedLabelText = showInlineLabel ? "Optional" : "\(oldText) Optional" + updatedLabelText = showInlineLabel ? "Optional" : "\(oldText) Optional" attributes.append(optionColorAttr) } @@ -234,22 +263,74 @@ open class DropdownSelect: EntryFieldBase { open func updateSelectedOptionLabel(option: DropdownOptionModel? = nil) { selectedOptionLabel.text = option?.text ?? "" - value = option?.value } open override func updateErrorLabel() { super.updateErrorLabel() if !showError && !hasInternalError { - icon.name = .downCaret + statusIcon.name = .downCaret } - icon.surface = surface - icon.isHidden = readOnly ? true : false - icon.color = iconColorConfiguration.getColor(self) + statusIcon.surface = surface + statusIcon.isHidden = isReadOnly ? true : false + statusIcon.color = iconColorConfiguration.getColor(self) } + open override func updateAccessibility() { + super.updateAccessibility() + let label = "Dropdown Select, \(isReadOnly ? ", read only" : "")" + if let errorText, showError { + fieldStackView.accessibilityLabel = "\(label) ,error, \(errorText)" + } else { + fieldStackView.accessibilityLabel = label + } + fieldStackView.accessibilityHint = isReadOnly || !isEnabled ? "" : "Double tap to open." + fieldStackView.accessibilityValue = value + } + + open override var accessibilityElements: [Any]? { + get { + var elements = [Any]() + elements.append(contentsOf: [titleLabel, fieldStackView]) + + if showError { + elements.append(statusIcon) + if let errorText, !errorText.isEmpty { + elements.append(errorLabel) + } + } + + if let helperText, !helperText.isEmpty { + elements.append(helperLabel) + } + + return elements + } + + set { super.accessibilityElements = newValue } + } + + @objc open func pickerDoneClicked() { optionsPicker.isHidden = true dropdownField.resignFirstResponder() + setNeedsUpdate() + UIAccessibility.post(notification: .layoutChanged, argument: fieldStackView) + } + + open override var canBecomeFirstResponder: Bool { + return dropdownField.canBecomeFirstResponder + } + + open override func becomeFirstResponder() -> Bool { + return dropdownField.becomeFirstResponder() + } + + open override var canResignFirstResponder: Bool { + return dropdownField.canResignFirstResponder + } + + open override func resignFirstResponder() -> Bool { + return dropdownField.resignFirstResponder() } } @@ -257,14 +338,16 @@ open class DropdownSelect: EntryFieldBase { // MARK: - UIPickerView Delegate & Datasource //-------------------------------------------------- extension DropdownSelect: UIPickerViewDelegate, UIPickerViewDataSource { - + internal func launchPicker() { if optionsPicker.isHidden { + UIAccessibility.post(notification: .layoutChanged, argument: optionsPicker) dropdownField.becomeFirstResponder() } else { dropdownField.resignFirstResponder() } optionsPicker.isHidden = !optionsPicker.isHidden + updateContainerView() } public func numberOfComponents(in pickerView: UIPickerView) -> Int { @@ -284,6 +367,6 @@ extension DropdownSelect: UIPickerViewDelegate, UIPickerViewDataSource { guard options.count > row else { return } selectId = row updateSelectedOptionLabel(option: options[row]) - self.onItemSelected?(row, options[row]) + sendActions(for: .valueChanged) } } diff --git a/VDS/Components/Icon/ButtonIcon/ButtonIcon.swift b/VDS/Components/Icon/ButtonIcon/ButtonIcon.swift index 658cb05e..5ba6a9fe 100644 --- a/VDS/Components/Icon/ButtonIcon/ButtonIcon.swift +++ b/VDS/Components/Icon/ButtonIcon/ButtonIcon.swift @@ -14,7 +14,7 @@ import Combine /// It usually represents a supplementary or utilitarian action. A button icon can stand alone, but often /// exists in a group when there are several actions that can be performed. @objc(VDSButtonIcon) -open class ButtonIcon: Control, Changeable, FormFieldable { +open class ButtonIcon: Control, Changeable { //-------------------------------------------------- // MARK: - Initializers @@ -173,10 +173,6 @@ open class ButtonIcon: Control, Changeable, FormFieldable { /// Used to move the icon inside the button in both x and y axis. open var iconOffset: CGPoint = .init(x: 0, y: 0) { didSet { setNeedsUpdate() } } - open var inputId: String? { didSet { setNeedsUpdate() } } - - open var value: AnyHashable? { didSet { setNeedsUpdate() } } - //-------------------------------------------------- // MARK: - Configuration //-------------------------------------------------- @@ -548,6 +544,7 @@ open class ButtonIcon: Control, Changeable, FormFieldable { badgeIndicator.horizontalPadding = badgeIndicatorModel.horizontalPadding badgeIndicator.hideDot = badgeIndicatorModel.hideDot badgeIndicator.hideBorder = badgeIndicatorModel.hideBorder + badgeIndicator.accessibilityText = badgeIndicatorModel.accessibilityText } private func updateExpandDirectionalConstraints() { diff --git a/VDS/Components/Icon/ButtonIcon/ButtonIconBadgeIndicatorModel.swift b/VDS/Components/Icon/ButtonIcon/ButtonIconBadgeIndicatorModel.swift index e1c04b23..28d731b0 100644 --- a/VDS/Components/Icon/ButtonIcon/ButtonIconBadgeIndicatorModel.swift +++ b/VDS/Components/Icon/ButtonIcon/ButtonIconBadgeIndicatorModel.swift @@ -46,6 +46,9 @@ extension ButtonIcon { /// Trailing Text height that will be used for the badge indicator. public var trailingText: String? + /// Accessibliity Text + public var accessibilityText: String? + /// Dot Size that will be used for the badge indicator. public var dotSize: CGFloat? @@ -61,7 +64,7 @@ extension ButtonIcon { /// Hide Border that will be used for the badge indicator. public var hideBorder: Bool = false - public init(kind: BadgeIndicator.Kind = .simple, fillColor: BadgeIndicator.FillColor = .red, expandDirection: ExpandDirection = .right, size: BadgeIndicator.Size = .xxlarge, maximumDigits: BadgeIndicator.MaximumDigits = .two, width: CGFloat? = nil, height: CGFloat? = nil, number: Int? = nil, leadingCharacter: String? = "", trailingText: String? = "", dotSize: CGFloat? = nil, verticalPadding: CGFloat? = nil, horizontalPadding: CGFloat? = nil, hideDot: Bool = false, hideBorder: Bool = false) { + public init(kind: BadgeIndicator.Kind = .simple, fillColor: BadgeIndicator.FillColor = .red, expandDirection: ExpandDirection = .right, size: BadgeIndicator.Size = .xxlarge, maximumDigits: BadgeIndicator.MaximumDigits = .two, width: CGFloat? = nil, height: CGFloat? = nil, number: Int? = nil, leadingCharacter: String? = "", trailingText: String? = "", accessibilityText: String? = nil, dotSize: CGFloat? = nil, verticalPadding: CGFloat? = nil, horizontalPadding: CGFloat? = nil, hideDot: Bool = false, hideBorder: Bool = false) { self.kind = kind self.fillColor = fillColor self.expandDirection = expandDirection @@ -70,6 +73,7 @@ extension ButtonIcon { self.width = width self.height = height self.number = number + self.accessibilityText = accessibilityText self.leadingCharacter = leadingCharacter self.trailingText = trailingText self.dotSize = dotSize diff --git a/VDS/Components/Icon/Icon.swift b/VDS/Components/Icon/Icon.swift index 09c1ffec..e5069f8f 100644 --- a/VDS/Components/Icon/Icon.swift +++ b/VDS/Components/Icon/Icon.swift @@ -56,10 +56,12 @@ open class Icon: View { if let hex = color.hexString, !UIColor.isVDSColor(color: color) { print("icon.color is not a VDSColor. Hex: \(hex) is not a supported color") } - setNeedsUpdate() + colorConfiguration = SurfaceColorConfiguration(color, color) } } + open var colorConfiguration = SurfaceColorConfiguration(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark) { didSet { setNeedsUpdate() } } + /// Size of the icon. open var size: Size = .medium { didSet { setNeedsUpdate() } } @@ -98,15 +100,8 @@ open class Icon: View { open override func updateView() { super.updateView() //get the color for the image - var imageColor = color - - //ensure the correct color for white/black colors - if surface == .dark && color == VDSColor.paletteBlack { - imageColor = VDSColor.elementsPrimaryOndark - } else if surface == .light && color == VDSColor.paletteBlack { - imageColor = VDSColor.elementsPrimaryOnlight - } - + let imageColor = colorConfiguration.getColor(surface) + //get the image name //set the image if let name, let image = UIImage.image(for: name, color: imageColor) { diff --git a/VDS/Components/Icon/IconName.swift b/VDS/Components/Icon/IconName.swift index 3a17f686..6af40d3d 100644 --- a/VDS/Components/Icon/IconName.swift +++ b/VDS/Components/Icon/IconName.swift @@ -48,12 +48,14 @@ extension Icon { internal static let paginationRightCaret = Name(name: "pagination-right-caret") internal static let verizonUp = Name(name: "verizon-up") internal static let warningBold = Name(name: "warning-bold") + public static let calendar = Name(name: "calendar") public static let checkmark = Name(name: "checkmark") public static let checkmarkAlt = Name(name: "checkmark-alt") public static let close = Name(name: "close") public static let downCaret = Name(name: "down-caret") public static let downCaretBold = Name(name: "down-caret-bold") public static let error = Name(name: "error") + public static let externalLink = Icon.Name(name: "external-link") public static let info = Name(name: "info") public static let multipleDocuments = Name(name: "multiple-documents") public static let leftArrow = Name(name: "left-arrow") @@ -61,5 +63,6 @@ extension Icon { public static let rightArrow = Name(name: "right-arrow") public static let rightCaret = Name(name: "right-caret") public static let warning = Name(name: "warning") + } } diff --git a/VDS/Components/Label/Attributes/TooltipLabelAttribute.swift b/VDS/Components/Label/Attributes/TooltipLabelAttribute.swift index 187d4eda..6b03d103 100644 --- a/VDS/Components/Label/Attributes/TooltipLabelAttribute.swift +++ b/VDS/Components/Label/Attributes/TooltipLabelAttribute.swift @@ -37,17 +37,19 @@ public class TooltipLabelAttribute: ActionLabelAttributeModel, TooltipLaunchable } var frame = CGRect.zero + let ratio: Double = 1.0 //0.80 + let yPosition: Double = -3 if let font = attributedString.attribute(.font, at: 0, effectiveRange: &originalRange) as? UIFont { switch font.pointSize { case 15..<25: size = .medium - frame = CGRect(x: 0, y: -1, width: size.value.dimensions.width * 0.80, height: size.value.dimensions.height * 0.80) + frame = CGRect(x: 0, y: yPosition, width: size.value.dimensions.width * ratio, height: size.value.dimensions.height * ratio) case 0..<14: size = .small - frame = CGRect(x: 0, y: -1, width: size.value.dimensions.width * 0.80 , height: size.value.dimensions.height * 0.80) + frame = CGRect(x: 0, y: yPosition, width: size.value.dimensions.width * ratio , height: size.value.dimensions.height * ratio) default: size = .medium - frame = CGRect(x: 0, y: -1, width: size.value.dimensions.width, height: size.value.dimensions.height) + frame = CGRect(x: 0, y: yPosition, width: size.value.dimensions.width, height: size.value.dimensions.height) } } @@ -75,7 +77,7 @@ public class TooltipLabelAttribute: ActionLabelAttributeModel, TooltipLaunchable self.subscriber = subscriber self.surface = surface self.model = model - self.accessibleText = accessibleText + self.accessibleText = accessibleText ?? model.accessibleText self.presenter = presenter //create the tooltip click event diff --git a/VDS/Components/Notification/Notification.swift b/VDS/Components/Notification/Notification.swift index 241efb34..b1010176 100644 --- a/VDS/Components/Notification/Notification.swift +++ b/VDS/Components/Notification/Notification.swift @@ -52,7 +52,7 @@ open class Notification: View { } } - var accessibilityText: String { + var accessibleText: String { switch self { case .info: "Information Message" @@ -104,6 +104,7 @@ open class Notification: View { open var typeIcon = Icon().with { $0.name = .infoBold $0.size = UIDevice.isIPad ? .medium : .small + $0.accessibilityTraits.remove(.image) } /// Icon used for the close. @@ -374,7 +375,7 @@ open class Notification: View { open override func updateAccessibility() { super.updateAccessibility() closeButton.accessibilityLabel = "Close Notification" - typeIcon.accessibilityLabel = style.accessibilityText + typeIcon.accessibilityLabel = style.accessibleText } private func setConstraints() { diff --git a/VDS/Components/Pagination/Pagination.swift b/VDS/Components/Pagination/Pagination.swift index f5a4e2c7..d3c0298a 100644 --- a/VDS/Components/Pagination/Pagination.swift +++ b/VDS/Components/Pagination/Pagination.swift @@ -189,6 +189,7 @@ open class Pagination: View { nextButton.isHidden = _selectedPageIndex == total - 1 collectionView.reloadData() verifyIfMaxDigitChanged() + setNeedsUpdate() } ///Identifying if there is any change in the digits of upcoming page diff --git a/VDS/Components/RadioBox/RadioBoxGroup.swift b/VDS/Components/RadioBox/RadioBoxGroup.swift index 18d3c26b..296ea8ed 100644 --- a/VDS/Components/RadioBox/RadioBoxGroup.swift +++ b/VDS/Components/RadioBox/RadioBoxGroup.swift @@ -32,6 +32,10 @@ open class RadioBoxGroup: SelectorGroupBase, SelectorGroupSingleSe //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- + public var inputId: String? + + public var value: SelectorItemType? { selectedItem } + /// Array of ``RadioBoxItemModel`` that will be used to build the selectorViews of type ``RadioBoxItem``. open var selectorModels: [RadioBoxItemModel]? { didSet { @@ -48,9 +52,11 @@ open class RadioBoxGroup: SelectorGroupBase, SelectorGroupSingleSe $0.subTextRightAttributes = model.subTextRightAttributes $0.isEnabled = !model.disabled $0.inputId = model.inputId + $0.hiddenValue = model.value $0.isSelected = model.selected $0.strikethrough = model.strikethrough $0.strikethroughAccessibilityText = model.strikethroughAccessibileText + $0.accessibilityValueText = "item \(index+1) of \(selectorModels.count)" } } } diff --git a/VDS/Components/RadioBox/RadioBoxItem.swift b/VDS/Components/RadioBox/RadioBoxItem.swift index 23dd2b41..f102b7c2 100644 --- a/VDS/Components/RadioBox/RadioBoxItem.swift +++ b/VDS/Components/RadioBox/RadioBoxItem.swift @@ -13,7 +13,7 @@ import VDSTokens /// Radio boxes are single-select components through which a customer indicates a choice /// that are used within a ``RadioBoxGroup``. @objc(VDSRadioBoxItem) -open class RadioBoxItem: Control, Changeable, FormFieldable { +open class RadioBoxItem: Control, Changeable, FormFieldable, Groupable { //-------------------------------------------------- // MARK: - Initializers @@ -127,8 +127,12 @@ open class RadioBoxItem: Control, Changeable, FormFieldable { open var inputId: String? { didSet { setNeedsUpdate() } } - open var value: AnyHashable? { didSet { setNeedsUpdate() } } + open var value: AnyHashable? { hiddenValue } + open var hiddenValue: AnyHashable? { didSet { setNeedsUpdate() } } + + open var accessibilityValueText: String? + //-------------------------------------------------- // MARK: - Configuration Properties //-------------------------------------------------- @@ -211,7 +215,7 @@ open class RadioBoxItem: Control, Changeable, FormFieldable { subTextRightAttributedText = nil strikethrough = false inputId = nil - value = nil + hiddenValue = nil isSelected = false onChange = nil @@ -239,7 +243,15 @@ open class RadioBoxItem: Control, Changeable, FormFieldable { open override func updateAccessibility() { super.updateAccessibility() setAccessibilityLabel(for: [textLabel, subTextLabel, subTextRightLabel]) - accessibilityValue = strikethrough ? strikethroughAccessibilityText : nil + if let accessibilityValueText { + accessibilityValue = strikethrough + ? "\(strikethroughAccessibilityText), \(accessibilityValueText)" + : accessibilityValueText + } else { + accessibilityValue = strikethrough + ? "\(strikethroughAccessibilityText)" + : accessibilityValueText + } } //-------------------------------------------------- diff --git a/VDS/Components/RadioButton/RadioButtonGroup.swift b/VDS/Components/RadioButton/RadioButtonGroup.swift index 6b12f60a..ca91f3e5 100644 --- a/VDS/Components/RadioButton/RadioButtonGroup.swift +++ b/VDS/Components/RadioButton/RadioButtonGroup.swift @@ -32,6 +32,10 @@ open class RadioButtonGroup: SelectorGroupBase, SelectorGroupSi //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- + public var inputId: String? + + public var value: SelectorItemType? { selectedItem } + /// Array of ``RadioButtonItemModel`` that will be used to build the selectorViews of type ``RadioButtonItem``. open var selectorModels: [RadioButtonItemModel]? { didSet { @@ -41,9 +45,9 @@ open class RadioButtonGroup: SelectorGroupBase, SelectorGroupSi $0.isEnabled = !model.disabled $0.surface = model.surface $0.inputId = model.inputId - $0.value = model.value + $0.hiddenValue = model.value $0.accessibilityLabel = model.accessibileText - $0.accessibilityValue = "item \(index+1) of \(selectorModels.count)" + $0.accessibilityValueText = "item \(index+1) of \(selectorModels.count)" $0.labelText = model.labelText $0.labelTextAttributes = model.labelTextAttributes $0.childText = model.childText @@ -57,7 +61,7 @@ open class RadioButtonGroup: SelectorGroupBase, SelectorGroupSi setNeedsUpdate() } } - + private var _showError: Bool = false /// Whether not to show the error. diff --git a/VDS/Components/Tabs/Tab.swift b/VDS/Components/Tabs/Tab.swift index e96e20d6..2dddef93 100644 --- a/VDS/Components/Tabs/Tab.swift +++ b/VDS/Components/Tabs/Tab.swift @@ -13,7 +13,7 @@ import Combine extension Tabs { @objc(VDSTab) - open class Tab: Control { + open class Tab: Control, Groupable { //-------------------------------------------------- // MARK: - Initializers @@ -89,6 +89,8 @@ extension Tabs { open override var shouldHighlight: Bool { false } + open var accessibilityValueText: String? + //-------------------------------------------------- // MARK: - Configuration //-------------------------------------------------- @@ -178,6 +180,7 @@ extension Tabs { open override func updateAccessibility() { super.updateAccessibility() accessibilityLabel = text + accessibilityValue = accessibilityValueText } open override func layoutSubviews() { diff --git a/VDS/Components/Tabs/Tabs.swift b/VDS/Components/Tabs/Tabs.swift index 225b3f98..ddef2ad6 100644 --- a/VDS/Components/Tabs/Tabs.swift +++ b/VDS/Components/Tabs/Tabs.swift @@ -303,7 +303,7 @@ open class Tabs: View { tabItem.orientation = orientation tabItem.surface = surface tabItem.indicatorPosition = indicatorPosition - tabItem.accessibilityValue = "\(index+1) of \(tabViews.count) Tabs" + tabItem.accessibilityValueText = "\(index+1) of \(tabViews.count) Tabs" } } diff --git a/VDS/Components/TextFields/EntryFieldBase.swift b/VDS/Components/TextFields/EntryFieldBase.swift index a8ad917a..8cedb2f8 100644 --- a/VDS/Components/TextFields/EntryFieldBase.swift +++ b/VDS/Components/TextFields/EntryFieldBase.swift @@ -36,25 +36,42 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { public enum HelperTextPlacement: String, CaseIterable { case bottom, right } - + //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- - internal var stackView: UIStackView = { - return UIStackView().with { - $0.translatesAutoresizingMaskIntoConstraints = false - $0.axis = .vertical - $0.distribution = .fill - } - }() - - internal var containerView: UIView = { - return UIView().with { - $0.translatesAutoresizingMaskIntoConstraints = false - } - }() + internal let mainStackView = UIStackView().with { + $0.axis = .vertical + $0.alignment = .fill + $0.spacing = VDSLayout.space1X + $0.translatesAutoresizingMaskIntoConstraints = false + } - internal var containerStackView: UIStackView = { + internal let contentStackView = UIStackView().with { + $0.axis = .vertical + $0.alignment = .fill + $0.distribution = .fill + $0.spacing = VDSLayout.space2X + $0.translatesAutoresizingMaskIntoConstraints = false + } + + /// only used for helperTextPosition == .right + internal let row1StackView = UIStackView().with { + $0.axis = .horizontal + $0.spacing = VDSLayout.space3X + $0.alignment = .top + $0.distribution = .fillEqually + } + + /// only used for helperTextPosition == .right + internal let row2StackView = UIStackView().with { + $0.axis = .horizontal + $0.spacing = VDSLayout.space3X + $0.alignment = .top + $0.distribution = .fillEqually + } + + internal var fieldStackView: UIStackView = { return UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.axis = .horizontal @@ -63,31 +80,41 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { } }() - internal var controlContainerView: UIView = { - return UIView().with { - $0.translatesAutoresizingMaskIntoConstraints = false - } - }() - - internal var bottomContainerView: UIView = { - return UIView().with { - $0.translatesAutoresizingMaskIntoConstraints = false - } - }() - + /// This is a vertical stack used for the errorLabel and helperLabel. internal var bottomContainerStackView: UIStackView = { return UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.axis = .vertical $0.distribution = .fill + $0.spacing = VDSLayout.space2X + } + }() + + /// This is the view that will be wrapped with the border for userInteraction. + /// The only subview of this view is the fieldStackView + internal var containerView: UIView = { + return UIView().with { + $0.translatesAutoresizingMaskIntoConstraints = false } }() + /// This is set by a local method. + internal var bottomContainerView: UIView! + + //-------------------------------------------------- + // MARK: - Constraints + //-------------------------------------------------- + internal var widthConstraint: NSLayoutConstraint? + internal var trailingEqualsConstraint: NSLayoutConstraint? + internal var trailingLessThanEqualsConstraint: NSLayoutConstraint? + //-------------------------------------------------- // MARK: - Configuration Properties //-------------------------------------------------- // Sizes are from InVision design specs. - internal var containerSize: CGSize { CGSize(width: 45, height: 44) } + internal var maxWidth: CGFloat { frame.size.width } + internal var minWidth: CGFloat { containerSize.width } + internal var containerSize: CGSize { CGSize(width: minWidth, height: 44) } internal let primaryColorConfiguration = ViewColorConfiguration().with { $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forDisabled: true) @@ -103,15 +130,24 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { $0.setSurfaceColors(VDSFormControlsColor.backgroundOnlight, VDSFormControlsColor.backgroundOndark, forState: .normal) $0.setSurfaceColors(VDSFormControlsColor.backgroundOnlight, VDSFormControlsColor.backgroundOndark, forState: .disabled) $0.setSurfaceColors(VDSColor.feedbackErrorBackgroundOnlight, VDSColor.feedbackErrorBackgroundOndark, forState: .error) + $0.setSurfaceColors(VDSColor.feedbackErrorBackgroundOnlight, VDSColor.feedbackErrorBackgroundOndark, forState: [.error, .focused]) } internal var borderColorConfiguration = ControlColorConfiguration().with { $0.setSurfaceColors(VDSFormControlsColor.borderOnlight, VDSFormControlsColor.borderOndark, forState: .normal) + $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOnlight, forState: .focused) + $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOnlight, forState: [.focused, .error]) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: .disabled) $0.setSurfaceColors(VDSColor.feedbackErrorOnlight, VDSColor.feedbackErrorOndark, forState: .error) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: [.disabled,.error]) } + internal let iconColorConfiguration = ControlColorConfiguration().with { + $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .normal) + $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: .disabled) + $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .error) + } + internal var readOnlyBorderColorConfiguration = ControlColorConfiguration().with { $0.setSurfaceColors(VDSFormControlsColor.borderReadonlyOnlight, VDSFormControlsColor.borderReadonlyOndark, forState: .normal) } @@ -119,7 +155,7 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- - open var onChangeSubscriber: AnyCancellable? + open var onChangeSubscriber: AnyCancellable? open var titleLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) @@ -137,8 +173,9 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { $0.textStyle = .bodySmall } - open var icon: Icon = Icon().with { + open var statusIcon: Icon = Icon().with { $0.size = .medium + $0.isAccessibilityElement = false } open var labelText: String? { didSet { setNeedsUpdate() } } @@ -149,10 +186,7 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { open var showError: Bool = false { didSet { setNeedsUpdate() } } /// FormFieldValidator - internal var validator: (any FormFieldValidatorable)? - - /// Whether or not to show the internal error - open var hasInternalError: Bool { !(validator?.isValid ?? true) } + open var validator: (any FormFieldValidatorable)? /// Override UIControl state to add the .error state if showError is true. open override var state: UIControl.State { @@ -165,22 +199,8 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { } } - open var errorText: String? { - didSet { - updateContainerView() - updateErrorLabel() - setNeedsUpdate() - } - } - - open var internalErrorText: String? { - didSet { - updateContainerView() - updateErrorLabel() - setNeedsUpdate() - } - } - + open var errorText: String? { didSet { setNeedsUpdate() } } + open var tooltipModel: Tooltip.TooltipModel? { didSet { setNeedsUpdate() } } open var transparentBackground: Bool = false { didSet { setNeedsUpdate() } } @@ -190,92 +210,102 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { open var inputId: String? { didSet { setNeedsUpdate() } } /// The text of this textField. - internal var _value: String? open var value: String? { - get { _value } - set { - if let newValue, newValue != _value { - _value = newValue - sendActions(for: .valueChanged) - } - } + get { fatalError("must be read from subclass")} } open var defaultValue: AnyHashable? { didSet { setNeedsUpdate() } } - open var required: Bool = false { didSet { setNeedsUpdate() } } + open var isRequired: Bool = false { didSet { setNeedsUpdate() } } - open var readOnly: Bool = false { didSet { setNeedsUpdate() } } - - //-------------------------------------------------- - // MARK: - Constraints - //-------------------------------------------------- - internal var heightConstraint: NSLayoutConstraint? - internal var widthConstraint: NSLayoutConstraint? + open var isReadOnly: Bool = false { didSet { setNeedsUpdate() } } + + open var helperTextPlacement: HelperTextPlacement = .bottom { + didSet { + updateHelperTextPosition() + } + } + open var rules = [AnyRule]() + //-------------------------------------------------- // 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() - - isAccessibilityElement = true - addSubview(stackView) - //create the wrapping view - heightConstraint = containerView.heightAnchor.constraint(greaterThanOrEqualToConstant: containerSize.height) - heightConstraint?.priority = .defaultHigh - heightConstraint?.isActive = true + let layoutGuide = UILayoutGuide() + addLayoutGuide(layoutGuide) + layoutGuide + .pinTop() + .pinLeading() + .pinBottom() - widthConstraint = containerView.widthAnchor.constraint(equalToConstant: 0) - widthConstraint?.priority = .defaultHigh + trailingEqualsConstraint = layoutGuide.pinTrailing(anchor: trailingAnchor) + + // width constraints + trailingLessThanEqualsConstraint = layoutGuide.pinTrailingLessThanOrEqualTo(anchor: trailingAnchor)?.deactivate() + widthConstraint = layoutGuide.widthAnchor.constraint(equalToConstant: 0).deactivate() + + // Add mainStackView to the view + addSubview(mainStackView) - //get the container this is what is color - //border, internal, etc... - let container = getContainer() + mainStackView.pinTop(anchor: layoutGuide.topAnchor) + mainStackView.pinLeading(anchor: layoutGuide.leadingAnchor) + mainStackView.pinBottom(anchor: layoutGuide.bottomAnchor) + mainStackView.pinTrailing(anchor: layoutGuide.trailingAnchor) //add ContainerStackView //this is the horizontal stack that contains - //the left, InputContainer, Icons, Buttons - container.addSubview(containerStackView) - containerStackView.pinToSuperView(.uniform(12)) - + //InputContainer, Icons, Buttons + containerView.addSubview(fieldStackView) + fieldStackView.pinToSuperView(.uniform(VDSLayout.space3X)) + + let fieldContainerView = getFieldContainer() + fieldContainerView.translatesAutoresizingMaskIntoConstraints = false + //add the view to add input fields - containerStackView.addArrangedSubview(controlContainerView) - containerStackView.addArrangedSubview(icon) - containerStackView.setCustomSpacing(VDSLayout.space3X, after: controlContainerView) + fieldStackView.addArrangedSubview(fieldContainerView) + fieldStackView.addArrangedSubview(statusIcon) + fieldStackView.setCustomSpacing(VDSLayout.space3X, after: fieldContainerView) //get the container this is what show helper text, error text //can include other for character count, max length - let bottomContainer = getBottomContainer() - - //add bottomContainerStackView + bottomContainerView = getBottomContainer() + //this is the vertical stack that contains error text, helper text - bottomContainerView.addSubview(bottomContainerStackView) - bottomContainerStackView.pinToSuperView() bottomContainerStackView.addArrangedSubview(errorLabel) bottomContainerStackView.addArrangedSubview(helperLabel) - stackView.addArrangedSubview(titleLabel) - stackView.addArrangedSubview(container) - stackView.addArrangedSubview(bottomContainer) - - stackView.setCustomSpacing(4, after: titleLabel) - stackView.setCustomSpacing(8, after: container) - stackView.setCustomSpacing(8, after: bottomContainer) - - stackView - .pinTop() - .pinLeading() - .pinTrailing(0, .defaultHigh) - .pinBottom(0, .defaultHigh) + // Add arranged subviews to textFieldStackView + contentStackView.addArrangedSubview(containerView) + contentStackView.addArrangedSubview(bottomContainerView) + + // Add arranged subviews to mainStackView + mainStackView.addArrangedSubview(titleLabel) + mainStackView.addArrangedSubview(contentStackView) + // Initial position of the helper label + updateHelperTextPosition() + + // colorconfigs titleLabel.textColorConfiguration = primaryColorConfiguration.eraseToAnyColorable() errorLabel.textColorConfiguration = primaryColorConfiguration.eraseToAnyColorable() helperLabel.textColorConfiguration = secondaryColorConfiguration.eraseToAnyColorable() } - + + /// Updates the UI + open override func updateView() { + super.updateView() + updateContainerView() + updateContainerWidth() + updateTitleLabel() + updateErrorLabel() + updateHelperLabel() + } + /// Resets to default settings. open override func reset() { super.reset() @@ -295,50 +325,32 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { transparentBackground = false width = nil inputId = nil - value = nil defaultValue = nil - required = false - readOnly = false - onChange = nil + isRequired = false + isReadOnly = false + onChange = nil } - - /// Used to make changes to the View based off a change events or from local properties. - open override func updateView() { - super.updateView() - - updateContainerView() - updateTitleLabel() - updateErrorLabel() - updateHelperLabel() - - backgroundColor = surface.color - validator?.validate() - internalErrorText = validator?.errorMessage - } - - //-------------------------------------------------- - // MARK: - Private Methods - //-------------------------------------------------- - private func updateContainerView() { - containerView.backgroundColor = backgroundColorConfiguration.getColor(self) - containerView.layer.borderColor = readOnly ? readOnlyBorderColorConfiguration.getColor(self).cgColor : borderColorConfiguration.getColor(self).cgColor - containerView.layer.borderWidth = VDSFormControls.borderWidth - containerView.layer.cornerRadius = VDSFormControls.borderRadius - } - + //-------------------------------------------------- // MARK: - Public Methods //-------------------------------------------------- /// Container for the area in which the user interacts. - open func getContainer() -> UIView { - return containerView + open func getFieldContainer() -> UIView { + fatalError("Subclass must return the view that contains the field/view the user will interact with.") } /// Container for the area in which helper or error text presents. open func getBottomContainer() -> UIView { - return bottomContainerView + return bottomContainerStackView } - + + open func validate(){ + updateRules() + validator = FormFieldValidator(field: self, rules: rules) + validator?.validate() + setNeedsUpdate() + } + open func updateTitleLabel() { //update the local vars for the label since we no @@ -347,15 +359,15 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { var updatedLabelText = labelText //dealing with the "Optional" addition to the text - if let oldText = updatedLabelText, !required, !oldText.hasSuffix("Optional") { + if let oldText = updatedLabelText, !isRequired, !oldText.hasSuffix("Optional") { if isEnabled { - let optionColorAttr = ColorLabelAttribute(location: oldText.count + 2, + let optionColorAttr = ColorLabelAttribute(location: oldText.count + 1, length: 8, color: VDSColor.elementsSecondaryOnlight) attributes.append(optionColorAttr) } - updatedLabelText = "\(oldText) Optional" + updatedLabelText = "\(oldText) Optional" } if let tooltipModel { @@ -368,39 +380,29 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { titleLabel.surface = surface titleLabel.isEnabled = isEnabled } - + open func updateErrorLabel(){ - if showError, hasInternalError, let errorText, let internalErrorText { - errorLabel.text = [internalErrorText, errorText].joined(separator: "\n") - errorLabel.surface = surface - errorLabel.isEnabled = isEnabled - errorLabel.isHidden = false - icon.name = .error - icon.color = VDSColor.paletteBlack - icon.surface = surface - icon.isHidden = !isEnabled - } else if showError, let errorText { + if showError, let errorText { errorLabel.text = errorText errorLabel.surface = surface errorLabel.isEnabled = isEnabled errorLabel.isHidden = false - icon.name = .error - icon.color = VDSColor.paletteBlack - icon.surface = surface - icon.isHidden = !isEnabled + statusIcon.name = .error + statusIcon.surface = surface + statusIcon.isHidden = !isEnabled || state.contains(.focused) } else if hasInternalError, let internalErrorText { errorLabel.text = internalErrorText errorLabel.surface = surface errorLabel.isEnabled = isEnabled errorLabel.isHidden = false - icon.name = .error - icon.color = VDSColor.paletteBlack - icon.surface = surface - icon.isHidden = !isEnabled + statusIcon.name = .error + statusIcon.surface = surface + statusIcon.isHidden = !isEnabled || state.contains(.focused) } else { - icon.isHidden = true + statusIcon.isHidden = true errorLabel.isHidden = true } + statusIcon.color = iconColorConfiguration.getColor(self) } open func updateHelperLabel(){ @@ -414,4 +416,87 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { helperLabel.isHidden = true } } + + //-------------------------------------------------- + // MARK: - Private Methods + //-------------------------------------------------- + internal func updateRules() { + rules.removeAll() + if self.isRequired { + let rule = RequiredRule() + if let errorText, !errorText.isEmpty { + rule.errorMessage = errorText + } else if let labelText{ + rule.errorMessage = "You must enter a \(labelText)" + } else { + rule.errorMessage = "You must enter a value" + } + rules.append(.init(rule)) + } + } + + internal func updateContainerView() { + containerView.backgroundColor = backgroundColorConfiguration.getColor(self) + containerView.layer.borderColor = borderColorConfiguration.getColor(self).cgColor + containerView.layer.borderWidth = VDSFormControls.borderWidth + containerView.layer.cornerRadius = VDSFormControls.borderRadius + } + + internal func updateContainerWidth() { + widthConstraint?.deactivate() + trailingLessThanEqualsConstraint?.deactivate() + trailingEqualsConstraint?.deactivate() + + if let width, width >= minWidth, width <= maxWidth { + widthConstraint?.constant = width + widthConstraint?.activate() + trailingLessThanEqualsConstraint?.activate() + } else { + trailingEqualsConstraint?.activate() + } + } + + internal func updateHelperTextPosition() { + + titleLabel.removeFromSuperview() + helperLabel.removeFromSuperview() + + contentStackView.removeFromSuperview() + mainStackView.removeArrangedSubviews() + + //rows for helper-right + row1StackView.removeArrangedSubviews() + row2StackView.removeArrangedSubviews() + row1StackView.removeFromSuperview() + row2StackView.removeFromSuperview() + + switch helperTextPlacement { + case .bottom: + //add helper back into the contentView + bottomContainerStackView.addArrangedSubview(helperLabel) + mainStackView.addArrangedSubview(titleLabel) + mainStackView.addArrangedSubview(contentStackView) + + case .right: + //first row + row1StackView.addArrangedSubview(titleLabel) + //add spacer + row1StackView.addArrangedSubview(UIView()) + + //second row + row2StackView.addArrangedSubview(contentStackView) + //add under spacer + row2StackView.addArrangedSubview(helperLabel) + + //add 2 rows to vertical stack to create the grid + mainStackView.addArrangedSubview(row1StackView) + mainStackView.addArrangedSubview(row2StackView) + } + } +} + +extension UIStackView { + public func removeArrangedSubviews() { + arrangedSubviews.forEach { removeArrangedSubview($0) } + } } diff --git a/VDS/Components/TextFields/InputField/FieldTypes/CreditCard.swift b/VDS/Components/TextFields/InputField/FieldTypes/CreditCard.swift new file mode 100644 index 00000000..43fd6309 --- /dev/null +++ b/VDS/Components/TextFields/InputField/FieldTypes/CreditCard.swift @@ -0,0 +1,230 @@ +// +// CreditCard.swift +// VDS +// +// Created by Matt Bruce on 5/9/24. +// + +import Foundation +import UIKit + +extension InputField { + + public enum CreditCardType: String, CaseIterable { + case generic + case visa + case mastercard + case amex + case discover + case dinersClub + case jcb + case unionPay + + public func imageName(surface: Surface) -> String { + func getImageName(_ surface: Surface, name: String) -> String { + return surface == .light ? name : "\(name)-inverted" + } + switch self { + case .visa: return getImageName(surface, name: "visa") + case .mastercard: return "mastercard" + case .amex: return "amex" + case .discover: return "discover" + case .dinersClub: return "dinersClub"//getImageName(surface, name: "dinersClub") + case .jcb: return "jcb" + case .unionPay: return getImageName(surface, name: "unionPay") + default: return getImageName(surface, name: "generic") + } + } + + var separatorIndices: [Int] { + switch self { + case .dinersClub: + return [4, 10] + default: + return [4, 8, 12] + } + } + + var securityCodeLength: Int { + if self == .amex { + return 4 + } else { + return 3 + } + } + + var maxLength: Int { + switch self { + case .dinersClub: return 14 + default: return 16 + } + } + + static func from(cardNumber: String) -> CreditCardType { + let clean = cardNumber.filter { $0.isNumber } + let firstNum = Int(clean.prefix(1)) ?? 0 + let twoChar = Int(clean.prefix(2)) ?? 0 + let threeChar = Int(clean.prefix(3)) ?? 0 + let fourChar = Int(clean.prefix(4)) ?? 0 + let sixChar = Int(clean.prefix(6)) ?? 0 + + if firstNum == 4 { + return .visa + } else if twoChar == 34 || twoChar == 37 { + return .amex + } else if (twoChar == 62 && !(sixChar >= 622126 && sixChar <= 622925)) || twoChar == 81 || (twoChar == 60 && fourChar != 6011) { + return .unionPay + } else if (threeChar > 299 && threeChar <= 305) || threeChar == 309 || twoChar == 36 || twoChar == 38 || twoChar == 39 { + return .dinersClub + } else if fourChar == 6011 || (sixChar >= 622126 && sixChar <= 622925) || (threeChar >= 644 && threeChar <= 649) || twoChar == 65 { + return .discover + } else if fourChar >= 3528 && fourChar <= 3589 { + return .jcb + } else if (twoChar >= 51 && twoChar <= 55) || (sixChar >= 222100 && sixChar <= 272099) { + return .mastercard + } else { + return .generic + } + } + + } + + class CreditCardHandler: FieldTypeHandler { + static let shared = CreditCardHandler() + + private override init() { + super.init() + self.validateOnChange = false + self.keyboardType = .numberPad + } + + fileprivate func updateLeftImage(_ inputField: InputField) { + let imageName = inputField.cardType.imageName(surface: inputField.surface) + creditCardImageView.image = BundleManager.shared.image(for: imageName) + } + + override func updateView(_ inputField: InputField) { + minWidth = 288.0 + super.updateView(inputField) + + // Set the UIImageView as the left view of the UITextField + let iconContainerView: UIView = UIView() + iconContainerView.addSubview(creditCardImageView) + creditCardImageView.pinToSuperView(.init(top: 0, left: 0, bottom: 0, right: 10)) + + inputField.textField.leftView = iconContainerView + inputField.textField.leftViewMode = .always + + updateLeftImage(inputField) + } + + internal var creditCardImageView = UIImageView().with { + $0.height(20) + $0.width(32) + $0.isAccessibilityElement = false + $0.translatesAutoresizingMaskIntoConstraints = false + $0.contentMode = .scaleAspectFill + $0.clipsToBounds = true + } + + override func appendRules(_ inputField: InputField) { + if let text = inputField.textField.text, text.count > 0 { + let rule = CharacterCountRule().copyWith { + $0.maxLength = inputField.cardType.maxLength + $0.compareType = .equals + $0.errorMessage = "Enter a valid credit card." + } + inputField.rules.append(.init(rule)) + } + } + + override func textFieldDidBeginEditing(_ inputField: InputField, textField: UITextField) { + //reset the textField when you start editing + value = nil + inputField.cardType = .generic + textField.text = "" + inputField.validate() + updateLeftImage(inputField) + } + + override func textFieldDidEndEditing(_ inputField: InputField, textField: UITextField) { + if let value { + textField.text = maskCreditCardNumber(inputField.cardType, number: value) + } + } + + override func textField(_ inputField: InputField, textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + let allowedCharacters = CharacterSet.decimalDigits + if string.rangeOfCharacter(from: allowedCharacters.inverted) != nil && !string.isEmpty { + return false + } + + // Get the current text + let currentText = textField.text ?? "" + + // Calculate the new text + let newText = (currentText as NSString).replacingCharacters(in: range, with: string) + + // Remove any existing formatting + let rawNumber = newText.filter { $0.isNumber } + + if rawNumber.count > inputField.cardType.maxLength { + return false + } + + // Format the number with spaces + let formattedNumber = formatCreditCardNumber(inputField.cardType, number: rawNumber) + + // Update the icon based on the first four digits + updateCardTypeIcon(inputField, rawNumber: rawNumber) + + // Check again + if rawNumber.count > inputField.cardType.maxLength { + return false + } + + // Set the formatted text + textField.text = formattedNumber + + // Calculate the new cursor position + if let newPosition = textField.cursorPosition(range: range, + replacementString: string, + rawNumber: rawNumber, + formattedNumber: formattedNumber) { + textField.selectedTextRange = textField.textRange(from: newPosition, to: newPosition) + } + + // if all passes, then set the number1 + value = rawNumber + + // Prevent the default behavior + return false + } + + /// Private + internal func formatCreditCardNumber(_ cardType: CreditCardType, number: String) -> String { + let formattedInput = number.filter { $0.isNumber } // Remove any existing slashes + return String.format(formattedInput, indices: cardType.separatorIndices, with: " ") + } + + internal func updateCardTypeIcon(_ inputField: InputField, rawNumber: String) { + if rawNumber.count >= 4 { + inputField.cardType = CreditCardType.from(cardNumber: rawNumber) + } else { + inputField.cardType = .generic + } + updateLeftImage(inputField) + } + + internal func maskCreditCardNumber(_ cardType: CreditCardType, number: String) -> String { + // Mask the first 12 characters if the length is 16 + let rawNumber = number.filter { $0.isNumber } + guard rawNumber.count == cardType.maxLength else { return formatCreditCardNumber(cardType, number: number) } + let lastFourDigits = rawNumber.suffix(4) + let maskedSection = String(repeating: "•", count: 12) + let formattedMaskSection = String.format(maskedSection, indices: cardType.separatorIndices, with: " ") + return formattedMaskSection + " " + lastFourDigits + } + } + +} diff --git a/VDS/Components/TextFields/InputField/FieldTypes/Date.swift b/VDS/Components/TextFields/InputField/FieldTypes/Date.swift new file mode 100644 index 00000000..19656745 --- /dev/null +++ b/VDS/Components/TextFields/InputField/FieldTypes/Date.swift @@ -0,0 +1,99 @@ +// +// Date.swift +// VDS +// +// Created by Matt Bruce on 5/9/24. +// + +import Foundation +import UIKit + +extension InputField { + + public enum DateFormat: String, CaseIterable { + case mmyy + case mmddyy + case mmddyyyy + + public var placeholderText: String { + switch self { + case .mmyy: "MM/YY" + case .mmddyy: "MM/DD/YY" + case .mmddyyyy: "MM/DD/YYYY" + } + } + + public var formatString: String { + switch self { + case .mmyy: "MM/yy" + case .mmddyy: "MM/dd/yy" + case .mmddyyyy: "MM/dd/yyyy" + } + } + + public var maxLength: Int { + switch self { + case .mmyy: 5 + case .mmddyy: 8 + case .mmddyyyy: 10 + } + } + + internal var separatorIndices: [Int] { + switch self { + case .mmyy: [2] + case .mmddyy: [2,4] + case .mmddyyyy: [2,4] + } + } + } + + class DateHandler: FieldTypeHandler { + static let shared = DateHandler() + + private override init() { + super.init() + self.keyboardType = .numberPad + } + + override func updateView(_ inputField: InputField) { + minWidth = 114.0 + placeholderText = inputField.dateFormat.placeholderText + + super.updateView(inputField) + } + + override func appendRules(_ inputField: InputField) { + if let text = inputField.textField.text, text.count > 0 { + let rule = CharacterCountRule().copyWith { + $0.maxLength = inputField.dateFormat.maxLength + $0.compareType = .equals + $0.errorMessage = "Enter a valid date." + } + inputField.rules.append(.init(rule)) + } + } + + override func textField(_ inputField: InputField, textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + // Allow only numbers and limit the length of text. + guard let oldText = textField.text, + let textRange = Range(range, in: oldText), + string.rangeOfCharacter(from: CharacterSet.decimalDigits.inverted) == nil else { + return false + } + + let newText = oldText.replacingCharacters(in: textRange, with: string) + if newText.count > inputField.dateFormat.maxLength { + return false + } + + if newText.count <= inputField.dateFormat.maxLength { + textField.text = String.format(newText, indices: inputField.dateFormat.separatorIndices, with: "/") + return false + } else { + return true + } + } + } + +} diff --git a/VDS/Components/TextFields/InputField/FieldTypes/FieldType.swift b/VDS/Components/TextFields/InputField/FieldTypes/FieldType.swift new file mode 100644 index 00000000..f16f8c51 --- /dev/null +++ b/VDS/Components/TextFields/InputField/FieldTypes/FieldType.swift @@ -0,0 +1,103 @@ +// +// FieldType.swift +// VDS +// +// Created by Matt Bruce on 5/9/24. +// + +import Foundation +import UIKit +import VDSTokens + +extension InputField { + + public enum FieldType: String, CaseIterable { + case text, number, inlineAction, password, creditCard, telephone, date, securityCode + + func handler() -> FieldTypeHandler { + switch self { + case .text: + return TextHandler.shared + case .number: + return NumberHandler.shared + case .inlineAction: + return InlineActionHandler.shared + case .password: + return PasswordHandler.shared + case .creditCard: + return CreditCardHandler.shared + case .telephone: + return TelephoneHandler.shared + case .date: + return DateHandler.shared + case .securityCode: + return SecurityCodeHandler.shared + } + } + } + + class FieldTypeHandler: NSObject { + var keyboardType: UIKeyboardType + var minWidth: CGFloat = 40.0 + var actionModel: TextLinkModel? + var toolTipModel: Tooltip.TooltipModel? + var isSecureTextEntry = false + var placeholderText: String? + var value: String? + var validateOnChange = false + + internal override init() { + keyboardType = .default + super.init() + } + + func updateView(_ inputField: InputField) { + + //keyboard + inputField.textField.keyboardType = keyboardType + + //textField + inputField.textField.isSecureTextEntry = isSecureTextEntry + + //actionLink + inputField.actionTextLink.surface = inputField.surface + inputField.actionTextLink.isEnabled = inputField.isEnabled + if let actionModel { + inputField.actionTextLink.text = actionModel.text + inputField.actionTextLink.onClick = { _ in + actionModel.onClick(inputField) + } + inputField.actionTextLink.isHidden = false + inputField.fieldStackView.setCustomSpacing(VDSLayout.space2X, after: inputField.statusIcon) + } else { + inputField.actionTextLink.isHidden = true + inputField.fieldStackView.setCustomSpacing(0, after: inputField.statusIcon) + } + + //placeholder + inputField.textField.placeholder = placeholderText + + //tooltip from Types take precedence + //if one was set, it would show as usual. + if let toolTipModel { + inputField.tooltipModel = toolTipModel + } + + inputField.textField.leftView = nil + } + + func appendRules(_ inputField: InputField) {} + + func textFieldDidBeginEditing(_ inputField: InputField, textField: UITextField) { } + + func textFieldDidEndEditing(_ inputField: InputField, textField: UITextField) {} + + func textFieldDidChangeSelection(_ inputField: InputField, textField: UITextField) {} + + func textField(_ inputField: InputField, textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + return true + } + + } + +} diff --git a/VDS/Components/TextFields/InputField/FieldTypes/InlineAction.swift b/VDS/Components/TextFields/InputField/FieldTypes/InlineAction.swift new file mode 100644 index 00000000..68fe8b04 --- /dev/null +++ b/VDS/Components/TextFields/InputField/FieldTypes/InlineAction.swift @@ -0,0 +1,28 @@ +// +// InlineAction.swift +// VDS +// +// Created by Matt Bruce on 5/9/24. +// + +import Foundation +import UIKit + +extension InputField { + + class InlineActionHandler: FieldTypeHandler { + static let shared = InlineActionHandler() + + private override init() { + super.init() + } + + override func updateView(_ inputField: InputField) { + minWidth = 102.0 + actionModel = inputField.actionTextLinkModel + + super.updateView(inputField) + } + } + +} diff --git a/VDS/Components/TextFields/InputField/FieldTypes/Number.swift b/VDS/Components/TextFields/InputField/FieldTypes/Number.swift new file mode 100644 index 00000000..9e653421 --- /dev/null +++ b/VDS/Components/TextFields/InputField/FieldTypes/Number.swift @@ -0,0 +1,29 @@ +// +// Number.swift +// VDS +// +// Created by Matt Bruce on 5/9/24. +// + +import Foundation +import UIKit + +extension InputField { + + class NumberHandler: FieldTypeHandler { + static let shared = NumberHandler() + + private override init() { + super.init() + self.keyboardType = .numberPad + } + + override func textField(_ inputField: InputField, textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + // Allow only numbers + let allowedCharacters = CharacterSet.decimalDigits + let characterSet = CharacterSet(charactersIn: string) + return allowedCharacters.isSuperset(of: characterSet) + } + } + +} diff --git a/VDS/Components/TextFields/InputField/FieldTypes/Password.swift b/VDS/Components/TextFields/InputField/FieldTypes/Password.swift new file mode 100644 index 00000000..00c00a0d --- /dev/null +++ b/VDS/Components/TextFields/InputField/FieldTypes/Password.swift @@ -0,0 +1,68 @@ +// +// Password.swift +// VDS +// +// Created by Matt Bruce on 5/9/24. +// + +import Foundation +import UIKit + +extension InputField { + + enum PasswordAction { + case show, hide + + func toggle() -> PasswordAction { + self == .hide ? .show : .hide + } + } + + class PasswordHandler: FieldTypeHandler { + static let shared = PasswordHandler() + + internal var passwordActionType: PasswordAction = .hide + + private override init() { + super.init() + } + + override func updateView(_ inputField: InputField) { + let isHide = passwordActionType == .hide + let buttonText = isHide ? + inputField.hidePasswordButtonText.isEmpty ? "Hide" : inputField.hidePasswordButtonText : + inputField.showPasswordButtonText.isEmpty ? "Show" : inputField.showPasswordButtonText + + isSecureTextEntry = !isHide + let nextPasswordActionType = passwordActionType.toggle() + if let text = inputField.text, !text.isEmpty { + actionModel = .init(text: buttonText, + onClick: { [weak self] _ in + guard let self else { return } + self.passwordActionType = nextPasswordActionType + inputField.setNeedsUpdate() + }) + } else { + passwordActionType = .show + } + minWidth = 62.0 + + super.updateView(inputField) + + updateLink(inputField) + } + + func updateLink(_ inputField: InputField) { + if let text = inputField.textField.text, !text.isEmpty { + inputField.actionTextLink.isHidden = false + } else { + inputField.actionTextLink.isHidden = true + } + } + + override func textFieldDidChangeSelection(_ inputField: InputField, textField: UITextField) { + updateLink(inputField) + } + } + +} diff --git a/VDS/Components/TextFields/InputField/FieldTypes/SecurityCode.swift b/VDS/Components/TextFields/InputField/FieldTypes/SecurityCode.swift new file mode 100644 index 00000000..d651058c --- /dev/null +++ b/VDS/Components/TextFields/InputField/FieldTypes/SecurityCode.swift @@ -0,0 +1,110 @@ +// +// SecurityCode.swift +// VDS +// +// Created by Matt Bruce on 5/9/24. +// + +import Foundation +import UIKit +import VDSTokens + +extension InputField { + + class SecurityCodeHandler: FieldTypeHandler { + static let shared = SecurityCodeHandler() + + private override init() { + super.init() + self.keyboardType = .numberPad + } + + override func appendRules(_ inputField: InputField) { + if let text = inputField.textField.text, text.count > 0 { + let rule = CharacterCountRule().copyWith { + $0.maxLength = inputField.cardType.securityCodeLength + $0.compareType = .equals + $0.errorMessage = "Enter a valid security code." + } + inputField.rules.append(.init(rule)) + } + } + + override func updateView(_ inputField: InputField) { + minWidth = 88.0 + isSecureTextEntry = true + toolTipModel = getToolTip(inputField) + super.updateView(inputField) + } + + func getToolTip(_ inputField: InputField) -> Tooltip.TooltipModel { + + let surface = inputField.surface + + var contentView: UIView + let code3Label = Label().with { + $0.text = "Most credit or debit cards have a 3-digit security code on the back." + $0.isEnabled = true + $0.surface = surface + } + let code4Label = Label().with { + $0.text = "American Express cards have a 4-digit code on the front." + $0.isEnabled = true + $0.surface = surface + } + + let code3ImageView = UIImageView().with { + $0.image = BundleManager.shared.image(for: "securityCode\(surface == .dark ? "-inverted": "")") + } + let code4ImageView = UIImageView().with { + $0.image = BundleManager.shared.image(for: "securityCodeAmex\(surface == .dark ? "-inverted": "")") + } + + func stack(_ axis: NSLayoutConstraint.Axis = .vertical) -> UIStackView { + UIStackView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.axis = axis + $0.distribution = .fill + $0.alignment = .leading + $0.spacing = VDSLayout.space2X + } + } + + func view3() -> UIView { + stack().with { + $0.addArrangedSubview(code3ImageView) + $0.addArrangedSubview(code3Label) + } + } + + func view4() -> UIView { + stack().with { + $0.addArrangedSubview(code4ImageView) + $0.addArrangedSubview(code4Label) + } + } + + switch inputField.cardType { + case .amex: + contentView = view4() + case .generic: + contentView = stack(.horizontal).with { + $0.addArrangedSubview(view3()) + $0.addArrangedSubview(view4()) + } + default: + contentView = view3() + } + + return .init(contentView: contentView) + } + + override func textField(_ inputField: InputField, textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + // Allow only numbers and limit the length of text. + let allowedCharacters = CharacterSet.decimalDigits + let characterSet = CharacterSet(charactersIn: string) + return allowedCharacters.isSuperset(of: characterSet) && ((textField.text?.count ?? 0) + string.count - range.length) <= inputField.cardType.securityCodeLength + } + } + +} diff --git a/VDS/Components/TextFields/InputField/FieldTypes/Telephone.swift b/VDS/Components/TextFields/InputField/FieldTypes/Telephone.swift new file mode 100644 index 00000000..bfcd9ef7 --- /dev/null +++ b/VDS/Components/TextFields/InputField/FieldTypes/Telephone.swift @@ -0,0 +1,104 @@ +// +// Tel.swift +// VDS +// +// Created by Matt Bruce on 5/9/24. +// + +import Foundation +import UIKit + +extension InputField { + + class TelephoneHandler: FieldTypeHandler { + static let shared = TelephoneHandler() + + private override init() { + super.init() + self.keyboardType = .phonePad + } + + override func updateView(_ inputField: InputField) { + minWidth = 176.0 + + super.updateView(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)) + } + } + + override func textField(_ inputField: InputField, textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + // Allow only numbers and limit the length of text. + let allowedCharacters = CharacterSet(charactersIn: "01233456789") + let characterSet = CharacterSet(charactersIn: string) + let currentText = textField.text ?? "" + if !allowedCharacters.isSuperset(of: characterSet) { return false } + + // Calculate the new text + let newText = (currentText as NSString).replacingCharacters(in: range, with: string) + + // Remove any existing formatting + let rawNumber = newText.filter { $0.isNumber } + + // Format the number with dashes + let formattedNumber = formatUSNumber(rawNumber) + + // Set the formatted text + textField.text = formattedNumber + + // Calculate the new cursor position + if let newPosition = textField.cursorPosition(range: range, + replacementString: string, + rawNumber: rawNumber, + formattedNumber: formattedNumber) { + textField.selectedTextRange = textField.textRange(from: newPosition, to: newPosition) + } + + // Prevent the default behavior + return false + + } + + internal 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.. UIView { + return textField + } + /// Resets to default settings. open override func reset() { super.reset() textField.text = "" - textField.delegate = self successLabel.reset() successLabel.textStyle = .bodySmall @@ -198,29 +200,33 @@ open class InputField: EntryFieldBase, UITextFieldDelegate { successText = nil helperTextPlacement = .bottom } - - /// Container for the area in which the user interacts. - open override func getContainer() -> UIView { - inputFieldStackView.addArrangedSubview(containerView) - return inputFieldStackView - } /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { + + //update fieldType first + fieldType.handler().updateView(self) + super.updateView() textField.isEnabled = isEnabled + textField.isUserInteractionEnabled = isEnabled && !isReadOnly textField.textColor = textFieldTextColorConfiguration.getColor(self) - - if let actionTextLinkModel { - actionTextLink.text = actionTextLinkModel.text - actionTextLink.onClick = actionTextLinkModel.onClick - actionTextLink.isHidden = false - containerStackView.setCustomSpacing(VDSLayout.space2X, after: icon) + } + + open override func updateAccessibility() { + super.updateAccessibility() + let label = "\(isReadOnly ? "read only" : "")" + if let errorText, showError { + textField.accessibilityLabel = "\(label) ,error, \(errorText)" } else { - actionTextLink.isHidden = true - containerStackView.setCustomSpacing(0, after: icon) + textField.accessibilityLabel = label } + textField.accessibilityHint = isReadOnly || !isEnabled ? "" : "Double tap to open." + } + + open override func updateErrorLabel() { + super.updateErrorLabel() //show error or success if showError, let _ = errorText { @@ -232,85 +238,100 @@ open class InputField: EntryFieldBase, UITextFieldDelegate { successLabel.isEnabled = isEnabled successLabel.isHidden = false errorLabel.isHidden = true - icon.name = .checkmarkAlt - icon.color = VDSColor.paletteBlack - icon.surface = surface - icon.isHidden = !isEnabled + statusIcon.name = .checkmarkAlt + statusIcon.color = VDSColor.paletteBlack + statusIcon.surface = surface + statusIcon.isHidden = !isEnabled } else { - icon.isHidden = true successLabel.isHidden = true } - - //set the width constraints - if let width, width > fieldType.width { - widthConstraint?.constant = width - widthConstraint?.isActive = true - minWidthConstraint?.isActive = false - } else { - minWidthConstraint?.constant = fieldType.width - widthConstraint?.isActive = false - minWidthConstraint?.isActive = true - } } - - open override func updateHelperLabel(){ - //remove first - helperLabel.removeFromSuperview() - super.updateHelperLabel() - - //set the helper label position - if helperText != nil { - if helperTextPlacement == .right { - inputFieldStackView.spacing = 12 - inputFieldStackView.distribution = .fillEqually - inputFieldStackView.addArrangedSubview(helperLabel) - } else { - inputFieldStackView.spacing = 0 - inputFieldStackView.distribution = .fill - stackView.addArrangedSubview(helperLabel) + override func updateRules() { + super.updateRules() + fieldType.handler().appendRules(self) + } + + open override var accessibilityElements: [Any]? { + get { + var elements = [Any]() + elements.append(contentsOf: [titleLabel, textField]) + if showError { + elements.append(statusIcon) + if let errorText, !errorText.isEmpty { + elements.append(errorLabel) + } + } else if showSuccess, let successText, !successText.isEmpty { + elements.append(successLabel) } + + if let helperText, !helperText.isEmpty { + elements.append(helperLabel) + } + + return elements } + + set { super.accessibilityElements = newValue } } - /// Used to update any Accessibility properties. - open override func updateAccessibility() { - super.updateAccessibility() - textField.accessibilityLabel = showError ? "error" : nil - if showError { - accessibilityElements = [titleLabel, textField, icon, errorLabel, helperLabel] - } else { - accessibilityElements = [titleLabel, textField, helperLabel] - } + open override var canBecomeFirstResponder: Bool { + return textField.canBecomeFirstResponder } - - open override var canBecomeFirstResponder: Bool { true } - + + open override func becomeFirstResponder() -> Bool { + return textField.becomeFirstResponder() + } + + open override var canResignFirstResponder: Bool { + return textField.canResignFirstResponder + } + open override func resignFirstResponder() -> Bool { - if textField.isFirstResponder { - textField.resignFirstResponder() - } - return super.resignFirstResponder() + return textField.resignFirstResponder() } } -extension InputField.FieldType { - var width: CGFloat { - switch self { - case .inlineAction: - return 102 - case .password: - return 62.0 - case .creditCard: - return 288.0 - case .tel: - return 176.0 - case .date: - return 114.0 - case .securityCode: - return 88.0 - default: - return 40.0 +extension InputField: UITextFieldDelegate { + public func textFieldDidBeginEditing(_ textField: UITextField) { + fieldType.handler().textFieldDidBeginEditing(self, textField: textField) + updateContainerView() + } + + public func textFieldDidEndEditing(_ textField: UITextField) { + fieldType.handler().textFieldDidEndEditing(self, textField: textField) + validate() + } + + public func textFieldDidChangeSelection(_ textField: UITextField) { + fieldType.handler().textFieldDidChangeSelection(self, textField: textField) + if fieldType.handler().validateOnChange { + validate() } + sendActions(for: .valueChanged) + setNeedsUpdate() + } + + public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + return fieldType.handler().textField(self, textField: textField, shouldChangeCharactersIn: range, replacementString: string) } } + +extension String { + + internal static func format(_ value: String, indices: [Int], with separator: String) -> String { + var formattedString = "" + var currentIndex = value.startIndex + + for index in 0..() + + //-------------------------------------------------- + // MARK: - Private Properties + //-------------------------------------------------- + private var initialSetupPerformed = false + + private var horizontalPadding: CGFloat = 0 + + //-------------------------------------------------- + // MARK: - Properties + //-------------------------------------------------- + /// Key of whether or not updateView() is called in setNeedsUpdate() + open var shouldUpdateView: Bool = true + + open var surface: Surface = .light { didSet { setNeedsUpdate() } } + + open var showError: Bool = false { didSet { setNeedsUpdate() } } + + open var errorText: String? { didSet { setNeedsUpdate() } } + + //-------------------------------------------------- + // MARK: - Lifecycle + //-------------------------------------------------- + open func initialSetup() { + if !initialSetupPerformed { + initialSetupPerformed = true + backgroundColor = .clear + translatesAutoresizingMaskIntoConstraints = false + setup() + setNeedsUpdate() + } + } + + open func setup() { + 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) + let done = UIButton(type: .system) + done.setTitle("Done", for: .normal) + done.translatesAutoresizingMaskIntoConstraints = false + done.addTarget(self, action: #selector(doneButtonAction), for: .touchUpInside) + accessView.addSubview(done) + done.pinCenterY() + .pinTrailing(16) + inputAccessoryView = accessView + } + + @objc func doneButtonAction() { + // Resigns the first responder status when 'Done' is tapped + resignFirstResponder() + } + + open func updateView() {} + + open func updateAccessibility() { + if let errorText, showError { + accessibilityLabel = "error, \(errorText)" + } else { + accessibilityLabel = nil + } + } + + open func reset() { + shouldUpdateView = false + surface = .light + text = nil + shouldUpdateView = true + setNeedsUpdate() + } + + //-------------------------------------------------- + // MARK: - Overrides + //-------------------------------------------------- + open override func textRect(forBounds bounds: CGRect) -> CGRect { + let rect = super.textRect(forBounds: bounds) + return rect.insetBy(dx: -horizontalPadding, dy: 0) + } + + open override func editingRect(forBounds bounds: CGRect) -> CGRect { + let rect = super.editingRect(forBounds: bounds) + return rect.insetBy(dx: -horizontalPadding, dy: 0) + } + + open override func placeholderRect(forBounds bounds: CGRect) -> CGRect { + let rect = super.placeholderRect(forBounds: bounds) + return rect.insetBy(dx: -horizontalPadding, dy: 0) + } + + open override var isSecureTextEntry: Bool { + didSet { + if isFirstResponder { + _ = becomeFirstResponder() + } + } + } + + open override func becomeFirstResponder() -> Bool { + let success = super.becomeFirstResponder() + if isSecureTextEntry, let text { + self.text?.removeAll() + insertText(text) + } + return success + } +} + +extension UITextField { + public func cursorPosition(range: NSRange, replacementString string: String, rawNumber: String, formattedNumber: String) -> UITextPosition? { + let start = range.location + let length = string.count + + let newCursorLocation = start + length + + // Adjust the cursor position to skip over formatting characters + var formattedCharacterCount = 0 + for (index, character) in formattedNumber.enumerated() { + if index >= newCursorLocation + formattedCharacterCount { + break + } + if !character.isNumber { + formattedCharacterCount += 1 + } + } + + let finalCursorLocation = min(newCursorLocation + formattedCharacterCount, formattedNumber.count) + return position(from: beginningOfDocument, offset: finalCursorLocation) + } +} diff --git a/VDS/Components/TextFields/InputField/TextLinkModel.swift b/VDS/Components/TextFields/InputField/TextLinkModel.swift index d5d8c7ba..a9c557a0 100644 --- a/VDS/Components/TextFields/InputField/TextLinkModel.swift +++ b/VDS/Components/TextFields/InputField/TextLinkModel.swift @@ -14,9 +14,9 @@ extension InputField { public var text: String ///Click event when you click on a tab - public var onClick: ((TextLink) -> Void)? - - public init(text: String, onClick: ((TextLink) -> Void)? = nil) { + public var onClick: ((InputField) -> Void) + + public init(text: String = "Apply", onClick: @escaping (InputField) -> Void) { self.text = text self.onClick = onClick } diff --git a/VDS/Components/TextFields/Rules/CharacterCountRule.swift b/VDS/Components/TextFields/Rules/CharacterCountRule.swift new file mode 100644 index 00000000..46cbed05 --- /dev/null +++ b/VDS/Components/TextFields/Rules/CharacterCountRule.swift @@ -0,0 +1,34 @@ +// +// CharacterCountRule.swift +// VDS +// +// Created by Matt Bruce on 4/30/24. +// + +import Foundation + +class CharacterCountRule: Rule, Withable { + enum CompareType { + case equals, greaterThanEquals, lessThan, lessThanEquals + } + var maxLength: Int? + var errorMessage: String = "You have exceeded the character limit." + var compareType: CompareType = .lessThanEquals + + func isValid(value: String?) -> Bool { + guard let text = value, let maxLength, maxLength > 0 else { return true } + switch compareType { + case .equals: + return text.count == maxLength + + case .greaterThanEquals: + return text.count >= maxLength + + case .lessThan: + return text.count < maxLength + + case .lessThanEquals: + return text.count <= maxLength + } + } +} diff --git a/VDS/Components/TextFields/Rules/RequiredRule.swift b/VDS/Components/TextFields/Rules/RequiredRule.swift new file mode 100644 index 00000000..df00bc59 --- /dev/null +++ b/VDS/Components/TextFields/Rules/RequiredRule.swift @@ -0,0 +1,19 @@ +// +// RequiredRule.swift +// VDS +// +// Created by Matt Bruce on 4/30/24. +// + +import Foundation + +class RequiredRule: Rule { + var maxLength: Int? + var errorMessage: String = "This field is required." + + func isValid(value: String?) -> Bool { + guard let value, !value.isEmpty, value.count > 0 else { return false } + return true + } +} + diff --git a/VDS/Components/TextFields/TextArea/TextArea.swift b/VDS/Components/TextFields/TextArea/TextArea.swift index d057333b..f03300d1 100644 --- a/VDS/Components/TextFields/TextArea/TextArea.swift +++ b/VDS/Components/TextFields/TextArea/TextArea.swift @@ -32,7 +32,6 @@ open class TextArea: EntryFieldBase { //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- - internal var minWidthConstraint: NSLayoutConstraint? internal var textViewHeightConstraint: NSLayoutConstraint? internal var inputFieldStackView: UIStackView = { @@ -43,90 +42,73 @@ open class TextArea: EntryFieldBase { $0.spacing = VDSLayout.space3X } }() - - internal var bottomStackView: UIStackView = { - return UIStackView().with { - $0.translatesAutoresizingMaskIntoConstraints = false - $0.axis = .horizontal - $0.distribution = .fill - $0.alignment = .top - $0.spacing = VDSLayout.space2X - } - }() - + open var characterCounterLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.textStyle = .bodySmall $0.textAlignment = .right $0.numberOfLines = 1 } - - private var _minHeight: Height = .twoX - - open var minHeight: Height? { - get { return _minHeight } - set { - if let newValue { - _minHeight = newValue - } else { - _minHeight = .twoX - } - textViewHeightConstraint?.constant = _minHeight.value - setNeedsUpdate() - } - } + + open var minHeight: Height = .twoX { didSet { setNeedsUpdate() } } //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- - override var containerSize: CGSize { CGSize(width: 182, height: 88) } + /// Override UIControl state to add the .error state if showSuccess is true and if showError is true. + open override var state: UIControl.State { + get { + var state = super.state + if textView.isFirstResponder { + state.insert(.focused) + } + return state + } + } + + override var containerSize: CGSize { CGSize(width: 182, height: Height.twoX.value) } /// Enum used to describe the the height of TextArea. public enum Height: String, CaseIterable { case twoX = "2X" case fourX = "4X" case eightX = "8X" + var containerVerticalPadding: CGFloat { VDSLayout.space3X * 2 } var value: CGFloat { switch self { case .twoX: - 88 + 88 - containerVerticalPadding case .fourX: - 176 + 176 - containerVerticalPadding case .eightX: - 352 + 352 - containerVerticalPadding } } } - + /// The text of this TextArea. private var _text: String? open var text: String? { - get { _text } + get { textView.text } set { - if let newValue, newValue != _text { - _text = newValue - textView.text = newValue - value = newValue - } + textView.text = newValue setNeedsUpdate() } } /// The value of this textField. open override var value: String? { - didSet { - if text != value { - text = value - } - } + return textView.text } - + /// UITextView shown in the TextArea. open var textView = TextView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.sizeToFit() $0.isScrollEnabled = false + $0.textContainerInset = .zero + $0.textContainer.lineFragmentPadding = 0 } open var maxLength: Int? { @@ -135,20 +117,15 @@ open class TextArea: EntryFieldBase { } didSet { - setNeedsUpdate() + validate() } } - /// Color configuration for error icon. - internal var iconColorConfiguration = ControlColorConfiguration().with { - $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .normal) - } - /// Color configuration for character counter's highlight background color internal var highlightBackgroundColor = ControlColorConfiguration().with { $0.setSurfaceColors(VDSColor.backgroundPrimaryDark, VDSColor.backgroundPrimaryLight, forState: .normal) } - + /// Color configuration for character counter's highlight text color internal var highlightTextColor = ControlColorConfiguration().with { $0.setSurfaceColors(VDSColor.elementsPrimaryOndark, VDSColor.elementsPrimaryOnlight, forState: .normal) @@ -160,28 +137,38 @@ open class TextArea: EntryFieldBase { /// Called once when a view is initialized and is used to Setup additional UI or other constants and configurations. open override func setup() { super.setup() - isAccessibilityElement = false - validator = FormFieldValidator