Skip to content

Commit

Permalink
PM-17001: Add BitwardenFloatingTextLabel
Browse files Browse the repository at this point in the history
  • Loading branch information
matt-livefront committed Jan 30, 2025
1 parent 883761a commit ff68f0d
Show file tree
Hide file tree
Showing 3 changed files with 152 additions and 89 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import SwiftUI

// MARK: - BitwardenFloatingTextLabel

/// A component for displaying a floating text label for a text input field. The text label can
/// display as a placeholder centered over the input field until the field either has focus or
/// contains a value. At that point, the text label will float up above the input field. This is
/// primarily meant to wrap a text field or view.
///
struct BitwardenFloatingTextLabel<Content: View, TrailingContent: View>: View {
// MARK: Properties

/// The primary content containing the text input field for the label.
let content: Content

/// The title of the field.
let title: String?

/// Whether the title text should display as a placeholder centered over the field.
let showPlaceholder: Bool

/// Optional trailing content to display on the trailing edge of the label and text input field.
let trailingContent: TrailingContent?

// MARK: View

var body: some View {
HStack(spacing: 8) {
ZStack(alignment: showPlaceholder ? .leading : .topLeading) {
// The placeholder and title text which is vertically centered in the view when the
// text field doesn't have focus and is empty and otherwise displays above the text field.
titleText(showPlaceholder: showPlaceholder)
.accessibilityHidden(true)

// Since the title changes font size based on if it's the placeholder, this hidden
// view preserves space to show the title in it's placeholder form. This prevents
// the field from changing size when the placeholder's visibility changes.
titleText(showPlaceholder: true)
.hidden()

VStack(alignment: .leading, spacing: 2) {
// This preserves space for the title to lay out above the text field when
// it transitions from the centered to top position. But it's always hidden and
// the text above is the one that moves during the transition.
titleText(showPlaceholder: false)
.hidden()

content
}
}

trailingContent
}
.animation(.linear(duration: 0.1), value: showPlaceholder)
.padding(.trailing, 16)
.padding(.vertical, 12)
.frame(minHeight: 64)
}

// MARK: Initialization

/// Initialize a `BitwardenFloatingTextLabel`.
///
/// - Parameters:
/// - title: The title of the field.
/// - showPlaceholder: Whether the title text should display as a placeholder centered over
/// the field.
/// - content: The primary content containing the text input field for the label.
/// - trailingContent: Optional trailing content to display on the trailing edge of the label
/// and text input field.
///
init(
title: String?,
showPlaceholder: Bool,
@ViewBuilder content: () -> Content,
@ViewBuilder trailingContent: () -> TrailingContent
) {
self.content = content()
self.showPlaceholder = showPlaceholder
self.title = title
self.trailingContent = trailingContent()
}

/// Initialize a `BitwardenFloatingTextLabel`.
///
/// - Parameters:
/// - title: The title of the field.
/// - showPlaceholder: Whether the title text should display as a placeholder centered over
/// the field.
/// - content: The primary content containing the text input field for the label.
///
init(
title: String?,
showPlaceholder: Bool,
@ViewBuilder content: () -> Content
) where TrailingContent == EmptyView {
self.content = content()
self.showPlaceholder = showPlaceholder
self.title = title
trailingContent = nil
}

// MARK: Private

/// The title/placeholder text for the field.
@ViewBuilder
private func titleText(showPlaceholder: Bool) -> some View {
if let title {
Text(title)
.styleGuide(
showPlaceholder ? .body : .subheadline,
weight: showPlaceholder ? .regular : .semibold,
includeLinePadding: false,
includeLineSpacing: false
)
.foregroundStyle(Asset.Colors.textSecondary.swiftUIColor)
}
}
}

// MARK: - Previews

#if DEBUG
#Preview {
VStack {
BitwardenFloatingTextLabel(title: "Title", showPlaceholder: true) {
TextField("", text: .constant(""))
}

BitwardenFloatingTextLabel(title: "Title", showPlaceholder: false) {
TextField("", text: .constant("Value"))
}

BitwardenFloatingTextLabel(title: "Title", showPlaceholder: false) {
TextField("", text: .constant("Value"))
} trailingContent: {
Asset.Images.cog24.swiftUIImage
.foregroundStyle(Asset.Colors.iconPrimary.swiftUIColor)
}
}
.padding()
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -86,34 +86,9 @@ struct BitwardenTextField<FooterContent: View, TrailingContent: View>: View {

/// The main content for the view, containing the title label and text field.
@ViewBuilder private var contentView: some View {
HStack(spacing: 8) {
ZStack(alignment: showPlaceholder ? .leading : .topLeading) {
// The placeholder and title text which is vertically centered in the view when the
// text field doesn't have focus and is empty and otherwise displays above the text field.
titleText(showPlaceholder: showPlaceholder)

// Since the title changes font size based on if it's the placeholder, this hidden
// view preserves space to show the title in it's placeholder form. This prevents
// the field from changing size when the placeholder's visibility changes.
titleText(showPlaceholder: true)
.hidden()

VStack(alignment: .leading, spacing: 2) {
// This preserves space for the title to lay out above the text field when
// it transitions from the centered to top position. But it's always hidden and
// the text above is the one that moves during the transition.
titleText(showPlaceholder: false)
.hidden()

textField
}
}
.accessibilityRepresentation {
TextField("", text: $text)
.accessibilityLabel(title ?? "")
.accessibilityIdentifier(accessibilityIdentifier ?? "BitwardenTextField")
}

BitwardenFloatingTextLabel(title: title, showPlaceholder: showPlaceholder) {
textField
} trailingContent: {
HStack(spacing: 16) {
if let isPasswordVisible, canViewPassword {
AccessoryButton(
Expand All @@ -132,10 +107,6 @@ struct BitwardenTextField<FooterContent: View, TrailingContent: View>: View {
trailingContent
}
}
.animation(.linear(duration: 0.1), value: isFocused)
.padding(.trailing, 16)
.padding(.vertical, 12)
.frame(minHeight: 64)
}

/// The view to display at a footer below the text field.
Expand Down Expand Up @@ -180,11 +151,15 @@ struct BitwardenTextField<FooterContent: View, TrailingContent: View>: View {
.introspect(.textField, on: .iOS(.v15, .v16, .v17, .v18)) { textField in
textField.smartDashesType = isPassword ? .no : .default
}
.accessibilityIdentifier(accessibilityIdentifier ?? "BitwardenTextField")
.accessibilityLabel(title ?? "")
if isPassword, !isPasswordVisible {
SecureField("", text: $text)
.focused($isSecureFieldFocused)
.styleGuide(.bodyMonospaced, includeLineSpacing: false)
.id(title)
.accessibilityIdentifier(accessibilityIdentifier ?? "BitwardenTextField")
.accessibilityLabel(title ?? "")
}
}
.frame(maxWidth: .infinity, minHeight: 28)
Expand Down Expand Up @@ -268,23 +243,6 @@ struct BitwardenTextField<FooterContent: View, TrailingContent: View>: View {
self.title = title
self.trailingContent = trailingContent()
}

// MARK: Private

/// The title/placeholder text for the field.
@ViewBuilder
private func titleText(showPlaceholder: Bool) -> some View {
if let title {
Text(title)
.styleGuide(
showPlaceholder ? .body : .subheadline,
weight: showPlaceholder ? .regular : .semibold,
includeLinePadding: false,
includeLineSpacing: false
)
.foregroundStyle(Asset.Colors.textSecondary.swiftUIColor)
}
}
}

extension BitwardenTextField where TrailingContent == EmptyView {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,32 +59,9 @@ struct BitwardenTextView: View {

/// The main content for the view, containing the title label and text view.
private func contentView() -> some View {
ZStack(alignment: showPlaceholder ? .leading : .topLeading) {
// The placeholder and title text which is vertically centered in the view when the
// text field doesn't have focus and is empty and otherwise displays above the text field.
titleText(showPlaceholder: showPlaceholder)
.accessibilityHidden(true)

// Since the title changes font size based on if it's the placeholder, this hidden
// view preserves space to show the title in it's placeholder form. This prevents
// the field from changing size when the placeholder's visibility changes.
titleText(showPlaceholder: true)
.hidden()

VStack(alignment: .leading, spacing: 2) {
// This preserves space for the title to lay out above the text field when
// it transitions from the centered to top position. But it's always hidden and
// the text above is the one that moves during the transition.
titleText(showPlaceholder: false)
.hidden()

textView()
}
BitwardenFloatingTextLabel(title: title, showPlaceholder: showPlaceholder) {
textView()
}
.animation(.linear(duration: 0.1), value: isFocused)
.padding(.trailing, 16)
.padding(.vertical, 12)
.frame(minHeight: 64)
}

/// The text view which can contain multiple lines of text.
Expand All @@ -98,21 +75,6 @@ struct BitwardenTextView: View {
.frame(minHeight: textViewHeight)
.accessibilityLabel(title ?? "")
}

/// The title/placeholder text for the field.
@ViewBuilder
private func titleText(showPlaceholder: Bool) -> some View {
if let title {
Text(title)
.styleGuide(
showPlaceholder ? .body : .subheadline,
weight: showPlaceholder ? .regular : .semibold,
includeLinePadding: false,
includeLineSpacing: false
)
.foregroundStyle(Asset.Colors.textSecondary.swiftUIColor)
}
}
}

// MARK: - Previews
Expand Down

0 comments on commit ff68f0d

Please sign in to comment.