Skip to content

Commit

Permalink
AI-638: FieldSet Component (#2053)
Browse files Browse the repository at this point in the history
* AI-638: FieldSet componenet

* AI-638: Screenshots

* AI-638: Accessibility prefix & comments

* record snapshots

* AI-638: Readme

* Updated snapshots

* Swiftlint

* AI-638: change description to constant

* AI-638: Address PR comments

---------

Co-authored-by: Alaa Amin <[email protected]>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
  • Loading branch information
3 people authored Aug 26, 2024
1 parent efc18e1 commit c747062
Show file tree
Hide file tree
Showing 42 changed files with 621 additions and 0 deletions.
184 changes: 184 additions & 0 deletions Backpack-SwiftUI/FieldSet/Classes/BPKFieldSet.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/*
* 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 UIKit

public protocol BPKFieldSetContentView: View {
associatedtype ContentView: BPKFieldSetContentView

func inputState(_ state: BPKFieldSet<ContentView>.State) -> ContentView
}

// swiftlint:disable line_length

/// A component which wraps its content (view) and optionally adds a title, description and error label (depending on the field's state) around it.
/// Supported states are Default, and Error. The states are dispatched to the wrapped view. The wrapped view must conform to `BPKFieldSetStatusHandling` to ensure it can handle the dispatched state.
///
/// Use `inputState(_ state: State)` to change the state of the field set.
///
/// Use `accessibilityPrefix(_ prefix: String)` to add a prefix to each accessibilityIdentifier
/// that gets added to all fieldset's subcomponents

// swiftlint:enable line_length

public struct BPKFieldSet<Content: BPKFieldSetContentView>: View {
private var state: BPKFieldSet<Content.ContentView>.State = .default
private let label: String?
private let content: Content
private let description: String?
private var accessibilityPrefix: String?

public init(
label: String? = nil,
description: String? = nil,
content: () -> Content
) {
self.label = label
self.description = description
self.content = content()
}

public var body: some View {
VStack(alignment: .leading, spacing: .sm) {
labelView
content
.inputState(state)
.padding(.bottom, .sm)
.accessibilityIdentifier(accessibilityIdentifier(for: "wrapped_view"))
descriptionView
if case let .error(message) = state {
errorMessage(message)
.padding(.top, .sm)
}
}
}

@ViewBuilder
private var labelView: some View {
if let label {
BPKText(label, style: .label2)
.lineLimit(nil)
.foregroundColor(state.labelColor)
.accessibilityIdentifier(accessibilityIdentifier(for: "label"))
}
}

@ViewBuilder
private var descriptionView: some View {
if let description {
BPKText(description, style: .caption)
.lineLimit(nil)
.foregroundColor(state.descriptionColor)
.accessibilityIdentifier(accessibilityIdentifier(for: "descritpion"))
}
}

private func errorMessage(_ message: String) -> some View {
HStack(spacing: .md) {
BPKIconView(.exclamationCircle)
.foregroundColor(.textErrorColor)
.accessibilityHidden(true)
BPKText(message, style: .caption)
.lineLimit(nil)
.foregroundColor(.textErrorColor)
.accessibilityIdentifier(accessibilityIdentifier(for: "error_message"))
}
}

public func inputState(_ state: BPKFieldSet<Content.ContentView>.State) -> BPKFieldSet {
var result = self
result.state = state
return result
}
}

// MARK: - Accessibility

extension BPKFieldSet {
public func accessibilityPrefix(_ prefix: String?) -> BPKFieldSet {
var result = self
result.accessibilityPrefix = prefix
return result
}

private func accessibilityIdentifier(for label: String) -> String {
if let prefix = accessibilityPrefix {
return "\(prefix)_\(label)"
}
return ""
}
}

// MARK: - Previews

// swiftlint:disable closure_body_length
#Preview {
return ScrollView {
constructViews()
}

@ViewBuilder
func constructViews() -> some View {
VStack(spacing: .base) {
BPKText("With Label & Description", style: .label1)
constructFieldSet(
withLabel: "Label",
andDescription: "Description",
wrappedView: BPKTextField(placeholder: "Enter text", .constant(""))
)
Divider()
BPKText("With Label & No Description", style: .label1)
constructFieldSet(
withLabel: "Label",
wrappedView: BPKTextField(placeholder: "Enter text", .constant(""))
)
Divider()
BPKText("With No Label & Description", style: .label1)
constructFieldSet(
andDescription: "Description",
wrappedView: BPKTextArea(.constant(""), placeholder: "Enter text")
)
Divider()
BPKText("With No Label & No Description", style: .label1)
constructFieldSet(
wrappedView: BPKSelect(
placeholder: "Breakfast Choices",
options: ["Porridge", "Eggs", "Swift UI"],
selectedIndex: .constant(1)
)
)
}
}

@ViewBuilder
func constructFieldSet(
withLabel label: String? = nil,
andDescription description: String? = nil,
wrappedView: some BPKFieldSetContentView
) -> some View {
ForEach([0, 1], id: \.self) { index in
BPKFieldSet(label: label, description: description) {
wrappedView
}
.if(index == 1) { view in
view.inputState(.error(message: "Error Message"))
}
}
}
}
44 changes: 44 additions & 0 deletions Backpack-SwiftUI/FieldSet/Classes/BPKFieldSetState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Backpack - Skyscanner's Design System
*
* Copyright 2018-2022 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

extension BPKFieldSet {
/// The state of the field set.
public enum State {
/// Default state.
case `default`
/// Error state
/// Adds an errors message beneath the description or the wrapped view if no description is provided.
case error(message: String)

var labelColor: BPKColor {
switch self {
case .default: return .textPrimaryColor
case .error: return .textErrorColor
}
}

var descriptionColor: BPKColor {
switch self {
case .default: return .textSecondaryColor
case .error: return .textSecondaryColor
}
}
}
}
43 changes: 43 additions & 0 deletions Backpack-SwiftUI/FieldSet/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Backpack-SwiftUI/BannerAlert

[![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/BPKFieldSet.html)
[![view on Github](https://img.shields.io/badge/Source%20code-GitHub-lightgrey)](https://github.com/Skyscanner/backpack-ios/tree/main/Backpack-SwiftUI/BPKFieldSet)

## Default

| Day | Night |
| --- | --- |
| <img src="https://raw.githubusercontent.com/Skyscanner/backpack-ios/main/screenshots/iPhone-swiftui_field-set___default_lm.png" alt="" width="375" /> |<img src="https://raw.githubusercontent.com/Skyscanner/backpack-ios/main/screenshots/iPhone-swiftui_field-set___default_dm.png" alt="" width="375" /> |

## Error

| Day | Night |
| --- | --- |
| <img src="https://raw.githubusercontent.com/Skyscanner/backpack-ios/main/screenshots/iPhone-swiftui_field-set___error_lm.png" alt="" width="375" /> |<img src="https://raw.githubusercontent.com/Skyscanner/backpack-ios/main/screenshots/iPhone-swiftui_field-set___error_dm.png" alt="" width="375" /> |

# Usage

FieldSet is a component which wraps its content (view) and optionally adds a title, description and error label (depending on the field's state) around it.

Supported states are Default, and Error. The states are dispatched to the wrapped view. The wrapped view must conform to `BPKFieldSetContentView` to ensure it can handle the dispatched state.


## BPKFieldSet

### Example of a FieldSet with Default state:

```swift
BPKFieldSet(label: "Label", description: "Description") {
BPKTextField(placeholder: "Enter text", .constant(""))
}
```

### Example of a FieldSet with Error state:

```swift
BPKFieldSet(label: "Label", description: "Description") {
BPKTextField(placeholder: "Enter text", .constant(""))
}
.inputState(.error(message: "Error Message"))
```
42 changes: 42 additions & 0 deletions Backpack-SwiftUI/Select/Classes/BPKSelect+FieldSet.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* 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

extension BPKSelect: BPKFieldSetContentView {
public func inputState(_ state: BPKFieldSet<BPKSelect>.State) -> BPKSelect {
switch state {
case .default:
return inputState(BPKSelect.State.default)
case .error:
return inputState(BPKSelect.State.error)
}
}
}

#Preview {
BPKFieldSet(label: "Label", description: "Description") {
BPKSelect(
placeholder: "Breakfast Choices",
options: ["Porridge", "Eggs", "Swift UI"],
selectedIndex: .constant(1)
)
}
.inputState(.error(message: "Error Message"))
.padding()
}
58 changes: 58 additions & 0 deletions Backpack-SwiftUI/Tests/FieldSet/BPKFieldSetTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* 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

final class BPKFieldSetTests: XCTestCase {
// swiftlint:disable large_tuple
private var testsCases: [(label: String?, description: String?, name: String)] {
[
("Label", "Description", "LabelAndDescription"),
("Label", nil, "LabelAndNoDescription"),
(nil, "Description", "NoLabelAndDescription"),
(nil, nil, "NoLabelAndNoDescription")
]
}

func test_defaultState() {
testsCases.forEach { testCase in
assertSnapshot(
BPKFieldSet(label: testCase.label, description: testCase.description, content: {
BPKTextField(placeholder: "Enter text", .constant(""))
})
.frame(width: 300),
testName: "test_defaultStateWith\(testCase.name)"
)
}
}

func test_errorState() {
testsCases.forEach { testCase in
assertSnapshot(
BPKFieldSet(label: testCase.label, description: testCase.description, content: {
BPKTextField(placeholder: "Enter text", .constant(""))
})
.inputState(.error(message: "Error Message"))
.frame(width: 300),
testName: "test_errorStateWith\(testCase.name)"
)
}
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit c747062

Please sign in to comment.