diff --git a/Backpack-SwiftUI/Calendar/Classes/AccessibilityConfigurations/RangeAccessibilityConfigurations.swift b/Backpack-SwiftUI/Calendar/Classes/AccessibilityConfigurations/RangeAccessibilityConfigurations.swift new file mode 100644 index 000000000..b8997540a --- /dev/null +++ b/Backpack-SwiftUI/Calendar/Classes/AccessibilityConfigurations/RangeAccessibilityConfigurations.swift @@ -0,0 +1,61 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// Create a multi-selection configuration with given accessibility strings. +/// - Parameters: +/// - startSelectionHint: The hint provided to assistive technologies informing a user how to select the first +/// date in the range. +/// - endSelectionHint: The hint provided to assistive technologies informing a user how to select the second +/// date in the range. +/// - startSelectionState: The label provided to assistive technologies informing a user that a date is selected +/// as the first date in the range. +/// - endSelectionState: The label provided to assistive technologies informing a user that a date is selected +/// as the second date in the range. +/// - betweenSelectionState: The label provided to assistive technologies informing a user that a date lies +/// between the first and second selected dates. +/// - startAndEndSelectionState: The label provided to assistive technologies informing a user that a date +/// is selected as both the first and second date in the range. +/// - returnDatePrompt: The prompt provided to assistive technologies informing a user that they should now +/// select a second date. +public struct RangeAccessibilityConfigurations { + let startSelectionHint: String + let endSelectionHint: String + let startSelectionState: String + let endSelectionState: String + let betweenSelectionState: String + let startAndEndSelectionState: String + let returnDatePrompt: String + + public init( + startSelectionHint: String, + endSelectionHint: String, + startSelectionState: String, + endSelectionState: String, + betweenSelectionState: String, + startAndEndSelectionState: String, + returnDatePrompt: String + ) { + self.startSelectionHint = startSelectionHint + self.endSelectionHint = endSelectionHint + self.startSelectionState = startSelectionState + self.endSelectionState = endSelectionState + self.betweenSelectionState = betweenSelectionState + self.startAndEndSelectionState = startAndEndSelectionState + self.returnDatePrompt = returnDatePrompt + } +} diff --git a/Backpack-SwiftUI/Calendar/Classes/AccessibilityConfigurations/SingleAccessibilityConfigurations.swift b/Backpack-SwiftUI/Calendar/Classes/AccessibilityConfigurations/SingleAccessibilityConfigurations.swift new file mode 100644 index 000000000..8fa947be8 --- /dev/null +++ b/Backpack-SwiftUI/Calendar/Classes/AccessibilityConfigurations/SingleAccessibilityConfigurations.swift @@ -0,0 +1,28 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// Create a single-selection configuration with given accessibility strings. +/// - Parameters: +/// - selectionHint: The hint provided to assistive technologies informing a user how to select a date. +public struct SingleAccessibilityConfigurations { + let selectionHint: String + + public init(selectionHint: String) { + self.selectionHint = selectionHint + } +} diff --git a/Backpack-SwiftUI/Calendar/Classes/BPKCalendar.swift b/Backpack-SwiftUI/Calendar/Classes/BPKCalendar.swift new file mode 100644 index 000000000..de20c7512 --- /dev/null +++ b/Backpack-SwiftUI/Calendar/Classes/BPKCalendar.swift @@ -0,0 +1,129 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + +/// `BPKCalendar` is a SwiftUI view that represents a calendar. +/// +/// This view is designed to be customizable and flexible. It allows you to specify the type of selection, +/// the calendar system, and the valid range of dates. +/// +/// - Parameters: +/// - selectionType: The type of selection that the calendar should support. This can be single, range, or multiple. +/// - calendar: The calendar system that the calendar should use. This can be any calendar system supported by +/// the `Calendar` struct in Swift. +/// - validRange: The range of dates that the calendar should allow the user to select. +/// This is specified as a`ClosedRange`. +/// +/// The `BPKCalendar` view also allows you to specify an accessory action. This is a closure that takes a string and +/// a date, and is called when the user interacts with an accessory in the calendar. +public struct BPKCalendar: View { + let calendar: Calendar + let selectionType: CalendarSelectionType + let validRange: ClosedRange + private var accessoryAction: CalendarMonthAccessoryAction? + private let monthHeaderDateFormatter: DateFormatter + + @State private var currentlyShownMonth: Date + + public init( + selectionType: CalendarSelectionType, + calendar: Calendar, + validRange: ClosedRange + ) { + _currentlyShownMonth = State(initialValue: validRange.lowerBound) + self.validRange = validRange + self.calendar = calendar + self.selectionType = selectionType + + monthHeaderDateFormatter = DateFormatter() + monthHeaderDateFormatter.dateFormat = "MMMM yyyy" + } + + public var body: some View { + GeometryReader { calendarProxy in + VStack(spacing: BPKSpacing.none) { + CalendarHeader(calendar: calendar) + ZStack { + CalendarTypeContainerFactory( + selectionType: selectionType, + calendar: calendar, + validRange: validRange + ) { monthDate in + CalendarMonthHeader( + monthDate: monthDate, + dateFormatter: monthHeaderDateFormatter, + calendar: calendar, + accessoryAction: accessoryAction, + currentlyShownMonth: $currentlyShownMonth, + parentProxy: calendarProxy + ) + } + yearBadge + } + } + } + } + + private var yearBadge: some View { + VStack { + CalendarBadge( + currentlyShownMonth: currentlyShownMonth, + calendar: calendar + ) + .padding(.top, .base) + Spacer() + } + } + + /// Sets the accessory action for the calendar to be applied to each month. + public func monthAccessoryAction(_ action: CalendarMonthAccessoryAction) -> BPKCalendar { + var result = self + result.accessoryAction = action + return result + } +} + +struct BPKCalendar_Previews: PreviewProvider { + static var previews: some View { + let calendar = Calendar.current + let minValidDate = calendar.date(from: .init(year: 2023, month: 10, day: 12))! + let maxValidDate = calendar.date(from: .init(year: 2025, month: 12, day: 2))! + + let minSelectedDate = calendar.date(from: .init(year: 2023, month: 11, day: 19))! + let maxSelectedDate = calendar.date(from: .init(year: 2023, month: 11, day: 30))! + + return BPKCalendar( + selectionType: .range( + selection: .constant(.range(minSelectedDate...maxSelectedDate)), + accessibilityConfigurations: .init( + startSelectionHint: "", + endSelectionHint: "", + startSelectionState: "", + endSelectionState: "", + betweenSelectionState: "", + startAndEndSelectionState: "", + returnDatePrompt: "" + ) + ), + calendar: calendar, + validRange: minValidDate...maxValidDate + ) + .monthAccessoryAction(CalendarMonthAccessoryAction(title: "Select whole month", action: { _ in })) + } +} diff --git a/Backpack-SwiftUI/Calendar/Classes/CalendarBadge.swift b/Backpack-SwiftUI/Calendar/Classes/CalendarBadge.swift new file mode 100644 index 000000000..8714a29b4 --- /dev/null +++ b/Backpack-SwiftUI/Calendar/Classes/CalendarBadge.swift @@ -0,0 +1,33 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + +struct CalendarBadge: View { + let currentlyShownMonth: Date + let calendar: Calendar + + var body: some View { + let shownYear = calendar.component(.year, from: currentlyShownMonth) + if shownYear != calendar.component(.year, from: Date()) { + BPKBadge("\(shownYear)") + .badgeStyle(.brand) + .accessibilityHidden(true) + } + } +} diff --git a/Backpack-SwiftUI/Calendar/Classes/CalendarDayCells/CalendarSelectableCell.swift b/Backpack-SwiftUI/Calendar/Classes/CalendarDayCells/CalendarSelectableCell.swift new file mode 100644 index 000000000..bc246cb79 --- /dev/null +++ b/Backpack-SwiftUI/Calendar/Classes/CalendarDayCells/CalendarSelectableCell.swift @@ -0,0 +1,28 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + +struct CalendarSelectableCell: View { + @ViewBuilder let cell: Cell + let onSelection: () -> Void + + var body: some View { + cell.onTapGesture(perform: onSelection) + } +} diff --git a/Backpack-SwiftUI/Calendar/Classes/CalendarDayCells/DefaultCalendarDayCell.swift b/Backpack-SwiftUI/Calendar/Classes/CalendarDayCells/DefaultCalendarDayCell.swift new file mode 100644 index 000000000..988409657 --- /dev/null +++ b/Backpack-SwiftUI/Calendar/Classes/CalendarDayCells/DefaultCalendarDayCell.swift @@ -0,0 +1,30 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + +struct DefaultCalendarDayCell: View { + let calendar: Calendar + let date: Date + + var body: some View { + BPKText("\(calendar.component(.day, from: date))", style: .label1) + .lineLimit(1) + .padding(.vertical, .md) + } +} diff --git a/Backpack-SwiftUI/Calendar/Classes/CalendarDayCells/DefaultEmptyCalendarDayCell.swift b/Backpack-SwiftUI/Calendar/Classes/CalendarDayCells/DefaultEmptyCalendarDayCell.swift new file mode 100644 index 000000000..7123ba463 --- /dev/null +++ b/Backpack-SwiftUI/Calendar/Classes/CalendarDayCells/DefaultEmptyCalendarDayCell.swift @@ -0,0 +1,25 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + +struct DefaultEmptyCalendarDayCell: View { + var body: some View { + Color.clear + } +} diff --git a/Backpack-SwiftUI/Calendar/Classes/CalendarDayCells/DisabledCalendarDayCell.swift b/Backpack-SwiftUI/Calendar/Classes/CalendarDayCells/DisabledCalendarDayCell.swift new file mode 100644 index 000000000..1304b28fc --- /dev/null +++ b/Backpack-SwiftUI/Calendar/Classes/CalendarDayCells/DisabledCalendarDayCell.swift @@ -0,0 +1,33 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + +struct DisabledCalendarDayCell: View { + let calendar: Calendar + let date: Date + + var body: some View { + BPKText("\(calendar.component(.day, from: date))", style: .label1) + .lineLimit(1) + .foregroundColor(.textDisabledColor) + .frame(maxWidth: .infinity) + .padding(.vertical, .md) + .accessibilityHidden(true) + } +} diff --git a/Backpack-SwiftUI/Calendar/Classes/CalendarHeader.swift b/Backpack-SwiftUI/Calendar/Classes/CalendarHeader.swift new file mode 100644 index 000000000..15b62a14d --- /dev/null +++ b/Backpack-SwiftUI/Calendar/Classes/CalendarHeader.swift @@ -0,0 +1,52 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + +struct CalendarHeader: View { + let calendar: Calendar + + var body: some View { + VStack(spacing: BPKSpacing.none) { + HStack { + ForEach(0..<7) { index in + BPKText("\(weekdays[index])", style: .label2) + .foregroundColor(.textSecondaryColor) + .lineLimit(1) + .frame(maxWidth: .infinity) + .padding(.vertical, BPKSpacing.md.value + BPKSpacing.sm.value) + } + } + Divider() + } + .accessibilityHidden(true) + } + + private var weekdays: [String] { + var weekdaySymbols = calendar.veryShortStandaloneWeekdaySymbols + // We treat Sunday as the last day of the week, so we rotate the symbols array to match. + weekdaySymbols.append(weekdaySymbols.remove(at: weekdaySymbols.startIndex)) + return weekdaySymbols + } +} + +struct CalendarHeader_Previews: PreviewProvider { + static var previews: some View { + CalendarHeader(calendar: Calendar.current) + } +} diff --git a/Backpack-SwiftUI/Calendar/Classes/CalendarMonthAccessoryAction.swift b/Backpack-SwiftUI/Calendar/Classes/CalendarMonthAccessoryAction.swift new file mode 100644 index 000000000..56a33051b --- /dev/null +++ b/Backpack-SwiftUI/Calendar/Classes/CalendarMonthAccessoryAction.swift @@ -0,0 +1,35 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + +/// `CalendarMonthAccessoryAction` is a type alias for a closure that represents an action to +/// be performed when a user interacts with an accessory in the calendar. +public struct CalendarMonthAccessoryAction { + let title: String + let action: (Date) -> Void + + /// - Parameters: + /// - title: The title of the accessory. + /// - action: The action to be performed when the user interacts with the accessory. + /// This closure takes a `Date` as a parameter, which is the date that the user interacted with. + public init(title: String, action: @escaping (Date) -> Void) { + self.title = title + self.action = action + } +} diff --git a/Backpack-SwiftUI/Calendar/Classes/CalendarMonthHeader.swift b/Backpack-SwiftUI/Calendar/Classes/CalendarMonthHeader.swift new file mode 100644 index 000000000..a6a6e0275 --- /dev/null +++ b/Backpack-SwiftUI/Calendar/Classes/CalendarMonthHeader.swift @@ -0,0 +1,88 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + +/// CalendarMonthHeader is a view that displays the month name and an optional accessory action. +/// Also exposes a binding to the currently shown month. +struct CalendarMonthHeader: View { + let monthDate: Date + let dateFormatter: DateFormatter + let calendar: Calendar + let accessoryAction: CalendarMonthAccessoryAction? + @Binding var currentlyShownMonth: Date + let parentProxy: GeometryProxy + + var body: some View { + HStack { + let form = dateFormatter.string(from: monthDate) + BPKText(form, style: .heading4) + .accessibilityAddTraits(.isHeader) + Spacer() + GeometryReader { monthProxy in + if isCurrentlyShowingMonth(proxy: monthProxy) { + Color.clear + .onAppear { currentlyShownMonth = monthDate } + } + } + .frame(width: 1) + if let accessoryAction { + BPKButton(accessoryAction.title) { + accessoryAction.action(monthDate) + } + .buttonStyle(.link) + } + } + .padding(.horizontal, .base) + .padding(.vertical, .lg) + } + + private func isCurrentlyShowingMonth(proxy: GeometryProxy) -> Bool { + let parentGlobalFrame = parentProxy.frame(in: .global) + let yParentOrigin = parentGlobalFrame.origin.y + let parentHeight = parentGlobalFrame.height + let calendarVerticalCenter = yParentOrigin + parentHeight / 2 + let currentlyShownValidationRange = yParentOrigin...calendarVerticalCenter + let yOrigin = proxy.frame(in: .global).origin.y + return currentlyShownValidationRange.contains(yOrigin) + } +} + +struct CalendarMonthHeader_Previews: PreviewProvider { + static let dateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "MMMM yyyy" + return dateFormatter + }() + + static var previews: some View { + GeometryReader { proxy in + CalendarMonthHeader( + monthDate: Date(), + dateFormatter: Self.dateFormatter, + calendar: Calendar.current, + accessoryAction: .init(title: "Action", action: { _ in }), + currentlyShownMonth: .constant(Date()), + parentProxy: proxy + ) + .border(.black) + .padding(.base) + } + .fixedSize(horizontal: false, vertical: true) + } +} diff --git a/Backpack-SwiftUI/Calendar/Classes/CalendarRangeSelectionState.swift b/Backpack-SwiftUI/Calendar/Classes/CalendarRangeSelectionState.swift new file mode 100644 index 000000000..bafcecb1b --- /dev/null +++ b/Backpack-SwiftUI/Calendar/Classes/CalendarRangeSelectionState.swift @@ -0,0 +1,28 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + +/// The `CalendarRangeSelectionState` enum represents the different states of a range selection. +public enum CalendarRangeSelectionState { + /// The intermediate state, where the user has selected the first date of the range, but not the second. + case intermediate(Date) + + /// The final state, where the user has selected both dates of the range. + case range(ClosedRange) +} diff --git a/Backpack-SwiftUI/Calendar/Classes/CalendarSelectionType.swift b/Backpack-SwiftUI/Calendar/Classes/CalendarSelectionType.swift new file mode 100644 index 000000000..050da7156 --- /dev/null +++ b/Backpack-SwiftUI/Calendar/Classes/CalendarSelectionType.swift @@ -0,0 +1,34 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + +/// The `CalendarSelectionType` enum represents the different types of selection that can be made in a calendar. +public enum CalendarSelectionType { + /// A range selection, where the user can select a range of dates. + case range( + selection: Binding, + accessibilityConfigurations: RangeAccessibilityConfigurations + ) + + /// A single selection, where the user can select a single date. + case single( + selected: Binding, + accessibilityConfigurations: SingleAccessibilityConfigurations + ) +} diff --git a/Backpack-SwiftUI/Calendar/Classes/Core/CalendarContainer.swift b/Backpack-SwiftUI/Calendar/Classes/Core/CalendarContainer.swift new file mode 100644 index 000000000..b3b4207c7 --- /dev/null +++ b/Backpack-SwiftUI/Calendar/Classes/Core/CalendarContainer.swift @@ -0,0 +1,75 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + +struct CalendarContainer: View { + let calendar: Calendar + let validRange: ClosedRange + @ViewBuilder let monthContent: (_ month: Date) -> MonthContent + + var body: some View { + ScrollView { + VStack(spacing: BPKSpacing.none) { + ForEach(0...monthsToShow, id: \.self) { monthIndex in + let firstDayOfMonth = firstDayOf(monthIndex: monthIndex) + monthContent(firstDayOfMonth) + } + } + } + } + + private var monthsToShow: Int { + let firstMonthComponents = calendar.dateComponents([.year, .month], from: validRange.lowerBound) + let firstMonth = calendar.date(from: firstMonthComponents)! + let components = calendar.dateComponents([.year, .month], from: firstMonth, to: validRange.upperBound) + return components.month! + components.year! * 12 + } + + private func firstDayOf(monthIndex: Int) -> Date { + let month = calendar.date( + byAdding: .init(month: monthIndex), + to: validRange.lowerBound + )! + return calendar.date( + from: calendar.dateComponents( + [.year, .month], + from: month + ) + )! + } +} + +struct CalendarContainer_Previews: PreviewProvider { + static var previews: some View { + let calendar = Calendar.current + let start = calendar.date(from: .init(year: 2023, month: 10, day: 30))! + let end = calendar.date(from: .init(year: 2025, month: 12, day: 25))! + + CalendarContainer( + calendar: calendar, + validRange: start...end, + monthContent: { monthNumber in + VStack { + BPKText("Calendar Grid \(monthNumber)") + Divider() + } + } + ) + } +} diff --git a/Backpack-SwiftUI/Calendar/Classes/Core/CalendarMonthGrid.swift b/Backpack-SwiftUI/Calendar/Classes/Core/CalendarMonthGrid.swift new file mode 100644 index 000000000..d6de3289b --- /dev/null +++ b/Backpack-SwiftUI/Calendar/Classes/Core/CalendarMonthGrid.swift @@ -0,0 +1,105 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + +struct CalendarMonthGrid< + DayCell: View, + EmptyLeadingDayCell: View, + EmptyTrailingDayCell: View +>: View { + let monthDate: Date + let calendar: Calendar + let validRange: ClosedRange + + @ViewBuilder let dayCell: (Date) -> DayCell + @ViewBuilder let emptyLeadingDayCell: () -> EmptyLeadingDayCell + @ViewBuilder let emptyTrailingDayCell: () -> EmptyTrailingDayCell + + private let daysInAWeek = 7 + + var body: some View { + // Sunday is the first day of the week in the Calendar, so we need to offset (rotate the array) the days to + // make Monday the first day + let weekdaysOffset = calendar.component(.weekday, from: monthDate) - 2 + // If the offset is -1 (Sunday), we want it to be 6 (the last day of the week), + // otherwise we keep the offset as it is. + // This is because we need to fill the first row with empty cells if the month doesn't start on a Monday. + let daysFromPreviousMonth = weekdaysOffset == -1 ? 6 : weekdaysOffset + LazyVGrid( + columns: Array(repeating: GridItem(spacing: BPKSpacing.none.value), count: daysInAWeek), + spacing: BPKSpacing.lg.value + ) { + // Create cells for the days from the previous month that are shown in the first week of the current month. + ForEach(0.. some View { + ForEach(0..: View { + let selectionType: CalendarSelectionType + let calendar: Calendar + let validRange: ClosedRange + @ViewBuilder let monthHeader: (_ monthDate: Date) -> MonthHeader + + private var accessibilityDateFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.locale = calendar.locale + formatter.dateStyle = .full + return formatter + } + + var body: some View { + switch selectionType { + case .range(let selection, let accessibilityConfigurations): + RangeCalendarContainer( + selectionState: selection, + calendar: calendar, + validRange: validRange, + accessibilityProvider: RangeDayAccessibilityProvider( + accessibilityConfigurations: accessibilityConfigurations, + dateFormatter: accessibilityDateFormatter + ), + monthHeader: monthHeader + ) + case .single(let selected, let accessibilityConfigurations): + SingleCalendarContainer( + selection: selected, + calendar: calendar, + validRange: validRange, + accessibilityProvider: SingleDayAccessibilityProvider( + accessibilityConfigurations: accessibilityConfigurations, + dateFormatter: accessibilityDateFormatter + ), + monthHeader: monthHeader + ) + } + } +} diff --git a/Backpack-SwiftUI/Calendar/Classes/Range/Cells/EmptyRangeSelectionCalendarDayCell.swift b/Backpack-SwiftUI/Calendar/Classes/Range/Cells/EmptyRangeSelectionCalendarDayCell.swift new file mode 100644 index 000000000..2c30e2144 --- /dev/null +++ b/Backpack-SwiftUI/Calendar/Classes/Range/Cells/EmptyRangeSelectionCalendarDayCell.swift @@ -0,0 +1,57 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + +struct EmptyRangeSelectionCalendarDayCell: View { + let cellIndex: Int + let correspondingDate: Date + let selection: ClosedRange? + + var body: some View { + if let selection, selection.contains(correspondingDate) { + if cellIndex < 8 { // First week row + Color(.surfaceSubtleColor) + } else if cellIndex < 35 { // Last week row + Color(.surfaceSubtleColor) + } + } else if cellIndex < 8 { // First week row, needed to fill the grid + DefaultEmptyCalendarDayCell() + } + } +} + +struct EmptyRangeSelectionCalendarDayCell_Previews: PreviewProvider { + static var previews: some View { + let calendar = Calendar.current + let date = calendar.date(from: .init(year: 2023, month: 11, day: 8))! + let startSelection = calendar.date(from: .init(year: 2023, month: 10, day: 30))! + let endSelection = calendar.date(from: .init(year: 2023, month: 11, day: 10))! + + LazyVGrid(columns: Array(repeating: GridItem(spacing: 0), count: 3), spacing: 0) { + LowerBoundSelectedCell(calendar: calendar, date: date) + EmptyRangeSelectionCalendarDayCell( + cellIndex: 0, + correspondingDate: date, + selection: startSelection...endSelection + ) + UpperBoundSelectedCell(calendar: calendar, date: date) + + } + } +} diff --git a/Backpack-SwiftUI/Calendar/Classes/Range/Cells/InbetweenSelectionCell.swift b/Backpack-SwiftUI/Calendar/Classes/Range/Cells/InbetweenSelectionCell.swift new file mode 100644 index 000000000..282475970 --- /dev/null +++ b/Backpack-SwiftUI/Calendar/Classes/Range/Cells/InbetweenSelectionCell.swift @@ -0,0 +1,41 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + +struct InbetweenSelectionCell: View { + let calendar: Calendar + let date: Date + + var body: some View { + BPKText("\(calendar.component(.day, from: date))", style: .label1) + .lineLimit(1) + .padding(.vertical, .md) + .frame(maxWidth: .infinity) + .background(.surfaceSubtleColor) + } +} + +struct InbetweenSelectionCell_Previews: PreviewProvider { + static var previews: some View { + let calendar = Calendar.current + let date = calendar.date(from: .init(year: 2023, month: 11, day: 8))! + + InbetweenSelectionCell(calendar: calendar, date: date) + } +} diff --git a/Backpack-SwiftUI/Calendar/Classes/Range/Cells/LowerAndUpperBoundSelectedCell.swift b/Backpack-SwiftUI/Calendar/Classes/Range/Cells/LowerAndUpperBoundSelectedCell.swift new file mode 100644 index 000000000..bc7b751e6 --- /dev/null +++ b/Backpack-SwiftUI/Calendar/Classes/Range/Cells/LowerAndUpperBoundSelectedCell.swift @@ -0,0 +1,54 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + +struct LowerAndUpperBoundSelectedCell: View { + let calendar: Calendar + let date: Date + + var body: some View { + ZStack { + GeometryReader { proxy in + ZStack { + Circle() + .strokeBorder(Color(.coreAccentColor), lineWidth: 1) + Circle() + .frame(width: proxy.size.height - 6) + .foregroundColor(.coreAccentColor) + + } + } + BPKText("\(calendar.component(.day, from: date))", style: .label1) + .foregroundColor(.textPrimaryInverseColor) + .lineLimit(1) + .padding(.md) + } + } +} + +struct LowerAndUpperBoundSelectedCell_Previews: PreviewProvider { + static var previews: some View { + let calendar = Calendar.current + let date = calendar.date(from: .init(year: 2023, month: 11, day: 8))! + + LazyVGrid(columns: Array(repeating: GridItem(spacing: 0), count: 1), spacing: 0) { + LowerAndUpperBoundSelectedCell(calendar: calendar, date: date) + } + } +} diff --git a/Backpack-SwiftUI/Calendar/Classes/Range/Cells/LowerBoundSelectedCell.swift b/Backpack-SwiftUI/Calendar/Classes/Range/Cells/LowerBoundSelectedCell.swift new file mode 100644 index 000000000..46ab6349d --- /dev/null +++ b/Backpack-SwiftUI/Calendar/Classes/Range/Cells/LowerBoundSelectedCell.swift @@ -0,0 +1,50 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + +struct LowerBoundSelectedCell: View { + let calendar: Calendar + let date: Date + + var body: some View { + ZStack { + GeometryReader { proxy in + Color(.surfaceSubtleColor) + .frame(width: proxy.size.width / 2) + .offset(x: proxy.size.width / 2) + } + BPKText("\(calendar.component(.day, from: date))", style: .label1) + .foregroundColor(.textPrimaryInverseColor) + .lineLimit(1) + .frame(maxWidth: .infinity) + .padding(.vertical, .md) + .background(.coreAccentColor) + .clipShape(Circle()) + } + } +} + +struct LowerBoundSelectedCell_Previews: PreviewProvider { + static var previews: some View { + let calendar = Calendar.current + let date = calendar.date(from: .init(year: 2023, month: 11, day: 8))! + + LowerBoundSelectedCell(calendar: calendar, date: date) + } +} diff --git a/Backpack-SwiftUI/Calendar/Classes/Range/Cells/RangeSelectionCalendarDayCell.swift b/Backpack-SwiftUI/Calendar/Classes/Range/Cells/RangeSelectionCalendarDayCell.swift new file mode 100644 index 000000000..3bb480741 --- /dev/null +++ b/Backpack-SwiftUI/Calendar/Classes/Range/Cells/RangeSelectionCalendarDayCell.swift @@ -0,0 +1,53 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + +struct RangeSelectionCalendarDayCell: View { + let date: Date + let selection: ClosedRange + let calendar: Calendar + + var body: some View { + if selection.lowerBound == selection.upperBound { + LowerAndUpperBoundSelectedCell(calendar: calendar, date: date) + } else if date == selection.lowerBound { + LowerBoundSelectedCell(calendar: calendar, date: date) + } else if date == selection.upperBound { + UpperBoundSelectedCell(calendar: calendar, date: date) + } else { + InbetweenSelectionCell(calendar: calendar, date: date) + } + } +} + +struct SelectedCalendarDayCell_Previews: PreviewProvider { + static var previews: some View { + let calendar = Calendar.current + let date = calendar.date(from: .init(year: 2023, month: 11, day: 8))! + + LazyVGrid(columns: Array(repeating: GridItem(spacing: 0), count: 6), spacing: 0) { + DefaultCalendarDayCell(calendar: calendar, date: date) + DisabledCalendarDayCell(calendar: calendar, date: date) + LowerBoundSelectedCell(calendar: calendar, date: date) + InbetweenSelectionCell(calendar: calendar, date: date) + UpperBoundSelectedCell(calendar: calendar, date: date) + LowerAndUpperBoundSelectedCell(calendar: calendar, date: date) + } + } +} diff --git a/Backpack-SwiftUI/Calendar/Classes/Range/Cells/UpperBoundSelectedCell.swift b/Backpack-SwiftUI/Calendar/Classes/Range/Cells/UpperBoundSelectedCell.swift new file mode 100644 index 000000000..2e8070ea9 --- /dev/null +++ b/Backpack-SwiftUI/Calendar/Classes/Range/Cells/UpperBoundSelectedCell.swift @@ -0,0 +1,49 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + +struct UpperBoundSelectedCell: View { + let calendar: Calendar + let date: Date + + var body: some View { + ZStack { + GeometryReader { proxy in + Color(.surfaceSubtleColor) + .frame(width: proxy.size.width / 2) + } + BPKText("\(calendar.component(.day, from: date))", style: .label1) + .foregroundColor(.textPrimaryInverseColor) + .lineLimit(1) + .frame(maxWidth: .infinity) + .padding(.vertical, .md) + .background(.coreAccentColor) + .clipShape(Circle()) + } + } +} + +struct UpperBoundSelectedCell_Previews: PreviewProvider { + static var previews: some View { + let calendar = Calendar.current + let date = calendar.date(from: .init(year: 2023, month: 11, day: 8))! + + UpperBoundSelectedCell(calendar: calendar, date: date) + } +} diff --git a/Backpack-SwiftUI/Calendar/Classes/Range/RangeCalendarContainer.swift b/Backpack-SwiftUI/Calendar/Classes/Range/RangeCalendarContainer.swift new file mode 100644 index 000000000..c07f72b5b --- /dev/null +++ b/Backpack-SwiftUI/Calendar/Classes/Range/RangeCalendarContainer.swift @@ -0,0 +1,200 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + +struct RangeCalendarContainer: View { + @Binding var selectionState: CalendarRangeSelectionState? + let calendar: Calendar + let validRange: ClosedRange + let accessibilityProvider: RangeDayAccessibilityProvider + @ViewBuilder let monthHeader: (_ monthDate: Date) -> MonthHeader + + private func handleSelection(_ date: Date) { + switch selectionState { + case .intermediate(let initialDateSelection): + if date < initialDateSelection { + selectionState = .intermediate(date) + UIAccessibility.post( + notification: .announcement, + argument: accessibilityProvider.accessibilityInstructionAfterSelectingDate() + ) + } else { + selectionState = .range(initialDateSelection...date) + } + default: + selectionState = .intermediate(date) + UIAccessibility.post( + notification: .announcement, + argument: accessibilityProvider.accessibilityInstructionAfterSelectingDate() + ) + } + } + + @ViewBuilder + private func cell(_ dayDate: Date) -> some View { + if + case .intermediate(let selectedDate) = selectionState, + initialSelection(selectedDate, matchesDate: dayDate) + { + SingleSelectedCell(calendar: calendar, date: dayDate) + .accessibilityLabel(Text( + accessibilityProvider.accessibilityLabel( + for: dayDate, + intermediateSelectionDate: selectedDate + ) + )) + } else if case .range(let closedRange) = selectionState, closedRange.contains(dayDate) { + RangeSelectionCalendarDayCell( + date: dayDate, + selection: closedRange, + calendar: calendar + ) + .accessibilityLabel(Text( + accessibilityProvider.accessibilityLabel( + for: dayDate, + selection: closedRange + ) + )) + .accessibility(addTraits: closedRange.contains(dayDate) ? .isSelected : []) + } else { + DefaultCalendarDayCell(calendar: calendar, date: dayDate) + .accessibilityLabel(Text( + accessibilityProvider.accessibilityLabel(for: dayDate) + )) + } + } + + @ViewBuilder + private func makeDayCell(_ dayDate: Date) -> some View { + if !validRange.contains(dayDate) { + DisabledCalendarDayCell(calendar: calendar, date: dayDate) + } else { + CalendarSelectableCell { + cell(dayDate) + } onSelection: { + handleSelection(dayDate) + } + .accessibilityHint(Text( + accessibilityProvider.accessibilityHint( + for: dayDate, + rangeSelectionState: selectionState + ) + )) + .accessibility(addTraits: .isButton) + } + } + + private func initialSelection(_ initialDateSelection: Date, matchesDate date: Date) -> Bool { + let matchingDayComponents = calendar.dateComponents([.year, .month, .day], from: date) + return calendar.date(initialDateSelection, matchesComponents: matchingDayComponents) + } + + var body: some View { + CalendarContainer( + calendar: calendar, + validRange: validRange + ) { month in + monthHeader(month) + CalendarMonthGrid( + monthDate: month, + calendar: calendar, + validRange: validRange, + dayCell: makeDayCell, + emptyLeadingDayCell: { makeEmptyLeadingDayCell(for: month) }, + emptyTrailingDayCell: { makeEmptyTrailingDayCell(for: month) } + ) + } + } + + /// - Parameters: + /// - firstDayOfMonth: The first day of the month we are showing + @ViewBuilder + private func makeEmptyLeadingDayCell(for firstDayOfMonth: Date) -> some View { + // if both the last day of the previous month and the first of the current are selected, we want to show the + // selected surface color + if + case .range(let selection) = selectionState, + let lastDayOfPreviousMonth = calendar.date(byAdding: .init(day: -1), to: firstDayOfMonth), + let firstDayOfCurrentMonth = calendar.date(byAdding: .init(day: 1), to: lastDayOfPreviousMonth), + selection.contains(lastDayOfPreviousMonth), + selection.contains(firstDayOfCurrentMonth) + { + Color(.surfaceSubtleColor) + } else { + // otherwise we occupy the space with a clear view + DefaultEmptyCalendarDayCell() + } + } + + /// - Parameters: + /// - firstDayOfMonth: The first day of the month we are showing + @ViewBuilder + private func makeEmptyTrailingDayCell(for firstDayOfMonth: Date) -> some View { + // if both the last day of the current month and the first of the next are selected, we want to show the + // space between them as selected + if + case .range(let selection) = selectionState, + let firstDayOfNextMonth = calendar.date(byAdding: .init(month: 1), to: firstDayOfMonth), + let lastDayOfCurrentMonth = calendar.date(byAdding: .init(day: -1), to: firstDayOfNextMonth), + selection.contains(lastDayOfCurrentMonth), + selection.contains(firstDayOfNextMonth) + { + Color(.surfaceSubtleColor) + } + } +} + +struct RangeCalendarContainer_Previews: PreviewProvider { + static let formatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "MMMM yyyy" + return formatter + }() + + static var previews: some View { + let calendar = Calendar.current + + let start = calendar.date(from: .init(year: 2023, month: 10, day: 1))! + let end = calendar.date(from: .init(year: 2025, month: 12, day: 25))! + + let startSelection = calendar.date(from: .init(year: 2023, month: 10, day: 30))! + let endSelection = calendar.date(from: .init(year: 2023, month: 11, day: 10))! + + RangeCalendarContainer( + selectionState: .constant(.range(startSelection...endSelection)), + calendar: calendar, + validRange: start...end, + accessibilityProvider: RangeDayAccessibilityProvider( + accessibilityConfigurations: .init( + startSelectionHint: "", + endSelectionHint: "", + startSelectionState: "", + endSelectionState: "", + betweenSelectionState: "", + startAndEndSelectionState: "", + returnDatePrompt: "" + ), + dateFormatter: Self.formatter + ), + monthHeader: { month in + BPKText("\(Self.formatter.string(from: month))") + } + ) + } +} diff --git a/Backpack-SwiftUI/Calendar/Classes/Range/RangeDayAccessibilityProvider.swift b/Backpack-SwiftUI/Calendar/Classes/Range/RangeDayAccessibilityProvider.swift new file mode 100644 index 000000000..8b74a6f4b --- /dev/null +++ b/Backpack-SwiftUI/Calendar/Classes/Range/RangeDayAccessibilityProvider.swift @@ -0,0 +1,75 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +struct RangeDayAccessibilityProvider { + let accessibilityConfigurations: RangeAccessibilityConfigurations + let dateFormatter: DateFormatter + + func accessibilityInstructionAfterSelectingDate() -> String { + accessibilityConfigurations.returnDatePrompt + } + + func accessibilityLabel(for dayDate: Date) -> String { + dateFormatter.string(from: dayDate) + } + + func accessibilityLabel(for dayDate: Date, selection: ClosedRange) -> String { + let baseLabel = accessibilityLabel(for: dayDate) + var state: String? + if selection.contains(dayDate) { + if selection.lowerBound == selection.upperBound { + state = accessibilityConfigurations.startAndEndSelectionState + } else if dayDate == selection.lowerBound { + state = accessibilityConfigurations.startSelectionState + } else if dayDate == selection.upperBound { + state = accessibilityConfigurations.endSelectionState + } else { + state = accessibilityConfigurations.betweenSelectionState + } + } + guard let state else { return baseLabel } + return "\(baseLabel), \(state)" + } + + func accessibilityLabel(for dayDate: Date, intermediateSelectionDate: Date) -> String { + let baseLabel = accessibilityLabel(for: dayDate) + let state = accessibilityConfigurations.startSelectionState + return "\(baseLabel), \(state)" + } + + func accessibilityHint(for dayDate: Date, rangeSelectionState: CalendarRangeSelectionState?) -> String { + if shouldClearSelectedDates(for: dayDate, rangeSelectionState: rangeSelectionState) { + return accessibilityConfigurations.startSelectionHint + } + return accessibilityConfigurations.endSelectionHint + } + + private func shouldClearSelectedDates( + for date: Date, + rangeSelectionState: CalendarRangeSelectionState? + ) -> Bool { + switch rangeSelectionState { + case .intermediate(let initialDateSelection): + return date < initialDateSelection + case .range: + return true + case nil: + return false + } + } +} diff --git a/Backpack-SwiftUI/Calendar/Classes/Single/SingleCalendarContainer.swift b/Backpack-SwiftUI/Calendar/Classes/Single/SingleCalendarContainer.swift new file mode 100644 index 000000000..a204be415 --- /dev/null +++ b/Backpack-SwiftUI/Calendar/Classes/Single/SingleCalendarContainer.swift @@ -0,0 +1,85 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + +struct SingleCalendarContainer: View { + @Binding var selection: Date? + let calendar: Calendar + let validRange: ClosedRange + let accessibilityProvider: SingleDayAccessibilityProvider + @ViewBuilder let monthHeader: (_ monthDate: Date) -> MonthHeader + + @ViewBuilder + private func makeDayCell(_ dayDate: Date) -> some View { + CalendarSelectableCell { + if selection == dayDate { + SingleSelectedCell(calendar: calendar, date: dayDate) + } else { + DefaultCalendarDayCell(calendar: calendar, date: dayDate) + } + } onSelection: { + selection = dayDate + } + .accessibilityAddTraits(.isButton) + .accessibilityAddTraits(selection == dayDate ? .isSelected : []) + .accessibilityLabel(accessibilityProvider.accessibilityLabel(for: dayDate)) + .accessibilityHint(accessibilityProvider.accessibilityHint(for: dayDate, selection: selection)) + } + + var body: some View { + CalendarContainer(calendar: calendar, validRange: validRange) { month in + monthHeader(month) + CalendarMonthGrid( + monthDate: month, + calendar: calendar, + validRange: validRange, + dayCell: makeDayCell, + emptyLeadingDayCell: { DefaultEmptyCalendarDayCell() }, + emptyTrailingDayCell: { DefaultEmptyCalendarDayCell() } + ) + } + } +} + +struct SingleCalendarContainer_Previews: PreviewProvider { + static let formatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "MMMM yyyy" + return formatter + }() + + static var previews: some View { + let calendar = Calendar.current + let start = calendar.date(from: .init(year: 2023, month: 10, day: 30))! + let end = calendar.date(from: .init(year: 2025, month: 12, day: 25))! + + SingleCalendarContainer( + selection: .constant(calendar.date(from: .init(year: 2023, month: 11, day: 10))!), + calendar: calendar, + validRange: start...end, + accessibilityProvider: SingleDayAccessibilityProvider( + accessibilityConfigurations: .init(selectionHint: ""), + dateFormatter: Self.formatter + ), + monthHeader: { month in + BPKText("\(Self.formatter.string(from: month))") + } + ) + } +} diff --git a/Backpack-SwiftUI/Calendar/Classes/Single/SingleDayAccessibilityProvider.swift b/Backpack-SwiftUI/Calendar/Classes/Single/SingleDayAccessibilityProvider.swift new file mode 100644 index 000000000..eefb7f9b3 --- /dev/null +++ b/Backpack-SwiftUI/Calendar/Classes/Single/SingleDayAccessibilityProvider.swift @@ -0,0 +1,33 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +struct SingleDayAccessibilityProvider { + let accessibilityConfigurations: SingleAccessibilityConfigurations + let dateFormatter: DateFormatter + + func accessibilityLabel(for dayDate: Date) -> String { + dateFormatter.string(from: dayDate) + } + + func accessibilityHint(for dayDate: Date, selection: Date?) -> String { + if dayDate == selection { + return "" + } + return accessibilityConfigurations.selectionHint + } +} diff --git a/Backpack-SwiftUI/Calendar/Classes/Single/SingleSelectedCell.swift b/Backpack-SwiftUI/Calendar/Classes/Single/SingleSelectedCell.swift new file mode 100644 index 000000000..96fafab73 --- /dev/null +++ b/Backpack-SwiftUI/Calendar/Classes/Single/SingleSelectedCell.swift @@ -0,0 +1,43 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + +struct SingleSelectedCell: View { + let calendar: Calendar + let date: Date + + var body: some View { + BPKText("\(calendar.component(.day, from: date))", style: .label1) + .foregroundColor(.textPrimaryInverseColor) + .lineLimit(1) + .frame(maxWidth: .infinity) + .padding(.vertical, .md) + .background(.coreAccentColor) + .clipShape(Circle()) + } +} + +struct SingleSelectedCell_Previews: PreviewProvider { + static var previews: some View { + let calendar = Calendar.current + let date = calendar.date(from: .init(year: 2023, month: 11, day: 8))! + + SingleSelectedCell(calendar: calendar, date: date) + } +} diff --git a/Backpack-SwiftUI/Calendar/README.md b/Backpack-SwiftUI/Calendar/README.md new file mode 100644 index 000000000..8941ca3c1 --- /dev/null +++ b/Backpack-SwiftUI/Calendar/README.md @@ -0,0 +1,66 @@ +# Backpack-SwiftUI/Calendar + +[![Cocoapods](https://img.shields.io/cocoapods/v/Backpack-SwiftUI.svg?style=flat)](hhttps://cocoapods.org/pods/Backpack-SwiftUI) +[![class reference](https://img.shields.io/badge/Class%20reference-iOS-blue)](https://backpack.github.io/ios/versions/latest/swiftui/Structs/BPKCalendar.html) +[![view on Github](https://img.shields.io/badge/Source%20code-GitHub-lightgrey)](https://github.com/Skyscanner/backpack-ios/tree/main/Backpack-SwiftUI/Calendar) + +## Range selection + +| Day | Night | +| --- | --- | +| | | + +## Single selection + +| Day | Night | +| --- | --- | +| | | + +## Usage + +`BPKCalendar` can be configured with different selection types and an accessory action for each month. + +## Selection types + +### Single selection + +Allows the user to only select one date at a time. + +```swift +@State var selectedDate: Date? + +BPKCalendar( + selectionType: .single(selected: $selectedDate), + calendar: .current, + validRange: validStartDate...validEndDate +) +``` + +### Range selection + +Allows the user to select a range of dates. + +```swift +@State var selectedDateRange: ClosedRange? + +BPKCalendar( + selectionType: .range(selectedRange: $selectedDateRange), + calendar: .current, + validRange: validStartDate...validEndDate +) +``` + +## Valid range + +The valid range is used to determine which dates are selectable by the user. Dates outside of the valid range will be disabled. + +```swift +let startDate = Calendar.current.date(byAdding: .day, value: -1, to: Date()) +let endDate = Calendar.current.date(byAdding: .day, value: 1, to: Date()) + +BPKCalendar( + selectionType: .single(selected: $selectedDate), + calendar: .current, + validRange: startDate...endDate +) +``` diff --git a/Backpack-SwiftUI/Tests/Calendar/BPKCalendarTests.swift b/Backpack-SwiftUI/Tests/Calendar/BPKCalendarTests.swift new file mode 100644 index 000000000..114566867 --- /dev/null +++ b/Backpack-SwiftUI/Tests/Calendar/BPKCalendarTests.swift @@ -0,0 +1,122 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import XCTest +import SwiftUI +@testable import Backpack_SwiftUI + +class BPKCalendarTests: XCTestCase { + let validStart = Calendar.current.date(from: DateComponents(year: 2020, month: 1, day: 13))! + let validEnd = Calendar.current.date(from: DateComponents(year: 2020, month: 2, day: 20))! + + let rangeAccessibilityConfig = RangeAccessibilityConfigurations( + startSelectionHint: "", + endSelectionHint: "", + startSelectionState: "", + endSelectionState: "", + betweenSelectionState: "", + startAndEndSelectionState: "", + returnDatePrompt: "" + ) + + func test_singleSelectionCalendar() { + let testDate = Calendar.current.date(from: DateComponents(year: 2020, month: 2, day: 5))! + + assertSnapshot( + BPKCalendar( + selectionType: .single( + selected: .constant(testDate), + accessibilityConfigurations: SingleAccessibilityConfigurations( + selectionHint: "" + ) + ), + calendar: Calendar.current, + validRange: validStart...validEnd + ) + .frame(width: 320, height: 720) + ) + } + + func test_rangeSelectionCalendar_sameMonth() { + let selectionStart = Calendar.current.date(from: DateComponents(year: 2020, month: 2, day: 5))! + let selectionEnd = Calendar.current.date(from: DateComponents(year: 2020, month: 2, day: 18))! + + assertSnapshot( + BPKCalendar( + selectionType: .range( + selection: .constant(.range(selectionStart...selectionEnd)), + accessibilityConfigurations: rangeAccessibilityConfig + ), + calendar: Calendar.current, + validRange: validStart...validEnd + ) + .frame(width: 320, height: 720) + ) + } + + func test_rangeSelectionCalendar_differentMonth() { + let selectionStart = Calendar.current.date(from: DateComponents(year: 2020, month: 1, day: 28))! + let selectionEnd = Calendar.current.date(from: DateComponents(year: 2020, month: 2, day: 5))! + + assertSnapshot( + BPKCalendar( + selectionType: .range( + selection: .constant(.range(selectionStart...selectionEnd)), + accessibilityConfigurations: rangeAccessibilityConfig + ), + calendar: Calendar.current, + validRange: validStart...validEnd + ) + .frame(width: 320, height: 720) + ) + } + + func test_rangeSelectionCalendar_sameDay() { + let selectionStart = Calendar.current.date(from: DateComponents(year: 2020, month: 2, day: 5))! + let selectionEnd = Calendar.current.date(from: DateComponents(year: 2020, month: 2, day: 5))! + + assertSnapshot( + BPKCalendar( + selectionType: .range( + selection: .constant(.range(selectionStart...selectionEnd)), + accessibilityConfigurations: rangeAccessibilityConfig + ), + calendar: Calendar.current, + validRange: validStart...validEnd + ) + .frame(width: 320, height: 720) + ) + } + + func test_rangeCalendarDayCells() { + let calendar = Calendar.current + let date = calendar.date(from: .init(year: 2023, month: 11, day: 8))! + + assertSnapshot( + LazyVGrid(columns: Array(repeating: GridItem(spacing: 0), count: 6), spacing: 0) { + DefaultCalendarDayCell(calendar: calendar, date: date) + DisabledCalendarDayCell(calendar: calendar, date: date) + LowerBoundSelectedCell(calendar: calendar, date: date) + InbetweenSelectionCell(calendar: calendar, date: date) + UpperBoundSelectedCell(calendar: calendar, date: date) + LowerAndUpperBoundSelectedCell(calendar: calendar, date: date) + } + .frame(width: 400) + ) + } +} diff --git a/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeCalendarDayCells.dark-mode.png b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeCalendarDayCells.dark-mode.png new file mode 100644 index 000000000..783ef0129 Binary files /dev/null and b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeCalendarDayCells.dark-mode.png differ diff --git a/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeCalendarDayCells.light-mode.png b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeCalendarDayCells.light-mode.png new file mode 100644 index 000000000..11927dcf7 Binary files /dev/null and b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeCalendarDayCells.light-mode.png differ diff --git a/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeCalendarDayCells.rtl.png b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeCalendarDayCells.rtl.png new file mode 100644 index 000000000..c9e4aadd4 Binary files /dev/null and b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeCalendarDayCells.rtl.png differ diff --git a/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeSelectionCalendar_differentMonth.dark-mode.png b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeSelectionCalendar_differentMonth.dark-mode.png new file mode 100644 index 000000000..bf2179ecd Binary files /dev/null and b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeSelectionCalendar_differentMonth.dark-mode.png differ diff --git a/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeSelectionCalendar_differentMonth.light-mode.png b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeSelectionCalendar_differentMonth.light-mode.png new file mode 100644 index 000000000..b55f740c8 Binary files /dev/null and b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeSelectionCalendar_differentMonth.light-mode.png differ diff --git a/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeSelectionCalendar_differentMonth.rtl.png b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeSelectionCalendar_differentMonth.rtl.png new file mode 100644 index 000000000..4905a1202 Binary files /dev/null and b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeSelectionCalendar_differentMonth.rtl.png differ diff --git a/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeSelectionCalendar_sameDay.dark-mode.png b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeSelectionCalendar_sameDay.dark-mode.png new file mode 100644 index 000000000..e9af1f20a Binary files /dev/null and b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeSelectionCalendar_sameDay.dark-mode.png differ diff --git a/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeSelectionCalendar_sameDay.light-mode.png b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeSelectionCalendar_sameDay.light-mode.png new file mode 100644 index 000000000..3cbffa43c Binary files /dev/null and b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeSelectionCalendar_sameDay.light-mode.png differ diff --git a/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeSelectionCalendar_sameDay.rtl.png b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeSelectionCalendar_sameDay.rtl.png new file mode 100644 index 000000000..a1ef6a1f6 Binary files /dev/null and b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeSelectionCalendar_sameDay.rtl.png differ diff --git a/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeSelectionCalendar_sameMonth.dark-mode.png b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeSelectionCalendar_sameMonth.dark-mode.png new file mode 100644 index 000000000..95f67f76b Binary files /dev/null and b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeSelectionCalendar_sameMonth.dark-mode.png differ diff --git a/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeSelectionCalendar_sameMonth.light-mode.png b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeSelectionCalendar_sameMonth.light-mode.png new file mode 100644 index 000000000..b37fb8f9a Binary files /dev/null and b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeSelectionCalendar_sameMonth.light-mode.png differ diff --git a/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeSelectionCalendar_sameMonth.rtl.png b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeSelectionCalendar_sameMonth.rtl.png new file mode 100644 index 000000000..97faba841 Binary files /dev/null and b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_rangeSelectionCalendar_sameMonth.rtl.png differ diff --git a/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_singleSelectionCalendar.dark-mode.png b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_singleSelectionCalendar.dark-mode.png new file mode 100644 index 000000000..fd3f80ba8 Binary files /dev/null and b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_singleSelectionCalendar.dark-mode.png differ diff --git a/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_singleSelectionCalendar.light-mode.png b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_singleSelectionCalendar.light-mode.png new file mode 100644 index 000000000..6fef736a7 Binary files /dev/null and b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_singleSelectionCalendar.light-mode.png differ diff --git a/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_singleSelectionCalendar.rtl.png b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_singleSelectionCalendar.rtl.png new file mode 100644 index 000000000..ad6db6e5d Binary files /dev/null and b/Backpack-SwiftUI/Tests/Calendar/__Snapshots__/BPKCalendarTests/test_singleSelectionCalendar.rtl.png differ diff --git a/Example/Backpack Screenshot/SwiftUIScreenshots.swift b/Example/Backpack Screenshot/SwiftUIScreenshots.swift index 2179af2e1..cfa39f12e 100644 --- a/Example/Backpack Screenshot/SwiftUIScreenshots.swift +++ b/Example/Backpack Screenshot/SwiftUIScreenshots.swift @@ -359,6 +359,16 @@ class SwiftUIScreenshots: BackpackSnapshotTestCase { tapBackButton() } + navigate(title: "Calendar") { + switchTab(title: "SwiftUI") + app.tables.staticTexts["Range Selection"].tap() + saveScreenshot(component: "calendar", scenario: "range", userInterfaceStyle: userInterfaceStyle) + tapBackButton() + app.tables.staticTexts["Single Selection"].tap() + saveScreenshot(component: "calendar", scenario: "single", userInterfaceStyle: userInterfaceStyle) + tapBackButton() + } + navigate(title: "Snippet") { app.tables.staticTexts["Landscape"].tap() saveScreenshot(component: "snippet", scenario: "landscape", userInterfaceStyle: userInterfaceStyle) diff --git a/Example/Backpack.xcodeproj/project.pbxproj b/Example/Backpack.xcodeproj/project.pbxproj index 7a5143d65..2b545ade3 100644 --- a/Example/Backpack.xcodeproj/project.pbxproj +++ b/Example/Backpack.xcodeproj/project.pbxproj @@ -20,9 +20,11 @@ 3AA018EF215BE26600838FBB /* SpinnersViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA018EE215BE26500838FBB /* SpinnersViewController.swift */; }; 3AA018F4215D000700838FBB /* TextViewsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA018F3215D000700838FBB /* TextViewsViewController.swift */; }; 530459F52A961F9E00244EA8 /* CarouselExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 530459F42A961F9E00244EA8 /* CarouselExampleView.swift */; }; + 5318E3342AF506FA00C66D18 /* CalendarExampleSingleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5318E3332AF506FA00C66D18 /* CalendarExampleSingleView.swift */; }; 534624382A7429080059A0B5 /* ChipGroupMultipleSelectWrapExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 534624372A7429080059A0B5 /* ChipGroupMultipleSelectWrapExampleView.swift */; }; 5346243A2A74292D0059A0B5 /* ChipGroupMultipleSelectRailExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 534624392A74292D0059A0B5 /* ChipGroupMultipleSelectRailExampleView.swift */; }; 5346243D2A7437A10059A0B5 /* ProgressBarExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5346243C2A7437A10059A0B5 /* ProgressBarExampleView.swift */; }; + 537AA67F2B050D1000D97B42 /* CalendarExampleRangeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537AA67E2B050D1000D97B42 /* CalendarExampleRangeView.swift */; }; 537ED1AF282D65A300032105 /* ShadowTokensView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537ED1AE282D65A300032105 /* ShadowTokensView.swift */; }; 5390DB5D29098CE400F0F790 /* RadiusTokensViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5390DB5C29098CE400F0F790 /* RadiusTokensViewController.swift */; }; 5390DB5F29098D7300F0F790 /* SpacingTokensViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5390DB5E29098D7300F0F790 /* SpacingTokensViewController.swift */; }; @@ -237,9 +239,11 @@ 3DDD5D1DB6A77816BAA9481F /* Pods-Backpack-Native.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Backpack-Native.release.xcconfig"; path = "Pods/Target Support Files/Pods-Backpack-Native/Pods-Backpack-Native.release.xcconfig"; sourceTree = ""; }; 455FB6E0E01310446E946427 /* Backpack.podspec */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = Backpack.podspec; path = ../Backpack.podspec; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.ruby; }; 530459F42A961F9E00244EA8 /* CarouselExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarouselExampleView.swift; sourceTree = ""; }; + 5318E3332AF506FA00C66D18 /* CalendarExampleSingleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarExampleSingleView.swift; sourceTree = ""; }; 534624372A7429080059A0B5 /* ChipGroupMultipleSelectWrapExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipGroupMultipleSelectWrapExampleView.swift; sourceTree = ""; }; 534624392A74292D0059A0B5 /* ChipGroupMultipleSelectRailExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipGroupMultipleSelectRailExampleView.swift; sourceTree = ""; }; 5346243C2A7437A10059A0B5 /* ProgressBarExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressBarExampleView.swift; sourceTree = ""; }; + 537AA67E2B050D1000D97B42 /* CalendarExampleRangeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarExampleRangeView.swift; sourceTree = ""; }; 537ED1AE282D65A300032105 /* ShadowTokensView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowTokensView.swift; sourceTree = ""; }; 5390DB5C29098CE400F0F790 /* RadiusTokensViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadiusTokensViewController.swift; sourceTree = ""; }; 5390DB5E29098D7300F0F790 /* SpacingTokensViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpacingTokensViewController.swift; sourceTree = ""; }; @@ -521,6 +525,15 @@ path = Carousel; sourceTree = ""; }; + 5318E3352AF5070300C66D18 /* Calendar */ = { + isa = PBXGroup; + children = ( + 537AA67E2B050D1000D97B42 /* CalendarExampleRangeView.swift */, + 5318E3332AF506FA00C66D18 /* CalendarExampleSingleView.swift */, + ); + path = Calendar; + sourceTree = ""; + }; 5346243B2A7433040059A0B5 /* MultiSelect */ = { isa = PBXGroup; children = ( @@ -908,6 +921,7 @@ 793EC5502836139A00D627F6 /* Components */ = { isa = PBXGroup; children = ( + 5318E3352AF5070300C66D18 /* Calendar */, BA809EC32B05ED100030D1E7 /* AppSearchModal */, 2A8000102AB3DC3B009FDB10 /* TextArea */, 530459F32A961F8F00244EA8 /* Carousel */, @@ -1716,6 +1730,7 @@ D217701825ACCC5E00C0FD8C /* Presentable.swift in Sources */, 53C6621B29EA0DAB00BF1A62 /* CardExampleView.swift in Sources */, 53E075BE27FCBE8C0033147C /* RootViewControllerFactory.swift in Sources */, + 537AA67F2B050D1000D97B42 /* CalendarExampleRangeView.swift in Sources */, F00F15692993F24700213F0D /* CarouselViewController.swift in Sources */, 793C2D5527852B640055AB9A /* AppDelegate.swift in Sources */, 1A2A4BD82A6801AE00C18218 /* SelectExampleView.swift in Sources */, @@ -1758,6 +1773,7 @@ 53C6622429EA0DAB00BF1A62 /* ButtonsPlaygroundView.swift in Sources */, 02D34C4A2A712A2200F99085 /* SnippetGroups.swift in Sources */, 53B6DB5A27FB6F930042B7C0 /* TokenCells.swift in Sources */, + 5318E3342AF506FA00C66D18 /* CalendarExampleSingleView.swift in Sources */, 3A7D2D47214AB9F400ECBD5B /* BPKButtonsViewController.m in Sources */, D2644E3022C0EB4E008B50C0 /* TappableLinkLabelsSelectorViewController.swift in Sources */, 53C6622529EA0DAB00BF1A62 /* PanelExampleView.swift in Sources */, diff --git a/Example/Backpack/SwiftUI/Components/Calendar/CalendarExampleRangeView.swift b/Example/Backpack/SwiftUI/Components/Calendar/CalendarExampleRangeView.swift new file mode 100644 index 000000000..6a8cc0826 --- /dev/null +++ b/Example/Backpack/SwiftUI/Components/Calendar/CalendarExampleRangeView.swift @@ -0,0 +1,89 @@ +// +/* + * Backpack - Skyscanner's Design System + * + * Copyright © 2023 Skyscanner Ltd. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI +import Backpack_SwiftUI + +struct CalendarExampleRangeView: View { + @State var selection: CalendarRangeSelectionState? + + let validRange: ClosedRange + let calendar: Calendar + let formatter: DateFormatter + + init() { + let calendar = Calendar.current + let start = calendar.date(from: .init(year: 2023, month: 11, day: 6))! + let end = calendar.date(from: .init(year: 2024, month: 11, day: 28))! + + self.validRange = start...end + self.calendar = calendar + + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.locale = calendar.locale + self.formatter = formatter + + let selectionStart = calendar.date(from: .init(year: 2023, month: 11, day: 23))! + let selectionEnd = calendar.date(from: .init(year: 2023, month: 12, day: 2))! + _selection = State(initialValue: .range(selectionStart...selectionEnd)) + } + + var body: some View { + let accessibilityConfigurations = RangeAccessibilityConfigurations( + startSelectionHint: "Double tap to select departure date", + endSelectionHint: "Double tap to select return date", + startSelectionState: "Selected as departure date", + endSelectionState: "Selected as return date", + betweenSelectionState: "Between departure and return date", + startAndEndSelectionState: "Selected as both departure and return date", + returnDatePrompt: "Now please select a return date" + ) + return VStack { + HStack { + BPKText("Selected inbound:", style: .caption) + if case .range(let selectedRange) = selection { + BPKText("\(formatter.string(from: selectedRange.lowerBound))", style: .caption) + } else if case .intermediate(let selectedDate) = selection { + BPKText("\(formatter.string(from: selectedDate))", style: .caption) + } + } + HStack { + BPKText("Selected outbound:", style: .caption) + if case .range(let selectedRange) = selection { + BPKText("\(formatter.string(from: selectedRange.upperBound))", style: .caption) + } + } + BPKCalendar( + selectionType: .range( + selection: $selection, + accessibilityConfigurations: accessibilityConfigurations + ), + calendar: calendar, + validRange: validRange + ) + } + } +} + +struct CalendarExampleRangeView_Previews: PreviewProvider { + static var previews: some View { + CalendarExampleRangeView() + } +} diff --git a/Example/Backpack/SwiftUI/Components/Calendar/CalendarExampleSingleView.swift b/Example/Backpack/SwiftUI/Components/Calendar/CalendarExampleSingleView.swift new file mode 100644 index 000000000..e6c265403 --- /dev/null +++ b/Example/Backpack/SwiftUI/Components/Calendar/CalendarExampleSingleView.swift @@ -0,0 +1,72 @@ +// +/* + * Backpack - Skyscanner's Design System + * + * Copyright © 2023 Skyscanner Ltd. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI +import Backpack_SwiftUI + +struct CalendarExampleSingleView: View { + @State var selectedDate: Date? + + let validRange: ClosedRange + let calendar: Calendar + let formatter: DateFormatter + + init() { + let calendar = Calendar.current + let start = calendar.date(from: .init(year: 2023, month: 11, day: 6))! + let end = calendar.date(from: .init(year: 2024, month: 11, day: 28))! + + self.validRange = start...end + self.calendar = calendar + + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.locale = calendar.locale + self.formatter = formatter + + _selectedDate = State(initialValue: calendar.date(from: .init(year: 2023, month: 11, day: 15))!) + } + + var body: some View { + VStack { + HStack { + BPKText("Selected date:", style: .caption) + if let selectedDate { + BPKText("\(formatter.string(from: selectedDate))", style: .caption) + } + } + BPKCalendar( + selectionType: .single( + selected: $selectedDate, + accessibilityConfigurations: SingleAccessibilityConfigurations( + selectionHint: "Double tap to select date" + ) + ), + calendar: calendar, + validRange: validRange + ) + } + } +} + +struct CalendarExampleSingleView_Previews: PreviewProvider { + static var previews: some View { + CalendarExampleSingleView() + } +} diff --git a/Example/Backpack/Utils/FeatureStories/ComponentCells.swift b/Example/Backpack/Utils/FeatureStories/ComponentCells.swift index b3fe60e75..506955db0 100644 --- a/Example/Backpack/Utils/FeatureStories/ComponentCells.swift +++ b/Example/Backpack/Utils/FeatureStories/ComponentCells.swift @@ -134,10 +134,13 @@ extension ComponentCellsProvider { ) } private func calendar() -> CellDataSource { - GroupCellDataSource( + ComponentCellDataSource( title: "Calendar", - groups: CalendarGroupsProvider(showPresentable: show(presentable:)).groups(), - showChildren: { showChildren(title: "Calendar", children: $0) } + tabs: [ + .uikit(groups: CalendarGroupsProvider(showPresentable: show(presentable:)).groups()), + .swiftui(groups: CalendarGroupsProvider(showPresentable: show(presentable:)).swiftUIGroups()) + ], + showChildren: { showComponent(title: "Calendar", tabs: $0) } ) } private func card() -> CellDataSource { diff --git a/Example/Backpack/Utils/FeatureStories/Groups/CalendarGroups.swift b/Example/Backpack/Utils/FeatureStories/Groups/CalendarGroups.swift index ff37e0d3e..32b6673db 100644 --- a/Example/Backpack/Utils/FeatureStories/Groups/CalendarGroups.swift +++ b/Example/Backpack/Utils/FeatureStories/Groups/CalendarGroups.swift @@ -16,6 +16,8 @@ * limitations under the License. */ +import SwiftUI + struct CalendarGroupsProvider { let showPresentable: (Presentable) -> Void @@ -31,6 +33,17 @@ struct CalendarGroupsProvider { ) } + private func presentableCalendar( + _ title: String, + view: Content + ) -> CellDataSource { + PresentableCellDataSource.custom( + title: title, + customController: { ContentUIHostingController(view) }, + showPresentable: showPresentable + ) + } + func groups() -> [Components.Group] { SingleGroupProvider( cellDataSources: [ @@ -59,4 +72,13 @@ struct CalendarGroupsProvider { ] ).groups() } + + func swiftUIGroups() -> [Components.Group] { + SingleGroupProvider( + cellDataSources: [ + presentableCalendar("Range Selection", view: CalendarExampleRangeView()), + presentableCalendar("Single Selection", view: CalendarExampleSingleView()) + ] + ).groups() + } } diff --git a/screenshots/iPhone-swiftui_calendar___range_dm.png b/screenshots/iPhone-swiftui_calendar___range_dm.png new file mode 100644 index 000000000..73a025d60 Binary files /dev/null and b/screenshots/iPhone-swiftui_calendar___range_dm.png differ diff --git a/screenshots/iPhone-swiftui_calendar___range_lm.png b/screenshots/iPhone-swiftui_calendar___range_lm.png new file mode 100644 index 000000000..aa87dbe16 Binary files /dev/null and b/screenshots/iPhone-swiftui_calendar___range_lm.png differ diff --git a/screenshots/iPhone-swiftui_calendar___single_dm.png b/screenshots/iPhone-swiftui_calendar___single_dm.png new file mode 100644 index 000000000..f395feb7b Binary files /dev/null and b/screenshots/iPhone-swiftui_calendar___single_dm.png differ diff --git a/screenshots/iPhone-swiftui_calendar___single_lm.png b/screenshots/iPhone-swiftui_calendar___single_lm.png new file mode 100644 index 000000000..62a2b8127 Binary files /dev/null and b/screenshots/iPhone-swiftui_calendar___single_lm.png differ