* Update copyright holding and dates * compound IDE Macros updated * update copyright * update copyrights done * update templates and README
314 lines
9.9 KiB
Swift
314 lines
9.9 KiB
Swift
//
|
|
// Copyright 2025 Element Creations Ltd.
|
|
// Copyright 2022-2025 New Vector Ltd.
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
// Please see LICENSE files in the repository root for full details.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
public extension ButtonStyle where Self == CompoundButtonStyle {
|
|
/// A button style that applies Compound design tokens to a button with various configuration options.
|
|
/// - Parameter kind: The kind of button being shown such as primary or secondary.
|
|
/// - Parameter size: The button size to use. Defaults to `large`.
|
|
static func compound(_ kind: Self.Kind, size: Self.Size = .large) -> CompoundButtonStyle {
|
|
CompoundButtonStyle(kind: kind, size: size)
|
|
}
|
|
}
|
|
|
|
/// Default button style for standalone buttons.
|
|
public struct CompoundButtonStyle: ButtonStyle {
|
|
@Environment(\.isEnabled) private var isEnabled
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
@Environment(\.accessibilityShowButtonShapes) private var accessibilityShowButtonShapes
|
|
|
|
var kind: Kind
|
|
public enum Kind {
|
|
/// A stroked button that uses colour to highlight important actions.
|
|
case `super`
|
|
/// A filled button usually representing the default action.
|
|
case primary
|
|
/// A stroked button usually representing alternate actions.
|
|
case secondary
|
|
/// A plain button with matching dimensions to ``primary`` and ``secondary``.
|
|
case tertiary
|
|
/// A plain button with no padding.
|
|
case textLink
|
|
}
|
|
|
|
var size: Size
|
|
public enum Size {
|
|
/// A button that is prominently sized.
|
|
case large
|
|
/// A button that is a regular size.
|
|
case medium
|
|
/// A button that is a small size.
|
|
case small
|
|
/// A (super/primary/secondary) button that should be place within a toolbar.
|
|
case toolbarIcon
|
|
}
|
|
|
|
private var font: Font {
|
|
if kind == .textLink, size == .small {
|
|
.compound.bodyMDSemibold
|
|
} else {
|
|
.compound.bodyLGSemibold
|
|
}
|
|
}
|
|
|
|
private var horizontalPadding: CGFloat {
|
|
if kind == .textLink {
|
|
return 0
|
|
}
|
|
|
|
return switch size {
|
|
case .large: 20
|
|
case .medium: 20
|
|
case .small: 16
|
|
case .toolbarIcon: 3
|
|
}
|
|
}
|
|
|
|
private var verticalPadding: CGFloat {
|
|
if kind == .textLink {
|
|
return 0
|
|
}
|
|
|
|
return switch size {
|
|
case .large: 14
|
|
case .medium: 7
|
|
case .small: 4
|
|
case .toolbarIcon: 3
|
|
}
|
|
}
|
|
|
|
private var maxWidth: CGFloat? {
|
|
if kind == .textLink {
|
|
return nil
|
|
}
|
|
|
|
return switch size {
|
|
case .large: .infinity
|
|
case .medium: nil
|
|
case .small: nil
|
|
case .toolbarIcon: nil
|
|
}
|
|
}
|
|
|
|
private var pressedOpacity: Double {
|
|
colorScheme == .light ? 0.3 : 0.6
|
|
}
|
|
|
|
private var isUnderlined: Bool {
|
|
kind == .textLink && accessibilityShowButtonShapes
|
|
}
|
|
|
|
public func makeBody(configuration: Self.Configuration) -> some View {
|
|
configuration.label
|
|
.font(font)
|
|
.underline(isUnderlined)
|
|
.multilineTextAlignment(.center)
|
|
.foregroundColor(textColor(configuration: configuration))
|
|
.padding(.horizontal, horizontalPadding)
|
|
.padding(.vertical, verticalPadding)
|
|
.frame(maxWidth: maxWidth)
|
|
.background {
|
|
makeBackground(configuration: configuration)
|
|
}
|
|
.contentShape(contentShape)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func makeBackground(configuration: Self.Configuration) -> some View {
|
|
switch kind {
|
|
case .super:
|
|
if isEnabled {
|
|
ZStack {
|
|
Capsule().fill(.compound.bgCanvasDefault)
|
|
Capsule().fill(LinearGradient(gradient: .compound.action,
|
|
startPoint: .top, endPoint: .bottom))
|
|
.opacity(0.04)
|
|
Capsule().strokeBorder(LinearGradient(gradient: .compound.action,
|
|
startPoint: .top, endPoint: .bottom))
|
|
}
|
|
.compositingGroup()
|
|
.opacity(configuration.isPressed ? pressedOpacity : 1)
|
|
} else {
|
|
Capsule().strokeBorder(strokeColor(configuration: configuration))
|
|
}
|
|
case .primary:
|
|
Capsule().fill(fillColor(configuration: configuration))
|
|
case .secondary:
|
|
Capsule().strokeBorder(strokeColor(configuration: configuration))
|
|
case .tertiary:
|
|
EmptyView()
|
|
case .textLink:
|
|
EmptyView()
|
|
}
|
|
}
|
|
|
|
private var contentShape: AnyShape {
|
|
switch kind {
|
|
case .super, .primary, .secondary, .tertiary:
|
|
return AnyShape(Capsule())
|
|
case .textLink:
|
|
return AnyShape(Rectangle())
|
|
}
|
|
}
|
|
|
|
private func fillColor(configuration: Self.Configuration) -> Color {
|
|
guard isEnabled else { return .compound.bgActionPrimaryDisabled }
|
|
if configuration.role == .destructive {
|
|
return .compound.bgCriticalPrimary.opacity(configuration.isPressed ? pressedOpacity : 1)
|
|
} else {
|
|
return configuration.isPressed ? .compound.bgActionPrimaryPressed : .compound.bgActionPrimaryRest
|
|
}
|
|
}
|
|
|
|
private func strokeColor(configuration: Self.Configuration) -> Color {
|
|
if configuration.role == .destructive {
|
|
return .compound.borderCriticalPrimary.opacity(configuration.isPressed ? pressedOpacity : 1)
|
|
} else {
|
|
return .compound.borderInteractiveSecondary.opacity(configuration.isPressed ? pressedOpacity : 1)
|
|
}
|
|
}
|
|
|
|
private func textColor(configuration: Configuration) -> Color {
|
|
if kind == .primary {
|
|
return .compound.textOnSolidPrimary
|
|
} else {
|
|
guard isEnabled else { return .compound.textDisabled }
|
|
let textColor: Color = configuration.role == .destructive ? .compound.textCriticalPrimary : .compound.textActionPrimary
|
|
return textColor.opacity(configuration.isPressed ? pressedOpacity : 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Previews
|
|
|
|
public struct CompoundButtonStyle_Previews: PreviewProvider, TestablePreview {
|
|
public static var previews: some View {
|
|
ScrollView {
|
|
states
|
|
}
|
|
.previewLayout(.fixed(width: 390, height: 1875))
|
|
}
|
|
|
|
@ViewBuilder
|
|
public static var states: some View {
|
|
Section {
|
|
buttons(.large)
|
|
} header: {
|
|
Header(title: "Large")
|
|
}
|
|
|
|
Section {
|
|
buttons(.medium)
|
|
} header: {
|
|
Header(title: "Medium")
|
|
}
|
|
|
|
Section {
|
|
buttons(.small)
|
|
} header: {
|
|
Header(title: "Small")
|
|
}
|
|
|
|
Section {
|
|
textLinks(.medium)
|
|
} header: {
|
|
Header(title: "Text Link")
|
|
}
|
|
|
|
Section {
|
|
textLinks(.small)
|
|
} header: {
|
|
Header(title: "Text Link Small")
|
|
}
|
|
|
|
Section {
|
|
startChat
|
|
.padding(.bottom) // Only for the snapshot.
|
|
} header: {
|
|
Header(title: "Start chat")
|
|
}
|
|
}
|
|
|
|
public static func buttons(_ size: CompoundButtonStyle.Size) -> some View {
|
|
VStack(spacing: 8) {
|
|
Button("Super") { }
|
|
.buttonStyle(.compound(.super, size: size))
|
|
|
|
Button("Disabled") { }
|
|
.buttonStyle(.compound(.super, size: size))
|
|
.disabled(true)
|
|
|
|
Button("Primary") { }
|
|
.buttonStyle(.compound(.primary, size: size))
|
|
|
|
Button("Destructive", role: .destructive) { }
|
|
.buttonStyle(.compound(.primary, size: size))
|
|
|
|
Button("Disabled") { }
|
|
.buttonStyle(.compound(.primary, size: size))
|
|
.disabled(true)
|
|
|
|
Button("Secondary") { }
|
|
.buttonStyle(.compound(.secondary, size: size))
|
|
|
|
Button("Destructive", role: .destructive) { }
|
|
.buttonStyle(.compound(.secondary, size: size))
|
|
|
|
Button("Disabled") { }
|
|
.buttonStyle(.compound(.secondary, size: size))
|
|
.disabled(true)
|
|
|
|
Button("Tertiary") { }
|
|
.buttonStyle(.compound(.tertiary, size: size))
|
|
|
|
Button("Destructive", role: .destructive) { }
|
|
.buttonStyle(.compound(.tertiary, size: size))
|
|
|
|
Button("Disabled") { }
|
|
.buttonStyle(.compound(.tertiary, size: size))
|
|
.disabled(true)
|
|
}
|
|
.padding(.horizontal)
|
|
}
|
|
|
|
static func textLinks(_ size: CompoundButtonStyle.Size) -> some View {
|
|
HStack(spacing: 20) {
|
|
Button("Text Link") { }
|
|
.buttonStyle(.compound(.textLink, size: size))
|
|
|
|
Button("Destructive", role: .destructive) { }
|
|
.buttonStyle(.compound(.textLink, size: size))
|
|
|
|
Button("Disabled") { }
|
|
.buttonStyle(.compound(.textLink, size: size))
|
|
.disabled(true)
|
|
}
|
|
.padding(.top, 1)
|
|
}
|
|
|
|
static var startChat: some View {
|
|
Button { } label: {
|
|
CompoundIcon(\.plus)
|
|
}
|
|
.buttonStyle(.compound(.super, size: .toolbarIcon))
|
|
}
|
|
|
|
struct Header: View {
|
|
let title: String
|
|
|
|
var body: some View {
|
|
Text(title)
|
|
.foregroundStyle(.compound.textSecondary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding([.leading, .top])
|
|
.padding(.leading)
|
|
}
|
|
}
|
|
}
|